├── modules ├── __init__.py ├── executor.py ├── ranking.py ├── risk.py ├── momentum.py ├── alpaca_client.py ├── state.py └── sheets.py ├── requirements.txt ├── .env.example ├── .gitignore ├── .github └── workflows │ └── trader.yml ├── README.md └── main.py /modules/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | AI Trader modules package. 3 | """ 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | alpaca-trade-api>=3.0.0 2 | gspread>=5.12.0 3 | pandas>=2.0.0 4 | numpy>=1.24.0 5 | python-dotenv>=1.0.0 6 | google-auth>=2.20.0 7 | google-auth-oauthlib>=1.0.0 8 | google-auth-httplib2>=0.1.0 9 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Alpaca API Configuration 2 | ALPACA_API_KEY=your_alpaca_api_key_here 3 | ALPACA_SECRET_KEY=your_alpaca_secret_key_here 4 | ALPACA_BASE_URL=https://paper-api.alpaca.markets # Use paper trading for testing 5 | 6 | # Google Sheets Configuration 7 | GOOGLE_SHEET_ID=your_google_sheet_id_here 8 | # Place your service account JSON file as 'credentials.json' in the root directory 9 | 10 | # Trading Configuration 11 | # Portfolio allocation is now configured in the Google Sheets 'Config' tab 12 | # See README.md for setup instructions 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | *.so 6 | .Python 7 | env/ 8 | venv/ 9 | ENV/ 10 | build/ 11 | develop-eggs/ 12 | dist/ 13 | downloads/ 14 | eggs/ 15 | .eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | wheels/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # Environment variables 27 | .env 28 | credentials.json 29 | service-account.json 30 | 31 | # IDE 32 | .vscode/ 33 | .idea/ 34 | *.swp 35 | *.swo 36 | *~ 37 | 38 | # OS 39 | .DS_Store 40 | Thumbs.db 41 | 42 | # Logs 43 | *.log 44 | -------------------------------------------------------------------------------- /.github/workflows/trader.yml: -------------------------------------------------------------------------------- 1 | name: AI Trader - Scheduled Execution 2 | 3 | on: 4 | schedule: 5 | # Run every weekday at 9:00 AM EST (14:00 UTC) 6 | # Adjust time based on your trading strategy 7 | - cron: '0 14 * * 1-5' 8 | 9 | # Allow manual triggering 10 | workflow_dispatch: 11 | 12 | jobs: 13 | trade: 14 | runs-on: ubuntu-latest 15 | 16 | permissions: 17 | contents: read 18 | 19 | steps: 20 | - name: Checkout repository 21 | uses: actions/checkout@v4 22 | 23 | - name: Set up Python 24 | uses: actions/setup-python@v4 25 | with: 26 | python-version: '3.11' 27 | 28 | - name: Install dependencies 29 | run: | 30 | python -m pip install --upgrade pip 31 | pip install -r requirements.txt 32 | 33 | - name: Create credentials file 34 | run: | 35 | echo '${{ secrets.GOOGLE_CREDENTIALS_JSON }}' > credentials.json 36 | 37 | - name: Run trading assistant 38 | env: 39 | ALPACA_API_KEY: ${{ secrets.ALPACA_API_KEY }} 40 | ALPACA_SECRET_KEY: ${{ secrets.ALPACA_SECRET_KEY }} 41 | ALPACA_BASE_URL: ${{ secrets.ALPACA_BASE_URL }} 42 | GOOGLE_SHEET_ID: ${{ secrets.GOOGLE_SHEET_ID }} 43 | run: | 44 | python main.py 45 | 46 | - name: Clean up credentials 47 | if: always() 48 | run: | 49 | rm -f credentials.json 50 | -------------------------------------------------------------------------------- /modules/executor.py: -------------------------------------------------------------------------------- 1 | """ 2 | Trade executor module. 3 | Handles trade execution logic based on approved suggestions. 4 | """ 5 | import pandas as pd 6 | from datetime import datetime 7 | 8 | 9 | def execute_approved_trades(sheets_manager, alpaca_client): 10 | """ 11 | Execute trades that have been approved in the TradeSuggestions sheet. 12 | 13 | Args: 14 | sheets_manager: SheetsManager instance 15 | alpaca_client: AlpacaClient instance 16 | 17 | Returns: 18 | list: List of executed trade results 19 | """ 20 | approved_trades = sheets_manager.get_approved_trades() 21 | 22 | if approved_trades.empty: 23 | print("No approved trades to execute.") 24 | return [] 25 | 26 | executed = [] 27 | 28 | for _, trade in approved_trades.iterrows(): 29 | ticker = trade['Ticker'] 30 | action_str = str(trade['Action']).lower() 31 | quantity = int(trade['Quantity']) 32 | 33 | # Determine order side 34 | side = 'buy' if action_str == 'buy' else 'sell' 35 | 36 | # Submit order 37 | order = alpaca_client.submit_order( 38 | symbol=ticker, 39 | qty=quantity, 40 | side=side 41 | ) 42 | 43 | if order: 44 | executed.append({ 45 | 'Ticker': ticker, 46 | 'Action': action_str, 47 | 'Quantity': quantity, 48 | 'Status': 'Executed', 49 | 'OrderId': order.id, 50 | 'Timestamp': datetime.now().isoformat() 51 | }) 52 | else: 53 | executed.append({ 54 | 'Ticker': ticker, 55 | 'Action': action_str, 56 | 'Quantity': quantity, 57 | 'Status': 'Failed', 58 | 'OrderId': None, 59 | 'Timestamp': datetime.now().isoformat() 60 | }) 61 | 62 | return executed 63 | 64 | 65 | def calculate_position_size(account_value, allocation_pct=0.5, price=None): 66 | """ 67 | Calculate position size based on account value and allocation. 68 | 69 | Args: 70 | account_value: Total account value 71 | allocation_pct: Percentage to allocate per position (e.g., 0.5 for 50%) 72 | price: Price per share 73 | 74 | Returns: 75 | int: Number of shares to buy 76 | """ 77 | if price is None or price <= 0: 78 | return 0 79 | 80 | position_value = account_value * allocation_pct 81 | shares = int(position_value / price) 82 | 83 | return shares 84 | -------------------------------------------------------------------------------- /modules/ranking.py: -------------------------------------------------------------------------------- 1 | """ 2 | Ranking module. 3 | Ranks tickers based on momentum and other criteria. 4 | """ 5 | import pandas as pd 6 | 7 | 8 | def rank_tickers(momentum_df, top_n=10): 9 | """ 10 | Rank tickers based on momentum score. 11 | 12 | Args: 13 | momentum_df: DataFrame with momentum data 14 | top_n: Number of top tickers to return (default 10) 15 | 16 | Returns: 17 | pd.DataFrame: Top ranked tickers 18 | """ 19 | if momentum_df.empty: 20 | return pd.DataFrame() 21 | 22 | # Already sorted by combined momentum in momentum module 23 | # Return top N tickers 24 | top_tickers = momentum_df.head(top_n).copy() 25 | 26 | # Add rank column 27 | top_tickers['Rank'] = range(1, len(top_tickers) + 1) 28 | 29 | return top_tickers 30 | 31 | 32 | def identify_buy_candidates(ranked_df, current_positions, max_positions=10): 33 | """ 34 | Identify tickers to buy based on ranking. 35 | 36 | Args: 37 | ranked_df: DataFrame with ranked tickers 38 | current_positions: List of currently held tickers 39 | max_positions: Maximum number of positions to hold 40 | 41 | Returns: 42 | pd.DataFrame: Buy candidates 43 | """ 44 | if ranked_df.empty: 45 | return pd.DataFrame() 46 | 47 | # Filter out tickers already held 48 | buy_candidates = ranked_df[~ranked_df['Ticker'].isin(current_positions)].copy() 49 | 50 | # Limit to available slots 51 | available_slots = max(0, max_positions - len(current_positions)) 52 | buy_candidates = buy_candidates.head(available_slots) 53 | 54 | return buy_candidates 55 | 56 | 57 | def identify_sell_candidates(current_positions, ranked_df, rank_threshold=20): 58 | """ 59 | Identify positions to sell based on poor ranking or stop loss. 60 | 61 | Args: 62 | current_positions: List of currently held tickers 63 | ranked_df: DataFrame with all ranked tickers 64 | rank_threshold: Rank below which to consider selling 65 | 66 | Returns: 67 | list: Tickers to sell 68 | """ 69 | sell_candidates = [] 70 | 71 | for ticker in current_positions: 72 | # Check if ticker is in ranked list 73 | ticker_rank = ranked_df[ranked_df['Ticker'] == ticker] 74 | 75 | if ticker_rank.empty: 76 | # Ticker not in universe anymore - sell 77 | sell_candidates.append(ticker) 78 | elif 'Rank' in ticker_rank.columns and not ticker_rank.empty: 79 | rank_value = ticker_rank['Rank'].iloc[0] 80 | if rank_value > rank_threshold: 81 | # Poor ranking - sell 82 | sell_candidates.append(ticker) 83 | 84 | return sell_candidates 85 | -------------------------------------------------------------------------------- /modules/risk.py: -------------------------------------------------------------------------------- 1 | """ 2 | Risk management module. 3 | Calculates ATR-based stop losses. 4 | """ 5 | import pandas as pd 6 | import numpy as np 7 | 8 | 9 | def calculate_atr(bars_df, period=14): 10 | """ 11 | Calculate Average True Range (ATR). 12 | 13 | Args: 14 | bars_df: DataFrame with OHLCV data 15 | period: ATR period (default 14) 16 | 17 | Returns: 18 | float: ATR value or None if insufficient data 19 | """ 20 | if bars_df.empty or len(bars_df) < period: 21 | return None 22 | 23 | high = bars_df['high'].values 24 | low = bars_df['low'].values 25 | close = bars_df['close'].values 26 | 27 | # Calculate True Range 28 | tr_list = [] 29 | for i in range(1, len(bars_df)): 30 | tr = max( 31 | high[i] - low[i], 32 | abs(high[i] - close[i-1]), 33 | abs(low[i] - close[i-1]) 34 | ) 35 | tr_list.append(tr) 36 | 37 | if len(tr_list) < period: 38 | return None 39 | 40 | # Calculate ATR as simple moving average of TR 41 | atr = np.mean(tr_list[-period:]) 42 | return atr 43 | 44 | 45 | def calculate_atr_stop(current_price, atr, multiplier=2.0): 46 | """ 47 | Calculate ATR-based stop loss. 48 | 49 | Args: 50 | current_price: Current price of the asset 51 | atr: Average True Range value 52 | multiplier: ATR multiplier for stop distance (default 2.0) 53 | 54 | Returns: 55 | float: Stop loss price 56 | """ 57 | if atr is None or current_price is None: 58 | return None 59 | 60 | stop_loss = current_price - (atr * multiplier) 61 | return stop_loss 62 | 63 | 64 | def get_atr_for_ticker(alpaca_client, ticker, period=14, multiplier=2.0): 65 | """ 66 | Calculate ATR and stop loss for a ticker. 67 | 68 | Args: 69 | alpaca_client: AlpacaClient instance 70 | ticker: Ticker symbol 71 | period: ATR period (default 14) 72 | multiplier: ATR multiplier (default 2.0) 73 | 74 | Returns: 75 | dict: Dictionary with ATR and stop loss values 76 | """ 77 | bars = alpaca_client.get_historical_bars(ticker, days_back=60) 78 | 79 | if bars.empty: 80 | return {'atr': None, 'stop_loss': None} 81 | 82 | atr = calculate_atr(bars, period=period) 83 | current_price = bars['close'].iloc[-1] 84 | stop_loss = calculate_atr_stop(current_price, atr, multiplier) 85 | 86 | return { 87 | 'atr': atr, 88 | 'stop_loss': stop_loss, 89 | 'current_price': current_price 90 | } 91 | 92 | 93 | def update_trailing_stop(current_price, peak_price, atr, multiplier=2.0): 94 | """ 95 | Update trailing stop based on new peak. 96 | 97 | Args: 98 | current_price: Current price 99 | peak_price: Peak price achieved 100 | atr: Average True Range 101 | multiplier: ATR multiplier 102 | 103 | Returns: 104 | float: Updated stop loss price 105 | """ 106 | if atr is None or peak_price is None: 107 | return None 108 | 109 | # Trail from the peak 110 | stop_loss = peak_price - (atr * multiplier) 111 | return stop_loss 112 | -------------------------------------------------------------------------------- /modules/momentum.py: -------------------------------------------------------------------------------- 1 | """ 2 | Momentum calculation module. 3 | Calculates 3-month and 6-month momentum for ranking tickers. 4 | """ 5 | import pandas as pd 6 | import numpy as np 7 | 8 | 9 | def calculate_momentum(bars_df, period_months=3): 10 | """ 11 | Calculate momentum over a specified period. 12 | 13 | Args: 14 | bars_df: DataFrame with OHLCV data (datetime index) 15 | period_months: Number of months for momentum calculation 16 | 17 | Returns: 18 | float: Momentum percentage or None if insufficient data 19 | """ 20 | if bars_df.empty or len(bars_df) < 2: 21 | return None 22 | 23 | # Approximate trading days per month 24 | days_per_month = 21 25 | period_days = period_months * days_per_month 26 | 27 | if len(bars_df) < period_days: 28 | # Use available data if less than desired period 29 | period_days = len(bars_df) 30 | 31 | # Get close prices 32 | closes = bars_df['close'].values 33 | 34 | # Calculate momentum: (current_price - old_price) / old_price 35 | current_price = closes[-1] 36 | old_price = closes[-period_days] 37 | 38 | if old_price == 0: 39 | return None 40 | 41 | momentum = ((current_price - old_price) / old_price) * 100 42 | return momentum 43 | 44 | 45 | def calculate_multi_period_momentum(bars_df): 46 | """ 47 | Calculate both 3-month and 6-month momentum. 48 | 49 | Args: 50 | bars_df: DataFrame with OHLCV data (datetime index) 51 | 52 | Returns: 53 | dict: Dictionary with '3m' and '6m' momentum values 54 | """ 55 | momentum_3m = calculate_momentum(bars_df, period_months=3) 56 | momentum_6m = calculate_momentum(bars_df, period_months=6) 57 | 58 | return { 59 | '3m': momentum_3m, 60 | '6m': momentum_6m, 61 | 'combined': (momentum_3m + momentum_6m) / 2 if momentum_3m is not None and momentum_6m is not None else None 62 | } 63 | 64 | 65 | def get_momentum_for_universe(alpaca_client, tickers): 66 | """ 67 | Calculate momentum for all tickers in the universe. 68 | 69 | Args: 70 | alpaca_client: AlpacaClient instance 71 | tickers: List of ticker symbols 72 | 73 | Returns: 74 | pd.DataFrame: DataFrame with tickers and their momentum values 75 | """ 76 | results = [] 77 | 78 | for ticker in tickers: 79 | print(f"Calculating momentum for {ticker}...") 80 | bars = alpaca_client.get_historical_bars(ticker, days_back=180) 81 | 82 | if bars.empty: 83 | print(f" No data available for {ticker}") 84 | continue 85 | 86 | momentum = calculate_multi_period_momentum(bars) 87 | 88 | if momentum['combined'] is not None: 89 | results.append({ 90 | 'Ticker': ticker, 91 | 'Momentum_3M': momentum['3m'], 92 | 'Momentum_6M': momentum['6m'], 93 | 'Momentum_Combined': momentum['combined'], 94 | 'CurrentPrice': bars['close'].iloc[-1] 95 | }) 96 | 97 | df = pd.DataFrame(results) 98 | 99 | if not df.empty: 100 | # Sort by combined momentum (descending) 101 | df = df.sort_values('Momentum_Combined', ascending=False) 102 | 103 | return df 104 | -------------------------------------------------------------------------------- /modules/alpaca_client.py: -------------------------------------------------------------------------------- 1 | """ 2 | Alpaca API client module. 3 | Handles fetching market data and executing trades. 4 | """ 5 | import alpaca_trade_api as tradeapi 6 | import pandas as pd 7 | from datetime import datetime, timedelta 8 | 9 | 10 | class AlpacaClient: 11 | """Manages Alpaca API operations for market data and trading.""" 12 | 13 | def __init__(self, api_key, secret_key, base_url): 14 | """ 15 | Initialize Alpaca client. 16 | 17 | Args: 18 | api_key: Alpaca API key 19 | secret_key: Alpaca secret key 20 | base_url: Alpaca base URL (paper or live) 21 | """ 22 | self.api = tradeapi.REST(api_key, secret_key, base_url, api_version='v2') 23 | 24 | def get_historical_bars(self, symbol, timeframe='1Day', days_back=180): 25 | """ 26 | Fetch historical price data for a symbol. 27 | 28 | Args: 29 | symbol: Ticker symbol 30 | timeframe: Bar timeframe (e.g., '1Day', '1Hour') 31 | days_back: Number of days to look back 32 | 33 | Returns: 34 | pd.DataFrame: OHLCV data with datetime index 35 | """ 36 | try: 37 | end = datetime.now() 38 | start = end - timedelta(days=days_back) 39 | 40 | bars = self.api.get_bars( 41 | symbol, 42 | timeframe, 43 | start=start.strftime('%Y-%m-%d'), 44 | end=end.strftime('%Y-%m-%d'), 45 | adjustment='raw' 46 | ).df 47 | 48 | return bars 49 | except Exception as e: 50 | print(f"Error fetching bars for {symbol}: {e}") 51 | return pd.DataFrame() 52 | 53 | def get_current_price(self, symbol): 54 | """ 55 | Get current price for a symbol. 56 | 57 | Args: 58 | symbol: Ticker symbol 59 | 60 | Returns: 61 | float: Current price or None if unavailable 62 | """ 63 | try: 64 | trade = self.api.get_latest_trade(symbol) 65 | return float(trade.price) 66 | except Exception as e: 67 | print(f"Error fetching current price for {symbol}: {e}") 68 | return None 69 | 70 | def get_account(self): 71 | """ 72 | Get account information. 73 | 74 | Returns: 75 | Account object with portfolio details 76 | """ 77 | return self.api.get_account() 78 | 79 | def get_positions(self): 80 | """ 81 | Get current positions. 82 | 83 | Returns: 84 | list: List of Position objects 85 | """ 86 | return self.api.list_positions() 87 | 88 | def submit_order(self, symbol, qty, side, order_type='market', time_in_force='day'): 89 | """ 90 | Submit a trading order. 91 | 92 | Args: 93 | symbol: Ticker symbol 94 | qty: Quantity to trade 95 | side: 'buy' or 'sell' 96 | order_type: Order type (default 'market') 97 | time_in_force: Time in force (default 'day') 98 | 99 | Returns: 100 | Order object or None if failed 101 | """ 102 | try: 103 | order = self.api.submit_order( 104 | symbol=symbol, 105 | qty=qty, 106 | side=side, 107 | type=order_type, 108 | time_in_force=time_in_force 109 | ) 110 | print(f"Order submitted: {side.upper()} {qty} shares of {symbol}") 111 | return order 112 | except Exception as e: 113 | print(f"Error submitting order for {symbol}: {e}") 114 | return None 115 | 116 | def get_position(self, symbol): 117 | """ 118 | Get position for a specific symbol. 119 | 120 | Args: 121 | symbol: Ticker symbol 122 | 123 | Returns: 124 | Position object or None if no position 125 | """ 126 | try: 127 | return self.api.get_position(symbol) 128 | except Exception: 129 | return None 130 | -------------------------------------------------------------------------------- /modules/state.py: -------------------------------------------------------------------------------- 1 | """ 2 | State management module. 3 | Tracks position state including peaks and stops. 4 | """ 5 | import pandas as pd 6 | from datetime import datetime 7 | 8 | 9 | def update_position_peaks(state_df, alpaca_client): 10 | """ 11 | Update peak prices for current positions. 12 | 13 | Args: 14 | state_df: DataFrame with current state 15 | alpaca_client: AlpacaClient instance 16 | 17 | Returns: 18 | pd.DataFrame: Updated state DataFrame 19 | """ 20 | if state_df.empty: 21 | return state_df 22 | 23 | updated_state = state_df.copy() 24 | 25 | for idx, row in updated_state.iterrows(): 26 | ticker = row['Ticker'] 27 | current_peak = float(row.get('Peak', 0)) 28 | 29 | # Get current price 30 | current_price = alpaca_client.get_current_price(ticker) 31 | 32 | if current_price is None: 33 | continue 34 | 35 | # Update peak if current price is higher 36 | if current_price > current_peak: 37 | updated_state.at[idx, 'Peak'] = current_price 38 | updated_state.at[idx, 'LastUpdated'] = datetime.now().isoformat() 39 | 40 | return updated_state 41 | 42 | 43 | def initialize_state_for_positions(alpaca_client): 44 | """ 45 | Initialize state from current Alpaca positions. 46 | 47 | Args: 48 | alpaca_client: AlpacaClient instance 49 | 50 | Returns: 51 | pd.DataFrame: State DataFrame 52 | """ 53 | positions = alpaca_client.get_positions() 54 | 55 | if not positions: 56 | return pd.DataFrame() 57 | 58 | state_records = [] 59 | 60 | for pos in positions: 61 | ticker = pos.symbol 62 | quantity = int(pos.qty) 63 | entry_price = float(pos.avg_entry_price) 64 | current_price = float(pos.current_price) 65 | 66 | # Initialize peak as current price 67 | peak = max(entry_price, current_price) 68 | 69 | state_records.append({ 70 | 'Ticker': ticker, 71 | 'Position': quantity, 72 | 'EntryPrice': entry_price, 73 | 'Peak': peak, 74 | 'ATRStop': 0.0, # Will be calculated separately 75 | 'LastUpdated': datetime.now().isoformat() 76 | }) 77 | 78 | return pd.DataFrame(state_records) 79 | 80 | 81 | def merge_state(existing_state_df, new_positions_df): 82 | """ 83 | Merge existing state with new position data. 84 | 85 | Args: 86 | existing_state_df: Existing state from Sheets 87 | new_positions_df: New positions from Alpaca 88 | 89 | Returns: 90 | pd.DataFrame: Merged state 91 | """ 92 | if existing_state_df.empty: 93 | return new_positions_df 94 | 95 | if new_positions_df.empty: 96 | return existing_state_df 97 | 98 | # Create a dictionary from existing state 99 | existing_dict = {} 100 | for _, row in existing_state_df.iterrows(): 101 | ticker = row['Ticker'] 102 | existing_dict[ticker] = row.to_dict() 103 | 104 | # Update with new positions 105 | merged_records = [] 106 | 107 | for _, new_row in new_positions_df.iterrows(): 108 | ticker = new_row['Ticker'] 109 | 110 | if ticker in existing_dict: 111 | # Keep existing peak if higher 112 | existing_peak = float(existing_dict[ticker].get('Peak', 0)) 113 | new_peak = float(new_row.get('Peak', 0)) 114 | 115 | merged_records.append({ 116 | 'Ticker': ticker, 117 | 'Position': new_row['Position'], 118 | 'EntryPrice': new_row['EntryPrice'], 119 | 'Peak': max(existing_peak, new_peak), 120 | 'ATRStop': existing_dict[ticker].get('ATRStop', 0.0), 121 | 'LastUpdated': datetime.now().isoformat() 122 | }) 123 | else: 124 | # New position 125 | merged_records.append(new_row.to_dict()) 126 | 127 | return pd.DataFrame(merged_records) 128 | 129 | 130 | def check_stop_losses(state_df, alpaca_client): 131 | """ 132 | Check if any positions have hit their stop losses. 133 | 134 | Args: 135 | state_df: DataFrame with state including stops 136 | alpaca_client: AlpacaClient instance 137 | 138 | Returns: 139 | list: Tickers that hit their stops 140 | """ 141 | stopped_out = [] 142 | 143 | if state_df.empty: 144 | return stopped_out 145 | 146 | for _, row in state_df.iterrows(): 147 | ticker = row['Ticker'] 148 | stop_price = float(row.get('ATRStop', 0)) 149 | 150 | if stop_price == 0: 151 | continue 152 | 153 | current_price = alpaca_client.get_current_price(ticker) 154 | 155 | if current_price is None: 156 | continue 157 | 158 | # Check if current price is below stop 159 | if current_price <= stop_price: 160 | stopped_out.append(ticker) 161 | print(f"Stop loss triggered for {ticker}: Price {current_price:.2f} <= Stop {stop_price:.2f}") 162 | 163 | return stopped_out 164 | -------------------------------------------------------------------------------- /modules/sheets.py: -------------------------------------------------------------------------------- 1 | """ 2 | Google Sheets integration module. 3 | Handles reading configuration/universe and writing trade suggestions and state. 4 | """ 5 | import gspread 6 | from google.oauth2.service_account import Credentials 7 | import pandas as pd 8 | import os 9 | 10 | 11 | class SheetsManager: 12 | """Manages Google Sheets operations for trading data.""" 13 | 14 | def __init__(self, sheet_id, credentials_file='credentials.json'): 15 | """ 16 | Initialize Sheets Manager. 17 | 18 | Args: 19 | sheet_id: Google Sheet ID 20 | credentials_file: Path to service account credentials JSON 21 | """ 22 | self.sheet_id = sheet_id 23 | scope = [ 24 | 'https://spreadsheets.google.com/feeds', 25 | 'https://www.googleapis.com/auth/drive' 26 | ] 27 | 28 | creds = Credentials.from_service_account_file( 29 | credentials_file, scopes=scope 30 | ) 31 | self.client = gspread.authorize(creds) 32 | self.spreadsheet = self.client.open_by_key(sheet_id) 33 | 34 | def get_config(self): 35 | """ 36 | Read configuration from the 'Config' worksheet. 37 | 38 | Expected format (sheet must have these exact column headers): 39 | | Setting | Value | 40 | |---------------------|-------| 41 | | PortfolioAllocation | 0.5 | 42 | 43 | Returns: 44 | dict: Configuration key-value pairs 45 | """ 46 | try: 47 | worksheet = self.spreadsheet.worksheet('Config') 48 | data = worksheet.get_all_records() 49 | config = {} 50 | for row in data: 51 | # Columns must be named 'Setting' and 'Value' 52 | setting = row.get('Setting', '').strip() 53 | value = row.get('Value', '').strip() 54 | if setting and value: 55 | config[setting] = value 56 | return config 57 | except gspread.exceptions.WorksheetNotFound: 58 | print("Warning: 'Config' worksheet not found. Using defaults.") 59 | return {} 60 | 61 | def get_universe(self): 62 | """ 63 | Read trading universe from the 'Universe' worksheet. 64 | 65 | Returns: 66 | list: List of ticker symbols 67 | """ 68 | try: 69 | worksheet = self.spreadsheet.worksheet('Universe') 70 | # Assume tickers are in column A, starting from row 2 (skip header) 71 | values = worksheet.col_values(1)[1:] # Skip header 72 | # Filter out empty values 73 | tickers = [ticker.strip().upper() for ticker in values if ticker.strip()] 74 | return tickers 75 | except gspread.exceptions.WorksheetNotFound: 76 | print("Warning: 'Universe' worksheet not found. Returning empty list.") 77 | return [] 78 | 79 | def write_trade_suggestions(self, suggestions_df): 80 | """ 81 | Write trade suggestions to 'TradeSuggestions' worksheet. 82 | 83 | Args: 84 | suggestions_df: DataFrame with columns [Ticker, Action, Quantity, Price, Reason, Approved] 85 | """ 86 | try: 87 | worksheet = self.spreadsheet.worksheet('TradeSuggestions') 88 | except gspread.exceptions.WorksheetNotFound: 89 | worksheet = self.spreadsheet.add_worksheet( 90 | title='TradeSuggestions', 91 | rows=1000, 92 | cols=10 93 | ) 94 | 95 | # Clear existing content 96 | worksheet.clear() 97 | 98 | # Write header 99 | header = ['Ticker', 'Action', 'Quantity', 'Price', 'Reason', 'Approved', 'Timestamp'] 100 | worksheet.append_row(header) 101 | 102 | # Write data 103 | for _, row in suggestions_df.iterrows(): 104 | worksheet.append_row([ 105 | row.get('Ticker', ''), 106 | row.get('Action', ''), 107 | str(row.get('Quantity', '')), 108 | str(row.get('Price', '')), 109 | row.get('Reason', ''), 110 | row.get('Approved', 'NO'), 111 | row.get('Timestamp', '') 112 | ]) 113 | 114 | def get_approved_trades(self): 115 | """ 116 | Read approved trades from 'TradeSuggestions' worksheet. 117 | 118 | Returns: 119 | pd.DataFrame: DataFrame with approved trades 120 | """ 121 | try: 122 | worksheet = self.spreadsheet.worksheet('TradeSuggestions') 123 | data = worksheet.get_all_records() 124 | df = pd.DataFrame(data) 125 | 126 | if df.empty: 127 | return df 128 | 129 | # Filter for approved trades (case-insensitive) 130 | # Convert to string and handle NaN/None values 131 | df['Approved'] = df['Approved'].astype(str) 132 | approved_df = df[df['Approved'].str.upper() == 'YES'].copy() 133 | return approved_df 134 | except gspread.exceptions.WorksheetNotFound: 135 | return pd.DataFrame() 136 | 137 | def get_state(self): 138 | """ 139 | Read state data from 'State' worksheet. 140 | 141 | Returns: 142 | pd.DataFrame: DataFrame with state data including peaks 143 | """ 144 | try: 145 | worksheet = self.spreadsheet.worksheet('State') 146 | data = worksheet.get_all_records() 147 | return pd.DataFrame(data) 148 | except gspread.exceptions.WorksheetNotFound: 149 | return pd.DataFrame() 150 | 151 | def update_state(self, state_df): 152 | """ 153 | Update state data in 'State' worksheet. 154 | 155 | Args: 156 | state_df: DataFrame with columns [Ticker, Position, EntryPrice, Peak, ATRStop, LastUpdated] 157 | """ 158 | try: 159 | worksheet = self.spreadsheet.worksheet('State') 160 | except gspread.exceptions.WorksheetNotFound: 161 | worksheet = self.spreadsheet.add_worksheet( 162 | title='State', 163 | rows=1000, 164 | cols=10 165 | ) 166 | 167 | # Clear existing content 168 | worksheet.clear() 169 | 170 | # Write header 171 | header = ['Ticker', 'Position', 'EntryPrice', 'Peak', 'ATRStop', 'LastUpdated'] 172 | worksheet.append_row(header) 173 | 174 | # Write data 175 | for _, row in state_df.iterrows(): 176 | worksheet.append_row([ 177 | row.get('Ticker', ''), 178 | str(row.get('Position', '')), 179 | str(row.get('EntryPrice', '')), 180 | str(row.get('Peak', '')), 181 | str(row.get('ATRStop', '')), 182 | row.get('LastUpdated', '') 183 | ]) 184 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AI-Trader 2 | Stock market momentum trading AI using Alpaca, Google Sheets, and GitHub Actions 3 | 4 | ## Overview 5 | 6 | This automated trading assistant uses momentum-based strategies to analyze stocks, generate trade suggestions, and execute approved trades. It integrates with: 7 | 8 | - **Alpaca Markets** - For market data and trade execution 9 | - **Google Sheets** - For configuration, trade approvals, and state tracking 10 | - **GitHub Actions** - For automated scheduled execution 11 | 12 | ## Features 13 | 14 | - **Momentum Analysis**: Calculates 3-month and 6-month momentum for ranking tickers 15 | - **ATR-based Risk Management**: Uses Average True Range for stop-loss calculations 16 | - **Automated Suggestions**: Proposes BUY/SELL trades with 50% portfolio allocation 17 | - **Manual Approval**: Execute only approved trades from Google Sheets 18 | - **Peak Tracking**: Monitors position peaks and implements trailing stops 19 | - **Scheduled Execution**: Runs automatically via GitHub Actions 20 | 21 | ## Architecture 22 | 23 | ``` 24 | AI-Trader/ 25 | ├── main.py # Main entry point 26 | ├── modules/ 27 | │ ├── sheets.py # Google Sheets integration 28 | │ ├── alpaca_client.py # Alpaca API client 29 | │ ├── momentum.py # Momentum calculations 30 | │ ├── risk.py # ATR and stop-loss logic 31 | │ ├── ranking.py # Ticker ranking 32 | │ ├── executor.py # Trade execution 33 | │ └── state.py # Position state management 34 | ├── requirements.txt # Python dependencies 35 | ├── .env.example # Environment variables template 36 | └── .github/workflows/ 37 | └── trader.yml # GitHub Actions workflow 38 | 39 | ``` 40 | 41 | ## Google Sheets Setup 42 | 43 | Create a Google Sheet with the following tabs: 44 | 45 | ### 1. Config Tab 46 | Configuration settings for the trading system: 47 | 48 | | Setting | Value | 49 | |---------|-------| 50 | | PortfolioAllocation | 0.5 | 51 | 52 | - **PortfolioAllocation**: Percentage of portfolio to allocate per trade (0.5 = 50%) 53 | 54 | ### 2. Universe Tab 55 | List of ticker symbols to analyze (one per row): 56 | 57 | | Ticker | 58 | |--------| 59 | | AAPL | 60 | | MSFT | 61 | | GOOGL | 62 | | AMZN | 63 | | ... | 64 | 65 | ### 3. TradeSuggestions Tab 66 | Auto-populated with trade suggestions. To approve a trade, change "Approved" from "NO" to "YES": 67 | 68 | | Ticker | Action | Quantity | Price | Reason | Approved | Timestamp | 69 | |--------|--------|----------|-------|--------|----------|-----------| 70 | | AAPL | BUY | 10 | 150.0 | Rank #1| YES | 2024-... | 71 | 72 | ### 4. State Tab 73 | Auto-populated with position tracking data: 74 | 75 | | Ticker | Position | EntryPrice | Peak | ATRStop | LastUpdated | 76 | |--------|----------|------------|------|---------|-------------| 77 | | AAPL | 10 | 150.0 | 155.0| 145.0 | 2024-... | 78 | 79 | ## Setup Instructions 80 | 81 | ### 1. Alpaca Account Setup 82 | 83 | 1. Sign up for an Alpaca account at [alpaca.markets](https://alpaca.markets) 84 | 2. Generate API keys (use paper trading for testing) 85 | 3. Note your API Key and Secret Key 86 | 87 | ### 2. Google Cloud Setup 88 | 89 | 1. Go to [Google Cloud Console](https://console.cloud.google.com) 90 | 2. Create a new project 91 | 3. Enable Google Sheets API 92 | 4. Create a Service Account: 93 | - Go to "IAM & Admin" → "Service Accounts" 94 | - Create a new service account 95 | - Generate a JSON key file 96 | 5. Share your Google Sheet with the service account email 97 | 98 | ### 3. Local Installation 99 | 100 | ```bash 101 | # Clone the repository 102 | git clone https://github.com/ProLoser/AI-Trader.git 103 | cd AI-Trader 104 | 105 | # Install dependencies 106 | pip install -r requirements.txt 107 | 108 | # Configure environment 109 | cp .env.example .env 110 | # Edit .env with your credentials 111 | 112 | # Place Google service account JSON as credentials.json 113 | cp /path/to/your-service-account.json credentials.json 114 | 115 | # Run the trader 116 | python main.py 117 | ``` 118 | 119 | ### 4. GitHub Actions Setup 120 | 121 | Configure the following secrets in your GitHub repository (Settings → Secrets and variables → Actions): 122 | 123 | - `ALPACA_API_KEY` - Your Alpaca API key 124 | - `ALPACA_SECRET_KEY` - Your Alpaca secret key 125 | - `ALPACA_BASE_URL` - Alpaca API URL (paper or live) 126 | - `GOOGLE_SHEET_ID` - Your Google Sheet ID (from the URL) 127 | - `GOOGLE_CREDENTIALS_JSON` - Contents of your service account JSON file 128 | 129 | **Note**: Portfolio allocation is now configured in the Google Sheets 'Config' tab, not as a GitHub secret. 130 | 131 | The workflow runs automatically on weekdays at 9:00 AM EST. You can also trigger it manually from the Actions tab. 132 | 133 | ## Configuration 134 | 135 | ### Environment Variables 136 | 137 | - `ALPACA_API_KEY` - Alpaca API key 138 | - `ALPACA_SECRET_KEY` - Alpaca secret key 139 | - `ALPACA_BASE_URL` - API endpoint (paper: `https://paper-api.alpaca.markets`) 140 | - `GOOGLE_SHEET_ID` - Google Sheet ID from the URL 141 | 142 | ### Google Sheets Config Tab 143 | 144 | The 'Config' tab in Google Sheets contains trading parameters: 145 | 146 | - `PortfolioAllocation` - Percentage of portfolio per trade (default: 0.5 for 50%) 147 | 148 | ### Trading Parameters 149 | 150 | Modify in the code as needed: 151 | - **Momentum periods**: 3 months and 6 months (in `modules/momentum.py`) 152 | - **ATR period**: 14 days (in `modules/risk.py`) 153 | - **ATR multiplier**: 2.0x for stops (in `modules/risk.py`) 154 | - **Max positions**: 10 (in `modules/ranking.py`) 155 | - **Rank threshold**: 20 (in `modules/ranking.py`) 156 | 157 | ## Workflow 158 | 159 | 1. **Read Universe**: Fetches ticker list from Google Sheets 160 | 2. **Calculate Momentum**: Computes 3-month and 6-month momentum 161 | 3. **Rank Tickers**: Sorts by combined momentum score 162 | 4. **Generate Suggestions**: 163 | - BUY top-ranked tickers not in portfolio 164 | - SELL poorly-ranked or stopped-out positions 165 | 5. **Write to Sheets**: Updates TradeSuggestions tab 166 | 6. **Execute Trades**: Processes approved suggestions 167 | 7. **Update State**: Tracks positions, peaks, and stops 168 | 169 | ## Safety Features 170 | 171 | - **Paper Trading**: Test with Alpaca paper account 172 | - **Manual Approval**: Trades require "YES" in Approved column 173 | - **Stop Losses**: ATR-based trailing stops 174 | - **Position Limits**: Maximum 10 positions 175 | - **Allocation Control**: 50% allocation per suggestion 176 | 177 | ## Monitoring 178 | 179 | Check the following: 180 | - **GitHub Actions logs**: View execution history and errors 181 | - **Google Sheets**: Review suggestions and state 182 | - **Alpaca Dashboard**: Monitor account and positions 183 | 184 | ## Troubleshooting 185 | 186 | ### No data for tickers 187 | - Verify ticker symbols are valid 188 | - Check Alpaca API access 189 | - Ensure market is open (for real-time data) 190 | 191 | ### Google Sheets errors 192 | - Verify service account has edit access 193 | - Check Sheet ID is correct 194 | - Ensure credentials.json is valid 195 | 196 | ### Trade execution failures 197 | - Check Alpaca account status 198 | - Verify sufficient buying power 199 | - Review order logs in Alpaca dashboard 200 | 201 | ## License 202 | 203 | MIT License - See LICENSE file for details 204 | 205 | ## Disclaimer 206 | 207 | This software is for educational purposes only. Use at your own risk. Past performance does not guarantee future results. Always test with paper trading before using real money. 208 | 209 | ## Contributing 210 | 211 | Contributions are welcome! Please open an issue or submit a pull request. 212 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | AI Trader - Main Entry Point 4 | 5 | This script orchestrates the trading workflow: 6 | 1. Reads ticker universe from Google Sheets 7 | 2. Fetches price data from Alpaca 8 | 3. Calculates momentum (3m/6m) and ranks tickers 9 | 4. Computes ATR-based stops 10 | 5. Generates BUY/SELL suggestions (50% allocation) 11 | 6. Writes suggestions to Google Sheets 12 | 7. Executes approved trades 13 | 8. Updates position state and tracks peaks 14 | """ 15 | 16 | import os 17 | import sys 18 | from datetime import datetime 19 | from dotenv import load_dotenv 20 | import pandas as pd 21 | 22 | from modules.sheets import SheetsManager 23 | from modules.alpaca_client import AlpacaClient 24 | from modules.momentum import get_momentum_for_universe 25 | from modules.risk import get_atr_for_ticker, update_trailing_stop 26 | from modules.ranking import rank_tickers, identify_buy_candidates, identify_sell_candidates 27 | from modules.executor import execute_approved_trades, calculate_position_size 28 | from modules.state import ( 29 | initialize_state_for_positions, 30 | update_position_peaks, 31 | merge_state, 32 | check_stop_losses 33 | ) 34 | 35 | 36 | def load_configuration(): 37 | """Load configuration from environment variables.""" 38 | load_dotenv() 39 | 40 | config = { 41 | 'alpaca_api_key': os.getenv('ALPACA_API_KEY'), 42 | 'alpaca_secret_key': os.getenv('ALPACA_SECRET_KEY'), 43 | 'alpaca_base_url': os.getenv('ALPACA_BASE_URL', 'https://paper-api.alpaca.markets'), 44 | 'google_sheet_id': os.getenv('GOOGLE_SHEET_ID') 45 | } 46 | 47 | # Validate required configuration 48 | required_keys = ['alpaca_api_key', 'alpaca_secret_key', 'google_sheet_id'] 49 | missing = [key for key in required_keys if not config.get(key)] 50 | 51 | if missing: 52 | print(f"ERROR: Missing required configuration: {', '.join(missing)}") 53 | print("Please check your .env file or environment variables.") 54 | sys.exit(1) 55 | 56 | return config 57 | 58 | 59 | def generate_trade_suggestions(ranked_df, current_positions, account_value, allocation_pct, alpaca_client): 60 | """ 61 | Generate trade suggestions based on rankings and current positions. 62 | 63 | Args: 64 | ranked_df: DataFrame with ranked tickers 65 | current_positions: List of currently held ticker symbols 66 | account_value: Total account value 67 | allocation_pct: Allocation percentage per trade 68 | alpaca_client: AlpacaClient instance 69 | 70 | Returns: 71 | pd.DataFrame: Trade suggestions 72 | """ 73 | suggestions = [] 74 | 75 | # Identify buy candidates (tickers not currently held) 76 | buy_candidates = identify_buy_candidates(ranked_df, current_positions, max_positions=10) 77 | 78 | for _, row in buy_candidates.iterrows(): 79 | ticker = row['Ticker'] 80 | price = row['CurrentPrice'] 81 | 82 | # Calculate position size 83 | quantity = calculate_position_size(account_value, allocation_pct, price) 84 | 85 | if quantity > 0: 86 | # Get ATR data for stop loss 87 | atr_data = get_atr_for_ticker(alpaca_client, ticker) 88 | stop_loss = atr_data.get('stop_loss', 'N/A') 89 | 90 | # Format stop loss for display 91 | if isinstance(stop_loss, (int, float)): 92 | stop_loss_str = f"{stop_loss:.2f}" 93 | else: 94 | stop_loss_str = str(stop_loss) 95 | 96 | suggestions.append({ 97 | 'Ticker': ticker, 98 | 'Action': 'BUY', 99 | 'Quantity': quantity, 100 | 'Price': f"{price:.2f}", 101 | 'Reason': f"Rank #{row['Rank']}, Momentum: {row['Momentum_Combined']:.2f}%, Stop: {stop_loss_str}", 102 | 'Approved': 'NO', 103 | 'Timestamp': datetime.now().isoformat() 104 | }) 105 | 106 | # Identify sell candidates (poor ranking or stop loss) 107 | sell_candidates = identify_sell_candidates(current_positions, ranked_df, rank_threshold=20) 108 | 109 | for ticker in sell_candidates: 110 | # Get position info 111 | position = alpaca_client.get_position(ticker) 112 | 113 | if position: 114 | quantity = abs(int(position.qty)) 115 | price = float(position.current_price) 116 | 117 | # Find reason for sell 118 | ticker_rank = ranked_df[ranked_df['Ticker'] == ticker] 119 | if ticker_rank.empty: 120 | reason = "Ticker removed from universe" 121 | else: 122 | rank = ticker_rank['Rank'].iloc[0] 123 | reason = f"Poor ranking (#{rank})" 124 | 125 | suggestions.append({ 126 | 'Ticker': ticker, 127 | 'Action': 'SELL', 128 | 'Quantity': quantity, 129 | 'Price': f"{price:.2f}", 130 | 'Reason': reason, 131 | 'Approved': 'NO', 132 | 'Timestamp': datetime.now().isoformat() 133 | }) 134 | 135 | return pd.DataFrame(suggestions) 136 | 137 | 138 | def update_state_with_stops(state_df, alpaca_client): 139 | """ 140 | Update state with ATR-based trailing stops. 141 | 142 | Args: 143 | state_df: Current state DataFrame 144 | alpaca_client: AlpacaClient instance 145 | 146 | Returns: 147 | pd.DataFrame: Updated state with stops 148 | """ 149 | if state_df.empty: 150 | return state_df 151 | 152 | updated_state = state_df.copy() 153 | 154 | for idx, row in updated_state.iterrows(): 155 | ticker = row['Ticker'] 156 | peak = float(row.get('Peak', 0)) 157 | 158 | # Get ATR data 159 | atr_data = get_atr_for_ticker(alpaca_client, ticker) 160 | atr = atr_data.get('atr') 161 | 162 | if atr and peak: 163 | # Calculate trailing stop from peak 164 | stop = update_trailing_stop( 165 | current_price=atr_data.get('current_price', peak), 166 | peak_price=peak, 167 | atr=atr, 168 | multiplier=2.0 169 | ) 170 | if stop: 171 | updated_state.at[idx, 'ATRStop'] = stop 172 | 173 | return updated_state 174 | 175 | 176 | def main(): 177 | """Main execution function.""" 178 | print("=" * 60) 179 | print("AI Trader - Momentum Trading Assistant") 180 | print("=" * 60) 181 | print(f"Execution time: {datetime.now().isoformat()}\n") 182 | 183 | # Load configuration 184 | print("Loading configuration...") 185 | config = load_configuration() 186 | 187 | # Initialize clients 188 | print("Initializing Alpaca client...") 189 | alpaca_client = AlpacaClient( 190 | api_key=config['alpaca_api_key'], 191 | secret_key=config['alpaca_secret_key'], 192 | base_url=config['alpaca_base_url'] 193 | ) 194 | 195 | print("Initializing Google Sheets client...") 196 | sheets_manager = SheetsManager(sheet_id=config['google_sheet_id']) 197 | 198 | # Read configuration from Google Sheets 199 | print("Reading configuration from Google Sheets...") 200 | sheet_config = sheets_manager.get_config() 201 | 202 | # Get portfolio allocation with validation 203 | try: 204 | portfolio_allocation = float(sheet_config.get('PortfolioAllocation', 0.5)) 205 | # Validate range 206 | if portfolio_allocation <= 0 or portfolio_allocation > 1: 207 | print(f"Warning: Invalid PortfolioAllocation value ({portfolio_allocation}). Using default 0.5") 208 | portfolio_allocation = 0.5 209 | except (ValueError, TypeError): 210 | print(f"Warning: Non-numeric PortfolioAllocation in Config sheet. Using default 0.5") 211 | portfolio_allocation = 0.5 212 | 213 | print(f"Portfolio Allocation: {portfolio_allocation * 100:.0f}%") 214 | 215 | # Get account info 216 | print("\nFetching account information...") 217 | account = alpaca_client.get_account() 218 | account_value = float(account.equity) 219 | print(f"Account Value: ${account_value:,.2f}") 220 | 221 | # Get current positions 222 | print("\nFetching current positions...") 223 | positions = alpaca_client.get_positions() 224 | current_positions = [pos.symbol for pos in positions] 225 | print(f"Current positions ({len(current_positions)}): {', '.join(current_positions) if current_positions else 'None'}") 226 | 227 | # Read universe from Google Sheets 228 | print("\nReading ticker universe from Google Sheets...") 229 | universe = sheets_manager.get_universe() 230 | print(f"Universe size: {len(universe)} tickers") 231 | 232 | if not universe: 233 | print("ERROR: No tickers in universe. Please populate the 'Universe' sheet.") 234 | sys.exit(1) 235 | 236 | # Calculate momentum for all tickers 237 | print("\nCalculating momentum for all tickers...") 238 | momentum_df = get_momentum_for_universe(alpaca_client, universe) 239 | print(f"Momentum calculated for {len(momentum_df)} tickers") 240 | 241 | if momentum_df.empty: 242 | print("ERROR: No momentum data available. Check ticker symbols and market data access.") 243 | sys.exit(1) 244 | 245 | # Rank tickers 246 | print("\nRanking tickers by momentum...") 247 | ranked_df = rank_tickers(momentum_df, top_n=20) 248 | print(f"Top 5 tickers:") 249 | for _, row in ranked_df.head(5).iterrows(): 250 | print(f" #{row['Rank']}: {row['Ticker']} - Momentum: {row['Momentum_Combined']:.2f}%") 251 | 252 | # Generate trade suggestions 253 | print("\nGenerating trade suggestions...") 254 | suggestions_df = generate_trade_suggestions( 255 | ranked_df=ranked_df, 256 | current_positions=current_positions, 257 | account_value=account_value, 258 | allocation_pct=portfolio_allocation, 259 | alpaca_client=alpaca_client 260 | ) 261 | 262 | print(f"Generated {len(suggestions_df)} trade suggestions") 263 | 264 | # Write suggestions to Google Sheets 265 | if not suggestions_df.empty: 266 | print("\nWriting trade suggestions to Google Sheets...") 267 | sheets_manager.write_trade_suggestions(suggestions_df) 268 | print("Trade suggestions written to 'TradeSuggestions' tab") 269 | 270 | # Display suggestions 271 | print("\nTrade Suggestions:") 272 | for _, row in suggestions_df.iterrows(): 273 | print(f" {row['Action']:4s} {row['Quantity']:4d} {row['Ticker']:6s} @ ${row['Price']:8s} - {row['Reason']}") 274 | else: 275 | print("No trade suggestions generated.") 276 | 277 | # Execute approved trades 278 | print("\nChecking for approved trades...") 279 | executed_trades = execute_approved_trades(sheets_manager, alpaca_client) 280 | 281 | if executed_trades: 282 | print(f"Executed {len(executed_trades)} trades:") 283 | for trade in executed_trades: 284 | print(f" {trade['Action'].upper()} {trade['Quantity']} {trade['Ticker']} - {trade['Status']}") 285 | else: 286 | print("No approved trades to execute.") 287 | 288 | # Update state 289 | print("\nUpdating position state...") 290 | 291 | # Initialize state from current positions 292 | current_state = initialize_state_for_positions(alpaca_client) 293 | 294 | # Load existing state from Sheets 295 | existing_state = sheets_manager.get_state() 296 | 297 | # Merge states 298 | merged_state = merge_state(existing_state, current_state) 299 | 300 | # Update peaks 301 | updated_state = update_position_peaks(merged_state, alpaca_client) 302 | 303 | # Update stops 304 | state_with_stops = update_state_with_stops(updated_state, alpaca_client) 305 | 306 | # Check for stop losses 307 | stopped_out = check_stop_losses(state_with_stops, alpaca_client) 308 | if stopped_out: 309 | print(f"WARNING: {len(stopped_out)} positions hit stop loss: {', '.join(stopped_out)}") 310 | 311 | # Write updated state to Sheets 312 | if not state_with_stops.empty: 313 | sheets_manager.update_state(state_with_stops) 314 | print("Position state updated in 'State' tab") 315 | 316 | print("\nCurrent State:") 317 | for _, row in state_with_stops.iterrows(): 318 | print(f" {row['Ticker']:6s}: {row['Position']:4.0f} shares, Entry: ${row['EntryPrice']:7.2f}, Peak: ${row['Peak']:7.2f}, Stop: ${row['ATRStop']:7.2f}") 319 | else: 320 | print("No positions to track.") 321 | 322 | print("\n" + "=" * 60) 323 | print("Execution completed successfully!") 324 | print("=" * 60) 325 | 326 | 327 | if __name__ == "__main__": 328 | try: 329 | main() 330 | except KeyboardInterrupt: 331 | print("\n\nExecution interrupted by user.") 332 | sys.exit(0) 333 | except Exception as e: 334 | print(f"\n\nERROR: {e}") 335 | import traceback 336 | traceback.print_exc() 337 | sys.exit(1) 338 | --------------------------------------------------------------------------------