e02a7e3453
CRITICAL FIXES (would have prevented the $194K crisis): 1. HARD MAX SHORT POSITION CAP (safety.py + executor.py) - new config: max_short_positions=4 (hard limit) - new config: max_total_positions=8 (hard limit) - executor now queries broker.get_positions() for AUTHORITATIVE count - BEFORE opening any short, checks broker directly (not just store) - validate_trade() now accepts broker_positions and blocks at hard cap - This directly prevents the 13-shorts scenario 2. YESTERDAY-CLOSE DRAWDC OWN CIRCUIT BREAKER (safety.py) - new config: yesterday_close_drawdown_limit=3% - new config: yesterday_close_drawdown_reduction=50% - Separate from peak-equity tracking (which is too slow) - At $194K from $200K initial = 3% → fires IMMEDIATELY, cuts 50% of positions - Tested: check_yesterday_close_drawdown($194K) → triggers with 50% reduction 3. PER-POSITION STOP LOSS DEFAULTS (safety.py + executor.py) - new config: stop_loss_default_pct_long=1.0%, short=1.5% - new config: stop_loss_max_pct=3.0% (never wider than this) - new config: take_profit_default_pct=2.0% - NEW: get_default_stop_loss() + get_default_take_profit() methods - executor.execute_signal() now ALWAYS sets stop loss (even if GA provides None) - Previously stops were often None → exits never triggered - Tested: get_default_stop_loss($100, 'short') = $101.50 ✓ 4. BROKER/STORE SYNC VALIDATION (safety.py + main_auto.py) - new: validate_broker_position_count() detects discrepancies - new: _trade_symbol() now passes combined_equity to executor - new: _trading_cycle() validates broker vs store BEFORE evaluating new trades - Logs warning when broker has positions not tracked in store - Blocks new trades if broker position count already at hard cap 5. CRISIS MODE: 10+ LOSING POSITIONS (executor.py check_exits) - If 8+ of 10+ positions are losing money → force reduce 50% of ALL positions - Catches cascading blowups before drawdown thresholds are hit - Logs CRITICAL warning when triggered 6. DEFAULT STOP LOSS ENFORCEMENT (executor.py) - _enforce_stop_loss_tightness() was overriding None stops to None - Now executor ALWAYS applies safety defaults if GA provides no stop - Every new position gets a stop loss on entry Config changes (auto_config.json): - Added max_short_positions, max_total_positions - Added stop_loss_default_pct_long/short, take_profit_default_pct - Added stop_loss_atr_multiplier, stop_loss_max_pct - Added yesterday_close_drawdown_limit=3%, yesterday_close_drawdown_reduction=50% HOW THIS WOULD HAVE HELPED THE $194,940 PORTFOLIO: - At $194,940 from $200K = 2.53% drawdown from initial - If yesterday closed at $200K: 2.53% < 3% limit → NOT triggered - But at open today if equity dropped to $194,000 → 3.00% → TRIGGERS IMMEDIATELY - Hard cap at 4 shorts: after 4 shorts, executor blocks action 5/6 - Stop losses: each of the 4 shorts would have had 1.5% stop → 2 shorts would have been stopped out before they lost further, limiting damage - Crisis mode: if 8 positions were losing, 50% of all positions closed
329 lines
12 KiB
Plaintext
329 lines
12 KiB
Plaintext
"""
|
|
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,
|
|
}
|