summaryrefslogtreecommitdiff
path: root/src/FundLab.Api/App.fs
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 /src/FundLab.Api/App.fs
parent3abe605c205e0524803312eb259dc85b92051ca6 (diff)
downloadfund-lab-d4b0c26b396bfcf1be029d8db3a1c0fc033a6765.tar.gz
Add bond sell/redemption ledger, bond cashflow events and maturity calendar (3d-30 A)
Diffstat (limited to 'src/FundLab.Api/App.fs')
-rw-r--r--src/FundLab.Api/App.fs338
1 files changed, 338 insertions, 0 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)