diff options
| -rw-r--r-- | qa/driver/browser-test.js | 112 | ||||
| -rw-r--r-- | src/FundLab.Api/App.fs | 158 | ||||
| -rw-r--r-- | src/FundLab.Api/Persistence.fs | 351 | ||||
| -rw-r--r-- | src/FundLab.Web/App.fs | 303 | ||||
| -rw-r--r-- | src/FundLab.Web/src/api.js | 16 | ||||
| -rw-r--r-- | src/FundLab.Web/src/styles.css | 19 | ||||
| -rw-r--r-- | tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj | 1 | ||||
| -rw-r--r-- | tests/FundLab.Api.Tests/StockTradeTests.fs | 166 | ||||
| -rw-r--r-- | tests/FundLab.Web.Tests/BoundaryTests.fs | 74 |
9 files changed, 1199 insertions, 1 deletions
diff --git a/qa/driver/browser-test.js b/qa/driver/browser-test.js index 22385f8..aaa820c 100644 --- a/qa/driver/browser-test.js +++ b/qa/driver/browser-test.js @@ -37,6 +37,8 @@ let lastNavSeriesResponse = null; let lastBondQuoteResponse = null; let lastStockQuoteResponse = null; let lastStockDailyResponse = null; +let lastStockTradeResponse = null; +let lastStockPositionsResponse = null; function check(name, ok, detail) { results.push({ name, ok, detail: detail || "" }); @@ -201,7 +203,17 @@ async function summaryLabelExists(page, label, name) { lastStockDailyResponse = 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())) { + if (/\/stock-trades$/i.test(new URL(r.url()).pathname) && r.request().method() === "POST" && r.status() < 400) { + try { + lastStockTradeResponse = 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(); + } 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())) { consoleErrors.push("resource " + r.status() + ": " + r.url()); } }); @@ -255,6 +267,7 @@ async function summaryLabelExists(page, label, name) { await bondsScenario(page); await stocksScenario(page); await stockDailyScenario(page); + await stockTradeScenario(page); } finally { check("G1 无浏览器控制台/页面错误", consoleErrors.length === 0, consoleErrors.slice(0, 3).join(" | ")); await browser.close(); @@ -850,6 +863,103 @@ async function stockDailyScenario(page) { await page.screenshot({ path: SHOTS + "/23-stock-daily.png" }); } +async function stockTradeScenario(page) { + // S4: 股票买入 -> 持仓最小垂直闭环走真实 API(服务端以桩行情价成交)。 + await page.waitForSelector(".stocks-panel", { timeout: 10000 }); + + const token = process.env.QA_TOKEN || "qa-token"; + + lastStockTradeResponse = null; + lastStockPositionsResponse = null; + await page.fill(".stock-code-input", "600519"); + await page.fill(".stock-trade-quantity-input", "100"); + await page.click(".stock-trade-action"); + + await page.waitForSelector(".stock-position-row", { timeout: 10000 }); + + const rowCount = await page.locator(".stock-position-row").count(); + const rowText = ((await page.textContent(".stock-position-row")) || "").trim(); + const trade = lastStockTradeResponse || {}; + const positions = lastStockPositionsResponse || {}; + const rows = Array.isArray(positions.positions) ? positions.positions : []; + const matched = rows.find((item) => item.instrumentCode === "600519") || {}; + + check( + "S4a 真实买入生成持仓行且金额与行情价一致", + trade.instrumentCode === "600519" && + trade.price === "1252.57000000" && + trade.costCash === "125257.00" && + rowCount >= 1 && + rowText.includes("600519") && + matched.quantity === "100.00000000" && + matched.costCash === "125257.00", + `row=${rowText.slice(0, 160)} trade=${JSON.stringify(trade).slice(0, 160)}` + ); + + await page.fill(".stock-code-input", "999999"); + await page.fill(".stock-trade-quantity-input", "100"); + await page.click(".stock-trade-action"); + await page.waitForFunction( + () => { + const element = document.querySelector(".stock-trade-unavailable"); + return element && !element.hidden && element.textContent.includes("买入失败"); + }, + { timeout: 10000 } + ); + + const unavailableText = ((await page.textContent(".stock-trade-unavailable")) || "").trim(); + + const failure = await page.evaluate( + async ({ token, fundId }) => { + const response = await fetch(`/api/funds/${fundId}/stock-trades`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "Idempotency-Key": "qastocktradefail", + }, + body: JSON.stringify({ instrumentCode: "999999", quantity: "100" }), + }); + let body = null; + try { + body = await response.json(); + } catch {} + return { status: response.status, body }; + }, + { token, fundId: await currentFundId(page) } + ); + + const positionsAfter = await page.evaluate( + async ({ token, fundId }) => { + const response = await fetch(`/api/funds/${fundId}/stock-positions`, { + headers: { Authorization: `Bearer ${token}` }, + }); + let body = null; + try { + body = await response.json(); + } catch {} + return body; + }, + { token, fundId: await currentFundId(page) } + ); + + const afterRows = positionsAfter && Array.isArray(positionsAfter.positions) ? positionsAfter.positions : []; + const hasBadCode = afterRows.some((item) => item.instrumentCode === "999999"); + + check( + "S4b 无有效行情时买入失败且不产生 999999 持仓", + unavailableText.includes("买入失败") && + !unavailableText.includes("0.00") && + failure.status === 503 && + failure.body && + failure.body.error === "MARKET_DATA_UNAVAILABLE" && + !hasBadCode, + `message=${unavailableText.slice(0, 200)} status=${failure.status} codes=${afterRows.map((item) => item.instrumentCode).join(",")}` + ); + + await page.screenshot({ path: SHOTS + "/24-stock-trade.png" }); +} + async function postInvestmentPlanViaApi(page, fundId, token) { return page.evaluate( async ({ fundId, token }) => { 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 []) diff --git a/src/FundLab.Api/Persistence.fs b/src/FundLab.Api/Persistence.fs index 6028b7c..ab3fe0a 100644 --- a/src/FundLab.Api/Persistence.fs +++ b/src/FundLab.Api/Persistence.fs @@ -289,6 +289,44 @@ type SipPlanCommand = Frequency: SipFrequency } +type StockTradeCommand = + { + InstrumentCode: string + StockName: string option + Quantity: decimal + Price: decimal + } + +type StockTradeRecord = + { + Id: Guid + FundId: Guid + InstrumentCode: string + StockName: string option + Quantity: decimal + Price: decimal + CostCash: decimal + IsSynthetic: bool + ExecutedAt: DateTimeOffset + } + +type StockPositionRecord = + { + FundId: Guid + InstrumentCode: string + StockName: string option + Quantity: decimal + CostCash: decimal + LastTradedAt: DateTimeOffset + } + +type StockTradeWriteResult = + | StockTradeCreated of StockTradeRecord + | StockTradeReplayed of StockTradeRecord + | StockTradeIdempotencyConflict + | StockTradeInvalid of string + | StockTradeFundNotFound + type SipPlanRecord = { Id: Guid @@ -829,6 +867,36 @@ type FundRepository(connectionString: string) = created_at timestamptz NOT NULL DEFAULT now() ); + CREATE TABLE IF NOT EXISTS stock_trades ( + 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), + cost_cash numeric(20, 2) NOT NULL CHECK (cost_cash >= 0), + is_synthetic boolean NOT NULL, + executed_at timestamptz NOT NULL + ); + + CREATE TABLE IF NOT EXISTS stock_trade_idempotencies ( + idempotency_key text PRIMARY KEY, + request_hash text NOT NULL, + trade_id uuid NOT NULL REFERENCES stock_trades(id), + fund_id uuid NOT NULL REFERENCES funds(id), + created_at timestamptz NOT NULL DEFAULT now() + ); + + CREATE TABLE IF NOT EXISTS stock_positions ( + 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), + cost_cash numeric(20, 2) NOT NULL CHECK (cost_cash >= 0), + last_traded_at timestamptz NOT NULL, + PRIMARY KEY (fund_id, instrument_code) + ); + CREATE TABLE IF NOT EXISTS dividend_idempotencies ( idempotency_key text PRIMARY KEY, request_hash text NOT NULL, @@ -1687,6 +1755,123 @@ type FundRepository(connectionString: string) = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload))) + let stockTradeRecordFromReader (reader: DbDataReader) : StockTradeRecord = + { + 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) + CostCash = reader.GetDecimal(6) + IsSynthetic = reader.GetBoolean(7) + ExecutedAt = reader.GetFieldValue<DateTimeOffset>(8) + } + + let insertStockTrade connection transaction (trade: StockTradeRecord) = + use command = + commandWithTransaction + connection + transaction + """ + INSERT INTO stock_trades + (id, fund_id, instrument_code, stock_name, quantity, price, cost_cash, is_synthetic, executed_at) + VALUES + (@id, @fund_id, @instrument_code, @stock_name, @quantity, @price, @cost_cash, @is_synthetic, @executed_at) + """ + + addParameter command "id" NpgsqlDbType.Uuid (box trade.Id) |> ignore + addParameter command "fund_id" NpgsqlDbType.Uuid (box trade.FundId) |> ignore + addParameter command "instrument_code" NpgsqlDbType.Text (box trade.InstrumentCode) |> ignore + + let nameParameter = + match trade.StockName with + | Some name -> box name + | None -> box DBNull.Value + + addParameter command "stock_name" NpgsqlDbType.Text nameParameter |> ignore + addParameter command "quantity" NpgsqlDbType.Numeric (box trade.Quantity) |> ignore + addParameter command "price" NpgsqlDbType.Numeric (box trade.Price) |> ignore + addParameter command "cost_cash" NpgsqlDbType.Numeric (box trade.CostCash) |> ignore + addParameter command "is_synthetic" NpgsqlDbType.Boolean (box trade.IsSynthetic) |> ignore + addParameter command "executed_at" NpgsqlDbType.TimestampTz (box trade.ExecutedAt) |> ignore + command.ExecuteNonQuery() |> ignore + + let insertStockTradeIdempotency connection transaction key requestHash tradeId fundId = + use command = + commandWithTransaction + connection + transaction + """ + INSERT INTO stock_trade_idempotencies (idempotency_key, request_hash, trade_id, fund_id) + VALUES (@idempotency_key, @request_hash, @trade_id, @fund_id) + """ + + addParameter command "idempotency_key" NpgsqlDbType.Text (box key) |> ignore + addParameter command "request_hash" NpgsqlDbType.Text (box requestHash) |> ignore + addParameter command "trade_id" NpgsqlDbType.Uuid (box tradeId) |> ignore + addParameter command "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore + command.ExecuteNonQuery() |> ignore + + let findStockTradeIdempotency connection transaction key = + use command = + commandWithTransaction + connection + transaction + """ + SELECT request_hash, fund_id, trade_id + FROM stock_trade_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 findStockTrade connection transaction tradeId = + use command = + commandWithTransaction + connection + transaction + """ + SELECT id, fund_id, instrument_code, stock_name, quantity, price, cost_cash, is_synthetic, executed_at + FROM stock_trades + WHERE id = @id + """ + + addParameter command "id" NpgsqlDbType.Uuid (box tradeId) |> ignore + + use reader = command.ExecuteReader() + + if reader.Read() then + Some(stockTradeRecordFromReader reader) + else + None + + let stockTradeRequestHash (fundId: Guid) (command: StockTradeCommand) = + 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-trade" + encoded (fundId.ToString("D")) + encoded command.InstrumentCode + encoded name + encoded (command.Quantity.ToString("G29", invariant)) + encoded (command.Price.ToString("G29", invariant)) + ] + + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload))) + let sipPlanRecordFromReader (reader: DbDataReader) : SipPlanRecord = { Id = reader.GetGuid(0) @@ -4020,6 +4205,172 @@ type FundRepository(connectionString: string) = raise error + member _.CreateStockTrade(idempotencyKey: string, fundId: Guid, command: StockTradeCommand, ?executedAtOverride: DateTimeOffset) : StockTradeWriteResult = + if String.IsNullOrWhiteSpace idempotencyKey then + StockTradeWriteResult.StockTradeInvalid "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 + StockTradeWriteResult.StockTradeInvalid "stock code must contain exactly six digits" + elif command.Quantity <= 0m then + StockTradeWriteResult.StockTradeInvalid "quantity must be positive" + elif command.Price <= 0m then + StockTradeWriteResult.StockTradeInvalid "price must be positive" + else + let normalized = { command with InstrumentCode = code } + let fingerprint = stockTradeRequestHash 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 findStockTradeIdempotency connection (Some transaction) idempotencyKey with + | Some(existingHash, existingFundId, tradeId) + when existingHash = fingerprint && existingFundId = fundId -> + match findStockTrade connection (Some transaction) tradeId with + | Some trade -> + transaction.Commit() + StockTradeWriteResult.StockTradeReplayed trade + | None -> + transaction.Rollback() + StockTradeWriteResult.StockTradeInvalid "idempotency record references a missing trade" + | Some _ -> + transaction.Rollback() + StockTradeWriteResult.StockTradeIdempotencyConflict + | None -> + match lockFundForOrder connection (Some transaction) fundId with + | None -> + transaction.Rollback() + StockTradeWriteResult.StockTradeFundNotFound + | Some isSynthetic -> + let executedAt = defaultArg executedAtOverride DateTimeOffset.UtcNow + let costCash = Decimal.Round(normalized.Quantity * normalized.Price, 2, MidpointRounding.AwayFromZero) + + let trade: StockTradeRecord = + { + Id = Guid.NewGuid() + FundId = fundId + InstrumentCode = normalized.InstrumentCode + StockName = normalized.StockName + Quantity = normalized.Quantity + Price = normalized.Price + CostCash = costCash + IsSynthetic = isSynthetic + ExecutedAt = executedAt + } + + insertStockTrade connection (Some transaction) trade + insertStockTradeIdempotency connection (Some transaction) idempotencyKey fingerprint trade.Id fundId + + use positionCommand = + commandWithTransaction + connection + (Some transaction) + """ + INSERT INTO stock_positions + (fund_id, instrument_code, stock_name, quantity, cost_cash, last_traded_at) + VALUES (@fund_id, @code, @name, @quantity, @cost_cash, @last_traded_at) + ON CONFLICT (fund_id, instrument_code) DO UPDATE + SET quantity = stock_positions.quantity + EXCLUDED.quantity, + cost_cash = stock_positions.cost_cash + EXCLUDED.cost_cash, + stock_name = COALESCE(EXCLUDED.stock_name, stock_positions.stock_name), + last_traded_at = EXCLUDED.last_traded_at + """ + + addParameter positionCommand "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore + addParameter positionCommand "code" NpgsqlDbType.Text (box normalized.InstrumentCode) |> ignore + + let nameParameter = + match normalized.StockName with + | Some name -> box name + | None -> box DBNull.Value + + addParameter positionCommand "name" NpgsqlDbType.Text nameParameter |> ignore + addParameter positionCommand "quantity" NpgsqlDbType.Numeric (box normalized.Quantity) |> ignore + addParameter positionCommand "cost_cash" NpgsqlDbType.Numeric (box costCash) |> ignore + addParameter positionCommand "last_traded_at" NpgsqlDbType.TimestampTz (box executedAt) |> ignore + positionCommand.ExecuteNonQuery() |> ignore + + let persisted = { trade with ExecutedAt = executedAt } + transaction.Commit() + StockTradeWriteResult.StockTradeCreated persisted + with error -> + try + transaction.Rollback() + with _ -> + () + + raise error + + member _.GetStockTrades(fundId: Guid) : StockTradeRecord list = + use connection = new NpgsqlConnection(connectionString) + connection.Open() + + use command = + commandWithTransaction + connection + None + """ + SELECT id, fund_id, instrument_code, stock_name, quantity, price, cost_cash, is_synthetic, executed_at + FROM stock_trades + 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<StockTradeRecord>() + + while reader.Read() do + records.Add(stockTradeRecordFromReader reader) + + records |> Seq.toList + + member _.GetStockPositions(fundId: Guid) : StockPositionRecord list = + use connection = new NpgsqlConnection(connectionString) + connection.Open() + + use command = + commandWithTransaction + connection + None + """ + SELECT instrument_code, stock_name, quantity, cost_cash, last_traded_at + FROM stock_positions + WHERE fund_id = @fund_id + ORDER BY instrument_code + """ + + addParameter command "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore + + use reader = command.ExecuteReader() + let records = ResizeArray<StockPositionRecord>() + + while reader.Read() do + records.Add( + { + FundId = fundId + InstrumentCode = reader.GetString(0) + StockName = if reader.IsDBNull(1) then None else Some(reader.GetString(1)) + Quantity = reader.GetDecimal(2) + CostCash = reader.GetDecimal(3) + LastTradedAt = reader.GetFieldValue<DateTimeOffset>(4) + } + ) + + records |> Seq.toList + member _.GetCapitalDeposits(fundId: Guid) = use connection = new NpgsqlConnection(connectionString) connection.Open() diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs index d18cff9..772b97e 100644 --- a/src/FundLab.Web/App.fs +++ b/src/FundLab.Web/App.fs @@ -425,6 +425,34 @@ type RawStockDaily = observations: RawStockDailyObservation array } +type RawStockTrade = + { + id: string + fundId: string + instrumentCode: string + stockName: obj + quantity: string + price: string + costCash: string + executedAt: string + isSynthetic: bool + } + +type RawStockPosition = + { + instrumentCode: string + stockName: obj + quantity: string + costCash: string + lastTradedAt: string + } + +type RawStockPositions = + { + fundId: string + positions: RawStockPosition array + } + type CreateAttempt = { idempotencyKey: string @@ -506,6 +534,13 @@ type CreateSipPayload = frequency: string } +type CreateStockTradePayload = + { + idempotencyKey: string + instrumentCode: string + quantity: string + } + type SipPlan = { id: string @@ -752,6 +787,33 @@ type StockDailyBar = amount: string option } +type StockTradeView = + { + id: string + instrumentCode: string + stockName: string option + quantity: string + price: string + costCash: string + executedAt: string + } + +type StockPositionView = + { + instrumentCode: string + stockName: string option + quantity: string + costCash: string + lastTradedAt: string + } + +type StockTradeAttempt = + { + idempotencyKey: string + instrumentCode: string + quantity: string + } + module Api = [<Import("searchInstruments", "./src/api.js")>] let searchInstruments (token: string) (query: string) : JS.Promise<RawSearchResponse> = jsNative @@ -810,6 +872,12 @@ module Api = [<Import("getStockDaily", "./src/api.js")>] let getStockDaily (token: string) (code: string) (days: int) : JS.Promise<RawStockDaily> = jsNative + [<Import("createStockTrade", "./src/api.js")>] + let createStockTrade (token: string) (fundId: string) (payload: CreateStockTradePayload) : JS.Promise<RawStockTrade> = jsNative + + [<Import("getStockPositions", "./src/api.js")>] + let getStockPositions (token: string) (fundId: string) : JS.Promise<RawStockPositions> = jsNative + [<Import("createCapitalDeposit", "./src/api.js")>] let createCapitalDeposit (token: string) (fundId: string) (payload: CreateCapitalPayload) : JS.Promise<obj> = jsNative @@ -1109,6 +1177,26 @@ module Api = amount = decodeOptionalText raw.amount } + let decodeStockTrade (raw: RawStockTrade) : StockTradeView = + { + id = raw.id + instrumentCode = raw.instrumentCode + stockName = decodeOptionalText raw.stockName + quantity = raw.quantity + price = raw.price + costCash = raw.costCash + executedAt = raw.executedAt + } + + let decodeStockPosition (raw: RawStockPosition) : StockPositionView = + { + instrumentCode = raw.instrumentCode + stockName = decodeOptionalText raw.stockName + quantity = raw.quantity + costCash = raw.costCash + lastTradedAt = raw.lastTradedAt + } + type Model = { token: string @@ -1206,6 +1294,16 @@ type Model = stockDailyInFlight: bool stockDaily: StockDailyBar list stockDailyMessage: string option + stockTradeQuantity: string + stockTradeSeq: int + stockTradeInFlight: bool + lastStockTrade: StockTradeView option + lastStockTradeAttempt: StockTradeAttempt option + stockTradeMessage: string option + stockPositionsReadSeq: int + stockPositionsInFlight: bool + stockPositions: StockPositionView list + stockPositionsMessage: string option returnsSelectedDate: string option planReadSeq: int planInFlight: bool @@ -1317,6 +1415,13 @@ type Msg = | StockDailyRequested | StockDailyCompleted of requestId: int * daily: RawStockDaily | StockDailyFailed of requestId: int * message: string + | StockTradeQuantityChanged of string + | StockTradeRequested + | StockTradeCompleted of requestId: int * fundId: string * trade: RawStockTrade + | StockTradeFailed of requestId: int * fundId: string * message: string + | StockPositionsReadRequested + | StockPositionsReadCompleted of requestId: int * fundId: string * positions: RawStockPositions + | StockPositionsReadFailed of requestId: int * fundId: string * message: string | ReturnsDateChanged of string | InvestmentPlansReadRequested | InvestmentPlansReadCompleted of requestId: int * plans: RawInvestmentPlan array @@ -1342,6 +1447,15 @@ let isNonNegativeCash (text: string) = | true, value -> value >= 0M | false, _ -> false +let private quantityPattern = + Text.RegularExpressions.Regex("^[0-9]{1,18}$", Text.RegularExpressions.RegexOptions.Compiled) + +let isValidQuantityText (text: string) = + quantityPattern.IsMatch text + && match Decimal.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture) with + | true, value -> value > 0M + | false, _ -> false + let resolveCreateKey (lastAttempt: CreateAttempt option) (name: string) (cash: string) = match lastAttempt with | Some attempt when attempt.name = name && attempt.cash = cash -> attempt.idempotencyKey @@ -1450,6 +1564,16 @@ let init () = stockDailyInFlight = false stockDaily = [] stockDailyMessage = None + stockTradeQuantity = "" + stockTradeSeq = 0 + stockTradeInFlight = false + lastStockTrade = None + lastStockTradeAttempt = None + stockTradeMessage = None + stockPositionsReadSeq = 0 + stockPositionsInFlight = false + stockPositions = [] + stockPositionsMessage = None returnsSelectedDate = None planReadSeq = 0 planInFlight = false @@ -1655,6 +1779,20 @@ let private readStockDailyCommand token code days requestId = (fun daily -> StockDailyCompleted(requestId, daily)) (fun error -> StockDailyFailed(requestId, errorText error)) +let private createStockTradeCommand token fundId payload requestId = + Cmd.OfPromise.either + (fun () -> Api.createStockTrade token fundId payload) + () + (fun trade -> StockTradeCompleted(requestId, fundId, trade)) + (fun error -> StockTradeFailed(requestId, fundId, errorText error)) + +let private readStockPositionsCommand token fundId requestId = + Cmd.OfPromise.either + (fun () -> Api.getStockPositions token fundId) + () + (fun positions -> StockPositionsReadCompleted(requestId, fundId, positions)) + (fun error -> StockPositionsReadFailed(requestId, fundId, errorText error)) + let private readInvestmentPlansCommand token fundId requestId = Cmd.OfPromise.either (fun () -> Api.getInvestmentPlans token fundId) @@ -3011,6 +3149,105 @@ let update message model = Cmd.none else model, Cmd.none + | StockTradeQuantityChanged value -> { model with stockTradeQuantity = value }, Cmd.none + | StockTradeRequested -> + let code = model.stockCode.Trim() + let quantity = model.stockTradeQuantity.Trim() + + if String.IsNullOrWhiteSpace model.token then + { model with stockTradeMessage = Some "请输入 API token" }, Cmd.none + elif model.createdFund.IsNone then + { model with stockTradeMessage = Some "请先创建一个基金" }, Cmd.none + elif code.Length <> 6 || not (code |> Seq.forall Char.IsDigit) then + { model with stockTradeMessage = Some "无效代码 — 请输入六位股票代码" }, Cmd.none + elif not (isValidQuantityText quantity) then + { model with stockTradeMessage = Some "数量必须是大于零的整数股数,例如 100" }, Cmd.none + elif model.stockTradeInFlight then + model, Cmd.none + else + let requestId = model.stockTradeSeq + 1 + + let idempotencyKey = + match model.lastStockTradeAttempt with + | Some attempt when attempt.instrumentCode = code && attempt.quantity = quantity -> attempt.idempotencyKey + | _ -> Guid.NewGuid().ToString("N") + + { + model with + stockCode = code + stockTradeQuantity = quantity + stockTradeSeq = requestId + stockTradeInFlight = true + lastStockTradeAttempt = + Some + { + idempotencyKey = idempotencyKey + instrumentCode = code + quantity = quantity + } + stockTradeMessage = None + }, + createStockTradeCommand + model.token + model.createdFund.Value.id + { idempotencyKey = idempotencyKey; instrumentCode = code; quantity = quantity } + requestId + | StockTradeCompleted (requestId, fundId, trade) -> + if requestId = model.stockTradeSeq + && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then + { + model with + stockTradeInFlight = false + lastStockTrade = Some(Api.decodeStockTrade trade) + lastStockTradeAttempt = None + stockTradeMessage = None + }, + Cmd.ofMsg StockPositionsReadRequested + else + model, Cmd.none + | StockTradeFailed (requestId, fundId, message) -> + if requestId = model.stockTradeSeq + && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then + { model with stockTradeInFlight = false; stockTradeMessage = Some(sprintf "买入失败 — %s" message) }, Cmd.none + else + model, Cmd.none + | StockPositionsReadRequested -> + match model.createdFund with + | Some fund when not (String.IsNullOrWhiteSpace model.token) -> + let requestId = model.stockPositionsReadSeq + 1 + + { + model with + stockPositionsReadSeq = requestId + stockPositionsInFlight = true + stockPositionsMessage = None + }, + readStockPositionsCommand model.token fund.id requestId + | _ -> model, Cmd.none + | StockPositionsReadCompleted (requestId, fundId, positions) -> + if requestId = model.stockPositionsReadSeq + && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then + { + model with + stockPositions = positions.positions |> Array.toList |> List.map Api.decodeStockPosition + stockPositionsInFlight = false + stockPositionsMessage = None + }, + Cmd.none + else + model, Cmd.none + | StockPositionsReadFailed (requestId, fundId, message) -> + if requestId = model.stockPositionsReadSeq + && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then + { + model with + stockPositions = [] + stockPositionsInFlight = false + stockPositionsMessage = Some(sprintf "持仓不可用 — %s" message) + }, + Cmd.none + else + model, Cmd.none | ReturnsDateChanged value -> { model with returnsSelectedDate = (if String.IsNullOrWhiteSpace value then None else Some value) }, Cmd.none | InvestmentPlansReadRequested -> @@ -4865,6 +5102,72 @@ let private stocksPanel model dispatch = prop.hidden (model.stockDailyMessage.IsNone) prop.text (model.stockDailyMessage |> Option.defaultValue "") ] + Html.div [ + prop.className "stock-trade" + prop.children [ + Html.p [ prop.className "returns-subheading"; prop.text "买入(以实时行情价成交)" ] + Html.div [ + prop.className "search-row" + prop.children [ + Html.input [ + prop.className "text-input stock-trade-quantity-input" + prop.placeholder "买入数量(整数股),如 100" + prop.value model.stockTradeQuantity + prop.onChange (fun value -> dispatch (StockTradeQuantityChanged value)) + ] + Html.button [ + prop.className "primary-action stock-trade-action" + prop.disabled model.stockTradeInFlight + prop.onClick (fun _ -> dispatch StockTradeRequested) + prop.text ((if model.stockTradeInFlight then "买入中..." else "买入"): string) + ] + Html.button [ + prop.className "secondary-action stock-positions-refresh-action" + prop.disabled model.stockPositionsInFlight + prop.onClick (fun _ -> dispatch StockPositionsReadRequested) + prop.text ((if model.stockPositionsInFlight then "读取中..." else "刷新持仓"): string) + ] + ] + ] + Html.p [ + prop.className "stock-trade-unavailable" + prop.hidden (model.stockTradeMessage.IsNone) + prop.text (model.stockTradeMessage |> Option.defaultValue "") + ] + ] + ] + Html.div [ + prop.className "stock-positions" + prop.hidden (List.isEmpty model.stockPositions) + prop.children ( + Html.p [ prop.className "returns-subheading"; prop.text "股票持仓" ] + :: Html.div [ + prop.className "stock-positions-row stock-positions-header" + prop.children [ + 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 + |> List.map (fun position -> + Html.div [ + prop.className "stock-positions-row stock-position-row" + prop.children [ + Html.span [ prop.className "fund-detail-value"; prop.text position.instrumentCode ] + 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.p [ + prop.className "stock-positions-unavailable" + prop.hidden (model.stockPositionsMessage.IsNone) + prop.text (model.stockPositionsMessage |> Option.defaultValue "") + ] ] ] diff --git a/src/FundLab.Web/src/api.js b/src/FundLab.Web/src/api.js index 304c79f..968b86f 100644 --- a/src/FundLab.Web/src/api.js +++ b/src/FundLab.Web/src/api.js @@ -147,6 +147,22 @@ export function getStockDaily(token, code, days) { return requestJson(`/api/market/stock-daily?code=${encodeURIComponent(code)}&days=${days}`, token); } +export function createStockTrade(token, fundId, payload) { + const body = `{"instrumentCode":${JSON.stringify(payload.instrumentCode)},"quantity":${JSON.stringify(payload.quantity)}}`; + return requestJson(`/api/funds/${encodeURIComponent(fundId)}/stock-trades`, 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); +} + export function createSipPlan(token, fundId, payload) { const body = `{"instrumentCode":${JSON.stringify(payload.instrumentCode)},"amount":${JSON.stringify(payload.amount)},"frequency":${JSON.stringify(payload.frequency)}}`; return requestJson(`/api/funds/${encodeURIComponent(fundId)}/sip/plans`, token, { diff --git a/src/FundLab.Web/src/styles.css b/src/FundLab.Web/src/styles.css index c0ac4a4..bf0627b 100644 --- a/src/FundLab.Web/src/styles.css +++ b/src/FundLab.Web/src/styles.css @@ -599,6 +599,25 @@ h2 { font-size: 12px; } +.stock-trade-unavailable, +.stock-positions-unavailable { + color: #b45309; + font-size: 12px; +} + +.stock-positions-header .fund-detail-label { + font-weight: 600; +} + +.stock-positions-row { + display: grid; + grid-template-columns: 1fr 1.4fr 1fr 1fr; + gap: 8px; + align-items: center; + padding: 4px 0; + border-bottom: 1px solid rgba(148, 163, 184, 0.2); +} + .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 83c96bc..65352a7 100644 --- a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj +++ b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj @@ -34,6 +34,7 @@ <Compile Include="BondQuoteProbeTests.fs" /> <Compile Include="StockQuoteProbeTests.fs" /> <Compile Include="StockDailyProbeTests.fs" /> + <Compile Include="StockTradeTests.fs" /> <Compile Include="Program.fs" /> </ItemGroup> </Project> diff --git a/tests/FundLab.Api.Tests/StockTradeTests.fs b/tests/FundLab.Api.Tests/StockTradeTests.fs new file mode 100644 index 0000000..a2269a1 --- /dev/null +++ b/tests/FundLab.Api.Tests/StockTradeTests.fs @@ -0,0 +1,166 @@ +namespace FundLab.Api.Tests + +open System +open System.Threading +open Xunit +open FundLab.Api + +module StockTradeSupport = + let stubQuoteProbe (quote: StockQuote) = + { new IStockQuoteProbe with + member _.GetQuote(_, _) = Ok quote } + + let failingQuoteProbe (failure: MarketDataFailure) = + { new IStockQuoteProbe with + member _.GetQuote(_, _) = Error failure } + + let quote600519 = + { + Code = "600519" + Name = Some "贵州茅台" + Price = Some 1252.57m + Currency = "CNY" + } + + let appWithQuote (probe: IStockQuoteProbe) = + { NavDates = NavDateProbeSupport.stubNavDateProbe + NavSeries = NavDateProbeSupport.stubNavSeriesProbe + BondQuotes = NavDateProbeSupport.stubBondQuoteProbe + StockQuotes = probe + StockDaily = NavDateProbeSupport.stubStockDailyProbe } + +[<Collection("postgres")>] +type StockTradeTests(fixture: PostgresFixture) = + let sharedRepository = + lazy + let value = FundRepository(fixture.ConnectionString) + value.EnsureSchema() + value + + let repository () = sharedRepository.Value + + let createFund () = + let command = + { + Name = "股票闭环测试 FOF" + InitialCash = 100000.00m + InitialUnitNav = 1.00000000m + IsSynthetic = true + } + + let key = fixture.Key(sprintf "stock-trade-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 probe = + let probes = StockTradeSupport.appWithQuote probe + App.createApplicationWithProbes (repository ()) probes + + let buy (probe: IStockQuoteProbe) fundId body idempotencyKey = + PersistenceTestHelpers.invoke + (app probe) + "POST" + (sprintf "/api/funds/%O/stock-trades" fundId) + [ "Authorization", "Bearer test-token"; "Idempotency-Key", idempotencyKey ] + body + + let readPositions (probe: IStockQuoteProbe) fundId = + PersistenceTestHelpers.invoke + (app probe) + "GET" + (sprintf "/api/funds/%O/stock-positions" fundId) + [ "Authorization", "Bearer test-token" ] + "" + + [<Fact>] + member _.``buying a stock with a live quote records a trade and a position``() = + let fundId = createFund () + let probe = StockTradeSupport.stubQuoteProbe StockTradeSupport.quote600519 + + let status, body = + buy probe fundId """{"instrumentCode":"600519","quantity":"100"}""" (fixture.Key "stock-trade-buy-ok") + + Assert.Equal(201, status) + Assert.Contains("\"instrumentCode\":\"600519\"", body) + Assert.Contains("贵州茅台", body) + Assert.Contains("1252.57", body) + Assert.Contains("125257.00", body) + + let status, positionsBody = readPositions probe fundId + Assert.Equal(200, status) + Assert.Contains("\"instrumentCode\":\"600519\"", positionsBody) + Assert.Contains("\"quantity\":\"100.00000000\"", positionsBody) + Assert.Contains("125257.00", positionsBody) + + [<Fact>] + member _.``buying an unknown code with an unavailable quote does not create a position``() = + let fundId = createFund () + + let probe = + StockTradeSupport.failingQuoteProbe ( + MarketDataCollectorUnavailable "AKShare collector failed: no valid quote for 999999" + ) + + let status, body = + buy probe fundId """{"instrumentCode":"999999","quantity":"100"}""" (fixture.Key "stock-trade-unknown") + + Assert.Equal(503, status) + Assert.Contains("MARKET_DATA_UNAVAILABLE", body) + + let status, positionsBody = readPositions probe fundId + Assert.Equal(200, status) + Assert.DoesNotContain("999999", positionsBody) + Assert.Contains("\"positions\":[]", positionsBody) + + [<Fact>] + member _.``buying with a non-positive quantity is rejected without a position``() = + let fundId = createFund () + let probe = StockTradeSupport.stubQuoteProbe StockTradeSupport.quote600519 + + let status, body = + buy probe fundId """{"instrumentCode":"600519","quantity":"0"}""" (fixture.Key "stock-trade-zero") + + Assert.Equal(400, status) + Assert.Contains("INVALID_STOCK_TRADE_REQUEST", body) + + let _, positionsBody = readPositions probe fundId + Assert.Contains("\"positions\":[]", positionsBody) + + [<Fact>] + member _.``buying with a malformed code is rejected``() = + let fundId = createFund () + let probe = StockTradeSupport.stubQuoteProbe StockTradeSupport.quote600519 + + let status, body = + buy probe fundId """{"instrumentCode":"abc","quantity":"100"}""" (fixture.Key "stock-trade-badcode") + + Assert.Equal(400, status) + Assert.Contains("six digits", body) + + [<Fact>] + member _.``replaying a buy with the same idempotency key does not double the position``() = + let fundId = createFund () + let probe = StockTradeSupport.stubQuoteProbe StockTradeSupport.quote600519 + let key = fixture.Key "stock-trade-replay" + + let status, _ = buy probe fundId """{"instrumentCode":"600519","quantity":"100"}""" key + Assert.Equal(201, status) + + let status, _ = buy probe fundId """{"instrumentCode":"600519","quantity":"100"}""" key + Assert.Equal(200, status) + + let _, positionsBody = readPositions probe fundId + Assert.Contains("\"quantity\":\"100.00000000\"", positionsBody) + Assert.DoesNotContain("\"quantity\":\"200.00000000\"", positionsBody) + + [<Fact>] + member _.``buying a fund that does not exist answers 404``() = + let probe = StockTradeSupport.stubQuoteProbe StockTradeSupport.quote600519 + + let status, body = + buy probe (Guid.NewGuid()) """{"instrumentCode":"600519","quantity":"100"}""" (fixture.Key "stock-trade-missing") + + Assert.Equal(404, status) + Assert.Contains("FUND_NOT_FOUND", body) diff --git a/tests/FundLab.Web.Tests/BoundaryTests.fs b/tests/FundLab.Web.Tests/BoundaryTests.fs index 2892633..25d7867 100644 --- a/tests/FundLab.Web.Tests/BoundaryTests.fs +++ b/tests/FundLab.Web.Tests/BoundaryTests.fs @@ -584,6 +584,80 @@ module ConfirmationBoundaryTests = Assert.False(updated.stockDailyInFlight) [<Fact>] + let ``stock buy requires a created fund before dispatching a trade`` () = + let model = { init () with token = "qa-token"; stockCode = "600519"; stockTradeQuantity = "100" } + let updated, _ = update StockTradeRequested model + + Assert.Contains("请先创建一个基金", updated.stockTradeMessage |> Option.defaultValue "") + Assert.False(updated.stockTradeInFlight) + + [<Fact>] + let ``stock buy rejects a non-positive quantity without dispatching`` () = + let model = + { init () with + token = "qa-token" + createdFund = Some(FundBoundaryTests.sampleFund "3fa85f64-5717-4562-b3fc-2c963f66afa6") + stockCode = "600519" + stockTradeQuantity = "0" } + + let updated, _ = update StockTradeRequested model + + Assert.Contains("数量必须是大于零的整数股数", updated.stockTradeMessage |> Option.defaultValue "") + Assert.False(updated.stockTradeInFlight) + + [<Fact>] + let ``stock buy failure keeps no position and surfaces a message`` () = + let fundId = "3fa85f64-5717-4562-b3fc-2c963f66afa6" + + let model = + { init () with + token = "qa-token" + createdFund = Some(FundBoundaryTests.sampleFund fundId) + stockCode = "999999" + stockTradeQuantity = "100" + stockTradeInFlight = true + stockTradeSeq = 1 } + + let updated, _ = update (StockTradeFailed(1, fundId, "collector exited with code 2")) model + + Assert.Empty updated.stockPositions + Assert.Contains("买入失败", updated.stockTradeMessage |> Option.defaultValue "") + Assert.False(updated.stockTradeInFlight) + + [<Fact>] + let ``stock positions read completion stores decoded rows`` () = + let fundId = "3fa85f64-5717-4562-b3fc-2c963f66afa6" + + let model = + { init () with + createdFund = Some(FundBoundaryTests.sampleFund fundId) + stockPositionsReadSeq = 1 + stockPositionsInFlight = true } + + let raw : RawStockPositions = + { + fundId = fundId + positions = + [| + { + instrumentCode = "600519" + stockName = box "贵州茅台" + quantity = "100" + costCash = "125257.00" + lastTradedAt = "2026-09-21T08:00:00Z" + } + |] + } + + let updated, _ = update (StockPositionsReadCompleted(1, fundId, raw)) model + + Assert.Equal(1, updated.stockPositions.Length) + Assert.Equal("600519", updated.stockPositions[0].instrumentCode) + Assert.Equal(Some "贵州茅台", updated.stockPositions[0].stockName) + Assert.Equal("100", updated.stockPositions[0].quantity) + Assert.False(updated.stockPositionsInFlight) + + [<Fact>] let ``order decode keeps decimal strings and decodes option fields`` () = let confirmed = Api.decodeOrder (sampleConfirmedRaw ()) let pending = Api.decodeOrder (samplePendingRaw ()) |
