diff options
Diffstat (limited to 'tests/FundLab.Api.Tests')
| -rw-r--r-- | tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj | 1 | ||||
| -rw-r--r-- | tests/FundLab.Api.Tests/StockSipTests.fs | 154 |
2 files changed, 155 insertions, 0 deletions
diff --git a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj index 1c2dd86..b2e587f 100644 --- a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj +++ b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj @@ -37,6 +37,7 @@ <Compile Include="StockTradeTests.fs" /> <Compile Include="StockSellTests.fs" /> <Compile Include="StockCashflowTests.fs" /> + <Compile Include="StockSipTests.fs" /> <Compile Include="BondTradeTests.fs" /> <Compile Include="FundValuationTests.fs" /> <Compile Include="SipPlanStatusTests.fs" /> diff --git a/tests/FundLab.Api.Tests/StockSipTests.fs b/tests/FundLab.Api.Tests/StockSipTests.fs new file mode 100644 index 0000000..2dae473 --- /dev/null +++ b/tests/FundLab.Api.Tests/StockSipTests.fs @@ -0,0 +1,154 @@ +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 } + +[<Collection("postgres")>] +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) + + [<Fact>] + 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) + + [<Fact>] + 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) + + [<Fact>] + 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) + + [<Fact>] + 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) |
