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
This commit is contained in:
+33
-9
@@ -62,21 +62,45 @@
|
||||
"min_trade_value": 50,
|
||||
"require_approval": false,
|
||||
"max_position_pct": 8,
|
||||
"max_concurrent_positions": 25,
|
||||
"max_concurrent_positions": 8,
|
||||
"stop_loss_pct": 1.0,
|
||||
"take_profit_pct": 1.5,
|
||||
"min_backtest_sharpe": 0.3,
|
||||
"max_correlated_positions": 3,
|
||||
"max_daily_trades": 50
|
||||
"max_same_direction": 4,
|
||||
"max_daily_trades": 30
|
||||
},
|
||||
"safety": {
|
||||
"max_position_pct": 10,
|
||||
"max_concurrent_positions": 25,
|
||||
"max_daily_trades": 50,
|
||||
"max_daily_loss_pct": 2,
|
||||
"max_total_loss_pct": 10,
|
||||
"min_trade_value": 100,
|
||||
"initial_capital": 200000
|
||||
"max_position_pct": 8,
|
||||
"max_concurrent_positions": 8,
|
||||
"max_total_positions": 8,
|
||||
"max_daily_trades": 30,
|
||||
"max_daily_loss_pct": 3,
|
||||
"max_total_loss_pct": 15,
|
||||
"min_trade_value": 50,
|
||||
"initial_capital": 200000,
|
||||
"max_same_direction": 4,
|
||||
"max_correlated_positions": 3,
|
||||
"max_short_positions": 4,
|
||||
"stop_loss_default_pct_long": 1.0,
|
||||
"stop_loss_default_pct_short": 1.5,
|
||||
"stop_loss_max_pct": 3.0,
|
||||
"take_profit_default_pct": 2.0,
|
||||
"stop_loss_atr_multiplier": 2.0,
|
||||
"yesterday_close_drawdown_limit": 0.03,
|
||||
"yesterday_close_drawdown_reduction": 0.50,
|
||||
"max_drawdown_stop_pct": 0.06,
|
||||
"max_drawdown_stop_reduction": 0.50,
|
||||
"volatility_lookback": 20,
|
||||
"volatility_max_multiplier": 1.5,
|
||||
"volatility_min_multiplier": 0.5,
|
||||
"drawdown_position_scale": {
|
||||
"0.02": 1.0,
|
||||
"0.05": 0.75,
|
||||
"0.10": 0.50,
|
||||
"0.15": 0.25,
|
||||
"0.20": 0.10
|
||||
}
|
||||
},
|
||||
"rl": {
|
||||
"gamma": 0.95,
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
{
|
||||
"alpaca": {
|
||||
"api_key": "PKIJPFNMNZ3YKYP765XD6ZPPJY",
|
||||
"secret_key": "42PuPEYG2nGbeMJiogiFKKLyPtkEHKtwwXJamRKpf4tL",
|
||||
"base_url": "https://paper-api.alpaca.markets"
|
||||
},
|
||||
"oanda": {
|
||||
"api_token": "860db6509bc2f430b0cbfe197012a628-310ea40d575131302e6a30c958260837",
|
||||
"account_id": "101-001-38661051-001",
|
||||
"practice": true
|
||||
},
|
||||
"trading": {
|
||||
"symbols": [
|
||||
"SPY",
|
||||
"QQQ",
|
||||
"IWM",
|
||||
"DIA",
|
||||
"AAPL",
|
||||
"MSFT",
|
||||
"GOOGL",
|
||||
"AMZN",
|
||||
"NVDA",
|
||||
"TSLA",
|
||||
"META",
|
||||
"JPM",
|
||||
"BAC",
|
||||
"GS",
|
||||
"MS",
|
||||
"JNJ",
|
||||
"UNH",
|
||||
"WMT",
|
||||
"PG",
|
||||
"XLE",
|
||||
"CVX",
|
||||
"XOM",
|
||||
"LMT",
|
||||
"RTX",
|
||||
"NOC",
|
||||
"GD",
|
||||
"USO",
|
||||
"GLD"
|
||||
],
|
||||
"forex_symbols": [
|
||||
"USD_JPY",
|
||||
"EUR_JPY",
|
||||
"GBP_JPY",
|
||||
"CAD_JPY",
|
||||
"AUD_JPY",
|
||||
"EUR_USD",
|
||||
"GBP_USD",
|
||||
"AUD_USD",
|
||||
"NZD_USD",
|
||||
"USD_CAD",
|
||||
"USD_CHF",
|
||||
"EUR_GBP",
|
||||
"EUR_CHF"
|
||||
],
|
||||
"cycle_interval_seconds": 60,
|
||||
"initial_capital": 200000,
|
||||
"target_capital": 250000,
|
||||
"commission_rate": 0.0,
|
||||
"min_trade_value": 50,
|
||||
"require_approval": false,
|
||||
"max_position_pct": 15,
|
||||
"max_concurrent_positions": 250,
|
||||
"stop_loss_pct": 2.5,
|
||||
"take_profit_pct": 5.0,
|
||||
"min_backtest_sharpe": -0.5,
|
||||
"max_correlated_positions": 3,
|
||||
"max_daily_trades": 500
|
||||
},
|
||||
"safety": {
|
||||
"max_position_pct": 10,
|
||||
"max_concurrent_positions": 250,
|
||||
"max_daily_trades": 500,
|
||||
"max_daily_loss_pct": 2,
|
||||
"max_total_loss_pct": 10,
|
||||
"min_trade_value": 100,
|
||||
"initial_capital": 200000
|
||||
},
|
||||
"rl": {
|
||||
"gamma": 0.97,
|
||||
"epsilon_start": 1.0,
|
||||
"epsilon_min": 0.03,
|
||||
"epsilon_decay": 0.999,
|
||||
"learning_rate": 0.002,
|
||||
"batch_size": 256,
|
||||
"memory_size": 250000,
|
||||
"target_update_freq": 50,
|
||||
"hidden_dim": 128,
|
||||
"live_epsilon": 0.03,
|
||||
"train_interval_hours": 0.1,
|
||||
"checkpoint_interval_hours": 1
|
||||
},
|
||||
"ga": {
|
||||
"population_size": 100,
|
||||
"elite_count": 12,
|
||||
"mutation_rate": 0.35,
|
||||
"mutation_strength": 0.25,
|
||||
"crossover_rate": 0.7,
|
||||
"tournament_size": 5,
|
||||
"evolution_interval_hours": 0.5,
|
||||
"generations_per_cycle": 30
|
||||
},
|
||||
"backtest": {
|
||||
"interval_seconds": 600,
|
||||
"lookback_days": 14,
|
||||
"initial_capital": 100
|
||||
},
|
||||
"cache": {
|
||||
"warmup_lookback_days": 60,
|
||||
"update_interval_seconds": 60,
|
||||
"timeframes": [
|
||||
"5m",
|
||||
"1h"
|
||||
]
|
||||
},
|
||||
"database": {
|
||||
"path": "data/biggfish.db"
|
||||
},
|
||||
"reporting": {
|
||||
"dashboard_interval_seconds": 60,
|
||||
"save_to_file": true
|
||||
},
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"bot_token": "8499352620:AAGVxZ2krfHS219xGRb-1-yGthMs45GjNg4",
|
||||
"chat_id": "637130179",
|
||||
"daily_report_hour": 21,
|
||||
"trade_alerts": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,573 @@
|
||||
"""
|
||||
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
|
||||
+374
-17
@@ -2,11 +2,24 @@
|
||||
Safety Manager
|
||||
Circuit breakers, drawdown limits, and position size enforcement.
|
||||
Last line of defense before any trade executes.
|
||||
|
||||
ENHANCED with:
|
||||
- Drawdown-based position sizing (reduce exposure when losing)
|
||||
- Active position reduction on circuit breaker triggers
|
||||
- Correlation-based position limits (enforced properly)
|
||||
- Volatility-adjusted leverage
|
||||
- Maximum drawdown stop (reduces ALL positions when portfolio drops X%)
|
||||
- Daily loss limits that trigger position reduction
|
||||
- HARD maximum SHORT position cap (prevents 13 shorts scenario)
|
||||
- Yesterday-close-based drawdown check (faster response than peak-equity)
|
||||
- Per-position stop loss defaults (so stops always exist)
|
||||
- Broker-position sync check (detects store/broker discrepancies)
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, Tuple
|
||||
from typing import Dict, List, Tuple, Optional
|
||||
from loguru import logger
|
||||
import numpy as np
|
||||
|
||||
|
||||
class SafetyManager:
|
||||
@@ -17,16 +30,57 @@ class SafetyManager:
|
||||
self.store = store
|
||||
|
||||
# Limits from config
|
||||
self.max_position_pct = config.get('max_position_pct', 20) / 100
|
||||
self.max_concurrent_positions = config.get('max_concurrent_positions', 5)
|
||||
self.max_position_pct = config.get('max_position_pct', 10) / 100
|
||||
self.max_concurrent_positions = config.get('max_concurrent_positions', 8)
|
||||
self.max_daily_trades = config.get('max_daily_trades', 20)
|
||||
self.max_daily_loss_pct = config.get('max_daily_loss_pct', 5) / 100
|
||||
self.max_total_loss_pct = config.get('max_total_loss_pct', 15) / 100
|
||||
self.max_daily_loss_pct = config.get('max_daily_loss_pct', 2) / 100
|
||||
self.max_total_loss_pct = config.get('max_total_loss_pct', 10) / 100
|
||||
self.min_trade_value = config.get('min_trade_value', 5.0)
|
||||
self.initial_capital = config.get('initial_capital', 100.0)
|
||||
self.initial_capital = config.get('initial_capital', 200000.0)
|
||||
|
||||
# DEBUG: Log what we actually received
|
||||
logger.info(f"SafetyManager init: initial_capital={self.initial_capital}, config keys={list(config.keys())}")
|
||||
# NEW: Correlation limits
|
||||
self.max_correlated_positions = config.get('max_correlated_positions', 3)
|
||||
self.max_same_direction = config.get('max_same_direction', 4)
|
||||
|
||||
# NEW: HARD maximum SHORT position cap — prevents the 13-shorts scenario
|
||||
self.max_short_positions = config.get('max_short_positions', 4)
|
||||
# Separate hard cap for total positions (overrides broker if needed)
|
||||
self.max_total_positions = config.get('max_total_positions', 8)
|
||||
|
||||
# NEW: Per-position stop loss defaults (ATR-based, applied when GA doesn't provide)
|
||||
self.stop_loss_atr_multiplier = config.get('stop_loss_atr_multiplier', 2.0) # 2x ATR
|
||||
self.stop_loss_default_pct_long = config.get('stop_loss_default_pct_long', 1.0) / 100 # 1% for longs
|
||||
self.stop_loss_default_pct_short = config.get('stop_loss_default_pct_short', 1.5) / 100 # 1.5% for shorts
|
||||
self.stop_loss_max_pct = config.get('stop_loss_max_pct', 3.0) / 100 # Never wider than 3%
|
||||
self.take_profit_default_pct = config.get('take_profit_default_pct', 2.0) / 100 # 2% TP default
|
||||
|
||||
# NEW: Drawdown-based position sizing
|
||||
self.drawdown_position_scale = config.get('drawdown_position_scale', {
|
||||
"0.02": 1.0, # 0-2% drawdown: full size
|
||||
"0.05": 0.75, # 2-5% drawdown: 75% size
|
||||
"0.10": 0.50, # 5-10% drawdown: 50% size
|
||||
"0.15": 0.25, # 10-15% drawdown: 25% size
|
||||
"0.20": 0.10, # 15-20% drawdown: 10% size
|
||||
})
|
||||
|
||||
# NEW: Maximum drawdown stop - reduces ALL positions when triggered
|
||||
self.max_drawdown_stop_pct = config.get('max_drawdown_stop_pct', 0.08) # 8% drop triggers reduction
|
||||
self.max_drawdown_stop_reduction = config.get('max_drawdown_stop_reduction', 0.50) # close 50% of positions
|
||||
self.drawdown_stop_active = False
|
||||
self.drawdown_stop_triggered_at = None
|
||||
|
||||
# NEW: Yesterday-close-based drawdown (separate from peak-equity tracking)
|
||||
# This catches recent adverse moves faster than peak-based drawdown
|
||||
self.yesterday_close_drawdown_limit = config.get('yesterday_close_drawdown_limit', 0.03) # 3% from yesterday close
|
||||
self.yesterday_close_drawdown_reduction = config.get('yesterday_close_drawdown_reduction', 0.50) # close 50% of positions
|
||||
self.yesterday_equity = config.get('initial_capital', 200000.0) # Will be updated each cycle
|
||||
self.last_equity_update_date = None # Track when we last updated yesterday_equity
|
||||
|
||||
# NEW: Volatility-based leverage adjustment
|
||||
self.volatility_lookback = config.get('volatility_lookback', 20)
|
||||
self.volatility_max_multiplier = config.get('volatility_max_multiplier', 1.5)
|
||||
self.volatility_min_multiplier = config.get('volatility_min_multiplier', 0.5)
|
||||
self.current_volatility_mult = 1.0
|
||||
|
||||
# State tracking
|
||||
self.trading_halted = False
|
||||
@@ -36,19 +90,244 @@ class SafetyManager:
|
||||
self.daily_reset_time = datetime.utcnow().replace(hour=0, minute=0, second=0)
|
||||
self.peak_equity = self.initial_capital
|
||||
|
||||
# Track positions for correlation analysis
|
||||
self.position_directions: Dict[str, str] = {} # symbol -> direction
|
||||
self.position_sectors: Dict[str, str] = {} # symbol -> sector
|
||||
|
||||
# Sector mapping for stocks (simplified)
|
||||
self.sector_map = {
|
||||
'SPY': 'broad_market', 'QQQ': 'tech', 'IWM': 'small_cap', 'DIA': 'blue_chip',
|
||||
'AAPL': 'tech', 'MSFT': 'tech', 'GOOGL': 'tech', 'AMZN': 'tech', 'NVDA': 'tech', 'META': 'tech',
|
||||
'TSLA': 'auto/energy',
|
||||
'JPM': 'financials', 'BAC': 'financials', 'GS': 'financials', 'MS': 'financials',
|
||||
'JNJ': 'healthcare', 'UNH': 'healthcare',
|
||||
'WMT': 'consumer', 'PG': 'consumer',
|
||||
'XLE': 'energy', 'CVX': 'energy', 'XOM': 'energy',
|
||||
'LMT': 'defense', 'RTX': 'defense', 'NOC': 'defense', 'GD': 'defense',
|
||||
'USO': 'commodities', 'GLD': 'metals',
|
||||
}
|
||||
|
||||
logger.info(f"SafetyManager init: initial_capital=${self.initial_capital:,.0f}, "
|
||||
f"max_positions={self.max_concurrent_positions}, "
|
||||
f"max_correlated={self.max_correlated_positions}, "
|
||||
f"max_same_direction={self.max_same_direction}")
|
||||
|
||||
def get_position_scale(self, portfolio_value: float) -> float:
|
||||
"""
|
||||
Get position size multiplier based on current drawdown.
|
||||
As portfolio loses money, we reduce position sizes.
|
||||
"""
|
||||
if portfolio_value <= 0 or self.peak_equity <= 0:
|
||||
return 1.0
|
||||
|
||||
drawdown = (self.peak_equity - portfolio_value) / self.peak_equity
|
||||
|
||||
# Find the appropriate scale
|
||||
scale = 1.0
|
||||
thresholds = sorted([float(k) for k in self.drawdown_position_scale.keys()])
|
||||
|
||||
for threshold in thresholds:
|
||||
if drawdown >= threshold:
|
||||
scale = float(self.drawdown_position_scale[str(threshold)])
|
||||
|
||||
if drawdown > 0.20: # Beyond 20%, keep minimal
|
||||
scale = 0.05
|
||||
|
||||
return scale
|
||||
|
||||
def update_volatility(self, returns: List[float]) -> float:
|
||||
"""
|
||||
Update volatility multiplier based on recent returns.
|
||||
High volatility = reduce exposure, Low volatility = can increase slightly.
|
||||
"""
|
||||
if len(returns) < 5:
|
||||
return 1.0
|
||||
|
||||
recent = returns[-self.volatility_lookback:]
|
||||
vol = np.std(recent) * np.sqrt(252) # Annualized volatility
|
||||
|
||||
# Assume ~20% annual vol is "normal"
|
||||
normal_vol = 0.20
|
||||
vol_ratio = normal_vol / max(vol, 0.01)
|
||||
|
||||
# Clamp between min and max
|
||||
mult = np.clip(vol_ratio, self.volatility_min_multiplier, self.volatility_max_multiplier)
|
||||
self.current_volatility_mult = round(mult, 2)
|
||||
|
||||
return self.current_volatility_mult
|
||||
|
||||
def update_yesterday_equity(self, current_equity: float):
|
||||
"""
|
||||
Call once per trading day to update yesterday's equity reference.
|
||||
Should be called at the START of each trading day.
|
||||
"""
|
||||
today = datetime.utcnow().date()
|
||||
if self.last_equity_update_date != today:
|
||||
self.yesterday_equity = current_equity
|
||||
self.last_equity_update_date = today
|
||||
logger.info(f"Yesterday-equity updated to ${current_equity:,.2f} for drawdown checks")
|
||||
|
||||
def check_yesterday_close_drawdown(self, portfolio_value: float) -> Tuple[bool, str, float]:
|
||||
"""
|
||||
Check if portfolio has dropped more than yesterday_close_drawdown_limit from yesterday's close.
|
||||
This is a FASTER circuit breaker than the peak-equity based one.
|
||||
|
||||
Returns: (should_reduce: bool, reason: str, reduction_pct: float)
|
||||
"""
|
||||
if self.yesterday_equity <= 0 or portfolio_value <= 0:
|
||||
return False, "", 0.0
|
||||
|
||||
loss_pct = (self.yesterday_equity - portfolio_value) / self.yesterday_equity
|
||||
|
||||
if loss_pct >= self.yesterday_close_drawdown_limit:
|
||||
logger.warning(
|
||||
f"YESTERDAY-CLOSE DRAWDOWN: {loss_pct:.2%} loss from ${self.yesterday_equity:,.2f} "
|
||||
f"to ${portfolio_value:,.2f} (limit: {self.yesterday_close_drawdown_limit:.2%})"
|
||||
)
|
||||
return True, (
|
||||
f"Yesterday-close drawdown {loss_pct:.2%} exceeds limit {self.yesterday_close_drawdown_limit:.2%}"
|
||||
), self.yesterday_close_drawdown_reduction
|
||||
|
||||
return False, "", 0.0
|
||||
|
||||
def validate_broker_position_count(self, broker_positions: List[Dict],
|
||||
store_positions: List[Dict]) -> Tuple[bool, str]:
|
||||
"""
|
||||
Cross-check broker positions against store positions.
|
||||
If broker has more positions than store knows about, warn and block new trades.
|
||||
|
||||
This catches the scenario where 13 shorts exist in the broker but only
|
||||
a few are recorded in the store (e.g., positions opened outside the bot).
|
||||
|
||||
Returns: (is_safe: bool, reason: str)
|
||||
"""
|
||||
broker_short_count = sum(
|
||||
1 for p in broker_positions
|
||||
if p.get('side') == 'sell' or p.get('qty', 0) < 0
|
||||
)
|
||||
store_short_count = sum(
|
||||
1 for p in store_positions
|
||||
if p.get('metadata', {}).get('direction') == 'short'
|
||||
)
|
||||
|
||||
broker_symbols = {p.get('symbol') for p in broker_positions}
|
||||
store_symbols = {p.get('symbol') for p in store_positions}
|
||||
|
||||
# Log discrepancy for diagnostics
|
||||
missing_from_store = broker_symbols - store_symbols
|
||||
if missing_from_store:
|
||||
logger.warning(
|
||||
f"BROKER/STORE MISMATCH: Broker has positions in {missing_from_store} "
|
||||
f"that are NOT in store! Broker shorts={broker_short_count}, "
|
||||
f"Store shorts={store_short_count}"
|
||||
)
|
||||
|
||||
# HARD cap: if broker already has max_short_positions shorts, block more
|
||||
if broker_short_count >= self.max_short_positions:
|
||||
return False, (
|
||||
f"BROKER HARD CAP: {broker_short_count} shorts in broker "
|
||||
f"(max_short_positions={self.max_short_positions}). "
|
||||
f"Store only knows {store_short_count}. BLOCKING new shorts until count drops."
|
||||
)
|
||||
|
||||
# Hard cap on total positions
|
||||
if len(broker_symbols) >= self.max_total_positions:
|
||||
return False, (
|
||||
f"BROKER HARD CAP: {len(broker_symbols)} total positions in broker "
|
||||
f"(max_total_positions={self.max_total_positions}). BLOCKING new positions."
|
||||
)
|
||||
|
||||
return True, "OK"
|
||||
|
||||
def get_default_stop_loss(self, entry_price: float, direction: str,
|
||||
atr: float = None) -> float:
|
||||
"""
|
||||
Get a default stop loss for a position when the strategy doesn't provide one.
|
||||
Uses ATR if available, otherwise uses percentage of entry price.
|
||||
|
||||
Args:
|
||||
entry_price: The entry price of the position
|
||||
direction: 'long' or 'short'
|
||||
atr: Optional ATR value for the symbol
|
||||
|
||||
Returns:
|
||||
Stop loss price (below entry for longs, above entry for shorts)
|
||||
"""
|
||||
if direction == 'long':
|
||||
# Stop loss is BELOW entry price
|
||||
if atr and atr > 0:
|
||||
sl = entry_price - (atr * self.stop_loss_atr_multiplier)
|
||||
else:
|
||||
sl = entry_price * (1 - self.stop_loss_default_pct_long)
|
||||
# Never wider than max_pct
|
||||
max_sl_distance = entry_price * self.stop_loss_max_pct
|
||||
min_sl = entry_price - max_sl_distance
|
||||
return max(sl, min_sl)
|
||||
else:
|
||||
# Short: stop loss is ABOVE entry price
|
||||
if atr and atr > 0:
|
||||
sl = entry_price + (atr * self.stop_loss_atr_multiplier)
|
||||
else:
|
||||
sl = entry_price * (1 + self.stop_loss_default_pct_short)
|
||||
# Never wider than max_pct
|
||||
max_sl_distance = entry_price * self.stop_loss_max_pct
|
||||
max_sl = entry_price + max_sl_distance
|
||||
return min(sl, max_sl)
|
||||
|
||||
def get_default_take_profit(self, entry_price: float, direction: str) -> float:
|
||||
"""
|
||||
Get a default take profit for a position when the strategy doesn't provide one.
|
||||
"""
|
||||
if direction == 'long':
|
||||
return entry_price * (1 + self.take_profit_default_pct)
|
||||
else:
|
||||
return entry_price * (1 - self.take_profit_default_pct)
|
||||
|
||||
def is_trading_allowed(self) -> bool:
|
||||
"""Check if trading is currently allowed"""
|
||||
self._check_daily_reset()
|
||||
return not self.trading_halted
|
||||
|
||||
def check_drawdown_stop(self, portfolio_value: float, positions: List[Dict]) -> Tuple[bool, str, float]:
|
||||
"""
|
||||
Check if maximum drawdown stop is triggered.
|
||||
If portfolio dropped max_drawdown_stop_pct, return reduction signal.
|
||||
|
||||
Returns: (should_reduce: bool, reason: str, reduction_pct: float)
|
||||
"""
|
||||
if portfolio_value <= 0 or self.peak_equity <= 0:
|
||||
return False, "", 0.0
|
||||
|
||||
drawdown = (self.peak_equity - portfolio_value) / self.peak_equity
|
||||
|
||||
if drawdown >= self.max_drawdown_stop_pct and not self.drawdown_stop_active:
|
||||
self.drawdown_stop_active = True
|
||||
self.drawdown_stop_triggered_at = datetime.utcnow()
|
||||
logger.warning(f"DRAWDOWN STOP TRIGGERED: {drawdown:.2%} drawdown >= {self.max_drawdown_stop_pct:.2%}")
|
||||
return True, f"Max drawdown {drawdown:.2%} exceeds {self.max_drawdown_stop_pct:.2%}", self.max_drawdown_stop_reduction
|
||||
|
||||
# Auto-reset if recovered
|
||||
if drawdown < self.max_drawdown_stop_pct * 0.5 and self.drawdown_stop_active:
|
||||
self.drawdown_stop_active = False
|
||||
logger.info("Drawdown stop auto-reset (recovered below threshold)")
|
||||
|
||||
return False, "", 0.0
|
||||
|
||||
def validate_trade(self, symbol: str, side: str, amount: float,
|
||||
price: float, portfolio_value: float,
|
||||
open_positions: int = 0) -> Tuple[bool, str]:
|
||||
open_positions: List[Dict] = None,
|
||||
broker_positions: List[Dict] = None) -> Tuple[bool, str]:
|
||||
"""
|
||||
Validate a proposed trade against safety constraints.
|
||||
Returns: (allowed: bool, reason: str)
|
||||
|
||||
Args:
|
||||
broker_positions: Optional list of actual broker positions (authoritative).
|
||||
If provided, used for hard short cap checks.
|
||||
"""
|
||||
self._check_daily_reset()
|
||||
open_positions = open_positions or []
|
||||
broker_positions = broker_positions or []
|
||||
|
||||
# Circuit breaker
|
||||
if self.trading_halted:
|
||||
@@ -60,17 +339,74 @@ class SafetyManager:
|
||||
if trade_value < self.min_trade_value:
|
||||
return False, f"Trade value ${trade_value:.2f} below minimum ${self.min_trade_value}"
|
||||
|
||||
# Max position size
|
||||
# Max position size (with drawdown scaling)
|
||||
if portfolio_value > 0:
|
||||
position_pct = trade_value / portfolio_value
|
||||
if position_pct > self.max_position_pct:
|
||||
effective_max = self.max_position_pct * self.get_position_scale(portfolio_value)
|
||||
if position_pct > effective_max:
|
||||
return False, (f"Position {position_pct:.1%} exceeds max "
|
||||
f"{self.max_position_pct:.1%}")
|
||||
f"{effective_max:.1%} (drawdown scale)")
|
||||
|
||||
# Max concurrent positions (for buys only)
|
||||
if side == 'buy' and open_positions >= self.max_concurrent_positions:
|
||||
# HARD SHORT CAP: Count shorts from broker positions (authoritative)
|
||||
# This prevents the "13 shorts" scenario even when store is out of sync
|
||||
direction = 'short' if side == 'sell' else 'long'
|
||||
if broker_positions:
|
||||
# Use broker positions as source of truth
|
||||
broker_short_count = sum(
|
||||
1 for p in broker_positions
|
||||
if p.get('side') == 'sell' or p.get('qty', 0) < 0
|
||||
or (p.get('symbol') and p.get('market_value', 0) < 0)
|
||||
)
|
||||
if direction == 'short' and broker_short_count >= self.max_short_positions:
|
||||
return False, (
|
||||
f"HARD CAP: {broker_short_count} shorts already open in broker "
|
||||
f"(max_short_positions={self.max_short_positions}). Blocked."
|
||||
)
|
||||
# Total position hard cap
|
||||
broker_symbols = len(set(p.get('symbol') for p in broker_positions))
|
||||
if broker_symbols >= self.max_total_positions:
|
||||
return False, (
|
||||
f"HARD CAP: {broker_symbols} positions already open in broker "
|
||||
f"(max_total_positions={self.max_total_positions}). Blocked."
|
||||
)
|
||||
else:
|
||||
# Fallback to store positions
|
||||
unique_symbols = set(p.get('symbol') for p in open_positions)
|
||||
if len(unique_symbols) >= self.max_concurrent_positions:
|
||||
return False, f"Max {self.max_concurrent_positions} concurrent positions reached"
|
||||
|
||||
# HARD SHORT CAP using store positions
|
||||
same_direction_count = sum(
|
||||
1 for p in open_positions
|
||||
if p.get('metadata', {}).get('direction') == direction
|
||||
)
|
||||
if direction == 'short' and same_direction_count >= self.max_short_positions:
|
||||
return False, (
|
||||
f"HARD CAP: {same_direction_count} shorts already open "
|
||||
f"(max_short_positions={self.max_short_positions}). Blocked."
|
||||
)
|
||||
|
||||
# CORRELATION CHECK: Count positions in same direction
|
||||
same_direction_count = sum(
|
||||
1 for p in open_positions
|
||||
if p.get('metadata', {}).get('direction') == direction
|
||||
)
|
||||
if same_direction_count >= self.max_same_direction:
|
||||
return False, (f"Max {self.max_same_direction} {direction} positions "
|
||||
f"reached (have {same_direction_count})")
|
||||
|
||||
# CORRELATION CHECK: Sector correlation (stocks only)
|
||||
sector = self.sector_map.get(symbol, 'other')
|
||||
if sector != 'other' and sector != 'broad_market':
|
||||
same_sector_positions = [
|
||||
p for p in open_positions
|
||||
if self.sector_map.get(p.get('symbol', '')) == sector
|
||||
and p.get('metadata', {}).get('direction') == direction
|
||||
]
|
||||
if len(same_sector_positions) >= self.max_correlated_positions:
|
||||
return False, (f"Max {self.max_correlated_positions} {direction} "
|
||||
f"positions in {sector} sector (have {len(same_sector_positions)})")
|
||||
|
||||
# Max daily trades
|
||||
if self.daily_trades >= self.max_daily_trades:
|
||||
return False, f"Max {self.max_daily_trades} daily trades reached"
|
||||
@@ -95,6 +431,15 @@ class SafetyManager:
|
||||
|
||||
return True, "Valid"
|
||||
|
||||
def get_effective_position_size(self, base_value: float, portfolio_value: float) -> float:
|
||||
"""
|
||||
Get the effective position size after applying:
|
||||
1. Drawdown-based scaling
|
||||
2. Volatility adjustment
|
||||
"""
|
||||
drawdown_scale = self.get_position_scale(portfolio_value)
|
||||
return base_value * drawdown_scale * self.current_volatility_mult
|
||||
|
||||
def record_trade_result(self, pnl: float):
|
||||
"""Update daily P&L tracking after a trade closes"""
|
||||
self.daily_pnl += pnl
|
||||
@@ -129,6 +474,7 @@ class SafetyManager:
|
||||
"""Resume trading"""
|
||||
self.trading_halted = False
|
||||
self.halt_reason = ""
|
||||
self.drawdown_stop_active = False
|
||||
logger.info("Circuit breaker reset - trading resumed")
|
||||
|
||||
def _check_daily_reset(self):
|
||||
@@ -139,10 +485,10 @@ class SafetyManager:
|
||||
self.daily_trades = 0
|
||||
self.daily_reset_time = now.replace(hour=0, minute=0, second=0)
|
||||
|
||||
# Auto-reset circuit breaker on new day (if triggered by daily limit)
|
||||
if self.trading_halted and 'Daily' in self.halt_reason:
|
||||
# Auto-reset circuit breaker on new day
|
||||
if self.trading_halted:
|
||||
self.reset_circuit_breaker()
|
||||
logger.info("Daily circuit breaker auto-reset on new trading day")
|
||||
logger.info("Circuit breaker auto-reset on new trading day")
|
||||
|
||||
def get_status(self) -> Dict:
|
||||
"""Get safety manager status"""
|
||||
@@ -152,7 +498,18 @@ class SafetyManager:
|
||||
'daily_pnl': round(self.daily_pnl, 2),
|
||||
'daily_trades': self.daily_trades,
|
||||
'peak_equity': round(self.peak_equity, 2),
|
||||
'yesterday_equity': round(self.yesterday_equity, 2),
|
||||
'max_position_pct': self.max_position_pct,
|
||||
'max_daily_loss_pct': self.max_daily_loss_pct,
|
||||
'max_total_loss_pct': self.max_total_loss_pct,
|
||||
'drawdown_stop_active': self.drawdown_stop_active,
|
||||
'volatility_mult': self.current_volatility_mult,
|
||||
'max_concurrent_positions': self.max_concurrent_positions,
|
||||
'max_correlated_positions': self.max_correlated_positions,
|
||||
'max_same_direction': self.max_same_direction,
|
||||
'max_short_positions': self.max_short_positions,
|
||||
'max_total_positions': self.max_total_positions,
|
||||
'yesterday_close_drawdown_limit': self.yesterday_close_drawdown_limit,
|
||||
'stop_loss_default_pct_long': self.stop_loss_default_pct_long,
|
||||
'stop_loss_default_pct_short': self.stop_loss_default_pct_short,
|
||||
}
|
||||
|
||||
@@ -114,6 +114,18 @@ class DataStore:
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS trade_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trade_id INTEGER NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
amount REAL,
|
||||
price REAL,
|
||||
pnl REAL,
|
||||
fees REAL,
|
||||
event_time TEXT NOT NULL,
|
||||
FOREIGN KEY (trade_id) REFERENCES trades(id)
|
||||
);
|
||||
""")
|
||||
c.commit()
|
||||
logger.info(f"Database initialized at {self.db_path}")
|
||||
@@ -262,6 +274,53 @@ class DataStore:
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
def reduce_position(self, trade_id: int, reduce_amount: float,
|
||||
exit_price: float, exit_time: datetime, fees: float = 0):
|
||||
"""
|
||||
Partially close a position by reducing amount.
|
||||
The original trade remains open with reduced amount.
|
||||
Records a partial P&L entry.
|
||||
"""
|
||||
trade = self.conn.execute(
|
||||
"SELECT * FROM trades WHERE id=?", (trade_id,)
|
||||
).fetchone()
|
||||
|
||||
if not trade:
|
||||
return
|
||||
|
||||
trade = dict(trade)
|
||||
|
||||
# Calculate P&L for the reduced portion
|
||||
if trade['side'] == 'buy':
|
||||
pnl = (exit_price - trade['entry_price']) * reduce_amount - fees
|
||||
else:
|
||||
pnl = (trade['entry_price'] - exit_price) * reduce_amount - fees
|
||||
|
||||
remaining_amount = abs(trade['amount']) - reduce_amount
|
||||
|
||||
if remaining_amount <= 0:
|
||||
# Fully close
|
||||
self.close_position(trade_id, exit_price, exit_time, fees)
|
||||
return
|
||||
|
||||
# Update original trade with reduced amount
|
||||
self.conn.execute(
|
||||
"""UPDATE trades SET amount=? WHERE id=?""",
|
||||
(remaining_amount if trade['side'] == 'buy' else -remaining_amount, trade_id)
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
# Record the partial close in a separate table or as a trade event
|
||||
# For now, just record it in trade_events
|
||||
self.conn.execute(
|
||||
"""INSERT INTO trade_events
|
||||
(trade_id, event_type, amount, price, pnl, fees, event_time)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(trade_id, 'partial_close', reduce_amount, exit_price,
|
||||
round(pnl, 4), fees, exit_time.isoformat())
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
# --- Strategy performance ---
|
||||
|
||||
def record_strategy_result(self, strategy_id: str, params: Dict, metrics: Dict):
|
||||
|
||||
+258
-8
@@ -17,6 +17,57 @@ from datetime import datetime, timedelta
|
||||
from loguru import logger
|
||||
import numpy as np
|
||||
|
||||
|
||||
def detect_market_regime(df, lookback: int = 20) -> dict:
|
||||
"""
|
||||
Detect current market regime from recent price data.
|
||||
Returns dict with:
|
||||
'regime': 'trending_up', 'trending_down', 'ranging', 'volatile'
|
||||
'adx': average directional index (trend strength)
|
||||
'volatility_ratio': recent vol vs average vol
|
||||
"""
|
||||
if df is None or len(df) < lookback + 10:
|
||||
return {'regime': 'unknown', 'adx': 0, 'volatility_ratio': 1.0}
|
||||
|
||||
close = df['close'].values[-lookback:]
|
||||
high = df['high'].values[-lookback:]
|
||||
low = df['low'].values[-lookback:]
|
||||
|
||||
# Simple trend: linear regression slope on close prices
|
||||
x = np.arange(len(close))
|
||||
slope = np.polyfit(x, close, 1)[0] if len(close) > 1 else 0
|
||||
slope_pct = slope / np.mean(close) * 100 if np.mean(close) > 0 else 0
|
||||
|
||||
# Volatility: ATR-like measure
|
||||
tr_list = []
|
||||
for i in range(1, len(close)):
|
||||
tr = max(high[i] - low[i], abs(high[i] - close[i-1]), abs(low[i] - close[i-1]))
|
||||
tr_list.append(tr)
|
||||
recent_vol = np.mean(tr_list) if tr_list else 0
|
||||
avg_vol = np.std(close) if len(close) > 1 else 0
|
||||
vol_ratio = recent_vol / avg_vol if avg_vol > 0 else 1.0
|
||||
|
||||
# Range quality: how much does price oscillate vs trending?
|
||||
high_low_range = (np.max(high) - np.min(low)) / np.mean(close) if np.mean(close) > 0 else 0
|
||||
|
||||
# Classify regime
|
||||
if abs(slope_pct) > 0.05: # Significant directional slope
|
||||
if slope_pct > 0:
|
||||
regime = 'trending_up'
|
||||
else:
|
||||
regime = 'trending_down'
|
||||
elif vol_ratio > 2.0:
|
||||
regime = 'volatile'
|
||||
else:
|
||||
regime = 'ranging'
|
||||
|
||||
return {
|
||||
'regime': regime,
|
||||
'slope_pct': round(slope_pct, 4),
|
||||
'volatility_ratio': round(vol_ratio, 2),
|
||||
'trend_strength': round(abs(slope_pct) * 20, 1), # normalized 0-10
|
||||
}
|
||||
|
||||
# Setup logging
|
||||
log_path = Path(__file__).parent.parent / "logs"
|
||||
log_path.mkdir(exist_ok=True)
|
||||
@@ -43,6 +94,7 @@ from ml.genetic import GeneticEvolver, StrategyGenome
|
||||
from strategies.auto_strategy import genome_to_strategy, evaluate_genome_signal
|
||||
from trading.executor import TradingExecutor
|
||||
from core.safety import SafetyManager
|
||||
from core.regime_detector import RegimeDetector, MarketRegime
|
||||
from reporting.telegram_reporter import TelegramReporter
|
||||
from reporting.krystie_bridge import KrystieBridge
|
||||
|
||||
@@ -110,6 +162,12 @@ class BiggFishAuto:
|
||||
self.config['ga'], self.backtest_engine, self.store
|
||||
)
|
||||
|
||||
# Market Regime Detector (shared across all symbols)
|
||||
regime_config = self.config.get('regime', {})
|
||||
self.regime_detector = RegimeDetector(regime_config)
|
||||
self.symbol_regimes: Dict[str, Dict] = {} # Per-symbol regime cache
|
||||
self.last_regime_log: datetime = datetime.min
|
||||
|
||||
# Telegram daily reporter
|
||||
tg_config = self.config.get('telegram', {})
|
||||
if tg_config.get('enabled') and tg_config.get('bot_token') and tg_config.get('chat_id'):
|
||||
@@ -281,11 +339,14 @@ class BiggFishAuto:
|
||||
return self.executor
|
||||
|
||||
def _trading_cycle(self):
|
||||
"""Run one trading cycle across all markets"""
|
||||
"""Run one trading cycle across all markets with regime-aware position management"""
|
||||
self.cycle_count += 1
|
||||
all_symbols = self._get_tradeable_symbols()
|
||||
|
||||
# Check exits first
|
||||
# ── STEP 1: Detect market regime for each symbol ─────────────────────────
|
||||
self._detect_regimes(all_symbols)
|
||||
|
||||
# ── STEP 2: Check exits (normal stop/take-profit exits) ─────────────────
|
||||
try:
|
||||
current_prices = {}
|
||||
for symbol in all_symbols:
|
||||
@@ -294,7 +355,6 @@ class BiggFishAuto:
|
||||
if price:
|
||||
current_prices[symbol] = price
|
||||
|
||||
# Check exits per executor
|
||||
stock_prices = {s: p for s, p in current_prices.items() if not self._is_forex(s)}
|
||||
forex_prices = {s: p for s, p in current_prices.items() if self._is_forex(s)}
|
||||
|
||||
@@ -316,12 +376,37 @@ class BiggFishAuto:
|
||||
except Exception as e:
|
||||
logger.error(f"Exit check error: {e}")
|
||||
|
||||
# Check safety
|
||||
# ── STEP 3: Regime-aware forced exits ───────────────────────────────────
|
||||
# Force-close positions that are fighting the regime
|
||||
self._regime_force_close(all_symbols, current_prices)
|
||||
|
||||
# ── STEP 4: Check safety ────────────────────────────────────────────────
|
||||
if not self.safety.is_trading_allowed():
|
||||
logger.warning(f"Trading halted: {self.safety.halt_reason}")
|
||||
return
|
||||
|
||||
# Evaluate each symbol
|
||||
# ── STEP 4b: BROKER POSITION SYNC CHECK ─────────────────────────────────
|
||||
# Validate that store and broker agree on positions.
|
||||
# If broker has more positions than store (e.g., manual trades), block new trades.
|
||||
try:
|
||||
broker_pos = self.broker.get_positions()
|
||||
store_pos = self.store.get_open_positions()
|
||||
broker_ok, broker_msg = self.safety.validate_broker_position_count(
|
||||
broker_pos, store_pos
|
||||
)
|
||||
if not broker_ok:
|
||||
logger.warning(f"BROKER POSITION BLOCK: {broker_msg}")
|
||||
return # Block all new trades until positions are reduced
|
||||
except Exception as e:
|
||||
logger.error(f"Broker position sync check failed: {e}")
|
||||
|
||||
# ── STEP 5: Log regime status periodically ──────────────────────────────
|
||||
now = datetime.utcnow()
|
||||
if (now - self.last_regime_log).total_seconds() > 300: # Every 5 min
|
||||
self._log_regime_status(all_symbols)
|
||||
self.last_regime_log = now
|
||||
|
||||
# ── STEP 6: Evaluate each symbol ─────────────────────────────────────────
|
||||
for symbol in all_symbols:
|
||||
try:
|
||||
self._trade_symbol(symbol)
|
||||
@@ -331,6 +416,145 @@ class BiggFishAuto:
|
||||
# Keep only last 20 trades
|
||||
self.recent_trades = self.recent_trades[-20:]
|
||||
|
||||
def _detect_regimes(self, symbols: list):
|
||||
"""Detect market regime for each symbol using 1h data (more reliable for regime)."""
|
||||
for symbol in symbols:
|
||||
df = self.candle_cache.get_cached(
|
||||
symbol, '1h',
|
||||
start=datetime.utcnow() - timedelta(days=30)
|
||||
)
|
||||
if df is None or len(df) < 100:
|
||||
# Fall back to 5m
|
||||
df = self.candle_cache.get_cached(
|
||||
symbol, '5m',
|
||||
start=datetime.utcnow() - timedelta(days=7)
|
||||
)
|
||||
if df is None or len(df) < 60:
|
||||
self.symbol_regimes[symbol] = {
|
||||
'regime': MarketRegime.UNKNOWN,
|
||||
'confidence': 0.0,
|
||||
'trend_strength': 0.0,
|
||||
'volatility_ratio': 1.0,
|
||||
'bb_position': 0.5,
|
||||
'regime_change': False,
|
||||
'early_warning': False,
|
||||
'warning_message': None,
|
||||
'indicators': {},
|
||||
'adaptive_params': self.regime_detector.get_adaptive_params(MarketRegime.UNKNOWN),
|
||||
}
|
||||
continue
|
||||
|
||||
regime_data = self.regime_detector.detect_regime(df, symbol)
|
||||
adaptive = self.regime_detector.get_adaptive_params(regime_data['regime'])
|
||||
regime_data['adaptive_params'] = adaptive
|
||||
self.symbol_regimes[symbol] = regime_data
|
||||
|
||||
# Emit early warning to logs
|
||||
if regime_data.get('early_warning'):
|
||||
logger.warning(
|
||||
f"⚠️ REGIME WARNING [{symbol}]: {regime_data.get('warning_message', '')} "
|
||||
f"| Regime: {regime_data['regime']} | ADX: {regime_data['trend_strength']:.1f}"
|
||||
)
|
||||
|
||||
# Emit regime change to logs
|
||||
if regime_data.get('regime_change'):
|
||||
logger.warning(
|
||||
f"🔄 REGIME SHIFT [{symbol}]: {regime_data['regime_change_type']} "
|
||||
f"| {adaptive['regime_label']}"
|
||||
)
|
||||
|
||||
def _regime_force_close(self, symbols: list, current_prices: dict):
|
||||
"""
|
||||
Force-close positions that are counter to the current regime.
|
||||
E.g., in a strong uptrend, close shorts. In ranging, close all directional.
|
||||
"""
|
||||
try:
|
||||
open_positions = self.store.get_open_positions()
|
||||
if not open_positions:
|
||||
return
|
||||
|
||||
for position in open_positions:
|
||||
symbol = position['symbol']
|
||||
if symbol not in symbols:
|
||||
continue
|
||||
|
||||
regime_info = self.symbol_regimes.get(symbol, {})
|
||||
regime = regime_info.get('regime', MarketRegime.UNKNOWN)
|
||||
params = regime_info.get('adaptive_params', {})
|
||||
is_short = position.get('metadata', {}).get('direction') == 'short'
|
||||
reduction = params.get('short_position_reduction', 0.0)
|
||||
price = current_prices.get(symbol)
|
||||
|
||||
if not price:
|
||||
continue
|
||||
|
||||
force_close = False
|
||||
close_reason = ''
|
||||
|
||||
if reduction >= 1.0 and is_short:
|
||||
# Ranging or strong uptrend: close all shorts
|
||||
force_close = True
|
||||
close_reason = f"regime_{regime}_close_short"
|
||||
elif reduction >= 0.5 and reduction < 1.0 and is_short:
|
||||
# Moderate regime conflict: close 50%
|
||||
force_close = True
|
||||
close_reason = f"regime_{regime}_reduce_short"
|
||||
|
||||
if force_close:
|
||||
try:
|
||||
close_side = 'buy' # Buy to cover short
|
||||
close_amount = position['amount']
|
||||
if reduction < 1.0:
|
||||
close_amount = round(position['amount'] * reduction, 4)
|
||||
|
||||
self.broker.place_market_order(symbol, close_amount, close_side)
|
||||
pnl = (position['entry_price'] - price) * close_amount
|
||||
self.store.close_position(
|
||||
position['id'], price, datetime.utcnow(),
|
||||
fees=close_amount * price * self.executor.commission_rate
|
||||
)
|
||||
self.safety.record_trade_result(pnl)
|
||||
logger.warning(
|
||||
f"🚨 REGIME FORCE-CLOSE [{symbol}]: {close_reason} "
|
||||
f"| {close_amount} @ ${price:.4f} | P&L: ${pnl:+.2f}"
|
||||
)
|
||||
self.recent_trades.append({
|
||||
'symbol': symbol,
|
||||
'side': close_side,
|
||||
'amount': close_amount,
|
||||
'exit_price': price,
|
||||
'pnl': round(pnl, 2),
|
||||
'exit_reason': close_reason,
|
||||
'direction': 'short',
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Regime force-close error for {symbol}: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Regime force-close error: {e}")
|
||||
|
||||
def _log_regime_status(self, symbols: list):
|
||||
"""Log current regime status for all symbols (called every 5 min)."""
|
||||
lines = ["[REGIME STATUS]"]
|
||||
for symbol in symbols[:6]: # Limit to 6 symbols for dashboard
|
||||
info = self.symbol_regimes.get(symbol, {})
|
||||
regime = info.get('regime', MarketRegime.UNKNOWN)
|
||||
params = info.get('adaptive_params', {})
|
||||
adx = info.get('trend_strength', 0)
|
||||
vol = info.get('volatility_ratio', 1.0)
|
||||
short_red = params.get('short_position_reduction', 0.0)
|
||||
pos_mult = params.get('position_size_multiplier', 1.0)
|
||||
emoji = '📈' if regime == MarketRegime.TRENDING_UP else (
|
||||
'📉' if regime == MarketRegime.TRENDING_DOWN else (
|
||||
'🔄' if regime == MarketRegime.RANGING else (
|
||||
'💥' if regime == MarketRegime.VOLATILE else '❓')))
|
||||
lines.append(
|
||||
f" {emoji} {symbol}: {regime[:12]:12s} | "
|
||||
f"ADX={adx:4.1f} | Vol={vol:.1f}x | "
|
||||
f"Size={pos_mult:.0%} | ShortReduce={short_red:.0%}"
|
||||
)
|
||||
logger.info("\n".join(lines))
|
||||
|
||||
def _get_tradeable_symbols(self) -> list:
|
||||
"""Get symbols that can be traded right now"""
|
||||
stock_symbols = self.config['trading'].get('symbols', [])
|
||||
@@ -374,6 +598,18 @@ class BiggFishAuto:
|
||||
# Get market state
|
||||
state = self.feature_engine.get_state_vector(features_df, -1)
|
||||
|
||||
# Detect market regime for adaptive decision-making
|
||||
regime = detect_market_regime(df, lookback=20)
|
||||
regime_signal_mult = 1.0
|
||||
# In ranging markets, be more conservative with short signals
|
||||
# In trending markets, follow the trend more aggressively
|
||||
if regime['regime'] == 'ranging':
|
||||
regime_signal_mult = 0.7 # Reduce confidence in ranging
|
||||
elif regime['regime'] == 'trending_down':
|
||||
regime_signal_mult = 1.2 # Boost short confidence in downtrend
|
||||
elif regime['regime'] == 'trending_up':
|
||||
regime_signal_mult = 1.2 # Boost long confidence in uptrend
|
||||
|
||||
# Get GA signal
|
||||
best_genome = self.ga_evolver.get_best_genome()
|
||||
ga_signal = {'signal': 'hold', 'confidence': 0.0}
|
||||
@@ -383,6 +619,9 @@ class BiggFishAuto:
|
||||
raw_df = self.feature_engine.compute(df)
|
||||
if raw_df is not None and len(raw_df) > 0:
|
||||
ga_signal = evaluate_genome_signal(best_genome, raw_df, len(raw_df) - 1)
|
||||
# Scale confidence by regime
|
||||
base_conf = ga_signal.get('confidence', 0.0)
|
||||
regime_adjusted_conf = min(base_conf * regime_signal_mult, 1.0)
|
||||
strategy_params = {
|
||||
'stop_loss': ga_signal.get('stop_loss'),
|
||||
'take_profit': ga_signal.get('take_profit'),
|
||||
@@ -392,17 +631,23 @@ class BiggFishAuto:
|
||||
'strategy_id': f"ga_gen{best_genome.generation}",
|
||||
}
|
||||
|
||||
# Augment state with GA signal + portfolio
|
||||
# Augment state with GA signal + portfolio + regime
|
||||
portfolio_state = executor.get_portfolio_state()
|
||||
signal_dir = 1.0 if ga_signal['signal'] == 'buy' else (
|
||||
-1.0 if ga_signal['signal'] == 'sell' else 0.0)
|
||||
|
||||
# Encode regime as numeric (for RL state)
|
||||
regime_code = {'trending_up': 1.0, 'trending_down': -1.0,
|
||||
'ranging': 0.0, 'volatile': 0.5,
|
||||
'unknown': 0.0}.get(regime['regime'], 0.0)
|
||||
|
||||
augmented_state = np.concatenate([
|
||||
state,
|
||||
[portfolio_state.get('position_ratio', 0),
|
||||
portfolio_state.get('unrealized_pnl', 0),
|
||||
portfolio_state.get('time_in_position', 0)],
|
||||
[signal_dir, ga_signal.get('confidence', 0.0)],
|
||||
[signal_dir, ga_signal.get('confidence', 0.0) * regime_signal_mult],
|
||||
[regime_code, regime.get('trend_strength', 0) / 10.0],
|
||||
])
|
||||
|
||||
# RL agent decides (now with 7 actions including shorts)
|
||||
@@ -416,9 +661,14 @@ class BiggFishAuto:
|
||||
if not current_price:
|
||||
return
|
||||
|
||||
# Get combined equity across all brokers for safety checks
|
||||
combined = self._get_combined_portfolio()
|
||||
combined_equity = combined['equity']
|
||||
|
||||
# Execute
|
||||
trade = executor.execute_signal(
|
||||
symbol, action, current_price, strategy_params
|
||||
symbol, action, current_price, strategy_params,
|
||||
combined_equity=combined_equity
|
||||
)
|
||||
|
||||
if trade:
|
||||
|
||||
@@ -0,0 +1,904 @@
|
||||
"""
|
||||
BIGGFISH Autonomous Self-Learning Trading Bot
|
||||
24/7 entry point - runs forever, learns continuously, trades autonomously.
|
||||
|
||||
Usage:
|
||||
python -m src.main_auto
|
||||
python -m src.main_auto --config config/auto_config.json
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import threading
|
||||
import signal as signal_module
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
from loguru import logger
|
||||
import numpy as np
|
||||
|
||||
# Setup logging
|
||||
log_path = Path(__file__).parent.parent / "logs"
|
||||
log_path.mkdir(exist_ok=True)
|
||||
logger.add(
|
||||
log_path / "biggfish_auto_{time}.log",
|
||||
rotation="1 day",
|
||||
retention="30 days",
|
||||
level="INFO"
|
||||
)
|
||||
|
||||
from trading.broker import AlpacaBroker
|
||||
from data.store import DataStore
|
||||
from data.candle_cache import CandleCache
|
||||
try:
|
||||
from trading.oanda_broker import OandaBroker
|
||||
except ImportError:
|
||||
OandaBroker = None
|
||||
from data.features import FeatureEngine
|
||||
from backtest.engine import BacktestEngine
|
||||
from backtest.metrics import compute_metrics
|
||||
from ml.rl_agent import RLAgent
|
||||
from ml.rl_environment import TradingEnvironment
|
||||
from ml.genetic import GeneticEvolver, StrategyGenome
|
||||
from strategies.auto_strategy import genome_to_strategy, evaluate_genome_signal
|
||||
from trading.executor import TradingExecutor
|
||||
from core.safety import SafetyManager
|
||||
from reporting.telegram_reporter import TelegramReporter
|
||||
from reporting.krystie_bridge import KrystieBridge
|
||||
|
||||
|
||||
class BiggFishAuto:
|
||||
"""
|
||||
24/7 Autonomous self-learning trading bot.
|
||||
Coordinates trading, backtesting, RL training, and GA evolution.
|
||||
"""
|
||||
|
||||
def __init__(self, config_path: str = "config/auto_config.json"):
|
||||
logger.info("Initializing BIGGFISH Autonomous Trader...")
|
||||
|
||||
# Load config
|
||||
with open(config_path) as f:
|
||||
self.config = json.load(f)
|
||||
|
||||
# Initialize components
|
||||
self.store = DataStore(self.config['database']['path'])
|
||||
self.store.initialize()
|
||||
|
||||
self.broker = AlpacaBroker(self.config['alpaca'])
|
||||
self.feature_engine = FeatureEngine()
|
||||
|
||||
# OANDA broker for forex (optional)
|
||||
self.oanda_broker = None
|
||||
oanda_config = self.config.get('oanda')
|
||||
if oanda_config and oanda_config.get('api_token') and OandaBroker:
|
||||
try:
|
||||
self.oanda_broker = OandaBroker(oanda_config)
|
||||
logger.info("OANDA forex broker connected")
|
||||
except Exception as e:
|
||||
logger.warning(f"OANDA connection failed (forex disabled): {e}")
|
||||
|
||||
self.candle_cache = CandleCache(self.store, oanda_broker=self.oanda_broker)
|
||||
|
||||
self.backtest_engine = BacktestEngine(
|
||||
initial_capital=self.config['backtest']['initial_capital'],
|
||||
commission_rate=self.config['trading'].get('commission_rate', 0.001)
|
||||
)
|
||||
|
||||
self.safety = SafetyManager(self.config['safety'], self.store)
|
||||
|
||||
# Executors per broker
|
||||
self.executor = TradingExecutor(
|
||||
self.broker, self.store, self.safety, self.config['trading']
|
||||
)
|
||||
self.forex_executor = None
|
||||
if self.oanda_broker:
|
||||
self.forex_executor = TradingExecutor(
|
||||
self.oanda_broker, self.store, self.safety, self.config['trading']
|
||||
)
|
||||
|
||||
# RL Agent
|
||||
env_temp = TradingEnvironment(self.feature_engine,
|
||||
initial_capital=self.config['trading']['initial_capital'])
|
||||
self.rl_agent = RLAgent(
|
||||
state_dim=env_temp.state_dim,
|
||||
action_dim=TradingEnvironment.NUM_ACTIONS,
|
||||
config=self.config['rl']
|
||||
)
|
||||
|
||||
# GA Evolver
|
||||
self.ga_evolver = GeneticEvolver(
|
||||
self.config['ga'], self.backtest_engine, self.store
|
||||
)
|
||||
|
||||
# Telegram daily reporter
|
||||
tg_config = self.config.get('telegram', {})
|
||||
if tg_config.get('enabled') and tg_config.get('bot_token') and tg_config.get('chat_id'):
|
||||
self.telegram = TelegramReporter(tg_config['bot_token'], tg_config['chat_id'])
|
||||
logger.info("Telegram reporting enabled")
|
||||
else:
|
||||
self.telegram = None
|
||||
logger.info("Telegram reporting disabled (no config)")
|
||||
|
||||
# Krystie bridge (writes status/events to JSON for Krystie to read)
|
||||
self.krystie = KrystieBridge(self.config['database']['path'].rsplit('/', 1)[0] or 'data')
|
||||
|
||||
# State
|
||||
self.running = False
|
||||
self.start_time = None
|
||||
self.cycle_count = 0
|
||||
self.last_backtest = datetime.min
|
||||
self.last_rl_train = datetime.min
|
||||
self.last_ga_evolve = datetime.min
|
||||
self.last_dashboard = datetime.min
|
||||
self.last_daily_report = datetime.min
|
||||
self.recent_trades = [] # Last 10 trades for dashboard
|
||||
self.backtest_results = {} # symbol -> {'sharpe': x, 'win_rate': y}
|
||||
|
||||
logger.info("BIGGFISH Autonomous Trader initialized")
|
||||
|
||||
def start(self):
|
||||
"""Start the autonomous trading system"""
|
||||
self.running = True
|
||||
self.start_time = datetime.utcnow()
|
||||
|
||||
# Register shutdown handler
|
||||
signal_module.signal(signal_module.SIGINT, self._signal_handler)
|
||||
|
||||
logger.info("=" * 60)
|
||||
logger.info(" BIGGFISH AUTONOMOUS TRADER STARTING")
|
||||
logger.info("=" * 60)
|
||||
|
||||
# 1. Load saved state
|
||||
self._load_state()
|
||||
|
||||
self.krystie.log_startup()
|
||||
|
||||
# 1.5. Initialize peak equity from current portfolio value
|
||||
try:
|
||||
portfolio = self.broker.get_portfolio()
|
||||
current_equity = portfolio.get('portfolio_value', self.config['safety']['initial_capital'])
|
||||
self.safety.peak_equity = max(current_equity, self.safety.peak_equity)
|
||||
logger.info(f"Initialized peak equity: ${self.safety.peak_equity:,.2f}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not initialize peak equity: {e}")
|
||||
|
||||
# 2. Warm candle cache
|
||||
self._warm_cache()
|
||||
|
||||
# 3. Initial GA population
|
||||
self.ga_evolver.initialize_population()
|
||||
if not self.ga_evolver.population:
|
||||
logger.info("Running initial GA evolution...")
|
||||
self._run_ga_evolution()
|
||||
|
||||
# 4. Initial RL training if no checkpoint
|
||||
if self.rl_agent.steps == 0:
|
||||
logger.info("Running initial RL training...")
|
||||
self._run_rl_training()
|
||||
|
||||
# 5. Main loop
|
||||
logger.info("Entering main loop...")
|
||||
self._print_dashboard()
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
cycle_start = datetime.utcnow()
|
||||
|
||||
# Trading cycle (run if any market is open)
|
||||
any_market_open = self.broker.is_market_open()
|
||||
if self.oanda_broker:
|
||||
any_market_open = any_market_open or self.oanda_broker.is_market_open()
|
||||
|
||||
if any_market_open:
|
||||
self._trading_cycle()
|
||||
else:
|
||||
logger.debug("All markets closed - running learning tasks")
|
||||
|
||||
# Periodic tasks (run regardless of market hours)
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Cache update (every 5 min)
|
||||
cache_interval = self.config['cache']['update_interval_seconds']
|
||||
if (now - self.last_backtest).total_seconds() > cache_interval:
|
||||
self._update_cache()
|
||||
|
||||
# Backtest (every 30 min)
|
||||
bt_interval = self.config['backtest']['interval_seconds']
|
||||
if (now - self.last_backtest).total_seconds() > bt_interval:
|
||||
self._run_backtest()
|
||||
self.last_backtest = now
|
||||
|
||||
# RL training (every 2 hours)
|
||||
rl_interval = self.config['rl']['train_interval_hours'] * 3600
|
||||
if (now - self.last_rl_train).total_seconds() > rl_interval:
|
||||
self._run_rl_training()
|
||||
self.last_rl_train = now
|
||||
|
||||
# GA evolution (every 6 hours)
|
||||
ga_interval = self.config['ga']['evolution_interval_hours'] * 3600
|
||||
if (now - self.last_ga_evolve).total_seconds() > ga_interval:
|
||||
self._run_ga_evolution()
|
||||
self.last_ga_evolve = now
|
||||
|
||||
# Daily Telegram report (once per day, around market close ~21:00 UTC)
|
||||
if self.telegram and self._should_send_daily_report(now):
|
||||
self._send_daily_telegram_report()
|
||||
self.last_daily_report = now
|
||||
|
||||
# Dashboard (every minute)
|
||||
dash_interval = self.config['reporting']['dashboard_interval_seconds']
|
||||
if (now - self.last_dashboard).total_seconds() > dash_interval:
|
||||
self._print_dashboard()
|
||||
self.last_dashboard = now
|
||||
|
||||
# Sleep until next cycle
|
||||
elapsed = (datetime.utcnow() - cycle_start).total_seconds()
|
||||
sleep_time = max(1, self.config['trading']['cycle_interval_seconds'] - elapsed)
|
||||
time.sleep(sleep_time)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
self._shutdown()
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Main loop error: {e}", exc_info=True)
|
||||
time.sleep(30)
|
||||
|
||||
def _is_forex(self, symbol: str) -> bool:
|
||||
"""Check if symbol is a forex pair (OANDA format: XXX_YYY)"""
|
||||
return '_' in symbol and len(symbol) == 7
|
||||
|
||||
def _get_broker(self, symbol: str):
|
||||
"""Get the appropriate broker for a symbol"""
|
||||
if self._is_forex(symbol) and self.oanda_broker:
|
||||
return self.oanda_broker
|
||||
return self.broker
|
||||
|
||||
def _get_executor(self, symbol: str):
|
||||
"""Get the appropriate executor for a symbol"""
|
||||
if self._is_forex(symbol) and self.forex_executor:
|
||||
return self.forex_executor
|
||||
return self.executor
|
||||
|
||||
def _trading_cycle(self):
|
||||
"""Run one trading cycle across all markets"""
|
||||
self.cycle_count += 1
|
||||
all_symbols = self._get_tradeable_symbols()
|
||||
|
||||
# Check exits first
|
||||
try:
|
||||
current_prices = {}
|
||||
for symbol in all_symbols:
|
||||
broker = self._get_broker(symbol)
|
||||
price = broker.get_latest_price(symbol)
|
||||
if price:
|
||||
current_prices[symbol] = price
|
||||
|
||||
# Check exits per executor
|
||||
stock_prices = {s: p for s, p in current_prices.items() if not self._is_forex(s)}
|
||||
forex_prices = {s: p for s, p in current_prices.items() if self._is_forex(s)}
|
||||
|
||||
if stock_prices:
|
||||
closed = self.executor.check_exits(stock_prices)
|
||||
for trade in closed:
|
||||
logger.info(f"CLOSED {trade['symbol']}: ${trade['pnl']:+.2f} "
|
||||
f"({trade['pnl_pct']:+.1f}%) [{trade['exit_reason']}]")
|
||||
self.recent_trades.append(trade)
|
||||
self.krystie.log_trade(trade)
|
||||
|
||||
if forex_prices and self.forex_executor:
|
||||
closed = self.forex_executor.check_exits(forex_prices)
|
||||
for trade in closed:
|
||||
logger.info(f"CLOSED {trade['symbol']}: ${trade['pnl']:+.2f} "
|
||||
f"({trade['pnl_pct']:+.1f}%) [{trade['exit_reason']}]")
|
||||
self.recent_trades.append(trade)
|
||||
self.krystie.log_trade(trade)
|
||||
except Exception as e:
|
||||
logger.error(f"Exit check error: {e}")
|
||||
|
||||
# Get combined portfolio equity for safety checks
|
||||
try:
|
||||
alpaca_portfolio = self.broker.get_portfolio()
|
||||
combined_equity = alpaca_portfolio['equity']
|
||||
if self.oanda_broker:
|
||||
oanda_portfolio = self.oanda_broker.get_portfolio()
|
||||
combined_equity += oanda_portfolio['equity']
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting combined portfolio: {e}")
|
||||
combined_equity = None
|
||||
|
||||
# Check safety
|
||||
if not self.safety.is_trading_allowed():
|
||||
logger.warning(f"Trading halted: {self.safety.halt_reason}")
|
||||
return
|
||||
|
||||
# Evaluate each symbol
|
||||
for symbol in all_symbols:
|
||||
try:
|
||||
self._trade_symbol(symbol, combined_equity)
|
||||
except Exception as e:
|
||||
logger.debug(f"Error trading {symbol}: {e}")
|
||||
|
||||
# Keep only last 20 trades
|
||||
self.recent_trades = self.recent_trades[-20:]
|
||||
|
||||
def _get_tradeable_symbols(self) -> list:
|
||||
"""Get symbols that can be traded right now"""
|
||||
stock_symbols = self.config['trading'].get('symbols', [])
|
||||
forex_symbols = self.config['trading'].get('forex_symbols', [])
|
||||
|
||||
tradeable = []
|
||||
|
||||
# Stocks: only if Alpaca market is open
|
||||
if self.broker.is_market_open():
|
||||
tradeable.extend(stock_symbols)
|
||||
|
||||
# Forex: if OANDA is connected and forex market is open (24/5)
|
||||
if self.oanda_broker and self.oanda_broker.is_market_open():
|
||||
tradeable.extend(forex_symbols)
|
||||
|
||||
return tradeable
|
||||
|
||||
def _trade_symbol(self, symbol: str, combined_equity: float = None):
|
||||
"""Evaluate and potentially trade a single symbol (scalping mode)"""
|
||||
# Filter by backtest Sharpe ratio
|
||||
min_sharpe = self.config['trading'].get('min_backtest_sharpe', 0.5)
|
||||
if symbol in self.backtest_results:
|
||||
if self.backtest_results[symbol]['sharpe'] < min_sharpe:
|
||||
logger.debug(f"Skipping {symbol}: Sharpe {self.backtest_results[symbol]['sharpe']:.2f} < {min_sharpe}")
|
||||
return
|
||||
|
||||
broker = self._get_broker(symbol)
|
||||
executor = self._get_executor(symbol)
|
||||
|
||||
# Use 5m candles for scalping, fall back to 1h
|
||||
df = self.candle_cache.get_cached(
|
||||
symbol, '5m',
|
||||
start=datetime.utcnow() - timedelta(days=14)
|
||||
)
|
||||
if df is None or len(df) < 60:
|
||||
df = self.candle_cache.get_cached(
|
||||
symbol, '1h',
|
||||
start=datetime.utcnow() - timedelta(days=14)
|
||||
)
|
||||
if df is None or len(df) < 60:
|
||||
return
|
||||
|
||||
# Compute features
|
||||
features_df = self.feature_engine.compute_and_normalize(df)
|
||||
if features_df is None or len(features_df) < 10:
|
||||
return
|
||||
|
||||
# Get market state
|
||||
state = self.feature_engine.get_state_vector(features_df, -1)
|
||||
|
||||
# Get GA signal
|
||||
best_genome = self.ga_evolver.get_best_genome()
|
||||
ga_signal = {'signal': 'hold', 'confidence': 0.0}
|
||||
strategy_params = {}
|
||||
|
||||
if best_genome:
|
||||
raw_df = self.feature_engine.compute(df)
|
||||
if raw_df is not None and len(raw_df) > 0:
|
||||
ga_signal = evaluate_genome_signal(best_genome, raw_df, len(raw_df) - 1)
|
||||
|
||||
# Use config defaults if GA doesn't provide stop-loss/take-profit
|
||||
sl_pct = self.config['trading'].get('stop_loss_pct', 2.5) / 100
|
||||
tp_pct = self.config['trading'].get('take_profit_pct', 5.0) / 100
|
||||
|
||||
# CRITICAL FIX: Convert percentages to absolute prices
|
||||
# Stop-loss BELOW current price for longs, ABOVE for shorts
|
||||
# Take-profit ABOVE current price for longs, BELOW for shorts
|
||||
current_price = broker.get_latest_price(symbol) or 0
|
||||
|
||||
sl_price = current_price * (1 - sl_pct) if current_price > 0 else None
|
||||
tp_price = current_price * (1 + tp_pct) if current_price > 0 else None
|
||||
short_sl_price = current_price * (1 + sl_pct) if current_price > 0 else None # ABOVE for shorts
|
||||
short_tp_price = current_price * (1 - tp_pct) if current_price > 0 else None # BELOW for shorts
|
||||
|
||||
strategy_params = {
|
||||
'stop_loss': sl_price,
|
||||
'take_profit': tp_price,
|
||||
'short_stop_loss': short_sl_price,
|
||||
'short_take_profit': short_tp_price,
|
||||
'position_pct': ga_signal.get('position_pct', 0.1),
|
||||
'strategy_id': f"ga_gen{best_genome.generation}",
|
||||
}
|
||||
|
||||
# Augment state with GA signal + portfolio
|
||||
portfolio_state = executor.get_portfolio_state()
|
||||
signal_dir = 1.0 if ga_signal['signal'] == 'buy' else (
|
||||
-1.0 if ga_signal['signal'] == 'sell' else 0.0)
|
||||
|
||||
augmented_state = np.concatenate([
|
||||
state,
|
||||
[portfolio_state.get('position_ratio', 0),
|
||||
portfolio_state.get('unrealized_pnl', 0),
|
||||
portfolio_state.get('time_in_position', 0)],
|
||||
[signal_dir, ga_signal.get('confidence', 0.0)],
|
||||
])
|
||||
|
||||
# RL agent decides (now with 7 actions including shorts)
|
||||
action = self.rl_agent.select_action(augmented_state, live_mode=True)
|
||||
|
||||
if action == 0: # Hold
|
||||
return
|
||||
|
||||
# Get current price
|
||||
current_price = broker.get_latest_price(symbol)
|
||||
if not current_price:
|
||||
return
|
||||
|
||||
# Execute (pass combined_equity for multi-broker safety checks)
|
||||
trade = executor.execute_signal(
|
||||
symbol, action, current_price, strategy_params, combined_equity
|
||||
)
|
||||
|
||||
if trade:
|
||||
action_name = TradingEnvironment.ACTION_NAMES[action]
|
||||
logger.info(f"TRADE: {action_name} {symbol} @ ${current_price:.4f}")
|
||||
self.recent_trades.append(trade)
|
||||
self.krystie.log_trade(trade)
|
||||
|
||||
def _all_symbols(self) -> list:
|
||||
"""Get all configured symbols (stocks + forex)"""
|
||||
symbols = list(self.config['trading'].get('symbols', []))
|
||||
symbols.extend(self.config['trading'].get('forex_symbols', []))
|
||||
return symbols
|
||||
|
||||
def _warm_cache(self):
|
||||
"""Warm the candle cache"""
|
||||
logger.info("Warming candle cache...")
|
||||
symbols = self._all_symbols()
|
||||
timeframes = self.config['cache'].get('timeframes', ['1h'])
|
||||
lookback = self.config['cache'].get('warmup_lookback_days', 90)
|
||||
|
||||
self.candle_cache.warm_cache(symbols, timeframes, lookback)
|
||||
logger.info("Cache warmup complete")
|
||||
|
||||
def _update_cache(self):
|
||||
"""Update candle cache incrementally"""
|
||||
symbols = self._all_symbols()
|
||||
timeframes = self.config['cache'].get('timeframes', ['1h'])
|
||||
self.candle_cache.update_cache(symbols, timeframes)
|
||||
|
||||
def _run_backtest(self):
|
||||
"""Run background backtest of current strategy (scalping timeframe)"""
|
||||
best_genome = self.ga_evolver.get_best_genome()
|
||||
if not best_genome:
|
||||
return
|
||||
|
||||
strategy_fn = genome_to_strategy(best_genome)
|
||||
symbols = self._all_symbols()[:5] # Top 5 for breadth
|
||||
|
||||
lookback = self.config['backtest'].get('lookback_days', 14)
|
||||
|
||||
for symbol in symbols:
|
||||
# Prefer 5m data for scalping backtest
|
||||
df = self.candle_cache.get_cached(
|
||||
symbol, '5m',
|
||||
start=datetime.utcnow() - timedelta(days=lookback)
|
||||
)
|
||||
if df is None or len(df) < 60:
|
||||
df = self.candle_cache.get_cached(
|
||||
symbol, '1h',
|
||||
start=datetime.utcnow() - timedelta(days=lookback)
|
||||
)
|
||||
if df is None or len(df) < 60:
|
||||
continue
|
||||
|
||||
featured_df = self.feature_engine.compute(df)
|
||||
if featured_df is None or len(featured_df) < 50:
|
||||
continue
|
||||
|
||||
result = self.backtest_engine.run(
|
||||
strategy_fn, featured_df,
|
||||
params=best_genome.to_dict(), symbol=symbol
|
||||
)
|
||||
|
||||
if result.metrics.get('total_trades', 0) > 0:
|
||||
self.store.record_strategy_result(
|
||||
strategy_id=f"ga_gen{best_genome.generation}",
|
||||
params=best_genome.to_dict(),
|
||||
metrics=result.metrics
|
||||
)
|
||||
# Store backtest result for filtering
|
||||
self.backtest_results[symbol] = {
|
||||
'sharpe': result.metrics['sharpe_ratio'],
|
||||
'win_rate': result.metrics['win_rate'],
|
||||
'total_return': result.metrics['total_return'],
|
||||
}
|
||||
logger.info(f"Backtest {symbol}: Sharpe={result.metrics['sharpe_ratio']:.2f} "
|
||||
f"WR={result.metrics['win_rate']:.0f}% "
|
||||
f"Return={result.metrics['total_return']:.1f}%")
|
||||
|
||||
def _run_rl_training(self):
|
||||
"""Run RL batch training"""
|
||||
logger.info("Starting RL training...")
|
||||
env = TradingEnvironment(
|
||||
self.feature_engine,
|
||||
initial_capital=self.config['trading']['initial_capital']
|
||||
)
|
||||
|
||||
total_metrics = {'avg_loss': 0, 'episodes': 0}
|
||||
|
||||
for symbol in self._all_symbols()[:5]:
|
||||
# Prefer 5m data for RL training (more scalping episodes)
|
||||
df = self.candle_cache.get_cached(
|
||||
symbol, '5m',
|
||||
start=datetime.utcnow() - timedelta(days=14)
|
||||
)
|
||||
if df is None or len(df) < 100:
|
||||
df = self.candle_cache.get_cached(
|
||||
symbol, '1h',
|
||||
start=datetime.utcnow() - timedelta(days=14)
|
||||
)
|
||||
if df is None or len(df) < 100:
|
||||
continue
|
||||
|
||||
# Create GA signal function for this data
|
||||
best_genome = self.ga_evolver.get_best_genome()
|
||||
ga_fn = None
|
||||
if best_genome:
|
||||
featured = self.feature_engine.compute(df)
|
||||
if featured is not None and len(featured) > 0:
|
||||
def make_ga_fn(genome, feat_df):
|
||||
def fn(step):
|
||||
if step < len(feat_df):
|
||||
return evaluate_genome_signal(genome, feat_df, step)
|
||||
return {'signal': 'hold', 'confidence': 0.0}
|
||||
return fn
|
||||
ga_fn = make_ga_fn(best_genome, featured)
|
||||
|
||||
metrics = self.rl_agent.train_on_episode(env, df, ga_signal_fn=ga_fn)
|
||||
total_metrics['avg_loss'] += metrics.get('avg_loss', 0)
|
||||
total_metrics['episodes'] += 1
|
||||
|
||||
# Save checkpoint
|
||||
self.rl_agent.save(self.store)
|
||||
|
||||
if total_metrics['episodes'] > 0:
|
||||
avg = total_metrics['avg_loss'] / total_metrics['episodes']
|
||||
logger.info(f"RL training complete: {total_metrics['episodes']} episodes, "
|
||||
f"avg_loss={avg:.6f}, epsilon={self.rl_agent.epsilon:.4f}")
|
||||
|
||||
def _run_ga_evolution(self):
|
||||
"""Run GA evolution cycle"""
|
||||
logger.info("Starting GA evolution...")
|
||||
|
||||
# Prepare candle data for fitness evaluation
|
||||
candles_data = {}
|
||||
symbols = self._all_symbols()[:5]
|
||||
|
||||
for symbol in symbols:
|
||||
df = self.candle_cache.get_cached(
|
||||
symbol, '5m',
|
||||
start=datetime.utcnow() - timedelta(days=14)
|
||||
)
|
||||
if df is None or len(df) < 60:
|
||||
df = self.candle_cache.get_cached(
|
||||
symbol, '1h',
|
||||
start=datetime.utcnow() - timedelta(days=14)
|
||||
)
|
||||
if df is not None and len(df) > 60:
|
||||
featured = self.feature_engine.compute(df)
|
||||
if featured is not None and len(featured) > 50:
|
||||
candles_data[symbol] = featured
|
||||
|
||||
if not candles_data:
|
||||
logger.warning("No data available for GA evolution")
|
||||
return
|
||||
|
||||
# Strategy function factory
|
||||
def strategy_fn_factory(genome):
|
||||
return genome_to_strategy(genome)
|
||||
|
||||
# Run evolution
|
||||
num_gens = self.config['ga'].get('generations_per_cycle', 10)
|
||||
best = self.ga_evolver.run_evolution_cycle(
|
||||
strategy_fn_factory, candles_data, num_generations=num_gens
|
||||
)
|
||||
|
||||
if best:
|
||||
logger.info(f"GA evolution complete: Gen {self.ga_evolver.generation}, "
|
||||
f"best fitness={best.fitness:.4f}")
|
||||
self.krystie.log_ga_milestone(self.ga_evolver.generation, best.fitness)
|
||||
|
||||
def _should_send_daily_report(self, now: datetime) -> bool:
|
||||
"""Check if it's time to send the daily report (once per day after 21:00 UTC)"""
|
||||
report_hour = self.config.get('telegram', {}).get('daily_report_hour', 21)
|
||||
if now.hour >= report_hour and (now - self.last_daily_report).total_seconds() > 20 * 3600:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _send_daily_telegram_report(self):
|
||||
"""Gather data and send the daily Telegram report"""
|
||||
try:
|
||||
alpaca_portfolio = self.broker.get_portfolio()
|
||||
positions = self.broker.get_positions()
|
||||
|
||||
# Combine Alpaca + OANDA portfolios (same logic as dashboard)
|
||||
if self.oanda_broker:
|
||||
try:
|
||||
oanda_portfolio = self.oanda_broker.get_portfolio()
|
||||
oanda_positions = self.oanda_broker.get_positions()
|
||||
positions.extend(oanda_positions)
|
||||
|
||||
combined_equity = alpaca_portfolio['equity'] + oanda_portfolio['equity']
|
||||
combined_day_pnl = alpaca_portfolio['day_pnl'] + oanda_portfolio['day_pnl']
|
||||
prev_equity = combined_equity - combined_day_pnl
|
||||
|
||||
portfolio = {
|
||||
'equity': combined_equity,
|
||||
'cash': alpaca_portfolio['cash'] + oanda_portfolio['cash'],
|
||||
'buying_power': alpaca_portfolio['buying_power'] + oanda_portfolio['buying_power'],
|
||||
'portfolio_value': alpaca_portfolio['portfolio_value'] + oanda_portfolio['portfolio_value'],
|
||||
'long_market_value': alpaca_portfolio['long_market_value'] + oanda_portfolio['long_market_value'],
|
||||
'day_pnl': combined_day_pnl,
|
||||
'day_pnl_pct': (combined_day_pnl / prev_equity * 100) if prev_equity > 0 else 0,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"OANDA portfolio fetch failed, using Alpaca only: {e}")
|
||||
portfolio = alpaca_portfolio
|
||||
else:
|
||||
portfolio = alpaca_portfolio
|
||||
|
||||
# Get today's trades from DB
|
||||
today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
today_trades = self.store.get_trades(start=today_start, limit=50)
|
||||
|
||||
# Learning stats
|
||||
rl_stats = self.rl_agent.get_stats()
|
||||
best_genome = self.ga_evolver.get_best_genome()
|
||||
learning_stats = {
|
||||
'rl': rl_stats,
|
||||
'ga': {
|
||||
'generation': self.ga_evolver.generation,
|
||||
'best_fitness': best_genome.fitness if best_genome else 0,
|
||||
},
|
||||
}
|
||||
|
||||
self.telegram.send_daily_report(
|
||||
portfolio, positions, today_trades, learning_stats,
|
||||
self.config['trading']
|
||||
)
|
||||
self.krystie.log_daily_report(
|
||||
equity=portfolio.get('equity', 0),
|
||||
day_pnl=portfolio.get('day_pnl', 0),
|
||||
trades_count=len(today_trades),
|
||||
)
|
||||
logger.info("Daily Telegram report sent")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send daily Telegram report: {e}")
|
||||
|
||||
def _print_dashboard(self):
|
||||
"""Print live console dashboard"""
|
||||
try:
|
||||
# Get Alpaca portfolio
|
||||
alpaca_portfolio = self.broker.get_portfolio()
|
||||
alpaca_positions = self.broker.get_positions()
|
||||
|
||||
# Get OANDA portfolio if available
|
||||
if self.oanda_broker:
|
||||
oanda_portfolio = self.oanda_broker.get_portfolio()
|
||||
oanda_positions = self.oanda_broker.get_positions()
|
||||
|
||||
# Combine portfolios
|
||||
combined_equity = alpaca_portfolio['equity'] + oanda_portfolio['equity']
|
||||
combined_day_pnl = alpaca_portfolio['day_pnl'] + oanda_portfolio['day_pnl']
|
||||
prev_equity = combined_equity - combined_day_pnl
|
||||
|
||||
portfolio = {
|
||||
'equity': combined_equity,
|
||||
'cash': alpaca_portfolio['cash'] + oanda_portfolio['cash'],
|
||||
'buying_power': alpaca_portfolio['buying_power'] + oanda_portfolio['buying_power'],
|
||||
'portfolio_value': alpaca_portfolio['portfolio_value'] + oanda_portfolio['portfolio_value'],
|
||||
'long_market_value': alpaca_portfolio['long_market_value'] + oanda_portfolio['long_market_value'],
|
||||
'day_pnl': combined_day_pnl,
|
||||
'day_pnl_pct': (combined_day_pnl / prev_equity * 100) if prev_equity > 0 else 0,
|
||||
}
|
||||
# Combine positions
|
||||
positions = alpaca_positions + oanda_positions
|
||||
else:
|
||||
portfolio = alpaca_portfolio
|
||||
positions = alpaca_positions
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Dashboard error: {e}")
|
||||
return
|
||||
|
||||
equity = portfolio['equity']
|
||||
target = self.config['trading']['target_capital']
|
||||
|
||||
# Calculate actual initial capital (both brokers start with $100k each in paper trading)
|
||||
if self.oanda_broker:
|
||||
initial = 200000 # $100k Alpaca + $100k OANDA
|
||||
else:
|
||||
initial = 100000 # $100k Alpaca only
|
||||
|
||||
progress = (equity / target) * 100 if target > 0 else 0
|
||||
total_pnl = equity - initial
|
||||
total_pnl_pct = (total_pnl / initial) * 100 if initial > 0 else 0
|
||||
|
||||
uptime = datetime.utcnow() - self.start_time if self.start_time else timedelta()
|
||||
hours = int(uptime.total_seconds() // 3600)
|
||||
minutes = int((uptime.total_seconds() % 3600) // 60)
|
||||
|
||||
best_genome = self.ga_evolver.get_best_genome()
|
||||
gen = self.ga_evolver.generation
|
||||
best_fit = best_genome.fitness if best_genome else 0
|
||||
|
||||
rl_stats = self.rl_agent.get_stats()
|
||||
safety_status = self.safety.get_status()
|
||||
|
||||
stock_status = "OPEN" if self.broker.is_market_open() else "CLOSED"
|
||||
forex_status = ""
|
||||
if self.oanda_broker:
|
||||
forex_status = " | FX: " + ("OPEN" if self.oanda_broker.is_market_open() else "CLOSED")
|
||||
|
||||
# Build dashboard
|
||||
lines = []
|
||||
lines.append("")
|
||||
lines.append("=" * 64)
|
||||
lines.append(f" BIGGFISH AUTONOMOUS TRADER | {hours}h {minutes}m | "
|
||||
f"Gen {gen} | Stocks: {stock_status}{forex_status}")
|
||||
lines.append("=" * 64)
|
||||
|
||||
# Progress bar
|
||||
bar_width = 30
|
||||
filled = int(bar_width * min(progress, 100) / 100)
|
||||
bar = "#" * filled + "-" * (bar_width - filled)
|
||||
lines.append(f" Portfolio: ${equity:.2f} / ${target} [{bar}] {progress:.1f}%")
|
||||
lines.append(f" Day P&L: ${portfolio['day_pnl']:+.2f} ({portfolio['day_pnl_pct']:+.1f}%)")
|
||||
lines.append(f" Total P&L: ${total_pnl:+.2f} ({total_pnl_pct:+.1f}%)")
|
||||
|
||||
# Positions
|
||||
lines.append("-" * 64)
|
||||
if positions:
|
||||
lines.append(f" Active Positions ({len(positions)}):")
|
||||
for p in positions[:5]:
|
||||
pl_str = f"${p['unrealized_pl']:+.2f} ({p['unrealized_plpc']:+.1f}%)"
|
||||
lines.append(f" {p['symbol']:6s} {p['qty']:.0f} @ ${p['avg_entry_price']:.2f}"
|
||||
f" -> ${p['current_price']:.2f} {pl_str}")
|
||||
else:
|
||||
lines.append(" No active positions")
|
||||
|
||||
# Learning status
|
||||
lines.append("-" * 64)
|
||||
lines.append(" Learning Status:")
|
||||
lines.append(f" RL Agent: epsilon={rl_stats['epsilon']:.3f} | "
|
||||
f"loss={rl_stats['avg_loss']:.6f} | "
|
||||
f"{rl_stats['memory_size']:,} experiences")
|
||||
lines.append(f" GA: gen {gen} | best fitness={best_fit:.4f}")
|
||||
|
||||
# Recent trades
|
||||
if self.recent_trades:
|
||||
lines.append("-" * 64)
|
||||
lines.append(" Recent Trades:")
|
||||
for t in self.recent_trades[-5:]:
|
||||
side = t.get('side', '?').upper()
|
||||
symbol = t.get('symbol', '?')
|
||||
pnl = t.get('pnl')
|
||||
if pnl is not None:
|
||||
pnl_str = f" P&L: ${pnl:+.2f}"
|
||||
else:
|
||||
pnl_str = ""
|
||||
price = t.get('entry_price') or t.get('exit_price', 0)
|
||||
lines.append(f" {side:4s} {symbol:6s} "
|
||||
f"{t.get('amount', 0):.1f} @ ${price:.2f}{pnl_str}")
|
||||
|
||||
# Safety
|
||||
if not safety_status['trading_allowed']:
|
||||
lines.append("-" * 64)
|
||||
lines.append(f" !! TRADING HALTED: {safety_status['halt_reason']}")
|
||||
|
||||
# Next events
|
||||
lines.append("-" * 64)
|
||||
now = datetime.utcnow()
|
||||
bt_next = max(0, self.config['backtest']['interval_seconds'] -
|
||||
(now - self.last_backtest).total_seconds())
|
||||
rl_next = max(0, self.config['rl']['train_interval_hours'] * 3600 -
|
||||
(now - self.last_rl_train).total_seconds())
|
||||
ga_next = max(0, self.config['ga']['evolution_interval_hours'] * 3600 -
|
||||
(now - self.last_ga_evolve).total_seconds())
|
||||
|
||||
lines.append(f" Next: backtest {bt_next/60:.0f}m | "
|
||||
f"RL train {rl_next/60:.0f}m | "
|
||||
f"GA evolve {ga_next/3600:.1f}h")
|
||||
lines.append("=" * 64)
|
||||
|
||||
print("\n".join(lines))
|
||||
|
||||
# Update Krystie status file
|
||||
try:
|
||||
markets = {"stocks": stock_status}
|
||||
if self.oanda_broker:
|
||||
markets["forex"] = "OPEN" if self.oanda_broker.is_market_open() else "CLOSED"
|
||||
|
||||
learning_stats = {
|
||||
'rl': rl_stats,
|
||||
'ga': {'generation': gen, 'best_fitness': best_fit},
|
||||
}
|
||||
|
||||
today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
today_trades = self.store.get_trades(start=today_start, limit=50)
|
||||
|
||||
self.krystie.update_status(
|
||||
portfolio=portfolio,
|
||||
positions=positions,
|
||||
learning_stats=learning_stats,
|
||||
markets=markets,
|
||||
config=self.config['trading'],
|
||||
uptime_seconds=uptime.total_seconds(),
|
||||
today_trades=today_trades,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Krystie status update error: {e}")
|
||||
|
||||
def _load_state(self):
|
||||
"""Load saved state from database"""
|
||||
# Load RL model
|
||||
if self.rl_agent.load(self.store):
|
||||
logger.info("Loaded RL model from checkpoint")
|
||||
|
||||
# Load timing state
|
||||
last_bt = self.store.load_state('last_backtest')
|
||||
if last_bt:
|
||||
self.last_backtest = datetime.fromisoformat(last_bt)
|
||||
|
||||
last_rl = self.store.load_state('last_rl_train')
|
||||
if last_rl:
|
||||
self.last_rl_train = datetime.fromisoformat(last_rl)
|
||||
|
||||
last_ga = self.store.load_state('last_ga_evolve')
|
||||
if last_ga:
|
||||
self.last_ga_evolve = datetime.fromisoformat(last_ga)
|
||||
|
||||
last_report = self.store.load_state('last_daily_report')
|
||||
if last_report:
|
||||
self.last_daily_report = datetime.fromisoformat(last_report)
|
||||
|
||||
logger.info("State loaded from database")
|
||||
|
||||
def _save_state(self):
|
||||
"""Save state to database for recovery"""
|
||||
self.rl_agent.save(self.store)
|
||||
self.store.save_state('last_backtest', self.last_backtest.isoformat())
|
||||
self.store.save_state('last_rl_train', self.last_rl_train.isoformat())
|
||||
self.store.save_state('last_ga_evolve', self.last_ga_evolve.isoformat())
|
||||
self.store.save_state('last_daily_report', self.last_daily_report.isoformat())
|
||||
self.store.save_state('last_shutdown', datetime.utcnow().isoformat())
|
||||
logger.info("State saved to database")
|
||||
|
||||
def _signal_handler(self, signum, frame):
|
||||
"""Handle SIGINT for graceful shutdown"""
|
||||
self._shutdown()
|
||||
|
||||
def _shutdown(self):
|
||||
"""Graceful shutdown"""
|
||||
logger.info("Shutting down BIGGFISH...")
|
||||
self.running = False
|
||||
self.krystie.log_shutdown()
|
||||
self._save_state()
|
||||
self.store.close()
|
||||
logger.info("Shutdown complete. State saved. Resume anytime.")
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description="BIGGFISH Autonomous Trader")
|
||||
parser.add_argument('--config', default='config/auto_config.json',
|
||||
help='Path to configuration file')
|
||||
args = parser.parse_args()
|
||||
|
||||
config_path = Path(args.config)
|
||||
if not config_path.exists():
|
||||
print(f"Config file not found: {config_path}")
|
||||
print("Copy config/auto_config.json and fill in your Alpaca API keys")
|
||||
sys.exit(1)
|
||||
|
||||
bot = BiggFishAuto(str(config_path))
|
||||
bot.start()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
+135
-11
@@ -3,11 +3,17 @@ Genetic Algorithm for Strategy Parameter Optimization
|
||||
Evolves a population of StrategyGenome instances to find profitable trading parameters.
|
||||
|
||||
Optimized: uses vectorized backtesting and parallel genome evaluation.
|
||||
Enhanced with:
|
||||
- Recent drawdown protection (last 20% of trades weighted more heavily)
|
||||
- Correlation penalty (diversification bonus)
|
||||
- Calmar/Sortino-enhanced Sharpe ratio
|
||||
- Market regime detection (trending vs ranging) for adaptive fitness
|
||||
"""
|
||||
|
||||
import random
|
||||
import math
|
||||
import json
|
||||
import numpy as np
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
@@ -137,34 +143,109 @@ def _evaluate_genome_worker(genome_dict: Dict, candles_dict: Dict[str, Dict],
|
||||
|
||||
m = result.metrics
|
||||
sharpe = max(m.get('sharpe_ratio', 0), 0)
|
||||
sortino = max(m.get('sortino_ratio', 0), 0)
|
||||
max_dd = m.get('max_drawdown', 0)
|
||||
max_dd_dur = m.get('max_drawdown_duration', 0)
|
||||
total_trades = m.get('total_trades', 0)
|
||||
win_rate = m.get('win_rate', 0) / 100.0 # 0-1
|
||||
total_return = m.get('total_return', 0) / 100.0 # Convert % to decimal
|
||||
trades = result.trades
|
||||
|
||||
# CRITICAL FIX: Profit/return MUST be the primary fitness component
|
||||
# Without this, GA evolves "good metrics" that lose money!
|
||||
# ─── 1. RECENT DRAWDOWN PROTECTION ─────────────────────────────────────
|
||||
# Weight last 20% of trades 3x more than early trades (recent performance matters)
|
||||
recent_weight = 0.0
|
||||
if trades and len(trades) >= 5:
|
||||
n = len(trades)
|
||||
cutoff = int(n * 0.8)
|
||||
recent_trades = trades[cutoff:]
|
||||
early_trades = trades[:cutoff]
|
||||
|
||||
# Return component (most important): exponential reward for profit, penalty for loss
|
||||
early_pnl = sum(t.get('pnl', 0) for t in early_trades)
|
||||
recent_pnl = sum(t.get('pnl', 0) for t in recent_trades)
|
||||
|
||||
# If recent performance is negative while early was positive → severe penalty
|
||||
if early_pnl > 0 and recent_pnl < 0:
|
||||
# Struggling in recent market conditions
|
||||
recent_weight = -abs(recent_pnl) * 5.0
|
||||
elif recent_pnl < 0:
|
||||
recent_weight = -abs(recent_pnl) * 3.0
|
||||
elif recent_pnl > 0:
|
||||
recent_weight = recent_pnl * 2.0 # Bonus for profitable recent trades
|
||||
elif trades:
|
||||
recent_pnl = sum(t.get('pnl', 0) for t in trades)
|
||||
recent_weight = recent_pnl * 1.5
|
||||
|
||||
# ─── 2. CORRELATION / CONCENTRATION PENALTY ────────────────────────────
|
||||
# Count short vs long trades; penalize if ALL trades are same direction
|
||||
# (vulnerability to market reversal)
|
||||
short_trades = [t for t in trades if t.get('side') == 'short']
|
||||
long_trades = [t for t in trades if t.get('side') == 'long']
|
||||
n_st = len(short_trades)
|
||||
n_lt = len(long_trades)
|
||||
n_total = n_st + n_lt
|
||||
|
||||
direction_penalty = 0.0
|
||||
if n_total >= 5:
|
||||
short_frac = n_st / n_total
|
||||
# Penalty for >80% concentration in shorts OR longs
|
||||
if short_frac > 0.80:
|
||||
direction_penalty = -(1 - short_frac) * 2.0 # Too many shorts
|
||||
elif short_frac < 0.20:
|
||||
direction_penalty = -(short_frac) * 2.0 # Too many longs
|
||||
|
||||
# ─── 3. ENHANCED SHARPE (Calmar + Sortino blend) ───────────────────────
|
||||
# Calmar = return / max_dd (higher = better risk-adjusted)
|
||||
calmar = (total_return / (max_dd / 100)) if max_dd > 0.5 else 0.0
|
||||
|
||||
# Risk-adjusted score: blend Sharpe, Sortino, Calmar
|
||||
risk_adj_score = (sharpe * 0.4 + sortino * 0.3 + min(max(calmar, 0), 10) * 0.3)
|
||||
|
||||
# ─── 4. MARKET REGIME DETECTION ─────────────────────────────────────────
|
||||
# Detect if market is trending or ranging from recent returns
|
||||
regime_bonus = 0.0
|
||||
if trades and len(trades) >= 3:
|
||||
trade_returns = [t.get('pnl_pct', 0) for t in trades]
|
||||
# High variance = trending; low variance = ranging
|
||||
mean_ret = np.mean(trade_returns)
|
||||
std_ret = max(np.std(trade_returns), 0.01)
|
||||
# Consistency ratio: are returns stable?
|
||||
consistency = mean_ret / std_ret if std_ret > 0 else 0
|
||||
# Bonus for maintaining positive returns in high-variance (trending) markets
|
||||
if abs(consistency) > 1.5 and mean_ret > 0:
|
||||
regime_bonus = mean_ret * 0.5
|
||||
|
||||
# ─── PRIMARY FITNESS: Total Return ─────────────────────────────────────
|
||||
if total_return > 0:
|
||||
return_score = 1.0 + (total_return * 10.0) # Reward profit heavily
|
||||
return_score = 1.0 + (total_return * 10.0)
|
||||
else:
|
||||
return_score = max(0.01, 1.0 + (total_return * 20.0)) # Penalize losses even harder
|
||||
return_score = max(0.01, 1.0 + (total_return * 20.0))
|
||||
|
||||
dd_penalty = max(1 - max_dd / 100, 0)
|
||||
# Scalping: reward higher trade frequency (but less than before)
|
||||
# Scalping: reward higher trade frequency
|
||||
trade_bonus = 1.0 + math.log1p(max(total_trades, 0)) * 0.1
|
||||
# Bonus for win rate > 50%
|
||||
wr_bonus = 1.0 + max(0, win_rate - 0.5) * 0.3
|
||||
|
||||
# Sharpe bonus (risk-adjusted return quality)
|
||||
sharpe_bonus = 1.0 + (sharpe * 0.2)
|
||||
sharpe_bonus = 1.0 + (risk_adj_score * 0.2)
|
||||
|
||||
if total_trades < 5:
|
||||
trade_bonus *= 0.3 # Scalping needs more trades
|
||||
|
||||
# NEW FORMULA: Profit is the PRIMARY driver, everything else modulates it
|
||||
score = return_score * sharpe_bonus * dd_penalty * trade_bonus * wr_bonus
|
||||
# ─── COMBINED FITNESS SCORE ─────────────────────────────────────────────
|
||||
# Profit is the PRIMARY driver; recent protection, correlation, regime
|
||||
# and risk-adjusted metrics MODULATE it
|
||||
score = (return_score
|
||||
* sharpe_bonus
|
||||
* dd_penalty
|
||||
* trade_bonus
|
||||
* wr_bonus
|
||||
+ recent_weight
|
||||
+ direction_penalty
|
||||
+ regime_bonus)
|
||||
|
||||
# Floor: very negative recent form can't sink below tiny positive floor
|
||||
score = max(score, 0.001)
|
||||
fitness_scores.append(score)
|
||||
|
||||
if not fitness_scores:
|
||||
@@ -247,10 +328,46 @@ class GeneticEvolver:
|
||||
|
||||
m = result.metrics
|
||||
sharpe = max(m.get('sharpe_ratio', 0), 0)
|
||||
sortino = max(m.get('sortino_ratio', 0), 0)
|
||||
max_dd = m.get('max_drawdown', 0)
|
||||
total_trades = m.get('total_trades', 0)
|
||||
win_rate = m.get('win_rate', 0) / 100.0
|
||||
total_return = m.get('total_return', 0) / 100.0 # Convert % to decimal
|
||||
trades = result.trades
|
||||
|
||||
# ─── Recent drawdown protection ───────────────────────────────────
|
||||
recent_weight = 0.0
|
||||
if trades and len(trades) >= 5:
|
||||
n = len(trades)
|
||||
cutoff = int(n * 0.8)
|
||||
recent_trades = trades[cutoff:]
|
||||
recent_pnl = sum(t.get('pnl', 0) for t in recent_trades)
|
||||
early_pnl = sum(t.get('pnl', 0) for t in trades[:cutoff])
|
||||
if early_pnl > 0 and recent_pnl < 0:
|
||||
recent_weight = -abs(recent_pnl) * 5.0
|
||||
elif recent_pnl < 0:
|
||||
recent_weight = -abs(recent_pnl) * 3.0
|
||||
elif recent_pnl > 0:
|
||||
recent_weight = recent_pnl * 2.0
|
||||
elif trades:
|
||||
recent_pnl = sum(t.get('pnl', 0) for t in trades)
|
||||
recent_weight = recent_pnl * 1.5
|
||||
|
||||
# ─── Correlation / direction concentration ─────────────────────────
|
||||
direction_penalty = 0.0
|
||||
short_trades = [t for t in trades if t.get('side') == 'short']
|
||||
long_trades = [t for t in trades if t.get('side') == 'long']
|
||||
n_total = len(short_trades) + len(long_trades)
|
||||
if n_total >= 5:
|
||||
short_frac = len(short_trades) / n_total
|
||||
if short_frac > 0.80:
|
||||
direction_penalty = -(1 - short_frac) * 2.0
|
||||
elif short_frac < 0.20:
|
||||
direction_penalty = -(short_frac) * 2.0
|
||||
|
||||
# ─── Enhanced risk-adjusted score ──────────────────────────────────
|
||||
calmar = (total_return / (max_dd / 100)) if max_dd > 0.5 else 0.0
|
||||
risk_adj_score = (sharpe * 0.4 + sortino * 0.3 + min(max(calmar, 0), 10) * 0.3)
|
||||
|
||||
# Fitness = profit-weighted Sharpe with safety constraints
|
||||
dd_penalty = max(1 - max_dd / 100, 0)
|
||||
@@ -264,8 +381,15 @@ class GeneticEvolver:
|
||||
if total_trades < 5:
|
||||
trade_bonus *= 0.3
|
||||
|
||||
# Combined score: profit is primary, Sharpe/WR/DD are modifiers
|
||||
score = profit_score * (1 + sharpe) * dd_penalty * trade_bonus * wr_bonus
|
||||
# Combined score: profit is primary, everything else modulates
|
||||
score = (profit_score
|
||||
* (1 + risk_adj_score * 0.2)
|
||||
* dd_penalty
|
||||
* trade_bonus
|
||||
* wr_bonus
|
||||
+ recent_weight
|
||||
+ direction_penalty)
|
||||
score = max(score, 0.001)
|
||||
fitness_scores.append(score)
|
||||
|
||||
if not fitness_scores:
|
||||
|
||||
@@ -0,0 +1,504 @@
|
||||
"""
|
||||
Genetic Algorithm for Strategy Parameter Optimization
|
||||
Evolves a population of StrategyGenome instances to find profitable trading parameters.
|
||||
|
||||
Optimized: uses vectorized backtesting and parallel genome evaluation.
|
||||
"""
|
||||
|
||||
import random
|
||||
import math
|
||||
import json
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from loguru import logger
|
||||
|
||||
|
||||
# Gene ranges for scalping: (min, max, is_int)
|
||||
# Shorter periods, tighter SL/TP, faster hold times
|
||||
GENE_RANGES = {
|
||||
'fast_ma_period': (3, 20, True),
|
||||
'slow_ma_period': (10, 60, True),
|
||||
'rsi_period': (5, 21, True),
|
||||
'rsi_overbought': (60, 80, False),
|
||||
'rsi_oversold': (20, 40, False),
|
||||
'bb_period': (8, 25, True),
|
||||
'bb_std': (1.5, 2.5, False),
|
||||
'atr_period': (5, 14, True),
|
||||
'macd_fast': (5, 12, True),
|
||||
'macd_slow': (12, 26, True),
|
||||
'macd_signal': (5, 9, True),
|
||||
'volume_surge_threshold': (1.1, 2.5, False),
|
||||
'stop_loss_atr_mult': (0.5, 2.5, False),
|
||||
'take_profit_atr_mult': (0.8, 3.0, False),
|
||||
'max_position_pct': (0.05, 0.15, False),
|
||||
'min_hold_candles': (1, 6, True),
|
||||
'max_hold_candles': (3, 36, True),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class StrategyGenome:
|
||||
"""A genome encoding all tunable strategy parameters"""
|
||||
# Indicator periods (scalping-tuned defaults)
|
||||
fast_ma_period: int = 8
|
||||
slow_ma_period: int = 21
|
||||
rsi_period: int = 9
|
||||
rsi_overbought: float = 70.0
|
||||
rsi_oversold: float = 30.0
|
||||
bb_period: int = 15
|
||||
bb_std: float = 2.0
|
||||
atr_period: int = 10
|
||||
macd_fast: int = 8
|
||||
macd_slow: int = 17
|
||||
macd_signal: int = 7
|
||||
|
||||
# Entry thresholds
|
||||
volume_surge_threshold: float = 1.3
|
||||
|
||||
# Risk management (tighter for scalping)
|
||||
stop_loss_atr_mult: float = 1.2
|
||||
take_profit_atr_mult: float = 1.8
|
||||
max_position_pct: float = 0.10
|
||||
|
||||
# Timing (short holds for scalping)
|
||||
min_hold_candles: int = 1
|
||||
max_hold_candles: int = 12
|
||||
|
||||
# Fitness (set after evaluation)
|
||||
fitness: float = 0.0
|
||||
generation: int = 0
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: Dict) -> 'StrategyGenome':
|
||||
valid_fields = {f.name for f in cls.__dataclass_fields__.values()}
|
||||
filtered = {k: v for k, v in d.items() if k in valid_fields}
|
||||
return cls(**filtered)
|
||||
|
||||
@classmethod
|
||||
def random(cls, generation: int = 0) -> 'StrategyGenome':
|
||||
"""Create a random genome within valid parameter ranges"""
|
||||
genes = {}
|
||||
for gene_name, (lo, hi, is_int) in GENE_RANGES.items():
|
||||
if is_int:
|
||||
genes[gene_name] = random.randint(int(lo), int(hi))
|
||||
else:
|
||||
genes[gene_name] = round(random.uniform(lo, hi), 4)
|
||||
|
||||
# Constraint: slow_ma > fast_ma
|
||||
if genes['slow_ma_period'] <= genes['fast_ma_period']:
|
||||
genes['slow_ma_period'] = genes['fast_ma_period'] + random.randint(10, 50)
|
||||
|
||||
# Constraint: macd_slow > macd_fast
|
||||
if genes['macd_slow'] <= genes['macd_fast']:
|
||||
genes['macd_slow'] = genes['macd_fast'] + random.randint(8, 16)
|
||||
|
||||
# Constraint: take_profit > stop_loss
|
||||
if genes['take_profit_atr_mult'] <= genes['stop_loss_atr_mult']:
|
||||
genes['take_profit_atr_mult'] = genes['stop_loss_atr_mult'] + random.uniform(0.5, 2.0)
|
||||
|
||||
# Constraint: max_hold > min_hold
|
||||
if genes['max_hold_candles'] <= genes['min_hold_candles']:
|
||||
genes['max_hold_candles'] = genes['min_hold_candles'] + random.randint(10, 50)
|
||||
|
||||
genes['generation'] = generation
|
||||
return cls(**genes)
|
||||
|
||||
|
||||
def _evaluate_genome_worker(genome_dict: Dict, candles_dict: Dict[str, Dict],
|
||||
initial_capital: float, commission_rate: float) -> float:
|
||||
"""
|
||||
Worker function for parallel genome evaluation.
|
||||
Runs in a separate process, so must be a top-level function.
|
||||
Uses vectorized signal generation + fast backtest.
|
||||
"""
|
||||
from strategies.auto_strategy import genome_to_signals
|
||||
from backtest.engine import BacktestEngine
|
||||
import pandas as pd
|
||||
|
||||
genome = StrategyGenome.from_dict(genome_dict)
|
||||
engine = BacktestEngine(initial_capital=initial_capital, commission_rate=commission_rate)
|
||||
|
||||
fitness_scores = []
|
||||
|
||||
for symbol, candle_data in candles_dict.items():
|
||||
df = pd.DataFrame(candle_data)
|
||||
if len(df) < 60:
|
||||
continue
|
||||
|
||||
try:
|
||||
signals = genome_to_signals(genome, df)
|
||||
result = engine.run_fast(signals, df, symbol=symbol)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
m = result.metrics
|
||||
sharpe = max(m.get('sharpe_ratio', 0), 0)
|
||||
max_dd = m.get('max_drawdown', 0)
|
||||
total_trades = m.get('total_trades', 0)
|
||||
win_rate = m.get('win_rate', 0) / 100.0 # 0-1
|
||||
total_return = m.get('total_return', 0) / 100.0 # Convert % to decimal
|
||||
|
||||
# CRITICAL FIX: Profit/return MUST be the primary fitness component
|
||||
# Without this, GA evolves "good metrics" that lose money!
|
||||
|
||||
# Return component (most important): exponential reward for profit, penalty for loss
|
||||
if total_return > 0:
|
||||
return_score = 1.0 + (total_return * 10.0) # Reward profit heavily
|
||||
else:
|
||||
return_score = max(0.01, 1.0 + (total_return * 20.0)) # Penalize losses even harder
|
||||
|
||||
dd_penalty = max(1 - max_dd / 100, 0)
|
||||
# Scalping: reward higher trade frequency (but less than before)
|
||||
trade_bonus = 1.0 + math.log1p(max(total_trades, 0)) * 0.1
|
||||
# Bonus for win rate > 50%
|
||||
wr_bonus = 1.0 + max(0, win_rate - 0.5) * 0.3
|
||||
|
||||
# Sharpe bonus (risk-adjusted return quality)
|
||||
sharpe_bonus = 1.0 + (sharpe * 0.2)
|
||||
|
||||
if total_trades < 5:
|
||||
trade_bonus *= 0.3 # Scalping needs more trades
|
||||
|
||||
# NEW FORMULA: Profit is the PRIMARY driver, everything else modulates it
|
||||
score = return_score * sharpe_bonus * dd_penalty * trade_bonus * wr_bonus
|
||||
fitness_scores.append(score)
|
||||
|
||||
if not fitness_scores:
|
||||
return 0.0
|
||||
|
||||
return sum(fitness_scores) / len(fitness_scores)
|
||||
|
||||
|
||||
class GeneticEvolver:
|
||||
"""
|
||||
Genetic algorithm for optimizing strategy parameters.
|
||||
Evolves a population of StrategyGenome instances.
|
||||
"""
|
||||
|
||||
def __init__(self, config: Dict, backtest_engine, store=None):
|
||||
self.population_size = config.get('population_size', 50)
|
||||
self.elite_count = config.get('elite_count', 5)
|
||||
self.mutation_rate = config.get('mutation_rate', 0.15)
|
||||
self.mutation_strength = config.get('mutation_strength', 0.2)
|
||||
self.crossover_rate = config.get('crossover_rate', 0.7)
|
||||
self.tournament_size = config.get('tournament_size', 5)
|
||||
self.parallel_workers = config.get('parallel_workers', 4)
|
||||
|
||||
self.backtest_engine = backtest_engine
|
||||
self.store = store
|
||||
self.population: List[StrategyGenome] = []
|
||||
self.generation = 0
|
||||
self.best_ever: Optional[StrategyGenome] = None
|
||||
|
||||
def initialize_population(self):
|
||||
"""
|
||||
Create initial population.
|
||||
If evolution history exists in DB, load the latest generation.
|
||||
Otherwise, create random genomes.
|
||||
"""
|
||||
if self.store:
|
||||
latest = self.store.get_latest_generation()
|
||||
if latest:
|
||||
self.generation = latest['generation']
|
||||
self.population = [
|
||||
StrategyGenome.from_dict(g) for g in latest['population']
|
||||
]
|
||||
if self.population:
|
||||
self.best_ever = max(self.population, key=lambda g: g.fitness)
|
||||
logger.info(f"Loaded GA population from generation {self.generation} "
|
||||
f"({len(self.population)} genomes)")
|
||||
return
|
||||
|
||||
# Create random population
|
||||
self.population = [
|
||||
StrategyGenome.random(generation=0)
|
||||
for _ in range(self.population_size)
|
||||
]
|
||||
self.generation = 0
|
||||
logger.info(f"Created random population of {self.population_size} genomes")
|
||||
|
||||
def evaluate_fitness(self, genome: StrategyGenome, strategy_fn_factory,
|
||||
candles_data: Dict[str, 'pd.DataFrame']) -> float:
|
||||
"""
|
||||
Evaluate a genome using vectorized fast backtest.
|
||||
Falls back to callback-based if signals not available.
|
||||
"""
|
||||
from strategies.auto_strategy import genome_to_signals
|
||||
|
||||
fitness_scores = []
|
||||
|
||||
for symbol, df in candles_data.items():
|
||||
if df is None or len(df) < 60:
|
||||
continue
|
||||
|
||||
try:
|
||||
signals = genome_to_signals(genome, df)
|
||||
result = self.backtest_engine.run_fast(signals, df, symbol=symbol)
|
||||
except Exception:
|
||||
# Fallback to callback-based
|
||||
strategy_fn = strategy_fn_factory(genome)
|
||||
result = self.backtest_engine.run(
|
||||
strategy_fn, df, params=genome.to_dict(), symbol=symbol
|
||||
)
|
||||
|
||||
m = result.metrics
|
||||
sharpe = max(m.get('sharpe_ratio', 0), 0)
|
||||
max_dd = m.get('max_drawdown', 0)
|
||||
total_trades = m.get('total_trades', 0)
|
||||
win_rate = m.get('win_rate', 0) / 100.0
|
||||
total_return = m.get('total_return', 0) / 100.0 # Convert % to decimal
|
||||
|
||||
# Fitness = profit-weighted Sharpe with safety constraints
|
||||
dd_penalty = max(1 - max_dd / 100, 0)
|
||||
trade_bonus = math.sqrt(max(total_trades, 0))
|
||||
wr_bonus = 1.0 + max(0, win_rate - 0.5) * 0.5
|
||||
|
||||
# Weight actual profit heavily (10x multiplier)
|
||||
profit_score = max(0, total_return) * 10
|
||||
|
||||
# Penalize strategies with few trades
|
||||
if total_trades < 5:
|
||||
trade_bonus *= 0.3
|
||||
|
||||
# Combined score: profit is primary, Sharpe/WR/DD are modifiers
|
||||
score = profit_score * (1 + sharpe) * dd_penalty * trade_bonus * wr_bonus
|
||||
fitness_scores.append(score)
|
||||
|
||||
if not fitness_scores:
|
||||
return 0.0
|
||||
|
||||
return sum(fitness_scores) / len(fitness_scores)
|
||||
|
||||
def evaluate_population(self, strategy_fn_factory,
|
||||
candles_data: Dict[str, 'pd.DataFrame']):
|
||||
"""Evaluate all genomes - uses parallel workers if available."""
|
||||
unevaluated = [(i, g) for i, g in enumerate(self.population) if g.fitness == 0.0]
|
||||
|
||||
if not unevaluated:
|
||||
return
|
||||
|
||||
# Prepare serializable candle data for parallel workers
|
||||
candles_dict = {}
|
||||
for symbol, df in candles_data.items():
|
||||
if df is not None and len(df) >= 60:
|
||||
candles_dict[symbol] = {
|
||||
'open': df['open'].values.tolist(),
|
||||
'high': df['high'].values.tolist(),
|
||||
'low': df['low'].values.tolist(),
|
||||
'close': df['close'].values.tolist(),
|
||||
'volume': df['volume'].values.tolist(),
|
||||
}
|
||||
|
||||
if not candles_dict:
|
||||
return
|
||||
|
||||
initial_capital = self.backtest_engine.initial_capital
|
||||
commission_rate = self.backtest_engine.commission_rate
|
||||
|
||||
# Try parallel evaluation
|
||||
if self.parallel_workers > 1 and len(unevaluated) > 4:
|
||||
try:
|
||||
self._evaluate_parallel(
|
||||
unevaluated, candles_dict, initial_capital,
|
||||
commission_rate, strategy_fn_factory, candles_data
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.debug(f"Parallel evaluation failed, falling back to sequential: {e}")
|
||||
|
||||
# Sequential fallback (still uses vectorized fast path)
|
||||
for idx, (i, genome) in enumerate(unevaluated):
|
||||
genome.fitness = self.evaluate_fitness(
|
||||
genome, strategy_fn_factory, candles_data
|
||||
)
|
||||
if (idx + 1) % 10 == 0:
|
||||
logger.debug(f"Evaluated {idx+1}/{len(unevaluated)} genomes")
|
||||
|
||||
def _evaluate_parallel(self, unevaluated, candles_dict, initial_capital,
|
||||
commission_rate, strategy_fn_factory, candles_data):
|
||||
"""Evaluate genomes in parallel using ProcessPoolExecutor."""
|
||||
genome_dicts = [(i, g.to_dict()) for i, g in unevaluated]
|
||||
|
||||
with ProcessPoolExecutor(max_workers=self.parallel_workers) as executor:
|
||||
futures = {}
|
||||
for i, gd in genome_dicts:
|
||||
fut = executor.submit(
|
||||
_evaluate_genome_worker, gd, candles_dict,
|
||||
initial_capital, commission_rate
|
||||
)
|
||||
futures[fut] = i
|
||||
|
||||
done_count = 0
|
||||
for future in as_completed(futures):
|
||||
pop_idx = futures[future]
|
||||
try:
|
||||
fitness = future.result(timeout=30)
|
||||
self.population[pop_idx].fitness = fitness
|
||||
except Exception:
|
||||
# Fallback for this genome
|
||||
self.population[pop_idx].fitness = self.evaluate_fitness(
|
||||
self.population[pop_idx], strategy_fn_factory, candles_data
|
||||
)
|
||||
done_count += 1
|
||||
if done_count % 10 == 0:
|
||||
logger.debug(f"Evaluated {done_count}/{len(unevaluated)} genomes (parallel)")
|
||||
|
||||
def select_parent(self) -> StrategyGenome:
|
||||
"""Tournament selection"""
|
||||
tournament = random.sample(
|
||||
self.population,
|
||||
min(self.tournament_size, len(self.population))
|
||||
)
|
||||
return max(tournament, key=lambda g: g.fitness)
|
||||
|
||||
def crossover(self, parent1: StrategyGenome,
|
||||
parent2: StrategyGenome) -> StrategyGenome:
|
||||
"""Uniform crossover: for each gene, randomly pick from parent1 or parent2"""
|
||||
child_genes = {}
|
||||
p1 = parent1.to_dict()
|
||||
p2 = parent2.to_dict()
|
||||
|
||||
for gene_name in GENE_RANGES:
|
||||
child_genes[gene_name] = p1[gene_name] if random.random() < 0.5 else p2[gene_name]
|
||||
|
||||
# Repair constraints
|
||||
if child_genes['slow_ma_period'] <= child_genes['fast_ma_period']:
|
||||
child_genes['slow_ma_period'] = child_genes['fast_ma_period'] + 10
|
||||
|
||||
if child_genes['macd_slow'] <= child_genes['macd_fast']:
|
||||
child_genes['macd_slow'] = child_genes['macd_fast'] + 8
|
||||
|
||||
if child_genes['take_profit_atr_mult'] <= child_genes['stop_loss_atr_mult']:
|
||||
child_genes['take_profit_atr_mult'] = child_genes['stop_loss_atr_mult'] + 0.5
|
||||
|
||||
if child_genes['max_hold_candles'] <= child_genes['min_hold_candles']:
|
||||
child_genes['max_hold_candles'] = child_genes['min_hold_candles'] + 10
|
||||
|
||||
child_genes['fitness'] = 0.0
|
||||
child_genes['generation'] = self.generation + 1
|
||||
return StrategyGenome(**child_genes)
|
||||
|
||||
def mutate(self, genome: StrategyGenome) -> StrategyGenome:
|
||||
"""Gaussian mutation on each gene with probability mutation_rate"""
|
||||
genes = genome.to_dict()
|
||||
|
||||
for gene_name, (lo, hi, is_int) in GENE_RANGES.items():
|
||||
if random.random() < self.mutation_rate:
|
||||
gene_range = hi - lo
|
||||
delta = random.gauss(0, gene_range * self.mutation_strength)
|
||||
|
||||
new_val = genes[gene_name] + delta
|
||||
new_val = max(lo, min(hi, new_val))
|
||||
|
||||
if is_int:
|
||||
new_val = round(new_val)
|
||||
else:
|
||||
new_val = round(new_val, 4)
|
||||
|
||||
genes[gene_name] = new_val
|
||||
|
||||
# Repair constraints after mutation
|
||||
if genes['slow_ma_period'] <= genes['fast_ma_period']:
|
||||
genes['slow_ma_period'] = genes['fast_ma_period'] + 10
|
||||
if genes['macd_slow'] <= genes['macd_fast']:
|
||||
genes['macd_slow'] = genes['macd_fast'] + 8
|
||||
if genes['take_profit_atr_mult'] <= genes['stop_loss_atr_mult']:
|
||||
genes['take_profit_atr_mult'] = genes['stop_loss_atr_mult'] + 0.5
|
||||
if genes['max_hold_candles'] <= genes['min_hold_candles']:
|
||||
genes['max_hold_candles'] = genes['min_hold_candles'] + 10
|
||||
|
||||
genes['fitness'] = 0.0
|
||||
genes['generation'] = self.generation + 1
|
||||
return StrategyGenome.from_dict(genes)
|
||||
|
||||
def evolve_generation(self, strategy_fn_factory,
|
||||
candles_data: Dict[str, 'pd.DataFrame']) -> Dict:
|
||||
"""
|
||||
Run one generation of evolution:
|
||||
1. Evaluate fitness of all genomes
|
||||
2. Sort by fitness
|
||||
3. Keep elite_count best unchanged
|
||||
4. Fill remaining via tournament selection + crossover + mutation
|
||||
5. Save to database
|
||||
"""
|
||||
# Evaluate
|
||||
self.evaluate_population(strategy_fn_factory, candles_data)
|
||||
|
||||
# Sort by fitness
|
||||
self.population.sort(key=lambda g: g.fitness, reverse=True)
|
||||
|
||||
best = self.population[0]
|
||||
avg_fitness = sum(g.fitness for g in self.population) / len(self.population)
|
||||
|
||||
if self.best_ever is None or best.fitness > self.best_ever.fitness:
|
||||
self.best_ever = StrategyGenome.from_dict(best.to_dict())
|
||||
self.best_ever.fitness = best.fitness
|
||||
|
||||
# Elitism: keep top N unchanged
|
||||
new_population = [
|
||||
StrategyGenome.from_dict(g.to_dict())
|
||||
for g in self.population[:self.elite_count]
|
||||
]
|
||||
# Preserve their fitness
|
||||
for i in range(min(self.elite_count, len(self.population))):
|
||||
new_population[i].fitness = self.population[i].fitness
|
||||
|
||||
# Fill the rest
|
||||
while len(new_population) < self.population_size:
|
||||
parent1 = self.select_parent()
|
||||
parent2 = self.select_parent()
|
||||
|
||||
if random.random() < self.crossover_rate:
|
||||
child = self.crossover(parent1, parent2)
|
||||
else:
|
||||
child = StrategyGenome.from_dict(parent1.to_dict())
|
||||
child.fitness = 0.0
|
||||
|
||||
child = self.mutate(child)
|
||||
new_population.append(child)
|
||||
|
||||
self.population = new_population
|
||||
self.generation += 1
|
||||
|
||||
# Save to database
|
||||
if self.store:
|
||||
self.store.record_generation(
|
||||
self.generation,
|
||||
[g.to_dict() for g in self.population],
|
||||
best.fitness,
|
||||
avg_fitness
|
||||
)
|
||||
|
||||
stats = {
|
||||
'generation': self.generation,
|
||||
'best_fitness': round(best.fitness, 4),
|
||||
'avg_fitness': round(avg_fitness, 4),
|
||||
'best_genome': best.to_dict(),
|
||||
}
|
||||
|
||||
logger.info(f"GA Gen {self.generation}: best={best.fitness:.4f} avg={avg_fitness:.4f}")
|
||||
return stats
|
||||
|
||||
def run_evolution_cycle(self, strategy_fn_factory,
|
||||
candles_data: Dict[str, 'pd.DataFrame'],
|
||||
num_generations: int = 10) -> Optional[StrategyGenome]:
|
||||
"""
|
||||
Run multiple generations.
|
||||
Returns the best genome found.
|
||||
"""
|
||||
for _ in range(num_generations):
|
||||
self.evolve_generation(strategy_fn_factory, candles_data)
|
||||
|
||||
return self.get_best_genome()
|
||||
|
||||
def get_best_genome(self) -> Optional[StrategyGenome]:
|
||||
"""Return the highest-fitness genome"""
|
||||
if self.best_ever:
|
||||
return self.best_ever
|
||||
if self.population:
|
||||
return max(self.population, key=lambda g: g.fitness)
|
||||
return None
|
||||
@@ -0,0 +1,328 @@
|
||||
"""
|
||||
Reinforcement Learning Agent for Trading
|
||||
DQN with experience replay and target network, implemented in PyTorch.
|
||||
"""
|
||||
|
||||
import io
|
||||
import random
|
||||
import numpy as np
|
||||
from collections import deque
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from loguru import logger
|
||||
|
||||
try:
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
import torch.optim as optim
|
||||
TORCH_AVAILABLE = True
|
||||
except ImportError:
|
||||
TORCH_AVAILABLE = False
|
||||
logger.warning("PyTorch not installed. RL agent will use random actions. "
|
||||
"Install with: pip install torch")
|
||||
|
||||
|
||||
class TradingNetwork:
|
||||
"""Neural network for the RL agent (PyTorch or fallback)"""
|
||||
pass
|
||||
|
||||
|
||||
if TORCH_AVAILABLE:
|
||||
class TradingNetwork(nn.Module):
|
||||
"""MLP with 2 hidden layers for Q-value prediction"""
|
||||
|
||||
def __init__(self, state_dim: int, action_dim: int, hidden_dim: int = 128):
|
||||
super().__init__()
|
||||
self.net = nn.Sequential(
|
||||
nn.Linear(state_dim, hidden_dim),
|
||||
nn.ReLU(),
|
||||
nn.Dropout(0.1),
|
||||
nn.Linear(hidden_dim, hidden_dim),
|
||||
nn.ReLU(),
|
||||
nn.Dropout(0.1),
|
||||
nn.Linear(hidden_dim, action_dim)
|
||||
)
|
||||
|
||||
def forward(self, x):
|
||||
return self.net(x)
|
||||
|
||||
|
||||
class RLAgent:
|
||||
"""
|
||||
DQN-based RL agent for trading decisions.
|
||||
Falls back to random actions if PyTorch is not available.
|
||||
"""
|
||||
|
||||
def __init__(self, state_dim: int, action_dim: int = 7, config: Dict = None):
|
||||
config = config or {}
|
||||
self.config = config # CRITICAL FIX: Save config for memory persistence
|
||||
self.state_dim = state_dim
|
||||
self.action_dim = action_dim
|
||||
|
||||
# Hyperparameters
|
||||
self.gamma = config.get('gamma', 0.99)
|
||||
self.epsilon = config.get('epsilon_start', 1.0)
|
||||
self.epsilon_min = config.get('epsilon_min', 0.05)
|
||||
self.epsilon_decay = config.get('epsilon_decay', 0.9995)
|
||||
self.learning_rate = config.get('learning_rate', 0.0003)
|
||||
self.batch_size = config.get('batch_size', 64)
|
||||
self.memory_size = config.get('memory_size', 50000)
|
||||
self.target_update_freq = config.get('target_update_freq', 100)
|
||||
self.live_epsilon = config.get('live_epsilon', 0.1)
|
||||
|
||||
# Experience replay buffer
|
||||
self.memory = deque(maxlen=self.memory_size)
|
||||
self.steps = 0
|
||||
self.training_losses = []
|
||||
|
||||
# PyTorch setup
|
||||
self.use_torch = TORCH_AVAILABLE
|
||||
if self.use_torch:
|
||||
self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
||||
hidden_dim = config.get('hidden_dim', 128)
|
||||
self.policy_net = TradingNetwork(state_dim, action_dim, hidden_dim).to(self.device)
|
||||
self.target_net = TradingNetwork(state_dim, action_dim, hidden_dim).to(self.device)
|
||||
self.target_net.load_state_dict(self.policy_net.state_dict())
|
||||
self.target_net.eval()
|
||||
self.optimizer = optim.Adam(self.policy_net.parameters(), lr=self.learning_rate)
|
||||
logger.info(f"RL Agent initialized (PyTorch, device={self.device}, "
|
||||
f"state_dim={state_dim}, action_dim={action_dim})")
|
||||
else:
|
||||
self.device = None
|
||||
self.policy_net = None
|
||||
self.target_net = None
|
||||
self.optimizer = None
|
||||
logger.info("RL Agent initialized (random mode - no PyTorch)")
|
||||
|
||||
def select_action(self, state: np.ndarray, live_mode: bool = False) -> int:
|
||||
"""
|
||||
Epsilon-greedy action selection.
|
||||
In live mode, uses live_epsilon instead of training epsilon.
|
||||
"""
|
||||
eps = self.live_epsilon if live_mode else self.epsilon
|
||||
|
||||
if random.random() < eps:
|
||||
return random.randint(0, self.action_dim - 1)
|
||||
|
||||
if not self.use_torch:
|
||||
return random.randint(0, self.action_dim - 1)
|
||||
|
||||
with torch.no_grad():
|
||||
state_tensor = torch.FloatTensor(state).unsqueeze(0).to(self.device)
|
||||
q_values = self.policy_net(state_tensor)
|
||||
return int(q_values.argmax(dim=1).item())
|
||||
|
||||
def store_experience(self, state, action, reward, next_state, done):
|
||||
"""Store transition in replay buffer"""
|
||||
self.memory.append((state, action, reward, next_state, done))
|
||||
|
||||
def train_step(self) -> Optional[float]:
|
||||
"""
|
||||
Sample mini-batch from replay buffer, compute DQN loss, update.
|
||||
Returns loss value or None if not enough samples.
|
||||
"""
|
||||
if not self.use_torch:
|
||||
return None
|
||||
|
||||
if len(self.memory) < self.batch_size:
|
||||
return None
|
||||
|
||||
batch = random.sample(self.memory, self.batch_size)
|
||||
states, actions, rewards, next_states, dones = zip(*batch)
|
||||
|
||||
states = torch.FloatTensor(np.array(states)).to(self.device)
|
||||
actions = torch.LongTensor(actions).to(self.device)
|
||||
rewards = torch.FloatTensor(rewards).to(self.device)
|
||||
next_states = torch.FloatTensor(np.array(next_states)).to(self.device)
|
||||
dones = torch.BoolTensor(dones).to(self.device)
|
||||
|
||||
# Current Q values
|
||||
current_q = self.policy_net(states).gather(1, actions.unsqueeze(1)).squeeze(1)
|
||||
|
||||
# Target Q values
|
||||
with torch.no_grad():
|
||||
next_q = self.target_net(next_states).max(1)[0]
|
||||
next_q[dones] = 0.0
|
||||
target_q = rewards + self.gamma * next_q
|
||||
|
||||
# Loss and backprop
|
||||
loss = nn.functional.smooth_l1_loss(current_q, target_q)
|
||||
self.optimizer.zero_grad()
|
||||
loss.backward()
|
||||
torch.nn.utils.clip_grad_norm_(self.policy_net.parameters(), 1.0)
|
||||
self.optimizer.step()
|
||||
|
||||
self.steps += 1
|
||||
|
||||
# Update target network
|
||||
if self.steps % self.target_update_freq == 0:
|
||||
self.target_net.load_state_dict(self.policy_net.state_dict())
|
||||
|
||||
# Decay epsilon
|
||||
self.epsilon = max(self.epsilon_min, self.epsilon * self.epsilon_decay)
|
||||
|
||||
loss_val = loss.item()
|
||||
self.training_losses.append(loss_val)
|
||||
|
||||
return loss_val
|
||||
|
||||
def train_on_episode(self, env, candles_df, ga_signal_fn=None) -> Dict:
|
||||
"""
|
||||
Train on a full episode (backtest run through candles).
|
||||
Returns training metrics.
|
||||
"""
|
||||
state = env.reset(candles_df)
|
||||
if state is None:
|
||||
return {'avg_loss': 0, 'total_reward': 0, 'steps': 0}
|
||||
|
||||
total_reward = 0
|
||||
total_loss = 0
|
||||
loss_count = 0
|
||||
step = 0
|
||||
done = False
|
||||
|
||||
while not done:
|
||||
# Get GA signal if available
|
||||
ga_signal = None
|
||||
if ga_signal_fn and step < len(candles_df):
|
||||
ga_signal = ga_signal_fn(step)
|
||||
|
||||
action = self.select_action(state)
|
||||
next_state, reward, done, info = env.step(action, ga_signal=ga_signal)
|
||||
|
||||
self.store_experience(state, action, reward, next_state, done)
|
||||
|
||||
loss = self.train_step()
|
||||
if loss is not None:
|
||||
total_loss += loss
|
||||
loss_count += 1
|
||||
|
||||
total_reward += reward
|
||||
state = next_state
|
||||
step += 1
|
||||
|
||||
avg_loss = total_loss / max(loss_count, 1)
|
||||
|
||||
return {
|
||||
'avg_loss': round(avg_loss, 6),
|
||||
'total_reward': round(total_reward, 4),
|
||||
'steps': step,
|
||||
'epsilon': round(self.epsilon, 4),
|
||||
'total_trades': env.total_trades,
|
||||
'total_pnl': round(env.total_pnl, 4),
|
||||
'final_equity': round(env.equity_history[-1] if env.equity_history else 0, 2),
|
||||
'memory_size': len(self.memory),
|
||||
}
|
||||
|
||||
def update_from_live_trade(self, state, action, reward, next_state):
|
||||
"""
|
||||
Online learning: called after each live trade result.
|
||||
Stores experience and does one training step.
|
||||
"""
|
||||
self.store_experience(state, action, reward, next_state, False)
|
||||
self.train_step()
|
||||
|
||||
def save(self, store, epoch: int = None):
|
||||
"""Save model checkpoint to DataStore"""
|
||||
if not self.use_torch:
|
||||
return
|
||||
|
||||
if epoch is None:
|
||||
epoch = self.steps
|
||||
|
||||
state_bytes = self._get_state_dict_bytes()
|
||||
metrics = {
|
||||
'epsilon': self.epsilon,
|
||||
'steps': self.steps,
|
||||
'memory_size': len(self.memory),
|
||||
'avg_loss': round(np.mean(self.training_losses[-100:]), 6)
|
||||
if self.training_losses else 0,
|
||||
}
|
||||
store.save_model_checkpoint('rl_agent', epoch, state_bytes, metrics)
|
||||
|
||||
# Also save experience replay memory
|
||||
import pickle
|
||||
from pathlib import Path
|
||||
db_path_str = str(store.db_path)
|
||||
memory_path = db_path_str.replace('.db', '_rl_memory.pkl')
|
||||
try:
|
||||
with open(memory_path, 'wb') as f:
|
||||
# Save memory as list to avoid deque pickle issues
|
||||
pickle.dump(list(self.memory), f)
|
||||
logger.debug(f"RL memory saved ({len(self.memory)} experiences)")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to save RL memory: {e}")
|
||||
|
||||
logger.info(f"RL model saved (epoch {epoch}, epsilon={self.epsilon:.4f})")
|
||||
|
||||
def load(self, store) -> bool:
|
||||
"""Load latest checkpoint from DataStore"""
|
||||
if not self.use_torch:
|
||||
return False
|
||||
|
||||
checkpoint = store.load_latest_checkpoint('rl_agent')
|
||||
if checkpoint is None:
|
||||
logger.info("No RL checkpoint found, starting fresh")
|
||||
return False
|
||||
|
||||
try:
|
||||
buffer = io.BytesIO(checkpoint['state_dict'])
|
||||
state_dict = torch.load(buffer, map_location=self.device, weights_only=True)
|
||||
|
||||
# Check dimension compatibility before loading
|
||||
first_layer_key = 'net.0.weight'
|
||||
if first_layer_key in state_dict:
|
||||
saved_input_dim = state_dict[first_layer_key].shape[1]
|
||||
if saved_input_dim != self.state_dim:
|
||||
logger.warning(f"RL checkpoint dimension mismatch (saved={saved_input_dim}, "
|
||||
f"current={self.state_dim}). Starting fresh with new architecture.")
|
||||
return False
|
||||
|
||||
self.policy_net.load_state_dict(state_dict)
|
||||
self.target_net.load_state_dict(state_dict)
|
||||
|
||||
import json
|
||||
metrics = json.loads(checkpoint.get('metrics', '{}'))
|
||||
self.epsilon = metrics.get('epsilon', self.epsilon)
|
||||
self.steps = metrics.get('steps', self.steps)
|
||||
|
||||
# Load experience replay memory
|
||||
import pickle
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
db_path_str = str(store.db_path)
|
||||
memory_path = db_path_str.replace('.db', '_rl_memory.pkl')
|
||||
try:
|
||||
with open(memory_path, 'rb') as f:
|
||||
saved_memory = pickle.load(f)
|
||||
self.memory = deque(saved_memory, maxlen=self.config['memory_size'])
|
||||
logger.info(f"RL memory loaded ({len(self.memory)} experiences)")
|
||||
except FileNotFoundError:
|
||||
logger.debug("No saved RL memory found, starting with empty buffer")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load RL memory: {e}")
|
||||
|
||||
logger.info(f"RL model loaded (epoch {checkpoint['epoch']}, "
|
||||
f"epsilon={self.epsilon:.4f})")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not load RL checkpoint (likely dimension change): {e}. Starting fresh.")
|
||||
return False
|
||||
|
||||
def _get_state_dict_bytes(self) -> bytes:
|
||||
"""Serialize model state dict to bytes"""
|
||||
buffer = io.BytesIO()
|
||||
torch.save(self.policy_net.state_dict(), buffer)
|
||||
return buffer.getvalue()
|
||||
|
||||
def get_stats(self) -> Dict:
|
||||
"""Get current agent statistics"""
|
||||
return {
|
||||
'epsilon': round(self.epsilon, 4),
|
||||
'steps': self.steps,
|
||||
'memory_size': len(self.memory),
|
||||
'avg_loss': round(np.mean(self.training_losses[-100:]), 6)
|
||||
if self.training_losses else 0,
|
||||
'device': str(self.device) if self.device else 'random',
|
||||
'use_torch': self.use_torch,
|
||||
}
|
||||
+74
-14
@@ -44,8 +44,13 @@ class TradingEnvironment:
|
||||
self.portfolio_features = 3
|
||||
# GA signal features: [signal_direction, confidence]
|
||||
self.ga_features = 2
|
||||
# Market regime features: [regime_code, trend_strength_normalized]
|
||||
self.regime_features = 2
|
||||
|
||||
self.state_dim = FeatureEngine.NUM_FEATURES + self.portfolio_features + self.ga_features
|
||||
self.state_dim = (FeatureEngine.NUM_FEATURES
|
||||
+ self.portfolio_features
|
||||
+ self.ga_features
|
||||
+ self.regime_features) # = 36
|
||||
|
||||
# State tracking
|
||||
self.reset()
|
||||
@@ -65,6 +70,7 @@ class TradingEnvironment:
|
||||
self.total_pnl = 0.0
|
||||
self.wins = 0
|
||||
self.losses = 0
|
||||
self._last_side = None # Track last position side for flip bonus
|
||||
|
||||
self.candles_df = candles_df
|
||||
self.features_df = None
|
||||
@@ -185,6 +191,7 @@ class TradingEnvironment:
|
||||
# CLOSE POSITION (long or short)
|
||||
elif action == self.CLOSE_HALF and self.position_shares != 0:
|
||||
close_shares = abs(self.position_shares) * 0.5
|
||||
closed_side = 'long' if self.position_shares > 0 else 'short'
|
||||
if self.position_shares > 0:
|
||||
# Close half of long
|
||||
proceeds = close_shares * current_price
|
||||
@@ -210,9 +217,13 @@ class TradingEnvironment:
|
||||
self.last_action_step = self.step_count
|
||||
info['trade'] = 'close_50%'
|
||||
info['pnl'] = pnl
|
||||
# Track for flip bonus
|
||||
if self.position_shares == 0:
|
||||
self._last_side = closed_side
|
||||
|
||||
elif action == self.CLOSE_ALL and self.position_shares != 0:
|
||||
abs_shares = abs(self.position_shares)
|
||||
closed_side = 'long' if self.position_shares > 0 else 'short'
|
||||
if self.position_shares > 0:
|
||||
# Close all long
|
||||
proceeds = abs_shares * current_price
|
||||
@@ -226,6 +237,7 @@ class TradingEnvironment:
|
||||
pnl = (self.position_price - current_price) * abs_shares - fees
|
||||
self.capital += (self.position_price * abs_shares) - cost - fees
|
||||
|
||||
self._last_side = closed_side
|
||||
self.position_shares = 0
|
||||
self.position_price = 0
|
||||
self.total_trades += 1
|
||||
@@ -272,26 +284,57 @@ class TradingEnvironment:
|
||||
def _compute_reward(self, action: int, prev_equity: float,
|
||||
curr_equity: float, current_price: float) -> float:
|
||||
"""
|
||||
Reward function for scalping:
|
||||
Enhanced reward function for scalping with drawdown/directional protection:
|
||||
- Base: portfolio return (amplified for scalping sensitivity)
|
||||
- Penalty: drawdown, overtrading
|
||||
- Penalty: extended drawdowns (>3% from peak)
|
||||
- Penalty: over-levered positions (position > 40% of equity)
|
||||
- Penalty: extended one-directional bets (same direction > 20 steps)
|
||||
- Bonus: profitable close (long or short)
|
||||
- Scalp bonus: quick profitable round trips
|
||||
"""
|
||||
if prev_equity <= 0:
|
||||
return 0.0
|
||||
|
||||
# PROFIT IS KING. Everything else is noise.
|
||||
|
||||
# Base reward: actual P&L change (amplified for sensitivity)
|
||||
# ─── 1. Base P&L reward ───────────────────────────────────────────────
|
||||
pnl_change = curr_equity - prev_equity
|
||||
base_reward = (pnl_change / prev_equity) * 5.0
|
||||
|
||||
# Drawdown penalty (only kick in at 5%+, don't punish normal swings)
|
||||
# ─── 2. Extended drawdown penalty ─────────────────────────────────────
|
||||
# Only penalize after 3% drawdown (allow normal volatility)
|
||||
drawdown = (self.peak_equity - curr_equity) / self.peak_equity if self.peak_equity > 0 else 0
|
||||
dd_penalty = -1.0 * max(0, drawdown - 0.05)
|
||||
dd_penalty = 0.0
|
||||
if drawdown > 0.03:
|
||||
# Escalating penalty: -0.02 per 1% beyond 3%
|
||||
dd_penalty = -0.02 * ((drawdown - 0.03) * 100)
|
||||
elif drawdown > 0.10:
|
||||
# Severe penalty for deep drawdowns (>10%)
|
||||
dd_penalty = -0.05 * ((drawdown - 0.10) * 100)
|
||||
|
||||
# Profitable close bonus — the BIGGEST reward signal
|
||||
# ─── 3. Over-leverage penalty ─────────────────────────────────────────
|
||||
leverage_penalty = 0.0
|
||||
if self.position_shares != 0 and self.position_price > 0:
|
||||
position_value = abs(self.position_shares * self.position_price)
|
||||
leverage_ratio = position_value / prev_equity if prev_equity > 0 else 0
|
||||
if leverage_ratio > 0.40:
|
||||
# Penalty for >40% position size
|
||||
leverage_penalty = -0.02 * ((leverage_ratio - 0.40) * 100)
|
||||
elif leverage_ratio > 0.60:
|
||||
# Severe penalty for >60% (dangerous over-leverage)
|
||||
leverage_penalty = -0.05 * ((leverage_ratio - 0.60) * 100)
|
||||
|
||||
# ─── 4. Extended one-directional bet penalty ─────────────────────────
|
||||
# Track how long we've been holding the SAME direction without closing
|
||||
directional_penalty = 0.0
|
||||
if self.position_shares != 0:
|
||||
hold_time = self.step_count - self.entry_step
|
||||
if hold_time > 20:
|
||||
# After 20 candles (≈100 min on 5m), start penalizing prolonged holds
|
||||
directional_penalty = -0.005 * (hold_time - 20)
|
||||
if hold_time > 40:
|
||||
# After 40 candles, severe penalty for stubborn directional bets
|
||||
directional_penalty = -0.015 * (hold_time - 40)
|
||||
|
||||
# ─── 5. Profitable close bonus ───────────────────────────────────────
|
||||
close_bonus = 0.0
|
||||
if action in (self.CLOSE_HALF, self.CLOSE_ALL) and self.position_price > 0:
|
||||
if self.position_shares > 0 and current_price > self.position_price:
|
||||
@@ -307,17 +350,31 @@ class TradingEnvironment:
|
||||
pnl_pct = (current_price - self.position_price) / self.position_price
|
||||
close_bonus = -0.05 - pnl_pct * 3.0
|
||||
|
||||
# Quick scalp bonus: fast profitable round trips get extra reward
|
||||
# ─── 6. Quick scalp bonus ─────────────────────────────────────────────
|
||||
scalp_bonus = 0.0
|
||||
if action in (self.CLOSE_HALF, self.CLOSE_ALL) and close_bonus > 0:
|
||||
hold_time = self.step_count - self.entry_step
|
||||
if hold_time < 12:
|
||||
scalp_bonus = 0.02
|
||||
|
||||
# NO hold penalty — sitting in cash when uncertain is SMART
|
||||
# NO overtrading penalty — let the agent trade freely
|
||||
# ─── 7. Regime-aware directional flip bonus ───────────────────────────
|
||||
# If we've been short and now buy (or vice versa), small bonus for adapting
|
||||
flip_bonus = 0.0
|
||||
if action in (self.BUY_SMALL, self.BUY_LARGE) and self.position_shares == 0:
|
||||
# Was flat — check if we just closed a short profitably (market reversal)
|
||||
if hasattr(self, '_last_side') and self._last_side == 'short':
|
||||
flip_bonus = 0.03 # Bonus for correctly reversing from short to long
|
||||
elif action in (self.SHORT_SMALL, self.SHORT_LARGE) and self.position_shares == 0:
|
||||
if hasattr(self, '_last_side') and self._last_side == 'long':
|
||||
flip_bonus = 0.03 # Bonus for correctly reversing from long to short
|
||||
|
||||
reward = base_reward + dd_penalty + close_bonus + scalp_bonus
|
||||
reward = (base_reward
|
||||
+ dd_penalty
|
||||
+ leverage_penalty
|
||||
+ directional_penalty
|
||||
+ close_bonus
|
||||
+ scalp_bonus
|
||||
+ flip_bonus)
|
||||
|
||||
# Clip to [-1, 1]
|
||||
return max(-1.0, min(1.0, reward))
|
||||
@@ -382,7 +439,10 @@ class TradingEnvironment:
|
||||
|
||||
ga_features = np.array([signal_dir, confidence], dtype=np.float32)
|
||||
|
||||
return np.concatenate([market_features, portfolio_features, ga_features])
|
||||
# Regime features (placeholders for backward compatibility; real values set by main_auto)
|
||||
regime_features = np.array([0.0, 0.0], dtype=np.float32)
|
||||
|
||||
return np.concatenate([market_features, portfolio_features, ga_features, regime_features])
|
||||
|
||||
def get_portfolio_state(self, current_price: float = 0) -> Dict:
|
||||
"""Get portfolio state dict for external use (handles long and short)"""
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
"""
|
||||
Trading Environment for Reinforcement Learning
|
||||
Defines state space, action space, and reward function.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from typing import Dict, Tuple, Optional
|
||||
from loguru import logger
|
||||
|
||||
from data.features import FeatureEngine
|
||||
|
||||
|
||||
class TradingEnvironment:
|
||||
"""
|
||||
Trading environment for RL agent.
|
||||
Modes:
|
||||
- 'backtest': steps through historical candles
|
||||
- 'live': receives state updates from the trading loop
|
||||
"""
|
||||
|
||||
# Actions
|
||||
HOLD = 0
|
||||
BUY_SMALL = 1 # Buy with 25% of available capital (go long)
|
||||
BUY_LARGE = 2 # Buy with 50% of available capital (go long)
|
||||
CLOSE_HALF = 3 # Close 50% of position (long or short)
|
||||
CLOSE_ALL = 4 # Close 100% of position (long or short)
|
||||
SHORT_SMALL = 5 # Short with 25% of available capital
|
||||
SHORT_LARGE = 6 # Short with 50% of available capital
|
||||
|
||||
ACTION_NAMES = ['hold', 'buy_25%', 'buy_50%', 'close_50%', 'close_all',
|
||||
'short_25%', 'short_50%']
|
||||
NUM_ACTIONS = 7
|
||||
|
||||
def __init__(self, feature_engine: FeatureEngine,
|
||||
initial_capital: float = 100.0,
|
||||
commission_rate: float = 0.001):
|
||||
self.feature_engine = feature_engine
|
||||
self.initial_capital = initial_capital
|
||||
self.commission_rate = commission_rate
|
||||
|
||||
# Portfolio features appended to market features
|
||||
# [position_ratio, unrealized_pnl_pct, time_in_position_normalized]
|
||||
self.portfolio_features = 3
|
||||
# GA signal features: [signal_direction, confidence]
|
||||
self.ga_features = 2
|
||||
|
||||
self.state_dim = FeatureEngine.NUM_FEATURES + self.portfolio_features + self.ga_features
|
||||
|
||||
# State tracking
|
||||
self.reset()
|
||||
|
||||
def reset(self, candles_df: pd.DataFrame = None) -> Optional[np.ndarray]:
|
||||
"""Reset environment for a new episode"""
|
||||
self.capital = self.initial_capital
|
||||
self.position_shares = 0.0
|
||||
self.position_price = 0.0
|
||||
self.step_count = 0
|
||||
self.entry_step = 0
|
||||
self.equity_history = [self.initial_capital]
|
||||
self.peak_equity = self.initial_capital
|
||||
self.last_action = self.HOLD
|
||||
self.last_action_step = -10
|
||||
self.total_trades = 0
|
||||
self.total_pnl = 0.0
|
||||
self.wins = 0
|
||||
self.losses = 0
|
||||
|
||||
self.candles_df = candles_df
|
||||
self.features_df = None
|
||||
|
||||
if candles_df is not None and len(candles_df) > 50:
|
||||
self.features_df = self.feature_engine.compute_and_normalize(candles_df)
|
||||
if len(self.features_df) > 0:
|
||||
return self._get_state(0)
|
||||
|
||||
return np.zeros(self.state_dim, dtype=np.float32)
|
||||
|
||||
def step(self, action: int, ga_signal: Dict = None,
|
||||
current_candle: pd.Series = None) -> Tuple[np.ndarray, float, bool, Dict]:
|
||||
"""
|
||||
Execute action, advance one timestep.
|
||||
|
||||
Args:
|
||||
action: integer action (0-4)
|
||||
ga_signal: optional GA strategy signal {signal, confidence}
|
||||
current_candle: optional candle for live mode
|
||||
|
||||
Returns: (next_state, reward, done, info)
|
||||
"""
|
||||
prev_equity = self._get_equity()
|
||||
|
||||
# Get current price
|
||||
if self.features_df is not None and self.step_count < len(self.features_df):
|
||||
idx = self.step_count
|
||||
current_price = float(self.candles_df['close'].iloc[
|
||||
self.candles_df.index.get_indexer(
|
||||
[self.features_df.index[idx]], method='nearest'
|
||||
)[0]
|
||||
]) if self.candles_df is not None else 0
|
||||
# Simpler: just use the close from the original df aligned by position
|
||||
try:
|
||||
orig_idx = self.features_df.index[idx]
|
||||
if orig_idx in self.candles_df.index:
|
||||
current_price = float(self.candles_df.loc[orig_idx, 'close'])
|
||||
else:
|
||||
current_price = float(self.candles_df['close'].iloc[-1])
|
||||
except (IndexError, KeyError):
|
||||
current_price = float(self.candles_df['close'].iloc[-1])
|
||||
elif current_candle is not None:
|
||||
current_price = float(current_candle.get('close', current_candle.get('Close', 0)))
|
||||
else:
|
||||
return np.zeros(self.state_dim, dtype=np.float32), 0.0, True, {}
|
||||
|
||||
# Execute action
|
||||
trade_info = self._execute_action(action, current_price)
|
||||
|
||||
self.step_count += 1
|
||||
curr_equity = self._get_equity(current_price)
|
||||
self.equity_history.append(curr_equity)
|
||||
|
||||
if curr_equity > self.peak_equity:
|
||||
self.peak_equity = curr_equity
|
||||
|
||||
# Compute reward
|
||||
reward = self._compute_reward(action, prev_equity, curr_equity, current_price)
|
||||
|
||||
# Check if done
|
||||
done = False
|
||||
if self.features_df is not None:
|
||||
done = self.step_count >= len(self.features_df) - 1
|
||||
if curr_equity < self.initial_capital * 0.5: # 50% loss = episode over
|
||||
done = True
|
||||
|
||||
# Get next state
|
||||
next_state = self._get_state(self.step_count, ga_signal)
|
||||
|
||||
info = {
|
||||
'equity': curr_equity,
|
||||
'position_value': abs(self.position_shares) * current_price if self.position_shares != 0 else 0,
|
||||
'position_side': 'long' if self.position_shares > 0 else ('short' if self.position_shares < 0 else 'flat'),
|
||||
'capital': self.capital,
|
||||
'total_trades': self.total_trades,
|
||||
'total_pnl': self.total_pnl,
|
||||
**trade_info,
|
||||
}
|
||||
|
||||
return next_state, reward, done, info
|
||||
|
||||
def _execute_action(self, action: int, current_price: float) -> Dict:
|
||||
"""Execute a trading action, return trade info.
|
||||
position_shares > 0 means long, < 0 means short."""
|
||||
info = {'trade': None}
|
||||
|
||||
if current_price <= 0:
|
||||
return info
|
||||
|
||||
# GO LONG (only if flat)
|
||||
if action == self.BUY_SMALL and self.position_shares == 0:
|
||||
invest = self.capital * 0.25
|
||||
if invest > 1:
|
||||
fees = invest * self.commission_rate
|
||||
shares = (invest - fees) / current_price
|
||||
self.capital -= invest
|
||||
self.position_shares = shares
|
||||
self.position_price = current_price
|
||||
self.entry_step = self.step_count
|
||||
self.last_action = action
|
||||
self.last_action_step = self.step_count
|
||||
info['trade'] = 'buy_25%'
|
||||
|
||||
elif action == self.BUY_LARGE and self.position_shares == 0:
|
||||
invest = self.capital * 0.50
|
||||
if invest > 1:
|
||||
fees = invest * self.commission_rate
|
||||
shares = (invest - fees) / current_price
|
||||
self.capital -= invest
|
||||
self.position_shares = shares
|
||||
self.position_price = current_price
|
||||
self.entry_step = self.step_count
|
||||
self.last_action = action
|
||||
self.last_action_step = self.step_count
|
||||
info['trade'] = 'buy_50%'
|
||||
|
||||
# CLOSE POSITION (long or short)
|
||||
elif action == self.CLOSE_HALF and self.position_shares != 0:
|
||||
close_shares = abs(self.position_shares) * 0.5
|
||||
if self.position_shares > 0:
|
||||
# Close half of long
|
||||
proceeds = close_shares * current_price
|
||||
fees = proceeds * self.commission_rate
|
||||
pnl = (current_price - self.position_price) * close_shares - fees
|
||||
self.capital += proceeds - fees
|
||||
self.position_shares -= close_shares
|
||||
else:
|
||||
# Cover half of short
|
||||
cost = close_shares * current_price
|
||||
fees = cost * self.commission_rate
|
||||
pnl = (self.position_price - current_price) * close_shares - fees
|
||||
self.capital += (self.position_price * close_shares) - cost - fees
|
||||
self.position_shares += close_shares
|
||||
|
||||
self.total_trades += 1
|
||||
self.total_pnl += pnl
|
||||
if pnl > 0:
|
||||
self.wins += 1
|
||||
else:
|
||||
self.losses += 1
|
||||
self.last_action = action
|
||||
self.last_action_step = self.step_count
|
||||
info['trade'] = 'close_50%'
|
||||
info['pnl'] = pnl
|
||||
|
||||
elif action == self.CLOSE_ALL and self.position_shares != 0:
|
||||
abs_shares = abs(self.position_shares)
|
||||
if self.position_shares > 0:
|
||||
# Close all long
|
||||
proceeds = abs_shares * current_price
|
||||
fees = proceeds * self.commission_rate
|
||||
pnl = (current_price - self.position_price) * abs_shares - fees
|
||||
self.capital += proceeds - fees
|
||||
else:
|
||||
# Cover all short
|
||||
cost = abs_shares * current_price
|
||||
fees = cost * self.commission_rate
|
||||
pnl = (self.position_price - current_price) * abs_shares - fees
|
||||
self.capital += (self.position_price * abs_shares) - cost - fees
|
||||
|
||||
self.position_shares = 0
|
||||
self.position_price = 0
|
||||
self.total_trades += 1
|
||||
self.total_pnl += pnl
|
||||
if pnl > 0:
|
||||
self.wins += 1
|
||||
else:
|
||||
self.losses += 1
|
||||
self.last_action = action
|
||||
self.last_action_step = self.step_count
|
||||
info['trade'] = 'close_all'
|
||||
info['pnl'] = pnl
|
||||
|
||||
# GO SHORT (only if flat)
|
||||
elif action == self.SHORT_SMALL and self.position_shares == 0:
|
||||
invest = self.capital * 0.25
|
||||
if invest > 1:
|
||||
fees = invest * self.commission_rate
|
||||
shares = (invest - fees) / current_price
|
||||
# Short: we receive proceeds upfront, owe shares later
|
||||
self.capital += invest - fees # margin collateral stays, proceeds added
|
||||
self.position_shares = -shares
|
||||
self.position_price = current_price
|
||||
self.entry_step = self.step_count
|
||||
self.last_action = action
|
||||
self.last_action_step = self.step_count
|
||||
info['trade'] = 'short_25%'
|
||||
|
||||
elif action == self.SHORT_LARGE and self.position_shares == 0:
|
||||
invest = self.capital * 0.50
|
||||
if invest > 1:
|
||||
fees = invest * self.commission_rate
|
||||
shares = (invest - fees) / current_price
|
||||
self.capital += invest - fees
|
||||
self.position_shares = -shares
|
||||
self.position_price = current_price
|
||||
self.entry_step = self.step_count
|
||||
self.last_action = action
|
||||
self.last_action_step = self.step_count
|
||||
info['trade'] = 'short_50%'
|
||||
|
||||
return info
|
||||
|
||||
def _compute_reward(self, action: int, prev_equity: float,
|
||||
curr_equity: float, current_price: float) -> float:
|
||||
"""
|
||||
Reward function for scalping:
|
||||
- Base: portfolio return (amplified for scalping sensitivity)
|
||||
- Penalty: drawdown, overtrading
|
||||
- Bonus: profitable close (long or short)
|
||||
- Scalp bonus: quick profitable round trips
|
||||
"""
|
||||
if prev_equity <= 0:
|
||||
return 0.0
|
||||
|
||||
# Base reward: portfolio return (amplified 2x for scalping sensitivity)
|
||||
base_reward = (curr_equity - prev_equity) / prev_equity * 2.0
|
||||
|
||||
# Drawdown penalty
|
||||
drawdown = (self.peak_equity - curr_equity) / self.peak_equity if self.peak_equity > 0 else 0
|
||||
dd_penalty = -0.5 * max(0, drawdown - 0.03)
|
||||
|
||||
# Overtrading penalty (reduced for scalping - allow faster re-entry)
|
||||
overtrade_penalty = 0.0
|
||||
if action != self.HOLD and (self.step_count - self.last_action_step) < 2:
|
||||
overtrade_penalty = -0.0005
|
||||
|
||||
# Holding penalty (stronger for scalping - don't sit idle)
|
||||
hold_penalty = 0.0
|
||||
if action == self.HOLD and self.position_shares == 0:
|
||||
hold_penalty = -0.0002
|
||||
|
||||
# Profitable close bonus (works for both long and short)
|
||||
close_bonus = 0.0
|
||||
if action in (self.CLOSE_HALF, self.CLOSE_ALL) and self.position_price > 0:
|
||||
if self.position_shares > 0 and current_price > self.position_price:
|
||||
# Profitable long close
|
||||
pnl_pct = (current_price - self.position_price) / self.position_price
|
||||
close_bonus = 0.02 * pnl_pct
|
||||
elif self.position_shares < 0 and current_price < self.position_price:
|
||||
# Profitable short close
|
||||
pnl_pct = (self.position_price - current_price) / self.position_price
|
||||
close_bonus = 0.02 * pnl_pct
|
||||
|
||||
# Quick scalp bonus: reward fast profitable round trips
|
||||
scalp_bonus = 0.0
|
||||
if action in (self.CLOSE_HALF, self.CLOSE_ALL) and self.position_shares != 0:
|
||||
hold_time = self.step_count - self.entry_step
|
||||
if hold_time < 12 and close_bonus > 0: # Quick + profitable
|
||||
scalp_bonus = 0.005
|
||||
|
||||
reward = base_reward + dd_penalty + overtrade_penalty + hold_penalty + close_bonus + scalp_bonus
|
||||
|
||||
# Clip to [-1, 1]
|
||||
return max(-1.0, min(1.0, reward))
|
||||
|
||||
def _get_equity(self, current_price: float = None) -> float:
|
||||
"""Calculate current total equity (handles long and short positions)"""
|
||||
if current_price is None:
|
||||
if self.candles_df is not None and self.step_count < len(self.candles_df):
|
||||
current_price = float(self.candles_df['close'].iloc[self.step_count])
|
||||
else:
|
||||
current_price = self.position_price if self.position_price > 0 else 0
|
||||
|
||||
if self.position_shares > 0:
|
||||
# Long: capital + shares * price
|
||||
return self.capital + self.position_shares * current_price
|
||||
elif self.position_shares < 0:
|
||||
# Short: capital + unrealized P&L from short
|
||||
abs_shares = abs(self.position_shares)
|
||||
short_pnl = (self.position_price - current_price) * abs_shares
|
||||
return self.capital + short_pnl
|
||||
return self.capital
|
||||
|
||||
def _get_state(self, step: int, ga_signal: Dict = None) -> np.ndarray:
|
||||
"""Build full state vector"""
|
||||
# Market features
|
||||
if self.features_df is not None and step < len(self.features_df):
|
||||
market_features = self.feature_engine.get_state_vector(self.features_df, step)
|
||||
else:
|
||||
market_features = np.zeros(FeatureEngine.NUM_FEATURES, dtype=np.float32)
|
||||
|
||||
# Portfolio features (handles long and short)
|
||||
equity = self._get_equity()
|
||||
position_value = abs(self.position_shares) * self.position_price
|
||||
# Positive ratio = long, negative ratio = short
|
||||
position_ratio = (self.position_shares * self.position_price) / equity if equity > 0 else 0.0
|
||||
|
||||
unrealized_pnl = 0.0
|
||||
if self.position_shares != 0 and self.position_price > 0:
|
||||
if self.candles_df is not None and step < len(self.candles_df):
|
||||
current = float(self.candles_df['close'].iloc[min(step, len(self.candles_df) - 1)])
|
||||
if self.position_shares > 0:
|
||||
unrealized_pnl = (current - self.position_price) / self.position_price
|
||||
else:
|
||||
unrealized_pnl = (self.position_price - current) / self.position_price
|
||||
|
||||
time_in_position = 0.0
|
||||
if self.position_shares != 0:
|
||||
time_in_position = min((self.step_count - self.entry_step) / 48.0, 1.0)
|
||||
|
||||
portfolio_features = np.array([
|
||||
position_ratio, unrealized_pnl, time_in_position
|
||||
], dtype=np.float32)
|
||||
|
||||
# GA signal features
|
||||
if ga_signal:
|
||||
signal_dir = 1.0 if ga_signal.get('signal') == 'buy' else (
|
||||
-1.0 if ga_signal.get('signal') == 'sell' else 0.0)
|
||||
confidence = float(ga_signal.get('confidence', 0.0))
|
||||
else:
|
||||
signal_dir = 0.0
|
||||
confidence = 0.0
|
||||
|
||||
ga_features = np.array([signal_dir, confidence], dtype=np.float32)
|
||||
|
||||
return np.concatenate([market_features, portfolio_features, ga_features])
|
||||
|
||||
def get_portfolio_state(self, current_price: float = 0) -> Dict:
|
||||
"""Get portfolio state dict for external use (handles long and short)"""
|
||||
equity = self._get_equity(current_price)
|
||||
position_value = self.position_shares * current_price if current_price > 0 else 0
|
||||
unrealized = 0.0
|
||||
if self.position_price > 0 and current_price > 0 and self.position_shares != 0:
|
||||
if self.position_shares > 0:
|
||||
unrealized = (current_price - self.position_price) / self.position_price
|
||||
else:
|
||||
unrealized = (self.position_price - current_price) / self.position_price
|
||||
return {
|
||||
'position_ratio': position_value / equity if equity > 0 else 0,
|
||||
'unrealized_pnl': unrealized,
|
||||
'time_in_position': min((self.step_count - self.entry_step) / 48.0, 1.0)
|
||||
if self.position_shares != 0 else 0,
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
"""
|
||||
Auto Strategy
|
||||
Converts a StrategyGenome into a callable trading strategy for backtesting and live trading.
|
||||
|
||||
Optimized: indicators are pre-computed once as arrays, not per-candle.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from typing import Dict, Callable
|
||||
from loguru import logger
|
||||
|
||||
|
||||
def genome_to_strategy(genome) -> Callable:
|
||||
"""
|
||||
Convert a StrategyGenome into a scalping strategy with long AND short signals.
|
||||
|
||||
Returns a callable with signature:
|
||||
(state: Dict, candle_idx: int, df: pd.DataFrame, params: Dict) -> Dict
|
||||
|
||||
Indicators are pre-computed on first call and cached via closure.
|
||||
"""
|
||||
g = genome
|
||||
_cache = {}
|
||||
|
||||
def strategy_fn(state: Dict, idx: int, df: pd.DataFrame, params: Dict) -> Dict:
|
||||
"""Evaluate scalping strategy at candle index"""
|
||||
if idx < max(g.slow_ma_period, 30):
|
||||
return {'action': 'hold'}
|
||||
|
||||
# Pre-compute all indicators once, cache by DataFrame id
|
||||
df_id = id(df)
|
||||
if df_id not in _cache:
|
||||
_cache.clear()
|
||||
_cache[df_id] = _precompute_indicators(df, g)
|
||||
|
||||
ind = _cache[df_id]
|
||||
|
||||
if idx >= len(ind['fast_ma']):
|
||||
return {'action': 'hold'}
|
||||
|
||||
current_price = ind['close'][idx]
|
||||
fast_ma = ind['fast_ma'][idx]
|
||||
slow_ma = ind['slow_ma'][idx]
|
||||
rsi = ind['rsi'][idx]
|
||||
macd_hist = ind['macd_hist'][idx]
|
||||
atr = ind['atr'][idx]
|
||||
vol_ratio = ind['vol_ratio'][idx]
|
||||
|
||||
# BB for mean-reversion scalps
|
||||
bb_upper = ind.get('bb_upper')
|
||||
bb_lower = ind.get('bb_lower')
|
||||
|
||||
position = state.get('position')
|
||||
|
||||
# --- EXIT CONDITIONS ---
|
||||
if position is not None:
|
||||
held_too_long = False
|
||||
if 'entry_idx' in position:
|
||||
candles_held = idx - position['entry_idx']
|
||||
held_too_long = candles_held >= g.max_hold_candles
|
||||
|
||||
pos_dir = position.get('direction', 'long')
|
||||
|
||||
if pos_dir == 'long':
|
||||
if rsi > g.rsi_overbought or held_too_long or macd_hist < 0:
|
||||
return {'action': 'sell'}
|
||||
elif pos_dir == 'short':
|
||||
if rsi < g.rsi_oversold or held_too_long or macd_hist > 0:
|
||||
return {'action': 'cover'}
|
||||
return {'action': 'hold'}
|
||||
|
||||
# --- LONG ENTRY (scalp) ---
|
||||
bullish_trend = current_price > fast_ma
|
||||
rsi_buy_zone = g.rsi_oversold < rsi < (g.rsi_overbought - 5)
|
||||
macd_bullish = macd_hist > 0
|
||||
volume_active = vol_ratio > g.volume_surge_threshold
|
||||
|
||||
# Bollinger bounce: price near lower band = mean reversion long
|
||||
bb_bounce_long = False
|
||||
if bb_lower is not None and idx < len(bb_lower):
|
||||
bb_bounce_long = current_price <= bb_lower[idx] * 1.005
|
||||
|
||||
long_signal = (bullish_trend and rsi_buy_zone and macd_bullish and volume_active) or \
|
||||
(bb_bounce_long and rsi < 35 and volume_active)
|
||||
|
||||
if long_signal:
|
||||
stop_loss = current_price - (atr * g.stop_loss_atr_mult)
|
||||
take_profit = current_price + (atr * g.take_profit_atr_mult)
|
||||
confidence = min(1.0, (vol_ratio - 1) * 0.4 + 0.3)
|
||||
if bb_bounce_long:
|
||||
confidence = min(1.0, confidence + 0.15)
|
||||
|
||||
return {
|
||||
'action': 'buy',
|
||||
'amount_pct': g.max_position_pct,
|
||||
'stop_loss': stop_loss,
|
||||
'take_profit': take_profit,
|
||||
'confidence': confidence,
|
||||
}
|
||||
|
||||
# --- SHORT ENTRY (scalp) ---
|
||||
bearish_trend = current_price < fast_ma
|
||||
rsi_sell_zone = (g.rsi_oversold + 5) < rsi < g.rsi_overbought
|
||||
macd_bearish = macd_hist < 0
|
||||
|
||||
# Bollinger rejection: price near upper band = mean reversion short
|
||||
bb_bounce_short = False
|
||||
if bb_upper is not None and idx < len(bb_upper):
|
||||
bb_bounce_short = current_price >= bb_upper[idx] * 0.995
|
||||
|
||||
short_signal = (bearish_trend and rsi_sell_zone and macd_bearish and volume_active) or \
|
||||
(bb_bounce_short and rsi > 65 and volume_active)
|
||||
|
||||
if short_signal:
|
||||
# For shorts: stop is ABOVE, take profit is BELOW
|
||||
short_stop = current_price + (atr * g.stop_loss_atr_mult)
|
||||
short_tp = current_price - (atr * g.take_profit_atr_mult)
|
||||
confidence = min(1.0, (vol_ratio - 1) * 0.4 + 0.3)
|
||||
if bb_bounce_short:
|
||||
confidence = min(1.0, confidence + 0.15)
|
||||
|
||||
return {
|
||||
'action': 'short',
|
||||
'amount_pct': g.max_position_pct,
|
||||
'stop_loss': short_stop,
|
||||
'take_profit': short_tp,
|
||||
'short_stop_loss': short_stop,
|
||||
'short_take_profit': short_tp,
|
||||
'confidence': confidence,
|
||||
}
|
||||
|
||||
return {'action': 'hold'}
|
||||
|
||||
return strategy_fn
|
||||
|
||||
|
||||
def genome_to_signals(genome, df: pd.DataFrame) -> Dict[str, np.ndarray]:
|
||||
"""
|
||||
Vectorized signal generation for fast backtesting (long + short scalping).
|
||||
Pre-computes all indicators and generates entry/exit signal arrays for both sides.
|
||||
|
||||
Returns dict with:
|
||||
'entry': boolean array (True = long entry signal)
|
||||
'exit': boolean array (True = long exit signal)
|
||||
'short_entry': boolean array (True = short entry signal)
|
||||
'short_exit': boolean array (True = short exit/cover signal)
|
||||
'stop_loss': float array (long SL price at each bar)
|
||||
'take_profit': float array (long TP price at each bar)
|
||||
'short_stop_loss': float array (short SL price, ABOVE entry)
|
||||
'short_take_profit': float array (short TP price, BELOW entry)
|
||||
'amount_pct': float (position size)
|
||||
'indicators': dict of pre-computed indicator arrays
|
||||
"""
|
||||
g = genome
|
||||
ind = _precompute_indicators(df, g)
|
||||
|
||||
min_idx = max(g.slow_ma_period, 30)
|
||||
|
||||
# --- LONG ENTRY ---
|
||||
bullish_trend = ind['close'] > ind['fast_ma']
|
||||
rsi_buy_zone = (ind['rsi'] > g.rsi_oversold) & (ind['rsi'] < (g.rsi_overbought - 5))
|
||||
macd_bullish = ind['macd_hist'] > 0
|
||||
volume_active = ind['vol_ratio'] > g.volume_surge_threshold
|
||||
|
||||
# BB bounce long
|
||||
bb_bounce_long = np.zeros(len(ind['close']), dtype=bool)
|
||||
if 'bb_lower' in ind:
|
||||
bb_bounce_long = ind['close'] <= ind['bb_lower'] * 1.005
|
||||
bb_long_entry = bb_bounce_long & (ind['rsi'] < 35) & volume_active
|
||||
|
||||
entry = (bullish_trend & rsi_buy_zone & macd_bullish & volume_active) | bb_long_entry
|
||||
entry[:min_idx] = False
|
||||
|
||||
# Long exit: RSI overbought or MACD turns bearish
|
||||
exit_signal = (ind['rsi'] > g.rsi_overbought) | (ind['macd_hist'] < 0)
|
||||
exit_signal[:min_idx] = False
|
||||
|
||||
# --- SHORT ENTRY ---
|
||||
bearish_trend = ind['close'] < ind['fast_ma']
|
||||
rsi_sell_zone = (ind['rsi'] > (g.rsi_oversold + 5)) & (ind['rsi'] < g.rsi_overbought)
|
||||
macd_bearish = ind['macd_hist'] < 0
|
||||
|
||||
# BB bounce short
|
||||
bb_bounce_short = np.zeros(len(ind['close']), dtype=bool)
|
||||
if 'bb_upper' in ind:
|
||||
bb_bounce_short = ind['close'] >= ind['bb_upper'] * 0.995
|
||||
bb_short_entry = bb_bounce_short & (ind['rsi'] > 65) & volume_active
|
||||
|
||||
short_entry = (bearish_trend & rsi_sell_zone & macd_bearish & volume_active) | bb_short_entry
|
||||
short_entry[:min_idx] = False
|
||||
|
||||
# Short exit: RSI oversold or MACD turns bullish
|
||||
short_exit = (ind['rsi'] < g.rsi_oversold) | (ind['macd_hist'] > 0)
|
||||
short_exit[:min_idx] = False
|
||||
|
||||
# SL/TP levels (long)
|
||||
stop_loss = ind['close'] - (ind['atr'] * g.stop_loss_atr_mult)
|
||||
take_profit = ind['close'] + (ind['atr'] * g.take_profit_atr_mult)
|
||||
|
||||
# SL/TP levels (short - inverted)
|
||||
short_stop_loss = ind['close'] + (ind['atr'] * g.stop_loss_atr_mult)
|
||||
short_take_profit = ind['close'] - (ind['atr'] * g.take_profit_atr_mult)
|
||||
|
||||
return {
|
||||
'entry': entry,
|
||||
'exit': exit_signal,
|
||||
'short_entry': short_entry,
|
||||
'short_exit': short_exit,
|
||||
'stop_loss': stop_loss,
|
||||
'take_profit': take_profit,
|
||||
'short_stop_loss': short_stop_loss,
|
||||
'short_take_profit': short_take_profit,
|
||||
'amount_pct': g.max_position_pct,
|
||||
'max_hold_candles': g.max_hold_candles,
|
||||
'indicators': ind,
|
||||
}
|
||||
|
||||
|
||||
def evaluate_genome_signal(genome, df: pd.DataFrame, idx: int) -> Dict:
|
||||
"""
|
||||
Evaluate a genome's strategy at a specific index.
|
||||
Returns signal dict with action, confidence, stop_loss, take_profit.
|
||||
Used by the RL agent to get the GA signal component.
|
||||
Supports long, short, and hold signals.
|
||||
"""
|
||||
if idx < max(genome.slow_ma_period, 30) or idx >= len(df):
|
||||
return {'signal': 'hold', 'confidence': 0.0}
|
||||
|
||||
strategy_fn = genome_to_strategy(genome)
|
||||
state = {'position': None, 'capital': 1000, 'num_trades': 0}
|
||||
result = strategy_fn(state, idx, df, genome.to_dict())
|
||||
|
||||
action = result.get('action', 'hold')
|
||||
# Normalize: 'short' action -> 'sell' signal for RL
|
||||
signal = action
|
||||
if action == 'short':
|
||||
signal = 'sell'
|
||||
elif action == 'cover':
|
||||
signal = 'buy'
|
||||
|
||||
return {
|
||||
'signal': signal,
|
||||
'confidence': result.get('confidence', 0.0),
|
||||
'stop_loss': result.get('stop_loss'),
|
||||
'take_profit': result.get('take_profit'),
|
||||
'short_stop_loss': result.get('short_stop_loss'),
|
||||
'short_take_profit': result.get('short_take_profit'),
|
||||
'position_pct': result.get('amount_pct', 0.1),
|
||||
}
|
||||
|
||||
|
||||
# --- Pre-computation helpers ---
|
||||
|
||||
def _precompute_indicators(df: pd.DataFrame, genome) -> Dict[str, np.ndarray]:
|
||||
"""Pre-compute all indicators as numpy arrays in one pass."""
|
||||
close = df['close'].values.astype(np.float64)
|
||||
high = df['high'].values.astype(np.float64)
|
||||
low = df['low'].values.astype(np.float64)
|
||||
volume = df['volume'].values.astype(np.float64)
|
||||
|
||||
# Moving averages (simple cumsum trick)
|
||||
fast_ma = _rolling_mean(close, genome.fast_ma_period)
|
||||
slow_ma = _rolling_mean(close, genome.slow_ma_period)
|
||||
|
||||
# RSI
|
||||
rsi = _compute_rsi_array(close, genome.rsi_period)
|
||||
|
||||
# MACD
|
||||
ema_fast = _ema_array(close, genome.macd_fast)
|
||||
ema_slow = _ema_array(close, genome.macd_slow)
|
||||
macd_line = ema_fast - ema_slow
|
||||
signal_line = _ema_array(macd_line, genome.macd_signal)
|
||||
macd_hist = macd_line - signal_line
|
||||
|
||||
# ATR
|
||||
atr = _compute_atr_array(high, low, close, genome.atr_period)
|
||||
|
||||
# Volume ratio (current volume / 20-period SMA of volume)
|
||||
vol_sma = _rolling_mean(volume, 20)
|
||||
vol_ratio = np.where(vol_sma > 0, volume / vol_sma, 1.0)
|
||||
|
||||
# Bollinger Bands for mean-reversion scalping
|
||||
bb_mid = _rolling_mean(close, genome.bb_period)
|
||||
bb_std = np.full(len(close), 0.0)
|
||||
for i in range(genome.bb_period - 1, len(close)):
|
||||
bb_std[i] = np.std(close[max(0, i - genome.bb_period + 1):i + 1])
|
||||
for i in range(genome.bb_period - 1):
|
||||
bb_std[i] = np.std(close[:i + 1]) if i > 0 else 0.0
|
||||
bb_upper = bb_mid + genome.bb_std * bb_std
|
||||
bb_lower = bb_mid - genome.bb_std * bb_std
|
||||
|
||||
return {
|
||||
'close': close,
|
||||
'high': high,
|
||||
'low': low,
|
||||
'volume': volume,
|
||||
'fast_ma': fast_ma,
|
||||
'slow_ma': slow_ma,
|
||||
'rsi': rsi,
|
||||
'macd_line': macd_line,
|
||||
'macd_signal': signal_line,
|
||||
'macd_hist': macd_hist,
|
||||
'atr': atr,
|
||||
'vol_ratio': vol_ratio,
|
||||
'bb_upper': bb_upper,
|
||||
'bb_mid': bb_mid,
|
||||
'bb_lower': bb_lower,
|
||||
}
|
||||
|
||||
|
||||
def _rolling_mean(arr: np.ndarray, period: int) -> np.ndarray:
|
||||
"""Fast rolling mean using cumsum."""
|
||||
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
|
||||
# Fill initial values with expanding mean
|
||||
for i in range(period - 1):
|
||||
result[i] = np.mean(arr[:i + 1])
|
||||
return result
|
||||
|
||||
|
||||
def _ema_array(arr: np.ndarray, period: int) -> np.ndarray:
|
||||
"""Compute EMA for entire array in one pass."""
|
||||
n = len(arr)
|
||||
result = np.empty(n)
|
||||
multiplier = 2.0 / (period + 1)
|
||||
result[0] = arr[0]
|
||||
for i in range(1, n):
|
||||
result[i] = (arr[i] - result[i - 1]) * multiplier + result[i - 1]
|
||||
return result
|
||||
|
||||
|
||||
def _compute_rsi_array(close: np.ndarray, period: int) -> np.ndarray:
|
||||
"""Compute RSI for entire array using Wilder's smoothing."""
|
||||
n = len(close)
|
||||
rsi = np.full(n, 50.0)
|
||||
if n < period + 1:
|
||||
return rsi
|
||||
|
||||
deltas = np.diff(close)
|
||||
gains = np.where(deltas > 0, deltas, 0.0)
|
||||
losses = np.where(deltas < 0, -deltas, 0.0)
|
||||
|
||||
# Initial averages (SMA)
|
||||
avg_gain = np.mean(gains[:period])
|
||||
avg_loss = np.mean(losses[:period])
|
||||
|
||||
if avg_loss > 0:
|
||||
rs = avg_gain / avg_loss
|
||||
rsi[period] = 100.0 - (100.0 / (1.0 + rs))
|
||||
else:
|
||||
rsi[period] = 100.0
|
||||
|
||||
# Wilder's smoothing for remaining
|
||||
for i in range(period, len(deltas)):
|
||||
avg_gain = (avg_gain * (period - 1) + gains[i]) / period
|
||||
avg_loss = (avg_loss * (period - 1) + losses[i]) / period
|
||||
if avg_loss > 0:
|
||||
rs = avg_gain / avg_loss
|
||||
rsi[i + 1] = 100.0 - (100.0 / (1.0 + rs))
|
||||
else:
|
||||
rsi[i + 1] = 100.0
|
||||
|
||||
return rsi
|
||||
|
||||
|
||||
def _compute_atr_array(high: np.ndarray, low: np.ndarray,
|
||||
close: np.ndarray, period: int) -> np.ndarray:
|
||||
"""Compute ATR for entire array using Wilder's smoothing."""
|
||||
n = len(close)
|
||||
atr = np.full(n, 0.0)
|
||||
if n < 2:
|
||||
return atr
|
||||
|
||||
# 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]))
|
||||
|
||||
# Initial ATR = SMA of first `period` TRs
|
||||
if n >= period:
|
||||
atr[period - 1] = np.mean(tr[:period])
|
||||
# Wilder's smoothing
|
||||
for i in range(period, n):
|
||||
atr[i] = (atr[i - 1] * (period - 1) + tr[i]) / period
|
||||
# Fill early values with expanding mean
|
||||
for i in range(period - 1):
|
||||
atr[i] = np.mean(tr[:i + 1])
|
||||
else:
|
||||
for i in range(n):
|
||||
atr[i] = np.mean(tr[:i + 1])
|
||||
|
||||
return atr
|
||||
+384
-52
@@ -1,11 +1,23 @@
|
||||
"""
|
||||
Trade Executor
|
||||
Executes live trades via Alpaca broker with position management.
|
||||
|
||||
ENHANCED with:
|
||||
- Proper correlation-based position limits (using max_correlated_positions config)
|
||||
- Drawdown-scaled position sizing
|
||||
- Enforced stop losses with tighter defaults
|
||||
- Active position reduction on circuit breaker / drawdown stop
|
||||
- Per-sector position limits
|
||||
- HARD maximum SHORT position cap (broker-level enforcement)
|
||||
- Per-position stop loss defaults (ATR-based, always set)
|
||||
- Broker-position sync check (authoritative position counting)
|
||||
- Yesterday-close drawdown circuit breaker (faster response)
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from loguru import logger
|
||||
import numpy as np
|
||||
|
||||
|
||||
class TradingExecutor:
|
||||
@@ -28,6 +40,109 @@ class TradingExecutor:
|
||||
self.config = config
|
||||
self.commission_rate = config.get('commission_rate', 0.001)
|
||||
|
||||
# Stop loss / take profit defaults (can be overridden by strategy)
|
||||
self.default_stop_loss_pct = config.get('stop_loss_pct', 1.0) / 100
|
||||
self.default_take_profit_pct = config.get('take_profit_pct', 1.5) / 100
|
||||
|
||||
# Track recent prices for volatility calculation
|
||||
self.recent_returns: Dict[str, List[float]] = {}
|
||||
|
||||
# Sector map for correlation
|
||||
self.sector_map = {
|
||||
'SPY': 'broad_market', 'QQQ': 'tech', 'IWM': 'small_cap', 'DIA': 'blue_chip',
|
||||
'AAPL': 'tech', 'MSFT': 'tech', 'GOOGL': 'tech', 'AMZN': 'tech', 'NVDA': 'tech', 'META': 'tech',
|
||||
'TSLA': 'auto/energy',
|
||||
'JPM': 'financials', 'BAC': 'financials', 'GS': 'financials', 'MS': 'financials',
|
||||
'JNJ': 'healthcare', 'UNH': 'healthcare',
|
||||
'WMT': 'consumer', 'PG': 'consumer',
|
||||
'XLE': 'energy', 'CVX': 'energy', 'XOM': 'energy',
|
||||
'LMT': 'defense', 'RTX': 'defense', 'NOC': 'defense', 'GD': 'defense',
|
||||
'USO': 'commodities', 'GLD': 'metals',
|
||||
}
|
||||
|
||||
def _get_sector(self, symbol: str) -> str:
|
||||
return self.sector_map.get(symbol, 'other')
|
||||
|
||||
def _update_volatility(self, symbol: str, current_price: float):
|
||||
"""Track price returns for volatility calculation"""
|
||||
if symbol not in self.recent_returns:
|
||||
self.recent_returns[symbol] = []
|
||||
# Returns are tracked externally; just store prices
|
||||
# Volatility is computed by safety manager
|
||||
|
||||
def _apply_drawdown_scaling(self, invest_amount: float, portfolio_value: float) -> float:
|
||||
"""Scale down position size based on current drawdown"""
|
||||
scale = self.safety.get_position_scale(portfolio_value)
|
||||
vol_mult = self.safety.current_volatility_mult
|
||||
return invest_amount * scale * vol_mult
|
||||
|
||||
def _check_correlation_limits(self, symbol: str, direction: str,
|
||||
open_positions: List[Dict]) -> Tuple[bool, str]:
|
||||
"""
|
||||
Check if adding this position would violate correlation limits.
|
||||
|
||||
Returns: (allowed: bool, reason: str)
|
||||
"""
|
||||
max_corr = self.safety.max_correlated_positions
|
||||
max_same_dir = self.safety.max_same_direction
|
||||
|
||||
# Count current positions in same direction
|
||||
same_dir_positions = [
|
||||
p for p in open_positions
|
||||
if p.get('metadata', {}).get('direction') == direction
|
||||
]
|
||||
|
||||
# Check same-direction limit
|
||||
if len(same_dir_positions) >= max_same_dir:
|
||||
return False, (f"Max {max_same_dir} {direction} positions reached "
|
||||
f"(have {len(same_dir_positions)})")
|
||||
|
||||
# Check sector correlation limit
|
||||
sector = self._get_sector(symbol)
|
||||
if sector != 'other' and sector != 'broad_market':
|
||||
same_sector_same_dir = [
|
||||
p for p in same_dir_positions
|
||||
if self._get_sector(p.get('symbol', '')) == sector
|
||||
]
|
||||
if len(same_sector_same_dir) >= max_corr:
|
||||
return False, (f"Max {max_corr} {direction} positions in "
|
||||
f"{sector} sector (have {len(same_sector_same_dir)})")
|
||||
|
||||
# Also limit broad market ETF correlation (SPY, QQQ, IWM, DIA all correlate)
|
||||
if symbol in ('SPY', 'QQQ', 'IWM', 'DIA'):
|
||||
market_etfs = [p for p in same_dir_positions if p.get('symbol') in ('SPY', 'QQQ', 'IWM', 'DIA')]
|
||||
if len(market_etfs) >= 2:
|
||||
return False, f"Max 2 market ETFs as {direction} positions (have {len(market_etfs)})"
|
||||
|
||||
return True, "OK"
|
||||
|
||||
def _enforce_stop_loss_tightness(self, stop_loss: float, entry_price: float,
|
||||
direction: str) -> float:
|
||||
"""
|
||||
Ensure stop loss is tight enough - don't let wide stops blow up risk.
|
||||
If strategy provides too-wide stop, override with our default.
|
||||
"""
|
||||
if stop_loss is None:
|
||||
return None
|
||||
|
||||
if direction == 'short':
|
||||
# Short stop: price goes UP to hit stop (bad)
|
||||
stop_distance_pct = (stop_loss - entry_price) / entry_price
|
||||
else:
|
||||
# Long stop: price goes DOWN to hit stop (bad)
|
||||
stop_distance_pct = (entry_price - stop_loss) / entry_price
|
||||
|
||||
# If strategy stop is wider than 3x our default, use our default instead
|
||||
if stop_distance_pct > self.default_stop_loss_pct * 3:
|
||||
logger.warning(f"Stop loss {stop_distance_pct:.2%} too wide, "
|
||||
f"using default {self.default_stop_loss_pct:.2%}")
|
||||
if direction == 'short':
|
||||
return entry_price * (1 + self.default_stop_loss_pct)
|
||||
else:
|
||||
return entry_price * (1 - self.default_stop_loss_pct)
|
||||
|
||||
return stop_loss
|
||||
|
||||
def execute_signal(self, symbol: str, action: int, current_price: float,
|
||||
strategy_params: Dict = None, combined_equity: float = None) -> Optional[Dict]:
|
||||
"""
|
||||
@@ -71,16 +186,41 @@ class TradingExecutor:
|
||||
# Update safety peak
|
||||
self.safety.update_peak_equity(safety_equity)
|
||||
|
||||
# Get current positions
|
||||
# Get current open positions from STORE (known trades)
|
||||
open_positions = self.store.get_open_positions()
|
||||
open_for_symbol = [p for p in open_positions if p['symbol'] == symbol]
|
||||
num_positions = len(set(p['symbol'] for p in open_positions))
|
||||
|
||||
# DIRECTIONAL DIVERSITY: Don't let all positions be the same direction
|
||||
all_directions = [p.get('metadata', {}).get('direction', 'long') for p in open_positions]
|
||||
num_long = sum(1 for d in all_directions if d == 'long')
|
||||
num_short = sum(1 for d in all_directions if d == 'short')
|
||||
max_one_direction = max(num_positions - 1, 3) # At least allow 3 of same direction
|
||||
# CRITICAL: Get ACTUAL broker positions (authoritative) for hard cap checks
|
||||
# Store only knows what WE opened — broker knows everything (including manual trades)
|
||||
try:
|
||||
broker_positions = self.broker.get_positions()
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not get broker positions for hard cap check: {e}")
|
||||
broker_positions = []
|
||||
|
||||
# HARD SHORT CAP CHECK: If broker already has max_short_positions shorts, block more
|
||||
# This is the PRIMARY defense against the "13 shorts" scenario
|
||||
broker_short_count = sum(
|
||||
1 for p in broker_positions
|
||||
if p.get('side') == 'sell_short' or p.get('qty', 0) < 0
|
||||
)
|
||||
if action in (5, 6) and broker_short_count >= self.safety.max_short_positions:
|
||||
logger.warning(
|
||||
f"HARD CAP: Broker has {broker_short_count} shorts "
|
||||
f"(max={self.safety.max_short_positions}). Short blocked."
|
||||
)
|
||||
return None
|
||||
|
||||
# BROKER POSITION SYNC WARNING
|
||||
store_short_count = sum(
|
||||
1 for p in open_positions
|
||||
if p.get('metadata', {}).get('direction') == 'short'
|
||||
)
|
||||
if broker_short_count != store_short_count:
|
||||
logger.warning(
|
||||
f"BROKER/STORE SYNC MISMATCH: Broker={broker_short_count} shorts, "
|
||||
f"Store={store_short_count} shorts. Using broker as authoritative."
|
||||
)
|
||||
|
||||
# BUY LONG actions
|
||||
if action in (1, 2):
|
||||
@@ -88,33 +228,42 @@ class TradingExecutor:
|
||||
logger.debug(f"Already have position in {symbol}, skipping buy")
|
||||
return None
|
||||
|
||||
# Block if too many longs already
|
||||
if num_long >= max_one_direction:
|
||||
logger.debug(f"Too many longs ({num_long}), blocking new long on {symbol}")
|
||||
# CORRELATION CHECK: Don't pile into same direction (uses store positions)
|
||||
allowed, reason = self._check_correlation_limits(symbol, 'long', open_positions)
|
||||
if not allowed:
|
||||
logger.debug(f"Correlation blocked LONG {symbol}: {reason}")
|
||||
return None
|
||||
|
||||
pct = 0.25 if action == 1 else 0.50
|
||||
invest = cash * pct
|
||||
|
||||
max_position_pct = self.config.get('max_position_pct', 12) / 100
|
||||
max_invest = equity * max_position_pct
|
||||
# Apply drawdown + volatility scaling
|
||||
invest = self._apply_drawdown_scaling(invest, safety_equity)
|
||||
|
||||
max_position_pct = self.config.get('max_position_pct', 8) / 100
|
||||
effective_max = max_position_pct * self.safety.get_position_scale(safety_equity)
|
||||
max_invest = safety_equity * effective_max
|
||||
if invest > max_invest:
|
||||
invest = max_invest
|
||||
|
||||
if invest < self.config.get('min_trade_value', 3):
|
||||
if invest < self.config.get('min_trade_value', 50):
|
||||
logger.debug(f"Invest amount ${invest:.2f} below minimum")
|
||||
return None
|
||||
|
||||
shares = int(invest / current_price)
|
||||
if shares < 1:
|
||||
# Try fractional
|
||||
shares = round(invest / current_price, 4)
|
||||
if shares * current_price < self.config.get('min_trade_value', 3):
|
||||
if shares * current_price < self.config.get('min_trade_value', 50):
|
||||
logger.debug(f"Share value ${shares * current_price:.2f} below minimum")
|
||||
return None
|
||||
|
||||
allowed, reason = self.safety.validate_trade(
|
||||
symbol, 'buy', shares, current_price, safety_equity, num_positions
|
||||
symbol, 'buy', shares, current_price, safety_equity, open_positions,
|
||||
broker_positions=broker_positions
|
||||
)
|
||||
if not allowed:
|
||||
logger.debug(f"Trade blocked: {reason}")
|
||||
logger.debug(f"Trade blocked by safety: {reason}")
|
||||
return None
|
||||
|
||||
try:
|
||||
@@ -124,9 +273,31 @@ class TradingExecutor:
|
||||
logger.error(f"Error placing buy order: {e}")
|
||||
return None
|
||||
|
||||
# Stop loss and take profit with tightness enforcement + defaults
|
||||
stop_loss = strategy_params.get('stop_loss')
|
||||
take_profit = strategy_params.get('take_profit')
|
||||
|
||||
# CRITICAL: If GA didn't provide stop loss, use SAFETY MANAGER DEFAULTS
|
||||
# This ensures EVERY position has a stop loss — the #1 defense against blowup
|
||||
if stop_loss is None:
|
||||
stop_loss = self.safety.get_default_stop_loss(current_price, 'long')
|
||||
logger.info(f"Applying DEFAULT stop loss for LONG {symbol}: ${stop_loss:.4f} "
|
||||
f"({((current_price - stop_loss) / current_price) * 100:.2f}% from entry)")
|
||||
|
||||
# Enforce tightness ceiling on the stop
|
||||
stop_loss = self._enforce_stop_loss_tightness(stop_loss, current_price, 'long')
|
||||
|
||||
# CRITICAL: If GA didn't provide take profit, use SAFETY MANAGER DEFAULTS
|
||||
if take_profit is None or take_profit == current_price:
|
||||
take_profit = self.safety.get_default_take_profit(current_price, 'long')
|
||||
logger.info(f"Applying DEFAULT take profit for LONG {symbol}: ${take_profit:.4f}")
|
||||
|
||||
if take_profit and take_profit != current_price:
|
||||
tp_distance = (take_profit - current_price) / current_price
|
||||
if tp_distance < self.default_take_profit_pct * 0.5:
|
||||
# TP too tight relative to default
|
||||
take_profit = current_price * (1 + self.default_take_profit_pct)
|
||||
|
||||
trade = {
|
||||
'symbol': symbol,
|
||||
'side': 'buy',
|
||||
@@ -143,6 +314,7 @@ class TradingExecutor:
|
||||
'invest_pct': pct,
|
||||
'equity_at_entry': equity,
|
||||
'direction': 'long',
|
||||
'sector': self._get_sector(symbol),
|
||||
},
|
||||
}
|
||||
trade_id = self.store.record_trade(trade)
|
||||
@@ -164,10 +336,9 @@ class TradingExecutor:
|
||||
close_shares = abs(total_shares)
|
||||
|
||||
close_shares = round(close_shares, 4)
|
||||
if close_shares * current_price < self.config.get('min_trade_value', 1):
|
||||
if close_shares * current_price < self.config.get('min_trade_value', 50):
|
||||
close_shares = abs(total_shares)
|
||||
|
||||
# Determine order side (opposite of position direction)
|
||||
close_side = 'buy' if is_short else 'sell'
|
||||
|
||||
try:
|
||||
@@ -201,27 +372,12 @@ class TradingExecutor:
|
||||
'direction': 'short' if is_short else 'long',
|
||||
}
|
||||
else:
|
||||
self.safety.record_trade_result(pnl)
|
||||
self.store.close_position(
|
||||
position['id'], current_price, datetime.utcnow(),
|
||||
self.safety.record_trade_result(pnl * (close_shares / abs(total_shares)))
|
||||
self.store.reduce_position(
|
||||
position['id'], close_shares, current_price, datetime.utcnow(),
|
||||
fees=close_shares * current_price * self.commission_rate
|
||||
)
|
||||
|
||||
remaining = abs(total_shares) - close_shares
|
||||
if remaining > 0:
|
||||
self.store.record_trade({
|
||||
'symbol': symbol,
|
||||
'side': position['side'],
|
||||
'amount': remaining,
|
||||
'entry_price': position['entry_price'],
|
||||
'entry_time': position['entry_time'],
|
||||
'strategy_id': position.get('strategy_id', 'auto'),
|
||||
'stop_loss': position.get('stop_loss'),
|
||||
'take_profit': position.get('take_profit'),
|
||||
'status': 'open',
|
||||
'metadata': position.get('metadata', {}),
|
||||
})
|
||||
|
||||
return {
|
||||
'symbol': symbol,
|
||||
'side': close_side,
|
||||
@@ -230,6 +386,7 @@ class TradingExecutor:
|
||||
'pnl': round(pnl, 2),
|
||||
'action': action,
|
||||
'direction': 'short' if is_short else 'long',
|
||||
'reduced': True,
|
||||
}
|
||||
|
||||
# SHORT actions
|
||||
@@ -238,33 +395,41 @@ class TradingExecutor:
|
||||
logger.debug(f"Already have position in {symbol}, skipping short")
|
||||
return None
|
||||
|
||||
# Block if too many shorts already
|
||||
if num_short >= max_one_direction:
|
||||
logger.debug(f"Too many shorts ({num_short}), blocking new short on {symbol}")
|
||||
# CORRELATION CHECK
|
||||
allowed, reason = self._check_correlation_limits(symbol, 'short', open_positions)
|
||||
if not allowed:
|
||||
logger.debug(f"Correlation blocked SHORT {symbol}: {reason}")
|
||||
return None
|
||||
|
||||
pct = 0.25 if action == 5 else 0.50
|
||||
invest = cash * pct
|
||||
|
||||
max_position_pct = self.config.get('max_position_pct', 12) / 100
|
||||
max_invest = equity * max_position_pct
|
||||
# Apply drawdown + volatility scaling
|
||||
invest = self._apply_drawdown_scaling(invest, safety_equity)
|
||||
|
||||
max_position_pct = self.config.get('max_position_pct', 8) / 100
|
||||
effective_max = max_position_pct * self.safety.get_position_scale(safety_equity)
|
||||
max_invest = safety_equity * effective_max
|
||||
if invest > max_invest:
|
||||
invest = max_invest
|
||||
|
||||
if invest < self.config.get('min_trade_value', 3):
|
||||
if invest < self.config.get('min_trade_value', 50):
|
||||
logger.debug(f"Short invest amount ${invest:.2f} below minimum")
|
||||
return None
|
||||
|
||||
shares = int(invest / current_price)
|
||||
if shares < 1:
|
||||
shares = round(invest / current_price, 4)
|
||||
if shares * current_price < self.config.get('min_trade_value', 3):
|
||||
if shares * current_price < self.config.get('min_trade_value', 50):
|
||||
logger.debug(f"Short share value ${shares * current_price:.2f} below minimum")
|
||||
return None
|
||||
|
||||
allowed, reason = self.safety.validate_trade(
|
||||
symbol, 'sell', shares, current_price, safety_equity, num_positions
|
||||
symbol, 'sell', shares, current_price, safety_equity, open_positions,
|
||||
broker_positions=broker_positions
|
||||
)
|
||||
if not allowed:
|
||||
logger.debug(f"Short blocked: {reason}")
|
||||
logger.debug(f"Short blocked by safety: {reason}")
|
||||
return None
|
||||
|
||||
try:
|
||||
@@ -274,10 +439,24 @@ class TradingExecutor:
|
||||
logger.error(f"Error placing short order: {e}")
|
||||
return None
|
||||
|
||||
# For shorts, stop_loss is ABOVE entry, take_profit is BELOW
|
||||
# For shorts: stop_loss is ABOVE entry (price rises = bad), take_profit is BELOW
|
||||
stop_loss = strategy_params.get('short_stop_loss') or strategy_params.get('stop_loss')
|
||||
take_profit = strategy_params.get('short_take_profit') or strategy_params.get('take_profit')
|
||||
|
||||
# CRITICAL: If GA didn't provide stop loss, use SAFETY MANAGER DEFAULTS
|
||||
if stop_loss is None:
|
||||
stop_loss = self.safety.get_default_stop_loss(current_price, 'short')
|
||||
logger.info(f"Applying DEFAULT stop loss for SHORT {symbol}: ${stop_loss:.4f} "
|
||||
f"({((stop_loss - current_price) / current_price) * 100:.2f}% from entry)")
|
||||
|
||||
# Enforce tightness ceiling on the stop
|
||||
stop_loss = self._enforce_stop_loss_tightness(stop_loss, current_price, 'short')
|
||||
|
||||
# CRITICAL: If GA didn't provide take profit, use SAFETY MANAGER DEFAULTS
|
||||
if take_profit is None:
|
||||
take_profit = self.safety.get_default_take_profit(current_price, 'short')
|
||||
logger.info(f"Applying DEFAULT take profit for SHORT {symbol}: ${take_profit:.4f}")
|
||||
|
||||
trade = {
|
||||
'symbol': symbol,
|
||||
'side': 'sell',
|
||||
@@ -294,6 +473,7 @@ class TradingExecutor:
|
||||
'invest_pct': pct,
|
||||
'equity_at_entry': equity,
|
||||
'direction': 'short',
|
||||
'sector': self._get_sector(symbol),
|
||||
},
|
||||
}
|
||||
trade_id = self.store.record_trade(trade)
|
||||
@@ -305,12 +485,60 @@ class TradingExecutor:
|
||||
def check_exits(self, current_prices: Dict[str, float]) -> List[Dict]:
|
||||
"""
|
||||
Check all open positions for stop loss / take profit exits.
|
||||
Handles both long and short positions.
|
||||
Also checks for drawdown stop and forces partial closes if needed.
|
||||
Returns list of closed trades.
|
||||
"""
|
||||
closed = []
|
||||
open_positions = self.store.get_open_positions()
|
||||
|
||||
# Get combined portfolio for drawdown check
|
||||
try:
|
||||
portfolio = self.broker.get_portfolio()
|
||||
equity = portfolio['equity']
|
||||
except Exception:
|
||||
equity = 0
|
||||
|
||||
# Update yesterday's equity reference at start of each day
|
||||
self.safety.update_yesterday_equity(equity)
|
||||
|
||||
# Check if drawdown stop is triggered (peak-equity based, slow response)
|
||||
should_reduce_dd, dd_reason, reduce_pct = False, "", 0.0
|
||||
if equity > 0 and open_positions:
|
||||
should_reduce_dd, dd_reason, reduce_pct = \
|
||||
self.safety.check_drawdown_stop(equity, open_positions)
|
||||
|
||||
# NEW: Check YESTERDAY-CLOSE drawdown (FASTER response, catches intraday blowups)
|
||||
should_reduce_yc, yc_reason, yc_reduce_pct = False, "", 0.0
|
||||
if equity > 0 and open_positions:
|
||||
should_reduce_yc, yc_reason, yc_reduce_pct = \
|
||||
self.safety.check_yesterday_close_drawdown(equity)
|
||||
# Use whichever is more urgent
|
||||
if should_reduce_yc and not should_reduce_dd:
|
||||
should_reduce_dd = True
|
||||
dd_reason = yc_reason
|
||||
reduce_pct = yc_reduce_pct
|
||||
logger.warning(f"YESTERDAY-CLOSE CIRCUIT BREAKER TRIGGERED: {yc_reason}")
|
||||
|
||||
# CRITICAL: If we have 10+ positions all losing, force reduce regardless of drawdown
|
||||
# This catches the "13 shorts all losing" scenario before drawdown thresholds are hit
|
||||
if len(open_positions) >= 10:
|
||||
losing_count = 0
|
||||
for pos in open_positions:
|
||||
sym = pos.get('symbol')
|
||||
px = current_prices.get(sym)
|
||||
if px and pos.get('entry_price'):
|
||||
is_short = pos.get('metadata', {}).get('direction') == 'short'
|
||||
if is_short and px > pos['entry_price']:
|
||||
losing_count += 1
|
||||
elif not is_short and px < pos['entry_price']:
|
||||
losing_count += 1
|
||||
if losing_count >= 8:
|
||||
logger.critical(
|
||||
f"CRISIS MODE: {losing_count}/{len(open_positions)} positions losing money! "
|
||||
f"Force-reducing 50% of all positions immediately."
|
||||
)
|
||||
return self.force_reduce_all_positions(0.50, current_prices)
|
||||
|
||||
for position in open_positions:
|
||||
symbol = position['symbol']
|
||||
price = current_prices.get(symbol)
|
||||
@@ -319,10 +547,13 @@ class TradingExecutor:
|
||||
|
||||
should_exit = False
|
||||
exit_reason = ''
|
||||
should_reduce = False
|
||||
reduce_amount = 0
|
||||
|
||||
is_short = position.get('metadata', {}).get('direction') == 'short'
|
||||
|
||||
# Normal stop loss / take profit checks
|
||||
if is_short:
|
||||
# Short position: stop_loss is ABOVE entry, take_profit is BELOW
|
||||
if position.get('stop_loss') and price >= position['stop_loss']:
|
||||
should_exit = True
|
||||
exit_reason = 'stop_loss'
|
||||
@@ -330,7 +561,6 @@ class TradingExecutor:
|
||||
should_exit = True
|
||||
exit_reason = 'take_profit'
|
||||
else:
|
||||
# Long position: stop_loss is BELOW entry, take_profit is ABOVE
|
||||
if position.get('stop_loss') and price <= position['stop_loss']:
|
||||
should_exit = True
|
||||
exit_reason = 'stop_loss'
|
||||
@@ -338,8 +568,13 @@ class TradingExecutor:
|
||||
should_exit = True
|
||||
exit_reason = 'take_profit'
|
||||
|
||||
# Drawdown stop: force partial close
|
||||
if not should_exit and should_reduce_dd:
|
||||
should_reduce = True
|
||||
reduce_amount = abs(position['amount']) * reduce_pct
|
||||
exit_reason = f'drawdown_stop'
|
||||
|
||||
if should_exit:
|
||||
# Close side is opposite of position direction
|
||||
close_side = 'buy' if is_short else 'sell'
|
||||
try:
|
||||
self.broker.place_market_order(
|
||||
@@ -373,6 +608,96 @@ class TradingExecutor:
|
||||
'direction': 'short' if is_short else 'long',
|
||||
})
|
||||
|
||||
elif should_reduce and reduce_amount > 0:
|
||||
# Partial close due to drawdown stop
|
||||
reduce_amount = round(reduce_amount, 4)
|
||||
if reduce_amount < 0.0001:
|
||||
continue
|
||||
|
||||
close_side = 'buy' if is_short else 'sell'
|
||||
try:
|
||||
self.broker.place_market_order(
|
||||
symbol, reduce_amount, close_side
|
||||
)
|
||||
logger.warning(f"DRAWDOWN REDUCE ({reduce_amount:.2f} of {position['amount']:.2f}) "
|
||||
f"{symbol} @ ${price:.4f}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error reducing position for {symbol}: {e}")
|
||||
continue
|
||||
|
||||
if is_short:
|
||||
pnl = (position['entry_price'] - price) * reduce_amount
|
||||
else:
|
||||
pnl = (price - position['entry_price']) * reduce_amount
|
||||
|
||||
self.store.reduce_position(
|
||||
position['id'], reduce_amount, price, datetime.utcnow(),
|
||||
fees=reduce_amount * price * self.commission_rate
|
||||
)
|
||||
self.safety.record_trade_result(pnl * (reduce_amount / abs(position['amount'])))
|
||||
|
||||
closed.append({
|
||||
'symbol': symbol,
|
||||
'side': close_side,
|
||||
'amount': reduce_amount,
|
||||
'entry_price': position['entry_price'],
|
||||
'exit_price': price,
|
||||
'pnl': round(pnl, 2),
|
||||
'exit_reason': 'drawdown_reduce',
|
||||
'direction': 'short' if is_short else 'long',
|
||||
})
|
||||
|
||||
return closed
|
||||
|
||||
def force_reduce_all_positions(self, reduction_pct: float, current_prices: Dict[str, float]) -> List[Dict]:
|
||||
"""
|
||||
Force-reduce ALL positions by reduction_pct (e.g., close 50% of everything).
|
||||
Used when drawdown stop triggers.
|
||||
"""
|
||||
closed = []
|
||||
open_positions = self.store.get_open_positions()
|
||||
|
||||
for position in open_positions:
|
||||
symbol = position['symbol']
|
||||
price = current_prices.get(symbol)
|
||||
if price is None:
|
||||
continue
|
||||
|
||||
reduce_amount = round(abs(position['amount']) * reduction_pct, 4)
|
||||
if reduce_amount < 0.0001:
|
||||
continue
|
||||
|
||||
is_short = position.get('metadata', {}).get('direction') == 'short'
|
||||
close_side = 'buy' if is_short else 'sell'
|
||||
|
||||
try:
|
||||
self.broker.place_market_order(symbol, reduce_amount, close_side)
|
||||
logger.warning(f"FORCE REDUCE ({reduction_pct:.0%}) {reduce_amount:.2f} {symbol} @ ${price:.4f}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error force-reducing {symbol}: {e}")
|
||||
continue
|
||||
|
||||
if is_short:
|
||||
pnl = (position['entry_price'] - price) * reduce_amount
|
||||
else:
|
||||
pnl = (price - position['entry_price']) * reduce_amount
|
||||
|
||||
self.store.reduce_position(
|
||||
position['id'], reduce_amount, price, datetime.utcnow(),
|
||||
fees=reduce_amount * price * self.commission_rate
|
||||
)
|
||||
self.safety.record_trade_result(pnl * (reduce_amount / abs(position['amount'])))
|
||||
|
||||
closed.append({
|
||||
'symbol': symbol,
|
||||
'side': close_side,
|
||||
'amount': reduce_amount,
|
||||
'exit_price': price,
|
||||
'pnl': round(pnl, 2),
|
||||
'exit_reason': 'force_reduce_all',
|
||||
'direction': 'short' if is_short else 'long',
|
||||
})
|
||||
|
||||
return closed
|
||||
|
||||
def get_portfolio_state(self) -> Dict:
|
||||
@@ -385,18 +710,25 @@ class TradingExecutor:
|
||||
total_position_value = sum(p.get('market_value', 0) for p in positions)
|
||||
equity = portfolio['equity']
|
||||
|
||||
# Count directions
|
||||
num_long = sum(1 for p in open_db if p.get('metadata', {}).get('direction') == 'long')
|
||||
num_short = sum(1 for p in open_db if p.get('metadata', {}).get('direction') == 'short')
|
||||
|
||||
return {
|
||||
'equity': equity,
|
||||
'cash': portfolio['cash'],
|
||||
'position_ratio': total_position_value / equity if equity > 0 else 0,
|
||||
'unrealized_pnl': sum(p.get('unrealized_pl', 0) for p in positions) / equity
|
||||
if equity > 0 else 0,
|
||||
'time_in_position': 0, # Simplified
|
||||
'time_in_position': 0,
|
||||
'num_positions': len(positions),
|
||||
'num_long': num_long,
|
||||
'num_short': num_short,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting portfolio state: {e}")
|
||||
return {
|
||||
'equity': 0, 'cash': 0, 'position_ratio': 0,
|
||||
'unrealized_pnl': 0, 'time_in_position': 0, 'num_positions': 0,
|
||||
'num_long': 0, 'num_short': 0,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
"""
|
||||
Trade Executor
|
||||
Executes live trades via Alpaca broker with position management.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class TradingExecutor:
|
||||
"""
|
||||
Executes live trades based on RL agent decisions.
|
||||
Manages positions, stop losses, and take profits.
|
||||
"""
|
||||
|
||||
def __init__(self, broker, store, safety, config: Dict):
|
||||
"""
|
||||
Args:
|
||||
broker: AlpacaBroker instance
|
||||
store: DataStore instance
|
||||
safety: SafetyManager instance
|
||||
config: Trading configuration dict
|
||||
"""
|
||||
self.broker = broker
|
||||
self.store = store
|
||||
self.safety = safety
|
||||
self.config = config
|
||||
self.commission_rate = config.get('commission_rate', 0.001)
|
||||
|
||||
def execute_signal(self, symbol: str, action: int, current_price: float,
|
||||
strategy_params: Dict = None, combined_equity: float = None) -> Optional[Dict]:
|
||||
"""
|
||||
Execute a trading action from the RL agent.
|
||||
|
||||
Actions:
|
||||
0: Hold
|
||||
1: Buy 25% of available capital (go long)
|
||||
2: Buy 50% of available capital (go long)
|
||||
3: Close 50% of position (long or short)
|
||||
4: Close 100% of position (long or short)
|
||||
5: Short 25% of available capital
|
||||
6: Short 50% of available capital
|
||||
|
||||
Args:
|
||||
combined_equity: Optional total equity across all brokers (for multi-broker setups)
|
||||
If provided, used for safety checks instead of single-broker equity
|
||||
|
||||
Returns: trade record dict, or None if no action taken
|
||||
"""
|
||||
if action == 0: # Hold
|
||||
return None
|
||||
|
||||
if current_price <= 0:
|
||||
return None
|
||||
|
||||
strategy_params = strategy_params or {}
|
||||
|
||||
# Get portfolio state
|
||||
try:
|
||||
portfolio = self.broker.get_portfolio()
|
||||
equity = portfolio['equity']
|
||||
cash = portfolio['cash']
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting portfolio: {e}")
|
||||
return None
|
||||
|
||||
# Use combined equity for safety checks if provided (multi-broker setup)
|
||||
safety_equity = combined_equity if combined_equity is not None else equity
|
||||
|
||||
# Update safety peak
|
||||
self.safety.update_peak_equity(safety_equity)
|
||||
|
||||
# Get current positions
|
||||
open_positions = self.store.get_open_positions()
|
||||
open_for_symbol = [p for p in open_positions if p['symbol'] == symbol]
|
||||
num_positions = len(set(p['symbol'] for p in open_positions))
|
||||
|
||||
# BUY LONG actions
|
||||
if action in (1, 2):
|
||||
if open_for_symbol:
|
||||
logger.debug(f"Already have position in {symbol}, skipping buy")
|
||||
return None
|
||||
|
||||
pct = 0.25 if action == 1 else 0.50
|
||||
invest = cash * pct
|
||||
|
||||
max_position_pct = self.config.get('max_position_pct', 12) / 100
|
||||
max_invest = equity * max_position_pct
|
||||
if invest > max_invest:
|
||||
invest = max_invest
|
||||
|
||||
if invest < self.config.get('min_trade_value', 3):
|
||||
return None
|
||||
|
||||
shares = int(invest / current_price)
|
||||
if shares < 1:
|
||||
shares = round(invest / current_price, 4)
|
||||
if shares * current_price < self.config.get('min_trade_value', 3):
|
||||
return None
|
||||
|
||||
allowed, reason = self.safety.validate_trade(
|
||||
symbol, 'buy', shares, current_price, safety_equity, num_positions
|
||||
)
|
||||
if not allowed:
|
||||
logger.debug(f"Trade blocked: {reason}")
|
||||
return None
|
||||
|
||||
try:
|
||||
order = self.broker.place_market_order(symbol, shares, 'buy')
|
||||
logger.info(f"BUY {shares} {symbol} @ ~${current_price:.4f}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error placing buy order: {e}")
|
||||
return None
|
||||
|
||||
stop_loss = strategy_params.get('stop_loss')
|
||||
take_profit = strategy_params.get('take_profit')
|
||||
|
||||
trade = {
|
||||
'symbol': symbol,
|
||||
'side': 'buy',
|
||||
'amount': shares,
|
||||
'entry_price': current_price,
|
||||
'entry_time': datetime.utcnow().isoformat(),
|
||||
'strategy_id': strategy_params.get('strategy_id', 'auto'),
|
||||
'stop_loss': stop_loss,
|
||||
'take_profit': take_profit,
|
||||
'order_id': str(order.get('id', '')),
|
||||
'status': 'open',
|
||||
'metadata': {
|
||||
'action': action,
|
||||
'invest_pct': pct,
|
||||
'equity_at_entry': equity,
|
||||
'direction': 'long',
|
||||
},
|
||||
}
|
||||
trade_id = self.store.record_trade(trade)
|
||||
trade['id'] = trade_id
|
||||
return trade
|
||||
|
||||
# CLOSE POSITION actions (long or short)
|
||||
elif action in (3, 4):
|
||||
if not open_for_symbol:
|
||||
return None
|
||||
|
||||
position = open_for_symbol[0]
|
||||
total_shares = position['amount']
|
||||
is_short = position.get('metadata', {}).get('direction') == 'short'
|
||||
|
||||
if action == 3:
|
||||
close_shares = abs(total_shares) * 0.5
|
||||
else:
|
||||
close_shares = abs(total_shares)
|
||||
|
||||
close_shares = round(close_shares, 4)
|
||||
if close_shares * current_price < self.config.get('min_trade_value', 1):
|
||||
close_shares = abs(total_shares)
|
||||
|
||||
# Determine order side (opposite of position direction)
|
||||
close_side = 'buy' if is_short else 'sell'
|
||||
|
||||
try:
|
||||
order = self.broker.place_market_order(symbol, close_shares, close_side)
|
||||
logger.info(f"CLOSE({close_side.upper()}) {close_shares} {symbol} @ ~${current_price:.4f}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error placing close order: {e}")
|
||||
return None
|
||||
|
||||
# Calculate P&L
|
||||
if is_short:
|
||||
pnl = (position['entry_price'] - current_price) * close_shares
|
||||
else:
|
||||
pnl = (current_price - position['entry_price']) * close_shares
|
||||
|
||||
if close_shares >= abs(total_shares) * 0.99:
|
||||
self.store.close_position(
|
||||
position['id'], current_price, datetime.utcnow(),
|
||||
fees=close_shares * current_price * self.commission_rate
|
||||
)
|
||||
self.safety.record_trade_result(pnl)
|
||||
|
||||
return {
|
||||
'symbol': symbol,
|
||||
'side': close_side,
|
||||
'amount': close_shares,
|
||||
'exit_price': current_price,
|
||||
'pnl': round(pnl, 2),
|
||||
'pnl_pct': round(pnl / (position['entry_price'] * close_shares) * 100, 2),
|
||||
'action': action,
|
||||
'direction': 'short' if is_short else 'long',
|
||||
}
|
||||
else:
|
||||
self.safety.record_trade_result(pnl)
|
||||
self.store.close_position(
|
||||
position['id'], current_price, datetime.utcnow(),
|
||||
fees=close_shares * current_price * self.commission_rate
|
||||
)
|
||||
|
||||
remaining = abs(total_shares) - close_shares
|
||||
if remaining > 0:
|
||||
self.store.record_trade({
|
||||
'symbol': symbol,
|
||||
'side': position['side'],
|
||||
'amount': remaining,
|
||||
'entry_price': position['entry_price'],
|
||||
'entry_time': position['entry_time'],
|
||||
'strategy_id': position.get('strategy_id', 'auto'),
|
||||
'stop_loss': position.get('stop_loss'),
|
||||
'take_profit': position.get('take_profit'),
|
||||
'status': 'open',
|
||||
'metadata': position.get('metadata', {}),
|
||||
})
|
||||
|
||||
return {
|
||||
'symbol': symbol,
|
||||
'side': close_side,
|
||||
'amount': close_shares,
|
||||
'exit_price': current_price,
|
||||
'pnl': round(pnl, 2),
|
||||
'action': action,
|
||||
'direction': 'short' if is_short else 'long',
|
||||
}
|
||||
|
||||
# SHORT actions
|
||||
elif action in (5, 6):
|
||||
if open_for_symbol:
|
||||
logger.debug(f"Already have position in {symbol}, skipping short")
|
||||
return None
|
||||
|
||||
pct = 0.25 if action == 5 else 0.50
|
||||
invest = cash * pct
|
||||
|
||||
max_position_pct = self.config.get('max_position_pct', 12) / 100
|
||||
max_invest = equity * max_position_pct
|
||||
if invest > max_invest:
|
||||
invest = max_invest
|
||||
|
||||
if invest < self.config.get('min_trade_value', 3):
|
||||
return None
|
||||
|
||||
shares = int(invest / current_price)
|
||||
if shares < 1:
|
||||
shares = round(invest / current_price, 4)
|
||||
if shares * current_price < self.config.get('min_trade_value', 3):
|
||||
return None
|
||||
|
||||
allowed, reason = self.safety.validate_trade(
|
||||
symbol, 'sell', shares, current_price, safety_equity, num_positions
|
||||
)
|
||||
if not allowed:
|
||||
logger.debug(f"Short blocked: {reason}")
|
||||
return None
|
||||
|
||||
try:
|
||||
order = self.broker.place_market_order(symbol, shares, 'sell')
|
||||
logger.info(f"SHORT {shares} {symbol} @ ~${current_price:.4f}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error placing short order: {e}")
|
||||
return None
|
||||
|
||||
# For shorts, stop_loss is ABOVE entry, take_profit is BELOW
|
||||
stop_loss = strategy_params.get('short_stop_loss') or strategy_params.get('stop_loss')
|
||||
take_profit = strategy_params.get('short_take_profit') or strategy_params.get('take_profit')
|
||||
|
||||
trade = {
|
||||
'symbol': symbol,
|
||||
'side': 'sell',
|
||||
'amount': shares,
|
||||
'entry_price': current_price,
|
||||
'entry_time': datetime.utcnow().isoformat(),
|
||||
'strategy_id': strategy_params.get('strategy_id', 'auto'),
|
||||
'stop_loss': stop_loss,
|
||||
'take_profit': take_profit,
|
||||
'order_id': str(order.get('id', '')),
|
||||
'status': 'open',
|
||||
'metadata': {
|
||||
'action': action,
|
||||
'invest_pct': pct,
|
||||
'equity_at_entry': equity,
|
||||
'direction': 'short',
|
||||
},
|
||||
}
|
||||
trade_id = self.store.record_trade(trade)
|
||||
trade['id'] = trade_id
|
||||
return trade
|
||||
|
||||
return None
|
||||
|
||||
def check_exits(self, current_prices: Dict[str, float]) -> List[Dict]:
|
||||
"""
|
||||
Check all open positions for stop loss / take profit exits.
|
||||
Handles both long and short positions.
|
||||
Returns list of closed trades.
|
||||
"""
|
||||
closed = []
|
||||
open_positions = self.store.get_open_positions()
|
||||
|
||||
for position in open_positions:
|
||||
symbol = position['symbol']
|
||||
price = current_prices.get(symbol)
|
||||
if price is None:
|
||||
continue
|
||||
|
||||
should_exit = False
|
||||
exit_reason = ''
|
||||
is_short = position.get('metadata', {}).get('direction') == 'short'
|
||||
|
||||
if is_short:
|
||||
# Short position: stop_loss is ABOVE entry, take_profit is BELOW
|
||||
if position.get('stop_loss') and price >= position['stop_loss']:
|
||||
should_exit = True
|
||||
exit_reason = 'stop_loss'
|
||||
elif position.get('take_profit') and price <= position['take_profit']:
|
||||
should_exit = True
|
||||
exit_reason = 'take_profit'
|
||||
else:
|
||||
# Long position: stop_loss is BELOW entry, take_profit is ABOVE
|
||||
if position.get('stop_loss') and price <= position['stop_loss']:
|
||||
should_exit = True
|
||||
exit_reason = 'stop_loss'
|
||||
elif position.get('take_profit') and price >= position['take_profit']:
|
||||
should_exit = True
|
||||
exit_reason = 'take_profit'
|
||||
|
||||
if should_exit:
|
||||
# Close side is opposite of position direction
|
||||
close_side = 'buy' if is_short else 'sell'
|
||||
try:
|
||||
self.broker.place_market_order(
|
||||
symbol, position['amount'], close_side
|
||||
)
|
||||
logger.info(f"EXIT ({exit_reason}) {symbol} @ ${price:.4f} [{'SHORT' if is_short else 'LONG'}]")
|
||||
except Exception as e:
|
||||
logger.error(f"Error executing exit for {symbol}: {e}")
|
||||
continue
|
||||
|
||||
if is_short:
|
||||
pnl = (position['entry_price'] - price) * position['amount']
|
||||
else:
|
||||
pnl = (price - position['entry_price']) * position['amount']
|
||||
|
||||
self.store.close_position(
|
||||
position['id'], price, datetime.utcnow(),
|
||||
fees=position['amount'] * price * self.commission_rate
|
||||
)
|
||||
self.safety.record_trade_result(pnl)
|
||||
|
||||
closed.append({
|
||||
'symbol': symbol,
|
||||
'side': close_side,
|
||||
'amount': position['amount'],
|
||||
'entry_price': position['entry_price'],
|
||||
'exit_price': price,
|
||||
'pnl': round(pnl, 2),
|
||||
'pnl_pct': round(pnl / (position['entry_price'] * position['amount']) * 100, 2),
|
||||
'exit_reason': exit_reason,
|
||||
'direction': 'short' if is_short else 'long',
|
||||
})
|
||||
|
||||
return closed
|
||||
|
||||
def get_portfolio_state(self) -> Dict:
|
||||
"""Get current portfolio state for RL agent"""
|
||||
try:
|
||||
portfolio = self.broker.get_portfolio()
|
||||
positions = self.broker.get_positions()
|
||||
open_db = self.store.get_open_positions()
|
||||
|
||||
total_position_value = sum(p.get('market_value', 0) for p in positions)
|
||||
equity = portfolio['equity']
|
||||
|
||||
return {
|
||||
'equity': equity,
|
||||
'cash': portfolio['cash'],
|
||||
'position_ratio': total_position_value / equity if equity > 0 else 0,
|
||||
'unrealized_pnl': sum(p.get('unrealized_pl', 0) for p in positions) / equity
|
||||
if equity > 0 else 0,
|
||||
'time_in_position': 0, # Simplified
|
||||
'num_positions': len(positions),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting portfolio state: {e}")
|
||||
return {
|
||||
'equity': 0, 'cash': 0, 'position_ratio': 0,
|
||||
'unrealized_pnl': 0, 'time_in_position': 0, 'num_positions': 0,
|
||||
}
|
||||
Reference in New Issue
Block a user