Parameter space expansion: - Original 15 params: 101K configurations - NEW: MA gap filter (3 dimensions) = 18× expansion - Total: ~810,000 configurations across 4 time profiles - Chunk size: 1,000 configs/chunk = ~810 chunks MA Gap Filter parameters: - use_ma_gap: True/False (2 values) - ma_gap_min_long: -5.0%, 0%, +5.0% (3 values) - ma_gap_min_short: -5.0%, 0%, +5.0% (3 values) Implementation: - money_line_v9.py: Full v9 indicator with MA gap logic - v9_advanced_worker.py: Chunk processor (1,000 configs) - v9_advanced_coordinator.py: Work distributor (2 EPYC workers) - run_v9_advanced_sweep.sh: Startup script (generates + launches) Infrastructure: - Uses existing EPYC cluster (64 cores total) - Worker1: bd-epyc-02 (32 threads) - Worker2: bd-host01 (32 threads via SSH hop) - Expected runtime: 70-80 hours - Database: SQLite (chunk tracking + results) Goal: Find optimal MA gap thresholds for filtering false breakouts during MA whipsaw zones while preserving trend entries.
379 lines
13 KiB
Python
379 lines
13 KiB
Python
"""
|
||
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
|