Files
fxhnt/tests/integration/test_paper_backfill_cli.py
jgrusewski dbf428f485 refactor(paper): revert xsfunding to live-only (2-edge replay) per foundation audit
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>
2026-06-22 17:04:49 +02:00

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"