Holdings Engine
FIFO replay engine, day P&L decomposition, and valuation event-replay — the math behind every portfolio number
The holdings/ package is the heart of the portfolio module. It owns three concerns:
Rebuild— replays a portfolio company's full event history into open lots, lot consumptions, and a flattened holdings row. Runs inside every trade and corporate-action mutation.DayPnL— splits today's gain across overnight, intraday-buy, and intraday-sell legs.HoldingFromRow— projects anepse_portfolio_holdingsrow + live price into theHoldingResponseDTO.
Plus a fourth concern that lives in core/valuation.go and shares the event-walk pattern:
buildValuation— walks every event for an entire portfolio against bulk daily prices to produce a value/cost-basis time-series.
FIFO replay: holdings.Rebuild
The contract:
func Rebuild(ctx context.Context, qtx *sqlc.Queries, portfolioID, companyID uuid.UUID) errorAlways called inside shared.TxRunner.Run. Always scoped to a single (portfolioID, companyID) pair.
Algorithm
Event ordering
sort.SliceStable(events, func(i, j int) bool {
if !events[i].at.Equal(events[j].at) {
return events[i].at.Before(events[j].at)
}
if !events[i].createdAt.Equal(events[j].createdAt) {
return events[i].createdAt.Before(events[j].createdAt)
}
return events[i].id.String() < events[j].id.String()
})Three keys, in order:
- Effective time (trade
traded_ator actioneffective_date). - Created time (when the user logged the event). Disambiguates two events on the same date.
- UUID (lexicographic). Total order even if a user double-submits at the exact same instant.
This ordering is what makes the replay deterministic across runs. Two clients hitting the same portfolio with simultaneous writes can never produce a different lot tree.
Per-event semantics
A new open lot is appended:
lot.remaining_qty = trade.quantity
lot.cost_per_unit = trade.totalAmount / quantity // BUY total includes fees, so WACC bakes them in
lot.source = "BUY"
lot.acquired_at = trade.traded_at
lot.trade_id = trade.idtotalInv and costCurrent both grow by trade.totalAmount. currentUnits grows by qty.
Oldest open lots are consumed first. For each lot consumed:
consume_qty = min(lot.remaining_qty, sell_qty_remaining)
lot.remaining_qty -= consume_qty
sell_qty_remaining -= consume_qty
cons_row = {
sell_trade_id, lot_id, qty: consume_qty,
cost_per_unit: lot.cost_per_unit
}Open lots are kept in arrival order. A lot with remaining_qty = 0 is left in memory but not persisted (the lot tree only contains open lots).
Aggregates updated:
currentUnits -= sell_qtysoldUnits += sell_qtycostCurrent -= Σ(consume_qty × lot.cost_per_unit)— WACC of consumed costsoldGross += sell.gross(qty × price)soldNet += sell.totalAmount(after fees, after CGT)realDisplay += soldGross - Σ(consume_qty × lot.cost_per_unit)— display realised P&L (excludes CGT)realTax += realDisplay - cgt_amount
Two realised-P&L numbers because the user-visible "realized" hides CGT (treated as a tax expense), while the audit trail keeps the after-tax view too.
A zero-cost lot is appended:
lot.remaining_qty = action.quantity
lot.cost_per_unit = 0
lot.source = "BONUS"
lot.acquired_at = action.effective_date
lot.action_id = action.idcurrentUnits grows; costCurrent is unchanged. The bonus dilutes WACC on the next read.
A cost lot is appended at the action's price_per_unit + fees / quantity:
cost = action.price_per_unit × action.quantity + action.fees
lot.remaining_qty = action.quantity
lot.cost_per_unit = cost / quantity
lot.source = action.type // "RIGHT_SUBSCRIBED", "IPO", "FPO", "AUCTION"
lot.acquired_at = action.effective_date
lot.action_id = action.idcurrentUnits and costCurrent both grow.
For RIGHT_ISSUE specifically, quantity is the subscribed count (could be 0 → action skipped). EntitledQty is informational on the response only.
Not part of the lot tree. Accumulated into a single dividendNet counter that lands on the holdings row:
dividendNet += dividend.amount // post-tax net dividend the user receivedPersisted output
After the walk, the engine issues:
CreatePortfolioLot × N— only lots withremaining_qty > 0.RecordLotConsumption × M— full audit trail of every sell.UpsertPortfolioHolding(portfolioID, companyID, currentUnits, soldUnits, totalInvested, totalCostCurrent, soldValueGross, soldValueNet, realizedPnlDisplay, realizedPnlTax, dividendNet, firstTradeAt, lastTradeAt).UpdateTradeAudit(called from the trade service after Rebuild) — backfillscostBasisAtSell,cgtComputed,totalAmountComputed,settlesOnon the trade row that triggered the rebuild.
Edge cases
- Sell exceeds held —
holdings.CurrentUnitsForHoldingruns before the new SELL row is written; the trade service rejects with 422 if the new SELL would over-sell. So during replay the assumption "open lots cover the sell" already holds for any trade that made it into the table. - Back-dated correction — fully supported. Insert an old BUY at any point and
Rebuildre-derives forward. - Soft-deleted trades — excluded by
FindPortfolioTradesForReplay(the query filtersdeleted_at IS NULL). They show up in the audit log only. - Zero-quantity action — skipped (
if qty == 0 { continue }).
Day P&L decomposition
holdings.DayPnL(row, todayTrades) decimal.Decimal returns one Decimal value per company, summed up per portfolio in core.detailToDTO.
Three legs
overnight_leg = (LTP - PreviousClose) × overnight_units_still_held
intra_sell_leg = Σ for each SELL today (price - reference) × qty
intra_buy_leg = (LTP - price) × intra_buy_qty_still_held
dayPnl = overnight_leg + intra_sell_leg + intra_buy_legThe reference for an intraday SELL is PreviousClose if the units came from overnight, or the matching intraday BUY price if the units were bought today (FIFO matched within the day). The split happens in daypnl.go via sameDayBuyLot.
Worked examples
100 units overnight (PC=600, LTP=620). BUY 50 today @ 610, SELL 30 today @ 615.
- Today's net = +20 units (50 bought, 30 sold)
- 30 of the 50 today-bought are gone; 20 today-bought are still held.
- The SELL of 30 matches today's BUY at 610:
intra_sell_leg = (615-610) × 30 = 150 - Today's still-held BUYs:
intra_buy_leg = (620-610) × 20 = 200 - Overnight units still held (all 100):
overnight_leg = (620-600) × 100 = 2000
Wait — currentUnits is now 120 (100 + 50 - 30). Reconciling with the actual test:
// TestDayPnL_SameDayRoundTrip
row.CurrentUnits = 20 // not 120 — BUY 50, SELL 30 so net +20 today, but the test
// configures the row as if today started at 0 holding.
trades = [BUY 50 @ 610, SELL 30 @ 615]
expected dayPnl = 350With the test's setup (no overnight position; position came from today only):
intra_sell_leg = (615 - 610) × 30 = 150intra_buy_leg = (620 - 610) × 20 = 200overnight_leg = 0(no overnight)- Total = 350 ✓
100 units held overnight (PC=125, LTP=130). SELL 40 today @ 150.
- 60 units still held overnight.
- The SELL came from overnight (no today-buys). Reference = PC.
overnight_leg = (130-125) × 60 = 300intra_sell_leg = (150-125) × 40 = 1000intra_buy_leg = 0- Total = 1300 ✓ (matches
TestDayPnL_OvernightSell)
100 units overnight (PC=600, LTP=620). BUY 20 @ 605 then SELL 50 @ 615 today.
The SELL of 50 exhausts today's 20 first (FIFO within the day) then 30 from overnight.
intra_sell_legagainst today-BUY:(615-605) × 20 = 200intra_sell_legagainst overnight:(615-600) × 30 = 450overnight_leg=(620-600) × (100-30) = 1400(overnight units still held)intra_buy_leg=0(today's BUYs were all consumed by the SELL)- Total = 2050 ✓ (matches
TestDayPnL_OvernightBuySell)
Implementation: internal/modules/portfolio/holdings/daypnl.go. Tests: holdings_test.go.
HoldingFromRow projection
func HoldingFromRow(
row sqlc.FindHoldingsWithPricesByPortfolioRow,
activeCurVal decimal.Decimal,
dayPnl decimal.Decimal,
) HoldingResponseMaps one DB row + portfolio-wide active-value-sum + per-company day P&L into the HoldingResponse DTO. Computes:
| Field | Formula |
|---|---|
wacc | totalCostCurrent / currentUnits (over open lots only) |
curVal | LTP × currentUnits |
curInv | totalCostCurrent (open-lot cost) |
unrealPnl | curVal - curInv |
realPnl | realizedPnlDisplay (cost-only realised, excludes CGT) |
totalPnl | unrealPnl + realPnl + dividendNet |
totalPnlPct | totalPnl / totalInvested × 100 (over the original cost contributed, not the open residue) |
holdingPct | curVal / activeCurVal × 100 (only counts active companies) |
52wPos | (LTP - yearLow) / (yearHigh - yearLow) × 100 |
suspended | status != 'A' |
Active vs suspended split: only status = 'A' rows participate in activeCurVal and the portfolio-wide Summary. Suspended companies still appear in the holdings[] array (with suspended: true) but their P&L numbers are aggregated separately into suspendedSummary so they don't pollute the headline totals.
Valuation event-replay
core.buildValuation is structurally similar to Rebuild but operates across all companies at once and produces a time-series rather than a single state.
Inputs
buildValuation(
trades []sqlc.FindAllPortfolioTradesForValuationRow,
actions []sqlc.FindAllPortfolioCorporateActionsForValuationRow,
priceRows []sqlc.FindBulkPriceHistoryForPortfolioRow,
rangeStr string,
now time.Time,
) ValuationResponsepriceRows is fetched in one round trip via FindBulkPriceHistoryForPortfolio with ANY($1::uuid[]) over the unique company IDs the portfolio has touched. O(1) queries — not O(N) per company.
Algorithm
State per company
type compState struct {
units int32
costCurrent decimal.Decimal
}Per-event update rules (mirrors Rebuild but maintains one aggregated state, not a lot list — valuation only needs (units, costCurrent), not which-lot-was-sold):
| Event | Effect |
|---|---|
BUY | units += qty; costCurrent += totalAmount |
SELL | wacc = costCurrent / units; costCurrent -= wacc × min(qty, units) (no underflow); units = max(0, units - qty) |
BONUS | units += qty (no cost) |
RIGHT_ISSUE, IPO, FPO, AUCTION | units += qty; costCurrent += price × qty + fees |
One bar per trading date
for _, d := range dates { // dates from priceRows only
// apply all events with date ≤ d
for evtIdx < len(events) && events[evtIdx].date <= d {
applyEvent(state, events[evtIdx])
evtIdx++
}
// value the portfolio
var totalValue, totalCost decimal.Decimal
for cid, cs := range state {
if cs.units <= 0 { continue }
ltp, ok := prices[priceKey{cid, d}]
if !ok { continue } // skip silently if no price for this co/date
totalValue = totalValue.Add(ltp.Mul(decimal.NewFromInt(int64(cs.units))))
totalCost = totalCost.Add(cs.costCurrent)
}
series = append(series, ValuationPoint{D: d.Format("2006-01-02"), V: float64(totalValue), CB: float64(totalCost)})
}The two-pointer walk over events + dates is O(events + dates), not O(events × dates).
Range parameter
func valuationRangeStart(rangeStr string, now time.Time) time.Time| Range | Start |
|---|---|
1M | now - 1 month |
3M (default) | now - 3 months |
6M | now - 6 months |
1Y | now - 1 year |
YTD | Jan 1 of now.Year() |
ALL | 2000-01-01 |
The price query uses these as bounds. Events outside the bound still apply (we need them to compute the state at the start of the range), but only dates inside the bound generate series entries.
now is captured once
The service captures time.Now() once and threads it through the price query and buildValuation. Without this, the upper bound of the price query and the upper bound of the date walk could disagree across the midnight boundary. Found by reviewer in Session 10.
Edge case: silent empty series
If the portfolio has trades but no price rows in range — typically means the company has no daily history loaded yet — the response is {range, series: []} with a slog.Warn. The endpoint returns 200, not 500.
Cache: 6 keys per portfolio
The valuation response is cached at portfolio:valuation:<portfolioID>:<RANGE> with a 10-minute TTL per range. Every write that touches CacheScopeDetailAndValuation busts all 6 range keys for the portfolio. See Caching.
Tests
| Concern | Test file |
|---|---|
HoldingFromRow projection | holdings/holdings_test.go |
DayPnL 4 scenarios | holdings/holdings_test.go |
buildValuation 4 base + 4 §10 edge cases + range table | core/valuation_test.go |
buildDistribution 4 cases | companies/distribution_test.go |
Rebuild end-to-end (incl. corporate-action interactions) | smoke_db_test.go (real DB) |
The smoke test exercises the full chain: AddTrade BUY → AddDividend → AddCorporateAction BONUS → AddTrade SELL → UpdateTrade → asserts every projection field.
References
internal/modules/portfolio/holdings/replay.go—Rebuild+CurrentUnitsForHoldinginternal/modules/portfolio/holdings/daypnl.go—DayPnL+sameDayBuyLotinternal/modules/portfolio/holdings/view.go—HoldingResponse+HoldingFromRowinternal/modules/portfolio/core/valuation.go—buildValuation+ range helpersinternal/platform/database/queries/portfolio_*.sql— sqlc query definitions