75 lines
2.9 KiB
Python
75 lines
2.9 KiB
Python
import numpy as np
|
||
|
||
from fxhnt.application.allocation_engine import (
|
||
cvar_daily,
|
||
equal_weight_marginal_prune,
|
||
full_history_vol,
|
||
killswitch_active,
|
||
)
|
||
|
||
|
||
def test_full_history_vol_is_annualised_std():
|
||
r = {
|
||
f"2026-06-{d:02d}": v
|
||
for d, v in zip(range(1, 6), [0.01, -0.01, 0.02, -0.02, 0.0], strict=True)
|
||
}
|
||
assert (
|
||
abs(
|
||
full_history_vol(r)
|
||
- float(np.std([0.01, -0.01, 0.02, -0.02, 0.0]) * np.sqrt(365.0))
|
||
)
|
||
< 1e-12
|
||
)
|
||
assert full_history_vol({"2026-06-01": 0.01}) == 0.0 # < 2 obs → 0
|
||
|
||
|
||
def test_marginal_prune_benches_non_additive_strategy():
|
||
dates = [f"2026-06-{d:02d}" for d in range(1, 41)]
|
||
good = {d: (0.01 if i % 2 else -0.008) for i, d in enumerate(dates)} # positive-Sharpe
|
||
noise = {d: (0.03 if i % 3 == 0 else -0.02) for i, d in enumerate(dates)} # drags the book
|
||
kept = equal_weight_marginal_prune({"good": good, "noise": noise})
|
||
assert "good" in kept and "noise" not in kept
|
||
|
||
|
||
def test_killswitch_latches_on_deep_dd_and_reenters():
|
||
# a −30% run then recovery; kill at 0.25, reenter at 0.10
|
||
down = {f"2026-06-{d:02d}": -0.04 for d in range(1, 11)} # ~ -33% cumulative → breach 0.25
|
||
assert killswitch_active(down, kill_dd=0.25, reenter_dd=0.10) is True
|
||
calm = {f"2026-06-{d:02d}": 0.001 for d in range(1, 11)}
|
||
assert killswitch_active(calm, kill_dd=0.25, reenter_dd=0.10) is False
|
||
|
||
|
||
def test_marginal_prune_tie_break_is_deterministic():
|
||
# two identical NON-additive noise strategies + one good one: whichever gets benched must be STABLE
|
||
# (sorted order), not hash-order-dependent. Run it and assert the survivor set is the same each call.
|
||
dates = [f"2026-06-{d:02d}" for d in range(1, 41)]
|
||
good = {d: (0.01 if i % 2 else -0.008) for i, d in enumerate(dates)}
|
||
noise = {d: (0.03 if i % 3 == 0 else -0.02) for i, d in enumerate(dates)}
|
||
inp = {"good": good, "noiseA": dict(noise), "noiseB": dict(noise)}
|
||
r1 = equal_weight_marginal_prune(inp)
|
||
r2 = equal_weight_marginal_prune(inp)
|
||
assert r1 == r2 # deterministic
|
||
assert "good" in r1 # the good one survives
|
||
|
||
|
||
def test_cvar_daily_worst_tail_positive_loss():
|
||
# 10 days; alpha=0.10 -> worst 1 day = -0.05 -> CVaR = +0.05 (positive loss)
|
||
r = {f"d{i}": v for i, v in enumerate([0.01, 0.02, -0.05, 0.0, 0.01, 0.02, -0.01, 0.0, 0.01, 0.02])}
|
||
assert abs(cvar_daily(r, 0.10) - 0.05) < 1e-9
|
||
|
||
|
||
def test_cvar_daily_all_gains_is_zero():
|
||
r = {f"d{i}": 0.01 for i in range(10)} # worst 10% still +0.01 -> loss magnitude floored at 0
|
||
assert cvar_daily(r, 0.10) == 0.0
|
||
|
||
|
||
def test_cvar_daily_too_short_is_zero():
|
||
assert cvar_daily({"d0": -0.5}, 0.10) == 0.0
|
||
|
||
|
||
def test_cvar_daily_full_history_not_trailing():
|
||
base = {f"d{i}": 0.001 for i in range(20)}
|
||
worse = dict(base)
|
||
worse["old"] = -0.20 # an OLD bad day must change full-history CVaR
|
||
assert cvar_daily(worse, 0.10) > cvar_daily(base, 0.10)
|