Files
fxhnt/tests/integration/test_bybit_edges_eval.py

255 lines
11 KiB
Python

"""READ-ONLY multi-edge evaluator on a warehouse store — run tstrend/unlock/stablecoin/xsfunding on Bybit.
Seeds a synthetic in-memory `TimescaleFeatureStore("sqlite://", table="bybit_features")` and asserts:
* `edge_metrics_from_store(store, "tstrend")` yields sane non-degenerate metrics on a clear momentum
pattern, and a `universe` restriction changes the breadth (fewer symbols);
* the evaluator is READ-ONLY — it never calls any write/persist method on the store or a paper repo
(asserted via a write-tripwire store proxy);
* unlock computes with an injected (mock) DefiLlama-style calendar, and reports N/A cleanly when the
calendar is absent; stablecoin reports N/A (reason) when the Bybit warehouse has no stablecoin spot
panel, and computes when an explicit spot panel is injected;
* xsfunding reuses `carry_metrics_from_store`;
* `evaluate_all_edges` returns a dict keyed by edge with metric dicts plus a combined book.
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_edges_eval import (
edge_metrics_from_store,
evaluate_all_edges,
)
_DAY = 86_400
def _seed_momentum(store: TimescaleFeatureStore, *, n_symbols: int = 8, days: int = 200,
drift: float = 0.01) -> None:
"""Write `days` of daily closes for `n_symbols` with a clear, persistent up/down momentum pattern.
Even-indexed symbols trend UP (compounding +drift/day), odd-indexed trend DOWN, each with a small
deterministic wobble so realized vol is finite/non-degenerate — a long/flat TSMOM book takes the
up-trenders, giving sane non-degenerate metrics. >= 200 days clears the 130-close min-history gate."""
for idx in range(n_symbols):
sign = 1.0 if idx % 2 == 0 else -1.0
px = 100.0
rows = []
for d in range(days):
wobble = 0.002 * math.cos(0.4 * d + idx)
px *= (1.0 + sign * drift + wobble)
rows.append((d * _DAY, {"close": px}))
store.write_features(f"SYM{idx:02d}USDT", rows)
def _seed_carry(store: TimescaleFeatureStore, *, days: int = 60) -> None:
"""Funding dispersion + flat perp/spot closes → a hand-checkable positive carry curve for xsfunding."""
funding = {"AAAUSDT": 0.001, "BBBUSDT": 0.001, "CCCUSDT": 0.001,
"XXXUSDT": -0.001, "YYYUSDT": -0.001, "ZZZUSDT": -0.001}
for idx, (sym, fund) in enumerate(funding.items()):
rows = []
for d in range(days):
f = fund + 0.0002 * math.cos(0.5 * d + idx)
rows.append((d * _DAY, {"funding": f, "close": 100.0, "spot_close": 100.0}))
store.write_features(sym, rows)
class _WriteTripwireStore:
"""Read-only proxy over a TimescaleFeatureStore: forwards reads, but ANY write/persist call trips an
assertion. Proves the evaluator never mutates the warehouse (the READ-ONLY guarantee)."""
_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: evaluator called write method {name!r}")
return getattr(self._inner, name)
# --- tstrend ------------------------------------------------------------------------------------
def test_tstrend_metrics_are_sane_and_non_degenerate() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_momentum(store)
m = edge_metrics_from_store(store, "tstrend", cost_bps=0.0)
store.close()
assert m["days"] > 0
assert m["symbols"] > 0
assert math.isfinite(m["sharpe"])
assert math.isfinite(m["cagr"])
assert m["max_dd"] <= 0.0
# A persistent up-trend long/flat book should make money gross.
assert m["total_return"] > 0.0
def test_tstrend_universe_restriction_changes_breadth() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_momentum(store, n_symbols=8)
full = edge_metrics_from_store(store, "tstrend")
restricted = edge_metrics_from_store(store, "tstrend",
universe={"SYM00USDT", "SYM02USDT"})
store.close()
assert restricted["symbols"] < full["symbols"]
def test_tstrend_is_read_only() -> None:
inner = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_momentum(inner)
guarded = _WriteTripwireStore(inner)
# Must NOT raise an AssertionError (no write path touched).
m = edge_metrics_from_store(guarded, "tstrend")
inner.close()
assert m["days"] > 0
# --- unlock -------------------------------------------------------------------------------------
def test_unlock_with_injected_calendar_computes() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_momentum(store, n_symbols=6, days=200)
# An unlock cliff for SYM02USDT (an up-trender we will short into) + BTC as the market hedge leg.
events = [{"sym": "SYM02USDT", "cliff_day": 190, "n": 5_000_000.0, "cat": "investors"}]
# Need a market symbol for the hedge leg (BTCUSDT) — seed a simple flat-ish series.
btc_rows = [(d * _DAY, {"close": 30_000.0 * (1.0 + 0.0005 * d)}) for d in range(200)]
store.write_features("BTCUSDT", btc_rows)
m = edge_metrics_from_store(store, "unlock", unlock_events=events)
store.close()
assert m["available"] is True
assert m["days"] >= 0
assert math.isfinite(m["sharpe"])
def test_unlock_na_when_calendar_absent() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_momentum(store, n_symbols=4)
# No unlock_events injected AND no calendar file loader provided → N/A, not a fabricated number.
m = edge_metrics_from_store(store, "unlock", unlock_events=None, load_unlock_events=None)
store.close()
assert m["available"] is False
assert "reason" in m and m["reason"]
# --- stablecoin ---------------------------------------------------------------------------------
def test_stablecoin_na_when_no_spot_panel() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_momentum(store, n_symbols=4) # no stablecoin spot closes in the perp warehouse
m = edge_metrics_from_store(store, "stablecoin_rotation")
store.close()
assert m["available"] is False
assert "reason" in m and m["reason"]
def test_stablecoin_with_injected_spot_panel_computes() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_momentum(store, n_symbols=4)
# A stablecoin spot panel with a clear "rich" (>1.0) peg deviation that mean-reverts.
spot_panel = {
"FDUSDUSDT": {d: (1.01 if d % 2 == 0 else 1.0) for d in range(40)},
"TUSDUSDT": {d: (1.008 if d % 2 == 0 else 1.0) for d in range(40)},
}
m = edge_metrics_from_store(store, "stablecoin_rotation", stablecoin_spot_panel=spot_panel)
store.close()
assert m["available"] is True
assert m["symbols"] == 2
assert math.isfinite(m["sharpe"])
# --- xsfunding ----------------------------------------------------------------------------------
def test_xsfunding_reuses_carry_metrics() -> None:
from fxhnt.application.bybit_carry_revalidate import carry_metrics_from_store
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_carry(store)
via_edge = edge_metrics_from_store(store, "xsfunding", cost_bps=5.5)
via_carry = carry_metrics_from_store(store, cost_bps=5.5)
store.close()
assert via_edge["sharpe"] == via_carry["sharpe"]
assert via_edge["total_return"] == via_carry["total_return"]
assert via_edge["symbols"] == via_carry["symbols"]
# --- evaluate_all_edges -------------------------------------------------------------------------
def test_evaluate_all_edges_returns_keyed_dict() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_momentum(store, n_symbols=6, days=200)
_seed_carry(store) # adds AAAUSDT.. with funding+spot_close for xsfunding
out = evaluate_all_edges(store, cost_bps=5.5)
store.close()
for edge in ("tstrend", "unlock", "stablecoin_rotation", "xsfunding", "combined"):
assert edge in out
assert isinstance(out[edge], dict)
# tstrend + xsfunding have data → available; unlock/stablecoin N/A without external inputs.
assert out["tstrend"]["available"] is True
assert out["xsfunding"]["available"] is True
assert out["unlock"]["available"] is False
assert out["stablecoin_rotation"]["available"] is False
# The combined book is reported (equal-weight over the available edges).
assert out["combined"]["available"] is True
def test_evaluate_all_edges_is_read_only() -> None:
inner = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_momentum(inner, n_symbols=6, days=200)
_seed_carry(inner)
guarded = _WriteTripwireStore(inner)
out = evaluate_all_edges(guarded, cost_bps=5.5) # must not trip the write tripwire
inner.close()
assert "combined" in out
# --- CLI smoke (mocked store) -------------------------------------------------------------------
def test_cli_bybit_edges_eval_prints_table(monkeypatch) -> None:
"""CLI smoke: a seeded in-memory store stands in for the live Bybit warehouse (no network), and the
command prints the per-edge table including tstrend + xsfunding + the combined row."""
from typer.testing import CliRunner
import fxhnt.cli as cli
seeded = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_momentum(seeded, n_symbols=6, days=200)
_seed_carry(seeded)
# Make the command construct OUR seeded store instead of connecting to the operational DSN, and stub the
# external loaders so NO network is touched (unlock calendar absent → N/A; stablecoin spot absent → N/A).
monkeypatch.setattr(
"fxhnt.adapters.warehouse.timescale_feature_store.TimescaleFeatureStore",
lambda *a, **k: seeded, raising=False)
class _NoSpot:
def daily_close(self, sym): # noqa: ANN001, ANN201 — test stub; raise to mimic no data
raise RuntimeError("no network in tests")
monkeypatch.setattr(
"fxhnt.adapters.data.binance_spot_history.BinanceSpotHistory",
_NoSpot, raising=False)
result = CliRunner().invoke(cli.app, ["bybit-edges-eval", "--all"])
seeded.close()
assert result.exit_code == 0, result.output
assert "Bybit multi-edge evaluation" in result.output
assert "tstrend" in result.output
assert "xsfunding" in result.output
assert "combined" in result.output