summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/FundLab.Api/App.fs137
-rw-r--r--src/FundLab.Api/Persistence.fs344
-rw-r--r--tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj1
-rw-r--r--tests/FundLab.Api.Tests/StockCashflowTests.fs147
4 files changed, 629 insertions, 0 deletions
diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs
index a77fdf5..f45f7ae 100644
--- a/src/FundLab.Api/App.fs
+++ b/src/FundLab.Api/App.fs
@@ -174,6 +174,27 @@ type StockSellResponse =
isSynthetic: bool
}
+type StockCashflowResponse =
+ {
+ id: Guid
+ fundId: Guid
+ instrumentCode: string
+ stockName: string option
+ eventType: string
+ eventDate: string
+ quantity: string
+ amount: string
+ note: string option
+ isSynthetic: bool
+ createdAt: string
+ }
+
+type StockCashflowsResponse =
+ {
+ fundId: Guid
+ events: StockCashflowResponse list
+ }
+
type BondTradeResponse =
{
id: Guid
@@ -724,6 +745,21 @@ module App =
isSynthetic = sell.IsSynthetic
}
+ let private stockCashflowResponse (record: StockCashflowRecord) : StockCashflowResponse =
+ {
+ id = record.Id
+ fundId = record.FundId
+ instrumentCode = record.InstrumentCode
+ stockName = record.StockName
+ eventType = record.EventType
+ eventDate = dateText record.EventDate
+ quantity = decimalText record.Quantity
+ amount = cashText record.Amount
+ note = record.Note
+ isSynthetic = record.IsSynthetic
+ createdAt = timestampText record.CreatedAt
+ }
+
let private stockPositionResponse (position: StockPositionRecord) : StockPositionResponse =
{
instrumentCode = position.InstrumentCode
@@ -2396,6 +2432,105 @@ module App =
return! invokeHandler (errorResponse 500 "PERSISTENCE_ERROR" "stock trade persistence failed") next ctx
}
+ let private parseStockCashflowCommand (body: string) : Result<StockCashflowCommand, 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",
+ tryStringProperty root "eventType",
+ tryStringProperty root "eventDate"
+ with
+ | Some code, Some eventType, Some eventDateText ->
+ if code.Trim().Length <> 6 || not (code.Trim() |> Seq.forall Char.IsDigit) then
+ Error "instrumentCode must contain exactly six digits"
+ else
+ match DateOnly.TryParseExact(eventDateText, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None) with
+ | false, _ -> Error "eventDate must be an ISO date (yyyy-MM-dd)"
+ | true, eventDate ->
+ let quantity =
+ match tryStringProperty root "quantity" with
+ | None -> Ok 0m
+ | Some text -> tryDecimal "quantity" text
+
+ let amount =
+ match tryStringProperty root "amount" with
+ | None -> Ok 0m
+ | Some text -> tryDecimal "amount" text
+
+ match quantity, amount with
+ | Error message, _ -> Error message
+ | _, Error message -> Error message
+ | Ok quantity, Ok amount ->
+ Ok
+ {
+ InstrumentCode = code.Trim()
+ StockName = tryStringProperty root "stockName"
+ EventType = eventType
+ EventDate = eventDate
+ Quantity = quantity
+ Amount = amount
+ Note = tryStringProperty root "note"
+ }
+ | _ -> Error "instrumentCode, eventType and eventDate are required"
+ with
+ | :? JsonException -> Error "request body must be valid JSON"
+
+ let private recordStockCashflow (repository: FundRepository) (fundIdText: string) : HttpHandler =
+ fun next ctx ->
+ task {
+ match Guid.TryParse fundIdText with
+ | false, _ ->
+ return! invokeHandler (errorResponse 400 "INVALID_STOCK_CASHFLOW_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 parseStockCashflowCommand body with
+ | Error message ->
+ return! invokeHandler (errorResponse 400 "INVALID_STOCK_CASHFLOW_REQUEST" message) next ctx
+ | Ok command ->
+ try
+ match repository.RecordStockCashflow(idempotencyKey, fundId, command) with
+ | StockCashflowWriteResult.StockCashflowCreated record ->
+ return! invokeHandler (setStatusCode 201 >=> json (stockCashflowResponse record)) next ctx
+ | StockCashflowWriteResult.StockCashflowReplayed record ->
+ return! invokeHandler (json (stockCashflowResponse record)) next ctx
+ | StockCashflowWriteResult.StockCashflowIdempotencyConflict ->
+ return!
+ invokeHandler
+ (errorResponse 409 "IDEMPOTENCY_CONFLICT" "idempotency key was used with a different request")
+ next
+ ctx
+ | StockCashflowWriteResult.StockCashflowInvalid message ->
+ return! invokeHandler (errorResponse 400 "INVALID_STOCK_CASHFLOW_REQUEST" message) next ctx
+ | StockCashflowWriteResult.StockCashflowFundNotFound ->
+ return! invokeHandler (errorResponse 404 "FUND_NOT_FOUND" "fund was not found") next ctx
+ with _ ->
+ return! invokeHandler (errorResponse 500 "PERSISTENCE_ERROR" "stock cashflow persistence failed") next ctx
+ }
+
+ let private getStockCashflows (repository: FundRepository) (fundIdText: string) : HttpHandler =
+ fun next ctx ->
+ match Guid.TryParse fundIdText with
+ | false, _ -> errorResponse 400 "INVALID_STOCK_CASHFLOW_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 events =
+ repository.GetStockCashflows fundId |> List.map stockCashflowResponse
+
+ json ({ fundId = fund.Id; events = events } : StockCashflowsResponse) next ctx
+ with _ ->
+ errorResponse 500 "PERSISTENCE_ERROR" "stock cashflow persistence failed" next ctx
+
let private getStockPositions (repository: FundRepository) (fundIdText: string) : HttpHandler =
fun next ctx ->
match Guid.TryParse fundIdText with
@@ -3404,6 +3539,8 @@ module App =
POST >=> routef "/funds/%s/stock-trades" (createStockTrade repository probes)
POST >=> routef "/funds/%s/stock-sells" (createStockSell repository probes)
GET >=> routef "/funds/%s/stock-positions" (getStockPositions repository)
+ POST >=> routef "/funds/%s/stock-cashflows" (recordStockCashflow repository)
+ GET >=> routef "/funds/%s/stock-cashflows" (getStockCashflows repository)
POST >=> routef "/funds/%s/bond-trades" (createBondTrade repository probes)
GET >=> routef "/funds/%s/bond-positions" (getBondPositions repository)
POST >=> routef "/funds/%s/bond-sells" (createBondSell repository probes)
diff --git a/src/FundLab.Api/Persistence.fs b/src/FundLab.Api/Persistence.fs
index 9ccb0c7..b22fbd9 100644
--- a/src/FundLab.Api/Persistence.fs
+++ b/src/FundLab.Api/Persistence.fs
@@ -358,6 +358,41 @@ type StockSellWriteResult =
| StockSellInsufficientHoldings of string
| StockSellFundNotFound
+type StockCashflowCommand =
+ {
+ InstrumentCode: string
+ StockName: string option
+ /// "buy" (cash out), "sell" (cash in) or "dividend" (cash in).
+ EventType: string
+ EventDate: DateOnly
+ Quantity: decimal
+ /// Absolute cash magnitude; the sign is implied by the event type.
+ Amount: decimal
+ Note: string option
+ }
+
+type StockCashflowRecord =
+ {
+ Id: Guid
+ FundId: Guid
+ InstrumentCode: string
+ StockName: string option
+ EventType: string
+ EventDate: DateOnly
+ Quantity: decimal
+ Amount: decimal
+ Note: string option
+ IsSynthetic: bool
+ CreatedAt: DateTimeOffset
+ }
+
+type StockCashflowWriteResult =
+ | StockCashflowCreated of StockCashflowRecord
+ | StockCashflowReplayed of StockCashflowRecord
+ | StockCashflowIdempotencyConflict
+ | StockCashflowInvalid of string
+ | StockCashflowFundNotFound
+
type BondTradeCommand =
{
InstrumentCode: string
@@ -1100,6 +1135,28 @@ type FundRepository(connectionString: string) =
executed_at timestamptz NOT NULL
);
+ CREATE TABLE IF NOT EXISTS stock_cashflow_events (
+ id uuid PRIMARY KEY,
+ fund_id uuid NOT NULL REFERENCES funds(id),
+ instrument_code text NOT NULL,
+ stock_name text NULL,
+ event_type text NOT NULL CHECK (event_type IN ('buy', 'sell', 'dividend')),
+ event_date date NOT NULL,
+ quantity numeric(28, 8) NOT NULL CHECK (quantity >= 0),
+ amount numeric(20, 2) NOT NULL CHECK (amount >= 0),
+ note text NULL,
+ is_synthetic boolean NOT NULL,
+ created_at timestamptz NOT NULL
+ );
+
+ CREATE TABLE IF NOT EXISTS stock_cashflow_idempotencies (
+ idempotency_key text PRIMARY KEY,
+ request_hash text NOT NULL,
+ event_id uuid NOT NULL REFERENCES stock_cashflow_events(id),
+ fund_id uuid NOT NULL REFERENCES funds(id),
+ created_at timestamptz NOT NULL DEFAULT now()
+ );
+
CREATE TABLE IF NOT EXISTS stock_sell_idempotencies (
idempotency_key text PRIMARY KEY,
request_hash text NOT NULL,
@@ -2312,6 +2369,165 @@ type FundRepository(connectionString: string) =
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload)))
+ let stockCashflowRecordFromReader (reader: DbDataReader) : StockCashflowRecord =
+ {
+ Id = reader.GetGuid(0)
+ FundId = reader.GetGuid(1)
+ InstrumentCode = reader.GetString(2)
+ StockName = if reader.IsDBNull(3) then None else Some(reader.GetString(3))
+ EventType = reader.GetString(4)
+ EventDate = reader.GetFieldValue<DateOnly>(5)
+ Quantity = reader.GetDecimal(6)
+ Amount = reader.GetDecimal(7)
+ Note = readStringOption reader 8
+ IsSynthetic = reader.GetBoolean(9)
+ CreatedAt = reader.GetFieldValue<DateTimeOffset>(10)
+ }
+
+ let stockCashflowColumns =
+ "id, fund_id, instrument_code, stock_name, event_type, event_date, quantity, amount, note, is_synthetic, created_at"
+
+ let insertStockCashflow connection transaction (record: StockCashflowRecord) =
+ use command =
+ commandWithTransaction
+ connection
+ transaction
+ """
+ INSERT INTO stock_cashflow_events
+ (id, fund_id, instrument_code, stock_name, event_type, event_date, quantity, amount, note, is_synthetic, created_at)
+ VALUES
+ (@id, @fund_id, @instrument_code, @stock_name, @event_type, @event_date, @quantity, @amount, @note, @is_synthetic, @created_at)
+ """
+
+ addParameter command "id" NpgsqlDbType.Uuid (box record.Id) |> ignore
+ addParameter command "fund_id" NpgsqlDbType.Uuid (box record.FundId) |> ignore
+ addParameter command "instrument_code" NpgsqlDbType.Text (box record.InstrumentCode) |> ignore
+
+ let nameParameter =
+ match record.StockName with
+ | Some name -> box name
+ | None -> box DBNull.Value
+
+ addParameter command "stock_name" NpgsqlDbType.Text nameParameter |> ignore
+ addParameter command "event_type" NpgsqlDbType.Text (box record.EventType) |> ignore
+ addParameter command "event_date" NpgsqlDbType.Date (box record.EventDate) |> ignore
+ addParameter command "quantity" NpgsqlDbType.Numeric (box record.Quantity) |> ignore
+ addParameter command "amount" NpgsqlDbType.Numeric (box record.Amount) |> ignore
+
+ let noteParameter =
+ match record.Note with
+ | Some note -> box note
+ | None -> box DBNull.Value
+
+ addParameter command "note" NpgsqlDbType.Text noteParameter |> ignore
+ addParameter command "is_synthetic" NpgsqlDbType.Boolean (box record.IsSynthetic) |> ignore
+ addParameter command "created_at" NpgsqlDbType.TimestampTz (box record.CreatedAt) |> ignore
+ command.ExecuteNonQuery() |> ignore
+
+ let insertStockCashflowIdempotency connection transaction key requestHash eventId fundId =
+ use command =
+ commandWithTransaction
+ connection
+ transaction
+ """
+ INSERT INTO stock_cashflow_idempotencies (idempotency_key, request_hash, event_id, fund_id)
+ VALUES (@idempotency_key, @request_hash, @event_id, @fund_id)
+ """
+
+ addParameter command "idempotency_key" NpgsqlDbType.Text (box key) |> ignore
+ addParameter command "request_hash" NpgsqlDbType.Text (box requestHash) |> ignore
+ addParameter command "event_id" NpgsqlDbType.Uuid (box eventId) |> ignore
+ addParameter command "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore
+ command.ExecuteNonQuery() |> ignore
+
+ let findStockCashflowIdempotency connection transaction key =
+ use command =
+ commandWithTransaction
+ connection
+ transaction
+ """
+ SELECT request_hash, fund_id, event_id
+ FROM stock_cashflow_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 findStockCashflow connection transaction eventId =
+ use command =
+ commandWithTransaction
+ connection
+ transaction
+ $"""
+ SELECT {stockCashflowColumns}
+ FROM stock_cashflow_events
+ WHERE id = @id
+ """
+
+ addParameter command "id" NpgsqlDbType.Uuid (box eventId) |> ignore
+
+ use reader = command.ExecuteReader()
+
+ if reader.Read() then
+ Some(stockCashflowRecordFromReader reader)
+ else
+ None
+
+ let stockCashflowRequestHash (fundId: Guid) (command: StockCashflowCommand) =
+ let invariant = CultureInfo.InvariantCulture
+ let encoded (value: string) = sprintf "%d:%s" value.Length value
+ let name = command.StockName |> Option.defaultValue ""
+ let note = command.Note |> Option.defaultValue ""
+
+ let payload =
+ String.concat
+ "|"
+ [
+ "stock-cashflow"
+ encoded (fundId.ToString("D"))
+ encoded command.InstrumentCode
+ encoded name
+ encoded command.EventType
+ encoded (command.EventDate.ToString("yyyy-MM-dd", invariant))
+ encoded (command.Quantity.ToString("G29", invariant))
+ encoded (command.Amount.ToString("G29", invariant))
+ encoded note
+ ]
+
+ Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload)))
+
+ let stockCashflowEvent
+ (fundId: Guid)
+ (instrumentCode: string)
+ (stockName: string option)
+ (eventType: string)
+ (eventDate: DateOnly)
+ (quantity: decimal)
+ (amount: decimal)
+ (note: string option)
+ (isSynthetic: bool)
+ : StockCashflowRecord =
+ {
+ Id = Guid.NewGuid()
+ FundId = fundId
+ InstrumentCode = instrumentCode
+ StockName = stockName
+ EventType = eventType
+ EventDate = eventDate
+ Quantity = quantity
+ Amount = amount
+ Note = note
+ IsSynthetic = isSynthetic
+ CreatedAt = DateTimeOffset.UtcNow
+ }
+
let bondTradeRecordFromReader (reader: DbDataReader) : BondTradeRecord =
{
Id = reader.GetGuid(0)
@@ -5392,6 +5608,20 @@ type FundRepository(connectionString: string) =
addParameter positionCommand "last_traded_at" NpgsqlDbType.TimestampTz (box executedAt) |> ignore
positionCommand.ExecuteNonQuery() |> ignore
+ let buyEvent =
+ stockCashflowEvent
+ fundId
+ normalized.InstrumentCode
+ normalized.StockName
+ "buy"
+ (DateOnly.FromDateTime executedAt.UtcDateTime)
+ normalized.Quantity
+ costCash
+ None
+ isSynthetic
+
+ insertStockCashflow connection (Some transaction) buyEvent
+
let persisted = { trade with ExecutedAt = executedAt }
transaction.Commit()
StockTradeWriteResult.StockTradeCreated persisted
@@ -5487,6 +5717,105 @@ type FundRepository(connectionString: string) =
records |> Seq.toList
+ member _.RecordStockCashflow(idempotencyKey: string, fundId: Guid, command: StockCashflowCommand) : StockCashflowWriteResult =
+ let code = if isNull command.InstrumentCode then "" else command.InstrumentCode.Trim()
+ let eventType = if isNull command.EventType then "" else command.EventType.Trim().ToLowerInvariant()
+
+ if String.IsNullOrWhiteSpace idempotencyKey then
+ StockCashflowWriteResult.StockCashflowInvalid "idempotency key cannot be empty"
+ elif code.Length <> 6 || not (code |> Seq.forall Char.IsDigit) then
+ StockCashflowWriteResult.StockCashflowInvalid "stock code must contain exactly six digits"
+ elif eventType <> "buy" && eventType <> "sell" && eventType <> "dividend" then
+ StockCashflowWriteResult.StockCashflowInvalid "event type must be buy, sell or dividend"
+ elif command.Quantity < 0m then
+ StockCashflowWriteResult.StockCashflowInvalid "quantity cannot be negative"
+ elif command.Amount < 0m then
+ StockCashflowWriteResult.StockCashflowInvalid "amount cannot be negative"
+ else
+ let normalized = { command with InstrumentCode = code; EventType = eventType }
+ let fingerprint = stockCashflowRequestHash 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 findStockCashflowIdempotency connection (Some transaction) idempotencyKey with
+ | Some(existingHash, existingFundId, eventId)
+ when existingHash = fingerprint && existingFundId = fundId ->
+ match findStockCashflow connection (Some transaction) eventId with
+ | Some record ->
+ transaction.Commit()
+ StockCashflowWriteResult.StockCashflowReplayed record
+ | None ->
+ transaction.Rollback()
+ StockCashflowWriteResult.StockCashflowInvalid "idempotency record references a missing event"
+ | Some _ ->
+ transaction.Rollback()
+ StockCashflowWriteResult.StockCashflowIdempotencyConflict
+ | None ->
+ match lockFundForOrder connection (Some transaction) fundId with
+ | None ->
+ transaction.Rollback()
+ StockCashflowWriteResult.StockCashflowFundNotFound
+ | Some isSynthetic ->
+ let record =
+ stockCashflowEvent
+ fundId
+ normalized.InstrumentCode
+ normalized.StockName
+ normalized.EventType
+ normalized.EventDate
+ normalized.Quantity
+ normalized.Amount
+ normalized.Note
+ isSynthetic
+
+ insertStockCashflow connection (Some transaction) record
+ insertStockCashflowIdempotency connection (Some transaction) idempotencyKey fingerprint record.Id fundId
+ transaction.Commit()
+ StockCashflowWriteResult.StockCashflowCreated record
+ with error ->
+ try
+ transaction.Rollback()
+ with _ ->
+ ()
+
+ raise error
+
+ member _.GetStockCashflows(fundId: Guid) : StockCashflowRecord list =
+ use connection = new NpgsqlConnection(connectionString)
+ connection.Open()
+
+ use command =
+ commandWithTransaction
+ connection
+ None
+ $"""
+ SELECT {stockCashflowColumns}
+ FROM stock_cashflow_events
+ WHERE fund_id = @fund_id
+ ORDER BY event_date, created_at, id
+ """
+
+ addParameter command "fund_id" NpgsqlDbType.Uuid (box fundId) |> ignore
+
+ use reader = command.ExecuteReader()
+ let records = ResizeArray<StockCashflowRecord>()
+
+ while reader.Read() do
+ records.Add(stockCashflowRecordFromReader reader)
+
+ records |> Seq.toList
+
member _.CreateStockSell(idempotencyKey: string, fundId: Guid, command: StockSellCommand, ?executedAtOverride: DateTimeOffset) : StockSellWriteResult =
if String.IsNullOrWhiteSpace idempotencyKey then
StockSellWriteResult.StockSellInvalid "idempotency key cannot be empty"
@@ -5646,6 +5975,21 @@ type FundRepository(connectionString: string) =
insertStockSell connection (Some transaction) sell
insertStockSellIdempotency connection (Some transaction) idempotencyKey fingerprint sell.Id fundId
+
+ let sellEvent =
+ stockCashflowEvent
+ fundId
+ normalized.InstrumentCode
+ normalized.StockName
+ "sell"
+ (DateOnly.FromDateTime executedAt.UtcDateTime)
+ normalized.Quantity
+ proceeds
+ None
+ isSynthetic
+
+ insertStockCashflow connection (Some transaction) sellEvent
+
transaction.Commit()
StockSellWriteResult.StockSellCreated sell
with error ->
diff --git a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
index f7e1225..1c2dd86 100644
--- a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
+++ b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
@@ -36,6 +36,7 @@
<Compile Include="StockDailyProbeTests.fs" />
<Compile Include="StockTradeTests.fs" />
<Compile Include="StockSellTests.fs" />
+ <Compile Include="StockCashflowTests.fs" />
<Compile Include="BondTradeTests.fs" />
<Compile Include="FundValuationTests.fs" />
<Compile Include="SipPlanStatusTests.fs" />
diff --git a/tests/FundLab.Api.Tests/StockCashflowTests.fs b/tests/FundLab.Api.Tests/StockCashflowTests.fs
new file mode 100644
index 0000000..0468edc
--- /dev/null
+++ b/tests/FundLab.Api.Tests/StockCashflowTests.fs
@@ -0,0 +1,147 @@
+namespace FundLab.Api.Tests
+
+open System
+open Xunit
+open FundLab.Api
+
+module StockCashflowSupport =
+ let quote (code: string) (price: decimal) : StockQuote =
+ { Code = code; Name = Some "贵州茅台"; Price = Some price; Currency = "CNY"; Market = Some "sh"; Adjust = Some "none"; Suspended = Some false }
+
+ let stubQuoteProbe (quotes: Map<string, decimal>) =
+ { new IStockQuoteProbe with
+ member _.GetQuote(code, _) =
+ match quotes.TryFind code with
+ | Some price -> Ok(quote code price)
+ | None -> Error(MarketDataCollectorUnavailable(sprintf "no valid quote for %s" code)) }
+
+ let probes (quotes: Map<string, decimal>) =
+ { NavDates = NavDateProbeSupport.stubNavDateProbe
+ NavSeries = NavDateProbeSupport.stubNavSeriesProbe
+ BondQuotes = NavDateProbeSupport.stubBondQuoteProbe
+ StockQuotes = stubQuoteProbe quotes
+ StockDaily = NavDateProbeSupport.stubStockDailyProbe }
+
+[<Collection("postgres")>]
+type StockCashflowTests(fixture: PostgresFixture) =
+ let sharedRepository =
+ lazy
+ let value = FundRepository(fixture.ConnectionString)
+ value.EnsureSchema()
+ value
+
+ let repository () = sharedRepository.Value
+
+ let createFund () =
+ let command =
+ {
+ Name = "股票现金账本测试 FOF"
+ InitialCash = 1000000.00m
+ InitialUnitNav = 1.00000000m
+ IsSynthetic = true
+ }
+
+ let key = fixture.Key(sprintf "stock-cashflow-fund-%s" (Guid.NewGuid().ToString("N")))
+
+ match repository().CreateFund(key, command) with
+ | FundWriteResult.Created fund -> fund.Id
+ | other -> failwithf "unexpected fund creation result: %A" other
+
+ let app (quotes: Map<string, decimal>) =
+ App.createApplicationWithProbes (repository ()) (StockCashflowSupport.probes quotes)
+
+ let buy (quotes: Map<string, decimal>) fundId body idempotencyKey =
+ PersistenceTestHelpers.invoke
+ (app quotes)
+ "POST"
+ (sprintf "/api/funds/%O/stock-trades" fundId)
+ [ "Authorization", "Bearer test-token"; "Idempotency-Key", idempotencyKey ]
+ body
+
+ let sell (quotes: Map<string, decimal>) fundId body idempotencyKey =
+ PersistenceTestHelpers.invoke
+ (app quotes)
+ "POST"
+ (sprintf "/api/funds/%O/stock-sells" fundId)
+ [ "Authorization", "Bearer test-token"; "Idempotency-Key", idempotencyKey ]
+ body
+
+ let recordCashflow (quotes: Map<string, decimal>) fundId body idempotencyKey =
+ PersistenceTestHelpers.invoke
+ (app quotes)
+ "POST"
+ (sprintf "/api/funds/%O/stock-cashflows" fundId)
+ [ "Authorization", "Bearer test-token"; "Idempotency-Key", idempotencyKey ]
+ body
+
+ let readCashflows (quotes: Map<string, decimal>) fundId =
+ PersistenceTestHelpers.invoke
+ (app quotes)
+ "GET"
+ (sprintf "/api/funds/%O/stock-cashflows" fundId)
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ [<Fact>]
+ member _.``buying a stock records a buy cashflow event``() =
+ let fundId = createFund ()
+ let quotes = Map.ofList [ "600519", 1252.57m ]
+
+ let buyStatus, _ = buy quotes fundId """{"instrumentCode":"600519","quantity":"100"}""" (fixture.Key "stock-cashflow-buy")
+ Assert.Equal(201, buyStatus)
+
+ let status, body = readCashflows quotes fundId
+ Assert.Equal(200, status)
+ Assert.Contains("\"eventType\":\"buy\"", body)
+ Assert.Contains("\"instrumentCode\":\"600519\"", body)
+ Assert.Contains("\"amount\":\"125257.00\"", body)
+
+ [<Fact>]
+ member _.``selling a stock records a sell cashflow event``() =
+ let fundId = createFund ()
+ let quotes = Map.ofList [ "600519", 1252.57m ]
+
+ let buyStatus, _ = buy quotes fundId """{"instrumentCode":"600519","quantity":"100"}""" (fixture.Key "stock-cashflow-sell-buy")
+ Assert.Equal(201, buyStatus)
+
+ let sellStatus, _ = sell quotes fundId """{"instrumentCode":"600519","quantity":"40"}""" (fixture.Key "stock-cashflow-sell")
+ Assert.Equal(201, sellStatus)
+
+ let _, body = readCashflows quotes fundId
+ Assert.Contains("\"eventType\":\"sell\"", body)
+ Assert.Contains("\"amount\":\"50102.80\"", body)
+
+ [<Fact>]
+ member _.``a recorded dividend cashflow is idempotent``() =
+ let fundId = createFund ()
+ let quotes = Map.ofList [ "600519", 1252.57m ]
+ let key = fixture.Key "stock-cashflow-dividend"
+
+ let body =
+ """{"instrumentCode":"600519","eventType":"dividend","eventDate":"2026-06-20","quantity":"100","amount":"250.00"}"""
+
+ let firstStatus, firstBody = recordCashflow quotes fundId body key
+ Assert.Equal(201, firstStatus)
+ Assert.Contains("\"eventType\":\"dividend\"", firstBody)
+ Assert.Contains("\"amount\":\"250.00\"", firstBody)
+
+ let secondStatus, _ = recordCashflow quotes fundId body key
+ Assert.Equal(200, secondStatus)
+
+ let _, events = readCashflows quotes fundId
+ Assert.Contains("\"eventType\":\"dividend\"", events)
+
+ [<Fact>]
+ member _.``a malformed event type is rejected``() =
+ let fundId = createFund ()
+ let quotes = Map.ofList [ "600519", 1252.57m ]
+
+ let status, body =
+ recordCashflow
+ quotes
+ fundId
+ """{"instrumentCode":"600519","eventType":"interest","eventDate":"2026-06-20","amount":"1.00"}"""
+ (fixture.Key "stock-cashflow-bad-type")
+
+ Assert.Equal(400, status)
+ Assert.Contains("INVALID_STOCK_CASHFLOW_REQUEST", body)