Strategy2LiquiditySentinel
♡
Basics
mode: spot
timeframe: 5m
interface version: 3
Settings
stoploss: -0.05
has minimal roi
custom stoploss
process only new candles
startup candle count: 60
hyperopt
hyperopt params: 7
Concepts
breakout
divergence
mean_reversion
Methods
_get_funding_rate
_get_oi_trend
_rolling_vp
confirm_trade_entry
confirm_trade_exit
custom_entry_price
custom_exit
custom_stoploss
Other
explanation_engine
requests
9 related strategies (⧉ identical code, ≈ similar name)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 | """ Strategy 2: The Liquidity Sentinel — Freqtrade Implementation ============================================================== Converted from the standalone intraday_trader.py to Freqtrade's IStrategy API. Architecture: Phase 1 Macro Filter : Funding Rate + OI Divergence (Binance public API) Phase 2 Regime Detection : ADX + Volume-Profile-inspired VAH/VAL/POC Phase 3 Entry Signals : Engine A (Breakout/Delta-Z) + Engine B (MFI Reversion) Phase 4 Risk Management : ATR-based dynamic stop (1.5x) + liquidity target (3.0x) Phase 5 Safety Suite : Market hours guard, circuit breaker, SPY correlation Freqtrade version: >= 2024.1 Pair: BTC/USD on Alpaca (via CCXT) Timeframe: 5m """ from datetime import datetime, timezone, time as dtime from functools import reduce import logging import numpy as np import pandas as pd import requests from freqtrade.strategy import IStrategy, DecimalParameter, IntParameter from freqtrade.persistence import Trade # ── XAI layer (non-blocking — all I/O runs in daemon threads) ───────────────── try: from explanation_engine import ExplanationEngine _xai = ExplanationEngine() _XAI_AVAILABLE = True except Exception as _xai_err: _XAI_AVAILABLE = False import warnings warnings.warn(f"[XAI] explanation_engine unavailable: {_xai_err}") logger = logging.getLogger(__name__) class Strategy2LiquiditySentinel(IStrategy): # ------------------------------------------------------------------ # Freqtrade metadata # ------------------------------------------------------------------ INTERFACE_VERSION = 3 timeframe = "5m" can_short = False # Alpaca paper spot = long only startup_candle_count = 60 # need 60 bars for VP + EMA50 stoploss = -0.05 # fallback hard stop (5%) minimal_roi = {"0": 0.10} # fallback take-profit (10%) trailing_stop = False # we use custom_stoploss process_only_new_candles = True # ------------------------------------------------------------------ # Tunable parameters (Hyperopt-ready) # ------------------------------------------------------------------ adx_threshold = IntParameter(15, 35, default=25, space="buy") delta_z_min = DecimalParameter(1.5, 3.0, default=2.0, decimals=1, space="buy") mfi_oversold = IntParameter(20, 40, default=30, space="buy") mfi_overbought = IntParameter(60, 80, default=70, space="sell") atr_stop_mult = DecimalParameter(1.0, 2.5, default=1.5, decimals=1, space="sell") atr_target_mult = DecimalParameter(2.0, 5.0, default=3.0, decimals=1, space="sell") vp_window = IntParameter(30, 90, default=60, space="buy") # ------------------------------------------------------------------ # Market hours (UTC) — "Smart Money" window: Milan/London/NY overlap # ------------------------------------------------------------------ MARKET_OPEN_H, MARKET_OPEN_M = 9, 29 # resume 1 min before open MARKET_CLOSE_H, MARKET_CLOSE_M = 17, 0 # stop at 17:00 UTC # ------------------------------------------------------------------ # Risk / Circuit Breaker # ------------------------------------------------------------------ DAILY_LOSS_LIMIT_USD = 3_000.0 FUNDING_RATE_THRESHOLD = 0.0001 # 0.01% per 8h = long-heavy # ------------------------------------------------------------------ # Sleeping state (logged every 10 minutes) # ------------------------------------------------------------------ _last_sleep_log: datetime = None # ================================================================== # PHASE 1 — MACRO FILTER (called once per candle via populate_indicators) # ================================================================== @staticmethod def _get_funding_rate() -> float: """Binance Futures public API — no auth required.""" try: r = requests.get( "https://fapi.binance.com/fapi/v1/fundingRate", params={"symbol": "BTCUSDT", "limit": 1}, timeout=4 ) return float(r.json()[0]["fundingRate"]) except Exception: return 0.0 @staticmethod def _get_oi_trend() -> str: """Returns 'increasing', 'decreasing', or 'neutral'.""" try: r = requests.get( "https://fapi.binance.com/futures/data/openInterestHist", params={"symbol": "BTCUSDT", "period": "5m", "limit": 6}, timeout=4 ) data = r.json() oi_old = float(data[0]["sumOpenInterest"]) oi_new = float(data[-1]["sumOpenInterest"]) pct = (oi_new - oi_old) / oi_old * 100 if pct > 0.1: return "increasing" if pct < -0.1: return "decreasing" return "neutral" except Exception: return "neutral" # ================================================================== # PHASE 2 — VOLUME PROFILE (vectorized rolling approximation) # ================================================================== @staticmethod def _rolling_vp(df: pd.DataFrame, window: int) -> tuple: """ Rolling Volume-Profile approximation — O(N) per bar. POC = volume-weighted mean price over window Std = volume-weighted std dev VAH = POC + 1 std (top of 68% value area) VAL = POC - 1 std (bottom of 68% value area) For true VP accuracy, use a larger window (60+ bars). """ tp = (df["high"] + df["low"] + df["close"]) / 3 vol = df["volume"] # Volume-weighted mean (POC proxy) poc = (tp * vol).rolling(window).sum() / vol.rolling(window).sum() # Volume-weighted std variance = ((tp - poc) ** 2 * vol).rolling(window).sum() / vol.rolling(window).sum() std = variance.apply(lambda x: x ** 0.5 if x >= 0 else 0) vah = poc + std val = poc - std return poc, vah, val # ================================================================== # POPULATE INDICATORS # ================================================================== def populate_indicators(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame: # EMA 50 dataframe["ema50"] = dataframe["close"].ewm(span=50, adjust=False).mean() # VWAP (cumulative session — resets on first bar of the day) tp = (dataframe["high"] + dataframe["low"] + dataframe["close"]) / 3 dataframe["vwap"] = (tp * dataframe["volume"]).cumsum() / dataframe["volume"].cumsum() # ATR (14) prev_close = dataframe["close"].shift(1) tr = pd.concat([ dataframe["high"] - dataframe["low"], (dataframe["high"] - prev_close).abs(), (dataframe["low"] - prev_close).abs() ], axis=1).max(axis=1) dataframe["atr"] = tr.rolling(14).mean() # ADX (14) — manual calculation (no talib dependency) h_diff = dataframe["high"].diff() l_diff = -dataframe["low"].diff() plus_dm = np.where((h_diff > l_diff) & (h_diff > 0), h_diff, 0.0) minus_dm = np.where((l_diff > h_diff) & (l_diff > 0), l_diff, 0.0) atr14 = tr.rolling(14).mean() + 1e-9 plus_di = 100 * (pd.Series(plus_dm).rolling(14).mean() / atr14) minus_di = 100 * (pd.Series(minus_dm).rolling(14).mean() / atr14) dx = 100 * ((plus_di - minus_di).abs() / (plus_di + minus_di + 1e-9)) dataframe["adx"] = dx.rolling(14).mean() dataframe["plus_di"] = plus_di dataframe["minus_di"] = minus_di # MFI (14) — Money Flow Index mf = tp * dataframe["volume"] pos_mf = mf.where(tp > tp.shift(1), 0.0).rolling(14).sum() neg_mf = mf.where(tp < tp.shift(1), 0.0).rolling(14).sum() + 1e-9 dataframe["mfi"] = 100 - (100 / (1 + pos_mf / neg_mf)) # Bollinger Bands (20, 2σ) sma = dataframe["close"].rolling(20).mean() std = dataframe["close"].rolling(20).std() dataframe["bb_upper"] = sma + 2 * std dataframe["bb_lower"] = sma - 2 * std dataframe["bb_mid"] = sma # Volume Delta Z-Score bar_range = (dataframe["high"] - dataframe["low"]).replace(0, np.nan) dataframe["delta"] = ((2 * dataframe["close"] - dataframe["high"] - dataframe["low"]) / bar_range * dataframe["volume"]).fillna(0) d_mean = dataframe["delta"].rolling(20).mean() d_std = dataframe["delta"].rolling(20).std() + 1e-9 dataframe["delta_z"] = (dataframe["delta"] - d_mean) / d_std # Volatility index (normalized ATR) dataframe["vol_index"] = dataframe["atr"] / dataframe["close"] # Volume Profile (rolling) window = int(self.vp_window.value) dataframe["poc"], dataframe["vah"], dataframe["val"] = self._rolling_vp(dataframe, window) # Regime dataframe["regime"] = np.where( dataframe["adx"] > self.adx_threshold.value, "TRENDING", "RANGING" ) # Macro filter (fetched once per candle — cached via simple class var) dataframe["funding_rate"] = self._get_funding_rate() dataframe["oi_trend"] = self._get_oi_trend() # Price direction (last 3 bars) dataframe["price_rising"] = dataframe["close"] > dataframe["close"].shift(3) # Long-heavy = funding > threshold AND price rising dataframe["macro_blocks_long"] = ( (dataframe["funding_rate"] > self.FUNDING_RATE_THRESHOLD) | (dataframe["price_rising"] & (dataframe["oi_trend"] == "decreasing")) ) # MFI divergence flags (price vs MFI over 5 bars) dataframe["mfi_bull_div"] = ( (dataframe["close"] < dataframe["close"].shift(5)) & (dataframe["mfi"] > dataframe["mfi"].shift(5)) ) dataframe["mfi_bear_div"] = ( (dataframe["close"] > dataframe["close"].shift(5)) & (dataframe["mfi"] < dataframe["mfi"].shift(5)) ) return dataframe # ================================================================== # PHASE 3 — ENTRY SIGNALS # ================================================================== def populate_entry_trend(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame: dataframe["enter_long"] = 0 # ---- Engine A: IMBALANCED / Breakout ------------------------- engine_a = ( (dataframe["regime"] == "TRENDING") & # Price just crossed above VAH (dataframe["close"] > dataframe["vah"]) & (dataframe["close"].shift(1) <= dataframe["vah"].shift(1)) & # Volume Delta Z-Score confirms aggressive buyers (dataframe["delta_z"] >= self.delta_z_min.value) & # Above VWAP and EMA50 (dataframe["close"] > dataframe["vwap"]) & (dataframe["close"] > dataframe["ema50"]) & # Macro allows longs (~dataframe["macro_blocks_long"]) ) # ---- Engine B: BALANCED / Mean Reversion --------------------- engine_b = ( (dataframe["regime"] == "RANGING") & # Price near lower band (within 0.3 ATR) (dataframe["close"] <= dataframe["val"] + dataframe["atr"] * 0.3) & # RSI/MFI oversold or bullish divergence ((dataframe["mfi"] < self.mfi_oversold.value) | dataframe["mfi_bull_div"]) & (~dataframe["macro_blocks_long"]) ) dataframe.loc[engine_a | engine_b, "enter_long"] = 1 dataframe.loc[engine_a, "enter_tag"] = "A-Breakout" dataframe.loc[engine_b, "enter_tag"] = "B-Reversion" return dataframe def populate_exit_trend(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame: dataframe["exit_long"] = 0 # Engine A exit: price falls back below VAH exit_a = ( (dataframe["regime"] == "TRENDING") & (dataframe["close"] < dataframe["vah"]) & (dataframe["delta_z"] <= -self.delta_z_min.value) ) # Engine B exit: price reaches VAH or MFI overbought / bearish divergence exit_b = ( (dataframe["close"] >= dataframe["vah"] - dataframe["atr"] * 0.3) & ((dataframe["mfi"] > self.mfi_overbought.value) | dataframe["mfi_bear_div"]) ) dataframe.loc[exit_a | exit_b, "exit_long"] = 1 return dataframe # ================================================================== # PHASE 4 — DYNAMIC STOP LOSS + TAKE PROFIT # ================================================================== def custom_stoploss(self, pair: str, trade: Trade, current_time: datetime, current_rate: float, current_profit: float, **kwargs) -> float: """ Structural stop: POC - (1.5 x ATR) below entry. Returned as a ratio relative to current rate for Freqtrade. """ dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) if dataframe is None or dataframe.empty: return self.stoploss # fallback last = dataframe.iloc[-1] atr = float(last["atr"]) poc = float(last["poc"]) stop_price = poc - self.atr_stop_mult.value * atr # Return as negative ratio from current rate stop_ratio = (stop_price - current_rate) / current_rate return max(stop_ratio, -0.15) # never wider than 15% def custom_exit(self, pair: str, trade: Trade, current_time: datetime, current_rate: float, current_profit: float, **kwargs): """ Exit at liquidity target: entry + (3.0 x ATR). """ dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) if dataframe is None or dataframe.empty: return None last = dataframe.iloc[-1] atr = float(last["atr"]) target_price = trade.open_rate + self.atr_target_mult.value * atr if current_rate >= target_price: return f"liquidity-target-{self.atr_target_mult.value}atr" return None # ================================================================== # PHASE 5 — SAFETY SUITE # ================================================================== def confirm_trade_entry(self, pair: str, order_type: str, amount: float, rate: float, time_in_force: str, current_time: datetime, entry_tag: str = None, **kwargs) -> bool: """ Gate 1 — Market hours (UTC): Sleep : 17:00 – 09:28 UTC Active : 09:29 – 16:59 UTC Gate 2 — Daily loss circuit breaker ($3,000) """ # ---- Market hours guard ---------------------------------------- t = current_time.astimezone(timezone.utc).time() open_t = dtime(self.MARKET_OPEN_H, self.MARKET_OPEN_M) close_t = dtime(self.MARKET_CLOSE_H, self.MARKET_CLOSE_M) if not (open_t <= t < close_t): # Log every 10 minutes to avoid spam now_dt = datetime.now(timezone.utc) if (self._last_sleep_log is None or (now_dt - self._last_sleep_log).total_seconds() >= 600): # Compute time until open if t >= close_t: # Past close — open is tomorrow from datetime import timedelta next_open = datetime.combine( current_time.date() + timedelta(days=1), open_t, tzinfo=timezone.utc ) else: next_open = datetime.combine( current_time.date(), open_t, tzinfo=timezone.utc ) remaining = next_open - now_dt h = int(remaining.total_seconds() // 3600) m = int((remaining.total_seconds() % 3600) // 60) logger.info(f"[MARKET CLOSED] Sleeping {h}h {m}m until market open " f"({open_t.strftime('%H:%M')} UTC)") Strategy2LiquiditySentinel._last_sleep_log = now_dt return False # ---- Circuit breaker ------------------------------------------- today = current_time.date() closed_trades = Trade.get_trades( [Trade.close_date >= datetime.combine(today, dtime.min, tzinfo=timezone.utc)] ).all() daily_loss = sum( t.close_profit_abs for t in closed_trades if t.close_profit_abs is not None and t.close_profit_abs < 0 ) if abs(daily_loss) >= self.DAILY_LOSS_LIMIT_USD: logger.warning(f"[CIRCUIT BREAKER] Daily loss ${abs(daily_loss):,.2f} " f">= limit ${self.DAILY_LOSS_LIMIT_USD:,.2f}. No new trades today.") return False # ── XAI: capture entry state ───────────────────────────────────────── if _XAI_AVAILABLE: try: dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) if dataframe is not None and not dataframe.empty: _xai.capture_entry( df = dataframe, pair = pair, rate = rate, entry_tag = entry_tag or "strategy2", current_time= current_time, ) except Exception as _e: logger.debug("[XAI] Entry capture error: %s", _e) return True def custom_entry_price(self, pair: str, trade: Trade = None, current_time: datetime = None, proposed_rate: float = None, entry_tag: str = None, side: str = "long", **kwargs) -> float: """ Slippage buffer: willing to pay 0.1% above mid for guaranteed fill. """ return proposed_rate * 1.001 def confirm_trade_exit(self, pair: str, trade: Trade, order_type: str, amount: float, rate: float, time_in_force: str, exit_reason: str, current_time: datetime, **kwargs) -> bool: """ XAI: capture exit state after every trade closure. Generates narrative text + chart PNG in a background thread. Trading logic is NOT modified — always returns True. """ if _XAI_AVAILABLE: try: dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) current_profit = (rate - trade.open_rate) / trade.open_rate _xai.capture_exit( trade = trade, df = dataframe if dataframe is not None else pd.DataFrame(), rate = rate, exit_reason = exit_reason, current_profit = current_profit, current_time = current_time, ) except Exception as _e: logger.debug("[XAI] Exit capture error: %s", _e) return True # never block the exit |
Strategy League — fixed backtest that feeds the ranking
The fixed-params backtest (33 pairs · 20210101-20260101) — the only run that feeds the Strategy League ranking.
Backtests — over a market period
Backtest this strategy over a chosen crypto-cycle period. These don't affect the League ranking, and need that period's candle data downloaded.
Log in or sign up to run backtests.
| Period | Range | Total % | Win % | Max DD | Trades | |
|---|---|---|---|---|---|---|
| 2020 · DeFi Summer & Pre-Halving Rally | 20200101-20210101 | not run | ||||
| 2021 · Institutional Bull Market | 20210101-20220101 | not run | ||||
| 2022 · Post-Bull Crash & Macro Tightening | 20220101-20230101 | not run | ||||
| 2023–2024 · Recovery & ETF Anticipation | 20230101-20250101 | not run | ||||
| 2025–2026 · Current Cycle | 20250101-20260101 | not run | ||||
Walk forward
Out-of-sample backtest on recent data · 33 pairs · 20260101-20260701.
Backtest trust check
Static source analysis — instant, does not run the strategy. Flags future-data leaks, backtest-realism problems, and indicators worth a second look.
Lookahead analysis
freqtrade lookahead-analysis: detects strategies peeking at future candles.