fix(b1): funding port faithful — qualify on tf30 (30d mean), crypto-native+hedgeable universe

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-06-16 07:08:39 +02:00
parent 84bfb2110e
commit e2168ee0ef
5 changed files with 132 additions and 93 deletions

View File

@@ -1,9 +1,11 @@
"""urllib BinanceFundingClient — Binance USDⓈ-M futures public API (no key, no capital). Faithful to
foxhunt scripts/surfer/crypto_funding_paper.py's universe/funding/liquidity fetches.
foxhunt scripts/surfer/crypto_funding_paper.py's scan() (lines 48-92): the hedgeable, liquid,
crypto-native universe + trailing-30d-mean funding (tf30) + today's booking rate (last24).
* universe() -> USDT perps from /fapi/v1/exchangeInfo (TRADING status)
* funding_daily(sym) -> sum of the most-recent INTERVALS_PER_DAY funding intervals (8h settles → /day)
* quote_volume_usd(s) -> 24h quoteVolume from /fapi/v1/ticker/24hr
* crypto_native() -> symbols with underlyingType == "COIN" from /fapi/v1/exchangeInfo
* spot_symbols() -> symbols with a Binance SPOT market (api.binance.com/api/v3/ticker/price)
* universe() -> {sym: quoteVolume} for crypto-native, spot-hedgeable USDT perps with vol > LIQ_USD
* scan(prev) -> (liq, tf30, last24) over union of the universe and prev positions
Retries with backoff; mirrors the original `get()` helper and the yahoo_daily `_get` style."""
from __future__ import annotations
@@ -34,18 +36,51 @@ class BinanceFundingClient:
time.sleep(2 * (a + 1))
raise RuntimeError("unreachable")
def universe(self) -> list[str]:
def crypto_native(self) -> set[str]:
"""Symbols whose underlyingType is COIN (original crypto_native(), line 48-50)."""
info = self._get(f"{_FAPI}/fapi/v1/exchangeInfo")
return [
s["symbol"]
for s in info["symbols"]
if s.get("status") == "TRADING" and s["symbol"].endswith("USDT")
]
return {s["symbol"] for s in info["symbols"] if s.get("underlyingType") == "COIN"}
def funding_daily(self, symbol: str) -> float:
h = self._get(f"{_FAPI}/fapi/v1/fundingRate?symbol={symbol}&limit={INTERVALS_PER_DAY}")
return sum(float(x["fundingRate"]) for x in h[-INTERVALS_PER_DAY:])
def spot_symbols(self) -> set[str]:
"""Symbols with a Binance SPOT market — required to build the delta-neutral hedge
(original spot_symbols(), line 53-57). Perp-only coins can't be cash-and-carry harvested."""
return {x["symbol"] for x in self._get("https://api.binance.com/api/v3/ticker/price")}
def quote_volume_usd(self, symbol: str) -> float:
t = self._get(f"{_FAPI}/fapi/v1/ticker/24hr?symbol={symbol}")
return float(t["quoteVolume"])
def universe(self) -> dict[str, float]:
"""Liquid, crypto-native, HEDGEABLE (spot+perp) USDT perps (original universe(), line 60-68)."""
native = self.crypto_native()
spot = self.spot_symbols()
t = self._get(f"{_FAPI}/fapi/v1/ticker/24hr")
return {
x["symbol"]: float(x["quoteVolume"])
for x in t
if x["symbol"].endswith("USDT")
and x["symbol"] in native
and x["symbol"] in spot
and float(x["quoteVolume"]) > self._liq_usd
}
def _funding_hist(self, sym: str, limit: int = 90) -> list[float]:
"""Funding-rate history (original funding_hist(), line 71-73)."""
h = self._get(f"{_FAPI}/fapi/v1/fundingRate?symbol={sym}&limit={limit}")
return [float(x["fundingRate"]) for x in h]
def scan(self, prev: set[str]) -> tuple[dict[str, float], dict[str, float], dict[str, float]]:
"""(liq, tf30, last24) for the union of the universe and prev positions (original scan(),
line 76-92)."""
liq = self.universe()
need = set(liq) | set(prev)
tf30: dict[str, float] = {}
last24: dict[str, float] = {}
for i, sym in enumerate(sorted(need)):
try:
h = self._funding_hist(sym)
except Exception: # noqa: BLE001 — skip transient per-symbol failures
continue
if len(h) < 30:
continue
tf30[sym] = (sum(h) / len(h)) * INTERVALS_PER_DAY
last24[sym] = sum(h[-INTERVALS_PER_DAY:])
if i % 50 == 49:
time.sleep(0.5)
return liq, tf30, last24

