diff --git a/modules/purchase_trade/pricing.py b/modules/purchase_trade/pricing.py index 545b4ba..486a972 100755 --- a/modules/purchase_trade/pricing.py +++ b/modules/purchase_trade/pricing.py @@ -56,10 +56,21 @@ DAYS = [ ('thursday', 'Thursday'), ('friday', 'Friday'), ('saturday', 'Saturday'), - ('sunday', 'Sunday'), - ] - -class Estimated(ModelSQL, ModelView): + ('sunday', 'Sunday'), + ] + +MTM_VALUATION_DATE_TYPES = [ + ('fixed', 'Fixed Date'), + ('today', 'Today'), + ('month_start', 'Start of Month'), + ('month_end', 'End of Month'), + ('previous_month_end', 'End of Previous Month'), + ('next_month_end', 'End of Next Month'), + ('year_start', 'Start of Year'), + ('year_end', 'End of Year'), + ] + +class Estimated(ModelSQL, ModelView): "Estimated date" __name__ = 'pricing.estimated' @@ -558,18 +569,64 @@ class ImportPrices(Wizard): if re.match(r'^\d+(\.\d+)?$', text): return cls._as_date(text).strftime('%Y-%m') raise UserError("Invalid month term: %s" % text) -class MtmScenario(ModelSQL, ModelView): - "MtM Scenario" - __name__ = 'mtm.scenario' - - name = fields.Char("Scenario", required=True) - valuation_date = fields.Date("Valuation Date", required=True) - use_last_price = fields.Boolean("Use Last Available Price") - calendar = fields.Many2One( - 'price.calendar', "Calendar" - ) - -class MtmStrategy(ModelSQL, ModelView): +class MtmScenario(ModelSQL, ModelView): + "MtM Scenario" + __name__ = 'mtm.scenario' + + name = fields.Char("Scenario", required=True) + valuation_date_type = fields.Selection( + MTM_VALUATION_DATE_TYPES, "Valuation Date Type") + valuation_date = fields.Date( + "Valuation Date", + states={ + 'required': Eval('valuation_date_type') == 'fixed', + }, + depends=['valuation_date_type']) + resolved_valuation_date = fields.Function( + fields.Date("Resolved Valuation Date"), + 'get_resolved_valuation_date') + use_last_price = fields.Boolean("Use Last Available Price") + calendar = fields.Many2One( + 'price.calendar', "Calendar" + ) + + @staticmethod + def default_valuation_date_type(): + return 'fixed' + + def get_resolved_valuation_date(self, name=None): + return self.get_valuation_date() + + def get_valuation_date(self): + date_type = self.valuation_date_type or 'fixed' + if date_type == 'fixed': + return self.valuation_date + + today = Pool().get('ir.date').today() + if date_type == 'today': + return today + if date_type == 'month_start': + return datetime.date(today.year, today.month, 1) + if date_type == 'month_end': + return datetime.date( + today.year, today.month, + calendar.monthrange(today.year, today.month)[1]) + if date_type == 'previous_month_end': + return ( + datetime.date(today.year, today.month, 1) + - datetime.timedelta(days=1)) + if date_type == 'next_month_end': + year = today.year + (today.month // 12) + month = (today.month % 12) + 1 + return datetime.date( + year, month, calendar.monthrange(year, month)[1]) + if date_type == 'year_start': + return datetime.date(today.year, 1, 1) + if date_type == 'year_end': + return datetime.date(today.year, 12, 31) + return self.valuation_date + +class MtmStrategy(ModelSQL, ModelView): "Mark to Market Strategy" __name__ = 'mtm.strategy' @@ -598,16 +655,24 @@ class MtmStrategy(ModelSQL, ModelView): currency = default_itsa_currency() if currency: return currency - - def get_mtm(self,line,qty): - pool = Pool() - Currency = pool.get('currency.currency') - total = Decimal(0) - - scenario = self.scenario - dt = scenario.valuation_date - - for comp in self.components: + + @staticmethod + def _scenario_valuation_date(scenario): + get_valuation_date = getattr( + type(scenario), 'get_valuation_date', None) + if callable(get_valuation_date): + return scenario.get_valuation_date() + return scenario.valuation_date + + def get_mtm(self,line,qty): + pool = Pool() + Currency = pool.get('currency.currency') + total = Decimal(0) + + scenario = self.scenario + dt = self._scenario_valuation_date(scenario) + + for comp in self.components: value = Decimal(0) if comp.price_source_type == 'curve' and comp.price_index: @@ -662,13 +727,14 @@ class MtmStrategy(ModelSQL, ModelView): Snapshot = Pool().get('mtm.snapshot') for strat in Strategy.search([('active', '=', True)]): - amount = strat.compute_mtm() - Snapshot.create([{ - 'strategy': strat.id, - 'valuation_date': strat.scenario.valuation_date, - 'amount': amount, - 'currency': strat.currency.id, - }]) + amount = strat.compute_mtm() + valuation_date = strat._scenario_valuation_date(strat.scenario) + Snapshot.create([{ + 'strategy': strat.id, + 'valuation_date': valuation_date, + 'amount': amount, + 'currency': strat.currency.id, + }]) class Mtm(ModelSQL, ModelView): "MtM Component" diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py index d105461..f2d1c50 100644 --- a/modules/purchase_trade/tests/test_module.py +++ b/modules/purchase_trade/tests/test_module.py @@ -20,6 +20,7 @@ from trytond.modules.purchase_trade import purchase as purchase_module from trytond.modules.purchase_trade import sale as sale_module from trytond.modules.purchase_trade import stock as stock_module from trytond.modules.purchase_trade import fee as fee_module +from trytond.modules.purchase_trade import pricing as pricing_module from trytond.modules.purchase_trade import company_defaults from trytond.modules.purchase_trade.service import ContractFactory @@ -715,6 +716,78 @@ class PurchaseTradeTestCase(ModuleTestCase): strategy.currency, relative_last=True) + def test_mtm_scenario_resolves_fixed_valuation_date(self): + 'mtm scenario can force an explicit valuation date' + scenario = pricing_module.MtmScenario() + scenario.valuation_date_type = 'fixed' + scenario.valuation_date = datetime.date(2026, 6, 18) + + self.assertEqual( + scenario.get_valuation_date(), + datetime.date(2026, 6, 18)) + + def test_mtm_scenario_resolves_relative_valuation_dates(self): + 'mtm scenario can resolve relative valuation dates' + scenario = pricing_module.MtmScenario() + Date = Mock() + Date.today.return_value = datetime.date(2026, 2, 12) + + with patch( + 'trytond.modules.purchase_trade.pricing.Pool' + ) as PoolMock: + PoolMock.return_value.get.return_value = Date + + scenario.valuation_date_type = 'today' + self.assertEqual( + scenario.get_valuation_date(), + datetime.date(2026, 2, 12)) + + scenario.valuation_date_type = 'month_end' + self.assertEqual( + scenario.get_valuation_date(), + datetime.date(2026, 2, 28)) + + scenario.valuation_date_type = 'next_month_end' + self.assertEqual( + scenario.get_valuation_date(), + datetime.date(2026, 3, 31)) + + @with_transaction() + def test_get_mtm_uses_resolved_scenario_date(self): + 'get_mtm uses the scenario resolved valuation date' + Strategy = Pool().get('mtm.strategy') + strategy = Strategy() + scenario = pricing_module.MtmScenario() + scenario.valuation_date_type = 'month_end' + scenario.use_last_price = True + strategy.scenario = scenario + strategy.currency = Mock() + price_index = Mock(get_price=Mock(return_value=Decimal('100'))) + strategy.components = [Mock( + price_source_type='curve', + price_index=price_index, + price_matrix=None, + ratio=Decimal('100'), + )] + line = Mock(unit=Mock()) + Date = Mock() + Date.today.return_value = datetime.date(2026, 2, 12) + + with patch( + 'trytond.modules.purchase_trade.pricing.Pool' + ) as PoolMock: + PoolMock.return_value.get.return_value = Date + + self.assertEqual( + strategy.get_mtm(line, Decimal('2')), + Decimal('200.00')) + + price_index.get_price.assert_called_once_with( + datetime.date(2026, 2, 28), + line.unit, + strategy.currency, + relative_last=True) + @with_transaction() def test_get_mtm_uses_fixed_pricing_component(self): 'get_mtm values fixed components like a constant price curve' diff --git a/modules/purchase_trade/valuation.py b/modules/purchase_trade/valuation.py index db43d13..de91d16 100644 --- a/modules/purchase_trade/valuation.py +++ b/modules/purchase_trade/valuation.py @@ -513,24 +513,25 @@ class ValuationBase(ModelSQL): scenario = getattr(strategy, 'scenario', None) if not scenario: return None + valuation_date = cls._strategy_valuation_date(strategy) for comp in strategy.components or []: value = Decimal(0) if comp.price_source_type == 'curve' and comp.price_index: value = Decimal(comp.price_index.get_price( - scenario.valuation_date, + valuation_date, line.unit, strategy.currency, relative_last=scenario.use_last_price )) elif comp.price_source_type == 'matrix' and comp.price_matrix: value = Decimal(strategy._get_matrix_price( - comp, line, scenario.valuation_date)) + comp, line, valuation_date)) elif comp.price_source_type == 'fixed': value = Decimal(comp.get_price( - scenario.valuation_date, + valuation_date, line.unit, strategy.currency, relative_last=scenario.use_last_price @@ -543,6 +544,15 @@ class ValuationBase(ModelSQL): return round(total, 4) + @staticmethod + def _strategy_valuation_date(strategy): + scenario = strategy.scenario + get_valuation_date = getattr( + type(scenario), 'get_valuation_date', None) + if callable(get_valuation_date): + return scenario.get_valuation_date() + return scenario.valuation_date + @staticmethod def _supports_strategy_mtm(values): return values and values.get('type') in {'pur. priced', 'sale priced'} @@ -579,14 +589,15 @@ class ValuationBase(ModelSQL): @classmethod def _curve_component_price(cls, component, line, strategy): scenario = strategy.scenario + valuation_date = cls._strategy_valuation_date(strategy) value = Decimal(component.price_index.get_price( - scenario.valuation_date, + valuation_date, line.unit, strategy.currency, relative_last=scenario.use_last_price)) previous = cls._previous_curve_price( component.price_index, - scenario.valuation_date, + valuation_date, line.unit, strategy.currency) return round(value, 4), ( diff --git a/modules/purchase_trade/view/mtm_scenario_form.xml b/modules/purchase_trade/view/mtm_scenario_form.xml index 6dadc61..774a4a5 100644 --- a/modules/purchase_trade/view/mtm_scenario_form.xml +++ b/modules/purchase_trade/view/mtm_scenario_form.xml @@ -1,10 +1,14 @@