From e8f63a157d3a86528630cf00a331503e64615d50 Mon Sep 17 00:00:00 2001 From: "Roberson, Martin [GBM Public]" Date: Wed, 22 Jan 2025 12:00:48 +0000 Subject: [PATCH] Chore: Make release 1.2.19 --- .../demos/07_Continuous_Optimization.ipynb | 595 ++++++++++++++++++ gs_quant/entities/entity.py | 1 - gs_quant/markets/optimizer.py | 23 +- gs_quant/markets/position_set.py | 166 ++++- gs_quant/markets/report.py | 40 ++ 5 files changed, 802 insertions(+), 23 deletions(-) create mode 100644 gs_quant/documentation/10_one_delta/demos/07_Continuous_Optimization.ipynb diff --git a/gs_quant/documentation/10_one_delta/demos/07_Continuous_Optimization.ipynb b/gs_quant/documentation/10_one_delta/demos/07_Continuous_Optimization.ipynb new file mode 100644 index 00000000..539c2f50 --- /dev/null +++ b/gs_quant/documentation/10_one_delta/demos/07_Continuous_Optimization.ipynb @@ -0,0 +1,595 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "initial_id", + "metadata": { + "collapsed": true + }, + "outputs": [], + "source": [ + "import datetime as dt\n", + "import time\n", + "from math import copysign\n", + "\n", + "import pandas as pd\n", + "\n", + "from gs_quant.datetime.relative_date import RelativeDate\n", + "from gs_quant.markets.position_set import Position, PositionSet, PositionTag\n", + "from gs_quant.markets.portfolio import Portfolio\n", + "from gs_quant.markets.portfolio_manager import PortfolioManager\n", + "from gs_quant.markets.securities import Asset, AssetIdentifier\n", + "from gs_quant.markets.report import FactorRiskReport, ReturnFormat\n", + "from gs_quant.models.risk_model import FactorRiskModel\n", + "from gs_quant.session import GsSession\n", + "from gs_quant.target.common import PositionSetWeightingStrategy\n", + "\n", + "from gs_quant.target.hedge import CorporateActionsTypes\n", + "from gs_quant.markets.optimizer import OptimizerStrategy, OptimizerUniverse, \\\n", + " AssetConstraint, FactorConstraint, SectorConstraint, OptimizerSettings, OptimizerConstraints, \\\n", + " OptimizerObjective, OptimizerType\n", + "\n", + "pd.set_option('display.width', 1000)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "outputs": [], + "source": [ + "# Initialize your session\n", + "GsSession.use(client_id='client_id', client_secret='client_secret')" + ], + "metadata": { + "collapsed": false + }, + "id": "7ee60ed7c1a0065e" + }, + { + "cell_type": "markdown", + "source": [ + "First, let's setup our starting point. We will create a small portfolio of 3 stocks." + ], + "metadata": { + "collapsed": false + }, + "id": "32dbcc5251b6725f" + }, + { + "cell_type": "code", + "execution_count": null, + "outputs": [], + "source": [ + "start_date = dt.date(2024, 1, 2)\n", + "reference_notional = 100_000_000\n", + "hedge_notional_pct = 0.4\n", + "universe = ['SPX']\n", + "rebalance_freq = '3m'\n", + "risk_model_id = 'AXIOMA_AXUS4S'\n", + "holdings = [\n", + " {\n", + " 'identifier': 'AAPL UW', \n", + " 'weight' : 0.4,\n", + " 'source': 'Portfolio'\n", + " },\n", + " {\n", + " 'identifier': 'MSFT UW',\n", + " 'weight' : 0.25,\n", + " 'source': 'Portfolio'\n", + " },\n", + " {\n", + " 'identifier': 'META UW',\n", + " 'weight' : 0.25,\n", + " 'source': 'Portfolio'\n", + " }\n", + "]\n", + "apply_factor_constraints_on_total = True" + ], + "metadata": { + "collapsed": false + }, + "id": "a60039d9549afdc5" + }, + { + "cell_type": "markdown", + "source": [ + "If you do not have historical identifiers of your holdings, you can use SecurityMaster to resolve today's identifiers to a past point in time. In this example, META is an identifier as of today, not as of 2020. " + ], + "metadata": { + "collapsed": false + }, + "id": "28dfc1fb4300a20f" + }, + { + "cell_type": "code", + "execution_count": null, + "outputs": [], + "source": [ + "from gs_quant.api.gs.secmaster import GsSecurityMasterApi\n", + "\n", + "historical_identifiers = {}\n", + " \n", + "def get_historical_id(identifier, date, listed=True, id_type='bbid'):\n", + " \n", + " data = GsSecurityMasterApi.get_many_securities(**{\"identifier\": [identifier], \"isPrimary\": True})\n", + " if not data or 'results' not in data or not data['results']:\n", + " raise ValueError(f\"No security found for identifier {identifier}\")\n", + " secm_id = data['results'][0]['id']\n", + " historical_data = GsSecurityMasterApi.get_identifiers(secmaster_id=secm_id)\n", + " for record in historical_data:\n", + " start_date = dt.datetime.strptime(record['startDate'], '%Y-%m-%d').date()\n", + " end_date = dt.datetime.strptime(record['endDate'], '%Y-%m-%d').date() if record['endDate'] != '9999-99-99' else dt.date.today()\n", + " if start_date <= date <= end_date:\n", + " if listed and record['type'] == id_type:\n", + " return record['value']\n", + " return identifier\n", + " \n", + "holdings = [\n", + " {'identifier': 'META UW'},\n", + "]\n", + "start_date = dt.date(2020, 1, 1)\n", + " \n", + "for holding in holdings:\n", + " try:\n", + " historical_identifiers[holding['identifier']] = get_historical_id(holding['identifier'], start_date)\n", + " except ValueError as e:\n", + " print(e)\n", + " \n", + "historical_identifiers" + ], + "metadata": { + "collapsed": false + }, + "id": "8122d4ab8610db6a" + }, + { + "cell_type": "markdown", + "source": [ + "You can then use this information to update your holdings" + ], + "metadata": { + "collapsed": false + }, + "id": "348122bdff707adb" + }, + { + "cell_type": "code", + "execution_count": null, + "outputs": [], + "source": [ + "# Function to replace incorrect identifiers with correct ones\n", + "def update_identifiers(holdings, historical_identifiers):\n", + " for holding in holdings:\n", + " incorrect_id = holding['identifier']\n", + " if incorrect_id in historical_identifiers:\n", + " holding['identifier'] = historical_identifiers[incorrect_id]\n", + " return holdings\n", + " \n", + "# Update the portfolio positions\n", + "updated_holdings = update_identifiers(holdings, historical_identifiers)\n", + " \n", + "# Print the updated portfolio positions\n", + "updated_holdings" + ], + "metadata": { + "collapsed": false + }, + "id": "5b4495f9531c9378" + }, + { + "cell_type": "markdown", + "source": [ + "Once the holdings are finalised, we convert them to a PositionSet object, and resolve them to Marquee Identifiers. We also price our positions to filter out any assets that might be missing prices." + ], + "metadata": { + "collapsed": false + }, + "id": "4c330e417e4641a7" + }, + { + "cell_type": "code", + "execution_count": null, + "outputs": [], + "source": [ + "position_set = PositionSet.from_dicts(\n", + " holdings, \n", + " date=start_date, \n", + " add_tags=True, \n", + " reference_notional=reference_notional\n", + ")\n", + "position_set.resolve()\n", + "\n", + "position_set.to_frame(add_tags=True)" + ], + "metadata": { + "collapsed": false + }, + "id": "6ad18bac693f0105" + }, + { + "cell_type": "code", + "execution_count": null, + "outputs": [], + "source": [ + "combined_pset = position_set.clone()\n", + "combined_pset.price(use_unadjusted_close_price=True,\n", + " weighting_strategy=PositionSetWeightingStrategy.Weight,\n", + " fallbackDate='5d')\n", + "combined_pset.positions.append(Position(identifier='USD', quantity=hedge_notional_pct*reference_notional*-1, tags=[PositionTag(name='source', value='Optimization')]))\n", + "combined_pset.resolve()\n", + "combined_pset.price(use_unadjusted_close_price=True,\n", + " fallbackDate='5d',\n", + " handle_long_short=True)\n", + "\n", + "combined_pset.to_frame(add_tags=True)" + ], + "metadata": { + "collapsed": false + }, + "id": "4b38fc04b8d5671d" + }, + { + "cell_type": "code", + "execution_count": null, + "outputs": [], + "source": [ + "portfolio = Portfolio(name='My New Strategy')\n", + "portfolio.save()\n", + "print('Created portfolio with id: {0}'.format(portfolio.id))\n", + "port_id = portfolio.id" + ], + "metadata": { + "collapsed": false + }, + "id": "b98c7d30f6f886a7" + }, + { + "cell_type": "code", + "execution_count": null, + "outputs": [], + "source": [ + "port_manager = PortfolioManager(port_id)\n", + "# share the portfolio with your account, as your app is different from yourself \n", + "port_manager.share(emails=['user.1@yourcompany.com'], admin=True)\n", + "port_manager.share(emails=['user.2@yourcompany.com'], admin=False)\n", + "port_manager.update_positions([combined_pset])\n", + "port_manager.set_tag_name_hierarchy(['source'])\n", + "\n", + "risk_report = FactorRiskReport(risk_model_id=risk_model_id)\n", + "risk_report.set_position_source(port_id)\n", + "risk_report.save()\n", + "\n", + "port_manager.update_portfolio_tree()\n", + "port_manager.schedule_reports(start_date=position_set.date, \n", + " end_date=RelativeDate(\"5b\", position_set.date).apply_rule())" + ], + "metadata": { + "collapsed": false + }, + "id": "9362d1c6221d86fd" + }, + { + "cell_type": "code", + "execution_count": null, + "outputs": [], + "source": [ + "print('Waiting for risk calculations to complete...')\n", + "risk_report = port_manager.get_factor_risk_report(risk_model_id=risk_model_id, tags={'source': 'Portfolio'})\n", + "risk_report.get_most_recent_job().wait_for_completion()\n", + "\n", + "factor_exposure_data = risk_report.get_factor_exposure(start_date=position_set.date, end_date=position_set.date)\n", + "factor_exposure_map = factor_exposure_data.to_dict(orient='records')[0]" + ], + "metadata": { + "collapsed": false + }, + "id": "8b6f5768cc18b35b" + }, + { + "cell_type": "code", + "execution_count": null, + "outputs": [], + "source": [ + "def prepare_factor_constraints(factor_constraints, port_factor_exposure_map):\n", + " \"\"\"Given factor constraints defined on the total portfolio and factor exposures of the core portfolio, \n", + " return constraints to be applied on the hedge\"\"\"\n", + " new_constraints = []\n", + " for fc in factor_constraints:\n", + " old = fc.max_exposure\n", + " new = port_factor_exposure_map.get(fc.factor.name, 0) - fc.max_exposure\n", + " print('Changing factor constraint for ', fc.factor.name, 'from ', old, 'to ', new)\n", + " new_constraints.append(\n", + " FactorConstraint(\n", + " fc.factor,\n", + " port_factor_exposure_map.get(fc.factor.name, 0) - fc.max_exposure\n", + " )\n", + " )\n", + " return new_constraints" + ], + "metadata": { + "collapsed": false + }, + "id": "9286ce2c6ece38cd" + }, + { + "cell_type": "markdown", + "source": [ + "Now that you have a position set, you can get a hedge according to your liking. We have put in some sample settings below. " + ], + "metadata": { + "collapsed": false + }, + "id": "6800b724c0bfa2ff" + }, + { + "cell_type": "code", + "execution_count": null, + "outputs": [], + "source": [ + "hedge_universe = OptimizerUniverse(\n", + " assets=[Asset.get(a, AssetIdentifier.BLOOMBERG_ID) for a in universe],\n", + " explode_composites=True,\n", + " exclude_corporate_actions_types=[CorporateActionsTypes.Mergers]\n", + ")\n", + "\n", + "risk_model = FactorRiskModel.get(risk_model_id)\n", + "\n", + "asset_constraints = [\n", + " AssetConstraint(Asset.get('MSFT UW', AssetIdentifier.BLOOMBERG_ID), 0, 5),\n", + " AssetConstraint(Asset.get('AAPL UW', AssetIdentifier.BLOOMBERG_ID), 0, 5)\n", + "]\n", + "\n", + "# here, we have specified the constraints on factor exposure of the Total Optimized portfolio\n", + "factor_constraints = [\n", + " FactorConstraint(risk_model.get_factor('Size'), 0),\n", + " FactorConstraint(risk_model.get_factor('Market Sensitivity'), 0)\n", + "]\n", + "\n", + "if apply_factor_constraints_on_total:\n", + " hedge_factor_constraints = prepare_factor_constraints(factor_constraints, factor_exposure_map)\n", + "else:\n", + " hedge_factor_constraints = factor_constraints\n", + " \n", + "sector_constraints = [\n", + " SectorConstraint('Energy', 0, 30),\n", + " SectorConstraint('Health Care', 0, 30)\n", + "]\n", + "settings = OptimizerSettings(notional=hedge_notional_pct * position_set.reference_notional, # 40% of your original portfolio \n", + " allow_long_short=False)\n", + "constraints = OptimizerConstraints(\n", + " asset_constraints=asset_constraints,\n", + " factor_constraints=hedge_factor_constraints,\n", + " sector_constraints=sector_constraints,\n", + ")\n", + "\n", + "strategy = OptimizerStrategy(\n", + " initial_position_set=position_set,\n", + " constraints=constraints,\n", + " settings=settings,\n", + " universe=hedge_universe,\n", + " risk_model=risk_model,\n", + " objective=OptimizerObjective.MINIMIZE_FACTOR_RISK\n", + ")\n", + "\n", + "strategy.run(optimizer_type=OptimizerType.AXIOMA_PORTFOLIO_OPTIMIZER)\n", + "\n", + "optimization = strategy.get_optimization(by_weight=True) # Returns just the optimization results as a PositionSet object\n", + "optimization.to_frame()" + ], + "metadata": { + "collapsed": false + }, + "id": "829ffee757033b0" + }, + { + "cell_type": "markdown", + "source": [ + "The Optimizer uses adjusted prices, while the portfolio analytics use unadjusted prices to offer users more fine-grained control on historical positions. We need to convert output from hedger to unadjusted prices to be able to use it in the portfolio analytics." + ], + "metadata": { + "collapsed": false + }, + "id": "13b77816be333b3a" + }, + { + "cell_type": "code", + "execution_count": null, + "outputs": [], + "source": [ + "position_set.price(use_unadjusted_close_price=True,\n", + " weighting_strategy=PositionSetWeightingStrategy.Weight,\n", + " fallbackDate='5d')\n", + "\n", + "optimization.price(use_unadjusted_close_price=True,\n", + " weighting_strategy=PositionSetWeightingStrategy.Weight,\n", + " fallbackDate='5d')\n", + "\n", + "for p in optimization.positions:\n", + " p.quantity *= -1\n", + " p.add_tag('source', 'Optimization')\n", + "\n", + "combined_pset = PositionSet(\n", + " date=position_set.date,\n", + " # take only quantity of the newly priced positions\n", + " positions=[Position(identifier=p.identifier, asset_id=p.asset_id, quantity=p.quantity, tags=p.tags) for p in position_set.positions+optimization.positions]\n", + ")\n", + "combined_pset.to_frame()" + ], + "metadata": { + "collapsed": false + }, + "id": "ffeb9251c171a8a" + }, + { + "cell_type": "code", + "execution_count": null, + "outputs": [], + "source": [ + "port_manager.update_positions([combined_pset])\n", + "port_manager.schedule_reports(start_date=combined_pset.date, \n", + " end_date=RelativeDate(rebalance_freq, combined_pset.date).apply_rule())" + ], + "metadata": { + "collapsed": false + }, + "id": "cff94b8b386e7d99" + }, + { + "cell_type": "markdown", + "source": [ + "With our initial setup done and settings configured, we are now ready to launch a flow that will continuously optimize our portfolio at our desired frequency.\n", + "\n", + "We will take th positions from the previous rebalance, utilize performance analytics to get the latest positions, and then optimize the portfolio again. We will then update the portfolio with the new positions and schedule reports for the next rebalance date.\n", + "\n", + "This operation of moving your portfolio forward using performance analytics relies solely on availability of the underlying assets. " + ], + "metadata": { + "collapsed": false + }, + "id": "81de648080f25477" + }, + { + "cell_type": "code", + "execution_count": null, + "outputs": [], + "source": [ + "port_manager = PortfolioManager(port_id)\n", + "start = position_set.date\n", + "max_end = RelativeDate(\"-1b\", dt.date.today()).apply_rule(exchanges=['NYSE'])\n", + "start_time = time.time()\n", + "rebal = RelativeDate(rebalance_freq, start).apply_rule(exchanges=['NYSE'])\n", + "\n", + "while rebal < max_end:\n", + " print(f'Moving to rebalance date {rebal}')\n", + " port_perf_report = port_manager.get_performance_report({'source': 'Portfolio'})\n", + " perf_report_job = port_perf_report.get_most_recent_job()\n", + " print(f'Waiting for performance calculations till date {perf_report_job.end_date} to complete (job id {perf_report_job.job_id})')\n", + " perf_report_job.wait_for_completion()\n", + " \n", + " latest_port_pos = port_perf_report.get_portfolio_constituents(\n", + " start_date=rebal, \n", + " end_date=rebal, \n", + " fields=['quantity', 'grossWeight'], \n", + " prefer_rebalance_positions=True,\n", + " return_format=ReturnFormat.JSON\n", + " )\n", + " latest_port_exp = port_perf_report.get_gross_exposure(start_date=rebal, end_date=rebal)['grossExposure'][0]\n", + " latest_position_set = PositionSet(\n", + " date=rebal,\n", + " reference_notional=latest_port_exp,\n", + " positions=[Position(\n", + " asset_id=p['assetId'],\n", + " identifier=p['assetId'],\n", + " # We recommend using gross weight to find your reference weight, like below\n", + " weight=copysign(p.get('grossWeight', 0), p.get('quantity', 0)),\n", + " tags=[{'source': 'Portfolio'}]\n", + " ) for p in latest_port_pos]\n", + " )\n", + " print('Latest Portfolio Position set:')\n", + " print(latest_position_set.to_frame(add_tags=True))\n", + " settings = OptimizerSettings(notional=hedge_notional_pct * latest_position_set.reference_notional, # 30% of your original portfolio \n", + " allow_long_short=False)\n", + " \n", + " if factor_constraints and apply_factor_constraints_on_total:\n", + " port_risk_report = port_manager.get_factor_risk_report(risk_model_id=risk_model_id, tags={'source': 'Portfolio'})\n", + " risk_report_job = port_risk_report.get_most_recent_job()\n", + " print(f'Waiting for risk calculations till date {risk_report_job.end_date} to complete (job id {risk_report_job.job_id})')\n", + " risk_report_job.wait_for_completion()\n", + " \n", + " factor_exposure_data = risk_report.get_factor_exposure(start_date=latest_position_set.date, end_date=latest_position_set.date)\n", + " factor_exposure_map = factor_exposure_data.to_dict(orient='records')[0]\n", + " hedge_factor_constraints = prepare_factor_constraints(factor_constraints, factor_exposure_map)\n", + " else:\n", + " hedge_factor_constraints = factor_constraints\n", + " \n", + " constraints = OptimizerConstraints(\n", + " asset_constraints=asset_constraints,\n", + " factor_constraints=hedge_factor_constraints,\n", + " sector_constraints=sector_constraints,\n", + " )\n", + " strategy = OptimizerStrategy(\n", + " initial_position_set=latest_position_set,\n", + " constraints=constraints,\n", + " settings=settings,\n", + " universe=hedge_universe,\n", + " risk_model=risk_model,\n", + " objective=OptimizerObjective.MINIMIZE_FACTOR_RISK\n", + " )\n", + " print('Optimizing...')\n", + " strategy.run(optimizer_type=OptimizerType.AXIOMA_PORTFOLIO_OPTIMIZER)\n", + " print('Optimization complete')\n", + " print('Optimized Position Set quantities:')\n", + " print(strategy.get_optimized_position_set().to_frame())\n", + " optimization = strategy.get_optimization(by_weight=True)\n", + " print('Hedge position set:')\n", + " print(optimization.to_frame(add_tags=True))\n", + " optimization.price(\n", + " use_unadjusted_close_price=True,\n", + " weighting_strategy=PositionSetWeightingStrategy.Weight,\n", + " fallbackDate='5d'\n", + " )\n", + " for p in optimization.positions:\n", + " p.quantity *= -1\n", + " p.add_tag('source', 'Optimization')\n", + " latest_position_set.price(\n", + " use_unadjusted_close_price=True,\n", + " weighting_strategy=PositionSetWeightingStrategy.Weight,\n", + " fallbackDate='5d'\n", + " )\n", + " combined_pset = PositionSet(\n", + " date=optimization.date,\n", + " positions=[Position(identifier=p.identifier, asset_id=p.asset_id, quantity=p.quantity, tags=p.tags) for p in latest_position_set.positions+optimization.positions]\n", + " )\n", + " print('Combined position set:')\n", + " print(combined_pset.to_frame(add_tags=True))\n", + " port_manager.update_positions([combined_pset])\n", + " start = rebal\n", + " rebal = min(max_end, RelativeDate(rebalance_freq, start).apply_rule())\n", + " print(f'Scheduling reports to calculate performance till {rebal}...')\n", + " port_manager.schedule_reports(start_date=start,\n", + " end_date=rebal)\n", + " time.sleep(2)\n", + "\n", + "print(f'Done! Processing completed in {time.time() - start_time} seconds')" + ], + "metadata": { + "collapsed": false + }, + "id": "6a57977f27fa4717" + }, + { + "cell_type": "markdown", + "source": [ + "And that's it! You have successfully completed a basic run of our Quant Backtesting Workflow. \n", + "\n", + "For questions, please reach out to [Marquee Sales](mailto:gs-marquee-sales@gs.com)!" + ], + "metadata": { + "collapsed": false + }, + "id": "23208a6f7f529d31" + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 2 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython2", + "version": "2.7.6" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/gs_quant/entities/entity.py b/gs_quant/entities/entity.py index e8149cf5..1ab71fdc 100644 --- a/gs_quant/entities/entity.py +++ b/gs_quant/entities/entity.py @@ -520,7 +520,6 @@ def get_factor_risk_report(self, if risk_model_id: reports = [report for report in reports if report.parameters.risk_model == risk_model_id] reports = [report for report in reports if report.parameters.benchmark == benchmark_id] - reports = [report for report in reports if report.parameters.tags == tags] if len(reports) == 0: raise MqError(f'This {position_source_type} has no factor risk reports that match ' 'your parameters. Please edit the risk model ID, fxHedged, and/or benchmark value in the ' diff --git a/gs_quant/markets/optimizer.py b/gs_quant/markets/optimizer.py index 91bf7447..343b782c 100644 --- a/gs_quant/markets/optimizer.py +++ b/gs_quant/markets/optimizer.py @@ -536,6 +536,16 @@ def __init__(self, factor_constraints: List[FactorConstraint] = [], max_factor_proportion_of_risk: MaxFactorProportionOfRiskConstraint = None, max_proportion_of_risk_by_groups: List[MaxProportionOfRiskByGroupConstraint] = None): + """Set of Constraints for the optimizer + + :param asset_constraints: list of asset constraints + :param country_constraints: list of country constraints + :param sector_constraints: list of sector constraints + :param industry_constraints: list of industry constraints + :param factor_constraints: list of factor constraints + :param max_factor_proportion_of_risk: maximum proportion of risk + :param max_proportion_of_risk_by_groups: maximum proportion of risk by groups + """ self.__asset_constraints = asset_constraints self.__country_constraints = country_constraints self.__sector_constraints = sector_constraints @@ -1010,18 +1020,21 @@ def objective(self, value: OptimizerObjective): self.__objective = value def to_dict(self, fail_on_unpriced_positions: bool = True): + """Converts input to suitable json payload for optimizer. Does not modify initial_position_set""" if self.constraints is None: self.constraints = OptimizerConstraints() if self.settings is None: self.settings = OptimizerSettings() backtest_start_date = self.initial_position_set.date - relativedelta(weeks=1) - if self.initial_position_set.reference_notional is None: - positions_as_dict = [{'assetId': p.asset_id, 'quantity': p.quantity} - for p in self.initial_position_set.positions] + positions_frame = self.initial_position_set.to_frame() + if self.initial_position_set.reference_notional: + positions_as_dict = positions_frame[['asset_id', 'weight']] else: - positions_as_dict = [{'assetId': p.asset_id, 'weight': p.weight} - for p in self.initial_position_set.positions] + positions_as_dict = positions_frame[['asset_id', 'quantity']] + + positions_as_dict = positions_as_dict.rename(columns={'asset_id': 'assetId'}).to_dict(orient='records') + parameters = { 'hedgeTarget': { 'positions': positions_as_dict diff --git a/gs_quant/markets/position_set.py b/gs_quant/markets/position_set.py index 1d164f85..0e7719d7 100644 --- a/gs_quant/markets/position_set.py +++ b/gs_quant/markets/position_set.py @@ -31,14 +31,22 @@ _group_temporal_xrefs_into_discrete_time_ranges, _resolve_many_assets from gs_quant.models.risk_model_utils import _repeat_try_catch_request from gs_quant.target.common import Position as CommonPosition, PositionPriceInput, PositionSet as CommonPositionSet, \ - PositionTag, Currency, PositionSetWeightingStrategy, MarketDataFrequency + PositionTag as PositionTagTarget, Currency, PositionSetWeightingStrategy, MarketDataFrequency from gs_quant.target.positions_v2_pricing import PositionsPricingParameters, PositionsRequest, PositionSetRequest, \ PositionsPricingRequest -from gs_quant.target.price import PriceParameters, PositionSetPriceInput +from gs_quant.target.price import PriceParameters, PositionSetPriceInput, PositionPriceResponse _logger = logging.getLogger(__name__) +class PositionTag(PositionTagTarget): + @classmethod + def from_dict(cls, tag_dict: Dict): + if len(tag_dict) > 1: + raise MqValueError('PositionTag.from_dict only accepts a single key-value pair') + return cls(name=list(tag_dict.keys())[0], value=list(tag_dict.values())[0]) + + class Position: def __init__(self, identifier: str, @@ -46,13 +54,16 @@ def __init__(self, quantity: float = None, name: str = None, asset_id: str = None, - tags: List[PositionTag] = None): + tags: Optional[List[Union[PositionTag, Dict]]] = None): self.__identifier = identifier self.__weight = weight self.__quantity = quantity self.__name = name self.__asset_id = asset_id - self.__tags = tags + if tags is not None: + self.__tags = [PositionTag.from_dict(tag) if isinstance(tag, dict) else tag for tag in tags] + else: + self.__tags = tags self.__restricted, self.__hard_to_borrow = None, None def __eq__(self, other) -> bool: @@ -132,12 +143,47 @@ def restricted(self) -> bool: def _restricted(self, value: bool): self.__restricted = value - def as_dict(self) -> Dict: + def add_tag(self, name: str, value: str): + if self.tags is None: + self.tags = [] + if not any(tag.name == name for tag in self.tags): + self.tags.append(PositionTag(name=name, value=value)) + else: + raise MqValueError(f'Position already has tag with name {name}') + + def tags_as_dict(self): + return {tag.name: tag.value for tag in self.tags} + + def as_dict(self, tags_as_keys: bool = False) -> Dict: position_dict = dict(identifier=self.identifier, weight=self.weight, - quantity=self.quantity, name=self.name, asset_id=self.asset_id, restricted=self.restricted, - tags=self.tags) + quantity=self.quantity, name=self.name, asset_id=self.asset_id, restricted=self.restricted) + if self.tags and tags_as_keys: + position_dict.update(self.tags_as_dict()) + else: + position_dict['tags'] = self.tags return {k: v for k, v in position_dict.items() if v is not None} + @classmethod + def from_dict(cls, position_dict: Dict, add_tags: bool = True): + fields = [k.lower() for k in position_dict.keys()] + if 'id' in fields and 'asset_id' in fields: + raise MqValueError('Position cannot have both id and asset_id') + if 'id' in fields: + position_dict['asset_id'] = position_dict.pop('id') + position_fields = ['identifier', 'weight', 'quantity', 'name', 'asset_id'] + tag_dict = {k: v for k, v in position_dict.items() if k not in position_fields} + return cls( + identifier=position_dict['identifier'], + weight=position_dict.get('weight'), + quantity=position_dict.get('quantity'), + name=position_dict.get('name'), + asset_id=position_dict.get('asset_id'), + tags=[PositionTag(name=k, value=v) for k, v in tag_dict.items()] if add_tags else position_dict.get('tags') + ) + + def clone(self): + return Position.from_dict(self.as_dict(tags_as_keys=True), add_tags=True) + def to_target(self, common: bool = True) -> Union[CommonPosition, PositionPriceInput]: """ Returns Position type defined in target file for API payloads """ if common: @@ -225,6 +271,27 @@ def unresolved_positions(self) -> List[Position]: def unpriced_positions(self) -> List[Position]: return self.__unpriced_positions + def clone(self, keep_reference_notional: bool = False): + """Create a clone of the current position set + + :param keep_reference_notional: Whether to keep the reference notional of the original position set in case it + has both quantity and reference notional + """ + frame = self.to_frame(add_tags=True) + ref_notional = self.reference_notional + if 'quantity' in frame.columns and ref_notional is not None: + if keep_reference_notional: + frame = frame.drop(columns=['quantity']) + else: + ref_notional = None + return PositionSet.from_frame( + frame, + date=self.date, + reference_notional=ref_notional, + divisor=self.divisor, + add_tags=True + ) + def get_positions(self) -> pd.DataFrame: """ Retrieve formatted positions @@ -507,7 +574,7 @@ def equalize_position_weights(self): equally_weighted_positions.append(p) self.positions = equally_weighted_positions - def to_frame(self) -> pd.DataFrame: + def to_frame(self, add_tags: bool = False) -> pd.DataFrame: """ Retrieve formatted position set @@ -527,6 +594,16 @@ def to_frame(self) -> pd.DataFrame: >>> position_set = PositionSet(positions=my_positions) >>> position_set.to_frame() + Retrieve tags in the pd DataFrame: + + >>> from gs_quant.markets.position_set import PositionSet + >>> pset = PositionSet.from_dicts([ + >>> {'identifier': 'AAPL UW', 'quantity': 100, 'MyTag': 'Name 1'}, + >>> {'identifier': 'AAPL UW', 'quantity': 100, 'MyTag': 'Name 2'}, + >>> {'identifier': 'META UW', 'quantity': 100, 'MyTag': 'Name 1'} + >>> ], add_tags=True) + >>> pset.to_frame(add_tags=True) + **See also** :func:`from_frame` :func:`from_dicts` :func:`from_list` @@ -536,7 +613,7 @@ def to_frame(self) -> pd.DataFrame: position = dict(date=self.date.isoformat()) if self.divisor is not None: position.update(dict(divisor=self.divisor)) - position.update(p.as_dict()) + position.update(p.as_dict(tags_as_keys=add_tags)) positions.append(position) return pd.DataFrame(positions) @@ -618,7 +695,8 @@ def redistribute_weights(self): def price(self, currency: Optional[Currency] = Currency.USD, use_unadjusted_close_price: bool = True, - weighting_strategy: Optional[PositionSetWeightingStrategy] = None, **kwargs): + weighting_strategy: Optional[PositionSetWeightingStrategy] = None, + handle_long_short: bool = False, **kwargs): """ Fetch positions weights from quantities, or vice versa @@ -626,6 +704,9 @@ def price(self, currency: Optional[Currency] = Currency.USD, :param use_unadjusted_close_price: Use adjusted or unadjusted close prices (defaults to unadjusted) :param weighting_strategy: Quantity or Weighted weighting strategy (defaults based on positions info) :param use_tags: Determines if tags are used to index the position response for non-netted positions + :param handle_long_short: Whether to handle the loss of directionality in weights that comes from pricing using + gross notional. Useful when input position iset is a long/short. Note, this also sets the reference notional to + Gross Notional if not already so **Usage** @@ -682,14 +763,59 @@ def price(self, currency: Optional[Currency] = Currency.USD, for p in self.positions: asset_key = f'{p.asset_id}{self.__hash_position_tag_list(p.tags)}' if asset_key in position_result_map: - p.weight = position_result_map.get(asset_key).weight - p.quantity = position_result_map.get(asset_key).quantity - p._hard_to_borrow = position_result_map.get(asset_key).hard_to_borrow + pos: PositionPriceResponse = position_result_map.get(asset_key) + p.quantity = pos.quantity + w = pos.weight + if handle_long_short: + # In case of long/short positions, we need to convert the returned gross weight to reference weight + w = math.copysign(w, pos.notional) + p.weight = w + p._hard_to_borrow = pos.hard_to_borrow priced_positions.append(p) else: unpriced_positions.append(p) self.positions = priced_positions self.__unpriced_positions = unpriced_positions + if handle_long_short: + # Set notional to gross notional because in case of L/S pricing the API normalizes all weights wrt gross + self.reference_notional = results.gross_notional + + def get_subset(self, copy: bool = True, **kwargs): + """Extract a subset of the position set based on values of tags. + + Not that weights are returned with respect to original position set. Use redistribute_weights function. + For more advanced filtering, use .to_frame() to get the frame as a pandas DataFrame. + + **Usage** + Given a position set that has tags, extract a subset of the positions based on the values of one or more of the + tags. + + **Examples** + Extract a subset of the position set based on the value of a single tag: + + >>> from gs_quant.markets.position_set import Position, PositionSet + >>> from gs_quant.target.common import PositionTag + >>> pset = PositionSet.from_dicts([ + >>> {'identifier': 'AAPL UW', 'quantity': 1000, 'MyTag': 'Name 1', 'MyOtherTag': 'Class 1'}, + >>> {'identifier': 'MSFT UW', 'quantity': 2000, 'MyTag': 'Name 2', 'MyOtherTag': 'Class 1'}, + >>> {'identifier': 'GOOGL UW', 'quantity': 3000, 'MyTag': 'Name 1', 'MyOtherTag': 'Class 2'} + >>> ]) + >>> + >>> subset = pset.get_subset(MyTag='Name 1') + + Extract a subset of the position set based on the values of multiple tags: + + >>> subset = pset.get_subset(MyTag='Name 1', MyOtherTag='Class 2') + + """ + subset = [] + for p in self.positions: + if not p.tags: + raise MqValueError(f'PositionSet has position {p.identifier} that does not have tags') + tags_dict = p.tags_as_dict() + if all(tags_dict.get(k) == v for k, v in kwargs.items()): + subset.append(p if not copy else p.clone()) + return PositionSet(positions=subset, date=self.date, reference_notional=self.reference_notional) def to_target(self, common: bool = True) -> Union[CommonPositionSet, List[PositionPriceInput]]: """ Returns PostionSet type defined in target file for API payloads """ @@ -763,13 +889,14 @@ def from_dicts(cls, positions: List[Dict], :func:`get_positions` :func:`resolve` :func:`from_list` :func:`from_frame` :func:`to_frame` """ positions_df = pd.DataFrame(positions) - return cls.from_frame(positions_df, date, reference_notional, add_tags) + return cls.from_frame(positions_df, date, reference_notional, add_tags=add_tags) @classmethod def from_frame(cls, positions: pd.DataFrame, date: datetime.date = datetime.date.today(), reference_notional: float = None, + divisor: float = None, add_tags: bool = False): """ Create PostionSet instance from a dataframe of positions @@ -812,17 +939,22 @@ def from_frame(cls, ) ) - return cls(positions_list, date, reference_notional=reference_notional) + return cls(positions_list, date, reference_notional=reference_notional, divisor=divisor) @staticmethod def __get_tag_columns(positions: pd.DataFrame) -> List[str]: - return [c for c in positions.columns if c.lower() not in ['identifier', 'id', 'quantity', 'weight', 'date']] + return [c for c in positions.columns if c.lower() not in + ['identifier', 'id', 'quantity', 'weight', 'date', 'restricted']] @staticmethod def __normalize_position_columns(positions: pd.DataFrame) -> List[str]: columns = [] + if 'asset_id' in positions.columns and 'id' not in positions.columns: + positions = positions.rename(columns={'asset_id': 'id'}) for c in positions.columns: - columns.append(c.lower() if c.lower() in ['identifier', 'id', 'quantity', 'weight', 'date'] else c) + columns.append( + c.lower() if c.lower() in ['identifier', 'id', 'quantity', 'weight', 'date', 'restricted'] else c + ) return columns @staticmethod diff --git a/gs_quant/markets/report.py b/gs_quant/markets/report.py index ccba1a83..55259778 100644 --- a/gs_quant/markets/report.py +++ b/gs_quant/markets/report.py @@ -130,6 +130,14 @@ def __init__(self, self.__start_date = start_date self.__end_date = end_date + @property + def job_id(self) -> str: + return self.__job_id + + @property + def end_date(self) -> dt.date: + return self.__end_date + def status(self) -> ReportStatus: """ :return: the status of the report job @@ -168,6 +176,26 @@ def result(self): return pd.DataFrame(results) return None + def wait_for_completion(self, sleep_time: int = 10, max_retries: int = 10, error_on_timeout: bool = True) -> bool: + """Periodically query status and sleep till the status become done. If error_on_timeout is false, returns + boolean value indicating if the job is done or not""" + retries = 0 + while not self.done() and retries < max_retries: + sleep(sleep_time) + retries += 1 + if retries == max_retries: + if error_on_timeout: + raise MqValueError(f'Report job {self.__job_id} is taking longer than expected to finish. ' + f'Please contact the Marquee Analytics team at gs-marquee-analytics-support@gs.com ' + 'if the issue persists.') + else: + print(f'Report job {self.__job_id} is taking longer than expected to finish.') + return False + return True + + def reschedule(self): + GsReportApi.reschedule_report_job(self.__job_id) + class Report: """General report class""" @@ -778,6 +806,7 @@ def get_portfolio_constituents(self, fields: List[str] = None, start_date: dt.date = None, end_date: dt.date = None, + prefer_rebalance_positions: bool = False, return_format: ReturnFormat = ReturnFormat.DATA_FRAME) -> Union[Dict, pd.DataFrame]: """ Get historical portfolio constituents @@ -785,6 +814,7 @@ def get_portfolio_constituents(self, :param fields: list of fields to include in the results :param start_date: start date :param end_date: end date + :param prefer_rebalance_positions: If both Holding and Rebalance entries are present, prefer rebalance entries :param return_format: return format; defaults to a Pandas DataFrame, but can be manually set to ReturnFormat.JSON :return: Portfolio constituent data for each day in the requested date range @@ -799,6 +829,16 @@ def get_portfolio_constituents(self, results = [GsDataApi.query_data(query=query, dataset_id=ReportDataset.PORTFOLIO_CONSTITUENTS.value) for query in queries] results = sum(results, []) + if prefer_rebalance_positions: + rebalance_dates = set() + for result in results: + if result['entryType'] == 'Rebalance': + rebalance_dates.add(result['date']) + + results = [ + result for result in results + if result['date'] not in rebalance_dates or result['entryType'] == 'Rebalance' + ] return pd.DataFrame(results) if return_format == ReturnFormat.DATA_FRAME else results def get_pnl_contribution(self,