Shop It Docs
Portfolio Module

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:

  1. 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.
  2. DayPnL — splits today's gain across overnight, intraday-buy, and intraday-sell legs.
  3. HoldingFromRow — projects a nepse_portfolio_holdings row + live price into the HoldingResponse DTO.

Plus a fourth concern that lives in core/valuation.go and shares the event-walk pattern:

  1. 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) error

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

  1. Effective time (trade traded_at or action effective_date).
  2. Created time (when the user logged the event). Disambiguates two events on the same date.
  3. 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.id

totalInv 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_qty
  • soldUnits += sell_qty
  • costCurrent -= Σ(consume_qty × lot.cost_per_unit) — WACC of consumed cost
  • soldGross += 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.id

currentUnits 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.id

currentUnits 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 received

Persisted output

After the walk, the engine issues:

  • CreatePortfolioLot × N — only lots with remaining_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) — backfills costBasisAtSell, cgtComputed, totalAmountComputed, settlesOn on the trade row that triggered the rebuild.

Edge cases

  • Sell exceeds heldholdings.CurrentUnitsForHolding runs 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 Rebuild re-derives forward.
  • Soft-deleted trades — excluded by FindPortfolioTradesForReplay (the query filters deleted_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_leg

The 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 = 350

With the test's setup (no overnight position; position came from today only):

  • intra_sell_leg = (615 - 610) × 30 = 150
  • intra_buy_leg = (620 - 610) × 20 = 200
  • overnight_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 = 300
  • intra_sell_leg = (150-125) × 40 = 1000
  • intra_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_leg against today-BUY: (615-605) × 20 = 200
  • intra_sell_leg against overnight: (615-600) × 30 = 450
  • overnight_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,
) HoldingResponse

Maps one DB row + portfolio-wide active-value-sum + per-company day P&L into the HoldingResponse DTO. Computes:

FieldFormula
wacctotalCostCurrent / currentUnits (over open lots only)
curValLTP × currentUnits
curInvtotalCostCurrent (open-lot cost)
unrealPnlcurVal - curInv
realPnlrealizedPnlDisplay (cost-only realised, excludes CGT)
totalPnlunrealPnl + realPnl + dividendNet
totalPnlPcttotalPnl / totalInvested × 100 (over the original cost contributed, not the open residue)
holdingPctcurVal / activeCurVal × 100 (only counts active companies)
52wPos(LTP - yearLow) / (yearHigh - yearLow) × 100
suspendedstatus != '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,
) ValuationResponse

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

EventEffect
BUYunits += qty; costCurrent += totalAmount
SELLwacc = costCurrent / units; costCurrent -= wacc × min(qty, units) (no underflow); units = max(0, units - qty)
BONUSunits += qty (no cost)
RIGHT_ISSUE, IPO, FPO, AUCTIONunits += 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
RangeStart
1Mnow - 1 month
3M (default)now - 3 months
6Mnow - 6 months
1Ynow - 1 year
YTDJan 1 of now.Year()
ALL2000-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

ConcernTest file
HoldingFromRow projectionholdings/holdings_test.go
DayPnL 4 scenariosholdings/holdings_test.go
buildValuation 4 base + 4 §10 edge cases + range tablecore/valuation_test.go
buildDistribution 4 casescompanies/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.goRebuild + CurrentUnitsForHolding
  • internal/modules/portfolio/holdings/daypnl.goDayPnL + sameDayBuyLot
  • internal/modules/portfolio/holdings/view.goHoldingResponse + HoldingFromRow
  • internal/modules/portfolio/core/valuation.gobuildValuation + range helpers
  • internal/platform/database/queries/portfolio_*.sql — sqlc query definitions