From aaa1490389c72b4fe6020bdb4d6041298f231985 Mon Sep 17 00:00:00 2001 From: laurentbarontini Date: Tue, 28 Jul 2026 20:32:58 +0200 Subject: [PATCH] Long & Short --- modules/purchase_trade/__init__.py | 4 + modules/purchase_trade/ctrm_reporting.py | 252 ++++++++++++++++-- modules/purchase_trade/global_reporting.xml | 98 ++++++- ...rm_long_short_commodity_direction_list.xml | 16 ++ .../ctrm_long_short_period_product_list.xml | 17 ++ .../ctrm_long_short_pricing_status_list.xml | 16 ++ .../ctrm_long_short_product_strategy_list.xml | 17 ++ .../view/ctrm_open_position_context_form.xml | 16 ++ 8 files changed, 420 insertions(+), 16 deletions(-) create mode 100644 modules/purchase_trade/view/ctrm_long_short_commodity_direction_list.xml create mode 100644 modules/purchase_trade/view/ctrm_long_short_period_product_list.xml create mode 100644 modules/purchase_trade/view/ctrm_long_short_pricing_status_list.xml create mode 100644 modules/purchase_trade/view/ctrm_long_short_product_strategy_list.xml diff --git a/modules/purchase_trade/__init__.py b/modules/purchase_trade/__init__.py index 3b12ae4..2a2241f 100755 --- a/modules/purchase_trade/__init__.py +++ b/modules/purchase_trade/__init__.py @@ -68,6 +68,10 @@ def register(): ctrm_reporting.CTRMNetPositionContext, ctrm_reporting.CTRMOpenPosition, ctrm_reporting.CTRMOpenPositionContext, + ctrm_reporting.CTRMLongShortProductStrategy, + ctrm_reporting.CTRMLongShortCommodityDirection, + ctrm_reporting.CTRMLongShortPeriodProduct, + ctrm_reporting.CTRMLongShortPricingStatus, ctrm_reporting.CTRMRealizedPnl, ctrm_reporting.CTRMRealizedPnlContext, ctrm_reporting.CTRMMtmPnl, diff --git a/modules/purchase_trade/ctrm_reporting.py b/modules/purchase_trade/ctrm_reporting.py index 26d37cc..64dda5b 100644 --- a/modules/purchase_trade/ctrm_reporting.py +++ b/modules/purchase_trade/ctrm_reporting.py @@ -1,3 +1,5 @@ +import datetime + from sql import Literal, Null, Union, Window from sql.aggregate import Count, Max, Min, Sum from sql.conditionals import Case, Coalesce @@ -332,6 +334,34 @@ class CTRMOpenPositionContext(ModelView): ('partly', 'Partly fix'), ], "Pricing Status") shipment_period = fields.Many2One('product.month', "Shipment Period") + purchase_period_mode = fields.Selection([ + (None, ''), + ('all', 'All'), + ('exact', 'Exact'), + ('current_3_10', 'Current -3/+10'), + ('around_3m', 'Around 3M'), + ('around_6m', 'Around 6M'), + ('range', 'Range'), + ], "Purchase Period") + purchase_period = fields.Many2One('product.month', "Purchase Period") + purchase_period_from = fields.Many2One( + 'product.month', "Purchase Period From") + purchase_period_to = fields.Many2One( + 'product.month', "Purchase Period To") + sale_period_mode = fields.Selection([ + (None, ''), + ('all', 'All'), + ('exact', 'Exact'), + ('current_3_10', 'Current -3/+10'), + ('around_3m', 'Around 3M'), + ('around_6m', 'Around 6M'), + ('range', 'Range'), + ], "Sale Period") + sale_period = fields.Many2One('product.month', "Sale Period") + sale_period_from = fields.Many2One( + 'product.month', "Sale Period From") + sale_period_to = fields.Many2One( + 'product.month', "Sale Period To") strategy = fields.Many2One('mtm.strategy', "Strategy") counterparty = fields.Many2One('party.party', "Counterparty") direction = fields.Selection([ @@ -345,11 +375,16 @@ class CTRMOpenPositionContext(ModelView): def default_valuation_date(cls): return Pool().get('ir.date').today() + @classmethod + def default_purchase_period_mode(cls): + return 'all' -class CTRMOpenPosition(ModelSQL, ModelView): - "CTRM Open Position" - __name__ = 'ctrm.reporting.risk.open_position' + @classmethod + def default_sale_period_mode(cls): + return 'all' + +class CTRMLongShortMixin: product = fields.Many2One( 'product.product', "Product", readonly=True) commodity = fields.Many2One( @@ -361,6 +396,10 @@ class CTRMOpenPosition(ModelSQL, ModelView): ], "Pricing", readonly=True) shipment_period = fields.Many2One( 'product.month', "Shipment Pd", readonly=True) + purchase_period = fields.Many2One( + 'product.month', "Purchase Pd", readonly=True) + sale_period = fields.Many2One( + 'product.month', "Sale Pd", readonly=True) strategy = fields.Many2One( 'mtm.strategy', "Strategy", readonly=True) bought = fields.Numeric("Bought", digits=(16, 3), readonly=True) @@ -386,7 +425,100 @@ class CTRMOpenPosition(ModelSQL, ModelView): group_by=[getattr(lot, line_field)]) @classmethod - def table_query(cls): + def _as_date(cls, value): + if isinstance(value, datetime.date): + return value + if isinstance(value, str): + return datetime.date.fromisoformat(value) + return None + + @classmethod + def _add_months(cls, date, months): + month = date.month - 1 + months + year = date.year + month // 12 + month = month % 12 + 1 + day = min(date.day, cls._last_day(year, month)) + return datetime.date(year, month, day) + + @staticmethod + def _last_day(year, month): + if month == 12: + next_month = datetime.date(year + 1, 1, 1) + else: + next_month = datetime.date(year, month + 1, 1) + return (next_month - datetime.timedelta(days=1)).day + + @classmethod + def _period_record(cls, period_id): + if not period_id: + return None + if hasattr(period_id, 'beg_date'): + return period_id + return Pool().get('product.month')(period_id) + + @classmethod + def _period_ids_for_context(cls, prefix): + context = Transaction().context + mode = context.get('%s_period_mode' % prefix) or 'all' + if mode == 'all': + return None + + exact = context.get('%s_period' % prefix) + if mode == 'exact': + return [exact] if exact else None + + Period = Pool().get('product.month') + start = end = None + if mode in {'around_3m', 'around_6m'}: + months = 3 if mode == 'around_3m' else 6 + anchor_period = cls._period_record(exact) + anchor_date = ( + anchor_period.beg_date if anchor_period else + cls._as_date(context.get('valuation_date'))) + if not anchor_date: + anchor_date = Pool().get('ir.date').today() + month_start = anchor_date.replace(day=1) + start = cls._add_months(month_start, -months) + end_start = cls._add_months(month_start, months) + end = end_start.replace( + day=cls._last_day(end_start.year, end_start.month)) + elif mode == 'current_3_10': + anchor_date = cls._as_date(context.get('valuation_date')) + if not anchor_date: + anchor_date = Pool().get('ir.date').today() + month_start = anchor_date.replace(day=1) + start = cls._add_months(month_start, -3) + end_start = cls._add_months(month_start, 10) + end = end_start.replace( + day=cls._last_day(end_start.year, end_start.month)) + elif mode == 'range': + period_from = cls._period_record( + context.get('%s_period_from' % prefix)) + period_to = cls._period_record( + context.get('%s_period_to' % prefix)) + start = period_from.beg_date if period_from else None + end = period_to.end_date if period_to else None + + domain = [] + if start: + domain.append(('end_date', '>=', start)) + if end: + domain.append(('beg_date', '<=', end)) + if not domain: + return None + return [p.id for p in Period.search(domain)] + + @classmethod + def _apply_period_filter(cls, where, field, prefix): + period_ids = cls._period_ids_for_context(prefix) + if period_ids is None: + return where + if period_ids: + return where & field.in_(period_ids) + return where & (field == -1) + + @classmethod + def _open_position_lines(cls): pool = Pool() PurchaseLine = pool.get('purchase.line') Purchase = pool.get('purchase.purchase') @@ -434,6 +566,8 @@ class CTRMOpenPosition(ModelSQL, ModelView): if context.get('shipment_period'): purchase_where &= ( purchase_line.del_period == context['shipment_period']) + purchase_where = cls._apply_period_filter( + purchase_where, purchase_line.del_period, 'purchase') if context.get('strategy'): purchase_where &= ( purchase_strategy.strategy == context['strategy']) @@ -457,6 +591,8 @@ class CTRMOpenPosition(ModelSQL, ModelView): purchase_line.product.as_('product'), Max(template_category.category).as_('commodity'), purchase_line.del_period.as_('shipment_period'), + purchase_line.del_period.as_('purchase_period'), + Literal(None).as_('sale_period'), Max(purchase_strategy.strategy).as_('strategy'), purchase.party.as_('counterparty'), purchase_line.unit.as_('unit'), @@ -466,6 +602,8 @@ class CTRMOpenPosition(ModelSQL, ModelView): 'fixed_qty'), Case((purchase_fixed, 0), else_=purchase_open_qty).as_( 'unfixed_qty'), + Case((purchase_fixed, 'fixed'), else_='unfixed').as_( + 'line_pricing'), where=purchase_where, group_by=[ purchase_line.id, @@ -500,6 +638,8 @@ class CTRMOpenPosition(ModelSQL, ModelView): sale_where &= template_category.category == context['commodity'] if context.get('shipment_period'): sale_where &= sale_line.del_period == context['shipment_period'] + sale_where = cls._apply_period_filter( + sale_where, sale_line.del_period, 'sale') if context.get('strategy'): sale_where &= sale_strategy.strategy == context['strategy'] if context.get('counterparty'): @@ -522,6 +662,8 @@ class CTRMOpenPosition(ModelSQL, ModelView): sale_line.product.as_('product'), Max(template_category.category).as_('commodity'), sale_line.del_period.as_('shipment_period'), + Literal(None).as_('purchase_period'), + sale_line.del_period.as_('sale_period'), Max(sale_strategy.strategy).as_('strategy'), sale.party.as_('counterparty'), sale_line.unit.as_('unit'), @@ -531,6 +673,8 @@ class CTRMOpenPosition(ModelSQL, ModelView): 'fixed_qty'), Case((sale_fixed, 0), else_=sale_open_qty).as_( 'unfixed_qty'), + Case((sale_fixed, 'fixed'), else_='unfixed').as_( + 'line_pricing'), where=sale_where, group_by=[ sale_line.id, @@ -545,16 +689,25 @@ class CTRMOpenPosition(ModelSQL, ModelView): ], having=sale_open_qty > 0)) - lines = Union(purchase_query, sale_query, all_=True) + return Union(purchase_query, sale_query, all_=True) + + @classmethod + def _long_short_query(cls, group_names, order_names=None, + line_pricing=False): + context = Transaction().context + lines = cls._open_position_lines() bought = Sum(Coalesce(lines.bought, 0)) sold = Sum(Coalesce(lines.sold, 0)) net = bought - sold fixed_qty = Sum(Coalesce(lines.fixed_qty, 0)) unfixed_qty = Sum(Coalesce(lines.unfixed_qty, 0)) - pricing = Case( - ((fixed_qty > 0) & (unfixed_qty > 0), 'partly'), - (unfixed_qty > 0, 'unfixed'), - else_='fixed') + if line_pricing: + pricing = lines.line_pricing + else: + pricing = Case( + ((fixed_qty > 0) & (unfixed_qty > 0), 'partly'), + (unfixed_qty > 0, 'unfixed'), + else_='fixed') direction = Case( (net > 0, 'long'), (net < 0, 'short'), @@ -570,8 +723,18 @@ class CTRMOpenPosition(ModelSQL, ModelView): elif context.get('direction') == 'flat': having &= net == 0 + group_by = [getattr(lines, name) for name in group_names] + if line_pricing and 'line_pricing' not in group_names: + group_by.append(lines.line_pricing) + if order_names is None: + order_names = group_names + order_by = [getattr(lines, name) for name in order_names] report_id = RowNumber(window=Window([], - order_by=[lines.product, lines.unit])) + order_by=order_by)) + + def grouped_or_max(name): + column = getattr(lines, name) + return column if name in group_names else Max(column) return lines.select( Literal(0).as_('create_uid'), @@ -579,11 +742,13 @@ class CTRMOpenPosition(ModelSQL, ModelView): Literal(None).as_('write_uid'), Literal(None).as_('write_date'), report_id.as_('id'), - lines.product.as_('product'), - Max(lines.commodity).as_('commodity'), + grouped_or_max('product').as_('product'), + grouped_or_max('commodity').as_('commodity'), pricing.as_('pricing'), - Max(lines.shipment_period).as_('shipment_period'), - Max(lines.strategy).as_('strategy'), + grouped_or_max('shipment_period').as_('shipment_period'), + grouped_or_max('purchase_period').as_('purchase_period'), + grouped_or_max('sale_period').as_('sale_period'), + grouped_or_max('strategy').as_('strategy'), bought.as_('bought'), sold.as_('sold'), net.as_('net'), @@ -591,10 +756,67 @@ class CTRMOpenPosition(ModelSQL, ModelView): lines.unit.as_('unit'), Count(lines.line_id).as_('open_contracts'), unfixed_qty.as_('unfixed_qty'), - group_by=[lines.product, lines.unit], + group_by=group_by, having=having) +class CTRMOpenPosition(CTRMLongShortMixin, ModelSQL, ModelView): + "CTRM Open Position" + __name__ = 'ctrm.reporting.risk.open_position' + + @classmethod + def table_query(cls): + return cls._long_short_query( + ['product', 'unit'], order_names=['product', 'unit']) + + +class CTRMLongShortProductStrategy( + CTRMLongShortMixin, ModelSQL, ModelView): + "CTRM Long & Short by Product and Strategy" + __name__ = 'ctrm.reporting.position.long_short.product_strategy' + + @classmethod + def table_query(cls): + return cls._long_short_query( + ['product', 'strategy', 'unit'], + order_names=['product', 'strategy', 'unit']) + + +class CTRMLongShortCommodityDirection( + CTRMLongShortMixin, ModelSQL, ModelView): + "CTRM Long & Short by Commodity and Direction" + __name__ = 'ctrm.reporting.position.long_short.commodity_direction' + + @classmethod + def table_query(cls): + return cls._long_short_query( + ['commodity', 'unit'], order_names=['commodity', 'unit']) + + +class CTRMLongShortPeriodProduct( + CTRMLongShortMixin, ModelSQL, ModelView): + "CTRM Long & Short by Period and Product" + __name__ = 'ctrm.reporting.position.long_short.period_product' + + @classmethod + def table_query(cls): + return cls._long_short_query( + ['shipment_period', 'product', 'unit'], + order_names=['shipment_period', 'product', 'unit']) + + +class CTRMLongShortPricingStatus( + CTRMLongShortMixin, ModelSQL, ModelView): + "CTRM Long & Short by Pricing Status" + __name__ = 'ctrm.reporting.position.long_short.pricing_status' + + @classmethod + def table_query(cls): + return cls._long_short_query( + ['product', 'unit'], order_names=['product', 'unit'], + line_pricing=True) + + class CTRMPnlContextMixin: date = fields.Date("Valuation Date") product = fields.Many2One('product.product', "Product") diff --git a/modules/purchase_trade/global_reporting.xml b/modules/purchase_trade/global_reporting.xml index 632b90b..40a8ed6 100644 --- a/modules/purchase_trade/global_reporting.xml +++ b/modules/purchase_trade/global_reporting.xml @@ -55,9 +55,29 @@ tree ctrm_open_position_list + + ctrm.reporting.position.long_short.product_strategy + tree + ctrm_long_short_product_strategy_list + + + ctrm.reporting.position.long_short.commodity_direction + tree + ctrm_long_short_commodity_direction_list + + + ctrm.reporting.position.long_short.period_product + tree + ctrm_long_short_period_product_list + + + ctrm.reporting.position.long_short.pricing_status + tree + ctrm_long_short_pricing_status_list + - Open Position + Long & Short ctrm.reporting.risk.open_position ctrm.reporting.risk.open_position.context @@ -66,6 +86,46 @@ + + Long & Short by Product & Strategy + ctrm.reporting.position.long_short.product_strategy + ctrm.reporting.risk.open_position.context + + + + + + + + Long & Short by Commodity & Direction + ctrm.reporting.position.long_short.commodity_direction + ctrm.reporting.risk.open_position.context + + + + + + + + Long & Short by Period & Product + ctrm.reporting.position.long_short.period_product + ctrm.reporting.risk.open_position.context + + + + + + + + Long & Short by Pricing Status + ctrm.reporting.position.long_short.pricing_status + ctrm.reporting.risk.open_position.context + + + + + + + + + + + + + + + + + + + + + + + diff --git a/modules/purchase_trade/view/ctrm_long_short_period_product_list.xml b/modules/purchase_trade/view/ctrm_long_short_period_product_list.xml new file mode 100644 index 0000000..74f9664 --- /dev/null +++ b/modules/purchase_trade/view/ctrm_long_short_period_product_list.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + diff --git a/modules/purchase_trade/view/ctrm_long_short_pricing_status_list.xml b/modules/purchase_trade/view/ctrm_long_short_pricing_status_list.xml new file mode 100644 index 0000000..990e213 --- /dev/null +++ b/modules/purchase_trade/view/ctrm_long_short_pricing_status_list.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + diff --git a/modules/purchase_trade/view/ctrm_long_short_product_strategy_list.xml b/modules/purchase_trade/view/ctrm_long_short_product_strategy_list.xml new file mode 100644 index 0000000..2cb2a9a --- /dev/null +++ b/modules/purchase_trade/view/ctrm_long_short_product_strategy_list.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + diff --git a/modules/purchase_trade/view/ctrm_open_position_context_form.xml b/modules/purchase_trade/view/ctrm_open_position_context_form.xml index 2606d86..ba3b777 100644 --- a/modules/purchase_trade/view/ctrm_open_position_context_form.xml +++ b/modules/purchase_trade/view/ctrm_open_position_context_form.xml @@ -9,6 +9,22 @@