Files
biggfish/src/core/regime_detector.py
T
sami7777 e02a7e3453 Risk management overhaul: prevent 13-shorts blowup scenario
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
2026-04-06 03:21:15 -07:00

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