fix(bybit): stablecoin detector requires staying near peg (dispersion), not just median — excludes volatile coins averaging ~$1

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-06-24 13:05:37 +02:00
parent b0cf2aa332
commit a2e8516f77
3 changed files with 147 additions and 33 deletions

View File

@@ -353,6 +353,37 @@ class TimescaleFeatureStore:
import statistics as _st
return {s: _st.median(v) for s, v in per_symbol.items() if v}
def spot_close_peg_stats(
self, *, suffix: str = "USDT", band: float = 0.05
) -> dict[str, tuple[float, float, int]]:
"""{symbol: (median, frac_within_band, n)} for symbols ending in `suffix` — the LIGHT pass that lets
a caller auto-detect symbols that STAY near $1 (true stablecoins), not merely AVERAGE ~$1.
`frac_within_band` is the fraction of observations with `spot_close ∈ [1-band, 1+band]` (default
±5% of $1). A volatile coin whose median ≈ $1 but which wanders (SUSHI/THETA/TWT/VIRTUAL) has a LOW
frac; a real peg (USDC/USDE) has frac ≈ 1.0. `n` is the observation count (history length).
Reads only `(symbol, value)` (no ts) for `spot_close` and streams the per-symbol median + in-band
fraction in Python (portable across PG/SQLite; SQL median isn't standard). Memory-bounded by total
spot rows × one float, not the panel-dict-per-day blow-up. READ-ONLY (one SELECT)."""
with self._engine.connect() as c:
rows = c.execute(text(
f"SELECT symbol, value FROM {self._table} WHERE feature = 'spot_close' AND symbol LIKE :pat"
), {"pat": f"%{suffix}"}).fetchall()
per_symbol: dict[str, list[float]] = {}
for symbol, value in rows:
if value is not None:
per_symbol.setdefault(str(symbol), []).append(float(value))
import statistics as _st
lo, hi = 1.0 - band, 1.0 + band
out: dict[str, tuple[float, float, int]] = {}
for s, v in per_symbol.items():
if not v:
continue
in_band = sum(1 for x in v if lo <= x <= hi)
out[s] = (_st.median(v), in_band / len(v), len(v))
return out
def trading_days_epoch(self) -> list[int]:
"""Sorted distinct adjclose trading days as epoch-DAYS (ts // 86400)."""
with self._engine.connect() as c:

View File

