diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-22 04:29:42 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-22 04:29:42 +0800 |
| commit | b6008d67232756d743699472865025159f6ec533 (patch) | |
| tree | 739cc45c5e9677a08c03cebd0d52a8b773984754 /src/FundLab.Api/App.fs | |
| parent | b5a80f69cb2e2793e5134e23a0eb3e72cdbdbe0b (diff) | |
| download | fund-lab-b6008d67232756d743699472865025159f6ec533.tar.gz | |
Add stock buy to holdings minimal vertical slice (3d-21)
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 82f695e..fb55743 100644 --- a/src/FundLab.Api/App.fs +++ b/src/FundLab.Api/App.fs @@ -132,6 +132,34 @@ type FundPositionsResponse = positions: FundPositionResponse list } +type StockTradeResponse = + { + id: Guid + fundId: Guid + instrumentCode: string + stockName: string option + quantity: string + price: string + costCash: string + executedAt: string + isSynthetic: bool + } + +type StockPositionResponse = + { + instrumentCode: string + stockName: string option + quantity: string + costCash: string + lastTradedAt: string + } + +type StockPositionsResponse = + { + fundId: Guid + positions: StockPositionResponse list + } + type SipPlanResponse = { id: Guid @@ -482,6 +510,28 @@ module App = isSynthetic = order.IsSynthetic } + let private stockTradeResponse (trade: StockTradeRecord) : StockTradeResponse = + { + id = trade.Id + fundId = trade.FundId + instrumentCode = trade.InstrumentCode + stockName = trade.StockName + quantity = decimalText trade.Quantity + price = decimalText trade.Price + costCash = cashText trade.CostCash + executedAt = timestampText trade.ExecutedAt + isSynthetic = trade.IsSynthetic + } + + let private stockPositionResponse (position: StockPositionRecord) : StockPositionResponse = + { + instrumentCode = position.InstrumentCode + stockName = position.StockName + quantity = decimalText position.Quantity + costCash = cashText position.CostCash + lastTradedAt = timestampText position.LastTradedAt + } + let private capitalDepositResponse (deposit: CapitalDepositRecord) : CapitalDepositResponse = { id = deposit.Id @@ -750,6 +800,36 @@ module App = with | :? JsonException -> Error "request body must be valid JSON" + let private parseStockTradeCommand (body: string) : Result<StockTradeCommand, 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() + StockName = tryStringProperty root "stockName" + 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) @@ -1810,6 +1890,82 @@ module App = ctx | Error failure -> marketDataError failure next ctx + let private createStockTrade (repository: FundRepository) (probes: MarketProbes option) (fundIdText: string) : HttpHandler = + fun next ctx -> + task { + match Guid.TryParse fundIdText with + | false, _ -> + return! invokeHandler (errorResponse 400 "INVALID_STOCK_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 parseStockTradeCommand body with + | Error message -> + return! invokeHandler (errorResponse 400 "INVALID_STOCK_TRADE_REQUEST" message) next ctx + | Ok command -> + match probes with + | None -> + return! invokeHandler (marketDataError (MarketDataCollectorUnavailable "stock quote probe is not configured")) next ctx + | Some configured -> + let quoteResult = configured.StockQuotes.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 "stock quote did not include a price")) + next + ctx + | Some price -> + let resolvedName = + match command.StockName 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; StockName = resolvedName } + + try + match repository.CreateStockTrade(idempotencyKey, fundId, priced) with + | StockTradeWriteResult.StockTradeCreated trade -> + return! invokeHandler (setStatusCode 201 >=> json (stockTradeResponse trade)) next ctx + | StockTradeWriteResult.StockTradeReplayed trade -> + return! invokeHandler (json (stockTradeResponse trade)) next ctx + | StockTradeWriteResult.StockTradeIdempotencyConflict -> + return! invokeHandler (errorResponse 409 "IDEMPOTENCY_CONFLICT" "idempotency key was used with a different request") next ctx + | StockTradeWriteResult.StockTradeInvalid message -> + return! invokeHandler (errorResponse 400 "INVALID_STOCK_TRADE_REQUEST" message) next ctx + | StockTradeWriteResult.StockTradeFundNotFound -> + return! invokeHandler (errorResponse 404 "FUND_NOT_FOUND" "fund was not found") next ctx + with _ -> + return! invokeHandler (errorResponse 500 "PERSISTENCE_ERROR" "stock trade persistence failed") next ctx + } + + let private getStockPositions (repository: FundRepository) (fundIdText: string) : HttpHandler = + fun next ctx -> + match Guid.TryParse fundIdText with + | false, _ -> errorResponse 400 "INVALID_STOCK_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.GetStockPositions fundId + |> List.map stockPositionResponse + + json ({ fundId = fund.Id; positions = positions } : StockPositionsResponse) next ctx + with _ -> + errorResponse 500 "PERSISTENCE_ERROR" "stock position persistence failed" next ctx + let private marketProbeRoutes (probes: MarketProbes) = [ GET >=> route "/market/nav-dates" >=> getMarketNavDates probes.NavDates @@ -1851,6 +2007,8 @@ module App = POST >=> routef "/funds/%s/investment-plans/run" (runInvestmentPlans repository) POST >=> routef "/funds/%s/investment-plans" (createInvestmentPlan repository) 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) GET >=> routef "/funds/%s" (getFund repository) ] @ (marketData |> Option.map marketDataRoutes |> Option.defaultValue []) |
