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 :doc:`/database/trade`). 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)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

.. list-table::
   :header-rows: 1

   * - 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 :doc:`/database/market`, 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 :doc:`/database/multi_asset`.

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 :doc:`/risk`).

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
:doc:`/database/architecture` and the :doc:`roadmap </database/hardening_roadmap>`).

The web API can run each step separately (``POST /pnl``, ``POST /risk``); see
:doc:`/web`. Booking trades after an EOD run does not change stored results
until the batch is run again for that date.