View File

@@ -58,14 +58,11 @@ class FundingCarryStrategy:
def advance(self, last_date: str | None, extra: dict[str, Any]) -> tuple[list[tuple[str, float]], dict[str, Any]]:
prev: dict[str, float] = extra.get("positions", {})
syms = self._client.universe()
funding = {s: self._client.funding_daily(s) for s in syms}
volume = {s: self._client.quote_volume_usd(s) for s in syms}
liq, tf30, last24 = self._client.scan(set(prev))
q = funding_carry.qualify(funding, volume, held=set(prev))
q = funding_carry.qualify(liq, tf30, prev)
n = len(q)
newpos = {c: 1.0 / n for c in q} if n else {}
realized = funding_carry.book_return(prev, funding, newpos)
net = funding_carry.book_return(prev, last24, newpos)
rows = [(self._clock(), realized)] if prev else []
return rows, {"positions": newpos}
return [(self._clock(), net)], {"positions": newpos}

View File

@@ -2,18 +2,18 @@
scripts/surfer/crypto_funding_paper.py (qualify + cmd_run booking). No I/O.
Constants and logic match the original exactly:
* HURDLE = 5e-4 open a position when trailing daily funding > 5 bp/day
* EXIT_HURDLE = 3e-4 hysteresis: keep a HELD coin until funding falls below 3 bp/day
* LIQ_USD = 5e6 only coins with > $5M/day quote volume are in the liquid universe
* HURDLE = 5e-4 open a position when trailing-30d mean daily funding (tf30) > 5 bp/day
* EXIT_HURDLE = 3e-4 hysteresis: keep a HELD coin until its tf30 falls below 3 bp/day
* LIQ_USD = 5e6 only coins with > $5M/day quote volume are in the liquid universe (in `liq`)
* COST_RT = 1e-3 10 bp round-trip; the run books realized turnover * (COST_RT/2)
Original `qualify(liq, tf30, prev)` (lines 95-101): loops over the *liquid* universe `liq`
(already volume-filtered in `universe()`), keeping `c` when `t > HURDLE` or
(`c in prev and t > EXIT_HURDLE`). Held coins that drop out of the liquid universe are NOT
kept (the loop is over `liq` only) — we replicate that by applying the liquidity gate first.
(already volume-filtered + crypto-native + spot-hedgeable in `universe()`/`scan()`), keeping `c` when
its TRAILING-30d-MEAN funding `tf30[c] > HURDLE` or (`c in prev and tf30[c] > EXIT_HURDLE`). Returns
`{c: tf30[c]}`. Held coins outside the liquid universe are NOT kept (the loop is over `liq` only).
Original booking (cmd_run lines 136-141):
realized = sum(w * last24[c] for c,w in prev.items()) # equal-weight carry on prior book
realized = sum(w * last24[c] for c,w in prev.items()) # equal-weight carry on prior book (TODAY's rate)
turnover = sum(|newpos[c] - prev[c]| over union)
net = realized - turnover * (COST_RT / 2)
"""
@@ -26,36 +26,36 @@ COST_RT = 1e-3
def qualify(
funding: dict[str, float],
volume: dict[str, float],
held: set[str],
liq: dict[str, float],
tf30: dict[str, float],
prev: dict[str, float],
hurdle: float = HURDLE,
exit_hurdle: float = EXIT_HURDLE,
liq: float = LIQ_USD,
) -> list[str]:
"""Coins to hold today. Liquidity-gated first (mirrors the original `liq` universe),
then hysteresis: open new coins above `hurdle`, keep held coins still above `exit_hurdle`."""
out: list[str] = []
for c, t in funding.items():
if volume.get(c, 0.0) <= liq: # original `universe()` keeps only quoteVolume > LIQ_USD
continue
if t > hurdle or (c in held and t > exit_hurdle):
out.append(c)
return out
) -> dict[str, float]:
"""Coins to hold today, qualified on the TRAILING-30d-MEAN funding `tf30` (NOT today's rate).
Liquidity/native/hedgeability are already enforced by `liq` (the keys of `liq`). Then hysteresis:
open new coins whose tf30 > `hurdle`, keep held coins whose tf30 is still > `exit_hurdle`.
Returns `{c: tf30[c]}` (faithful to original lines 95-101)."""
q: dict[str, float] = {}
for c in liq:
t = tf30.get(c)
if t is not None and (t > hurdle or (c in prev and t > exit_hurdle)):
q[c] = t
return q
def book_return(
prev_positions: dict[str, float],
funding: dict[str, float],
new_positions: dict[str, float],
prev: dict[str, float],
last24: dict[str, float],
newpos: dict[str, float],
cost_rt: float = COST_RT,
) -> float:
"""Net realized carry: equal-weight realized funding on the PRIOR book at today's funding,
minus the round-trip cost on turnover between prior and new books. Faithful to cmd_run:
`net = realized - turnover * (COST_RT / 2)`."""
realized = sum(w * funding.get(c, 0.0) for c, w in prev_positions.items())
"""Net realized carry: equal-weight realized funding on the PRIOR book at TODAY's booking rate
`last24`, minus the round-trip cost on turnover between the prior and new books. Faithful to
cmd_run (lines 136-141): `net = realized - turnover * (COST_RT / 2)`."""
realized = sum(w * last24.get(c, 0.0) for c, w in prev.items())
turnover = sum(
abs(new_positions.get(c, 0.0) - prev_positions.get(c, 0.0))
for c in set(new_positions) | set(prev_positions)
abs(newpos.get(c, 0.0) - prev.get(c, 0.0))
for c in set(newpos) | set(prev)
)
return realized - turnover * (cost_rt / 2)

View File

@@ -13,11 +13,12 @@ class DailyBarClient(Protocol):
class BinanceFundingClient(Protocol):
def universe(self) -> list[str]: ...
def funding_daily(self, symbol: str) -> float:
"""Most-recent funding as a per-DAY rate (sum of the day's intervals)."""
def scan(self, prev: set[str]) -> tuple[dict[str, float], dict[str, float], dict[str, float]]:
"""(liq, tf30, last24) for the union of the hedgeable liquid universe and prev positions.
liq: {symbol: 24h_quote_volume_usd} for crypto-native, spot-hedgeable USDT perps with vol>LIQ_USD.
tf30: {symbol: 30d-mean funding × INTERVALS_PER_DAY} (daily rate; only symbols with >=30 history).
last24: {symbol: sum of the most recent INTERVALS_PER_DAY funding intervals} (today's booking rate)."""
...
def quote_volume_usd(self, symbol: str) -> float: ...
class CrossVenueFundingClient(Protocol):

View File

@@ -23,20 +23,23 @@ class FakeDailyBars:
class FakeBinanceFunding:
"""Deterministic BinanceFundingClient: fixed universe + per-symbol daily funding + 24h quote volume."""
"""Deterministic BinanceFundingClient: fixed (liq, tf30, last24) scan output.
def __init__(self, funding: dict[str, float], volume: dict[str, float]) -> None:
self._funding = funding
self._volume = volume
liq is the liquid/native/hedgeable universe ({sym: 24h_quote_volume}); tf30 is the trailing-30d-mean
daily funding (the qualify signal); last24 is today's booking rate (the book_return signal)."""
def universe(self) -> list[str]:
return list(self._funding)
def __init__(
self,
liq: dict[str, float],
tf30: dict[str, float],
last24: dict[str, float],
) -> None:
self._liq = liq
self._tf30 = tf30
self._last24 = last24
def funding_daily(self, symbol: str) -> float:
return self._funding[symbol]
def quote_volume_usd(self, symbol: str) -> float:
return self._volume[symbol]
def scan(self, prev: set[str]) -> tuple[dict[str, float], dict[str, float], dict[str, float]]:
return dict(self._liq), dict(self._tf30), dict(self._last24)
def test_sixtyforty_daily_return_is_weighted_mean() -> None:
@@ -158,38 +161,41 @@ def test_growth_discipline_service_round_trips_through_reader(tmp_path) -> None:
assert summary.days >= 1
def test_funding_qualify_hysteresis_and_book_return() -> None:
def test_funding_qualify_on_tf30_and_book_return_on_last24() -> None:
from fxhnt.domain.strategies.funding_carry import book_return, qualify
# Liquidity 10M > LIQ_USD 5e6 for all three.
volume = {"AAAUSDT": 10e6, "BBBUSDT": 10e6, "CCCUSDT": 10e6}
# AAA above hurdle (new opens); BBB between exit(3bp) and hurdle(5bp) (only stays if held);
# CCC below exit (never).
funding = {"AAAUSDT": 0.0006, "BBBUSDT": 0.0004, "CCCUSDT": 0.0002}
# `liq` is the already-filtered liquid/native/hedgeable universe (keys = qualify candidates,
# values = 24h quote volume, unused by qualify).
liq = {"AAAUSDT": 10e6, "BBBUSDT": 10e6, "DDDUSDT": 10e6}
# qualify reads tf30 (trailing-30d MEAN), NOT today's last24.
# AAA: tf30 above hurdle (new opens)
# BBB: tf30 between exit(3bp) and hurdle(5bp) (only stays if held)
# DDD: tf30 below exit, BUT a huge last24 today — must NOT qualify (qualify ignores last24).
tf30 = {"AAAUSDT": 0.0006, "BBBUSDT": 0.0004, "DDDUSDT": 0.0002}
last24 = {"AAAUSDT": 0.0006, "BBBUSDT": 0.0004, "DDDUSDT": 0.0090} # DDD spikes today but low tf30
# No prior holdings: only AAA qualifies (BBB below hurdle, CCC below exit).
assert qualify(funding, volume, held=set()) == ["AAAUSDT"]
# BBB held and still above exit_hurdle → kept; AAA still opens; CCC dropped.
assert sorted(qualify(funding, volume, held={"BBBUSDT"})) == ["AAAUSDT", "BBBUSDT"]
# A low-liquidity coin is never qualified even if held and above exit.
low_vol = dict(volume, BBBUSDT=1e6)
assert qualify(funding, low_vol, held={"BBBUSDT"}) == ["AAAUSDT"]
# No prior holdings: only AAA qualifies (BBB below hurdle, DDD below exit despite high last24).
assert qualify(liq, tf30, prev={}) == {"AAAUSDT": 0.0006}
# BBB held and still above exit_hurdle on tf30 → kept; AAA still opens; DDD dropped.
assert sorted(qualify(liq, tf30, prev={"BBBUSDT": 0.5})) == ["AAAUSDT", "BBBUSDT"]
# A held coin whose tf30 is high last24 but low tf30 still does NOT qualify (proves tf30 is the gate).
assert qualify(liq, tf30, prev={"DDDUSDT": 0.5}) == {"AAAUSDT": 0.0006}
# book_return: prior equal-weight book of 2 coins (w=0.5 each), today's funding,
# rebalance to a single-coin book → realized minus cost on turnover (COST_RT/2).
# book_return uses last24 (today's booking rate), NOT tf30: prior equal-weight book of 2 coins
# (w=0.5 each), rebalance to a single-coin book → realized minus cost on turnover (COST_RT/2).
prev = {"AAAUSDT": 0.5, "BBBUSDT": 0.5}
new = {"AAAUSDT": 1.0}
today_funding = {"AAAUSDT": 0.0006, "BBBUSDT": 0.0004}
realized = 0.5 * 0.0006 + 0.5 * 0.0004
newpos = {"AAAUSDT": 1.0}
realized = 0.5 * 0.0006 + 0.5 * 0.0004 # last24 of AAA and BBB
turnover = abs(1.0 - 0.5) + abs(0.0 - 0.5) # AAA up 0.5, BBB out 0.5
expected = realized - turnover * (1e-3 / 2)
assert abs(book_return(prev, today_funding, new) - expected) < 1e-15
assert abs(book_return(prev, last24, newpos) - expected) < 1e-15
def test_funding_service_round_trips_through_reader(tmp_path) -> None:
volume = {"AAAUSDT": 10e6, "BBBUSDT": 10e6}
funding = {"AAAUSDT": 0.0006, "BBBUSDT": 0.0006}
fake = FakeBinanceFunding(funding, volume)
liq = {"AAAUSDT": 10e6, "BBBUSDT": 10e6}
tf30 = {"AAAUSDT": 0.0006, "BBBUSDT": 0.0006} # both above hurdle on the 30d mean
last24 = {"AAAUSDT": 0.0006, "BBBUSDT": 0.0006} # today's booking rate
fake = FakeBinanceFunding(liq, tf30, last24)
p = str(tmp_path / "funding_state.json")
# Live-booking books rows dated by the wall clock. On the freeze run rows are empty so the tracker
@@ -203,9 +209,9 @@ def test_funding_service_round_trips_through_reader(tmp_path) -> None:
loaded0 = json.loads(Path(p).read_text())
assert loaded0["extra"]["positions"] == {"AAAUSDT": 0.5, "BBBUSDT": 0.5}
# Next day: bump funding so the booked carry differs; second step books exactly one row.
fake._funding["AAAUSDT"] = 0.0008
fake._funding["BBBUSDT"] = 0.0008
# Next day: bump today's booking rate so the booked carry differs; second step books exactly one row.
fake._last24["AAAUSDT"] = 0.0008
fake._last24["BBBUSDT"] = 0.0008
day[0] = "2999-01-01" # strictly after the frozen inception → booked
st1 = ForwardTracker(FundingCarryStrategy(fake, clock=lambda: day[0]), p).step()
assert st1.forward_days == 1