From c48b5560778548e2e87e05b1df5b170d7db1a345 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Tue, 22 Sep 2026 06:20:25 +0800 Subject: Add stock sell vertical slice with cash recovery and snapshot pricing (3d-26) --- tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj | 1 + tests/FundLab.Api.Tests/StockSellTests.fs | 237 +++++++++++++++++++++++ 2 files changed, 238 insertions(+) create mode 100644 tests/FundLab.Api.Tests/StockSellTests.fs (limited to 'tests') diff --git a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj index d42612a..f7e1225 100644 --- a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj +++ b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj @@ -35,6 +35,7 @@ + diff --git a/tests/FundLab.Api.Tests/StockSellTests.fs b/tests/FundLab.Api.Tests/StockSellTests.fs new file mode 100644 index 0000000..cbdef8b --- /dev/null +++ b/tests/FundLab.Api.Tests/StockSellTests.fs @@ -0,0 +1,237 @@ +namespace FundLab.Api.Tests + +open System +open Xunit +open FundLab.Api + +module StockSellSupport = + let quote (code: string) (price: decimal) : StockQuote = + { Code = code; Name = Some "招商银行"; Price = Some price; Currency = "CNY" } + + let stubQuoteProbe (quotes: Map) = + { new IStockQuoteProbe with + member _.GetQuote(code, _) = + match quotes.TryFind code with + | Some price -> Ok(quote code price) + | None -> Error(MarketDataCollectorUnavailable(sprintf "no valid quote for %s" code)) } + + let probes (quotes: Map) = + { NavDates = NavDateProbeSupport.stubNavDateProbe + NavSeries = NavDateProbeSupport.stubNavSeriesProbe + BondQuotes = NavDateProbeSupport.stubBondQuoteProbe + StockQuotes = stubQuoteProbe quotes + StockDaily = NavDateProbeSupport.stubStockDailyProbe } + + let snapshot (code: string) (price: decimal) : InstrumentSnapshotRecord = + { + InstrumentCode = code + AssetClass = "stock" + SnapshotDate = DateOnly(2026, 9, 21) + Price = price + Source = "test" + SourceRevision = "test" + SourceCollectedAt = DateTimeOffset.UtcNow + SourcePayloadHash = sprintf "test:%s" code + } + +[] +type StockSellTests(fixture: PostgresFixture) = + let sharedRepository = + lazy + let value = FundRepository(fixture.ConnectionString) + value.EnsureSchema() + value + + let repository () = sharedRepository.Value + + let createFund (initialCash: decimal) = + let command = + { + Name = "股票卖出闭环测试 FOF" + InitialCash = initialCash + InitialUnitNav = 1.00000000m + IsSynthetic = true + } + + let key = fixture.Key(sprintf "stock-sell-fund-%s" (Guid.NewGuid().ToString("N"))) + + match repository().CreateFund(key, command) with + | FundWriteResult.Created fund -> fund.Id + | other -> failwithf "unexpected fund creation result: %A" other + + let app (quotes: Map) = + App.createApplicationWithProbes (repository ()) (StockSellSupport.probes quotes) + + let buy (quotes: Map) fundId body idempotencyKey = + PersistenceTestHelpers.invoke + (app quotes) + "POST" + (sprintf "/api/funds/%O/stock-trades" fundId) + [ "Authorization", "Bearer test-token"; "Idempotency-Key", idempotencyKey ] + body + + let sell (quotes: Map) fundId body idempotencyKey = + PersistenceTestHelpers.invoke + (app quotes) + "POST" + (sprintf "/api/funds/%O/stock-sells" fundId) + [ "Authorization", "Bearer test-token"; "Idempotency-Key", idempotencyKey ] + body + + let positions (quotes: Map) fundId = + PersistenceTestHelpers.invoke + (app quotes) + "GET" + (sprintf "/api/funds/%O/stock-positions" fundId) + [ "Authorization", "Bearer test-token" ] + "" + + let fund (quotes: Map) fundId = + PersistenceTestHelpers.invoke + (app quotes) + "GET" + (sprintf "/api/funds/%O" fundId) + [ "Authorization", "Bearer test-token" ] + "" + + let valuation (quotes: Map) fundId = + PersistenceTestHelpers.invoke + (app quotes) + "GET" + (sprintf "/api/funds/%O/valuation" fundId) + [ "Authorization", "Bearer test-token" ] + "" + + [] + member _.``selling part of a position credits cash and reduces the position``() = + let fundId = createFund 1000.00m + let quotes = Map.ofList [ "600030", 10.00m ] + + let status, _ = buy quotes fundId """{"instrumentCode":"600030","quantity":"100"}""" (fixture.Key "stock-sell-buy-1") + Assert.Equal(201, status) + + let sellQuotes = Map.ofList [ "600030", 12.00m ] + + let status, body = + sell sellQuotes fundId """{"instrumentCode":"600030","quantity":"40"}""" (fixture.Key "stock-sell-partial") + + Assert.Equal(201, status) + Assert.Contains("\"instrumentCode\":\"600030\"", body) + Assert.Contains("\"quantity\":\"40.00000000\"", body) + Assert.Contains("\"price\":\"12.00000000\"", body) + Assert.Contains("\"feeAmount\":\"0.00\"", body) + Assert.Contains("\"proceeds\":\"480.00\"", body) + + let status, positionsBody = positions sellQuotes fundId + Assert.Equal(200, status) + Assert.Contains("\"quantity\":\"60.00000000\"", positionsBody) + Assert.Contains("\"costCash\":\"600.00\"", positionsBody) + + let status, fundBody = fund sellQuotes fundId + Assert.Equal(200, status) + Assert.Contains("\"availableCash\":\"1480.00\"", fundBody) + + [] + member _.``selling all units removes the position row and credits the proceeds``() = + let fundId = createFund 1000.00m + let quotes = Map.ofList [ "600030", 10.00m ] + + let status, _ = buy quotes fundId """{"instrumentCode":"600030","quantity":"100"}""" (fixture.Key "stock-sell-buy-2") + Assert.Equal(201, status) + + let sellQuotes = Map.ofList [ "600030", 12.00m ] + + let status, _ = + sell sellQuotes fundId """{"instrumentCode":"600030","quantity":"100"}""" (fixture.Key "stock-sell-all") + + Assert.Equal(201, status) + + let _, positionsBody = positions sellQuotes fundId + Assert.Contains("\"positions\":[]", positionsBody) + + let _, fundBody = fund sellQuotes fundId + Assert.Contains("\"availableCash\":\"2200.00\"", fundBody) + + [] + member _.``overselling is rejected with a reason and leaves cash and position unchanged``() = + let fundId = createFund 1000.00m + let quotes = Map.ofList [ "600030", 10.00m ] + + let status, _ = buy quotes fundId """{"instrumentCode":"600030","quantity":"100"}""" (fixture.Key "stock-sell-buy-3") + Assert.Equal(201, status) + + let status, body = + sell quotes fundId """{"instrumentCode":"600030","quantity":"150"}""" (fixture.Key "stock-sell-over") + + Assert.Equal(400, status) + Assert.Contains("INSUFFICIENT_STOCK_HOLDINGS", body) + Assert.Contains("are not enough", body) + + let _, positionsBody = positions quotes fundId + Assert.Contains("\"quantity\":\"100.00000000\"", positionsBody) + + let _, fundBody = fund quotes fundId + Assert.Contains("\"availableCash\":\"1000.00\"", fundBody) + + [] + member _.``valuation after a partial sale prices only the remaining units``() = + let fundId = createFund 1000.00m + let quotes = Map.ofList [ "600030", 10.00m ] + + let status, _ = buy quotes fundId """{"instrumentCode":"600030","quantity":"100"}""" (fixture.Key "stock-sell-buy-4") + Assert.Equal(201, status) + + let status, _ = + sell quotes fundId """{"instrumentCode":"600030","quantity":"40"}""" (fixture.Key "stock-sell-valuation") + + Assert.Equal(201, status) + + let valuationQuotes = Map.ofList [ "600030", 15.00m ] + let status, body = valuation valuationQuotes fundId + + Assert.Equal(200, status) + Assert.Contains("\"quantity\":\"60.00000000\"", body) + Assert.Contains("\"positionsMarketValue\":\"900.00\"", body) + Assert.Contains("\"pricedPositions\":1", body) + + [] + member _.``replaying a sale with the same idempotency key does not double-sell or double-credit``() = + let fundId = createFund 1000.00m + let quotes = Map.ofList [ "600030", 10.00m ] + + let status, _ = buy quotes fundId """{"instrumentCode":"600030","quantity":"100"}""" (fixture.Key "stock-sell-buy-5") + Assert.Equal(201, status) + + let key = fixture.Key "stock-sell-replay" + + let status, _ = sell quotes fundId """{"instrumentCode":"600030","quantity":"30"}""" key + Assert.Equal(201, status) + + let status, _ = sell quotes fundId """{"instrumentCode":"600030","quantity":"30"}""" key + Assert.Equal(200, status) + + let _, positionsBody = positions quotes fundId + Assert.Contains("\"quantity\":\"70.00000000\"", positionsBody) + Assert.DoesNotContain("\"quantity\":\"40.00000000\"", positionsBody) + + let _, fundBody = fund quotes fundId + Assert.Contains("\"availableCash\":\"1300.00\"", fundBody) + + [] + member _.``a persisted snapshot price wins over the live quote when selling``() = + let fundId = createFund 1000.00m + let quotes = Map.ofList [ "600036", 10.00m ] + + let status, _ = buy quotes fundId """{"instrumentCode":"600036","quantity":"100"}""" (fixture.Key "stock-sell-buy-6") + Assert.Equal(201, status) + + repository().UpsertInstrumentSnapshots [ StockSellSupport.snapshot "600036" 20.00m ] + + let liveQuotes = Map.ofList [ "600036", 99.00m ] + + let status, body = + sell liveQuotes fundId """{"instrumentCode":"600036","quantity":"50"}""" (fixture.Key "stock-sell-snapshot") + + Assert.Equal(201, status) + Assert.Contains("\"price\":\"20.00000000\"", body) + Assert.Contains("\"proceeds\":\"1000.00\"", body) -- cgit v1.2.3