Long & Short

This commit is contained in:
2026-07-28 20:32:58 +02:00
parent 3994154cad
commit aaa1490389
8 changed files with 420 additions and 16 deletions

View File

@@ -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,

View File

@@ -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")

View File

@@ -55,9 +55,29 @@
<field name="type">tree</field>
<field name="name">ctrm_open_position_list</field>
</record>
<record model="ir.ui.view" id="ctrm_long_short_product_strategy_view_list">
<field name="model">ctrm.reporting.position.long_short.product_strategy</field>
<field name="type">tree</field>
<field name="name">ctrm_long_short_product_strategy_list</field>
</record>
<record model="ir.ui.view" id="ctrm_long_short_commodity_direction_view_list">
<field name="model">ctrm.reporting.position.long_short.commodity_direction</field>
<field name="type">tree</field>
<field name="name">ctrm_long_short_commodity_direction_list</field>
</record>
<record model="ir.ui.view" id="ctrm_long_short_period_product_view_list">
<field name="model">ctrm.reporting.position.long_short.period_product</field>
<field name="type">tree</field>
<field name="name">ctrm_long_short_period_product_list</field>
</record>
<record model="ir.ui.view" id="ctrm_long_short_pricing_status_view_list">
<field name="model">ctrm.reporting.position.long_short.pricing_status</field>
<field name="type">tree</field>
<field name="name">ctrm_long_short_pricing_status_list</field>
</record>
<record model="ir.action.act_window" id="act_ctrm_open_position">
<field name="name">Open Position</field>
<field name="name">Long &amp; Short</field>
<field name="res_model">ctrm.reporting.risk.open_position</field>
<field name="context_model">ctrm.reporting.risk.open_position.context</field>
</record>
@@ -66,6 +86,46 @@
<field name="view" ref="ctrm_open_position_view_list"/>
<field name="act_window" ref="act_ctrm_open_position"/>
</record>
<record model="ir.action.act_window" id="act_ctrm_long_short_product_strategy">
<field name="name">Long &amp; Short by Product &amp; Strategy</field>
<field name="res_model">ctrm.reporting.position.long_short.product_strategy</field>
<field name="context_model">ctrm.reporting.risk.open_position.context</field>
</record>
<record model="ir.action.act_window.view" id="act_ctrm_long_short_product_strategy_view">
<field name="sequence" eval="10"/>
<field name="view" ref="ctrm_long_short_product_strategy_view_list"/>
<field name="act_window" ref="act_ctrm_long_short_product_strategy"/>
</record>
<record model="ir.action.act_window" id="act_ctrm_long_short_commodity_direction">
<field name="name">Long &amp; Short by Commodity &amp; Direction</field>
<field name="res_model">ctrm.reporting.position.long_short.commodity_direction</field>
<field name="context_model">ctrm.reporting.risk.open_position.context</field>
</record>
<record model="ir.action.act_window.view" id="act_ctrm_long_short_commodity_direction_view">
<field name="sequence" eval="10"/>
<field name="view" ref="ctrm_long_short_commodity_direction_view_list"/>
<field name="act_window" ref="act_ctrm_long_short_commodity_direction"/>
</record>
<record model="ir.action.act_window" id="act_ctrm_long_short_period_product">
<field name="name">Long &amp; Short by Period &amp; Product</field>
<field name="res_model">ctrm.reporting.position.long_short.period_product</field>
<field name="context_model">ctrm.reporting.risk.open_position.context</field>
</record>
<record model="ir.action.act_window.view" id="act_ctrm_long_short_period_product_view">
<field name="sequence" eval="10"/>
<field name="view" ref="ctrm_long_short_period_product_view_list"/>
<field name="act_window" ref="act_ctrm_long_short_period_product"/>
</record>
<record model="ir.action.act_window" id="act_ctrm_long_short_pricing_status">
<field name="name">Long &amp; Short by Pricing Status</field>
<field name="res_model">ctrm.reporting.position.long_short.pricing_status</field>
<field name="context_model">ctrm.reporting.risk.open_position.context</field>
</record>
<record model="ir.action.act_window.view" id="act_ctrm_long_short_pricing_status_view">
<field name="sequence" eval="10"/>
<field name="view" ref="ctrm_long_short_pricing_status_view_list"/>
<field name="act_window" ref="act_ctrm_long_short_pricing_status"/>
</record>
<menuitem
name="Global Reporting"
@@ -84,6 +144,42 @@
sequence="10"
id="menu_gr_configuration"
icon="tryton-list"/>
<menuitem
name="Positions"
parent="menu_global_reporting"
sequence="10"
id="menu_ctrm_positions"
icon="tradon-reporting-positions"/>
<menuitem
name="Long &amp; Short"
parent="menu_ctrm_positions"
action="act_ctrm_open_position"
sequence="10"
id="menu_ctrm_positions_long_short"/>
<menuitem
name="By Product &amp; Strategy"
parent="menu_ctrm_positions"
action="act_ctrm_long_short_product_strategy"
sequence="20"
id="menu_ctrm_positions_long_short_product_strategy"/>
<menuitem
name="By Commodity &amp; Direction"
parent="menu_ctrm_positions"
action="act_ctrm_long_short_commodity_direction"
sequence="30"
id="menu_ctrm_positions_long_short_commodity_direction"/>
<menuitem
name="By Period &amp; Product"
parent="menu_ctrm_positions"
action="act_ctrm_long_short_period_product"
sequence="40"
id="menu_ctrm_positions_long_short_period_product"/>
<menuitem
name="By Pricing Status"
parent="menu_ctrm_positions"
action="act_ctrm_long_short_pricing_status"
sequence="50"
id="menu_ctrm_positions_long_short_pricing_status"/>
<menuitem
name="Other"
parent="menu_global_reporting"

