namespace FundLab.Api.Tests open System open Xunit open FundLab.Api open FundLab.Domain module StockSipSupport = let quoteWith (code: string) (price: decimal option) (suspended: bool option) : StockQuote = { Code = code Name = Some "贵州茅台" Price = price Currency = "CNY" Market = Some "sh" Adjust = Some "none" Suspended = suspended } let stubQuoteProbe (quote: StockQuote) = { new IStockQuoteProbe with member _.GetQuote(_, _) = Ok quote } let probes (quote: StockQuote) = { NavDates = NavDateProbeSupport.stubNavDateProbe NavSeries = NavDateProbeSupport.stubNavSeriesProbe BondQuotes = NavDateProbeSupport.stubBondQuoteProbe StockQuotes = stubQuoteProbe quote StockDaily = NavDateProbeSupport.stubStockDailyProbe } [] type StockSipTests(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-sip-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 createStockPlan fundId code amount frequency anchor = let key = fixture.Key(sprintf "stock-sip-plan-%s" (Guid.NewGuid().ToString("N"))) match repository().CreateSipPlan( key, fundId, { InstrumentCode = code; Amount = amount; Frequency = frequency }, anchor, "stock" ) with | SipPlanWriteResult.SipPlanCreated plan -> plan.Id | other -> failwithf "unexpected sip plan result: %A" other let app (quote: StockQuote) = App.createApplicationWithProbes (repository ()) (StockSipSupport.probes quote) let advance (quote: StockQuote) fundId body = PersistenceTestHelpers.invoke (app quote) "POST" (sprintf "/api/funds/%O/sip/advance" fundId) [ "Authorization", "Bearer test-token" ] body let availableCash fundId = match repository().GetFund fundId with | Some fund -> fund.AvailableCash | None -> failwith "fund not found" let stockQuantity fundId code = repository().GetStockPositions fundId |> List.filter (fun position -> position.InstrumentCode = code) |> List.sumBy (fun position -> position.Quantity) [] member _.``a stock sip period buys whole lots and debits cash through the shared pipeline``() = let fundId = createFund 10000.00m let quote = StockSipSupport.quoteWith "600519" (Some 10.00m) (Some false) let _ = createStockPlan fundId "600519" 2000.00m SipFrequency.Weekly (DateOnly(2026, 9, 7)) let status, body = advance quote fundId """{"endDate":"2026-09-14"}""" Assert.Equal(200, status) Assert.Contains("\"assetClass\":\"stock\"", body) Assert.Contains("\"status\":\"succeeded\"", body) Assert.Equal(200m, stockQuantity fundId "600519") Assert.Equal(8000.00m, availableCash fundId) let buyEvents = repository().GetStockCashflows fundId |> List.filter (fun record -> record.EventType = "buy" && record.InstrumentCode = "600519") Assert.Single(buyEvents) |> ignore Assert.Equal(2000.00m, buyEvents.Head.Amount) [] member _.``a suspended stock sip period is rejected without any trade``() = let fundId = createFund 10000.00m let quote = StockSipSupport.quoteWith "600519" (Some 10.00m) (Some true) let _ = createStockPlan fundId "600519" 2000.00m SipFrequency.Weekly (DateOnly(2026, 9, 7)) let status, body = advance quote fundId """{"endDate":"2026-09-14"}""" Assert.Equal(200, status) Assert.Contains("\"status\":\"suspended\"", body) Assert.Equal(0m, stockQuantity fundId "600519") Assert.Equal(10000.00m, availableCash fundId) [] member _.``a stock sip period with insufficient cash is rejected and never drives cash negative``() = let fundId = createFund 500.00m let quote = StockSipSupport.quoteWith "600519" (Some 10.00m) (Some false) let _ = createStockPlan fundId "600519" 2000.00m SipFrequency.Weekly (DateOnly(2026, 9, 7)) let status, body = advance quote fundId """{"endDate":"2026-09-14"}""" Assert.Equal(200, status) Assert.Contains("\"status\":\"insufficient_cash\"", body) Assert.Equal(0m, stockQuantity fundId "600519") Assert.Equal(500.00m, availableCash fundId) [] member _.``re-running the same stock sip window does not double the position``() = let fundId = createFund 10000.00m let quote = StockSipSupport.quoteWith "600519" (Some 10.00m) (Some false) let _ = createStockPlan fundId "600519" 2000.00m SipFrequency.Weekly (DateOnly(2026, 9, 7)) let firstStatus, _ = advance quote fundId """{"endDate":"2026-09-14"}""" Assert.Equal(200, firstStatus) let secondStatus, secondBody = advance quote fundId """{"endDate":"2026-09-14"}""" Assert.Equal(200, secondStatus) Assert.Contains("\"status\":\"succeeded\"", secondBody) Assert.Equal(200m, stockQuantity fundId "600519") Assert.Equal(8000.00m, availableCash fundId)