Shop It Docs
Portfolio Module

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 engineholdings.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-replaycore.buildValuation walks 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 pipelineshared.TxRunner.Run(ctx, portfolioID, scope, fn) wraps pool.Begin → fn(qtx) → Commit, then busts the appropriate cache keys post-commit. Forgetting cache invalidation requires explicitly passing CacheScopeNone.
  • 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_prices and are populated by the scheduler/poller. Portfolio reads them but never writes them.
  • The customer record — nepse_customers is owned by auth.
  • Company master — nepse_companies is 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

TagCountEndpoints
Portfolio7Create, List, Get, GetSummary, GetValuation, Update, Delete
Portfolio Companies3ListHoldings, GetDistribution, GetCompanyDetail
Portfolio Trades4AddTrade, ListTrades, UpdateTrade, DeleteTrade
Portfolio Dividends5AddDividend, ListCompanyDividends, ListPortfolioDividends, UpdateDividend, DeleteDividend
Portfolio Corporate Actions4AddCorporateAction, 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 NewModule

8 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_actions are source-of-truth event tables (CRUD by the user).
  • nepse_portfolio_holdings, nepse_portfolio_lots, nepse_portfolio_lot_consumptions are a projection maintained by holdings.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 tradedAt is 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.