Portfolio Overview
Authenticated CRUD for user portfolios, trades, dividends, and corporate actions, with a FIFO holdings engine that recomputes positions on every write
The Bullhouse NEPSE API exposes a fully authenticated Portfolio surface at /api/v1/portfolios/*. Customers create portfolios, log trades, record dividends and corporate actions, and the server recomputes their FIFO lots, holdings, and valuation series on every write — delivering live P&L, cost basis, and sector distribution back to the client without any client-side bookkeeping.
Every endpoint in this module is JWT-authenticated. The customer ID is taken from the token; never from the request body. No customer ever sees another customer's portfolio.
What this module owns
- 23 HTTP endpoints across 5 Swagger tags — see Reference.
- Portfolio aggregate — a portfolio is a tree: the portfolio row, its trades, its corporate actions, its dividends, and the derived projection of holdings + FIFO lots + lot consumptions.
- FIFO holdings engine —
holdings.Rebuild(ctx, qtx, portfolioID, companyID)deletes the existing lots/consumptions and re-derives the entire position from the source events (trades + actions). This runs inside every trade/action mutation. - Valuation event-replay —
core.buildValuationwalks all trades + actions chronologically against bulk daily prices to produce a[{d, v, cb}]time-series for charting, parameterised by range (1M,3M,6M,1Y,YTD,ALL). - Day P&L — splits intraday gains across overnight-held units, intraday-bought units, and intraday-sold units (
holdings.DayPnL). - Cache invalidation pipeline —
shared.TxRunner.Run(ctx, portfolioID, scope, fn)wrapspool.Begin → fn(qtx) → Commit, then busts the appropriate cache keys post-commit. Forgetting cache invalidation requires explicitly passingCacheScopeNone. - Trade fee model — broker rate brackets, SEBON fee, DP charge, CGT (capital gains tax) all auto-computed against the user's submitted total; mismatches raise a 400.
- Trading-day warnings — back-dated trades on non-trading days produce a soft warning rather than a hard reject.
What this module does not own
- Live market prices — those live in
nepse_live_prices/nepse_intraday_pricesand are populated by the scheduler/poller. Portfolio reads them but never writes them. - The customer record —
nepse_customersis owned by auth. - Company master —
nepse_companiesis owned by the NEPSE catalog module. - Watchlists — separate module with its own surface.
- Index / sub-index level data — only stocks (
status = 'A') participate in portfolios.
Endpoint summary
| Tag | Count | Endpoints |
|---|---|---|
| Portfolio | 7 | Create, List, Get, GetSummary, GetValuation, Update, Delete |
| Portfolio Companies | 3 | ListHoldings, GetDistribution, GetCompanyDetail |
| Portfolio Trades | 4 | AddTrade, ListTrades, UpdateTrade, DeleteTrade |
| Portfolio Dividends | 5 | AddDividend, ListCompanyDividends, ListPortfolioDividends, UpdateDividend, DeleteDividend |
| Portfolio Corporate Actions | 4 | AddCorporateAction, ListCorporateActions, UpdateCorporateAction, DeleteCorporateAction |
Total: 23 endpoints. Full path table in Reference.
Sub-package layout
The module is decomposed into vertical slices, one per Swagger tag, plus two cross-cutting infrastructure packages:
internal/modules/portfolio/
├── portfolio.go # Module + NewModule + RegisterRoutes
├── shared/ # plumbing: TxRunner, Invalidator, FindPortfolio guard, parsers
├── holdings/ # domain: Rebuild (FIFO replay) + DayPnL + HoldingResponse
├── core/ # tag: Portfolio (CRUD + Summary + Valuation)
├── companies/ # tag: Portfolio Companies (read-side)
├── trades/ # tag: Portfolio Trades
├── dividends/ # tag: Portfolio Dividends
├── actions/ # tag: Portfolio Corporate Actions
└── smoke_db_test.go # integration test against NewModule8 packages exactly. Each tag-named package has its own store.go, types.go, mappers.go, service.go, handler.go. See Architecture.
Data model
nepse_portfolio_trades,nepse_portfolio_dividends,nepse_portfolio_corporate_actionsare source-of-truth event tables (CRUD by the user).nepse_portfolio_holdings,nepse_portfolio_lots,nepse_portfolio_lot_consumptionsare a projection maintained byholdings.Rebuild. They are deleted and re-derived end-to-end on every relevant write.
Soft-delete columns (deleted_at) on the source tables; hard delete + re-create on the projection tables.
Quick start
BASE="https://api.ranjanyadav.com.np/api/v1"
JWT="..."
auth="-H Authorization: Bearer $JWT"
# Create the portfolio (first one becomes default)
PID=$(curl -s -X POST $auth -H "Content-Type: application/json" \
-d '{"name":"Long-term"}' \
"$BASE/portfolios" | jq -r '.data.id')
# Look up the NABIL company id (from the catalog)
CID=$(curl -s "$BASE/nepse/companies/NABIL" | jq -r '.data.id')
# Buy 100 NABIL @ 1250 — let the server compute fees from the user-supplied total
curl -s -X POST $auth -H "Content-Type: application/json" \
-d "{
\"companyId\":\"$CID\",
\"type\":\"BUY\",
\"quantity\":100,
\"pricePerUnit\":1250.00,
\"totalAmount\":125461.19,
\"tradedAt\":\"2026-03-15T00:00:00.000Z\"
}" \
"$BASE/portfolios/$PID/trades"# Top-level summary numbers — derives from FindDetail
curl -s $auth "$BASE/portfolios/$PID/summary" | jq .
# Bundled dashboard payload — summary, distribution, positions[]
curl -s $auth "$BASE/portfolios/$PID" | jq .data.positions
# Paginated, filterable, sortable holdings table (the dashboard table source)
curl -s $auth "$BASE/portfolios/$PID/holdings?q=bank&sort=dayPnl&order=desc&page=1&size=20" | jq .
# Sector distribution (by invested + by current value)
curl -s $auth "$BASE/portfolios/$PID/distribution" | jq .
# One-company drill-down — live price, FIFO lots, full timeline
curl -s $auth "$BASE/portfolios/$PID/companies/NABIL?lots=true" | jq .# Time-series of value + cost basis. range ∈ {1M,3M,6M,1Y,YTD,ALL}, default 3M.
curl -s $auth "$BASE/portfolios/$PID/valuation?range=1Y" | jq '.data.series | length'Each entry is { d: "YYYY-MM-DD", v: <market_value>, cb: <cost_basis> } for one trading day.
NEPSE-specific decisions
NEPSE trades Monday–Friday, 11:00–15:00 NPT. The server hand-keys exchange holidays into nepse_trading_days and uses them for:
- Settlement-date computation on SELL trades (
FindSettlementDate). - Soft-warning back-dated trades whose
tradedAtis not a trading day (tradedAtWarnings).
No automatic holiday detection. If the date table is missing future entries, settlement falls back gracefully and a 422 surfaces only on SELL.
SELL trades carry a computed settlesOn date (T+2 trading days). Used downstream for receivable balances. BUY trades don't compute settlement.
Five components: broker commission (rate × gross or fixed), SEBON fee (0.015% of gross), DP charge (NPR 25, only on SELL), and CGT (only on SELL). The user can override any component; missing fields are auto-computed against current NEPSE bracket tables. The server then validates the user-submitted totalAmount against the computed total — mismatch > NPR 1 → 400.
On a SELL the server computes a cgtComputed from the WACC of the open lots being sold. If the user-supplied cgtAmount differs by more than NPR 1, cgtMismatch: true flags it on the response — informational, not blocking.
WACC over the open-lot residue. Each lot tracks (remaining_qty, cost_per_unit, source) where source ∈ BUY, BONUS, RIGHT_SUBSCRIBED, IPO, FPO, AUCTION. SELLs consume oldest-first (FIFO) producing rows in nepse_portfolio_lot_consumptions. The reported WACC is Σ(remaining_qty × cost_per_unit) / Σ(remaining_qty) over open lots only — sold cost is excluded.
Where to go next
- Architecture — sub-package layout, request lifecycle, middleware stack.
- Data flow — write path, replay, cache invalidation.
- Holdings engine — deep dive into FIFO replay, day P&L, and valuation event-replay.
- Reference — every endpoint at a glance.