From 76b2b5f5955ec3c2858cb11f722fd045b0dbfe2d Mon Sep 17 00:00:00 2001 From: Christian Scialino Date: Tue, 20 Apr 2021 20:12:07 +0100 Subject: [PATCH] SE-702: Fixing merge conflicts SE-702:added setup recipe code SE-702: Added setup base class SE-702: WIP - extended base setup class SE-702: WIP - extended base setup, clean commented out code SE-702: Updated EDI tests to only seek marketresolverfailure --- .../valuation/test_quotes_scaling_factor.py | 114 +++++++++++ .../test_recipe_currency_subunits.py | 136 +++++++++++++ .../test_recipe_diff_quote_txns_ids.py | 186 ++++++++++++++++++ .../tutorials/valuation/test_recipe_edi_tm.py | 142 +++++++++++++ .../valuation/test_recipe_multi_provider.py | 151 ++++++++++++++ .../valuation/test_recipe_multi_source.py | 153 ++++++++++++++ .../valuation/test_recipe_quote_interval.py | 174 ++++++++++++++++ .../tutorials/valuation/test_valuation.py | 185 ++++++----------- sdk/tests/utilities/__init__.py | 4 +- .../utilities/base_valuation_tests_setup.py | 129 ++++++++++++ sdk/tests/utilities/portfolio_loader.py | 120 +++++++++++ 11 files changed, 1373 insertions(+), 121 deletions(-) create mode 100644 sdk/tests/tutorials/valuation/test_quotes_scaling_factor.py create mode 100644 sdk/tests/tutorials/valuation/test_recipe_currency_subunits.py create mode 100644 sdk/tests/tutorials/valuation/test_recipe_diff_quote_txns_ids.py create mode 100644 sdk/tests/tutorials/valuation/test_recipe_edi_tm.py create mode 100644 sdk/tests/tutorials/valuation/test_recipe_multi_provider.py create mode 100644 sdk/tests/tutorials/valuation/test_recipe_multi_source.py create mode 100644 sdk/tests/tutorials/valuation/test_recipe_quote_interval.py create mode 100644 sdk/tests/utilities/base_valuation_tests_setup.py create mode 100644 sdk/tests/utilities/portfolio_loader.py diff --git a/sdk/tests/tutorials/valuation/test_quotes_scaling_factor.py b/sdk/tests/tutorials/valuation/test_quotes_scaling_factor.py new file mode 100644 index 000000000000..3ad344c830d9 --- /dev/null +++ b/sdk/tests/tutorials/valuation/test_quotes_scaling_factor.py @@ -0,0 +1,114 @@ +import unittest + +import lusid +import lusid.models as models +from utilities import TestDataUtilities, BaseValuationUtilities + + +class ScalingFactor(BaseValuationUtilities): + """This is an example of using the scale_factor in an + upsert_quotes request, which is applicable to instruments + where the price requires scaling, such as bonds. For a bond + with quotes as a percentage of par of 100, adding this value + as the scaling factor will account for the transformation when + running a valuation + """ + + def upsert_quotes(self, scale_factor) -> None: + """ + Upserts quotes using a scaling factor parameter, that can be + used to for non-standard quotes such as bond prices that are + quotes as percentage as par (i.e. 'scaling_factor=100'). + :param float scale_factor: scale factor for non-standard quotes + :return: None + """ + # Add prices as a percentage of par + prices = [ + (self.instrument_ids[0], 100), + ] + + requests = [ + models.UpsertQuoteRequest( + quote_id=models.QuoteId( + models.QuoteSeriesId( + provider="Lusid", + instrument_id=price[0], + instrument_id_type="LusidInstrumentId", + quote_type="Price", + field="mid", + ), + effective_at=self.effective_date, + ), + metric_value=models.MetricValue(value=price[1], unit="GBP"), + # Set a scaling factor for the par value + scale_factor=scale_factor, + ) + for price in prices + ] + + self.quotes_api.upsert_quotes( + TestDataUtilities.tutorials_scope, + request_body={ + "quote" + str(request_number): requests[request_number] + for request_number in range(len(requests)) + }, + ) + + def create_configuration_recipe( + self, recipe_scope, recipe_code + ) -> lusid.models.ConfigurationRecipe: + """ + Creates a configuration recipe that can be used inline or upserted + :param str recipe_scope: The scope for the configuration recipe + :param str recipe_code: The code of the the configuration recipe + :return: ConfigurationRecipe + """ + + return models.ConfigurationRecipe( + scope=recipe_scope, + code=recipe_code, + market=models.MarketContext( + market_rules=[], + suppliers=models.MarketContextSuppliers(equity="Lusid"), + options=models.MarketOptions( + default_supplier="Lusid", + default_instrument_code_type="LusidInstrumentId", + default_scope=TestDataUtilities.tutorials_scope, + ), + ), + ) + + def test_par_scaled_valuation(self) -> None: + """ + Valuation test for a simple instrument using quotes upserted with a + scale_factor of 100, this would typically be applicable to bonds or + other instruments where the par amount is other than 1. + """ + + # Upsert quotes with scale_factor of 100 + self.upsert_quotes(100) + + # Upsert recipe + recipe = self.create_configuration_recipe(self.recipe_scope, self.recipe_code) + self.upsert_recipe_request(recipe) + + # Create valuation request + valuation_request = self.create_valuation_request( + self.portfolio_scope, + self.portfolio_code, + self.recipe_scope, + self.recipe_code, + ) + + # Complete valuation + valuation = self.aggregation_api.get_valuation( + valuation_request=valuation_request + ) + + # Asserts + self.assertEqual(len(valuation.data), 3) + self.assertEqual(valuation.data[0][self.valuation_portfolio_key], 100) + + +if __name__ == "__main__": + unittest.main() diff --git a/sdk/tests/tutorials/valuation/test_recipe_currency_subunits.py b/sdk/tests/tutorials/valuation/test_recipe_currency_subunits.py new file mode 100644 index 000000000000..b67d602f5dfe --- /dev/null +++ b/sdk/tests/tutorials/valuation/test_recipe_currency_subunits.py @@ -0,0 +1,136 @@ +import unittest +from datetime import datetime + +import lusid +import lusid.models as models +from utilities import BaseValuationUtilities + + +class CurrencySubUnitValuation(BaseValuationUtilities): + """ "This is an example of running valuation with prices quoted + in currency subunits, a typical example will be GBp/GBx where a + valuation may be carried out in GBP. In order for LUSID to carry out + the fx transformation, the key parameter is 'attempt_to_infer_missing_fx' + which needs to set to 'True' in the ConfigurationRecipe's pricing options + """ + + def create_configuration_recipe( + self, recipe_scope, recipe_code, infer_fx_rate + ) -> lusid.models.ConfigurationRecipe: + """ + Creates a configuration recipe that infers fx_rates when required, + this can be used to have the valuation request look for inverse fx + quotes or currency sub-units such as GBp/GBx. A key distinction in + valuation, is the use of the metric/key that will ensure valuation + is returned in either the domestic or portfolio currency, these + are 'Valuation/Pv' and 'Valuation/PvInPortfolioCcy' respectively. + :param str recipe_scope: The scope for the configuration recipe + :param str recipe_code: The code of the the configuration recipe + :param bool infer_fx_rate: looks up fx_rate from quote store when set to 'True' + :return: ConfigurationRecipe + """ + + return models.ConfigurationRecipe( + scope=recipe_scope, + code=recipe_code, + market=models.MarketContext( + market_rules=[ + models.MarketDataKeyRule( + key="Equity.Figi.*", + supplier="Lusid", + data_scope=self.market_data_scope, + quote_type="Price", + field="mid", + quote_interval="1D.0D", + ), + ], + suppliers=models.MarketContextSuppliers( + equity=self.market_data_provider + ), + options=models.MarketOptions( + default_supplier=self.market_data_provider, + default_instrument_code_type="Figi", + default_scope=self.market_data_scope, + attempt_to_infer_missing_fx=infer_fx_rate, + ), + ), + ) + + def upsert_quotes(self, quotes_date) -> models.UpsertQuotesResponse: + """ + Upserts quotes into LUSID to be used in pricing valuation + :param datetime quotes_date: The date of the upserted quotes + :return: UpsertQuotesResponse + """ + + prices = [ + ("BBG000BF46Y8", 10000), + ("BBG000PQKVN8", 20000), + ("BBG000FD8G46", 30000), + ] + + requests = [ + models.UpsertQuoteRequest( + quote_id=models.QuoteId( + models.QuoteSeriesId( + provider="Lusid", + instrument_id=price[0], + instrument_id_type="Figi", + quote_type="Price", + field="mid", + ), + effective_at=quotes_date, + ), + metric_value=models.MetricValue(value=price[1], unit="GBp"), + ) + for price in prices + ] + + return self.quotes_api.upsert_quotes( + scope=self.market_data_scope, + request_body={ + "quote" + str(request_number): requests[request_number] + for request_number in range(len(requests)) + }, + ) + + def test_currency_subunit(self): + """ + Tests that a recipe including an 'infer_fx_rate' boolean reconciles + market data when quotes provided in currency subunits such as GBp/GBx. + LUSID will look to translate the quotes back to the portfolio base currency, + based on prices in the quotes store + :return: None + """ + + # Upsert quotes with a timedelta of 2 days against out valuation date + quotes_response = self.upsert_quotes(self.effective_date) + self.assertEqual(len(quotes_response.failed), 0) + + # Upsert recipe with fx inference set to True + recipe = self.create_configuration_recipe( + self.recipe_scope, self.recipe_code, infer_fx_rate=True + ) + self.upsert_recipe_request(recipe) + + # Create valuation request + valuation_request = self.create_valuation_request( + self.portfolio_scope, + self.portfolio_code, + self.recipe_scope, + self.recipe_code, + ) + + valuation = self.aggregation_api.get_valuation( + valuation_request=valuation_request, + ) + + # Asserts + self.assertEqual(len(valuation.data), 3) + self.assertEqual(valuation.data[0][self.valuation_portfolio_key], 10000) + self.assertEqual(valuation.data[1][self.valuation_portfolio_key], 20000) + self.assertEqual(valuation.data[2][self.valuation_portfolio_key], 30000) + + +if __name__ == "__main__": + unittest.main() diff --git a/sdk/tests/tutorials/valuation/test_recipe_diff_quote_txns_ids.py b/sdk/tests/tutorials/valuation/test_recipe_diff_quote_txns_ids.py new file mode 100644 index 000000000000..784f1f3871f2 --- /dev/null +++ b/sdk/tests/tutorials/valuation/test_recipe_diff_quote_txns_ids.py @@ -0,0 +1,186 @@ +import json + +import lusid +import lusid.models as models +from utilities import BaseValuationUtilities + + +class IdentifiersValuation(BaseValuationUtilities): + """This is an example recipe showing handling of differing + identifiers in valuation. When using a different identifier + when setting transactions to the ones used for market data + in the quotes store, the valuation engine can resolve the + two provided both are stored on the instrument level. When + only one is stored in the instruments master, valuation will + return a 'MarketResolverFailure', which can be solved by + adding the missing identifier to the instrument. + """ + + def upsert_quotes(self, instrument_id) -> models.UpsertQuotesResponse: + """ + Upserts quotes into LUSID to be used in pricing valuation + :param str instrument_id: The identifier used in the upserted quotes + :return: UpsertQuotesResponse + """ + + prices = [ + ("GB0008847096", 100), + ("GB00B1CRLC47", 200), + ("BMG4593F1389", 300), + ] + + requests = [ + models.UpsertQuoteRequest( + quote_id=models.QuoteId( + models.QuoteSeriesId( + provider=self.market_data_provider, + instrument_id=price[0], + instrument_id_type=instrument_id, + quote_type="Price", + field="mid", + ), + effective_at=self.effective_date, + ), + metric_value=models.MetricValue(value=price[1], unit="GBP"), + ) + for price in prices + ] + + return self.quotes_api.upsert_quotes( + scope=self.market_data_scope, + request_body={ + "quote" + str(request_number): requests[request_number] + for request_number in range(len(requests)) + }, + ) + + def create_configuration_recipe( + self, recipe_scope, recipe_code, default_id + ) -> lusid.models.ConfigurationRecipe: + """ + Creates a configuration recipe that sets the default lookup identifier + used for reconciling quotes. In cases where transactions are booked using + different identifiers than those used in upserting quotes, LUSID will try + and reconcile these, provided both identifiers are stored on the instruments. + valuation request have + :param str recipe_scope: The scope for the configuration recipe + :param str recipe_code: The code of the the configuration recipe + :param str default_id: The default identifier used for looking up quotes + :return: ConfigurationRecipe + """ + + return models.ConfigurationRecipe( + scope=recipe_scope, + code=recipe_code, + # Specify the market context around which pricing data to use + market=models.MarketContext( + # Set the pricing data defaults for identifiers, scope and provider + options=models.MarketOptions( + default_supplier=self.market_data_provider, + default_instrument_code_type=default_id, + default_scope=self.market_data_scope, + ), + ), + ) + + def test_raises_exception_when_instrument_id_not_updated(self) -> None: + """ + Tests that valuation raises a 'MarketResolverFailure' exception when quotes and + transactions are loaded using different identifiers. This only occurs when the + identifiers (in this case 'Isin') have not been added to the instruments used + in the valuation request, i.e. instruments need updating to resolve quotes. + """ + # Upsert quotes specifying the quote identifier + quotes_response = self.upsert_quotes(instrument_id="Isin") + self.assertEqual(len(quotes_response.failed), 0) + + # Create and upsert recipe, setting a default look-up id for quotes + recipe = self.create_configuration_recipe( + self.recipe_scope, self.recipe_code, default_id="Isin" + ) + self.upsert_recipe_request(recipe) + + # Create valuation request + valuation_request = self.create_valuation_request( + self.portfolio_scope, + self.portfolio_code, + self.recipe_scope, + self.recipe_code, + ) + + + with self.assertRaises(Exception) as context: + # Complete aggregation + self.aggregation_api.get_valuation( + valuation_request=valuation_request, + ) + # Check that 'MarketResolverFailure' exception raised + self.assertEqual( + json.loads(context.exception.body)["name"], "MarketResolverFailure" + ) + + def test_differing_quote_and_transaction_instrument_ids(self) -> None: + """ + Tests that valuation works when quotes uploaded using different identifiers + than the ones used in booking transactions. LUSID will seek to match quotes + to instruments using the provided identifiers, which will act as a bridge to + the identifiers used in valuation. Such a market data reconciliation issue + can be solved by adding the quote identifiers to the instruments. + """ + + quotes_response = self.upsert_quotes(instrument_id="Isin") + self.assertEqual(len(quotes_response.failed), 0) + + # Create and upsert recipe, setting a default look-up id for quotes + recipe = self.create_configuration_recipe( + self.recipe_scope, self.recipe_code, default_id="Isin" + ) + self.upsert_recipe_request(recipe) + + # Instrument ISINs from the upserted quotes + prices = [ + ("GB0008847096", 100), + ("GB00B1CRLC47", 200), + ("BMG4593F1389", 300), + ] + # Instrument IDs as upserted originally using the InstrumentLoader() + instruments = [ + ("BBG000BF46Y8", "TESCO PLC"), + ("BBG000PQKVN8", "MONDI PLC"), + ("BBG000FD8G46", "HISCOX LTD"), + ] + + # Create an upsert instrument request including the ISINs + update_instrument_request = { + id[0]: models.InstrumentDefinition( + name=id[1], + identifiers={ + "Figi": models.InstrumentIdValue(value=id[0]), + "Isin": models.InstrumentIdValue(value=prices[i][0]), + }, + ) + for i, id in enumerate(instruments) + } + + # Upsert instruments with ISINs to update the instrument identifiers + response = self.instruments_api.upsert_instruments(update_instrument_request) + assert len(response.failed) == 0 + + # Create valuation request + valuation_request = self.create_valuation_request( + self.portfolio_scope, + self.portfolio_code, + self.recipe_scope, + self.recipe_code, + ) + + # Complete valuation + valuation = self.aggregation_api.get_valuation( + valuation_request=valuation_request + ) + + # Asserts + self.assertEqual(len(valuation.data), 3) + self.assertEqual(valuation.data[0][self.valuation_portfolio_key], 10000) + self.assertEqual(valuation.data[1][self.valuation_portfolio_key], 20000) + self.assertEqual(valuation.data[2][self.valuation_portfolio_key], 30000) diff --git a/sdk/tests/tutorials/valuation/test_recipe_edi_tm.py b/sdk/tests/tutorials/valuation/test_recipe_edi_tm.py new file mode 100644 index 000000000000..5c6aab75a818 --- /dev/null +++ b/sdk/tests/tutorials/valuation/test_recipe_edi_tm.py @@ -0,0 +1,142 @@ +import unittest +import json + +import lusid +import lusid.models as models +from utilities import BaseValuationUtilities + + +class EdiTmRecipes(BaseValuationUtilities): + """This is an example of a recipe for running valuations with EDI + and TraderMade market data, that can be added automatically to a + LUSID environment upon request. When using automated market data + services, valuation can be carried out by simply providing a recipe + that points to these sources as shown below.""" + + + def create_configuration_recipe( + self, recipe_scope, recipe_code + ) -> lusid.models.ConfigurationRecipe: + """ + Creates a configuration recipe that can be used inline or upserted, + using market data key rules to infer EDI close prices for instruments + using FIGIs or LUIDs as look-up identifiers for equities, and TraderMade + FX rates. These can be setup based on client request, and would allow for + pricing without the need to upsert quotes separately + :param str recipe_scope: The scope for the configuration recipe + :param str recipe_code: The code of the the configuration recipe + :param str default_id: The default identifier used for looking up quotes + :return: ConfigurationRecipe + """ + + return models.ConfigurationRecipe( + scope=recipe_scope, + code=recipe_code, + market=models.MarketContext( + market_rules=[ + models.MarketDataKeyRule( + key="Equity.Figi.*", + supplier="Edi", + data_scope="EDI", + quote_type="Price", + field="close", + quote_interval="1D.0D", + price_source="EOD price file", + ), + models.MarketDataKeyRule( + key="Equity.LusidInstrumentId.*", + supplier="Edi", + data_scope="EDI", + quote_type="Price", + field="close", + quote_interval="1D.0D", + price_source="EOD price file", + ), + models.MarketDataKeyRule( + key="Fx.CurrencyPair.*", + supplier="TraderMade", + data_scope="TraderMadeFxData", + quote_type="Price", + field="close", + quote_interval="1D.0D", + price_source="", + ), + ], + suppliers=models.MarketContextSuppliers( + equity=self.market_data_provider + ), + options=models.MarketOptions( + default_supplier=self.market_data_provider, + default_scope=self.market_data_scope, + ), + ), + ) + + def test_edi_base_recipe(self): + """ + Tests that valuation for a recipe using EDI equity quotes. + These are updated in LUSID upon client request, and will not require a + separate API call to upsert quotes/market data. + """ + + # # Setup scopes for recipe tests + self.recipe_code = "EdiQuotes" + self.recipe_scope = "TestEdi" + + # Upsert recipe + recipe = self.create_configuration_recipe(self.recipe_scope, self.recipe_code) + self.upsert_recipe_request(recipe) + + # Create valuation request + valuation_request = self.create_valuation_request( + self.portfolio_scope, + self.portfolio_code, + self.recipe_scope, + self.recipe_code, + ) + + # Test recipe queries EDI quotes + with self.assertRaises(Exception) as context: + # Complete aggregation + self.aggregation_api.get_valuation( + valuation_request=valuation_request, + ) + # Check that 'MarketResolverFailure' exception raised + self.assertEqual( + json.loads(context.exception.body)["name"], "MarketResolverFailure" + ) + + def test_trader_made_base_recipe(self): + """ + Tests that valuation/valuation for a recipe using TraderMade FX quotes, + by valuing a cross-currency portfolio using GBP quotes. + These are updated in LUSID upon client request, and will not require a + separate API call to upsert quotes/market data. + """ + + # Upsert recipe + recipe = self.create_configuration_recipe(self.recipe_scope, self.recipe_code) + self.upsert_recipe_request(recipe) + + # Call valuation with recipe identifiers + valuation_request = self.create_valuation_request( + self.portfolio_scope, + self.xccy_portfolio_code, + self.recipe_scope, + self.recipe_code, + ) + + # Test recipe queries TM quotes + with self.assertRaises(Exception) as context: + # Complete aggregation + self.aggregation_api.get_valuation( + valuation_request=valuation_request, + ) + # Check that 'MarketResolverFailure' exception raised + self.assertEqual( + json.loads(context.exception.body)["name"], "MarketResolverFailure" + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/sdk/tests/tutorials/valuation/test_recipe_multi_provider.py b/sdk/tests/tutorials/valuation/test_recipe_multi_provider.py new file mode 100644 index 000000000000..f3bfe8c7e747 --- /dev/null +++ b/sdk/tests/tutorials/valuation/test_recipe_multi_provider.py @@ -0,0 +1,151 @@ +import unittest + +import lusid +import lusid.models as models +from utilities import BaseValuationUtilities + + +class MultiProviderRecipe(BaseValuationUtilities): + """This is an example of using recipes to manage multiple + market data providers, using a 'MarketDataRule'. These rules + can be passed as a list, where the providers will be prioritised + in order, allowing for waterfall logic when resolving market data. + Notice that a provider can have multiple price sources. + """ + + + def create_configuration_recipe( + self, recipe_scope, recipe_code + ) -> lusid.models.ConfigurationRecipe: + """ + Creates a configuration recipe that allows for use of 2 different + market data providers, which can be setup in hierarchical order + within using a 'MarketDataKeyRule'. When using the market_rule + parameter, LUSID will see to resolve using the first available data + from the providers specified in the list. + This example prioritises quotes from supplier 'Client' over 'Lusid' + :param str recipe_scope: The scope for the configuration recipe + :param str recipe_code: The code of the the configuration recipe + :return: ConfigurationRecipe + """ + + return models.ConfigurationRecipe( + scope=recipe_scope, + code=recipe_code, + market=models.MarketContext( + market_rules=[ + models.MarketDataKeyRule( + key="Equity.Figi.*", + supplier="Client", + data_scope=self.market_data_scope, + quote_type="Price", + field="mid", + quote_interval="1D.0D", + ), + models.MarketDataKeyRule( + key="Equity.Figi.*", + supplier="Lusid", + data_scope=self.market_data_scope, + quote_type="Price", + field="mid", + quote_interval="1D.0D", + ), + ], + suppliers=models.MarketContextSuppliers( + equity=self.market_data_provider + ), + options=models.MarketOptions( + default_supplier=self.market_data_provider, + default_instrument_code_type="Figi", + default_scope=self.market_data_scope, + ), + ), + ) + + def upsert_quotes(self) -> models.UpsertQuotesResponse: + """ + Upserts quotes into LUSID to be used in pricing valuation + using quotes from two separate sources for the same date + :return: UpsertQuotesResponse + """ + + provider_1 = [ + ("BBG000BF46Y8", 100), + ] + + provider_2 = [ + ("BBG000PQKVN8", 200), + ("BBG000FD8G46", 300), + ] + + requests_1 = [ + models.UpsertQuoteRequest( + quote_id=models.QuoteId( + models.QuoteSeriesId( + provider="Client", + instrument_id=price[0], + instrument_id_type="Figi", + quote_type="Price", + field="mid", + ), + effective_at=self.effective_date, + ), + metric_value=models.MetricValue(value=price[1], unit="GBP"), + ) + for price in provider_1 + ] + requests_2 = [ + models.UpsertQuoteRequest( + quote_id=models.QuoteId( + models.QuoteSeriesId( + provider="Lusid", + instrument_id=price[0], + instrument_id_type="Figi", + quote_type="Price", + field="mid", + ), + effective_at=self.effective_date, + ), + metric_value=models.MetricValue(value=price[1], unit="GBP"), + ) + for price in provider_2 + ] + + requests = requests_1 + requests_2 + + return self.quotes_api.upsert_quotes( + scope=self.market_data_scope, + request_body={ + "quote" + str(request_number): requests[request_number] + for request_number in range(len(requests)) + }, + ) + + def test_waterfall_provider(self): + + # Upsert quotes and recipe + quotes_response = self.upsert_quotes() + self.assertEqual(len(quotes_response.failed), 0) + recipe = self.create_configuration_recipe(self.recipe_scope, self.recipe_code) + self.upsert_recipe_request(recipe) + + # Create valuation request + valuation_request = self.create_valuation_request( + self.portfolio_scope, + self.portfolio_code, + self.recipe_scope, + self.recipe_code, + ) + valuation = self.aggregation_api.get_valuation( + valuation_request=valuation_request, + ) + + # Asserts + self.assertEqual(len(valuation.data), 3) + self.assertEqual(valuation.data[0][self.valuation_portfolio_key], 10000) + self.assertEqual(valuation.data[1][self.valuation_portfolio_key], 20000) + self.assertEqual(valuation.data[2][self.valuation_portfolio_key], 30000) + + +if __name__ == "__main__": + unittest.main() diff --git a/sdk/tests/tutorials/valuation/test_recipe_multi_source.py b/sdk/tests/tutorials/valuation/test_recipe_multi_source.py new file mode 100644 index 000000000000..cc8a3e852363 --- /dev/null +++ b/sdk/tests/tutorials/valuation/test_recipe_multi_source.py @@ -0,0 +1,153 @@ +import unittest + +import lusid +import lusid.models as models +from utilities import BaseValuationUtilities + + +class MultiSourceRecipe(BaseValuationUtilities): + """This is an example of using recipes to manage multiple + market data sources, using a 'MarketDataRule'. These rules + can be passed as a list, where the sources will be prioritised + in order, allowing for waterfall logic when resolving market data. + Multiple price sources can exist under a given market data provider. + """ + + def create_configuration_recipe( + self, recipe_scope, recipe_code + ) -> lusid.models.ConfigurationRecipe: + """ + Creates a configuration recipe that allows for use of 2 different + price_sources under a given market_data provider, using a + 'MarketDataKeyRule'. When using the market_rule parameter, LUSID + will seek to resolve using the first available data from the providers + specified in the list. + This example prioritises quotes from supplier 'Client' over 'Lusid' + :param str recipe_scope: The scope for the configuration recipe + :param str recipe_code: The code of the the configuration recipe + :return: ConfigurationRecipe + """ + + return models.ConfigurationRecipe( + scope=recipe_scope, + code=recipe_code, + market=models.MarketContext( + market_rules=[ + models.MarketDataKeyRule( + key="Equity.Figi.*", + supplier=self.market_data_provider, + data_scope=self.market_data_scope, + quote_type="Price", + field="mid", + quote_interval="1D.0D", + price_source="source_1", + ), + models.MarketDataKeyRule( + key="Equity.Figi.*", + supplier=self.market_data_provider, + data_scope=self.market_data_scope, + quote_type="Price", + field="mid", + quote_interval="1D.0D", + price_source="source_2", + ), + ], + suppliers=models.MarketContextSuppliers( + equity=self.market_data_provider + ), + options=models.MarketOptions( + default_supplier=self.market_data_provider, + default_instrument_code_type="Figi", + default_scope=self.market_data_scope, + ), + ), + ) + + def upsert_quotes(self) -> models.UpsertQuotesResponse: + """ + Upserts quotes into LUSID to be used in pricing valuation + using quotes from two separate sources for the same date + :return: UpsertQuotesResponse + """ + + source_1 = [ + ("BBG000BF46Y8", 100), + ] + + source_2 = [ + ("BBG000PQKVN8", 200), + ("BBG000FD8G46", 300), + ] + + requests_1 = [ + models.UpsertQuoteRequest( + quote_id=models.QuoteId( + models.QuoteSeriesId( + provider=self.market_data_provider, + price_source="source_1", + instrument_id=price[0], + instrument_id_type="Figi", + quote_type="Price", + field="mid", + ), + effective_at=self.effective_date, + ), + metric_value=models.MetricValue(value=price[1], unit="GBP"), + ) + for price in source_1 + ] + requests_2 = [ + models.UpsertQuoteRequest( + quote_id=models.QuoteId( + models.QuoteSeriesId( + provider=self.market_data_provider, + price_source="source_2", + instrument_id=price[0], + instrument_id_type="Figi", + quote_type="Price", + field="mid", + ), + effective_at=self.effective_date, + ), + metric_value=models.MetricValue(value=price[1], unit="GBP"), + ) + for price in source_2 + ] + + requests = requests_1 + requests_2 + + return self.quotes_api.upsert_quotes( + scope=self.market_data_scope, + request_body={ + "quote" + str(request_number): requests[request_number] + for request_number in range(len(requests)) + }, + ) + + def test_waterfall_source(self): + # Upsert quotes and recipe + quotes_response = self.upsert_quotes() + self.assertEqual(len(quotes_response.failed), 0) + recipe = self.create_configuration_recipe(self.recipe_scope, self.recipe_code) + self.upsert_recipe_request(recipe) + + # Create valuation request + valuation_request = self.create_valuation_request( + self.portfolio_scope, + self.portfolio_code, + self.recipe_scope, + self.recipe_code, + ) + valuation = self.aggregation_api.get_valuation( + valuation_request=valuation_request, + ) + + # Asserts + self.assertEqual(len(valuation.data), 3) + self.assertEqual(valuation.data[0][self.valuation_portfolio_key], 10000) + self.assertEqual(valuation.data[1][self.valuation_portfolio_key], 20000) + self.assertEqual(valuation.data[2][self.valuation_portfolio_key], 30000) + + +if __name__ == "__main__": + unittest.main() diff --git a/sdk/tests/tutorials/valuation/test_recipe_quote_interval.py b/sdk/tests/tutorials/valuation/test_recipe_quote_interval.py new file mode 100644 index 000000000000..c32989ff52a9 --- /dev/null +++ b/sdk/tests/tutorials/valuation/test_recipe_quote_interval.py @@ -0,0 +1,174 @@ +import unittest +from datetime import datetime +from datetime import timedelta +import json + +import lusid +import lusid.models as models +from utilities import BaseValuationUtilities + + +class QuoteIntervalRecipe(BaseValuationUtilities): + """This is an example of using recipes to resolve market data + using a quote interval, which allows the valuation engine to + resolve data for a broader time window. For example, setting the + window to '5D.0D' will look back 5 days from the provided valuation + date and use the first price that matches the criteria. + """ + + def create_configuration_recipe( + self, recipe_scope, recipe_code, quote_interval + ) -> lusid.models.ConfigurationRecipe: + """ + Creates a configuration recipe that can be used inline or upserted, + using a 'quote_interval' to look back for pricing data in the quotes + store when missing for the selected valuation date. The interval is + specified as a dot separated string with a start and end period, e.g. + 5D.0D to look back 5D starting today (0 days ago). + :param str recipe_scope: The scope for the configuration recipe + :param str recipe_code: The code of the the configuration recipe + :param str quote_interval: The look back interval for quotes data + :return: ConfigurationRecipe + """ + + return models.ConfigurationRecipe( + scope=recipe_scope, + code=recipe_code, + market=models.MarketContext( + market_rules=[ + models.MarketDataKeyRule( + key="Equity.Figi.*", + supplier="Lusid", + data_scope=self.market_data_scope, + quote_type="Price", + field="mid", + quote_interval=quote_interval, + ), + ], + suppliers=models.MarketContextSuppliers( + equity=self.market_data_provider + ), + options=models.MarketOptions( + default_supplier=self.market_data_provider, + default_instrument_code_type="Figi", + default_scope=self.market_data_scope, + ), + ), + ) + + + def upsert_quotes(self, quotes_date) -> models.UpsertQuotesResponse: + """ + Upserts quotes into LUSID to be used in pricing valuation + :param datetime quotes_date: The date of the upserted quotes + :return: UpsertQuotesResponse + """ + + prices = [ + ("BBG000BF46Y8", 100), + ("BBG000PQKVN8", 200), + ("BBG000FD8G46", 300), + ] + + requests = [ + models.UpsertQuoteRequest( + quote_id=models.QuoteId( + models.QuoteSeriesId( + provider="Lusid", + instrument_id=price[0], + instrument_id_type="Figi", + quote_type="Price", + field="mid", + ), + effective_at=quotes_date, + ), + metric_value=models.MetricValue(value=price[1], unit="GBP"), + ) + for price in prices + ] + + return self.quotes_api.upsert_quotes( + scope=self.market_data_scope, + request_body={ + "quote" + str(request_number): requests[request_number] + for request_number in range(len(requests)) + }, + ) + + def test_quote_interval(self): + """ + Tests that a recipe including a 'quote_interval' parameter + reconciles market data when not within the selected valuation + date or time-interval. LUSID will look back for prices in the quotes + store based on the specified interval in the 'MarketDataKeyRule' + :return: None + """ + + # Upsert quotes with a timedelta of 2 days against out valuation date + quotes_response = self.upsert_quotes(self.effective_date - timedelta(days=2)) + self.assertEqual(len(quotes_response.failed), 0) + + # Upsert recipe with '2D' quote interval to increase valuation window + recipe = self.create_configuration_recipe( + self.recipe_scope, self.recipe_code, "2D.0D" + ) + self.upsert_recipe_request(recipe) + + # Create valuation request + valuation_request = self.create_valuation_request( + self.portfolio_scope, + self.portfolio_code, + self.recipe_scope, + self.recipe_code, + ) + + valuation = self.aggregation_api.get_valuation( + valuation_request=valuation_request, + ) + + # Asserts + self.assertEqual(len(valuation.data), 3) + self.assertEqual(valuation.data[0][self.valuation_portfolio_key], 10000) + self.assertEqual(valuation.data[1][self.valuation_portfolio_key], 20000) + self.assertEqual(valuation.data[2][self.valuation_portfolio_key], 30000) + + def test_error_raised_outside_quote_interval(self): + """ + Tests that a recipe including a 'quote_interval' parameter + reconciles market data when not within the selected valuation + date or time-interval. LUSID will look back for prices in the quotes + store based on the specified interval in the 'MarketDataKeyRule' + :return: None + """ + # Upsert quotes with a timedelta of 2 days against out valuation date + quotes_response = self.upsert_quotes(self.effective_date - timedelta(days=2)) + self.assertEqual(len(quotes_response.failed), 0) + + # Upsert recipe with '0D' quote interval which is the default value + recipe = self.create_configuration_recipe( + self.recipe_scope, self.recipe_code, "0D.0D" + ) + self.upsert_recipe_request(recipe) + + # Create valuation request + valuation_request = self.create_valuation_request( + self.portfolio_scope, + self.portfolio_code, + self.recipe_scope, + self.recipe_code, + ) + + + with self.assertRaises(Exception) as context: + # Complete aggregation + self.aggregation_api.get_valuation( + valuation_request=valuation_request, + ) + # Check that 'MarketResolverFailure' exception raised + self.assertEqual( + json.loads(context.exception.body)["name"], "MarketResolverFailure" + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/sdk/tests/tutorials/valuation/test_valuation.py b/sdk/tests/tutorials/valuation/test_valuation.py index 54c3b09f0f8a..4e137b6cac42 100644 --- a/sdk/tests/tutorials/valuation/test_valuation.py +++ b/sdk/tests/tutorials/valuation/test_valuation.py @@ -1,97 +1,26 @@ -import unittest -from datetime import datetime -from parameterized import parameterized - -import pytz - import lusid import lusid.models as models -from utilities import InstrumentLoader -from utilities import TestDataUtilities - - -class Valuation(unittest.TestCase): - @classmethod - def setUpClass(cls): - # Create a configured API client - api_client = TestDataUtilities.api_client() - - # Setup required LUSID APIs - cls.transaction_portfolios_api = lusid.TransactionPortfoliosApi(api_client) - cls.portfolios_api = lusid.PortfoliosApi(api_client) - cls.instruments_api = lusid.InstrumentsApi(api_client) - cls.aggregation_api = lusid.AggregationApi(api_client) - cls.quotes_api = lusid.QuotesApi(api_client) - cls.recipes_api = lusid.ConfigurationRecipeApi(api_client) - instrument_loader = InstrumentLoader(cls.instruments_api) - cls.instrument_ids = instrument_loader.load_instruments() - - # Setup test data from utilities - cls.test_data_utilities = TestDataUtilities(cls.transaction_portfolios_api) - - # Set test parameters - cls.effective_date = datetime(2019, 4, 15, tzinfo=pytz.utc) - cls.portfolio_code = cls.test_data_utilities.create_transaction_portfolio( - TestDataUtilities.tutorials_scope - ) +from utilities import TestDataUtilities, BaseValuationUtilities - # Setup test portfolio - cls.setup_portfolio(cls.effective_date, cls.portfolio_code) - @classmethod - def tearDownClass(cls): - # Delete portfolio once tests are concluded - cls.portfolios_api.delete_portfolio( - TestDataUtilities.tutorials_scope, - cls.portfolio_code - ) +class Valuation(BaseValuationUtilities): + """This is an example of conducting a simple valution in LUSID, + illustrating how to create a portfolio, add transactions and up- + sert market data for pricing. Finally, a valuation_request is + carried out on the constructed portfolio + """ - @classmethod - def setup_portfolio(cls, effective_date, portfolio_code) -> None: + def upsert_quotes(self) -> models.UpsertQuotesResponse: """ - Sets up instrument, quotes and portfolio data from TestDataUtilities - :param datetime effective_date: The portfolio creation date - :param str portfolio_code: The code of the the test portfolio - :return: None + Upserts quotes into LUSID to be used in pricing valuation + :param str instrument_id: The identifier used in the upserted quotes + :return: UpsertQuotesResponse """ - transactions = [ - cls.test_data_utilities.build_transaction_request( - instrument_id=cls.instrument_ids[0], - units=100, - price=101, - currency="GBP", - trade_date=effective_date, - transaction_type="StockIn", - ), - cls.test_data_utilities.build_transaction_request( - instrument_id=cls.instrument_ids[1], - units=100, - price=102, - currency="GBP", - trade_date=effective_date, - transaction_type="StockIn", - ), - cls.test_data_utilities.build_transaction_request( - instrument_id=cls.instrument_ids[2], - units=100, - price=103, - currency="GBP", - trade_date=effective_date, - transaction_type="StockIn", - ), - ] - - cls.transaction_portfolios_api.upsert_transactions( - scope=TestDataUtilities.tutorials_scope, - code=portfolio_code, - transaction_request=transactions, - ) - prices = [ - (cls.instrument_ids[0], 100), - (cls.instrument_ids[1], 200), - (cls.instrument_ids[2], 300), + (self.instrument_ids[0], 100), + (self.instrument_ids[1], 200), + (self.instrument_ids[2], 300), ] requests = [ @@ -104,14 +33,14 @@ def setup_portfolio(cls, effective_date, portfolio_code) -> None: quote_type="Price", field="mid", ), - effective_at=effective_date, + effective_at=self.effective_date, ), metric_value=models.MetricValue(value=price[1], unit="GBP"), ) for price in prices ] - cls.quotes_api.upsert_quotes( + self.quotes_api.upsert_quotes( TestDataUtilities.tutorials_scope, request_body={ "quote" + str(request_number): requests[request_number] @@ -123,7 +52,8 @@ def create_configuration_recipe( self, recipe_scope, recipe_code ) -> lusid.models.ConfigurationRecipe: """ - Creates a configuration recipe that can be used inline or upserted + Creates a simple configuration recipe that can be upserted and + and referenced in valuation. Sets a simple lookup based recipe. :param str recipe_scope: The scope for the configuration recipe :param str recipe_code: The code of the the configuration recipe :return: ConfigurationRecipe @@ -153,52 +83,67 @@ def upsert_recipe_request(self, configuration_recipe) -> None: upsert_recipe_request = models.UpsertRecipeRequest(configuration_recipe) self.recipes_api.upsert_configuration_recipe(upsert_recipe_request) - @parameterized.expand( - [ - [ - "Test valuation with an aggregation request using an already upserted recipe", - None, - "TestRecipes", - "SimpleQuotes", - ], - ] - ) - def test_aggregation(self, _, in_line_recipe, recipe_scope, recipe_code) -> None: + def create_valuation_request( + self, recipe_scope=None, recipe_code=None + ) -> lusid.models.ValuationRequest: """ - General valuation/aggregation test + Creates a valuation request that can be used to return results based on + an upserted recipe for the selected portfolio. The selected key in this + example will return valuation in the portfolio currency (see 'Valuation/PV' + for results in domestic currency). Additionally, the 'op' parameter will + allow for use of arithmetic operators on a given metric. For more info on + the set of available metrics, see the 'get_queryable_keys()' call under + LUSID API docs, part of the Aggregation endpoint. + :param str recipe_scope: The scope for an already upserted recipe + :param str recipe_code: The code for an already upserted recipe + :return: ValuationRequest """ - # create recipe (provides model parameters, locations to use in resolving market data etc. - # and push it into LUSID. Only needs to happen once each time when updated, or first time run to create. - recipe = self.create_configuration_recipe(recipe_scope, recipe_code) - self.upsert_recipe_request(recipe) - # Set valuation result key - valuation_key = "Sum(Holding/default/PV)" + recipe_id = models.ResourceId(scope=recipe_scope, code=recipe_code) - # create valuation request - valuation_request = models.ValuationRequest( - recipe_id=models.ResourceId(scope=recipe_scope, code=recipe_code), + return models.ValuationRequest( + recipe_id=recipe_id, metrics=[ models.AggregateSpec("Instrument/default/Name", "Value"), - models.AggregateSpec("Holding/default/PV", "Proportion"), - models.AggregateSpec("Holding/default/PV", "Sum"), + models.AggregateSpec("Valuation/PvInPortfolioCcy", "Proportion"), + models.AggregateSpec("Valuation/PvInPortfolioCcy", "Sum"), ], group_by=["Instrument/default/Name"], - valuation_schedule=models.ValuationSchedule(effective_at=self.effective_date), portfolio_entity_ids=[ models.PortfolioEntityId( scope=TestDataUtilities.tutorials_scope, - code=self.portfolio_code) - ] + code=self.portfolio_code, + portfolio_entity_type="SinglePortfolio", + ) + ], + valuation_schedule=models.ValuationSchedule( + effective_from=self.effective_date, effective_at=self.effective_date + ), + ) + + def test_valuation(self) -> None: + """ + General valuation test using an upserted recipe + """ + + # Upsert recipes and quotes + recipe = self.create_configuration_recipe(self.recipe_scope, self.recipe_code) + self.upsert_recipe_request(recipe) + self.upsert_quotes() + + # Create valuation request + valuation_request = self.create_valuation_request( + self.recipe_scope, + self.recipe_code, ) - # Complete aggregation - aggregation = self.aggregation_api.get_valuation( + # Complete valuation + valuation = self.aggregation_api.get_valuation( valuation_request=valuation_request ) # Asserts - self.assertEqual(len(aggregation.data), 3) - self.assertEqual(aggregation.data[0][valuation_key], 10000) - self.assertEqual(aggregation.data[1][valuation_key], 20000) - self.assertEqual(aggregation.data[2][valuation_key], 30000) + self.assertEqual(len(valuation.data), 3) + self.assertEqual(valuation.data[0][self.valuation_portfolio_key], 10000) + self.assertEqual(valuation.data[1][self.valuation_portfolio_key], 20000) + self.assertEqual(valuation.data[2][self.valuation_portfolio_key], 30000) diff --git a/sdk/tests/utilities/__init__.py b/sdk/tests/utilities/__init__.py index 04a4066c546a..bf6fa805f24d 100644 --- a/sdk/tests/utilities/__init__.py +++ b/sdk/tests/utilities/__init__.py @@ -3,4 +3,6 @@ from utilities.test_data_utilities import TestDataUtilities from utilities.token_utilities import TokenUtilities from utilities.temp_file_manager import TempFileManager -from utilities.mock_api_response import MockApiResponse \ No newline at end of file +from utilities.mock_api_response import MockApiResponse +from utilities.portfolio_loader import PortfolioLoader +from utilities.base_valuation_tests_setup import BaseValuationUtilities \ No newline at end of file diff --git a/sdk/tests/utilities/base_valuation_tests_setup.py b/sdk/tests/utilities/base_valuation_tests_setup.py new file mode 100644 index 000000000000..596eaee88c85 --- /dev/null +++ b/sdk/tests/utilities/base_valuation_tests_setup.py @@ -0,0 +1,129 @@ +import lusid +import lusid.models as models +import pytz +from datetime import datetime +import uuid +import unittest + +from utilities import TestDataUtilities, PortfolioLoader, InstrumentLoader + +class BaseValuationUtilities(unittest.TestCase): + + @classmethod + def setUpClass(cls): + # Create a configured API client + api_client = TestDataUtilities.api_client() + + # Setup required LUSID APIs + cls.transaction_portfolios_api = lusid.TransactionPortfoliosApi(api_client) + cls.portfolios_api = lusid.PortfoliosApi(api_client) + cls.instruments_api = lusid.InstrumentsApi(api_client) + cls.aggregation_api = lusid.AggregationApi(api_client) + cls.quotes_api = lusid.QuotesApi(api_client) + cls.recipes_api = lusid.ConfigurationRecipeApi(api_client) + + # Setup test parameters + cls.effective_date = datetime(2019, 4, 15, tzinfo=pytz.utc) + + # Setup test data from utilities + cls.test_data_utilities = TestDataUtilities(cls.transaction_portfolios_api) + + # Setup test portfolios + cls.portfolio_scope = TestDataUtilities.tutorials_scope + cls.portfolio_code = cls.test_data_utilities.create_transaction_portfolio( + TestDataUtilities.tutorials_scope + ) + cls.xccy_portfolio_code = cls.test_data_utilities.create_transaction_portfolio( + TestDataUtilities.tutorials_scope + ) + # Load transactions to test portfolio + portfolio_loader = PortfolioLoader( + cls.transaction_portfolios_api, cls.instruments_api + ) + portfolio_loader.setup_gbp_portfolio( + cls.portfolio_scope, cls.portfolio_code, cls.effective_date + ) + portfolio_loader.setup_xccy_portfolio( + cls.portfolio_scope, cls.xccy_portfolio_code, cls.effective_date + ) + + # Required instruments + instrument_loader = InstrumentLoader(cls.instruments_api) + cls.instrument_ids = instrument_loader.load_instruments() + + # Setup scopes for recipe tests + cls.recipe_scope = "TestIdentifiers" + cls.recipe_code = "SimpleQuotes" + + # Set market data scope to be used with quotes and recipes + cls.market_data_provider = "Lusid" + cls.market_data_scope = "Test-" + str(uuid.uuid4()) + + # Set valuation key + cls.valuation_key = "Sum(Valuation/PV)" + cls.valuation_portfolio_key = "Sum(Valuation/PvInPortfolioCcy)" + + + @classmethod + def upsert_recipe_request(cls, configuration_recipe) -> None: + """ + Structures a recipe request and upserts it into LUSID + :param ConfigurationRecipe configuration_recipe: Recipe configuration + :return: None + """ + + upsert_recipe_request = models.UpsertRecipeRequest(configuration_recipe) + cls.recipes_api.upsert_configuration_recipe(upsert_recipe_request) + + @classmethod + def create_valuation_request( + cls, portfolio_scope, portfolio_code, recipe_scope, recipe_code + ) -> lusid.models.ValuationRequest: + """ + Creates a valuation request that can be used to return results based on + an upserted recipe for the selected portfolio. The selected key in this + example will return valuation in the portfolio currency (see 'Valuation/PV' + for results in domestic currency). Additionally, the 'op' parameter will + allow for use of arithmetic operators on a given metric. For more info on + the set of available metrics, see the 'get_queryable_keys()' call under + LUSID API docs, part of the Aggregation endpoint. + :param str recipe_scope: The scope for an already upserted recipe + :param str recipe_code: The code for an already upserted recipe + :return: ValuationRequest + """ + + recipe_id = models.ResourceId(scope=recipe_scope, code=recipe_code) + + return models.ValuationRequest( + recipe_id=recipe_id, + metrics=[ + models.AggregateSpec("Instrument/default/Name", "Value"), + models.AggregateSpec("Valuation/PvInPortfolioCcy", "Proportion"), + models.AggregateSpec("Valuation/PvInPortfolioCcy", "Sum"), + models.AggregateSpec("Valuation/PV", "Proportion"), + models.AggregateSpec("Valuation/PV", "Sum"), + ], + group_by=["Instrument/default/Name"], + portfolio_entity_ids=[ + models.PortfolioEntityId( + scope=portfolio_scope, + code=portfolio_code, + portfolio_entity_type="SinglePortfolio", + ) + ], + valuation_schedule=models.ValuationSchedule( + effective_from=cls.effective_date, effective_at=cls.effective_date + ), + ) + + @classmethod + def tearDownClass(cls): + # Delete Portfolios once tests are finished + portfolio_codes = [ + cls.portfolio_code, + cls.xccy_portfolio_code, + ] + + # Delete portfolio once tests are concluded + for code in portfolio_codes: + cls.portfolios_api.delete_portfolio(TestDataUtilities.tutorials_scope, code) \ No newline at end of file diff --git a/sdk/tests/utilities/portfolio_loader.py b/sdk/tests/utilities/portfolio_loader.py new file mode 100644 index 000000000000..1e327bbd3809 --- /dev/null +++ b/sdk/tests/utilities/portfolio_loader.py @@ -0,0 +1,120 @@ +import lusid +import lusid.models as models +from utilities import InstrumentLoader +import uuid + + +class PortfolioLoader: + + def __init__(self, transaction_portfolios_api: lusid.TransactionPortfoliosApi, instruments_api: lusid.InstrumentsApi): + self.transaction_portfolios_api = transaction_portfolios_api + self.instruments_api = instruments_api + + def load_instruments(self, instruments_api) -> list: + + instrument_loader = InstrumentLoader(instruments_api) + return instrument_loader.load_instruments() + + def build_transaction_request(self, instrument_id, id_type, units, price, currency, trade_date, transaction_type): + return models.TransactionRequest(transaction_id=str(uuid.uuid4()), + type=transaction_type, + instrument_identifiers={f"Instrument/default/{id_type}": instrument_id}, + transaction_date=trade_date, + settlement_date=trade_date, + units=units, + transaction_price=models.TransactionPrice(price=price), + total_consideration=models.CurrencyAndAmount(amount=price * units, + currency=currency), + source="Broker") + + def setup_gbp_portfolio(self, portfolio_scope, portfolio_code, effective_date) -> None: + """ + Sets up a GBP portfolio for testing purposes + :param str portfolio_scope: The scope of the the test portfolio + :param str portfolio_code: The code of the the test portfolio + :param datetime effective_date: The portfolio creation date + :return: None + """ + instrument_ids = self.load_instruments(self.instruments_api) + + transactions = [ + self.build_transaction_request( + instrument_id=instrument_ids[0], + id_type="LusidInstrumentId", + units=100, + price=101, + currency="GBP", + trade_date=effective_date, + transaction_type="StockIn", + ), + self.build_transaction_request( + instrument_id=instrument_ids[1], + id_type="LusidInstrumentId", + units=100, + price=102, + currency="GBP", + trade_date=effective_date, + transaction_type="StockIn", + ), + self.build_transaction_request( + instrument_id=instrument_ids[2], + id_type="LusidInstrumentId", + units=100, + price=103, + currency="GBP", + trade_date=effective_date, + transaction_type="StockIn", + ), + ] + + self.transaction_portfolios_api.upsert_transactions( + scope=portfolio_scope, + code=portfolio_code, + transaction_request=transactions, + ) + + def setup_xccy_portfolio(self, portfolio_scope, portfolio_code, effective_date) -> None: + """ + Sets up a cross-currency portfolio for testing purposes + :param str portfolio_scope: The scope of the the test portfolio + :param str portfolio_code: The code of the the test portfolio + :param datetime effective_date: The portfolio creation date + :return: None + """ + instrument_ids = self.load_instruments(self.instruments_api) + + transactions = [ + self.build_transaction_request( + instrument_id=instrument_ids[0], + id_type="LusidInstrumentId", + units=100, + price=101, + currency="EUR", + trade_date=effective_date, + transaction_type="StockIn", + ), + self.build_transaction_request( + instrument_id=instrument_ids[1], + id_type="LusidInstrumentId", + units=100, + price=102, + currency="USD", + trade_date=effective_date, + transaction_type="StockIn", + ), + self.build_transaction_request( + instrument_id=instrument_ids[2], + id_type="LusidInstrumentId", + units=100, + price=103, + currency="JPY", + trade_date=effective_date, + transaction_type="StockIn", + ), + ] + + self.transaction_portfolios_api.upsert_transactions( + scope=portfolio_scope, + code=portfolio_code, + transaction_request=transactions, + ) \ No newline at end of file