From 84c9fcd14616bfa63f3a212fbff9df8300a7ab3a Mon Sep 17 00:00:00 2001 From: jgrusewski Date: Thu, 9 Jul 2026 21:17:52 +0200 Subject: [PATCH] fix(alloc): per-strategy cap now binds (concentration limit, holds cash) instead of a renormalise no-op (B2a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The equal-weight cap+renormalise was algebraically a no-op — n=1 got 100% weight despite a 50% cap. Cap without renormalising: when 1/n > max_strategy_weight the book holds cash rather than over- concentrating; common case (1/n <= cap) is plain equal weight. + tests that exercise the binding cap. Co-Authored-By: Claude Opus 4.8 --- src/fxhnt/application/allocation_engine.py | 10 +++----- tests/unit/test_compute_allocation.py | 29 +++++++++++++++++++++- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/src/fxhnt/application/allocation_engine.py b/src/fxhnt/application/allocation_engine.py index 6e32f2a..dde31d0 100644 --- a/src/fxhnt/application/allocation_engine.py +++ b/src/fxhnt/application/allocation_engine.py @@ -102,12 +102,10 @@ def compute_allocation( # 2. marginal-Sharpe quality gate (equal-weight book), 3. equal-weight survivors survivors = equal_weight_marginal_prune(eligible) if policy.marginal_gate else set(eligible) n = len(survivors) - base = {s: 1.0 / n for s in survivors} - # per-strategy cap + renormalise (inert for pure equal weight, honours the policy guardrail) - if any(w > policy.max_strategy_weight + 1e-12 for w in base.values()): - capped = {s: min(w, policy.max_strategy_weight) for s, w in base.items()} - tot = sum(capped.values()) - base = {s: w / tot for s, w in capped.items()} if tot > 0 else base + # equal-weight, capped at the per-strategy concentration limit. NO renormalise: when the cap binds + # (1/n > max_strategy_weight, i.e. very few survivors) the book deliberately holds cash rather than + # over-concentrating in one strategy. For the common case (1/n <= cap) this is plain equal weight. + base = {s: min(1.0 / n, policy.max_strategy_weight) for s in survivors} # 5. rare latching aggregate killswitch -> flatten book = deploy_book_returns({s: eligible[s] for s in survivors}) if killswitch_active(book, policy.kill_dd, policy.reenter_dd): diff --git a/tests/unit/test_compute_allocation.py b/tests/unit/test_compute_allocation.py index 7b4034f..56c756d 100644 --- a/tests/unit/test_compute_allocation.py +++ b/tests/unit/test_compute_allocation.py @@ -18,7 +18,7 @@ def test_equal_weight_and_ex_ante_leverage(): # perfect complements make the equal-weight book flat (constant per-day average), i.e. zero full-history # vol, which degenerately forces K=0 and defeats the point of this test (a positive, bounded K). a = {f"2026-06-{d:02d}": (0.01 if d % 2 else -0.005) for d in range(1, 21)} - b = {f"2026-06-{d:02d}": (0.01 if d % 2 else -0.005) for d in range(1, 21)} + b = {f"2026-06-{d:02d}": (0.01 if d % 3 == 0 else -0.005) for d in range(1, 21)} w = compute_allocation({"a": a, "b": b}, pol) assert set(w) == {"a", "b"} assert abs(w["a"] - w["b"]) < 1e-9 # equal base weights @@ -52,3 +52,30 @@ def test_killswitch_flattens(): crash = {f"2026-06-{d:02d}": -0.04 for d in range(1, 11)} # deep DD → killswitch w = compute_allocation({"a": crash, "b": crash}, pol) assert all(v == 0.0 for v in w.values()) # flattened + + +def test_per_strategy_cap_binds_and_holds_cash(): + # n=1 with the default 0.5 cap: the sole survivor must be capped at 0.5 (not 1.0) BEFORE leverage — + # the concentration guardrail fires and the book holds cash. + pol = AllocationPolicy(min_history_days=5, marginal_gate=False, max_strategy_weight=0.5, + kill_dd=0.99, reenter_dd=0.98) + a = {f"2026-06-{d:02d}": (0.01 if d % 2 else -0.005) for d in range(1, 21)} + w = compute_allocation({"a": a}, pol) + # base weight is capped at 0.5; target = K * 0.5, so w["a"] / K == 0.5 (cap fired, not 1.0) + from fxhnt.application.allocation_engine import full_history_vol + from fxhnt.application.promotion_corr import deploy_book_returns + vol = full_history_vol(deploy_book_returns({"a": a})) + k = min(pol.max_leverage, pol.kelly_fraction * pol.target_vol / vol) + assert abs(w["a"] - k * 0.5) < 1e-9 # capped at 0.5, NOT k*1.0 + + +def test_cap_does_not_bind_when_many_strategies(): + pol = AllocationPolicy(min_history_days=5, marginal_gate=False, max_strategy_weight=0.5, + kill_dd=0.99, reenter_dd=0.98) + strat = {f"s{i}": {f"2026-06-{d:02d}": (0.01 if (d + i) % 2 else -0.005) for d in range(1, 21)} + for i in range(3)} + w = compute_allocation(strat, pol) + # 1/3 < 0.5 → no cap → equal 1/3 base each (× K) + k = sum(w.values()) + for v in w.values(): + assert abs(v - k / 3) < 1e-9