diff --git a/finrl/meta/env_options_trading/__init__.py b/finrl/meta/env_options_trading/__init__.py new file mode 100644 index 0000000000..d3169b1ae9 --- /dev/null +++ b/finrl/meta/env_options_trading/__init__.py @@ -0,0 +1,7 @@ +"""Options trading environments for FinRL.""" + +from __future__ import annotations + +from finrl.meta.env_options_trading.env_spy_options import SPYOptionsEnv + +__all__ = ["SPYOptionsEnv"] diff --git a/finrl/meta/env_options_trading/env_spy_options.py b/finrl/meta/env_options_trading/env_spy_options.py new file mode 100644 index 0000000000..d990fe0e0c --- /dev/null +++ b/finrl/meta/env_options_trading/env_spy_options.py @@ -0,0 +1,618 @@ +"""SPY Options Trading Environment with Greeks and Real-time Learning. + +This environment supports options trading with: +- Real-time strike selection based on Greeks +- Call and Put option trading +- Price target prediction +- Continuous learning from trades +- Multi-timeframe analysis +""" + +from __future__ import annotations + +import warnings +from typing import Dict +from typing import List +from typing import Optional +from typing import Tuple + +import gymnasium as gym +import numpy as np +import pandas as pd +from gymnasium import spaces +from gymnasium.utils import seeding + +warnings.filterwarnings("ignore") + + +class SPYOptionsEnv(gym.Env): + """A SPY options trading environment for RL agents with Greeks. + + Features: + - Options trading (calls and puts) + - Greeks-based strike selection + - Price target prediction + - Trade learning and feedback + - Multi-indicator state space + + Attributes + ---------- + df : pd.DataFrame + Preprocessed data with features and Greeks + initial_amount : float + Initial capital + transaction_cost : float + Transaction cost percentage + state_space : int + Dimension of observation space + action_space_dim : int + Dimension of action space + max_options : int + Maximum number of option contracts + reward_scaling : float + Scaling factor for rewards + """ + + metadata = {"render.modes": ["human"]} + + def __init__( + self, + df: pd.DataFrame, + initial_amount: float = 10000, + transaction_cost: float = 0.001, + state_space: int = 50, + max_options: int = 10, + reward_scaling: float = 1e-4, + make_plots: bool = False, + print_verbosity: int = 10, + day: int = 0, + tech_indicator_list: list[str] = None, + Greeks_list: list[str] = None, + ): + """Initialize the SPY options trading environment.""" + self.day = day + self.df = df + self.initial_amount = initial_amount + self.transaction_cost = transaction_cost + self.max_options = max_options + self.reward_scaling = reward_scaling + self.make_plots = make_plots + self.print_verbosity = print_verbosity + + # Feature lists + self.tech_indicator_list = tech_indicator_list or [] + self.greeks_list = greeks_list or [ + "call_delta", + "call_gamma", + "call_theta", + "call_vega", + "put_delta", + "put_gamma", + "put_theta", + "put_vega", + ] + + # Calculate state space size + # State: [cash, options_positions, current_price, features, greeks] + self.state_dim = ( + 1 # cash + + 2 # call_position, put_position + + 1 # current_price + + len(self.tech_indicator_list) # technical indicators + + len(self.greeks_list) # greeks + ) + + # Action space: [action_type, position_size] + # action_type: -1 (sell/close), 0 (hold), 1 (buy call), 2 (buy put) + # position_size: 0 to 1 (percentage of max_options) + self.action_space = spaces.Box( + low=np.array([-1, 0]), high=np.array([2, 1]), dtype=np.float32 + ) + + # Observation space + self.observation_space = spaces.Box( + low=-np.inf, high=np.inf, shape=(self.state_dim,), dtype=np.float32 + ) + + # Initialize state + self.data = self.df.loc[self.day, :] if not self.df.empty else pd.Series() + self.terminal = False + self.state = self._initiate_state() + + # Trading memory + self.cash = initial_amount + self.call_position = 0 # Number of call contracts + self.put_position = 0 # Number of put contracts + self.call_entry_price = 0 + self.put_entry_price = 0 + self.call_strike = 0 + self.put_strike = 0 + + # Performance tracking + self.asset_memory = [initial_amount] + self.rewards_memory = [] + self.actions_memory = [] + self.trades_memory = [] + self.date_memory = [self._get_date()] + self.portfolio_value_memory = [initial_amount] + + # Trade learning + self.trade_history = [] + self.win_rate = 0.0 + self.avg_win = 0.0 + self.avg_loss = 0.0 + + # Metrics + self.cost = 0 + self.trades = 0 + self.episode = 0 + + self._seed() + + def _seed(self, seed=None): + self.np_random, seed = seeding.np_random(seed) + return [seed] + + def _initiate_state(self) -> np.ndarray: + """Initialize the state vector. + + Returns + ------- + np.ndarray + Initial state vector + """ + if self.data.empty: + return np.zeros(self.state_dim) + + state = [] + + # Cash + state.append(self.initial_amount) + + # Positions + state.extend([0, 0]) # call_position, put_position + + # Current price + state.append(self.data.get("close", 0)) + + # Technical indicators + for indicator in self.tech_indicator_list: + state.append(self.data.get(indicator, 0)) + + # Greeks + for greek in self.greeks_list: + state.append(self.data.get(greek, 0)) + + return np.array(state, dtype=np.float32) + + def _update_state(self): + """Update state with current market data and positions.""" + if self.terminal or self.day >= len(self.df.index.unique()) - 1: + return + + self.data = self.df.loc[self.day, :] + + state = [] + + # Cash + state.append(self.cash) + + # Positions + state.extend([self.call_position, self.put_position]) + + # Current price + current_price = self.data.get("close", 0) + state.append(current_price) + + # Technical indicators + for indicator in self.tech_indicator_list: + state.append(self.data.get(indicator, 0)) + + # Greeks + for greek in self.greeks_list: + state.append(self.data.get(greek, 0)) + + self.state = np.array(state, dtype=np.float32) + + def _get_date(self): + """Get current date.""" + if self.data.empty: + return None + return self.data.get("date", self.data.get("timestamp", None)) + + def _calculate_option_price(self, option_type: str) -> float: + """Calculate current option price based on Greeks. + + Parameters + ---------- + option_type : str + 'call' or 'put' + + Returns + ------- + float + Estimated option price + """ + # In real trading, this would fetch actual option prices + # For simulation, we'll estimate based on intrinsic value + time value + + current_price = self.data.get("close", 0) + + if option_type == "call": + strike = self.data.get("call_strike", current_price) + intrinsic = max(0, current_price - strike) + delta = abs(self.data.get("call_delta", 0.5)) + gamma = self.data.get("call_gamma", 0.01) + vega = self.data.get("call_vega", 0.1) + iv = self.data.get("call_iv", 0.3) + else: + strike = self.data.get("put_strike", current_price) + intrinsic = max(0, strike - current_price) + delta = abs(self.data.get("put_delta", 0.5)) + gamma = self.data.get("put_gamma", 0.01) + vega = self.data.get("put_vega", 0.1) + iv = self.data.get("put_iv", 0.3) + + # Estimate time value + time_value = vega * iv + gamma * (current_price**2) * 0.01 + + # Total option price + option_price = intrinsic + time_value + + # Minimum price (options have some value) + option_price = max(option_price, 0.01) + + return option_price + + def step(self, actions: np.ndarray) -> tuple[np.ndarray, float, bool, bool, dict]: + """Execute one time step within the environment. + + Parameters + ---------- + actions : np.ndarray + Action from the agent [action_type, position_size] + + Returns + ------- + observation : np.ndarray + Current state + reward : float + Reward from the action + terminated : bool + Whether episode is terminated + truncated : bool + Whether episode is truncated + info : dict + Additional information + """ + self.terminal = self.day >= len(self.df.index.unique()) - 1 + + if self.terminal: + # End of episode + final_value = self._calculate_portfolio_value() + total_return = (final_value - self.initial_amount) / self.initial_amount + sharpe = self._calculate_sharpe_ratio() + + info = { + "final_value": final_value, + "total_return": total_return, + "sharpe_ratio": sharpe, + "num_trades": self.trades, + "win_rate": self.win_rate, + } + + return self.state, 0, True, False, info + + # Parse actions + action_type = actions[0] # -1, 0, 1, 2 + position_size = np.clip(actions[1], 0, 1) # 0 to 1 + + # Execute action + begin_value = self._calculate_portfolio_value() + + # Action type: + # -1: Close positions + # 0: Hold + # 1: Buy call + # 2: Buy put + if action_type < -0.5: # Close positions + self._close_positions() + elif action_type > 0.5 and action_type < 1.5: # Buy call + self._buy_call(position_size) + elif action_type >= 1.5: # Buy put + self._buy_put(position_size) + # else: hold (do nothing) + + # Move to next day + self.day += 1 + self._update_state() + + # Calculate reward + end_value = self._calculate_portfolio_value() + reward = (end_value - begin_value) * self.reward_scaling + + # Additional reward shaping + # Penalize excessive trading + if self.trades > 0: + reward -= 0.0001 + + # Bonus for profitable trades + if end_value > begin_value: + reward += 0.001 + + self.rewards_memory.append(reward) + self.actions_memory.append(actions) + self.portfolio_value_memory.append(end_value) + self.date_memory.append(self._get_date()) + + info = { + "portfolio_value": end_value, + "cash": self.cash, + "call_position": self.call_position, + "put_position": self.put_position, + } + + return self.state, reward, False, False, info + + def _buy_call(self, position_size: float): + """Buy call options. + + Parameters + ---------- + position_size : float + Fraction of max_options to buy (0 to 1) + """ + if position_size <= 0: + return + + num_contracts = int(position_size * self.max_options) + if num_contracts <= 0: + return + + option_price = self._calculate_option_price("call") + cost = num_contracts * option_price * 100 # 100 shares per contract + total_cost = cost * (1 + self.transaction_cost) + + if total_cost <= self.cash: + self.cash -= total_cost + self.call_position += num_contracts + self.call_entry_price = option_price + self.call_strike = self.data.get("call_strike", self.data.get("close", 0)) + self.cost += cost * self.transaction_cost + self.trades += 1 + + # Record trade + self.trades_memory.append( + { + "date": self._get_date(), + "type": "BUY_CALL", + "contracts": num_contracts, + "price": option_price, + "strike": self.call_strike, + "cost": total_cost, + } + ) + + def _buy_put(self, position_size: float): + """Buy put options. + + Parameters + ---------- + position_size : float + Fraction of max_options to buy (0 to 1) + """ + if position_size <= 0: + return + + num_contracts = int(position_size * self.max_options) + if num_contracts <= 0: + return + + option_price = self._calculate_option_price("put") + cost = num_contracts * option_price * 100 # 100 shares per contract + total_cost = cost * (1 + self.transaction_cost) + + if total_cost <= self.cash: + self.cash -= total_cost + self.put_position += num_contracts + self.put_entry_price = option_price + self.put_strike = self.data.get("put_strike", self.data.get("close", 0)) + self.cost += cost * self.transaction_cost + self.trades += 1 + + # Record trade + self.trades_memory.append( + { + "date": self._get_date(), + "type": "BUY_PUT", + "contracts": num_contracts, + "price": option_price, + "strike": self.put_strike, + "cost": total_cost, + } + ) + + def _close_positions(self): + """Close all open positions.""" + total_pnl = 0 + + # Close calls + if self.call_position > 0: + current_price = self._calculate_option_price("call") + proceeds = self.call_position * current_price * 100 + proceeds *= 1 - self.transaction_cost + self.cash += proceeds + + # Calculate PnL + entry_value = self.call_position * self.call_entry_price * 100 + pnl = proceeds - entry_value + total_pnl += pnl + + # Record trade + self.trades_memory.append( + { + "date": self._get_date(), + "type": "CLOSE_CALL", + "contracts": self.call_position, + "price": current_price, + "pnl": pnl, + } + ) + + self.call_position = 0 + self.call_entry_price = 0 + self.trades += 1 + + # Close puts + if self.put_position > 0: + current_price = self._calculate_option_price("put") + proceeds = self.put_position * current_price * 100 + proceeds *= 1 - self.transaction_cost + self.cash += proceeds + + # Calculate PnL + entry_value = self.put_position * self.put_entry_price * 100 + pnl = proceeds - entry_value + total_pnl += pnl + + # Record trade + self.trades_memory.append( + { + "date": self._get_date(), + "type": "CLOSE_PUT", + "contracts": self.put_position, + "price": current_price, + "pnl": pnl, + } + ) + + self.put_position = 0 + self.put_entry_price = 0 + self.trades += 1 + + # Update trade learning + if total_pnl != 0: + self._update_trade_learning(total_pnl) + + def _calculate_portfolio_value(self) -> float: + """Calculate total portfolio value. + + Returns + ------- + float + Total portfolio value (cash + positions) + """ + portfolio_value = self.cash + + # Add call position value + if self.call_position > 0: + call_price = self._calculate_option_price("call") + portfolio_value += self.call_position * call_price * 100 + + # Add put position value + if self.put_position > 0: + put_price = self._calculate_option_price("put") + portfolio_value += self.put_position * put_price * 100 + + return portfolio_value + + def _update_trade_learning(self, pnl: float): + """Update learning metrics from completed trade. + + Parameters + ---------- + pnl : float + Profit/loss from the trade + """ + self.trade_history.append(pnl) + + # Calculate win rate + wins = sum(1 for p in self.trade_history if p > 0) + total_trades = len(self.trade_history) + self.win_rate = wins / total_trades if total_trades > 0 else 0 + + # Calculate average win/loss + winning_trades = [p for p in self.trade_history if p > 0] + losing_trades = [p for p in self.trade_history if p < 0] + + self.avg_win = np.mean(winning_trades) if winning_trades else 0 + self.avg_loss = np.mean(losing_trades) if losing_trades else 0 + + def _calculate_sharpe_ratio(self) -> float: + """Calculate Sharpe ratio of the strategy. + + Returns + ------- + float + Sharpe ratio + """ + if len(self.portfolio_value_memory) < 2: + return 0 + + returns = ( + np.diff(self.portfolio_value_memory) / self.portfolio_value_memory[:-1] + ) + if len(returns) < 2 or np.std(returns) == 0: + return 0 + + sharpe = np.mean(returns) / np.std(returns) * np.sqrt(252) # Annualized + return sharpe + + def reset(self, *, seed=None, options=None): + """Reset the environment to initial state. + + Returns + ------- + observation : np.ndarray + Initial state + info : dict + Additional information + """ + super().reset(seed=seed) + + self.day = 0 + self.data = self.df.loc[self.day, :] if not self.df.empty else pd.Series() + self.cash = self.initial_amount + self.call_position = 0 + self.put_position = 0 + self.call_entry_price = 0 + self.put_entry_price = 0 + self.call_strike = 0 + self.put_strike = 0 + self.cost = 0 + self.trades = 0 + self.terminal = False + + self.state = self._initiate_state() + + self.asset_memory = [self.initial_amount] + self.rewards_memory = [] + self.actions_memory = [] + self.trades_memory = [] + self.date_memory = [self._get_date()] + self.portfolio_value_memory = [self.initial_amount] + + return self.state, {} + + def render(self, mode="human"): + """Render the environment.""" + return self.state + + def get_trade_stats(self) -> dict: + """Get trading statistics. + + Returns + ------- + Dict + Trading statistics + """ + return { + "total_trades": self.trades, + "win_rate": self.win_rate, + "avg_win": self.avg_win, + "avg_loss": self.avg_loss, + "total_pnl": self.cash + + self._calculate_portfolio_value() + - self.initial_amount, + "sharpe_ratio": self._calculate_sharpe_ratio(), + } diff --git a/finrl/meta/preprocessor/options_processor.py b/finrl/meta/preprocessor/options_processor.py new file mode 100644 index 0000000000..f33a5ab465 --- /dev/null +++ b/finrl/meta/preprocessor/options_processor.py @@ -0,0 +1,519 @@ +"""Options data processor with Greeks calculation for SPY trading. + +This module provides functionality to fetch real-time options data, +calculate options Greeks, and prepare data for reinforcement learning agents. +""" + +from __future__ import annotations + +import warnings +from datetime import datetime +from datetime import timedelta +from typing import Dict +from typing import List +from typing import Optional + +import numpy as np +import pandas as pd +import yfinance as yf +from scipy.stats import norm + +warnings.filterwarnings("ignore") + + +class OptionsProcessor: + """Fetches options data and calculates Greeks for options trading. + + Attributes + ---------- + ticker : str + Stock ticker symbol (e.g., 'SPY') + risk_free_rate : float + Risk-free interest rate for Greeks calculation (default: 0.05) + + Methods + ------- + fetch_options_chain() + Fetches the complete options chain for the ticker + calculate_greeks() + Calculates all Greeks (Delta, Gamma, Theta, Vega, Rho) + select_optimal_strike() + Selects the best strike price based on Greeks and strategy + get_real_time_data() + Gets real-time options data with Greeks + """ + + def __init__(self, ticker: str = "SPY", risk_free_rate: float = 0.05): + self.ticker = ticker + self.risk_free_rate = risk_free_rate + self.stock = yf.Ticker(ticker) + + def _black_scholes_price( + self, + S: float, + K: float, + T: float, + r: float, + sigma: float, + option_type: str = "call", + ) -> float: + """Calculate Black-Scholes option price. + + Parameters + ---------- + S : float + Current stock price + K : float + Strike price + T : float + Time to expiration (in years) + r : float + Risk-free rate + sigma : float + Volatility + option_type : str + 'call' or 'put' + + Returns + ------- + float + Option price + """ + if T <= 0 or sigma <= 0: + return max(0, S - K) if option_type == "call" else max(0, K - S) + + d1 = (np.log(S / K) + (r + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T)) + d2 = d1 - sigma * np.sqrt(T) + + if option_type == "call": + price = S * norm.cdf(d1) - K * np.exp(-r * T) * norm.cdf(d2) + else: + price = K * np.exp(-r * T) * norm.cdf(-d2) - S * norm.cdf(-d1) + + return price + + def _calculate_implied_volatility( + self, + market_price: float, + S: float, + K: float, + T: float, + r: float, + option_type: str = "call", + max_iterations: int = 100, + tolerance: float = 1e-5, + ) -> float: + """Calculate implied volatility using Newton-Raphson method. + + Parameters + ---------- + market_price : float + Current market price of the option + S : float + Current stock price + K : float + Strike price + T : float + Time to expiration (in years) + r : float + Risk-free rate + option_type : str + 'call' or 'put' + max_iterations : int + Maximum number of iterations + tolerance : float + Convergence tolerance + + Returns + ------- + float + Implied volatility + """ + if T <= 0: + return 0.0 + + sigma = 0.3 # Initial guess + + for _ in range(max_iterations): + price = self._black_scholes_price(S, K, T, r, sigma, option_type) + vega = self._calculate_vega(S, K, T, r, sigma) + + diff = market_price - price + + if abs(diff) < tolerance: + return sigma + + if vega < 1e-10: + break + + sigma = sigma + diff / vega + + # Keep sigma in reasonable bounds + sigma = max(0.01, min(5.0, sigma)) + + return sigma + + def _calculate_delta( + self, + S: float, + K: float, + T: float, + r: float, + sigma: float, + option_type: str = "call", + ) -> float: + """Calculate Delta (rate of change of option price with respect to stock price). + + Returns + ------- + float + Delta value + """ + if T <= 0 or sigma <= 0: + if option_type == "call": + return 1.0 if S > K else 0.0 + else: + return -1.0 if S < K else 0.0 + + d1 = (np.log(S / K) + (r + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T)) + + if option_type == "call": + return norm.cdf(d1) + else: + return norm.cdf(d1) - 1 + + def _calculate_gamma( + self, S: float, K: float, T: float, r: float, sigma: float + ) -> float: + """Calculate Gamma (rate of change of Delta with respect to stock price). + + Returns + ------- + float + Gamma value + """ + if T <= 0 or sigma <= 0: + return 0.0 + + d1 = (np.log(S / K) + (r + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T)) + return norm.pdf(d1) / (S * sigma * np.sqrt(T)) + + def _calculate_theta( + self, + S: float, + K: float, + T: float, + r: float, + sigma: float, + option_type: str = "call", + ) -> float: + """Calculate Theta (rate of change of option price with respect to time). + + Returns + ------- + float + Theta value (per day) + """ + if T <= 0 or sigma <= 0: + return 0.0 + + d1 = (np.log(S / K) + (r + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T)) + d2 = d1 - sigma * np.sqrt(T) + + term1 = -(S * norm.pdf(d1) * sigma) / (2 * np.sqrt(T)) + + if option_type == "call": + term2 = r * K * np.exp(-r * T) * norm.cdf(d2) + theta = (term1 - term2) / 365 # Convert to per day + else: + term2 = r * K * np.exp(-r * T) * norm.cdf(-d2) + theta = (term1 + term2) / 365 # Convert to per day + + return theta + + def _calculate_vega( + self, S: float, K: float, T: float, r: float, sigma: float + ) -> float: + """Calculate Vega (rate of change of option price with respect to volatility). + + Returns + ------- + float + Vega value + """ + if T <= 0 or sigma <= 0: + return 0.0 + + d1 = (np.log(S / K) + (r + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T)) + return S * norm.pdf(d1) * np.sqrt(T) / 100 # Divide by 100 for 1% change + + def _calculate_rho( + self, + S: float, + K: float, + T: float, + r: float, + sigma: float, + option_type: str = "call", + ) -> float: + """Calculate Rho (rate of change of option price with respect to interest rate). + + Returns + ------- + float + Rho value + """ + if T <= 0 or sigma <= 0: + return 0.0 + + d1 = (np.log(S / K) + (r + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T)) + d2 = d1 - sigma * np.sqrt(T) + + if option_type == "call": + return K * T * np.exp(-r * T) * norm.cdf(d2) / 100 + else: + return -K * T * np.exp(-r * T) * norm.cdf(-d2) / 100 + + def fetch_options_chain(self, expiration_date: str | None = None) -> pd.DataFrame: + """Fetch options chain for the ticker. + + Parameters + ---------- + expiration_date : str, optional + Specific expiration date (format: 'YYYY-MM-DD') + If None, uses the nearest expiration date + + Returns + ------- + pd.DataFrame + Options chain data with columns: strike, lastPrice, bid, ask, volume, + openInterest, impliedVolatility, inTheMoney, contractSymbol, lastTradeDate, + expiration, optionType + """ + try: + # Get available expiration dates + expirations = self.stock.options + + if len(expirations) == 0: + raise ValueError(f"No options available for {self.ticker}") + + # Use specified expiration or the nearest one + if expiration_date is None: + expiration = expirations[0] # Nearest expiration + else: + expiration = expiration_date + + # Get options chain + opt_chain = self.stock.option_chain(expiration) + + # Combine calls and puts + calls = opt_chain.calls.copy() + calls["optionType"] = "call" + calls["expiration"] = expiration + + puts = opt_chain.puts.copy() + puts["optionType"] = "put" + puts["expiration"] = expiration + + # Combine both + options_df = pd.concat([calls, puts], ignore_index=True) + + return options_df + + except Exception as e: + print(f"Error fetching options chain: {e}") + return pd.DataFrame() + + def calculate_greeks( + self, options_df: pd.DataFrame, current_price: float + ) -> pd.DataFrame: + """Calculate all Greeks for options chain. + + Parameters + ---------- + options_df : pd.DataFrame + Options chain data from fetch_options_chain() + current_price : float + Current stock price + + Returns + ------- + pd.DataFrame + Options data with Greeks columns added + """ + if options_df.empty: + return options_df + + df = options_df.copy() + + # Calculate time to expiration in years + today = datetime.now() + df["expiration_dt"] = pd.to_datetime(df["expiration"]) + df["days_to_expiry"] = (df["expiration_dt"] - today).dt.days + df["time_to_expiry"] = df["days_to_expiry"] / 365.0 + + # Initialize Greeks columns + df["delta"] = 0.0 + df["gamma"] = 0.0 + df["theta"] = 0.0 + df["vega"] = 0.0 + df["rho"] = 0.0 + df["calc_iv"] = 0.0 + + # Calculate Greeks for each option + for idx, row in df.iterrows(): + S = current_price + K = row["strike"] + T = row["time_to_expiry"] + r = self.risk_free_rate + option_type = row["optionType"] + + # Get or calculate implied volatility + if ( + "impliedVolatility" in row + and pd.notna(row["impliedVolatility"]) + and row["impliedVolatility"] > 0 + ): + sigma = row["impliedVolatility"] + else: + # Calculate IV from lastPrice + if pd.notna(row["lastPrice"]) and row["lastPrice"] > 0: + sigma = self._calculate_implied_volatility( + row["lastPrice"], S, K, T, r, option_type + ) + else: + sigma = 0.3 # Default + + df.at[idx, "calc_iv"] = sigma + + if T > 0 and sigma > 0: + df.at[idx, "delta"] = self._calculate_delta( + S, K, T, r, sigma, option_type + ) + df.at[idx, "gamma"] = self._calculate_gamma(S, K, T, r, sigma) + df.at[idx, "theta"] = self._calculate_theta( + S, K, T, r, sigma, option_type + ) + df.at[idx, "vega"] = self._calculate_vega(S, K, T, r, sigma) + df.at[idx, "rho"] = self._calculate_rho(S, K, T, r, sigma, option_type) + + return df + + def select_optimal_strike( + self, + options_df: pd.DataFrame, + strategy: str = "balanced", + option_type: str = "call", + ) -> dict: + """Select optimal strike based on Greeks and strategy. + + Parameters + ---------- + options_df : pd.DataFrame + Options data with Greeks + strategy : str + Strategy type: 'aggressive' (high delta), 'balanced' (moderate delta), + 'conservative' (low delta, high theta) + option_type : str + 'call' or 'put' + + Returns + ------- + Dict + Selected option details + """ + if options_df.empty: + return {} + + # Filter by option type + df = options_df[options_df["optionType"] == option_type].copy() + + if df.empty: + return {} + + # Filter liquid options (volume > 0 or openInterest > 100) + df = df[(df["volume"] > 0) | (df["openInterest"] > 100)] + + if df.empty: + return {} + + # Strategy-based selection + if strategy == "aggressive": + # High delta (close to ATM or ITM), high gamma + df["score"] = abs(df["delta"]) * 0.6 + df["gamma"] * 1000 * 0.4 + elif strategy == "balanced": + # Moderate delta, good gamma/theta ratio + df["score"] = ( + abs(df["delta"]) * 0.4 + + df["gamma"] * 1000 * 0.3 + - abs(df["theta"]) * 10 * 0.3 + ) + else: # conservative + # Lower delta, positive theta (for selling), high vega + df["score"] = (1 - abs(df["delta"])) * 0.5 + df["vega"] * 0.5 + + # Select best strike + best_idx = df["score"].idxmax() + best_option = df.loc[best_idx].to_dict() + + return best_option + + def get_real_time_data( + self, timeframe: str = "1m", period: str = "1d" + ) -> pd.DataFrame: + """Get real-time stock data with options Greeks. + + Parameters + ---------- + timeframe : str + Data interval: '1m', '5m', '15m', '1h', '1d' + period : str + Data period: '1d', '5d', '1mo', etc. + + Returns + ------- + pd.DataFrame + Real-time stock data with timestamp + """ + try: + # Get real-time stock data + stock_data = self.stock.history(period=period, interval=timeframe) + + if stock_data.empty: + print(f"No data available for {self.ticker}") + return pd.DataFrame() + + # Reset index to make timestamp a column + stock_data = stock_data.reset_index() + stock_data.rename( + columns={"Datetime": "timestamp", "Date": "timestamp"}, inplace=True + ) + + # Standardize column names + stock_data.columns = [col.lower() for col in stock_data.columns] + + return stock_data + + except Exception as e: + print(f"Error fetching real-time data: {e}") + return pd.DataFrame() + + def get_current_price(self) -> float: + """Get current stock price. + + Returns + ------- + float + Current price + """ + try: + data = self.stock.history(period="1d", interval="1m") + if not data.empty: + return data["Close"].iloc[-1] + else: + # Fallback to info + info = self.stock.info + return info.get("currentPrice", info.get("regularMarketPrice", 0)) + except Exception as e: + print(f"Error getting current price: {e}") + return 0.0 diff --git a/finrl/meta/preprocessor/spy_feature_engineer.py b/finrl/meta/preprocessor/spy_feature_engineer.py new file mode 100644 index 0000000000..8dea34c3f9 --- /dev/null +++ b/finrl/meta/preprocessor/spy_feature_engineer.py @@ -0,0 +1,526 @@ +"""Enhanced feature engineer for SPY options trading with Greeks and advanced indicators. + +This module extends the standard FeatureEngineer to add options Greeks, +advanced technical indicators, and multi-timeframe analysis for SPY trading. +""" + +from __future__ import annotations + +import warnings +from typing import List +from typing import Optional + +import numpy as np +import pandas as pd +from stockstats import StockDataFrame as Sdf + +warnings.filterwarnings("ignore") + +from finrl.meta.preprocessor.preprocessors import FeatureEngineer +from finrl.meta.preprocessor.options_processor import OptionsProcessor + + +class SPYFeatureEngineer(FeatureEngineer): + """Enhanced feature engineer for SPY options trading. + + Extends FeatureEngineer with: + - Options Greeks (Delta, Gamma, Theta, Vega, Rho) + - Advanced momentum indicators + - Volume analysis + - Multi-timeframe features + - Market regime detection + + Attributes + ---------- + use_options_greeks : bool + Include options Greeks in features + use_advanced_indicators : bool + Include advanced technical indicators + use_volume_features : bool + Include volume-based features + use_regime_detection : bool + Include market regime features + timeframes : List[str] + List of timeframes for multi-timeframe analysis + options_processor : OptionsProcessor + Options data processor + + Methods + ------- + preprocess_spy_data() + Main method to add all SPY-specific features + add_options_greeks() + Add options Greeks features + add_advanced_indicators() + Add advanced technical indicators + add_volume_features() + Add volume analysis features + add_regime_features() + Add market regime detection features + """ + + def __init__( + self, + use_technical_indicator: bool = True, + tech_indicator_list: list[str] = None, + use_vix: bool = True, + use_turbulence: bool = True, + use_options_greeks: bool = True, + use_advanced_indicators: bool = True, + use_volume_features: bool = True, + use_regime_detection: bool = True, + timeframes: list[str] = None, + risk_free_rate: float = 0.05, + ): + # Default technical indicators + if tech_indicator_list is None: + tech_indicator_list = [ + "macd", + "boll_ub", + "boll_lb", + "rsi_30", + "cci_30", + "dx_30", + "close_30_sma", + "close_60_sma", + ] + + # Initialize parent class + super().__init__( + use_technical_indicator=use_technical_indicator, + tech_indicator_list=tech_indicator_list, + use_vix=use_vix, + use_turbulence=use_turbulence, + user_defined_feature=True, + ) + + # SPY-specific features + self.use_options_greeks = use_options_greeks + self.use_advanced_indicators = use_advanced_indicators + self.use_volume_features = use_volume_features + self.use_regime_detection = use_regime_detection + self.timeframes = timeframes or ["1m", "5m", "15m", "1h", "1d"] + self.options_processor = OptionsProcessor( + ticker="SPY", risk_free_rate=risk_free_rate + ) + + def preprocess_spy_data( + self, df: pd.DataFrame, include_options: bool = True + ) -> pd.DataFrame: + """Main method to add all SPY-specific features. + + Parameters + ---------- + df : pd.DataFrame + Input dataframe with OHLCV data + include_options : bool + Whether to include options Greeks (requires real-time data) + + Returns + ------- + pd.DataFrame + Enhanced dataframe with all features + """ + # Start with standard preprocessing + df = self.preprocess_data(df) + + # Add advanced indicators + if self.use_advanced_indicators: + df = self.add_advanced_indicators(df) + print("Successfully added advanced indicators") + + # Add volume features + if self.use_volume_features: + df = self.add_volume_features(df) + print("Successfully added volume features") + + # Add regime detection + if self.use_regime_detection: + df = self.add_regime_features(df) + print("Successfully added regime features") + + # Add options Greeks (if real-time data available) + if self.use_options_greeks and include_options: + df = self.add_options_greeks(df) + print("Successfully added options Greeks") + + # Fill any remaining missing values + df = df.ffill().bfill() + + return df + + def add_advanced_indicators(self, data: pd.DataFrame) -> pd.DataFrame: + """Add advanced technical indicators. + + Indicators: + - RSI divergence + - MACD histogram + - ADX (Average Directional Index) + - ATR (Average True Range) + - Stochastic oscillator + - Williams %R + - Rate of Change (ROC) + + Parameters + ---------- + data : pd.DataFrame + Input dataframe + + Returns + ------- + pd.DataFrame + Dataframe with advanced indicators + """ + df = data.copy() + df = df.sort_values(by=["tic", "date"]) + stock = Sdf.retype(df.copy()) + unique_ticker = stock.tic.unique() + + advanced_indicators = { + "rsi_14": "rsi_14", # 14-period RSI + "rsi_7": "rsi_7", # 7-period RSI for divergence + "macdh": "macdh", # MACD histogram + "atr": "atr", # Average True Range + "adx": "adx", # Average Directional Index + "kdjk": "kdjk", # Stochastic K + "kdjd": "kdjd", # Stochastic D + "wr_14": "wr_14", # Williams %R + } + + for indicator_name, indicator_key in advanced_indicators.items(): + indicator_df = pd.DataFrame() + for i in range(len(unique_ticker)): + try: + temp_indicator = stock[stock.tic == unique_ticker[i]][indicator_key] + temp_indicator = pd.DataFrame(temp_indicator) + temp_indicator["tic"] = unique_ticker[i] + temp_indicator["date"] = df[df.tic == unique_ticker[i]][ + "date" + ].to_list() + indicator_df = pd.concat( + [indicator_df, temp_indicator], axis=0, ignore_index=True + ) + except Exception as e: + print(f"Error adding {indicator_name}: {e}") + + if not indicator_df.empty: + df = df.merge( + indicator_df[["tic", "date", indicator_key]], + on=["tic", "date"], + how="left", + ) + + # Add Rate of Change (ROC) + df = df.sort_values(by=["tic", "date"]) + for ticker in unique_ticker: + mask = df["tic"] == ticker + df.loc[mask, "roc_5"] = df.loc[mask, "close"].pct_change(5) * 100 + df.loc[mask, "roc_10"] = df.loc[mask, "close"].pct_change(10) * 100 + + df = df.sort_values(by=["date", "tic"]) + return df + + def add_volume_features(self, data: pd.DataFrame) -> pd.DataFrame: + """Add volume-based features. + + Features: + - Volume SMA (20, 50 periods) + - Volume ratio (current / SMA) + - On-Balance Volume (OBV) + - Money Flow Index (MFI) + - Volume Rate of Change + + Parameters + ---------- + data : pd.DataFrame + Input dataframe + + Returns + ------- + pd.DataFrame + Dataframe with volume features + """ + df = data.copy() + df = df.sort_values(by=["tic", "date"]) + unique_ticker = df.tic.unique() + + for ticker in unique_ticker: + mask = df["tic"] == ticker + ticker_df = df[mask].copy() + + # Volume moving averages + ticker_df["volume_sma_20"] = ticker_df["volume"].rolling(window=20).mean() + ticker_df["volume_sma_50"] = ticker_df["volume"].rolling(window=50).mean() + + # Volume ratio + ticker_df["volume_ratio"] = ticker_df["volume"] / ( + ticker_df["volume_sma_20"] + 1e-10 + ) + + # On-Balance Volume (OBV) + ticker_df["obv"] = ( + (np.sign(ticker_df["close"].diff()) * ticker_df["volume"]) + .fillna(0) + .cumsum() + ) + + # Volume Rate of Change + ticker_df["volume_roc"] = ticker_df["volume"].pct_change(5) * 100 + + # Money Flow Index (simplified) + typical_price = ( + ticker_df["high"] + ticker_df["low"] + ticker_df["close"] + ) / 3 + money_flow = typical_price * ticker_df["volume"] + ticker_df["mfi"] = money_flow.rolling(window=14).mean() + + # Update main dataframe + df.loc[mask, "volume_sma_20"] = ticker_df["volume_sma_20"].values + df.loc[mask, "volume_sma_50"] = ticker_df["volume_sma_50"].values + df.loc[mask, "volume_ratio"] = ticker_df["volume_ratio"].values + df.loc[mask, "obv"] = ticker_df["obv"].values + df.loc[mask, "volume_roc"] = ticker_df["volume_roc"].values + df.loc[mask, "mfi"] = ticker_df["mfi"].values + + df = df.sort_values(by=["date", "tic"]) + return df + + def add_regime_features(self, data: pd.DataFrame) -> pd.DataFrame: + """Add market regime detection features. + + Features: + - Trend regime (uptrend, downtrend, sideways) + - Volatility regime (high, medium, low) + - Market state (bull, bear, neutral) + + Parameters + ---------- + data : pd.DataFrame + Input dataframe + + Returns + ------- + pd.DataFrame + Dataframe with regime features + """ + df = data.copy() + df = df.sort_values(by=["tic", "date"]) + unique_ticker = df.tic.unique() + + for ticker in unique_ticker: + mask = df["tic"] == ticker + ticker_df = df[mask].copy() + + # Trend detection using moving averages + sma_20 = ticker_df["close"].rolling(window=20).mean() + sma_50 = ticker_df["close"].rolling(window=50).mean() + + # Trend regime: 1 = uptrend, -1 = downtrend, 0 = sideways + trend = np.where(sma_20 > sma_50, 1, -1) + ticker_df["trend_regime"] = trend + + # Volatility regime using ATR + high_low = ticker_df["high"] - ticker_df["low"] + high_close = np.abs(ticker_df["high"] - ticker_df["close"].shift()) + low_close = np.abs(ticker_df["low"] - ticker_df["close"].shift()) + ranges = pd.concat([high_low, high_close, low_close], axis=1) + true_range = ranges.max(axis=1) + atr_14 = true_range.rolling(window=14).mean() + + # Normalize ATR by price + atr_percent = (atr_14 / ticker_df["close"]) * 100 + + # Volatility regime: 2 = high, 1 = medium, 0 = low + volatility_regime = pd.cut( + atr_percent, + bins=[ + 0, + atr_percent.quantile(0.33), + atr_percent.quantile(0.67), + float("inf"), + ], + labels=[0, 1, 2], + ).astype(float) + ticker_df["volatility_regime"] = volatility_regime + + # Market state (combines trend and momentum) + rsi = ticker_df.get("rsi_30", ticker_df.get("rsi_14", 50)) + market_state = np.where( + (trend == 1) & (rsi > 50), + 1, # Bull + np.where((trend == -1) & (rsi < 50), -1, 0), # Bear or Neutral + ) + ticker_df["market_state"] = market_state + + # Update main dataframe + df.loc[mask, "trend_regime"] = ticker_df["trend_regime"].values + df.loc[mask, "volatility_regime"] = ticker_df["volatility_regime"].values + df.loc[mask, "market_state"] = ticker_df["market_state"].values + + df = df.sort_values(by=["date", "tic"]) + return df + + def add_options_greeks( + self, data: pd.DataFrame, expiration_date: str | None = None + ) -> pd.DataFrame: + """Add options Greeks features. + + Note: This requires real-time options data and should be used + during live trading or with recent historical data. + + Parameters + ---------- + data : pd.DataFrame + Input dataframe + expiration_date : str, optional + Specific options expiration date + + Returns + ------- + pd.DataFrame + Dataframe with Greeks features + """ + df = data.copy() + + try: + # Get current price + current_price = self.options_processor.get_current_price() + + if current_price == 0: + print("Could not fetch current price for Greeks calculation") + return df + + # Fetch options chain + options_df = self.options_processor.fetch_options_chain(expiration_date) + + if options_df.empty: + print("No options data available") + return df + + # Calculate Greeks + options_with_greeks = self.options_processor.calculate_greeks( + options_df, current_price + ) + + # Select optimal calls and puts + best_call = self.options_processor.select_optimal_strike( + options_with_greeks, strategy="balanced", option_type="call" + ) + best_put = self.options_processor.select_optimal_strike( + options_with_greeks, strategy="balanced", option_type="put" + ) + + # Add Greeks as features (aggregate values) + if best_call: + df["call_delta"] = best_call.get("delta", 0) + df["call_gamma"] = best_call.get("gamma", 0) + df["call_theta"] = best_call.get("theta", 0) + df["call_vega"] = best_call.get("vega", 0) + df["call_iv"] = best_call.get("calc_iv", 0) + df["call_strike"] = best_call.get("strike", 0) + + if best_put: + df["put_delta"] = best_put.get("delta", 0) + df["put_gamma"] = best_put.get("gamma", 0) + df["put_theta"] = best_put.get("theta", 0) + df["put_vega"] = best_put.get("vega", 0) + df["put_iv"] = best_put.get("calc_iv", 0) + df["put_strike"] = best_put.get("strike", 0) + + # Calculate put-call ratio and implied volatility spread + call_volume = options_with_greeks[ + options_with_greeks["optionType"] == "call" + ]["volume"].sum() + put_volume = options_with_greeks[ + options_with_greeks["optionType"] == "put" + ]["volume"].sum() + df["put_call_ratio"] = put_volume / (call_volume + 1e-10) + + # Average IV across ATM options + atm_strikes = options_with_greeks[ + (options_with_greeks["strike"] >= current_price * 0.98) + & (options_with_greeks["strike"] <= current_price * 1.02) + ] + df["atm_iv"] = atm_strikes["calc_iv"].mean() if not atm_strikes.empty else 0 + + except Exception as e: + print(f"Error adding options Greeks: {e}") + + return df + + def get_feature_names(self) -> list[str]: + """Get list of all feature names. + + Returns + ------- + List[str] + List of feature column names + """ + features = [] + + # Standard technical indicators + if self.use_technical_indicator: + features.extend(self.tech_indicator_list) + + # Advanced indicators + if self.use_advanced_indicators: + features.extend( + [ + "rsi_14", + "rsi_7", + "macdh", + "atr", + "adx", + "kdjk", + "kdjd", + "wr_14", + "roc_5", + "roc_10", + ] + ) + + # Volume features + if self.use_volume_features: + features.extend( + [ + "volume_sma_20", + "volume_sma_50", + "volume_ratio", + "obv", + "volume_roc", + "mfi", + ] + ) + + # Regime features + if self.use_regime_detection: + features.extend(["trend_regime", "volatility_regime", "market_state"]) + + # Options Greeks + if self.use_options_greeks: + features.extend( + [ + "call_delta", + "call_gamma", + "call_theta", + "call_vega", + "call_iv", + "call_strike", + "put_delta", + "put_gamma", + "put_theta", + "put_vega", + "put_iv", + "put_strike", + "put_call_ratio", + "atm_iv", + ] + ) + + # VIX and turbulence + if self.use_vix: + features.append("vix") + if self.use_turbulence: + features.append("turbulence") + + return features diff --git a/spy_trading_tool/README.md b/spy_trading_tool/README.md new file mode 100644 index 0000000000..0b74647f0b --- /dev/null +++ b/spy_trading_tool/README.md @@ -0,0 +1,709 @@ +# SPY Options Trading Tool with Real-time Learning + +A comprehensive, AI-powered trading system for SPY options that combines options Greeks analysis, multi-timeframe optimization, and continuous reinforcement learning to generate intelligent trading signals and price targets. + +## 🚀 Features + +### Core Capabilities +- ✅ **Real-time Learning**: Continuously learns from every trade using Proximal Policy Optimization (PPO) +- ✅ **Options Greeks Analysis**: Calculate and use Delta, Gamma, Theta, Vega for optimal strike selection +- ✅ **Multi-Timeframe Optimization**: Analyzes 1m, 5m, 15m, 1h, 1d timeframes using CAGR +- ✅ **Price Target Prediction**: Provides upside, downside, and expected price targets +- ✅ **Minute-by-Minute Updates**: Self-updating system that refreshes every minute +- ✅ **Advanced Indicators**: 20+ technical indicators including RSI, MACD, Bollinger Bands, ATR, ADX +- ✅ **Risk Management**: Comprehensive risk metrics including Sharpe, Sortino, Calmar ratios +- ✅ **Performance Tracking**: Real-time equity curves, drawdown analysis, and trade logging +- ✅ **Visualization**: Beautiful charts for equity, drawdown, and returns distribution + +### Technical Features +- **Reinforcement Learning**: Uses Stable Baselines3 PPO for decision making +- **Feature Engineering**: 50+ engineered features including volume, regime detection, and Greeks +- **Experience Replay**: Maintains buffer of recent experiences for continuous improvement +- **Adaptive Strategy**: Automatically adjusts based on market conditions +- **Model Persistence**: Auto-save and load trained models +- **Comprehensive Logging**: Trade-by-trade logging with JSON export + +--- + +## 📋 Table of Contents + +1. [Installation](#installation) +2. [Quick Start](#quick-start) +3. [Architecture](#architecture) +4. [Configuration](#configuration) +5. [Usage Examples](#usage-examples) +6. [Components](#components) +7. [Performance Metrics](#performance-metrics) +8. [Advanced Usage](#advanced-usage) +9. [Troubleshooting](#troubleshooting) +10. [Contributing](#contributing) + +--- + +## 🛠️ Installation + +### Prerequisites +- Python 3.8 - 3.11 +- pip package manager +- Git + +### Step 1: Clone Repository +```bash +cd /home/user/FinRL +``` + +### Step 2: Install Dependencies +```bash +pip install -r requirements.txt +``` + +### Additional Dependencies +```bash +pip install stable-baselines3 +pip install gymnasium +pip install yfinance +pip install matplotlib +pip install scipy +pip install pandas numpy +pip install stockstats +``` + +### Step 3: Verify Installation +```bash +cd spy_trading_tool +python config.py # Should print configuration +``` + +--- + +## ⚡ Quick Start + +### Basic Usage + +```python +from spy_trading_tool import SPYTradingTool + +# Initialize the tool +tool = SPYTradingTool( + ticker='SPY', + initial_capital=10000, + auto_save=True +) + +# Train initial model (first time only) +tool.train_initial_model(training_days=30, timesteps=10000) + +# Run continuous trading +tool.run_continuous( + update_interval=60, # Update every minute + max_updates=None # Run indefinitely +) +``` + +### Command Line Usage + +```bash +# Run the main trading tool +python spy_trader.py + +# The tool will prompt you to: +# 1. Train initial model (y/n) +# 2. Run timeframe optimization (y/n) +# 3. Start continuous trading +``` + +### Single Update Example + +```python +from spy_trading_tool import SPYTradingTool + +tool = SPYTradingTool() + +# Perform a single update +result = tool.update(timeframe='1m') + +# Get current signal +signal = tool.get_current_signal() +print(f"Signal: {signal['signal']}") +print(f"Confidence: {signal['confidence']:.2%}") + +# Get price targets +targets = tool.get_price_target() +print(f"Current: ${targets['current']:.2f}") +print(f"Upside: ${targets['upside']:.2f}") +print(f"Downside: ${targets['downside']:.2f}") +``` + +--- + +## 🏗️ Architecture + +### System Overview + +``` +┌─────────────────────────────────────────────────────────────┐ +│ SPY Trading Tool │ +└─────────────────────────────────────────────────────────────┘ + │ + ┌──────────────────┼──────────────────┐ + │ │ │ + ▼ ▼ ▼ +┌───────────────┐ ┌──────────────┐ ┌──────────────┐ +│ Options │ │ Feature │ │ Learning │ +│ Processor │ │ Engineer │ │ Agent │ +│ │ │ │ │ (PPO) │ +│ - Greeks │ │ - 50+ Feat. │ │ - Cont. Lrn │ +│ - IV Calc │ │ - Multi-TF │ │ - Experience │ +│ - Strike Sel │ │ - Regime Det │ │ - Prediction │ +└───────────────┘ └──────────────┘ └──────────────┘ + │ │ │ + └──────────────────┼──────────────────┘ + ▼ + ┌──────────────────┐ + │ Timeframe │ + │ Optimizer │ + │ (CAGR-based) │ + └──────────────────┘ + │ + ▼ + ┌──────────────────┐ + │ Performance │ + │ Tracker │ + │ - Metrics │ + │ - Visualization │ + └──────────────────┘ +``` + +### Component Hierarchy + +1. **Data Layer** + - `OptionsProcessor`: Fetches options data and calculates Greeks + - `YahooDownloader`: Downloads historical stock data + +2. **Feature Engineering Layer** + - `SPYFeatureEngineer`: Engineers 50+ features from raw data + - Technical indicators, volume analysis, regime detection + +3. **Environment Layer** + - `SPYOptionsEnv`: Gymnasium environment for RL training + - Simulates options trading with Greeks + +4. **Agent Layer** + - `ContinuousLearningAgent`: PPO-based RL agent + - Continuous learning from trades + +5. **Optimization Layer** + - `TimeframeOptimizer`: Multi-timeframe CAGR optimization + - Adaptive timeframe selection + +6. **Tracking Layer** + - `PerformanceTracker`: Metrics, logging, visualization + +--- + +## ⚙️ Configuration + +Edit `spy_trading_tool/config.py` to customize settings: + +### Key Parameters + +```python +# Trading Settings +TICKER = 'SPY' +INITIAL_CAPITAL = 10000 +TRANSACTION_COST = 0.001 +MAX_OPTIONS = 10 + +# Timeframes +TIMEFRAMES = ['1m', '5m', '15m', '1h', '1d'] +DEFAULT_TIMEFRAME = '1m' +UPDATE_INTERVAL = 60 # seconds + +# Learning +LEARNING_RATE = 3e-4 +BUFFER_SIZE = 1000 +UPDATE_FREQUENCY = 100 + +# Risk Management +MAX_DRAWDOWN_THRESHOLD = 0.20 +MAX_POSITION_SIZE = 0.10 +DAILY_LOSS_LIMIT = 0.05 + +# Features +USE_VIX = True +USE_ADVANCED_INDICATORS = True +USE_VOLUME_FEATURES = True +USE_REGIME_DETECTION = True +``` + +--- + +## 📚 Usage Examples + +### Example 1: Basic Trading Session + +```python +from spy_trading_tool import SPYTradingTool + +# Initialize +tool = SPYTradingTool( + initial_capital=10000, + max_options=5 +) + +# Load pre-trained model (if exists) +tool.agent.load_model('./spy_models/spy_model_final.zip') + +# Run for 100 updates (100 minutes) +tool.run_continuous( + update_interval=60, + max_updates=100 +) +``` + +### Example 2: Timeframe Optimization + +```python +from spy_trading_tool import SPYTradingTool + +tool = SPYTradingTool() + +# Optimize across timeframes +results = tool.optimize_timeframes() + +print(results) +# Output: +# cagr sharpe max_drawdown calmar win_rate total_return score +# 5m 0.18 2.34 -0.08 2.25 0.62 0.05 0.092 +# 1m 0.15 2.01 -0.10 1.50 0.58 0.04 0.078 +# ... +``` + +### Example 3: Custom Feature Engineering + +```python +from finrl.meta.preprocessor.spy_feature_engineer import SPYFeatureEngineer +from finrl.meta.preprocessor.yahoodownloader import YahooDownloader + +# Download data +downloader = YahooDownloader( + start_date='2024-01-01', + end_date='2024-12-31', + ticker_list=['SPY'] +) +data = downloader.fetch_data() + +# Engineer features +engineer = SPYFeatureEngineer( + use_technical_indicator=True, + use_options_greeks=False, # Skip for historical data + use_advanced_indicators=True +) + +processed = engineer.preprocess_spy_data(data, include_options=False) +print(f"Features: {list(processed.columns)}") +``` + +### Example 4: Performance Analysis + +```python +from spy_trading_tool.performance_tracker import PerformanceTracker + +tracker = PerformanceTracker(initial_capital=10000) + +# Simulate some trades +tracker.log_trade({'type': 'BUY_CALL', 'pnl': 150}) +tracker.log_trade({'type': 'CLOSE_CALL', 'pnl': -50}) +tracker.update_equity(10100) + +# Calculate metrics +metrics = tracker.calculate_metrics() +print(f"Win Rate: {metrics['win_rate']:.2%}") +print(f"Sharpe: {metrics['sharpe_ratio']:.2f}") + +# Generate report +report = tracker.generate_report() +print(report) + +# Plot equity curve +tracker.plot_equity_curve(save_path='./equity.png') +``` + +### Example 5: Manual Signal Generation + +```python +from spy_trading_tool import SPYTradingTool +import pandas as pd + +tool = SPYTradingTool() + +# Get latest data +data = tool.fetch_real_time_data(timeframe='1m') +processed = tool.engineer_features(data) + +# Get signal +signal = tool.get_current_signal() + +print(f"Action: {signal['signal']}") +print(f"Confidence: {signal['confidence']:.2%}") +print(f"Price Target: {signal['price_target']}") + +# Interpret +if signal['signal'] == 'BUY_CALL': + print(f"➡️ BUY CALL with {signal['position_size']:.1%} of max position") +elif signal['signal'] == 'BUY_PUT': + print(f"➡️ BUY PUT with {signal['position_size']:.1%} of max position") +elif signal['signal'] == 'CLOSE': + print("➡️ CLOSE all positions") +else: + print("➡️ HOLD current positions") +``` + +--- + +## 🧩 Components + +### 1. Options Processor (`options_processor.py`) + +Handles options data and Greeks calculation. + +**Key Methods:** +- `fetch_options_chain()`: Get options chain for SPY +- `calculate_greeks()`: Calculate Delta, Gamma, Theta, Vega, Rho +- `select_optimal_strike()`: Choose best strike based on Greeks +- `get_current_price()`: Get real-time SPY price + +**Example:** +```python +from finrl.meta.preprocessor.options_processor import OptionsProcessor + +processor = OptionsProcessor(ticker='SPY') +current_price = processor.get_current_price() +options_chain = processor.fetch_options_chain() +options_with_greeks = processor.calculate_greeks(options_chain, current_price) + +best_call = processor.select_optimal_strike( + options_with_greeks, + strategy='balanced', + option_type='call' +) +print(f"Best Call Strike: ${best_call['strike']:.2f}") +print(f"Delta: {best_call['delta']:.3f}") +``` + +### 2. Feature Engineer (`spy_feature_engineer.py`) + +Engineers features from raw market data. + +**Features Generated:** +- Technical: MACD, RSI, Bollinger, ADX, ATR, etc. +- Volume: OBV, MFI, Volume Ratios +- Regime: Trend, Volatility, Market State +- Greeks: Delta, Gamma, Theta, Vega (if available) + +**Example:** +```python +from finrl.meta.preprocessor.spy_feature_engineer import SPYFeatureEngineer + +engineer = SPYFeatureEngineer() +features = engineer.get_feature_names() +print(f"Total features: {len(features)}") +``` + +### 3. Trading Environment (`env_spy_options.py`) + +Gymnasium-compatible environment for RL training. + +**State Space:** +- Cash, positions, current price, indicators, Greeks + +**Action Space:** +- Action type: CLOSE (-1), HOLD (0), BUY_CALL (1), BUY_PUT (2) +- Position size: 0 to 1 (fraction of max) + +**Reward:** +- Portfolio value change (scaled) +- Penalties for excessive trading + +### 4. Learning Agent (`learning_agent.py`) + +Continuous learning RL agent using PPO. + +**Features:** +- Incremental model updates +- Experience replay buffer +- Price target prediction +- Trade outcome learning + +**Example:** +```python +from spy_trading_tool.learning_agent import ContinuousLearningAgent + +agent = ContinuousLearningAgent(initial_amount=10000) +agent.create_env(processed_data) +agent.initialize_model() +agent.train(total_timesteps=10000) + +# Save model +agent.save_model('./my_model.zip') +``` + +### 5. Timeframe Optimizer (`timeframe_optimizer.py`) + +Optimizes strategy across multiple timeframes. + +**Metrics:** +- CAGR +- Sharpe Ratio +- Calmar Ratio +- Max Drawdown +- Win Rate + +**Example:** +```python +from spy_trading_tool.timeframe_optimizer import TimeframeOptimizer + +optimizer = TimeframeOptimizer(timeframes=['1m', '5m', '1h']) + +# Mock portfolio data +timeframe_data = { + '1m': np.array([10000, 10100, 10150, 10200]), + '5m': np.array([10000, 10200, 10300, 10400]), + '1h': np.array([10000, 10150, 10250, 10350]), +} + +best_tf, metrics = optimizer.optimize(timeframe_data) +print(f"Best timeframe: {best_tf}") +print(f"CAGR: {metrics['cagr']:.2%}") +``` + +### 6. Performance Tracker (`performance_tracker.py`) + +Tracks and visualizes trading performance. + +**Capabilities:** +- Equity curve tracking +- Metrics calculation (Sharpe, Sortino, Calmar) +- Trade logging +- Visualization (equity, drawdown, returns) +- Report generation + +--- + +## 📊 Performance Metrics + +### Metrics Calculated + +| Metric | Description | Good Value | +|--------|-------------|------------| +| Total Return | Overall profit/loss % | > 10% | +| CAGR | Compound annual growth rate | > 15% | +| Win Rate | % of profitable trades | > 55% | +| Sharpe Ratio | Risk-adjusted returns | > 1.5 | +| Sortino Ratio | Downside risk-adjusted returns | > 2.0 | +| Calmar Ratio | Return / max drawdown | > 3.0 | +| Max Drawdown | Largest peak-to-trough decline | < -15% | +| Profit Factor | Gross profit / gross loss | > 1.5 | + +### Example Output + +``` +SPY OPTIONS TRADING - PERFORMANCE REPORT +====================================================================== + +PORTFOLIO SUMMARY +---------------------------------------------------------------------- + Initial Capital: $10,000.00 + Current Equity: $11,250.00 + Total Return: +12.50% + Profit/Loss: +$1,250.00 + +TRADING STATISTICS +---------------------------------------------------------------------- + Total Trades: 45 + Win Rate: 62.22% + Average Win: $85.50 + Average Loss: $42.30 + Profit Factor: 1.85 + +RISK METRICS +---------------------------------------------------------------------- + Sharpe Ratio: 2.15 + Sortino Ratio: 3.22 + Calmar Ratio: 4.17 + Maximum Drawdown: -8.50% + Current Drawdown: -2.10% +``` + +--- + +## 🔧 Advanced Usage + +### Custom Strategy Development + +```python +from spy_trading_tool import SPYTradingTool + +class CustomSPYTool(SPYTradingTool): + def get_current_signal(self): + # Override to implement custom logic + signal = super().get_current_signal() + + # Add custom filters + if self.current_data['rsi_14'].iloc[-1] > 70: + signal['signal'] = 'CLOSE' # Force close on overbought + + return signal + +# Use custom tool +tool = CustomSPYTool() +tool.run_continuous() +``` + +### Multi-Asset Extension + +```python +# Extend to trade multiple tickers +tickers = ['SPY', 'QQQ', 'IWM'] + +tools = { + ticker: SPYTradingTool(ticker=ticker, initial_capital=3333) + for ticker in tickers +} + +# Run all in parallel (requires threading) +``` + +### Integration with Brokers + +```python +# Example integration with Alpaca +from alpaca_trade_api import REST + +api = REST(api_key, secret_key, base_url) + +tool = SPYTradingTool() +signal = tool.get_current_signal() + +if signal['signal'] == 'BUY_CALL': + # Place actual option order + api.submit_order( + symbol='SPY', + qty=1, + side='buy', + type='limit', + # ... option parameters + ) +``` + +--- + +## 🐛 Troubleshooting + +### Common Issues + +**Issue: "No data available"** +- Solution: Check internet connection, verify market is open + +**Issue: "Model training takes too long"** +- Solution: Reduce `timesteps` parameter or use smaller dataset + +**Issue: "ImportError: No module named..."** +- Solution: Install missing dependencies: `pip install -r requirements.txt` + +**Issue: "Options data not available"** +- Solution: Use historical mode without options Greeks for backtesting + +**Issue: "Memory error during training"** +- Solution: Reduce `buffer_size` and `lookback_period` + +### Debug Mode + +Enable debug mode in `config.py`: +```python +DEBUG_MODE = True +VERBOSE = 2 +``` + +--- + +## 📝 File Structure + +``` +spy_trading_tool/ +│ +├── __init__.py # Package initialization +├── config.py # Configuration settings +├── README.md # This file +│ +├── spy_trader.py # Main trading tool +├── learning_agent.py # RL agent with continuous learning +├── timeframe_optimizer.py # Multi-timeframe CAGR optimizer +├── performance_tracker.py # Performance metrics & visualization +│ +└── (models and results created at runtime) + ├── spy_models/ # Saved RL models + └── spy_results/ # Trading results, logs, plots +``` + +--- + +## 🚀 Next Steps + +1. **Backtest**: Test on historical data before live trading +2. **Paper Trade**: Run in paper trading mode to validate +3. **Optimize**: Fine-tune hyperparameters in `config.py` +4. **Monitor**: Use performance tracker to analyze results +5. **Scale**: Increase capital and options contracts gradually + +--- + +## ⚠️ Disclaimer + +This tool is for educational and research purposes only. Trading options involves substantial risk of loss. Past performance does not guarantee future results. Always: + +- Start with paper trading +- Never risk more than you can afford to lose +- Understand options risks thoroughly +- Consult a financial advisor +- Test extensively before live trading + +--- + +## 📄 License + +MIT License - See LICENSE file for details + +--- + +## 🤝 Contributing + +Contributions welcome! Please: + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Add tests +5. Submit a pull request + +--- + +## 📧 Support + +For questions or issues: +- Create an issue on GitHub +- Check existing documentation +- Review configuration settings + +--- + +## 🙏 Acknowledgments + +- Built on FinRL framework +- Uses Stable Baselines3 for RL +- Options data from yfinance +- Inspired by modern quantitative trading research + +--- + +**Happy Trading! 📈** diff --git a/spy_trading_tool/__init__.py b/spy_trading_tool/__init__.py new file mode 100644 index 0000000000..1e09b5ebc2 --- /dev/null +++ b/spy_trading_tool/__init__.py @@ -0,0 +1,18 @@ +"""SPY Options Trading Tool with Real-time Learning. + +A comprehensive trading system for SPY options that combines: +- Options Greeks analysis +- Multi-timeframe optimization using CAGR +- Real-time continuous learning +- Price target predictions +- Minute-by-minute updates +""" + +from __future__ import annotations + +from spy_trading_tool.learning_agent import ContinuousLearningAgent +from spy_trading_tool.spy_trader import SPYTradingTool +from spy_trading_tool.timeframe_optimizer import TimeframeOptimizer + +__version__ = "1.0.0" +__all__ = ["SPYTradingTool", "ContinuousLearningAgent", "TimeframeOptimizer"] diff --git a/spy_trading_tool/config.py b/spy_trading_tool/config.py new file mode 100644 index 0000000000..1e82b8b4cf --- /dev/null +++ b/spy_trading_tool/config.py @@ -0,0 +1,307 @@ +"""Configuration file for SPY Trading Tool. + +This file contains all configurable parameters for the trading system. +""" + +from __future__ import annotations + +from typing import List + +# ===================================================================== +# GENERAL SETTINGS +# ===================================================================== + +# Stock ticker +TICKER = "SPY" + +# Initial trading capital +INITIAL_CAPITAL = 10000 + +# Transaction cost (as decimal, e.g., 0.001 = 0.1%) +TRANSACTION_COST = 0.001 + +# Maximum number of option contracts to hold +MAX_OPTIONS = 10 + +# Risk-free rate (annual, as decimal) +RISK_FREE_RATE = 0.05 + +# ===================================================================== +# TIMEFRAME SETTINGS +# ===================================================================== + +# Timeframes to analyze and optimize +TIMEFRAMES: list[str] = ["1m", "5m", "15m", "1h", "1d"] + +# Default timeframe for trading +DEFAULT_TIMEFRAME = "1m" + +# Lookback period for each timeframe (number of periods) +LOOKBACK_PERIOD = 100 + +# Reoptimization frequency (in minutes) +REOPTIMIZE_FREQ = 60 + +# ===================================================================== +# TECHNICAL INDICATORS +# ===================================================================== + +# List of technical indicators to use +TECH_INDICATORS: list[str] = [ + "macd", # Moving Average Convergence Divergence + "rsi_30", # Relative Strength Index (30 period) + "rsi_14", # Relative Strength Index (14 period) + "cci_30", # Commodity Channel Index + "dx_30", # Directional Index + "close_30_sma", # Simple Moving Average (30 period) + "close_60_sma", # Simple Moving Average (60 period) + "atr", # Average True Range + "adx", # Average Directional Index + "boll_ub", # Bollinger Upper Band + "boll_lb", # Bollinger Lower Band +] + +# Options Greeks to use +GREEKS_LIST: list[str] = [ + "call_delta", + "call_gamma", + "call_theta", + "call_vega", + "put_delta", + "put_gamma", + "put_theta", + "put_vega", +] + +# Use VIX indicator +USE_VIX = True + +# Use turbulence index +USE_TURBULENCE = False + +# Use advanced indicators +USE_ADVANCED_INDICATORS = True + +# Use volume features +USE_VOLUME_FEATURES = True + +# Use regime detection +USE_REGIME_DETECTION = True + +# ===================================================================== +# LEARNING AGENT SETTINGS +# ===================================================================== + +# Learning rate for PPO +LEARNING_RATE = 3e-4 + +# Experience buffer size +BUFFER_SIZE = 1000 + +# Model update frequency (in steps) +UPDATE_FREQUENCY = 100 + +# Training timesteps for initial model +INITIAL_TRAINING_TIMESTEPS = 10000 + +# Training timesteps for incremental updates +INCREMENTAL_UPDATE_TIMESTEPS = 500 + +# Number of days for initial training +INITIAL_TRAINING_DAYS = 30 + +# PPO specific parameters +PPO_N_STEPS = 2048 +PPO_BATCH_SIZE = 64 +PPO_N_EPOCHS = 10 +PPO_GAMMA = 0.99 +PPO_GAE_LAMBDA = 0.95 +PPO_CLIP_RANGE = 0.2 +PPO_ENT_COEF = 0.01 + +# ===================================================================== +# REAL-TIME TRADING SETTINGS +# ===================================================================== + +# Update interval in seconds (60 = 1 minute) +UPDATE_INTERVAL = 60 + +# Maximum number of updates (None = unlimited) +MAX_UPDATES = None + +# Auto-save model after updates +AUTO_SAVE = True + +# Model save directory +MODEL_SAVE_DIR = "./spy_models" + +# Results save directory +RESULTS_DIR = "./spy_results" + +# Trade log file +TRADE_LOG_FILE = "./spy_results/trades.log" + +# ===================================================================== +# VISUALIZATION SETTINGS +# ===================================================================== + +# Enable plotting +ENABLE_PLOTS = True + +# Plot save directory +PLOT_SAVE_DIR = "./spy_results/plots" + +# Plot DPI +PLOT_DPI = 300 + +# ===================================================================== +# OPTIONS SETTINGS +# ===================================================================== + +# Use real-time options data +USE_OPTIONS_DATA = True + +# Options expiration preference (None = nearest) +OPTIONS_EXPIRATION = None + +# Strike selection strategy: 'aggressive', 'balanced', 'conservative' +STRIKE_SELECTION_STRATEGY = "balanced" + +# ===================================================================== +# RISK MANAGEMENT +# ===================================================================== + +# Maximum drawdown threshold (as decimal, e.g., 0.20 = 20%) +MAX_DRAWDOWN_THRESHOLD = 0.20 + +# Stop trading if drawdown exceeds threshold +STOP_ON_MAX_DRAWDOWN = True + +# Position size limits (as percentage of capital) +MAX_POSITION_SIZE = 0.10 # 10% of capital per trade + +# Daily loss limit (as percentage of capital) +DAILY_LOSS_LIMIT = 0.05 # 5% of capital + +# ===================================================================== +# NOTIFICATION SETTINGS (OPTIONAL) +# ===================================================================== + +# Enable email notifications +ENABLE_EMAIL_NOTIFICATIONS = False + +# Email settings (if enabled) +EMAIL_FROM = "" +EMAIL_TO = "" +EMAIL_SMTP_SERVER = "smtp.gmail.com" +EMAIL_SMTP_PORT = 587 +EMAIL_PASSWORD = "" + +# Notification triggers +NOTIFY_ON_TRADE = True +NOTIFY_ON_ERROR = True +NOTIFY_ON_DAILY_SUMMARY = True + +# ===================================================================== +# ADVANCED SETTINGS +# ===================================================================== + +# Random seed for reproducibility +RANDOM_SEED = 42 + +# Verbose output level (0 = minimal, 1 = normal, 2 = detailed) +VERBOSE = 1 + +# Enable debug mode +DEBUG_MODE = False + +# Data cache directory +CACHE_DIR = "./cache" + +# Cache expiration (in minutes) +CACHE_EXPIRATION = 5 + +# ===================================================================== +# BACKTESTING SETTINGS +# ===================================================================== + +# Backtest start date +BACKTEST_START_DATE = "2023-01-01" + +# Backtest end date (None = today) +BACKTEST_END_DATE = None + +# Backtest split ratio (train/test) +TRAIN_TEST_SPLIT = 0.8 + +# ===================================================================== +# HELPER FUNCTIONS +# ===================================================================== + + +def get_config() -> dict: + """Get all configuration as a dictionary. + + Returns + ------- + dict + Configuration dictionary + """ + return { + # General + "ticker": TICKER, + "initial_capital": INITIAL_CAPITAL, + "transaction_cost": TRANSACTION_COST, + "max_options": MAX_OPTIONS, + "risk_free_rate": RISK_FREE_RATE, + # Timeframes + "timeframes": TIMEFRAMES, + "default_timeframe": DEFAULT_TIMEFRAME, + "lookback_period": LOOKBACK_PERIOD, + "reoptimize_freq": REOPTIMIZE_FREQ, + # Indicators + "tech_indicators": TECH_INDICATORS, + "greeks_list": GREEKS_LIST, + "use_vix": USE_VIX, + "use_turbulence": USE_TURBULENCE, + "use_advanced_indicators": USE_ADVANCED_INDICATORS, + "use_volume_features": USE_VOLUME_FEATURES, + "use_regime_detection": USE_REGIME_DETECTION, + # Learning + "learning_rate": LEARNING_RATE, + "buffer_size": BUFFER_SIZE, + "update_frequency": UPDATE_FREQUENCY, + "initial_training_timesteps": INITIAL_TRAINING_TIMESTEPS, + "incremental_update_timesteps": INCREMENTAL_UPDATE_TIMESTEPS, + # Trading + "update_interval": UPDATE_INTERVAL, + "max_updates": MAX_UPDATES, + "auto_save": AUTO_SAVE, + "model_save_dir": MODEL_SAVE_DIR, + "results_dir": RESULTS_DIR, + # Options + "use_options_data": USE_OPTIONS_DATA, + "strike_selection_strategy": STRIKE_SELECTION_STRATEGY, + # Risk + "max_drawdown_threshold": MAX_DRAWDOWN_THRESHOLD, + "max_position_size": MAX_POSITION_SIZE, + "daily_loss_limit": DAILY_LOSS_LIMIT, + } + + +def print_config(): + """Print current configuration.""" + config = get_config() + + print("\n" + "=" * 70) + print("SPY TRADING TOOL - CONFIGURATION") + print("=" * 70) + + for key, value in config.items(): + print(f"{key:.<40} {value}") + + print("=" * 70 + "\n") + + +if __name__ == "__main__": + print_config() diff --git a/spy_trading_tool/example_usage.py b/spy_trading_tool/example_usage.py new file mode 100644 index 0000000000..bd95ba0948 --- /dev/null +++ b/spy_trading_tool/example_usage.py @@ -0,0 +1,208 @@ +"""Example usage of the SPY Trading Tool. + +This script demonstrates the basic usage of the SPY options trading system. +""" + +from __future__ import annotations + +import os +import sys + +# Add parent directory to path +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from spy_trading_tool import SPYTradingTool + + +def example_basic_usage(): + """Example 1: Basic usage with default settings.""" + print("\n" + "=" * 70) + print("EXAMPLE 1: Basic Usage") + print("=" * 70) + + # Initialize the tool + tool = SPYTradingTool( + ticker="SPY", + initial_capital=10000, + max_options=5, + auto_save=True, + save_dir="./example_models", + ) + + # Perform a single update + print("\nPerforming single update...") + result = tool.update(timeframe="1m") + + if result["status"] == "success": + signal = result["signal"] + print(f"\n✅ Update successful!") + print(f"Signal: {signal['signal']}") + print(f"Confidence: {signal['confidence']:.2%}") + + if signal.get("price_target"): + targets = signal["price_target"] + print(f"\nPrice Targets:") + print(f" Current: ${targets.get('current', 0):.2f}") + print(f" Upside: ${targets.get('upside', 0):.2f}") + print(f" Downside: ${targets.get('downside', 0):.2f}") + else: + print(f"❌ Update failed: {result.get('status')}") + + +def example_training(): + """Example 2: Train initial model.""" + print("\n" + "=" * 70) + print("EXAMPLE 2: Initial Model Training") + print("=" * 70) + + tool = SPYTradingTool(initial_capital=10000, save_dir="./example_models") + + # Train initial model + print("\nTraining initial model on historical data...") + print("This may take a few minutes...") + + tool.train_initial_model( + training_days=7, timesteps=1000 # Use 7 days for quick demo # Reduced for demo + ) + + print("\n✅ Training complete!") + print("Model saved to ./example_models/") + + +def example_timeframe_optimization(): + """Example 3: Timeframe optimization.""" + print("\n" + "=" * 70) + print("EXAMPLE 3: Timeframe Optimization") + print("=" * 70) + + tool = SPYTradingTool( + timeframes=["1m", "5m", "15m"], initial_capital=10000 # Reduced set for demo + ) + + print("\nOptimizing across timeframes...") + print("This will analyze multiple timeframes and select the best one.\n") + + results = tool.optimize_timeframes() + + if not results.empty: + print("\n✅ Optimization complete!") + print("\nResults:") + print(results.to_string()) + else: + print("❌ Optimization failed - no data available") + + +def example_continuous_trading(): + """Example 4: Run continuous trading (limited updates).""" + print("\n" + "=" * 70) + print("EXAMPLE 4: Continuous Trading (5 updates)") + print("=" * 70) + + tool = SPYTradingTool( + initial_capital=10000, + auto_save=False, # Don't save for demo + ) + + print("\nRunning 5 consecutive updates with 10-second intervals...") + print("Press Ctrl+C to stop early.\n") + + try: + tool.run_continuous( + update_interval=10, max_updates=5 # 10 seconds for demo # Only 5 updates + ) + + print("\n✅ Continuous trading demo complete!") + + except KeyboardInterrupt: + print("\n\n⚠️ Stopped by user") + + +def example_performance_tracking(): + """Example 5: Performance tracking and visualization.""" + print("\n" + "=" * 70) + print("EXAMPLE 5: Performance Tracking") + print("=" * 70) + + from spy_trading_tool.performance_tracker import PerformanceTracker + import numpy as np + + # Create tracker + tracker = PerformanceTracker(initial_capital=10000, log_file="./example_trades.log") + + # Simulate some trades + print("\nSimulating trades...") + + tracker.log_trade({"type": "BUY_CALL", "pnl": 150, "contracts": 2}) + tracker.update_equity(10150) + + tracker.log_trade({"type": "BUY_PUT", "pnl": -50, "contracts": 1}) + tracker.update_equity(10100) + + tracker.log_trade({"type": "CLOSE_CALL", "pnl": 200, "contracts": 2}) + tracker.update_equity(10300) + + tracker.log_trade({"type": "CLOSE_PUT", "pnl": 100, "contracts": 1}) + tracker.update_equity(10400) + + # Calculate metrics + metrics = tracker.calculate_metrics() + + print("\n✅ Performance Metrics:") + print(f" Total Return: {metrics['total_return_pct']:.2f}%") + print(f" Win Rate: {metrics['win_rate']:.2%}") + print(f" Sharpe Ratio: {metrics['sharpe_ratio']:.2f}") + print(f" Max Drawdown: {metrics['max_drawdown_pct']:.2f}%") + + # Generate report + report = tracker.generate_report() + print(report) + + # Note: Plotting requires display - skip in headless environments + print("\n📊 Equity curve plot saved to ./example_equity.png") + try: + tracker.plot_equity_curve(save_path="./example_equity.png", show=False) + except Exception as e: + print(f" (Plotting skipped: {e})") + + +def main(): + """Run all examples.""" + print("\n" + "=" * 70) + print("SPY TRADING TOOL - USAGE EXAMPLES") + print("=" * 70) + print("\nThis script demonstrates various usage scenarios.") + print("Choose an example to run:\n") + + examples = { + "1": ("Basic Usage (Single Update)", example_basic_usage), + "2": ("Initial Model Training", example_training), + "3": ("Timeframe Optimization", example_timeframe_optimization), + "4": ("Continuous Trading (Demo)", example_continuous_trading), + "5": ("Performance Tracking", example_performance_tracking), + "all": ("Run All Examples", None), + } + + for key, (name, _) in examples.items(): + print(f" {key}. {name}") + + print("\nEnter choice (1-5, or 'all'): ", end="") + choice = input().strip().lower() + + if choice == "all": + # Run all examples in sequence + for key in ["1", "5"]: # Run safe examples only + if key in examples and examples[key][1]: + examples[key][1]() + print("\n" + "-" * 70 + "\n") + elif choice in examples and examples[choice][1]: + examples[choice][1]() + else: + print("Invalid choice. Please run again and choose 1-5 or 'all'.") + + print("\n" + "=" * 70) + print("Examples complete! Check the README.md for more information.") + print("=" * 70 + "\n") + + +if __name__ == "__main__": + main() diff --git a/spy_trading_tool/learning_agent.py b/spy_trading_tool/learning_agent.py new file mode 100644 index 0000000000..c24f375b38 --- /dev/null +++ b/spy_trading_tool/learning_agent.py @@ -0,0 +1,597 @@ +"""Real-time learning agent for SPY options trading. + +This module implements a continuous learning agent that: +- Uses PPO (Proximal Policy Optimization) for training +- Updates model incrementally from new data +- Learns from every trade taken +- Provides price target predictions +- Adapts to changing market conditions +""" + +from __future__ import annotations + +import os +import pickle +import warnings +from datetime import datetime +from typing import Dict +from typing import List +from typing import Optional +from typing import Tuple + +import numpy as np +import pandas as pd + +warnings.filterwarnings("ignore") + +from stable_baselines3 import PPO +from stable_baselines3.common.vec_env import DummyVecEnv +from stable_baselines3.common.callbacks import BaseCallback + +from finrl.meta.env_options_trading.env_spy_options import SPYOptionsEnv +from finrl.meta.preprocessor.spy_feature_engineer import SPYFeatureEngineer + + +class TradeLogger(BaseCallback): + """Callback to log trades and performance during training.""" + + def __init__(self, verbose=0): + super().__init__(verbose) + self.trades = [] + self.episode_rewards = [] + + def _on_step(self) -> bool: + # Log info if available + if len(self.locals.get("infos", [])) > 0: + info = self.locals["infos"][0] + if "portfolio_value" in info: + self.trades.append( + { + "step": self.num_timesteps, + "portfolio_value": info["portfolio_value"], + "cash": info.get("cash", 0), + } + ) + return True + + +class ContinuousLearningAgent: + """Continuous learning agent for SPY options trading. + + Features: + - Real-time model updates + - Experience replay buffer + - Price target prediction + - Trade analysis and learning + - Adaptive strategy based on performance + + Attributes + ---------- + model : PPO + The RL model (PPO) + env : SPYOptionsEnv + Trading environment + feature_engineer : SPYFeatureEngineer + Feature engineering pipeline + experience_buffer : List + Buffer of recent experiences + buffer_size : int + Maximum buffer size + update_frequency : int + How often to update model (in steps) + """ + + def __init__( + self, + initial_amount: float = 10000, + transaction_cost: float = 0.001, + max_options: int = 10, + buffer_size: int = 1000, + update_frequency: int = 100, + model_path: str | None = None, + tech_indicator_list: list[str] = None, + greeks_list: list[str] = None, + ): + """Initialize the continuous learning agent. + + Parameters + ---------- + initial_amount : float + Initial trading capital + transaction_cost : float + Transaction cost percentage + max_options : int + Maximum option contracts + buffer_size : int + Experience replay buffer size + update_frequency : int + Model update frequency (steps) + model_path : str, optional + Path to load pre-trained model + tech_indicator_list : List[str], optional + List of technical indicators + greeks_list : List[str], optional + List of Greeks to use + """ + self.initial_amount = initial_amount + self.transaction_cost = transaction_cost + self.max_options = max_options + self.buffer_size = buffer_size + self.update_frequency = update_frequency + self.model_path = model_path + + # Feature engineering + self.tech_indicator_list = tech_indicator_list or [ + "macd", + "rsi_30", + "cci_30", + "dx_30", + "close_30_sma", + "close_60_sma", + "rsi_14", + "atr", + ] + self.greeks_list = greeks_list or [ + "call_delta", + "call_gamma", + "call_theta", + "call_vega", + "put_delta", + "put_gamma", + "put_theta", + "put_vega", + ] + + self.feature_engineer = SPYFeatureEngineer( + use_technical_indicator=True, + tech_indicator_list=self.tech_indicator_list, + use_vix=True, + use_turbulence=False, + use_options_greeks=True, + use_advanced_indicators=True, + use_volume_features=True, + use_regime_detection=True, + ) + + # Initialize environment and model + self.env = None + self.model = None + self.experience_buffer = [] + self.training_history = [] + self.trade_outcomes = [] + + # Performance tracking + self.total_steps = 0 + self.total_updates = 0 + self.win_rate = 0.0 + self.sharpe_ratio = 0.0 + + # Price prediction + self.price_predictions = [] + self.prediction_accuracy = 0.0 + + def create_env(self, df: pd.DataFrame) -> DummyVecEnv: + """Create trading environment from data. + + Parameters + ---------- + df : pd.DataFrame + Preprocessed trading data + + Returns + ------- + DummyVecEnv + Vectorized environment + """ + env = SPYOptionsEnv( + df=df, + initial_amount=self.initial_amount, + transaction_cost=self.transaction_cost, + max_options=self.max_options, + tech_indicator_list=self.tech_indicator_list, + greeks_list=self.greeks_list, + ) + + # Wrap in DummyVecEnv for compatibility with Stable Baselines3 + env_wrapped = DummyVecEnv([lambda: env]) + self.env = env_wrapped + + return env_wrapped + + def initialize_model(self, learning_rate: float = 3e-4): + """Initialize or load the RL model. + + Parameters + ---------- + learning_rate : float + Learning rate for PPO + """ + if self.env is None: + raise ValueError("Environment must be created first using create_env()") + + if self.model_path and os.path.exists(self.model_path): + # Load existing model + print(f"Loading model from {self.model_path}") + self.model = PPO.load(self.model_path, env=self.env) + else: + # Create new model + print("Creating new PPO model") + self.model = PPO( + "MlpPolicy", + self.env, + learning_rate=learning_rate, + n_steps=2048, + batch_size=64, + n_epochs=10, + gamma=0.99, + gae_lambda=0.95, + clip_range=0.2, + ent_coef=0.01, + verbose=0, + ) + + def train(self, total_timesteps: int = 10000, callback=None): + """Train the model. + + Parameters + ---------- + total_timesteps : int + Number of timesteps to train + callback : BaseCallback, optional + Training callback + """ + if self.model is None: + raise ValueError("Model must be initialized first using initialize_model()") + + print(f"Training model for {total_timesteps} timesteps...") + + if callback is None: + callback = TradeLogger() + + self.model.learn( + total_timesteps=total_timesteps, + callback=callback, + reset_num_timesteps=False, + ) + + self.total_steps += total_timesteps + self.total_updates += 1 + + print(f"Training complete. Total steps: {self.total_steps}") + + def predict( + self, observation: np.ndarray, deterministic: bool = True + ) -> tuple[np.ndarray, dict]: + """Make a prediction (action) given an observation. + + Parameters + ---------- + observation : np.ndarray + Current state + deterministic : bool + Use deterministic policy + + Returns + ------- + Tuple[np.ndarray, Dict] + Action and additional info + """ + if self.model is None: + raise ValueError("Model must be initialized first") + + action, _states = self.model.predict(observation, deterministic=deterministic) + + # Generate price target prediction + price_target = self._predict_price_target(observation) + + info = { + "price_target": price_target, + "confidence": self._calculate_confidence(observation), + } + + return action, info + + def _predict_price_target(self, observation: np.ndarray) -> dict[str, float]: + """Predict price targets for SPY. + + Parameters + ---------- + observation : np.ndarray + Current state + + Returns + ------- + Dict[str, float] + Price targets (upside, downside, expected) + """ + # Extract current price from observation + # Assuming observation structure: [cash, call_pos, put_pos, price, indicators...] + current_price = ( + observation[0][3] if len(observation.shape) > 1 else observation[3] + ) + + # Use technical indicators to estimate targets + # This is a simplified approach - in practice, you'd want more sophisticated prediction + + # Simple momentum-based prediction + # Extract RSI if available (assuming it's in the indicators) + try: + # Simplified: use basic heuristics + # In production, you could use a separate regression model + + volatility = 0.01 # Default 1% + trend = 0.0 # Neutral + + # Estimate based on regime if available + upside_target = current_price * (1 + volatility * 2) + downside_target = current_price * (1 - volatility * 2) + expected_target = current_price * (1 + trend) + + except Exception as e: + # Fallback to simple targets + upside_target = current_price * 1.02 + downside_target = current_price * 0.98 + expected_target = current_price + + return { + "upside": upside_target, + "downside": downside_target, + "expected": expected_target, + "current": current_price, + } + + def _calculate_confidence(self, observation: np.ndarray) -> float: + """Calculate confidence level for the prediction. + + Parameters + ---------- + observation : np.ndarray + Current state + + Returns + ------- + float + Confidence level (0 to 1) + """ + # Base confidence on recent win rate and market conditions + base_confidence = 0.5 + + # Adjust based on win rate + if self.win_rate > 0: + confidence = base_confidence + (self.win_rate - 0.5) * 0.5 + else: + confidence = base_confidence + + # Clip to [0, 1] + confidence = np.clip(confidence, 0, 1) + + return confidence + + def update_from_trade(self, trade_result: dict): + """Update the agent based on a completed trade. + + Parameters + ---------- + trade_result : Dict + Trade outcome information + """ + self.trade_outcomes.append(trade_result) + + # Update win rate + if len(self.trade_outcomes) > 0: + wins = sum(1 for t in self.trade_outcomes if t.get("pnl", 0) > 0) + self.win_rate = wins / len(self.trade_outcomes) + + # Add to experience buffer + if "experience" in trade_result: + self.experience_buffer.append(trade_result["experience"]) + + # Trim buffer if too large + if len(self.experience_buffer) > self.buffer_size: + self.experience_buffer = self.experience_buffer[-self.buffer_size :] + + print(f"Trade logged. Win rate: {self.win_rate:.2%}") + + def should_update_model(self) -> bool: + """Check if model should be updated. + + Returns + ------- + bool + True if should update + """ + return self.total_steps % self.update_frequency == 0 + + def incremental_update(self, new_data: pd.DataFrame, timesteps: int = 1000): + """Perform incremental model update with new data. + + Parameters + ---------- + new_data : pd.DataFrame + New market data + timesteps : int + Number of timesteps for update + """ + print("Performing incremental model update...") + + # Create new environment with recent data + temp_env = self.create_env(new_data) + + # Update model with new environment + self.model.set_env(temp_env) + + # Train on new data + self.train(total_timesteps=timesteps) + + print("Incremental update complete") + + def save_model(self, path: str): + """Save the model to disk. + + Parameters + ---------- + path : str + Save path + """ + if self.model is None: + raise ValueError("No model to save") + + self.model.save(path) + print(f"Model saved to {path}") + + # Save agent state + agent_state = { + "total_steps": self.total_steps, + "total_updates": self.total_updates, + "win_rate": self.win_rate, + "trade_outcomes": self.trade_outcomes[-100:], # Keep last 100 + } + + with open(f"{path}_agent_state.pkl", "wb") as f: + pickle.dump(agent_state, f) + + def load_model(self, path: str): + """Load model from disk. + + Parameters + ---------- + path : str + Load path + """ + if self.env is None: + raise ValueError("Environment must be created first") + + self.model = PPO.load(path, env=self.env) + print(f"Model loaded from {path}") + + # Load agent state if available + state_path = f"{path}_agent_state.pkl" + if os.path.exists(state_path): + with open(state_path, "rb") as f: + agent_state = pickle.load(f) + self.total_steps = agent_state.get("total_steps", 0) + self.total_updates = agent_state.get("total_updates", 0) + self.win_rate = agent_state.get("win_rate", 0.0) + self.trade_outcomes = agent_state.get("trade_outcomes", []) + + def get_performance_summary(self) -> dict: + """Get performance summary. + + Returns + ------- + Dict + Performance metrics + """ + recent_trades = ( + self.trade_outcomes[-20:] if len(self.trade_outcomes) > 0 else [] + ) + + if len(recent_trades) > 0: + recent_pnl = [t.get("pnl", 0) for t in recent_trades] + recent_wins = sum(1 for p in recent_pnl if p > 0) + recent_win_rate = recent_wins / len(recent_trades) + avg_pnl = np.mean(recent_pnl) + else: + recent_win_rate = 0 + avg_pnl = 0 + + return { + "total_trades": len(self.trade_outcomes), + "win_rate": self.win_rate, + "recent_win_rate": recent_win_rate, + "avg_pnl": avg_pnl, + "total_updates": self.total_updates, + "total_steps": self.total_steps, + } + + def generate_signal(self, current_data: pd.DataFrame) -> dict: + """Generate trading signal from current data. + + Parameters + ---------- + current_data : pd.DataFrame + Current market data (single row) + + Returns + ------- + Dict + Trading signal with action, confidence, and targets + """ + # Prepare observation + observation = self._prepare_observation(current_data) + + # Get action and prediction + action, info = self.predict(observation, deterministic=True) + + # Interpret action + action_type = action[0][0] # -1, 0, 1, 2 + position_size = action[0][1] # 0 to 1 + + if action_type < -0.5: + signal = "CLOSE" + elif action_type > 0.5 and action_type < 1.5: + signal = "BUY_CALL" + elif action_type >= 1.5: + signal = "BUY_PUT" + else: + signal = "HOLD" + + return { + "signal": signal, + "position_size": position_size, + "price_target": info["price_target"], + "confidence": info["confidence"], + "timestamp": datetime.now(), + } + + def _prepare_observation(self, data: pd.DataFrame) -> np.ndarray: + """Prepare observation from data row. + + Parameters + ---------- + data : pd.DataFrame + Current data + + Returns + ------- + np.ndarray + Observation vector + """ + # This would extract features from the data + # For now, return a placeholder + # In production, this should match the environment's state structure + + observation = [] + + # Cash (start with initial amount for prediction) + observation.append(self.initial_amount) + + # Positions (assume no current positions for signal generation) + observation.extend([0, 0]) + + # Current price + observation.append( + data.get("close", 0) + if isinstance(data, pd.Series) + else data["close"].iloc[0] + ) + + # Technical indicators + for indicator in self.tech_indicator_list: + val = ( + data.get(indicator, 0) + if isinstance(data, pd.Series) + else data[indicator].iloc[0] + ) + observation.append(val) + + # Greeks + for greek in self.greeks_list: + val = ( + data.get(greek, 0) + if isinstance(data, pd.Series) + else data[greek].iloc[0] + ) + observation.append(val) + + return np.array([observation], dtype=np.float32) diff --git a/spy_trading_tool/performance_tracker.py b/spy_trading_tool/performance_tracker.py new file mode 100644 index 0000000000..e39c89fd3d --- /dev/null +++ b/spy_trading_tool/performance_tracker.py @@ -0,0 +1,540 @@ +"""Performance tracking and visualization for SPY trading tool. + +This module provides comprehensive performance tracking, metrics calculation, +and visualization capabilities for the trading system. +""" + +from __future__ import annotations + +import json +import os +import warnings +from datetime import datetime +from typing import Dict +from typing import List +from typing import Optional +from typing import Tuple + +import matplotlib.dates as mdates +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd + +warnings.filterwarnings("ignore") + + +class PerformanceTracker: + """Tracks and analyzes trading performance. + + Features: + - Real-time performance metrics + - Trade-by-trade logging + - Equity curve tracking + - Drawdown analysis + - Risk metrics (Sharpe, Sortino, Calmar) + - Win/loss statistics + - Visualization tools + + Attributes + ---------- + trades : List[Dict] + List of all trades + equity_curve : List[float] + Portfolio values over time + timestamps : List[datetime] + Timestamps for each update + initial_capital : float + Starting capital + """ + + def __init__( + self, + initial_capital: float = 10000, + log_file: str | None = None, + risk_free_rate: float = 0.02, + ): + """Initialize performance tracker. + + Parameters + ---------- + initial_capital : float + Initial trading capital + log_file : str, optional + Path to save trade log + risk_free_rate : float + Annual risk-free rate + """ + self.initial_capital = initial_capital + self.log_file = log_file + self.risk_free_rate = risk_free_rate + + # Trading records + self.trades = [] + self.equity_curve = [initial_capital] + self.timestamps = [datetime.now()] + self.signals = [] + + # Performance metrics + self.metrics = {} + self.daily_metrics = [] + + def log_trade(self, trade: dict): + """Log a completed trade. + + Parameters + ---------- + trade : Dict + Trade information + """ + trade["timestamp"] = datetime.now() + self.trades.append(trade) + + # Save to file if specified + if self.log_file: + self._save_trade_to_file(trade) + + def log_signal(self, signal: dict): + """Log a trading signal. + + Parameters + ---------- + signal : Dict + Signal information + """ + signal["timestamp"] = datetime.now() + self.signals.append(signal) + + def update_equity(self, portfolio_value: float): + """Update equity curve with current portfolio value. + + Parameters + ---------- + portfolio_value : float + Current portfolio value + """ + self.equity_curve.append(portfolio_value) + self.timestamps.append(datetime.now()) + + def calculate_metrics(self) -> dict: + """Calculate comprehensive performance metrics. + + Returns + ------- + Dict + Performance metrics + """ + if len(self.equity_curve) < 2: + return {} + + equity = np.array(self.equity_curve) + returns = np.diff(equity) / equity[:-1] + + # Basic metrics + total_return = (equity[-1] - equity[0]) / equity[0] + num_trades = len(self.trades) + + # Win rate + if num_trades > 0: + winning_trades = [t for t in self.trades if t.get("pnl", 0) > 0] + win_rate = len(winning_trades) / num_trades + avg_win = ( + np.mean([t["pnl"] for t in winning_trades]) if winning_trades else 0 + ) + losing_trades = [t for t in self.trades if t.get("pnl", 0) < 0] + avg_loss = ( + np.mean([t["pnl"] for t in losing_trades]) if losing_trades else 0 + ) + profit_factor = ( + abs( + sum([t["pnl"] for t in winning_trades]) + / sum([t["pnl"] for t in losing_trades]) + ) + if losing_trades + else float("inf") + ) + else: + win_rate = 0 + avg_win = 0 + avg_loss = 0 + profit_factor = 0 + + # Sharpe ratio + if len(returns) > 1 and np.std(returns) > 0: + sharpe = ( + (np.mean(returns) - self.risk_free_rate / 252) + / np.std(returns) + * np.sqrt(252) + ) + else: + sharpe = 0 + + # Sortino ratio (uses only downside deviation) + downside_returns = returns[returns < 0] + if len(downside_returns) > 1 and np.std(downside_returns) > 0: + sortino = ( + (np.mean(returns) - self.risk_free_rate / 252) + / np.std(downside_returns) + * np.sqrt(252) + ) + else: + sortino = 0 + + # Maximum drawdown + cummax = np.maximum.accumulate(equity) + drawdown = (equity - cummax) / cummax + max_drawdown = np.min(drawdown) + max_drawdown_pct = max_drawdown * 100 + + # Calmar ratio + if max_drawdown != 0: + cagr = self._calculate_cagr(equity) + calmar = cagr / abs(max_drawdown) + else: + calmar = 0 + + # Current drawdown + current_drawdown = (equity[-1] - cummax[-1]) / cummax[-1] * 100 + + self.metrics = { + "total_return": total_return, + "total_return_pct": total_return * 100, + "num_trades": num_trades, + "win_rate": win_rate, + "avg_win": avg_win, + "avg_loss": avg_loss, + "profit_factor": profit_factor, + "sharpe_ratio": sharpe, + "sortino_ratio": sortino, + "max_drawdown": max_drawdown, + "max_drawdown_pct": max_drawdown_pct, + "current_drawdown_pct": current_drawdown, + "calmar_ratio": calmar, + "current_equity": equity[-1], + } + + return self.metrics + + def _calculate_cagr(self, equity: np.ndarray) -> float: + """Calculate Compound Annual Growth Rate.""" + if len(equity) < 2 or len(self.timestamps) < 2: + return 0 + + initial_value = equity[0] + final_value = equity[-1] + years = (self.timestamps[-1] - self.timestamps[0]).days / 365.25 + + if years <= 0 or initial_value <= 0: + return 0 + + cagr = (final_value / initial_value) ** (1 / years) - 1 + return cagr + + def get_trade_summary(self) -> pd.DataFrame: + """Get summary of all trades. + + Returns + ------- + pd.DataFrame + Trade summary + """ + if not self.trades: + return pd.DataFrame() + + df = pd.DataFrame(self.trades) + return df + + def get_equity_curve_df(self) -> pd.DataFrame: + """Get equity curve as DataFrame. + + Returns + ------- + pd.DataFrame + Equity curve with timestamps + """ + return pd.DataFrame( + { + "timestamp": self.timestamps, + "equity": self.equity_curve, + } + ) + + def plot_equity_curve(self, save_path: str | None = None, show: bool = True): + """Plot equity curve. + + Parameters + ---------- + save_path : str, optional + Path to save figure + show : bool + Display the plot + """ + if len(self.equity_curve) < 2: + print("Not enough data to plot") + return + + fig, ax = plt.subplots(figsize=(12, 6)) + + # Plot equity curve + ax.plot( + self.timestamps, self.equity_curve, linewidth=2, label="Portfolio Value" + ) + ax.axhline( + y=self.initial_capital, + color="gray", + linestyle="--", + label="Initial Capital", + ) + + # Fill area + ax.fill_between( + self.timestamps, + self.equity_curve, + self.initial_capital, + where=np.array(self.equity_curve) >= self.initial_capital, + interpolate=True, + alpha=0.3, + color="green", + label="Profit", + ) + ax.fill_between( + self.timestamps, + self.equity_curve, + self.initial_capital, + where=np.array(self.equity_curve) < self.initial_capital, + interpolate=True, + alpha=0.3, + color="red", + label="Loss", + ) + + # Formatting + ax.set_xlabel("Time", fontsize=12) + ax.set_ylabel("Portfolio Value ($)", fontsize=12) + ax.set_title("SPY Trading - Equity Curve", fontsize=14, fontweight="bold") + ax.legend(loc="best") + ax.grid(True, alpha=0.3) + + # Format x-axis + ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m-%d %H:%M")) + plt.xticks(rotation=45) + + plt.tight_layout() + + if save_path: + plt.savefig(save_path, dpi=300, bbox_inches="tight") + print(f"Equity curve saved to {save_path}") + + if show: + plt.show() + + plt.close() + + def plot_drawdown(self, save_path: str | None = None, show: bool = True): + """Plot drawdown curve. + + Parameters + ---------- + save_path : str, optional + Path to save figure + show : bool + Display the plot + """ + if len(self.equity_curve) < 2: + print("Not enough data to plot") + return + + equity = np.array(self.equity_curve) + cummax = np.maximum.accumulate(equity) + drawdown = (equity - cummax) / cummax * 100 + + fig, ax = plt.subplots(figsize=(12, 6)) + + # Plot drawdown + ax.fill_between(self.timestamps, drawdown, 0, alpha=0.3, color="red") + ax.plot(self.timestamps, drawdown, linewidth=2, color="darkred") + + # Formatting + ax.set_xlabel("Time", fontsize=12) + ax.set_ylabel("Drawdown (%)", fontsize=12) + ax.set_title("SPY Trading - Drawdown", fontsize=14, fontweight="bold") + ax.grid(True, alpha=0.3) + ax.axhline(y=0, color="black", linestyle="-", linewidth=0.5) + + # Format x-axis + ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m-%d %H:%M")) + plt.xticks(rotation=45) + + plt.tight_layout() + + if save_path: + plt.savefig(save_path, dpi=300, bbox_inches="tight") + print(f"Drawdown plot saved to {save_path}") + + if show: + plt.show() + + plt.close() + + def plot_returns_distribution( + self, save_path: str | None = None, show: bool = True + ): + """Plot returns distribution histogram. + + Parameters + ---------- + save_path : str, optional + Path to save figure + show : bool + Display the plot + """ + if len(self.equity_curve) < 2: + print("Not enough data to plot") + return + + equity = np.array(self.equity_curve) + returns = np.diff(equity) / equity[:-1] * 100 + + fig, ax = plt.subplots(figsize=(12, 6)) + + # Plot histogram + n, bins, patches = ax.hist(returns, bins=50, alpha=0.7, edgecolor="black") + + # Color bars + for i in range(len(patches)): + if bins[i] < 0: + patches[i].set_facecolor("red") + else: + patches[i].set_facecolor("green") + + # Add vertical line at mean + ax.axvline( + x=np.mean(returns), + color="blue", + linestyle="--", + linewidth=2, + label=f"Mean: {np.mean(returns):.3f}%", + ) + + # Formatting + ax.set_xlabel("Return (%)", fontsize=12) + ax.set_ylabel("Frequency", fontsize=12) + ax.set_title( + "SPY Trading - Returns Distribution", fontsize=14, fontweight="bold" + ) + ax.legend(loc="best") + ax.grid(True, alpha=0.3, axis="y") + + plt.tight_layout() + + if save_path: + plt.savefig(save_path, dpi=300, bbox_inches="tight") + print(f"Returns distribution saved to {save_path}") + + if show: + plt.show() + + plt.close() + + def generate_report(self, save_path: str | None = None) -> str: + """Generate comprehensive performance report. + + Parameters + ---------- + save_path : str, optional + Path to save report + + Returns + ------- + str + Formatted report + """ + metrics = self.calculate_metrics() + + report = "\n" + "=" * 70 + "\n" + report += "SPY OPTIONS TRADING - PERFORMANCE REPORT\n" + report += "=" * 70 + "\n\n" + + report += f"Report Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n" + report += f"Trading Period: {self.timestamps[0].strftime('%Y-%m-%d')} to {self.timestamps[-1].strftime('%Y-%m-%d')}\n\n" + + report += "PORTFOLIO SUMMARY\n" + report += "-" * 70 + "\n" + report += f" Initial Capital: ${self.initial_capital:,.2f}\n" + report += ( + f" Current Equity: ${metrics.get('current_equity', 0):,.2f}\n" + ) + report += ( + f" Total Return: {metrics.get('total_return_pct', 0):+.2f}%\n" + ) + report += f" Profit/Loss: ${metrics.get('current_equity', 0) - self.initial_capital:+,.2f}\n\n" + + report += "TRADING STATISTICS\n" + report += "-" * 70 + "\n" + report += f" Total Trades: {metrics.get('num_trades', 0)}\n" + report += f" Win Rate: {metrics.get('win_rate', 0)*100:.2f}%\n" + report += f" Average Win: ${metrics.get('avg_win', 0):,.2f}\n" + report += f" Average Loss: ${metrics.get('avg_loss', 0):,.2f}\n" + report += f" Profit Factor: {metrics.get('profit_factor', 0):.2f}\n\n" + + report += "RISK METRICS\n" + report += "-" * 70 + "\n" + report += f" Sharpe Ratio: {metrics.get('sharpe_ratio', 0):.2f}\n" + report += f" Sortino Ratio: {metrics.get('sortino_ratio', 0):.2f}\n" + report += f" Calmar Ratio: {metrics.get('calmar_ratio', 0):.2f}\n" + report += ( + f" Maximum Drawdown: {metrics.get('max_drawdown_pct', 0):.2f}%\n" + ) + report += f" Current Drawdown: {metrics.get('current_drawdown_pct', 0):.2f}%\n\n" + + report += "=" * 70 + "\n" + + if save_path: + with open(save_path, "w") as f: + f.write(report) + print(f"Report saved to {save_path}") + + return report + + def _save_trade_to_file(self, trade: dict): + """Save trade to log file.""" + if not self.log_file: + return + + # Convert datetime to string + trade_copy = trade.copy() + if "timestamp" in trade_copy and isinstance(trade_copy["timestamp"], datetime): + trade_copy["timestamp"] = trade_copy["timestamp"].isoformat() + + # Append to file + with open(self.log_file, "a") as f: + f.write(json.dumps(trade_copy) + "\n") + + def export_to_csv(self, directory: str = "./results"): + """Export all data to CSV files. + + Parameters + ---------- + directory : str + Directory to save CSV files + """ + os.makedirs(directory, exist_ok=True) + + # Export trades + if self.trades: + trades_df = self.get_trade_summary() + trades_path = os.path.join(directory, "trades.csv") + trades_df.to_csv(trades_path, index=False) + print(f"Trades exported to {trades_path}") + + # Export equity curve + equity_df = self.get_equity_curve_df() + equity_path = os.path.join(directory, "equity_curve.csv") + equity_df.to_csv(equity_path, index=False) + print(f"Equity curve exported to {equity_path}") + + # Export metrics + metrics = self.calculate_metrics() + metrics_df = pd.DataFrame([metrics]) + metrics_path = os.path.join(directory, "metrics.csv") + metrics_df.to_csv(metrics_path, index=False) + print(f"Metrics exported to {metrics_path}") diff --git a/spy_trading_tool/requirements.txt b/spy_trading_tool/requirements.txt new file mode 100644 index 0000000000..e6d66f5d0b --- /dev/null +++ b/spy_trading_tool/requirements.txt @@ -0,0 +1,33 @@ +# SPY Trading Tool - Python Dependencies + +gymnasium>=0.28.1 + +# Visualization +matplotlib>=3.6.0 +numpy>=1.23.0 + +# Data Processing +pandas>=1.5.0 + +# Utilities +python-dateutil>=2.8.0 +pytz>=2023.3 +scipy>=1.10.0 +seaborn>=0.12.0 +# Core ML/RL Libraries +stable-baselines3>=2.0.0 +stockstats>=0.5.0 +torch>=2.0.0 +tqdm>=4.65.0 + +# Market Data +yfinance>=0.2.0 + +# FinRL Dependencies (already installed in main project) +# gymnasium +# numpy +# pandas +# matplotlib +# stockstats +# exchange-calendars +# quantstats diff --git a/spy_trading_tool/spy_trader.py b/spy_trading_tool/spy_trader.py new file mode 100644 index 0000000000..3d45c9e95e --- /dev/null +++ b/spy_trading_tool/spy_trader.py @@ -0,0 +1,560 @@ +"""Main SPY Options Trading Tool with Real-time Learning. + +This is the main entry point for the SPY trading system that: +- Updates every minute with new data +- Uses options Greeks for strike selection +- Learns from every trade +- Optimizes across multiple timeframes using CAGR +- Provides price targets and trading signals +""" + +from __future__ import annotations + +import os +import sys +import time +import warnings +from datetime import datetime +from datetime import timedelta +from typing import Dict +from typing import List +from typing import Optional + +import numpy as np +import pandas as pd + +warnings.filterwarnings("ignore") + +# Add parent directory to path for imports +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from finrl.meta.preprocessor.options_processor import OptionsProcessor +from finrl.meta.preprocessor.spy_feature_engineer import SPYFeatureEngineer +from spy_trading_tool.learning_agent import ContinuousLearningAgent +from spy_trading_tool.timeframe_optimizer import TimeframeOptimizer + + +class SPYTradingTool: + """Main SPY options trading tool with continuous learning. + + Features: + - Minute-by-minute updates + - Options Greeks analysis + - Multi-indicator signals + - CAGR-based timeframe optimization + - Real-time learning from trades + - Price target predictions + + Attributes + ---------- + ticker : str + Stock ticker (SPY) + initial_capital : float + Starting capital + options_processor : OptionsProcessor + Options data and Greeks processor + feature_engineer : SPYFeatureEngineer + Feature engineering pipeline + agent : ContinuousLearningAgent + RL agent for trading decisions + timeframe_optimizer : TimeframeOptimizer + Multi-timeframe optimizer + """ + + def __init__( + self, + ticker: str = "SPY", + initial_capital: float = 10000, + transaction_cost: float = 0.001, + max_options: int = 10, + timeframes: list[str] = None, + risk_free_rate: float = 0.05, + model_path: str | None = None, + auto_save: bool = True, + save_dir: str = "./models", + ): + """Initialize the SPY trading tool. + + Parameters + ---------- + ticker : str + Stock ticker symbol + initial_capital : float + Initial trading capital + transaction_cost : float + Transaction cost percentage + max_options : int + Maximum option contracts + timeframes : List[str], optional + Timeframes to analyze + risk_free_rate : float + Risk-free interest rate + model_path : str, optional + Path to load pre-trained model + auto_save : bool + Auto-save model after updates + save_dir : str + Directory for saving models + """ + self.ticker = ticker + self.initial_capital = initial_capital + self.transaction_cost = transaction_cost + self.max_options = max_options + self.risk_free_rate = risk_free_rate + self.auto_save = auto_save + self.save_dir = save_dir + + # Create save directory if needed + os.makedirs(save_dir, exist_ok=True) + + # Initialize components + print("Initializing SPY Trading Tool...") + + # Options processor + self.options_processor = OptionsProcessor( + ticker=ticker, risk_free_rate=risk_free_rate + ) + + # Feature engineer + self.feature_engineer = SPYFeatureEngineer( + use_technical_indicator=True, + use_vix=True, + use_turbulence=False, + use_options_greeks=True, + use_advanced_indicators=True, + use_volume_features=True, + use_regime_detection=True, + risk_free_rate=risk_free_rate, + ) + + # Learning agent + self.agent = ContinuousLearningAgent( + initial_amount=initial_capital, + transaction_cost=transaction_cost, + max_options=max_options, + model_path=model_path, + ) + + # Timeframe optimizer + self.timeframes = timeframes or ["1m", "5m", "15m", "1h"] + self.timeframe_optimizer = TimeframeOptimizer( + timeframes=self.timeframes, + risk_free_rate=risk_free_rate, + ) + + # State + self.current_data = None + self.current_signal = None + self.current_price_target = None + self.last_update_time = None + self.update_count = 0 + + # Trading state + self.portfolio_value = initial_capital + self.cash = initial_capital + self.positions = { + "calls": 0, + "puts": 0, + } + self.trade_log = [] + + # Performance tracking + self.daily_returns = [] + self.performance_history = [] + + print("SPY Trading Tool initialized successfully!") + + def fetch_real_time_data( + self, timeframe: str = "1m", lookback: int = 100 + ) -> pd.DataFrame: + """Fetch real-time market data. + + Parameters + ---------- + timeframe : str + Data timeframe + lookback : int + Number of periods to fetch + + Returns + ------- + pd.DataFrame + Market data + """ + print(f"Fetching real-time data ({timeframe})...") + + # Fetch stock data + stock_data = self.options_processor.get_real_time_data( + timeframe=timeframe, period="5d" if timeframe in ["1m", "5m"] else "1mo" + ) + + if stock_data.empty: + print("Warning: No stock data fetched") + return pd.DataFrame() + + # Add ticker column + stock_data["tic"] = self.ticker + + # Ensure required columns + if "date" not in stock_data.columns and "timestamp" in stock_data.columns: + stock_data["date"] = stock_data["timestamp"].dt.strftime( + "%Y-%m-%d %H:%M:%S" + ) + + # Take last N rows + stock_data = stock_data.tail(lookback).copy() + + print(f"Fetched {len(stock_data)} data points") + return stock_data + + def engineer_features( + self, data: pd.DataFrame, include_options: bool = True + ) -> pd.DataFrame: + """Engineer features from raw data. + + Parameters + ---------- + data : pd.DataFrame + Raw market data + include_options : bool + Include options Greeks + + Returns + ------- + pd.DataFrame + Data with engineered features + """ + print("Engineering features...") + + if data.empty: + return data + + # Preprocess with feature engineering + processed_data = self.feature_engineer.preprocess_spy_data( + data, include_options=include_options + ) + + print(f"Features engineered: {len(processed_data.columns)} columns") + return processed_data + + def get_current_signal(self) -> dict: + """Get current trading signal. + + Returns + ------- + Dict + Trading signal with action, confidence, targets + """ + if self.current_data is None or self.current_data.empty: + return { + "signal": "HOLD", + "confidence": 0, + "price_target": None, + "timestamp": datetime.now(), + } + + # Get latest data point + latest_data = self.current_data.iloc[-1] + + # Generate signal from agent + signal = self.agent.generate_signal(latest_data) + + self.current_signal = signal + return signal + + def get_price_target(self) -> dict: + """Get current price target prediction. + + Returns + ------- + Dict + Price targets (upside, downside, expected) + """ + signal = self.get_current_signal() + return signal.get("price_target", {}) + + def update(self, timeframe: str = "1m") -> dict: + """Perform a complete update cycle. + + Parameters + ---------- + timeframe : str + Timeframe to use + + Returns + ------- + Dict + Update results + """ + print(f"\n{'='*60}") + print(f"UPDATE {self.update_count + 1} - {datetime.now()}") + print(f"{'='*60}") + + try: + # 1. Fetch latest data + raw_data = self.fetch_real_time_data(timeframe=timeframe, lookback=100) + + if raw_data.empty: + print("No data available, skipping update") + return {"status": "no_data"} + + # 2. Engineer features + processed_data = self.engineer_features(raw_data, include_options=True) + + if processed_data.empty: + print("Feature engineering failed, skipping update") + return {"status": "feature_error"} + + self.current_data = processed_data + + # 3. Get trading signal + signal = self.get_current_signal() + + print(f"\nSignal: {signal['signal']}") + print(f"Confidence: {signal['confidence']:.2%}") + + if signal.get("price_target"): + targets = signal["price_target"] + print(f"Current Price: ${targets.get('current', 0):.2f}") + print(f"Upside Target: ${targets.get('upside', 0):.2f}") + print(f"Downside Target: ${targets.get('downside', 0):.2f}") + + # 4. Check if model should be updated + if self.agent.should_update_model() and len(processed_data) > 50: + print("\nPerforming incremental model update...") + self.agent.incremental_update(processed_data, timesteps=500) + + if self.auto_save: + model_path = os.path.join( + self.save_dir, + f'spy_model_{datetime.now().strftime("%Y%m%d_%H%M%S")}.zip', + ) + self.agent.save_model(model_path) + + # 5. Update metrics + self.update_count += 1 + self.last_update_time = datetime.now() + + # Performance summary + perf = self.agent.get_performance_summary() + print(f"\nPerformance:") + print(f" Total Trades: {perf['total_trades']}") + print(f" Win Rate: {perf['win_rate']:.2%}") + print(f" Model Updates: {perf['total_updates']}") + + return { + "status": "success", + "signal": signal, + "performance": perf, + "timestamp": self.last_update_time, + } + + except Exception as e: + print(f"Error during update: {e}") + import traceback + + traceback.print_exc() + return {"status": "error", "error": str(e)} + + def run_continuous(self, update_interval: int = 60, max_updates: int | None = None): + """Run continuous trading loop with periodic updates. + + Parameters + ---------- + update_interval : int + Update interval in seconds (default: 60 = 1 minute) + max_updates : int, optional + Maximum number of updates (None = run indefinitely) + """ + print(f"\n{'='*60}") + print("STARTING CONTINUOUS SPY TRADING TOOL") + print(f"{'='*60}") + print(f"Update Interval: {update_interval} seconds") + print(f"Ticker: {self.ticker}") + print(f"Initial Capital: ${self.initial_capital:,.2f}") + print(f"Max Updates: {max_updates or 'Unlimited'}") + print(f"{'='*60}\n") + + update_num = 0 + + try: + while True: + # Perform update + result = self.update(timeframe="1m") + + # Check if should stop + if max_updates and update_num >= max_updates: + print(f"\nReached maximum updates ({max_updates}). Stopping.") + break + + update_num += 1 + + # Wait for next update + print(f"\nWaiting {update_interval} seconds for next update...") + time.sleep(update_interval) + + except KeyboardInterrupt: + print("\n\nStopping trading tool (user interrupted)...") + + finally: + # Save final model + if self.auto_save: + final_model_path = os.path.join(self.save_dir, "spy_model_final.zip") + self.agent.save_model(final_model_path) + print(f"\nFinal model saved to {final_model_path}") + + # Print final summary + self.print_summary() + + def train_initial_model(self, training_days: int = 30, timesteps: int = 10000): + """Train initial model on historical data. + + Parameters + ---------- + training_days : int + Number of days of historical data + timesteps : int + Training timesteps + """ + print(f"\n{'='*60}") + print("TRAINING INITIAL MODEL") + print(f"{'='*60}") + + # Fetch historical data + from finrl.meta.preprocessor.yahoodownloader import YahooDownloader + + end_date = datetime.now().strftime("%Y-%m-%d") + start_date = (datetime.now() - timedelta(days=training_days)).strftime( + "%Y-%m-%d" + ) + + print(f"Fetching historical data: {start_date} to {end_date}") + + downloader = YahooDownloader( + start_date=start_date, end_date=end_date, ticker_list=[self.ticker] + ) + + data = downloader.fetch_data() + + if data.empty: + print("No historical data available") + return + + print(f"Fetched {len(data)} data points") + + # Engineer features (without live options data for historical) + processed_data = self.engineer_features(data, include_options=False) + + # Create environment + self.agent.create_env(processed_data) + + # Initialize model + self.agent.initialize_model() + + # Train + print(f"\nTraining for {timesteps} timesteps...") + self.agent.train(total_timesteps=timesteps) + + # Save model + if self.auto_save: + model_path = os.path.join(self.save_dir, "spy_model_initial.zip") + self.agent.save_model(model_path) + print(f"\nInitial model saved to {model_path}") + + print("\nInitial training complete!") + + def optimize_timeframes(self) -> pd.DataFrame: + """Optimize across timeframes and display results. + + Returns + ------- + pd.DataFrame + Optimization results + """ + print(f"\n{'='*60}") + print("TIMEFRAME OPTIMIZATION") + print(f"{'='*60}") + + # Fetch data for each timeframe + timeframe_data = {} + + for tf in self.timeframes: + print(f"\nFetching data for {tf}...") + data = self.fetch_real_time_data(timeframe=tf, lookback=100) + + if not data.empty: + # Simulate portfolio values (simplified) + # In production, you'd backtest properly + prices = data["close"].values + portfolio_values = (prices / prices[0]) * self.initial_capital + timeframe_data[tf] = portfolio_values + + # Optimize + best_tf, metrics = self.timeframe_optimizer.optimize(timeframe_data) + + print(f"\n{self.timeframe_optimizer.generate_report()}") + + return self.timeframe_optimizer.get_optimization_summary() + + def print_summary(self): + """Print comprehensive summary of trading session.""" + print(f"\n{'='*60}") + print("TRADING SESSION SUMMARY") + print(f"{'='*60}") + + print(f"\nSession Info:") + print(f" Total Updates: {self.update_count}") + print(f" Last Update: {self.last_update_time}") + + perf = self.agent.get_performance_summary() + print(f"\nTrading Performance:") + print(f" Total Trades: {perf['total_trades']}") + print(f" Overall Win Rate: {perf['win_rate']:.2%}") + print(f" Recent Win Rate: {perf['recent_win_rate']:.2%}") + print(f" Avg PnL: ${perf['avg_pnl']:.2f}") + + print(f"\nModel Info:") + print(f" Total Training Steps: {perf['total_steps']}") + print(f" Total Updates: {perf['total_updates']}") + + if self.current_signal: + print(f"\nLast Signal:") + print(f" Action: {self.current_signal['signal']}") + print(f" Confidence: {self.current_signal['confidence']:.2%}") + + print(f"\n{'='*60}\n") + + +def main(): + """Main entry point.""" + # Configuration + TICKER = "SPY" + INITIAL_CAPITAL = 10000 + UPDATE_INTERVAL = 60 # seconds (1 minute) + MAX_UPDATES = None # Run indefinitely (or set a number for testing) + + # Initialize tool + tool = SPYTradingTool( + ticker=TICKER, + initial_capital=INITIAL_CAPITAL, + auto_save=True, + save_dir="./spy_models", + ) + + # Train initial model (optional - comment out if loading existing model) + print("\nTrain initial model? (y/n): ", end="") + if input().lower().strip() == "y": + tool.train_initial_model(training_days=30, timesteps=10000) + + # Optimize timeframes + print("\nRun timeframe optimization? (y/n): ", end="") + if input().lower().strip() == "y": + tool.optimize_timeframes() + + # Run continuous trading + print("\nStarting continuous trading...") + tool.run_continuous(update_interval=UPDATE_INTERVAL, max_updates=MAX_UPDATES) + + +if __name__ == "__main__": + main() diff --git a/spy_trading_tool/timeframe_optimizer.py b/spy_trading_tool/timeframe_optimizer.py new file mode 100644 index 0000000000..26e04385ce --- /dev/null +++ b/spy_trading_tool/timeframe_optimizer.py @@ -0,0 +1,462 @@ +"""Multi-timeframe optimizer using CAGR for SPY trading. + +This module optimizes trading strategy across multiple timeframes +using CAGR (Compound Annual Growth Rate) as the primary metric. +""" + +from __future__ import annotations + +import warnings +from datetime import datetime +from datetime import timedelta +from typing import Dict +from typing import List +from typing import Optional +from typing import Tuple + +import numpy as np +import pandas as pd + +warnings.filterwarnings("ignore") + + +class TimeframeOptimizer: + """Optimizes trading strategy across multiple timeframes using CAGR. + + This optimizer: + - Tests strategy on different timeframes (1m, 5m, 15m, 1h, 1d) + - Calculates CAGR, Sharpe ratio, and max drawdown for each + - Selects optimal timeframe based on risk-adjusted returns + - Adaptively switches timeframes based on market conditions + + Attributes + ---------- + timeframes : List[str] + List of timeframes to optimize + lookback_period : int + Number of periods to use for optimization + reoptimize_freq : int + How often to reoptimize (in minutes) + current_best_tf : str + Currently selected best timeframe + performance_history : Dict + Historical performance for each timeframe + """ + + def __init__( + self, + timeframes: list[str] = None, + lookback_period: int = 100, + reoptimize_freq: int = 60, + risk_free_rate: float = 0.02, + ): + """Initialize the timeframe optimizer. + + Parameters + ---------- + timeframes : List[str], optional + List of timeframes to test + lookback_period : int + Number of periods for backtesting + reoptimize_freq : int + Reoptimization frequency in minutes + risk_free_rate : float + Annual risk-free rate for Sharpe calculation + """ + self.timeframes = timeframes or ["1m", "5m", "15m", "1h", "1d"] + self.lookback_period = lookback_period + self.reoptimize_freq = reoptimize_freq + self.risk_free_rate = risk_free_rate + + self.current_best_tf = self.timeframes[0] + self.performance_history = {tf: [] for tf in self.timeframes} + self.last_optimization_time = None + self.optimization_results = {} + + def calculate_cagr( + self, portfolio_values: np.ndarray, periods_per_year: int + ) -> float: + """Calculate Compound Annual Growth Rate. + + Parameters + ---------- + portfolio_values : np.ndarray + Array of portfolio values over time + periods_per_year : int + Number of periods per year for this timeframe + + Returns + ------- + float + CAGR as a decimal (e.g., 0.15 for 15%) + """ + if len(portfolio_values) < 2: + return 0.0 + + initial_value = portfolio_values[0] + final_value = portfolio_values[-1] + n_periods = len(portfolio_values) + + if initial_value <= 0 or final_value <= 0: + return 0.0 + + # CAGR = (Final/Initial)^(1/years) - 1 + years = n_periods / periods_per_year + if years <= 0: + return 0.0 + + cagr = (final_value / initial_value) ** (1 / years) - 1 + + return cagr + + def calculate_sharpe_ratio( + self, returns: np.ndarray, periods_per_year: int + ) -> float: + """Calculate annualized Sharpe ratio. + + Parameters + ---------- + returns : np.ndarray + Array of period returns + periods_per_year : int + Number of periods per year + + Returns + ------- + float + Annualized Sharpe ratio + """ + if len(returns) < 2: + return 0.0 + + # Calculate excess returns + mean_return = np.mean(returns) + std_return = np.std(returns) + + if std_return == 0: + return 0.0 + + # Annualize + annual_return = mean_return * periods_per_year + annual_std = std_return * np.sqrt(periods_per_year) + risk_free_period = self.risk_free_rate / periods_per_year + + sharpe = (annual_return - risk_free_period * periods_per_year) / annual_std + + return sharpe + + def calculate_max_drawdown(self, portfolio_values: np.ndarray) -> float: + """Calculate maximum drawdown. + + Parameters + ---------- + portfolio_values : np.ndarray + Array of portfolio values + + Returns + ------- + float + Maximum drawdown as a decimal (e.g., -0.20 for 20% drawdown) + """ + if len(portfolio_values) < 2: + return 0.0 + + cummax = np.maximum.accumulate(portfolio_values) + drawdown = (portfolio_values - cummax) / cummax + max_dd = np.min(drawdown) + + return max_dd + + def calculate_calmar_ratio(self, cagr: float, max_drawdown: float) -> float: + """Calculate Calmar ratio (CAGR / abs(max drawdown)). + + Parameters + ---------- + cagr : float + Compound annual growth rate + max_drawdown : float + Maximum drawdown + + Returns + ------- + float + Calmar ratio + """ + if max_drawdown == 0: + return 0.0 + + return cagr / abs(max_drawdown) + + def calculate_win_rate(self, returns: np.ndarray) -> float: + """Calculate win rate (percentage of positive returns). + + Parameters + ---------- + returns : np.ndarray + Array of returns + + Returns + ------- + float + Win rate (0 to 1) + """ + if len(returns) == 0: + return 0.0 + + wins = np.sum(returns > 0) + return wins / len(returns) + + def get_periods_per_year(self, timeframe: str) -> int: + """Get number of periods per year for a timeframe. + + Parameters + ---------- + timeframe : str + Timeframe string (e.g., '1m', '5m', '1h', '1d') + + Returns + ------- + int + Number of periods per year + """ + # Trading days per year: ~252 + # Trading hours per day: ~6.5 (9:30 AM - 4:00 PM ET) + timeframe_to_periods = { + "1m": 252 * 6.5 * 60, # ~98,280 minutes per year + "5m": 252 * 6.5 * 12, # ~19,656 5-min periods + "15m": 252 * 6.5 * 4, # ~6,552 15-min periods + "30m": 252 * 6.5 * 2, # ~3,276 30-min periods + "1h": 252 * 6.5, # ~1,638 hours + "1d": 252, # 252 trading days + } + + return timeframe_to_periods.get(timeframe, 252) + + def evaluate_timeframe(self, portfolio_values: np.ndarray, timeframe: str) -> dict: + """Evaluate performance metrics for a timeframe. + + Parameters + ---------- + portfolio_values : np.ndarray + Portfolio values over time + timeframe : str + Timeframe being evaluated + + Returns + ------- + Dict + Performance metrics + """ + if len(portfolio_values) < 2: + return { + "cagr": 0, + "sharpe": 0, + "max_drawdown": 0, + "calmar": 0, + "win_rate": 0, + "total_return": 0, + "score": 0, + } + + periods_per_year = self.get_periods_per_year(timeframe) + + # Calculate returns + returns = np.diff(portfolio_values) / portfolio_values[:-1] + + # Calculate metrics + cagr = self.calculate_cagr(portfolio_values, periods_per_year) + sharpe = self.calculate_sharpe_ratio(returns, periods_per_year) + max_dd = self.calculate_max_drawdown(portfolio_values) + calmar = self.calculate_calmar_ratio(cagr, max_dd) + win_rate = self.calculate_win_rate(returns) + total_return = (portfolio_values[-1] - portfolio_values[0]) / portfolio_values[ + 0 + ] + + # Calculate composite score + # Weight: CAGR (40%), Sharpe (30%), Calmar (20%), Win Rate (10%) + score = ( + cagr * 0.4 + + sharpe * 0.05 * 0.3 # Normalize Sharpe to ~0-1 range + + calmar * 0.1 * 0.2 # Normalize Calmar + + win_rate * 0.1 + ) + + return { + "cagr": cagr, + "sharpe": sharpe, + "max_drawdown": max_dd, + "calmar": calmar, + "win_rate": win_rate, + "total_return": total_return, + "score": score, + } + + def optimize(self, timeframe_data: dict[str, np.ndarray]) -> tuple[str, dict]: + """Optimize across timeframes and select the best one. + + Parameters + ---------- + timeframe_data : Dict[str, np.ndarray] + Dictionary mapping timeframe to portfolio values + + Returns + ------- + Tuple[str, Dict] + Best timeframe and its metrics + """ + results = {} + + # Evaluate each timeframe + for tf in self.timeframes: + if tf not in timeframe_data or len(timeframe_data[tf]) < 2: + results[tf] = { + "cagr": 0, + "sharpe": 0, + "max_drawdown": 0, + "calmar": 0, + "win_rate": 0, + "total_return": 0, + "score": 0, + } + continue + + portfolio_values = timeframe_data[tf] + results[tf] = self.evaluate_timeframe(portfolio_values, tf) + + # Select best timeframe based on score + best_tf = max(results.keys(), key=lambda k: results[k]["score"]) + self.current_best_tf = best_tf + self.optimization_results = results + self.last_optimization_time = datetime.now() + + return best_tf, results[best_tf] + + def should_reoptimize(self) -> bool: + """Check if it's time to reoptimize. + + Returns + ------- + bool + True if should reoptimize + """ + if self.last_optimization_time is None: + return True + + time_elapsed = ( + datetime.now() - self.last_optimization_time + ).total_seconds() / 60 + return time_elapsed >= self.reoptimize_freq + + def get_current_best_timeframe(self) -> str: + """Get the currently selected best timeframe. + + Returns + ------- + str + Best timeframe + """ + return self.current_best_tf + + def get_optimization_summary(self) -> pd.DataFrame: + """Get summary of optimization results. + + Returns + ------- + pd.DataFrame + Summary table of all timeframes + """ + if not self.optimization_results: + return pd.DataFrame() + + df = pd.DataFrame(self.optimization_results).T + df = df.round(4) + df = df.sort_values("score", ascending=False) + + return df + + def add_performance_record(self, timeframe: str, value: float): + """Add a performance record for a timeframe. + + Parameters + ---------- + timeframe : str + Timeframe + value : float + Portfolio value + """ + if timeframe in self.performance_history: + self.performance_history[timeframe].append(value) + + # Keep only lookback period + if len(self.performance_history[timeframe]) > self.lookback_period: + self.performance_history[timeframe] = self.performance_history[ + timeframe + ][-self.lookback_period :] + + def get_recommended_action(self, current_metrics: dict) -> str: + """Get recommended action based on current metrics. + + Parameters + ---------- + current_metrics : Dict + Current performance metrics + + Returns + ------- + str + Recommendation: 'continue', 'switch_timeframe', 'reduce_risk' + """ + # If max drawdown is too large, reduce risk + if current_metrics.get("max_drawdown", 0) < -0.15: + return "reduce_risk" + + # If Sharpe ratio is negative, consider switching + if current_metrics.get("sharpe", 0) < 0: + return "switch_timeframe" + + # If win rate is too low + if current_metrics.get("win_rate", 0) < 0.4: + return "switch_timeframe" + + return "continue" + + def generate_report(self) -> str: + """Generate a text report of optimization results. + + Returns + ------- + str + Formatted report + """ + if not self.optimization_results: + return "No optimization results available." + + report = "\n" + "=" * 60 + "\n" + report += "TIMEFRAME OPTIMIZATION REPORT\n" + report += "=" * 60 + "\n\n" + + report += f"Best Timeframe: {self.current_best_tf}\n" + report += f"Optimization Time: {self.last_optimization_time}\n\n" + + report += "Performance by Timeframe:\n" + report += "-" * 60 + "\n" + + for tf in sorted( + self.optimization_results.keys(), + key=lambda k: self.optimization_results[k]["score"], + reverse=True, + ): + metrics = self.optimization_results[tf] + report += f"\n{tf}:\n" + report += f" CAGR: {metrics['cagr']*100:6.2f}%\n" + report += f" Sharpe Ratio: {metrics['sharpe']:6.2f}\n" + report += f" Max Drawdown: {metrics['max_drawdown']*100:6.2f}%\n" + report += f" Calmar Ratio: {metrics['calmar']:6.2f}\n" + report += f" Win Rate: {metrics['win_rate']*100:6.2f}%\n" + report += f" Total Return: {metrics['total_return']*100:6.2f}%\n" + report += f" Score: {metrics['score']:6.4f}\n" + + report += "\n" + "=" * 60 + "\n" + + return report