summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-21 23:21:54 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-21 23:21:54 +0800
commitd4b539a2791a0cd4d098fecfece0039073a9f7e2 (patch)
treedfd314b37fa24379ef13dcd66a1de76e2a923bf2 /tests
parent12c4d3625458c830a2746a1f277fb684e9c498cb (diff)
downloadfund-lab-d4b539a2791a0cd4d098fecfece0039073a9f7e2.tar.gz
Add scheduled investment plans (3d-10)
Diffstat (limited to 'tests')
-rw-r--r--tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj1
-rw-r--r--tests/FundLab.Api.Tests/InvestmentPlanTests.fs322
-rw-r--r--tests/FundLab.Domain.Tests/FundLab.Domain.Tests.fsproj1
-rw-r--r--tests/FundLab.Domain.Tests/InvestmentPlanTests.fs81
4 files changed, 405 insertions, 0 deletions
diff --git a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
index 0024deb..ca5bde8 100644
--- a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
+++ b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
@@ -27,6 +27,7 @@
<Compile Include="SipAdvanceTests.fs" />
<Compile Include="DividendTests.fs" />
<Compile Include="ReturnsTests.fs" />
+ <Compile Include="InvestmentPlanTests.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
</Project>
diff --git a/tests/FundLab.Api.Tests/InvestmentPlanTests.fs b/tests/FundLab.Api.Tests/InvestmentPlanTests.fs
new file mode 100644
index 0000000..77a90ab
--- /dev/null
+++ b/tests/FundLab.Api.Tests/InvestmentPlanTests.fs
@@ -0,0 +1,322 @@
+namespace FundLab.Api.Tests
+
+open System
+open System.Text.Json
+open Xunit
+open FundLab.Api
+open FundLab.Domain
+
+[<Collection("postgres")>]
+type InvestmentPlanTests(fixture: PostgresFixture) =
+ let sharedRepository =
+ lazy
+ let value = FundRepository(fixture.ConnectionString)
+ value.EnsureSchema()
+ value
+
+ let repository () = sharedRepository.Value
+
+ let seedInstrument () =
+ let code = Random.Shared.Next(0, 1000000).ToString("D6")
+
+ let payload =
+ {
+ Source = "akshare"
+ SourceRevision = "akshare-test/eastmoney"
+ CollectedAt = DateTimeOffset(2026, 9, 21, 8, 0, 0, TimeSpan.Zero)
+ Instruments = [ { Code = code; Name = "定投测试基金"; FundType = None } ]
+ }
+
+ repository().UpsertInstruments(payload, "investment-plan-test-hash")
+ code
+
+ let createFund (initialCash: decimal) =
+ let command =
+ {
+ Name = "定投测试 FOF"
+ InitialCash = initialCash
+ InitialUnitNav = 1.00000000m
+ IsSynthetic = true
+ }
+
+ let key = fixture.Key(sprintf "investment-plan-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 () = App.createApplication (repository ())
+
+ let truncateMicroseconds (moment: DateTimeOffset) =
+ let utc = moment.ToUniversalTime()
+ DateTimeOffset(utc.Ticks - (utc.Ticks % 10L), TimeSpan.Zero)
+
+ let insertQuoteOnDate (code: string) (nav: decimal) (navDate: DateOnly) =
+ let revision = sprintf "akshare-test/%O" (Guid.NewGuid())
+
+ let payload: MarketDataNavPayload =
+ {
+ Source = "akshare"
+ SourceRevision = revision
+ CollectedAt = truncateMicroseconds (DateTimeOffset.Now.AddSeconds(-10.0))
+ Code = code
+ Observations =
+ [
+ {
+ NavDate = navDate
+ PublishedAt = None
+ Nav = nav
+ AccumulatedNav = Some nav
+ DailyReturn = Some 0.0m
+ }
+ ]
+ }
+
+ repository().UpsertNavObservations(payload, sprintf "investment-plan-hash/%s" revision)
+
+ let createPlanViaApi fundId (code: string) (amount: string) (frequency: string) =
+ let body =
+ sprintf "{\"instrumentCode\":\"%s\",\"amount\":\"%s\",\"frequency\":\"%s\"}" code amount frequency
+
+ PersistenceTestHelpers.invoke
+ (app ())
+ "POST"
+ (sprintf "/api/funds/%O/investment-plans" fundId)
+ [
+ "Authorization", "Bearer test-token"
+ "Idempotency-Key", fixture.Key(sprintf "investment-plan-%s" (Guid.NewGuid().ToString("N")))
+ ]
+ body
+
+ let createPlanWithKey fundId (code: string) (amount: string) (frequency: string) idempotencyKey =
+ let body =
+ sprintf "{\"instrumentCode\":\"%s\",\"amount\":\"%s\",\"frequency\":\"%s\"}" code amount frequency
+
+ PersistenceTestHelpers.invoke
+ (app ())
+ "POST"
+ (sprintf "/api/funds/%O/investment-plans" fundId)
+ [ "Authorization", "Bearer test-token"; "Idempotency-Key", idempotencyKey ]
+ body
+
+ let listPlansViaApi fundId =
+ PersistenceTestHelpers.invoke
+ (app ())
+ "GET"
+ (sprintf "/api/funds/%O/investment-plans" fundId)
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ let runPlansViaApi fundId =
+ PersistenceTestHelpers.invoke
+ (app ())
+ "POST"
+ (sprintf "/api/funds/%O/investment-plans/run" fundId)
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ let runPlansOnViaApi fundId (processingDate: DateOnly) =
+ PersistenceTestHelpers.invoke
+ (app ())
+ "POST"
+ (sprintf "/api/funds/%O/investment-plans/run" fundId)
+ [ "Authorization", "Bearer test-token" ]
+ (sprintf "{\"processingDate\":\"%s\"}" (processingDate.ToString("yyyy-MM-dd")))
+
+ let createPlanWithAnchor (fundId: Guid) (code: string) (amount: decimal) (frequency: InvestmentFrequency) (anchor: DateOnly) =
+ let key = fixture.Key(sprintf "investment-plan-anchor-%s" (Guid.NewGuid().ToString("N")))
+
+ match repository().CreateInvestmentPlan(key, fundId, { InstrumentCode = code; Amount = amount; Frequency = frequency }, anchor) with
+ | InvestmentPlanWriteResult.InvestmentPlanCreated plan -> plan.Id
+ | other -> failwithf "unexpected investment plan result: %A" other
+
+ let today = ConfirmationPolicy.tradeDateFor DateTimeOffset.UtcNow
+
+ let availableCash fundId =
+ match repository().GetFund fundId with
+ | Some fund -> fund.AvailableCash
+ | None -> failwith "fund was not found"
+
+ let unitsFor fundId (code: string) =
+ repository().GetFundPositions fundId
+ |> List.tryFind (fun position -> position.InstrumentCode = code)
+ |> Option.map (fun position -> position.Units)
+ |> Option.defaultValue 0m
+
+ let planRuns (body: string) =
+ use document = JsonDocument.Parse(body)
+
+ document.RootElement.GetProperty("plans").EnumerateArray()
+ |> Seq.collect (fun plan -> plan.GetProperty("runs").EnumerateArray())
+ |> Seq.map (fun run -> run.Clone())
+ |> Seq.toList
+
+ [<Fact>]
+ member _.``create is idempotent, lists the plan and rejects an unknown instrument``() =
+ let fundId = createFund 10000.00m
+ let code = seedInstrument ()
+ let key = fixture.Key(sprintf "investment-plan-%s" (Guid.NewGuid().ToString("N")))
+
+ let firstStatus, firstBody = createPlanWithKey fundId code "100.00" "daily" key
+ Assert.Equal(201, firstStatus)
+
+ use firstDocument = JsonDocument.Parse(firstBody)
+ let planId = firstDocument.RootElement.GetProperty("id").GetString()
+ Assert.Equal(code, firstDocument.RootElement.GetProperty("instrumentCode").GetString())
+ Assert.Equal("100.00", firstDocument.RootElement.GetProperty("amount").GetString())
+ Assert.Equal("daily", firstDocument.RootElement.GetProperty("frequency").GetString())
+ Assert.Equal(today.ToString("yyyy-MM-dd"), firstDocument.RootElement.GetProperty("nextRunDate").GetString())
+
+ let replayStatus, replayBody = createPlanWithKey fundId code "100.00" "daily" key
+ Assert.Equal(200, replayStatus)
+
+ use replayDocument = JsonDocument.Parse(replayBody)
+ Assert.Equal(planId, replayDocument.RootElement.GetProperty("id").GetString())
+
+ let listStatus, listBody = listPlansViaApi fundId
+ Assert.Equal(200, listStatus)
+ Assert.Contains(planId, listBody)
+
+ let missingStatus, missingBody = createPlanViaApi fundId "999999" "100.00" "daily"
+ Assert.Equal(404, missingStatus)
+ Assert.Contains("INSTRUMENT_NOT_FOUND", missingBody)
+
+ [<Fact>]
+ member _.``create rejects a bad amount and a bad frequency``() =
+ let fundId = createFund 10000.00m
+ let code = seedInstrument ()
+
+ let badAmountStatus, badAmountBody = createPlanViaApi fundId code "0.00" "daily"
+ Assert.Equal(400, badAmountStatus)
+ Assert.Contains("INVALID_INVESTMENT_PLAN_REQUEST", badAmountBody)
+
+ let badFrequencyStatus, badFrequencyBody = createPlanViaApi fundId code "100.00" "hourly"
+ Assert.Equal(400, badFrequencyStatus)
+ Assert.Contains("frequency must be one of daily, weekly or monthly", badFrequencyBody)
+
+ [<Fact>]
+ member _.``run executes a due plan through the shared order pipeline``() =
+ let fundId = createFund 10000.00m
+ let code = seedInstrument ()
+ insertQuoteOnDate code 2.0m today
+ let _ = createPlanViaApi fundId code "100.00" "daily"
+
+ let status, body = runPlansViaApi fundId
+ Assert.Equal(200, status)
+
+ let runs = planRuns body
+ Assert.Single(runs) |> ignore
+ Assert.Equal("succeeded", runs.Head.GetProperty("status").GetString())
+ Assert.Equal(today.ToString("yyyy-MM-dd"), runs.Head.GetProperty("runDate").GetString())
+
+ Assert.Equal(50.00000000m, unitsFor fundId code)
+ Assert.Equal(9900.00m, availableCash fundId)
+
+ let returnsStatus, returnsBody =
+ PersistenceTestHelpers.invoke
+ (app ())
+ "GET"
+ (sprintf "/api/funds/%O/returns" fundId)
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ Assert.Equal(200, returnsStatus)
+ Assert.Contains("\"cumulativeReturn\":\"0.00\"", returnsBody)
+
+ [<Fact>]
+ member _.``run is idempotent across repeated drives``() =
+ let fundId = createFund 10000.00m
+ let code = seedInstrument ()
+ insertQuoteOnDate code 2.0m today
+ let _ = createPlanViaApi fundId code "100.00" "daily"
+
+ let firstStatus, _ = runPlansViaApi fundId
+ Assert.Equal(200, firstStatus)
+
+ let secondStatus, secondBody = runPlansViaApi fundId
+ Assert.Equal(200, secondStatus)
+ Assert.Single(planRuns secondBody) |> ignore
+
+ Assert.Equal(50.00000000m, unitsFor fundId code)
+ Assert.Equal(9900.00m, availableCash fundId)
+
+ [<Fact>]
+ member _.``run defers a plan when nav is missing and retries once it lands``() =
+ let fundId = createFund 10000.00m
+ let code = seedInstrument ()
+ let _ = createPlanViaApi fundId code "100.00" "daily"
+
+ let pendingStatus, pendingBody = runPlansViaApi fundId
+ Assert.Equal(200, pendingStatus)
+
+ let pendingRuns = planRuns pendingBody
+ Assert.Single(pendingRuns) |> ignore
+ Assert.Equal("pending_nav", pendingRuns.Head.GetProperty("status").GetString())
+ // the pending order freezes the scheduled amount without spending it
+ Assert.Equal(9900.00m, availableCash fundId)
+ Assert.Equal(0m, unitsFor fundId code)
+
+ insertQuoteOnDate code 2.0m today
+
+ let retryStatus, retryBody = runPlansViaApi fundId
+ Assert.Equal(200, retryStatus)
+
+ let retryRuns = planRuns retryBody
+ Assert.Single(retryRuns) |> ignore
+ Assert.Equal("succeeded", retryRuns.Head.GetProperty("status").GetString())
+ Assert.Equal(50.00000000m, unitsFor fundId code)
+ Assert.Equal(9900.00m, availableCash fundId)
+
+ [<Fact>]
+ member _.``run records insufficient cash without debiting twice``() =
+ let fundId = createFund 50.00m
+ let code = seedInstrument ()
+ insertQuoteOnDate code 2.0m today
+ let _ = createPlanViaApi fundId code "100.00" "daily"
+
+ let firstStatus, firstBody = runPlansViaApi fundId
+ Assert.Equal(200, firstStatus)
+
+ let firstRuns = planRuns firstBody
+ Assert.Single(firstRuns) |> ignore
+ Assert.Equal("insufficient_cash", firstRuns.Head.GetProperty("status").GetString())
+ Assert.Equal(50.00m, availableCash fundId)
+ Assert.Equal(0m, unitsFor fundId code)
+
+ let secondStatus, secondBody = runPlansViaApi fundId
+ Assert.Equal(200, secondStatus)
+ Assert.Single(planRuns secondBody) |> ignore
+ Assert.Equal(50.00m, availableCash fundId)
+
+ [<Fact>]
+ member _.``run rolls the plan pointer across multiple due dates``() =
+ let fundId = createFund 10000.00m
+ let code = seedInstrument ()
+ insertQuoteOnDate code 2.0m (DateOnly(2026, 9, 15))
+ insertQuoteOnDate code 2.0m (DateOnly(2026, 9, 16))
+ insertQuoteOnDate code 2.0m (DateOnly(2026, 9, 17))
+ let _ = createPlanWithAnchor fundId code 100.00m InvestmentFrequency.Daily (DateOnly(2026, 9, 15))
+
+ let status, body = runPlansOnViaApi fundId (DateOnly(2026, 9, 17))
+ Assert.Equal(200, status)
+
+ let runs = planRuns body
+ Assert.Equal(3, runs.Length)
+ Assert.All(runs, fun run -> Assert.Equal("succeeded", run.GetProperty("status").GetString()))
+ Assert.Equal(150.00000000m, unitsFor fundId code)
+ Assert.Equal(9700.00m, availableCash fundId)
+
+ use document = JsonDocument.Parse(body)
+
+ let nextRunDate =
+ document.RootElement.GetProperty("plans").EnumerateArray()
+ |> Seq.head
+ |> fun plan -> plan.GetProperty("nextRunDate").GetString()
+
+ Assert.Equal("2026-09-18", nextRunDate)
+
+ [<Fact>]
+ member _.``run for an unknown fund is not found``() =
+ let status, body = runPlansViaApi (Guid.NewGuid())
+ Assert.Equal(404, status)
+ Assert.Contains("FUND_NOT_FOUND", body)
diff --git a/tests/FundLab.Domain.Tests/FundLab.Domain.Tests.fsproj b/tests/FundLab.Domain.Tests/FundLab.Domain.Tests.fsproj
index 2ba20b5..a9c6fc0 100644
--- a/tests/FundLab.Domain.Tests/FundLab.Domain.Tests.fsproj
+++ b/tests/FundLab.Domain.Tests/FundLab.Domain.Tests.fsproj
@@ -20,6 +20,7 @@
</ItemGroup>
<ItemGroup>
<Compile Include="DomainTests.fs" />
+ <Compile Include="InvestmentPlanTests.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
</Project>
diff --git a/tests/FundLab.Domain.Tests/InvestmentPlanTests.fs b/tests/FundLab.Domain.Tests/InvestmentPlanTests.fs
new file mode 100644
index 0000000..9a5e2b9
--- /dev/null
+++ b/tests/FundLab.Domain.Tests/InvestmentPlanTests.fs
@@ -0,0 +1,81 @@
+namespace FundLab.Domain.Tests
+
+module InvestmentPlanTests =
+
+ open System
+ open Xunit
+ open FundLab.Domain
+
+ let private anchor = DateOnly(2026, 9, 21)
+
+ [<Fact>]
+ let ``frequency parsing and text roundtrip`` () =
+ Assert.Equal(Some InvestmentFrequency.Daily, InvestmentPlanPolicy.parseFrequency "daily")
+ Assert.Equal(Some InvestmentFrequency.Weekly, InvestmentPlanPolicy.parseFrequency "weekly")
+ Assert.Equal(Some InvestmentFrequency.Monthly, InvestmentPlanPolicy.parseFrequency "monthly")
+ Assert.Equal(None, InvestmentPlanPolicy.parseFrequency "biweekly")
+ Assert.Equal(None, InvestmentPlanPolicy.parseFrequency null)
+ Assert.Equal("daily", InvestmentPlanPolicy.frequencyText InvestmentFrequency.Daily)
+ Assert.Equal("weekly", InvestmentPlanPolicy.frequencyText InvestmentFrequency.Weekly)
+ Assert.Equal("monthly", InvestmentPlanPolicy.frequencyText InvestmentFrequency.Monthly)
+
+ [<Fact>]
+ let ``amount validation requires positive two-decimal cash`` () =
+ Assert.Equal(Ok(), InvestmentPlanPolicy.validateAmount 200.00m)
+ Assert.Equal(Error "investment plan amount must be positive", InvestmentPlanPolicy.validateAmount 0m)
+ Assert.Equal(Error "investment plan amount must be positive", InvestmentPlanPolicy.validateAmount -1.00m)
+ Assert.Equal(Error "investment plan amount exceeds cash precision", InvestmentPlanPolicy.validateAmount 1.005m)
+
+ [<Fact>]
+ let ``daily schedule runs every weekday and skips weekends`` () =
+ Assert.Equal(DateOnly(2026, 9, 21), InvestmentPlanPolicy.nextRunDate InvestmentFrequency.Daily anchor anchor)
+ Assert.Equal(DateOnly(2026, 9, 22), InvestmentPlanPolicy.nextRunDate InvestmentFrequency.Daily anchor (DateOnly(2026, 9, 22)))
+ // Friday -> next scheduled run is Monday
+ Assert.Equal(DateOnly(2026, 9, 28), InvestmentPlanPolicy.nextRunDate InvestmentFrequency.Daily anchor (DateOnly(2026, 9, 26)))
+
+ let due =
+ InvestmentPlanPolicy.dueDates InvestmentFrequency.Daily (DateOnly(2026, 9, 25)) (DateOnly(2026, 9, 25)) (DateOnly(2026, 9, 28))
+
+ Assert.Equal<DateOnly list>([ DateOnly(2026, 9, 25); DateOnly(2026, 9, 28) ], due)
+
+ [<Fact>]
+ let ``weekly schedule keeps the seven day cadence`` () =
+ Assert.Equal(DateOnly(2026, 9, 21), InvestmentPlanPolicy.nextRunDate InvestmentFrequency.Weekly anchor anchor)
+ Assert.Equal(DateOnly(2026, 9, 28), InvestmentPlanPolicy.nextRunDate InvestmentFrequency.Weekly anchor (DateOnly(2026, 9, 22)))
+
+ let due =
+ InvestmentPlanPolicy.dueDates InvestmentFrequency.Weekly anchor anchor (DateOnly(2026, 10, 5))
+
+ Assert.Equal<DateOnly list>(
+ [ DateOnly(2026, 9, 21); DateOnly(2026, 9, 28); DateOnly(2026, 10, 5) ],
+ due
+ )
+
+ [<Fact>]
+ let ``monthly schedule clamps to month end and rolls off weekends`` () =
+ // 2026-02-15 is a Sunday, so the February run rolls to Monday 2026-02-16
+ Assert.Equal(DateOnly(2026, 2, 16), InvestmentPlanPolicy.nextRunDate InvestmentFrequency.Monthly (DateOnly(2026, 1, 15)) (DateOnly(2026, 2, 1)))
+
+ // 2026-02 has 28 days, so a 31st anchor clamps to the 28th then rolls to Monday 2026-03-02
+ Assert.Equal(DateOnly(2026, 3, 2), InvestmentPlanPolicy.nextRunDate InvestmentFrequency.Monthly (DateOnly(2026, 1, 31)) (DateOnly(2026, 2, 1)))
+
+ let due =
+ InvestmentPlanPolicy.dueDates InvestmentFrequency.Monthly anchor anchor (DateOnly(2026, 11, 30))
+
+ // 2026-11-21 is a Saturday, so the November run rolls to Monday 2026-11-23
+ Assert.Equal<DateOnly list>(
+ [ DateOnly(2026, 9, 21); DateOnly(2026, 10, 21); DateOnly(2026, 11, 23) ],
+ due
+ )
+
+ [<Fact>]
+ let ``due dates are truncated at the processing window`` () =
+ Assert.Equal<DateOnly list>([], InvestmentPlanPolicy.dueDates InvestmentFrequency.Daily anchor anchor (DateOnly(2026, 9, 20)))
+
+ let truncated =
+ InvestmentPlanPolicy.dueDates InvestmentFrequency.Daily anchor anchor (DateOnly(2026, 9, 22))
+
+ Assert.Equal<DateOnly list>([ DateOnly(2026, 9, 21); DateOnly(2026, 9, 22) ], truncated)
+
+ // a repeat drive over the same window yields the same dates (idempotent schedule)
+ Assert.Equal<DateOnly list>(truncated, InvestmentPlanPolicy.dueDates InvestmentFrequency.Daily anchor anchor (DateOnly(2026, 9, 22)))