The 30-day paper backfill spent ~65% of runtime on per-date DB round-trips (prior-state reads + per-table/per-sleeve writes), not on the book/overlay math. Two optimizations, both bit-identical on the persisted rows: 1. Incremental refresh (default). `paper-backfill` now computes only the dates strictly after max(paper_nav.run_date) — the routine dev-loop refresh skips already-done history. `--rebuild` forces the full --days window. The per-date computation depends only on strictly-prior state, so resuming from the last persisted date reproduces exactly what a full rebuild would for those dates. 2. Batched I/O. The unchanged compute path now runs against a write-buffering proxy (_BufferingPaperRepo): per-date writes are buffered and flushed in chunks (one multi-row batch per table, per-chunk commit → resumable), and prior-state reads (positions_before / nav_equity_before / positions_at) are served buffer-first so the not-yet-committed rows stay causal. New bulk_* repo methods do the chunked upserts. For a 90-date/2-sleeve run this cuts connection checkouts 811→11 and commits 539→6. Tests: chunked-writes == single-chunk (bit-identical), incremental == full rebuild, and a write-call counter proving zero per-date writes (all batched). Existing 24 backfill golden/idempotency tests pass unchanged. Also fixed pre-existing lint in the touched files (E501/E702/F401/SIM114). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
943 lines
49 KiB
Python
943 lines
49 KiB
Python
import datetime as dt
|
||
|
||
from fxhnt.adapters.persistence.paper_repo import PaperRepo
|
||
from fxhnt.application.paper_backfill import backfill_paper_book
|
||
|
||
|
||
class _Sleeve: # as_of-aware fake
|
||
def current_weights(self, as_of):
|
||
return {"BTCUSDT": 1.0}
|
||
|
||
|
||
class _BadSleeve: # always raises -> skipped per-date
|
||
def current_weights(self, as_of):
|
||
raise RuntimeError("no data")
|
||
|
||
|
||
class _EmptySleeve: # empty book -> skipped
|
||
def current_weights(self, as_of):
|
||
return {}
|
||
|
||
|
||
class _ExitMidRangeSleeve: # holds BTC, then fully exits (empty) from the exit date on
|
||
def __init__(self, exit_date):
|
||
self._exit_date = exit_date
|
||
|
||
def current_weights(self, as_of):
|
||
return {} if as_of >= self._exit_date else {"BTCUSDT": 1.0}
|
||
|
||
|
||
def test_backfill_builds_nav_series():
|
||
repo = PaperRepo("sqlite://")
|
||
repo.migrate()
|
||
dates = ["2026-06-19", "2026-06-20", "2026-06-21"]
|
||
closes_by_date = {"2026-06-19": {"BTCUSDT": 100.0},
|
||
"2026-06-20": {"BTCUSDT": 110.0},
|
||
"2026-06-21": {"BTCUSDT": 121.0}}
|
||
n = backfill_paper_book(
|
||
repo, capital=100_000.0,
|
||
sleeves={"crypto_tstrend": _Sleeve()},
|
||
dates=dates, closes_by_date=closes_by_date, at=dt.datetime(2026, 6, 21, tzinfo=dt.UTC))
|
||
hist = repo.nav_history()
|
||
assert [h.run_date for h in hist] == dates # one nav row per date, ascending
|
||
assert n == len(dates)
|
||
|
||
|
||
def test_backfill_skips_bad_and_empty_sleeves():
|
||
repo = PaperRepo("sqlite://")
|
||
repo.migrate()
|
||
dates = ["2026-06-20", "2026-06-21"]
|
||
closes_by_date = {"2026-06-20": {"BTCUSDT": 100.0}, "2026-06-21": {"BTCUSDT": 110.0}}
|
||
n = backfill_paper_book(
|
||
repo, capital=100_000.0,
|
||
sleeves={"good": _Sleeve(), "bad": _BadSleeve(), "empty": _EmptySleeve()},
|
||
dates=dates, closes_by_date=closes_by_date, at=dt.datetime(2026, 6, 21, tzinfo=dt.UTC))
|
||
assert n == len(dates)
|
||
# only the good sleeve's symbol survives
|
||
pos = repo.positions_at("2026-06-21")
|
||
assert {p.sleeve for p in pos} == {"good"}
|
||
|
||
|
||
def test_backfill_equal_weights_active_sleeves_size_positions():
|
||
# Two active sleeves on a single date -> each gets 1/N = 0.5 of capital, so positions are SIZED
|
||
# (non-zero notional) regardless of the incoming allocator sleeve_weights (which is {} in prod).
|
||
repo = PaperRepo("sqlite://")
|
||
repo.migrate()
|
||
dates = ["2026-06-21"]
|
||
closes_by_date = {"2026-06-21": {"BTCUSDT": 100.0, "ETHUSDT": 50.0}}
|
||
n = backfill_paper_book(
|
||
repo, capital=100_000.0,
|
||
sleeves={"a": _Sleeve(), "b": _SleeveEth()},
|
||
dates=dates, closes_by_date=closes_by_date,
|
||
at=dt.datetime(2026, 6, 21, tzinfo=dt.UTC))
|
||
assert n == len(dates)
|
||
pos = {p.sleeve: p for p in repo.positions_at("2026-06-21")}
|
||
assert set(pos) == {"a", "b"}
|
||
# each sleeve gets 0.5 * 100_000 notional; BTC @ 100 -> 500 qty, ETH @ 50 -> 1000 qty
|
||
assert pos["a"].qty == 0.5 * 100_000.0 / 100.0
|
||
assert pos["b"].qty == 0.5 * 100_000.0 / 50.0
|
||
|
||
|
||
class _SleeveEth:
|
||
def current_weights(self, as_of):
|
||
return {"ETHUSDT": 1.0}
|
||
|
||
|
||
def test_backfill_rerun_is_idempotent_and_equity_reproduces():
|
||
# Open BTC on 06-19 at 100, hold flat (price 100, so 06-20 is a no-op mark) through 06-20, then fully
|
||
# exit on 06-21 at 121. Under the compounding mark-to-market model the held book's appreciation flows
|
||
# into EQUITY (prior book marked to today's close), not into a realized sliver: the prior book on 06-21
|
||
# is qty 1000 @ entry 100, marked to 121 -> +21_000 gain -> equity compounds 100_000 -> 121_000.
|
||
dates = ["2026-06-19", "2026-06-20", "2026-06-21"]
|
||
closes_by_date = {"2026-06-19": {"BTCUSDT": 100.0},
|
||
"2026-06-20": {"BTCUSDT": 100.0},
|
||
"2026-06-21": {"BTCUSDT": 121.0}}
|
||
|
||
def run(repo):
|
||
return backfill_paper_book(
|
||
repo, capital=100_000.0,
|
||
sleeves={"crypto_tstrend": _ExitMidRangeSleeve("2026-06-21")},
|
||
dates=dates, closes_by_date=closes_by_date,
|
||
at=dt.datetime(2026, 6, 21, tzinfo=dt.UTC))
|
||
|
||
# qty bought on 06-19 = 100_000 / 100 = 1000; held-book MTM gain on 06-21 = 1000 * (121 - 100) = 21_000.
|
||
gain = 1000.0 * (121.0 - 100.0)
|
||
|
||
repo = PaperRepo("sqlite://")
|
||
repo.migrate()
|
||
run(repo)
|
||
first = repo.nav_history()
|
||
equity_by_date = {h.run_date: h.equity for h in first}
|
||
# Equity is flat (= capital) while the position is held flat, then compounds by the held book's MTM
|
||
# gain on the exit-mark date — NOT a realized total smeared onto every row.
|
||
assert equity_by_date["2026-06-19"] == 100_000.0
|
||
assert equity_by_date["2026-06-20"] == 100_000.0
|
||
assert abs(equity_by_date["2026-06-21"] - (100_000.0 + gain)) < 1e-6
|
||
# Equity is monotone non-decreasing across this all-gains scenario.
|
||
series = [h.equity for h in first]
|
||
assert series == sorted(series)
|
||
|
||
# Re-run the identical backfill ascending: nav_history (run_date, equity, realized, unrealized per row)
|
||
# must be IDENTICAL — the compounding accounting is idempotent (strictly-prior reads only).
|
||
run(repo)
|
||
second = repo.nav_history()
|
||
assert [(h.run_date, h.equity, h.realized, h.unrealized) for h in first] \
|
||
== [(h.run_date, h.equity, h.realized, h.unrealized) for h in second]
|
||
|
||
|
||
def test_backfill_uses_isv_adaptive_weights():
|
||
import datetime as dt
|
||
from fxhnt.adapters.persistence.paper_repo import PaperRepo
|
||
from fxhnt.application.paper_backfill import backfill_paper_book
|
||
|
||
class Sleeve:
|
||
def __init__(self, sym): self.sym = sym
|
||
def current_weights(self, d): return {self.sym: 1.0} # always active, one name
|
||
|
||
repo = PaperRepo("sqlite://"); repo.migrate()
|
||
dates = [f"2026-01-{i:02d}" for i in range(1, 29)] # 28 days
|
||
closes_by_date = {}
|
||
for i, d in enumerate(dates):
|
||
lo = 100.0 + 0.05 * i # low-vol smooth drift
|
||
hi = 100.0 + (8.0 if i % 2 else -8.0) # high-vol oscillation
|
||
closes_by_date[d] = {"LO": lo, "HI": hi}
|
||
n = backfill_paper_book(repo, capital=100_000.0,
|
||
sleeves={"lo": Sleeve("LO"), "hi": Sleeve("HI")},
|
||
dates=dates, closes_by_date=closes_by_date,
|
||
at=dt.datetime(2026, 2, 1, tzinfo=dt.UTC))
|
||
assert n == len(dates)
|
||
rets = repo.sleeve_returns(before="2026-02-01")
|
||
assert "lo" in rets and "hi" in rets and len(rets["lo"]) > 10
|
||
assert repo.weight_for("lo") > repo.weight_for("hi") # low-vol sleeve weighted up
|
||
|
||
|
||
def test_backfill_killswitch_flattens_then_reenters():
|
||
import datetime as dt
|
||
from fxhnt.adapters.persistence.paper_repo import PaperRepo
|
||
from fxhnt.application.paper_backfill import backfill_paper_book
|
||
|
||
class Sleeve:
|
||
def __init__(self, sym): self.sym = sym
|
||
def current_weights(self, d): return {self.sym: 1.0}
|
||
|
||
repo = PaperRepo("sqlite://"); repo.migrate()
|
||
dates = [f"2026-{(1+(i//28)):02d}-{(1+(i%28)):02d}" for i in range(112)] # ~4 months
|
||
closes = {}
|
||
px = 100.0
|
||
for i, d in enumerate(dates):
|
||
if i < 40: px *= 1.001 # calm up
|
||
elif i < 70: px *= 0.985 # deep crash -> dd>15% -> kill fires
|
||
else: px *= 1.01 # recovery -> eventual re-enter
|
||
closes[d] = {"X": px}
|
||
n = backfill_paper_book(repo, capital=100_000.0, sleeves={"s": Sleeve("X")},
|
||
dates=dates, closes_by_date=closes, at=dt.datetime(2026, 6, 1, tzinfo=dt.UTC))
|
||
assert n == len(dates)
|
||
# shadow signal is continuous across the whole window (no kill gaps)
|
||
assert len(repo.sleeve_returns(before=dates[-1]).get("s", {})) > 80
|
||
# at least one date during the crash has a FLAT throttled book (killed) ...
|
||
flat = [d for d in dates[55:75] if repo.positions_at(d) == []]
|
||
assert flat, "expected a killed/flat throttled book during the crash"
|
||
# ... while the shadow book for those dates is non-empty (signal survives)
|
||
assert all(repo.shadow_positions_before(d) for d in flat)
|
||
|
||
|
||
class _CountingRepo:
|
||
"""Wraps a real PaperRepo, counting sleeve_returns calls; delegates everything else."""
|
||
def __init__(self, inner):
|
||
self._inner = inner
|
||
self.sleeve_returns_calls = 0
|
||
|
||
def sleeve_returns(self, *, before):
|
||
self.sleeve_returns_calls += 1
|
||
return self._inner.sleeve_returns(before=before)
|
||
|
||
def __getattr__(self, name):
|
||
return getattr(self._inner, name)
|
||
|
||
|
||
def test_backfill_queries_sleeve_returns_once():
|
||
# Perf invariant: the in-memory accumulator means sleeve_returns is queried EXACTLY ONCE (the seed
|
||
# before the loop), not once per date — O(N) DB round-trips, not O(N^2).
|
||
inner = PaperRepo("sqlite://")
|
||
inner.migrate()
|
||
repo = _CountingRepo(inner)
|
||
dates = ["2026-06-19", "2026-06-20", "2026-06-21", "2026-06-22"]
|
||
closes_by_date = {d: {"BTCUSDT": 100.0 + i} for i, d in enumerate(dates)}
|
||
n = backfill_paper_book(
|
||
repo, capital=100_000.0,
|
||
sleeves={"crypto_tstrend": _Sleeve()},
|
||
dates=dates, closes_by_date=closes_by_date, at=dt.datetime(2026, 6, 22, tzinfo=dt.UTC))
|
||
assert n == len(dates)
|
||
assert repo.sleeve_returns_calls == 1, (
|
||
f"expected sleeve_returns queried once (seed), got {repo.sleeve_returns_calls}")
|
||
|
||
|
||
def test_backfill_inmemory_acc_matches_per_date_query():
|
||
# Behaviour-unchanged: the in-memory accumulator must produce identical persisted sleeve returns,
|
||
# weights and nav to what a per-date DB requery would (the strictly-before causal window). Two
|
||
# active sleeves with different vol over a multi-date range exercise the overlay's history use.
|
||
repo = PaperRepo("sqlite://")
|
||
repo.migrate()
|
||
dates = [f"2026-01-{i:02d}" for i in range(1, 25)]
|
||
closes_by_date = {}
|
||
for i, d in enumerate(dates):
|
||
closes_by_date[d] = {"LO": 100.0 + 0.05 * i, "HI": 100.0 + (8.0 if i % 2 else -8.0)}
|
||
|
||
class Sleeve:
|
||
def __init__(self, sym): self.sym = sym
|
||
def current_weights(self, d): return {self.sym: 1.0}
|
||
|
||
backfill_paper_book(repo, capital=100_000.0,
|
||
sleeves={"lo": Sleeve("LO"), "hi": Sleeve("HI")},
|
||
dates=dates, closes_by_date=closes_by_date,
|
||
at=dt.datetime(2026, 2, 1, tzinfo=dt.UTC))
|
||
|
||
# The accumulator fed to date D must equal the DB's strictly-before-D returns for every D.
|
||
persisted = repo.sleeve_returns(before="2026-02-01")
|
||
for d in dates:
|
||
before_db = repo.sleeve_returns(before=d)
|
||
ordinal = dt.date.fromisoformat(d).toordinal()
|
||
# reconstruct what the accumulator held at the top of iteration D: all persisted ret with key < ordinal
|
||
acc_at_d = {s: {k: v for k, v in inner.items() if k < ordinal}
|
||
for s, inner in persisted.items()}
|
||
acc_at_d = {s: m for s, m in acc_at_d.items() if m}
|
||
assert acc_at_d == before_db, f"accumulator/DB mismatch at {d}"
|
||
|
||
|
||
# --- perf/backfill-io: batched I/O (chunked writes + carry-state reads) + incremental refresh --------
|
||
|
||
def _multi_sleeve_fixture(n_days=12):
|
||
"""A multi-date, multi-sleeve fixture incl. a carry sleeve — the bit-identical golden bar."""
|
||
closes, funding, dates, closes_by_date, funding_by_date = _carry_panels()
|
||
dates = dates[:n_days]
|
||
closes_by_date = {d: closes_by_date[d] for d in dates}
|
||
funding_by_date = {d: funding_by_date[d] for d in dates}
|
||
|
||
def make_sleeves():
|
||
return {"crypto_tstrend": _Dir2("AAAUSDT"), "unlock": _Dir2("BBBUSDT"),
|
||
"xsfunding": _xsfunding_replay_fixture(closes, funding)}
|
||
|
||
return make_sleeves, dates, closes_by_date, funding_by_date
|
||
|
||
|
||
def test_backfill_chunked_writes_bit_identical_to_single_chunk():
|
||
"""GOLDEN: a chunk_dates=3 backfill (forces multiple mid-loop flushes + chunk-boundary prior-state
|
||
seeding from the DB) produces BIT-IDENTICAL nav / positions / trades / sleeve_ret to a single-chunk
|
||
run. Proves the buffer-first prior-state reads + per-chunk commit don't perturb the money path."""
|
||
make_sleeves, dates, closes_by_date, funding_by_date = _multi_sleeve_fixture()
|
||
|
||
def run(chunk):
|
||
repo = PaperRepo("sqlite://"); repo.migrate()
|
||
backfill_paper_book(repo, capital=100_000.0, sleeves=make_sleeves(),
|
||
dates=dates, closes_by_date=closes_by_date, funding_by_date=funding_by_date,
|
||
chunk_dates=chunk, at=dt.datetime(2026, 3, 1, tzinfo=dt.UTC))
|
||
nav = [(h.run_date, h.equity, h.realized, h.unrealized) for h in repo.nav_history()]
|
||
pos = {d: sorted((p.sleeve, p.symbol, p.qty, p.entry_price, p.entry_date)
|
||
for p in repo.positions_at(d)) for d in dates}
|
||
trades = {d: sorted((t.sleeve, t.symbol, t.side, t.qty, t.price)
|
||
for t in repo.trades_on(d)) for d in dates}
|
||
shadow = {d: sorted((p.sleeve, p.symbol, p.qty, p.entry_price)
|
||
for p in repo.shadow_positions_before(
|
||
(dt.date.fromisoformat(d) + dt.timedelta(days=1)).isoformat()))
|
||
for d in dates}
|
||
rets = repo.sleeve_returns(before="2026-03-01")
|
||
return nav, pos, trades, shadow, rets
|
||
|
||
big = run(10_000) # single chunk (all dates buffered, one flush)
|
||
small = run(3) # multiple chunks (forces mid-loop flush + DB-seeded chunk boundaries)
|
||
assert big == small, "chunked-write backfill diverged from single-chunk (NOT bit-identical)"
|
||
|
||
|
||
def test_backfill_incremental_equals_full_rebuild():
|
||
"""INCREMENTAL == FULL: a full rebuild over all dates equals (a) a rebuild over all-but-last-K dates,
|
||
then (b) an incremental append of the last K dates (dates strictly after the last persisted nav).
|
||
The append must reproduce the SAME persisted rows as the full rebuild for those dates."""
|
||
make_sleeves, dates, closes_by_date, funding_by_date = _multi_sleeve_fixture()
|
||
K = 4
|
||
head, tail = dates[:-K], dates[-K:]
|
||
|
||
def image(repo):
|
||
nav = [(h.run_date, h.equity, h.realized, h.unrealized) for h in repo.nav_history()]
|
||
pos = {d: sorted((p.sleeve, p.symbol, p.qty, p.entry_price) for p in repo.positions_at(d))
|
||
for d in dates}
|
||
rets = repo.sleeve_returns(before="2026-03-01")
|
||
return nav, pos, rets
|
||
|
||
# (1) Full rebuild over ALL dates.
|
||
full = PaperRepo("sqlite://"); full.migrate()
|
||
backfill_paper_book(full, capital=100_000.0, sleeves=make_sleeves(),
|
||
dates=dates, closes_by_date=closes_by_date, funding_by_date=funding_by_date,
|
||
at=dt.datetime(2026, 3, 1, tzinfo=dt.UTC))
|
||
|
||
# (2) Rebuild HEAD only, then incrementally append TAIL (resume strictly after the last nav date).
|
||
inc = PaperRepo("sqlite://"); inc.migrate()
|
||
backfill_paper_book(inc, capital=100_000.0, sleeves=make_sleeves(),
|
||
dates=head, closes_by_date=closes_by_date, funding_by_date=funding_by_date,
|
||
at=dt.datetime(2026, 3, 1, tzinfo=dt.UTC))
|
||
last = inc.latest_nav_run_date()
|
||
assert last == head[-1]
|
||
resume = [d for d in dates if d > last] # the incremental window: strictly-after the last date
|
||
assert resume == tail
|
||
backfill_paper_book(inc, capital=100_000.0, sleeves=make_sleeves(),
|
||
dates=resume, closes_by_date=closes_by_date, funding_by_date=funding_by_date,
|
||
at=dt.datetime(2026, 3, 1, tzinfo=dt.UTC))
|
||
|
||
assert image(full) == image(inc), "incremental append diverged from full rebuild"
|
||
|
||
|
||
class _CountingWriteRepo:
|
||
"""Wraps a real PaperRepo, counting every per-date AND bulk write call; delegates everything."""
|
||
_PER_DATE = ("replace_positions", "replace_trades", "set_realized_for_run_date",
|
||
"upsert_nav", "replace_shadow_positions", "upsert_sleeve_ret")
|
||
_BULK = ("bulk_replace_positions", "bulk_replace_trades", "bulk_set_realized",
|
||
"bulk_upsert_nav", "bulk_replace_shadow_positions", "bulk_upsert_sleeve_ret")
|
||
|
||
def __init__(self, inner):
|
||
object.__setattr__(self, "_inner", inner)
|
||
object.__setattr__(self, "per_date_calls", 0)
|
||
object.__setattr__(self, "bulk_calls", 0)
|
||
|
||
def __getattr__(self, name):
|
||
inner_attr = getattr(self._inner, name)
|
||
if name in self._PER_DATE or name in self._BULK:
|
||
def wrapped(*a, **kw):
|
||
if name in self._PER_DATE:
|
||
object.__setattr__(self, "per_date_calls", self.per_date_calls + 1)
|
||
else:
|
||
object.__setattr__(self, "bulk_calls", self.bulk_calls + 1)
|
||
return inner_attr(*a, **kw)
|
||
return wrapped
|
||
return inner_attr
|
||
|
||
|
||
def test_backfill_batches_writes_not_per_date():
|
||
"""I/O REDUCTION: the batched backfill must issue NO per-date write calls to the repo — all writes go
|
||
through the bulk methods (a bounded number of batches, NOT O(dates·tables) per-date statements)."""
|
||
make_sleeves, dates, closes_by_date, funding_by_date = _multi_sleeve_fixture()
|
||
inner = PaperRepo("sqlite://"); inner.migrate()
|
||
repo = _CountingWriteRepo(inner)
|
||
n = backfill_paper_book(repo, capital=100_000.0, sleeves=make_sleeves(),
|
||
dates=dates, closes_by_date=closes_by_date, funding_by_date=funding_by_date,
|
||
chunk_dates=10_000, at=dt.datetime(2026, 3, 1, tzinfo=dt.UTC))
|
||
assert n == len(dates)
|
||
# Single chunk → exactly 6 bulk write batches (one per table), and ZERO per-date write round-trips.
|
||
assert repo.per_date_calls == 0, (
|
||
f"expected NO per-date writes (all batched), got {repo.per_date_calls}")
|
||
assert repo.bulk_calls == 6, f"expected 6 bulk batches (one per table), got {repo.bulk_calls}"
|
||
|
||
|
||
class _GapSleeve:
|
||
"""Holds a name on D1 and D5, but returns {} (no signal) on D2/D3/D4 — the GAP."""
|
||
def __init__(self, hold_dates):
|
||
self._hold = set(hold_dates)
|
||
def current_weights(self, as_of):
|
||
return {"BTCUSDT": 1.0} if as_of in self._hold else {}
|
||
|
||
|
||
def test_backfill_no_spurious_return_across_empty_weight_gap():
|
||
# LUNA-style reproduction: one sleeve holds on D1 and D5 but has NO signal (empty weights) on
|
||
# D2/D3/D4. The price HALVES between D1 and D5. The buggy code read the prior shadow via
|
||
# repo.shadow_positions_before (which SKIPS the empty D2/D3/D4) and so marked the STALE D1 shadow
|
||
# over the whole gap → a spurious ~-50% return booked on D5. Correct semantics: a sleeve only
|
||
# earns a return when it held a position on the IMMEDIATELY-prior date; the adjacent prior (D4) was
|
||
# empty → NO return row for D5.
|
||
repo = PaperRepo("sqlite://")
|
||
repo.migrate()
|
||
dates = ["2026-05-09", "2026-05-10", "2026-05-11", "2026-05-12", "2026-05-13"]
|
||
d1, _d2, _d3, _d4, d5 = dates
|
||
closes_by_date = {
|
||
d1: {"BTCUSDT": 100.0},
|
||
_d2: {"BTCUSDT": 80.0}, # no shadow held this day (sleeve empty), price still moves
|
||
_d3: {"BTCUSDT": 70.0},
|
||
_d4: {"BTCUSDT": 60.0},
|
||
d5: {"BTCUSDT": 50.0}, # price halved vs D1 — a stale-D1 mark would book ~-50%
|
||
}
|
||
backfill_paper_book(
|
||
repo, capital=100_000.0,
|
||
sleeves={"crypto_tstrend": _GapSleeve([d1, d5])},
|
||
dates=dates, closes_by_date=closes_by_date,
|
||
at=dt.datetime(2026, 5, 13, tzinfo=dt.UTC))
|
||
|
||
rets = repo.sleeve_returns(before="2026-05-14").get("crypto_tstrend", {})
|
||
d5_ord = dt.date.fromisoformat(d5).toordinal()
|
||
# The bug booked rets[d5] ≈ -0.5 (stale D1 shadow marked to D5's halved price). After the fix the
|
||
# adjacent prior (D4) was empty → there is NO return row for D5 (and certainly not the -50% gap value).
|
||
assert d5_ord not in rets, (
|
||
f"expected NO sleeve return on D5 (adjacent prior D4 was empty); got {rets.get(d5_ord)!r} "
|
||
"— the stale-gap spurious return bug")
|
||
|
||
|
||
def test_backfill_consecutive_dates_book_correct_one_day_return():
|
||
# Control for the gap test: when the sleeve holds on two ADJACENT dates, the normal 1-day price-MTM
|
||
# return is still booked correctly (the in-memory adjacent prior must reproduce the consecutive case).
|
||
repo = PaperRepo("sqlite://")
|
||
repo.migrate()
|
||
dates = ["2026-05-09", "2026-05-10"]
|
||
d1, d2 = dates
|
||
closes_by_date = {d1: {"BTCUSDT": 100.0}, d2: {"BTCUSDT": 110.0}}
|
||
backfill_paper_book(
|
||
repo, capital=100_000.0,
|
||
sleeves={"crypto_tstrend": _GapSleeve([d1, d2])}, # holds both adjacent days
|
||
dates=dates, closes_by_date=closes_by_date,
|
||
at=dt.datetime(2026, 5, 10, tzinfo=dt.UTC))
|
||
rets = repo.sleeve_returns(before="2026-05-11").get("crypto_tstrend", {})
|
||
d2_ord = dt.date.fromisoformat(d2).toordinal()
|
||
# unit shadow on D1 = long BTC @ 100; marked to D2 @ 110 → +10% return on D2.
|
||
assert abs(rets[d2_ord] - 0.10) < 1e-9
|
||
|
||
|
||
def test_backfill_carry_sleeve_books_funding_not_price():
|
||
import datetime as dt
|
||
from fxhnt.adapters.persistence.paper_repo import PaperRepo
|
||
from fxhnt.application.paper_backfill import backfill_paper_book
|
||
|
||
class CarrySleeve:
|
||
return_mode = "carry"
|
||
def current_weights(self, d): return {"FUND": 1.0} # 1 name, weight 1
|
||
repo = PaperRepo("sqlite://"); repo.migrate()
|
||
dates = ["2026-01-01", "2026-01-02", "2026-01-03"]
|
||
# price MOVES a lot (would dominate if price-MTM were used) but carry must come from funding
|
||
closes = {"2026-01-01": {"FUND": 100.0}, "2026-01-02": {"FUND": 200.0}, "2026-01-03": {"FUND": 50.0}}
|
||
funding = {"2026-01-02": {"FUND": 0.01}, "2026-01-03": {"FUND": 0.02}}
|
||
backfill_paper_book(repo, capital=100_000.0, sleeves={"xs": CarrySleeve()},
|
||
dates=dates, closes_by_date=closes, funding_by_date=funding,
|
||
at=dt.datetime(2026, 2, 1, tzinfo=dt.UTC))
|
||
rets = repo.sleeve_returns(before="2026-02-01").get("xs", {})
|
||
import datetime as _d
|
||
d2 = _d.date(2026, 1, 2).toordinal()
|
||
# carry on 01-02 = w*funding/gross = 1*0.01/1 = 0.01 (NOT the +100% price move)
|
||
assert abs(rets[d2] - 0.01) < 1e-9
|
||
|
||
|
||
# --- Piece 1: xsfunding (carry) in the backtest via funding-accrual accounting ---------------------
|
||
|
||
def _xsfunding_replay_fixture(closes, funding):
|
||
"""Build the XsFundingReplay carry sleeve from a (close, funding) fixture panel, exactly as the CLI
|
||
backfill wires it from the warehouse (warehouse_funding_panel over close+funding epoch-day panels)."""
|
||
from fxhnt.application.funding_panel import warehouse_funding_panel
|
||
from fxhnt.application.xsfunding_replay import XsFundingReplay
|
||
panel = warehouse_funding_panel(closes, funding, qvol_fallback=1e7)
|
||
# min_history small so the short fixture is tradeable; lookback short so the score responds.
|
||
return XsFundingReplay(panel, min_qvol=1e6, min_history=3, lookback_days=3)
|
||
|
||
|
||
def _carry_panels():
|
||
"""A 40-day fixture: 4 coins, two with persistently high funding (LONG) and two with low (SHORT).
|
||
closes flat (price legs net to ~0 anyway); funding is the only P&L source. Returns
|
||
(closes_epoch_day, funding_epoch_day, dates_iso, closes_by_date_iso, funding_by_date_iso)."""
|
||
import datetime as dt
|
||
epoch = dt.date(1970, 1, 1)
|
||
base = (dt.date(2026, 1, 1) - epoch).days
|
||
days = list(range(base, base + 40))
|
||
syms = ("AAAUSDT", "BBBUSDT", "CCCUSDT", "DDDUSDT")
|
||
closes = {s: {d: 100.0 for d in days} for s in syms}
|
||
# AAA/BBB high funding -> LONG; CCC/DDD low (negative) funding -> SHORT
|
||
funding = {
|
||
"AAAUSDT": {d: 0.03 for d in days},
|
||
"BBBUSDT": {d: 0.02 for d in days},
|
||
"CCCUSDT": {d: -0.02 for d in days},
|
||
"DDDUSDT": {d: -0.03 for d in days},
|
||
}
|
||
dates = [(epoch + dt.timedelta(days=d)).isoformat() for d in days]
|
||
closes_by_date = {(epoch + dt.timedelta(days=d)).isoformat(): {s: closes[s][d] for s in syms}
|
||
for d in days}
|
||
funding_by_date = {(epoch + dt.timedelta(days=d)).isoformat(): {s: funding[s][d] for s in syms}
|
||
for d in days}
|
||
return closes, funding, dates, closes_by_date, funding_by_date
|
||
|
||
|
||
def test_carry_persisted_return_matches_strategy_live_eq_replay():
|
||
"""The persisted paper_sleeve_ret['xsfunding'][day] equals the strategy's funding-capture return
|
||
(sleeve_daily_carry over the prior delta-neutral book at that day's funding). This is the live==replay
|
||
guarantee for carry: the backfill books exactly what the live snapshot's sleeve_daily_carry books."""
|
||
import datetime as dt
|
||
from fxhnt.adapters.persistence.paper_repo import PaperRepo
|
||
from fxhnt.application.paper_backfill import backfill_paper_book
|
||
from fxhnt.application.paper_book import sleeve_daily_carry, unit_shadow_positions
|
||
|
||
closes, funding, dates, closes_by_date, funding_by_date = _carry_panels()
|
||
sleeve = _xsfunding_replay_fixture(closes, funding)
|
||
|
||
repo = PaperRepo("sqlite://"); repo.migrate()
|
||
backfill_paper_book(repo, capital=100_000.0, sleeves={"xsfunding": sleeve},
|
||
dates=dates, closes_by_date=closes_by_date, funding_by_date=funding_by_date,
|
||
at=dt.datetime(2026, 3, 1, tzinfo=dt.UTC))
|
||
rets = repo.sleeve_returns(before="2026-03-01").get("xsfunding", {})
|
||
assert rets, "carry sleeve must persist a non-empty return series"
|
||
|
||
# Independently reproduce the carry return for each adjacent date pair from the SAME pure pipeline the
|
||
# live strategy uses: prior-day delta-neutral book (unit shadow) marked at THIS day's funding.
|
||
for i in range(1, len(dates)):
|
||
prev_iso, cur_iso = dates[i - 1], dates[i]
|
||
prev_w = sleeve.current_weights(prev_iso)
|
||
if not prev_w:
|
||
continue
|
||
prior_shadow = unit_shadow_positions({"xsfunding": prev_w}, closes_by_date[prev_iso], prev_iso)
|
||
expected = sleeve_daily_carry(prior_shadow, funding_by_date[cur_iso])
|
||
ordinal = dt.date.fromisoformat(cur_iso).toordinal()
|
||
if expected is None:
|
||
continue
|
||
assert ordinal in rets, f"missing carry return on {cur_iso}"
|
||
assert abs(rets[ordinal] - expected) < 1e-12, (
|
||
f"carry return mismatch on {cur_iso}: persisted {rets[ordinal]} vs strategy {expected}")
|
||
|
||
|
||
def test_carry_return_does_not_require_costs_flag():
|
||
"""The carry RETURN (sleeve_daily_carry) is the sleeve's P&L and must be booked from funding_by_date
|
||
REGARDLESS of the costs toggle (costs only governs the funding cash-flow + trading-cost on the
|
||
THROTTLED book). With costs OFF, the carry sleeve still books a non-zero funding return."""
|
||
import datetime as dt
|
||
from fxhnt.adapters.persistence.paper_repo import PaperRepo
|
||
from fxhnt.application.paper_backfill import backfill_paper_book
|
||
|
||
closes, funding, dates, closes_by_date, funding_by_date = _carry_panels()
|
||
sleeve = _xsfunding_replay_fixture(closes, funding)
|
||
repo = PaperRepo("sqlite://"); repo.migrate()
|
||
backfill_paper_book(repo, capital=100_000.0, sleeves={"xsfunding": sleeve},
|
||
dates=dates, closes_by_date=closes_by_date, funding_by_date=funding_by_date,
|
||
costs_enabled=False, # GROSS — costs OFF
|
||
at=dt.datetime(2026, 3, 1, tzinfo=dt.UTC))
|
||
rets = repo.sleeve_returns(before="2026-03-01").get("xsfunding", {})
|
||
# market-neutral long-high/short-low funding book on persistently dispersed funding -> positive carry.
|
||
assert rets and all(r > 0.0 for r in rets.values()), \
|
||
"carry return must be booked from funding even with costs OFF"
|
||
|
||
|
||
def test_directional_sleeves_bit_identical_with_xsfunding_present():
|
||
"""GOLDEN: adding the xsfunding carry sleeve must NOT change the directional sleeves' returns OR
|
||
positions (they're price-MTM and independent). The ONLY allowed effect is the shared ISV/leverage
|
||
normalization over the now-larger active set — which changes the THROTTLED book, but the per-sleeve
|
||
UNIT shadow returns + the unit shadow positions for the directional sleeves are invariant."""
|
||
import datetime as dt
|
||
from fxhnt.adapters.persistence.paper_repo import PaperRepo
|
||
from fxhnt.application.paper_backfill import backfill_paper_book
|
||
|
||
closes, funding, dates, closes_by_date, funding_by_date = _carry_panels()
|
||
|
||
class _Dir:
|
||
def __init__(self, sym): self.sym = sym
|
||
def current_weights(self, d): return {self.sym: 1.0}
|
||
|
||
def run(with_carry):
|
||
repo = PaperRepo("sqlite://"); repo.migrate()
|
||
sleeves = {"crypto_tstrend": _Dir("AAAUSDT"), "unlock": _Dir("BBBUSDT")}
|
||
if with_carry:
|
||
sleeves["xsfunding"] = _xsfunding_replay_fixture(closes, funding)
|
||
backfill_paper_book(repo, capital=100_000.0, sleeves=sleeves,
|
||
dates=dates, closes_by_date=closes_by_date, funding_by_date=funding_by_date,
|
||
at=dt.datetime(2026, 3, 1, tzinfo=dt.UTC))
|
||
return repo
|
||
|
||
without = run(with_carry=False)
|
||
with_ = run(with_carry=True)
|
||
|
||
# Per-sleeve UNIT-shadow returns for the directional sleeves are bit-identical (the carry sleeve only
|
||
# adds its own 'xsfunding' key; it does not perturb the directional sleeves' return signal).
|
||
for s in ("crypto_tstrend", "unlock"):
|
||
r0 = without.sleeve_returns(before="2026-03-01").get(s, {})
|
||
r1 = with_.sleeve_returns(before="2026-03-01").get(s, {})
|
||
assert r0 == r1, f"directional sleeve {s} return series changed when xsfunding was added"
|
||
|
||
# The directional sleeves' UNIT shadow positions (the kill-proof signal book) are bit-identical too.
|
||
for d in dates:
|
||
sp0 = [p for p in without.shadow_positions_before((dt.date.fromisoformat(d)
|
||
+ dt.timedelta(days=1)).isoformat()) if p.sleeve in ("crypto_tstrend", "unlock")]
|
||
sp1 = [p for p in with_.shadow_positions_before((dt.date.fromisoformat(d)
|
||
+ dt.timedelta(days=1)).isoformat()) if p.sleeve in ("crypto_tstrend", "unlock")]
|
||
key = lambda p: (p.sleeve, p.symbol)
|
||
assert sorted([(p.sleeve, p.symbol, p.qty, p.entry_price) for p in sp0], key=lambda t: t[:2]) == \
|
||
sorted([(p.sleeve, p.symbol, p.qty, p.entry_price) for p in sp1], key=lambda t: t[:2]), \
|
||
f"directional unit-shadow positions changed when xsfunding was added (date {d})"
|
||
|
||
|
||
def test_book_equity_reflects_carry_funding_pnl():
|
||
"""The compounding book equity must reflect the carry sleeve's funding P&L: a POSITIVE-funding carry
|
||
book raises the final book equity vs an otherwise-identical run WITHOUT the carry sleeve (carry's
|
||
funding return flows through the overlay into the A3f compounding book return)."""
|
||
import datetime as dt
|
||
from fxhnt.adapters.persistence.paper_repo import PaperRepo
|
||
from fxhnt.application.paper_backfill import backfill_paper_book
|
||
|
||
closes, funding, dates, closes_by_date, funding_by_date = _carry_panels()
|
||
|
||
class _Dir:
|
||
def __init__(self, sym): self.sym = sym
|
||
def current_weights(self, d): return {self.sym: 1.0}
|
||
|
||
def final_equity(with_carry):
|
||
repo = PaperRepo("sqlite://"); repo.migrate()
|
||
sleeves = {"crypto_tstrend": _Dir("AAAUSDT")}
|
||
if with_carry:
|
||
sleeves["xsfunding"] = _xsfunding_replay_fixture(closes, funding)
|
||
backfill_paper_book(repo, capital=100_000.0, sleeves=sleeves,
|
||
dates=dates, closes_by_date=closes_by_date, funding_by_date=funding_by_date,
|
||
at=dt.datetime(2026, 3, 1, tzinfo=dt.UTC))
|
||
hist = repo.nav_history()
|
||
return hist[-1].equity
|
||
|
||
# closes are flat (price-MTM ~0), so the directional sleeve contributes ~0; the carry sleeve's
|
||
# positive funding accrual is the only P&L source -> the with-carry book ends strictly higher.
|
||
assert final_equity(with_carry=True) > final_equity(with_carry=False)
|
||
|
||
|
||
# --- Carry FULL return (funding + daily basis change), not funding-only -----------------------------
|
||
|
||
def _basis_by_date(closes_by_date, dates, *, moved_sym, moved_day, basis_val):
|
||
"""A basis_by_date fixture: every (date, sym) gets 0 basis EXCEPT `moved_sym` on `moved_day` which
|
||
gets `basis_val` — so we can isolate the basis term's effect on the carry return + book equity."""
|
||
import datetime as dt
|
||
out: dict = {}
|
||
for d in dates:
|
||
syms = list(closes_by_date[d])
|
||
out[d] = {s: 0.0 for s in syms}
|
||
if moved_day in out and moved_sym in out[moved_day]:
|
||
out[moved_day][moved_sym] = basis_val
|
||
return out
|
||
|
||
|
||
def test_backfill_carry_return_includes_basis_term():
|
||
"""The persisted carry return reflects the daily BASIS CHANGE, not funding-only, and equals the
|
||
sleeve_daily_carry over the SAME full carry-return map (live==replay / single source)."""
|
||
import datetime as dt
|
||
from fxhnt.adapters.persistence.paper_repo import PaperRepo
|
||
from fxhnt.application.paper_backfill import backfill_paper_book
|
||
from fxhnt.application.paper_book import (
|
||
carry_return_by_symbol, sleeve_daily_carry, unit_shadow_positions,
|
||
)
|
||
|
||
closes, funding, dates, closes_by_date, funding_by_date = _carry_panels()
|
||
# Move AAA's basis hard NEGATIVE on one mid-range day (a basis blowup against the long).
|
||
moved_day = dates[20]
|
||
basis_by_date = _basis_by_date(closes_by_date, dates, moved_sym="AAAUSDT",
|
||
moved_day=moved_day, basis_val=-0.40)
|
||
sleeve = _xsfunding_replay_fixture(closes, funding)
|
||
repo = PaperRepo("sqlite://"); repo.migrate()
|
||
backfill_paper_book(repo, capital=100_000.0, sleeves={"xsfunding": sleeve},
|
||
dates=dates, closes_by_date=closes_by_date, funding_by_date=funding_by_date,
|
||
basis_by_date=basis_by_date, at=dt.datetime(2026, 3, 1, tzinfo=dt.UTC))
|
||
rets = repo.sleeve_returns(before="2026-03-01").get("xsfunding", {})
|
||
moved_ord = dt.date.fromisoformat(moved_day).toordinal()
|
||
assert moved_ord in rets
|
||
|
||
# Independently reproduce the FULL carry return for the moved day from the SAME helper.
|
||
prev_iso = dates[dates.index(moved_day) - 1]
|
||
prev_w = sleeve.current_weights(prev_iso)
|
||
prior_shadow = unit_shadow_positions({"xsfunding": prev_w}, closes_by_date[prev_iso], prev_iso)
|
||
full_map = carry_return_by_symbol(funding_by_date[moved_day], basis_by_date[moved_day])
|
||
expected_full = sleeve_daily_carry(prior_shadow, full_map)
|
||
funding_only = sleeve_daily_carry(prior_shadow, funding_by_date[moved_day])
|
||
assert abs(rets[moved_ord] - expected_full) < 1e-12 # persisted == full-return helper
|
||
assert abs(rets[moved_ord] - funding_only) > 1e-6 # and it is NOT funding-only
|
||
|
||
|
||
def test_backfill_basis_loss_reduces_book_equity_vs_flat_basis():
|
||
"""A basis that moves AGAINST the book reduces the final book equity vs an otherwise-identical run with
|
||
flat basis — proving the basis tail is now CHARGED (the whole point of the fix)."""
|
||
import datetime as dt
|
||
from fxhnt.adapters.persistence.paper_repo import PaperRepo
|
||
from fxhnt.application.paper_backfill import backfill_paper_book
|
||
|
||
closes, funding, dates, closes_by_date, funding_by_date = _carry_panels()
|
||
moved_day = dates[20]
|
||
|
||
def final_equity(basis_by_date):
|
||
repo = PaperRepo("sqlite://"); repo.migrate()
|
||
backfill_paper_book(repo, capital=100_000.0,
|
||
sleeves={"xsfunding": _xsfunding_replay_fixture(closes, funding)},
|
||
dates=dates, closes_by_date=closes_by_date, funding_by_date=funding_by_date,
|
||
basis_by_date=basis_by_date, at=dt.datetime(2026, 3, 1, tzinfo=dt.UTC))
|
||
return repo.nav_history()[-1].equity
|
||
|
||
flat = final_equity(_basis_by_date(closes_by_date, dates, moved_sym="AAAUSDT",
|
||
moved_day=moved_day, basis_val=0.0))
|
||
# AAA is a LONG (high funding); a big negative basis change on it is a loss to the book.
|
||
loss = final_equity(_basis_by_date(closes_by_date, dates, moved_sym="AAAUSDT",
|
||
moved_day=moved_day, basis_val=-0.40))
|
||
assert loss < flat, "a basis loss must reduce the carry book equity (the tail is now charged)"
|
||
|
||
|
||
def test_backfill_carry_pnl_over_gross_equals_sleeve_return_with_basis():
|
||
"""The consistency invariant holds WITH the basis term: for the carry sleeve, the equity P&L over the
|
||
prior book's gross notional equals the persisted sleeve return (carry_pnl/Σ|notional| == sleeve_ret)."""
|
||
import datetime as dt
|
||
from fxhnt.adapters.persistence.paper_repo import PaperRepo
|
||
from fxhnt.application.paper_backfill import backfill_paper_book
|
||
from fxhnt.application.paper_book import carry_pnl, carry_return_by_symbol
|
||
|
||
closes, funding, dates, closes_by_date, funding_by_date = _carry_panels()
|
||
moved_day = dates[20]
|
||
basis_by_date = _basis_by_date(closes_by_date, dates, moved_sym="AAAUSDT",
|
||
moved_day=moved_day, basis_val=-0.15)
|
||
repo = PaperRepo("sqlite://"); repo.migrate()
|
||
backfill_paper_book(repo, capital=100_000.0,
|
||
sleeves={"xsfunding": _xsfunding_replay_fixture(closes, funding)},
|
||
dates=dates, closes_by_date=closes_by_date, funding_by_date=funding_by_date,
|
||
basis_by_date=basis_by_date, at=dt.datetime(2026, 3, 1, tzinfo=dt.UTC))
|
||
rets = repo.sleeve_returns(before="2026-03-01").get("xsfunding", {})
|
||
moved_ord = dt.date.fromisoformat(moved_day).toordinal()
|
||
|
||
# Reconstruct the prior (moved_day-1) carry shadow book and check the invariant on the moved day.
|
||
prior = repo.shadow_positions_before(moved_day)
|
||
carry_prior = [p for p in prior if p.sleeve == "xsfunding"]
|
||
gross = sum(abs(p.qty * p.entry_price) for p in carry_prior)
|
||
full_map = carry_return_by_symbol(funding_by_date[moved_day], basis_by_date[moved_day])
|
||
assert gross > 0
|
||
assert abs(carry_pnl(carry_prior, full_map) / gross - rets[moved_ord]) < 1e-9
|
||
|
||
|
||
def _worst_basis_by_date(dates, *, moved_sym, moved_day, wb_val):
|
||
"""A worst_basis_by_date fixture: only `moved_sym` on `moved_day` carries `wb_val` (the intraday
|
||
excursion); every other (date, sym) is absent → the daily-close basis applies there."""
|
||
return {moved_day: {moved_sym: wb_val}}
|
||
|
||
|
||
def test_backfill_worst_basis_overrides_close_basis_and_is_capped():
|
||
"""When a worst_basis is present for (symbol, day) the persisted carry return uses max(worst_basis, −1.0)
|
||
INSTEAD of that day's daily-close basis — the LUNA pattern: a benign close basis, a catastrophic intraday
|
||
one. The persisted return matches carry_return_by_symbol(..., worst_basis) exactly (live==replay)."""
|
||
import datetime as dt
|
||
from fxhnt.adapters.persistence.paper_repo import PaperRepo
|
||
from fxhnt.application.paper_backfill import backfill_paper_book
|
||
from fxhnt.application.paper_book import carry_return_by_symbol, sleeve_daily_carry, unit_shadow_positions
|
||
|
||
closes, funding, dates, closes_by_date, funding_by_date = _carry_panels()
|
||
moved_day = dates[20]
|
||
# Benign daily-close basis on AAA (−0.7%, the LUNA daily-close), but a −163% intraday worst_basis.
|
||
basis_by_date = _basis_by_date(closes_by_date, dates, moved_sym="AAAUSDT",
|
||
moved_day=moved_day, basis_val=-0.007)
|
||
worst_basis_by_date = _worst_basis_by_date(dates, moved_sym="AAAUSDT", moved_day=moved_day, wb_val=-1.63)
|
||
sleeve = _xsfunding_replay_fixture(closes, funding)
|
||
repo = PaperRepo("sqlite://"); repo.migrate()
|
||
backfill_paper_book(repo, capital=100_000.0, sleeves={"xsfunding": sleeve},
|
||
dates=dates, closes_by_date=closes_by_date, funding_by_date=funding_by_date,
|
||
basis_by_date=basis_by_date, worst_basis_by_date=worst_basis_by_date,
|
||
at=dt.datetime(2026, 3, 1, tzinfo=dt.UTC))
|
||
rets = repo.sleeve_returns(before="2026-03-01").get("xsfunding", {})
|
||
moved_ord = dt.date.fromisoformat(moved_day).toordinal()
|
||
|
||
prev_iso = dates[dates.index(moved_day) - 1]
|
||
prev_w = sleeve.current_weights(prev_iso)
|
||
prior_shadow = unit_shadow_positions({"xsfunding": prev_w}, closes_by_date[prev_iso], prev_iso)
|
||
# The realized basis for AAA must be the CAPPED worst_basis (−1.0), not the −0.7% close basis.
|
||
charged_map = carry_return_by_symbol(funding_by_date[moved_day], basis_by_date[moved_day],
|
||
worst_basis_by_date[moved_day])
|
||
close_only_map = carry_return_by_symbol(funding_by_date[moved_day], basis_by_date[moved_day])
|
||
assert abs(rets[moved_ord] - sleeve_daily_carry(prior_shadow, charged_map)) < 1e-12
|
||
assert rets[moved_ord] < sleeve_daily_carry(prior_shadow, close_only_map) # the charge bites
|
||
|
||
|
||
def test_backfill_worst_basis_loss_reduces_book_equity():
|
||
"""An intraday worst_basis that moves AGAINST the book reduces final book equity vs the same run with
|
||
no worst_basis (= daily-close basis). This is the intraday-liquidation tail now being charged."""
|
||
import datetime as dt
|
||
from fxhnt.adapters.persistence.paper_repo import PaperRepo
|
||
from fxhnt.application.paper_backfill import backfill_paper_book
|
||
|
||
closes, funding, dates, closes_by_date, funding_by_date = _carry_panels()
|
||
moved_day = dates[20]
|
||
basis_by_date = _basis_by_date(closes_by_date, dates, moved_sym="AAAUSDT",
|
||
moved_day=moved_day, basis_val=-0.007) # benign daily-close
|
||
|
||
def final_equity(worst_basis_by_date):
|
||
repo = PaperRepo("sqlite://"); repo.migrate()
|
||
backfill_paper_book(repo, capital=100_000.0,
|
||
sleeves={"xsfunding": _xsfunding_replay_fixture(closes, funding)},
|
||
dates=dates, closes_by_date=closes_by_date, funding_by_date=funding_by_date,
|
||
basis_by_date=basis_by_date, worst_basis_by_date=worst_basis_by_date,
|
||
at=dt.datetime(2026, 3, 1, tzinfo=dt.UTC))
|
||
return repo.nav_history()[-1].equity
|
||
|
||
without = final_equity(None) # daily-close basis only
|
||
charged = final_equity(_worst_basis_by_date(dates, moved_sym="AAAUSDT",
|
||
moved_day=moved_day, wb_val=-1.63))
|
||
assert charged < without, "an intraday worst_basis loss must reduce the carry book equity"
|
||
|
||
|
||
def test_backfill_worst_basis_absent_is_bit_identical():
|
||
"""worst_basis_by_date=None must give a bit-identical equity curve to the prior daily-close behavior —
|
||
the absent path is a no-op (the live==replay / no-regression guarantee)."""
|
||
import datetime as dt
|
||
from fxhnt.adapters.persistence.paper_repo import PaperRepo
|
||
from fxhnt.application.paper_backfill import backfill_paper_book
|
||
|
||
closes, funding, dates, closes_by_date, funding_by_date = _carry_panels()
|
||
moved_day = dates[20]
|
||
basis_by_date = _basis_by_date(closes_by_date, dates, moved_sym="AAAUSDT",
|
||
moved_day=moved_day, basis_val=-0.15)
|
||
|
||
def curve(worst_basis_by_date):
|
||
repo = PaperRepo("sqlite://"); repo.migrate()
|
||
backfill_paper_book(repo, capital=100_000.0,
|
||
sleeves={"xsfunding": _xsfunding_replay_fixture(closes, funding)},
|
||
dates=dates, closes_by_date=closes_by_date, funding_by_date=funding_by_date,
|
||
basis_by_date=basis_by_date, worst_basis_by_date=worst_basis_by_date,
|
||
at=dt.datetime(2026, 3, 1, tzinfo=dt.UTC))
|
||
return [r.equity for r in repo.nav_history()]
|
||
|
||
assert curve(None) == curve({}) # absent (None) and empty both == daily-close behavior, bit-identical
|
||
|
||
|
||
# --- Phase 3: TRACKED (persist all) vs TRUSTED book (compose 2-edge) decoupling --------------------
|
||
|
||
class _Dir2:
|
||
"""A directional as_of-aware fake holding one symbol at unit weight."""
|
||
def __init__(self, sym):
|
||
self.sym = sym
|
||
|
||
def current_weights(self, d):
|
||
return {self.sym: 1.0}
|
||
|
||
|
||
def _decoupling_run(book_sleeves):
|
||
"""Backfill with all four TRACKED sleeves over the carry fixture, restricting the BOOK to
|
||
`book_sleeves`. Returns the repo. The three directional sleeves map to the three carry-fixture
|
||
coins so the directional book is non-trivial; xsfunding is the carry sleeve."""
|
||
import datetime as dt
|
||
from fxhnt.adapters.persistence.paper_repo import PaperRepo
|
||
from fxhnt.application.paper_backfill import backfill_paper_book
|
||
|
||
closes, funding, dates, closes_by_date, funding_by_date = _carry_panels()
|
||
sleeves = {
|
||
"crypto_tstrend": _Dir2("AAAUSDT"),
|
||
"unlock": _Dir2("BBBUSDT"),
|
||
"stablecoin_rotation": _Dir2("CCCUSDT"),
|
||
"xsfunding": _xsfunding_replay_fixture(closes, funding),
|
||
}
|
||
repo = PaperRepo("sqlite://"); repo.migrate()
|
||
backfill_paper_book(repo, capital=100_000.0, sleeves=sleeves,
|
||
dates=dates, closes_by_date=closes_by_date, funding_by_date=funding_by_date,
|
||
book_sleeves=book_sleeves, at=dt.datetime(2026, 3, 1, tzinfo=dt.UTC))
|
||
return repo, dates
|
||
|
||
|
||
def test_backfill_persists_all_tracked_but_books_only_trusted():
|
||
"""DECOUPLING: paper_sleeve_ret accumulates EVERY tracked sleeve (incl. xsfunding), while the trusted
|
||
paper_nav/positions reflect ONLY the 2-edge book (xsfunding excluded from positions)."""
|
||
from fxhnt.application.paper_sleeves import BOOK_CONFIGS, TRUSTED_BOOK
|
||
|
||
repo, dates = _decoupling_run(frozenset(BOOK_CONFIGS[TRUSTED_BOOK]))
|
||
|
||
rets = repo.sleeve_returns(before="2026-03-01")
|
||
# Every tracked sleeve with data has a persisted return series — the raw material for the sim.
|
||
for s in ("crypto_tstrend", "unlock", "stablecoin_rotation", "xsfunding"):
|
||
assert rets.get(s), f"tracked sleeve {s} must persist a non-empty return series"
|
||
|
||
# The trusted book's POSITIONS never contain the non-book (xsfunding) sleeve, on any date.
|
||
book_set = set(BOOK_CONFIGS[TRUSTED_BOOK])
|
||
for d in dates:
|
||
sleeves_held = {p.sleeve for p in repo.positions_at(d)}
|
||
assert sleeves_held <= book_set, f"non-trusted sleeve leaked into positions on {d}: {sleeves_held}"
|
||
assert "xsfunding" not in sleeves_held
|
||
|
||
|
||
def test_backfill_trusted_2edge_book_bit_identical_to_book_only_run():
|
||
"""GOLDEN: the trusted 2-edge book (directional sleeve returns + nav + positions) when ALL four
|
||
sleeves are tracked but book_sleeves=2-edge is BIT-IDENTICAL to a run that passes ONLY the three
|
||
directional sleeves. The only new behavior is xsfunding's return ALSO getting persisted."""
|
||
import datetime as dt
|
||
from fxhnt.adapters.persistence.paper_repo import PaperRepo
|
||
from fxhnt.application.paper_backfill import backfill_paper_book
|
||
from fxhnt.application.paper_sleeves import BOOK_CONFIGS, TRUSTED_BOOK
|
||
|
||
closes, funding, dates, closes_by_date, funding_by_date = _carry_panels()
|
||
book = BOOK_CONFIGS[TRUSTED_BOOK]
|
||
directional = {"crypto_tstrend": _Dir2("AAAUSDT"), "unlock": _Dir2("BBBUSDT"),
|
||
"stablecoin_rotation": _Dir2("CCCUSDT")}
|
||
|
||
# Reference: only the 3 directional sleeves, no decoupling (legacy book-only run).
|
||
ref = PaperRepo("sqlite://"); ref.migrate()
|
||
backfill_paper_book(ref, capital=100_000.0, sleeves=dict(directional),
|
||
dates=dates, closes_by_date=closes_by_date, funding_by_date=funding_by_date,
|
||
at=dt.datetime(2026, 3, 1, tzinfo=dt.UTC))
|
||
|
||
# Decoupled: all 4 tracked, book restricted to 2-edge.
|
||
dec, _ = _decoupling_run(frozenset(book))
|
||
|
||
# Directional sleeve returns are bit-identical.
|
||
rref, rdec = ref.sleeve_returns(before="2026-03-01"), dec.sleeve_returns(before="2026-03-01")
|
||
for s in book:
|
||
assert rref.get(s, {}) == rdec.get(s, {}), f"trusted sleeve {s} return changed under decoupling"
|
||
|
||
# The persisted nav curve (the 2-edge book equity) is bit-identical.
|
||
nav_ref = [(h.run_date, h.equity, h.realized, h.unrealized) for h in ref.nav_history()]
|
||
nav_dec = [(h.run_date, h.equity, h.realized, h.unrealized) for h in dec.nav_history()]
|
||
assert nav_ref == nav_dec, "the trusted 2-edge nav curve changed under decoupling"
|
||
|
||
# Positions are bit-identical per date.
|
||
for d in dates:
|
||
pr = sorted((p.sleeve, p.symbol, p.qty, p.entry_price) for p in ref.positions_at(d))
|
||
pd = sorted((p.sleeve, p.symbol, p.qty, p.entry_price) for p in dec.positions_at(d))
|
||
assert pr == pd, f"trusted positions changed under decoupling on {d}"
|
||
|
||
|
||
def test_simulate_book_over_persisted_returns_reproduces_tracks():
|
||
"""simulate_book over the persisted paper_sleeve_ret reproduces each BOOK_CONFIGS track: 2-edge matches
|
||
the trusted book's per-day return series, and 3-edge / xsfunding-solo compute (the derived forward
|
||
tracks the gate judges)."""
|
||
from fxhnt.application.paper_sim import simulate_book
|
||
from fxhnt.application.paper_sleeves import BOOK_CONFIGS, TRUSTED_BOOK
|
||
|
||
repo, _ = _decoupling_run(frozenset(BOOK_CONFIGS[TRUSTED_BOOK]))
|
||
returns = repo.sleeve_returns(before="2026-03-01")
|
||
|
||
# 2-edge sim equity curve == the persisted trusted nav equity curve (same engine, same returns).
|
||
res_2 = simulate_book(returns, capital=100_000.0, sleeves=list(BOOK_CONFIGS["2-edge"]))
|
||
nav = repo.nav_history()
|
||
sim_eq_by_ord = dict(zip(res_2.dates, res_2.equity, strict=True))
|
||
for h in nav:
|
||
import datetime as dt
|
||
o = dt.date.fromisoformat(h.run_date).toordinal()
|
||
if o in sim_eq_by_ord:
|
||
assert abs(sim_eq_by_ord[o] - h.equity) < 1e-6, (
|
||
f"2-edge sim equity != trusted nav on {h.run_date}: "
|
||
f"{sim_eq_by_ord[o]} vs {h.equity}")
|
||
|
||
# The derived tracks compute over the SAME persisted returns (no new persistence).
|
||
res_3 = simulate_book(returns, capital=100_000.0, sleeves=list(BOOK_CONFIGS["3-edge"]))
|
||
res_x = simulate_book(returns, capital=100_000.0, sleeves=list(BOOK_CONFIGS["xsfunding"]))
|
||
assert res_3.dates and res_3.equity, "3-edge derived track must compute a non-empty curve"
|
||
assert res_x.dates and res_x.equity, "xsfunding-solo derived track must compute a non-empty curve"
|
||
# 3-edge sees xsfunding (the carry sleeve), 2-edge does not → their weight sets differ.
|
||
assert any("xsfunding" in w for w in res_3.weights)
|
||
assert all("xsfunding" not in w for w in res_2.weights)
|