Files
fxhnt/tests/unit/test_compute_allocation.py

120 lines
6.7 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
import numpy as np
def _book(strat_returns): # single strategy so the book == its returns; marginal_gate off
pol = AllocationPolicy(min_history_days=5, marginal_gate=False, max_leverage=10.0,
kelly_fraction=0.5, target_vol=0.12, kill_dd=0.99, reenter_dd=0.98, cvar_alpha=0.10)
return compute_allocation({"a": {f"d{i}": v for i, v in enumerate(strat_returns)}}, pol)
def test_cvar_noop_on_gaussian_book():
from fxhnt.application.allocation_engine import full_history_vol
rng = np.random.default_rng(0)
g = list(rng.normal(0.0, 0.01, 400))
book = {f"d{i}": v for i, v in enumerate(g)}
w = _book(g)["a"]
# On a Gaussian book the CVaR floor is a NO-OP (k_cvar >= k_vol), so the weight must equal the std-only
# vol-target weight EXACTLY. Base weight for the single strategy = min(1/1, max_strategy_weight=0.5) = 0.5.
# This would FAIL if CVaR bit (a fat tail would de-lever below k_vol) — not a `w > 0` tautology.
k_vol = min(10.0, 0.5 * 0.12 / full_history_vol(book))
assert abs(w - k_vol * 0.5) < 1e-9
def test_cvar_bites_fat_left_tail():
# same std as a Gaussian control, but a fat LEFT tail (rare big losses) -> CVaR must de-lever it.
rng = np.random.default_rng(1)
gauss = list(rng.normal(0.0, 0.02, 400))
fat = list(rng.normal(0.0008, 0.006, 380)) + [-0.15, -0.12, -0.18, -0.10] * 5 # steady small gains + rare crashes
# match std EXACTLY so k_vol is identical for both books and the ONLY thing that can separate their
# leverage is the CVaR tail cap. Rescaling is std-invariant on the cv/std ratio, so the fat book keeps
# its fat left tail (cv/std ~= 2.39 vs the Gaussian's ~= 1.85). Without this the crashes would inflate the
# fat book's std and k_vol alone would de-lever it — the test would pass even under std-only sizing and
# prove nothing about CVaR.
fat = list(np.asarray(fat) * (np.std(gauss) / np.std(fat)))
w_g = _book(gauss)["a"]
w_f = _book(fat)["a"]
assert w_f < w_g # matched std -> identical k_vol; strictly-less leverage comes ONLY from the CVaR floor