📈 Price Resolver — the single valuation-mark source
backend/app/services/financial_math/price_marks.py answers the one question every engine needs —
what is the per-unit mark of asset X on day D? — with a single coherent daily model, so
charts, valuation (NAV) and metrics (MWRR/TWRR/ROI) all read from the same numbers. The
module belongs to the financial math layer.
Since the legacy valuation engine was removed (1.1 dev cycle), the resolver is the only valuation path: there is no market-only fallback cascade and no transition flag. If a value reaches a report, it came through here.
🪜 The daily model
Per asset, per day D, AssetPriceSeries.resolve(D) returns a ResolvedMark whose source
is decided by a four-tier ladder:
| Tier | MarkSource |
Meaning |
|---|---|---|
| 1 | MARKET |
An asset-system price (price_history.close) exists on D — the exact real quote |
| 2 | TRADE_AVG |
No quote, but same-day transactions exist — average of their unit prices |
| 3 | CARRIED |
Otherwise, the last observation ≤ D carried forward (LOCF; market- or trade-origin) |
| 4 | MISSING |
Nothing on or before D — unit_price = 0, currency = None |
Construction collapses the observation stream to one value per day first: a MARKET quote
wins the day; otherwise same-day TRADE observations are averaged. Queries are O(log n) via
bisect.
📏 Staleness and the estimated flag
Two orthogonal flags ride on every mark — do not conflate them:
price_backward_fill—Nonefor an exact same-day observation; aBackwardFillInfo(actual_rate_date+days_back) when the value was carried forward. This is the project-wide staleness contract FX and asset pricing already expose, so the frontend renders the "value fades with age" line for free.estimated—Truewhenever the value did not come from a real asset-system quote (i.e. it is TRADE-derived), independently of freshness. A real quote carried forward is stale but not estimated; a fresh trade mark is estimated but not stale.
🕯️ Intraday range: open / high / low
PriceObservation and ResolvedMark carry three optional fields, open, high and low, on the
same market ×quote_base_quantity scale as the close. They are filled only on an exact MARKET
day whose price_history row has all three; everywhere else they are None:
| Mark | open / high / low |
|---|---|
MARKET, full triple in price_history |
The row's values |
MARKET, one or two of the three missing |
None — a partial triple counts as no range; an extremum is never guessed |
TRADE_AVG |
None — a trade has one unit price, no intraday range |
CARRIED |
None — nothing traded that day, and the range of an earlier day is never carried forward |
MISSING |
None |
latest() never carries a range either.
The range enters through build_asset_price_series(ohlc_by_date=…): an optional map
date → (open, high, low) for MARKET days already present in price_rows. It is a separate
argument, not a wider price_rows tuple, so the other caller (lots_analysis_service.py) keeps the
(date, close, currency) shape; a day left out of the map has no range.
Only the portfolio engine (financial_math/portfolio/engine.py) passes it, and only for a calculation run with include_candles=True,
which POST /api/v1/portfolio/report requests through include_pnl_candles. The Growth chart asks
for it lazily — when the user switches to the P&L Candles submode and no candles are loaded yet —
on the Dashboard and on a broker page (see the
Portfolio Growth Chart).
The engine builds the map from the price_history rows it already loads, with no extra query, and
composes one synthetic P&L candle per day:
- each held asset with a range contributes its value at
open,highandlow; an asset without one contributes a flat value (open = high = low = close); offset = total_pnl − Σ close, and the candle isoffset + Σ open,offset + Σ high,offset + Σ low, closing attotal_pnl, the day's total P&L — the offset absorbs cash, the capital baseline and in-transit value;- a day on which any held asset is
MISSINGgets no candle at all.
🧊 Design contract
- Two layers.
AssetPriceSeriesis a pure, synchronous, immutable calculator built from pre-normalized observations — no I/O, no DB, no async, no FX — so engines (themselves pure) can query it and trust it blindly. Data acquisition happens once per calculation in the async caller, which then feeds normalized observations viabuild_asset_price_series(). This mirrors the load-once / query-sync shape ofFxRateResolver(financial_math/lots/conversions.py). - Native currency, market ×
quote_base_quantityscale — conversion belongs to the engine. Every observation stays in its own native currency; eachResolvedMarkcarries thatcurrencyso the consuming engine converts to the reporting currency at the valuation date (a carried mark must translate at the day it is read — otherwise a foreign holding freezes a stale FX). Trade unit prices are lifted to the qbq axis (× quote_base_quantity) so a qbq=100 bond lands on its ~100 par axis alongside market quotes. The portfolio engine takes those rates fromfx_rate_map, preloaded once per calculation by_preload_fx_rates()(financial_math/portfolio/engine.py) with a singleconvert_bulk()call — see FX Architecture for how that call loads only the rate window it can use. - Never for realized / cost basis. The mark is for open-position valuation and the price line only. Realized P&L and cost basis always come from the actual transaction prices, never from this estimate.
- What feeds it (
build_asset_price_series): MARKET observations fromprice_rows((date, close, currency);closeis already per-qbq and is not re-scaled), with their optional intraday range fromohlc_by_date, TRADE observations from BUY/SELL (|amount| / |quantity|in the transaction currency) and priced ADJUSTMENT carryovers (cost_basis_override). Split-linked rows and pure quantity adjustments carry no price and are skipped.
🔌 Who consumes it
Two consumers use the resolver today:
| Consumer | What it uses it for |
|---|---|
Portfolio engine (financial_math/portfolio/) |
The daily valuation brain: engine.py builds one AssetPriceSeries per held asset, and DailyStateBuilder (daily_state.py) reads it to build the daily portfolio states → NAV snapshots → ROI/TWRR/MWRR (financial_math/returns.py) and the value/P&L history lines; on request, also the synthetic P&L candles (with ohlc_by_date) |
lots_analysis_service.py |
qbq-aware lot valuations, market_prices / estimated_market_prices maps, performance NAV — see Lots Analysis Service |
Everything downstream (dashboard KPIs, portfolio/broker reports, AI Export valuation sections) reads numbers these two produced, which is what makes the resolver the single source: a change here moves every chart and every metric together.