From d4b539a2791a0cd4d098fecfece0039073a9f7e2 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Mon, 21 Sep 2026 23:21:54 +0800 Subject: Add scheduled investment plans (3d-10) --- tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj | 1 + tests/FundLab.Api.Tests/InvestmentPlanTests.fs | 322 +++++++++++++++++++++++ 2 files changed, 323 insertions(+) create mode 100644 tests/FundLab.Api.Tests/InvestmentPlanTests.fs (limited to 'tests/FundLab.Api.Tests') 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 @@ + 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 + +[] +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 + + [] + 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) + + [] + 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) + + [] + 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) + + [] + 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) + + [] + 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) + + [] + 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) + + [] + 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) + + [] + 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) -- cgit v1.2.3