Risk Management - Physical Open Position
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
<!-- open_position_list.xml -->
|
||||
<tree>
|
||||
<field name="product"/>
|
||||
<field name="commodity"/>
|
||||
<field name="pricing"/>
|
||||
<field name="shipment_period"/>
|
||||
<field name="strategy"/>
|
||||
<field name="bought"/>
|
||||
<field name="sold"/>
|
||||
<field name="net"/>
|
||||
<field name="direction"/>
|
||||
</tree>
|
||||
```
|
||||
|
||||
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 `<field>` 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.*
|
||||
@@ -39,16 +39,38 @@
|
||||
<field name="name">Configuration</field>
|
||||
<field name="res_model">gr.configuration</field>
|
||||
</record>
|
||||
<record model="ir.action.act_window.view" id="act_gr_configuration_form_view1">
|
||||
<field name="sequence" eval="10"/>
|
||||
<field name="view" ref="gr_configuration_view_form"/>
|
||||
<field name="act_window" ref="act_gr_configuration_form"/>
|
||||
</record>
|
||||
|
||||
<menuitem
|
||||
name="Global Reporting"
|
||||
sequence="5"
|
||||
id="menu_global_reporting"
|
||||
<record model="ir.action.act_window.view" id="act_gr_configuration_form_view1">
|
||||
<field name="sequence" eval="10"/>
|
||||
<field name="view" ref="gr_configuration_view_form"/>
|
||||
<field name="act_window" ref="act_gr_configuration_form"/>
|
||||
</record>
|
||||
|
||||
<record model="ir.ui.view" id="ctrm_open_position_context_view_form">
|
||||
<field name="model">ctrm.reporting.risk.open_position.context</field>
|
||||
<field name="type">form</field>
|
||||
<field name="name">ctrm_open_position_context_form</field>
|
||||
</record>
|
||||
<record model="ir.ui.view" id="ctrm_open_position_view_list">
|
||||
<field name="model">ctrm.reporting.risk.open_position</field>
|
||||
<field name="type">tree</field>
|
||||
<field name="name">ctrm_open_position_list</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.action.act_window" id="act_ctrm_open_position">
|
||||
<field name="name">Open Position</field>
|
||||
<field name="res_model">ctrm.reporting.risk.open_position</field>
|
||||
<field name="context_model">ctrm.reporting.risk.open_position.context</field>
|
||||
</record>
|
||||
<record model="ir.action.act_window.view" id="act_ctrm_open_position_view">
|
||||
<field name="sequence" eval="10"/>
|
||||
<field name="view" ref="ctrm_open_position_view_list"/>
|
||||
<field name="act_window" ref="act_ctrm_open_position"/>
|
||||
</record>
|
||||
|
||||
<menuitem
|
||||
name="Global Reporting"
|
||||
sequence="5"
|
||||
id="menu_global_reporting"
|
||||
icon="tradon-reporting"/>
|
||||
<menuitem
|
||||
name="Configuration"
|
||||
@@ -68,5 +90,61 @@
|
||||
sequence="60"
|
||||
id="menu_ctrm_other"
|
||||
icon="tradon-reporting-other"/>
|
||||
<menuitem
|
||||
name="Risk Management"
|
||||
parent="menu_ctrm_other"
|
||||
sequence="10"
|
||||
id="menu_ctrm_other_risk_management"/>
|
||||
<menuitem
|
||||
name="Inventory Report"
|
||||
parent="menu_ctrm_other_risk_management"
|
||||
sequence="10"
|
||||
id="menu_ctrm_other_risk_inventory_report"/>
|
||||
<menuitem
|
||||
name="Open Position"
|
||||
parent="menu_ctrm_other_risk_management"
|
||||
action="act_ctrm_open_position"
|
||||
sequence="20"
|
||||
id="menu_ctrm_other_risk_open_position"/>
|
||||
<menuitem
|
||||
name="Net Consolidated Position"
|
||||
parent="menu_ctrm_other_risk_management"
|
||||
sequence="30"
|
||||
id="menu_ctrm_other_risk_net_consolidated_position"/>
|
||||
<menuitem
|
||||
name="Contracts To Fix"
|
||||
parent="menu_ctrm_other_risk_management"
|
||||
sequence="40"
|
||||
id="menu_ctrm_other_risk_contracts_to_fix"/>
|
||||
<menuitem
|
||||
name="Market Exposure"
|
||||
parent="menu_ctrm_other_risk_management"
|
||||
sequence="50"
|
||||
id="menu_ctrm_other_risk_market_exposure"/>
|
||||
<menuitem
|
||||
name="Unrealized P&L"
|
||||
parent="menu_ctrm_other_risk_management"
|
||||
sequence="60"
|
||||
id="menu_ctrm_other_risk_unrealized_pnl"/>
|
||||
<menuitem
|
||||
name="Mark to Market"
|
||||
parent="menu_ctrm_other_risk_management"
|
||||
sequence="70"
|
||||
id="menu_ctrm_other_risk_mark_to_market"/>
|
||||
<menuitem
|
||||
name="Derivatives Valuation"
|
||||
parent="menu_ctrm_other_risk_management"
|
||||
sequence="80"
|
||||
id="menu_ctrm_other_risk_derivatives_valuation"/>
|
||||
<menuitem
|
||||
name="Forex Valuation"
|
||||
parent="menu_ctrm_other_risk_management"
|
||||
sequence="90"
|
||||
id="menu_ctrm_other_risk_forex_valuation"/>
|
||||
<menuitem
|
||||
name="Futures To Roll"
|
||||
parent="menu_ctrm_other_risk_management"
|
||||
sequence="100"
|
||||
id="menu_ctrm_other_risk_futures_to_roll"/>
|
||||
</data>
|
||||
</tryton>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<form>
|
||||
<label name="valuation_date"/>
|
||||
<field name="valuation_date"/>
|
||||
<label name="commodity"/>
|
||||
<field name="commodity"/>
|
||||
<label name="product"/>
|
||||
<field name="product"/>
|
||||
<label name="pricing_status"/>
|
||||
<field name="pricing_status"/>
|
||||
<label name="shipment_period"/>
|
||||
<field name="shipment_period"/>
|
||||
<label name="strategy"/>
|
||||
<field name="strategy"/>
|
||||
<label name="counterparty"/>
|
||||
<field name="counterparty"/>
|
||||
<label name="direction"/>
|
||||
<field name="direction"/>
|
||||
</form>
|
||||
18
modules/purchase_trade/view/ctrm_open_position_list.xml
Normal file
18
modules/purchase_trade/view/ctrm_open_position_list.xml
Normal file
@@ -0,0 +1,18 @@
|
||||
<tree>
|
||||
<field name="product" width="180"/>
|
||||
<field name="commodity" width="130"/>
|
||||
<field name="pricing"
|
||||
widget="badge"
|
||||
badge_colors="fixed:#16a34a,unfixed:#f59e0b,partly:#fbbf24"/>
|
||||
<field name="shipment_period" width="110"/>
|
||||
<field name="strategy" width="120"/>
|
||||
<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>
|
||||
Reference in New Issue
Block a user