Shop It Docs
Portfolio Module

Trades

Add, list, update, and delete BUY/SELL trades — fee math, settlement dates, CGT mismatch detection, and trading-day warnings

Four endpoints under the Portfolio Trades Swagger tag manage the trade ledger. Every write triggers holdings.Rebuild for the affected (portfolio, company) pair and busts both detail + valuation caches.

MethodPathService method
POST/portfolios/{id}/tradestrades.Service.AddTrade
GET/portfolios/{id}/tradestrades.Service.FindTrades
PATCH/portfolios/{id}/trades/{tradeId}trades.Service.UpdateTrade
DELETE/portfolios/{id}/trades/{tradeId}trades.Service.DeleteTrade

POST /portfolios/{id}/trades — AddTrade

Records a BUY or SELL.

Request body

type AddTradeRequest = {
  companyId:               string;     // UUID, required
  type:                    "BUY" | "SELL";
  quantity:                number;     // int ≥ 1
  pricePerUnit:            number;     // > 0
  totalAmount:             number;     // ≥ 0
  tradedAt:                string;     // ISO 8601, required

  brokerCommissionRate?:   number;     // optional override
  brokerCommissionAmount?: number;     // optional override
  sebonFee?:               number;
  dpCharge?:               number;
  cgtAmount?:              number;     // SELL only
  notes?:                  string;     // max 500
};

Validation

  1. type must be BUY or SELL.
  2. cgtAmount is rejected on BUY (cgtAmount is only valid on SELL trades).
  3. tradedAt must parse as ISO 8601, ≤ now + 1 minute (no future-dating).
  4. The server computes a synthetic totalAmount from pricePerUnit × quantity ± fees. If the user-submitted totalAmount differs by more than NPR 1, returns 400 totalAmount mismatch: expected X.XX.
  5. (SELL only) holdings.CurrentUnitsForHolding runs before the insert; if quantity > current_units, returns 422 SELL quantity exceeds current holding (N units).

Fee model

Five components computed in completeTradeFees:

ComponentDefault formulaUser override field
Broker commission ratetier-based on gross (see below)brokerCommissionRate
Broker commission amountgross × rate, rounded to 2dpbrokerCommissionAmount
SEBON feegross × 0.00015sebonFee
DP charge25 on SELL, 0 on BUYdpCharge
CGT(SELL only) cgtComputed from open-lot WACC; user supplies cgtAmount for the actual paid amountcgtAmount

Broker rate brackets (NEPSE 2025 schedule):

Gross (NPR)Rate
≤ 50,0000.40%
≤ 500,0000.37%
≤ 2,000,0000.34%
≤ 10,000,0000.30%
> 10,000,0000.27%

computedTradeTotal:

gross = price × qty
fees  = brokerAmount + sebonFee + dpCharge + (cgtAmount on SELL)
BUY:  total = gross + fees
SELL: total = gross - fees

Success (200)

{
  "message": "Trade added successfully",
  "data": {
    "id": "...",
    "portfolioId": "...",
    "sym": "NABIL",
    "type": "BUY",
    "quantity": 100,
    "pricePerUnit": 1250.00,
    "brokerCommissionRate": 0.0037,
    "brokerCommissionAmount": 462.50,
    "sebonFee": 18.75,
    "dpCharge": 0,
    "totalAmount": 125481.25,
    "totalAmountComputed": 125481.25,
    "tradedAt": "2026-03-15T00:00:00.000Z",
    "settlesOn": null,
    "warnings": [],
    "createdAt": "...",
    "updatedAt": "..."
  }
}

For SELLs, additional fields populate:

FieldNotes
settlesOnT+2 trading-day date from FindSettlementDate. May be null if trading days table is missing entries.
cgtComputedserver-computed CGT from open-lot WACC at sell time.
cgtMismatchtrue if |cgtAmount - cgtComputed| > 1. Informational, not blocking.
costBasisAtSellthe WACC-weighted cost basis attributed to the units sold.

Trading-day warning

If tradedAt isn't a NEPSE trading day (Mon–Fri, in nepse_trading_days), the response carries warnings: ["traded_at <date> is not a NEPSE trading day"]. The trade is still accepted — back-dating around exchange holidays happens, and the user-facing warning is correct UX.

Cache scope

CacheScopeDetailAndValuation — busts portfolio:detail:<id> + 6 valuation range keys.

Errors

StatusCause
400type invalid, cgt on BUY, totalAmount mismatch, tradedAt malformed/future, body invalid
401JWT missing/invalid
404portfolio not yours / not found
422SELL quantity exceeds current holding
500DB error

cURL

curl -X POST -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
  -d '{
    "companyId":"...",
    "type":"BUY",
    "quantity":100,
    "pricePerUnit":1250.00,
    "totalAmount":125481.25,
    "tradedAt":"2026-03-15T00:00:00.000Z"
  }' \
  "$BASE/portfolios/$PID/trades"
