summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-22 04:29:42 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-22 04:29:42 +0800
commitb6008d67232756d743699472865025159f6ec533 (patch)
tree739cc45c5e9677a08c03cebd0d52a8b773984754
parentb5a80f69cb2e2793e5134e23a0eb3e72cdbdbe0b (diff)
downloadfund-lab-b6008d67232756d743699472865025159f6ec533.tar.gz
Add stock buy to holdings minimal vertical slice (3d-21)
-rw-r--r--qa/driver/browser-test.js112
-rw-r--r--src/FundLab.Api/App.fs158
-rw-r--r--src/FundLab.Api/Persistence.fs351
-rw-r--r--src/FundLab.Web/App.fs303
-rw-r--r--src/FundLab.Web/src/api.js16
-rw-r--r--src/FundLab.Web/src/styles.css19
-rw-r--r--tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj1
-rw-r--r--tests/FundLab.Api.Tests/StockTradeTests.fs166
-rw-r--r--tests/FundLab.Web.Tests/BoundaryTests.fs74
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 ())