Portfolio Companies
ListHoldings, GetDistribution, and GetCompanyDetail — the read-side per-company surface
Three endpoints under the Portfolio Companies Swagger tag drive the holdings table, the sector pie chart, and the per-company drill-down. The companies/ package is fully read-only — no TxRunner.
| Method | Path | Service method |
|---|---|---|
GET | /portfolios/{id}/holdings | companies.Service.FindHoldings |
GET | /portfolios/{id}/distribution | companies.Service.FindDistribution |
GET | /portfolios/{id}/companies/{symbol} | companies.Service.FindCompanyDetail |
GET /portfolios/{id}/holdings — ListHoldings
Paginated, filterable, and sortable list of holdings — what the dashboard's stock table consumes.
Query params
| Param | Default | Notes |
|---|---|---|
page | 1 | 1-indexed |
size | 20 | max 100 |
q | "" | case-insensitive substring; matches against sym or name. Empty → no filter. |
sort | "" (SQL order) | one of: sym, name, sec, ltp, pch, cUnits, totalInv, wacc, curVal, curInv, unrealPnl, dayPnl, holdingPct. Unknown values return 400. |
order | asc | asc or desc. Anything other than desc coerces to asc. |
includeSuspended | false | when true, include status != 'A' rows |
Success (200)
{
"message": "Portfolio holdings fetched successfully",
"data": [
{
"sym": "NABIL",
"name": "Nabil Bank Limited",
"sec": "Commercial Banks",
"icon": "https://.../NABIL.webp",
"ltp": 910.0, "ch": 12.5, "pch": 1.39,
"cUnits": 100, "sUnits": 30,
"totalInv": 50461.19, "wacc": 504.61,
"soldVal": 18750.0, "div": 1500.0,
"curVal": 91000.0, "curInv": 50461.19,
"unrealPnl": 40538.81, "realPnl": 1234.56,
"totalPnl": 43273.37, "totalPnlPct": 85.75,
"dayPnl": 1250.0,
"recv": 5801.34,
"holdingPct": 100.0,
"52wPos": 67.4,
"suspended": false
}
],
"meta": { "total": 1, "page": 1, "size": 20 }
}Behaviour
- Reads
nepse_portfolio_holdingsjoined withnepse_live_pricesand the company catalog in one query (FindHoldingsWithPricesByPortfolio). - Today's trades fetched separately for day P&L computation (
FindPortfolioTradesInRange). - Builds
[]HoldingResponseviaholdingsFromRows, then in this order:- drop suspended rows when
includeSuspended=false - apply
qsubstring match againstsymorname(case-insensitive) - stable sort by
sortfield (asc or desc) - slice the requested
page/sizewindow
- drop suspended rows when
meta.totalreflects the filter result (post-q, post-suspended), not the raw row count.- Sort is stable. Ties keep SQL order, which is itself stable across calls.
- Not cached. Every call hits Postgres. The detail-cache hit rate already covers the dashboard headline; this endpoint is for users actively browsing.
Suspended filtering
includeSuspended | Behaviour |
|---|---|
false (default) | drops suspended: true rows. meta.total reflects only active rows. |
true | includes everything. holdingPct for suspended rows is 0 because they don't contribute to activeCurVal. |
Errors
| Status | Cause |
|---|---|
| 400 | bad id, bad page, bad size, unknown sort field |
| 401 | missing / invalid JWT |
| 404 | not yours, or doesn't exist |
cURL
# First page, exclude suspended
curl -H "Authorization: Bearer $JWT" "$BASE/portfolios/$PID/holdings"
# Include suspended for audit
curl -H "Authorization: Bearer $JWT" "$BASE/portfolios/$PID/holdings?includeSuspended=true"
# Search "bank" and sort by today's P&L descending
curl -H "Authorization: Bearer $JWT" \
"$BASE/portfolios/$PID/holdings?q=bank&sort=dayPnl&order=desc"
# Top 10 positions by current value
curl -H "Authorization: Bearer $JWT" \
"$BASE/portfolios/$PID/holdings?sort=curVal&order=desc&size=10"GET /portfolios/{id}/distribution — GetDistribution
Two sector breakdowns: by current investment (open-lot cost) and by current value (LTP × units).
Success (200)
{
"message": "Portfolio distribution fetched successfully",
"data": {
"byInvested": [
{ "sec": "Commercial Banks", "v": 50461.19, "pct": 49.7 },
{ "sec": "Hydropower", "v": 30000.00, "pct": 29.5 },
{ "sec": "Unknown", "v": 21000.00, "pct": 20.7 }
],
"byValue": [
{ "sec": "Commercial Banks", "v": 91000.00, "pct": 71.6 },
{ "sec": "Hydropower", "v": 25000.00, "pct": 19.7 },
{ "sec": "Unknown", "v": 11000.00, "pct": 8.6 }
]
}
}Behaviour
- Single SQL fetch (
FindHoldingsWithPricesByPortfolio), thenbuildDistributionaggregates in-memory. - Excluded rows:
status != 'A',currentUnits == 0. Suspended and fully-sold positions don't appear. - Sector grouping:
sectorNamefromnepse_sectors. NULL →"Unknown". - Sort: both arrays sorted by
value DESC. The two arrays don't necessarily have the same order — a sector with high cost but low LTP slides down inbyValueonly. - Percentages are rounded to 2 decimal places via the
pgconv.DecimalToFloathelper. - Not cached. Distribution is a O(active_holdings) compute, ~ms range.
Errors
| Status | Cause |
|---|---|
| 400 | bad id |
| 401 | missing / invalid JWT |
| 404 | not yours, or doesn't exist |
cURL
curl -H "Authorization: Bearer $JWT" "$BASE/portfolios/$PID/distribution"GET /portfolios/{id}/companies/{symbol} — GetCompanyDetail
Full drill-down for one company in one portfolio. Used by the per-stock page in the dashboard.
Path params
| Param | Notes |
|---|---|
id | Portfolio UUID |
symbol | Company ticker (case-sensitive) |
Query params
| Param | Default | Notes |
|---|---|---|
lots | false | when true, include open FIFO lot list |
Success (200)
{
"message": "Portfolio company fetched successfully",
"data": {
"company": {
"sym": "NABIL",
"name": "Nabil Bank Limited",
"sec": "Commercial Banks",
"icon": "https://.../NABIL.webp",
"ltp": 910.0, "ch": 12.5, "pch": 1.39,
"prevClose": 897.5,
"52h": 1200.0, "52l": 600.0, "52wPos": 51.7,
"suspended": false
},
"holding": {
"cUnits": 100, "sUnits": 30,
"wacc": 504.61,
"waccBreakdown": {
"boughtCost": 50000.0, "boughtQty": 100,
"bonusQty": 0,
"rightSubscribedCost": 0.0, "rightSubscribedQty": 0,
"ipoCost": 0.0, "ipoQty": 0,
"fpoCost": 0.0, "fpoQty": 0,
"auctionCost": 0.0, "auctionQty": 0,
"totalCost": 50461.19, "totalQty": 100, "wacc": 504.61
},
"totalInv": 50461.19, "curInv": 50461.19,
"curVal": 91000.0, "soldVal": 18750.0,
"div": 1500.0,
"realPnl": 1234.56, "unrealPnl": 40538.81,
"totalPnl": 43273.37, "totalPnlPct": 85.75,
"dayPnl": 1250.0,
"recv": 5801.34
},
"fifoLots": [
{
"id": "...",
"acquiredAt": "2026-03-15T00:00:00.000Z",
"qty": 100,
"costPerUnit": 504.61,
"type": "BUY"
}
],
"timeline": [
{ "kind": "TRADE", "id": "...", "type": "BUY", "qty": 100, "price": 1250, "totalAmount": 125461.19, "at": "2026-03-15T00:00:00.000Z" },
{ "kind": "DIVIDEND", "id": "...", "amount": 1500, "at": "2026-04-01" },
{ "kind": "ACTION", "id": "...", "type": "BONUS", "qty": 30, "ratio": "10:3", "at": "2026-04-15T00:00:00.000Z" }
]
}
}Behaviour
- Six SQL calls per request, all read-only:
shared.FindPortfolio(ownership)FindPortfolioCompanyBySymbol— resolves the symbol to a company UUIDFindHoldingWithPriceByCompany— the holdings row joined with live price for this companyFindOpenLotsByCompany— for the WACC breakdown andfifoLots[]FindPortfolioTradesForReplay— for the timeline (these are not soft-deleted)FindCorporateActionsForReplay+FindDividendsForReplay— timeline eventsFindPortfolioTradesInRange— today's trades, fordayPnl
- Composes the response via
companyDetailToDTO(the most complex mapper in the module — seecompanies/mappers.go). - Not cached. Cardinality is high (per portfolio × per stock) and individual response size is small.
lots: true vs lots: false
When lots=true, the open FIFO lot list is included in fifoLots[]. Useful for tax-prep views. When false, the array is omitted (saving payload size for the dashboard).
The holding.waccBreakdown always populates regardless of lots — its source is the same FindOpenLotsByCompany query, which always runs.
Timeline ordering
Events are sorted by at (effective date), then by createdAt, then by id — same total order as holdings.Rebuild. Two events on the same day appear in deterministic order.
Errors
| Status | Cause |
|---|---|
| 400 | bad id UUID, empty symbol |
| 401 | missing / invalid JWT |
| 404 | portfolio not yours, company not in catalog, holding doesn't exist for this (portfolio, company) pair |
cURL
# Compact dashboard view
curl -H "Authorization: Bearer $JWT" \
"$BASE/portfolios/$PID/companies/NABIL"
# Full FIFO lots for tax view
curl -H "Authorization: Bearer $JWT" \
"$BASE/portfolios/$PID/companies/NABIL?lots=true"Implementation
- Handler:
internal/modules/portfolio/companies/handler.go→ListHoldings,GetDistribution,GetCompanyDetail - Service:
internal/modules/portfolio/companies/service.go - Distribution math:
internal/modules/portfolio/companies/distribution.go→buildDistribution - Mappers:
internal/modules/portfolio/companies/mappers.go→companyDetailToDTO,holdingsFromRows,companySnapshotToDTO,companyHoldingToDTO,waccBreakdownFromLots,fifoLotsToDTO,portfolioTimelineToDTO,filterTradesForCompany,ratioString - DTOs:
internal/modules/portfolio/companies/types.go