summaryrefslogtreecommitdiff
path: root/tests/FundLab.Api.Tests/StockSellTests.fs
diff options
context:
space:
mode:
Diffstat (limited to 'tests/FundLab.Api.Tests/StockSellTests.fs')
-rw-r--r--tests/FundLab.Api.Tests/StockSellTests.fs237
1 files changed, 237 insertions, 0 deletions
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<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 }
+
+ 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
+ }
+
+[<Collection("postgres")>]
+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<string, decimal>) =
+ App.createApplicationWithProbes (repository ()) (StockSellSupport.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 positions (quotes: Map<string, decimal>) fundId =
+ PersistenceTestHelpers.invoke
+ (app quotes)
+ "GET"
+ (sprintf "/api/funds/%O/stock-positions" fundId)
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ let fund (quotes: Map<string, decimal>) fundId =
+ PersistenceTestHelpers.invoke
+ (app quotes)
+ "GET"
+ (sprintf "/api/funds/%O" fundId)
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ let valuation (quotes: Map<string, decimal>) fundId =
+ PersistenceTestHelpers.invoke
+ (app quotes)
+ "GET"
+ (sprintf "/api/funds/%O/valuation" fundId)
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ [<Fact>]
+ 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)
+
+ [<Fact>]
+ 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)
+
+ [<Fact>]
+ 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)
+
+ [<Fact>]
+ 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)
+
+ [<Fact>]
+ 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)
+
+ [<Fact>]
+ 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)