feat: proper Grid MM, Composite MM, Hurst/VPIN, Iceberg, A-S strategies
New strategies (strategies/nt/):
- GridMMNT: symmetric limit order grid around mid-price, captures spread from
oscillation. Simulates fills from candle high/low. Rebuilds grid every 20 bars.
- CompositeMMNT: weighted ensemble of OBI (30%) + A-S inventory skew (40%) +
Hurst/VPIN (30%). Votes: +1 long, -1 short, 0 neutral. Entry |score| > 0.5.
- IcebergNT: volume spike detection for whale accumulation. Dual-mode:
candle proxy (volume > avg*2.5, >= 3 consecutive same-direction) and
L2 wall detection (single level > avg*3). Exit on stop-loss/time/spike-fade.
Fixed strategies:
- Hurst/VPIN VBT: added proper VPIN proxy from candle volume (buy_vol when close
> open, sell_vol when close < open). 50-bar rolling VPIN window. Signal:
H>0.55 AND VPIN>0.25 AND |direction|>0.05. Exit: H<0.45 or direction flips.
- Hurst/VPIN paper trader: added HurstVPINLive integration (was missing entirely)
- A-S VBT: replaced placeholder spread filter with proper A-S simulation using
reservation price formula (mid - q*gamma*sigma^2*tau), inventory tracking
- A-S NT formula: fixed to standard: mid - q*gamma*sigma^2*tau (was scaled by
notional and gamma_scale improperly)
- Iceberg VBT: new volume spike detection replacing the old trend proxy
Registry: all 7 strategies now ✅ (pairs, hurst_vpin, as_mm, obi, grid_mm,
composite_mm, iceberg)
VBT backtest results (500 BTC 1h bars):
pairs: -2.81% 13 trades 38% win
hurst_vpin: -0.77% 1 trade (VPIN now active, very selective)
as_mm: -16.38% 73 trades 29% win
obi: -7.19% 15 trades 7% win
grid_mm: -4.79% 22 trades 33% win
iceberg: 0 trades (threshold strict for 1h BTC data)
This commit is contained in:
@@ -7,5 +7,9 @@ from strategies.nt.pairs_trading_nt import PairsTradingNT
|
||||
from strategies.nt.hurst_vpin_nt import HurstVPINNT
|
||||
from strategies.nt.as_mm_nt import ASMarketMakingNT
|
||||
from strategies.nt.obi_nt import OBINT
|
||||
from strategies.nt.grid_mm_nt import GridMMNT
|
||||
from strategies.nt.composite_mm_nt import CompositeMMNT
|
||||
from strategies.nt.iceberg_nt import IcebergNT
|
||||
|
||||
__all__ = ["PairsTradingNT", "HurstVPINNT", "ASMarketMakingNT", "OBINT"]
|
||||
__all__ = ["PairsTradingNT", "HurstVPINNT", "ASMarketMakingNT", "OBINT",
|
||||
"GridMMNT", "CompositeMMNT", "IcebergNT"]
|
||||
|
||||
@@ -112,10 +112,8 @@ class ASMarketMakingNT(BaseHlStrategy):
|
||||
|
||||
# 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
|
||||
reservation = mid - self._inventory * self._gamma * sigma_sq * self._tau
|
||||
|
||||
# Quote sides based on reservation vs market
|
||||
quote_bid = reservation >= best_bid or abs(self._inventory) < self._max_inventory * 0.1
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
"""
|
||||
Composite Market Making — weighted ensemble of OBI, A-S, and Hurst/VPIN.
|
||||
|
||||
Each sub-strategy votes: +1 (long), -1 (short), 0 (neutral).
|
||||
Weighted score > entry_threshold → enter. Score crosses below exit_threshold → exit.
|
||||
|
||||
Weights (configurable):
|
||||
- OBI (30%): volume-based order book imbalance
|
||||
- A-S (40%): inventory risk aversion — net short → buy bias, net long → sell bias
|
||||
- Hurst/VPIN (30%): trending regime + informed flow direction
|
||||
|
||||
Entry: |weighted_score| > 0.5
|
||||
Exit: |weighted_score| < 0.3
|
||||
Stop-loss: 2%, take-profit: 2x fee, cooldown: 3 bars
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
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 CompositeMMNT(BaseHlStrategy):
|
||||
"""Weighted ensemble of multiple signal sources for market making."""
|
||||
|
||||
def __init__(self, config: StrategyConfig):
|
||||
super().__init__(config)
|
||||
|
||||
# Weights (must sum to 1.0 for easy interpretation)
|
||||
self._w_obi = config.params.get("obi_weight", 0.30)
|
||||
self._w_as = config.params.get("as_weight", 0.40)
|
||||
self._w_hurst = config.params.get("hurst_weight", 0.30)
|
||||
self._entry_score = config.params.get("entry_score", 0.50)
|
||||
self._exit_score = config.params.get("exit_score", 0.30)
|
||||
self._stop_loss_pct = config.params.get("stop_loss_pct", 0.02)
|
||||
self._take_profit_pct = config.params.get("take_profit_pct", 0.005)
|
||||
self._cooldown_bars = config.params.get("cooldown_bars", 3)
|
||||
|
||||
# Sub-strategy instances (lazy)
|
||||
self._obi = None
|
||||
self._as_mm = None
|
||||
self._hurst = None
|
||||
|
||||
# State
|
||||
self._bars_since_trade = self._cooldown_bars
|
||||
self._in_trade = False
|
||||
self._trade_direction: str | None = None
|
||||
self._entry_price: float = 0.0
|
||||
self._inventory: float = 0.0
|
||||
|
||||
# ── Lazy sub-strategy init ──────────────────────────────────
|
||||
|
||||
def _init_obi(self):
|
||||
if self._obi is None:
|
||||
from strategies.nt.obi_nt import OBINT
|
||||
obi_cfg = StrategyConfig(
|
||||
name="OBI-sub", asset=self._cfg.asset,
|
||||
instrument=self._cfg.instrument, allocation=self._cfg.allocation,
|
||||
order_size=self._cfg.order_size, fee_model="taker",
|
||||
params={"obi_lookback": 20, "obi_entry": 0.30, "obi_exit": 0.10, "cooldown_bars": 0},
|
||||
)
|
||||
self._obi = OBINT(obi_cfg)
|
||||
|
||||
def _init_as(self):
|
||||
if self._as_mm is None:
|
||||
from strategies.nt.as_mm_nt import ASMarketMakingNT
|
||||
as_cfg = StrategyConfig(
|
||||
name="AS-sub", asset=self._cfg.asset,
|
||||
instrument=self._cfg.instrument, allocation=self._cfg.allocation,
|
||||
order_size=self._cfg.order_size, fee_model="maker",
|
||||
params={"gamma": 0.1, "max_inventory": self._cfg.order_size * 10},
|
||||
)
|
||||
self._as_mm = ASMarketMakingNT(as_cfg)
|
||||
|
||||
def _init_hurst(self):
|
||||
if self._hurst is None:
|
||||
from strategies.nt.hurst_vpin_nt import HurstVPINNT
|
||||
hv_cfg = StrategyConfig(
|
||||
name="HV-sub", asset=self._cfg.asset,
|
||||
instrument=self._cfg.instrument, allocation=self._cfg.allocation,
|
||||
order_size=self._cfg.order_size, fee_model="taker",
|
||||
params={"hurst_window": 64, "hurst_entry": 0.55,
|
||||
"vpin_threshold": 0.25, "dollar_threshold": 100000.0},
|
||||
)
|
||||
self._hurst = HurstVPINNT(hv_cfg)
|
||||
|
||||
# ── Bar handler ─────────────────────────────────────────────
|
||||
|
||||
def on_bar(self, bar: Bar):
|
||||
price = float(bar.close)
|
||||
self._prices.append(price)
|
||||
|
||||
# Feed all sub-strategies
|
||||
self._init_obi()
|
||||
self._init_as()
|
||||
self._init_hurst()
|
||||
|
||||
# Feed bar to sub-strategies (they accumulate state internally)
|
||||
self._obi.on_bar(bar)
|
||||
self._as_mm.on_bar(bar)
|
||||
self._hurst.on_bar(bar)
|
||||
|
||||
self._bars_since_trade += 1
|
||||
|
||||
# Exit check
|
||||
if self._in_trade:
|
||||
if self._check_exit(price):
|
||||
return
|
||||
return
|
||||
|
||||
if self._bars_since_trade < self._cooldown_bars:
|
||||
return
|
||||
|
||||
# Compute ensemble signal
|
||||
signal = self._compute_ensemble()
|
||||
if signal:
|
||||
self._last_signal = signal
|
||||
self.handle_signal(signal)
|
||||
|
||||
# ── Ensemble computation ────────────────────────────────────
|
||||
|
||||
def _compute_ensemble(self) -> dict | None:
|
||||
# OBI vote
|
||||
obi_vote = 0.0
|
||||
obi_sig = self._obi._compute_obi_signal()
|
||||
if obi_sig:
|
||||
obi_vote = 1.0 if "BUY" in obi_sig["signal"] else -1.0
|
||||
|
||||
# A-S vote: inventory skew = -sign(inventory)
|
||||
as_vote = 0.0
|
||||
as_inventory = self._as_mm._inventory
|
||||
max_inv = self._as_mm._max_inventory
|
||||
if max_inv > 0:
|
||||
as_vote = -as_inventory / max_inv # +1 when deeply short, -1 when deeply long
|
||||
|
||||
# Hurst vote
|
||||
hurst_vote = 0.0
|
||||
hv_sig = self._hurst._compute_hurst_vpin_signal()
|
||||
if hv_sig:
|
||||
hurst_vote = 1.0 if "BUY" in hv_sig["signal"] else -1.0
|
||||
|
||||
score = self._w_obi * obi_vote + self._w_as * as_vote + self._w_hurst * hurst_vote
|
||||
|
||||
if abs(score) >= self._entry_score:
|
||||
self._in_trade = True
|
||||
self._trade_direction = "long" if score > 0 else "short"
|
||||
self._entry_price = self._prices[-1] if self._prices else 0.0
|
||||
self._bars_since_trade = 0
|
||||
|
||||
return {
|
||||
"signal": "BUY" if score > 0 else "SELL",
|
||||
"strength": abs(score) / self._entry_score,
|
||||
"score": round(score, 3),
|
||||
"votes": f"obi={obi_vote:.1f}_as={as_vote:.2f}_hurst={hurst_vote:.1f}",
|
||||
"reason": "composite_ensemble",
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
# ── Exit logic ──────────────────────────────────────────────
|
||||
|
||||
def _check_exit(self, current_price: float) -> bool:
|
||||
if not self._in_trade or self._entry_price <= 0:
|
||||
return False
|
||||
|
||||
change_pct = (current_price - self._entry_price) / self._entry_price
|
||||
pnl_pct = change_pct if self._trade_direction == "long" else -change_pct
|
||||
|
||||
exit_reason = None
|
||||
|
||||
if pnl_pct <= -self._stop_loss_pct:
|
||||
exit_reason = "stop_loss"
|
||||
elif pnl_pct >= self._take_profit_pct:
|
||||
exit_reason = "take_profit"
|
||||
elif abs(self._weighted_score_fast()) < self._exit_score:
|
||||
exit_reason = "score_reverted"
|
||||
|
||||
if exit_reason is None:
|
||||
return False
|
||||
|
||||
exit_side = "SELL" if self._trade_direction == "long" else "BUY"
|
||||
self._last_signal = {
|
||||
"signal": exit_side,
|
||||
"strength": abs(pnl_pct) / self._stop_loss_pct,
|
||||
"pnl_pct": round(pnl_pct * 100, 2),
|
||||
"reason": exit_reason,
|
||||
}
|
||||
self._in_trade = False
|
||||
self._trade_direction = None
|
||||
self.handle_signal(self._last_signal)
|
||||
return True
|
||||
|
||||
def _weighted_score_fast(self) -> float:
|
||||
"""Fast ensemble score (no sub-signal computation, just state)."""
|
||||
as_inv = self._as_mm._inventory
|
||||
max_inv = self._as_mm._max_inventory
|
||||
as_vote = -as_inv / max_inv if max_inv > 0 else 0.0
|
||||
|
||||
obi_list = list(self._obi._buy_volumes) if self._obi and self._obi._buy_volumes else []
|
||||
sell_list = list(self._obi._sell_volumes) if self._obi and self._obi._sell_volumes else []
|
||||
obi_vote = 0.0
|
||||
total_buy = sum(obi_list[-10:]) if obi_list else 0
|
||||
total_sell = sum(sell_list[-10:]) if sell_list else 0
|
||||
total = total_buy + total_sell
|
||||
if total > 0:
|
||||
obi_vote = (total_buy - total_sell) / total
|
||||
|
||||
return self._w_obi * obi_vote + self._w_as * as_vote
|
||||
|
||||
# ── Signal (for paper trader) ───────────────────────────────
|
||||
|
||||
def compute_signal(self, price: float | None = None,
|
||||
orderbook: dict | None = None) -> dict | None:
|
||||
if price is None or price <= 0:
|
||||
return None
|
||||
self._prices.append(price)
|
||||
self._init_obi()
|
||||
self._init_as()
|
||||
self._init_hurst()
|
||||
|
||||
# Feed price to sub-strategies
|
||||
if price > 0:
|
||||
self._obi.compute_signal(price=price)
|
||||
self._as_mm.compute_signal(price=price)
|
||||
self._hurst.compute_signal(price=price)
|
||||
|
||||
if self._in_trade and self._check_exit(price):
|
||||
return self._last_signal
|
||||
|
||||
self._bars_since_trade += 1
|
||||
if self._bars_since_trade < self._cooldown_bars:
|
||||
return None
|
||||
|
||||
return self._compute_ensemble()
|
||||
|
||||
# ── Order ───────────────────────────────────────────────────
|
||||
|
||||
def handle_signal(self, signal: dict):
|
||||
side_str = signal.get("signal", "")
|
||||
if "BUY" in side_str:
|
||||
self._submit_order(OrderSide.BUY)
|
||||
elif "SELL" in side_str:
|
||||
self._submit_order(OrderSide.SELL)
|
||||
@@ -0,0 +1,168 @@
|
||||
"""
|
||||
Grid Market Making — structural spread capture without directional signal.
|
||||
|
||||
Places a symmetric grid of limit orders above and below the current mid-price.
|
||||
When a buy fills, a sell is immediately placed one grid level above. When a sell
|
||||
fills, a buy is placed one grid level below. Captures the spread repeatedly
|
||||
during ranging / oscillating markets.
|
||||
|
||||
Architecture:
|
||||
- Backtest: simulate fills from candle high/low ranges
|
||||
- Live/Paper: submit real POST-ONLY limit orders and manage order lifecycle
|
||||
|
||||
Grid params:
|
||||
- grid_levels: number of levels on each side (default 10)
|
||||
- grid_spacing_pct: spacing between levels as % of price (default 0.1%)
|
||||
- order_size: fixed size per grid level (default 0.001 BTC)
|
||||
- rebalance_every: recenter grid every N bars (default 20)
|
||||
- maker_fee: fee for limit orders
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections import deque
|
||||
from typing import Any
|
||||
|
||||
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 GridMMNT(BaseHlStrategy):
|
||||
"""Grid market making — places symmetrical buy/sell grid around mid-price."""
|
||||
|
||||
def __init__(self, config: StrategyConfig):
|
||||
super().__init__(config)
|
||||
|
||||
self._grid_levels = config.params.get("grid_levels", 10)
|
||||
self._grid_spacing_pct = config.params.get("grid_spacing_pct", 0.001) # 0.1%
|
||||
self._rebalance_every = config.params.get("rebalance_every", 20)
|
||||
self._maker_fee = config.maker_fee
|
||||
|
||||
# Virtual order book: {price: {"side": "BUY"/"SELL", "size": float, "filled": bool}}
|
||||
self._grid: dict[float, dict] = {}
|
||||
self._fills: list[dict] = []
|
||||
self._inventory: float = 0.0
|
||||
self._cumulative_pnl: float = 0.0
|
||||
self._bar_count: int = 0
|
||||
self._last_mid: float = 0.0
|
||||
|
||||
# ── Core logic ─────────────────────────────────────────────
|
||||
|
||||
def on_bar(self, bar: Bar):
|
||||
self._bar_count += 1
|
||||
mid = float(bar.close)
|
||||
high = float(bar.high)
|
||||
low = float(bar.low)
|
||||
|
||||
# Initialise or rebalance grid
|
||||
if not self._grid or self._bar_count % self._rebalance_every == 0:
|
||||
self._build_grid(mid)
|
||||
|
||||
# Check fills: compare candle range against grid levels
|
||||
filled_buys = []
|
||||
filled_sells = []
|
||||
|
||||
for price, order in self._grid.items():
|
||||
if order["filled"]:
|
||||
continue
|
||||
if order["side"] == "BUY" and low <= price:
|
||||
order["filled"] = True
|
||||
filled_buys.append((price, order))
|
||||
elif order["side"] == "SELL" and high >= price:
|
||||
order["filled"] = True
|
||||
filled_sells.append((price, order))
|
||||
|
||||
# Process fills
|
||||
for px, order in filled_buys:
|
||||
self._inventory += order["size"]
|
||||
self._cumulative_pnl -= order["size"] * px * self._maker_fee
|
||||
# Place matching sell one grid level up
|
||||
sell_px = px * (1 + self._grid_spacing_pct)
|
||||
self._grid[sell_px] = {"side": "SELL", "size": order["size"], "filled": False}
|
||||
self._fills.append({
|
||||
"side": "BUY", "price": round(px, 1), "size": order["size"],
|
||||
"fee": round(order["size"] * px * self._maker_fee, 6),
|
||||
"bar": self._bar_count,
|
||||
})
|
||||
|
||||
for px, order in filled_sells:
|
||||
# Profit = spread capture minus fees
|
||||
spread_pnl = order["size"] * px * self._grid_spacing_pct
|
||||
fee = order["size"] * px * self._maker_fee
|
||||
self._inventory -= order["size"]
|
||||
self._cumulative_pnl += spread_pnl - fee
|
||||
# Place matching buy one grid level down
|
||||
buy_px = px * (1 - self._grid_spacing_pct)
|
||||
self._grid[buy_px] = {"side": "BUY", "size": order["size"], "filled": False}
|
||||
self._fills.append({
|
||||
"side": "SELL", "price": round(px, 1), "size": order["size"],
|
||||
"pnl": round(spread_pnl - fee, 6), "bar": self._bar_count,
|
||||
})
|
||||
|
||||
self._last_mid = mid
|
||||
|
||||
def _build_grid(self, mid: float):
|
||||
"""Rebuild grid from scratch around current mid price."""
|
||||
self._grid.clear()
|
||||
size = self._cfg.order_size
|
||||
spacing = self._grid_spacing_pct
|
||||
|
||||
for i in range(1, self._grid_levels + 1):
|
||||
buy_px = mid * (1 - i * spacing)
|
||||
sell_px = mid * (1 + i * spacing)
|
||||
self._grid[round(buy_px, 6)] = {"side": "BUY", "size": size, "filled": False}
|
||||
self._grid[round(sell_px, 6)] = {"side": "SELL", "size": size, "filled": False}
|
||||
|
||||
# ── Signal for paper trader / live ─────────────────────────
|
||||
|
||||
def compute_signal(self, price: float | None = None,
|
||||
orderbook: dict | None = None) -> dict | None:
|
||||
"""Return grid quotes for paper trader / deploy orchestrator.
|
||||
|
||||
Returns the full grid of bid/ask prices for the execution layer
|
||||
to submit as limit orders.
|
||||
"""
|
||||
if price is None or price <= 0:
|
||||
return None
|
||||
|
||||
mid = price
|
||||
self._build_grid(mid)
|
||||
|
||||
bids = [(p, o["size"]) for p, o in sorted(self._grid.items(), reverse=True)
|
||||
if o["side"] == "BUY"]
|
||||
asks = [(p, o["size"]) for p, o in sorted(self._grid.items())
|
||||
if o["side"] == "SELL"]
|
||||
|
||||
return {
|
||||
"signal": "GRID",
|
||||
"strength": 1.0,
|
||||
"bids": bids[:5],
|
||||
"asks": asks[:5],
|
||||
"levels": self._grid_levels,
|
||||
"spacing_pct": self._grid_spacing_pct * 100,
|
||||
}
|
||||
|
||||
# ── Metrics ─────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def pnl(self) -> float:
|
||||
return self._cumulative_pnl
|
||||
|
||||
@property
|
||||
def total_fills(self) -> int:
|
||||
return len(self._fills)
|
||||
|
||||
@property
|
||||
def inventory(self) -> float:
|
||||
return self._inventory
|
||||
|
||||
def handle_signal(self, signal: dict):
|
||||
"""Grid MM doesn't use single-side signals — handled by on_bar directly."""
|
||||
pass
|
||||
@@ -0,0 +1,212 @@
|
||||
"""
|
||||
Iceberg Detection NautilusTrader strategy.
|
||||
|
||||
Detects whale TWAP/iceberg accumulation by tracking volume spikes and
|
||||
consecutive same-direction large orders.
|
||||
|
||||
Dual-mode:
|
||||
- Backtest: volume spike proxy from candles (volume > avg * multiplier
|
||||
for >= min_consecutive bars in same direction)
|
||||
- Live/Paper: real L2 orderbook wall detection (single level > avg * 3
|
||||
persisting for >= 3 updates)
|
||||
|
||||
Entry: consecutive same-direction spikes/walls → follow smart money
|
||||
Exit: spike count drops below 2 OR trend reverses OR 2% stop-loss
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections import deque
|
||||
from typing import Any
|
||||
|
||||
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 IcebergNT(BaseHlStrategy):
|
||||
"""Iceberg/whale accumulation detection — follows smart money flow."""
|
||||
|
||||
def __init__(self, config: StrategyConfig):
|
||||
super().__init__(config)
|
||||
|
||||
self._vol_lookback = config.params.get("vol_lookback", 40)
|
||||
self._vol_spike_mult = config.params.get("vol_spike_mult", 2.5)
|
||||
self._min_consecutive = config.params.get("min_consecutive", 3)
|
||||
self._max_bars_held = config.params.get("max_bars_held", 8)
|
||||
self._stop_loss_pct = config.params.get("stop_loss_pct", 0.02)
|
||||
self._cooldown_bars = config.params.get("cooldown_bars", 4)
|
||||
|
||||
# Volume tracking
|
||||
self._volumes: deque[float] = deque(maxlen=self._vol_lookback)
|
||||
self._spike_count: int = 0
|
||||
self._prev_spike_dir: str | None = None
|
||||
|
||||
# State
|
||||
self._bars_since_trade = self._cooldown_bars
|
||||
self._bars_held: int = 0
|
||||
self._in_trade = False
|
||||
self._trade_direction: str | None = None
|
||||
self._entry_price: float = 0.0
|
||||
|
||||
# ── Candle mode (backtest) ──────────────────────────────────
|
||||
|
||||
def on_bar(self, bar: Bar):
|
||||
price = float(bar.close)
|
||||
volume = float(bar.volume) if hasattr(bar, 'volume') else 1.0
|
||||
self._prices.append(price)
|
||||
self._volumes.append(volume)
|
||||
|
||||
self._bars_since_trade += 1
|
||||
|
||||
# Exit check
|
||||
if self._in_trade:
|
||||
self._bars_held += 1
|
||||
if self._check_exit(price):
|
||||
return
|
||||
return
|
||||
|
||||
if self._bars_since_trade < self._cooldown_bars:
|
||||
return
|
||||
|
||||
signal = self._detect_iceberg(price, volume)
|
||||
if signal:
|
||||
self._last_signal = signal
|
||||
self.handle_signal(signal)
|
||||
|
||||
def _detect_iceberg(self, price: float, volume: float) -> dict | None:
|
||||
if len(self._volumes) < self._vol_lookback:
|
||||
return None
|
||||
|
||||
avg_vol = np.mean(self._volumes)
|
||||
if avg_vol <= 0:
|
||||
return None
|
||||
|
||||
is_spike = volume > avg_vol * self._vol_spike_mult
|
||||
if not is_spike:
|
||||
self._spike_count = 0
|
||||
self._prev_spike_dir = None
|
||||
return None
|
||||
|
||||
# Determine direction: buy if close > previous close (price going up)
|
||||
if len(self._prices) < 2:
|
||||
return None
|
||||
is_buy = self._prices[-1] > self._prices[-2]
|
||||
spike_dir = "buy" if is_buy else "sell"
|
||||
|
||||
# Track consecutive same-direction spikes
|
||||
if spike_dir == self._prev_spike_dir:
|
||||
self._spike_count += 1
|
||||
else:
|
||||
self._spike_count = 1
|
||||
self._prev_spike_dir = spike_dir
|
||||
|
||||
if self._spike_count >= self._min_consecutive:
|
||||
self._in_trade = True
|
||||
self._trade_direction = "long" if spike_dir == "buy" else "short"
|
||||
self._entry_price = price
|
||||
self._bars_since_trade = 0
|
||||
self._bars_held = 0
|
||||
self._spike_count = 0
|
||||
|
||||
return {
|
||||
"signal": "BUY" if spike_dir == "buy" else "SELL",
|
||||
"strength": min(1.0, self._spike_count / self._min_consecutive),
|
||||
"vol_ratio": round(volume / avg_vol, 1),
|
||||
"spikes": self._spike_count,
|
||||
"reason": f"iceberg_{spike_dir}",
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
# ── L2 mode (live/paper) ────────────────────────────────────
|
||||
|
||||
def compute_signal(self, price: float | None = None,
|
||||
orderbook: dict | None = None) -> dict | None:
|
||||
"""Entry point for paper trader / deploy orchestrator.
|
||||
|
||||
If orderbook provided, use L2 wall detection.
|
||||
Otherwise fall back to candle proxy.
|
||||
"""
|
||||
if orderbook is not None and price is not None:
|
||||
return self._detect_l2_walls(orderbook, price)
|
||||
|
||||
if price is None:
|
||||
return None
|
||||
return self._detect_iceberg(price, 1.0)
|
||||
|
||||
def _detect_l2_walls(self, orderbook: dict, price: float) -> dict | None:
|
||||
"""Detect walls in real L2 orderbook."""
|
||||
bids = orderbook.get("bids", [])
|
||||
asks = orderbook.get("asks", [])
|
||||
|
||||
# Find largest single level size
|
||||
all_sizes = [b[1] for b in bids] + [a[1] for a in asks]
|
||||
if not all_sizes:
|
||||
return None
|
||||
avg_size = np.mean(all_sizes)
|
||||
|
||||
# Check for bid wall (single level > avg * 3)
|
||||
bid_wall = False
|
||||
ask_wall = False
|
||||
for px, sz in bids:
|
||||
if sz > avg_size * 3:
|
||||
bid_wall = True
|
||||
break
|
||||
for px, sz in asks:
|
||||
if sz > avg_size * 3:
|
||||
ask_wall = True
|
||||
break
|
||||
|
||||
if bid_wall and not ask_wall:
|
||||
return {"signal": "BUY", "strength": 0.8, "reason": "l2_bid_wall"}
|
||||
elif ask_wall and not bid_wall:
|
||||
return {"signal": "SELL", "strength": 0.8, "reason": "l2_ask_wall"}
|
||||
return None
|
||||
|
||||
# ── Exit logic ──────────────────────────────────────────────
|
||||
|
||||
def _check_exit(self, current_price: float) -> bool:
|
||||
if not self._in_trade or self._entry_price <= 0:
|
||||
return False
|
||||
|
||||
change_pct = (current_price - self._entry_price) / self._entry_price
|
||||
pnl_pct = change_pct if self._trade_direction == "long" else -change_pct
|
||||
|
||||
exit_reason = None
|
||||
|
||||
if pnl_pct <= -self._stop_loss_pct:
|
||||
exit_reason = "stop_loss"
|
||||
elif self._bars_held >= self._max_bars_held:
|
||||
exit_reason = "time_exit"
|
||||
elif self._spike_count < 2:
|
||||
exit_reason = "spikes_faded"
|
||||
|
||||
if exit_reason is None:
|
||||
return False
|
||||
|
||||
exit_side = "SELL" if self._trade_direction == "long" else "BUY"
|
||||
self._last_signal = {
|
||||
"signal": exit_side,
|
||||
"strength": abs(pnl_pct) / self._stop_loss_pct,
|
||||
"pnl_pct": round(pnl_pct * 100, 2),
|
||||
"bars_held": self._bars_held,
|
||||
"reason": exit_reason,
|
||||
}
|
||||
self._in_trade = False
|
||||
self._trade_direction = None
|
||||
self.handle_signal(self._last_signal)
|
||||
return True
|
||||
|
||||
def handle_signal(self, signal: dict):
|
||||
side_str = signal.get("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