""" CryptoInvestorStrategy v1 — Freqtrade Strategy ================================================= Trend-following strategy using EMA alignment + RSI pullback entries. Logic: ENTRY (Long): - Price above EMA 50 AND EMA 50 above EMA 200 (uptrend confirmed) - RSI 14 pulls back below 40 (momentum reset in uptrend) - Volume above 20-period SMA (confirming interest) - MACD histogram > 0 or turning positive (momentum confirmation) EXIT: - ROI targets (tiered) - Trailing stop loss (ATR-based) - RSI > 80 (overbought exit) - Price closes below EMA 50 (trend breakdown) Risk Management: - ATR-based stop loss (2x ATR below entry) - Trailing stop activates at 3% profit - Maximum 5 concurrent trades """ import logging from datetime import datetime, timedelta, timezone from functools import reduce from typing import Optional import numpy as np import pandas as pd import talib.abstract as ta from pandas import DataFrame from freqtrade.strategy import ( BooleanParameter, CategoricalParameter, DecimalParameter, IntParameter, IStrategy, merge_informative_pair, ) logger = logging.getLogger(__name__) class CryptoInvestorV1(IStrategy): """ Trend-following strategy with RSI pullback entries. Designed for spot crypto trading on 1h timeframe. """ # ── Strategy metadata ── INTERFACE_VERSION = 3 timeframe = "1h" can_short = False # Warm-up: need at least buy_ema_slow (100) candles for indicators to be valid. startup_candle_count = 150 # ── Risk API integration ── risk_api_url = "http://127.0.0.1:8000" risk_portfolio_id = 1 # ── ROI table (aggressive: take profits faster) ── minimal_roi = { "0": 0.05, # 5% ROI target "60": 0.03, # 3% after 1 hour "240": 0.02, # 2% after 4 hours "720": 0.005, # 0.5% after 12 hours } # ── Stop loss ── stoploss = -0.05 # -5% hard stop loss (ATR-based custom stop is primary) use_custom_stoploss = True # ── Trailing stop ── trailing_stop = True trailing_stop_positive = 0.015 trailing_stop_positive_offset = 0.035 trailing_only_offset_is_reached = True # ── Order settings ── order_types = { "entry": "limit", "exit": "limit", "stoploss": "market", "stoploss_on_exchange": False, } order_time_in_force = {"entry": "GTC", "exit": "GTC"} # ── Hyperopt parameters ── # Pilot-tuned: relaxed from 50/200/40 to generate trades in sideways markets. # Run hyperopt (--epochs 200) on recent kraken data to further optimize. buy_ema_fast = IntParameter(10, 80, default=21, space="buy", optimize=True) buy_ema_slow = IntParameter(50, 300, default=100, space="buy", optimize=True) buy_rsi_threshold = IntParameter(25, 55, default=45, space="buy", optimize=True) sell_rsi_threshold = IntParameter(65, 90, default=75, space="sell", optimize=True) atr_multiplier = DecimalParameter(1.5, 3.5, default=2.0, decimals=1, space="buy", optimize=True) # ── Informative pairs ── def informative_pairs(self): return [("BTC/USDT", self.timeframe)] def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: """Calculate all technical indicators.""" # ── Moving Averages ── for period in [7, 14, 21, 50, 100, 200]: dataframe[f"ema_{period}"] = ta.EMA(dataframe, timeperiod=period) dataframe[f"sma_{period}"] = ta.SMA(dataframe, timeperiod=period) # ── RSI ── dataframe["rsi"] = ta.RSI(dataframe, timeperiod=14) # ── MACD ── macd = ta.MACD(dataframe) dataframe["macd"] = macd["macd"] dataframe["macdsignal"] = macd["macdsignal"] dataframe["macdhist"] = macd["macdhist"] # ── Bollinger Bands ── bollinger = ta.BBANDS(dataframe, timeperiod=20, nbdevup=2.0, nbdevdn=2.0) dataframe["bb_upper"] = bollinger["upperband"] dataframe["bb_mid"] = bollinger["middleband"] dataframe["bb_lower"] = bollinger["lowerband"] dataframe["bb_width"] = (dataframe["bb_upper"] - dataframe["bb_lower"]) / dataframe["bb_mid"] # ── ATR ── dataframe["atr"] = ta.ATR(dataframe, timeperiod=14) # ── Volume ── dataframe["volume_sma_20"] = ta.SMA(dataframe["volume"], timeperiod=20) dataframe["volume_ratio"] = dataframe["volume"] / dataframe["volume_sma_20"] # ── Stochastic ── stoch = ta.STOCH(dataframe) dataframe["stoch_k"] = stoch["slowk"] dataframe["stoch_d"] = stoch["slowd"] # ── ADX (trend strength) ── dataframe["adx"] = ta.ADX(dataframe, timeperiod=14) # ── Trend alignment flags ── dataframe["uptrend"] = ( (dataframe["ema_50"] > dataframe["ema_200"]) & (dataframe["close"] > dataframe["ema_50"]) ).astype(int) dataframe["strong_uptrend"] = ( (dataframe["ema_21"] > dataframe["ema_50"]) & (dataframe["ema_50"] > dataframe["ema_200"]) & (dataframe["close"] > dataframe["ema_21"]) & (dataframe["adx"] > 25) ).astype(int) return dataframe def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: """Define entry (buy) conditions. Aggressive mode: RSI pullback + any ONE confirmation signal. EMA alignment removed — trades in any trend direction. """ # Required: RSI pullback (core signal) rsi_pullback = dataframe["rsi"] < self.buy_rsi_threshold.value # Confirmation signals — need at least ONE macd_improving = ( (dataframe["macdhist"] > dataframe["macdhist"].shift(1)) | (dataframe["macd"] > dataframe["macdsignal"]) ) near_bb_lower = dataframe["close"] < dataframe["bb_mid"] volume_spike = dataframe["volume_ratio"] > 0.8 any_confirmation = macd_improving | near_bb_lower | volume_spike # Basic filters has_volume = dataframe["volume"] > 0 not_overbought = dataframe["rsi"] > 10 # not in freefall dataframe.loc[ rsi_pullback & any_confirmation & has_volume & not_overbought, "enter_long", ] = 1 return dataframe def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: """Define exit (sell) conditions.""" conditions = [] # Exit 1: RSI overbought conditions.append(dataframe["rsi"] > self.sell_rsi_threshold.value) # Exit 2: Price closes below fast EMA (trend weakening) exit_trend_break = ( (dataframe["close"] < dataframe[f"ema_{self.buy_ema_fast.value}"]) & (dataframe["close"].shift(1) >= dataframe[f"ema_{self.buy_ema_fast.value}"].shift(1)) ) if conditions: dataframe.loc[ reduce(lambda x, y: x | y, conditions) | exit_trend_break, "exit_long" ] = 1 return dataframe def custom_stoploss( self, pair: str, trade, current_time: datetime, current_rate: float, current_profit: float, after_fill: bool, **kwargs, ) -> float: """ ATR-based dynamic stop loss. - Initial: 2x ATR below entry - Tightens as profit increases """ dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) if dataframe.empty: return self.stoploss last_candle = dataframe.iloc[-1] atr = last_candle.get("atr", 0) if atr == 0: return self.stoploss # ATR-based stop distance atr_stop = -(atr * float(self.atr_multiplier.value)) / current_rate # Tighten stop as profit increases if current_profit > 0.06: atr_stop = max(atr_stop, -0.02) # Tighten to -2% elif current_profit > 0.03: atr_stop = max(atr_stop, -0.03) # Tighten to -3% return max(atr_stop, self.stoploss) def custom_exit( self, pair: str, trade, current_time: datetime, current_rate: float, current_profit: float, after_fill: bool, **kwargs, ) -> Optional[str]: """Custom exit logic for specific conditions.""" dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) if dataframe.empty: return None last_candle = dataframe.iloc[-1] # Exit if trend fully breaks down (EMAs cross bearish) if ( last_candle.get(f"ema_{self.buy_ema_fast.value}", 0) < last_candle.get(f"ema_{self.buy_ema_slow.value}", 0) and current_profit > -0.02 ): return "trend_breakdown" # Exit if held too long with small profit (opportunity cost) if trade.open_date_utc + timedelta(days=7) < current_time: if current_profit < 0.01: return "stale_trade" return None 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: """Gate trades through the backend risk API (fail-open: approve if unreachable). In backtesting/hyperopt mode, skip the API call since the backend may not be running and risk checks are not meaningful for historical sims. """ from freqtrade.enums import RunMode if self.dp and self.dp.runmode in (RunMode.BACKTEST, RunMode.HYPEROPT): return True try: import requests stop_loss_price = rate * (1 + self.stoploss) # stoploss is negative resp = requests.post( f"{self.risk_api_url}/api/risk/{self.risk_portfolio_id}/check-trade", json={ "symbol": pair, "side": side, "size": amount, "entry_price": rate, "stop_loss_price": stop_loss_price, }, timeout=5, ) if resp.status_code == 200: data = resp.json() if not data.get("approved", False): logger.warning(f"Risk gate REJECTED {pair}: {data.get('reason')}") return False logger.info(f"Risk gate approved {pair}") return True logger.warning(f"Risk API returned {resp.status_code}, approving trade (fail-open)") return True except Exception as e: logger.warning(f"Risk API unreachable ({e}), approving trade (fail-open)") return True