""" RL Trading Agent wrapper for Stable Baselines3. Provides an easy-to-use interface for training, evaluating, and deploying RL trading agents within the NexQuant framework. Supported algorithms: - PPO: Proximal Policy Optimization (most stable, recommended for production) - A2C: Advantage Actor-Critic (faster training) - SAC: Soft Actor-Critic (best for continuous action spaces) """ from pathlib import Path from typing import Any, Dict, Optional, Union import numpy as np class RLTradingAgent: """ Wrapper for RL trading agents built on Stable Baselines3. Parameters ---------- algorithm : str RL algorithm to use ("PPO", "A2C", or "SAC") policy : str Policy network type (default: "MlpPolicy") params : dict, optional Algorithm-specific hyperparameters (merged with defaults) verbose : int Verbosity level (0 = silent, 1 = info, 2 = debug) Examples -------- >>> agent = RLTradingAgent("PPO") >>> agent.train(env, total_timesteps=50000) >>> action = agent.predict(observation) >>> agent.save("models/rl_trader.zip") """ _DEFAULT_PARAMS: Dict[str, Dict[str, Any]] = { "PPO": { "learning_rate": 3e-4, "n_steps": 2048, "batch_size": 64, "n_epochs": 10, "gamma": 0.99, "clip_range": 0.2, "ent_coef": 0.0, }, "A2C": { "learning_rate": 7e-4, "n_steps": 5, "gamma": 0.99, "ent_coef": 0.01, }, "SAC": { "learning_rate": 3e-4, "buffer_size": 1_000_000, "batch_size": 256, "gamma": 0.99, "tau": 0.005, "train_freq": 1, "gradient_steps": 1, }, } def __init__( self, algorithm: str = "PPO", policy: str = "MlpPolicy", params: Optional[Dict[str, Any]] = None, verbose: int = 0, ) -> None: self.algorithm = algorithm.upper() self.policy = policy self.verbose = verbose # Merge user params with defaults defaults = self._DEFAULT_PARAMS.get(self.algorithm, {}) self.params = {**defaults, **(params or {})} self.model: Optional[Any] = None self.is_trained = False # ------------------------------------------------------------------ # # Internal helpers # ------------------------------------------------------------------ # def _get_model_class(self) -> Any: """Return the SB3 model class for the selected algorithm.""" try: from stable_baselines3 import A2C, PPO, SAC model_map = {"PPO": PPO, "A2C": A2C, "SAC": SAC} if self.algorithm not in model_map: raise ImportError( f"Unknown algorithm '{self.algorithm}'. " f"Supported: {', '.join(model_map.keys())}" ) return model_map[self.algorithm] except ImportError: raise # ------------------------------------------------------------------ # # Public API # ------------------------------------------------------------------ # def create_model(self, env: Any) -> None: """Create a new RL model instance. Parameters ---------- env : gym.Env Trading environment compatible with Gymnasium API """ model_class = self._get_model_class() self.model = model_class( self.policy, env, verbose=self.verbose, **self.params, ) def train( self, env: Any, total_timesteps: int = 100_000, tb_log_name: Optional[str] = None, progress_bar: bool = False, ) -> Dict[str, Any]: """ Train the RL agent. Parameters ---------- env : gym.Env Trading environment total_timesteps : int Number of training timesteps tb_log_name : str, optional TensorBoard log name progress_bar : bool Show progress bar during training Returns ------- dict Training metadata """ if self.model is None: self.create_model(env) if self.model is None: raise RuntimeError("Model creation failed unexpectedly") self.model.learn( total_timesteps=total_timesteps, tb_log_name=tb_log_name, progress_bar=progress_bar, ) self.is_trained = True return { "algorithm": self.algorithm, "policy": self.policy, "total_timesteps": total_timesteps, "is_trained": True, } def predict( self, observation: np.ndarray, deterministic: bool = True, ) -> np.ndarray: """ Predict action from observation. Parameters ---------- observation : np.ndarray Current state observation vector deterministic : bool Use deterministic action (recommended for inference) Returns ------- np.ndarray Action to take """ if self.model is None: raise ValueError("Model not trained or loaded. Call train() or load() first.") action, _ = self.model.predict(observation, deterministic=deterministic) return np.asarray(action) def save(self, path: Union[str, Path]) -> None: """Save trained model to disk. Parameters ---------- path : str or Path Destination file path (e.g. "models/ppo_trader.zip") """ if self.model is None: raise ValueError("No model to save. Train first.") path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) self.model.save(str(path)) def load(self, path: Union[str, Path]) -> None: """Load a trained model from disk. Parameters ---------- path : str or Path Source file path (e.g. "models/ppo_trader.zip") """ model_class = self._get_model_class() self.model = model_class.load(str(path)) self.is_trained = True def evaluate( self, env: Any, n_episodes: int = 10, deterministic: bool = True, ) -> Dict[str, float]: """ Evaluate agent performance over multiple episodes. Parameters ---------- env : gym.Env Trading environment for evaluation n_episodes : int Number of evaluation episodes deterministic : bool Use deterministic actions during evaluation Returns ------- dict Evaluation metrics (mean/std of rewards and returns) """ if self.model is None: raise ValueError("Model not trained or loaded.") rewards: list[float] = [] returns: list[float] = [] for _ in range(n_episodes): obs, _ = env.reset() episode_reward = 0.0 done = False info: Dict[str, Any] = {} while not done: action = self.predict(obs, deterministic=deterministic) obs, reward, terminated, truncated, info = env.step(action) episode_reward += float(reward) done = terminated or truncated rewards.append(episode_reward) returns.append(float(info.get("return", 0.0))) return { "mean_reward": float(np.mean(rewards)), "std_reward": float(np.std(rewards)), "mean_return": float(np.mean(returns)), "std_return": float(np.std(returns)), "n_episodes": n_episodes, }