summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-22 08:52:59 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-22 08:52:59 +0800
commitd4b0c26b396bfcf1be029d8db3a1c0fc033a6765 (patch)
treead36325d5507c1607ad8de8496ded8afb0d73e31
parent3abe605c205e0524803312eb259dc85b92051ca6 (diff)
downloadfund-lab-d4b0c26b396bfcf1be029d8db3a1c0fc033a6765.tar.gz
Add bond sell/redemption ledger, bond cashflow events and maturity calendar (3d-30 A)
-rw-r--r--src/FundLab.Api/App.fs338
-rw-r--r--src/FundLab.Api/Persistence.fs438
-rw-r--r--src/FundLab.Web/App.fs215
-rw-r--r--src/FundLab.Web/src/api.js16
-rw-r--r--tests/FundLab.Api.Tests/BondTradeTests.fs154
5 files changed, 1156 insertions, 5 deletions
diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs
index 16da159..d515ca6 100644
--- a/src/FundLab.Api/App.fs
+++ b/src/FundLab.Api/App.fs
@@ -231,6 +231,53 @@ type BondCashflowsResponse =
events: BondCashflowResponse list
}
+type BondSellResponse =
+ {
+ id: Guid
+ fundId: Guid
+ instrumentCode: string
+ bondName: string option
+ quantity: string
+ price: string
+ cleanPrice: string
+ accruedInterest: string
+ parValue: string
+ settlementDate: string
+ tradeDate: string
+ feeAmount: string
+ proceeds: string
+ costReleased: string
+ realizedPnl: string
+ executedAt: string
+ isSynthetic: bool
+ }
+
+type BondSellsResponse =
+ {
+ fundId: Guid
+ sells: BondSellResponse list
+ }
+
+type BondCalendarEntryResponse =
+ {
+ instrumentCode: string
+ bondName: string option
+ eventType: string
+ eventDate: string
+ quantity: string
+ source: string
+ amount: string option
+ note: string option
+ }
+
+type BondCalendarResponse =
+ {
+ fundId: Guid
+ fromDate: string
+ toDate: string
+ entries: BondCalendarEntryResponse list
+ }
+
type ValuationPositionResponse =
{
instrumentCode: string
@@ -719,6 +766,27 @@ module App =
createdAt = timestampText record.CreatedAt
}
+ let private bondSellResponse (record: BondSellRecord) : BondSellResponse =
+ {
+ id = record.Id
+ fundId = record.FundId
+ instrumentCode = record.InstrumentCode
+ bondName = record.BondName
+ quantity = decimalText record.Quantity
+ price = decimalText record.Price
+ cleanPrice = decimalText record.CleanPrice
+ accruedInterest = decimalText record.AccruedInterest
+ parValue = decimalText record.ParValue
+ settlementDate = dateText record.SettlementDate
+ tradeDate = dateText record.TradeDate
+ feeAmount = cashText record.FeeAmount
+ proceeds = cashText record.Proceeds
+ costReleased = cashText record.CostReleased
+ realizedPnl = cashText record.RealizedPnl
+ executedAt = timestampText record.ExecutedAt
+ isSynthetic = record.IsSynthetic
+ }
+
let private bondPositionResponse (position: BondPositionRecord) : BondPositionResponse =
{
instrumentCode = position.InstrumentCode
@@ -2605,6 +2673,273 @@ module App =
with
| :? JsonException -> Error "request body must be valid JSON"
+ let private parseBondSellCommand (body: string) : Result<BondSellCommand, string> =
+ try
+ use document = JsonDocument.Parse(body)
+ let root = document.RootElement
+
+ if root.ValueKind <> JsonValueKind.Object then
+ Error "request body must be a JSON object"
+ else
+ match tryStringProperty root "instrumentCode", tryStringProperty root "quantity" with
+ | Some code, Some quantityText ->
+ if code.Trim().Length <> 6 || not (code.Trim() |> Seq.forall Char.IsDigit) then
+ Error "instrumentCode must contain exactly six digits"
+ else
+ match tryDecimal "quantity" quantityText with
+ | Error message -> Error message
+ | Ok quantity ->
+ let tradeDate =
+ match tryStringProperty root "tradeDate" with
+ | None -> Ok None
+ | Some text ->
+ match DateOnly.TryParseExact(text, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None) with
+ | true, date -> Ok(Some date)
+ | false, _ -> Error "tradeDate must be an ISO date (yyyy-MM-dd)"
+
+ let fee =
+ match tryStringProperty root "feeAmount" with
+ | None -> Ok 0m
+ | Some text -> tryDecimal "feeAmount" text
+
+ match tradeDate, fee with
+ | Error message, _ -> Error message
+ | _, Error message -> Error message
+ | Ok tradeDate, Ok fee ->
+ Ok
+ {
+ InstrumentCode = code.Trim()
+ BondName = tryStringProperty root "bondName"
+ Quantity = quantity
+ Price = 0m
+ CleanPrice = 0m
+ AccruedInterest = 0m
+ ParValue = 100m
+ SettlementDate = DateOnly.FromDateTime DateTime.UtcNow
+ TradeDate = tradeDate
+ FeeAmount = fee
+ }
+ | _ -> Error "instrumentCode and quantity are required"
+ with
+ | :? JsonException -> Error "request body must be valid JSON"
+
+ let private createBondSell (repository: FundRepository) (probes: MarketProbes option) (fundIdText: string) : HttpHandler =
+ fun next ctx ->
+ task {
+ match Guid.TryParse fundIdText with
+ | false, _ ->
+ return! invokeHandler (errorResponse 400 "INVALID_BOND_SELL_REQUEST" "fund id must be a UUID") next ctx
+ | true, fundId ->
+ use reader = new StreamReader(ctx.Request.Body)
+ let! body = reader.ReadToEndAsync()
+ let idempotencyKey = ctx.Request.Headers["Idempotency-Key"].ToString()
+
+ match parseBondSellCommand body with
+ | Error message ->
+ return! invokeHandler (errorResponse 400 "INVALID_BOND_SELL_REQUEST" message) next ctx
+ | Ok command ->
+ match probes with
+ | None ->
+ return! invokeHandler (marketDataError (MarketDataCollectorUnavailable "bond quote probe is not configured")) next ctx
+ | Some configured ->
+ match configured.BondQuotes.GetQuote(command.InstrumentCode, ctx.RequestAborted) with
+ | Error failure -> return! invokeHandler (marketDataError failure) next ctx
+ | Ok quote ->
+ let terms = BondQuote.tryTerms quote
+ let cleanPrice = quote.CleanPrice |> Option.orElse quote.Price
+
+ match cleanPrice with
+ | None ->
+ return!
+ invokeHandler
+ (marketDataError (InvalidMarketDataPayload "bond quote did not include a price"))
+ next
+ ctx
+ | Some clean ->
+ let asOf =
+ command.TradeDate
+ |> Option.orElse quote.Date
+ |> Option.orElse quote.PublishDate
+ |> Option.defaultValue (DateOnly.FromDateTime DateTime.UtcNow)
+
+ let computedAccrued =
+ terms |> Option.map (fun value -> BondRules.accruedInterest value asOf)
+
+ let accrued =
+ quote.AccruedInterest |> Option.orElse computedAccrued |> Option.defaultValue 0m
+
+ let dirtyPrice = BondRules.dirtyPrice clean accrued
+ let parValue = quote.ParValue |> Option.defaultValue 100m
+
+ let settlement =
+ terms
+ |> Option.map (fun value -> BondRules.settlementDate value asOf)
+ |> Option.defaultValue asOf
+
+ let resolvedName =
+ match command.BondName with
+ | Some name when not (String.IsNullOrWhiteSpace name) -> Some name
+ | _ ->
+ match quote.Name with
+ | Some name when not (String.IsNullOrWhiteSpace name) -> Some name
+ | _ -> None
+
+ let priced =
+ {
+ command with
+ Price = dirtyPrice
+ CleanPrice = clean
+ AccruedInterest = accrued
+ ParValue = parValue
+ SettlementDate = settlement
+ TradeDate = Some asOf
+ BondName = resolvedName
+ }
+
+ try
+ match repository.CreateBondSell(idempotencyKey, fundId, priced) with
+ | BondSellWriteResult.BondSellCreated sell ->
+ return! invokeHandler (setStatusCode 201 >=> json (bondSellResponse sell)) next ctx
+ | BondSellWriteResult.BondSellReplayed sell ->
+ return! invokeHandler (json (bondSellResponse sell)) next ctx
+ | BondSellWriteResult.BondSellIdempotencyConflict ->
+ return! invokeHandler (errorResponse 409 "IDEMPOTENCY_CONFLICT" "idempotency key was used with a different request") next ctx
+ | BondSellWriteResult.BondSellInvalid message ->
+ return! invokeHandler (errorResponse 400 "INVALID_BOND_SELL_REQUEST" message) next ctx
+ | BondSellWriteResult.BondSellInsufficientHoldings message ->
+ return! invokeHandler (errorResponse 400 "INSUFFICIENT_BOND_HOLDINGS" message) next ctx
+ | BondSellWriteResult.BondSellFundNotFound ->
+ return! invokeHandler (errorResponse 404 "FUND_NOT_FOUND" "fund was not found") next ctx
+ with _ ->
+ return! invokeHandler (errorResponse 500 "PERSISTENCE_ERROR" "bond sell persistence failed") next ctx
+ }
+
+ let private getBondSells (repository: FundRepository) (fundIdText: string) : HttpHandler =
+ fun next ctx ->
+ match Guid.TryParse fundIdText with
+ | false, _ -> errorResponse 400 "INVALID_BOND_SELL_REQUEST" "fund id must be a UUID" next ctx
+ | true, fundId ->
+ try
+ match repository.GetFund fundId with
+ | None -> errorResponse 404 "FUND_NOT_FOUND" "fund was not found" next ctx
+ | Some fund ->
+ let sells =
+ repository.GetBondSells fundId |> List.map bondSellResponse
+
+ json ({ fundId = fund.Id; sells = sells } : BondSellsResponse) next ctx
+ with _ ->
+ errorResponse 500 "PERSISTENCE_ERROR" "bond sell persistence failed" next ctx
+
+ let private getBondCalendar (repository: FundRepository) (fundIdText: string) : HttpHandler =
+ fun next ctx ->
+ match Guid.TryParse fundIdText with
+ | false, _ -> errorResponse 400 "INVALID_BOND_CALENDAR_REQUEST" "fund id must be a UUID" next ctx
+ | true, fundId ->
+ let today = DateOnly.FromDateTime DateTime.UtcNow
+
+ let parseDate (raw: string) (fallback: DateOnly) =
+ if String.IsNullOrWhiteSpace raw then
+ fallback
+ else
+ match DateOnly.TryParseExact(raw, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None) with
+ | true, date -> date
+ | _ -> fallback
+
+ let from = parseDate (ctx.Request.Query["from"].ToString()) today
+ let toDate = parseDate (ctx.Request.Query["to"].ToString()) (today.AddYears 1)
+
+ try
+ match repository.GetFund fundId with
+ | None -> errorResponse 404 "FUND_NOT_FOUND" "fund was not found" next ctx
+ | Some fund ->
+ let positions = repository.GetBondPositions fundId
+
+ let termsByCode =
+ repository.GetBondTrades fundId
+ |> List.fold
+ (fun acc trade ->
+ match trade.ValueDate, trade.MaturityDate, trade.CouponRate with
+ | Some valueDate, Some maturityDate, Some couponRate ->
+ Map.add
+ trade.InstrumentCode
+ (BondTerms.create trade.ParValue couponRate 1 valueDate maturityDate 10m 0 0m)
+ acc
+ | _ -> acc)
+ Map.empty
+
+ let scheduled =
+ positions
+ |> List.collect (fun position ->
+ match termsByCode |> Map.tryFind position.InstrumentCode with
+ | None -> []
+ | Some terms ->
+ let coupons =
+ BondRules.couponSchedule terms
+ |> List.filter (fun date ->
+ date > terms.ValueDate
+ && date < terms.MaturityDate
+ && date >= from
+ && date <= toDate)
+ |> List.map (fun date ->
+ {
+ instrumentCode = position.InstrumentCode
+ bondName = position.BondName
+ eventType = "coupon"
+ eventDate = dateText date
+ quantity = decimalText position.Quantity
+ source = "scheduled"
+ amount = None
+ note = None
+ })
+
+ let maturity =
+ if terms.MaturityDate >= from && terms.MaturityDate <= toDate then
+ [
+ {
+ instrumentCode = position.InstrumentCode
+ bondName = position.BondName
+ eventType = "maturity"
+ eventDate = dateText terms.MaturityDate
+ quantity = decimalText position.Quantity
+ source = "scheduled"
+ amount = None
+ note = None
+ }
+ ]
+ else
+ []
+
+ coupons @ maturity)
+
+ let recorded =
+ repository.GetBondCashflows fundId
+ |> List.filter (fun event -> event.EventDate >= from && event.EventDate <= toDate)
+ |> List.map (fun event ->
+ {
+ instrumentCode = event.InstrumentCode
+ bondName = event.BondName
+ eventType = event.EventType
+ eventDate = dateText event.EventDate
+ quantity = decimalText event.Quantity
+ source = "recorded"
+ amount = Some(cashText event.Amount)
+ note = event.Note
+ })
+
+ let entries =
+ (recorded @ scheduled) |> List.sortBy (fun entry -> entry.eventDate, entry.instrumentCode)
+
+ json
+ ({ fundId = fund.Id
+ fromDate = dateText from
+ toDate = dateText toDate
+ entries = entries }
+ : BondCalendarResponse)
+ next
+ ctx
+ with _ ->
+ errorResponse 500 "PERSISTENCE_ERROR" "bond calendar failed" next ctx
+
let private recordBondCashflow (repository: FundRepository) (fundIdText: string) : HttpHandler =
fun next ctx ->
task {
@@ -3065,8 +3400,11 @@ module App =
GET >=> routef "/funds/%s/stock-positions" (getStockPositions repository)
POST >=> routef "/funds/%s/bond-trades" (createBondTrade repository probes)
GET >=> routef "/funds/%s/bond-positions" (getBondPositions repository)
+ POST >=> routef "/funds/%s/bond-sells" (createBondSell repository probes)
+ GET >=> routef "/funds/%s/bond-sells" (getBondSells repository)
POST >=> routef "/funds/%s/bond-cashflows" (recordBondCashflow repository)
GET >=> routef "/funds/%s/bond-cashflows" (getBondCashflows repository)
+ GET >=> routef "/funds/%s/bond-calendar" (getBondCalendar repository)
GET >=> routef "/funds/%s/valuation" (getFundValuation repository probes)
POST >=> routef "/funds/%s/market-data/refresh" (fun fundId -> refreshFundMarketData repository marketData probes fundId)
GET >=> routef "/funds/%s" (getFund repository)
diff --git a/src/FundLab.Api/Persistence.fs b/src/FundLab.Api/Persistence.fs
index 2471c58..9ccb0c7 100644
--- a/src/FundLab.Api/Persistence.fs
+++ b/src/FundLab.Api/Persistence.fs
@@ -444,6 +444,52 @@ type BondPositionRecord =
LastTradedAt: DateTimeOffset
}
+type BondSellCommand =
+ {
+ InstrumentCode: string
+ BondName: string option
+ Quantity: decimal
+ /// All-in (dirty/全价) sell price per 100 of face value.
+ Price: decimal
+ CleanPrice: decimal
+ AccruedInterest: decimal
+ ParValue: decimal
+ SettlementDate: DateOnly
+ TradeDate: DateOnly option
+ FeeAmount: decimal
+ }
+
+type BondSellRecord =
+ {
+ Id: Guid
+ FundId: Guid
+ InstrumentCode: string
+ BondName: string option
+ Quantity: decimal
+ Price: decimal
+ CleanPrice: decimal
+ AccruedInterest: decimal
+ ParValue: decimal
+ SettlementDate: DateOnly
+ TradeDate: DateOnly
+ FeeAmount: decimal
+ /// Cash credited to the fund after fees (dirty amount minus fee).
+ Proceeds: decimal
+ /// Average-cost basis removed from the position.
+ CostReleased: decimal
+ RealizedPnl: decimal
+ IsSynthetic: bool
+ ExecutedAt: DateTimeOffset
+ }
+
+type BondSellWriteResult =
+ | BondSellCreated of BondSellRecord
+ | BondSellReplayed of BondSellRecord
+ | BondSellIdempotencyConflict
+ | BondSellInvalid of string
+ | BondSellInsufficientHoldings of string
+ | BondSellFundNotFound
+
type InstrumentSnapshotRecord =
{
InstrumentCode: string
@@ -1096,7 +1142,7 @@ type FundRepository(connectionString: string) =
fund_id uuid NOT NULL REFERENCES funds(id),
instrument_code text NOT NULL,
bond_name text NULL,
- event_type text NOT NULL CHECK (event_type IN ('coupon', 'maturity')),
+ event_type text NOT NULL CHECK (event_type IN ('coupon', 'maturity', 'redemption')),
event_date date NOT NULL,
quantity numeric(28, 8) NOT NULL CHECK (quantity > 0),
amount numeric(20, 2) NOT NULL CHECK (amount >= 0),
@@ -1105,6 +1151,9 @@ type FundRepository(connectionString: string) =
created_at timestamptz NOT NULL
);
+ ALTER TABLE bond_cashflow_events DROP CONSTRAINT IF EXISTS bond_cashflow_events_event_type_check;
+ ALTER TABLE bond_cashflow_events ADD CONSTRAINT bond_cashflow_events_event_type_check CHECK (event_type IN ('coupon', 'maturity', 'redemption'));
+
CREATE TABLE IF NOT EXISTS bond_cashflow_idempotencies (
idempotency_key text PRIMARY KEY,
request_hash text NOT NULL,
@@ -1113,6 +1162,34 @@ type FundRepository(connectionString: string) =
created_at timestamptz NOT NULL DEFAULT now()
);
+ CREATE TABLE IF NOT EXISTS bond_sells (
+ id uuid PRIMARY KEY,
+ fund_id uuid NOT NULL REFERENCES funds(id),
+ instrument_code text NOT NULL,
+ bond_name text NULL,
+ quantity numeric(28, 8) NOT NULL CHECK (quantity > 0),
+ price numeric(20, 4) NOT NULL CHECK (price > 0),
+ clean_price numeric(20, 4) NOT NULL DEFAULT 0,
+ accrued_interest numeric(20, 4) NOT NULL DEFAULT 0,
+ par_value numeric(20, 4) NOT NULL DEFAULT 100,
+ settlement_date date NOT NULL DEFAULT CURRENT_DATE,
+ trade_date date NOT NULL DEFAULT CURRENT_DATE,
+ fee_amount numeric(20, 2) NOT NULL DEFAULT 0 CHECK (fee_amount >= 0),
+ proceeds numeric(20, 2) NOT NULL DEFAULT 0,
+ cost_released numeric(20, 2) NOT NULL DEFAULT 0,
+ realized_pnl numeric(20, 2) NOT NULL DEFAULT 0,
+ is_synthetic boolean NOT NULL,
+ executed_at timestamptz NOT NULL
+ );
+
+ CREATE TABLE IF NOT EXISTS bond_sell_idempotencies (
+ idempotency_key text PRIMARY KEY,
+ request_hash text NOT NULL,
+ sell_id uuid NOT NULL REFERENCES bond_sells(id),
+ fund_id uuid NOT NULL REFERENCES funds(id),
+ created_at timestamptz NOT NULL DEFAULT now()
+ );
+
CREATE TABLE IF NOT EXISTS bond_positions (
fund_id uuid NOT NULL REFERENCES funds(id),
instrument_code text NOT NULL,
@@ -2540,6 +2617,154 @@ type FundRepository(connectionString: string) =
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload)))
+ let bondSellRecordFromReader (reader: DbDataReader) : BondSellRecord =
+ {
+ Id = reader.GetGuid(0)
+ FundId = reader.GetGuid(1)
+ InstrumentCode = reader.GetString(2)
+ BondName = if reader.IsDBNull(3) then None else Some(reader.GetString(3))
+ Quantity = reader.GetDecimal(4)
+ Price = reader.GetDecimal(5)
+ CleanPrice = reader.GetDecimal(6)
+ AccruedInterest = reader.GetDecimal(7)
+ ParValue = reader.GetDecimal(8)
+ SettlementDate = reader.GetFieldValue<DateOnly>(9)
+ TradeDate = reader.GetFieldValue<DateOnly>(10)
+ FeeAmount = reader.GetDecimal(11)
+ Proceeds = reader.GetDecimal(12)
+ CostReleased = reader.GetDecimal(13)
+ RealizedPnl = reader.GetDecimal(14)
+ IsSynthetic = reader.GetBoolean(15)
+ ExecutedAt = reader.GetFieldValue<DateTimeOffset>(16)
+ }
+
+ let bondSellColumns =
+ "id, fund_id, instrument_code, bond_name, quantity, price, clean_price, accrued_interest, par_value, settlement_date, trade_date, fee_amount, proceeds, cost_released, realized_pnl, is_synthetic, executed_at"
+
+ let insertBondSell connection transaction (record: BondSellRecord) =
+ use command =
+ commandWithTransaction
+ connection
+ transaction
+ """
+ INSERT INTO bond_sells
+ (id, fund_id, instrument_code, bond_name, quantity, price, clean_price, accrued_interest,
+ par_value, settlement_date, trade_date, fee_amount, proceeds, cost_released, realized_pnl,
+ is_synthetic, executed_at)
+ VALUES
+ (@id, @fund_id, @instrument_code, @bond_name, @quantity, @price, @clean_price, @accrued_interest,
+ @par_value, @settlement_date, @trade_date, @fee_amount, @proceeds, @cost_released, @realized_pnl,
+ @is_synthetic, @executed_at)
+ """
+
+ addParameter command "id" NpgsqlDbType.Uuid (box record.Id) |> ignore
+ addParameter command "fund_id" NpgsqlDbType.Uuid (box record.FundId) |> ignore
+ addParameter command "instrument_code" NpgsqlDbType.Text (box record.InstrumentCode) |> ignore
+
+ let nameParameter =
+ match record.BondName with
+ | Some name -> box name
+ | None -> box DBNull.Value
+
+ addParameter command "bond_name" NpgsqlDbType.Text nameParameter |> ignore
+ addParameter command "quantity" NpgsqlDbType.Numeric (box record.Quantity) |> ignore
+ addParameter command "price" NpgsqlDbType.Numeric (box record.Price) |> ignore
+ addParameter command "clean_price" NpgsqlDbType.Numeric (box record.CleanPrice) |> ignore
+ addParameter command "accrued_interest" NpgsqlDbType.Numeric (box record.AccruedInterest) |> ignore
+ addParameter command "par_value" NpgsqlDbType.Numeric (box record.ParValue) |> ignore
+ addParameter command "settlement_date" NpgsqlDbType.Date (box record.SettlementDate) |> ignore
+ addParameter command "trade_date" NpgsqlDbType.Date (box record.TradeDate) |> ignore
+ addParameter command "fee_amount" NpgsqlDbType.Numeric (box record.FeeAmount) |> ignore
+ addParameter command "proceeds" NpgsqlDbType.Numeric (box record.Proceeds) |> ignore
+ addParameter command "cost_released" NpgsqlDbType.Numeric (box record.CostReleased) |> ignore
+ addParameter command "realized_pnl" NpgsqlDbType.Numeric (box record.RealizedPnl) |> ignore
+ addParameter command "is_synthetic" NpgsqlDbType.Boolean (box record.IsSynthetic) |> ignore
+ addParameter command "executed_at" NpgsqlDbType.TimestampTz (box record.ExecutedAt) |> ignore
+ command.ExecuteNonQuery() |> ignore
+
+ let insertBondSellIdempotency connection transaction key requestHash sellId fundId =
+ use command =
+ commandWithTransaction
+ connection
+ transaction
+ """
+ INSERT INTO bond_sell_idempotencies (idempotency_key, request_hash, sell_id, fund_id)
+ VALUES (@idempotency_key, @request_hash, @sell_id, @fund_id)
+ """
+
+ addParameter command "idempotency_key" NpgsqlDbType.Text (box key) |> ignore
+ addParameter command "request_hash" NpgsqlDbType.Text (box requestHash) |> ignore
+ addParameter command "sell_id" NpgsqlDbType.Uuid (box sellId) |> ignore
+ addParameter command "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore
+ command.ExecuteNonQuery() |> ignore
+
+ let findBondSellIdempotency connection transaction key =
+ use command =
+ commandWithTransaction
+ connection
+ transaction
+ """
+ SELECT request_hash, fund_id, sell_id
+ FROM bond_sell_idempotencies
+ WHERE idempotency_key = @idempotency_key
+ """
+
+ addParameter command "idempotency_key" NpgsqlDbType.Text (box key) |> ignore
+
+ use reader = command.ExecuteReader()
+
+ if reader.Read() then
+ Some(reader.GetString(0), reader.GetGuid(1), reader.GetGuid(2))
+ else
+ None
+
+ let findBondSell connection transaction sellId =
+ use command =
+ commandWithTransaction
+ connection
+ transaction
+ $"""
+ SELECT {bondSellColumns}
+ FROM bond_sells
+ WHERE id = @id
+ """
+
+ addParameter command "id" NpgsqlDbType.Uuid (box sellId) |> ignore
+
+ use reader = command.ExecuteReader()
+
+ if reader.Read() then
+ Some(bondSellRecordFromReader reader)
+ else
+ None
+
+ let bondSellRequestHash (fundId: Guid) (command: BondSellCommand) =
+ let invariant = CultureInfo.InvariantCulture
+ let encoded (value: string) = sprintf "%d:%s" value.Length value
+ let name = command.BondName |> Option.defaultValue ""
+ let dateText (value: DateOnly) = value.ToString("yyyy-MM-dd", invariant)
+ let optionTradeDate = command.TradeDate |> Option.map dateText |> Option.defaultValue ""
+
+ let payload =
+ String.concat
+ "|"
+ [
+ "bond-sell"
+ encoded (fundId.ToString("D"))
+ encoded command.InstrumentCode
+ encoded name
+ encoded (command.Quantity.ToString("G29", invariant))
+ encoded (command.Price.ToString("G29", invariant))
+ encoded (command.CleanPrice.ToString("G29", invariant))
+ encoded (command.AccruedInterest.ToString("G29", invariant))
+ encoded (command.ParValue.ToString("G29", invariant))
+ encoded (dateText command.SettlementDate)
+ encoded optionTradeDate
+ encoded (command.FeeAmount.ToString("G29", invariant))
+ ]
+
+ Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload)))
+
let sipPlanRecordFromReader (reader: DbDataReader) : SipPlanRecord =
{
Id = reader.GetGuid(0)
@@ -5590,8 +5815,8 @@ type FundRepository(connectionString: string) =
BondCashflowWriteResult.BondCashflowInvalid "idempotency key cannot be empty"
elif code.Length <> 6 || not (code |> Seq.forall Char.IsDigit) then
BondCashflowWriteResult.BondCashflowInvalid "bond code must contain exactly six digits"
- elif eventType <> "coupon" && eventType <> "maturity" then
- BondCashflowWriteResult.BondCashflowInvalid "event type must be coupon or maturity"
+ elif eventType <> "coupon" && eventType <> "maturity" && eventType <> "redemption" then
+ BondCashflowWriteResult.BondCashflowInvalid "event type must be coupon, maturity or redemption"
elif command.Quantity <= 0m then
BondCashflowWriteResult.BondCashflowInvalid "quantity must be positive"
elif command.Amount < 0m then
@@ -5676,7 +5901,7 @@ type FundRepository(connectionString: string) =
addParameter cashCommand "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore
cashCommand.ExecuteNonQuery() |> ignore
- if normalized.EventType = "maturity" then
+ if normalized.EventType = "maturity" || normalized.EventType = "redemption" then
use removeCommand =
commandWithTransaction
connection
@@ -5722,6 +5947,211 @@ type FundRepository(connectionString: string) =
records |> Seq.toList
+ member _.CreateBondSell(idempotencyKey: string, fundId: Guid, command: BondSellCommand, ?executedAtOverride: DateTimeOffset) : BondSellWriteResult =
+ let code = if isNull command.InstrumentCode then "" else command.InstrumentCode.Trim()
+
+ if String.IsNullOrWhiteSpace idempotencyKey then
+ BondSellWriteResult.BondSellInvalid "idempotency key cannot be empty"
+ elif code.Length <> 6 || not (code |> Seq.forall Char.IsDigit) then
+ BondSellWriteResult.BondSellInvalid "bond code must contain exactly six digits"
+ elif command.Quantity <= 0m then
+ BondSellWriteResult.BondSellInvalid "quantity must be positive"
+ elif command.Price <= 0m then
+ BondSellWriteResult.BondSellInvalid "price must be positive"
+ elif command.CleanPrice <= 0m then
+ BondSellWriteResult.BondSellInvalid "clean price must be positive"
+ elif command.ParValue <= 0m then
+ BondSellWriteResult.BondSellInvalid "par value must be positive"
+ elif command.AccruedInterest < 0m then
+ BondSellWriteResult.BondSellInvalid "accrued interest cannot be negative"
+ elif command.FeeAmount < 0m then
+ BondSellWriteResult.BondSellInvalid "fee cannot be negative"
+ else
+ let normalized = { command with InstrumentCode = code }
+ let fingerprint = bondSellRequestHash fundId normalized
+ use connection = new NpgsqlConnection(connectionString)
+ connection.Open()
+ use transaction = connection.BeginTransaction(IsolationLevel.ReadCommitted)
+
+ try
+ use lockCommand =
+ commandWithTransaction
+ connection
+ (Some transaction)
+ "SELECT pg_advisory_xact_lock(hashtext(@lock_key))"
+
+ addParameter lockCommand "lock_key" NpgsqlDbType.Text (box idempotencyKey) |> ignore
+ lockCommand.ExecuteNonQuery() |> ignore
+
+ match findBondSellIdempotency connection (Some transaction) idempotencyKey with
+ | Some(existingHash, existingFundId, sellId)
+ when existingHash = fingerprint && existingFundId = fundId ->
+ match findBondSell connection (Some transaction) sellId with
+ | Some record ->
+ transaction.Commit()
+ BondSellWriteResult.BondSellReplayed record
+ | None ->
+ transaction.Rollback()
+ BondSellWriteResult.BondSellInvalid "idempotency record references a missing sell"
+ | Some _ ->
+ transaction.Rollback()
+ BondSellWriteResult.BondSellIdempotencyConflict
+ | None ->
+ match lockFundForOrder connection (Some transaction) fundId with
+ | None ->
+ transaction.Rollback()
+ BondSellWriteResult.BondSellFundNotFound
+ | Some isSynthetic ->
+ let position =
+ use positionQuery =
+ commandWithTransaction
+ connection
+ (Some transaction)
+ "SELECT quantity, cost_cash FROM bond_positions WHERE fund_id = @fund_id AND instrument_code = @code FOR UPDATE"
+
+ addParameter positionQuery "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore
+ addParameter positionQuery "code" NpgsqlDbType.Text (box normalized.InstrumentCode) |> ignore
+ use reader = positionQuery.ExecuteReader()
+ if reader.Read() then Some(reader.GetDecimal(0), reader.GetDecimal(1)) else None
+
+ match position with
+ | None ->
+ transaction.Rollback()
+ BondSellWriteResult.BondSellInsufficientHoldings "fund does not hold this bond"
+ | Some(positionQuantity, positionCost) ->
+ if normalized.Quantity > positionQuantity then
+ transaction.Rollback()
+
+ BondSellWriteResult.BondSellInsufficientHoldings(
+ sprintf "cannot sell %O 张: only %O held" normalized.Quantity positionQuantity
+ )
+ else
+ let executedAt = defaultArg executedAtOverride DateTimeOffset.UtcNow
+
+ let gross =
+ Decimal.Round(
+ normalized.Quantity * normalized.Price * normalized.ParValue / 100m,
+ 2,
+ MidpointRounding.AwayFromZero
+ )
+
+ let proceeds = Decimal.Round(gross - normalized.FeeAmount, 2, MidpointRounding.AwayFromZero)
+
+ if proceeds < 0m then
+ transaction.Rollback()
+ BondSellWriteResult.BondSellInvalid "fee exceeds gross proceeds"
+ else
+ let costReleased =
+ if normalized.Quantity = positionQuantity then
+ positionCost
+ else
+ Decimal.Round(
+ positionCost * normalized.Quantity / positionQuantity,
+ 2,
+ MidpointRounding.AwayFromZero
+ )
+
+ let realizedPnl = Decimal.Round(proceeds - costReleased, 2, MidpointRounding.AwayFromZero)
+
+ let record: BondSellRecord =
+ {
+ Id = Guid.NewGuid()
+ FundId = fundId
+ InstrumentCode = normalized.InstrumentCode
+ BondName = normalized.BondName
+ Quantity = normalized.Quantity
+ Price = normalized.Price
+ CleanPrice = normalized.CleanPrice
+ AccruedInterest = normalized.AccruedInterest
+ ParValue = normalized.ParValue
+ SettlementDate = normalized.SettlementDate
+ TradeDate = normalized.TradeDate |> Option.defaultValue normalized.SettlementDate
+ FeeAmount = normalized.FeeAmount
+ Proceeds = proceeds
+ CostReleased = costReleased
+ RealizedPnl = realizedPnl
+ IsSynthetic = isSynthetic
+ ExecutedAt = executedAt
+ }
+
+ insertBondSell connection (Some transaction) record
+ insertBondSellIdempotency connection (Some transaction) idempotencyKey fingerprint record.Id fundId
+
+ use cashCommand =
+ commandWithTransaction
+ connection
+ (Some transaction)
+ "UPDATE funds SET available_cash = available_cash + @proceeds WHERE id = @fund_id"
+
+ addParameter cashCommand "proceeds" NpgsqlDbType.Numeric (box proceeds) |> ignore
+ addParameter cashCommand "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore
+ cashCommand.ExecuteNonQuery() |> ignore
+
+ if normalized.Quantity = positionQuantity then
+ use removeCommand =
+ commandWithTransaction
+ connection
+ (Some transaction)
+ "DELETE FROM bond_positions WHERE fund_id = @fund_id AND instrument_code = @code"
+
+ addParameter removeCommand "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore
+ addParameter removeCommand "code" NpgsqlDbType.Text (box normalized.InstrumentCode) |> ignore
+ removeCommand.ExecuteNonQuery() |> ignore
+ else
+ use positionCommand =
+ commandWithTransaction
+ connection
+ (Some transaction)
+ """
+ UPDATE bond_positions
+ SET quantity = quantity - @quantity,
+ cost_cash = cost_cash - @cost_released,
+ last_traded_at = @last_traded_at
+ WHERE fund_id = @fund_id AND instrument_code = @code
+ """
+
+ addParameter positionCommand "quantity" NpgsqlDbType.Numeric (box normalized.Quantity) |> ignore
+ addParameter positionCommand "cost_released" NpgsqlDbType.Numeric (box costReleased) |> ignore
+ addParameter positionCommand "last_traded_at" NpgsqlDbType.TimestampTz (box executedAt) |> ignore
+ addParameter positionCommand "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore
+ addParameter positionCommand "code" NpgsqlDbType.Text (box normalized.InstrumentCode) |> ignore
+ positionCommand.ExecuteNonQuery() |> ignore
+
+ transaction.Commit()
+ BondSellWriteResult.BondSellCreated record
+ with error ->
+ try
+ transaction.Rollback()
+ with _ ->
+ ()
+
+ raise error
+
+ member _.GetBondSells(fundId: Guid) : BondSellRecord list =
+ use connection = new NpgsqlConnection(connectionString)
+ connection.Open()
+
+ use command =
+ commandWithTransaction
+ connection
+ None
+ $"""
+ SELECT {bondSellColumns}
+ FROM bond_sells
+ WHERE fund_id = @fund_id
+ ORDER BY executed_at, id
+ """
+
+ addParameter command "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore
+
+ use reader = command.ExecuteReader()
+ let records = ResizeArray<BondSellRecord>()
+
+ while reader.Read() do
+ records.Add(bondSellRecordFromReader reader)
+
+ records |> Seq.toList
+
member _.GetBondPositions(fundId: Guid) : BondPositionRecord list =
use connection = new NpgsqlConnection(connectionString)
connection.Open()
diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs
index 5e891ae..56422ed 100644
--- a/src/FundLab.Web/App.fs
+++ b/src/FundLab.Web/App.fs
@@ -503,6 +503,47 @@ type RawBondPositions =
positions: RawBondPosition array
}
+type RawBondSell =
+ {
+ id: string
+ fundId: string
+ instrumentCode: string
+ bondName: obj
+ quantity: string
+ price: string
+ cleanPrice: string
+ accruedInterest: string
+ parValue: string
+ settlementDate: string
+ tradeDate: string
+ feeAmount: string
+ proceeds: string
+ costReleased: string
+ realizedPnl: string
+ executedAt: string
+ isSynthetic: bool
+ }
+
+type RawBondCalendarEntry =
+ {
+ instrumentCode: string
+ bondName: obj
+ eventType: string
+ eventDate: string
+ quantity: string
+ source: string
+ amount: obj
+ note: obj
+ }
+
+type RawBondCalendar =
+ {
+ fundId: string
+ fromDate: string
+ toDate: string
+ entries: RawBondCalendarEntry array
+ }
+
type RawValuationPosition =
{
instrumentCode: string
@@ -1063,6 +1104,12 @@ module Api =
[<Import("getBondPositions", "./src/api.js")>]
let getBondPositions (token: string) (fundId: string) : JS.Promise<RawBondPositions> = jsNative
+ [<Import("createBondSell", "./src/api.js")>]
+ let createBondSell (token: string) (fundId: string) (payload: CreateBondTradePayload) : JS.Promise<RawBondSell> = jsNative
+
+ [<Import("getBondCalendar", "./src/api.js")>]
+ let getBondCalendar (token: string) (fundId: string) : JS.Promise<RawBondCalendar> = jsNative
+
[<Import("getFundValuation", "./src/api.js")>]
let getFundValuation (token: string) (fundId: string) : JS.Promise<RawFundValuation> = jsNative
@@ -1574,6 +1621,14 @@ type Model =
bondPositionsInFlight: bool
bondPositions: BondPositionView list
bondPositionsMessage: string option
+ bondSellQuantity: string
+ bondSellSeq: int
+ bondSellInFlight: bool
+ bondSellMessage: string option
+ bondCalendarSeq: int
+ bondCalendarInFlight: bool
+ bondCalendarEntries: string list
+ bondCalendarMessage: string option
valuationReadSeq: int
valuationInFlight: bool
valuation: FundValuationView option
@@ -1717,6 +1772,13 @@ type Msg =
| BondPositionsReadRequested
| BondPositionsReadCompleted of requestId: int * fundId: string * positions: RawBondPositions
| BondPositionsReadFailed of requestId: int * fundId: string * message: string
+ | BondSellQuantityChanged of string
+ | BondSellRequested
+ | BondSellCompleted of requestId: int * fundId: string * sell: RawBondSell
+ | BondSellFailed of requestId: int * fundId: string * message: string
+ | BondCalendarReadRequested
+ | BondCalendarReadCompleted of requestId: int * fundId: string * calendar: RawBondCalendar
+ | BondCalendarReadFailed of requestId: int * fundId: string * message: string
| ValuationReadRequested
| ValuationReadCompleted of requestId: int * fundId: string * valuation: RawFundValuation
| ValuationReadFailed of requestId: int * fundId: string * message: string
@@ -1894,6 +1956,14 @@ let init () =
bondPositionsInFlight = false
bondPositions = []
bondPositionsMessage = None
+ bondSellQuantity = ""
+ bondSellSeq = 0
+ bondSellInFlight = false
+ bondSellMessage = None
+ bondCalendarSeq = 0
+ bondCalendarInFlight = false
+ bondCalendarEntries = []
+ bondCalendarMessage = None
valuationReadSeq = 0
valuationInFlight = false
valuation = None
@@ -2162,6 +2232,20 @@ let private readBondPositionsCommand token fundId requestId =
(fun positions -> BondPositionsReadCompleted(requestId, fundId, positions))
(fun error -> BondPositionsReadFailed(requestId, fundId, errorText error))
+let private createBondSellCommand token fundId payload requestId =
+ Cmd.OfPromise.either
+ (fun () -> Api.createBondSell token fundId payload)
+ ()
+ (fun sell -> BondSellCompleted(requestId, fundId, sell))
+ (fun error -> BondSellFailed(requestId, fundId, errorText error))
+
+let private readBondCalendarCommand token fundId requestId =
+ Cmd.OfPromise.either
+ (fun () -> Api.getBondCalendar token fundId)
+ ()
+ (fun calendar -> BondCalendarReadCompleted(requestId, fundId, calendar))
+ (fun error -> BondCalendarReadFailed(requestId, fundId, errorText error))
+
let private readValuationCommand token fundId requestId =
Cmd.OfPromise.either
(fun () -> Api.getFundValuation token fundId)
@@ -3831,7 +3915,7 @@ let update message model =
bondPositionsInFlight = false
bondPositionsMessage = None
},
- Cmd.none
+ Cmd.ofMsg BondCalendarReadRequested
else
model, Cmd.none
| BondPositionsReadFailed (requestId, fundId, message) ->
@@ -3846,6 +3930,95 @@ let update message model =
Cmd.none
else
model, Cmd.none
+ | BondSellQuantityChanged value -> { model with bondSellQuantity = value }, Cmd.none
+ | BondSellRequested ->
+ let code = model.bondCode.Trim()
+ let quantity = model.bondSellQuantity.Trim()
+
+ if String.IsNullOrWhiteSpace model.token then
+ { model with bondSellMessage = Some "请输入 API token" }, Cmd.none
+ elif model.createdFund.IsNone then
+ { model with bondSellMessage = Some "请先创建一个基金" }, Cmd.none
+ elif code.Length <> 6 || not (code |> Seq.forall Char.IsDigit) then
+ { model with bondSellMessage = Some "无效代码 — 请输入六位债券代码" }, Cmd.none
+ elif not (isValidQuantityText quantity) then
+ { model with bondSellMessage = Some "数量必须是大于零的整数张数,例如 10" }, Cmd.none
+ elif model.bondSellInFlight then
+ model, Cmd.none
+ else
+ let requestId = model.bondSellSeq + 1
+ let idempotencyKey = Guid.NewGuid().ToString("N")
+
+ {
+ model with
+ bondSellQuantity = quantity
+ bondSellSeq = requestId
+ bondSellInFlight = true
+ bondSellMessage = None
+ },
+ createBondSellCommand
+ model.token
+ model.createdFund.Value.id
+ { idempotencyKey = idempotencyKey; instrumentCode = code; quantity = quantity }
+ requestId
+ | BondSellCompleted (requestId, fundId, sell) ->
+ if requestId = model.bondSellSeq
+ && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then
+ {
+ model with
+ bondSellInFlight = false
+ bondSellMessage =
+ Some(sprintf "已卖出 %s %s 张:回收现金 %s,已实现盈亏 %s" sell.instrumentCode sell.quantity sell.proceeds sell.realizedPnl)
+ },
+ Cmd.ofMsg BondPositionsReadRequested
+ else
+ model, Cmd.none
+ | BondSellFailed (requestId, fundId, message) ->
+ if requestId = model.bondSellSeq
+ && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then
+ { model with bondSellInFlight = false; bondSellMessage = Some(sprintf "卖出失败 — %s" message) }, Cmd.none
+ else
+ model, Cmd.none
+ | BondCalendarReadRequested ->
+ match model.createdFund with
+ | Some fund when not (String.IsNullOrWhiteSpace model.token) ->
+ let requestId = model.bondCalendarSeq + 1
+
+ {
+ model with
+ bondCalendarSeq = requestId
+ bondCalendarInFlight = true
+ bondCalendarMessage = None
+ },
+ readBondCalendarCommand model.token fund.id requestId
+ | _ -> model, Cmd.none
+ | BondCalendarReadCompleted (requestId, fundId, calendar) ->
+ if requestId = model.bondCalendarSeq
+ && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then
+ let formatEntry (entry: RawBondCalendarEntry) =
+ let amountText =
+ Api.decodeOptionalText entry.amount
+ |> Option.map (fun value -> sprintf " %s" value)
+ |> Option.defaultValue ""
+
+ sprintf "%s %s %s (%s)%s" entry.eventDate entry.eventType entry.instrumentCode entry.source amountText
+
+ {
+ model with
+ bondCalendarInFlight = false
+ bondCalendarEntries = calendar.entries |> Array.toList |> List.map formatEntry
+ bondCalendarMessage = None
+ },
+ Cmd.none
+ else
+ model, Cmd.none
+ | BondCalendarReadFailed (requestId, fundId, message) ->
+ if requestId = model.bondCalendarSeq
+ && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then
+ { model with bondCalendarInFlight = false; bondCalendarEntries = []; bondCalendarMessage = Some(sprintf "债券日历不可用 — %s" message) },
+ Cmd.none
+ else
+ model, Cmd.none
| ValuationReadRequested ->
match model.createdFund with
| Some fund when not (String.IsNullOrWhiteSpace model.token) ->
@@ -5757,6 +5930,29 @@ let private bondsPanel model dispatch =
prop.hidden (model.bondTradeMessage.IsNone)
prop.text (model.bondTradeMessage |> Option.defaultValue "")
]
+ Html.p [ prop.className "returns-subheading"; prop.text "卖出(净价/全价口径一致,应计利息随全价吐回)" ]
+ Html.div [
+ prop.className "search-row"
+ prop.children [
+ Html.input [
+ prop.className "text-input bond-sell-quantity-input"
+ prop.placeholder "卖出数量(整数张),如 10"
+ prop.value model.bondSellQuantity
+ prop.onChange (fun value -> dispatch (BondSellQuantityChanged value))
+ ]
+ Html.button [
+ prop.className "primary-action bond-sell-action"
+ prop.disabled model.bondSellInFlight
+ prop.onClick (fun _ -> dispatch BondSellRequested)
+ prop.text ((if model.bondSellInFlight then "卖出中..." else "卖出"): string)
+ ]
+ ]
+ ]
+ Html.p [
+ prop.className "bond-sell-unavailable"
+ prop.hidden (model.bondSellMessage.IsNone)
+ prop.text (model.bondSellMessage |> Option.defaultValue "")
+ ]
]
]
Html.div [
@@ -5791,6 +5987,23 @@ let private bondsPanel model dispatch =
prop.hidden (model.bondPositionsMessage.IsNone)
prop.text (model.bondPositionsMessage |> Option.defaultValue "")
]
+ Html.div [
+ prop.className "bond-calendar"
+ prop.children (
+ Html.p [ prop.className "returns-subheading"; prop.text "持仓到期/付息日历(已实现口径)" ]
+ :: (if List.isEmpty model.bondCalendarEntries then
+ [
+ Html.p [
+ prop.className "bond-calendar-empty"
+ prop.text (model.bondCalendarMessage |> Option.defaultValue "暂无到期或付息事件")
+ ]
+ ]
+ else
+ model.bondCalendarEntries
+ |> List.map (fun entry ->
+ Html.p [ prop.className "bond-calendar-row"; prop.text entry ]))
+ )
+ ]
]
]
diff --git a/src/FundLab.Web/src/api.js b/src/FundLab.Web/src/api.js
index 3947688..3136371 100644
--- a/src/FundLab.Web/src/api.js
+++ b/src/FundLab.Web/src/api.js
@@ -191,6 +191,22 @@ export function getBondPositions(token, fundId) {
return requestJson(`/api/funds/${encodeURIComponent(fundId)}/bond-positions`, token);
}
+export function createBondSell(token, fundId, payload) {
+ const body = `{"instrumentCode":${JSON.stringify(payload.instrumentCode)},"quantity":${JSON.stringify(payload.quantity)}}`;
+ return requestJson(`/api/funds/${encodeURIComponent(fundId)}/bond-sells`, token, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Idempotency-Key": payload.idempotencyKey
+ },
+ body
+ });
+}
+
+export function getBondCalendar(token, fundId) {
+ return requestJson(`/api/funds/${encodeURIComponent(fundId)}/bond-calendar`, token);
+}
+
export function getFundValuation(token, fundId) {
return requestJson(`/api/funds/${encodeURIComponent(fundId)}/valuation`, token);
}
diff --git a/tests/FundLab.Api.Tests/BondTradeTests.fs b/tests/FundLab.Api.Tests/BondTradeTests.fs
index b176aa8..5fdcc4b 100644
--- a/tests/FundLab.Api.Tests/BondTradeTests.fs
+++ b/tests/FundLab.Api.Tests/BondTradeTests.fs
@@ -105,6 +105,33 @@ type BondTradeTests(fixture: PostgresFixture) =
[ "Authorization", "Bearer test-token" ]
""
+ let sell (probe: IBondQuoteProbe) fundId body idempotencyKey =
+ PersistenceTestHelpers.invoke
+ (app probe)
+ "POST"
+ (sprintf "/api/funds/%O/bond-sells" fundId)
+ [ "Authorization", "Bearer test-token"; "Idempotency-Key", idempotencyKey ]
+ body
+
+ let readSells (probe: IBondQuoteProbe) fundId =
+ PersistenceTestHelpers.invoke
+ (app probe)
+ "GET"
+ (sprintf "/api/funds/%O/bond-sells" fundId)
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ let readCalendar (probe: IBondQuoteProbe) fundId fromDate toDate =
+ PersistenceTestHelpers.invoke
+ (app probe)
+ "GET"
+ (sprintf "/api/funds/%O/bond-calendar?from=%s&to=%s" fundId fromDate toDate)
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ let buyTen (probe: IBondQuoteProbe) fundId key =
+ buy probe fundId """{"instrumentCode":"110075","quantity":"10","tradeDate":"2026-09-21"}""" key
+
[<Fact>]
member _.``buying a bond records clean price, accrued interest, dirty price and settlement``() =
let fundId = createFund ()
@@ -297,3 +324,130 @@ type BondTradeTests(fixture: PostgresFixture) =
Assert.Equal(404, status)
Assert.Contains("FUND_NOT_FOUND", body)
+
+ [<Fact>]
+ member _.``selling part of a bond position credits dirty proceeds and trims the position``() =
+ let fundId = createFund ()
+ let probe = BondTradeSupport.stubQuoteProbe BondTradeSupport.quote110075
+
+ let buyStatus, _ = buyTen probe fundId (fixture.Key "bond-sell-buy")
+ Assert.Equal(201, buyStatus)
+
+ let status, body =
+ sell probe fundId """{"instrumentCode":"110075","quantity":"4","tradeDate":"2026-09-21"}""" (fixture.Key "bond-sell-partial")
+
+ Assert.Equal(201, status)
+ Assert.Contains("\"cleanPrice\":\"106.36800000\"", body)
+ Assert.Contains("\"accruedInterest\":\"1.86849300\"", body)
+ Assert.Contains("\"price\":\"108.23649300\"", body)
+ Assert.Contains("\"proceeds\":\"432.95\"", body)
+ Assert.Contains("\"costReleased\":\"432.94\"", body)
+ Assert.Contains("\"realizedPnl\":\"0.01\"", body)
+
+ let _, positionsBody = readPositions probe fundId
+ Assert.Contains("\"quantity\":\"6.00000000\"", positionsBody)
+
+ [<Fact>]
+ member _.``selling the whole bond position removes it and reports zero realized pnl at cost``() =
+ let fundId = createFund ()
+ let probe = BondTradeSupport.stubQuoteProbe BondTradeSupport.quote110075
+
+ let buyStatus, _ = buyTen probe fundId (fixture.Key "bond-sell-full-buy")
+ Assert.Equal(201, buyStatus)
+
+ let status, body =
+ sell probe fundId """{"instrumentCode":"110075","quantity":"10","tradeDate":"2026-09-21"}""" (fixture.Key "bond-sell-full")
+
+ Assert.Equal(201, status)
+ Assert.Contains("\"realizedPnl\":\"0.00\"", body)
+
+ let _, positionsBody = readPositions probe fundId
+ Assert.Contains("\"positions\":[]", positionsBody)
+
+ [<Fact>]
+ member _.``selling more than held is rejected and leaves the position untouched``() =
+ let fundId = createFund ()
+ let probe = BondTradeSupport.stubQuoteProbe BondTradeSupport.quote110075
+
+ let buyStatus, _ = buyTen probe fundId (fixture.Key "bond-sell-over-buy")
+ Assert.Equal(201, buyStatus)
+
+ let status, body =
+ sell probe fundId """{"instrumentCode":"110075","quantity":"20","tradeDate":"2026-09-21"}""" (fixture.Key "bond-sell-over")
+
+ Assert.Equal(400, status)
+ Assert.Contains("INSUFFICIENT_BOND_HOLDINGS", body)
+
+ let _, positionsBody = readPositions probe fundId
+ Assert.Contains("\"quantity\":\"10.00000000\"", positionsBody)
+
+ [<Fact>]
+ member _.``replaying a bond sell with the same idempotency key does not sell twice``() =
+ let fundId = createFund ()
+ let probe = BondTradeSupport.stubQuoteProbe BondTradeSupport.quote110075
+
+ let buyStatus, _ = buyTen probe fundId (fixture.Key "bond-sell-replay-buy")
+ Assert.Equal(201, buyStatus)
+
+ let key = fixture.Key "bond-sell-replay"
+ let body = """{"instrumentCode":"110075","quantity":"4","tradeDate":"2026-09-21"}"""
+
+ let firstStatus, _ = sell probe fundId body key
+ Assert.Equal(201, firstStatus)
+
+ let secondStatus, secondBody = sell probe fundId body key
+ Assert.Equal(200, secondStatus)
+ Assert.Contains("\"proceeds\":\"432.95\"", secondBody)
+
+ let _, positionsBody = readPositions probe fundId
+ Assert.Contains("\"quantity\":\"6.00000000\"", positionsBody)
+
+ [<Fact>]
+ member _.``bond calendar exposes scheduled maturity and recorded cashflow in range``() =
+ let fundId = createFund ()
+ let probe = BondTradeSupport.stubQuoteProbe BondTradeSupport.quote110075
+
+ let buyStatus, _ = buyTen probe fundId (fixture.Key "bond-calendar-buy")
+ Assert.Equal(201, buyStatus)
+
+ let status, body = readCalendar probe fundId "2026-09-01" "2026-10-31"
+ Assert.Equal(200, status)
+ Assert.Contains("\"fromDate\":\"2026-09-01\"", body)
+ Assert.Contains("\"toDate\":\"2026-10-31\"", body)
+ Assert.Contains("\"eventType\":\"maturity\"", body)
+ Assert.Contains("\"eventDate\":\"2026-10-15\"", body)
+ Assert.Contains("\"source\":\"scheduled\"", body)
+
+ let couponStatus, _ =
+ recordCashflow
+ probe
+ fundId
+ """{"instrumentCode":"110075","eventType":"coupon","eventDate":"2026-10-15","quantity":"10","amount":"20.00"}"""
+ (fixture.Key "bond-calendar-coupon")
+
+ Assert.Equal(201, couponStatus)
+
+ let _, withRecorded = readCalendar probe fundId "2026-09-01" "2026-10-31"
+ Assert.Contains("\"source\":\"recorded\"", withRecorded)
+ Assert.Contains("\"eventType\":\"coupon\"", withRecorded)
+
+ [<Fact>]
+ member _.``redemption records like maturity and clears the position``() =
+ let fundId = createFund ()
+ let probe = BondTradeSupport.stubQuoteProbe BondTradeSupport.quote110075
+
+ let buyStatus, _ = buyTen probe fundId (fixture.Key "bond-redeem-buy")
+ Assert.Equal(201, buyStatus)
+
+ let status, body =
+ recordCashflow
+ probe
+ fundId
+ """{"instrumentCode":"110075","eventType":"redemption","eventDate":"2026-06-10","quantity":"10","amount":"1050.00"}"""
+ (fixture.Key "bond-redeem-event")
+
+ Assert.Equal(201, status)
+ Assert.Contains("\"eventType\":\"redemption\"", body)
+
+ let _, positionsBody = readPositions probe fundId
+ Assert.Contains("\"positions\":[]", positionsBody)