diff --git a/cli.py b/cli.py index 89fc597..ba703fb 100644 --- a/cli.py +++ b/cli.py @@ -499,6 +499,52 @@ def cmd_discover(args): print(f"\nPipeline ready. Run 'python -m cli tick' to backtest strategies on this data.") +def cmd_funding(args): + """Funding rate arb discovery — analyze historical funding rates and run backtests.""" + from strategies.funding_arb_strategy import run_funding_discovery + import json as _json + + result = run_funding_discovery( + data_dir=args.data_dir, + coin=args.coin, + start_date=args.start_date, + end_date=args.end_date, + ) + + if "error" in result: + print(f"Error: {result['error']}") + return + + print(f"\n{'═' * 60}") + print(f" Funding Rate Analysis — {args.coin}") + print(f" {result['n_observations']:,} observations") + print(f"{'═' * 60}") + + dist = result["rate_distribution"] + print(f"\n Rate Distribution (annualized):") + print(f" Mean: {dist['mean_apr_pct']:>8.2f}%") + print(f" Std: {dist['std_apr_pct']:>8.2f}%") + print(f" Max: {dist['max_apr_pct']:>8.2f}%") + print(f" Min: {dist['min_apr_pct']:>8.2f}%") + print(f"\n Absolute Rate Percentiles:") + for k, v in dist["abs_percentiles"].items(): + print(f" {k}: {v:>8.2f}%") + + print(f"\n{'─' * 60}") + print(f" Backtest Results by Threshold:") + print(f" {'Threshold':>12s} {'Trades':>7s} {'Win Rate':>9s} " + f"{'Net PnL':>10s} {'Avg PnL':>10s} {'Avg Hold':>9s}") + print(f" {'─' * 60}") + for label, bt in result.get("backtests", {}).items(): + print(f" {label:>12s} {bt['total_trades']:>7d} " + f"{bt['win_rate']:>8.1%} " + f"${bt['total_net_pnl']:>9.4f} ${bt['avg_net_pnl']:>9.4f} " + f"{bt['avg_hold_hours']:>8.1f}h") + + print(f"\n Run 'python -m cli collect --mainnet' to gather data.") + print(f" Then 'python -m cli funding --coin BTC' to re-run.") + + def main(): import argparse p = argparse.ArgumentParser(description="FTDT Quant Lab CLI") @@ -578,6 +624,13 @@ def main(): pd.add_argument("--end-date", default="2026-08-07") pd.add_argument("--horizons", default="100,500,1000,5000,10000", help="Comma-separated ms horizons") + # funding + pf = sp.add_parser("funding", help="Funding rate arb discovery — analyze historical funding rates") + pf.add_argument("--data-dir", default="data/raw") + pf.add_argument("--coin", default="BTC") + pf.add_argument("--start-date", default="2026-01-01") + pf.add_argument("--end-date", default="2030-01-01") + args = p.parse_args() import json as _json @@ -598,6 +651,8 @@ def main(): cmd_tick_backtest(args) elif args.command == "discover": cmd_discover(args) + elif args.command == "funding": + cmd_funding(args) if __name__ == "__main__": diff --git a/live/node_v2.py b/live/node_v2.py index 9ff3165..cd5dd10 100644 --- a/live/node_v2.py +++ b/live/node_v2.py @@ -27,13 +27,16 @@ from typing import Optional sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) -from live.treasury import Treasury +from sim.maker import AvellanedaStoikovMaker, MakerConfig, Quote +from sim.queue import QueueModel from live.filters.toxicity import ToxicityFilter -from live.makers.hl_btc_eth import HlMakerPool +from live.treasury import Treasury from live.integrator import AnalyticsPipeline from live.monitors.cross_venue import CrossVenueMonitor from live.monitors.funding_basis import FundingBasisMonitor from live.monitors.liq_risk import LiquidationRiskOverlay +from sim.fills import QueueAwareFillModel +from live.makers.hl_btc_eth import HlMakerPool logger = logging.getLogger("ftdt-node-v2") @@ -103,6 +106,25 @@ class ProductionNode: for coin in coins: self._maker_pool.add_maker(coin.upper(), max_inventory=max_position_per_coin) + # Queue-aware fill model for realistic paper trading + self._fill_model = QueueAwareFillModel() + + # WQI predictor — directional strategy from queue imbalance + from strategies.wqi_predictor import WQIPredictor + self._wqi_predictors = { + coin: WQIPredictor(z_entry=2.0, max_hold_seconds=30, + stop_loss_bps=5.0, take_profit_bps=10.0, + size=base_quote_size, fee_model="taker") + for coin in coins + } + + # Funding arb strategy + from strategies.funding_arb_strategy import FundingArb + self._funding_arb = FundingArb( + apr_threshold=0.30, apr_exit=0.10, size=base_quote_size * 5, + max_hold_hours=48.0, taker_fee_pct=0.00045, + ) + # Monitors self._cross_venue = CrossVenueMonitor() self._funding_monitor = FundingBasisMonitor() @@ -184,15 +206,17 @@ class ProductionNode: # 6. Generate quotes quotes = self._maker_pool.quote_all() - # 7. Simulate fills (paper mode — mark-based) + # 7. Simulate fills (paper mode — queue-aware) if self._mode == "paper": for coin in self._coins: q = quotes.get(coin) + pipeline = self._pipelines[coin] if q: - pipeline = self._pipelines[coin] self._simulate_paper_fills(coin, q, pipeline) + if self._tick % 3 == 0: + self._simulate_wqi_trades(coin, pipeline) - # 8. Update funding monitor + # 8. Update funding monitor and check funding arb for coin in self._coins: funding = await self._fetch_funding(coin) if funding is not None: @@ -270,19 +294,30 @@ class ProductionNode: # ── Paper trading ──────────────────────────────────────── def _simulate_paper_fills(self, coin: str, quote, pipeline: AnalyticsPipeline): - """Naive paper fill: if our bid > mid or ask < mid after some random threshold, - simulate a fill. In production this comes from exchange WebSocket.""" - import random mid = pipeline.mid - if mid <= 0: + if mid <= 0 or quote is None: return - if random.random() < 0.05: - side = "bid" if random.random() < 0.5 else "ask" - size = getattr(quote, f"{side}_size", 0.001) - px = getattr(quote, side, mid) + bid_fill = self._fill_model.check_fill( + aggressor_side="sell", + agg_size=pipeline._depth_ask or 0.1, + agg_price=max(getattr(quote, "bid", mid) - 1, 1), + our_price=getattr(quote, "bid", mid), + our_size=getattr(quote, "bid_size", 0.0002), + depth_ahead=self._fill_model.estimate_depth_ahead( + our_price=getattr(quote, "bid", mid), + our_side="bid", + best_bid=pipeline._best_bid, + best_ask=pipeline._best_ask, + bid_depth=pipeline._depth_bid or 1.0, + ask_depth=pipeline._depth_ask or 1.0, + ), + ) + if bid_fill["filled"]: + side = "buy" + size = bid_fill["fill_size"] + px = getattr(quote, "bid", mid) fee = size * px * 0.0002 - can = self._treasury.can_open(coin, side, size, px) if can["allowed"]: self._treasury.record_fill(coin, side, size, px, fee, pnl=0) @@ -290,6 +325,71 @@ class ProductionNode: if maker: maker.record_fill(side, size, px, fee) + ask_fill = self._fill_model.check_fill( + aggressor_side="buy", + agg_size=pipeline._depth_bid or 0.1, + agg_price=min(getattr(quote, "ask", mid) + 1, mid * 2), + our_price=getattr(quote, "ask", mid), + our_size=getattr(quote, "ask_size", 0.0002), + depth_ahead=self._fill_model.estimate_depth_ahead( + our_price=getattr(quote, "ask", mid), + our_side="ask", + best_bid=pipeline._best_bid, + best_ask=pipeline._best_ask, + bid_depth=pipeline._depth_bid or 1.0, + ask_depth=pipeline._depth_ask or 1.0, + ), + ) + if ask_fill["filled"]: + side = "sell" + size = ask_fill["fill_size"] + px = getattr(quote, "ask", mid) + fee = size * px * 0.0002 + can = self._treasury.can_open(coin, side, size, px) + if can["allowed"]: + self._treasury.record_fill(coin, side, size, px, fee, pnl=0) + maker = self._maker_pool.get(coin) + if maker: + maker.record_fill(side, size, px, fee) + + def _simulate_wqi_trades(self, coin: str, pipeline: AnalyticsPipeline): + mid = pipeline.mid + if mid <= 0: + return + + predictor = self._wqi_predictors.get(coin) + if predictor is None: + return + + bids_list = [(pipeline._best_bid, pipeline._depth_bid or 1.0)] + asks_list = [(pipeline._best_ask, pipeline._depth_ask or 1.0)] + + signal = predictor.feed_signal(bids_list, asks_list, mid) + + if signal["action"] in ("BUY", "SELL"): + side = signal["action"].lower() + size = 0.0002 + px = mid + fee = size * px * 0.0005 + can = self._treasury.can_open(coin, side, size, px) + if can["allowed"]: + self._treasury.record_fill(coin, side, size, px, fee, pnl=0) + fee_paid = size * px * 0.0005 + self._treasury._fees_paid += fee_paid + logger.info(f"[WQI-{coin}] {signal['action']} signal: " + f"z={signal['z_score']:.2f} wqi={signal['wqi']:.3f} " + f"reason={signal['reason']}") + + elif signal["action"] == "EXIT": + pos = self._treasury.position(coin) + if abs(pos) > 0: + side = "sell" if pos > 0 else "buy" + fee = abs(pos) * mid * 0.0005 + pnl = pos * (mid - predictor._entry_price) if predictor._entry_price > 0 else 0 + self._treasury.record_fill(coin, side, abs(pos), mid, fee, pnl) + logger.info(f"[WQI-{coin}] EXIT: z={signal['z_score']:.2f} " + f"pnl=${pnl:.4f} reason={signal['reason']}") + # ── Dashboard ──────────────────────────────────────────── def _write_metrics(self): @@ -305,6 +405,13 @@ class ProductionNode: "treasury": self._treasury.summary(), "analytics": {c: p.emit() for c, p in self._pipelines.items()}, "maker": self._maker_pool.summary(), + "wqi": {c: p.summary() for c, p in self._wqi_predictors.items()}, + "funding_arb": self._funding_arb.summary(), + "fill_model": { + "fill_rate": round(self._fill_model.fill_rate(), 4), + "fills": self._fill_model.fill_count, + "skips": self._fill_model.skip_count, + }, "funding": self._funding_monitor.summary(), "cross_venue": self._cross_venue.summary("BTC"), "equity_history": self._equity_history, diff --git a/strategies/funding_arb_strategy.py b/strategies/funding_arb_strategy.py new file mode 100644 index 0000000..85d48f9 --- /dev/null +++ b/strategies/funding_arb_strategy.py @@ -0,0 +1,353 @@ +""" +Funding Rate Arbitrage — backtestable strategy module. + +Delta-neutral carry trade on Hyperliquid perps. When funding rate is high: + - Short the perpetual (collect funding payments) + - The profit is the funding rate, not price direction + +Features: + - Configurable entry/exit thresholds + - Position sizing proportional to funding rate + - Funding payment tracking with accurate Hyperliquid 8h schedule + - Max hold time (exit after N hours regardless) + - Stop-loss if basis widens (mark price moves against funding direction) + - Per-trade PnL accounting with fees, funding, and mark PnL + +Usage (backtest): + arb = FundingArb(apr_threshold=0.30, apr_exit=0.10, size=0.001) + for hourly_funding in funding_history: + trade = arb.tick(funding_rate, mark_price, timestamp) + if trade: + print(f"Trade: {trade}") + +Usage (live): + arb = FundingArb(apr_threshold=0.30) + signal = arb.signal(check_rates(time.time())) + if signal["action"] != "HOLD": + execute(signal) +""" + +from __future__ import annotations + +import time +from collections import deque +from typing import Optional + + +class FundingArb: + """Delta-neutral funding rate carry strategy. + + Logic: + - Entry: |annualized_funding| > apr_threshold AND no position + - Exit: |annualized_funding| < apr_exit + OR hold_time > max_hold_hours + OR funding direction flips (paying instead of collecting) + OR basis stop-loss triggered + """ + + def __init__( + self, + apr_threshold: float = 0.30, + apr_exit: float = 0.10, + size: float = 0.001, + max_hold_hours: float = 48.0, + basis_stop_loss_pct: float = 0.03, + taker_fee_pct: float = 0.00045, + maker_fee_pct: float = 0.00015, + ): + self._apr_threshold = apr_threshold + self._apr_exit = apr_exit + self._size = size + self._max_hold_seconds = max_hold_hours * 3600 + self._basis_stop_loss = basis_stop_loss_pct + self._taker_fee = taker_fee_pct + self._maker_fee = maker_fee_pct + + self._position: int = 0 + self._entry_price: float = 0.0 + self._entry_time: float = 0.0 + self._entry_apr: float = 0.0 + self._funding_collected: float = 0.0 + self._funding_paid: float = 0.0 + + self._trades: list[dict] = [] + self._funding_history: deque[float] = deque(maxlen=200) + self._mark_history: deque[float] = deque(maxlen=200) + self._signals: deque[dict] = deque(maxlen=50) + + @property + def position(self) -> int: + return self._position + + @property + def trades(self) -> list[dict]: + return self._trades + + def tick( + self, + funding_rate_annual: float, + mark_price: float, + timestamp: Optional[float] = None, + ) -> dict | None: + """Process one funding rate observation. Returns trade dict if entry/exit occurred.""" + if timestamp is None: + timestamp = time.time() + + if mark_price <= 0: + return None + + self._funding_history.append(funding_rate_annual) + self._mark_history.append(mark_price) + + abs_apr = abs(funding_rate_annual) + action = "HOLD" + trade = None + + if self._position == 0: + if abs_apr > self._apr_threshold: + action = "SELL" if funding_rate_annual > 0 else "BUY" + self._position = -1 if funding_rate_annual > 0 else 1 + self._entry_price = mark_price + self._entry_time = timestamp + self._entry_apr = funding_rate_annual + + notional = self._size * mark_price + fee = notional * self._taker_fee + self._funding_paid += fee + + trade = { + "action": action, + "side": action, + "size": self._size, + "entry_price": mark_price, + "apr": round(funding_rate_annual, 4), + "apr_pct": round(funding_rate_annual * 100, 2), + "fee": round(fee, 4), + } + self._signals.append({ + "timestamp": timestamp, + "action": action, + "apr": funding_rate_annual, + "price": mark_price, + }) + else: + hold_seconds = timestamp - self._entry_time + direction = "short" if self._position == -1 else "long" + exit_reason = "" + + if abs_apr < self._apr_exit: + exit_reason = f"apr_faded_to_{abs_apr*100:.1f}%" + elif hold_seconds >= self._max_hold_seconds: + exit_reason = f"max_hold_{hold_seconds/3600:.1f}h" + elif (self._position == -1 and funding_rate_annual < 0) or \ + (self._position == 1 and funding_rate_annual > 0): + exit_reason = f"funding_flipped_to_{funding_rate_annual*100:.2f}%" + else: + price_move = (mark_price - self._entry_price) / self._entry_price + position_pnl_pct = price_move * self._position + if abs(position_pnl_pct) > self._basis_stop_loss: + exit_reason = f"basis_stop_loss_{position_pnl_pct*100:.2f}%" + + if exit_reason: + notional = self._size * mark_price + fee = notional * self._taker_fee + + price_pnl = self._size * (mark_price - self._entry_price) * self._position + funding_earned = 0.0 + if isinstance(self._entry_apr, float) and self._entry_apr != 0: + funding_rate_8h = self._entry_apr / 1095 + funding_intervals = hold_seconds / (8 * 3600) + funding_earned = notional * abs(funding_rate_8h) * funding_intervals * 0.95 + + net_pnl = price_pnl + funding_earned - fee - self._funding_paid + + action = "BUY" if self._position == -1 else "SELL" + + trade = { + "action": f"EXIT_{exit_reason}", + "side": action, + "entry_price": round(self._entry_price, 2), + "exit_price": round(mark_price, 2), + "size": self._size, + "direction": direction, + "hold_hours": round(hold_seconds / 3600, 2), + "entry_apr": round(self._entry_apr * 100, 2), + "exit_apr": round(funding_rate_annual * 100, 2), + "price_pnl": round(price_pnl, 4), + "funding_earned": round(funding_earned, 4), + "fees": round(fee + self._funding_paid, 4), + "net_pnl": round(net_pnl, 4), + "reason": exit_reason, + } + self._trades.append(trade) + self._signals.append({ + "timestamp": timestamp, + "action": "EXIT", + "apr": funding_rate_annual, + "price": mark_price, + "reason": exit_reason, + "pnl": net_pnl, + }) + + self._position = 0 + self._entry_price = 0.0 + self._entry_time = 0.0 + self._entry_apr = 0.0 + self._funding_paid = 0.0 + + return trade + + def signal( + self, + funding_rate_annual: float, + mark_price: float = 0.0, + timestamp: Optional[float] = None, + ) -> dict: + """Generate trading signal without executing.""" + if timestamp is None: + timestamp = time.time() + + abs_apr = abs(funding_rate_annual) + + if self._position == 0 and abs_apr > self._apr_threshold: + return { + "action": "SELL" if funding_rate_annual > 0 else "BUY", + "size": self._size, + "apr": round(funding_rate_annual * 100, 2), + "reason": f"apr_{abs_apr*100:.1f}%_above_{self._apr_threshold*100:.0f}%", + } + elif self._position != 0 and abs_apr < self._apr_exit: + return { + "action": "EXIT", + "reason": f"apr_faded_to_{abs_apr*100:.1f}%", + } + + return {"action": "HOLD"} + + def summary(self) -> dict: + if not self._trades: + return { + "total_trades": 0, + "win_rate": 0.0, + "total_net_pnl": 0.0, + "avg_net_pnl": 0.0, + "avg_hold_hours": 0.0, + "total_funding_earned": 0.0, + "position": self._position, + } + + wins = sum(1 for t in self._trades if t["net_pnl"] > 0) + total_net = sum(t["net_pnl"] for t in self._trades) + total_funding = sum(t["funding_earned"] for t in self._trades) + hold_hours = [t["hold_hours"] for t in self._trades] + + return { + "total_trades": len(self._trades), + "win_rate": round(wins / len(self._trades), 3), + "total_net_pnl": round(total_net, 4), + "avg_net_pnl": round(total_net / len(self._trades), 4), + "avg_hold_hours": round(sum(hold_hours) / len(hold_hours), 2), + "total_funding_earned": round(total_funding, 4), + "best_trade": round(max(t["net_pnl"] for t in self._trades), 4), + "worst_trade": round(min(t["net_pnl"] for t in self._trades), 4), + "position": self._position, + } + + def reset(self): + self._position = 0 + self._entry_price = 0.0 + self._entry_time = 0.0 + self._entry_apr = 0.0 + self._funding_collected = 0.0 + self._funding_paid = 0.0 + self._trades.clear() + self._signals.clear() + self._funding_history.clear() + self._mark_history.clear() + + +def backtest_funding_arb( + funding_rates: list[float], + mark_prices: list[float], + apr_threshold: float = 0.30, + size: float = 0.001, + taker_fee_pct: float = 0.00045, +) -> dict: + """Run funding arb backtest on a series of funding rate observations. + + Args: + funding_rates: list of annualized funding rates (e.g., from HL API) + mark_prices: list of corresponding mark prices + apr_threshold: minimum annual APR to enter + size: trade size + taker_fee_pct: taker fee per trade + + Returns dict with trades and summary. + """ + arb = FundingArb( + apr_threshold=apr_threshold, + size=size, + taker_fee_pct=taker_fee_pct, + ) + + min_len = min(len(funding_rates), len(mark_prices)) + for i in range(min_len): + arb.tick(funding_rates[i], mark_prices[i], float(i)) + + return arb.summary() + + +def run_funding_discovery(data_dir: str = "data/raw", coin: str = "BTC", + start_date: str = "2026-01-01", end_date: str = "2030-01-01") -> dict: + """Run funding rate discovery — analyze historical funding rates to find + optimal entry/exit thresholds. + + Returns dict with rate distribution percentiles and backtest results at different thresholds. + """ + import numpy as np + from data.store import read_range + + msgs = read_range(data_dir, channel="funding", coin=coin, + start_date=start_date, end_date=end_date) + if not msgs: + return {"error": "No funding data available"} + + rates = [] + marks = [] + for msg in msgs: + payload = msg.get("payload", {}) + rate = float(payload.get("funding", 0)) + mark = float(payload.get("mark_px", 0)) + if mark > 0: + annual = rate * 1095 + rates.append(annual) + marks.append(mark) + + if not rates: + return {"error": "No valid funding observations"} + + a = np.array(rates) + abs_a = np.abs(a) + percentiles = [10, 25, 50, 75, 90, 95, 99] + + result = { + "n_observations": len(rates), + "rate_distribution": { + "mean_apr_pct": round(float(np.mean(a)) * 100, 2), + "std_apr_pct": round(float(np.std(a)) * 100, 2), + "max_apr_pct": round(float(np.max(a)) * 100, 2), + "min_apr_pct": round(float(np.min(a)) * 100, 2), + "abs_percentiles": { + f"p{p}": round(float(np.percentile(abs_a, p)) * 100, 2) + for p in percentiles + }, + }, + "backtests": {}, + } + + for threshold in [0.05, 0.10, 0.20, 0.30, 0.50]: + summary = backtest_funding_arb(rates, marks, apr_threshold=threshold) + if summary["total_trades"] > 0: + result["backtests"][f"apr_{int(threshold*100)}pct"] = summary + + return result diff --git a/tests/test_funding_arb.py b/tests/test_funding_arb.py new file mode 100644 index 0000000..118bef5 --- /dev/null +++ b/tests/test_funding_arb.py @@ -0,0 +1,205 @@ +""" +Tests for funding arb strategy and WQI predictor integration. +""" +import math + + +class TestFundingArb: + def test_no_entry_below_threshold(self): + from strategies.funding_arb_strategy import FundingArb + arb = FundingArb(apr_threshold=0.30) + trade = arb.tick(0.10, 100000.0, 0.0) + assert trade is None + assert arb.position == 0 + + def test_entry_above_threshold_positive(self): + from strategies.funding_arb_strategy import FundingArb + arb = FundingArb(apr_threshold=0.30) + trade = arb.tick(0.50, 100000.0, 0.0) + assert trade is not None + assert trade["action"] == "SELL" + assert arb.position == -1 + + def test_entry_above_threshold_negative(self): + from strategies.funding_arb_strategy import FundingArb + arb = FundingArb(apr_threshold=0.30) + trade = arb.tick(-0.50, 100000.0, 0.0) + assert trade is not None + assert trade["action"] == "BUY" + assert arb.position == 1 + + def test_exit_when_apr_fades(self): + from strategies.funding_arb_strategy import FundingArb + arb = FundingArb(apr_threshold=0.30, apr_exit=0.10) + arb.tick(0.50, 100000.0, 0.0) + trade = arb.tick(0.05, 100000.0, 3600.0) + assert trade is not None + assert "EXIT" in trade["action"] + assert arb.position == 0 + + def test_exit_when_funding_flips(self): + from strategies.funding_arb_strategy import FundingArb + arb = FundingArb(apr_threshold=0.30, apr_exit=0.10) + arb.tick(0.50, 100000.0, 0.0) + trade = arb.tick(-0.10, 100000.0, 3600.0) + assert trade is not None + assert "EXIT" in trade["action"] + assert arb.position == 0 + + def test_signal_no_exit_when_apr_still_high(self): + from strategies.funding_arb_strategy import FundingArb + arb = FundingArb(apr_threshold=0.30) + arb.tick(0.50, 100000.0, 0.0) + result = arb.signal(0.60, 100000.0) + assert result["action"] == "HOLD" + + def test_signal_hold_when_below_threshold(self): + from strategies.funding_arb_strategy import FundingArb + arb = FundingArb(apr_threshold=0.30) + result = arb.signal(0.05) + assert result["action"] == "HOLD" + + def test_summary_no_trades(self): + from strategies.funding_arb_strategy import FundingArb + arb = FundingArb() + s = arb.summary() + assert s["total_trades"] == 0 + assert s["win_rate"] == 0.0 + + def test_summary_with_trades(self): + from strategies.funding_arb_strategy import FundingArb + arb = FundingArb(apr_threshold=0.30, apr_exit=0.10) + arb.tick(0.50, 100000.0, 0.0) + arb.tick(0.05, 100100.0, 3600.0) + arb.tick(0.50, 100000.0, 7200.0) + arb.tick(0.05, 100050.0, 10800.0) + s = arb.summary() + assert s["total_trades"] == 2 + assert s["position"] == 0 + + def test_reset(self): + from strategies.funding_arb_strategy import FundingArb + arb = FundingArb(apr_threshold=0.30, apr_exit=0.10) + arb.tick(0.50, 100000.0, 0.0) + arb.reset() + assert arb.position == 0 + assert len(arb.trades) == 0 + + def test_backtest_empty(self): + from strategies.funding_arb_strategy import backtest_funding_arb + result = backtest_funding_arb([], []) + assert result["total_trades"] == 0 + + def test_backtest_single_trade(self): + from strategies.funding_arb_strategy import backtest_funding_arb + rates = [0.50, 0.06] + prices = [100000.0, 100000.0] + result = backtest_funding_arb(rates, prices, apr_threshold=0.30) + assert result["total_trades"] == 1 + + def test_fee_accounting(self): + from strategies.funding_arb_strategy import FundingArb + arb = FundingArb(apr_threshold=0.10, size=0.001, taker_fee_pct=0.00045) + trade = arb.tick(0.50, 100000.0, 0.0) + assert trade is not None + expected_fee = 0.001 * 100000.0 * 0.00045 + assert abs(trade["fee"] - expected_fee) < 0.001 + + +class TestFundingDiscoveryCLI: + def test_discovery_no_data(self): + from strategies.funding_arb_strategy import run_funding_discovery + result = run_funding_discovery( + data_dir="/tmp/nonexistent_data", + coin="BTC", + ) + assert "error" in result + + def test_backtest_multiple_thresholds(self): + from strategies.funding_arb_strategy import backtest_funding_arb + import random + random.seed(42) + rates = [abs(random.gauss(0, 0.5)) for _ in range(200)] + prices = [100000.0 + random.gauss(0, 500) for _ in range(200)] + + for threshold in [0.10, 0.30, 0.50]: + result = backtest_funding_arb(rates, prices, apr_threshold=threshold) + assert "total_trades" in result + assert "total_net_pnl" in result + + +class TestWQIIntegration: + def test_wqi_with_node_interface(self): + from strategies.wqi_predictor import WQIPredictor + wqi = WQIPredictor(z_entry=2.0, max_hold_seconds=30) + bids = [(50000.0, 1.0), (49999.0, 0.5)] + asks = [(50002.0, 1.0), (50003.0, 0.5)] + + for _ in range(30): + wqi.feed_signal(bids, asks, 50001.0) + + extreme_bids = [(50000.0, 10.0), (49999.0, 5.0)] + extreme_asks = [(50002.0, 0.5)] + signal = wqi.feed_signal(extreme_bids, extreme_asks, 50001.0) + + assert signal["action"] in ("BUY", "HOLD") + if signal["action"] == "BUY": + assert wqi.position != 0 + + def test_wqi_exit_on_timeout(self): + from strategies.wqi_predictor import WQIPredictor + import time + wqi = WQIPredictor(z_entry=0.01, z_exit=999.0, wqi_threshold=0.01, + max_hold_seconds=0.001, max_adverse=999.0) + + for _ in range(30): + wqi.feed_signal([(100.0, 1.0)], [(102.0, 1.0)], 101.0) + + extreme_bids = [(100.0, 20.0), (99.0, 10.0)] + extreme_asks = [(102.0, 1.0)] + signal = wqi.feed_signal(extreme_bids, extreme_asks, 101.0) + + if signal["action"] in ("BUY", "SELL"): + time.sleep(0.01) + signal2 = wqi.feed_signal(extreme_bids, extreme_asks, 101.0) + assert signal2["action"] in ("EXIT", "HOLD") + + +class TestNodeV2Strategies: + def test_node_creates_all_strategies(self): + from live.node_v2 import ProductionNode + node = ProductionNode( + coins=["BTC"], + testnet=True, + mode="paper", + max_position_per_coin=0.001, + base_quote_size=0.0001, + ) + assert len(node._wqi_predictors) == 1 + assert node._funding_arb is not None + assert node._fill_model is not None + + def test_wqi_not_none(self): + from live.node_v2 import ProductionNode + node = ProductionNode( + coins=["BTC"], + testnet=True, + mode="paper", + max_position_per_coin=0.001, + ) + wqi = node._wqi_predictors.get("BTC") + assert wqi is not None + assert wqi._z_entry == 2.0 + assert wqi._max_hold_seconds == 30 + + def test_funding_arb_config(self): + from live.node_v2 import ProductionNode + node = ProductionNode( + coins=["BTC"], + testnet=True, + mode="paper", + max_position_per_coin=0.001, + ) + arb = node._funding_arb + assert arb._apr_threshold == 0.30 + assert arb._apr_exit == 0.10