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):

  1. Walk the trades in replay order (trade_date, entry_timestamp, trade_id).

  2. A same-direction trade, or one opening from flat, updates the running average cost: avg = (|net| * avg + qty * price) / (|net| + qty).

  3. 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.

  4. 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 MID and record mark_basis on each pnl_eod row.

  • Optional liquidation value: mark longs at BID and shorts at ASK.

  • 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 != CANCELLED and trade_date <= calc_date. Later trades are ignored, so a past date can be recomputed without look-ahead.

  • Mark: the latest close at or before calc_date for the symbol, from the market database. A symbol with trades but no price makes the run fail with MarketDataUnavailableError, and nothing is written (inputs are computed before any result is persisted).

  • Instrument data: quote_ccy and price_multiplier from the reference database; an unknown instrument fails the run.

Output semantics#

  • Cumulative, not daily. realized_pnl and total_pnl are cumulative since the first trade up to calc_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-6 units 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:

  1. Resolve calc_date: the argument, or the latest price date across all symbols. With no market data it fails with “Load market data first.”

  2. PnlService.run_eod(calc_date) computes and persists pnl_eod.

  3. 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.