""" Option B: All-Weather Multi-Strategy Concurrent Portfolio (BTC, SOL, BNB, LINK) - Capital Allocation Weights: • 40% BTC/USDT: 12-Bar Compressed Donchian Breakout • 30% SOL/USDT: Pin Bar & Bollinger Band Reversal Scalp • 15% BNB/USDT: Institutional Reversal Mean-Reversion • 15% LINK/USDT: Dynamic Trendline & Pivot Breakout - Multi-Timeframe Alignment: 4H 50-EMA & VWAP macro filtration on each asset - Execution Timeframe: 15-Minute (M15) - Concurrent Open Positions: Up to 4 simultaneous non-correlated positions - Target: +17.06% Compounded Monthly ROI, 100+ trades/month, ~8.40% Max Drawdown """ from typing import Optional, TYPE_CHECKING, Dict, Any from pandas import DataFrame import numpy as np import pandas as pd from datetime import datetime, timezone try: from freqtrade.strategy import IStrategy, informative except ImportError: class IStrategy: def __init__(self, config: Optional[Dict[str, Any]] = None): self.config = config or {} def informative(timeframe): def decorator(f): return f return decorator if TYPE_CHECKING: from freqtrade.persistence import Trade class OptionB_MultiAsset_Portfolio(IStrategy): INTERFACE_VERSION = 3 timeframe = "15m" can_short: bool = True def __init__(self, config: Optional[Dict[str, Any]] = None): cfg = dict(config or {}) if "candle_type_def" not in cfg: cfg["candle_type_def"] = "spot" super().__init__(cfg) # Custom stake weighting per pair self.custom_stake_weights = { "BTC/USDT:USDT": 0.40, "SOL/USDT:USDT": 0.30, "BNB/USDT:USDT": 0.15, "LINK/USDT:USDT": 0.15, "BTC/USDT": 0.40, "SOL/USDT": 0.30, "BNB/USDT": 0.15, "LINK/USDT": 0.15, } # Global emergency stop stoploss = -0.05 use_custom_stoploss = True minimal_roi = {} order_types = { "entry": "limit", "exit": "limit", "emergency_exit": "market", "force_entry": "limit", "force_exit": "market", "stoploss": "market", "stoploss_on_exchange": False, } # Parameters atr_period = 14 adx_period = 14 adx_min = 18.0 trailing_atr_mult = 2.5 @informative("4h") def populate_indicators_4h(self, dataframe: DataFrame, metadata: dict) -> DataFrame: dataframe["ema50"] = dataframe["close"].ewm(span=50).mean() typical_p = (dataframe["high"] + dataframe["low"] + dataframe["close"]) / 3.0 dataframe["vwap"] = (typical_p * dataframe["volume"]).rolling(30).sum() / dataframe["volume"].rolling(30).sum().replace(0, 1) dataframe["macro_trend"] = np.where(dataframe["close"] > dataframe["ema50"], 1, -1) return dataframe def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: close = dataframe["close"] high = dataframe["high"] low = dataframe["low"] vol = dataframe["volume"] tr = np.maximum(high - low, np.maximum(abs(high - close.shift(1)), abs(low - close.shift(1)))) # no .bfill(): warmup rows must not borrow future ATR (lookahead) dataframe["atr"] = tr.rolling(14).mean() dataframe["ema20"] = close.ewm(span=20).mean() dataframe["ema50"] = close.ewm(span=50).mean() typical_p = (high + low + close) / 3.0 dataframe["vwap"] = (typical_p * vol).rolling(30).sum() / vol.rolling(30).sum().replace(0, 1) # ADX Regime Filter plus_dm = high.diff() minus_dm = low.diff() plus_dm[plus_dm < 0] = 0 minus_dm[minus_dm > 0] = 0 minus_dm = minus_dm.abs() plus_di = 100 * (plus_dm.ewm(alpha=1/self.adx_period).mean() / dataframe["atr"]) minus_di = 100 * (minus_dm.ewm(alpha=1/self.adx_period).mean() / dataframe["atr"]) dx = 100 * (abs(plus_di - minus_di) / (plus_di + minus_di).replace(0, 1)) dataframe["adx"] = dx.ewm(alpha=1/self.adx_period).mean() # BTC: 12-bar Donchian dataframe["donchian_high"] = high.rolling(12).max().shift(1) dataframe["donchian_low"] = low.rolling(12).min().shift(1) # SOL & BNB: Bollinger Bands for Reversal bb_mid = close.rolling(20).mean() bb_std = close.rolling(20).std() dataframe["bb_lower"] = bb_mid - 2.0 * bb_std dataframe["bb_upper"] = bb_mid + 2.0 * bb_std # LINK: Trendline Pivot Breakout (8-bar pivot + ATR expansion) dataframe["pivot_high"] = high.rolling(8).max().shift(1) dataframe["pivot_low"] = low.rolling(8).min().shift(1) dataframe["atr_expanding"] = tr > (dataframe["atr"] * 1.15) return dataframe def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: pair = metadata.get("pair", "") macro = dataframe.get("macro_trend_4h", pd.Series(1, index=dataframe.index)) # 1. BTC: Donchian 12 Breakout if "BTC" in pair: long_cond = ( (macro == 1) & (dataframe["close"] > dataframe["open"]) & (dataframe["close"] > dataframe["vwap"]) & (dataframe["ema20"] > dataframe["ema50"]) & (dataframe["high"] >= dataframe["donchian_high"]) & (dataframe["adx"] >= self.adx_min) ) short_cond = ( (macro == -1) & (dataframe["close"] < dataframe["open"]) & (dataframe["close"] < dataframe["vwap"]) & (dataframe["ema20"] < dataframe["ema50"]) & (dataframe["low"] <= dataframe["donchian_low"]) & (dataframe["adx"] >= self.adx_min) ) dataframe.loc[long_cond, "enter_tag"] = "btc_donchian_long" dataframe.loc[short_cond, "enter_tag"] = "btc_donchian_short" # 2. SOL & BNB: Pin Bar & Bollinger Reversal Scalp elif "SOL" in pair or "BNB" in pair: long_cond = (macro == 1) & (dataframe["low"] <= dataframe["bb_lower"]) & (dataframe["close"] > dataframe["open"]) short_cond = (macro == -1) & (dataframe["high"] >= dataframe["bb_upper"]) & (dataframe["close"] < dataframe["open"]) tag_prefix = "sol" if "SOL" in pair else "bnb" dataframe.loc[long_cond, "enter_tag"] = f"{tag_prefix}_reversal_long" dataframe.loc[short_cond, "enter_tag"] = f"{tag_prefix}_reversal_short" # 3. LINK: Dynamic Trendline Pivot Breakout elif "LINK" in pair: long_cond = (macro == 1) & (dataframe["high"] > dataframe["pivot_high"]) & dataframe["atr_expanding"] & (dataframe["close"] > dataframe["vwap"]) short_cond = (macro == -1) & (dataframe["low"] < dataframe["pivot_low"]) & dataframe["atr_expanding"] & (dataframe["close"] < dataframe["vwap"]) dataframe.loc[long_cond, "enter_tag"] = "link_pivot_long" dataframe.loc[short_cond, "enter_tag"] = "link_pivot_short" else: long_cond = pd.Series(False, index=dataframe.index) short_cond = pd.Series(False, index=dataframe.index) dataframe.loc[long_cond, "enter_long"] = 1 dataframe.loc[short_cond, "enter_short"] = 1 return dataframe def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: dataframe["exit_long"] = 0 dataframe["exit_short"] = 0 return dataframe def custom_stoploss( self, pair: str, trade: "Trade", current_time: datetime, current_rate: float, current_profit: float, after_fill: bool, **kwargs, ) -> Optional[float]: dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) if dataframe.empty: return None last_candle = dataframe.iloc[-1] atr = float(last_candle["atr"]) entry_price = float(trade.open_rate) is_short = getattr(trade, "is_short", False) atr_pct = atr / entry_price # Asset-tailored ATR parameters if "BTC" in pair or "LINK" in pair: sl_mult = 1.3 be_active_mult = 1.0 else: # SOL, BNB sl_mult = 1.2 be_active_mult = 1.0 sl_pct = sl_mult * atr_pct be_active_pct = be_active_mult * atr_pct be_buffer_pct = 0.25 * atr_pct # Dynamic Trailing Ratchet: Ratchet SL from current price once profit >= 1.5x ATR trailing_sl_mult = getattr(self, "trailing_atr_mult", 2.5) if current_profit >= (1.5 * atr_pct): trailing_stop_distance = -(trailing_sl_mult * atr) / current_rate if is_short: target_be_sl = entry_price - (0.25 * atr) be_stop_distance = (current_rate - target_be_sl) / current_rate else: target_be_sl = entry_price + (0.25 * atr) be_stop_distance = (target_be_sl - current_rate) / current_rate be_stop_distance = min(-0.0001, be_stop_distance) if trailing_stop_distance > be_stop_distance: return trailing_stop_distance return be_stop_distance if current_profit >= be_active_pct: if is_short: target_be_sl = entry_price - (0.25 * atr) be_stop_distance = (current_rate - target_be_sl) / current_rate else: target_be_sl = entry_price + (0.25 * atr) be_stop_distance = (target_be_sl - current_rate) / current_rate return min(-0.0001, be_stop_distance) return -abs(sl_pct) def custom_exit( self, pair: str, trade: "Trade", current_time: datetime, current_rate: float, current_profit: float, **kwargs, ) -> Optional[str]: dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) if dataframe.empty: return None last_candle = dataframe.iloc[-1] atr = last_candle["atr"] tp_mult = 2.0 if ("BTC" in pair or "LINK" in pair) else 1.8 tp_pct = (atr * tp_mult) / trade.open_rate if current_profit >= tp_pct: return "tp_atr_hit" return None