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)