P&L and the EOD batch#
End-of-day P&L is computed by a batch for a calculation date (calc_date) and
persisted to pnl_eod (see Trade database (trade.db)). The logic is pure
and lives in domain/pnl.py; services/pnl.py loads inputs and persists results.
Method (v1 simplification)#
Weighted-average cost, per (symbol, book):
Walk the trades in replay order (
trade_date,entry_timestamp,trade_id).A same-direction trade, or one opening from flat, updates the running average cost:
avg = (|net| * avg + qty * price) / (|net| + qty).An opposite-direction trade realizes P&L on the closed portion:
realized += (trade_price - avg_cost) * closed_qty * sign(position) * price_multiplier. The net quantity is reduced; if the trade flips the sign, the remainder opens a new position at the trade price.At
calc_date,unrealized = (mark_price - avg_cost) * net_qty * price_multiplier. The signed quantity carries the direction.
All amounts are in the instrument’s quote_ccy, stored in pnl_eod.ccy.
Worked example (the seeded sample trades)#
Trade |
Side |
Quantity |
Price |
Net after |
Avg cost after |
Realized on trade |
|---|---|---|---|---|---|---|
2016-01-04 |
BUY |
1,000,000 |
1.08651 |
1,000,000 |
1.08651 |
0 |
2016-01-08 |
BUY |
500,000 |
1.08052 |
1,500,000 |
1.084513 |
0 |
2016-01-13 |
SELL |
600,000 |
1.07360 |
900,000 |
1.084513 |
-6,548.00 |
2016-01-20 |
SELL |
400,000 |
1.09010 |
500,000 |
1.084513 |
+2,234.67 |
Cumulative realized is -4,313.33. With an illustrative mark of 1.0900,
unrealized is (1.0900 - 1.084513) * 500,000 = 2,743.33, and total P&L is
-1,570.00. The same trade sequence is tested in tests/test_domain_pnl.py
(with a different mark).
Mark basis (planned)#
The mark is currently the stored close, whose basis (bid, ask, mid or last) is
not recorded, so unrealized P&L can differ from a liquidation value by up to half
the spread. The intended policy (see Market database (market.db), roadmap item 15) is:
Default: mark at
MIDand recordmark_basison eachpnl_eodrow.Optional liquidation value: mark longs at
BIDand shorts atASK.Realized P&L is unaffected. It uses executed trade prices, which already include the spread paid.
A symbol with no price in the requested basis fails the run with
MarketDataUnavailableError; it never falls back to another basis.
Which inputs a run uses#
Trades:
status != CANCELLEDandtrade_date <= calc_date. Later trades are ignored, so a past date can be recomputed without look-ahead.Mark: the latest
closeat or beforecalc_datefor the symbol, from the market database. A symbol with trades but no price makes the run fail withMarketDataUnavailableError, and nothing is written (inputs are computed before any result is persisted).Instrument data:
quote_ccyandprice_multiplierfrom the reference database; an unknown instrument fails the run.
Output semantics#
Cumulative, not daily.
realized_pnlandtotal_pnlare cumulative since the first trade up tocalc_date. Daily P&L is the difference between consecutive dates; it is not computed or stored.One row per ``(calc_date, symbol, book)`` with any trade history, including flat positions (net 0, average price 0, unrealized 0).
Idempotent. A rerun for the same date deletes and re-inserts that date’s rows in one transaction.
A position within
1e-6units of zero counts as flat, which absorbs float residue (e.g. 0.1 + 0.2 - 0.3).
Not included#
This is not FIFO or lot-level accounting. Fees, coupons, dividends and accrued interest are ignored, and amounts in different currencies are not converted or summed. Whether a different accounting policy is required must be decided before real use. See Multi-asset design.
The EOD batch#
scripts/run_eod.py [--date YYYY-MM-DD] calls EodService.run:
Resolve
calc_date: the argument, or the latest price date across all symbols. With no market data it fails with “Load market data first.”PnlService.run_eod(calc_date)computes and persistspnl_eod.RiskService.run_eod(calc_date)reads those persisted positions and computes risk (see Risk calculations).
The two steps commit to different databases. Each is idempotent, so a failed run
is repaired by running it again; a crash between them leaves P&L and risk
temporarily out of step. The planned eod_runs ledger closes that gap (see
Data architecture and the roadmap).
The web API can run each step separately (POST /pnl, POST /risk); see
Web layer. Booking trades after an EOD run does not change stored results
until the batch is run again for that date.