diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-22 08:52:59 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-22 08:52:59 +0800 |
| commit | d4b0c26b396bfcf1be029d8db3a1c0fc033a6765 (patch) | |
| tree | ad36325d5507c1607ad8de8496ded8afb0d73e31 /src/FundLab.Api/App.fs | |
| parent | 3abe605c205e0524803312eb259dc85b92051ca6 (diff) | |
| download | fund-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.fs | 338 |
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) |
