Add POSITIONING_COIN_GROSS_CAP constant (0.07) to bybit_forward_track.py with full validation comment. Wire the constant through bybit_book_persist.py's sleeve-return computation. Add test verifying the constant value. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
466 lines
22 KiB
Python
466 lines
22 KiB
Python
"""READ-ONLY evaluator for the Bybit POSITIONING edge (long/short account-ratio contrarian / momentum).
|
||
|
||
The signal is the Bybit long/short ACCOUNT ratio (retail positioning). The CONTRARIAN edge SHORTS coins
|
||
where retail is over-LONG (high buyRatio) and LONGS coins where retail is over-SHORT (low buyRatio), betting
|
||
on PRICE (close-to-close) reversion — cross-sectional, market-neutral. MOMENTUM is the exact sign-flip.
|
||
|
||
Tests assert:
|
||
* contrarian weights short high-ratio / long low-ratio, market-neutral (Σw≈0) + unit-gross (Σ|w|≈1);
|
||
* momentum = exact negation of contrarian;
|
||
* on a fixture where fading retail is profitable, the contrarian Sharpe > 0 and momentum < 0;
|
||
* the evaluator is READ-ONLY (a write-tripwire never trips);
|
||
* the verify report is cost-monotone, per-coin sums to total, slices per-year, and carries the
|
||
correlation fields vs ALL THREE existing edges (tstrend, unlock, xsfunding);
|
||
* the CLI smoke prints the metrics + verify diagnostics (mocked in-memory store, NO network).
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
|
||
from fxhnt.adapters.warehouse.timescale_feature_store import TimescaleFeatureStore
|
||
from fxhnt.application.bybit_positioning_eval import (
|
||
positioning_metrics_from_store,
|
||
positioning_weights,
|
||
verify_positioning_edge,
|
||
)
|
||
|
||
_DAY = 86_400
|
||
|
||
|
||
# --- capturability haircuts (capacity + tail-concentration) -------------------------------------
|
||
|
||
def test_robust_sigma_is_mad_based_and_spike_immune() -> None:
|
||
import statistics as _st
|
||
|
||
from fxhnt.application.bybit_positioning_eval import _robust_sigma
|
||
calm = [0.01, -0.01, 0.008, -0.012, 0.005, -0.007, 0.009, -0.006]
|
||
base = _robust_sigma(calm)
|
||
assert base > 0.0
|
||
# one huge spike: a plain std explodes, but the MAD-based robust σ barely moves (spike-immune — the whole
|
||
# point, so the tail-cap's own reference is not inflated by the days it exists to clip).
|
||
with_spike = _robust_sigma([*calm, 1.0])
|
||
assert with_spike < 0.25 * _st.pstdev([*calm, 1.0])
|
||
assert _robust_sigma([]) == 0.0
|
||
|
||
|
||
def test_capacity_haircut_caps_gain_only_at_scale() -> None:
|
||
from fxhnt.application.bybit_positioning_eval import positioning_returns_from_store
|
||
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
|
||
# 2 coins, STABLE contrarian weights: AAA over-long (short, w<0), BBB over-short (long, w>0). BBB pumps
|
||
# +100% on the last transition; BBB turnover is modest so a LARGE book cannot exit that gain at size.
|
||
bbb_close = [100.0, 100.0, 100.0, 200.0] # +100% booked on epoch-day 2 (its next-day return)
|
||
for d in range(4):
|
||
store.write_features("AAAUSDT", [(d * _DAY, {"long_ratio": 0.8, "close": 100.0, "turnover": 1e12})])
|
||
store.write_features("BBBUSDT", [(d * _DAY, {"long_ratio": 0.2, "close": bbb_close[d], "turnover": 2e6})])
|
||
raw = positioning_returns_from_store(store, cost_bps=0.0) # no haircut
|
||
small = positioning_returns_from_store(store, cost_bps=0.0, capacity_capital=1_000.0) # tiny book
|
||
large = positioning_returns_from_store(store, cost_bps=0.0, capacity_capital=100_000_000.0) # huge book
|
||
store.close()
|
||
spike = max(raw, key=lambda d: raw[d])
|
||
assert small[spike] == raw[spike] # tiny capital: position << turnover → fully capturable (cf=1)
|
||
assert large[spike] < 0.25 * raw[spike] # huge capital: the un-exitable gain is heavily capped
|
||
|
||
|
||
def _seed_tail(store: TimescaleFeatureStore, *, spike: float, n: int = 28) -> None:
|
||
"""2 coins with stable contrarian weights + OPPOSITE small daily moves (so the book has a real ~1% daily
|
||
scale for σ_book), and one coin's next-day return spiking on day n-2. Huge turnover → capacity never binds,
|
||
isolating the tail-cap."""
|
||
ac, bc = 100.0, 100.0
|
||
for d in range(n):
|
||
store.write_features("AAAUSDT", [(d * _DAY, {"long_ratio": 0.8, "close": ac, "turnover": 1e12})])
|
||
store.write_features("BBBUSDT", [(d * _DAY, {"long_ratio": 0.2, "close": bc, "turnover": 1e12})])
|
||
ac *= 1.0 + (0.01 if d % 2 else -0.01)
|
||
bc *= 1.0 + ((spike if d == n - 2 else (-0.01 if d % 2 else 0.01)))
|
||
|
||
|
||
def test_tail_cap_clips_dominating_gain_not_loss() -> None:
|
||
from fxhnt.application.bybit_positioning_eval import positioning_returns_from_store
|
||
# a dominating GAIN day (BBB long, +60%) is clipped toward K·σ_book...
|
||
sg = TimescaleFeatureStore("sqlite://", table="bybit_features")
|
||
_seed_tail(sg, spike=0.60)
|
||
raw = positioning_returns_from_store(sg, cost_bps=0.0)
|
||
capped = positioning_returns_from_store(sg, cost_bps=0.0, tail_cap_k=4.0)
|
||
sg.close()
|
||
gday = max(raw, key=lambda d: raw[d])
|
||
assert capped[gday] < 0.5 * raw[gday] # the fat-right-tail day is materially clipped
|
||
# ...but a dominating LOSS day (BBB −40%) is untouched (gains-only: you are stuck with losses).
|
||
sl = TimescaleFeatureStore("sqlite://", table="bybit_features")
|
||
_seed_tail(sl, spike=-0.40)
|
||
raw_l = positioning_returns_from_store(sl, cost_bps=0.0)
|
||
capped_l = positioning_returns_from_store(sl, cost_bps=0.0, tail_cap_k=4.0)
|
||
sl.close()
|
||
lday = min(raw_l, key=lambda d: raw_l[d])
|
||
assert capped_l[lday] == raw_l[lday] # gains-only: the loss day is NOT capped
|
||
|
||
|
||
class _WriteTripwireStore:
|
||
"""Read-only proxy: forwards reads, but ANY write/persist call trips an assertion."""
|
||
|
||
_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)
|
||
|
||
|
||
# --- 1. positioning_weights --------------------------------------------------------------------
|
||
|
||
def test_contrarian_weights_short_high_ratio_long_low_ratio() -> None:
|
||
# AAA over-LONG (high buyRatio → SHORT), CCC over-SHORT (low buyRatio → LONG).
|
||
ratio = {0: {"AAAUSDT": 0.70, "BBBUSDT": 0.50, "CCCUSDT": 0.30}}
|
||
w = positioning_weights(ratio)[0]
|
||
assert w["AAAUSDT"] < 0.0 # short the over-long crowd
|
||
assert w["CCCUSDT"] > 0.0 # long the over-short crowd
|
||
assert abs(w["BBBUSDT"]) < 1e-9 # the at-mean coin carries ~zero weight
|
||
|
||
|
||
def test_contrarian_weights_market_neutral_and_unit_gross() -> None:
|
||
ratio = {0: {"AAAUSDT": 0.80, "BBBUSDT": 0.60, "CCCUSDT": 0.40, "DDDUSDT": 0.20}}
|
||
w = positioning_weights(ratio)[0]
|
||
assert abs(sum(w.values())) < 1e-9
|
||
assert math.isclose(sum(abs(v) for v in w.values()), 1.0, abs_tol=1e-9)
|
||
|
||
|
||
def test_momentum_weights_are_exact_negation_of_contrarian() -> None:
|
||
ratio = {0: {"AAAUSDT": 0.80, "BBBUSDT": 0.60, "CCCUSDT": 0.40, "DDDUSDT": 0.20}}
|
||
w_con = positioning_weights(ratio, direction="contrarian")[0]
|
||
w_mom = positioning_weights(ratio, direction="momentum")[0]
|
||
assert set(w_con) == set(w_mom)
|
||
for c in w_con:
|
||
assert math.isclose(w_mom[c], -w_con[c], abs_tol=1e-12)
|
||
# momentum LONGS the over-long crowd (informed-flow continuation).
|
||
assert w_mom["AAAUSDT"] > 0.0
|
||
assert w_mom["DDDUSDT"] < 0.0
|
||
|
||
|
||
def test_default_direction_is_contrarian() -> None:
|
||
ratio = {0: {"AAAUSDT": 0.7, "BBBUSDT": 0.3}}
|
||
assert positioning_weights(ratio) == positioning_weights(ratio, direction="contrarian")
|
||
|
||
|
||
def test_weights_skip_day_with_fewer_than_two_coins() -> None:
|
||
ratio = {0: {"AAAUSDT": 0.7}, 1: {"AAAUSDT": 0.6, "BBBUSDT": 0.4}}
|
||
out = positioning_weights(ratio)
|
||
assert 0 not in out and 1 in out
|
||
|
||
|
||
def test_coin_gross_cap_clips_and_renormalizes() -> None:
|
||
# Realistic ~60-coin panel with one dominant over-long coin (LABUSDT-class). Uncapped, the dominant coin's
|
||
# |weight| exceeds the 0.07 cap; capped, it is pulled down close to the cap (single-pass clip+renormalize
|
||
# binds strongly when many coins carry weight). Verifies the cap substantially reduces concentration,
|
||
# preserves unit gross, and keeps the sign structure.
|
||
ratio = {0: {f"C{i}USDT": 0.30 + 0.0067 * i for i in range(60)}} # 60 coins spread from 0.30 to ~0.70
|
||
ratio[0]["DOMUSDT"] = 1.10 # the extreme over-long coin (fade -> short)
|
||
uncapped = positioning_weights(ratio)[0]
|
||
capped = positioning_weights(ratio, coin_gross_cap=0.07)[0]
|
||
assert abs(uncapped["DOMUSDT"]) > 0.07 # dominant coin exceeds the cap uncapped
|
||
assert abs(capped["DOMUSDT"]) < abs(uncapped["DOMUSDT"]) # cap pulls it down
|
||
assert abs(capped["DOMUSDT"]) < 0.085 # close to the 0.07 cap (single-pass slack)
|
||
assert abs(sum(abs(w) for w in capped.values()) - 1.0) < 1e-9 # still unit gross
|
||
assert capped["DOMUSDT"] < 0.0 # over-long crowd -> short (sign preserved)
|
||
|
||
|
||
def test_coin_gross_cap_none_is_byte_identical() -> None:
|
||
ratio = {0: {"AAAUSDT": 0.9, "BBBUSDT": 0.5, "CCCUSDT": 0.1}}
|
||
assert positioning_weights(ratio, coin_gross_cap=None) == positioning_weights(ratio)
|
||
|
||
|
||
# --- 2. positioning_metrics_from_store ---------------------------------------------------------
|
||
|
||
def _seed_contrarian(store: TimescaleFeatureStore, *, days: int = 80) -> None:
|
||
"""Seed `store` so FADING retail is profitable: when AAA is over-LONG (high buyRatio) its price FALLS
|
||
next day (the crowd is wrong) — so SHORTing it earns. Two coins, oscillating positioning, flat-ish drift.
|
||
The price return is close-to-close; the contrarian bet is on price reversion."""
|
||
px = {"AAAUSDT": 100.0, "BBBUSDT": 100.0}
|
||
for d in range(days):
|
||
crowd_long_a = (d % 2 == 0) # AAA over-long on even days, BBB on odd days
|
||
ra = 0.70 if crowd_long_a else 0.30
|
||
rb = 0.30 if crowd_long_a else 0.70
|
||
store.write_features("AAAUSDT", [(d * _DAY, {"long_ratio": ra, "close": px["AAAUSDT"]})])
|
||
store.write_features("BBBUSDT", [(d * _DAY, {"long_ratio": rb, "close": px["BBBUSDT"]})])
|
||
# The over-long coin's price FALLS next day (crowd wrong → reversion); the over-short coin rises.
|
||
px["AAAUSDT"] *= (1.0 - 0.004) if crowd_long_a else (1.0 + 0.004)
|
||
px["BBBUSDT"] *= (1.0 + 0.004) if crowd_long_a else (1.0 - 0.004)
|
||
# One extra day so the last weighted day has a realized next-day return.
|
||
d = days
|
||
store.write_features("AAAUSDT", [(d * _DAY, {"long_ratio": 0.5, "close": px["AAAUSDT"]})])
|
||
store.write_features("BBBUSDT", [(d * _DAY, {"long_ratio": 0.5, "close": px["BBBUSDT"]})])
|
||
|
||
|
||
def test_metrics_contrarian_positive_when_fading_retail_pays() -> None:
|
||
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
|
||
_seed_contrarian(store)
|
||
m = positioning_metrics_from_store(store, cost_bps=0.0, direction="contrarian")
|
||
store.close()
|
||
assert m["available"] is True
|
||
assert m["n_coins"] == 2
|
||
assert m["days"] > 0
|
||
assert math.isfinite(m["sharpe"])
|
||
assert m["total_return"] > 0.0 # fading the (wrong) crowd is profitable gross
|
||
|
||
|
||
def test_metrics_direction_flip_makes_momentum_lose() -> None:
|
||
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
|
||
_seed_contrarian(store)
|
||
m_con = positioning_metrics_from_store(store, cost_bps=0.0, direction="contrarian")
|
||
m_mom = positioning_metrics_from_store(store, cost_bps=0.0, direction="momentum")
|
||
store.close()
|
||
assert m_mom["sharpe"] < 0.0 < m_con["sharpe"]
|
||
|
||
|
||
def test_metrics_na_when_no_ratio_data() -> None:
|
||
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
|
||
store.write_features("AAAUSDT", [(0, {"close": 100.0})]) # close but no long_ratio
|
||
m = positioning_metrics_from_store(store)
|
||
store.close()
|
||
assert m["available"] is False
|
||
assert "reason" in m and m["reason"]
|
||
|
||
|
||
def test_metrics_is_read_only() -> None:
|
||
inner = TimescaleFeatureStore("sqlite://", table="bybit_features")
|
||
_seed_contrarian(inner)
|
||
store = _WriteTripwireStore(inner)
|
||
m = positioning_metrics_from_store(store, cost_bps=5.5) # must not trip the tripwire
|
||
inner.close()
|
||
assert m["available"] is True
|
||
|
||
|
||
# --- 3. verify diagnostics ---------------------------------------------------------------------
|
||
|
||
def _seed_momentum(store: TimescaleFeatureStore, *, days: int = 220) -> None:
|
||
"""Seed `store` so the MOMENTUM (informed-flow) direction is profitable across MULTIPLE years and broad
|
||
across 3 coins: the over-LONG coin keeps RISING (flow continues). Start near 2021-01-01 so a 220-day span
|
||
crosses calendar years for the per-year slicing."""
|
||
px = {"AAAUSDT": 100.0, "BBBUSDT": 100.0, "CCCUSDT": 100.0}
|
||
start_day = 18628 # 2021-01-01
|
||
for i in range(days):
|
||
d = start_day + i
|
||
crowd = ["AAAUSDT", "BBBUSDT", "CCCUSDT"][i % 3] # rotate the over-long coin (broad attribution)
|
||
for c in ("AAAUSDT", "BBBUSDT", "CCCUSDT"):
|
||
r = 0.70 if c == crowd else 0.40
|
||
store.write_features(c, [(d * _DAY, {"long_ratio": r, "close": px[c]})])
|
||
# CONTINUATION: the over-long perp keeps rising; the others drift down.
|
||
for c in ("AAAUSDT", "BBBUSDT", "CCCUSDT"):
|
||
px[c] *= (1.0 + 0.005) if c == crowd else (1.0 - 0.0025)
|
||
d = start_day + days
|
||
for c in ("AAAUSDT", "BBBUSDT", "CCCUSDT"):
|
||
store.write_features(c, [(d * _DAY, {"long_ratio": 0.5, "close": px[c]})])
|
||
|
||
|
||
def test_verify_momentum_beats_contrarian_on_continuation_fixture() -> None:
|
||
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
|
||
_seed_momentum(store)
|
||
rep = verify_positioning_edge(store, cost_bps=5.5)
|
||
store.close()
|
||
assert rep["available"] is True
|
||
mom = rep["net_cost"]["momentum"][5.5]
|
||
con = rep["net_cost"]["contrarian"][5.5]
|
||
assert mom["sharpe"] > 0.0 > con["sharpe"]
|
||
|
||
|
||
def test_verify_cost_sensitivity_is_monotone() -> None:
|
||
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
|
||
_seed_momentum(store)
|
||
rep = verify_positioning_edge(store, cost_bps=5.5, cost_grid=(5.5, 11.0, 22.0))
|
||
store.close()
|
||
mom = rep["net_cost"]["momentum"]
|
||
assert mom[5.5]["sharpe"] >= mom[11.0]["sharpe"] >= mom[22.0]["sharpe"]
|
||
|
||
|
||
def test_verify_per_coin_attribution_sums_to_total() -> None:
|
||
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
|
||
_seed_momentum(store)
|
||
rep = verify_positioning_edge(store, cost_bps=0.0)
|
||
store.close()
|
||
attr = rep["per_coin"]["momentum"]
|
||
total = rep["gross_total"]["momentum"]
|
||
assert math.isclose(sum(attr.values()), total, rel_tol=1e-9, abs_tol=1e-12)
|
||
assert len(attr) == 3
|
||
assert 0.0 <= rep["top5_share"]["momentum"] <= 1.0 + 1e-9
|
||
|
||
|
||
def test_verify_per_year_sharpes_on_right_slices() -> None:
|
||
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
|
||
_seed_momentum(store, days=400) # spans 2021 and 2022
|
||
rep = verify_positioning_edge(store, cost_bps=5.5)
|
||
store.close()
|
||
per_year = rep["per_year"]["momentum"]
|
||
assert "2021" in per_year and "2022" in per_year
|
||
total_days = rep["net_cost"]["momentum"][5.5]["days"]
|
||
assert sum(y["days"] for y in per_year.values()) == total_days
|
||
|
||
|
||
def test_verify_reports_turnover() -> None:
|
||
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
|
||
_seed_momentum(store)
|
||
rep = verify_positioning_edge(store, cost_bps=5.5)
|
||
store.close()
|
||
assert rep["avg_turnover"]["momentum"] > 0.0
|
||
assert rep["avg_turnover"]["contrarian"] > 0.0
|
||
|
||
|
||
def test_verify_reports_correlation_with_all_three_edges() -> None:
|
||
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
|
||
_seed_momentum(store)
|
||
rep = verify_positioning_edge(store, cost_bps=5.5)
|
||
store.close()
|
||
# The corr block carries a key per direction, each mapping each existing edge -> corr-or-None.
|
||
for d in ("contrarian", "momentum"):
|
||
corrs = rep["corr_edges"][d]
|
||
for edge in ("tstrend", "unlock", "xsfunding"):
|
||
assert edge in corrs
|
||
v = corrs[edge]
|
||
assert v is None or (-1.0 - 1e-9 <= v <= 1.0 + 1e-9)
|
||
|
||
|
||
def test_verify_is_read_only() -> None:
|
||
inner = TimescaleFeatureStore("sqlite://", table="bybit_features")
|
||
_seed_momentum(inner)
|
||
store = _WriteTripwireStore(inner)
|
||
rep = verify_positioning_edge(store, cost_bps=5.5)
|
||
inner.close()
|
||
assert rep["available"] is True
|
||
|
||
|
||
# --- 4. CLI smoke ------------------------------------------------------------------------------
|
||
|
||
def test_cli_positioning_eval_prints_metrics(monkeypatch) -> None:
|
||
from typer.testing import CliRunner
|
||
|
||
import fxhnt.cli as cli
|
||
|
||
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
|
||
_seed_contrarian(store)
|
||
|
||
monkeypatch.setattr(
|
||
"fxhnt.adapters.warehouse.timescale_feature_store.TimescaleFeatureStore",
|
||
lambda *_a, **_k: store, raising=False)
|
||
|
||
result = CliRunner().invoke(cli.app, ["bybit-positioning-eval", "--all"])
|
||
store.close()
|
||
assert result.exit_code == 0, result.output
|
||
out = result.output.lower()
|
||
assert "positioning" in out
|
||
assert "contrarian" in out
|
||
|
||
|
||
def test_cli_positioning_eval_verify_prints_diagnostics(monkeypatch) -> None:
|
||
from typer.testing import CliRunner
|
||
|
||
import fxhnt.cli as cli
|
||
|
||
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
|
||
_seed_momentum(store)
|
||
|
||
monkeypatch.setattr(
|
||
"fxhnt.adapters.warehouse.timescale_feature_store.TimescaleFeatureStore",
|
||
lambda *_a, **_k: store, raising=False)
|
||
|
||
result = CliRunner().invoke(cli.app, ["bybit-positioning-eval", "--all", "--verify"])
|
||
store.close()
|
||
assert result.exit_code == 0, result.output
|
||
out = result.output.lower()
|
||
assert "contrarian" in out and "momentum" in out
|
||
assert "per-coin" in out or "per coin" in out
|
||
assert "turnover" in out
|
||
assert "tstrend" in out and "unlock" in out and "xsfunding" in out
|
||
|
||
|
||
# --- 5. coin_gross_cap constant and parameter threading through book_breakdown + returns_from_store ------
|
||
|
||
def test_positioning_coin_gross_cap_constant_is_007() -> None:
|
||
from fxhnt.application.bybit_forward_track import POSITIONING_COIN_GROSS_CAP
|
||
assert POSITIONING_COIN_GROSS_CAP == 0.07
|
||
|
||
|
||
def test_coin_gross_cap_reduces_single_coin_loss_in_book() -> None:
|
||
"""Shock store: one coin (retail over-short at extreme long_ratio ~0.05) craters ~50% the next day.
|
||
Uncapped, that coin's loss dominates the book loss. Capped at 0.07, its weight is bounded,
|
||
reducing (making less bad) the worst-day loss."""
|
||
from fxhnt.application.bybit_positioning_eval import positioning_returns_from_store
|
||
|
||
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
|
||
|
||
# Build shock store: 4 coins, 1 at extreme long_ratio (retail over-short), crash next day
|
||
# Days 0-2: normal, Day 2->3: crash day for LABUSDT
|
||
px = {"LABUSDT": 100.0, "AAAUSDT": 100.0, "BBBUSDT": 100.0, "CCCUSDT": 100.0}
|
||
for d in range(4):
|
||
# LABUSDT is over-short (low long_ratio ~0.05), others are near median ~0.5
|
||
store.write_features("LABUSDT", [(d * _DAY, {"long_ratio": 0.05, "close": px["LABUSDT"]})])
|
||
store.write_features("AAAUSDT", [(d * _DAY, {"long_ratio": 0.50, "close": px["AAAUSDT"]})])
|
||
store.write_features("BBBUSDT", [(d * _DAY, {"long_ratio": 0.48, "close": px["BBBUSDT"]})])
|
||
store.write_features("CCCUSDT", [(d * _DAY, {"long_ratio": 0.52, "close": px["CCCUSDT"]})])
|
||
# Normal prices on days 0, 1, 2
|
||
if d < 2:
|
||
px["LABUSDT"] *= 1.00
|
||
px["AAAUSDT"] *= 1.00
|
||
px["BBBUSDT"] *= 1.00
|
||
px["CCCUSDT"] *= 1.00
|
||
# LABUSDT crashes ~50% on the transition day 2->3 (its long_ratio on day 2 forms the weight)
|
||
elif d == 2:
|
||
px["LABUSDT"] *= 0.50 # crash next day
|
||
px["AAAUSDT"] *= 1.00
|
||
px["BBBUSDT"] *= 1.00
|
||
px["CCCUSDT"] *= 1.00
|
||
# (day 3: no more transitions)
|
||
|
||
uncapped = positioning_returns_from_store(store, cost_bps=0.0)
|
||
capped = positioning_returns_from_store(store, cost_bps=0.0, coin_gross_cap=0.07)
|
||
store.close()
|
||
|
||
# The store MUST produce a book (empty dicts would mean the fixture is broken — assert loudly, never
|
||
# skip the real assertion silently).
|
||
assert uncapped and capped, "shock store produced no positioning returns — fixture broken"
|
||
# Worst day = the crash day (day 2: weight formed, day 2->3 loss realized). The cap bounds the per-coin
|
||
# weight, making the worst-day loss less bad (less negative).
|
||
shock_day = min(uncapped, key=lambda d: uncapped[d])
|
||
assert capped[shock_day] > uncapped[shock_day], \
|
||
f"cap should improve worst day: uncapped={uncapped[shock_day]}, capped={capped[shock_day]}"
|
||
|
||
|
||
def test_sleeve_returns_positioning_forwards_coin_gross_cap() -> None:
|
||
"""sleeve_returns_from_store forwards coin_gross_cap to the positioning edge only."""
|
||
from fxhnt.application.bybit_book_eval import sleeve_returns_from_store
|
||
|
||
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
|
||
|
||
# Build shock store: 4 coins, 1 at extreme long_ratio (retail over-short), crash next day
|
||
px = {"LABUSDT": 100.0, "AAAUSDT": 100.0, "BBBUSDT": 100.0, "CCCUSDT": 100.0}
|
||
for d in range(4):
|
||
store.write_features("LABUSDT", [(d * _DAY, {"long_ratio": 0.05, "close": px["LABUSDT"]})])
|
||
store.write_features("AAAUSDT", [(d * _DAY, {"long_ratio": 0.50, "close": px["AAAUSDT"]})])
|
||
store.write_features("BBBUSDT", [(d * _DAY, {"long_ratio": 0.48, "close": px["BBBUSDT"]})])
|
||
store.write_features("CCCUSDT", [(d * _DAY, {"long_ratio": 0.52, "close": px["CCCUSDT"]})])
|
||
if d < 2:
|
||
px["LABUSDT"] *= 1.00
|
||
px["AAAUSDT"] *= 1.00
|
||
px["BBBUSDT"] *= 1.00
|
||
px["CCCUSDT"] *= 1.00
|
||
elif d == 2:
|
||
px["LABUSDT"] *= 0.50
|
||
px["AAAUSDT"] *= 1.00
|
||
px["BBBUSDT"] *= 1.00
|
||
px["CCCUSDT"] *= 1.00
|
||
|
||
uncapped = sleeve_returns_from_store(store, "positioning", cost_bps=0.0)
|
||
capped = sleeve_returns_from_store(store, "positioning", cost_bps=0.0, coin_gross_cap=0.07)
|
||
store.close()
|
||
|
||
# The store MUST produce a book (empty dicts would mean the seam or fixture is broken — assert loudly,
|
||
# never skip the real assertion silently).
|
||
assert uncapped and capped, "shock store produced no positioning returns via the sleeve seam — broken"
|
||
# cap forwarded through the sleeve seam -> worst day STRICTLY improved (the deterministic shock always
|
||
# yields a strict improvement: −0.25 -> −0.125). Strict `>` so a silent no-op (cap not forwarded) fails.
|
||
shock_day = min(uncapped, key=lambda d: uncapped[d])
|
||
assert capped[shock_day] > uncapped[shock_day], \
|
||
f"cap should improve worst day: uncapped={uncapped[shock_day]}, capped={capped[shock_day]}"
|