Files
trading-bot/main.py
T

293 lines
12 KiB
Python

import os
import sys
# Force unbuffered output for systemd/logging
os.environ['PYTHONUNBUFFERED'] = '1'
import time
import logging
import logging.handlers
import pytz
import threading
import csv
import random
import pandas as pd
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")
# Save original stdout/stderr to avoid recursion loops when redirecting
_original_stdout = sys.stdout
_original_stderr = sys.stderr
# Aggressive flush handler to ensure logs are physically written to disk
class HardFlushHandler(logging.FileHandler):
def emit(self, record):
super().emit(record)
self.flush()
if self.stream and not self.stream.closed and hasattr(self.stream, 'fileno'):
try:
os.fsync(self.stream.fileno())
except:
pass
# Configure logging
# We log to the file using our HardFlushHandler and to the ORIGINAL stdout for systemd
file_handler = HardFlushHandler(log_filename, mode='a')
stream_handler = logging.StreamHandler(_original_stdout)
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(threadName)s] %(levelname)s - %(message)s',
handlers=[file_handler, stream_handler]
)
logger = logging.getLogger(__name__)
# Redirect all print() statements to the logger
class LoggerWriter:
def __init__(self, level):
self.level = level
def write(self, message):
if message.strip():
# This logs to BOTH the file and the ORIGINAL stdout
logger.log(self.level, message.strip())
def flush(self):
for handler in logger.handlers:
handler.flush()
sys.stdout = LoggerWriter(logging.INFO)
sys.stderr = LoggerWriter(logging.ERROR)
def flush_logs():
for handler in logging.getLogger().handlers:
handler.flush()
if hasattr(handler, 'stream') and handler.stream and hasattr(handler.stream, 'fileno'):
try:
os.fsync(handler.stream.fileno())
except:
pass
PNL_FILE = "pnl_tracking.csv"
def record_pnl(ticker, direction, entry_price, exit_price, reason, pnl_r, trading_ticker=None):
"""Appends the result of a closed trade to the PnL CSV."""
file_exists = os.path.isfile(PNL_FILE)
if exit_price <= 0:
exit_price = entry_price
with open(PNL_FILE, mode='a', newline='') as file:
writer = csv.writer(file)
if not file_exists:
writer.writerow(["Date", "Ticker", "Trading Ticker", "Direction", "Entry Price", "Exit Price", "Reason", "PnL (R)"])
today = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
writer.writerow([today, ticker, trading_ticker or ticker, direction, round(entry_price, 2), round(exit_price, 2), reason, round(pnl_r, 2)])
label = f"{ticker} ({trading_ticker})" if trading_ticker else ticker
logger.info(f"Recorded trade in {PNL_FILE}: {label} {direction} | Result: {reason} | PnL: {pnl_r:.2f} R")
flush_logs()
def calculate_r_multiple(direction, entry_price, exit_price, stop_loss):
"""Calculates the PnL in terms of Risk Multiples (R)."""
if abs(entry_price - stop_loss) < 0.001:
return 0.0
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."""
strategy = TouchTurnStrategy(yf_ticker)
execution = ExecutionManager(client)
logger.info(f"Bot thread started for {yf_ticker} ({t212_ticker}).")
try:
now = datetime.now(tz)
target_entry_time = now.replace(hour=9, minute=45, second=0, microsecond=0)
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)
now = datetime.now(tz)
if now.hour == 9 and now.minute >= 45:
logger.info(f"Evaluating opening candle for {yf_ticker}...")
setup_found = False
for attempt in range(12):
if strategy.check_setup():
setup_found = True
break
elif attempt < 11:
time.sleep(15)
if setup_found:
params = strategy.get_trade_params()
params['ticker'] = t212_ticker
from src.strategy.inverse_mapping import INVERSE_TICKER_MAP
if params['direction'] == "SELL" and yf_ticker.split('_')[0] not in INVERSE_TICKER_MAP:
logger.warning(f"ISA Mode: Bypassing {yf_ticker} Short (No ETP).")
return
time.sleep(random.uniform(1.0, 10.0))
for attempt in range(3):
try:
account_info = client.get_account_info()
actual_balance = float(account_info.get('totalValue', 5000.0))
virtual_balance = max(0, actual_balance - 4750.0)
num_active = 0
for t in threading.enumerate():
if t.name.startswith("Bot-") and t.is_alive():
num_active += 1
num_active = max(1, num_active)
risk_share = (virtual_balance * 0.05) / num_active
capital_share = virtual_balance / num_active
logger.info(f"Active Trades: {num_active} | Virtual: {virtual_balance:.2f} | Share: {capital_share:.2f} | Risk: {risk_share:.2f}")
break
except Exception as e:
if '429' in str(e):
time.sleep((attempt + 1) * 5)
else:
break
if execution.execute_trade(params, target_risk_amount=risk_share, max_capital=capital_share, isa_rules=True):
if execution.monitor_and_bracket(params):
while datetime.now(tz).hour < 11:
is_closed, reason, exit_price = execution.check_exit_status()
if is_closed:
final_entry = execution.params.get('final_entry', params['entry_price'])
final_sl = execution.params.get('final_sl', params['stop_loss'])
trading_ticker = execution.params.get('trading_ticker', yf_ticker)
pnl_r = calculate_r_multiple("BUY" if execution.is_etp else params['direction'], final_entry, exit_price, final_sl)
record_pnl(yf_ticker, params['direction'], final_entry, exit_price, reason, pnl_r, trading_ticker=trading_ticker)
break
time.sleep(15)
now = datetime.now(tz)
else:
logger.info(f"No valid setup today for {yf_ticker}. Thread exiting.")
return
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)
except Exception as e:
logger.error(f"Unexpected error in {yf_ticker} lifecycle: {e}", exc_info=True)
finally:
time.sleep(random.uniform(0.1, 5.0))
logger.info(f"Cleanup phase reached for {yf_ticker}.")
if execution.is_in_position:
exit_price = execution.close_all(t212_ticker)
if hasattr(execution, 'params') and exit_price > 0:
final_entry = execution.params.get('final_entry', execution.params['entry_price'])
final_sl = execution.params.get('final_sl', execution.params['stop_loss'])
trading_ticker = execution.params.get('trading_ticker', yf_ticker)
pnl_r = calculate_r_multiple("BUY" if execution.is_etp else execution.params['direction'], final_entry, exit_price, final_sl)
record_pnl(yf_ticker, execution.params['direction'], final_entry, exit_price, "Forced Exit (Final)", pnl_r, trading_ticker=trading_ticker)
else:
execution.close_all(t212_ticker)
logger.info(f"Lifecycle complete for {yf_ticker}. Thread exiting.")
flush_logs()
def main():
load_dotenv()
logger.info("Touch & Turn Bot Initializing...")
key_id = os.getenv("TRADING212_API_KEY_ID")
key = os.getenv("TRADING212_API_KEY")
url = os.getenv("TRADING212_BASE_URL", "https://demo.trading212.com/api/v0/")
tz = pytz.timezone('US/Eastern')
if datetime.now(tz).weekday() >= 5:
logger.warning("Weekend detected. Exiting cleanly.")
return
if not key_id or not key:
logger.error("API credentials not found in .env")
return
client = Trading212Client(key_id, key, url)
try:
logger.info("Verifying API connection...")
client.get_account_info()
logger.info("API Connection verified successfully.")
except Exception as e:
logger.error(f"API Connection check failed: {e}")
return
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
logger.info("Running Backtests on top 10 candidates to find the current 'Edge'...")
all_results = []
profitable_tickers = []
for _, row in candidates_df.head(10).iterrows():
yf_t = row['Ticker']
res = backtest_ticker(yf_t, quiet=True)
if res:
all_results.append(res)
if res['Net PnL (R)'] > 0:
profitable_tickers.append({'yf': yf_t, 't212': row['T212_Ticker'], 'pnl': res['Net PnL (R)']})
if all_results:
from prettytable import PrettyTable
results_df = pd.DataFrame(all_results).sort_values(by="Net PnL (R)", ascending=False).reset_index(drop=True)
print("\n" + "="*80 + "\n🚀 MORNING BACKTEST LEADERBOARD (LAST ~60 DAYS) 🚀\n" + "="*80)
table = PrettyTable()
table.field_names = results_df.columns
for _, r in results_df.iterrows(): table.add_row(r.tolist())
print(table)
profitable_tickers.sort(key=lambda x: x['pnl'], reverse=True)
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]}")
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)
for t in threads: t.join()
logger.info("All threads completed. Bot shutting down for the day.")
flush_logs()
if __name__ == "__main__":
try:
main()
except Exception as e:
# Avoid recursion by printing to original stderr
print(f"FATAL ERROR in main: {e}", file=_original_stderr)
finally:
flush_logs()