Files
fxhnt/tests/integration/test_bybit_stablecoin_eval.py

215 lines
9.6 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.
"""READ-ONLY Phase-1 evaluator: stablecoin peg-reversion on BYBIT's OWN near-$1 stablecoin spot pairs.
Seeds a synthetic in-memory `TimescaleFeatureStore("sqlite://", table="bybit_features")` with `spot_close`
rows and asserts:
* `bybit_stable_spot_panel` auto-detects ONLY symbols that STAY near $1 (median ∈ [0.98, 1.02] AND >=90%
of days within ±5% of $1 AND >=60 days history) — excluding a gold-like (~3000), a dead peg (~0.02), a
normal coin (~100), AND a VOLATILE coin that merely averages ~$1 (SUSHI-like: regression for the bug);
* `stablecoin_reversion_metrics_from_store` runs the LIVE `StableReversionRunner` UNCHANGED on that panel —
a rich→revert panel produces a positive (short-rich) curve with sane finite metrics;
* the evaluator is READ-ONLY (write-tripwire store proxy);
* the CLI prints the detected pairs + metrics + caveat (mocked store, no network).
NO network — in-memory SQLite; no external inputs.
"""
from __future__ import annotations
import math
from fxhnt.adapters.warehouse.timescale_feature_store import TimescaleFeatureStore
from fxhnt.application.bybit_stablecoin_eval import (
bybit_stable_spot_panel,
stablecoin_reversion_metrics_from_store,
)
_DAY = 86_400
def _seed_spot(store: TimescaleFeatureStore, symbol: str, series: dict[int, float]) -> None:
"""Write a {epoch_day: spot_close} series for one symbol."""
store.write_features(symbol, [(d * _DAY, {"spot_close": px}) for d, px in series.items()])
def _seed_mixed_universe(store: TimescaleFeatureStore, *, days: int = 80) -> None:
"""A realistic mix: two TRUE near-$1 stablecoins (rich→revert, stay near peg), a SUSHI-like volatile coin
(median ~1 but WANDERS $0.5$5), a gold-like (~3000), a dead peg (~0.02), and a normal coin (~100). Only
the two true stablecoins should be auto-detected — the SUSHI-like one is the regression case for the bug.
`days >= 60` so the true stablecoins clear the min-history requirement."""
# USDC: persistently slightly rich (above peg) on even days, reverts to peg on odd days. Stays near $1.
_seed_spot(store, "USDCUSDT", {d: (1.01 if d % 2 == 0 else 1.0) for d in range(days)})
# USDE (Ethena): same rich→revert shape, slightly smaller deviation. Stays near $1.
_seed_spot(store, "USDEUSDT", {d: (1.008 if d % 2 == 0 else 1.0) for d in range(days)})
# SUSHI-like: values cycle 0.5, 0.8, 1.0, 2.5, 4.0 — median ~1 but WANDERS far from peg most days.
# Loose median-only filter wrongly admitted this (bogus 90% DD); the dispersion filter must EXCLUDE it.
_wander = [0.5, 0.8, 1.0, 2.5, 4.0]
_seed_spot(store, "SUSHIUSDT", {d: _wander[d % len(_wander)] for d in range(days)})
# Gold-proxy — median ~3000, must be EXCLUDED.
_seed_spot(store, "PAXGUSDT", {d: 3000.0 + d for d in range(days)})
# Dead peg — median ~0.02, must be EXCLUDED.
_seed_spot(store, "USTCUSDT", {d: 0.02 for d in range(days)})
# Normal coin — median ~100, must be EXCLUDED.
_seed_spot(store, "ETHUSDT", {d: 100.0 + d for d in range(days)})
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)
# --- auto-detect --------------------------------------------------------------------------------
def test_panel_selects_only_near_one_stablecoins() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_mixed_universe(store)
panel = bybit_stable_spot_panel(store)
store.close()
assert set(panel) == {"USDCUSDT", "USDEUSDT"}
# Gold / dead peg / normal coin are NOT in the panel.
for excluded in ("PAXGUSDT", "USTCUSDT", "ETHUSDT"):
assert excluded not in panel
# The selected series are full {epoch_day: close} dicts.
assert all(isinstance(v, dict) and v for v in panel.values())
def test_panel_excludes_volatile_coin_averaging_one() -> None:
"""REGRESSION for the bug: a SUSHI-like coin whose MEDIAN is ~$1 but which WANDERS $0.5$5 must NOT be
treated as a stablecoin — its median passes the old loose filter but it spends most days far from peg, so
the frac-in-band dispersion filter excludes it. (Admitting it caused a bogus 90% DD.)"""
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_mixed_universe(store)
panel = bybit_stable_spot_panel(store)
store.close()
assert "SUSHIUSDT" not in panel
assert set(panel) == {"USDCUSDT", "USDEUSDT"}
def test_panel_frac_in_band_boundary() -> None:
"""A coin near-$1 for MOST days but spiking occasionally: frac_in_band just under 0.90 → excluded;
just over 0.90 → included (boundary around STABLE_MIN_FRAC_IN_BAND)."""
from fxhnt.application.bybit_stablecoin_eval import STABLE_MIN_FRAC_IN_BAND
assert STABLE_MIN_FRAC_IN_BAND == 0.90
# 100 days: 11 spike days (off-peg) → 89% in-band → just UNDER 0.90 → excluded.
under = {d: (2.0 if d < 11 else 1.0) for d in range(100)}
# 100 days: 10 spike days → 90% in-band → exactly at threshold → included.
over = {d: (2.0 if d < 10 else 1.0) for d in range(100)}
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_spot(store, "UNDERUSDT", under)
_seed_spot(store, "OVERUSDT", over)
panel = bybit_stable_spot_panel(store)
store.close()
assert "UNDERUSDT" not in panel # 0.89 < 0.90
assert "OVERUSDT" in panel # 0.90 >= 0.90
def test_panel_excludes_short_history_coin() -> None:
"""Min-history: a brand-new coin with only ~5 near-$1 prints must be EXCLUDED (n < STABLE_MIN_HISTORY),
even though every print is exactly at peg."""
from fxhnt.application.bybit_stablecoin_eval import STABLE_MIN_HISTORY
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_spot(store, "NEWUSDT", {d: 1.0 for d in range(5)})
# A real stablecoin with ample history, for contrast.
_seed_spot(store, "USDCUSDT", {d: 1.0 for d in range(STABLE_MIN_HISTORY + 5)})
panel = bybit_stable_spot_panel(store)
store.close()
assert "NEWUSDT" not in panel
assert "USDCUSDT" in panel
def test_panel_empty_when_no_near_one_symbol() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_spot(store, "ETHUSDT", {d: 100.0 + d for d in range(80)})
_seed_spot(store, "PAXGUSDT", {d: 3000.0 for d in range(80)})
panel = bybit_stable_spot_panel(store)
store.close()
assert panel == {}
# --- metrics ------------------------------------------------------------------------------------
def test_metrics_short_rich_is_positive_and_sane() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_mixed_universe(store)
m = stablecoin_reversion_metrics_from_store(store, cost_bps=0.0)
store.close()
assert m["available"] is True
assert m["symbols"] == 2
assert m["pairs"] == ["USDCUSDT", "USDEUSDT"]
assert m["days"] > 0
assert math.isfinite(m["sharpe"])
assert math.isfinite(m["cagr"])
assert m["max_dd"] <= 0.0
# Shorting a rich (above-peg) name that mean-reverts to peg is profitable gross.
assert m["total_return"] > 0.0
def test_metrics_na_when_no_stable_pair() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_spot(store, "ETHUSDT", {d: 100.0 + d for d in range(40)})
m = stablecoin_reversion_metrics_from_store(store)
store.close()
assert m["available"] is False
assert "reason" in m and m["reason"]
assert m["pairs"] == []
def test_metrics_is_read_only() -> None:
inner = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_mixed_universe(inner)
guarded = _WriteTripwireStore(inner)
# Must NOT raise an AssertionError (no write path touched).
m = stablecoin_reversion_metrics_from_store(guarded, cost_bps=0.0)
inner.close()
assert m["available"] is True
# --- CLI smoke (mocked store) -------------------------------------------------------------------
def test_cli_bybit_stablecoin_eval_prints_pairs_and_caveat(monkeypatch) -> None:
"""CLI smoke: a seeded in-memory store stands in for the live Bybit warehouse (no network); the command
prints the detected pairs, the metrics, and the daily-close caveat."""
from typer.testing import CliRunner
import fxhnt.cli as cli
seeded = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_mixed_universe(seeded)
monkeypatch.setattr(
"fxhnt.adapters.warehouse.timescale_feature_store.TimescaleFeatureStore",
lambda *a, **k: seeded, raising=False)
result = CliRunner().invoke(cli.app, ["bybit-stablecoin-eval"])
seeded.close()
assert result.exit_code == 0, result.output
assert "stablecoin peg-reversion" in result.output
assert "USDCUSDT" in result.output
assert "USDEUSDT" in result.output
# The honesty caveats are surfaced.
assert "CONSERVATIVE LOWER BOUND" in result.output
assert "Phase-2" in result.output