Files
fxhnt/tests/integration/test_spread_overlay.py

346 lines
17 KiB
Python
Raw Permalink 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.
"""STEP 3 (final) of the "spread-as-a-strat" research — the SPREAD-AWARE EXECUTION OVERLAY.
Steps 1-2 falsified both DIRECTIONAL spread ideas (static illiquidity premium = cost trap on entry/holding;
deterioration shorts = cost trap on the widened exit). Conclusion: the spread is a COST signal, not alpha.
Step 3 tests the NON-DIRECTIONAL use — use the measured spread to AVOID cost on the edges we already run — as
a SHADOW eval that never changes the live book's returns.
Two overlay mechanisms vs the baseline, NET of the MEASURED per-coin cost (Bybit 5.5bp taker + half the
CorwinSchultz spread):
(A) BASELINE — the current naive eq-wt book (k=0, λ=0).
(B) SPREAD-AWARE SIZING — scale weight by 1/(1+k·spread_bps) then renormalise (gross preserved).
(C) NO-TRADE BANDS — only rebalance coin i when |Δw_i| exceeds a cost-proportional band λ·cost_i/1e4.
(B+C) combined.
Tests (TDD):
* sizing downweights high-spread coins AND renormalises (Σ|w| == the gross target); no-lookahead; sign kept;
* sizing is a NO-OP when k=0 or all spreads are 0;
* the no-trade band SKIPS sub-threshold rebalances → lower turnover than baseline; a BIG signal change still
trades;
* on a fixture with COSTLY high-spread names, the overlay's NET > baseline NET (a real lift);
* on an ALL-CHEAP fixture, the lift ≈ 0 (the overlay is a no-op when nothing is costly — proves it is not a
free lunch);
* baseline (k=0, λ=0) reconciles to the naive per-coin book (no engine drift);
* the eval/sweep run on a seeded store with the OOS split + verdict; the breadth table covers the universes;
* READ-ONLY (WriteTripwire) + determinism + CLI smoke.
NO network — the store is in-memory SQLite; external inputs are injected.
"""
from __future__ import annotations
import math
from fxhnt.adapters.warehouse.timescale_feature_store import TimescaleFeatureStore
from fxhnt.application.bybit_liquidity_sweep import per_coin_book_returns
from fxhnt.application.spread_overlay import (
_overlay_series,
evaluate_overlay,
per_coin_weight_panel,
sized_weights,
spread_overlay_eval,
spread_overlay_test,
)
_DAY = 86_400
# --- fixtures (mirror the liquidity-sweep fixtures) ----------------------------------------------
def _ohlc_from_close(close: float, spread_frac: float, vol_frac: float, phase: float) -> tuple[float, float]:
vol = vol_frac * (0.5 + 0.5 * math.cos(7.0 * phase))
half = 0.5 * spread_frac + vol
return close * (1.0 + half), close * (1.0 - half)
def _seed_carry(store: TimescaleFeatureStore, funding: dict[str, float], spread_by_sym: dict[str, float], *,
days: int = 400) -> list[str]:
"""A carry (xsfunding) book: half positive-funding, half negative-funding coins, each with a KNOWN
injected proportional spread so its MEASURED cost is the controlled driver."""
for idx, (sym, fund) in enumerate(funding.items()):
sf = spread_by_sym[sym]
rows = []
for d in range(days):
f = fund + 0.0003 * math.cos(0.5 * d + idx)
px = 100.0
high, low = _ohlc_from_close(px, sf, vol_frac=0.0005, phase=0.5 * d + idx)
rows.append((d * _DAY, {"funding": f, "close": px, "spot_close": px, "high": high, "low": low}))
store.write_features(sym, rows)
return list(funding)
def _seed_turnover(store: TimescaleFeatureStore, dv: dict[str, float], *, days: int = 400) -> None:
for sym, v in dv.items():
store.write_features(sym, [(d * _DAY, {"turnover": v}) for d in range(days)])
class _WriteTripwireStore:
_FORBIDDEN = frozenset({
"write_features", "write_features_bulk", "upsert_feature_rows", "upsert_membership",
"replace_positions", "upsert_nav", "upsert_sleeve_ret", "replace_shadow_positions",
"replace_trades", "_create_schema", "_bulk_upsert",
})
def __init__(self, inner: TimescaleFeatureStore) -> None:
object.__setattr__(self, "_inner", inner)
def __getattr__(self, name: str):
if name in _WriteTripwireStore._FORBIDDEN:
raise AssertionError(f"READ-ONLY violation: spread-overlay called write method {name!r}")
return getattr(self._inner, name)
# --- (B) spread-aware sizing (pure) --------------------------------------------------------------
def test_sizing_downweights_high_spread_and_preserves_gross() -> None:
"""Sizing scales by 1/(1+k·spread): the high-spread coin is downweighted relative to the low-spread coin,
and the renormalisation keeps the GROSS exposure Σ|w| equal to the original target (budget redistributed,
not reduced). Signs are preserved."""
weights = {"CHEAP": 0.5, "EXP": 0.5}
spread = {"CHEAP": 0.0, "EXP": 1000.0}
sized = sized_weights(weights, spread, k=0.01)
assert sized["CHEAP"] > sized["EXP"] # expensive coin downweighted
assert sized["CHEAP"] > weights["CHEAP"] # budget redistributed onto the cheap coin
assert abs(sum(abs(w) for w in sized.values()) - 1.0) < 1e-12 # gross exposure preserved
assert all((sized[c] > 0) == (weights[c] > 0) for c in weights) # signs kept
def test_sizing_preserves_gross_with_shorts() -> None:
"""Gross exposure (Σ|w|) is preserved even with a mix of long and short weights."""
weights = {"A": 0.6, "B": -0.4, "C": 0.2}
spread = {"A": 5.0, "B": 300.0, "C": 50.0}
sized = sized_weights(weights, spread, k=0.05)
assert abs(sum(abs(w) for w in sized.values()) - sum(abs(w) for w in weights.values())) < 1e-12
assert sized["B"] < 0 # short stays short
def test_sizing_is_noop_when_k_zero_or_all_cheap() -> None:
weights = {"A": 0.5, "B": -0.5}
assert sized_weights(weights, {"A": 100.0, "B": 200.0}, k=0.0) == weights
# all spreads zero → factor 1 everywhere → unchanged (within fp).
sized = sized_weights(weights, {"A": 0.0, "B": 0.0}, k=0.5)
assert all(abs(sized[c] - weights[c]) < 1e-12 for c in weights)
# --- (C) the no-trade band -----------------------------------------------------------------------
def _wiggle_panel(n_days: int = 60) -> dict[int, dict[str, tuple[float, float]]]:
"""A panel whose target weight WIGGLES by a tiny amount each day (sub-band churn) — the band should skip
most of these rebalances. Per-unit return ~0 so the test isolates turnover."""
daily: dict[int, dict[str, tuple[float, float]]] = {}
for d in range(n_days):
w = 0.50 + 0.002 * math.sin(0.7 * d) # tiny daily churn around 0.5
daily[d] = {"X": (w, 0.0)}
return daily
def test_no_trade_band_reduces_turnover() -> None:
daily = _wiggle_panel()
cost = {"X": 50.0} # 50bp cost → band at λ=4 is 0.02 (>> the 0.002 wiggle) so churn is skipped
spread = {"X": 100.0}
_g0, _n0, turn_base, _c0 = _overlay_series(daily, cost, spread, k=0.0, lam=0.0)
_g1, _n1, turn_band, _c1 = _overlay_series(daily, cost, spread, k=0.0, lam=4.0)
assert turn_band < turn_base, (turn_base, turn_band)
def test_no_trade_band_still_trades_a_big_signal_change() -> None:
"""A wiggle panel with one BIG jump: the band skips the small churn but the large move clears the band and
DOES trade (the held weight moves to the new target)."""
daily = _wiggle_panel(n_days=30)
daily[15] = {"X": (0.95, 0.0)} # a big jump well above any cost-proportional band
cost = {"X": 50.0}
spread = {"X": 100.0}
_gross, _net, turn, _cd = _overlay_series(daily, cost, spread, k=0.0, lam=4.0)
# turnover is positive (it traded the big move) but far below the always-trade baseline.
_g, _n, turn_base, _c = _overlay_series(daily, cost, spread, k=0.0, lam=0.0)
assert 0.0 < turn < turn_base
# the held weight after the jump must have moved to ~0.95 (it traded the big change): the very next day's
# small wiggle back toward 0.5 is within the band, so it should still be holding ~0.95 → big |Δ| trade
# already happened. Re-derive via a 2-day mini panel to assert the trade fired on the jump day.
mini = {0: {"X": (0.5, 0.0)}, 1: {"X": (0.95, 0.0)}}
_gg, _nn, mini_turn, _cc = _overlay_series(mini, cost, spread, k=0.0, lam=4.0)
assert mini_turn >= 0.45 - 1e-9 # 0.5 open + ~0.45 jump traded
# --- baseline reconciliation ---------------------------------------------------------------------
def test_baseline_reconciles_to_naive_per_coin_book() -> None:
"""k=0, λ=0 must reproduce the naive eq-wt per-coin book EXACTLY (no engine drift) — same gross and net."""
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
funding = {"AAAUSDT": 0.001, "BBBUSDT": 0.001, "XXXUSDT": -0.001, "YYYUSDT": -0.001}
spread = {s: 0.0008 for s in funding}
_seed_carry(store, funding, spread)
_seed_turnover(store, {s: 50_000_000.0 for s in funding})
daily, cost, spr = per_coin_weight_panel(store, universe=None, sleeves=["xsfunding"], unlock_events=[])
g, n, _t, _cd = _overlay_series(daily, cost, spr, k=0.0, lam=0.0)
pc = per_coin_book_returns(store, universe=None, sleeves=["xsfunding"], unlock_events=[])
store.close()
common = sorted(set(g) & set(pc["gross_series"]))
assert common
for d in common:
assert abs(g[d] - pc["gross_series"][d]) < 1e-12, (d, g[d], pc["gross_series"][d])
assert abs(n[d] - pc["net_series"][d]) < 1e-12, (d, n[d], pc["net_series"][d])
# --- the lift + no-free-lunch tests (the heart of Step 3) ----------------------------------------
def _costly_panel(n_days: int = 80) -> dict[int, dict[str, tuple[float, float]]]:
"""A 2-coin book where a CHEAP coin carries the alpha (steady positive contribution, near-zero cost) and an
EXPENSIVE coin is dead weight: zero return but it FLIPS sign every day, so the baseline pays its wide
spread on a full turnover EVERY day. The spread-aware overlay should downweight / stop-churning the
expensive coin and lift NET."""
daily: dict[int, dict[str, tuple[float, float]]] = {}
for d in range(n_days):
cheap_w = 0.5
cheap_ret = 0.0015 # steady positive per-unit return
exp_w = 0.5 if d % 2 == 0 else -0.5 # flips every day → full turnover each day
daily[d] = {"CHEAP": (cheap_w, cheap_w * cheap_ret), "EXP": (exp_w, 0.0)}
return daily
def test_overlay_lifts_net_on_costly_fixture() -> None:
"""COSTLY fixture: the best overlay's NET-FULL Sharpe beats the baseline (the spread is used to AVOID the
expensive coin's cost — the whole point of Step 3)."""
daily = _costly_panel()
cost = {"CHEAP": 6.0, "EXP": 300.0} # expensive coin: wide measured spread → big cost
spread = {"CHEAP": 1.0, "EXP": 600.0}
base = evaluate_overlay(daily, cost, spread, k=0.0, lam=0.0)
sizing = evaluate_overlay(daily, cost, spread, k=0.05, lam=0.0)
band = evaluate_overlay(daily, cost, spread, k=0.0, lam=4.0)
best = max((sizing, band), key=lambda r: r["net_full_sharpe"])
assert best["net_full_sharpe"] > base["net_full_sharpe"], (base["net_full_sharpe"],
best["net_full_sharpe"])
# and the cost drag / turnover are reduced.
assert best["cost_drag_bps"] <= base["cost_drag_bps"] + 1e-9
assert best["turnover"] <= base["turnover"] + 1e-9
def _all_cheap_panel(n_days: int = 80) -> dict[int, dict[str, tuple[float, float]]]:
"""Same SHAPE as the costly panel but every coin is DEEP (≈ zero spread / fee-floor cost). The overlay
should be ≈ a no-op — there is no cost to avoid (proves it is not a free lunch)."""
daily: dict[int, dict[str, tuple[float, float]]] = {}
for d in range(n_days):
a_w = 0.5
a_ret = 0.0015
b_w = 0.5 if d % 2 == 0 else -0.5
daily[d] = {"A": (a_w, a_w * a_ret), "B": (b_w, 0.0)}
return daily
def test_overlay_is_noop_when_nothing_is_costly() -> None:
"""ALL-CHEAP fixture: spreads ≈ 0 → sizing is a no-op (identical net series); the lift is ≈ 0. The overlay
only helps when there is genuine cost to avoid."""
daily = _all_cheap_panel()
cost = {"A": 5.5, "B": 5.5} # fee floor only
spread = {"A": 0.0, "B": 0.0}
base = evaluate_overlay(daily, cost, spread, k=0.0, lam=0.0)
sizing = evaluate_overlay(daily, cost, spread, k=0.2, lam=0.0)
# sizing with zero spreads is exactly the baseline.
assert sizing["net_series"] == base["net_series"]
# the band may trim a hair of turnover, but the Sharpe lift is negligible (no costly coin to avoid).
band = evaluate_overlay(daily, cost, spread, k=0.0, lam=4.0)
assert band["net_full_sharpe"] - base["net_full_sharpe"] < 0.10
# --- the one-universe eval + the breadth sweep ---------------------------------------------------
def _seed_two_tier(store: TimescaleFeatureStore, *, days: int = 400) -> None:
"""A liquid tier (tight spread, high $vol) + a thin tier (WIDE spread, low $vol) carry book — the broad
universe pulls in the thin coins the overlay can help with; the $10M bound excludes them."""
deep = {"AAAUSDT": 0.001, "BBBUSDT": 0.001, "CCCUSDT": 0.001,
"XXXUSDT": -0.001, "YYYUSDT": -0.001, "ZZZUSDT": -0.001}
thin = {"PPPUSDT": 0.0012, "QQQUSDT": 0.0012, "RRRUSDT": 0.0012,
"SSSUSDT": -0.0012, "TTTUSDT": -0.0012, "UUUUSDT": -0.0012}
spread = {s: 0.0004 for s in deep}
spread.update({s: 0.02 for s in thin}) # 200bp measured spread on the thin tier
_seed_carry(store, {**deep, **thin}, spread, days=days)
dv = {s: 100_000_000.0 for s in deep}
dv.update({s: 2_000_000.0 for s in thin}) # thin coins clear $1M but fail the $10M bound
_seed_turnover(store, dv, days=days)
def test_eval_runs_with_oos_split_and_verdict() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_two_tier(store)
rep = spread_overlay_eval(store, universe=None, sleeves=["xsfunding"], unlock_events=[])
store.close()
assert rep["available"] is True
for variant in ("baseline", "sizing", "band", "combined"):
r = rep[variant]
assert math.isfinite(r["net_test_sharpe"])
assert math.isfinite(r["cost_drag_bps"])
assert math.isfinite(r["turnover"])
assert rep["verdict"]["verdict"] in ("CONFIRM", "MARGINAL", "NO-HELP")
assert rep["verdict"]["best_variant"] in ("sizing", "band", "combined")
def test_breadth_sweep_covers_universes_and_lift_grows_with_breadth() -> None:
"""The broad universe (which includes the thin, wide-spread coins) should see at least as large an overlay
cost benefit as the liquid $10M bound — the overlay's value scales with breadth."""
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_two_tier(store)
rep = spread_overlay_test(store, min_dollar_vols=(10_000_000.0, 0.0),
sleeves=["xsfunding"], unlock_events=[])
store.close()
assert rep["available"] is True
by_label = {row["universe_label"]: row for row in rep["table"]}
assert "broad/all" in by_label
broad = by_label["broad/all"]
# the broad universe carries the wide-spread thin coins → a larger median measured spread than the bounded
# book, so there is genuinely more cost for the overlay to avoid.
if "$10M" in by_label:
assert broad["median_spread_bps"] >= by_label["$10M"]["median_spread_bps"] - 1e-9
# the overlay finds a cost-drag reduction on the broad book.
assert broad["verdict"]["d_cost_drag_bps"] >= -1e-9 or broad["verdict"]["d_sharpe"] >= 0.0
# --- READ-ONLY + determinism ---------------------------------------------------------------------
def test_eval_is_read_only() -> None:
inner = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_two_tier(inner)
guarded = _WriteTripwireStore(inner)
rep = spread_overlay_eval(guarded, universe=None, sleeves=["xsfunding"], unlock_events=[])
inner.close()
assert rep["available"] is True
def test_eval_is_deterministic() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_two_tier(store)
a = spread_overlay_eval(store, universe=None, sleeves=["xsfunding"], unlock_events=[])
b = spread_overlay_eval(store, universe=None, sleeves=["xsfunding"], unlock_events=[])
store.close()
assert a["baseline"]["net_series"] == b["baseline"]["net_series"]
assert a["combined"]["net_series"] == b["combined"]["net_series"]
def test_empty_store_reports_unavailable() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
rep = spread_overlay_eval(store, universe=None, sleeves=["xsfunding"], unlock_events=[])
store.close()
assert rep["available"] is False
# --- CLI smoke (mocked store) --------------------------------------------------------------------
def test_cli_spread_overlay_test_prints_table(monkeypatch) -> None:
from typer.testing import CliRunner
import fxhnt.cli as cli
seeded = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_two_tier(seeded, days=400)
monkeypatch.setattr(
"fxhnt.adapters.warehouse.timescale_feature_store.TimescaleFeatureStore",
lambda *a, **k: seeded, raising=False)
result = CliRunner().invoke(
cli.app, ["spread-overlay-test", "--min-dollar-vols", "10000000,0", "--sleeves", "xsfunding"])
seeded.close()
assert result.exit_code == 0, result.output
out = result.output.lower()
assert "universe" in out
assert "baseline" in out or "net" in out
assert "verdict" in out