@@ -8,17 +8,20 @@ stablecoins, using the `spot_close` data ALREADY in `bybit_features` — BEFORE
USDC-margined perps (Phase 2)?
HOW THE EDGE LOGIC IS REUSED (nothing reinvented):
* `bybit_stable_spot_panel(store)` auto-detects which symbols in the warehouse are near-$1 stablecoins
(median `spot_close` ∈ [0.95, 1.05]) and returns a `{sym: {epoch_day: close}}` panel — the EXACT shape
`StableReversionRunner` consumes.
* `bybit_stable_spot_panel(store)` auto-detects which symbols in the warehouse are TRUE near-$1
stablecoins — ones that STAY near peg (tiny dispersion), not merely AVERAGE ~$1: median `spot_close` ∈
[0.98, 1.02] AND >=90% of daily closes within ±5% of $1 AND >=60 days of history — and returns a
`{sym: {epoch_day: close}}` panel, the EXACT shape `StableReversionRunner` consumes. (A median-only
filter wrongly admitted volatile coins averaging ~$1 like SUSHI/THETA/TWT/VIRTUAL → bogus 90% DD.)
* `stablecoin_reversion_metrics_from_store` feeds that panel straight into `StableReversionRunner(panel)`
— the live sleeve's own runner, UNCHANGED (same short-rich logic, same threshold, same turnover cost) —
and wraps its (dates, returns) via the shared `_curve_metrics` (the carry/edges template).
MEMORY-BOUNDED auto-detect: a near-$1 symbol can't be known a-priori, but the full `spot_close` table is
~hundreds of coins. We do a LIGHT pass — `spot_close_medians(store)` reads only `(symbol, value)` (no ts),
streams a per-symbol median, and selects the near-$1 names — then read ONLY those full series via
`read_panel(near_one_syms, "spot_close")`. The whole multi-day panel is never materialised for non-stables.
MEMORY-BOUNDED auto-detect: a stablecoin symbol can't be known a-priori, but the full `spot_close` table is
~hundreds of coins. We do a LIGHT pass — `spot_close_peg_stats(store)` reads only `(symbol, value)` (no ts),
streams per-symbol `(median, frac_within_5pct, n)`, and selects the names that STAY near $1 — then read ONLY
those full series via `read_panel(stable_syms, "spot_close")`. The whole multi-day panel is never
materialised for non-stables.
READ-ONLY: there is no PaperRepo and no `store.write_*` / `paper_*` write is ever issued. Both the median
scan and the panel read are SELECTs; `StableReversionRunner` is pure. A write-tripwire store in the tests
@@ -37,36 +40,56 @@ from typing import Any, Protocol
from fxhnt.application.bybit_carry_revalidate import _curve_metrics
# A symbol whose MEDIAN spot_close falls in this band is treated as a near-$1 stablecoin. Wide enough to
# admit USDC/USDE/TUSD (and a transient depeg), tight enough to EXCLUDE gold (PAXG/XAUT ~3000), a dead peg
# (USTC ~0.02), and any normal coin.
_PEG_LO = 0.95
_PEG_HI = 1.05
# A stablecoin is defined by STAYING near $1 (tiny dispersion), NOT merely by AVERAGING ~$1. A loose
# median-only filter admits VOLATILE coins that happen to average ~$1 (SUSHI/THETA/TWT/VIRTUAL all wander
# $0.5$5 but median ≈ $1) — treating those as pegged ("short when rich") produced a bogus 90% DD. So a
# symbol qualifies ONLY if it is BOTH centered at peg AND stays there:
# 1. median(spot_close) ∈ [STABLE_MEDIAN_LO, STABLE_MEDIAN_HI] (centered at the $1 peg), AND
# 2. the FRACTION of days with spot_close ∈ [1±STABLE_PEG_BAND] is >= STABLE_MIN_FRAC_IN_BAND
# (i.e. >=90% of observations within ±5% of $1 — it STAYS near peg), AND
# 3. n >= STABLE_MIN_HISTORY days (a brand-new coin with a few near-$1 prints isn't admitted).
# This keeps USDC/USDE/BUSD/RLUSD/USD1 (near $1 ~always; brief depegs like USDE's intraday event are
# invisible at the daily close anyway) while EXCLUDING SUSHI/THETA/TWT/VIRTUAL, gold (PAXG/XAUT ~3000),
# and dead pegs (USTC ~0.02).
STABLE_MEDIAN_LO = 0.98
STABLE_MEDIAN_HI = 1.02
STABLE_PEG_BAND = 0.05 # half-width of the ±$1 "stays near peg" band
STABLE_MIN_FRAC_IN_BAND = 0.90 # >=90% of observations must lie within ±5% of $1
STABLE_MIN_HISTORY = 60 # require at least this many daily observations
class _FeatureStore(Protocol):
"""Just the reads this evaluator needs (TimescaleFeatureStore satisfies it). READ-ONLY."""
def read_panel(self, symbols: list[str], feature: str) -> dict[str, dict[int, float]]: ...
def spot_close_medians(self, *, suffix: str = "USDT") -> dict[str, float]: ...
def spot_close_peg_stats(
self, *, suffix: str = "USDT", band: float = STABLE_PEG_BAND
) -> dict[str, tuple[float, float, int]]: ...
def _near_one(median: float) -> bool:
return _PEG_LO <= median <= _PEG_HI
def _is_stablecoin(median: float, frac_in_band: float, n: int) -> bool:
"""A symbol qualifies as a stablecoin only if it is centered at peg AND stays there AND has history."""
return (
STABLE_MEDIAN_LO <= median <= STABLE_MEDIAN_HI
and frac_in_band >= STABLE_MIN_FRAC_IN_BAND
and n >= STABLE_MIN_HISTORY
)
def bybit_stable_spot_panel(store: _FeatureStore, *, suffix: str = "USDT") -> dict[str, dict[int, float]]:
"""Auto-detect Bybit's near-$1 stablecoin spot pairs and return the panel `StableReversionRunner` wants.
Step 1 (LIGHT, memory-bounded): `store.spot_close_medians()` streams a per-symbol median of `spot_close`
WITHOUT materialising the multi-day panel; we keep the symbols whose median ∈ [0.95, 1.05] (USDC/USDE/
TUSD), dropping gold (~3000), dead pegs (~0.02), and normal coins.
Step 2: read ONLY those near-$1 symbols' full series via `read_panel(near, "spot_close")` — so the heavy
panel materialises for a HANDFUL of stablecoins, never the whole table.
Step 1 (LIGHT, memory-bounded): `store.spot_close_peg_stats()` streams per-symbol `(median,
frac_within_5pct, n)` WITHOUT materialising the multi-day panel; we keep ONLY symbols that BOTH center at
peg (median ∈ [0.98, 1.02]) AND stay there (>=90% of observations within ±5% of $1) AND have >=60 days of
history. This excludes VOLATILE coins that merely average ~$1 (SUSHI/THETA/TWT/VIRTUAL), gold (~3000),
dead pegs (~0.02), and brand-new coins — keeping real pegs (USDC/USDE/BUSD/RLUSD/USD1).
Step 2: read ONLY those qualifying symbols' full series via `read_panel(near, "spot_close")` — so the
heavy panel materialises for a HANDFUL of stablecoins, never the whole table.
Returns `{symbol: {epoch_day: spot_close}}` (empty {} dropped). READ-ONLY (two SELECTs)."""
medians = store.spot_close_medians(suffix=suffix)
near = sorted(s for s, med in medians.items() if _near_one(med))
stats = store.spot_close_peg_stats(suffix=suffix, band=STABLE_PEG_BAND)
near = sorted(s for s, (med, frac, n) in stats.items() if _is_stablecoin(med, frac, n))
if not near:
return {}
panel = store.read_panel(near, "spot_close")
@@ -89,8 +112,10 @@ def stablecoin_reversion_metrics_from_store(
panel = bybit_stable_spot_panel(store, suffix=suffix)
pairs = sorted(panel)
if not panel:
return _na("no near-$1 stablecoin spot pair in bybit_features "
f"(median spot_close in [{_PEG_LO}, {_PEG_HI}]); ingest spot_close first", pairs)
return _na("no near-$1 stablecoin spot pair in bybit_features (median spot_close in "
f"[{STABLE_MEDIAN_LO}, {STABLE_MEDIAN_HI}] AND >={STABLE_MIN_FRAC_IN_BAND:.0%} of days "
f"within +-{STABLE_PEG_BAND:.0%} of $1 over >={STABLE_MIN_HISTORY} days); "
"ingest spot_close first", pairs)
res = StableReversionRunner(panel, cost_bps=cost_bps).run()
if res.returns.size == 0:

View File

@@ -2,8 +2,9 @@
Seeds a synthetic in-memory `TimescaleFeatureStore("sqlite://", table="bybit_features")` with `spot_close`
rows and asserts:
* `bybit_stable_spot_panel` auto-detects ONLY the near-$1 symbols (median spot_close ∈ [0.95, 1.05]) —
excluding a gold-like (~3000), a dead peg (~0.02), and a normal coin (~100);
* `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);
@@ -28,13 +29,19 @@ def _seed_spot(store: TimescaleFeatureStore, symbol: str, series: dict[int, floa
store.write_features(symbol, [(d * _DAY, {"spot_close": px}) for d, px in series.items()])
def _seed_mixed_universe(store: TimescaleFeatureStore, *, days: int = 40) -> None:
"""A realistic mix: two near-$1 stablecoins (rich→revert), a gold-like (~3000), a dead peg (~0.02), and a
normal coin (~100). Only the two near-$1 names should be auto-detected as stablecoin spot pairs."""
# USDC: persistently slightly rich (above peg) on even days, reverts to peg on odd days.
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.
# 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.
@@ -78,10 +85,61 @@ def test_panel_selects_only_near_one_stablecoins() -> None:
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(40)})
_seed_spot(store, "PAXGUSDT", {d: 3000.0 for d in range(40)})
_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()