summaryrefslogtreecommitdiff
path: root/src/FundLab.Api/App.fs
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-22 04:29:42 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-22 04:29:42 +0800
commitb6008d67232756d743699472865025159f6ec533 (patch)
tree739cc45c5e9677a08c03cebd0d52a8b773984754 /src/FundLab.Api/App.fs
parentb5a80f69cb2e2793e5134e23a0eb3e72cdbdbe0b (diff)
downloadfund-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.fs158
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 [])