255 lines
11 KiB
Python
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
|