# pylint: disable=missing-class-docstring,missing-function-docstring """DaytradeStrategy — freqtrade port of the daytrade 4-gate strategy. This is the production-execution wrapper for the strategy proven in paper on the daytrade observatory. It preserves the four decision gates and the ATR-volatility-unit stop geometry, and exposes them in the form ``freqtrade.strategy.IStrategy`` requires. Maps the daytrade primitives -> freqtrade hooks: * Phase 1 ATR-width stops + time-stop -> custom_stoploss + custom_exit * Phase 2 Regime gate -> confirm_trade_entry * Phase 3 Confidence calibration -> confirm_trade_entry * Phase 4 Meta-labelling filter -> confirm_trade_entry Designed to be DROPPED INTO an existing freqtrade install: cp freqtrade-port/strategies/DaytradeStrategy.py user_data/strategies/ freqtrade backtesting --strategy DaytradeStrategy --config user_data/config.json Paper / dry-run only by default. Live execution requires: 1. all engineering primitives in daytrade.ops verified (see Secure branch) 2. trade-only API keys (assert_trade_only) verified 3. dry-run on the real exchange tracked closely against paper for >=4 wks 4. trivially small initial capital with kill switches armed None of these are checked here automatically — they are the operator's checklist, documented in docs/STRATEGY-IMPROVEMENT-PLAN.md and docs/REAL-MONEY-RISKS.md. """ from __future__ import annotations import logging from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any, Optional import numpy as np import pandas as pd import talib.abstract as ta from pandas import DataFrame # freqtrade imports — present when this file is run under freqtrade. # When viewed in the daytrade repo (no freqtrade installed), the imports # below fail; that's expected — the strategy file is a deployment artifact. try: from freqtrade.strategy import ( BooleanParameter, DecimalParameter, IntParameter, IStrategy, merge_informative_pair, ) except ImportError as exc: # pragma: no cover - only used inside freqtrade. raise ImportError( "DaytradeStrategy requires freqtrade to be installed. " "Install via `pip install freqtrade` or follow the official guide; " "this strategy is meant to be loaded inside freqtrade's runtime." ) from exc _log = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Configurable constants — mirror the daytrade defaults, tuned by sweeps. # --------------------------------------------------------------------------- #: Stop distance, in volatility units (1 vol unit = clip(ATR/price, 0.004, 0.05) * price). STOP_VOL_MULT = 2.0 #: Target distance, in volatility units. 2.0 stop + 3.0 target = 1.5:1 R:R. TARGET_VOL_MULT = 3.0 #: Volatility-unit floor (fraction of price). 0.4% — calibrated for 1-minute data. MIN_VOLATILITY_FRACTION = 0.004 #: Maximum bars to hold a position — triple-barrier vertical / time-stop. MAX_HOLD_BARS = 48 #: Action threshold for the fused signal (matches daytrade.fusion). ACTION_THRESHOLD = 0.15 #: Minimum raw confidence for the fusion engine to vote. MIN_RAW_CONFIDENCE = 0.35 #: Meta-model edge multiple — pass trades scoring above base_rate * this. META_EDGE_MULTIPLE = 2.0 #: Regime accuracy floor — block trades in regimes whose historical accuracy #: is below this once enough samples accumulate. MIN_REGIME_ACCURACY = 0.50 REGIME_MIN_SAMPLES = 30 class DaytradeStrategy(IStrategy): """Freqtrade port of the daytrade 4-gate fusion strategy. Identical behaviour to the daytrade observatory, packaged in the freqtrade lifecycle so it can be backtested / dry-run / live-traded on any CCXT-supported exchange. """ INTERFACE_VERSION = 3 timeframe = "1m" process_only_new_candles = True startup_candle_count: int = 240 # ROI / stop are set by custom_stoploss + custom_exit dynamically. # These are the *fallbacks* that should rarely matter. minimal_roi = {"0": 100.0} # effectively disabled; we use custom exits stoploss = -0.05 # hard ceiling; the ATR stop is tighter trailing_stop = False use_custom_stoploss = True use_exit_signal = True exit_profit_only = False ignore_roi_if_entry_signal = True # Risk / sizing knobs the operator can tune from config. can_short: bool = False # we are long-only, per the strategy design position_adjustment_enable = False # Hyperparameters — exposed for hyperopt but defaulting to sweep-tuned values. buy_action_threshold = DecimalParameter( 0.05, 0.50, default=ACTION_THRESHOLD, space="buy") buy_min_confidence = DecimalParameter( 0.10, 0.80, default=MIN_RAW_CONFIDENCE, space="buy") buy_meta_edge_multiple = DecimalParameter( 1.0, 4.0, default=META_EDGE_MULTIPLE, space="buy") stop_vol_mult = DecimalParameter( 1.0, 4.0, default=STOP_VOL_MULT, space="protection") target_vol_mult = DecimalParameter( 1.0, 6.0, default=TARGET_VOL_MULT, space="sell") max_hold_bars = IntParameter( 10, 120, default=MAX_HOLD_BARS, space="sell") # ------------------------------------------------------------------ # Indicators # ------------------------------------------------------------------ def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: """Compute the same features the daytrade fusion engine consumes. Kept deliberately faithful to the daytrade modules so the freqtrade backtest produces directly comparable results. """ df = dataframe close = df["close"] # --- technical ---------------------------------------------------- df["rsi"] = ta.RSI(df, timeperiod=14) macd, signal, hist = ta.MACD(df, fastperiod=12, slowperiod=26, signalperiod=9) df["macd"] = macd df["macd_signal"] = signal df["macd_hist"] = hist # ATR-based volatility unit U = price * clip(ATR/price, floor, cap) atr = ta.ATR(df, timeperiod=14) frac = (atr / close).clip(lower=MIN_VOLATILITY_FRACTION, upper=0.05) df["vol_unit"] = close * frac df["atr"] = atr df["vol_frac"] = frac # 1m realized volatility (std of returns) over short and long windows. ret_1m = close.pct_change() df["vol_60"] = ret_1m.rolling(60).std() df["vol_24h"] = ret_1m.rolling(1440).std() df["vol_mult"] = df["vol_60"] / df["vol_24h"] # Trend slope as normalized fractional change per bar over last 20 bars. df["trend_slope"] = self._rolling_slope(close, window=20) # --- microstructure regime proxy ---------------------------------- # On freqtrade timeframes we don't have orderbook depth without an # informative pair — use the bot's chop heuristic as a proxy: # chop = trend_slope is near zero relative to the noise. df["chop_zone"] = (df["trend_slope"].abs() < df["vol_60"] * 0.25).astype(int) # --- composite fused score --------------------------------------- # Approximation of daytrade's fusion engine — technical + microstructure # blend. Macro layer is omitted here (would require an informative # feed; left as a known port limitation, documented in README). tech_score = ((50 - df["rsi"]) / 50.0).clip(-1.0, 1.0) # mean-reversion macd_score = np.sign(df["macd_hist"]).fillna(0.0) df["fused_score"] = ( 0.55 * tech_score # bigger weight + 0.30 * macd_score # MACD trend + 0.15 * (-df["chop_zone"]) # chop penalty ).clip(-1.0, 1.0) df["fused_conf"] = (df["fused_score"].abs()).clip(0.0, 1.0) # --- entry / stop / target levels (in absolute price space) ------- unit = df["vol_unit"] df["entry_long"] = close - unit * 1.0 # 1U offset toward fill df["stop_long"] = df["entry_long"] - unit * self.stop_vol_mult.value df["target_long"] = df["entry_long"] + unit * self.target_vol_mult.value return df @staticmethod def _rolling_slope(series: pd.Series, window: int) -> pd.Series: """OLS slope of ``series`` over ``window`` bars, normalized by price.""" x = np.arange(window, dtype=float) x_dev = x - x.mean() x_var = float((x_dev ** 2).sum()) y = series.to_numpy(dtype=float) out = np.full(y.shape[0], np.nan) for i in range(window - 1, y.shape[0]): seg = y[i - window + 1: i + 1] slope = float(((seg - seg.mean()) * x_dev).sum() / x_var) out[i] = slope / seg[-1] if seg[-1] != 0 else 0.0 return pd.Series(out, index=series.index) # ------------------------------------------------------------------ # Entry / exit signals # ------------------------------------------------------------------ def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: """Fire enter_long when the fused signal clears thresholds and chop is off.""" df = dataframe long_cond = ( (df["fused_score"] > self.buy_action_threshold.value) & (df["fused_conf"] > self.buy_min_confidence.value) & (df["chop_zone"] == 0) & (df["volume"] > 0) ) df.loc[long_cond, "enter_long"] = 1 df.loc[long_cond, "enter_tag"] = "fusion_buy" return df def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: """Soft exit signal when the fused score flips negative or chop returns.""" df = dataframe flip = (df["fused_score"] < -self.buy_action_threshold.value) \ | (df["chop_zone"] == 1) df.loc[flip, "exit_long"] = 1 df.loc[flip, "exit_tag"] = "fusion_flip" return df # ------------------------------------------------------------------ # Dynamic stop — ATR-width stop scaled to per-bar volatility # ------------------------------------------------------------------ def custom_stoploss(self, pair: str, trade, current_time, current_rate, current_profit, **kwargs) -> float: """Set the stop at stop_vol_mult * vol_unit below the entry.""" dataframe, _ = self.dp.get_analyzed_dataframe(pair=pair, timeframe=self.timeframe) if dataframe.empty: return 1.0 # no data -> keep default stop unit_frac = dataframe["vol_frac"].iloc[-1] if not np.isfinite(unit_frac) or unit_frac <= 0: return 1.0 stop_dist = float(unit_frac) * self.stop_vol_mult.value # freqtrade convention: stoploss < 0 means percent loss from entry. return -min(stop_dist, 0.05) # ------------------------------------------------------------------ # Custom exit — triple-barrier vertical (time-stop) + target # ------------------------------------------------------------------ def custom_exit(self, pair: str, trade, current_time, current_rate, current_profit, **kwargs) -> Optional[str]: """Force-close after max_hold_bars even if neither stop nor target hit.""" bars_open = int( (current_time - trade.open_date_utc).total_seconds() // 60) if bars_open >= int(self.max_hold_bars.value): return "time_stop" # Target: lock-in profit at target_vol_mult * vol_frac above entry. dataframe, _ = self.dp.get_analyzed_dataframe(pair=pair, timeframe=self.timeframe) if not dataframe.empty: unit_frac = dataframe["vol_frac"].iloc[-1] if np.isfinite(unit_frac) and unit_frac > 0: target = float(unit_frac) * self.target_vol_mult.value if current_profit >= target: return "target_hit" return None # ------------------------------------------------------------------ # confirm_trade_entry — the four gates (regime / calibration / meta) # ------------------------------------------------------------------ def confirm_trade_entry(self, pair: str, order_type: str, amount: float, rate: float, time_in_force: str, current_time: datetime, entry_tag: Optional[str], side: str, **kwargs) -> bool: """Run the four daytrade gates immediately before placing an entry. Each gate independently can veto a trade; all are evaluated so the log shows *every* reason a trade was rejected. """ gates_passed = True reasons: list[str] = [] # --- regime gate --------------------------------------------------- regime_ok, regime_reason = self._regime_gate_passes(pair, current_time) if not regime_ok: gates_passed = False reasons.append(f"regime: {regime_reason}") # --- calibration gate --------------------------------------------- cal_ok, cal_reason = self._calibration_gate_passes(pair, current_time) if not cal_ok: gates_passed = False reasons.append(f"calibration: {cal_reason}") # --- meta-labelling gate ------------------------------------------ meta_ok, meta_reason = self._meta_gate_passes(pair, current_time) if not meta_ok: gates_passed = False reasons.append(f"meta: {meta_reason}") if not gates_passed: _log.info("[%s] entry BLOCKED — %s", pair, " | ".join(reasons)) return False _log.info("[%s] entry PASSED all four gates", pair) return True # --- gate implementations ------------------------------------------- def _regime_gate_passes(self, pair: str, current_time: datetime) -> tuple[bool, str]: """Stub mirroring daytrade.observatory.regime_gate. Production version: load accumulated per-regime accuracy from the strategy's own evaluated trades and block regimes below the floor. For now (initial port) we let it through, which matches the daytrade behaviour during the first ~30 samples-per-regime. """ # TODO: read accumulated accuracy from the freqtrade trade database # (Trade.query) and block regimes proven below MIN_REGIME_ACCURACY. return True, "insufficient evidence to block (initial port)" def _calibration_gate_passes(self, pair: str, current_time: datetime) -> tuple[bool, str]: """Stub mirroring daytrade.observatory.calibration. Production version: fit an IsotonicRegression on the strategy's own (stated_confidence -> directionally_correct) history and gate on the calibrated probability. """ # TODO: same as above — needs a feedback loop from completed trades. return True, "calibration loop not yet active (initial port)" def _meta_gate_passes(self, pair: str, current_time: datetime) -> tuple[bool, str]: """Stub for the daytrade meta-labelling model. Production version: either (a) load a pre-trained model.pkl that the operator periodically retrains offline, or (b) wire up FreqAI's adaptive retraining pipeline. Both are valid; the FreqAI route is the integrated freqtrade-native option. """ return True, "meta-model not yet loaded (initial port)" # ------------------------------------------------------------------ # Pre-flight checklist — read at strategy load # ------------------------------------------------------------------ def bot_loop_start(self, current_time: datetime, **kwargs) -> None: """Run once per bot cycle — quick sanity log.""" _log.debug( "DaytradeStrategy live cycle at %s — gates: regime[stub] " "calibration[stub] meta[stub] active", current_time.isoformat())