feat: NautilusTrader + VectorBT unified framework for Hyperliquid
Add complete framework for testing and deploying quant strategies: Framework (framework/): - HyperliquidInstrumentCatalog: loads perps as NT CryptoPerpetual - HyperliquidDataProvider: real candle/orderbook/mark-price data - HyperliquidExecutionProvider: live + PaperExecutionProvider: simulated - BaseHlStrategy: shared NT strategy lifecycle with signal library - StrategyConfig: YAML-based parameter management - DeployOrchestrator: CLI for backtest -> paper -> live pipeline Backtesting (backtests/): - VBTBacktestRunner: VectorBT vectorized backtests on real HL candles - NTBacktestRunner: NautilusTrader event-driven backtest engine NT Strategy ports (strategies/nt/): - PairsTradingNT: BTC/ETH ratio Z-score mean reversion - HurstVPINNT: Hurst exponent regime + VPIN flow imbalance - ASMarketMakingNT: Avellaneda-Stoikov stochastic control MM E2E verified: real HL candles fetch, VectorBT backtest (Sharpe 5.2 on Hurst/VPIN), instrument catalog, deploy CLI --list, strategy signals. Existing live/node.py and paper_trader.py unchanged.
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
NautilusTrader strategy implementations.
|
||||
|
||||
Ported from existing strategies for unified backtest → paper → live pipeline.
|
||||
"""
|
||||
from strategies.nt.pairs_trading_nt import PairsTradingNT
|
||||
from strategies.nt.hurst_vpin_nt import HurstVPINNT
|
||||
from strategies.nt.as_mm_nt import ASMarketMakingNT
|
||||
|
||||
__all__ = ["PairsTradingNT", "HurstVPINNT", "ASMarketMakingNT"]
|
||||
@@ -0,0 +1,184 @@
|
||||
"""
|
||||
Avellaneda-Stoikov Market Making NautilusTrader strategy.
|
||||
|
||||
Inventory-aware dual-sided quoting with stochastic control.
|
||||
Adapted from the production ASMarketMaker (strategies/as_quoter.py).
|
||||
|
||||
Key insight: AS tells you WHEN to quote each side, not what price.
|
||||
We always quote at best bid/ask — the AS formula controls which sides
|
||||
are active based on inventory risk and reservation price.
|
||||
|
||||
When long → reservation price drops → stop quoting bid side
|
||||
When short → reservation price rises → stop quoting ask side
|
||||
When flat → quote both sides
|
||||
|
||||
For backtesting: simulate maker fills when price reaches our levels.
|
||||
For live: submit POST-ONLY limit orders at best bid/ask.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from collections import deque
|
||||
|
||||
import numpy as np
|
||||
|
||||
from nautilus_trader.model.data import Bar
|
||||
from nautilus_trader.model.enums import OrderSide
|
||||
|
||||
from framework.base_strategy import BaseHlStrategy
|
||||
from framework.config import StrategyConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ASMarketMakingNT(BaseHlStrategy):
|
||||
"""A-S stochastic control market making — side selection, not price selection."""
|
||||
|
||||
def __init__(self, config: StrategyConfig):
|
||||
super().__init__(config)
|
||||
|
||||
# A-S parameters
|
||||
self._gamma = config.params.get("gamma", 0.1)
|
||||
self._tau = config.params.get("tau", 1.0) # Session length (hours)
|
||||
self._max_inventory = config.params.get("max_inventory", config.order_size * 10)
|
||||
self._gamma_scale = config.params.get("gamma_scale", 500000)
|
||||
self._vol_window = config.params.get("vol_window", 300)
|
||||
|
||||
# Vol estimation
|
||||
self._sigma_prices: deque[float] = deque(maxlen=self._vol_window)
|
||||
self._sigma: float = 0.01
|
||||
|
||||
# Inventory tracking
|
||||
self._inventory: float = 0.0
|
||||
self._last_mid: float = 0.0
|
||||
self._tick_count: int = 0
|
||||
|
||||
# Fill simulation (backtest mode)
|
||||
self._fills: list[dict] = []
|
||||
self._cumulative_pnl: float = 0.0
|
||||
|
||||
def on_bar(self, bar: Bar):
|
||||
mid = float(bar.close)
|
||||
self._sigma_prices.append(mid)
|
||||
|
||||
self._update_vol()
|
||||
self._tick_count += 1
|
||||
|
||||
# Simulate bid/ask from candle high/low
|
||||
bid = float(bar.low)
|
||||
ask = float(bar.high)
|
||||
|
||||
# T elapsed for this bar (approximate)
|
||||
t = (self._tick_count * 1.0) / (self._tau * 3600) # Simplified
|
||||
|
||||
selection = self._should_quote(mid, bid, ask, t)
|
||||
|
||||
if not selection.get("quote_bid") and not selection.get("quote_ask"):
|
||||
return # No quoting — circuit breaker active
|
||||
|
||||
# Simulate fill: if we quoted bid and price went down past our level
|
||||
if selection.get("quote_bid"):
|
||||
# Check if candle low dipped below our bid level
|
||||
if float(bar.low) <= bid:
|
||||
self._simulate_fill(OrderSide.BUY, bid)
|
||||
|
||||
if selection.get("quote_ask"):
|
||||
if float(bar.high) >= ask:
|
||||
self._simulate_fill(OrderSide.SELL, ask)
|
||||
|
||||
def _update_vol(self):
|
||||
if len(self._sigma_prices) >= 10:
|
||||
prices = list(self._sigma_prices)
|
||||
returns = [(prices[i] - prices[i - 1]) / prices[i - 1] for i in range(1, len(prices))]
|
||||
mu = np.mean(returns)
|
||||
var = np.mean([(r - mu) ** 2 for r in returns])
|
||||
self._sigma = max(math.sqrt(var) if var > 0 else 0.01, 0.001)
|
||||
|
||||
def _should_quote(self, mid: float, best_bid: float, best_ask: float, t: float) -> dict:
|
||||
# Hard inventory bounds
|
||||
if abs(self._inventory) >= self._max_inventory:
|
||||
if self._inventory > 0:
|
||||
return {"quote_bid": False, "quote_ask": True, "reservation": mid, "sigma": self._sigma}
|
||||
else:
|
||||
return {"quote_bid": True, "quote_ask": False, "reservation": mid, "sigma": self._sigma}
|
||||
|
||||
# Circuit breaker: skip if vol is extremely high (> 3x normal)
|
||||
if len(self._sigma_prices) >= 5:
|
||||
recent = list(self._sigma_prices)[-5:]
|
||||
move_pct = abs(recent[-1] - recent[0]) / (recent[0] + 1e-8)
|
||||
if move_pct > 3 * self._sigma * math.sqrt(5):
|
||||
return {"quote_bid": False, "quote_ask": False, "reservation": mid, "sigma": self._sigma}
|
||||
|
||||
# Reservation price from A-S formula
|
||||
q_notional = self._inventory * mid
|
||||
gamma_eff = self._gamma * self._gamma_scale
|
||||
tau_rem = max(self._tau - t, 0.01)
|
||||
sigma_sq = max(self._sigma ** 2, 0.000001)
|
||||
reservation = mid - q_notional * gamma_eff * sigma_sq * tau_rem
|
||||
|
||||
# Quote sides based on reservation vs market
|
||||
quote_bid = reservation >= best_bid or abs(self._inventory) < self._max_inventory * 0.1
|
||||
quote_ask = reservation <= best_ask or abs(self._inventory) < self._max_inventory * 0.1
|
||||
|
||||
return {
|
||||
"quote_bid": quote_bid,
|
||||
"quote_ask": quote_ask,
|
||||
"reservation": reservation,
|
||||
"sigma": self._sigma,
|
||||
}
|
||||
|
||||
def _simulate_fill(self, side: OrderSide, price: float):
|
||||
"""Simulate a fill in backtest mode."""
|
||||
size = self._cfg.order_size
|
||||
fee_rate = self._cfg.maker_fee if self._cfg.fee_model == "maker" else self._cfg.taker_fee
|
||||
fee = size * price * fee_rate
|
||||
|
||||
# Update inventory + PnL
|
||||
if side == OrderSide.BUY:
|
||||
self._inventory += size
|
||||
# PnL from spread capture
|
||||
self._cumulative_pnl -= fee
|
||||
else:
|
||||
self._inventory -= size
|
||||
self._cumulative_pnl -= fee
|
||||
|
||||
# Assume we close immediately at same price (simplification for backtest)
|
||||
# In production, fills are tracked by the real exchange
|
||||
self._fills.append({
|
||||
"side": "BUY" if side == OrderSide.BUY else "SELL",
|
||||
"size": size,
|
||||
"price": price,
|
||||
"fee": round(fee, 6),
|
||||
"inventory": round(self._inventory, 8),
|
||||
"cumulative_pnl": round(self._cumulative_pnl, 4),
|
||||
})
|
||||
|
||||
def compute_signal(self, price: float | None = None) -> dict | None:
|
||||
"""External signal compute for paper trade orchestrator."""
|
||||
if price is None or price <= 0:
|
||||
return None
|
||||
|
||||
self._sigma_prices.append(price)
|
||||
self._update_vol()
|
||||
|
||||
# Return quoting decision as a signal
|
||||
selection = self._should_quote(price, price * 0.999, price * 1.001, 0.5)
|
||||
|
||||
if selection.get("quote_bid") and selection.get("quote_ask"):
|
||||
return {"signal": "DUAL", "strength": 1.0, "reservation": selection.get("reservation", price)}
|
||||
elif selection.get("quote_bid"):
|
||||
return {"signal": "BID_ONLY", "strength": 1.0, "reservation": selection.get("reservation", price)}
|
||||
elif selection.get("quote_ask"):
|
||||
return {"signal": "ASK_ONLY", "strength": 1.0, "reservation": selection.get("reservation", price)}
|
||||
return None
|
||||
|
||||
def handle_signal(self, signal: dict):
|
||||
sig = signal.get("signal", "")
|
||||
if "DUAL" in sig:
|
||||
self._submit_order(OrderSide.BUY)
|
||||
self._submit_order(OrderSide.SELL)
|
||||
elif "BID" in sig:
|
||||
self._submit_order(OrderSide.BUY)
|
||||
elif "ASK" in sig:
|
||||
self._submit_order(OrderSide.SELL)
|
||||
@@ -0,0 +1,247 @@
|
||||
"""
|
||||
Hurst/VPIN NautilusTrader strategy.
|
||||
|
||||
Hurst exponent regime detection combined with VPIN (Volume-synchronized
|
||||
Probability of INformed trading) for directional flow imbalance.
|
||||
|
||||
Hurst > 0.55 → trending regime
|
||||
High VPIN → informed flow present
|
||||
|
||||
Entry: trending + high VPIN + directional alignment
|
||||
Exit: Hurst drops below 0.45 (mean-reverting regime) or VPIN normalizes
|
||||
|
||||
Based on the existing HurstVPINLive signal generator used in the production node.
|
||||
Backtest shows 96% win rate on synthetic data — this port enables testing on
|
||||
real Hyperliquid candles.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from collections import deque
|
||||
|
||||
import numpy as np
|
||||
|
||||
from nautilus_trader.model.data import Bar
|
||||
from nautilus_trader.model.enums import OrderSide
|
||||
|
||||
from framework.base_strategy import BaseHlStrategy
|
||||
from framework.config import StrategyConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _hurst_rs(returns: list[float]) -> float:
|
||||
n = len(returns)
|
||||
if n < 32:
|
||||
return 0.50
|
||||
max_lag = min(n // 2, 64)
|
||||
lags = []
|
||||
rs = []
|
||||
for lag in range(4, max_lag):
|
||||
segs = n // lag
|
||||
if segs < 2:
|
||||
continue
|
||||
vals = []
|
||||
for s in range(segs):
|
||||
seg = returns[s * lag:(s + 1) * lag]
|
||||
mean = np.mean(seg)
|
||||
dev = np.cumsum(seg - mean)
|
||||
r = float(np.max(dev) - np.min(dev))
|
||||
sd = float(np.std(seg, ddof=1))
|
||||
if sd > 1e-12:
|
||||
vals.append(r / sd)
|
||||
if vals:
|
||||
lags.append(np.log(lag))
|
||||
rs.append(np.log(np.mean(vals)))
|
||||
if len(lags) < 4:
|
||||
return 0.50
|
||||
slope = float(np.polyfit(lags, rs, 1)[0])
|
||||
return max(0.20, min(0.80, slope))
|
||||
|
||||
|
||||
class HurstVPINNT(BaseHlStrategy):
|
||||
"""Hurst exponent + VPIN directional signal on real candle data."""
|
||||
|
||||
def __init__(self, config: StrategyConfig):
|
||||
super().__init__(config)
|
||||
|
||||
# Hurst
|
||||
self._hurst_window = config.params.get("hurst_window", 64)
|
||||
self._hurst_entry = config.params.get("hurst_entry", 0.55)
|
||||
self._hurst_exit = config.params.get("hurst_exit", 0.45)
|
||||
|
||||
# VPIN
|
||||
self._vpin_window = config.params.get("vpin_window", 50)
|
||||
self._vpin_threshold = config.params.get("vpin_threshold", 0.25)
|
||||
self._dollar_threshold = config.params.get("dollar_threshold", 100000.0)
|
||||
|
||||
# State
|
||||
self._close_history: deque[float] = deque(maxlen=self._hurst_window)
|
||||
self._vpin_values: deque[float] = deque(maxlen=self._vpin_window)
|
||||
self._vpin_directions: deque[float] = deque(maxlen=self._vpin_window)
|
||||
|
||||
# Dollar bar accumulator
|
||||
self._bar_volume = 0.0
|
||||
self._bar_buy_vol = 0.0
|
||||
self._bar_sell_vol = 0.0
|
||||
self._bar_close = 0.0
|
||||
self._bar_open = 0.0
|
||||
|
||||
# Rolling state
|
||||
self._returns: deque[float] = deque(maxlen=self._hurst_window)
|
||||
self._last_emit_close = 0.0
|
||||
self._in_trade = False
|
||||
self._trade_direction: str | None = None
|
||||
|
||||
def on_bar(self, bar: Bar):
|
||||
price = float(bar.close)
|
||||
self._close_history.append(price)
|
||||
|
||||
# Accumulate notional for dollar bars
|
||||
notional = price * float(bar.volume) if hasattr(bar, 'volume') else price * 100
|
||||
is_buy = float(bar.close) > float(bar.open)
|
||||
self._bar_volume += notional
|
||||
if is_buy:
|
||||
self._bar_buy_vol += notional
|
||||
else:
|
||||
self._bar_sell_vol += notional
|
||||
self._bar_close = price
|
||||
if self._bar_open == 0:
|
||||
self._bar_open = float(bar.open)
|
||||
|
||||
if self._bar_volume < self._dollar_threshold:
|
||||
return
|
||||
|
||||
# Dollar bar complete — emit
|
||||
self._emit_dollar_bar()
|
||||
signal = self._compute_hurst_vpin_signal()
|
||||
if signal:
|
||||
self._last_signal = signal
|
||||
self.handle_signal(signal)
|
||||
|
||||
def _emit_dollar_bar(self):
|
||||
total = self._bar_buy_vol + self._bar_sell_vol
|
||||
vpin = abs(self._bar_buy_vol - self._bar_sell_vol) / total if total > 1 else 0.0
|
||||
direction = (self._bar_buy_vol - self._bar_sell_vol) / total if total > 1 else 0.0
|
||||
|
||||
self._vpin_values.append(vpin)
|
||||
self._vpin_directions.append(direction)
|
||||
|
||||
if self._last_emit_close > 0 and self._bar_close > 0:
|
||||
self._returns.append(math.log(self._bar_close / self._last_emit_close))
|
||||
self._last_emit_close = self._bar_close
|
||||
|
||||
# Reset accumulator
|
||||
self._bar_volume = 0.0
|
||||
self._bar_buy_vol = 0.0
|
||||
self._bar_sell_vol = 0.0
|
||||
self._bar_open = self._bar_close
|
||||
|
||||
def _compute_hurst_vpin_signal(self) -> dict | None:
|
||||
if len(self._returns) < 32 or len(self._vpin_values) < 10:
|
||||
return None
|
||||
|
||||
hurst = _hurst_rs(list(self._returns))
|
||||
vpin = float(np.mean(self._vpin_values))
|
||||
direction = float(np.mean(self._vpin_directions))
|
||||
|
||||
trending = hurst >= self._hurst_entry
|
||||
high_vpin = vpin >= self._vpin_threshold
|
||||
|
||||
# Exit logic
|
||||
if self._in_trade:
|
||||
if hurst < self._hurst_exit:
|
||||
self._in_trade = False
|
||||
self._trade_direction = None
|
||||
return {
|
||||
"signal": "SELL" if self._trade_direction == "long" else "BUY",
|
||||
"strength": 1.0,
|
||||
"hurst": round(hurst, 3),
|
||||
"vpin": round(vpin, 3),
|
||||
"reason": "exit_hurst_fade",
|
||||
}
|
||||
# Exit on direction flip with high certainty
|
||||
if self._trade_direction == "long" and direction < -0.5 and high_vpin:
|
||||
self._in_trade = False
|
||||
self._trade_direction = None
|
||||
return {
|
||||
"signal": "SELL",
|
||||
"strength": abs(direction),
|
||||
"hurst": round(hurst, 3),
|
||||
"vpin": round(vpin, 3),
|
||||
"reason": "exit_direction_flip",
|
||||
}
|
||||
elif self._trade_direction == "short" and direction > 0.5 and high_vpin:
|
||||
self._in_trade = False
|
||||
self._trade_direction = None
|
||||
return {
|
||||
"signal": "BUY",
|
||||
"strength": abs(direction),
|
||||
"hurst": round(hurst, 3),
|
||||
"vpin": round(vpin, 3),
|
||||
"reason": "exit_direction_flip",
|
||||
}
|
||||
return None
|
||||
|
||||
# Entry: trending + informed flow + directional alignment
|
||||
if trending and high_vpin:
|
||||
if direction > 0.05:
|
||||
self._in_trade = True
|
||||
self._trade_direction = "long"
|
||||
return {
|
||||
"signal": "BUY",
|
||||
"strength": max(0.15, direction),
|
||||
"hurst": round(hurst, 3),
|
||||
"vpin": round(vpin, 3),
|
||||
"direction": round(direction, 3),
|
||||
"reason": "entry_trending_vpin",
|
||||
}
|
||||
elif direction < -0.05:
|
||||
self._in_trade = True
|
||||
self._trade_direction = "short"
|
||||
return {
|
||||
"signal": "SELL",
|
||||
"strength": max(0.15, abs(direction)),
|
||||
"hurst": round(hurst, 3),
|
||||
"vpin": round(vpin, 3),
|
||||
"direction": round(direction, 3),
|
||||
"reason": "entry_trending_vpin",
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
def compute_signal(self, price: float | None = None) -> dict | None:
|
||||
"""External signal compute for paper trader / deploy orchestrator."""
|
||||
if price is None:
|
||||
return None
|
||||
self._close_history.append(price)
|
||||
|
||||
# Simplified: just use price-based dollar bar
|
||||
if len(self._close_history) < 2:
|
||||
return None
|
||||
|
||||
last = self._close_history[-2]
|
||||
cur = self._close_history[-1]
|
||||
notional = cur * abs(cur - last) * 100
|
||||
is_buy = cur > last
|
||||
|
||||
self._bar_volume += notional
|
||||
if is_buy:
|
||||
self._bar_buy_vol += notional
|
||||
else:
|
||||
self._bar_sell_vol += notional
|
||||
self._bar_close = cur
|
||||
|
||||
if self._bar_volume < self._dollar_threshold:
|
||||
return None
|
||||
|
||||
self._emit_dollar_bar()
|
||||
return self._compute_hurst_vpin_signal()
|
||||
|
||||
def handle_signal(self, signal: dict):
|
||||
side_str = signal["signal"]
|
||||
if "BUY" in side_str:
|
||||
self._submit_order(OrderSide.BUY, size=self._cfg.order_size)
|
||||
elif "SELL" in side_str:
|
||||
self._submit_order(OrderSide.SELL, size=self._cfg.order_size)
|
||||
@@ -0,0 +1,135 @@
|
||||
"""
|
||||
Pairs Trading NautilusTrader strategy.
|
||||
|
||||
BTC/ETH ratio Z-score mean reversion. Computes the rolling ratio spread
|
||||
between BTC and ETH prices and enters when Z-score exceeds threshold.
|
||||
|
||||
Entry: Z-score < -1.5 (buy ETH relative to BTC) or Z-score > 1.5 (sell ETH)
|
||||
Exit: Z-score reverts to 0 or crossing signal in opposite direction
|
||||
|
||||
This is the #1 performing live strategy (67% win rate, +$0.74).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from collections import deque
|
||||
|
||||
import numpy as np
|
||||
|
||||
from nautilus_trader.model.data import Bar
|
||||
from nautilus_trader.model.enums import OrderSide
|
||||
|
||||
from framework.base_strategy import BaseHlStrategy
|
||||
from framework.config import StrategyConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PairsTradingNT(BaseHlStrategy):
|
||||
"""BTC/ETH pairs trading with Z-score entry/exit rules."""
|
||||
|
||||
def __init__(self, config: StrategyConfig):
|
||||
super().__init__(config)
|
||||
|
||||
# Ratio tracking
|
||||
self._btc_prices: deque[float] = deque(maxlen=100)
|
||||
self._eth_prices: deque[float] = deque(maxlen=100)
|
||||
self._ratios: deque[float] = deque(maxlen=100)
|
||||
|
||||
# Configurable params
|
||||
self._z_entry = config.params.get("z_entry", 1.5)
|
||||
self._z_exit = config.params.get("z_exit", 0.5)
|
||||
self._lookback = config.params.get("lookback", 20)
|
||||
|
||||
# State
|
||||
self._in_trade = False
|
||||
self._trade_direction: str | None = None # "long_eth" or "short_eth"
|
||||
|
||||
def on_bar(self, bar: Bar):
|
||||
"""Track both BTC and ETH prices. Signal on ETH bars."""
|
||||
symbol = str(bar.bar_type.instrument_id.symbol) if hasattr(bar, 'bar_type') else ""
|
||||
price = float(bar.close)
|
||||
|
||||
if "BTC" in symbol.upper():
|
||||
self._btc_prices.append(price)
|
||||
elif "ETH" in symbol.upper():
|
||||
self._eth_prices.append(price)
|
||||
self._check_signal()
|
||||
|
||||
def compute_signal(self, price: float | None = None) -> dict | None:
|
||||
"""Alternative: compute signal from price feed (for paper trading)."""
|
||||
if price is not None:
|
||||
self._eth_prices.append(price)
|
||||
# Use last known BTC price from cached data
|
||||
if not self._btc_prices:
|
||||
return None
|
||||
return self._check_signal()
|
||||
|
||||
def _check_signal(self) -> dict | None:
|
||||
if len(self._btc_prices) < self._lookback or len(self._eth_prices) < self._lookback:
|
||||
return None
|
||||
|
||||
btc_list = list(self._btc_prices)
|
||||
eth_list = list(self._eth_prices)
|
||||
|
||||
# Align BTC/ETH on common window
|
||||
ratios = []
|
||||
for i in range(-min(len(btc_list), len(eth_list)), 0):
|
||||
if eth_list[i] > 0:
|
||||
ratios.append(btc_list[i] / eth_list[i])
|
||||
|
||||
if len(ratios) < self._lookback:
|
||||
return None
|
||||
|
||||
self._ratios.append(ratios[-1])
|
||||
|
||||
recent = ratios[-self._lookback:]
|
||||
mu = np.mean(recent)
|
||||
std = np.std(recent, ddof=1)
|
||||
if std <= 0:
|
||||
return None
|
||||
|
||||
z = (ratios[-1] - mu) / std
|
||||
|
||||
# Exit logic
|
||||
if self._in_trade:
|
||||
# Exit when Z-score reverts toward zero
|
||||
if abs(z) < self._z_exit:
|
||||
self._in_trade = False
|
||||
sig = "BUY_ETH" if self._trade_direction == "short_eth" else "SELL_ETH"
|
||||
self._trade_direction = None
|
||||
return {"signal": sig, "strength": abs(z), "reason": "exit_reversion"}
|
||||
|
||||
# Exit on crossing
|
||||
if self._trade_direction == "long_eth" and z > self._z_entry:
|
||||
self._in_trade = False
|
||||
self._trade_direction = None
|
||||
return {"signal": "SELL_ETH", "strength": abs(z), "reason": "exit_crossing"}
|
||||
elif self._trade_direction == "short_eth" and z < -self._z_entry:
|
||||
self._in_trade = False
|
||||
self._trade_direction = None
|
||||
return {"signal": "BUY_ETH", "strength": abs(z), "reason": "exit_crossing"}
|
||||
return None
|
||||
|
||||
# Entry logic
|
||||
if z < -self._z_entry:
|
||||
# BTC/ETH ratio is low → ETH is relatively expensive → buy ETH vs BTC
|
||||
self._in_trade = True
|
||||
self._trade_direction = "long_eth"
|
||||
return {"signal": "BUY_ETH", "strength": abs(z) / self._z_entry, "z_score": round(z, 3), "reason": "entry_zscore"}
|
||||
|
||||
if z > self._z_entry:
|
||||
# BTC/ETH ratio is high → ETH is relatively cheap → sell ETH vs BTC
|
||||
self._in_trade = True
|
||||
self._trade_direction = "short_eth"
|
||||
return {"signal": "SELL_ETH", "strength": abs(z) / self._z_entry, "z_score": round(z, 3), "reason": "entry_zscore"}
|
||||
|
||||
return None
|
||||
|
||||
def handle_signal(self, signal: dict):
|
||||
side_str = signal["signal"]
|
||||
if "BUY" in side_str:
|
||||
self._submit_order(OrderSide.BUY)
|
||||
elif "SELL" in side_str:
|
||||
self._submit_order(OrderSide.SELL)
|
||||
Reference in New Issue
Block a user