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/.
This commit is contained in:
+14
@@ -0,0 +1,14 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.venv/
|
||||
venv/
|
||||
.env
|
||||
*.pem
|
||||
*_pk
|
||||
data/
|
||||
*.parquet
|
||||
.ipynb_checkpoints/
|
||||
.idea/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
@@ -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.
|
||||
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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Common utilities package
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
# Package init files (make these importable)
|
||||
@@ -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,
|
||||
))
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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,
|
||||
))
|
||||
@@ -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,
|
||||
))
|
||||
Reference in New Issue
Block a user