fix(xsfunding): charge exit cost for dropped-out holdings; expose n_rebalances vs panel_days

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-06-18 23:29:11 +02:00
parent 0f115abbf1
commit 17da65c6bd
3 changed files with 94 additions and 3 deletions

View File

@@ -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,
)

View File

@@ -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:

View File

@@ -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