💬 Forum

Strategy2LiquiditySentinel

uploads/strategy2_freqtrade.py · uploaded by 🐋 Ron · first seen 2026-07-18 · ⬇ 1 download

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
Other explanation_engine requests
9 related strategies ( identical code, similar name)

Each tile is a different kind of check — from an instant code lint to full sandboxed backtests and forward tests on recent data. Not sure what a check actually proves? See the FAQ →

  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