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.fs179
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)