curl -X POST -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
  -d '{
    "companyId":"...",
    "type":"SELL",
    "quantity":40,
    "pricePerUnit":1500.00,
    "cgtAmount":1000.00,
    "totalAmount":59000.00,
    "tradedAt":"2026-04-25T00:00:00.000Z"
  }' \
  "$BASE/portfolios/$PID/trades"

GET /portfolios/{id}/trades — ListTrades

Paginated trade ledger, filterable by type and date range.

Query params

ParamDefaultNotes
page1
size20max 100
type(all)BUY or SELL
from(none)YYYY-MM-DD inclusive lower bound on traded_at
to(none)YYYY-MM-DD inclusive upper bound on traded_at (server adds +1 day for inclusive semantics)

Success (200)

{
  "message": "Trades fetched successfully",
  "data": [ /* TradeResponse... */ ],
  "meta": { "total": 17, "page": 1, "size": 20 }
}

Same TradeResponse shape as AddTrade. CGT-related fields populate for SELLs from the persisted audit columns.

Behaviour

  • parseTradeFilter normalises optional query params to a TradeFilter struct.
  • FindTradesByPortfolio does the SQL filter; CountTradesByPortfolio runs in parallel for total.
  • Soft-deleted rows (deleted_at IS NOT NULL) are excluded.
  • Not cached. Trade list is for audit-trail browsing; cardinality varies wildly.

Errors

StatusCause
400bad id, bad pagination, malformed from/to, invalid type
401JWT missing/invalid
404not yours / not found

PATCH /portfolios/{id}/trades/{tradeId} — UpdateTrade

Partial update. Any field omitted preserves the existing value. Internally re-runs the FIFO replay.

Request body

type UpdateTradeRequest = {
  companyId?:              string;
  type?:                   "BUY" | "SELL";
  quantity?:               number;
  pricePerUnit?:           number;
  totalAmount?:            number;
  tradedAt?:               string;
  brokerCommissionRate?:   number;
  brokerCommissionAmount?: number;
  sebonFee?:               number;
  dpCharge?:               number;
  cgtAmount?:              number;
  notes?:                  string;
};

Behaviour

  1. Ownership guard.
  2. FindTradeForUpdate — locks the row.
  3. mergeTradeUpdate overlays the partial request onto the existing row.
  4. Same fee + total validation as AddTrade (using the merged values).
  5. (If type changed BUY↔SELL or company changed) the old company's holdings are rebuilt too — Rebuild(old_companyID) runs in addition to Rebuild(new_companyID).
  6. UpdateTrade SQL.
  7. Rebuild for the affected (portfolio, company) pair(s).
  8. UpdateTradeAudit to refresh cgtComputed, costBasisAtSell, totalAmountComputed, settlesOn.
  9. Commit + cache bust.

Cache scope

CacheScopeDetailAndValuation.

Errors

Same as AddTrade plus 404 if the trade ID isn't in this portfolio.

cURL

# Correct a typo in the BUY price; re-derives WACC across all later events
curl -X PATCH -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
  -d '{"pricePerUnit":1245.00,"totalAmount":124981.25}' \
  "$BASE/portfolios/$PID/trades/$TID"

DELETE /portfolios/{id}/trades/{tradeId} — DeleteTrade

Soft-deletes the trade and rebuilds the affected company's holdings.

Behaviour

  1. Ownership guard + FindTradeForUpdate to capture companyID.
  2. SoftDeleteTrade — sets deleted_at = now().
  3. Rebuild(portfolio, companyID) — fully re-derives the lot tree without this trade. If the deleted trade was a SELL whose proceeds covered an earlier BUY, the rebuild correctly leaves those BUY lots open again.
  4. Commit + cache bust.

Success

204 No Content.

Cache scope

CacheScopeDetailAndValuation.

Errors

StatusCause
400bad UUIDs
401JWT missing/invalid
404portfolio or trade not yours / not found

cURL

curl -X DELETE -H "Authorization: Bearer $JWT" \
  "$BASE/portfolios/$PID/trades/$TID"

Mutations always rebuild

Every Add / Update / Delete in this module follows the same shape:

Whether it was a BUY, a SELL correction, or a soft-delete, the lot tree always reflects the current persisted source events. There is no incremental "patch one lot" path.

Implementation

  • Handler: internal/modules/portfolio/trades/handler.go
  • Service: internal/modules/portfolio/trades/service.go
  • Calc helpers: internal/modules/portfolio/trades/calc.gobrokerRate, completeTradeFees, computedTradeTotal, mergeTradeUpdate, computeCgtMismatch, tradedAtWarnings
  • Mappers: internal/modules/portfolio/trades/mappers.go
  • DTOs + filter: internal/modules/portfolio/trades/types.go — including TradeFilter.toSQLParams
  • Tests: trades/calc_test.go (fee math), trades/handler_test.go (validation), smoke_db_test.go (end-to-end)