From 17da65c6bd25d83e47de358598ed127afa871383 Mon Sep 17 00:00:00 2001 From: jgrusewski Date: Thu, 18 Jun 2026 23:29:11 +0200 Subject: [PATCH] fix(xsfunding): charge exit cost for dropped-out holdings; expose n_rebalances vs panel_days Co-Authored-By: Claude Opus 4.8 (1M context) --- .../application/funding_backtest_runner.py | 15 +++- src/fxhnt/domain/cross_sectional_funding.py | 3 + .../test_funding_backtest_runner.py | 79 ++++++++++++++++++- 3 files changed, 94 insertions(+), 3 deletions(-) diff --git a/src/fxhnt/application/funding_backtest_runner.py b/src/fxhnt/application/funding_backtest_runner.py index 3f8839f..1d0e633 100644 --- a/src/fxhnt/application/funding_backtest_runner.py +++ b/src/fxhnt/application/funding_backtest_runner.py @@ -18,7 +18,8 @@ _MODES = ("long_tilt", "market_neutral", "executable") class FundingRunResult: returns_by_mode: dict[str, np.ndarray] n_names_avg: float - trading_days: int + panel_days: int # full panel span (incl. days skipped for <4-coin cross-section) + n_rebalances: int # active rebalances actually booked (= len of each mode's series) class FundingBacktestRunner: @@ -37,6 +38,12 @@ class FundingBacktestRunner: slip = min(self._slip_cap, self._slip_coef * (self._liq_ref / qvol)) if qvol > 0 else self._slip_cap return self._cost + slip + def _last_qvol(self, sym: str, day: int) -> float: + """Most recent qvol on/before `day` for `sym` (held coins that dropped out still have history).""" + series = self._p.get(sym, {}) + days = [x for x in series if x <= day] + return series[max(days)][1] if days else 0.0 + def run(self) -> FundingRunResult: all_days = sorted({d for s in self._p.values() for d in s}) series: dict[str, list[float]] = {m: [] for m in _MODES} @@ -51,7 +58,10 @@ class FundingBacktestRunner: scores = funding_score(self._p, elig, d, lookback_days=self._lb) borrowable = {s for s in elig if self._p[s].get(d, (0.0, 0.0, 0.0))[1] >= self._borrow_q} funding_next = {s: self._p[s][nxt][2] for s in elig if nxt in self._p[s]} + held = set().union(*(prev_w[m] for m in _MODES)) if any(prev_w[m] for m in _MODES) else set() cost = {s: self._coin_cost(self._p[s][d][1]) for s in elig} + for s in held - set(cost): + cost[s] = self._coin_cost(self._last_qvol(s, d)) # exit cost for dropped-out holdings names_seen += len(elig) n_rebals += 1 for m in _MODES: @@ -61,7 +71,8 @@ class FundingBacktestRunner: return FundingRunResult( returns_by_mode={m: np.asarray(series[m], dtype=float) for m in _MODES}, n_names_avg=(names_seen / n_rebals) if n_rebals else 0.0, - trading_days=len(all_days), + panel_days=len(all_days), + n_rebalances=n_rebals, ) diff --git a/src/fxhnt/domain/cross_sectional_funding.py b/src/fxhnt/domain/cross_sectional_funding.py index e0d7b0a..e4b7cbd 100644 --- a/src/fxhnt/domain/cross_sectional_funding.py +++ b/src/fxhnt/domain/cross_sectional_funding.py @@ -82,6 +82,9 @@ def construction_weights(scores: dict[str, float], mode: str, *, quantile: float for s in top: w[s] = 1.0 / len(top) shorts = [s for s in bot if borrowable is None or s in borrowable] + # If no bottom-quantile name is borrowable, `shorts` is empty -> short leg is empty and + # the book degrades to long_tilt (sum=1, no shorts). Intended: you cannot short what you + # cannot borrow, so the executable book holds only the long leg. for s in shorts: w[s] = w.get(s, 0.0) - 1.0 / len(shorts) else: diff --git a/tests/integration/test_funding_backtest_runner.py b/tests/integration/test_funding_backtest_runner.py index 63520e2..8c2c399 100644 --- a/tests/integration/test_funding_backtest_runner.py +++ b/tests/integration/test_funding_backtest_runner.py @@ -1,5 +1,5 @@ import numpy as np -from fxhnt.application.funding_backtest_runner import FundingBacktestRunner +from fxhnt.application.funding_backtest_runner import FundingBacktestRunner, _book def _panel(): @@ -26,3 +26,80 @@ def test_runner_deterministic(): r2 = FundingBacktestRunner(_panel(), min_qvol=1e6, min_history=20, lookback_days=7, quantile=0.5, cost_bps=8.0, slip_coef=0.0).run().returns_by_mode["long_tilt"] assert np.array_equal(r1, r2) + + +def test_result_reports_panel_days_and_n_rebalances(): + # Panel of 4 coins, 200 bars each. min_history=20 => first ~19 days skipped (<4 elig + # by history), but once all 4 qualify every remaining day is an active rebalance. + res = FundingBacktestRunner(_panel(), min_qvol=1e6, min_history=20, lookback_days=7, + quantile=0.5, cost_bps=8.0, slip_coef=0.0).run() + n_rebals = len(res.returns_by_mode["long_tilt"]) + assert res.n_rebalances == n_rebals + # panel_days is the full panel span (200 distinct days), strictly more than active rebalances + assert res.panel_days == 200 + assert res.panel_days > res.n_rebalances + + +def _drop_panel(): + """Synthetic panel where FALL is liquid (and top-quantile long, funding=0.0030) for an early + window, then its qvol craters so its trailing-30 mean falls below min_qvol and it leaves + `elig` while STILL HELD in prev_w — exactly the dropped-out-holding case FIX #1 covers.""" + panel: dict[str, dict[int, tuple[float, float, float]]] = {} + n_days, thr = 90, 25 + # Four always-liquid coins so the cross-section stays >= 4 names after FALL drops out. + fund = {"HI": 0.0020, "MID2": 0.0010, "MID": 0.0005, "NEG": -0.0015} + for sym, f in fund.items(): + panel[sym] = {1000 + i: (1.0, 5e6, f) for i in range(n_days)} + fall: dict[int, tuple[float, float, float]] = {} + for i in range(n_days): + qv = 5e6 if i < thr else 1.0 # liquid early, then near-zero qvol + fall[1000 + i] = (1.0, qv, 0.0030) # highest funding -> top-quantile long while eligible + panel["FALL"] = fall + return panel + + +def test_dropped_out_holding_charged_nonzero_exit_cost(monkeypatch): + # FIX #1: a held coin (FALL) that drops OUT of `elig` must still be charged its exit cost. + # Spy on _book to capture the cost dict on the rebalance where FALL is in prev_w but no + # longer in the new weights (the forced-exit day) and assert a strictly positive exit cost. + import fxhnt.application.funding_backtest_runner as mod + + captured: list[dict[str, float]] = [] + orig = mod._book + + def spy(w, prev_w, funding_next, cost): + if "FALL" in prev_w and "FALL" not in w: # FALL held last period, gone this period + captured.append(dict(cost)) + return orig(w, prev_w, funding_next, cost) + + monkeypatch.setattr(mod, "_book", spy) + FundingBacktestRunner(_drop_panel(), min_qvol=1e6, min_history=20, lookback_days=7, + quantile=0.5, cost_bps=8.0, slip_coef=0.0005, slip_cap_bps=50.0).run() + + assert captured, "expected at least one forced-exit rebalance (FALL held then dropped out)" + exit_cost = captured[0].get("FALL", 0.0) + assert exit_cost > 0.0, "dropped-out holding must carry a non-zero exit cost (FIX #1)" + # FALL's last-known qvol on the drop day is the post-crater 1.0 -> slippage hits the cap, + # so the exit cost is the conservative maximum (taker fee + slip_cap), strictly positive. + assert exit_cost == 0.0008 + 0.005 # cost_bps 8 -> 8e-4 ; slip_cap 50bps -> 5e-3 + + +def test_book_carry_and_turnover(): + # carry on the NEW book = sum w * funding_next + w = {"A": 0.5, "B": -0.5} + prev: dict[str, float] = {} # first rebalance: prev empty -> full setup turnover + funding_next = {"A": 0.004, "B": -0.002} + cost = {"A": 0.001, "B": 0.001} + r = _book(w, prev, funding_next, cost) + carry = 0.5 * 0.004 + (-0.5) * (-0.002) # = 0.003 + turnover = (abs(0.5 - 0.0) * 0.001 + abs(-0.5 - 0.0) * 0.001) / 2.0 # full setup = 0.0005 + assert abs(r - (carry - turnover)) < 1e-12 + + # Steady-state: identical book -> zero turnover, pure carry. + r2 = _book(w, w, funding_next, cost) + assert abs(r2 - carry) < 1e-12 + + # Full rotation A->B charges turnover on both legs. + r3 = _book({"B": 1.0}, {"A": 1.0}, {"B": 0.0}, {"A": 0.002, "B": 0.002}) + expected_turn = (abs(1.0) * 0.002 + abs(-1.0) * 0.002) / 2.0 # = 0.002 + assert abs(r3 - (0.0 - expected_turn)) < 1e-12