๐ Lots Analysis Service
backend/app/services/lots_analysis_service.py is the orchestration layer behind POST /portfolio/lots/analysis.
It runs FifoLotEngine once, then converts engine output into LotsAnalysisResponse: FX-normalized values, qbq-aware valuations, lot/event histories, WAC series, income allocation, and fallback valuation for assets without market prices. Frontend consumer: LotsAnalysisPanel.svelte.
๐ API Contract
๐ฃ๏ธ Endpoint
Router entrypoint:
@portfolio_router.post("/lots/analysis", response_model=LotsAnalysisResponse)
async def get_lots_analysis(body: LotsAnalysisQuery, ...):
...
return await service.get_lots_analysis(
user_id=current_user.id,
asset_id=body.asset_id,
broker_ids=body.broker_ids,
date_from=date_from,
date_to=date_to,
target_currency=body.target_currency,
selected_lot_ids=body.selected_lot_ids,
requested_analyses=body.requested_analyses,
)
If date_range contains min/max sentinels, router resolves them before calling service.
๐งพ Request Body โ LotsAnalysisQuery
| Field | Type | Required | Notes |
|---|---|---|---|
asset_id |
int |
Yes | Asset to analyze |
broker_ids |
list[int] \| None |
No | None = all accessible brokers |
date_range |
OpenDateRangeModel \| None |
No | Limits emitted histories/visible intervals, not engine starting state |
target_currency |
str \| None |
No | Defaults to global base currency |
selected_lot_ids |
list[int] \| None |
No | Lot subset for lot-scoped analyses |
requested_analyses |
list[LotAnalysisType] |
Yes | Non-empty, duplicates rejected |
๐งฉ LotAnalysisType
| Enum member | Response field | Meaning |
|---|---|---|
LOT_SUMMARY |
lots |
One row per lot with scalar metrics, states, custody, current valuation |
GANTT_TOPOLOGY |
gantt_segments |
Custody fragments for Gantt lanes |
CUSTODY_HISTORY |
custody_history |
Custody-only event subset |
EVENT_HISTORY |
lot_events |
Full lot chronology: openings, splits, transfers, closures |
VALUE_HISTORY |
value_history |
Per-lot open_value, proceeds, total_value, pnl, income |
RETURN_HISTORY |
return_history |
Per-lot total_return, relative_return, income |
PRICE_HISTORY |
price_history |
Per-lot market-price series |
BROKER_WAC_HISTORY |
broker_wac_history |
Broker-scoped WAC time series |
CUMULATIVE_WAC_HISTORY |
cumulative_wac_history |
Combined WAC time series |
PERFORMANCE_HISTORY |
performance_history |
Asset-wide ROI/TWRR, ignores lot selection |
INCOME_EVENTS |
income_events |
Dividend/interest markers allocated to open LONG lots |
๐ฆ Response Shape โ LotsAnalysisResponse
Top-level fields always present:
| Field | Meaning |
|---|---|
asset_id |
Requested asset |
target_currency |
Final response currency |
quote_base_quantity |
Asset qbq, needed by frontend price/WAC axis |
calculation_status |
COMPLETE, DEGRADED, or UNAVAILABLE |
calculation_metadata |
Broker scope, selection, requested/computed date bounds, generation date |
data_quality |
FIFO/data-quality issues mapped to UI-friendly DTOs |
All analysis payload sections are None unless explicitly requested.
โ๏ธ Processing Pipeline
Service entrypoint:
async def get_lots_analysis(
self,
user_id: int,
asset_id: int,
broker_ids: list[int] | None,
date_from: date_type | None,
date_to: date_type | None,
target_currency: str | None,
selected_lot_ids: list[int] | None,
requested_analyses: list[str | LotAnalysisType],
) -> LotsAnalysisResponse:
High-level flow:
- Load accessible brokers, asset transactions, split ratios, broker shorting flags,
price_history, asset income transactions. - Build
reference_price_lookup()and runrun_fifo_lot_engine(...). - Resolve selected lots.
- Collect needed FX pairs with
_collect_fx_needs()/_collect_performance_fx_needs(), then batch-load rates through_FxRateResolver. - Allocate dividends/interest with
_allocate_asset_income(). - Build qbq-aware market price map and WAC context.
- Emit only requested DTO sections.
๐ฑ FX Conversion
_FxRateResolver is thin prefetch + conversion helper:
need(currency, as_of_date)registers required pairsload(session)callsconvert_bulk(...)onceconvert(amount, currency, as_of_date)applies date-specific conversion intotarget_currency
Service converts:
- lot opening cost and opening unit price
- closure proceeds / realized P&L
- market prices from
price_history - dividend / interest cash flows
- WAC inputs
- performance-history external flows
โ ๏ธ qbq Scaling Gotcha
Two price scales coexist
price_history.close is quoted per quote_base_quantity.
opening_unit_price, closure unit prices, and WAC values are per single unit.
For bonds this mismatch is dangerous: comparing raw opening_unit_price with raw market price mixes
two scales and historically caused huge fake returns/P&L. Example: a bond can trade near 100, while
opening_unit_price is near 1 because cost was divided by nominal quantity.
Service fixes this in two places:
- Valuation path:
_build_lot_summaries(),_build_value_history(),_build_return_history(),_build_performance_history()callcompute_holding_value(..., quote_base_quantity)soopen_value = (qty / qbq) * price. - Reference-price fallback path:
_opening_reference_price()multiplies fallbacklot.opening_unit_price * quote_base_quantitywhen no market quote exists on opening date.
Exact fallback scaling code:
scale = quote_base_quantity if quote_base_quantity > 0 else 1
return lot.opening_unit_price * scale, lot.currency, lot.opening_date, "exact"
That multiplication is only for fallback Scenario B. Real market quotes already arrive in per-qbq scale and must not be multiplied again.
๐ธ Income Allocation
_allocate_asset_income(...) handles asset-linked DIVIDEND / INTEREST transactions, which are excluded from FIFO engine input because they have no quantity.
Rule:
Details:
- only LONG lots participate
- lot must be open on
tx.date - income amount is converted to
target_currencyfirst - lots are sorted by
lot_id - last lot absorbs running remainder so allocated sum exactly matches converted transaction amount
- if no LONG lot is open that day, service skips allocation here
Illustrative excerpt:
for idx, (lot_id, qty) in enumerate(open_lots):
if idx == len(open_lots) - 1:
allocated = remaining
else:
allocated = remaining * qty / remaining_qty
remaining -= allocated
remaining_qty -= qty
Outputs:
income_by_lotโ scalar cumulative income forLotSummarySchema.asset_incomeincome_prefix_by_lotโ per-date cumulative prefixes for value/return historiesincome_eventsโ chart markers with source tx and affected lot ids
๐งฎ Estimated-at-Cost Fallback
When no latest market quote exists (price_lookup.latest() is None), service enters estimated mode.
LONG lots with remaining quantity are valued at residual cost:
open_value = (opening_value or Decimal("0")) * lot.open_quantity / lot.original_quantity
value_source = "ESTIMATED_AT_COST"
market_pnl = Decimal("0")
Implications:
LotSummarySchema.value_source = "ESTIMATED_AT_COST"open_valuestill exists for crowdfunding / unquoted assetstotal_value,total_pnl,cash_yield,total_returnremain usablerelative_returnstaysNonebecause no true market/reference comparison exists- data quality report adds
CURRENT_PRICE_ASSUMED_AT_COST
For history builders, same idea appears in _build_value_history() and _build_return_history() when market_price is None but lot is still open.
๐ฏ Selection Model
Backend method:
def _resolve_selected_lot_ids(self, selected_lot_ids: list[int] | None, lots_by_id: dict[int, FifoLot]) -> list[int]:
if selected_lot_ids is None:
return list(lots_by_id)
...
return list(dict.fromkeys(selected_lot_ids))
Meaning at raw API level:
selected_lot_ids = Noneโ all lots in backend scope- explicit non-empty list โ exactly those lots
- explicit unknown ids โ
ValueError
Frontend empty-selection convention
UI state uses different semantics: empty selection means all visible lots, not "none".
LotsAnalysisPanel.svelte implements that by expanding empty UI selection into
effectiveSelectionIds = visibleLots.map((lot) => lot.lot_id) before lot-scoped requests.
So if you call API directly, send null/omit field for "all lots", or send explicit visible IDs
for "all visible lots after frontend filtering". Do not assume raw [] automatically means all.
Also note: PERFORMANCE_HISTORY ignores lot selection entirely by schema/implementation.
๐ฐ๏ธ History Builders
Key builders:
_build_lot_summaries()โ scalar lot rows for table/modal_build_gantt_segments()โ custody fragments_build_lot_event_rows()โ full lot chronology_build_value_history()โ continues closed lots todate_tovia_lot_history_end_date(..., extend_closed=True)_build_return_history()โ same continuity rule, plusrelative_return_build_price_history()โ truncates at closure date, no post-close points_build_broker_wac_history()/_build_cumulative_wac_history()โ WAC snapshots via_compute_wac_series()_build_performance_history()โ asset-wide ROI/TWRR from NAV + external cash flows
Important nuance: date_from trims emitted rows only. Engine still starts from earliest in-scope asset transaction so FIFO state stays correct.
๐ Related
- ๐ง FIFO Lot Engine โ Pure event-sourced FIFO core
- ๐ FIFO Lot Analysis Theory โ Financial interpretation of lot metrics
- ๐ฅ๏ธ Lots Analysis Frontend โ
LotsAnalysisPaneland chart/table consumers