Shop It Docs
Portfolio Module

Data Flow

How a trade, dividend, or corporate action flows from the user's request through FIFO replay into the holdings projection and back into responses

The portfolio module is event-sourced. Trades, dividends, and corporate actions are written by the user; everything else (lots, lot consumptions, holdings, day P&L, valuation series) is derived. This page traces a single write request through the system end-to-end and shows where reads come from.

Source-of-truth vs projection tables

  • Source tables carry deleted_at for soft delete. The user can correct a typo'd trade; the row stays in history but deleted_at IS NOT NULL.
  • Projection tables are deleted and re-derived on every replay. There is no soft delete here — the entire (portfolioID, companyID) triple is wiped and rebuilt inside the same transaction.
  • Live prices come from the scheduler/poller. Portfolio reads them; never writes.

Re-deriving the projection per write is intentional. The math (FIFO order, bonus units, right issues, IPO/FPO/AUCTION cost layering) is fiddly enough that we don't trust incremental update paths to stay correct under back-dated edits. Wiping and rebuilding for one company costs O(events_for_that_company), which is small.

End-to-end write path: AddTrade (BUY)

The same shape applies to SELL, except the service additionally validates that current holdings cover the SELL quantity, looks up FindSettlementDate, and emits tradedAtWarnings if the trade date isn't a NEPSE trading day.

End-to-end read path: GET /portfolios/{id}

The detail TTL is 15s — short enough to feel live, long enough to absorb spammy refreshes. Every write op busts this key. See Caching.

Cache invalidation matrix

Every write op declares its scope at the call site to tx.Run. The Invalidator translates the scope into specific DEL operations:

MethodOwnerScopeKeys busted
core.CreatecoreCacheScopeNonenone — new portfolio has no cache entries yet
core.UpdatecoreCacheScopeDetailportfolio:detail:<id>
core.DeletecoreCacheScopeDetailAndValuationdetail + 6 valuation range keys
trades.AddTradetradesCacheScopeDetailAndValuationdetail + 6 valuation range keys
trades.UpdateTradetradesCacheScopeDetailAndValuationdetail + 6 valuation range keys
trades.DeleteTradetradesCacheScopeDetailAndValuationdetail + 6 valuation range keys
dividends.AddDividenddividendsCacheScopeDetaildetail (dividends affect totals but not the value time-series)
dividends.UpdateDividenddividendsCacheScopeDetaildetail
dividends.DeleteDividenddividendsCacheScopeDetaildetail
actions.AddCorporateActionactionsCacheScopeDetailAndValuationdetail + 6 valuation range keys
actions.UpdateCorporateActionactionsCacheScopeDetailAndValuationdetail + 6 valuation range keys
actions.DeleteCorporateActionactionsCacheScopeDetailAndValuationdetail + 6 valuation range keys

Source: internal/modules/portfolio/shared/cache.go (Invalidator.Apply).

Replay drives every write

Inside tx.Run, every trade or corporate-action write is followed by holdings.Rebuild(ctx, qtx, portfolioID, companyID). There is no incremental "patch one lot" path — the entire (portfolioID, companyID) projection is wiped and re-derived from the source events.

For a deep dive into the event walk, lot semantics, and what happens for each event kind, see Holdings engine.

Read endpoints by query shape

EndpointHot pathSources
GET /portfoliosPostgres onlynepse_portfolios
GET /portfolios/{id}Redis (15s TTL)nepse_portfolios + nepse_portfolio_holdings + nepse_live_prices + nepse_portfolio_trades (today only for day P&L)
GET /portfolios/{id}/summaryderived from FindDetailsame as Get
GET /portfolios/{id}/holdingsPostgres onlysame join, no cache
GET /portfolios/{id}/distributionPostgres onlynepse_portfolio_holdings + nepse_live_prices
GET /portfolios/{id}/valuationRedis (10min TTL per range)nepse_portfolio_trades + nepse_portfolio_corporate_actions + nepse_price_history (bulk)
GET /portfolios/{id}/companies/{symbol}Postgres onlyfull company detail with lots + timeline
GET /portfolios/{id}/tradesPostgres onlynepse_portfolio_trades
GET /portfolios/{id}/companies/{symbol}/dividendsPostgres onlynepse_portfolio_dividends
GET /portfolios/{id}/companies/{symbol}/actionsPostgres onlynepse_portfolio_corporate_actions

Only two read endpoints are Redis-backed: detail (and its summary derivative) and valuation. Lists and audit-trail reads go straight to Postgres because their cardinality varies wildly per portfolio and they're inherently cheap.

Today-only trades for day P&L

FindDetail, FindHoldings, and FindCompanyDetail all need today's intra-day trades to compute dayPnl. They issue a single FindPortfolioTradesInRange against the NPT day boundary:

todayStart := timezone.StartOfNPTDay(time.Now())
qtx.FindPortfolioTradesInRange(ctx, sqlc.FindPortfolioTradesInRangeParams{
    PortfolioID: portfolioID,
    TradedAt:    pgtype.Timestamptz{Time: todayStart, Valid: true},
    TradedAt_2:  pgtype.Timestamptz{Time: todayStart.AddDate(0, 0, 1), Valid: true},
})

The result is grouped per company and threaded into holdings.DayPnL(row, todayTrades). See Holdings engine.

Valuation series

The valuation endpoint walks all of a portfolio's events chronologically against a bulk fetch of daily prices for every involved company in one SQL pass (FindBulkPriceHistoryForPortfolio with ANY($1::uuid[])). It produces a [{d, v, cb}] time-series per trading day in the requested range.

Algorithmic notes are on the Holdings engine page.

What the user can never trigger

  • A torn cache. Cache busts run after commit; if commit fails, no DEL fires.
  • A foreign-portfolio mutation. Ownership is in the WHERE clause, not in application code.
  • A leaked customer ID. The detail cache key is portfolio:detail:<portfolioID> — owner identity is not in the key. The ownership guard runs before any cache hit can be returned.
  • A stale lot tree. Every relevant write triggers Rebuild on the affected (portfolio, company) pair inside the same tx.

References

  • TxRunner: internal/modules/portfolio/shared/cache.go
  • FIFO replay: internal/modules/portfolio/holdings/replay.go
  • Cache keys: internal/platform/cache/keys.go
  • Time helpers: internal/shared/timezone