summaryrefslogtreecommitdiff
path: root/src/FundLab.Api/App.fs
diff options
context:
space:
mode:
Diffstat (limited to 'src/FundLab.Api/App.fs')
-rw-r--r--src/FundLab.Api/App.fs158
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 [])