summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/FundLab.Domain.Tests/DomainTests.fs114
1 files changed, 114 insertions, 0 deletions
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 }
+
+ [<Fact>]
+ 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 ]
+ )
+
+ [<Fact>]
+ 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)
+
+ [<Fact>]
+ 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)
+
+ [<Fact>]
+ 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)
+
+ [<Fact>]
+ 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))
+
+ [<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)
+
+ 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")