Shop It Docs
Portfolio Module

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.

MethodPathService method
GET/portfolios/{id}/holdingscompanies.Service.FindHoldings
GET/portfolios/{id}/distributioncompanies.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

ParamDefaultNotes
page11-indexed
size20max 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.
orderascasc or desc. Anything other than desc coerces to asc.
includeSuspendedfalsewhen 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_holdings joined with nepse_live_prices and the company catalog in one query (FindHoldingsWithPricesByPortfolio).
  • Today's trades fetched separately for day P&L computation (FindPortfolioTradesInRange).
  • Builds []HoldingResponse via holdingsFromRows, then in this order:
    1. drop suspended rows when includeSuspended=false
    2. apply q substring match against sym or name (case-insensitive)
    3. stable sort by sort field (asc or desc)
    4. slice the requested page/size window
  • meta.total reflects 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

includeSuspendedBehaviour
false (default)drops suspended: true rows. meta.total reflects only active rows.
trueincludes everything. holdingPct for suspended rows is 0 because they don't contribute to activeCurVal.

Errors

StatusCause
400bad id, bad page, bad size, unknown sort field
401missing / invalid JWT
404not 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), then buildDistribution aggregates in-memory.
  • Excluded rows: status != 'A', currentUnits == 0. Suspended and fully-sold positions don't appear.
  • Sector grouping: sectorName from nepse_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 in byValue only.
  • Percentages are rounded to 2 decimal places via the pgconv.DecimalToFloat helper.
  • Not cached. Distribution is a O(active_holdings) compute, ~ms range.

Errors

StatusCause
400bad id
401missing / invalid JWT
404not 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

ParamNotes
idPortfolio UUID
symbolCompany ticker (case-sensitive)

Query params

ParamDefaultNotes
lotsfalsewhen 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:
    1. shared.FindPortfolio (ownership)
    2. FindPortfolioCompanyBySymbol — resolves the symbol to a company UUID
    3. FindHoldingWithPriceByCompany — the holdings row joined with live price for this company
    4. FindOpenLotsByCompany — for the WACC breakdown and fifoLots[]
    5. FindPortfolioTradesForReplay — for the timeline (these are not soft-deleted)
    6. FindCorporateActionsForReplay + FindDividendsForReplay — timeline events
    7. FindPortfolioTradesInRange — today's trades, for dayPnl
  • Composes the response via companyDetailToDTO (the most complex mapper in the module — see companies/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

StatusCause
400bad id UUID, empty symbol
401missing / invalid JWT
404portfolio 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.goListHoldings, GetDistribution, GetCompanyDetail
  • Service: internal/modules/portfolio/companies/service.go
  • Distribution math: internal/modules/portfolio/companies/distribution.gobuildDistribution
  • Mappers: internal/modules/portfolio/companies/mappers.gocompanyDetailToDTO, holdingsFromRows, companySnapshotToDTO, companyHoldingToDTO, waccBreakdownFromLots, fifoLotsToDTO, portfolioTimelineToDTO, filterTradesForCompany, ratioString
  • DTOs: internal/modules/portfolio/companies/types.go