From 3994154cadca159da7106096900a58858b613db8 Mon Sep 17 00:00:00 2001 From: "AzureAD\\SylvainDUVERNAY" Date: Tue, 28 Jul 2026 16:56:45 +0200 Subject: [PATCH] Risk Management - Physical Open Position --- modules/purchase_trade/__init__.py | 16 +- modules/purchase_trade/ctrm_reporting.py | 277 +++++++++++++++ .../reporting/open_position_report_spec.md | 330 ++++++++++++++++++ modules/purchase_trade/global_reporting.xml | 98 +++++- .../view/ctrm_open_position_context_form.xml | 18 + .../view/ctrm_open_position_list.xml | 18 + 6 files changed, 740 insertions(+), 17 deletions(-) create mode 100644 modules/purchase_trade/docs/reporting/open_position_report_spec.md create mode 100644 modules/purchase_trade/view/ctrm_open_position_context_form.xml create mode 100644 modules/purchase_trade/view/ctrm_open_position_list.xml diff --git a/modules/purchase_trade/__init__.py b/modules/purchase_trade/__init__.py index 82e1c04..3b12ae4 100755 --- a/modules/purchase_trade/__init__.py +++ b/modules/purchase_trade/__init__.py @@ -61,13 +61,15 @@ def register(): lc.CreateLCStart, global_reporting.GRConfiguration, ctrm_reporting.CTRMPhysicalPosition, - ctrm_reporting.CTRMPhysicalPositionContext, - ctrm_reporting.CTRMFinancialPosition, - ctrm_reporting.CTRMFinancialPositionContext, - ctrm_reporting.CTRMNetPosition, - ctrm_reporting.CTRMNetPositionContext, - ctrm_reporting.CTRMRealizedPnl, - ctrm_reporting.CTRMRealizedPnlContext, + ctrm_reporting.CTRMPhysicalPositionContext, + ctrm_reporting.CTRMFinancialPosition, + ctrm_reporting.CTRMFinancialPositionContext, + ctrm_reporting.CTRMNetPosition, + ctrm_reporting.CTRMNetPositionContext, + ctrm_reporting.CTRMOpenPosition, + ctrm_reporting.CTRMOpenPositionContext, + ctrm_reporting.CTRMRealizedPnl, + ctrm_reporting.CTRMRealizedPnlContext, ctrm_reporting.CTRMMtmPnl, ctrm_reporting.CTRMMtmPnlContext, ctrm_reporting.CTRMPnlExplain, diff --git a/modules/purchase_trade/ctrm_reporting.py b/modules/purchase_trade/ctrm_reporting.py index 1865bec..26d37cc 100644 --- a/modules/purchase_trade/ctrm_reporting.py +++ b/modules/purchase_trade/ctrm_reporting.py @@ -318,6 +318,283 @@ class CTRMNetPosition(ModelSQL, ModelView): group_by=group_by) +class CTRMOpenPositionContext(ModelView): + "CTRM Open Position Context" + __name__ = 'ctrm.reporting.risk.open_position.context' + + valuation_date = fields.Date("Valuation Date") + commodity = fields.Many2One('product.category', "Commodity") + product = fields.Many2One('product.product', "Product") + pricing_status = fields.Selection([ + (None, ''), + ('fixed', 'Fixed'), + ('unfixed', 'Unfixed'), + ('partly', 'Partly fix'), + ], "Pricing Status") + shipment_period = fields.Many2One('product.month', "Shipment Period") + strategy = fields.Many2One('mtm.strategy', "Strategy") + counterparty = fields.Many2One('party.party', "Counterparty") + direction = fields.Selection([ + (None, ''), + ('long', 'LONG'), + ('short', 'SHORT'), + ('flat', 'Flat'), + ], "Direction") + + @classmethod + def default_valuation_date(cls): + return Pool().get('ir.date').today() + + +class CTRMOpenPosition(ModelSQL, ModelView): + "CTRM Open Position" + __name__ = 'ctrm.reporting.risk.open_position' + + product = fields.Many2One( + 'product.product', "Product", readonly=True) + commodity = fields.Many2One( + 'product.category', "Commodity", readonly=True) + pricing = fields.Selection([ + ('fixed', 'Fixed'), + ('unfixed', 'Unfixed'), + ('partly', 'Partly fix'), + ], "Pricing", readonly=True) + shipment_period = fields.Many2One( + 'product.month', "Shipment Pd", readonly=True) + strategy = fields.Many2One( + 'mtm.strategy', "Strategy", readonly=True) + bought = fields.Numeric("Bought", digits=(16, 3), readonly=True) + sold = fields.Numeric("Sold", digits=(16, 3), readonly=True) + net = fields.Numeric("Net", digits=(16, 3), readonly=True) + direction = fields.Selection([ + ('long', 'LONG'), + ('short', 'SHORT'), + ('flat', 'Flat'), + ], "Direction", readonly=True) + unit = fields.Many2One('product.uom', "Unit", readonly=True) + open_contracts = fields.Integer("Open Contracts", readonly=True) + unfixed_qty = fields.Numeric( + "Unfixed Qty", digits=(16, 3), readonly=True) + + @classmethod + def _physical_quantity_query(cls, lot, line_field): + return lot.select( + getattr(lot, line_field).as_('line'), + Sum(Coalesce(lot.lot_quantity, 0)).as_('quantity'), + where=((lot.lot_type == 'physic') + & (getattr(lot, line_field) != Null)), + group_by=[getattr(lot, line_field)]) + + @classmethod + def table_query(cls): + pool = Pool() + PurchaseLine = pool.get('purchase.line') + Purchase = pool.get('purchase.purchase') + SaleLine = pool.get('sale.line') + Sale = pool.get('sale.sale') + Lot = pool.get('lot.lot') + Product = pool.get('product.product') + Template = pool.get('product.template') + TemplateCategory = pool.get('product.template-product.category') + PurchaseStrategy = pool.get('purchase.strategy') + SaleStrategy = pool.get('sale.strategy') + + purchase_line = PurchaseLine.__table__() + purchase = Purchase.__table__() + sale_line = SaleLine.__table__() + sale = Sale.__table__() + lot = Lot.__table__() + product = Product.__table__() + template = Template.__table__() + template_category = TemplateCategory.__table__() + purchase_strategy = PurchaseStrategy.__table__() + sale_strategy = SaleStrategy.__table__() + + context = Transaction().context + + purchase_physical = cls._physical_quantity_query(lot, 'line') + purchase_contract_qty = Coalesce( + purchase_line.quantity_theorical, purchase_line.quantity, 0) + purchase_physical_qty = Coalesce(Max(purchase_physical.quantity), 0) + purchase_residual = purchase_contract_qty - purchase_physical_qty + purchase_open_qty = Case( + (purchase_line.finished == True, 0), + (purchase_residual > 0, purchase_residual), + else_=0) + purchase_fixed = purchase_line.price_type.in_(['cash', 'priced']) + purchase_where = ( + (purchase_line.product != Null) + & purchase.state.in_(['confirmed', 'processing']) + & (template.type != 'service')) + if context.get('product'): + purchase_where &= purchase_line.product == context['product'] + if context.get('commodity'): + purchase_where &= ( + template_category.category == context['commodity']) + if context.get('shipment_period'): + purchase_where &= ( + purchase_line.del_period == context['shipment_period']) + if context.get('strategy'): + purchase_where &= ( + purchase_strategy.strategy == context['strategy']) + if context.get('counterparty'): + purchase_where &= purchase.party == context['counterparty'] + + purchase_query = ( + purchase_line + .join(purchase, condition=purchase_line.purchase == purchase.id) + .join(purchase_physical, 'LEFT', + condition=purchase_physical.line == purchase_line.id) + .join(product, condition=purchase_line.product == product.id) + .join(template, condition=product.template == template.id) + .join(template_category, 'LEFT', + condition=template_category.template == template.id) + .join(purchase_strategy, 'LEFT', + condition=purchase_strategy.line == purchase_line.id) + .select( + Literal('purchase').as_('side'), + purchase_line.id.as_('line_id'), + purchase_line.product.as_('product'), + Max(template_category.category).as_('commodity'), + purchase_line.del_period.as_('shipment_period'), + Max(purchase_strategy.strategy).as_('strategy'), + purchase.party.as_('counterparty'), + purchase_line.unit.as_('unit'), + purchase_open_qty.as_('bought'), + Literal(0).as_('sold'), + Case((purchase_fixed, purchase_open_qty), else_=0).as_( + 'fixed_qty'), + Case((purchase_fixed, 0), else_=purchase_open_qty).as_( + 'unfixed_qty'), + where=purchase_where, + group_by=[ + purchase_line.id, + purchase_line.product, + purchase_line.del_period, + purchase.party, + purchase_line.unit, + purchase_line.finished, + purchase_line.quantity_theorical, + purchase_line.quantity, + purchase_line.price_type, + ], + having=purchase_open_qty > 0)) + + sale_physical = cls._physical_quantity_query(lot, 'sale_line') + sale_contract_qty = Coalesce( + sale_line.quantity_theorical, sale_line.quantity, 0) + sale_physical_qty = Coalesce(Max(sale_physical.quantity), 0) + sale_residual = sale_contract_qty - sale_physical_qty + sale_open_qty = Case( + (sale_line.finished == True, 0), + (sale_residual > 0, sale_residual), + else_=0) + sale_fixed = sale_line.price_type.in_(['cash', 'priced']) + sale_where = ( + (sale_line.product != Null) + & sale.state.in_(['confirmed', 'processing']) + & (template.type != 'service')) + if context.get('product'): + sale_where &= sale_line.product == context['product'] + if context.get('commodity'): + sale_where &= template_category.category == context['commodity'] + if context.get('shipment_period'): + sale_where &= sale_line.del_period == context['shipment_period'] + if context.get('strategy'): + sale_where &= sale_strategy.strategy == context['strategy'] + if context.get('counterparty'): + sale_where &= sale.party == context['counterparty'] + + sale_query = ( + sale_line + .join(sale, condition=sale_line.sale == sale.id) + .join(sale_physical, 'LEFT', + condition=sale_physical.line == sale_line.id) + .join(product, condition=sale_line.product == product.id) + .join(template, condition=product.template == template.id) + .join(template_category, 'LEFT', + condition=template_category.template == template.id) + .join(sale_strategy, 'LEFT', + condition=sale_strategy.sale_line == sale_line.id) + .select( + Literal('sale').as_('side'), + sale_line.id.as_('line_id'), + sale_line.product.as_('product'), + Max(template_category.category).as_('commodity'), + sale_line.del_period.as_('shipment_period'), + Max(sale_strategy.strategy).as_('strategy'), + sale.party.as_('counterparty'), + sale_line.unit.as_('unit'), + Literal(0).as_('bought'), + sale_open_qty.as_('sold'), + Case((sale_fixed, sale_open_qty), else_=0).as_( + 'fixed_qty'), + Case((sale_fixed, 0), else_=sale_open_qty).as_( + 'unfixed_qty'), + where=sale_where, + group_by=[ + sale_line.id, + sale_line.product, + sale_line.del_period, + sale.party, + sale_line.unit, + sale_line.finished, + sale_line.quantity_theorical, + sale_line.quantity, + sale_line.price_type, + ], + having=sale_open_qty > 0)) + + lines = Union(purchase_query, sale_query, all_=True) + 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') + direction = Case( + (net > 0, 'long'), + (net < 0, 'short'), + else_='flat') + + having = (bought + sold) > 0 + if context.get('pricing_status'): + having &= pricing == context['pricing_status'] + if context.get('direction') == 'long': + having &= net > 0 + elif context.get('direction') == 'short': + having &= net < 0 + elif context.get('direction') == 'flat': + having &= net == 0 + + report_id = RowNumber(window=Window([], + order_by=[lines.product, lines.unit])) + + return lines.select( + Literal(0).as_('create_uid'), + CurrentTimestamp().as_('create_date'), + Literal(None).as_('write_uid'), + Literal(None).as_('write_date'), + report_id.as_('id'), + lines.product.as_('product'), + Max(lines.commodity).as_('commodity'), + pricing.as_('pricing'), + Max(lines.shipment_period).as_('shipment_period'), + Max(lines.strategy).as_('strategy'), + bought.as_('bought'), + sold.as_('sold'), + net.as_('net'), + direction.as_('direction'), + lines.unit.as_('unit'), + Count(lines.line_id).as_('open_contracts'), + unfixed_qty.as_('unfixed_qty'), + group_by=[lines.product, lines.unit], + having=having) + + class CTRMPnlContextMixin: date = fields.Date("Valuation Date") product = fields.Many2One('product.product', "Product") diff --git a/modules/purchase_trade/docs/reporting/open_position_report_spec.md b/modules/purchase_trade/docs/reporting/open_position_report_spec.md new file mode 100644 index 0000000..da94377 --- /dev/null +++ b/modules/purchase_trade/docs/reporting/open_position_report_spec.md @@ -0,0 +1,330 @@ +# Open Position — Physical Net Long/Short + +**Report ID:** RPT-02 +**Module:** `commodity_risk_reporting` (Global Reporting → Positions) +**ERP:** Trad'On (Tryton 6.x) +**Type:** Tabular list report (no charts) +**Status:** Specification for development + +--- + +## Table of Contents + +1. [Goal of the Report](#1-goal-of-the-report) +2. [Scope and Definitions](#2-scope-and-definitions) +3. [Source Data](#3-source-data) +4. [Column Specification](#4-column-specification) +5. [Aggregation & Grouping](#5-aggregation--grouping) +6. [Filter Criteria](#6-filter-criteria) +7. [KPI Header Cards](#7-kpi-header-cards) +8. [Tryton Implementation](#8-tryton-implementation) +9. [Edge Cases & Business Rules](#9-edge-cases--business-rules) +10. [Acceptance Criteria](#10-acceptance-criteria) + +--- + +## 1. Goal of the Report + +The **Open Position** report answers one question for the risk manager and the trader: + +> *For each product, have I bought more than I have sold, or the reverse — and by how much?* + +It computes, per product, the **net physical position** as the algebraic difference between open purchase quantities and open sale quantities: + +``` +Net Position = Σ Open Purchase Qty − Σ Open Sale Qty +``` + +- **Net > 0 → LONG** — more bought than sold. The desk is exposed to a **falling** market (holds/owes goods it must sell). +- **Net < 0 → SHORT** — more sold than bought. The desk is exposed to a **rising** market (owes goods it must source). +- **Net = 0 → FLAT** — purchases and sales balance; no directional physical exposure. + +This is the *physical* position only. It deliberately excludes derivatives — the hedge overlay is handled by RPT-03 (Net Position / Consolidated Risk), which consumes this report's output as its physical leg. Keeping the physical position isolated here lets the trader see the raw physical book before any hedge is applied. + +**Primary users:** physical traders (per book), risk manager (portfolio view). +**Valuation basis:** quantity only — this report is not a P&L report. Values (price, MTM) are out of scope; see RPT-06 (Unrealized P&L). + +--- + +## 2. Scope and Definitions + +| Term | Definition | +|---|---| +| **Open quantity** | Contract line quantity **not yet delivered/executed**, i.e. nominal contract quantity minus quantity already shipped and closed. A fully executed line contributes 0. | +| **Purchase leg** | Contract lines where the company is the **buyer** (`P-xxxx` contracts). Add to net. | +| **Sale leg** | Contract lines where the company is the **seller** (`S-xxxx` contracts). Subtract from net. | +| **Product** | The tradeable product (e.g. `[FI-H2SO4] SULPHURIC ACID`). Grouping key. | +| **Commodity** | The parent commodity family (Metals, Coffee, Cocoa, Cotton, Grains, Iron Ore). Used for filtering and sub-totalling. | +| **Quantity UoM** | Unit in which the net is expressed: **MT** for most commodities, **lots** for those quoted in lots (e.g. Arabica/KC). Driven by the product's `risk_uom`, never mixed within a row. | +| **Pricing status** | Whether the open quantity is price-fixed, unfixed, or partly fixed. Informational here (full detail in RPT-04). | + +**Position sign convention:** purchases positive, sales negative. A long book shows a positive net; a short book shows a negative net. + +--- + +## 3. Source Data + +The report reads **contract lines** joined to their **linked shipments**. One source row per contract line, then aggregated by product. + +Primary source model: `commodity.contract.line` (or the Trad'On equivalent carrying targeted quantity, direction, product, and pricing state). The executed/shipped quantity is **not stored on the line** — it is derived by summing the linked shipment quantities. + +Fields required from source: + +- `contract.type` → buy / sell (leg direction) +- `product` → grouping key +- `product.commodity` → family + `risk_uom` +- `targeted_quantity` → nominal/target contract quantity on the line +- `finished` (boolean/state) → if the line is finished, open quantity is zeroed (see below) +- linked **shipment** quantities → summed per line to obtain shipped quantity +- `pricing_state` → fixed / unfixed / partly_fixed +- `shipment_period` → for the period filter +- `strategy` → for the strategy filter +- `counterparty` + +**Open quantity derivation.** + +The line's execution progress is defined as: + +``` +quantity_executed = targeted_quantity − Σ(linked shipment quantities) +``` + +Here `quantity_executed` is the quantity **still remaining to execute** (target minus what has already shipped) — i.e. it *is* the open quantity. Therefore, subject to the finished-line rule below: + +``` +open_qty = targeted_quantity − Σ(linked shipment quantities) if line NOT finished +open_qty = 0 if line IS finished +``` + +**Finished-line zeroing (balance zeroing).** A contract line can be **commercially closed before it is quantitatively complete**. When the line is marked *finished* — the option holder has declared the balance final under the tolerance framework — any remaining unshipped quantity ceases to be open exposure, even if `targeted_quantity − Σ(shipments) > 0`. A finished line therefore contributes **0** to the position regardless of the arithmetic. This guard takes precedence over the shipment sum: check the finished flag first, and only compute the residual for lines still open. + +Because there is no stored `quantity_executed` column, the report computes `Σ(shipment quantities)` directly from the shipment table (a `LEFT JOIN` + `SUM`, so lines with no shipment yet count their full target as open). Lines are excluded from the open position when **either**: the line is finished, **or** `open_qty <= 0` (fully executed, or over-delivered within tolerance — clamp to 0). + +--- + +## 4. Column Specification + +The report has **one row per product** (default grouping). Columns: + +| # | Column name | Type | How to create it | +|---|---|---|---| +| 1 | **Product** | Char | Display name of the product (`product.rec_name`). Grouping key of the row. | +| 2 | **Commodity** | Char | `product.commodity.name`. If the UoM is lots, append the quotation ref (e.g. "Coffee (lots)"). Used for sub-totals. | +| 3 | **Pricing** | Selection badge | Aggregated pricing state of the open quantity for this product: `Fixed` if all open lines fixed; `Unfixed` if none fixed; `Partly fix` if mixed. Rendered as a status pill. | +| 4 | **Shipment Pd** | Char | Earliest–latest shipment period spanning the product's open lines (e.g. "Q3 2026"). If a single period, show it; if several, show the range. | +| 5 | **Strategy** | Char | Strategy/book code the product's open lines belong to (e.g. `ACID-EU`). If lines span strategies, show "Multiple" and expose detail on drill-down. | +| 6 | **Bought (MT)** | Numeric, right-aligned | `Σ open_qty` over all **purchase** lines of this product, in `risk_uom`. 3 decimals for MT; integer for lots. | +| 7 | **Sold (MT)** | Numeric, right-aligned | `Σ open_qty` over all **sale** lines of this product, in `risk_uom`. | +| 8 | **Net (MT)** | Numeric, right-aligned | `Bought − Sold`. Colored: green if > 0, red if < 0, neutral if 0. This is the core figure. | +| 9 | **Direction** | Selection badge | Derived from Net: `LONG` (Net > 0, green), `SHORT` (Net < 0, red), `Flat` (Net = 0, neutral). Rendered as a pill. | + +**Column header note:** columns 6–8 carry the unit label of the *row's* UoM. Because different rows may be in MT or lots, the header reads "Bought (MT / lots)" and each cell shows its own unit; totals are only summed **within** a UoM (see §5). + +**Optional drill-down columns** (hidden by default, shown on row expand — one sub-row per contract line): Contract ref, B/S, Counterparty, Open Qty, Pricing period, Shipment date. + +--- + +## 5. Aggregation & Grouping + +- **Default grouping:** by Product (one row each). +- **Optional grouping:** by Commodity (sub-totals per family), then Product. Tryton's list grouping handles this natively. +- **Totals row (footer):** the "Net" column footer sums net positions **only across rows sharing the same UoM**. MT rows and lot rows are totalled separately and shown as two figures (e.g. `−7,880.000 MT eq` and `+120 lots`). Never sum MT and lots into one number. +- **MT-equivalent option:** if the business wants one grand total, provide a `risk_uom` → MT conversion factor on the product (e.g. 1 KC lot = 37,500 lb ≈ 17.01 MT) and expose a "MT eq" toggle. Off by default to avoid misleading single totals. + +--- + +## 6. Filter Criteria + +All filters are on the report's search bar (Tryton domain filters). Defaults in **bold**. + +| Filter | Values | Purpose | +|---|---|---| +| **Valuation Date** | date (default **today**) | Snapshot date; open quantity computed as of this date. | +| **Commodity** | All / Metals / Coffee / Cocoa / Cotton / Grains / Iron Ore | Restrict to a commodity family. | +| **Product** | All / specific product | Single-product focus. | +| **Pricing Status** | All / Fixed / Unfixed / Partly fixed | Isolate unpriced (price-at-risk) quantity. | +| **Shipment Period** | All / Q3 2026 / Q4 2026 / Q1 2027 … | Time-bucket the position. | +| **Strategy** | All / strategy codes | Per-book view. | +| **Counterparty** | All / specific | Concentration/limit checks. | +| **Direction** *(optional)* | All / Long / Short / Flat | Show only exposed products. | + +Active filters appear as removable chips under the filter bar (Valuation date and Weight unit shown by default). + +--- + +## 7. KPI Header Cards + +Five cards summarising the filtered result: + +| Card | Value | Computation | +|---|---|---| +| **Net Long** | Σ of positive nets (MT eq) | Sum of Net across products where Net > 0. | +| **Net Short** | Σ of negative nets (MT eq) | Sum of Net across products where Net < 0. | +| **Flat / Balanced** | count | Number of products with Net = 0. | +| **Unfixed Qty** | MT | Σ open_qty where pricing_state = unfixed (price risk open). | +| **Open Contracts** | count | Distinct open contract lines (buy + sell) in scope. | + +--- + +## 8. Tryton Implementation + +### 8.1 Recommended pattern + +Build on the shared abstract base `commodity.risk.report` (proposed for the whole suite), which provides: valuation date, `risk_uom` resolution, and UoM/FX conversion helpers. Open Position needs only the quantity logic — no market pricing. + +### 8.2 Model + +A read-only, non-stored `ModelSQL` backed by a SQL view (`table_query`) is the right shape: it aggregates contract lines on the fly, so it is always live and stores nothing. + +```python +# commodity_risk_reporting/open_position.py +from decimal import Decimal +from trytond.model import ModelSQL, ModelView, fields +from trytond.pool import Pool +from trytond.transaction import Transaction +from sql import Literal +from sql.aggregate import Sum +from sql.conditionals import Case + + +class OpenPosition(ModelSQL, ModelView): + "Open Position — Physical Net Long/Short" + __name__ = 'commodity.risk.open_position' + + product = fields.Many2One('product.product', 'Product', readonly=True) + commodity = fields.Many2One( + 'commodity.commodity', 'Commodity', readonly=True) + pricing = fields.Selection([ + ('fixed', 'Fixed'), + ('unfixed', 'Unfixed'), + ('partly', 'Partly fix'), + ], 'Pricing', readonly=True) + shipment_period = fields.Char('Shipment Pd', readonly=True) + strategy = fields.Many2One('commodity.strategy', 'Strategy', readonly=True) + bought = fields.Numeric('Bought', digits=(16, 3), readonly=True) + sold = fields.Numeric('Sold', digits=(16, 3), readonly=True) + net = fields.Numeric('Net', digits=(16, 3), readonly=True) + direction = fields.Selection([ + ('long', 'LONG'), + ('short', 'SHORT'), + ('flat', 'Flat'), + ], 'Direction', readonly=True) + risk_uom = fields.Many2One('product.uom', 'Unit', readonly=True) + + @classmethod + def table_query(cls): + pool = Pool() + Line = pool.get('commodity.contract.line') + Contract = pool.get('commodity.contract') + Shipment = pool.get('commodity.shipment') # linked shipments + line = Line.__table__() + contract = Contract.__table__() + shipment = Shipment.__table__() + + # Step 1: shipped quantity per line = Σ of linked shipment quantities. + # Grouped subquery so we get one shipped total per contract line. + shipped = shipment.select( + shipment.contract_line.as_('line'), + Sum(shipment.quantity).as_('shipped_qty'), + group_by=[shipment.contract_line], + ) + + # Step 2: open_qty = targeted_quantity − shipped_qty. + # LEFT JOIN so lines with no shipment yet keep their full target open. + # Coalesce the (possibly NULL) shipped total to 0. + shipped_qty = Coalesce(shipped.shipped_qty, Literal(0)) + residual = line.targeted_quantity - shipped_qty + + # Finished-line zeroing: a line marked finished contributes 0 open qty, + # regardless of the residual (balance declared final by the option + # holder). This guard takes precedence over the shipment arithmetic. + # Combined with per-line over-delivery clamp (residual > 0). + open_qty = Case( + (line.finished, Literal(0)), + (residual > Literal(0), residual), + else_=Literal(0), + ) + + is_buy = contract.type == 'purchase' + bought = Sum(Case((is_buy, open_qty), else_=Literal(0))) + sold = Sum(Case((~is_buy, open_qty), else_=Literal(0))) + + query = line.join( + contract, condition=line.contract == contract.id + ).join( + shipped, 'LEFT', + condition=shipped.line == line.id + ).select( + # a synthetic id per product group is required by Tryton + line.product.as_('id'), + line.product.as_('product'), + bought.as_('bought'), + sold.as_('sold'), + (bought - sold).as_('net'), + where=contract.state.in_(['confirmed', 'processing']), + group_by=[line.product], + # keep only products with a genuine open position; + # per-line clamping of over-delivery is handled in step 2 (see note) + having=(bought - sold) != Literal(0), + ) + return query +``` + +> **Per-line guards folded into `open_qty`.** The `Case` above applies both rules before aggregation: a **finished** line yields 0 (balance zeroed), and a non-finished line yields its residual only when positive (over-delivery within tolerance clamps to 0). Doing this per line is essential — otherwise one finished or over-shipped line silently offsets a genuine open position elsewhere in the same product. + +> **Imports.** Add `from sql.functions import Coalesce` (or `from sql.conditionals import Coalesce` depending on the `python-sql` version). + +> **Other columns.** `pricing`, `shipment_period`, `strategy`, `commodity`, `direction`, and `risk_uom` are resolved either in SQL (extra joins with `MIN`/`MAX`/`CASE` aggregates) or as `fields.Function` computed in Python after the aggregate. `direction` is a pure function of `net` — `long` if `net > 0`, `short` if `net < 0`, else `flat` — best as a `fields.Function` with a searcher so the Direction filter works. + +### 8.3 View (list / tree) + +```xml + + + + + + + + + + + + +``` + +Add colour via `tree` cell attributes / a `states` expression so Net is green when positive and red when negative, and render `pricing` and `direction` as badges. + +### 8.4 Menu & action + +Register under **Global Reporting → Positions → Open Position**, mirroring the existing Physical Position screen. Provide the search filters of §6 as `` search elements and a saved default filter on `valuation_date = today`. + +--- + +## 9. Edge Cases & Business Rules + +1. **Over-delivery within tolerance.** If a line's shipments exceed its target (`Σ shipment qty > targeted_quantity`, so `open_qty < 0`), clamp that line to 0 — it is closed, not a negative open position. Clamp **per line before aggregating** (see the `CASE` note in §8.2), otherwise one over-shipped line silently offsets a genuine open position elsewhere in the same product. +2. **Finished lines (balance zeroing).** A line marked *finished* contributes **0** open quantity even if `targeted_quantity − Σ(shipments) > 0`. The option holder has declared the balance final, so the residual is no longer exposure. This guard is checked first, ahead of the shipment arithmetic (see §8.2). +3. **Unconfirmed / draft contracts.** Excluded (`state in ('confirmed', 'processing')` only). Draft quotes are not risk. +4. **Mixed UoM within a product.** Should never happen — a product has exactly one `risk_uom`. Guard: if missing, flag the row rather than defaulting silently. +5. **Lots vs MT totals.** Column footers must not add across UoM (see §5). This is the single most common reporting error for multi-commodity desks. +6. **Multi-strategy products.** Show "Multiple" in the Strategy column; the per-line breakdown is available on drill-down. +7. **Matched vs total quantity.** This report uses **open (undelivered) quantity**, consistent with the physical exposure definition. It does *not* use matched quantity — that distinction (flagged in tracker items TO-004/TO-023/TO-031) concerns P&L, not position, and does not affect this report. + +--- + +## 10. Acceptance Criteria + +- [ ] One row per product with open contract lines; fully executed products absent. +- [ ] `Net = Bought − Sold` holds for every row, in the product's `risk_uom`. +- [ ] Direction badge matches the sign of Net (LONG/SHORT/Flat). +- [ ] Footer totals separate MT and lots; no cross-UoM summation. +- [ ] All eight filters (§6) apply correctly, Valuation Date defaults to today. +- [ ] KPI cards recompute against the filtered set. +- [ ] Draft/unconfirmed contracts excluded; over-delivered lines clamped to 0. +- [ ] Finished lines contribute 0 open quantity even with a positive residual (balance zeroing). +- [ ] Report is read-only and stores no records (live `table_query`). + +--- + +*Spec version 1.0 — feeds RPT-03 (Net Position / Consolidated Risk) as its physical leg.* diff --git a/modules/purchase_trade/global_reporting.xml b/modules/purchase_trade/global_reporting.xml index b70cc6f..632b90b 100644 --- a/modules/purchase_trade/global_reporting.xml +++ b/modules/purchase_trade/global_reporting.xml @@ -39,16 +39,38 @@ Configuration gr.configuration - - - - - - - + + + + + + + ctrm.reporting.risk.open_position.context + form + ctrm_open_position_context_form + + + ctrm.reporting.risk.open_position + tree + ctrm_open_position_list + + + + Open Position + ctrm.reporting.risk.open_position + ctrm.reporting.risk.open_position.context + + + + + + + + + + + + + + + + + + + diff --git a/modules/purchase_trade/view/ctrm_open_position_context_form.xml b/modules/purchase_trade/view/ctrm_open_position_context_form.xml new file mode 100644 index 0000000..2606d86 --- /dev/null +++ b/modules/purchase_trade/view/ctrm_open_position_context_form.xml @@ -0,0 +1,18 @@ +
+