View File

@@ -0,0 +1,16 @@
<tree>
<field name="commodity" width="180"/>
<field name="direction"
widget="badge"
badge_colors="long:#16a34a,short:#ef4444,flat:#64748b"/>
<field name="bought" symbol="unit" sum="1"/>
<field name="sold" symbol="unit" sum="1"/>
<field name="net" symbol="unit" sum="1"/>
<field name="pricing"
widget="badge"
badge_colors="fixed:#16a34a,unfixed:#f59e0b,partly:#fbbf24"/>
<field name="unit" optional="1"/>
<field name="product" optional="1"/>
<field name="unfixed_qty" symbol="unit" optional="1" sum="1"/>
<field name="open_contracts" optional="1" sum="1"/>
</tree>

View File

@@ -0,0 +1,17 @@
<tree>
<field name="shipment_period" width="110"/>
<field name="product" width="180"/>
<field name="commodity" width="130"/>
<field name="bought" symbol="unit" sum="1"/>
<field name="sold" symbol="unit" sum="1"/>
<field name="net" symbol="unit" sum="1"/>
<field name="direction"
widget="badge"
badge_colors="long:#16a34a,short:#ef4444,flat:#64748b"/>
<field name="pricing"
widget="badge"
badge_colors="fixed:#16a34a,unfixed:#f59e0b,partly:#fbbf24"/>
<field name="unit" optional="1"/>
<field name="unfixed_qty" symbol="unit" optional="1" sum="1"/>
<field name="open_contracts" optional="1" sum="1"/>
</tree>

View File

@@ -0,0 +1,16 @@
<tree>
<field name="pricing"
widget="badge"
badge_colors="fixed:#16a34a,unfixed:#f59e0b,partly:#fbbf24"/>
<field name="product" width="180"/>
<field name="commodity" width="130"/>
<field name="bought" symbol="unit" sum="1"/>
<field name="sold" symbol="unit" sum="1"/>
<field name="net" symbol="unit" sum="1"/>
<field name="direction"
widget="badge"
badge_colors="long:#16a34a,short:#ef4444,flat:#64748b"/>
<field name="unit" optional="1"/>
<field name="unfixed_qty" symbol="unit" optional="1" sum="1"/>
<field name="open_contracts" optional="1" sum="1"/>
</tree>

View File

@@ -0,0 +1,17 @@
<tree>
<field name="product" width="180"/>
<field name="commodity" width="130"/>
<field name="strategy" width="140"/>
<field name="bought" symbol="unit" sum="1"/>
<field name="sold" symbol="unit" sum="1"/>
<field name="net" symbol="unit" sum="1"/>
<field name="direction"
widget="badge"
badge_colors="long:#16a34a,short:#ef4444,flat:#64748b"/>
<field name="pricing"
widget="badge"
badge_colors="fixed:#16a34a,unfixed:#f59e0b,partly:#fbbf24"/>
<field name="unit" optional="1"/>
<field name="unfixed_qty" symbol="unit" optional="1" sum="1"/>
<field name="open_contracts" optional="1" sum="1"/>
</tree>

View File

@@ -9,6 +9,22 @@
<field name="pricing_status"/>
<label name="shipment_period"/>
<field name="shipment_period"/>
<label name="purchase_period_mode"/>
<field name="purchase_period_mode"/>
<label name="purchase_period"/>
<field name="purchase_period"/>
<label name="purchase_period_from"/>
<field name="purchase_period_from"/>
<label name="purchase_period_to"/>
<field name="purchase_period_to"/>
<label name="sale_period_mode"/>
<field name="sale_period_mode"/>
<label name="sale_period"/>
<field name="sale_period"/>
<label name="sale_period_from"/>
<field name="sale_period_from"/>
<label name="sale_period_to"/>
<field name="sale_period_to"/>
<label name="strategy"/>
<field name="strategy"/>
<label name="counterparty"/>