""" v9 "Money Line with MA Gap" indicator implementation for backtesting. Key features vs v8: - confirmBars = 0 (immediate signals, no wait) - flipThreshold = 0.5% (more responsive than v8's 0.8%) - MA gap analysis (50/200 MA convergence/divergence) - ATR profile system (timeframe-based ATR/multiplier) - Expanded filters: RSI boundaries, volume range, entry buffer - Heikin Ashi source mode support - Price position filters (don't chase extremes) ADVANCED OPTIMIZATION PARAMETERS: - 8 ATR profile params (4 timeframes × period + multiplier) - 4 RSI boundary params (long/short min/max) - Volume max threshold - Entry buffer ATR size - ADX length - Source mode (Chart vs Heikin Ashi) - MA gap filter (optional - not in original v9) """ from __future__ import annotations from dataclasses import dataclass from typing import Optional try: from typing import Literal except ImportError: from typing_extensions import Literal import numpy as np import pandas as pd from backtester.math_utils import calculate_adx, calculate_atr, rma Direction = Literal["long", "short"] @dataclass class MoneyLineV9Inputs: # Basic Money Line parameters (OPTIMIZED) confirm_bars: int = 0 # v9: Immediate signals (0 = no wait) flip_threshold_percent: float = 0.5 # v9: Lower threshold (more responsive) cooldown_bars: int = 3 # Prevent overtrading # ATR Profile System (NEW - for advanced optimization) # Default: "minutes" profile optimized for 5-minute charts atr_period: int = 12 # ATR calculation length multiplier: float = 3.8 # ATR band multiplier # Filter parameters (OPTIMIZED) adx_length: int = 16 # ADX calculation length adx_min: float = 21 # Minimum ADX for signal (momentum filter) rsi_length: int = 14 # RSI calculation length # RSI boundaries (EXPANDED - for advanced optimization) rsi_long_min: float = 35 # RSI minimum for longs rsi_long_max: float = 70 # RSI maximum for longs rsi_short_min: float = 30 # RSI minimum for shorts rsi_short_max: float = 70 # RSI maximum for shorts # Volume filter (OPTIMIZED) vol_min: float = 1.0 # Minimum volume ratio (1.0 = average) vol_max: float = 3.5 # Maximum volume ratio (prevent overheated) # Price position filter (OPTIMIZED) long_pos_max: float = 75 # Don't long above 75% of range (chase tops) short_pos_min: float = 20 # Don't short below 20% of range (chase bottoms) # Entry buffer (NEW - for advanced optimization) entry_buffer_atr: float = 0.20 # Require price X*ATR beyond line # Source mode (NEW - for advanced optimization) use_heikin_ashi: bool = False # Use Heikin Ashi candles vs Chart # MA gap filter (NEW - optional, not in original v9) use_ma_gap_filter: bool = False # Require MA alignment ma_gap_long_min: float = 0.0 # Require ma50 > ma200 by this % for longs ma_gap_short_max: float = 0.0 # Require ma50 < ma200 by this % for shorts @dataclass class MoneyLineV9Signal: timestamp: pd.Timestamp direction: Direction entry_price: float adx: float atr: float rsi: float volume_ratio: float price_position: float ma_gap: float # NEW: MA50-MA200 gap percentage def ema(series: pd.Series, length: int) -> pd.Series: """Exponential Moving Average.""" return series.ewm(span=length, adjust=False).mean() def sma(series: pd.Series, length: int) -> pd.Series: """Simple Moving Average.""" return series.rolling(length).mean() def rolling_volume_ratio(volume: pd.Series, length: int = 20) -> pd.Series: """Volume ratio vs moving average.""" avg = volume.rolling(length).mean() return volume / avg def price_position(high: pd.Series, low: pd.Series, close: pd.Series, length: int = 100) -> pd.Series: """Price position in percentage of range (0-100).""" highest = high.rolling(length).max() lowest = low.rolling(length).min() return 100.0 * (close - lowest) / (highest - lowest) def rsi(series: pd.Series, length: int) -> pd.Series: """Relative Strength Index.""" delta = series.diff() gain = np.where(delta > 0, delta, 0.0) loss = np.where(delta < 0, -delta, 0.0) avg_gain = rma(pd.Series(gain), length) avg_loss = rma(pd.Series(loss), length) rs = avg_gain / avg_loss.replace(0, np.nan) rsi_series = 100 - (100 / (1 + rs)) return rsi_series.fillna(50.0) def heikin_ashi(df: pd.DataFrame) -> pd.DataFrame: """Calculate Heikin Ashi candles.""" ha = pd.DataFrame(index=df.index) ha['close'] = (df['open'] + df['high'] + df['low'] + df['close']) / 4 # Calculate HA open ha['open'] = df['open'].copy() for i in range(1, len(df)): ha.loc[ha.index[i], 'open'] = (ha.loc[ha.index[i-1], 'open'] + ha.loc[ha.index[i-1], 'close']) / 2 ha['high'] = df[['high']].join(ha[['open', 'close']]).max(axis=1) ha['low'] = df[['low']].join(ha[['open', 'close']]).min(axis=1) return ha def supertrend_v9(df: pd.DataFrame, atr_period: int, multiplier: float, flip_threshold_percent: float, confirm_bars: int, use_heikin_ashi: bool = False) -> tuple[pd.Series, pd.Series]: """ Calculate v9 Money Line (Supertrend with flip threshold and momentum). Returns: (supertrend_line, trend): Line values and trend direction (1=bull, -1=bear) """ # Use Heikin Ashi or Chart if use_heikin_ashi: ha = heikin_ashi(df[['open', 'high', 'low', 'close']]) high, low, close = ha['high'], ha['low'], ha['close'] else: high, low, close = df['high'], df['low'], df['close'] # Calculate ATR on selected source tr = pd.concat([ high - low, (high - close.shift(1)).abs(), (low - close.shift(1)).abs() ], axis=1).max(axis=1) atr = rma(tr, atr_period) # Supertrend bands src = (high + low) / 2 up = src - (multiplier * atr) dn = src + (multiplier * atr) # Initialize tracking arrays up1 = up.copy() dn1 = dn.copy() trend = pd.Series(1, index=df.index) # Start bullish tsl = up1.copy() # Trailing stop line # Momentum tracking for anti-whipsaw bull_momentum = pd.Series(0, index=df.index) bear_momentum = pd.Series(0, index=df.index) # Calculate flip threshold threshold = flip_threshold_percent / 100.0 for i in range(1, len(df)): # Update bands if close.iloc[i-1] > up1.iloc[i-1]: up1.iloc[i] = max(up.iloc[i], up1.iloc[i-1]) else: up1.iloc[i] = up.iloc[i] if close.iloc[i-1] < dn1.iloc[i-1]: dn1.iloc[i] = min(dn.iloc[i], dn1.iloc[i-1]) else: dn1.iloc[i] = dn.iloc[i] # Get previous trend and tsl prev_trend = trend.iloc[i-1] prev_tsl = tsl.iloc[i-1] # Update TSL based on trend if prev_trend == 1: tsl.iloc[i] = max(up1.iloc[i], prev_tsl) else: tsl.iloc[i] = min(dn1.iloc[i], prev_tsl) # Check for flip with threshold and momentum threshold_amount = tsl.iloc[i] * threshold if prev_trend == 1: # Currently bullish - check for bearish flip if close.iloc[i] < (tsl.iloc[i] - threshold_amount): bear_momentum.iloc[i] = bear_momentum.iloc[i-1] + 1 bull_momentum.iloc[i] = 0 else: bear_momentum.iloc[i] = 0 bull_momentum.iloc[i] = 0 # Flip after confirm_bars + 1 consecutive bearish bars if bear_momentum.iloc[i] >= (confirm_bars + 1): trend.iloc[i] = -1 else: trend.iloc[i] = 1 else: # Currently bearish - check for bullish flip if close.iloc[i] > (tsl.iloc[i] + threshold_amount): bull_momentum.iloc[i] = bull_momentum.iloc[i-1] + 1 bear_momentum.iloc[i] = 0 else: bull_momentum.iloc[i] = 0 bear_momentum.iloc[i] = 0 # Flip after confirm_bars + 1 consecutive bullish bars if bull_momentum.iloc[i] >= (confirm_bars + 1): trend.iloc[i] = 1 else: trend.iloc[i] = -1 return tsl, trend def money_line_v9_signals(df: pd.DataFrame, inputs: Optional[MoneyLineV9Inputs] = None) -> list[MoneyLineV9Signal]: """ v9 "Money Line with MA Gap" signal generation. Key behavior: - Immediate signals on line flip (confirmBars=0) - Lower flip threshold (0.5% vs v8's 0.8%) - Expanded filters: RSI boundaries, volume range, price position - MA gap analysis for trend structure - Entry buffer requirement (price must be X*ATR beyond line) - Heikin Ashi source mode support Advanced optimization parameters: - ATR profile (period + multiplier) - RSI boundaries (4 params) - Volume max threshold - Entry buffer size - ADX length - Source mode - MA gap filter (optional) """ if inputs is None: inputs = MoneyLineV9Inputs() data = df.copy() data = data.sort_index() # Calculate Money Line supertrend, trend = supertrend_v9( data, inputs.atr_period, inputs.multiplier, inputs.flip_threshold_percent, inputs.confirm_bars, inputs.use_heikin_ashi ) data['supertrend'] = supertrend data['trend'] = trend # Calculate indicators (use Chart prices for consistency with filters) data["rsi"] = rsi(data["close"], inputs.rsi_length) data["atr"] = calculate_atr(data, inputs.atr_period) data["adx"] = calculate_adx(data, inputs.adx_length) data["volume_ratio"] = rolling_volume_ratio(data["volume"]) data["price_position"] = price_position(data["high"], data["low"], data["close"]) # MA gap analysis (NEW) data["ma50"] = sma(data["close"], 50) data["ma200"] = sma(data["close"], 200) data["ma_gap"] = ((data["ma50"] - data["ma200"]) / data["ma200"]) * 100 signals: list[MoneyLineV9Signal] = [] cooldown_remaining = 0 for idx in range(1, len(data)): row = data.iloc[idx] prev = data.iloc[idx - 1] # Detect trend flip flip_long = prev.trend == -1 and row.trend == 1 flip_short = prev.trend == 1 and row.trend == -1 if cooldown_remaining > 0: cooldown_remaining -= 1 continue # Apply filters adx_ok = row.adx >= inputs.adx_min volume_ok = inputs.vol_min <= row.volume_ratio <= inputs.vol_max # Entry buffer check (price must be X*ATR beyond line) if flip_long: entry_buffer_ok = row.close > (row.supertrend + inputs.entry_buffer_atr * row.atr) elif flip_short: entry_buffer_ok = row.close < (row.supertrend - inputs.entry_buffer_atr * row.atr) else: entry_buffer_ok = False if flip_long: # Long filters rsi_ok = inputs.rsi_long_min <= row.rsi <= inputs.rsi_long_max pos_ok = row.price_position < inputs.long_pos_max # MA gap filter (optional) if inputs.use_ma_gap_filter: ma_gap_ok = row.ma_gap >= inputs.ma_gap_long_min else: ma_gap_ok = True if adx_ok and volume_ok and rsi_ok and pos_ok and entry_buffer_ok and ma_gap_ok: signals.append( MoneyLineV9Signal( timestamp=row.name, direction="long", entry_price=float(row.close), adx=float(row.adx), atr=float(row.atr), rsi=float(row.rsi), volume_ratio=float(row.volume_ratio), price_position=float(row.price_position), ma_gap=float(row.ma_gap), ) ) cooldown_remaining = inputs.cooldown_bars elif flip_short: # Short filters rsi_ok = inputs.rsi_short_min <= row.rsi <= inputs.rsi_short_max pos_ok = row.price_position > inputs.short_pos_min # MA gap filter (optional) if inputs.use_ma_gap_filter: ma_gap_ok = row.ma_gap <= inputs.ma_gap_short_max else: ma_gap_ok = True if adx_ok and volume_ok and rsi_ok and pos_ok and entry_buffer_ok and ma_gap_ok: signals.append( MoneyLineV9Signal( timestamp=row.name, direction="short", entry_price=float(row.close), adx=float(row.adx), atr=float(row.atr), rsi=float(row.rsi), volume_ratio=float(row.volume_ratio), price_position=float(row.price_position), ma_gap=float(row.ma_gap), ) ) cooldown_remaining = inputs.cooldown_bars return signals