import os import time import logging import pytz import threading import csv from datetime import datetime, time as dtime from dotenv import load_dotenv from src.api.client import Trading212Client from src.strategy.touch_turn import TouchTurnStrategy from src.execution.manager import ExecutionManager from scripts.find_isa_candidates import find_best_isa_tickers from scripts.backtest import backtest_ticker # Ensure logs directory exists os.makedirs("logs", exist_ok=True) log_filename = datetime.now().strftime("logs/bot_%Y-%m-%d.log") # Configure logging to both console and file logging.basicConfig( level=logging.INFO, format='%(asctime)s [%(threadName)s] %(levelname)s - %(message)s', handlers=[ logging.FileHandler(log_filename), logging.StreamHandler() ] ) logger = logging.getLogger(__name__) PNL_FILE = "pnl_tracking.csv" def record_pnl(ticker, direction, entry_price, exit_price, reason, pnl_r): """Appends the result of a closed trade to the PnL CSV.""" file_exists = os.path.isfile(PNL_FILE) with open(PNL_FILE, mode='a', newline='') as file: writer = csv.writer(file) if not file_exists: writer.writerow(["Date", "Ticker", "Direction", "Entry Price", "Exit Price", "Reason", "PnL (R)"]) today = datetime.now().strftime("%Y-%m-%d %H:%M:%S") writer.writerow([today, ticker, direction, round(entry_price, 2), round(exit_price, 2), reason, round(pnl_r, 2)]) logger.info(f"Recorded trade in {PNL_FILE}: {ticker} {direction} | Result: {reason} | PnL: {pnl_r:.2f} R") def calculate_r_multiple(direction, entry_price, exit_price, stop_loss): """Calculates the PnL in terms of Risk Multiples (R).""" if direction == "BUY": # LONG risk = entry_price - stop_loss return (exit_price - entry_price) / risk if risk != 0 else 0 else: # SHORT risk = stop_loss - entry_price return (entry_price - exit_price) / risk if risk != 0 else 0 def run_ticker_lifecycle(client, yf_ticker, t212_ticker, tz): """Handles the full strategy lifecycle for a single ticker in its own thread, then exits.""" strategy = TouchTurnStrategy(yf_ticker) execution = ExecutionManager(client) logger.info(f"Bot thread started for {yf_ticker} ({t212_ticker}).") now = datetime.now(tz) target_entry_time = now.replace(hour=9, minute=45, second=0, microsecond=0) # 1. Wait until 09:45 EST if now < target_entry_time: wait_seconds = (target_entry_time - now).total_seconds() logger.info(f"Waiting {wait_seconds:.0f} seconds until 09:45 EST evaluation...") time.sleep(wait_seconds) # Re-evaluate current time now = datetime.now(tz) if now.hour == 9 and now.minute >= 45: logger.info(f"Evaluating opening candle for {yf_ticker}...") # Retry loop: wait for yfinance to publish the 09:30-09:45 candle setup_found = False max_retries = 12 for attempt in range(max_retries): if strategy.check_setup(): setup_found = True break elif attempt < max_retries - 1: logger.debug(f"Data not ready for {yf_ticker} yet, waiting 15s...") time.sleep(15) if setup_found: params = strategy.get_trade_params() params['ticker'] = t212_ticker # Anti-thundering-herd: Random jitter to prevent 429s from parallel threads import random time.sleep(random.uniform(0.1, 3.0)) # Fetch Account Balance to calculate risk with backoff risk_amount = 2.50 # Fallback for attempt in range(3): try: account_info = client.get_account_info() virtual_balance = float(os.getenv("VIRTUAL_STARTING_BALANCE", 0)) if virtual_balance > 0: risk_amount = virtual_balance * 0.01 else: available_cash = account_info.get('cash', {}).get('availableToTrade', 1000) risk_amount = available_cash * 0.01 break # Success except Exception as e: if '429' in str(e): logger.warning(f"Rate limited on account fetch for {yf_ticker}. Retrying in {2**(attempt+1)}s...") time.sleep(2**(attempt+1)) else: logger.error(f"Failed to fetch account info: {e}. Defaulting to £2.50 risk.") break if execution.execute_trade(params, target_risk_amount=risk_amount): # monitor_and_bracket is blocking, wait for fill (times out at 11:00) if execution.monitor_and_bracket(params): # Position is open, monitor for exit via SL/TP while datetime.now(tz).hour < 11: is_closed, reason, exit_price = execution.check_exit_status() if is_closed: pnl_r = calculate_r_multiple(params['direction'], params['entry_price'], exit_price, params['stop_loss']) record_pnl(yf_ticker, params['direction'], params['entry_price'], exit_price, reason, pnl_r) break # Exit the monitoring loop time.sleep(15) else: logger.info(f"No valid setup today for {yf_ticker}. Thread exiting.") return # 2. Wait until 11:00 EST for Forced Exit (if we are still in a position or have pending orders) now = datetime.now(tz) target_exit_time = now.replace(hour=11, minute=0, second=0, microsecond=0) if now < target_exit_time and execution.is_in_position: wait_seconds = (target_exit_time - now).total_seconds() logger.info(f"Waiting {wait_seconds:.0f} seconds until 11:00 EST forced exit...") time.sleep(wait_seconds) # 3. 11:00 EST - Cleanup logger.info(f"Time exit reached for {yf_ticker}. Cleaning up.") if execution.is_in_position: exit_price = execution.close_all(t212_ticker) if hasattr(execution, 'params') and exit_price > 0: pnl_r = calculate_r_multiple(execution.params['direction'], execution.params['entry_price'], exit_price, execution.params['stop_loss']) record_pnl(yf_ticker, execution.params['direction'], execution.params['entry_price'], exit_price, "11:00 Time Exit", pnl_r) else: # Cleanup any pending orders if entry wasn't filled execution.close_all(t212_ticker) logger.info(f"Lifecycle complete for {yf_ticker}. Thread exiting.") def main(): load_dotenv() api_key_id = os.getenv("TRADING212_API_KEY_ID") api_key = os.getenv("TRADING212_API_KEY") base_url = os.getenv("TRADING212_BASE_URL", "https://demo.trading212.com/api/v0/") tz = pytz.timezone('US/Eastern') now = datetime.now(tz) # Safety Guard: Check if it's a weekend if now.weekday() >= 5: # 5 = Saturday, 6 = Sunday logger.warning("Weekend detected. The market is closed. Exiting cleanly.") return # Safety Guard: Check if executed outside the expected morning window (allow 09:00 to 09:40 EST) if now.hour < 9 or (now.hour == 9 and now.minute > 40) or now.hour >= 10: logger.warning(f"Bot executed at {now.strftime('%H:%M')} EST. Expected launch window is 09:00 - 09:40 EST. Exiting cleanly.") return if not api_key_id or not api_key: logger.error("API credentials not found in .env") return client = Trading212Client(api_key_id, api_key, base_url) # 1. Morning Routine: Find Candidates logger.info("Starting Morning Routine: Finding ISA Candidates...") candidates_df = find_best_isa_tickers() if candidates_df is None or candidates_df.empty: logger.error("No candidates found. Exiting.") return # 2. Morning Routine: Backtest Candidates to find the 'Edge' logger.info("Running Backtests on candidates to find current winners...") profitable_tickers = [] # We'll test the top 10 candidates from the scanner for _, row in candidates_df.head(10).iterrows(): yf_t = row['Ticker'] t212_t = row['T212_Ticker'] res = backtest_ticker(yf_t, quiet=True) if res and res['Net PnL (R)'] > 0: profitable_tickers.append({ 'yf': yf_t, 't212': t212_t, 'pnl': res['Net PnL (R)'] }) # Sort by best backtest performance profitable_tickers.sort(key=lambda x: x['pnl'], reverse=True) # Select Top 3 final_watchlist = profitable_tickers[:3] if not final_watchlist: logger.warning("No tickers showed a positive backtest return. Bot will not trade today.") return logger.info(f"Final Watchlist for today: {[t['yf'] for t in final_watchlist]}") # 3. Launch execution threads threads = [] for ticker_info in final_watchlist: t = threading.Thread( target=run_ticker_lifecycle, args=(client, ticker_info['yf'], ticker_info['t212'], tz), name=f"Bot-{ticker_info['yf']}" ) t.start() threads.append(t) logger.info("All execution threads launched. Waiting for completion...") for t in threads: t.join() logger.info("All threads completed. Bot shutting down for the day.") if __name__ == "__main__": main()