Files
fxhnt/tests/integration/test_bybit_book_eval.py
jgrusewski fd5d97bc94 fix(cockpit): give the bybit_4edge sleeve breakdown its own display names
crypto_tstrend's standalone STRATEGY_REGISTRY entry was retired in Phase 0b
Task 4, but the still-live crypto_tstrend book SLEEVE in the bybit_4edge
per-edge breakdown (Overview + /paper) shared the same string id, so its
display-name lookup blanked to the raw id and linked to a dead
/strategy/crypto_tstrend (404).

Add _SLEEVE_DISPLAY_NAMES next to _DEFAULT_BYBIT_SLEEVES in
bybit_book_eval.py — the sleeve breakdown's own display-name source,
independent of STRATEGY_REGISTRY, so a future forward-track retirement can
never blank a live sleeve again. _deploy_individual_edges() now sources
display_name from that map and adds a `linkable` field (true only when the
sleeve id is also a real registry entry); deploy_constituent_row renders the
/strategy link only when linkable, plain text otherwise.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 20:11:31 +02:00

433 lines
21 KiB
Python

"""READ-ONLY, MEMORY-BOUNDED risk-managed BOOK evaluator on the Bybit liquid sleeves.
Seeds a synthetic in-memory `TimescaleFeatureStore("sqlite://", table="bybit_features")` and asserts:
* `sleeve_returns_from_store(store, "tstrend")` yields a per-day return series; a `universe` restriction
limits the symbols that load (the panel READ is restricted to the liquid set, not the whole table);
* `risk_managed_book_from_store` on a multi-sleeve fixture returns SANE book metrics, and the LIVE overlay
changes the result vs the naive equal-weight (leverage != 1 / a kill or a different curve);
* the evaluator is READ-ONLY — it never calls a write/persist method on the store (write-tripwire proxy);
* MEMORY-BOUND: the universe-restricted store reads via `read_panel(liquid_symbols, ...)`, NOT the
full-universe `crypto_close_panel()` — proven by a tripwire that fails if the full-table panel is read;
* CLI smoke (mocked store) prints the risk-managed book table.
NO network — the store is in-memory SQLite; external inputs are injected.
"""
from __future__ import annotations
import math
import pytest
from fxhnt.adapters.warehouse.timescale_feature_store import TimescaleFeatureStore
from fxhnt.application.bybit_book_eval import (
_DEFAULT_BYBIT_SLEEVES,
_SLEEVE_DISPLAY_NAMES,
risk_managed_book_from_store,
sleeve_returns_from_store,
)
_DAY = 86_400
def _seed_momentum(store: TimescaleFeatureStore, *, n_symbols: int = 8, days: int = 260,
drift: float = 0.01) -> None:
"""Daily closes for `n_symbols` with a persistent up/down momentum pattern + small wobble (finite vol),
>= 130 closes so the TSMOM min-history gate clears and the long/flat book books returns."""
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.003 * 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 = 260) -> None:
"""Funding dispersion + flat perp/spot closes → a positive carry curve for the xsfunding sleeve."""
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)
def _seed_turnover(store: TimescaleFeatureStore, symbols: list[str], *, days: int = 260,
dollar_vol: float = 50_000_000.0) -> None:
"""A flat turnover (USD) feature so `liquid_universe` admits these symbols (median >= floor)."""
for sym in symbols:
store.write_features(sym, [(d * _DAY, {"turnover": dollar_vol}) for d in range(days)])
def _seed_positioning(store: TimescaleFeatureStore, *, days: int = 80) -> None:
"""Seed `long_ratio` + `close` so the CONTRARIAN positioning edge is profitable: the over-LONG coin
(high buyRatio) FALLS next day (crowd wrong → fade pays). Two coins, oscillating positioning. The
positioning sleeve reads `long_ratio` + `close` (memory-bounded via read_panel)."""
px = {"PAAUSDT": 100.0, "PBBUSDT": 100.0}
for d in range(days):
crowd_long_a = (d % 2 == 0)
ra = 0.70 if crowd_long_a else 0.30
rb = 0.30 if crowd_long_a else 0.70
store.write_features("PAAUSDT", [(d * _DAY, {"long_ratio": ra, "close": px["PAAUSDT"]})])
store.write_features("PBBUSDT", [(d * _DAY, {"long_ratio": rb, "close": px["PBBUSDT"]})])
px["PAAUSDT"] *= (1.0 - 0.004) if crowd_long_a else (1.0 + 0.004)
px["PBBUSDT"] *= (1.0 + 0.004) if crowd_long_a else (1.0 - 0.004)
d = days
store.write_features("PAAUSDT", [(d * _DAY, {"long_ratio": 0.5, "close": px["PAAUSDT"]})])
store.write_features("PBBUSDT", [(d * _DAY, {"long_ratio": 0.5, "close": px["PBBUSDT"]})])
class _WriteTripwireStore:
"""Read-only proxy: forwards reads, but ANY write/persist call trips. Proves 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)
class _FullPanelTripwireStore:
"""Read-only proxy that FAILS if the full-table panel reads (`crypto_close_panel` etc.) are called, but
forwards the symbol-restricted `read_panel`. Proves the universe path NEVER materialises the whole
universe — the memory-bound guarantee. Records which symbol sets `read_panel` was asked for."""
_FULL_PANELS = frozenset({"crypto_close_panel", "crypto_funding_panel", "crypto_spot_panel"})
def __init__(self, inner: TimescaleFeatureStore) -> None:
object.__setattr__(self, "_inner", inner)
object.__setattr__(self, "read_panel_calls", [])
def read_panel(self, symbols, feature): # noqa: ANN001, ANN201
self.read_panel_calls.append((tuple(symbols), feature))
return self._inner.read_panel(symbols, feature)
def __getattr__(self, name: str):
if name in _FullPanelTripwireStore._FULL_PANELS:
raise AssertionError(
f"MEMORY-BOUND violation: evaluator read the FULL-universe panel {name!r} "
"instead of a universe-restricted read_panel")
return getattr(self._inner, name)
# --- sleeve_returns_from_store ------------------------------------------------------------------
def test_sleeve_returns_tstrend_is_a_per_day_series() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_momentum(store)
series = sleeve_returns_from_store(store, "tstrend", cost_bps=0.0)
store.close()
assert isinstance(series, dict)
assert len(series) > 0
# keys are epoch-days (ints), values finite floats.
for d, r in series.items():
assert isinstance(d, int)
assert math.isfinite(r)
def test_tstrend_sleeve_is_net_of_perp_funding() -> None:
"""The crypto_tstrend sleeve charges the perp funding a LONG book pays. Seeding positive funding on the
(long-held) trending symbols lowers the sleeve's cumulative return vs. the identical panel with none —
the honest deploy accounting: a long-biased perp trend book bleeds funding, it isn't free price-trend."""
def _series(with_funding: bool) -> dict[int, float]:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_momentum(store)
if with_funding:
for idx in range(8): # +10 bp/day funding on every seeded symbol (longs pay it)
store.write_features(f"SYM{idx:02d}USDT",
[(d * _DAY, {"funding": 0.001}) for d in range(260)])
s = sleeve_returns_from_store(store, "tstrend", cost_bps=0.0)
store.close()
return s
plain, funded = _series(False), _series(True)
assert sum(plain.values()) > sum(funded.values()) # funding is a strict net drag on the long book
def test_sleeve_returns_accepts_sleeve_name_alias() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_momentum(store)
by_edge = sleeve_returns_from_store(store, "tstrend")
by_sleeve = sleeve_returns_from_store(store, "crypto_tstrend") # paper_sleeves vocabulary
store.close()
assert by_edge == by_sleeve
def test_sleeve_returns_universe_restriction_limits_symbols_loaded() -> None:
"""The universe path reads via read_panel(liquid_symbols, ...) and NEVER the full-table panel."""
inner = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_momentum(inner, n_symbols=8)
guarded = _FullPanelTripwireStore(inner)
universe = {"SYM00USDT", "SYM02USDT", "SYM04USDT"}
series = sleeve_returns_from_store(guarded, "tstrend", universe=universe)
inner.close()
# The series computed (universe non-empty) AND the only reads were symbol-restricted read_panel calls.
assert isinstance(series, dict)
assert guarded.read_panel_calls, "expected a universe-restricted read_panel, none happened"
for symbols, _feature in guarded.read_panel_calls:
# Every read_panel was asked for a SUBSET of the liquid universe — never the full 8-symbol table.
assert set(symbols) <= universe
assert set(symbols), "read_panel must be given the liquid symbol set, not empty/all"
def test_sleeve_returns_xsfunding_series() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_carry(store)
series = sleeve_returns_from_store(store, "xsfunding", cost_bps=5.5)
store.close()
assert len(series) > 0
for d, r in series.items():
assert isinstance(d, int)
assert math.isfinite(r)
def test_sleeve_returns_positioning_is_a_per_day_series() -> None:
"""positioning is a known edge: a `long_ratio`+`close` fixture yields a sane per-day return series."""
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_positioning(store)
series = sleeve_returns_from_store(store, "positioning", cost_bps=0.0)
store.close()
assert isinstance(series, dict)
assert len(series) > 0
for d, r in series.items():
assert isinstance(d, int)
assert math.isfinite(r)
def test_sleeve_returns_positioning_universe_restricted_reads_only() -> None:
"""The positioning sleeve reads via read_panel(liquid_symbols, ...) — NEVER the full-table panel."""
inner = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_positioning(inner)
guarded = _FullPanelTripwireStore(inner)
universe = {"PAAUSDT", "PBBUSDT"}
series = sleeve_returns_from_store(guarded, "positioning", universe=universe, cost_bps=0.0)
inner.close()
assert isinstance(series, dict)
assert len(series) > 0
assert guarded.read_panel_calls, "expected a universe-restricted read_panel, none happened"
for symbols, _feature in guarded.read_panel_calls:
assert set(symbols) <= universe
assert set(symbols)
def test_sleeve_returns_positioning_empty_when_no_ratio_data() -> None:
"""No `long_ratio` (close-only fixture) → positioning is uncomputable → empty series, never fabricated."""
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_momentum(store, n_symbols=4)
series = sleeve_returns_from_store(store, "positioning")
store.close()
assert series == {}
def test_sleeve_returns_unknown_edge_raises() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
with pytest.raises(ValueError):
sleeve_returns_from_store(store, "not_an_edge")
store.close()
# --- risk_managed_book_from_store ---------------------------------------------------------------
def test_risk_managed_book_has_sane_metrics() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_momentum(store, n_symbols=8, days=260)
_seed_carry(store, days=260)
book = risk_managed_book_from_store(store, capital=100_000.0, cost_bps=5.5)
store.close()
assert book["days"] > 0
assert set(book["sleeves"]) <= {"crypto_tstrend", "unlock", "xsfunding"}
assert "crypto_tstrend" in book["sleeves"]
assert "xsfunding" in book["sleeves"]
assert math.isfinite(book["sharpe"])
assert math.isfinite(book["cagr"])
assert book["max_dd"] <= 0.0
# leverage stats present + finite; killed_days a count.
for k in ("avg", "min", "max"):
assert math.isfinite(book["leverage_stats"][k])
assert book["killed_days"] >= 0
# standalone Sharpe reported per active sleeve.
assert set(book["per_sleeve_standalone_sharpe"]) == set(book["sleeves"])
# final-day post-overlay weights present for the active sleeves.
assert book["per_sleeve_weights"]
def test_overlay_changes_result_vs_naive_equal_weight() -> None:
"""The LIVE overlay (vol-target leverage + ISV weights + killswitch) must move the book away from the
naive equal-weight baseline — same returns, different curve (leverage != 1 and/or different total)."""
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_momentum(store, n_symbols=8, days=260)
_seed_carry(store, days=260)
book = risk_managed_book_from_store(store, capital=100_000.0, cost_bps=0.0)
store.close()
naive = book["naive_equal_weight"]
assert naive["days"] > 0
# The overlay applies leverage (vol-target) so the average leverage is not pinned at exactly 1.0, OR
# the risk-managed total return differs from the equal-weight baseline. Either proves the overlay ran.
overlay_moved = (abs(book["leverage_stats"]["avg"] - 1.0) > 1e-6
or abs(book["total_return"] - naive["total_return"]) > 1e-9)
assert overlay_moved, (book["leverage_stats"], book["total_return"], naive["total_return"])
def test_risk_managed_book_includes_positioning_as_fourth_sleeve() -> None:
"""With tstrend + xsfunding + positioning data present the book carries all three (the default sleeve set
now includes positioning), and the correlation matrix is reported for the active set."""
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_momentum(store, n_symbols=8, days=260)
_seed_carry(store, days=260)
_seed_positioning(store, days=260)
book = risk_managed_book_from_store(store, capital=100_000.0, cost_bps=5.5)
store.close()
assert "positioning" in book["sleeves"]
assert {"crypto_tstrend", "xsfunding", "positioning"} <= set(book["sleeves"])
# correlation matrix is square over the active sleeves, diagonal 1.0.
cm = book["correlation_matrix"]
assert set(cm) == set(book["sleeves"])
for si in book["sleeves"]:
assert set(cm[si]) == set(book["sleeves"])
assert cm[si][si] == 1.0
for sj in book["sleeves"]:
v = cm[si][sj]
assert v is None or (-1.0 - 1e-9 <= v <= 1.0 + 1e-9)
# standalone Sharpe + final weights reported for every active sleeve incl. positioning.
assert set(book["per_sleeve_standalone_sharpe"]) == set(book["sleeves"])
def test_naive_eqwt_sharpe_improves_with_uncorrelated_positive_fourth_sleeve() -> None:
"""Diversification: adding an UNCORRELATED, positive 4th sleeve must NOT lower the naive equal-weight
Sharpe (it should help). Compare the 3-sleeve book vs the 4-sleeve book on the SAME fixture."""
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_momentum(store, n_symbols=8, days=260)
_seed_carry(store, days=260)
_seed_positioning(store, days=260)
three = risk_managed_book_from_store(
store, sleeves=["crypto_tstrend", "xsfunding"], capital=100_000.0, cost_bps=0.0)
four = risk_managed_book_from_store(
store, sleeves=["crypto_tstrend", "xsfunding", "positioning"], capital=100_000.0, cost_bps=0.0)
store.close()
# positioning is genuinely the added sleeve.
assert "positioning" in four["sleeves"] and "positioning" not in three["sleeves"]
# positioning is uncorrelated with the others (|corr| small) — the diversification premise.
cm = four["correlation_matrix"]
for other in ("crypto_tstrend", "xsfunding"):
c = cm["positioning"][other]
assert c is None or abs(c) < 0.5, (other, c)
# And positioning is positive standalone (a real positive sleeve, not noise).
assert four["per_sleeve_standalone_sharpe"]["positioning"] > 0.0
# The naive equal-weight Sharpe does not DROP when the uncorrelated positive sleeve joins.
assert four["naive_equal_weight"]["sharpe"] >= three["naive_equal_weight"]["sharpe"] - 1e-9
def test_risk_managed_book_skips_positioning_gracefully_when_absent() -> None:
"""No `long_ratio` data → positioning is omitted, the book is the 3 (or fewer) available sleeves."""
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_momentum(store, n_symbols=8, days=260)
_seed_carry(store, days=260)
book = risk_managed_book_from_store(store, capital=100_000.0, cost_bps=5.5)
store.close()
assert "positioning" not in book["sleeves"]
assert book["days"] > 0 # the rest of the book still builds
def test_risk_managed_book_is_read_only() -> None:
inner = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_momentum(inner, n_symbols=8, days=260)
_seed_carry(inner, days=260)
guarded = _WriteTripwireStore(inner)
book = risk_managed_book_from_store(guarded, capital=100_000.0, cost_bps=5.5)
inner.close()
assert book["days"] > 0 # ran without tripping the write tripwire
def test_risk_managed_book_empty_store_reports_reason() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
book = risk_managed_book_from_store(store)
store.close()
assert book["days"] == 0
assert book["sleeves"] == []
assert "reason" in book
def test_risk_managed_book_universe_restricted_reads_only() -> None:
"""End-to-end memory bound: with a universe, the WHOLE book build never reads a full-table panel."""
inner = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_momentum(inner, n_symbols=8, days=260)
guarded = _FullPanelTripwireStore(inner)
universe = {"SYM00USDT", "SYM02USDT", "SYM04USDT", "SYM06USDT"}
# Only tstrend (close-only fixture); must not raise the full-panel tripwire.
risk_managed_book_from_store(guarded, universe=universe, sleeves=["crypto_tstrend"])
inner.close()
assert guarded.read_panel_calls
for symbols, _feature in guarded.read_panel_calls:
assert set(symbols) <= universe
# --- CLI smoke (mocked store) -------------------------------------------------------------------
def test_cli_bybit_book_eval_prints_table(monkeypatch) -> None:
"""CLI smoke: a seeded in-memory store stands in for the live Bybit warehouse (no network); the command
prints the standalone-Sharpe rows + the RISK-MANAGED book table + the naive comparison."""
from typer.testing import CliRunner
import fxhnt.cli as cli
seeded = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_momentum(seeded, n_symbols=8, days=260)
_seed_carry(seeded, days=260)
_seed_positioning(seeded, days=260) # the 4th sleeve (long_ratio + close)
# turnover so --liquid admits the seeded symbols (median >= floor).
syms = ([f"SYM{i:02d}USDT" for i in range(8)] + ["AAAUSDT", "BBBUSDT", "CCCUSDT",
"XXXUSDT", "YYYUSDT", "ZZZUSDT"]
+ ["PAAUSDT", "PBBUSDT"])
_seed_turnover(seeded, syms, days=260)
monkeypatch.setattr(
"fxhnt.adapters.warehouse.timescale_feature_store.TimescaleFeatureStore",
lambda *a, **k: seeded, raising=False)
result = CliRunner().invoke(cli.app, ["bybit-book-eval", "--liquid", "--min-dollar-vol", "10000000"])
seeded.close()
assert result.exit_code == 0, result.output
out_lower = result.output.lower()
assert "risk-managed book" in out_lower
assert "Sharpe" in result.output
assert "naive" in out_lower
# the positioning sleeve appears + the correlation matrix is printed (the diversification view).
# unlock has no calendar in this smoke, so the active book is tstrend + xsfunding + positioning (3).
assert "positioning" in out_lower
assert "correlation" in out_lower
assert "sleeves=3" in result.output
def test_sleeve_display_names_cover_every_book_sleeve() -> None:
# `_SLEEVE_DISPLAY_NAMES` is the book breakdown's OWN display-name source, independent of
# STRATEGY_REGISTRY (see app.py `_deploy_individual_edges`) — every book sleeve must have an entry so a
# standalone forward-track retirement (e.g. crypto_tstrend, Phase 0b Task 4) can never blank one.
assert set(_SLEEVE_DISPLAY_NAMES) == set(_DEFAULT_BYBIT_SLEEVES)
def test_crypto_tstrend_display_name_is_human_not_the_raw_sleeve_id() -> None:
# The bug: crypto_tstrend's STRATEGY_REGISTRY entry was retired, so its book-breakdown row fell back to
# the raw sleeve id. The sleeve's own display name must never be the bare id.
assert _SLEEVE_DISPLAY_NAMES["crypto_tstrend"] != "crypto_tstrend"
assert _SLEEVE_DISPLAY_NAMES["crypto_tstrend"]