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