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.
| Method | Path | Service method |
|---|---|---|
POST | /portfolios/{id}/trades | trades.Service.AddTrade |
GET | /portfolios/{id}/trades | trades.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
typemust beBUYorSELL.cgtAmountis rejected on BUY (cgtAmount is only valid on SELL trades).tradedAtmust parse as ISO 8601, ≤now + 1 minute(no future-dating).- The server computes a synthetic
totalAmountfrompricePerUnit × quantity ± fees. If the user-submittedtotalAmountdiffers by more than NPR 1, returns 400totalAmount mismatch: expected X.XX. - (SELL only)
holdings.CurrentUnitsForHoldingruns before the insert; ifquantity > current_units, returns 422SELL quantity exceeds current holding (N units).
Fee model
Five components computed in completeTradeFees:
| Component | Default formula | User override field |
|---|---|---|
| Broker commission rate | tier-based on gross (see below) | brokerCommissionRate |
| Broker commission amount | gross × rate, rounded to 2dp | brokerCommissionAmount |
| SEBON fee | gross × 0.00015 | sebonFee |
| DP charge | 25 on SELL, 0 on BUY | dpCharge |
| CGT | (SELL only) cgtComputed from open-lot WACC; user supplies cgtAmount for the actual paid amount | cgtAmount |
Broker rate brackets (NEPSE 2025 schedule):
| Gross (NPR) | Rate |
|---|---|
| ≤ 50,000 | 0.40% |
| ≤ 500,000 | 0.37% |
| ≤ 2,000,000 | 0.34% |
| ≤ 10,000,000 | 0.30% |
| > 10,000,000 | 0.27% |
computedTradeTotal:
gross = price × qty
fees = brokerAmount + sebonFee + dpCharge + (cgtAmount on SELL)
BUY: total = gross + fees
SELL: total = gross - feesSuccess (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:
| Field | Notes |
|---|---|
settlesOn | T+2 trading-day date from FindSettlementDate. May be null if trading days table is missing entries. |
cgtComputed | server-computed CGT from open-lot WACC at sell time. |
cgtMismatch | true if |cgtAmount - cgtComputed| > 1. Informational, not blocking. |
costBasisAtSell | the 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
| Status | Cause |
|---|---|
| 400 | type invalid, cgt on BUY, totalAmount mismatch, tradedAt malformed/future, body invalid |
| 401 | JWT missing/invalid |
| 404 | portfolio not yours / not found |
| 422 | SELL quantity exceeds current holding |
| 500 | DB 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
| Param | Default | Notes |
|---|---|---|
page | 1 | |
size | 20 | max 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
parseTradeFilternormalises optional query params to aTradeFilterstruct.FindTradesByPortfoliodoes the SQL filter;CountTradesByPortfolioruns 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
| Status | Cause |
|---|---|
| 400 | bad id, bad pagination, malformed from/to, invalid type |
| 401 | JWT missing/invalid |
| 404 | not 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
- Ownership guard.
FindTradeForUpdate— locks the row.mergeTradeUpdateoverlays the partial request onto the existing row.- Same fee + total validation as
AddTrade(using the merged values). - (If type changed BUY↔SELL or company changed) the old company's holdings are rebuilt too —
Rebuild(old_companyID)runs in addition toRebuild(new_companyID). UpdateTradeSQL.Rebuildfor the affected(portfolio, company)pair(s).UpdateTradeAuditto refreshcgtComputed,costBasisAtSell,totalAmountComputed,settlesOn.- 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
- Ownership guard +
FindTradeForUpdateto capturecompanyID. SoftDeleteTrade— setsdeleted_at = now().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.- Commit + cache bust.
Success
204 No Content.
Cache scope
CacheScopeDetailAndValuation.
Errors
| Status | Cause |
|---|---|
| 400 | bad UUIDs |
| 401 | JWT missing/invalid |
| 404 | portfolio 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.go—brokerRate,completeTradeFees,computedTradeTotal,mergeTradeUpdate,computeCgtMismatch,tradedAtWarnings - Mappers:
internal/modules/portfolio/trades/mappers.go - DTOs + filter:
internal/modules/portfolio/trades/types.go— includingTradeFilter.toSQLParams - Tests:
trades/calc_test.go(fee math),trades/handler_test.go(validation),smoke_db_test.go(end-to-end)