215 lines
9.6 KiB
Python
215 lines
9.6 KiB
Python
"""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
|