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:
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user