Remove "xsfunding" from _REPLAYABLE_SLEEVES so _paper_backfill_sleeves no longer builds the carry sleeve — the replay reverts to a 2-edge directional book (crypto_tstrend + unlock) plus stablecoin rotation. Drop the now-dead _xsfunding builder and the delta-neutral funding-panel construction from the paper-backfill command (no carry sleeve consumes it); the carry helpers (XsFundingReplay, sleeve_daily_carry, delta_neutral_return_panel, warehouse_funding_panel) and backfill's funding_by_date param are retained (unit-tested) for the live xsfunding_nav forward-track. Update the command echo to "xsfunding excluded — live-only". Rationale (foundation audit): carry P&L isn't in the equity basis, its tail is unmodellable from daily/survivor data, and removing it resolves the gross-2 stacking (directional sleeves are ~gross-1). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
147 lines
6.2 KiB
Python
147 lines
6.2 KiB
Python
import datetime as dt
|
|
|
|
import duckdb
|
|
from typer.testing import CliRunner
|
|
|
|
import fxhnt.cli as cli
|
|
from fxhnt.adapters.persistence.paper_repo import PaperRepo
|
|
|
|
runner = CliRunner()
|
|
|
|
_EPOCH = dt.date(1970, 1, 1)
|
|
|
|
|
|
class _FakeSleeve:
|
|
def current_weights(self, as_of):
|
|
return {"BTCUSDT": 1.0}
|
|
|
|
|
|
def test_paper_backfill_cli(tmp_path, monkeypatch):
|
|
dsn = f"sqlite:///{tmp_path / 'cockpit.db'}"
|
|
|
|
monkeypatch.setattr(cli, "get_settings", lambda: _settings(dsn))
|
|
# Stub the heavy live-sleeve assembly (equal-weight sizing is internal to backfill_paper_book now).
|
|
monkeypatch.setattr(cli, "_paper_backfill_sleeves",
|
|
lambda s, panel: {"crypto_tstrend": _FakeSleeve()})
|
|
# Stub the warehouse panel read to a couple of crypto symbols on a small date axis.
|
|
axis = [(dt.date.fromisoformat(d) - _EPOCH).days for d in ("2026-06-19", "2026-06-20", "2026-06-21")]
|
|
panel = {"BTCUSDT": {day: 100.0 for day in axis}, "ETHUSDT": {day: 50.0 for day in axis}}
|
|
monkeypatch.setattr(cli, "_paper_backfill_warehouse_panel", lambda s, days: panel)
|
|
|
|
result = runner.invoke(cli.app, ["paper-backfill", "--days", "3"])
|
|
assert result.exit_code == 0, result.output
|
|
|
|
repo = PaperRepo(dsn)
|
|
hist = repo.nav_history()
|
|
assert hist # non-empty equity series
|
|
assert len(hist) >= 1
|
|
|
|
|
|
def _seed_warehouse(path: str, panel: dict[str, dict[int, float]]) -> None:
|
|
"""Seed a real DuckDB `features` table (symbol, ts, feature, value) with daily crypto closes."""
|
|
con = duckdb.connect(path)
|
|
con.execute("CREATE TABLE IF NOT EXISTS features (symbol VARCHAR, ts BIGINT, feature VARCHAR, value DOUBLE)")
|
|
rows = [(sym, day * 86_400, "close", float(c)) for sym, series in panel.items() for day, c in series.items()]
|
|
con.executemany("INSERT INTO features VALUES (?, ?, ?, ?)", rows)
|
|
con.close()
|
|
|
|
|
|
def test_paper_backfill_warehouse_driven(tmp_path, monkeypatch):
|
|
"""The universe + date axis come from the warehouse `features` table (consistent with the nightly
|
|
book), NOT from sleeve.current_weights sampling — and the real ts-trend sleeve, wired to the same
|
|
warehouse panel, surfaces non-empty weights so nav_history is populated over the warehouse axis."""
|
|
dsn = f"sqlite:///{tmp_path / 'cockpit.db'}"
|
|
wh = str(tmp_path / "warehouse.duckdb")
|
|
|
|
# Two crypto symbols with >130 daily closes on a clean uptrend so ts-trend goes long/flat.
|
|
base_day = (dt.date(2026, 1, 1) - _EPOCH).days
|
|
n = 140
|
|
panel = {
|
|
"BTCUSDT": {base_day + i: 100.0 * (1.003 ** i) for i in range(n)},
|
|
"ETHUSDT": {base_day + i: 50.0 * (1.002 ** i) for i in range(n)},
|
|
}
|
|
_seed_warehouse(wh, panel)
|
|
|
|
expected_axis = sorted((_EPOCH + dt.timedelta(days=base_day + i)).isoformat() for i in range(n))
|
|
|
|
monkeypatch.setattr(cli, "get_settings", lambda: _settings(dsn, warehouse_path=wh))
|
|
|
|
# cap window wide enough to keep the whole seeded axis
|
|
result = runner.invoke(cli.app, ["paper-backfill", "--days", "400"])
|
|
assert result.exit_code == 0, result.output
|
|
|
|
repo = PaperRepo(dsn)
|
|
hist = repo.nav_history()
|
|
assert hist, "nav_history must be non-empty (warehouse-driven backfill)"
|
|
nav_dates = {p.run_date for p in hist}
|
|
# the nav dates come from the warehouse axis, not from any current_weights probe
|
|
assert nav_dates.issubset(set(expected_axis))
|
|
assert max(nav_dates) == expected_axis[-1]
|
|
|
|
# ts-trend actually surfaced weights → at least one date holds a position
|
|
assert any(repo.positions_at(d) for d in sorted(nav_dates))
|
|
|
|
# xsfunding is live-only (NOT in the replay) per the foundation audit (2-edge directional book)
|
|
assert "xsfunding" not in cli._REPLAYABLE_SLEEVES
|
|
|
|
|
|
def _seed_funding(path: str, panel: dict[str, dict[int, float]]) -> None:
|
|
"""Append daily 'funding' rows to an existing DuckDB `features` table (same shape as closes)."""
|
|
con = duckdb.connect(path)
|
|
con.execute("CREATE TABLE IF NOT EXISTS features (symbol VARCHAR, ts BIGINT, feature VARCHAR, value DOUBLE)")
|
|
rows = [(sym, day * 86_400, "funding", float(v)) for sym, series in panel.items() for day, v in series.items()]
|
|
con.executemany("INSERT INTO features VALUES (?, ?, ?, ?)", rows)
|
|
con.close()
|
|
|
|
|
|
def test_paper_backfill_excludes_xsfunding_even_when_funding_present(tmp_path, monkeypatch):
|
|
"""xsfunding is live-only (carry reverted from the replay per the foundation audit): even with real
|
|
funding seeded in the warehouse, _paper_backfill_sleeves does NOT build the xsfunding carry sleeve.
|
|
The directional sleeves (crypto_tstrend) are still built."""
|
|
wh = str(tmp_path / "warehouse.duckdb")
|
|
base_day = (dt.date(2026, 1, 1) - _EPOCH).days
|
|
n = 140
|
|
closes = {
|
|
"BTCUSDT": {base_day + i: 100.0 * (1.003 ** i) for i in range(n)},
|
|
"ETHUSDT": {base_day + i: 50.0 * (1.002 ** i) for i in range(n)},
|
|
}
|
|
funding = {
|
|
"BTCUSDT": {base_day + i: 0.0001 for i in range(n)},
|
|
"ETHUSDT": {base_day + i: -0.0002 for i in range(n)},
|
|
}
|
|
_seed_warehouse(wh, closes)
|
|
_seed_funding(wh, funding)
|
|
|
|
s = _settings(f"sqlite:///{tmp_path / 'cockpit.db'}", warehouse_path=wh)
|
|
sleeves = cli._paper_backfill_sleeves(s, closes)
|
|
assert "xsfunding" not in sleeves
|
|
assert "crypto_tstrend" in sleeves
|
|
|
|
|
|
def test_funding_by_date_inversion():
|
|
"""The command's funding panel -> {iso_date: {symbol: funding}} inversion is correct."""
|
|
base_day = (dt.date(2026, 6, 20) - _EPOCH).days
|
|
funding_panel = {
|
|
"BTCUSDT": {base_day: 0.0001, base_day + 1: 0.0003},
|
|
"ETHUSDT": {base_day + 1: -0.0002},
|
|
}
|
|
funding_by_date: dict[str, dict[str, float]] = {}
|
|
for fsym, fseries in funding_panel.items():
|
|
for fday, fval in fseries.items():
|
|
fiso = (_EPOCH + dt.timedelta(days=int(fday))).isoformat()
|
|
funding_by_date.setdefault(fiso, {})[fsym] = float(fval)
|
|
|
|
assert funding_by_date == {
|
|
"2026-06-20": {"BTCUSDT": 0.0001},
|
|
"2026-06-21": {"BTCUSDT": 0.0003, "ETHUSDT": -0.0002},
|
|
}
|
|
|
|
|
|
class _settings:
|
|
def __init__(self, dsn, warehouse_path=":memory:"):
|
|
self.operational_dsn = dsn
|
|
self.paper_capital = 100_000.0
|
|
self.paper_enabled = True
|
|
self.warehouse_path = warehouse_path
|
|
self.crypto_pit_dir = "/nonexistent/crypto_pit"
|