diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-22 04:45:10 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-22 04:45:10 +0800 |
| commit | 0183b86f18d9897ff9487e6d864898954397991d (patch) | |
| tree | 245c8f197f5803802dd630f278e9f2a08affa4ec /src/FundLab.Api/App.fs | |
| parent | b6008d67232756d743699472865025159f6ec533 (diff) | |
| download | fund-lab-0183b86f18d9897ff9487e6d864898954397991d.tar.gz | |
Add bond buy to holdings minimal vertical slice (3d-22)
Diffstat (limited to 'src/FundLab.Api/App.fs')
| -rw-r--r-- | src/FundLab.Api/App.fs | 158 |
1 files changed, 158 insertions, 0 deletions
diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs index fb55743..36a8565 100644 --- a/src/FundLab.Api/App.fs +++ b/src/FundLab.Api/App.fs @@ -160,6 +160,34 @@ type StockPositionsResponse = positions: StockPositionResponse list } +type BondTradeResponse = + { + id: Guid + fundId: Guid + instrumentCode: string + bondName: string option + quantity: string + price: string + costCash: string + executedAt: string + isSynthetic: bool + } + +type BondPositionResponse = + { + instrumentCode: string + bondName: string option + quantity: string + costCash: string + lastTradedAt: string + } + +type BondPositionsResponse = + { + fundId: Guid + positions: BondPositionResponse list + } + type SipPlanResponse = { id: Guid @@ -532,6 +560,28 @@ module App = lastTradedAt = timestampText position.LastTradedAt } + let private bondTradeResponse (trade: BondTradeRecord) : BondTradeResponse = + { + id = trade.Id + fundId = trade.FundId + instrumentCode = trade.InstrumentCode + bondName = trade.BondName + quantity = decimalText trade.Quantity + price = decimalText trade.Price + costCash = cashText trade.CostCash + executedAt = timestampText trade.ExecutedAt + isSynthetic = trade.IsSynthetic + } + + let private bondPositionResponse (position: BondPositionRecord) : BondPositionResponse = + { + instrumentCode = position.InstrumentCode + bondName = position.BondName + quantity = decimalText position.Quantity + costCash = cashText position.CostCash + lastTradedAt = timestampText position.LastTradedAt + } + let private capitalDepositResponse (deposit: CapitalDepositRecord) : CapitalDepositResponse = { id = deposit.Id @@ -830,6 +880,36 @@ module App = with | :? JsonException -> Error "request body must be valid JSON" + let private parseBondTradeCommand (body: string) : Result<BondTradeCommand, 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" with + | None -> Error "instrumentCode is required" + | Some code -> + if code.Trim().Length <> 6 || not (code.Trim() |> Seq.forall Char.IsDigit) then + Error "instrumentCode must contain exactly six digits" + else + match tryStringProperty root "quantity" with + | None -> Error "quantity is required" + | Some quantityText -> + match tryDecimal "quantity" quantityText with + | Error message -> Error message + | Ok quantity -> + Ok + { + InstrumentCode = code.Trim() + BondName = tryStringProperty root "bondName" + Quantity = quantity + Price = 0m + } + with + | :? JsonException -> Error "request body must be valid JSON" + let private parseSipPlanCommand (body: string) : Result<SipPlanCommand, string> = try use document = JsonDocument.Parse(body) @@ -1966,6 +2046,82 @@ module App = with _ -> errorResponse 500 "PERSISTENCE_ERROR" "stock position persistence failed" next ctx + let private createBondTrade (repository: FundRepository) (probes: MarketProbes option) (fundIdText: string) : HttpHandler = + fun next ctx -> + task { + match Guid.TryParse fundIdText with + | false, _ -> + return! invokeHandler (errorResponse 400 "INVALID_BOND_TRADE_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 parseBondTradeCommand body with + | Error message -> + return! invokeHandler (errorResponse 400 "INVALID_BOND_TRADE_REQUEST" message) next ctx + | Ok command -> + match probes with + | None -> + return! invokeHandler (marketDataError (MarketDataCollectorUnavailable "bond quote probe is not configured")) next ctx + | Some configured -> + let quoteResult = configured.BondQuotes.GetQuote(command.InstrumentCode, ctx.RequestAborted) + + match quoteResult with + | Error failure -> + return! invokeHandler (marketDataError failure) next ctx + | Ok quote -> + match quote.Price with + | None -> + return! + invokeHandler + (marketDataError (InvalidMarketDataPayload "bond quote did not include a price")) + next + ctx + | Some price -> + 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 = price; BondName = resolvedName } + + try + match repository.CreateBondTrade(idempotencyKey, fundId, priced) with + | BondTradeWriteResult.BondTradeCreated trade -> + return! invokeHandler (setStatusCode 201 >=> json (bondTradeResponse trade)) next ctx + | BondTradeWriteResult.BondTradeReplayed trade -> + return! invokeHandler (json (bondTradeResponse trade)) next ctx + | BondTradeWriteResult.BondTradeIdempotencyConflict -> + return! invokeHandler (errorResponse 409 "IDEMPOTENCY_CONFLICT" "idempotency key was used with a different request") next ctx + | BondTradeWriteResult.BondTradeInvalid message -> + return! invokeHandler (errorResponse 400 "INVALID_BOND_TRADE_REQUEST" message) next ctx + | BondTradeWriteResult.BondTradeFundNotFound -> + return! invokeHandler (errorResponse 404 "FUND_NOT_FOUND" "fund was not found") next ctx + with _ -> + return! invokeHandler (errorResponse 500 "PERSISTENCE_ERROR" "bond trade persistence failed") next ctx + } + + let private getBondPositions (repository: FundRepository) (fundIdText: string) : HttpHandler = + fun next ctx -> + match Guid.TryParse fundIdText with + | false, _ -> errorResponse 400 "INVALID_BOND_TRADE_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 positions = + repository.GetBondPositions fundId + |> List.map bondPositionResponse + + json ({ fundId = fund.Id; positions = positions } : BondPositionsResponse) next ctx + with _ -> + errorResponse 500 "PERSISTENCE_ERROR" "bond position persistence failed" next ctx + let private marketProbeRoutes (probes: MarketProbes) = [ GET >=> route "/market/nav-dates" >=> getMarketNavDates probes.NavDates @@ -2009,6 +2165,8 @@ module App = GET >=> routef "/funds/%s/investment-plans" (getInvestmentPlans repository) POST >=> routef "/funds/%s/stock-trades" (createStockTrade repository probes) 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) GET >=> routef "/funds/%s" (getFund repository) ] @ (marketData |> Option.map marketDataRoutes |> Option.defaultValue []) |
