diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-21 23:47:50 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-21 23:47:50 +0800 |
| commit | 36ee23a0c406c16e975276238a861299899ec79a (patch) | |
| tree | 7085f24566b7c53ad75c1ddf8130e563513f673a /tests | |
| parent | d4b539a2791a0cd4d098fecfece0039073a9f7e2 (diff) | |
| download | fund-lab-36ee23a0c406c16e975276238a861299899ec79a.tar.gz | |
Add rebalancing execution slice (3d-11)
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj | 1 | ||||
| -rw-r--r-- | tests/FundLab.Api.Tests/RebalanceTests.fs | 278 | ||||
| -rw-r--r-- | tests/FundLab.Domain.Tests/DomainTests.fs | 48 |
3 files changed, 327 insertions, 0 deletions
diff --git a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj index ca5bde8..374c63e 100644 --- a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj +++ b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj @@ -28,6 +28,7 @@ <Compile Include="DividendTests.fs" /> <Compile Include="ReturnsTests.fs" /> <Compile Include="InvestmentPlanTests.fs" /> + <Compile Include="RebalanceTests.fs" /> <Compile Include="Program.fs" /> </ItemGroup> </Project> diff --git a/tests/FundLab.Api.Tests/RebalanceTests.fs b/tests/FundLab.Api.Tests/RebalanceTests.fs new file mode 100644 index 0000000..88ec479 --- /dev/null +++ b/tests/FundLab.Api.Tests/RebalanceTests.fs @@ -0,0 +1,278 @@ +namespace FundLab.Api.Tests + +open System +open System.Text.Json +open Xunit +open FundLab.Api +open FundLab.Domain + +[<Collection("postgres")>] +type RebalanceTests(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, "rebalance-test-hash") + code + + let createFund (initialCash: decimal) = + let command = + { + Name = "再平衡测试 FOF" + InitialCash = initialCash + InitialUnitNav = 1.00000000m + IsSynthetic = true + } + + let key = fixture.Key(sprintf "rebalance-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 "rebalance-hash/%s" revision) + + let today = ConfirmationPolicy.tradeDateFor DateTimeOffset.UtcNow + + let buyHolding fundId (code: string) (amount: decimal) (nav: decimal) = + insertQuoteOnDate code nav today + + let order = + match + repository().CreateSubscriptionOrder( + fixture.Key(sprintf "rebalance-hold-%s" (Guid.NewGuid().ToString("N"))), + fundId, + { FundCode = code; Amount = amount; FeeAmount = 0m }, + today + ) + with + | SubscriptionOrderWriteResult.OrderCreated order + | SubscriptionOrderWriteResult.OrderReplayed order -> order + | other -> failwithf "unexpected holding order result: %A" other + + match + repository().ConfirmSubscriptionOrder( + fixture.Key(sprintf "rebalance-confirm-%s" (Guid.NewGuid().ToString("N"))), + fundId, + order.Id + ) + with + | SubscriptionConfirmResult.OrderConfirmed _ -> () + | confirmResult -> failwithf "unexpected holding confirm: %A" confirmResult + + let createPlanViaApi fundId (targets: (string * string) list) = + let body = + targets + |> List.map (fun (code, percent) -> sprintf "{\"instrumentCode\":\"%s\",\"targetPercent\":\"%s\"}" code percent) + |> String.concat "," + |> sprintf "{\"targets\":[%s]}" + + PersistenceTestHelpers.invoke + (app ()) + "POST" + (sprintf "/api/funds/%O/rebalance/plans" fundId) + [ + "Authorization", "Bearer test-token" + "Idempotency-Key", fixture.Key(sprintf "rebalance-plan-%s" (Guid.NewGuid().ToString("N"))) + ] + body + + let createPlanId fundId targets = + match createPlanViaApi fundId targets with + | 201, body -> + use document = JsonDocument.Parse(body) + Guid.Parse(document.RootElement.GetProperty("id").GetString()) + | status, body -> failwithf "unexpected plan status %d: %s" status body + + let previewViaApi fundId planId = + PersistenceTestHelpers.invoke + (app ()) + "GET" + (sprintf "/api/funds/%O/rebalance/plans/%O/preview" fundId planId) + [ "Authorization", "Bearer test-token" ] + "" + + let executeViaApi fundId planId = + PersistenceTestHelpers.invoke + (app ()) + "POST" + (sprintf "/api/funds/%O/rebalance/plans/%O/execute" fundId planId) + [ "Authorization", "Bearer test-token" ] + "{}" + + let executionsViaApi fundId = + PersistenceTestHelpers.invoke + (app ()) + "GET" + (sprintf "/api/funds/%O/rebalance/executions" fundId) + [ "Authorization", "Bearer test-token" ] + "" + + let rowOn (code: string) (body: string) = + use document = JsonDocument.Parse(body) + + document.RootElement.GetProperty("rows").EnumerateArray() + |> Seq.find (fun row -> row.GetProperty("instrumentCode").GetString() = code) + |> fun row -> row.Clone() + + 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 availableCash fundId = + match repository().GetFund fundId with + | Some fund -> fund.AvailableCash + | None -> failwith "fund was not found" + + [<Fact>] + member _.``preview reports target versus current weights without creating orders``() = + let fundId = createFund 10000.00m + let codeA = seedInstrument () + let codeB = seedInstrument () + buyHolding fundId codeA 100.00m 2.0m + let planId = createPlanId fundId [ codeA, "50.00"; codeB, "50.00" ] + + let status, body = previewViaApi fundId planId + Assert.Equal(200, status) + + let rowA = rowOn codeA body + Assert.Equal("50.00", rowA.GetProperty("targetPercent").GetString()) + Assert.Equal("100.00", rowA.GetProperty("currentValue").GetString()) + Assert.Equal("1.00", rowA.GetProperty("currentPercent").GetString()) + Assert.Equal("buy", rowA.GetProperty("action").GetString()) + Assert.Equal("4900.00", rowA.GetProperty("deltaAmount").GetString()) + + let rowB = rowOn codeB body + Assert.Equal("buy", rowB.GetProperty("action").GetString()) + Assert.Equal("5000.00", rowB.GetProperty("deltaAmount").GetString()) + + // preview is read-only: the holding is untouched + Assert.Equal(50.00000000m, unitsFor fundId codeA) + Assert.Equal(9900.00m, availableCash fundId) + + [<Fact>] + member _.``execute applies buy and sell through the shared pipeline and records executions``() = + let fundId = createFund 10000.00m + let codeA = seedInstrument () + let codeB = seedInstrument () + buyHolding fundId codeA 8000.00m 5.0m + insertQuoteOnDate codeB 2.5m today + let planId = createPlanId fundId [ codeA, "50.00"; codeB, "50.00" ] + + let status, body = executeViaApi fundId planId + Assert.Equal(200, status) + Assert.Contains("\"action\":\"sell\"", body) + Assert.Contains("\"action\":\"buy\"", body) + Assert.Contains("\"status\":\"succeeded\"", body) + + Assert.Equal(1000.00000000m, unitsFor fundId codeA) + Assert.Equal(2000.00000000m, unitsFor fundId codeB) + Assert.Equal(0.00m, availableCash fundId) + + let executionsStatus, executionsBody = executionsViaApi fundId + Assert.Equal(200, executionsStatus) + Assert.Contains("\"instrumentCode\":\"" + codeA + "\"", executionsBody) + Assert.Contains("\"instrumentCode\":\"" + codeB + "\"", executionsBody) + Assert.Contains("\"action\":\"sell\"", executionsBody) + Assert.Contains("\"action\":\"buy\"", executionsBody) + + use executionsDocument = JsonDocument.Parse(executionsBody) + Assert.Equal(2, executionsDocument.RootElement.GetArrayLength()) + + [<Fact>] + member _.``re-executing a balanced plan produces no new operations``() = + let fundId = createFund 10000.00m + let codeA = seedInstrument () + let codeB = seedInstrument () + buyHolding fundId codeA 8000.00m 5.0m + insertQuoteOnDate codeB 2.5m today + let planId = createPlanId fundId [ codeA, "50.00"; codeB, "50.00" ] + + let firstStatus, _ = executeViaApi fundId planId + Assert.Equal(200, firstStatus) + + let secondStatus, secondBody = executeViaApi fundId planId + Assert.Equal(200, secondStatus) + Assert.Contains("\"outcomes\":[]", secondBody) + + Assert.Equal(1000.00000000m, unitsFor fundId codeA) + Assert.Equal(2000.00000000m, unitsFor fundId codeB) + + let _, executionsBody = executionsViaApi fundId + use executionsDocument = JsonDocument.Parse(executionsBody) + Assert.Equal(2, executionsDocument.RootElement.GetArrayLength()) + + [<Fact>] + member _.``preview for a plan owned by another fund is not found``() = + let fundId = createFund 10000.00m + let otherFundId = createFund 10000.00m + let codeA = seedInstrument () + let codeB = seedInstrument () + let planId = createPlanId fundId [ codeA, "50.00"; codeB, "50.00" ] + + let status, body = previewViaApi otherFundId planId + Assert.Equal(404, status) + Assert.Contains("PLAN_NOT_FOUND", body) + + [<Fact>] + member _.``preview and executions for unknown identifiers are not found``() = + let fundId = createFund 10000.00m + + let previewStatus, previewBody = previewViaApi fundId (Guid.NewGuid()) + Assert.Equal(404, previewStatus) + Assert.Contains("PLAN_NOT_FOUND", previewBody) + + let executionsStatus, executionsBody = executionsViaApi (Guid.NewGuid()) + Assert.Equal(404, executionsStatus) + Assert.Contains("FUND_NOT_FOUND", executionsBody) + + [<Fact>] + member _.``executions are empty for a fund without a run``() = + let fundId = createFund 10000.00m + let status, body = executionsViaApi fundId + Assert.Equal(200, status) + Assert.Equal("[]", body) diff --git a/tests/FundLab.Domain.Tests/DomainTests.fs b/tests/FundLab.Domain.Tests/DomainTests.fs index ff5245e..63bcab2 100644 --- a/tests/FundLab.Domain.Tests/DomainTests.fs +++ b/tests/FundLab.Domain.Tests/DomainTests.fs @@ -748,6 +748,54 @@ module RebalancePolicyTests = Assert.All(diffs, fun diff -> Assert.Equal(RebalancePolicy.Hold, diff.Action)) [<Fact>] + let ``weight rows expose current percent and proposed buy`` () = + let targets = [ target "000001" 60.00m; target "000002" 40.00m ] + let positions = [ position "000001" 5000.00m 1000m 1000m (Some 5m) ] + + let rows: RebalancePolicy.RebalanceWeightRow list = + RebalancePolicy.weightRows targets positions 7000.00m + |> unwrap + + let row = rows |> List.find (fun item -> item.InstrumentCode = "000001") + + Assert.Equal(60.00m, row.TargetPercent) + Assert.Equal(5000.00m, row.CurrentValue) + Assert.Equal(41.66m, row.CurrentPercent) + Assert.Equal(RebalancePolicy.Buy, row.Action) + Assert.Equal(2200.00m, row.DeltaAmount) + Assert.Equal(None, row.DeltaUnits) + + [<Fact>] + let ``weight rows propose a sell clamped to available units`` () = + let targets = [ target "000001" 50.00m; target "000002" 50.00m ] + let positions = [ position "000001" 1000.00m 400m 400m (Some 2.5m) ] + + let rows: RebalancePolicy.RebalanceWeightRow list = + RebalancePolicy.weightRows targets positions 0m + |> unwrap + + let row = rows |> List.find (fun item -> item.InstrumentCode = "000001") + + Assert.Equal(100.00m, row.CurrentPercent) + Assert.Equal(RebalancePolicy.Sell, row.Action) + Assert.Equal(-500.00m, row.DeltaAmount) + Assert.Equal(200.00000000m, row.DeltaUnits.Value) + + [<Fact>] + let ``weight rows hold when the drift is within the one cent tolerance`` () = + let targets = [ target "000001" 100.00m ] + let positions = [ position "000001" 199.99m 100m 100m (Some 1.9999m) ] + + let rows: RebalancePolicy.RebalanceWeightRow list = + RebalancePolicy.weightRows targets positions 0.01m + |> unwrap + + let row = rows |> List.find (fun item -> item.InstrumentCode = "000001") + + Assert.Equal(RebalancePolicy.Hold, row.Action) + Assert.Equal(0.01m, row.DeltaAmount) + + [<Fact>] let ``idempotency keys are deterministic per plan run and code`` () = let planId = Guid "7c9e6679-7425-40de-944b-e07fc1f90ae7" let runDate = DateOnly(2026, 9, 21) |
