120 lines
6.7 KiB
Python
120 lines
6.7 KiB
Python
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
|