diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-21 14:31:13 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-21 14:31:13 +0800 |
| commit | 98c41c37896a41c5f2f15ed187e55ab617735342 (patch) | |
| tree | a57cffc9e9147124af12b79e58ccf083bb12ab2f /tests/FundLab.Web.Tests | |
| parent | 336d5e5312eb6fa69ccc1fd875e930a067ff8143 (diff) | |
| download | fund-lab-98c41c37896a41c5f2f15ed187e55ab617735342.tar.gz | |
Add rebalance first slice (3d-7)
First vertical slice for portfolio rebalancing. Domain gains
RebalancePolicy: target-allocation validation (non-empty, per-code
shares positive two-decimal, sum exactly 100); diff computation over
the union of target and held codes (held without target = full exit)
producing BUY amount diffs and SELL unit diffs priced at the current
valuation NAV, clamped to available units, with no orders when a
weight already balances; and the deterministic idempotency key set
(rebalance:{plan}:{date}:{code} plus confirm/redeem counterparts).
Rebalance plans persist as rebalance_plans + rebalance_targets with
rebalance_idempotencies under the shared replay/conflict contract.
ExecuteRebalancePlan snapshots cash plus holdings valued at each
position's latest valuation NAV, then walks the diffs SELL-first
through the same Create/Confirm pipeline used by manual orders,
redemptions and SIP (nothing bypasses confirmation; a held code
without a valuation is skipped with an explicit marker; insufficient
cash/units fail the period visibly instead of silently trimming).
API exposes POST/GET /funds/{id}/rebalance/plans and POST
.../plans/{id}/execute. The frontend adds an 08/REBALANCE panel with
target editing (two codes, sum must equal 100), a plan list with a
manual execute button and post-run outcome rows; executing refreshes
orders/positions so the new orders are immediately visible. Domain
tests cover split validation, diff orders, exit/clamp logic, key
determinism; API tests cover create/execute end-to-end; Web tests
cover decoding and stale-execution protection.
Diffstat (limited to 'tests/FundLab.Web.Tests')
| -rw-r--r-- | tests/FundLab.Web.Tests/BoundaryTests.fs | 86 |
1 files changed, 86 insertions, 0 deletions
diff --git a/tests/FundLab.Web.Tests/BoundaryTests.fs b/tests/FundLab.Web.Tests/BoundaryTests.fs index 5ab327d..2435f32 100644 --- a/tests/FundLab.Web.Tests/BoundaryTests.fs +++ b/tests/FundLab.Web.Tests/BoundaryTests.fs @@ -803,3 +803,89 @@ module SipBoundaryTests = let staleList, _ = update (SipPlansReadCompleted(1, [| sampleSipRaw () |])) { model with sipReadSeq = 2 } Assert.Equal(1, staleList.sipPlans.Length) + +module RebalanceBoundaryTests = + + open Xunit + + let sampleFundInfo () : FundSummary = + { + id = "3fa85f64-5717-4562-b3fc-2c963f66afa6" + name = "再平衡测试" + currency = "CNY" + initialCash = "20000.00" + initialUnitNav = "1.00000000" + isSynthetic = false + availableCash = "19500.00" + reservedCash = "0.00" + status = "active" + } + + let sampleRebalanceRaw () : RawRebalancePlan = + { + id = "bb2c3d4e-0000-0000-0000-000000000003" + fundId = "3fa85f64-5717-4562-b3fc-2c963f66afa6" + targets = + [| + { instrumentCode = "000001"; targetPercent = "60.00" } + { instrumentCode = "000002"; targetPercent = "40.00" } + |] + status = "active" + createdAt = "2026-09-21T10:00:00+00:00" + } + + let sampleExecutionRaw () : RawRebalanceExecution = + { + planId = "cc3d4e5f-0000-0000-0000-000000000004" + runDate = "2026-09-21" + outcomes = + [| + { + instrumentCode = "000002" + action = "buy" + amount = "4800.00" + status = "succeeded" + orderId = box "9d8c7b6a-0000-0000-0000-000000000009" + pendingReason = null + } + |] + } + + [<Fact>] + let ``rebalance decode keeps decimal strings per target`` () = + let decoded = Api.decodeRebalancePlan (sampleRebalanceRaw ()) + + Assert.Equal(2, decoded.targets.Length) + Assert.Equal("000001", decoded.targets[0].instrumentCode) + Assert.Equal("60.00", decoded.targets[0].targetPercent) + Assert.Equal("40.00", decoded.targets[1].targetPercent) + Assert.Equal("active", decoded.status) + + [<Fact>] + let ``rebalance execution decodes outcome fields and refreshes orders`` () = + let started, _ = + update (RebalanceExecuteRequested "cc3d4e5f-0000-0000-0000-000000000004") + { init () with token = "qa-token"; createdFund = Some(sampleFundInfo ()); rebalExecuteSeq = 0 } + + Assert.Equal(1, started.rebalExecuteSeq) + Assert.True(started.rebalExecuteInFlight) + + let executed, _ = update (RebalanceExecuted(1, sampleExecutionRaw ())) started + + Assert.False(executed.rebalExecuteInFlight) + let result = executed.rebalResult.Value + Assert.Equal(1, result.Length) + Assert.Equal("buy", result[0].action) + Assert.Equal("4800.00", result[0].amount) + Assert.Equal("9d8c7b6a-0000-0000-0000-000000000009", result[0].orderId.Value) + Assert.Equal(None, result[0].pendingReason) + + [<Fact>] + let ``stale rebalance executions are ignored`` () = + let model = + { init () with token = "qa-token"; createdFund = Some(sampleFundInfo ()); rebalExecuteSeq = 3; rebalExecuteInFlight = true } + + let stale, _ = update (RebalanceExecuted(2, sampleExecutionRaw ())) model + + Assert.True(stale.rebalExecuteInFlight) + Assert.Equal(None, stale.rebalResult) |
