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..706731372e83 --- /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 = [ + ("BBG00Y271826", 100), + ] + + 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=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="Figi", + 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..15389d5596e7 --- /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 = [ + ("BBG00Y271826", 10000), + ("BBG005D5KGM0", 20000), + ("BBG000DPM932", 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_differing_ids_quote_txns.py b/sdk/tests/tutorials/valuation/test_recipe_differing_ids_quote_txns.py new file mode 100644 index 000000000000..73c2c0e9750f --- /dev/null +++ b/sdk/tests/tutorials/valuation/test_recipe_differing_ids_quote_txns.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 = [ + ("GB00BMH18Q19", 100), + ("US31959T1025", 200), + ("GB00B1QH8P22", 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 = [ + ("GB00BMH18Q19", 100), + ("US31959T1025", 200), + ("GB00B1QH8P22", 300), + ] + # Instrument IDs as upserted originally using the InstrumentLoader() + instruments = [ + ("BBG00Y271826", "BYTES TECHNOLOGY GROUP PLC"), + ("BBG005D5KGM0", "FIRST CITRUS BANCORPORATION"), + ("BBG000DPM932", "FRASERS GROUP PLC") + ] + + # 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) + self.assertEqual(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..a4436bf5507e 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), + ("BBG00Y271826", 100), + ("BBG005D5KGM0", 200), + ("BBG000DPM932", 300), ] requests = [ @@ -100,18 +29,18 @@ def setup_portfolio(cls, effective_date, portfolio_code) -> None: models.QuoteSeriesId( provider="Lusid", instrument_id=price[0], - instrument_id_type="LusidInstrumentId", + instrument_id_type="Figi", 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 @@ -137,7 +67,7 @@ def create_configuration_recipe( suppliers=models.MarketContextSuppliers(equity="Lusid"), options=models.MarketOptions( default_supplier="Lusid", - default_instrument_code_type="LusidInstrumentId", + default_instrument_code_type="Figi", default_scope=TestDataUtilities.tutorials_scope, ), ), @@ -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 00edc3bcc8de..14d37eee08cb 100644 --- a/sdk/tests/utilities/__init__.py +++ b/sdk/tests/utilities/__init__.py @@ -5,3 +5,5 @@ from utilities.temp_file_manager import TempFileManager from utilities.mock_api_response import MockApiResponse from utilities.id_generator import IdGenerator +from utilities.portfolio_loader import PortfolioLoader +from utilities.base_valuation_utilities import BaseValuationUtilities diff --git a/sdk/tests/utilities/base_valuation_utilities.py b/sdk/tests/utilities/base_valuation_utilities.py new file mode 100644 index 000000000000..c6d5bb03c38b --- /dev/null +++ b/sdk/tests/utilities/base_valuation_utilities.py @@ -0,0 +1,146 @@ +import uuid +import unittest +import pytz +from datetime import datetime + +import lusid +import lusid.models as models + +from utilities import TestDataUtilities, PortfolioLoader, InstrumentLoader, IdGenerator + +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) + + cls.id_generator = IdGenerator(scope=TestDataUtilities.tutorials_scope) + + _, cls.portfolio_scope, cls.portfolio_code = cls.id_generator.generate_scope_and_code( + entity="portfolio", + scope=TestDataUtilities.tutorials_scope, + code_prefix="portfolio-" + ) + _, cls.xccy_portfolio_scope, cls.xccy_portfolio_code = cls.id_generator.generate_scope_and_code( + entity="portfolio", + scope=TestDataUtilities.tutorials_scope, + code_prefix="portfolio-" + ) + + cls.portfolio_code = cls.test_data_utilities.create_transaction_portfolio( + scope=TestDataUtilities.tutorials_scope, + code=cls.portfolio_code + ) + cls.xccy_portfolio_code = cls.test_data_utilities.create_transaction_portfolio( + TestDataUtilities.tutorials_scope, + code=cls.xccy_portfolio_code + ) + + # 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 + cls.instrument_loader = InstrumentLoader(cls.instruments_api) + cls.instrument_ids = cls.instrument_loader.load_instruments() + + # Setup scopes for recipe tests + cls.recipe_scope = "SampleRecipes" + cls.recipe_code = "SampleRecipeQuotes" + + # Set market data scope to be used with quotes and recipes + cls.market_data_provider = "Lusid" + cls.market_data_scope = TestDataUtilities.market_data_scope + + # 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) + + cls.instrument_loader.delete_instruments() \ No newline at end of file diff --git a/sdk/tests/utilities/instrument_loader.py b/sdk/tests/utilities/instrument_loader.py index e848c0b0eb95..f24cc4ade1bc 100644 --- a/sdk/tests/utilities/instrument_loader.py +++ b/sdk/tests/utilities/instrument_loader.py @@ -18,7 +18,7 @@ class InstrumentLoader: def __init__(self, instruments_api: lusid.InstrumentsApi): self.instruments_api = instruments_api - def load_instruments(self): + def load_instruments(self, return_luids=True, alternate_id="Figi"): instruments_to_create = { i.Figi: models.InstrumentDefinition( name=i.Name, @@ -32,7 +32,12 @@ def load_instruments(self): assert (len(response.failed) == 0) - return sorted([i.lusid_instrument_id for i in response.values.values()]) + if return_luids: + identifiers = sorted([i.lusid_instrument_id for i in response.values.values()]) + else: + identifiers = sorted([i.identifiers[alternate_id] for i in response.values.values()]) + + return identifiers def delete_instruments(self): for i in self.__instruments: diff --git a/sdk/tests/utilities/portfolio_loader.py b/sdk/tests/utilities/portfolio_loader.py new file mode 100644 index 000000000000..50be7d50df37 --- /dev/null +++ b/sdk/tests/utilities/portfolio_loader.py @@ -0,0 +1,112 @@ +import lusid +from utilities import InstrumentLoader +from utilities import TestDataUtilities + + +class PortfolioLoader(TestDataUtilities): + + def __init__(self, transaction_portfolios_api: lusid.TransactionPortfoliosApi, instruments_api: lusid.InstrumentsApi): + self.transaction_portfolios_api = transaction_portfolios_api + self.instruments_api = instruments_api + + portfolio_figis = [ + "BBG00Y271826", + "BBG005D5KGM0", + "BBG000DPM932", + ] + + 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 = InstrumentLoader(self.instruments_api).load_instruments(return_luids=False) + for figi in self.portfolio_figis: + assert figi in instrument_ids + + transactions = [ + self.build_transaction_request( + instrument_id="BBG00Y271826", + id_type="Figi", + units=100, + price=100, + currency="GBP", + trade_date=effective_date, + transaction_type="StockIn", + ), + self.build_transaction_request( + instrument_id="BBG005D5KGM0", + id_type="Figi", + units=100, + price=100, + currency="GBP", + trade_date=effective_date, + transaction_type="StockIn", + ), + self.build_transaction_request( + instrument_id="BBG000DPM932", + id_type="Figi", + units=100, + price=100, + 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 = InstrumentLoader(self.instruments_api).load_instruments(return_luids=False) + for figi in self.portfolio_figis: + assert figi in instrument_ids + + transactions = [ + self.build_transaction_request( + instrument_id="BBG00Y271826", + id_type="Figi", + units=100, + price=101, + currency="EUR", + trade_date=effective_date, + transaction_type="StockIn", + ), + self.build_transaction_request( + instrument_id="BBG005D5KGM0", + id_type="Figi", + units=100, + price=102, + currency="USD", + trade_date=effective_date, + transaction_type="StockIn", + ), + self.build_transaction_request( + instrument_id="BBG000DPM932", + id_type="Figi", + 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 diff --git a/sdk/tests/utilities/test_data_utilities.py b/sdk/tests/utilities/test_data_utilities.py index 2e7e5f575f21..41607ea8892b 100644 --- a/sdk/tests/utilities/test_data_utilities.py +++ b/sdk/tests/utilities/test_data_utilities.py @@ -35,17 +35,17 @@ def api_client(cls): cls._api_client = ApiClientBuilder().build(CredentialsSource.secrets_path()) return cls._api_client - def create_transaction_portfolio(self, scope): - guid = str(uuid.uuid4()) + def create_transaction_portfolio(self, scope, code=None, currency="GBP"): + guid = code if code else str(uuid.uuid4()) # Effective date of the portfolio, this is the date the portfolio was created and became live. # All dates/times must be supplied in UTC effective_date = datetime(2018, 1, 1, tzinfo=pytz.utc) # Details of the new portfolio to be created, created here with the minimum set of mandatory fields - request = models.CreateTransactionPortfolioRequest(display_name="Portfolio-{}".format(guid), - code="Id-{}".format(guid), - base_currency="GBP", + request = models.CreateTransactionPortfolioRequest(display_name=f"Portfolio-{guid}", + code=f"Id-{guid}", + base_currency=currency, created=effective_date) # Create the portfolio in LUSID @@ -55,10 +55,10 @@ def create_transaction_portfolio(self, scope): return portfolio.id.code - def build_transaction_request(self, instrument_id, units, price, currency, trade_date, transaction_type): + def build_transaction_request(self, instrument_id, units, price, currency, trade_date, transaction_type, id_type="LusidInstrumentId"): return models.TransactionRequest(transaction_id=str(uuid.uuid4()), type=transaction_type, - instrument_identifiers={self.lusid_luid_identifier: instrument_id}, + instrument_identifiers={f"Instrument/default/{id_type}": instrument_id}, transaction_date=trade_date, settlement_date=trade_date, units=units, diff --git a/sdk/tests/utilities/test_instrument_loader.py b/sdk/tests/utilities/test_instrument_loader.py new file mode 100644 index 000000000000..de7cb7708b2a --- /dev/null +++ b/sdk/tests/utilities/test_instrument_loader.py @@ -0,0 +1,36 @@ +import unittest + +import lusid + +from utilities import InstrumentLoader +from utilities import TestDataUtilities + +class InstrumentLoaderTests(unittest.TestCase): + + @classmethod + def setUpClass(cls): + # Create a configured API client + api_client = TestDataUtilities.api_client() + cls.instruments_api = lusid.InstrumentsApi(api_client) + cls.instrument_loader = InstrumentLoader(cls.instruments_api) + + def test_load_instruments(self): + # Test luids returned in default case + luids = self.instrument_loader.load_instruments() + self.assertEqual(len(luids), 5) + + def test_load_instrument_figis(self): + + # Expected Figis - see utilities.instrument_loader + expected_figis = [ + "BBG00KTDTF73", + "BBG00Y271826", + "BBG00L7XVNP1", + "BBG005D5KGM0", + "BBG000DPM932", + ] + + # Test figis match expected identifiers + figis = self.instrument_loader.load_instruments(return_luids=False) + self.assertEqual(len(figis), len(expected_figis)) + self.assertTrue(set(figis).issuperset(set(expected_figis)))