Files
fxhnt/tests/unit/test_compute_allocation.py
jgrusewski 84c9fcd146 fix(alloc): per-strategy cap now binds (concentration limit, holds cash) instead of a renormalise no-op (B2a)
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 <noreply@anthropic.com>
2026-07-09 21:17:52 +02:00

82 lines
4.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from fxhnt.application.allocation_engine import compute_allocation
from fxhnt.application.allocation_policy import AllocationPolicy
def _series(n, ret): # n days of a constant-ish return
return {f"2026-{(6 + d // 30):02d}-{(d % 30) + 1:02d}": ret for d in range(n)}
def test_excludes_short_history_and_empty_returns_empty():
pol = AllocationPolicy(min_history_days=60, marginal_gate=False)
assert compute_allocation({"short": _series(10, 0.001)}, pol) == {} # < 60 obs → excluded → empty
def test_equal_weight_and_ex_ante_leverage():
pol = AllocationPolicy(min_history_days=5, marginal_gate=False, max_leverage=1.5,
kelly_fraction=0.5, target_vol=0.12, kill_dd=0.99, reenter_dd=0.98)
# NOTE: a and b use the SAME alternating pattern (not exact complements) — two strategies that are
# 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 % 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
# K applied: weights sum to K (the leverage), each = K/2
k = sum(w.values())
assert 0 < k <= 1.5
def test_recent_vol_spike_does_not_change_K_antireactive():
# THE load-bearing invariant: ex-ante vol uses the FULL history, so appending a recent spike must not
# change the leverage on the earlier window.
pol = AllocationPolicy(min_history_days=5, marginal_gate=False, kill_dd=0.99, reenter_dd=0.98)
base = {f"2026-06-{d:02d}": (0.01 if d % 2 else -0.01) for d in range(1, 61)}
w_before = compute_allocation({"a": dict(base), "b": dict(base)}, pol)
spiked = dict(base)
spiked["2026-06-30"] = 0.5 # a single huge recent day
w_after = compute_allocation({"a": spiked, "b": dict(base)}, pol)
# the full-history vol of "a" changes (it must — vol IS a full-history stat), so K adapts SLOWLY over the
# whole record, NOT reactively to the last window. Assert K moved only via the full-history vol, i.e. the
# book vol recomputed over ALL 60 days (not a trailing subset):
from fxhnt.application.allocation_engine import full_history_vol
from fxhnt.application.promotion_corr import deploy_book_returns
vol_all = full_history_vol(deploy_book_returns({"a": spiked, "b": dict(base)}))
k_before, k_after = sum(w_before.values()), sum(w_after.values())
assert abs(k_after - k_before) > 1e-9 # the spike DOES move full-history vol...
assert abs(k_after - min(pol.max_leverage, pol.kelly_fraction * pol.target_vol / vol_all)) < 1e-9
def test_killswitch_flattens():
pol = AllocationPolicy(min_history_days=5, marginal_gate=False, kill_dd=0.20, reenter_dd=0.10)
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