Initial commit: Touch & Turn Scalping Bot with fully automated execution, backtesting, and ISA screening
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
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}...")
|
||||
if strategy.check_setup():
|
||||
params = strategy.get_trade_params()
|
||||
params['ticker'] = t212_ticker
|
||||
|
||||
# Fetch Account Balance to calculate risk
|
||||
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
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch account info for risk calculation: {e}. Defaulting to £2.50 risk.")
|
||||
risk_amount = 2.50
|
||||
|
||||
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()
|
||||
Reference in New Issue
Block a user