diff --git a/src/fxhnt/adapters/data/binance_funding.py b/src/fxhnt/adapters/data/binance_funding.py index ee08131..ed5a70a 100644 --- a/src/fxhnt/adapters/data/binance_funding.py +++ b/src/fxhnt/adapters/data/binance_funding.py @@ -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 diff --git a/src/fxhnt/application/paper_strategies.py b/src/fxhnt/application/paper_strategies.py index 591da8d..06cd818 100644 --- a/src/fxhnt/application/paper_strategies.py +++ b/src/fxhnt/application/paper_strategies.py @@ -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} diff --git a/src/fxhnt/domain/strategies/funding_carry.py b/src/fxhnt/domain/strategies/funding_carry.py index 7812847..10f2089 100644 --- a/src/fxhnt/domain/strategies/funding_carry.py +++ b/src/fxhnt/domain/strategies/funding_carry.py @@ -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) diff --git a/src/fxhnt/ports/market_data.py b/src/fxhnt/ports/market_data.py index 4022e10..0e756df 100644 --- a/src/fxhnt/ports/market_data.py +++ b/src/fxhnt/ports/market_data.py @@ -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): diff --git a/tests/integration/test_paper_strategies.py b/tests/integration/test_paper_strategies.py index afae396..66f4968 100644 --- a/tests/integration/test_paper_strategies.py +++ b/tests/integration/test_paper_strategies.py @@ -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