#!/usr/bin/env python3 # Description: Day-Trading Script (Alpaca API) # Usage: python3 daytrader.py # Author: Justin Oros # Source: https://github.com/JustinOros import os import sys import time import logging import json from pathlib import Path from dotenv import load_dotenv import alpaca_trade_api as tradeapi # ----------------------------------------------------------------------------- # Configuration # ----------------------------------------------------------------------------- # Path configuration SCRIPT_DIR = Path(__file__).parent CONFIG_PATH = SCRIPT_DIR / "daytrader.json" ENV_PATH = SCRIPT_DIR / ".env" # Default configuration DEFAULT_CONFIG = { "SYMBOL": "SPY", "RISK_FRACTION": 0.02, "SHORT_WINDOW": 5, "LONG_WINDOW": 20, "MIN_NOTIONAL": 1.0, "POLL_INTERVAL": 30, "MAX_DRAWDOWN": 0.05, "PDT_RULE": True } # Load environment variables if ENV_PATH.exists(): load_dotenv(ENV_PATH) else: # Create placeholder .env file with open(ENV_PATH, "w") as f: f.write('APCA_API_KEY_ID="YOUR_API_KEY_HERE"\n') f.write('APCA_API_SECRET_KEY="YOUR_SECRET_KEY_HERE"\n') f.write('APCA_API_BASE_URL="https://paper-api.alpaca.markets"\n') print("âš ī¸ Created placeholder .env file.") print(" Please add your Alpaca API keys to .env file") sys.exit(1) # Load configuration if CONFIG_PATH.exists(): with open(CONFIG_PATH, "r") as f: config = json.load(f) else: # Create default config with open(CONFIG_PATH, "w") as f: json.dump(DEFAULT_CONFIG, f, indent=4) config = DEFAULT_CONFIG.copy() print(f"✅ Created default config file at {CONFIG_PATH}") # Extract configuration values SYMBOL = config["SYMBOL"] RISK_FRACTION = float(config["RISK_FRACTION"]) SHORT_WINDOW = int(config["SHORT_WINDOW"]) LONG_WINDOW = int(config["LONG_WINDOW"]) MIN_NOTIONAL = float(config["MIN_NOTIONAL"]) POLL_INTERVAL = int(config["POLL_INTERVAL"]) MAX_DRAWDOWN = float(config["MAX_DRAWDOWN"]) PDT_RULE = bool(config["PDT_RULE"]) # Initialize Alpaca API api = tradeapi.REST( os.getenv('APCA_API_KEY_ID'), os.getenv('APCA_API_SECRET_KEY'), os.getenv('APCA_API_BASE_URL'), api_version='v2' ) # ----------------------------------------------------------------------------- # Logging Configuration # ----------------------------------------------------------------------------- # Set up logging to daytrader.log in script directory LOG_PATH = SCRIPT_DIR / "daytrader.log" logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s', handlers=[ logging.FileHandler(LOG_PATH, mode='a'), logging.StreamHandler(sys.stdout) ] ) logger = logging.getLogger(__name__) # ----------------------------------------------------------------------------- # Helper Functions # ----------------------------------------------------------------------------- def seconds_to_human_readable(seconds): """Convert seconds to human-readable format (hours, minutes, seconds).""" if seconds < 0: return "0 seconds" hours = int(seconds // 3600) minutes = int((seconds % 3600) // 60) secs = int(seconds % 60) time_parts = [] if hours > 0: time_parts.append(f"{hours} hour{'s' if hours != 1 else ''}") if minutes > 0: time_parts.append(f"{minutes} minute{'s' if minutes != 1 else ''}") if secs > 0 and hours == 0: # Only show seconds if less than an hour time_parts.append(f"{secs} second{'s' if secs != 1 else ''}") return " ".join(time_parts) if time_parts else "0 seconds" def format_market_time(dt_obj): """Format datetime object to readable string.""" return dt_obj.strftime("%Y-%m-%d %I:%M:%S %p %Z") # ----------------------------------------------------------------------------- # Trading Functions # ----------------------------------------------------------------------------- def wait_until_market_open(): """Wait until the market opens.""" clock = api.get_clock() now = clock.timestamp next_open = clock.next_open if not clock.is_open: seconds_until_open = (next_open - now).total_seconds() if seconds_until_open > 0: readable_time = seconds_to_human_readable(seconds_until_open) logger.info(f"🕒 Market opens at {format_market_time(next_open)}") logger.info(f"âąī¸ Waiting {readable_time}...") # Sleep in smaller chunks to allow for graceful interruption while seconds_until_open > 0: sleep_time = min(60, seconds_until_open) # Check every minute max time.sleep(sleep_time) seconds_until_open -= sleep_time # Update remaining time display periodically if sleep_time >= 60: remaining_readable = seconds_to_human_readable(seconds_until_open) logger.info(f"âąī¸ {remaining_readable} remaining...") else: logger.info("✅ Market is open!") else: logger.info("✅ Market is open!") def fetch_equity(): """Fetch the current account equity.""" try: account = api.get_account() return float(account.equity) except Exception as e: logger.error(f"❌ Failed to fetch equity: {e}") return 0.0 def fetch_buying_power(): """Fetch the current buying power.""" try: account = api.get_account() return float(account.buying_power) except Exception as e: logger.error(f"❌ Failed to fetch buying power: {e}") return 0.0 def get_day_trade_count(): """Get the current day trade count.""" try: account = api.get_account() return int(account.day_trade_count) except Exception as e: logger.error(f"❌ Failed to fetch day trade count: {e}") return 0 def submit_buy(symbol, notional): """Submit a buy order.""" if notional < MIN_NOTIONAL: logger.warning(f"âš ī¸ Notional ${notional:.2f} < minimum ${MIN_NOTIONAL} - skipping.") return False try: api.submit_order( symbol=symbol, notional=round(notional, 2), side="buy", type="market", time_in_force="day" ) logger.info(f"đŸŸĸ BUY ${notional:.2f} of {symbol}") return True except Exception as e: logger.error(f"❌ Failed to buy {symbol}: {e}") return False def submit_sell(symbol, qty): """Submit a sell order.""" try: api.submit_order( symbol=symbol, qty=qty, side="sell", type="market", time_in_force="day" ) logger.info(f"🔴 SELL {qty} shares of {symbol}") return True except Exception as e: logger.error(f"❌ Failed to sell {symbol}: {e}") return False def close_all_positions(): """Close all open positions.""" try: positions = api.list_positions() if not positions: logger.info("✅ No open positions to close.") return logger.warning("âš ī¸ Closing all open positions...") for pos in positions: submit_sell(pos.symbol, int(float(pos.qty))) logger.info("✅ All positions closed.") except Exception as e: logger.error(f"❌ Failed to close positions: {e}") def get_recent_bars(symbol, limit=20): """Get recent bar data for a symbol.""" try: bars = api.get_bars( symbol, "minute", limit=limit ).df return bars except Exception as e: logger.error(f"❌ Failed to fetch bars for {symbol}: {e}") return None def ma_cross_signal(symbol): """Generate a moving average crossover signal.""" bars = get_recent_bars(symbol, LONG_WINDOW + 5) if bars is None or len(bars) < LONG_WINDOW: return None closes = bars['close'] short_ma = closes.rolling(window=SHORT_WINDOW).mean().iloc[-1] long_ma = closes.rolling(window=LONG_WINDOW).mean().iloc[-1] if short_ma > long_ma: return "buy" elif short_ma < long_ma: return "sell" else: return None def current_position_qty(symbol): """Get the current position quantity for a symbol.""" try: positions = api.list_positions() for pos in positions: if pos.symbol == symbol: return int(float(pos.qty)) return 0 except Exception as e: logger.error(f"❌ Failed to fetch positions: {e}") return 0 def pdt_allows_new_trade(): """Check if PDT rules allow a new trade.""" if not PDT_RULE: return True equity = fetch_equity() day_trade_count = get_day_trade_count() # PDT rule: If equity < $25,000, max 3 day trades per 5 rolling days if equity < 25000: if day_trade_count >= 3: logger.error(f"🛑 PDT rule triggered: {day_trade_count} day-trades in rolling 5-day window") return False return True def get_market_status(): """Get current market status and next open/close times.""" clock = api.get_clock() status = "open" if clock.is_open else "closed" next_event = clock.next_open if not clock.is_open else clock.next_close event_type = "open" if not clock.is_open else "close" return { "status": status, "next_event": next_event, "event_type": event_type, "timestamp": clock.timestamp } # ----------------------------------------------------------------------------- # Main Trading Loop # ----------------------------------------------------------------------------- def main(): """Main trading function.""" logger.info("đŸŽ¯ Starting daytrader.py...") # Display current market status market_info = get_market_status() logger.info(f"đŸ›ī¸ Market is currently {market_info['status'].upper()}") if market_info['status'] == 'closed': logger.info(f"📅 Next market {market_info['event_type']}: {format_market_time(market_info['next_event'])}") # Wait for market to open wait_until_market_open() # Record opening equity opening_equity = fetch_equity() if opening_equity == 0: logger.error("đŸ’Ĩ No equity available. Exiting...") return logger.info(f"💰 Opening equity: ${opening_equity:.2f}") # Compute per-trade notional per_trade_notional = max(MIN_NOTIONAL, opening_equity * RISK_FRACTION) logger.info(f"đŸŽ¯ Per-trade notional: ${per_trade_notional:.2f}") # Display trading parameters logger.info(f"âš™ī¸ Trading configuration:") logger.info(f" Symbol: {SYMBOL}") logger.info(f" Risk per trade: {RISK_FRACTION:.1%}") logger.info(f" Max drawdown: {MAX_DRAWDOWN:.1%}") logger.info(f" MA Windows: {SHORT_WINDOW}/{LONG_WINDOW} minutes") logger.info(f" PDT Rule enforced: {PDT_RULE}") # Main trading loop trade_count = 0 try: while True: # Check if market is open clock = api.get_clock() if not clock.is_open: logger.info("❌ Market is closed. Exiting...") break # Check equity drop current_equity = fetch_equity() drawdown = (opening_equity - current_equity) / opening_equity if drawdown > MAX_DRAWDOWN: logger.error(f"💸 Maximum drawdown exceeded: {drawdown:.2%}. Stopping...") break # Check PDT rule if not pdt_allows_new_trade(): logger.error("🛑 PDT rule violation. Stopping...") break # Generate trading signal signal = ma_cross_signal(SYMBOL) if signal == "buy": buying_power = fetch_buying_power() if buying_power >= per_trade_notional: if submit_buy(SYMBOL, per_trade_notional): trade_count += 1 logger.info(f"✅ Buy order executed for {SYMBOL} (Trade #{trade_count})") else: logger.warning(f"âš ī¸ Insufficient buying power: ${buying_power:.2f}") elif signal == "sell": qty = current_position_qty(SYMBOL) if qty > 0: if submit_sell(SYMBOL, qty): trade_count += 1 logger.info(f"✅ Sell order executed for {SYMBOL} (Trade #{trade_count})") else: logger.info("â„šī¸ No position to sell") # Display current status current_time = clock.timestamp.strftime("%I:%M:%S %p") logger.info(f"âąī¸ {current_time} - Waiting {POLL_INTERVAL} seconds for next check...") time.sleep(POLL_INTERVAL) except KeyboardInterrupt: logger.info("🛑 Script interrupted by user") except Exception as e: logger.error(f"đŸ’Ĩ Unexpected error: {e}") finally: logger.info("🔚 Script ending. Closing any remaining positions...") close_all_positions() logger.info(f"📊 Session summary: {trade_count} trades executed") logger.info("✅ daytrader.py finished.") if __name__ == "__main__": main()