summaryrefslogtreecommitdiff
path: root/tests/FundLab.Api.Tests/StockSipTests.fs
diff options
context:
space:
mode:
Diffstat (limited to 'tests/FundLab.Api.Tests/StockSipTests.fs')
-rw-r--r--tests/FundLab.Api.Tests/StockSipTests.fs154
1 files changed, 154 insertions, 0 deletions
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)