diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-22 06:20:25 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-22 06:20:25 +0800 |
| commit | c48b5560778548e2e87e05b1df5b170d7db1a345 (patch) | |
| tree | 95449a1c1437cba003373454ba77d15a047cbecd /src/FundLab.Api/App.fs | |
| parent | 79600c15aef9bd411abe88dcf36286a102b5abea (diff) | |
| download | fund-lab-c48b5560778548e2e87e05b1df5b170d7db1a345.tar.gz | |
Add stock sell vertical slice with cash recovery and snapshot pricing (3d-26)
Diffstat (limited to 'src/FundLab.Api/App.fs')
| -rw-r--r-- | src/FundLab.Api/App.fs | 179 |
1 files changed, 179 insertions, 0 deletions
diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs index 44a9afe..896a2c4 100644 --- a/src/FundLab.Api/App.fs +++ b/src/FundLab.Api/App.fs @@ -160,6 +160,20 @@ type StockPositionsResponse = positions: StockPositionResponse list } +type StockSellResponse = + { + id: Guid + fundId: Guid + instrumentCode: string + stockName: string option + quantity: string + price: string + feeAmount: string + proceeds: string + executedAt: string + isSynthetic: bool + } + type BondTradeResponse = { id: Guid @@ -599,6 +613,20 @@ module App = isSynthetic = trade.IsSynthetic } + let private stockSellResponse (sell: StockSellRecord) : StockSellResponse = + { + id = sell.Id + fundId = sell.FundId + instrumentCode = sell.InstrumentCode + stockName = sell.StockName + quantity = decimalText sell.Quantity + price = decimalText sell.Price + feeAmount = cashText sell.FeeAmount + proceeds = cashText sell.Proceeds + executedAt = timestampText sell.ExecutedAt + isSynthetic = sell.IsSynthetic + } + let private stockPositionResponse (position: StockPositionRecord) : StockPositionResponse = { instrumentCode = position.InstrumentCode @@ -928,6 +956,51 @@ module App = with | :? JsonException -> Error "request body must be valid JSON" + let private parseStockSellCommand (body: string) : Result<StockSellCommand, 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 -> + match tryStringProperty root "feeAmount" with + | None -> + Ok + { + InstrumentCode = code.Trim() + StockName = tryStringProperty root "stockName" + Quantity = quantity + Price = 0m + FeeAmount = 0m + } + | Some feeText -> + match tryDecimal "feeAmount" feeText with + | Error message -> Error message + | Ok fee -> + Ok + { + InstrumentCode = code.Trim() + StockName = tryStringProperty root "stockName" + Quantity = quantity + Price = 0m + FeeAmount = fee + } + with + | :? JsonException -> Error "request body must be valid JSON" + let private parseBondTradeCommand (body: string) : Result<BondTradeCommand, string> = try use document = JsonDocument.Parse(body) @@ -2149,6 +2222,111 @@ module App = with _ -> errorResponse 500 "PERSISTENCE_ERROR" "stock position persistence failed" next ctx + let private completeStockSell + (repository: FundRepository) + (fundId: Guid) + (idempotencyKey: string) + (command: StockSellCommand) + next + ctx + = + task { + try + match repository.CreateStockSell(idempotencyKey, fundId, command) with + | StockSellWriteResult.StockSellCreated sell -> + return! invokeHandler (setStatusCode 201 >=> json (stockSellResponse sell)) next ctx + | StockSellWriteResult.StockSellReplayed sell -> + return! invokeHandler (json (stockSellResponse sell)) next ctx + | StockSellWriteResult.StockSellIdempotencyConflict -> + return! invokeHandler (errorResponse 409 "IDEMPOTENCY_CONFLICT" "idempotency key was used with a different request") next ctx + | StockSellWriteResult.StockSellInsufficientHoldings message -> + return! invokeHandler (errorResponse 400 "INSUFFICIENT_STOCK_HOLDINGS" message) next ctx + | StockSellWriteResult.StockSellInvalid message -> + return! invokeHandler (errorResponse 400 "INVALID_STOCK_SELL_REQUEST" message) next ctx + | StockSellWriteResult.StockSellFundNotFound -> + return! invokeHandler (errorResponse 404 "FUND_NOT_FOUND" "fund was not found") next ctx + with _ -> + return! invokeHandler (errorResponse 500 "PERSISTENCE_ERROR" "stock sale persistence failed") next ctx + } + + let private createStockSell (repository: FundRepository) (probes: MarketProbes option) (fundIdText: string) : HttpHandler = + fun next ctx -> + task { + match Guid.TryParse fundIdText with + | false, _ -> + return! invokeHandler (errorResponse 400 "INVALID_STOCK_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 parseStockSellCommand body with + | Error message -> + return! invokeHandler (errorResponse 400 "INVALID_STOCK_SELL_REQUEST" message) next ctx + | Ok command -> + let asOfDate = ConfirmationPolicy.eventDateFor DateTimeOffset.UtcNow + + let snapshotPrice = + try + repository.GetLatestSnapshots(fundId, "stock", asOfDate) + |> Map.tryFind command.InstrumentCode + |> Option.map (fun snapshot -> snapshot.Price) + with _ -> + None + + let existingName = + try + repository.GetStockPositions fundId + |> List.tryFind (fun position -> position.InstrumentCode = command.InstrumentCode) + |> Option.bind (fun position -> position.StockName) + with _ -> + None + + let resolveName (candidate: string option) = + match candidate with + | Some name when not (String.IsNullOrWhiteSpace name) -> Some name + | _ -> existingName + + match snapshotPrice with + | Some price -> + return! + completeStockSell + repository + fundId + idempotencyKey + { command with Price = price; StockName = resolveName command.StockName } + next + ctx + | None -> + match probes with + | None -> + return! + invokeHandler + (marketDataError (MarketDataCollectorUnavailable "stock quote probe is not configured")) + next + ctx + | Some configured -> + match configured.StockQuotes.GetQuote(command.InstrumentCode, ctx.RequestAborted) 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 -> + return! + completeStockSell + repository + fundId + idempotencyKey + { command with Price = price; StockName = resolveName quote.Name } + next + ctx + } + let private createBondTrade (repository: FundRepository) (probes: MarketProbes option) (fundIdText: string) : HttpHandler = fun next ctx -> task { @@ -2584,6 +2762,7 @@ module App = 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) + POST >=> routef "/funds/%s/stock-sells" (createStockSell 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) |
