From a3dddc28f33254317d07323037ba5d1db35df3d6 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Mon, 21 Sep 2026 13:28:02 +0800 Subject: Add rebalance execution through shared order pipeline (3d-7) --- tests/FundLab.Domain.Tests/DomainTests.fs | 114 ++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) (limited to 'tests/FundLab.Domain.Tests/DomainTests.fs') diff --git a/tests/FundLab.Domain.Tests/DomainTests.fs b/tests/FundLab.Domain.Tests/DomainTests.fs index 42dd2a5..b2a2f0b 100644 --- a/tests/FundLab.Domain.Tests/DomainTests.fs +++ b/tests/FundLab.Domain.Tests/DomainTests.fs @@ -643,3 +643,117 @@ module SipPolicyTests = Assert.All(dates, fun (date: DateOnly) -> Assert.NotEqual(DayOfWeek.Saturday, date.DayOfWeek); Assert.NotEqual(DayOfWeek.Sunday, date.DayOfWeek)) Assert.Contains(d 2026 3 2, dates) Assert.Equal(4, dates.Length) + +module RebalancePolicyTests = + + open System + open Xunit + open FundLab.Domain + + let private unwrap result = + match result with + | Ok value -> value + | Error error -> failwithf "%A" error + + let target code percent : RebalancePolicy.TargetAllocation = + { InstrumentCode = code; TargetPercent = percent } + + let position code marketValue units availableUnits nav : RebalancePolicy.RebalancePositionSnapshot = + { InstrumentCode = code; MarketValue = marketValue; Units = units; AvailableUnits = availableUnits; ValuationNav = nav } + + [] + let ``target allocation validation enforces a full split`` () = + Assert.Equal(Error "at least one target allocation is required", RebalancePolicy.validateTargets []) + Assert.Equal( + Error "target percents must sum to 100 but sum to 90", + RebalancePolicy.validateTargets [ target "000001" 90.00m ] + ) + Assert.Equal( + Error "target percents must sum to 100 but sum to 110", + RebalancePolicy.validateTargets [ target "000001" 60.00m; target "000002" 50.00m ] + ) + Assert.Equal( + Error "duplicate instrument code 000001 in target allocation", + RebalancePolicy.validateTargets [ target "000001" 50.00m; target "000001" 50.00m ] + ) + Assert.Equal( + Error "target percent for 000001 must be a positive two-decimal share of 100", + RebalancePolicy.validateTargets [ target "000001" 0m ] + ) + Assert.Equal( + Ok(), + RebalancePolicy.validateTargets [ target "000001" 60.00m; target "000002" 40.00m ] + ) + + [] + let ``compute orders produces buy for shortfall and sell for excess`` () = + let targets = [ target "000001" 60.00m; target "000002" 40.00m ] + let positions = [ position "000001" 5000.00m 1000m 1000m (Some 5m) ] + + let diffs: RebalancePolicy.RebalanceDiff list = + RebalancePolicy.computeOrders targets positions 7000.00m + |> unwrap + + let buy = diffs |> List.find (fun diff -> diff.InstrumentCode = "000001") + + // equity 12000 -> target 7200 vs 5000 -> buy 2200 + Assert.Equal(RebalancePolicy.Buy, buy.Action) + Assert.Equal(2200.00m, buy.Amount) + + let sell = diffs |> List.find (fun diff -> diff.InstrumentCode = "000002") + + // target 4800 vs 0 -> buy 4800? no: 000002 has no position -> buy + Assert.Equal(RebalancePolicy.Buy, sell.Action) + Assert.Equal(4800.00m, sell.Amount) + + [] + let ``held codes without a target are fully exited at the valuation nav`` () = + let targets = [ target "000001" 100.00m ] + let positions = [ position "000002" 1000.00m 400m 400m (Some 2.5m) ] + + let diffs: RebalancePolicy.RebalanceDiff list = + RebalancePolicy.computeOrders targets positions 0m + |> unwrap + + let sell = diffs |> List.find (fun diff -> diff.InstrumentCode = "000002") + + Assert.Equal(RebalancePolicy.Sell, sell.Action) + Assert.Equal(400.00000000m, sell.Units.Value) + + [] + let ``sell units clamp to available holdings`` () = + let targets = [ target "000001" 100.00m ] + let positions = [ position "000002" 1000.00m 400m 150m (Some 2.5m) ] + + let diffs: RebalancePolicy.RebalanceDiff list = + RebalancePolicy.computeOrders targets positions 0m + |> unwrap + + let sell = diffs |> List.find (fun diff -> diff.InstrumentCode = "000002") + + Assert.Equal(RebalancePolicy.Sell, sell.Action) + Assert.Equal(150.00000000m, sell.Units.Value) + + [] + let ``balanced allocations produce no orders`` () = + let targets = [ target "000001" 60.00m; target "000002" 40.00m ] + + let positions = + [ position "000001" 7200.00m 1000m 1000m (Some 5m); position "000002" 4800.00m 2000m 2000m (Some 2.4m) ] + + let diffs: RebalancePolicy.RebalanceDiff list = + RebalancePolicy.computeOrders targets positions 0m + |> unwrap + + Assert.All(diffs, fun diff -> Assert.Equal(RebalancePolicy.Hold, diff.Action)) + + [] + let ``idempotency keys are deterministic per plan run and code`` () = + let planId = Guid "7c9e6679-7425-40de-944b-e07fc1f90ae7" + let runDate = DateOnly(2026, 9, 21) + + Assert.Equal("rebalance:7c9e6679-7425-40de-944b-e07fc1f90ae7:2026-09-21:000001", RebalancePolicy.orderKey planId runDate "000001") + Assert.Equal("rebalance-confirm:7c9e6679-7425-40de-944b-e07fc1f90ae7:2026-09-21:000001", RebalancePolicy.confirmKey planId runDate "000001") + Assert.Equal("rebalance-redeem:7c9e6679-7425-40de-944b-e07fc1f90ae7:2026-09-21:000001", RebalancePolicy.redemptionKey planId runDate "000001") + + Assert.Equal(RebalancePolicy.orderKey planId runDate "000001", RebalancePolicy.orderKey planId runDate "000001") -- cgit v1.2.3