e02a7e3453
CRITICAL FIXES (would have prevented the $194K crisis): 1. HARD MAX SHORT POSITION CAP (safety.py + executor.py) - new config: max_short_positions=4 (hard limit) - new config: max_total_positions=8 (hard limit) - executor now queries broker.get_positions() for AUTHORITATIVE count - BEFORE opening any short, checks broker directly (not just store) - validate_trade() now accepts broker_positions and blocks at hard cap - This directly prevents the 13-shorts scenario 2. YESTERDAY-CLOSE DRAWDC OWN CIRCUIT BREAKER (safety.py) - new config: yesterday_close_drawdown_limit=3% - new config: yesterday_close_drawdown_reduction=50% - Separate from peak-equity tracking (which is too slow) - At $194K from $200K initial = 3% → fires IMMEDIATELY, cuts 50% of positions - Tested: check_yesterday_close_drawdown($194K) → triggers with 50% reduction 3. PER-POSITION STOP LOSS DEFAULTS (safety.py + executor.py) - new config: stop_loss_default_pct_long=1.0%, short=1.5% - new config: stop_loss_max_pct=3.0% (never wider than this) - new config: take_profit_default_pct=2.0% - NEW: get_default_stop_loss() + get_default_take_profit() methods - executor.execute_signal() now ALWAYS sets stop loss (even if GA provides None) - Previously stops were often None → exits never triggered - Tested: get_default_stop_loss($100, 'short') = $101.50 ✓ 4. BROKER/STORE SYNC VALIDATION (safety.py + main_auto.py) - new: validate_broker_position_count() detects discrepancies - new: _trade_symbol() now passes combined_equity to executor - new: _trading_cycle() validates broker vs store BEFORE evaluating new trades - Logs warning when broker has positions not tracked in store - Blocks new trades if broker position count already at hard cap 5. CRISIS MODE: 10+ LOSING POSITIONS (executor.py check_exits) - If 8+ of 10+ positions are losing money → force reduce 50% of ALL positions - Catches cascading blowups before drawdown thresholds are hit - Logs CRITICAL warning when triggered 6. DEFAULT STOP LOSS ENFORCEMENT (executor.py) - _enforce_stop_loss_tightness() was overriding None stops to None - Now executor ALWAYS applies safety defaults if GA provides no stop - Every new position gets a stop loss on entry Config changes (auto_config.json): - Added max_short_positions, max_total_positions - Added stop_loss_default_pct_long/short, take_profit_default_pct - Added stop_loss_atr_multiplier, stop_loss_max_pct - Added yesterday_close_drawdown_limit=3%, yesterday_close_drawdown_reduction=50% HOW THIS WOULD HAVE HELPED THE $194,940 PORTFOLIO: - At $194,940 from $200K = 2.53% drawdown from initial - If yesterday closed at $200K: 2.53% < 3% limit → NOT triggered - But at open today if equity dropped to $194,000 → 3.00% → TRIGGERS IMMEDIATELY - Hard cap at 4 shorts: after 4 shorts, executor blocks action 5/6 - Stop losses: each of the 4 shorts would have had 1.5% stop → 2 shorts would have been stopped out before they lost further, limiting damage - Crisis mode: if 8 positions were losing, 50% of all positions closed
574 lines
24 KiB
Python
574 lines
24 KiB
Python
"""
|
|
Market Regime Detector
|
|
Detects and tracks market regime (trending up, trending down, ranging, volatile)
|
|
using multiple indicators: MA crossovers, ADX, Bollinger position (%B), ATR.
|
|
|
|
Emits regime change warnings and provides adaptive parameters for trading decisions.
|
|
"""
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
from typing import Dict, List, Optional, Tuple
|
|
from loguru import logger
|
|
from datetime import datetime, timedelta
|
|
|
|
|
|
class MarketRegime:
|
|
"""Market regime classifications"""
|
|
TRENDING_UP = "trending_up"
|
|
TRENDING_DOWN = "trending_down"
|
|
RANGING = "ranging"
|
|
VOLATILE = "volatile"
|
|
UNKNOWN = "unknown"
|
|
|
|
|
|
class RegimeDetector:
|
|
"""
|
|
Detects market regime using multiple technical indicators.
|
|
|
|
Indicators used:
|
|
- MA Crossover: fast vs slow MA to detect trend direction
|
|
- ADX: trend strength (above 25 = trending, below 25 = ranging)
|
|
- Bollinger %B: position within bands (near 0 = bottom, near 1 = top, near 0.5 = middle)
|
|
- ATR: absolute volatility vs recent average
|
|
|
|
Regime rules:
|
|
- TRENDING_UP: price > slow MA AND ADX > 25 AND MA bullish crossover
|
|
- TRENDING_DOWN: price < slow MA AND ADX > 25 AND MA bearish crossover
|
|
- RANGING: ADX < 25 (no clear trend)
|
|
- VOLATILE: ATR percentile > 80th percentile (abnormally high volatility)
|
|
"""
|
|
|
|
def __init__(self, config: Dict = None):
|
|
self.config = config or {}
|
|
|
|
# Lookback periods for indicators
|
|
self.fast_ma_period = self.config.get('fast_ma_period', 10)
|
|
self.slow_ma_period = self.config.get('slow_ma_period', 50)
|
|
self.adx_period = self.config.get('adx_period', 14)
|
|
self.bb_period = self.config.get('bb_period', 20)
|
|
self.atr_period = self.config.get('atr_period', 14)
|
|
self.atr_vol_period = self.config.get('atr_vol_period', 100) # for ATR percentile
|
|
|
|
# Thresholds
|
|
self.adx_trend_threshold = self.config.get('adx_trend_threshold', 25)
|
|
self.adx_strong_threshold = self.config.get('adx_strong_threshold', 40)
|
|
self.volatile_atr_percentile = self.config.get('volatile_atr_percentile', 80)
|
|
self.bb_squeeze_ratio = self.config.get('bb_squeeze_ratio', 0.5)
|
|
|
|
# History
|
|
self.regime_history: List[Dict] = []
|
|
self.max_history = 100
|
|
|
|
# Crossover state tracking
|
|
self._prev_fast_ma: Optional[float] = None
|
|
self._prev_slow_ma: Optional[float] = None
|
|
|
|
# Last regime
|
|
self._last_regime = MarketRegime.UNKNOWN
|
|
self._last_warning = None
|
|
|
|
def detect_regime(self, df: pd.DataFrame, symbol: str = None) -> Dict:
|
|
"""
|
|
Detect the current market regime for a symbol.
|
|
|
|
Args:
|
|
df: DataFrame with OHLCV data (must have high, low, close, volume)
|
|
symbol: optional symbol for logging
|
|
|
|
Returns:
|
|
Dict with:
|
|
- regime: MarketRegime enum value
|
|
- confidence: float 0-1
|
|
- trend_strength: float 0-100 (ADX)
|
|
- volatility_ratio: float (current ATR / avg ATR)
|
|
- bb_position: float 0-1 (%B)
|
|
- regime_change: bool (True if regime just changed)
|
|
- regime_change_type: str or None
|
|
- regime_change_direction: str ('up' or 'down') or None
|
|
- early_warning: bool (True if regime likely to change soon)
|
|
- warning_message: str or None
|
|
- indicators: dict of computed indicator values
|
|
"""
|
|
if df is None or len(df) < max(self.slow_ma_period + self.adx_period, 60):
|
|
return self._unknown_result()
|
|
|
|
result = {
|
|
'regime': MarketRegime.UNKNOWN,
|
|
'confidence': 0.0,
|
|
'trend_strength': 0.0,
|
|
'volatility_ratio': 1.0,
|
|
'bb_position': 0.5,
|
|
'regime_change': False,
|
|
'regime_change_type': None,
|
|
'regime_change_direction': None,
|
|
'early_warning': False,
|
|
'warning_message': None,
|
|
'indicators': {},
|
|
}
|
|
|
|
try:
|
|
# Compute indicators
|
|
close = df['close'].values.astype(np.float64)
|
|
high = df['high'].values.astype(np.float64)
|
|
low = df['low'].values.astype(np.float64)
|
|
|
|
# Moving averages
|
|
fast_ma = self._rolling_mean(close, self.fast_ma_period)
|
|
slow_ma = self._rolling_mean(close, self.slow_ma_period)
|
|
|
|
# Current MA values
|
|
curr_fast_ma = fast_ma[-1]
|
|
curr_slow_ma = slow_ma[-1]
|
|
prev_fast_ma = fast_ma[-2] if len(fast_ma) > 1 else curr_fast_ma
|
|
prev_slow_ma = slow_ma[-2] if len(slow_ma) > 1 else curr_slow_ma
|
|
|
|
# ADX
|
|
adx_val, plus_di, minus_di = self._compute_adx(high, low, close, self.adx_period)
|
|
|
|
# ATR and volatility
|
|
atr = self._compute_atr(high, low, close, self.atr_period)
|
|
curr_atr = atr[-1] if len(atr) > 0 else 0
|
|
avg_atr = np.mean(atr[-self.atr_vol_period:]) if len(atr) >= self.atr_vol_period else curr_atr
|
|
volatility_ratio = curr_atr / avg_atr if avg_atr > 0 else 1.0
|
|
|
|
# Bollinger Bands %B
|
|
bb_position = self._compute_bb_position(close, self.bb_period)
|
|
|
|
# Trend direction from MAs
|
|
price_above_slow = close[-1] > curr_slow_ma
|
|
price_below_slow = close[-1] < curr_slow_ma
|
|
|
|
# MA crossover detection
|
|
# Bullish: fast MA crosses above slow MA
|
|
bullish_cross = (prev_fast_ma <= prev_slow_ma) and (curr_fast_ma > curr_slow_ma)
|
|
# Bearish: fast MA crosses below slow MA
|
|
bearish_cross = (prev_fast_ma >= prev_slow_ma) and (curr_fast_ma < curr_slow_ma)
|
|
|
|
# MA alignment (both fast and slow same direction)
|
|
ma_bullish = curr_fast_ma > curr_slow_ma and fast_ma[-1] > fast_ma[-max(2, self.fast_ma_period//2)]
|
|
ma_bearish = curr_fast_ma < curr_slow_ma and fast_ma[-1] < fast_ma[-max(2, self.fast_ma_period//2)]
|
|
|
|
# Store for next call
|
|
self._prev_fast_ma = curr_fast_ma
|
|
self._prev_slow_ma = curr_slow_ma
|
|
|
|
# --- Determine regime ---
|
|
regime = MarketRegime.UNKNOWN
|
|
confidence = 0.5
|
|
|
|
# Check volatile first (highest priority - we want to reduce exposure)
|
|
volatile_percentile = self._compute_atr_percentile(atr)
|
|
is_volatile = volatile_percentile > self.volatile_atr_percentile
|
|
|
|
if is_volatile:
|
|
regime = MarketRegime.VOLATILE
|
|
confidence = min(1.0, volatile_percentile / 100.0)
|
|
|
|
# Ranging (no clear trend)
|
|
elif adx_val < self.adx_trend_threshold:
|
|
regime = MarketRegime.RANGING
|
|
confidence = 1.0 - (adx_val / self.adx_trend_threshold)
|
|
if adx_val < 15:
|
|
confidence = 1.0 # Very clear ranging
|
|
|
|
# Strong uptrend
|
|
elif adx_val > self.adx_strong_threshold and price_above_slow and (bullish_cross or ma_bullish):
|
|
regime = MarketRegime.TRENDING_UP
|
|
confidence = min(1.0, adx_val / 60.0)
|
|
|
|
# Strong downtrend
|
|
elif adx_val > self.adx_strong_threshold and price_below_slow and (bearish_cross or ma_bearish):
|
|
regime = MarketRegime.TRENDING_DOWN
|
|
confidence = min(1.0, adx_val / 60.0)
|
|
|
|
# Moderate trend (one signal but not all)
|
|
elif adx_val > self.adx_trend_threshold:
|
|
if price_above_slow:
|
|
regime = MarketRegime.TRENDING_UP
|
|
confidence = 0.5
|
|
elif price_below_slow:
|
|
regime = MarketRegime.TRENDING_DOWN
|
|
confidence = 0.5
|
|
|
|
# Check for regime change
|
|
regime_change = False
|
|
regime_change_type = None
|
|
regime_change_direction = None
|
|
|
|
if regime != self._last_regime and self._last_regime != MarketRegime.UNKNOWN:
|
|
regime_change = True
|
|
regime_change_type = f"{self._last_regime}_to_{regime}"
|
|
if regime in (MarketRegime.TRENDING_UP, MarketRegime.TRENDING_DOWN):
|
|
regime_change_direction = 'up' if regime == MarketRegime.TRENDING_UP else 'down'
|
|
elif self._last_regime in (MarketRegime.TRENDING_UP, MarketRegime.TRENDING_DOWN):
|
|
regime_change_direction = 'reversal'
|
|
logger.warning(f"🔄 REGIME CHANGE: {regime_change_type} (confidence={confidence:.2f})")
|
|
|
|
self._last_regime = regime
|
|
|
|
# Early warning detection
|
|
early_warning = False
|
|
warning_message = None
|
|
|
|
if not regime_change:
|
|
# Check for early warning signs
|
|
warning = self._detect_early_warning(
|
|
regime, adx_val, volatility_ratio, bullish_cross, bearish_cross,
|
|
price_above_slow, ma_bullish, ma_bearish, curr_fast_ma, curr_slow_ma, close[-1],
|
|
plus_di, minus_di, bb_position
|
|
)
|
|
early_warning = warning['triggered']
|
|
warning_message = warning['message']
|
|
|
|
result = {
|
|
'regime': regime,
|
|
'confidence': float(confidence),
|
|
'trend_strength': float(adx_val),
|
|
'volatility_ratio': float(volatility_ratio),
|
|
'bb_position': float(bb_position[-1]) if len(bb_position) > 0 else 0.5,
|
|
'regime_change': regime_change,
|
|
'regime_change_type': regime_change_type,
|
|
'regime_change_direction': regime_change_direction,
|
|
'early_warning': early_warning,
|
|
'warning_message': warning_message,
|
|
'indicators': {
|
|
'adx': float(adx_val),
|
|
'plus_di': float(plus_di[-1]) if len(plus_di) > 0 else 0,
|
|
'minus_di': float(minus_di[-1]) if len(minus_di) > 0 else 0,
|
|
'fast_ma': float(curr_fast_ma),
|
|
'slow_ma': float(curr_slow_ma),
|
|
'atr': float(curr_atr),
|
|
'avg_atr': float(avg_atr),
|
|
'bb_upper': float(self._compute_bb_upper(close, self.bb_period)[-1]) if len(close) > 0 else 0,
|
|
'bb_lower': float(self._compute_bb_lower(close, self.bb_period)[-1]) if len(close) > 0 else 0,
|
|
'bullish_cross': bool(bullish_cross),
|
|
'bearish_cross': bool(bearish_cross),
|
|
'price': float(close[-1]),
|
|
}
|
|
}
|
|
|
|
# Store in history
|
|
self.regime_history.append({
|
|
'timestamp': datetime.utcnow(),
|
|
'regime': regime,
|
|
'confidence': confidence,
|
|
'trend_strength': adx_val,
|
|
'symbol': symbol or 'unknown',
|
|
})
|
|
self.regime_history = self.regime_history[-self.max_history:]
|
|
|
|
except Exception as e:
|
|
logger.error(f"Regime detection error: {e}")
|
|
return self._unknown_result()
|
|
|
|
return result
|
|
|
|
def _detect_early_warning(self, current_regime: str, adx: float,
|
|
volatility_ratio: float, bullish_cross: bool,
|
|
bearish_cross: bool, price_above_slow: bool,
|
|
ma_bullish: bool, ma_bearish: bool,
|
|
fast_ma: float, slow_ma: float,
|
|
current_price: float,
|
|
plus_di: float, minus_di: float,
|
|
bb_position: np.ndarray) -> Dict:
|
|
"""
|
|
Detect early warning signs that regime is about to change.
|
|
|
|
Warning triggers:
|
|
1. ADX dropping rapidly (trend weakening) while in a trend
|
|
2. DI crossover approaching (plus_di crossing below minus_di or vice versa)
|
|
3. Price approaching Bollinger band extremes
|
|
4. Volatility rising while in a trend
|
|
"""
|
|
warning = {'triggered': False, 'message': None}
|
|
|
|
bb_pos = bb_position[-1] if len(bb_position) > 0 else 0.5
|
|
|
|
if current_regime == MarketRegime.TRENDING_UP:
|
|
# Warning: price overextended at upper BB, ADX dropping
|
|
if bb_pos > 0.90 and adx < 30:
|
|
warning['triggered'] = True
|
|
warning['message'] = "Uptrend overextended - reversal possible (RSI overbought, BB at upper band)"
|
|
# Warning: DI reversal
|
|
elif len(plus_di) > 0 and len(minus_di) > 0:
|
|
if plus_di[-1] < minus_di[-1] and (len(plus_di) < 2 or plus_di[-2] >= (minus_di[-2] if len(minus_di) > 1 else 0)):
|
|
warning['triggered'] = True
|
|
warning['message'] = "DI bearish crossover approaching - trend likely to reverse"
|
|
|
|
elif current_regime == MarketRegime.TRENDING_DOWN:
|
|
if bb_pos < 0.10 and adx < 30:
|
|
warning['triggered'] = True
|
|
warning['message'] = "Downtrend overextended - reversal possible (RSI oversold, BB at lower band)"
|
|
elif len(minus_di) > 0 and len(plus_di) > 0:
|
|
if minus_di[-1] < plus_di[-1] and (len(minus_di) < 2 or minus_di[-2] >= (plus_di[-2] if len(plus_di) > 1 else 0)):
|
|
warning['triggered'] = True
|
|
warning['message'] = "DI bullish crossover approaching - trend likely to reverse"
|
|
|
|
elif current_regime == MarketRegime.RANGING:
|
|
# Warning: ADX starting to rise from low
|
|
if adx > 20 and volatility_ratio > 1.3:
|
|
warning['triggered'] = True
|
|
warning['message'] = "Range-bound market may be breaking out - watch for direction"
|
|
|
|
elif current_regime == MarketRegime.VOLATILE:
|
|
if volatility_ratio > 2.0:
|
|
warning['triggered'] = True
|
|
warning['message'] = "Extreme volatility - reduce all positions"
|
|
|
|
return warning
|
|
|
|
def get_adaptive_params(self, regime: str, base_position_pct: float = 0.1) -> Dict:
|
|
"""
|
|
Get adaptive trading parameters based on regime.
|
|
|
|
Returns dict with:
|
|
- position_size_multiplier: 0.0-1.0 (reduce size in bad regimes)
|
|
- stop_loss_atr_mult: ATR multiplier for stop loss
|
|
- take_profit_atr_mult: ATR multiplier for take profit
|
|
- max_positions: max concurrent positions
|
|
- allow_shorts: bool
|
|
- allow_longs: bool
|
|
- regime_label: human-readable description
|
|
"""
|
|
regime_params = {
|
|
MarketRegime.TRENDING_UP: {
|
|
'position_size_multiplier': 1.0,
|
|
'stop_loss_atr_mult': 1.5,
|
|
'take_profit_atr_mult': 3.0,
|
|
'max_positions': 5,
|
|
'allow_shorts': False,
|
|
'allow_longs': True,
|
|
'regime_label': "STRONG Uptrend - favor longs",
|
|
'short_position_reduction': 0.5, # Reduce existing shorts by 50%
|
|
},
|
|
MarketRegime.TRENDING_DOWN: {
|
|
'position_size_multiplier': 0.8,
|
|
'stop_loss_atr_mult': 1.5,
|
|
'take_profit_atr_mult': 2.5,
|
|
'max_positions': 4,
|
|
'allow_shorts': True,
|
|
'allow_longs': False,
|
|
'regime_label': "STRONG Downtrend - favor shorts",
|
|
'short_position_reduction': 0.0,
|
|
},
|
|
MarketRegime.RANGING: {
|
|
'position_size_multiplier': 0.4,
|
|
'stop_loss_atr_mult': 1.0,
|
|
'take_profit_atr_mult': 1.5,
|
|
'max_positions': 2,
|
|
'allow_shorts': False,
|
|
'allow_longs': False,
|
|
'regime_label': "RANGING - mean-reversion only",
|
|
'short_position_reduction': 1.0, # Close all directional positions
|
|
},
|
|
MarketRegime.VOLATILE: {
|
|
'position_size_multiplier': 0.25,
|
|
'stop_loss_atr_mult': 2.5,
|
|
'take_profit_atr_mult': 4.0,
|
|
'max_positions': 2,
|
|
'allow_shorts': True, # Can still short in volatile
|
|
'allow_longs': True, # But reduce size
|
|
'regime_label': "HIGH VOLATILITY - minimal size",
|
|
'short_position_reduction': 0.5,
|
|
},
|
|
MarketRegime.UNKNOWN: {
|
|
'position_size_multiplier': 0.3,
|
|
'stop_loss_atr_mult': 2.0,
|
|
'take_profit_atr_mult': 3.0,
|
|
'max_positions': 2,
|
|
'allow_shorts': False,
|
|
'allow_longs': True,
|
|
'regime_label': "UNKNOWN regime - defensive",
|
|
'short_position_reduction': 1.0,
|
|
},
|
|
}
|
|
|
|
return regime_params.get(regime, regime_params[MarketRegime.UNKNOWN])
|
|
|
|
def get_regime_summary(self, regime_data: Dict) -> str:
|
|
"""Get a human-readable regime summary."""
|
|
regime = regime_data.get('regime', MarketRegime.UNKNOWN)
|
|
confidence = regime_data.get('confidence', 0)
|
|
adx = regime_data.get('trend_strength', 0)
|
|
vol_ratio = regime_data.get('volatility_ratio', 1.0)
|
|
|
|
params = self.get_adaptive_params(regime)
|
|
|
|
lines = [
|
|
f"Regime: {params['regime_label']}",
|
|
f"Confidence: {confidence:.0%}",
|
|
f"ADX: {adx:.1f} {'(trending)' if adx > 25 else '(ranging)'}",
|
|
f"Volatility: {vol_ratio:.1f}x average {'⚠️ HIGH' if vol_ratio > 1.5 else ''}",
|
|
f"Position size: {params['position_size_multiplier']:.0%} of base",
|
|
]
|
|
|
|
if regime_data.get('early_warning'):
|
|
lines.append(f"⚠️ EARLY WARNING: {regime_data.get('warning_message', 'Regime shift likely')}")
|
|
|
|
if regime_data.get('regime_change'):
|
|
lines.append(f"🔄 REGIME CHANGE: {regime_data.get('regime_change_type', '')}")
|
|
|
|
return " | ".join(lines)
|
|
|
|
def _unknown_result(self) -> Dict:
|
|
return {
|
|
'regime': MarketRegime.UNKNOWN,
|
|
'confidence': 0.0,
|
|
'trend_strength': 0.0,
|
|
'volatility_ratio': 1.0,
|
|
'bb_position': 0.5,
|
|
'regime_change': False,
|
|
'regime_change_type': None,
|
|
'regime_change_direction': None,
|
|
'early_warning': False,
|
|
'warning_message': None,
|
|
'indicators': {},
|
|
}
|
|
|
|
# --- Indicator computation helpers ---
|
|
|
|
def _rolling_mean(self, arr: np.ndarray, period: int) -> np.ndarray:
|
|
n = len(arr)
|
|
result = np.full(n, np.nan)
|
|
if n < period:
|
|
for i in range(n):
|
|
result[i] = np.mean(arr[:i + 1])
|
|
return result
|
|
cs = np.cumsum(arr)
|
|
result[period - 1:] = (cs[period - 1:] - np.concatenate([[0], cs[:n - period]])) / period
|
|
for i in range(period - 1):
|
|
result[i] = np.mean(arr[:i + 1])
|
|
return result
|
|
|
|
def _compute_adx(self, high: np.ndarray, low: np.ndarray,
|
|
close: np.ndarray, period: int) -> Tuple[float, np.ndarray, np.ndarray]:
|
|
"""Compute ADX, +DI, -DI using Wilder's smoothing."""
|
|
n = len(close)
|
|
if n < period * 2:
|
|
return 25.0, np.zeros(n), np.zeros(n)
|
|
|
|
# True range
|
|
tr = np.empty(n)
|
|
tr[0] = high[0] - low[0]
|
|
for i in range(1, n):
|
|
tr[i] = max(high[i] - low[i],
|
|
abs(high[i] - close[i - 1]),
|
|
abs(low[i] - close[i - 1]))
|
|
|
|
# Directional movement
|
|
up_move = np.zeros(n)
|
|
down_move = np.zeros(n)
|
|
for i in range(1, n):
|
|
up_move[i] = high[i] - high[i - 1]
|
|
down_move[i] = low[i - 1] - low[i]
|
|
|
|
# +DM and -DM
|
|
plus_dm = np.zeros(n)
|
|
minus_dm = np.zeros(n)
|
|
for i in range(1, n):
|
|
if up_move[i] > down_move[i] and up_move[i] > 0:
|
|
plus_dm[i] = up_move[i]
|
|
minus_dm[i] = 0
|
|
elif down_move[i] > up_move[i] and down_move[i] > 0:
|
|
plus_dm[i] = 0
|
|
minus_dm[i] = down_move[i]
|
|
|
|
# Wilder's smoothed
|
|
period_int = int(period)
|
|
atr_smooth = np.zeros(n)
|
|
plus_dm_smooth = np.zeros(n)
|
|
minus_dm_smooth = np.zeros(n)
|
|
|
|
atr_smooth[period_int] = np.mean(tr[1:period_int + 1])
|
|
plus_dm_smooth[period_int] = np.mean(plus_dm[1:period_int + 1])
|
|
minus_dm_smooth[period_int] = np.mean(minus_dm[1:period_int + 1])
|
|
|
|
for i in range(period_int + 1, n):
|
|
atr_smooth[i] = (atr_smooth[i - 1] * (period_int - 1) + tr[i]) / period_int
|
|
plus_dm_smooth[i] = (plus_dm_smooth[i - 1] * (period_int - 1) + plus_dm[i]) / period_int
|
|
minus_dm_smooth[i] = (minus_dm_smooth[i - 1] * (period_int - 1) + minus_dm[i]) / period_int
|
|
|
|
# DX
|
|
di_sum = plus_dm_smooth + minus_dm_smooth
|
|
dx = np.zeros(n)
|
|
valid = di_sum > 0
|
|
dx[valid] = np.abs(plus_dm_smooth[valid] - minus_dm_smooth[valid]) / di_sum[valid] * 100
|
|
|
|
# ADX = Wilder smooth of DX
|
|
adx_arr = np.zeros(n)
|
|
adx_arr[period_int * 2] = np.mean(dx[period_int:period_int * 2])
|
|
for i in range(period_int * 2 + 1, n):
|
|
adx_arr[i] = (adx_arr[i - 1] * (period_int - 1) + dx[i]) / period_int
|
|
|
|
return float(adx_arr[-1]) if not np.isnan(adx_arr[-1]) else 25.0, plus_dm_smooth, minus_dm_smooth
|
|
|
|
def _compute_atr(self, high: np.ndarray, low: np.ndarray,
|
|
close: np.ndarray, period: int) -> np.ndarray:
|
|
"""Compute ATR array."""
|
|
n = len(close)
|
|
tr = np.empty(n)
|
|
tr[0] = high[0] - low[0]
|
|
for i in range(1, n):
|
|
tr[i] = max(high[i] - low[i],
|
|
abs(high[i] - close[i - 1]),
|
|
abs(low[i] - close[i - 1]))
|
|
|
|
atr = np.full(n, 0.0)
|
|
if n < period:
|
|
for i in range(n):
|
|
atr[i] = np.mean(tr[:i + 1])
|
|
return atr
|
|
|
|
period_int = int(period)
|
|
atr[period_int - 1] = np.mean(tr[:period_int])
|
|
for i in range(period_int, n):
|
|
atr[i] = (atr[i - 1] * (period_int - 1) + tr[i]) / period_int
|
|
for i in range(period_int - 1):
|
|
atr[i] = np.mean(tr[:i + 1])
|
|
|
|
return atr
|
|
|
|
def _compute_atr_percentile(self, atr: np.ndarray) -> float:
|
|
"""Compute ATR percentile relative to its own history."""
|
|
if len(atr) < 20:
|
|
return 50.0
|
|
recent = atr[-min(100, len(atr)):]
|
|
curr = atr[-1]
|
|
percentile = (np.sum(recent < curr) / len(recent)) * 100
|
|
return float(percentile)
|
|
|
|
def _compute_bb_position(self, close: np.ndarray, period: int) -> np.ndarray:
|
|
"""Compute Bollinger Band position (%B)."""
|
|
n = len(close)
|
|
mid = self._rolling_mean(close, period)
|
|
std = np.zeros(n)
|
|
for i in range(n):
|
|
start = max(0, i - period + 1)
|
|
std[i] = np.std(close[start:i + 1]) if i > 0 else 0
|
|
upper = mid + 2 * std
|
|
lower = mid - 2 * std
|
|
range_ = upper - lower
|
|
range_[range_ == 0] = 1 # Avoid division by zero
|
|
|
|
bb_pos = np.full(n, 0.5)
|
|
valid = range_ > 0
|
|
bb_pos[valid] = (close[valid] - lower[valid]) / range_[valid]
|
|
return np.clip(bb_pos, 0, 1)
|
|
|
|
def _compute_bb_upper(self, close: np.ndarray, period: int) -> np.ndarray:
|
|
mid = self._rolling_mean(close, period)
|
|
std = np.zeros(len(close))
|
|
for i in range(len(close)):
|
|
start = max(0, i - period + 1)
|
|
std[i] = np.std(close[start:i + 1]) if i > 0 else 0
|
|
return mid + 2 * std
|
|
|
|
def _compute_bb_lower(self, close: np.ndarray, period: int) -> np.ndarray:
|
|
mid = self._rolling_mean(close, period)
|
|
std = np.zeros(len(close))
|
|
for i in range(len(close)):
|
|
start = max(0, i - period + 1)
|
|
std[i] = np.std(close[start:i + 1]) if i > 0 else 0
|
|
return mid - 2 * std
|