summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-22 06:20:25 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-22 06:20:25 +0800
commitc48b5560778548e2e87e05b1df5b170d7db1a345 (patch)
tree95449a1c1437cba003373454ba77d15a047cbecd /src
parent79600c15aef9bd411abe88dcf36286a102b5abea (diff)
downloadfund-lab-c48b5560778548e2e87e05b1df5b170d7db1a345.tar.gz
Add stock sell vertical slice with cash recovery and snapshot pricing (3d-26)
Diffstat (limited to 'src')
-rw-r--r--src/FundLab.Api/App.fs179
-rw-r--r--src/FundLab.Api/Persistence.fs366
-rw-r--r--src/FundLab.Web/App.fs126
-rw-r--r--src/FundLab.Web/src/api.js12
-rw-r--r--src/FundLab.Web/src/styles.css11
5 files changed, 693 insertions, 1 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)
diff --git a/src/FundLab.Api/Persistence.fs b/src/FundLab.Api/Persistence.fs
index 09e3e0e..1cf68cd 100644
--- a/src/FundLab.Api/Persistence.fs
+++ b/src/FundLab.Api/Persistence.fs
@@ -327,6 +327,37 @@ type StockTradeWriteResult =
| StockTradeInvalid of string
| StockTradeFundNotFound
+type StockSellCommand =
+ {
+ InstrumentCode: string
+ StockName: string option
+ Quantity: decimal
+ Price: decimal
+ FeeAmount: decimal
+ }
+
+type StockSellRecord =
+ {
+ Id: Guid
+ FundId: Guid
+ InstrumentCode: string
+ StockName: string option
+ Quantity: decimal
+ Price: decimal
+ FeeAmount: decimal
+ Proceeds: decimal
+ IsSynthetic: bool
+ ExecutedAt: DateTimeOffset
+ }
+
+type StockSellWriteResult =
+ | StockSellCreated of StockSellRecord
+ | StockSellReplayed of StockSellRecord
+ | StockSellIdempotencyConflict
+ | StockSellInvalid of string
+ | StockSellInsufficientHoldings of string
+ | StockSellFundNotFound
+
type BondTradeCommand =
{
InstrumentCode: string
@@ -955,6 +986,27 @@ type FundRepository(connectionString: string) =
PRIMARY KEY (fund_id, instrument_code)
);
+ CREATE TABLE IF NOT EXISTS stock_sells (
+ id uuid PRIMARY KEY,
+ fund_id uuid NOT NULL REFERENCES funds(id),
+ instrument_code text NOT NULL,
+ stock_name text NULL,
+ quantity numeric(28, 8) NOT NULL CHECK (quantity > 0),
+ price numeric(20, 4) NOT NULL CHECK (price > 0),
+ fee_amount numeric(20, 2) NOT NULL CHECK (fee_amount >= 0),
+ proceeds numeric(20, 2) NOT NULL CHECK (proceeds >= 0),
+ is_synthetic boolean NOT NULL,
+ executed_at timestamptz NOT NULL
+ );
+
+ CREATE TABLE IF NOT EXISTS stock_sell_idempotencies (
+ idempotency_key text PRIMARY KEY,
+ request_hash text NOT NULL,
+ sell_id uuid NOT NULL REFERENCES stock_sells(id),
+ fund_id uuid NOT NULL REFERENCES funds(id),
+ created_at timestamptz NOT NULL DEFAULT now()
+ );
+
CREATE TABLE IF NOT EXISTS bond_trades (
id uuid PRIMARY KEY,
fund_id uuid NOT NULL REFERENCES funds(id),
@@ -1977,6 +2029,126 @@ type FundRepository(connectionString: string) =
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload)))
+ let stockSellRecordFromReader (reader: DbDataReader) : StockSellRecord =
+ {
+ Id = reader.GetGuid(0)
+ FundId = reader.GetGuid(1)
+ InstrumentCode = reader.GetString(2)
+ StockName = if reader.IsDBNull(3) then None else Some(reader.GetString(3))
+ Quantity = reader.GetDecimal(4)
+ Price = reader.GetDecimal(5)
+ FeeAmount = reader.GetDecimal(6)
+ Proceeds = reader.GetDecimal(7)
+ IsSynthetic = reader.GetBoolean(8)
+ ExecutedAt = reader.GetFieldValue<DateTimeOffset>(9)
+ }
+
+ let insertStockSell connection transaction (sell: StockSellRecord) =
+ use command =
+ commandWithTransaction
+ connection
+ transaction
+ """
+ INSERT INTO stock_sells
+ (id, fund_id, instrument_code, stock_name, quantity, price, fee_amount, proceeds, is_synthetic, executed_at)
+ VALUES
+ (@id, @fund_id, @instrument_code, @stock_name, @quantity, @price, @fee_amount, @proceeds, @is_synthetic, @executed_at)
+ """
+
+ addParameter command "id" NpgsqlDbType.Uuid (box sell.Id) |> ignore
+ addParameter command "fund_id" NpgsqlDbType.Uuid (box sell.FundId) |> ignore
+ addParameter command "instrument_code" NpgsqlDbType.Text (box sell.InstrumentCode) |> ignore
+
+ let nameParameter =
+ match sell.StockName with
+ | Some name -> box name
+ | None -> box DBNull.Value
+
+ addParameter command "stock_name" NpgsqlDbType.Text nameParameter |> ignore
+ addParameter command "quantity" NpgsqlDbType.Numeric (box sell.Quantity) |> ignore
+ addParameter command "price" NpgsqlDbType.Numeric (box sell.Price) |> ignore
+ addParameter command "fee_amount" NpgsqlDbType.Numeric (box sell.FeeAmount) |> ignore
+ addParameter command "proceeds" NpgsqlDbType.Numeric (box sell.Proceeds) |> ignore
+ addParameter command "is_synthetic" NpgsqlDbType.Boolean (box sell.IsSynthetic) |> ignore
+ addParameter command "executed_at" NpgsqlDbType.TimestampTz (box sell.ExecutedAt) |> ignore
+ command.ExecuteNonQuery() |> ignore
+
+ let insertStockSellIdempotency connection transaction key requestHash sellId fundId =
+ use command =
+ commandWithTransaction
+ connection
+ transaction
+ """
+ INSERT INTO stock_sell_idempotencies (idempotency_key, request_hash, sell_id, fund_id)
+ VALUES (@idempotency_key, @request_hash, @sell_id, @fund_id)
+ """
+
+ addParameter command "idempotency_key" NpgsqlDbType.Text (box key) |> ignore
+ addParameter command "request_hash" NpgsqlDbType.Text (box requestHash) |> ignore
+ addParameter command "sell_id" NpgsqlDbType.Uuid (box sellId) |> ignore
+ addParameter command "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore
+ command.ExecuteNonQuery() |> ignore
+
+ let findStockSellIdempotency connection transaction key =
+ use command =
+ commandWithTransaction
+ connection
+ transaction
+ """
+ SELECT request_hash, fund_id, sell_id
+ FROM stock_sell_idempotencies
+ WHERE idempotency_key = @idempotency_key
+ """
+
+ addParameter command "idempotency_key" NpgsqlDbType.Text (box key) |> ignore
+
+ use reader = command.ExecuteReader()
+
+ if reader.Read() then
+ Some(reader.GetString(0), reader.GetGuid(1), reader.GetGuid(2))
+ else
+ None
+
+ let findStockSell connection transaction sellId =
+ use command =
+ commandWithTransaction
+ connection
+ transaction
+ """
+ SELECT id, fund_id, instrument_code, stock_name, quantity, price, fee_amount, proceeds, is_synthetic, executed_at
+ FROM stock_sells
+ WHERE id = @id
+ """
+
+ addParameter command "id" NpgsqlDbType.Uuid (box sellId) |> ignore
+
+ use reader = command.ExecuteReader()
+
+ if reader.Read() then
+ Some(stockSellRecordFromReader reader)
+ else
+ None
+
+ let stockSellRequestHash (fundId: Guid) (command: StockSellCommand) =
+ let invariant = CultureInfo.InvariantCulture
+ let encoded (value: string) = sprintf "%d:%s" value.Length value
+ let name = command.StockName |> Option.defaultValue ""
+
+ let payload =
+ String.concat
+ "|"
+ [
+ "stock-sell"
+ encoded (fundId.ToString("D"))
+ encoded command.InstrumentCode
+ encoded name
+ encoded (command.Quantity.ToString("G29", invariant))
+ encoded (command.Price.ToString("G29", invariant))
+ encoded (command.FeeAmount.ToString("G29", invariant))
+ ]
+
+ Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload)))
+
let bondTradeRecordFromReader (reader: DbDataReader) : BondTradeRecord =
{
Id = reader.GetGuid(0)
@@ -4791,6 +4963,200 @@ type FundRepository(connectionString: string) =
records |> Seq.toList
+ member _.GetStockSells(fundId: Guid) : StockSellRecord list =
+ use connection = new NpgsqlConnection(connectionString)
+ connection.Open()
+
+ use command =
+ commandWithTransaction
+ connection
+ None
+ """
+ SELECT id, fund_id, instrument_code, stock_name, quantity, price, fee_amount, proceeds, is_synthetic, executed_at
+ FROM stock_sells
+ WHERE fund_id = @fund_id
+ ORDER BY executed_at, id
+ """
+
+ addParameter command "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore
+
+ use reader = command.ExecuteReader()
+ let records = ResizeArray<StockSellRecord>()
+
+ while reader.Read() do
+ records.Add(stockSellRecordFromReader reader)
+
+ records |> Seq.toList
+
+ member _.CreateStockSell(idempotencyKey: string, fundId: Guid, command: StockSellCommand, ?executedAtOverride: DateTimeOffset) : StockSellWriteResult =
+ if String.IsNullOrWhiteSpace idempotencyKey then
+ StockSellWriteResult.StockSellInvalid "idempotency key cannot be empty"
+ else
+ let code = if isNull command.InstrumentCode then "" else command.InstrumentCode.Trim()
+
+ if code.Length <> 6 || not (code |> Seq.forall Char.IsDigit) then
+ StockSellWriteResult.StockSellInvalid "stock code must contain exactly six digits"
+ elif command.Quantity <= 0m then
+ StockSellWriteResult.StockSellInvalid "quantity must be positive"
+ elif command.Price <= 0m then
+ StockSellWriteResult.StockSellInvalid "price must be positive"
+ elif command.FeeAmount < 0m then
+ StockSellWriteResult.StockSellInvalid "fee amount cannot be negative"
+ elif Decimal.Round(command.FeeAmount, 2) <> command.FeeAmount then
+ StockSellWriteResult.StockSellInvalid "fee amount exceeds cash precision"
+ else
+ let normalized = { command with InstrumentCode = code }
+ let gross = Decimal.Round(normalized.Quantity * normalized.Price, 2, MidpointRounding.AwayFromZero)
+
+ if normalized.FeeAmount > gross then
+ StockSellWriteResult.StockSellInvalid "fee amount cannot exceed the sale proceeds"
+ else
+ let fingerprint = stockSellRequestHash fundId normalized
+ use connection = new NpgsqlConnection(connectionString)
+ connection.Open()
+ use transaction = connection.BeginTransaction(IsolationLevel.ReadCommitted)
+
+ try
+ use lockCommand =
+ commandWithTransaction
+ connection
+ (Some transaction)
+ "SELECT pg_advisory_xact_lock(hashtext(@lock_key))"
+
+ addParameter lockCommand "lock_key" NpgsqlDbType.Text (box idempotencyKey) |> ignore
+ lockCommand.ExecuteNonQuery() |> ignore
+
+ match findStockSellIdempotency connection (Some transaction) idempotencyKey with
+ | Some(existingHash, existingFundId, sellId)
+ when existingHash = fingerprint && existingFundId = fundId ->
+ match findStockSell connection (Some transaction) sellId with
+ | Some sell ->
+ transaction.Commit()
+ StockSellWriteResult.StockSellReplayed sell
+ | None ->
+ transaction.Rollback()
+ StockSellWriteResult.StockSellInvalid "idempotency record references a missing sale"
+ | Some _ ->
+ transaction.Rollback()
+ StockSellWriteResult.StockSellIdempotencyConflict
+ | None ->
+ match lockFundForOrder connection (Some transaction) fundId with
+ | None ->
+ transaction.Rollback()
+ StockSellWriteResult.StockSellFundNotFound
+ | Some isSynthetic ->
+ let position =
+ use positionCommand =
+ commandWithTransaction
+ connection
+ (Some transaction)
+ """
+ SELECT quantity, cost_cash
+ FROM stock_positions
+ WHERE fund_id = @fund_id AND instrument_code = @instrument_code
+ FOR UPDATE
+ """
+
+ addParameter positionCommand "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore
+ addParameter positionCommand "instrument_code" NpgsqlDbType.Text (box normalized.InstrumentCode) |> ignore
+
+ use reader = positionCommand.ExecuteReader()
+
+ if reader.Read() then
+ Some(reader.GetDecimal(0), reader.GetDecimal(1))
+ else
+ None
+
+ match position with
+ | None ->
+ transaction.Rollback()
+ StockSellWriteResult.StockSellInsufficientHoldings(sprintf "no stock position in %s to sell" normalized.InstrumentCode)
+ | Some(heldQuantity, _) when heldQuantity < normalized.Quantity ->
+ transaction.Rollback()
+
+ StockSellWriteResult.StockSellInsufficientHoldings(
+ sprintf
+ "available holdings %s are not enough for the requested sale quantity %s"
+ (heldQuantity.ToString("G29", CultureInfo.InvariantCulture))
+ (normalized.Quantity.ToString("G29", CultureInfo.InvariantCulture))
+ )
+ | Some(heldQuantity, heldCost) ->
+ let executedAt = defaultArg executedAtOverride DateTimeOffset.UtcNow
+ let proceeds = gross - normalized.FeeAmount
+ let remainingQuantity = heldQuantity - normalized.Quantity
+
+ let releasedCost =
+ if remainingQuantity <= 0m then
+ heldCost
+ else
+ Decimal.Round(heldCost * (normalized.Quantity / heldQuantity), 2, MidpointRounding.AwayFromZero)
+
+ if remainingQuantity <= 0m then
+ use deleteCommand =
+ commandWithTransaction
+ connection
+ (Some transaction)
+ "DELETE FROM stock_positions WHERE fund_id = @fund_id AND instrument_code = @instrument_code"
+
+ addParameter deleteCommand "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore
+ addParameter deleteCommand "instrument_code" NpgsqlDbType.Text (box normalized.InstrumentCode) |> ignore
+ deleteCommand.ExecuteNonQuery() |> ignore
+ else
+ use updateCommand =
+ commandWithTransaction
+ connection
+ (Some transaction)
+ """
+ UPDATE stock_positions
+ SET quantity = @quantity,
+ cost_cash = @cost_cash,
+ last_traded_at = @last_traded_at
+ WHERE fund_id = @fund_id AND instrument_code = @instrument_code
+ """
+
+ addParameter updateCommand "quantity" NpgsqlDbType.Numeric (box remainingQuantity) |> ignore
+ addParameter updateCommand "cost_cash" NpgsqlDbType.Numeric (box (max 0m (heldCost - releasedCost))) |> ignore
+ addParameter updateCommand "last_traded_at" NpgsqlDbType.TimestampTz (box executedAt) |> ignore
+ addParameter updateCommand "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore
+ addParameter updateCommand "instrument_code" NpgsqlDbType.Text (box normalized.InstrumentCode) |> ignore
+ updateCommand.ExecuteNonQuery() |> ignore
+
+ use cashCommand =
+ commandWithTransaction
+ connection
+ (Some transaction)
+ "UPDATE funds SET available_cash = available_cash + @proceeds WHERE id = @fund_id"
+
+ addParameter cashCommand "proceeds" NpgsqlDbType.Numeric (box proceeds) |> ignore
+ addParameter cashCommand "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore
+ cashCommand.ExecuteNonQuery() |> ignore
+
+ let sell: StockSellRecord =
+ {
+ Id = Guid.NewGuid()
+ FundId = fundId
+ InstrumentCode = normalized.InstrumentCode
+ StockName = normalized.StockName
+ Quantity = normalized.Quantity
+ Price = normalized.Price
+ FeeAmount = normalized.FeeAmount
+ Proceeds = proceeds
+ IsSynthetic = isSynthetic
+ ExecutedAt = executedAt
+ }
+
+ insertStockSell connection (Some transaction) sell
+ insertStockSellIdempotency connection (Some transaction) idempotencyKey fingerprint sell.Id fundId
+ transaction.Commit()
+ StockSellWriteResult.StockSellCreated sell
+ with error ->
+ try
+ transaction.Rollback()
+ with _ ->
+ ()
+
+ raise error
+
member _.CreateBondTrade(idempotencyKey: string, fundId: Guid, command: BondTradeCommand, ?executedAtOverride: DateTimeOffset) : BondTradeWriteResult =
if String.IsNullOrWhiteSpace idempotencyKey then
BondTradeWriteResult.BondTradeInvalid "idempotency key cannot be empty"
diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs
index fe16411..c150596 100644
--- a/src/FundLab.Web/App.fs
+++ b/src/FundLab.Web/App.fs
@@ -453,6 +453,20 @@ type RawStockPositions =
positions: RawStockPosition array
}
+type RawStockSell =
+ {
+ id: string
+ fundId: string
+ instrumentCode: string
+ stockName: obj
+ quantity: string
+ price: string
+ feeAmount: string
+ proceeds: string
+ executedAt: string
+ isSynthetic: bool
+ }
+
type RawBondTrade =
{
id: string
@@ -616,6 +630,20 @@ type CreateStockTradePayload =
quantity: string
}
+type CreateStockSellPayload =
+ {
+ idempotencyKey: string
+ instrumentCode: string
+ quantity: string
+ }
+
+type StockSellAttempt =
+ {
+ idempotencyKey: string
+ instrumentCode: string
+ quantity: string
+ }
+
type CreateBondTradePayload =
{
idempotencyKey: string
@@ -1010,6 +1038,9 @@ module Api =
[<Import("getStockPositions", "./src/api.js")>]
let getStockPositions (token: string) (fundId: string) : JS.Promise<RawStockPositions> = jsNative
+ [<Import("createStockSell", "./src/api.js")>]
+ let createStockSell (token: string) (fundId: string) (payload: CreateStockSellPayload) : JS.Promise<RawStockSell> = jsNative
+
[<Import("createBondTrade", "./src/api.js")>]
let createBondTrade (token: string) (fundId: string) (payload: CreateBondTradePayload) : JS.Promise<RawBondTrade> = jsNative
@@ -1504,6 +1535,11 @@ type Model =
stockPositionsInFlight: bool
stockPositions: StockPositionView list
stockPositionsMessage: string option
+ stockSellQuantities: Map<string, string>
+ stockSellAttempts: Map<string, StockSellAttempt>
+ stockSellSeq: int
+ stockSellInFlight: bool
+ stockSellMessage: string option
bondTradeQuantity: string
bondTradeSeq: int
bondTradeInFlight: bool
@@ -1646,6 +1682,10 @@ type Msg =
| StockPositionsReadRequested
| StockPositionsReadCompleted of requestId: int * fundId: string * positions: RawStockPositions
| StockPositionsReadFailed of requestId: int * fundId: string * message: string
+ | StockSellQuantityChanged of code: string * value: string
+ | StockSellRequested of code: string
+ | StockSellCompleted of requestId: int * fundId: string * code: string * sell: RawStockSell
+ | StockSellFailed of requestId: int * fundId: string * code: string * message: string
| BondTradeQuantityChanged of string
| BondTradeRequested
| BondTradeCompleted of requestId: int * fundId: string * trade: RawBondTrade
@@ -1815,6 +1855,11 @@ let init () =
stockPositionsInFlight = false
stockPositions = []
stockPositionsMessage = None
+ stockSellQuantities = Map.empty
+ stockSellAttempts = Map.empty
+ stockSellSeq = 0
+ stockSellInFlight = false
+ stockSellMessage = None
bondTradeQuantity = ""
bondTradeSeq = 0
bondTradeInFlight = false
@@ -2072,6 +2117,13 @@ let private readStockPositionsCommand token fundId requestId =
(fun positions -> StockPositionsReadCompleted(requestId, fundId, positions))
(fun error -> StockPositionsReadFailed(requestId, fundId, errorText error))
+let private createStockSellCommand token fundId payload requestId =
+ Cmd.OfPromise.either
+ (fun () -> Api.createStockSell token fundId payload)
+ ()
+ (fun sell -> StockSellCompleted(requestId, fundId, payload.instrumentCode, sell))
+ (fun error -> StockSellFailed(requestId, fundId, payload.instrumentCode, errorText error))
+
let private createBondTradeCommand token fundId payload requestId =
Cmd.OfPromise.either
(fun () -> Api.createBondTrade token fundId payload)
@@ -3616,6 +3668,61 @@ let update message model =
Cmd.none
else
model, Cmd.none
+ | StockSellQuantityChanged (code, value) ->
+ { model with stockSellQuantities = Map.add code value model.stockSellQuantities }, Cmd.none
+ | StockSellRequested code ->
+ match model.createdFund with
+ | Some fund when not (String.IsNullOrWhiteSpace model.token) ->
+ let quantity = (model.stockSellQuantities |> Map.tryFind code |> Option.defaultValue "").Trim()
+
+ if not (isValidQuantityText quantity) then
+ { model with stockSellMessage = Some "卖出数量必须是大于零的整数股数,例如 100" }, Cmd.none
+ else
+ let requestId = model.stockSellSeq + 1
+
+ let idempotencyKey =
+ match model.stockSellAttempts |> Map.tryFind code with
+ | Some attempt when attempt.quantity = quantity -> attempt.idempotencyKey
+ | _ -> Guid.NewGuid().ToString("N")
+
+ let payload : CreateStockSellPayload =
+ { idempotencyKey = idempotencyKey; instrumentCode = code; quantity = quantity }
+
+ let attempt : StockSellAttempt =
+ { idempotencyKey = idempotencyKey; instrumentCode = code; quantity = quantity }
+
+ {
+ model with
+ stockSellSeq = requestId
+ stockSellInFlight = true
+ stockSellMessage = None
+ stockSellAttempts = Map.add code attempt model.stockSellAttempts
+ },
+ createStockSellCommand model.token fund.id payload requestId
+ | _ when String.IsNullOrWhiteSpace model.token ->
+ { model with stockSellMessage = Some "请输入 API token" }, Cmd.none
+ | _ ->
+ { model with stockSellMessage = Some "请先创建一个基金" }, Cmd.none
+ | StockSellCompleted (requestId, fundId, code, sell) ->
+ if requestId = model.stockSellSeq
+ && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then
+ {
+ model with
+ stockSellInFlight = false
+ stockSellMessage =
+ Some(sprintf "已卖出 %s %s 股:成交价 %s,回收现金 %s" code sell.quantity sell.price sell.proceeds)
+ stockSellQuantities = Map.remove code model.stockSellQuantities
+ stockSellAttempts = Map.remove code model.stockSellAttempts
+ },
+ Cmd.batch [ Cmd.ofMsg StockPositionsReadRequested; Cmd.ofMsg ValuationReadRequested ]
+ else
+ model, Cmd.none
+ | StockSellFailed (requestId, fundId, code, message) ->
+ if requestId = model.stockSellSeq
+ && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then
+ { model with stockSellInFlight = false; stockSellMessage = Some(sprintf "卖出失败 — %s" message) }, Cmd.none
+ else
+ model, Cmd.none
| BondTradeQuantityChanged value -> { model with bondTradeQuantity = value }, Cmd.none
| BondTradeRequested ->
let code = model.bondCode.Trim()
@@ -5788,6 +5895,8 @@ let private stocksPanel model dispatch =
Html.span [ prop.className "fund-detail-label"; prop.text "名称" ]
Html.span [ prop.className "fund-detail-label"; prop.text "数量" ]
Html.span [ prop.className "fund-detail-label"; prop.text "成本" ]
+ Html.span [ prop.className "fund-detail-label"; prop.text "卖出数量" ]
+ Html.span [ prop.className "fund-detail-label"; prop.text "操作" ]
]
]
:: (model.stockPositions
@@ -5799,6 +5908,18 @@ let private stocksPanel model dispatch =
Html.span [ prop.className "fund-detail-value"; prop.text (position.stockName |> Option.defaultValue "—") ]
Html.span [ prop.className "fund-detail-value"; prop.text position.quantity ]
Html.span [ prop.className "fund-detail-value"; prop.text position.costCash ]
+ Html.input [
+ prop.className "text-input stock-sell-quantity-input"
+ prop.placeholder "卖出数量"
+ prop.value (model.stockSellQuantities |> Map.tryFind position.instrumentCode |> Option.defaultValue "")
+ prop.onChange (fun value -> dispatch (StockSellQuantityChanged(position.instrumentCode, value)))
+ ]
+ Html.button [
+ prop.className "secondary-action stock-sell-action"
+ prop.disabled model.stockSellInFlight
+ prop.onClick (fun _ -> dispatch (StockSellRequested position.instrumentCode))
+ prop.text ((if model.stockSellInFlight then "卖出中..." else "卖出"): string)
+ ]
]
]))
)
@@ -5808,6 +5929,11 @@ let private stocksPanel model dispatch =
prop.hidden (model.stockPositionsMessage.IsNone)
prop.text (model.stockPositionsMessage |> Option.defaultValue "")
]
+ Html.p [
+ prop.className "stock-sell-message"
+ prop.hidden (model.stockSellMessage.IsNone)
+ prop.text (model.stockSellMessage |> Option.defaultValue "")
+ ]
Html.div [
prop.className "fund-valuation"
prop.children [
diff --git a/src/FundLab.Web/src/api.js b/src/FundLab.Web/src/api.js
index eaa8978..3947688 100644
--- a/src/FundLab.Web/src/api.js
+++ b/src/FundLab.Web/src/api.js
@@ -159,6 +159,18 @@ export function createStockTrade(token, fundId, payload) {
});
}
+export function createStockSell(token, fundId, payload) {
+ const body = `{"instrumentCode":${JSON.stringify(payload.instrumentCode)},"quantity":${JSON.stringify(payload.quantity)}}`;
+ return requestJson(`/api/funds/${encodeURIComponent(fundId)}/stock-sells`, token, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Idempotency-Key": payload.idempotencyKey
+ },
+ body
+ });
+}
+
export function getStockPositions(token, fundId) {
return requestJson(`/api/funds/${encodeURIComponent(fundId)}/stock-positions`, token);
}
diff --git a/src/FundLab.Web/src/styles.css b/src/FundLab.Web/src/styles.css
index dbd53ed..8aee2d2 100644
--- a/src/FundLab.Web/src/styles.css
+++ b/src/FundLab.Web/src/styles.css
@@ -648,13 +648,22 @@ h2 {
.stock-positions-row {
display: grid;
- grid-template-columns: 1fr 1.4fr 1fr 1fr;
+ grid-template-columns: 1fr 1.4fr 1fr 1fr 1.1fr 0.9fr;
gap: 8px;
align-items: center;
padding: 4px 0;
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
}
+.stock-sell-quantity-input {
+ min-width: 0;
+}
+
+.stock-sell-message {
+ color: #0f766e;
+ font-size: 12px;
+}
+
.returns-pending {
color: #b45309;
font-size: 12px;