Shop It Docs
Portfolio Module

Summary & Valuation

GetSummary returns the aggregated headline numbers; GetValuation returns the value/cost-basis time-series for charting

Two read endpoints under the Portfolio Swagger tag drive the dashboard headline and the chart respectively.

MethodPathService method
GET/portfolios/{id}/summarycore.Service.FindSummary
GET/portfolios/{id}/valuationcore.Service.FindValuation

GET /portfolios/{id}/summary

Returns just the summary block from the full detail call. Cheap for clients that only need headline numbers.

Success (200)

{
  "message": "Portfolio summary fetched successfully",
  "data": {
    "cUnits": 70, "sUnits": 30,
    "totalInv": 125461.19, "curInv": 87822.83,
    "curVal": 91000.00, "soldVal": 18750.00, "div": 2000.00,
    "unrealPnl": 3177.17, "realPnl": 1234.56,
    "totalPnl": 6411.73, "totalPnlPct": 5.11,
    "dayPnl": 350.00, "dayPnlPct": 0.39,
    "recv": 5801.34
  }
}

Field semantics

FieldMeaning
cUnitsSum of currently-held units across all active holdings.
sUnitsSum of historically sold units.
totalInvSum of total_invested — original cost contributed (excludes proceeds-on-sell reduction).
curInvSum of total_cost_current — open-lot residual cost. Decreases with each SELL.
curValΣ LTP × currentUnits over active holdings.
soldValSum of gross sell proceeds (qty × price).
divSum of net dividends received.
unrealPnlcurVal - curInv.
realPnlSum of realized_pnl_display — display realised P&L (cost basis matched, excludes CGT).
totalPnlunrealPnl + realPnl + div.
totalPnlPcttotalPnl / totalInv × 100 — null if totalInv is 0.
dayPnlSum of per-company holdings.DayPnL. See Holdings engine.
dayPnlPctdayPnl / (curVal - dayPnl) × 100 — relative to yesterday's closing value. Null if denominator is 0.
recvSum of receivable — the T+2 settlement balance from sells not yet settled.

Suspended holdings (status != 'A') are excluded from summary. They show up only in suspendedSummary on the full detail call. This keeps headline P&L clean even if a portfolio holds a delisted ticker.

Behaviour

  • Internally calls FindDetail and returns just .summary. Same cache key, same TTL, same ownership guard. So calling summary and then Get within 15s is two cache reads, not two DB hits.
  • 404 on missing or not-yours.

cURL

curl -H "Authorization: Bearer $JWT" \
  "$BASE/portfolios/$PID/summary"

GET /portfolios/{id}/valuation

Returns a time-series of (value, cost_basis) per trading day for charting.

Query params

ParamRequiredDefaultNotes
range3MOne of 1M, 3M, 6M, 1Y, YTD, ALL

Range maps to a start date via valuationRangeStart. The upper bound is always time.Now() captured once at request start.

Success (200)

{
  "message": "Portfolio valuation fetched successfully",
  "data": {
    "range": "3M",
    "series": [
      { "d": "2026-01-27", "v": 50250.0, "cb": 50000.0 },
      { "d": "2026-01-28", "v": 50500.0, "cb": 50000.0 },
      ...
      { "d": "2026-04-25", "v": 91000.0, "cb": 87822.83 }
    ]
  }
}

Empty portfolio → {range, series: []} (200, not 404).

Algorithm

The full event-replay pipeline is documented on the Holdings engine page. Quick summary:

  1. Pull every trade and corporate action for the portfolio (no range filter — events outside the window still affect state).
  2. Bulk fetch daily prices for every involved company in one query: FindBulkPriceHistoryForPortfolio with ANY($1::uuid[]). The bound is [range_start, now].
  3. Walk events chronologically against trading dates in the price set. For each date, value the portfolio at that day's LTP and emit {d, v, cb}.
  4. Cache as portfolio:valuation:<id>:<RANGE>.

Behaviour

  • Cache TTL: 10 minutes. Stale-but-bounded; busted by trades/actions/delete.
  • 6 cache keys per portfolio: one per range. A write op that changes valuation busts all 6 in Invalidator.Apply.
  • now captured once before the price query and threaded through buildValuation to keep the upper bound consistent across midnight boundaries.
  • Silent empty series — if there are trades but no daily price rows in range (typically a never-listed company or a sparse company), the response is {range, series: []} with a slog.Warn. The endpoint never errors on missing market data.

Errors

StatusCause
400range not in whitelist
401missing / invalid JWT
404not yours, or doesn't exist
500DB read failed (any of the three queries)

cURL

# Default 3M
curl -H "Authorization: Bearer $JWT" "$BASE/portfolios/$PID/valuation"

# Year-to-date
curl -H "Authorization: Bearer $JWT" "$BASE/portfolios/$PID/valuation?range=YTD"

# All time (since 2000-01-01)
curl -H "Authorization: Bearer $JWT" "$BASE/portfolios/$PID/valuation?range=ALL" | jq '.data.series | length'

Range start dates

RangeStart (relative to now)
1Mnow - 1 month
3Mnow - 3 months
6Mnow - 6 months
1Ynow - 1 year
YTDJanuary 1 of now.Year() (NPT calendar)
ALL2000-01-01

Edge cases

  • Trade outside range — still applies. The state walks from the portfolio's first event; only emitted bars are bounded.
  • No trades at all — empty series, no warn.
  • One stock that has never traded post-listing — the daily price table is empty for that company; series may be empty. Warn logged.
  • Ranges crossing portfolio creation date — for ranges that start before the first event, the series begins empty (units=0) until the first event lands. cb and v are both 0 on those leading days.

Worked example

Day 1 (1 month ago): BUY 100 NABIL @ 500 (totalAmount = 50000)
Day 2 ... Day 30:    no events; daily price drifts 500 → 600
Day 30: query `GET /portfolios/{id}/valuation?range=1M`

series ≈ [
  { d: day-1,  v: 50000, cb: 50000 },   // bought today, LTP=500
  { d: day-2,  v: 50500, cb: 50000 },   // LTP=505
  ...
  { d: day-30, v: 60000, cb: 50000 },   // LTP=600
]

v tracks market value; cb only changes when a BUY/SELL/RIGHT/IPO/etc. is applied.

Implementation

  • Handler: internal/modules/portfolio/core/handler.goGetSummary, GetValuation
  • Service: internal/modules/portfolio/core/service.goFindSummary, FindValuation
  • Algorithm: internal/modules/portfolio/core/valuation.gobuildValuation, valuationRangeStart
  • Cache keys: internal/platform/cache/keys.goPortfolioValuationKey, TTLPortfolioValuation
  • sqlc queries: internal/platform/database/queries/portfolios.sql