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
+
+
+
+
+
+
+
+
+
+
+
+