From b59dcc36296a1a6aba79888b66be8bde9a0bba58 Mon Sep 17 00:00:00 2001 From: ramseshk Date: Mon, 3 Aug 2026 11:12:20 +0000 Subject: [PATCH] Initial project scaffold: five quant strategies for Hyperliquid Testnet Set up the directory structure and wrote placeholder logic for: - Order Book Imbalance: trades on L2 bid/ask skew - Iceberg/TWAP detection: follows whale accumulation patterns - Funding rate arbitrage: delta-neutral carry on perp funding - Pairs trading: BTC/ETH spread mean reversion - Avellaneda-Stoikov market making: optimal bid/ask quoting Also added shared risk manager, portfolio tracker, and a plain-language strategy walkthrough in docs/. --- .gitignore | 14 +++++ README.md | 60 +++++++++++++++++- common/__init__.py | 1 + common/metrics.py | 43 +++++++++++++ common/portfolio.py | 36 +++++++++++ common/risk_manager.py | 48 ++++++++++++++ config/avellaneda_stoikov.yaml | 14 +++++ config/funding_arb.yaml | 12 ++++ config/iceberg.yaml | 12 ++++ config/ofi.yaml | 13 ++++ config/pairs_trading.yaml | 13 ++++ docs/STRATEGIES.md | 101 ++++++++++++++++++++++++++++++ live/node.py | 35 +++++++++++ requirements.txt | 14 +++++ strategies/__init__.py | 1 + strategies/avellaneda_stoikov.py | 87 +++++++++++++++++++++++++ strategies/funding_rate_arb.py | 77 +++++++++++++++++++++++ strategies/iceberg_detection.py | 66 +++++++++++++++++++ strategies/orderbook_imbalance.py | 76 ++++++++++++++++++++++ strategies/pairs_trading.py | 95 ++++++++++++++++++++++++++++ 20 files changed, 816 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 common/__init__.py create mode 100644 common/metrics.py create mode 100644 common/portfolio.py create mode 100644 common/risk_manager.py create mode 100644 config/avellaneda_stoikov.yaml create mode 100644 config/funding_arb.yaml create mode 100644 config/iceberg.yaml create mode 100644 config/ofi.yaml create mode 100644 config/pairs_trading.yaml create mode 100644 docs/STRATEGIES.md create mode 100644 live/node.py create mode 100644 requirements.txt create mode 100644 strategies/__init__.py create mode 100644 strategies/avellaneda_stoikov.py create mode 100644 strategies/funding_rate_arb.py create mode 100644 strategies/iceberg_detection.py create mode 100644 strategies/orderbook_imbalance.py create mode 100644 strategies/pairs_trading.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d061b0d --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ +.env +*.pem +*_pk +data/ +*.parquet +.ipynb_checkpoints/ +.idea/ +.vscode/ +.DS_Store diff --git a/README.md b/README.md index 5c98f52..50f9581 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,59 @@ -# ftdt-quant-lab +# FTDT Quant Lab — Quantitative Trading Strategies -Quantitative trading lab — Nautilus Trader strategies on Hyperliquid Testnet. Part of my professional portfolio. \ No newline at end of file +A collection of quantitative trading strategies running on +**Hyperliquid Testnet** via **Nautilus Trader**. Built as part of +my professional portfolio to demonstrate algorithmic trading, +market microstructure, and risk management skills. + +## What's inside + +Five strategies, from simple to advanced: + +| # | Strategy | Concept | +|---|----------|---------| +| 1 | Order Book Imbalance | Trades on L2 bid/ask pressure | +| 2 | Iceberg / TWAP Detection | Follows whale accumulation patterns | +| 3 | Funding Rate Arbitrage | Delta-neutral carry trade | +| 4 | Pairs Trading (BTC/ETH) | Cointegration-based stat arb | +| 5 | Avellaneda-Stoikov Market Making | Stochastic optimal control | + +All strategies share a common risk manager and portfolio tracker. + +## Quick start + +```bash +# Install dependencies +pip install -r requirements.txt + +# Set your Hyperliquid testnet key +export HYPERLIQUID_TESTNET_PK=0x... + +# Run live (testnet only) +python live/node.py +``` + +## Project layout + +``` +ftdt-quant-lab/ +├── config/ # Per-strategy YAML configuration +├── strategies/ # Strategy implementations +├── common/ # Risk manager, portfolio tracker, metrics +├── backtests/ # Historical backtest runners +├── live/ # Live trading node (Hyperliquid Testnet) +├── docs/ # Documentation and strategy writeups +└── notebooks/ # Analysis notebooks +``` + +## Strategy details + +See `docs/STRATEGIES.md` for a walkthrough of each strategy. + +## Risk warning + +This is **testnet only**. These strategies are educational — they +are not financial advice and have no alpha guarantee. Never run +them on mainnet without thorough backtesting and your own due diligence. + +--- +Built by [Ramses Echikh](https://git.ftdt.io/rams) · Part of my quant trading portfolio diff --git a/common/__init__.py b/common/__init__.py new file mode 100644 index 0000000..00834d0 --- /dev/null +++ b/common/__init__.py @@ -0,0 +1 @@ +# Common utilities package diff --git a/common/metrics.py b/common/metrics.py new file mode 100644 index 0000000..0417478 --- /dev/null +++ b/common/metrics.py @@ -0,0 +1,43 @@ +""" +Performance metrics. + +Sharpe ratio, Sortino ratio, max drawdown, win rate. +Standard toolbox for evaluating a trading strategy. +""" +import numpy as np + + +def sharpe(returns: list[float], rf: float = 0.0, periods: int = 365) -> float: + if len(returns) < 2: + return 0.0 + excess = np.mean(returns) - rf + std = np.std(returns, ddof=1) + return (excess / std) * np.sqrt(periods) if std > 0 else 0.0 + + +def sortino(returns: list[float], rf: float = 0.0, periods: int = 365) -> float: + if len(returns) < 2: + return 0.0 + excess = np.mean(returns) - rf + downside = [r for r in returns if r < 0] + d_std = np.std(downside, ddof=1) if downside else 0.0 + return (excess / d_std) * np.sqrt(periods) if d_std > 0 else 0.0 + + +def max_drawdown(equity: list[float]) -> float: + if not equity: + return 0.0 + peak = equity[0] + worst = 0.0 + for v in equity: + if v > peak: + peak = v + dd = (peak - v) / peak if peak > 0 else 0.0 + worst = max(worst, dd) + return worst + + +def win_rate(trades: list[dict]) -> float: + if not trades: + return 0.0 + return sum(1 for t in trades if t.get("pnl", 0) > 0) / len(trades) diff --git a/common/portfolio.py b/common/portfolio.py new file mode 100644 index 0000000..22ba383 --- /dev/null +++ b/common/portfolio.py @@ -0,0 +1,36 @@ +""" +Portfolio tracker. + +Aggregates positions from all running strategies to prevent +over-concentration in any single instrument. +""" +from dataclasses import dataclass + + +@dataclass +class Position: + instrument: str + quantity: float + entry_price: float + strategy: str + + +class PortfolioTracker: + def __init__(self) -> None: + self.positions: dict[str, list[Position]] = {} + + def add(self, strategy: str, instrument: str, qty: float, price: float) -> None: + if instrument not in self.positions: + self.positions[instrument] = [] + self.positions[instrument].append(Position(instrument, qty, price, strategy)) + + def net_exposure(self, instrument: str) -> float: + if instrument not in self.positions: + return 0.0 + return sum(p.quantity for p in self.positions[instrument]) + + def all_exposures(self) -> dict[str, float]: + return {inst: self.net_exposure(inst) for inst in self.positions} + + def is_overconcentrated(self, instrument: str, max_pct: float, equity: float) -> bool: + return abs(self.net_exposure(instrument)) > equity * max_pct diff --git a/common/risk_manager.py b/common/risk_manager.py new file mode 100644 index 0000000..ada88b4 --- /dev/null +++ b/common/risk_manager.py @@ -0,0 +1,48 @@ +""" +Shared risk manager. + +Tracks exposure per-strategy and blocks orders that would +exceed position limits, drawdown limits, or daily trade caps. +""" +from dataclasses import dataclass + + +@dataclass +class RiskLimits: + max_position: float = 0.01 + max_drawdown_pct: float = 0.05 + max_daily_trades: int = 50 + max_leverage: float = 2.0 + + +class RiskManager: + def __init__(self) -> None: + self.strategy_limits: dict[str, RiskLimits] = {} + self.daily_trades: dict[str, int] = {} + self.peak_equity: float = 0.0 + + def register(self, name: str, limits: RiskLimits) -> None: + self.strategy_limits[name] = limits + self.daily_trades[name] = 0 + + def can_trade(self, name: str, position: float, equity: float) -> bool: + limits = self.strategy_limits.get(name) + if not limits: + return True + + if abs(position) >= limits.max_position: + return False + if self.daily_trades.get(name, 0) >= limits.max_daily_trades: + return False + if self.peak_equity > 0: + dd = 1 - (equity / self.peak_equity) + if dd >= limits.max_drawdown_pct: + return False + return True + + def record_trade(self, name: str) -> None: + self.daily_trades[name] = self.daily_trades.get(name, 0) + 1 + + def update_equity(self, equity: float) -> None: + if equity > self.peak_equity: + self.peak_equity = equity diff --git a/config/avellaneda_stoikov.yaml b/config/avellaneda_stoikov.yaml new file mode 100644 index 0000000..9466718 --- /dev/null +++ b/config/avellaneda_stoikov.yaml @@ -0,0 +1,14 @@ +# Avellaneda-Stoikov Market Making Strategy +strategy: + name: AvellanedaStoikov + instrument: BTC-USD-PERP + gamma: 0.1 + sigma: 0.02 + T: 1.0 + k: 1.5 + min_spread: 0.0001 + max_inventory: 0.01 + +risk: + max_drawdown_pct: 0.03 + inventory_hard_limit: 0.015 diff --git a/config/funding_arb.yaml b/config/funding_arb.yaml new file mode 100644 index 0000000..86fb9ec --- /dev/null +++ b/config/funding_arb.yaml @@ -0,0 +1,12 @@ +# Funding Rate Arbitrage Strategy +strategy: + name: FundingRateArb + spot_instrument: BTC-SPOT + perp_instrument: BTC-USD-PERP + min_funding_rate: 0.0001 + rebalance_threshold: 0.05 + position_size: 0.01 + +risk: + max_drawdown_pct: 0.03 + max_leverage: 1.0 diff --git a/config/iceberg.yaml b/config/iceberg.yaml new file mode 100644 index 0000000..8a52270 --- /dev/null +++ b/config/iceberg.yaml @@ -0,0 +1,12 @@ +# Iceberg / TWAP Detection Strategy +strategy: + name: IcebergDetector + instrument: BTC-USD-PERP + lookback_seconds: 300 + volume_spike_mult: 3.0 + min_slices: 4 + trade_size: 0.001 + +risk: + max_drawdown_pct: 0.05 + max_daily_trades: 10 diff --git a/config/ofi.yaml b/config/ofi.yaml new file mode 100644 index 0000000..439206b --- /dev/null +++ b/config/ofi.yaml @@ -0,0 +1,13 @@ +# Order Book Imbalance Strategy +strategy: + name: OrderBookImbalance + instrument: BTC-USD-PERP + depth: 10 + imbalance_threshold: 0.6 + trade_size: 0.001 + max_position: 0.003 + cooldown_bars: 5 + +risk: + max_drawdown_pct: 0.05 + max_daily_trades: 20 diff --git a/config/pairs_trading.yaml b/config/pairs_trading.yaml new file mode 100644 index 0000000..7f0a884 --- /dev/null +++ b/config/pairs_trading.yaml @@ -0,0 +1,13 @@ +# Pairs Trading Strategy (BTC-PERP / ETH-PERP) +strategy: + name: PairsTrading + pair: ["BTC-USD-PERP", "ETH-USD-PERP"] + z_entry: 2.0 + z_exit: 0.5 + lookback_hours: 24 + trade_size: 0.001 + hedge_ratio: 0.05 + +risk: + max_drawdown_pct: 0.05 + max_position_per_leg: 0.005 diff --git a/docs/STRATEGIES.md b/docs/STRATEGIES.md new file mode 100644 index 0000000..22cb0cc --- /dev/null +++ b/docs/STRATEGIES.md @@ -0,0 +1,101 @@ +# FTDT Quant Lab - Strategy Walkthrough + +A plain-language explanation of each strategy: what it does, +why it works (or might work), and what to watch out for. + +--- + +## 1. Order Book Imbalance + +**What it does:** +Watches the order book in real time. If there are way more +buy orders than sell orders stacked up, it buys. If the +opposite, it sells. + +**Why it might work:** +When one side of the book is heavy, market orders eat into +that side and push the price toward the thinner side. You're +basically front-running that move. + +**Risks:** +- Fake walls — someone puts up a huge order to bait you, + then cancels it. +- Low signal quality in ranging markets. + +--- + +## 2. Iceberg / TWAP Detection + +**What it does:** +Looks for big traders slicing their orders into small pieces. +When it spots the pattern, it trades in the same direction. + +**Why it might work:** +If someone is accumulating a lot of BTC slowly, they probably +know something (or at least their buying pressure will move +the price). You're piggybacking their flow. + +**Risks:** +- False positives — random noise looks like a pattern. +- The whale could be wrong. You're copying someone who + might lose money. + +--- + +## 3. Funding Rate Arbitrage + +**What it does:** +Hyperliquid charges a funding rate every 8 hours. When it's +positive, people who are long pay people who are short. +This strategy goes long spot (no funding) and short perp +(collects funding), staying delta-neutral the whole time. + +**Why it works:** +It doesn't bet on direction — it bets on the funding +mechanism itself. You earn the rate regardless of whether +BTC goes up or down. + +**Risks:** +- Funding rate can flip (you'd have to close and reopen + the other way). +- Execution risk — if one leg fails, you're no longer + delta-neutral. + +--- + +## 4. Pairs Trading (BTC/ETH) + +**What it does:** +Tracks the price ratio between BTC and ETH. When the spread +gets unusually wide, it bets it will narrow. Short the +expensive one, long the cheap one. + +**Why it might work:** +BTC and ETH tend to move together over time. Big moves apart +from each other often snap back. This trades the snap-back. + +**Risks:** +- Regime change — if something fundamentally changes the + BTC/ETH relationship, the spread might never revert. +- Needs enough data to calculate a reliable mean. + +--- + +## 5. Avellaneda-Stoikov Market Making + +**What it does:** +Places buy and sell orders at optimal prices around the +midpoint, adjusting based on how much inventory you're +holding and how much time is left in your trading session. + +**Why it works:** +Market makers profit from the spread (buy low, sell high). +The A-S model tells you exactly where to place your bid +and ask to balance profit vs risk. + +**Risks:** +- Adverse selection — someone who knows more than you + picks off your quotes. +- Requires low latency and accurate volatility estimates. +- More of a "keep the machine running" strategy than + a get-rich-quick one. The edge is small per trade. diff --git a/live/node.py b/live/node.py new file mode 100644 index 0000000..56682b4 --- /dev/null +++ b/live/node.py @@ -0,0 +1,35 @@ +""" +Live trading node for Hyperliquid Testnet. + +Runs all five strategies concurrently with shared risk management. +""" +import asyncio +import os +import sys + + +async def main(): + private_key = os.getenv("HYPERLIQUID_TESTNET_PK") + if not private_key: + print("Set HYPERLIQUID_TESTNET_PK environment variable") + sys.exit(1) + + print("=" * 55) + print(" FTDT Quant Lab - Live Trading Node") + print(" Hyperliquid Testnet") + print("=" * 55) + print() + print("Strategies:") + print(" 1. Order Book Imbalance (OFI)") + print(" 2. Iceberg / TWAP Detection") + print(" 3. Funding Rate Arbitrage") + print(" 4. Pairs Trading (BTC/ETH)") + print(" 5. Avellaneda-Stoikov Market Making") + print() + print("Connecting to Hyperliquid Testnet...") + # TODO: Full Nautilus TradingNode integration + print("Ready.") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6edf1b5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,14 @@ +# Nautilus Trader +nautilus-trader>=1.210.0 + +# Data & Math +numpy>=1.24.0 +pandas>=2.0.0 +pyyaml>=6.0 + +# Visualization +matplotlib>=3.7.0 +seaborn>=0.12.0 + +# Optional: dashboard +streamlit>=1.28.0 diff --git a/strategies/__init__.py b/strategies/__init__.py new file mode 100644 index 0000000..89775f3 --- /dev/null +++ b/strategies/__init__.py @@ -0,0 +1 @@ +# Package init files (make these importable) diff --git a/strategies/avellaneda_stoikov.py b/strategies/avellaneda_stoikov.py new file mode 100644 index 0000000..dee9659 --- /dev/null +++ b/strategies/avellaneda_stoikov.py @@ -0,0 +1,87 @@ +""" +Avellaneda-Stoikov Market Making strategy. + +A mathematical model for optimal market making based on +stochastic optimal control. Computes optimal bid/ask quotes +considering current inventory, risk aversion, volatility, +and time horizon. + +Key formulas: + Reservation price: r = s - q * gamma * sigma^2 * tau + Optimal spread: delta = gamma * sigma^2 * tau + (2/gamma) * ln(1 + gamma/k) + +where: + s = mid price, q = inventory, gamma = risk aversion + sigma = volatility, tau = remaining time, k = order intensity +""" +import math +from nautilus_trader.trading.strategy import Strategy +from nautilus_trader.config import StrategyConfig +from datetime import datetime, timezone + + +class AvellanedaStoikovConfig(StrategyConfig, frozen=True): + instrument_id: str + gamma: float = 0.1 + sigma: float = 0.02 + T: float = 1.0 + k: float = 1.5 + min_spread: float = 0.0001 + max_inventory: float = 0.01 + + +class AvellanedaStoikov(Strategy): + """ + A-S optimal market making. + + Instead of predicting direction, this strategy provides + liquidity by continuously quoting bid/ask prices at an + optimal distance from the mid price. The spread widens + as inventory builds up (to discourage further accumulation) + and tightens as the time horizon approaches. + """ + + def __init__(self, config: AvellanedaStoikovConfig) -> None: + super().__init__(config) + self.config = config + self.start_time: datetime | None = None + + def on_start(self) -> None: + self.start_time = self.clock.utc_now() + self.subscribe_quote_ticks(self.config.instrument_id) + self.log.info( + f"A-S MM on {self.config.instrument_id} " + f"(gamma={self.config.gamma})" + ) + + def on_quote_tick(self, tick) -> None: + self.cancel_all_orders(self.config.instrument_id) + + elapsed = (self.clock.utc_now() - self.start_time).total_seconds() / 3600 + tau = max(self.config.T - elapsed, 0.01) + + q = float(self.portfolio.net_position(self.config.instrument_id)) + if abs(q) >= self.config.max_inventory: + return + + g = self.config.gamma + s = self.config.sigma + k = self.config.k + + mid = (tick.bid + tick.ask) / 2 + reservation = mid - q * g * s**2 * tau + spread = g * s**2 * tau + (2 / g) * math.log(1 + g / k) + spread = max(spread, self.config.min_spread) + + self.submit_order(self.order_factory.limit( + instrument_id=self.config.instrument_id, + order_side="BUY", + quantity=self.config.max_inventory / 10, + price=reservation - spread / 2, + )) + self.submit_order(self.order_factory.limit( + instrument_id=self.config.instrument_id, + order_side="SELL", + quantity=self.config.max_inventory / 10, + price=reservation + spread / 2, + )) diff --git a/strategies/funding_rate_arb.py b/strategies/funding_rate_arb.py new file mode 100644 index 0000000..1d74fdd --- /dev/null +++ b/strategies/funding_rate_arb.py @@ -0,0 +1,77 @@ +""" +Funding Rate Arbitrage strategy. + +Hyperliquid pays funding every 8 hours. When the rate is positive, +longs pay shorts. This strategy: + +1. Goes LONG spot (no funding payments) +2. Goes SHORT perp (collects funding) +3. Maintains delta neutrality + +The profit comes from funding, not price direction. +""" +from nautilus_trader.trading.strategy import Strategy +from nautilus_trader.config import StrategyConfig + + +class FundingRateArbConfig(StrategyConfig, frozen=True): + spot_instrument: str + perp_instrument: str + min_funding_rate: float = 0.0001 + rebalance_threshold: float = 0.05 + position_size: float = 0.01 + + +class FundingRateArb(Strategy): + """ + Delta-neutral funding rate carry trade. + + Key idea: funding rate IS the edge. Stay neutral, collect + the payments. + """ + + def __init__(self, config: FundingRateArbConfig) -> None: + super().__init__(config) + self.config = config + self.position_open = False + + def on_start(self) -> None: + bar_type = f"{self.config.perp_instrument}-1-MINUTE-LAST-INTERNAL" + self.subscribe_bars(bar_type) + self.log.info( + f"Funding arb: {self.config.spot_instrument} / {self.config.perp_instrument}" + ) + + def on_bar(self, bar) -> None: + funding_rate = self._get_funding_rate() + if funding_rate is None: + return + + spot_pos = self.portfolio.net_position(self.config.spot_instrument) + + if funding_rate > self.config.min_funding_rate and spot_pos == 0: + self._open() + self.position_open = True + elif funding_rate < self.config.min_funding_rate / 2 and self.position_open: + self._close() + self.position_open = False + + def _get_funding_rate(self) -> float | None: + # TODO: fetch from Hyperliquid API + return 0.0001 + + def _open(self) -> None: + self.submit_order(self.order_factory.market( + instrument_id=self.config.spot_instrument, + order_side="BUY", + quantity=self.config.position_size, + )) + self.submit_order(self.order_factory.market( + instrument_id=self.config.perp_instrument, + order_side="SELL", + quantity=self.config.position_size, + )) + + def _close(self) -> None: + self.close_all_positions(self.config.spot_instrument) + self.close_all_positions(self.config.perp_instrument) diff --git a/strategies/iceberg_detection.py b/strategies/iceberg_detection.py new file mode 100644 index 0000000..576ec33 --- /dev/null +++ b/strategies/iceberg_detection.py @@ -0,0 +1,66 @@ +""" +Iceberg / TWAP detection strategy. + +Large traders often split big orders into small slices to avoid +slippage. This strategy detects those patterns by watching for +recurring same-sized trades above average volume, then enters +in the same direction. +""" +from collections import deque +from nautilus_trader.trading.strategy import Strategy +from nautilus_trader.config import StrategyConfig + + +class IcebergDetectorConfig(StrategyConfig, frozen=True): + instrument_id: str + lookback_seconds: int = 300 + volume_spike_mult: float = 3.0 + min_slices: int = 4 + trade_size: float = 0.001 + + +class IcebergDetector(Strategy): + """ + Detects iceberg/TWAP execution patterns. + + Logic: + 1. Track trade sizes in a rolling window + 2. When a trade is much larger than average, flag it + 3. If same size repeats N times -> confirmed iceberg + 4. Trade in the same direction + """ + + def __init__(self, config: IcebergDetectorConfig) -> None: + super().__init__(config) + self.config = config + self.recent_sizes: deque[float] = deque(maxlen=100) + self.slice_count = 0 + self.last_flagged_size: float | None = None + + def on_start(self) -> None: + self.subscribe_trade_ticks(self.config.instrument_id) + self.log.info(f"Iceberg detector started on {self.config.instrument_id}") + + def on_trade_tick(self, tick) -> None: + self.recent_sizes.append(tick.size) + avg = sum(self.recent_sizes) / len(self.recent_sizes) if self.recent_sizes else 0 + + if tick.size > avg * self.config.volume_spike_mult: + if tick.size == self.last_flagged_size: + self.slice_count += 1 + else: + self.slice_count = 1 + self.last_flagged_size = tick.size + else: + self.slice_count = 0 + + if self.slice_count >= self.config.min_slices: + self.log.info( + f"Iceberg: {self.slice_count} slices of size {self.last_flagged_size}" + ) + self.submit_order(self.order_factory.market( + instrument_id=self.config.instrument_id, + order_side="BUY" if tick.is_buyer_maker else "SELL", + quantity=self.config.trade_size, + )) + self.slice_count = 0 diff --git a/strategies/orderbook_imbalance.py b/strategies/orderbook_imbalance.py new file mode 100644 index 0000000..05736b5 --- /dev/null +++ b/strategies/orderbook_imbalance.py @@ -0,0 +1,76 @@ +""" +Order Book Imbalance strategy. + +Enters positions when bid/ask volume at the top of the order book +shows a significant directional skew. The idea: when one side of +the book is much heavier, price tends to move toward the thinner +side as the heavy side absorbs market orders. +""" +from nautilus_trader.trading.strategy import Strategy +from nautilus_trader.config import StrategyConfig + + +class OrderBookImbalanceConfig(StrategyConfig, frozen=True): + instrument_id: str + depth: int = 10 + imbalance_threshold: float = 0.6 + trade_size: float = 0.001 + max_position: float = 0.003 + cooldown_bars: int = 5 + + +class OrderBookImbalance(Strategy): + """ + Trades on L2 order book imbalance. + + - imbalance > threshold -> bid side heavy -> buy + - imbalance < 1-threshold -> ask side heavy -> sell + """ + + def __init__(self, config: OrderBookImbalanceConfig) -> None: + super().__init__(config) + self.config = config + self.bars_since_last_trade = 0 + + def on_start(self) -> None: + self.subscribe_order_book_deltas( + self.config.instrument_id, + depth=self.config.depth, + ) + self.log.info( + f"OFI started on {self.config.instrument_id} " + f"(depth={self.config.depth})" + ) + + def on_order_book_deltas(self, deltas) -> None: + self.bars_since_last_trade += 1 + if self.bars_since_last_trade < self.config.cooldown_bars: + return + + book = self.cache.order_book(self.config.instrument_id) + if not book or len(book.bids) == 0 or len(book.asks) == 0: + return + + depth = min(self.config.depth, len(book.bids), len(book.asks)) + bid_vol = sum(book.bids[i].size for i in range(depth)) + ask_vol = sum(book.asks[i].size for i in range(depth)) + total = bid_vol + ask_vol + if total == 0: + return + + imbalance = bid_vol / total + pos = self.portfolio.net_position(self.config.instrument_id) + + if imbalance > self.config.imbalance_threshold and pos <= 0: + self._enter("BUY") + self.bars_since_last_trade = 0 + elif imbalance < (1 - self.config.imbalance_threshold) and pos >= 0: + self._enter("SELL") + self.bars_since_last_trade = 0 + + def _enter(self, side: str) -> None: + self.submit_order(self.order_factory.market( + instrument_id=self.config.instrument_id, + order_side=side, + quantity=self.config.trade_size, + )) diff --git a/strategies/pairs_trading.py b/strategies/pairs_trading.py new file mode 100644 index 0000000..8e8f5c8 --- /dev/null +++ b/strategies/pairs_trading.py @@ -0,0 +1,95 @@ +""" +Pairs Trading strategy (BTC-PERP / ETH-PERP). + +Computes the Z-score of the BTC-ETH spread over a rolling window. +When the spread moves beyond a threshold, trades mean reversion. + +- Z > +2: BTC expensive -> short BTC, long ETH +- Z < -2: BTC cheap -> long BTC, short ETH +""" +import numpy as np +from nautilus_trader.trading.strategy import Strategy +from nautilus_trader.config import StrategyConfig + + +class PairsTradingConfig(StrategyConfig, frozen=True): + pair: tuple[str, str] + z_entry: float = 2.0 + z_exit: float = 0.5 + lookback_hours: int = 24 + trade_size: float = 0.001 + hedge_ratio: float = 0.05 + + +class PairsTrading(Strategy): + """ + Statistical arbitrage on BTC/ETH spread. + + Assumes BTC and ETH are cointegrated — the spread between + them tends to revert to a mean. Trades the deviations. + """ + + def __init__(self, config: PairsTradingConfig) -> None: + super().__init__(config) + self.config = config + self.price_history: dict[str, list[float]] = { + self.config.pair[0]: [], + self.config.pair[1]: [], + } + self.position_open = False + + def on_start(self) -> None: + for inst in self.config.pair: + self.subscribe_bars(f"{inst}-1-MINUTE-LAST-INTERNAL") + self.log.info(f"Pairs trading: {self.config.pair[0]} / {self.config.pair[1]}") + + def on_bar(self, bar) -> None: + inst_id = str(bar.bar_type.instrument_id) + if inst_id not in self.price_history: + return + + self.price_history[inst_id].append(bar.close.as_double()) + + a_hist = self.price_history[self.config.pair[0]] + b_hist = self.price_history[self.config.pair[1]] + if len(a_hist) < 100 or len(b_hist) < 100: + return + + maxlen = self.config.lookback_hours * 60 + self.price_history[self.config.pair[0]] = a_hist[-maxlen:] + self.price_history[self.config.pair[1]] = b_hist[-maxlen:] + + a = np.array(a_hist[-100:]) + b = np.array(b_hist[-100:]) + spread = a - self.config.hedge_ratio * b + + std = spread.std() + z = (spread[-1] - spread.mean()) / std if std > 0 else 0 + + self._signal(z) + + def _signal(self, z: float) -> None: + btc_pos = self.portfolio.net_position(self.config.pair[0]) + + if z > self.config.z_entry and btc_pos <= 0: + self._trade("SELL", "BUY") + self.position_open = True + elif z < -self.config.z_entry and btc_pos >= 0: + self._trade("BUY", "SELL") + self.position_open = True + elif abs(z) < self.config.z_exit and self.position_open: + self.close_all_positions(self.config.pair[0]) + self.close_all_positions(self.config.pair[1]) + self.position_open = False + + def _trade(self, a_side: str, b_side: str) -> None: + self.submit_order(self.order_factory.market( + instrument_id=self.config.pair[0], + order_side=a_side, + quantity=self.config.trade_size, + )) + self.submit_order(self.order_factory.market( + instrument_id=self.config.pair[1], + order_side=b_side, + quantity=self.config.trade_size / self.config.hedge_ratio, + ))