From c48b5560778548e2e87e05b1df5b170d7db1a345 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Tue, 22 Sep 2026 06:20:25 +0800 Subject: Add stock sell vertical slice with cash recovery and snapshot pricing (3d-26) --- qa/driver/browser-test.js | 103 ++++++- src/FundLab.Api/App.fs | 179 +++++++++++ src/FundLab.Api/Persistence.fs | 366 +++++++++++++++++++++++ src/FundLab.Web/App.fs | 126 ++++++++ src/FundLab.Web/src/api.js | 12 + src/FundLab.Web/src/styles.css | 11 +- tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj | 1 + tests/FundLab.Api.Tests/StockSellTests.fs | 237 +++++++++++++++ 8 files changed, 1033 insertions(+), 2 deletions(-) create mode 100644 tests/FundLab.Api.Tests/StockSellTests.fs diff --git a/qa/driver/browser-test.js b/qa/driver/browser-test.js index b271fbb..fda7c6f 100644 --- a/qa/driver/browser-test.js +++ b/qa/driver/browser-test.js @@ -39,6 +39,8 @@ let lastStockQuoteResponse = null; let lastStockDailyResponse = null; let lastStockTradeResponse = null; let lastStockPositionsResponse = null; +let lastStockSellResponse = null; +const stockSellStatuses = []; let lastBondTradeResponse = null; let lastBondPositionsResponse = null; let lastValuationResponse = null; @@ -214,6 +216,14 @@ async function summaryLabelExists(page, label, name) { lastStockTradeResponse = await r.json(); } catch {} } + if (/\/stock-sells$/i.test(new URL(r.url()).pathname) && r.request().method() === "POST") { + stockSellStatuses.push(r.status()); + if (r.status() < 400) { + try { + lastStockSellResponse = await r.json(); + } catch {} + } + } if (/\/stock-positions$/i.test(new URL(r.url()).pathname) && r.request().method() === "GET" && r.status() < 400) { try { lastStockPositionsResponse = await r.json(); @@ -249,7 +259,7 @@ async function summaryLabelExists(page, label, name) { lastSipAdvanceResponse = await r.json(); } catch {} } - if (r.status() >= 400 && !/\/api\/instruments\//.test(r.url()) && !/\/api\/market\//.test(r.url()) && !/\/orders/.test(r.url()) && !/\/redemptions/.test(r.url()) && !/\/capital/.test(r.url()) && !/\/stock-trades/.test(r.url()) && !/\/stock-positions/.test(r.url()) && !/\/bond-trades/.test(r.url()) && !/\/bond-positions/.test(r.url()) && !/\/valuation/.test(r.url()) && !/\/rebalance\/plans\//.test(r.url())) { + if (r.status() >= 400 && !/\/api\/instruments\//.test(r.url()) && !/\/api\/market\//.test(r.url()) && !/\/orders/.test(r.url()) && !/\/redemptions/.test(r.url()) && !/\/capital/.test(r.url()) && !/\/stock-trades/.test(r.url()) && !/\/stock-sells/.test(r.url()) && !/\/stock-positions/.test(r.url()) && !/\/bond-trades/.test(r.url()) && !/\/bond-positions/.test(r.url()) && !/\/valuation/.test(r.url()) && !/\/rebalance\/plans\//.test(r.url())) { consoleErrors.push("resource " + r.status() + ": " + r.url()); } }); @@ -309,6 +319,7 @@ async function summaryLabelExists(page, label, name) { await marketRefreshScenario(page); await sipStatusScenario(page); await rebalanceArchiveScenario(page); + await stockSellScenario(page); } finally { check("G1 无浏览器控制台/页面错误", consoleErrors.length === 0, consoleErrors.slice(0, 3).join(" | ")); await browser.close(); @@ -1597,6 +1608,96 @@ async function rebalanceArchiveScenario(page) { await page.screenshot({ path: SHOTS + "/28-rebalance-archive.png" }); } +async function stockSellScenario(page) { + // F5 (3d-26): 卖出部分股票 -> 持仓减少、现金回收、估值按剩余持仓快照价刷新。 + await page.waitForSelector(".stock-position-row", { timeout: 10000 }); + + const beforeText = ((await page.textContent(".stock-position-row")) || "").trim(); + check( + "F5a 持仓行显示卖出数量输入与卖出按钮", + (await page.locator(".stock-sell-quantity-input").count()) >= 1 && (await page.locator(".stock-sell-action").count()) >= 1, + beforeText.slice(0, 160) + ); + + lastStockSellResponse = null; + lastValuationResponse = null; + + await page.fill(".stock-sell-quantity-input", "40"); + await page.click(".stock-sell-action"); + + for (let i = 0; i < 200 && lastStockSellResponse === null; i++) { + await page.waitForTimeout(100); + } + + const sell = lastStockSellResponse || {}; + check( + "F5b 卖出 40 股以快照价成交并回收现金", + sell.instrumentCode === "600519" && + sell.quantity === "40.00000000" && + sell.price === "1252.57000000" && + sell.feeAmount === "0.00" && + sell.proceeds === "50102.80", + JSON.stringify(sell).slice(0, 260) + ); + + await page.waitForFunction( + () => { + const rows = Array.from(document.querySelectorAll(".stock-position-row")); + return rows.some((row) => (row.textContent || "").includes("60.00000000")); + }, + null, + { timeout: 10000 } + ); + check("F5c 卖出后持仓数量减少为 60", true); + + for (let i = 0; i < 200 && lastValuationResponse === null; i++) { + await page.waitForTimeout(100); + } + + const valuation = lastValuationResponse || {}; + const positions = Array.isArray(valuation.positions) ? valuation.positions : []; + const stock = positions.find((item) => item.instrumentCode === "600519") || {}; + const cash = parseFloat(valuation.cash || "0"); + const marketValue = parseFloat(valuation.positionsMarketValue || "0"); + + check( + "F5d 卖出后估值按剩余持仓与快照价重算", + stock.quantity === "60.00000000" && + stock.marketValue === "75154.20" && + stock.priceSource === "snapshot" && + valuation.portfolioValue && + Math.abs(parseFloat(valuation.portfolioValue) - (cash + marketValue)) < 0.005, + `valuation=${JSON.stringify(valuation).slice(0, 280)}` + ); + + const messageText = ((await page.textContent(".stock-sell-message")) || "").trim(); + check("F5e 页面显示卖出结果摘要", messageText.includes("已卖出") && messageText.includes("50102.80"), messageText.slice(0, 160)); + + // 超卖被拒绝,持仓不变。 + await page.fill(".stock-sell-quantity-input", "1000"); + await page.click(".stock-sell-action"); + await page.waitForFunction( + () => { + const element = document.querySelector(".stock-sell-message"); + return element && element.textContent.includes("卖出失败") && element.textContent.includes("not enough"); + }, + null, + { timeout: 10000 } + ); + + const oversellStatus = stockSellStatuses[stockSellStatuses.length - 1]; + check("F5f 超卖被 400 拒绝并提示原因", oversellStatus === 400, `status=${oversellStatus}`); + + const afterOversell = ((await page.textContent(".stock-positions")) || ""); + check( + "F5g 超卖后持仓仍为 60", + afterOversell.includes("60.00000000") && !afterOversell.includes("1000.00000000"), + afterOversell.slice(0, 160) + ); + + await page.screenshot({ path: SHOTS + "/30-stock-sell.png" }); +} + const failed = results.filter((r) => !r.ok); console.log(`\n==== ${results.length - failed.length}/${results.length} passed ====`); process.exit(failed.length > 0 ? 1 : 0); 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 = + 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 = 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(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() + + 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 = [] let getStockPositions (token: string) (fundId: string) : JS.Promise = jsNative + [] + let createStockSell (token: string) (fundId: string) (payload: CreateStockSellPayload) : JS.Promise = jsNative + [] let createBondTrade (token: string) (fundId: string) (payload: CreateBondTradePayload) : JS.Promise = jsNative @@ -1504,6 +1535,11 @@ type Model = stockPositionsInFlight: bool stockPositions: StockPositionView list stockPositionsMessage: string option + stockSellQuantities: Map + stockSellAttempts: Map + 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; diff --git a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj index d42612a..f7e1225 100644 --- a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj +++ b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj @@ -35,6 +35,7 @@ + diff --git a/tests/FundLab.Api.Tests/StockSellTests.fs b/tests/FundLab.Api.Tests/StockSellTests.fs new file mode 100644 index 0000000..cbdef8b --- /dev/null +++ b/tests/FundLab.Api.Tests/StockSellTests.fs @@ -0,0 +1,237 @@ +namespace FundLab.Api.Tests + +open System +open Xunit +open FundLab.Api + +module StockSellSupport = + let quote (code: string) (price: decimal) : StockQuote = + { Code = code; Name = Some "招商银行"; Price = Some price; Currency = "CNY" } + + let stubQuoteProbe (quotes: Map) = + { new IStockQuoteProbe with + member _.GetQuote(code, _) = + match quotes.TryFind code with + | Some price -> Ok(quote code price) + | None -> Error(MarketDataCollectorUnavailable(sprintf "no valid quote for %s" code)) } + + let probes (quotes: Map) = + { NavDates = NavDateProbeSupport.stubNavDateProbe + NavSeries = NavDateProbeSupport.stubNavSeriesProbe + BondQuotes = NavDateProbeSupport.stubBondQuoteProbe + StockQuotes = stubQuoteProbe quotes + StockDaily = NavDateProbeSupport.stubStockDailyProbe } + + let snapshot (code: string) (price: decimal) : InstrumentSnapshotRecord = + { + InstrumentCode = code + AssetClass = "stock" + SnapshotDate = DateOnly(2026, 9, 21) + Price = price + Source = "test" + SourceRevision = "test" + SourceCollectedAt = DateTimeOffset.UtcNow + SourcePayloadHash = sprintf "test:%s" code + } + +[] +type StockSellTests(fixture: PostgresFixture) = + let sharedRepository = + lazy + let value = FundRepository(fixture.ConnectionString) + value.EnsureSchema() + value + + let repository () = sharedRepository.Value + + let createFund (initialCash: decimal) = + let command = + { + Name = "股票卖出闭环测试 FOF" + InitialCash = initialCash + InitialUnitNav = 1.00000000m + IsSynthetic = true + } + + let key = fixture.Key(sprintf "stock-sell-fund-%s" (Guid.NewGuid().ToString("N"))) + + match repository().CreateFund(key, command) with + | FundWriteResult.Created fund -> fund.Id + | other -> failwithf "unexpected fund creation result: %A" other + + let app (quotes: Map) = + App.createApplicationWithProbes (repository ()) (StockSellSupport.probes quotes) + + let buy (quotes: Map) fundId body idempotencyKey = + PersistenceTestHelpers.invoke + (app quotes) + "POST" + (sprintf "/api/funds/%O/stock-trades" fundId) + [ "Authorization", "Bearer test-token"; "Idempotency-Key", idempotencyKey ] + body + + let sell (quotes: Map) fundId body idempotencyKey = + PersistenceTestHelpers.invoke + (app quotes) + "POST" + (sprintf "/api/funds/%O/stock-sells" fundId) + [ "Authorization", "Bearer test-token"; "Idempotency-Key", idempotencyKey ] + body + + let positions (quotes: Map) fundId = + PersistenceTestHelpers.invoke + (app quotes) + "GET" + (sprintf "/api/funds/%O/stock-positions" fundId) + [ "Authorization", "Bearer test-token" ] + "" + + let fund (quotes: Map) fundId = + PersistenceTestHelpers.invoke + (app quotes) + "GET" + (sprintf "/api/funds/%O" fundId) + [ "Authorization", "Bearer test-token" ] + "" + + let valuation (quotes: Map) fundId = + PersistenceTestHelpers.invoke + (app quotes) + "GET" + (sprintf "/api/funds/%O/valuation" fundId) + [ "Authorization", "Bearer test-token" ] + "" + + [] + member _.``selling part of a position credits cash and reduces the position``() = + let fundId = createFund 1000.00m + let quotes = Map.ofList [ "600030", 10.00m ] + + let status, _ = buy quotes fundId """{"instrumentCode":"600030","quantity":"100"}""" (fixture.Key "stock-sell-buy-1") + Assert.Equal(201, status) + + let sellQuotes = Map.ofList [ "600030", 12.00m ] + + let status, body = + sell sellQuotes fundId """{"instrumentCode":"600030","quantity":"40"}""" (fixture.Key "stock-sell-partial") + + Assert.Equal(201, status) + Assert.Contains("\"instrumentCode\":\"600030\"", body) + Assert.Contains("\"quantity\":\"40.00000000\"", body) + Assert.Contains("\"price\":\"12.00000000\"", body) + Assert.Contains("\"feeAmount\":\"0.00\"", body) + Assert.Contains("\"proceeds\":\"480.00\"", body) + + let status, positionsBody = positions sellQuotes fundId + Assert.Equal(200, status) + Assert.Contains("\"quantity\":\"60.00000000\"", positionsBody) + Assert.Contains("\"costCash\":\"600.00\"", positionsBody) + + let status, fundBody = fund sellQuotes fundId + Assert.Equal(200, status) + Assert.Contains("\"availableCash\":\"1480.00\"", fundBody) + + [] + member _.``selling all units removes the position row and credits the proceeds``() = + let fundId = createFund 1000.00m + let quotes = Map.ofList [ "600030", 10.00m ] + + let status, _ = buy quotes fundId """{"instrumentCode":"600030","quantity":"100"}""" (fixture.Key "stock-sell-buy-2") + Assert.Equal(201, status) + + let sellQuotes = Map.ofList [ "600030", 12.00m ] + + let status, _ = + sell sellQuotes fundId """{"instrumentCode":"600030","quantity":"100"}""" (fixture.Key "stock-sell-all") + + Assert.Equal(201, status) + + let _, positionsBody = positions sellQuotes fundId + Assert.Contains("\"positions\":[]", positionsBody) + + let _, fundBody = fund sellQuotes fundId + Assert.Contains("\"availableCash\":\"2200.00\"", fundBody) + + [] + member _.``overselling is rejected with a reason and leaves cash and position unchanged``() = + let fundId = createFund 1000.00m + let quotes = Map.ofList [ "600030", 10.00m ] + + let status, _ = buy quotes fundId """{"instrumentCode":"600030","quantity":"100"}""" (fixture.Key "stock-sell-buy-3") + Assert.Equal(201, status) + + let status, body = + sell quotes fundId """{"instrumentCode":"600030","quantity":"150"}""" (fixture.Key "stock-sell-over") + + Assert.Equal(400, status) + Assert.Contains("INSUFFICIENT_STOCK_HOLDINGS", body) + Assert.Contains("are not enough", body) + + let _, positionsBody = positions quotes fundId + Assert.Contains("\"quantity\":\"100.00000000\"", positionsBody) + + let _, fundBody = fund quotes fundId + Assert.Contains("\"availableCash\":\"1000.00\"", fundBody) + + [] + member _.``valuation after a partial sale prices only the remaining units``() = + let fundId = createFund 1000.00m + let quotes = Map.ofList [ "600030", 10.00m ] + + let status, _ = buy quotes fundId """{"instrumentCode":"600030","quantity":"100"}""" (fixture.Key "stock-sell-buy-4") + Assert.Equal(201, status) + + let status, _ = + sell quotes fundId """{"instrumentCode":"600030","quantity":"40"}""" (fixture.Key "stock-sell-valuation") + + Assert.Equal(201, status) + + let valuationQuotes = Map.ofList [ "600030", 15.00m ] + let status, body = valuation valuationQuotes fundId + + Assert.Equal(200, status) + Assert.Contains("\"quantity\":\"60.00000000\"", body) + Assert.Contains("\"positionsMarketValue\":\"900.00\"", body) + Assert.Contains("\"pricedPositions\":1", body) + + [] + member _.``replaying a sale with the same idempotency key does not double-sell or double-credit``() = + let fundId = createFund 1000.00m + let quotes = Map.ofList [ "600030", 10.00m ] + + let status, _ = buy quotes fundId """{"instrumentCode":"600030","quantity":"100"}""" (fixture.Key "stock-sell-buy-5") + Assert.Equal(201, status) + + let key = fixture.Key "stock-sell-replay" + + let status, _ = sell quotes fundId """{"instrumentCode":"600030","quantity":"30"}""" key + Assert.Equal(201, status) + + let status, _ = sell quotes fundId """{"instrumentCode":"600030","quantity":"30"}""" key + Assert.Equal(200, status) + + let _, positionsBody = positions quotes fundId + Assert.Contains("\"quantity\":\"70.00000000\"", positionsBody) + Assert.DoesNotContain("\"quantity\":\"40.00000000\"", positionsBody) + + let _, fundBody = fund quotes fundId + Assert.Contains("\"availableCash\":\"1300.00\"", fundBody) + + [] + member _.``a persisted snapshot price wins over the live quote when selling``() = + let fundId = createFund 1000.00m + let quotes = Map.ofList [ "600036", 10.00m ] + + let status, _ = buy quotes fundId """{"instrumentCode":"600036","quantity":"100"}""" (fixture.Key "stock-sell-buy-6") + Assert.Equal(201, status) + + repository().UpsertInstrumentSnapshots [ StockSellSupport.snapshot "600036" 20.00m ] + + let liveQuotes = Map.ofList [ "600036", 99.00m ] + + let status, body = + sell liveQuotes fundId """{"instrumentCode":"600036","quantity":"50"}""" (fixture.Key "stock-sell-snapshot") + + Assert.Equal(201, status) + Assert.Contains("\"price\":\"20.00000000\"", body) + Assert.Contains("\"proceeds\":\"1000.00\"", body) -- cgit v1.2.3