Files
fxhnt/tests/integration/test_paper_backfill.py
jgrusewski 3de029eaf9 perf(backfill): incremental refresh + batched I/O (per-date round-trips were ~65% of runtime; output bit-identical)
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>
2026-06-23 19:32:08 +02:00

943 lines
49 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)