| """ |
| Crypto Trading Environment |
| Custom Gymnasium environment for reinforcement learning-based crypto trading. |
| """ |
|
|
| import gymnasium as gym |
| from gymnasium import spaces |
| import numpy as np |
| import pandas as pd |
| from typing import Optional, Tuple, Dict, Any, List |
| from enum import IntEnum |
|
|
| from .indicators import TechnicalIndicators |
| from .rewards import RewardCalculator |
|
|
|
|
| class Actions(IntEnum): |
| """Trading actions.""" |
| HOLD = 0 |
| BUY = 1 |
| SELL = 2 |
|
|
|
|
| class Positions(IntEnum): |
| """Position states.""" |
| SHORT = -1 |
| FLAT = 0 |
| LONG = 1 |
|
|
|
|
| class CryptoTradingEnv(gym.Env): |
| """ |
| A Gymnasium environment for cryptocurrency trading. |
| |
| Observation Space: |
| - OHLCV data for lookback_window periods |
| - Technical indicators (RSI, MACD, BB, etc.) |
| - Agent state (position, unrealized P&L, balance, etc.) |
| |
| Action Space: |
| - 0: Hold |
| - 1: Buy/Long |
| - 2: Sell/Short |
| |
| Reward: |
| - Risk-adjusted returns using Sharpe/Sortino ratios |
| - Drawdown penalties |
| """ |
| |
| metadata = {'render_modes': ['human', 'ansi']} |
| |
| def __init__( |
| self, |
| df: pd.DataFrame, |
| initial_balance: float = 10000.0, |
| lookback_window: int = 30, |
| trading_fee: float = 0.001, |
| position_size: float = 0.1, |
| max_position: int = 1, |
| normalize_obs: bool = True, |
| render_mode: Optional[str] = None, |
| feature_engine: Optional[Any] = None, |
| symbol: str = "BTCUSDT", |
| ): |
| """ |
| Initialize the trading environment. |
| |
| Args: |
| df: DataFrame with OHLCV data and timestamp index |
| initial_balance: Starting balance in quote currency (USDT) |
| lookback_window: Number of historical candles in observation |
| trading_fee: Trading fee as fraction (0.001 = 0.1%) |
| position_size: Fraction of balance per trade (0.1 = 10%) |
| max_position: Maximum position size (-1, 0, 1) |
| normalize_obs: Whether to normalize observations |
| render_mode: Rendering mode ('human' or 'ansi') |
| feature_engine: Optional external feature engine (e.g. MultiAssetFeatureEngine) |
| symbol: Trading pair symbol (for feature engine) |
| """ |
| super().__init__() |
| |
| self.render_mode = render_mode |
| self.symbol = symbol |
| self.feature_engine = feature_engine |
| |
| |
| self.initial_balance = initial_balance |
| self.lookback_window = lookback_window |
| self.trading_fee = trading_fee |
| self.position_size = position_size |
| self.max_position = max_position |
| self.normalize_obs = normalize_obs |
| |
| |
| self.reward_calculator = RewardCalculator() |
| |
| |
| self.df = df.copy() |
| |
| if self.feature_engine: |
| |
| self._prepare_data_external() |
| else: |
| |
| self.indicators = TechnicalIndicators() |
| self._prepare_data_internal() |
| |
| |
| self.n_features = len(self.feature_columns) |
| self.n_ohlcv = 5 |
| self.n_agent_state = 5 |
| |
| |
| |
| |
| |
| if self.feature_engine: |
| |
| |
| obs_dim = self.lookback_window * self.n_features + self.n_agent_state |
| else: |
| |
| obs_dim = self.lookback_window * (self.n_features + self.n_ohlcv) + self.n_agent_state |
| |
| self.observation_space = spaces.Box( |
| low=-np.inf, high=np.inf, shape=(obs_dim,), dtype=np.float32 |
| ) |
| |
| |
| self.action_space = spaces.Discrete(3) |
| |
| |
| self._reset_state() |
| |
| def _prepare_data_internal(self): |
| """Compute technical indicators using internal engine.""" |
| self.df = self.indicators.compute_all(self.df) |
| self.feature_columns = self.indicators.get_feature_columns() |
| self._finalize_data() |
| |
| def _prepare_data_external(self): |
| """Compute features using external engine.""" |
| |
| |
| features = self.feature_engine.compute_features_batch(self.df, self.symbol) |
| |
| |
| feat_cols = [f"feat_{i}" for i in range(features.shape[1])] |
| feat_df = pd.DataFrame(features, columns=feat_cols, index=self.df.index) |
| |
| |
| self.df = pd.concat([self.df, feat_df], axis=1) |
| self.feature_columns = feat_cols |
| self._finalize_data() |
|
|
| def _finalize_data(self): |
| """Common data finalization steps.""" |
| |
| self.df = self.df.dropna() |
| |
| |
| self.prices = self.df['close'].values |
| self.highs = self.df['high'].values |
| self.lows = self.df['low'].values |
| |
| def _reset_state(self): |
| """Reset all state variables.""" |
| self.balance = self.initial_balance |
| self.position = Positions.FLAT |
| self.position_price = 0.0 |
| self.position_size_units = 0.0 |
| self.unrealized_pnl = 0.0 |
| self.realized_pnl = 0.0 |
| self.peak_balance = self.initial_balance |
| self.current_drawdown = 0.0 |
| self.trade_count = 0 |
| self.current_step = self.lookback_window |
| self.done = False |
| self.trades: List[Dict] = [] |
| |
| def _get_observation(self) -> np.ndarray: |
| """ |
| Construct the observation array. |
| |
| Returns: |
| Flattened observation array |
| """ |
| |
| start_idx = self.current_step - self.lookback_window |
| end_idx = self.current_step |
| |
| window_data = self.df.iloc[start_idx:end_idx] |
| |
| |
| ohlcv_cols = ['open', 'high', 'low', 'close', 'volume'] |
| ohlcv = window_data[ohlcv_cols].values.copy() |
| |
| |
| if self.normalize_obs: |
| price_norm = ohlcv[0, 3] |
| if price_norm > 0: |
| ohlcv[:, :4] = ohlcv[:, :4] / price_norm - 1 |
| vol_mean = ohlcv[:, 4].mean() |
| ohlcv[:, 4] = ohlcv[:, 4] / (vol_mean + 1e-8) - 1 |
| |
| |
| features = window_data[self.feature_columns].values.copy() |
| features = np.nan_to_num(features, nan=0.0, posinf=1.0, neginf=-1.0) |
| |
| |
| if self.feature_engine: |
| |
| combined = features |
| else: |
| |
| combined = np.concatenate([ohlcv, features], axis=1) |
| |
| |
| flat_history = combined.flatten() |
| |
| |
| portfolio_value = self._get_portfolio_value() |
| agent_state = np.array([ |
| float(self.position), |
| self.unrealized_pnl / self.initial_balance, |
| self.balance / self.initial_balance, |
| self.current_drawdown, |
| min(self.trade_count / 100, 1.0), |
| ], dtype=np.float32) |
| |
| |
| observation = np.concatenate([flat_history, agent_state]).astype(np.float32) |
| |
| return observation |
| |
| def _get_portfolio_value(self) -> float: |
| """Calculate total portfolio value including unrealized P&L.""" |
| return self.balance + self.unrealized_pnl |
| |
| def _update_unrealized_pnl(self): |
| """Update unrealized P&L based on current position.""" |
| if self.position == Positions.FLAT: |
| self.unrealized_pnl = 0.0 |
| return |
| |
| current_price = self.prices[self.current_step] |
| |
| if self.position == Positions.LONG: |
| self.unrealized_pnl = (current_price - self.position_price) * self.position_size_units |
| elif self.position == Positions.SHORT: |
| self.unrealized_pnl = (self.position_price - current_price) * self.position_size_units |
| |
| def _update_drawdown(self): |
| """Update peak balance and current drawdown.""" |
| portfolio_value = self._get_portfolio_value() |
| if portfolio_value > self.peak_balance: |
| self.peak_balance = portfolio_value |
| self.current_drawdown = (self.peak_balance - portfolio_value) / self.peak_balance |
| |
| def _execute_trade(self, action: int) -> Tuple[float, Optional[float]]: |
| """ |
| Execute a trading action. |
| |
| Args: |
| action: The action to take (0=hold, 1=buy, 2=sell) |
| |
| Returns: |
| Tuple of (step_return, trade_pnl or None) |
| """ |
| current_price = self.prices[self.current_step] |
| prev_price = self.prices[self.current_step - 1] |
| trade_pnl = None |
| |
| |
| if self.position == Positions.LONG: |
| step_return = (current_price - prev_price) / prev_price |
| elif self.position == Positions.SHORT: |
| step_return = (prev_price - current_price) / prev_price |
| else: |
| step_return = 0.0 |
| |
| |
| if action == Actions.BUY and self.position != Positions.LONG: |
| |
| if self.position == Positions.SHORT: |
| trade_pnl = self._close_position(current_price) |
| |
| |
| self._open_position(current_price, Positions.LONG) |
| |
| elif action == Actions.SELL and self.position != Positions.SHORT: |
| |
| if self.position == Positions.LONG: |
| trade_pnl = self._close_position(current_price) |
| |
| |
| self._open_position(current_price, Positions.SHORT) |
| |
| elif action == Actions.HOLD: |
| |
| pass |
| |
| return step_return, trade_pnl |
| |
| def _open_position(self, price: float, position_type: Positions): |
| """Open a new position.""" |
| trade_amount = self.balance * self.position_size |
| fee = trade_amount * self.trading_fee |
| |
| self.position = position_type |
| self.position_price = price |
| self.position_size_units = (trade_amount - fee) / price |
| self.balance -= fee |
| self.trade_count += 1 |
| |
| def _close_position(self, price: float) -> float: |
| """Close current position and return P&L.""" |
| if self.position == Positions.LONG: |
| pnl = (price - self.position_price) * self.position_size_units |
| elif self.position == Positions.SHORT: |
| pnl = (self.position_price - price) * self.position_size_units |
| else: |
| return 0.0 |
| |
| |
| fee = abs(pnl) * self.trading_fee |
| pnl -= fee |
| |
| |
| self.trades.append({ |
| 'entry_price': self.position_price, |
| 'exit_price': price, |
| 'position': int(self.position), |
| 'pnl': pnl, |
| 'step': self.current_step, |
| }) |
| |
| |
| self.balance += pnl |
| self.realized_pnl += pnl |
| |
| |
| self.position = Positions.FLAT |
| self.position_price = 0.0 |
| self.position_size_units = 0.0 |
| |
| return pnl |
| |
| def step(self, action: int) -> Tuple[np.ndarray, float, bool, bool, Dict[str, Any]]: |
| """ |
| Take a step in the environment. |
| |
| Args: |
| action: The action to take |
| |
| Returns: |
| observation, reward, terminated, truncated, info |
| """ |
| if self.done: |
| raise RuntimeError("Episode is done. Call reset().") |
| |
| |
| step_return, trade_pnl = self._execute_trade(action) |
| |
| |
| self._update_unrealized_pnl() |
| self._update_drawdown() |
| |
| |
| portfolio_value = self._get_portfolio_value() |
| reward = self.reward_calculator.calculate_reward( |
| step_return=step_return, |
| portfolio_value=portfolio_value, |
| position=int(self.position), |
| action_taken=action, |
| trade_pnl=trade_pnl, |
| ) |
| |
| |
| self.current_step += 1 |
| |
| |
| terminated = False |
| truncated = False |
| |
| if self.current_step >= len(self.df) - 1: |
| truncated = True |
| self.done = True |
| |
| if self.balance <= 0: |
| terminated = True |
| self.done = True |
| |
| |
| observation = self._get_observation() |
| |
| |
| info = { |
| 'balance': self.balance, |
| 'portfolio_value': portfolio_value, |
| 'position': int(self.position), |
| 'unrealized_pnl': self.unrealized_pnl, |
| 'realized_pnl': self.realized_pnl, |
| 'drawdown': self.current_drawdown, |
| 'trade_count': self.trade_count, |
| 'step': self.current_step, |
| 'price': self.prices[self.current_step], |
| } |
| |
| return observation, reward, terminated, truncated, info |
| |
| def reset( |
| self, |
| seed: Optional[int] = None, |
| options: Optional[Dict] = None, |
| ) -> Tuple[np.ndarray, Dict[str, Any]]: |
| """ |
| Reset the environment. |
| |
| Args: |
| seed: Random seed |
| options: Additional options |
| |
| Returns: |
| observation, info |
| """ |
| super().reset(seed=seed) |
| |
| self._reset_state() |
| self.reward_calculator.reset(self.initial_balance) |
| |
| |
| if options and options.get('random_start', False): |
| max_start = len(self.df) - self.lookback_window - 100 |
| if max_start > self.lookback_window: |
| self.current_step = self.np_random.integers( |
| self.lookback_window, max_start |
| ) |
| |
| observation = self._get_observation() |
| info = { |
| 'balance': self.balance, |
| 'portfolio_value': self._get_portfolio_value(), |
| } |
| |
| return observation, info |
| |
| def render(self): |
| """Render the environment.""" |
| if self.render_mode == 'human': |
| self._render_human() |
| elif self.render_mode == 'ansi': |
| return self._render_ansi() |
| |
| def _render_human(self): |
| """Print current state to console.""" |
| print(self._render_ansi()) |
| |
| def _render_ansi(self) -> str: |
| """Return string representation of current state.""" |
| portfolio = self._get_portfolio_value() |
| pos_str = {-1: 'SHORT', 0: 'FLAT', 1: 'LONG'}[int(self.position)] |
| |
| return ( |
| f"Step: {self.current_step} | " |
| f"Price: ${self.prices[self.current_step]:.2f} | " |
| f"Position: {pos_str} | " |
| f"Balance: ${self.balance:.2f} | " |
| f"Portfolio: ${portfolio:.2f} | " |
| f"P&L: ${self.realized_pnl:+.2f} | " |
| f"Drawdown: {self.current_drawdown:.2%}" |
| ) |
| |
| def get_episode_metrics(self) -> Dict[str, Any]: |
| """Get summary metrics for the episode.""" |
| reward_metrics = self.reward_calculator.get_episode_metrics() |
| |
| return { |
| **reward_metrics, |
| 'final_balance': self.balance, |
| 'final_portfolio_value': self._get_portfolio_value(), |
| 'total_pnl': self.realized_pnl, |
| 'total_return': (self._get_portfolio_value() - self.initial_balance) / self.initial_balance, |
| 'trade_count': self.trade_count, |
| 'trades': self.trades, |
| } |
| |
| def get_trade_signals(self) -> List[Dict]: |
| """Get list of trade signals for visualization.""" |
| return self.trades.copy() |
|
|
|
|
| def create_env_from_df( |
| df: pd.DataFrame, |
| config: Optional[Dict] = None, |
| ) -> CryptoTradingEnv: |
| """ |
| Factory function to create environment from dataframe. |
| |
| Args: |
| df: DataFrame with OHLCV data |
| config: Optional configuration dictionary |
| |
| Returns: |
| CryptoTradingEnv instance |
| """ |
| config = config or {} |
| |
| return CryptoTradingEnv( |
| df=df, |
| initial_balance=config.get('initial_balance', 10000.0), |
| lookback_window=config.get('lookback_window', 30), |
| trading_fee=config.get('trading_fee', 0.001), |
| position_size=config.get('position_size', 0.1), |
| max_position=config.get('max_position', 1), |
| normalize_obs=config.get('normalize_obs', True), |
| ) |
|
|