Initial commit: Touch & Turn Scalping Bot with fully automated execution, backtesting, and ISA screening

This commit is contained in:
pie
2026-04-22 21:19:33 +01:00
commit dc111abf8c
15 changed files with 1518 additions and 0 deletions
+291
View File
@@ -0,0 +1,291 @@
import yfinance as yf
import pandas as pd
import pandas_ta as ta
from datetime import datetime, time, timedelta
import pytz
import logging
from prettytable import PrettyTable
logging.basicConfig(level=logging.WARNING) # Set to INFO for detailed trade logs
logger = logging.getLogger(__name__)
def backtest_ticker(ticker: str, days: int = 59, risk_percent_atr: float = 25.0, rr_ratio: float = 2.0, quiet: bool = False):
"""
Backtests the Touch & Turn strategy on a single ticker over the maximum available 15m history.
"""
if not quiet:
print(f"\nRunning Backtest for {ticker} over {days} days...")
tz = pytz.timezone('US/Eastern')
# Fetch Daily data (Need roughly days + 30 for 14-day ATR buffer)
daily_data = yf.download(ticker, period="6mo", interval="1d", progress=False)
if daily_data.empty:
if not quiet: print(f"Failed to fetch daily data for {ticker}")
return None
# Clean up multi-index columns if present
if isinstance(daily_data.columns, pd.MultiIndex):
daily_data.columns = daily_data.columns.droplevel(1)
daily_data.ta.atr(length=14, append=True)
daily_data.dropna(subset=['ATRr_14'], inplace=True)
# Fetch 15m intraday data
intraday_data = yf.download(ticker, period=f"{days}d", interval="15m", progress=False)
if intraday_data.empty:
if not quiet: print(f"Failed to fetch 15m data for {ticker}")
return None
if isinstance(intraday_data.columns, pd.MultiIndex):
intraday_data.columns = intraday_data.columns.droplevel(1)
# Convert index to US/Eastern timezone to reliably filter by time
if intraday_data.index.tz is None:
intraday_data.index = intraday_data.index.tz_localize('UTC').tz_convert(tz)
else:
intraday_data.index = intraday_data.index.tz_convert(tz)
unique_dates = pd.Series(intraday_data.index.date).unique()
# Metrics
total_trades = 0
wins = 0
losses = 0
time_exits = 0
total_pnl_r = 0.0 # PnL measured in R multiples
trade_log = []
for trade_date in unique_dates:
# 1. Get Yesterday's ATR
try:
# Get daily data up to the day before `trade_date`
prior_daily = daily_data[daily_data.index.date < trade_date]
if prior_daily.empty:
continue
yesterday_atr = prior_daily['ATRr_14'].iloc[-1]
except IndexError:
continue
# 2. Get today's 15m data
day_data = intraday_data[intraday_data.index.date == trade_date]
# 3. Find 09:30 candle
opening_candles = day_data.between_time('09:30', '09:30')
if opening_candles.empty:
continue
open_c = opening_candles.iloc[0]
high, low, open_p, close_p = open_c['High'], open_c['Low'], open_c['Open'], open_c['Close']
range_size = high - low
# 4. Filter for Liquidity/Volatility
if range_size < (yesterday_atr * risk_percent_atr / 100):
continue # Market not volatile enough today
# 5. Determine Setup
direction = -1 if close_p < open_p else 1 # -1 for Long, 1 for Short
if direction == -1: # LONG setup (from Bearish open)
entry_price = low
target_price = low + (range_size * 0.382)
stop_distance = (target_price - entry_price) / rr_ratio
stop_loss = entry_price - stop_distance
trade_dir = "LONG"
else: # SHORT setup (from Bullish open)
entry_price = high
target_price = high - (range_size * 0.382)
stop_distance = (entry_price - target_price) / rr_ratio
stop_loss = entry_price + stop_distance
trade_dir = "SHORT"
# 6. Simulate Execution Window (09:45 to 10:45 inclusive - since 11:00 is forced exit)
sim_data = day_data.between_time('09:45', '10:45')
if sim_data.empty:
continue
position_open = False
trade_result = None
pnl_r = 0.0
exit_price = 0.0
exit_time = None
exit_reason = ""
for timestamp, candle in sim_data.iterrows():
c_high, c_low, c_close = candle['High'], candle['Low'], candle['Close']
# Check for Entry
if not position_open:
if (trade_dir == "LONG" and c_low <= entry_price) or (trade_dir == "SHORT" and c_high >= entry_price):
position_open = True
logger.info(f"{trade_date} - FILLED {trade_dir} at {entry_price:.2f}")
if trade_dir == "LONG":
if c_low <= stop_loss:
trade_result = "LOSS"
exit_price = stop_loss
exit_time = timestamp
exit_reason = "SL Hit"
pnl_r = -1.0
position_open = False
elif c_high >= target_price:
trade_result = "WIN"
exit_price = target_price
exit_time = timestamp
exit_reason = "TP Hit"
pnl_r = rr_ratio
position_open = False
else: # SHORT
if c_high >= stop_loss:
trade_result = "LOSS"
exit_price = stop_loss
exit_time = timestamp
exit_reason = "SL Hit"
pnl_r = -1.0
position_open = False
elif c_low <= target_price:
trade_result = "WIN"
exit_price = target_price
exit_time = timestamp
exit_reason = "TP Hit"
pnl_r = rr_ratio
position_open = False
continue
# If position is already open, check SL/TP for current candle
if position_open:
if trade_dir == "LONG":
if c_low <= stop_loss:
trade_result = "LOSS"
exit_price = stop_loss
exit_time = timestamp
exit_reason = "SL Hit"
pnl_r = -1.0
position_open = False
elif c_high >= target_price:
trade_result = "WIN"
exit_price = target_price
exit_time = timestamp
exit_reason = "TP Hit"
pnl_r = rr_ratio
position_open = False
else: # SHORT
if c_high >= stop_loss:
trade_result = "LOSS"
exit_price = stop_loss
exit_time = timestamp
exit_reason = "SL Hit"
pnl_r = -1.0
position_open = False
elif c_low <= target_price:
trade_result = "WIN"
exit_price = target_price
exit_time = timestamp
exit_reason = "TP Hit"
pnl_r = rr_ratio
position_open = False
if not position_open:
break
# 7. Force Exit at 11:00 (Close of 10:45 candle)
if position_open:
last_candle = sim_data.iloc[-1]
exit_price = last_candle['Close']
exit_time = sim_data.index[-1]
exit_reason = "11:00 Time Exit"
if trade_dir == "LONG":
distance = exit_price - entry_price
pnl_r = distance / stop_distance
else:
distance = entry_price - exit_price
pnl_r = distance / stop_distance
if pnl_r > 0:
trade_result = "WIN (Time)"
else:
trade_result = "LOSS (Time)"
position_open = False
# 8. Record Trade
if trade_result is not None:
total_trades += 1
if "WIN" in trade_result:
wins += 1
else:
losses += 1
if "Time" in trade_result:
time_exits += 1
total_pnl_r += pnl_r
trade_log.append([
trade_date.strftime("%Y-%m-%d"), trade_dir,
f"{entry_price:.2f}", f"{target_price:.2f}", f"{stop_loss:.2f}",
trade_result, f"{exit_price:.2f}", f"{pnl_r:.2f} R"
])
win_rate = (wins / total_trades) * 100 if total_trades > 0 else 0
if not quiet and total_trades > 0:
table = PrettyTable()
table.field_names = ["Date", "Dir", "Entry", "TP", "SL", "Result", "Exit Px", "PnL (R)"]
for row in trade_log:
table.add_row(row)
print(table)
print(f"\n--- Backtest Results: {ticker} ---")
print(f"Total Setups Triggered: {total_trades}")
print(f"Wins: {wins} | Losses: {losses}")
print(f"Win Rate: {win_rate:.2f}%")
print(f"Time Exits (11:00 EST): {time_exits}")
print(f"Net PnL: {total_pnl_r:.2f} R")
elif not quiet:
print(f"No valid setups triggered for {ticker} in the last {days} days.")
return {
"Ticker": ticker,
"Trades": total_trades,
"Wins": wins,
"Losses": losses,
"Win Rate (%)": round(win_rate, 2),
"Time Exits": time_exits,
"Net PnL (R)": round(total_pnl_r, 2)
}
if __name__ == "__main__":
import os
if os.path.exists("isa_watchlist.csv"):
print("Reading tickers from isa_watchlist.csv...")
df = pd.read_csv("isa_watchlist.csv")
tickers_to_test = df['Ticker'].tolist()
else:
print("isa_watchlist.csv not found, using default list...")
tickers_to_test = ["TSLA", "NVDA", "NFLX", "AMD"]
results = []
print(f"Running batch backtest for {len(tickers_to_test)} tickers. This may take a minute...")
for t in tickers_to_test:
res = backtest_ticker(t, quiet=True)
if res and res["Trades"] > 0:
results.append(res)
if results:
results_df = pd.DataFrame(results)
results_df = results_df.sort_values(by="Net PnL (R)", ascending=False).reset_index(drop=True)
print("\n" + "="*80)
print("🚀 BACKTEST LEADERBOARD (LAST ~60 DAYS) 🚀")
print("="*80)
table = PrettyTable()
table.field_names = results_df.columns
for _, row in results_df.iterrows():
table.add_row(row.tolist())
print(table)
print("\n* PnL is measured in 'R' (Risk Multiples). +2.0 R means you made twice what you risked.")
else:
print("No trades generated for any tickers.")
+89
View File
@@ -0,0 +1,89 @@
import pandas as pd
import json
import logging
from src.strategy.scanner import scan_for_candidates
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def find_best_isa_tickers():
"""
1. Reads available Trading212 instruments.
2. Filters for US Stocks (excluding ETFs/Warrants which are often non-ISA compliant or bad for this strategy).
3. Maps T212 tickers to Yahoo Finance tickers.
4. Runs the strategy scanner to find the most volatile/liquid candidates.
"""
try:
with open('available_instruments.json', 'r') as f:
instruments = json.load(f)
except FileNotFoundError:
logger.error("Please run get_available_tickers.py first to generate available_instruments.json")
return
# Filter for US Stocks only
# - type == 'STOCK': Excludes US ETFs (not allowed in UK ISAs) and Warrants
# - currencyCode == 'USD': Ensures it's trading during the US session (09:30 EST)
us_stocks = [
inst for inst in instruments
if inst.get('type') == 'STOCK' and inst.get('currencyCode') == 'USD'
]
logger.info(f"Filtered down to {len(us_stocks)} US Stocks from Trading212.")
# Extract short names (Yahoo Finance tickers)
# Trading212 usually uses 'shortName' for the actual market ticker (e.g., AAPL)
# and 'ticker' for their internal ID (e.g., AAPL_US_EQ).
t212_to_yf_map = {}
for stock in us_stocks:
short_name = stock.get('shortName')
t212_id = stock.get('ticker')
if short_name and t212_id:
# Avoid preferred shares/warrants that might sneak in with hyphens or dots
# if yfinance can't parse them easily, but we'll try them all.
t212_to_yf_map[short_name] = t212_id
yf_tickers = list(t212_to_yf_map.keys())
# We don't want to scan all 6,000+ stocks at once (Yahoo Finance will rate limit us).
# Let's filter locally first using T212 maxOpenQuantity or just take a known subset,
# OR we can pass the top 500 popular ones. For this script, let's scan a robust list of
# well-known highly liquid tech/growth stocks that are definitely ISA eligible.
# Known high-liquidity ISA-eligible US stocks:
focus_list = [
"TSLA", "NVDA", "AMD", "AAPL", "MSFT", "META", "AMZN", "GOOGL", "NFLX",
"COIN", "MSTR", "PLTR", "UBER", "HOOD", "RST", "SNOW", "CRM", "CRWD",
"PANW", "SMCI", "ARM", "SQ", "SHOP", "ROKU", "DDOG", "NET", "DOCN"
]
# Ensure they exist in our T212 account
valid_yf_tickers = [t for t in focus_list if t in t212_to_yf_map]
logger.info(f"Scanning {len(valid_yf_tickers)} highly liquid known ISA-eligible stocks...")
# Run the scanner
results_df = scan_for_candidates(tickers=valid_yf_tickers, min_price=20.0, min_volume=2_000_000)
if results_df.empty:
logger.warning("No candidates met the minimum price/volume criteria.")
return
# Map back to Trading212 internal tickers so the bot knows what to trade
results_df['T212_Ticker'] = results_df['Ticker'].map(t212_to_yf_map)
# Reorder columns
cols = ['Ticker', 'T212_Ticker', 'Close', 'ATR_14', 'ATR_Percent', 'Avg_Volume']
results_df = results_df[cols]
print("\n" + "="*80)
print("🏆 BEST ISA-ELIGIBLE CANDIDATES FOR TOUCH & TURN TODAY 🏆")
print("="*80)
print(results_df.to_string(index=False))
print("\n* Use the 'T212_Ticker' column when configuring the ExecutionManager.")
# Save the day's watchlist
results_df.to_csv("isa_watchlist.csv", index=False)
logger.info("Saved shortlist to isa_watchlist.csv")
return results_df
if __name__ == "__main__":
find_best_isa_tickers()
+60
View File
@@ -0,0 +1,60 @@
import os
import logging
import json
import pandas as pd
from dotenv import load_dotenv
from src.api.client import Trading212Client
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def fetch_and_save_tickers():
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/")
if not api_key_id or not api_key:
logger.error("API Key ID or API Key is missing in .env")
return
client = Trading212Client(api_key_id, api_key, base_url)
try:
logger.info("Fetching available instruments from Trading212...")
instruments = client.get_instruments()
logger.info(f"Retrieved {len(instruments)} instruments.")
# Save raw JSON
with open("available_instruments.json", "w") as f:
json.dump(instruments, f, indent=4)
logger.info("Saved raw data to available_instruments.json")
# Convert to a DataFrame and save as CSV for easier viewing/filtering
df = pd.DataFrame(instruments)
# Select the most useful columns if they exist
expected_columns = ['ticker', 'name', 'type', 'currencyCode', 'exchange']
available_columns = [col for col in expected_columns if col in df.columns]
if available_columns:
df_filtered = df[available_columns]
# Print a quick summary of US Equities (which is what Touch & Turn trades)
if 'type' in df.columns and 'currencyCode' in df.columns:
us_stocks = df[(df['type'] == 'STOCK') & (df['currencyCode'] == 'USD')]
logger.info(f"Found {len(us_stocks)} US Stocks.")
logger.info("\nSample of US Stocks available:")
print(us_stocks.head(10).to_string(index=False))
else:
df_filtered = df
df_filtered.to_csv("available_tickers.csv", index=False)
logger.info("Saved structured list to available_tickers.csv")
except Exception as e:
logger.error(f"Failed to fetch instruments: {e}")
if __name__ == "__main__":
fetch_and_save_tickers()