From dc111abf8cfad854fd4b516b0b50b07d5c6302c6 Mon Sep 17 00:00:00 2001 From: pie Date: Wed, 22 Apr 2026 21:19:33 +0100 Subject: [PATCH] Initial commit: Touch & Turn Scalping Bot with fully automated execution, backtesting, and ISA screening --- .env.example | 8 + .gitignore | 19 ++ GEMINI.md | 53 ++++++ README.md | 124 +++++++++++++ bot | 155 ++++++++++++++++ main.py | 214 +++++++++++++++++++++++ requirements.txt | 7 + scripts/backtest.py | 291 +++++++++++++++++++++++++++++++ scripts/find_isa_candidates.py | 89 ++++++++++ scripts/get_available_tickers.py | 60 +++++++ src/api/client.py | 85 +++++++++ src/execution/manager.py | 177 +++++++++++++++++++ src/strategy/scanner.py | 77 ++++++++ src/strategy/touch_turn.py | 129 ++++++++++++++ test_api_connection.py | 30 ++++ 15 files changed, 1518 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 GEMINI.md create mode 100644 README.md create mode 100644 bot create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 scripts/backtest.py create mode 100644 scripts/find_isa_candidates.py create mode 100644 scripts/get_available_tickers.py create mode 100644 src/api/client.py create mode 100644 src/execution/manager.py create mode 100644 src/strategy/scanner.py create mode 100644 src/strategy/touch_turn.py create mode 100644 test_api_connection.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bc851dd --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +TRADING212_API_KEY_ID=your_practice_api_key_id_here +TRADING212_API_KEY=your_practice_api_key_here +TRADING212_BASE_URL=https://demo.trading212.com/api/v0/ + +# Optional: Override the demo account's large starting balance (e.g. 5000) +# with a smaller amount to keep position sizing realistic for your future live account. +VIRTUAL_STARTING_BALANCE=250 + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ff3bd71 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# Virtual Environments +venv/ +env/ +.env + +# Application Data +*.log +logs/ +*.csv +*.json + +# IDE +.vscode/ +.idea/ diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..01259fc --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,53 @@ +# Trading212 Python Scalping Bot - "Touch & Turn" (Opening Range Reversal) + +This project implements the "Touch & Turn" scalping strategy, originally designed for ProRealTime, translated into Python for the Trading212 API. + +## Project Overview + +* **Strategy:** Opening Range Liquidity Reversal (Touch & Turn). +* **Asset Class:** US Stocks (e.g., Netflix, Apple, Tesla). +* **Timeframe:** 15-minute chart. +* **Operating Window:** 09:30 - 11:00 EST (Opening of the US Regular Trading Session). + +## Strategy Logic (The Workflow) + +1. **Identify the Opening Candle:** Capture the `High`, `Low`, `Open`, and `Close` of the first 15-minute candle of the session (09:30 to 09:45 EST). +2. **Filter for Liquidity:** + - Calculate the 14-day ATR (Average True Range). + - The opening range (`High - Low`) must be at least **25% of the ATR**. If smaller, the bot stays flat for the day. +3. **Determine Direction:** + - If the candle is **Bearish** (Close < Open): Prepare for a **LONG** entry at the `Low`. + - If the candle is **Bullish** (Close > Open): Prepare for a **SHORT** entry at the `High`. +4. **Calculate Targets (Fibonacci):** + - The target price is the **38.2% Fibonacci level** of the opening candle's range. +5. **Risk Management:** + - **Take Profit (TP):** The distance from the entry to the 38.2% Fib level. + - **Stop Loss (SL):** Half the TP distance (Risk:Reward ratio of 1:2). +6. **Automatic Exit:** Force close any open positions at 11:00 EST. + +## Technical Architecture + +* **API Client (`src/api/client.py`):** Handles REST calls to Trading212. +* **Strategy Engine (`src/strategy/touch_turn.py`):** + - Monitors the clock for the 09:45 EST trigger. + - Fetches 14-day ATR and the 09:30-09:45 15m candle. + - Calculates entry/TP/SL levels. +* **Execution Engine (`src/execution/manager.py`):** Places the limit orders and manages the position lifetime. + +## Getting Started + +1. **Setup Environment:** + ```bash + pip install -r requirements.txt + ``` +2. **Configuration:** + - Set `TRADING212_API_KEY` and `TRADING212_BASE_URL` in your `.env` file. + - Ensure your system clock is accurate or handle timezone conversions to EST. + +## TODOs + +- [x] Document the strategy logic. +- [x] Implement ATR calculation in the strategy engine. +- [x] Implement the 15m candle capture logic. +- [x] Implement the entry/exit order placement logic in the execution manager. +- [x] Create a backtesting script (optional but recommended). diff --git a/README.md b/README.md new file mode 100644 index 0000000..24bcd0f --- /dev/null +++ b/README.md @@ -0,0 +1,124 @@ +# Trading212 "Touch & Turn" Scalping Bot + +This project implements the "Touch & Turn" scalping strategy (Opening Range Liquidity Reversal) in Python for the Trading212 API. It is specifically designed to trade US Equities at the 09:30 EST market open. + +## ⚠️ Disclaimer +**This software is for educational purposes only.** Trading in financial markets involves a high degree of risk. Always use the practice/demo environment (`demo.trading212.com`) to test strategies before using real money. + +--- + +## Strategy Overview + +The strategy capitalizes on the initial liquidity and volatility of the US market open. + +1. **The Setup:** Captures the high and low of the first 15-minute candle (09:30 - 09:45 EST). +2. **The Filter:** The range of this opening candle must be at least **25%** of the stock's 14-day Average True Range (ATR). If the market is too quiet, no trade is taken. +3. **The Trigger:** + - If the opening candle closes **Bearish** (Close < Open), the bot prepares a **LONG** entry at the candle's Low. + - If the opening candle closes **Bullish** (Close > Open), the bot prepares a **SHORT** entry at the candle's High. +4. **The Targets:** + - **Take Profit (TP):** The 38.2% Fibonacci retracement level of the opening candle's range. + - **Stop Loss (SL):** Placed to ensure a Risk:Reward ratio of 1:2 (Risking 1 unit to make 2). +5. **Time Exit:** All open positions are forcefully closed at 11:00 EST to avoid mid-day chop. + +--- + +## Installation & Setup + +1. **Clone the repository and set up a virtual environment:** + ```bash + python3 -m venv venv + source venv/bin/activate + ``` + +2. **Install dependencies:** + ```bash + pip install -r requirements.txt + ``` + *(Note: The `prettytable` library was recently added for backtesting outputs. Run `pip install prettytable` if it's missing from your requirements file).* + +3. **Configure Environment Variables:** + Create a `.env` file in the root directory based on `.env.example`: + ```ini + TRADING212_API_KEY_ID=your_key_id_here + TRADING212_API_KEY=your_api_key_here + TRADING212_BASE_URL=https://demo.trading212.com/api/v0/ + ``` + *You can generate your API Key and ID inside the Trading212 app under Settings -> API.* + +4. **Verify Connection:** + Run the test script to ensure your credentials are correct and you can read your account balance: + ```bash + python3 test_api_connection.py + ``` + +--- + +## The Workflow & Scripts + +This repository is split into the live execution bot and several helper scripts to find the best assets to trade. + +### 1. Finding ISA-Eligible Tickers +If you are trading from a UK Trading212 Stocks ISA, you are restricted from trading US-domiciled ETFs and certain other assets. + +Run the ISA candidate script to fetch all available instruments, filter for ISA-compliant US Stocks, and rank a basket of popular tech stocks by their current volatility (ATR %): +```bash +PYTHONPATH=. python3 scripts/find_isa_candidates.py +``` +*This script outputs a leaderboard to the console and saves `isa_watchlist.csv`.* + +### 2. Backtesting the Watchlist +High volatility doesn't always guarantee a strategy works on a specific stock. You must backtest. + +Run the backtesting engine. It will automatically read the `isa_watchlist.csv` generated in the previous step and simulate the strategy over the last ~60 trading days (using 15m data from Yahoo Finance). +```bash +PYTHONPATH=. python3 scripts/backtest.py +``` +*This will output a leaderboard ranked by **Net PnL (in Risk Multiples/R)**. Identify the top 2-3 performing tickers (e.g., `NFLX`, `UBER`, `MSFT`) to configure the live bot.* + +### 3. Running the Live Bot +Once you have identified the best tickers for the day, the orchestrator will automatically spin up threads to monitor and trade them. + +Start the bot before the US market opens (09:30 EST): +```bash +python3 main.py +``` +The bot will: +1. Initialize and run the ISA scanner. +2. Backtest the top candidates and pick the **Top 3** with the highest historical R-multiple profit. +3. Spawn isolated background threads for each ticker to wait for exactly 09:45 EST. +4. Evaluate the opening candle, calculate risk-adjusted position sizes, and place the necessary Entry, Take Profit, and Stop Loss orders. +5. Poll for order fills and gracefully flatten all open positions at exactly 11:00 EST. + +--- + +## Risk Management & Position Sizing + +The bot uses dynamic **Risk-Based Position Sizing**. It does not buy a fixed number of shares. Instead, it calculates the distance between the Entry price and the Stop Loss price to determine the "Risk per Share". + +By default, the bot risks **1% of your account balance** per trade. + +**Virtual Balances:** +If you are testing on a demo account with a massive starting balance (e.g., £5,000) but plan to trade live with a much smaller amount, you can override the risk calculation to maintain psychological perspective. Set `VIRTUAL_STARTING_BALANCE=250` in your `.env` file. The bot will pretend your account only has £250 and will size its fraction share purchases to risk exactly £2.50 per trade. + +--- + +## Logging & PnL Tracking + +The bot provides comprehensive monitoring out of the box: + +- **Console & File Logging:** All activity (entries, fills, errors) is logged to the console and simultaneously appended to a daily file in the `logs/` directory (e.g., `logs/bot_2026-04-14.log`). +- **PnL Tracking:** A running ledger of all closed trades is kept in `pnl_tracking.csv`. This file records the Ticker, Direction, Entry/Exit prices, the reason for the exit (e.g., "TP Hit" or "11:00 Time Exit"), and the Profit/Loss measured in Risk Multiples (R). You can import this CSV into Excel or Python to chart your strategy's performance over time. + +--- + +## Utility Scripts + +- `scripts/get_available_tickers.py`: Fetches the raw metadata for all 16,000+ instruments available on Trading212 and saves them to `available_instruments.json` and `available_tickers.csv`. Useful if you want to manually search for new tickers outside the default tech/growth basket. + +## Architecture + +* **`src/api/client.py`:** Handles REST HTTP basic authentication and request formatting for the Trading212 API. +* **`src/strategy/touch_turn.py`:** The core logic engine. Fetches market data via Yahoo Finance, calculates ATR and Fibonacci levels, and returns the trade parameters. +* **`src/strategy/scanner.py`:** The ranking engine used to sort tickers by ATR volatility. +* **`src/execution/manager.py`:** Consumes the trade parameters and places the orders via the API client. diff --git a/bot b/bot new file mode 100644 index 0000000..634eb0f --- /dev/null +++ b/bot @@ -0,0 +1,155 @@ +// Generated by ProRealAlgos.com + +// Touch & Turn Scalper – Opening Range Liquidity Reversal + +// RECOMMENDED TIMEFRAME: 15-minute chart (strategy runs + +on 15m only) + +// Instrument example: Netflix (US stocks) – session 09:30–11:00 + +EST + +// Entry logic based on first 15m candle of regular session + +DEFPARAM CumulateOrders = False + +DEFPARAM FlatBefore = 090000 + +DEFPARAM FlatAfter = 110000 + +// === USER PARAMETERS === + +riskPercentATR = 25 // % of daily ATR to qualify as liquidity + +candle + +rrRatio = 2 // Risk:Reward ratio (TP = 2x SL) + +atrPeriod = 14 + +// === SESSION CONTROL (US STOCKS) === + +isNewSession = (Hour = 9 AND Minute = 30) + +// === RESET VARIABLES EACH DAY === + +IF isNewSession THEN + + rangeHigh = 0 + + rangeLow = 0 + + rangeSize = 0 + + fib38 = 0 + + direction = 0 + + validSetup = 0 + +ENDIF + +// === CAPTURE FIRST 15m OPENING RANGE === + +IF Hour = 9 AND Minute = 45 THEN + + rangeHigh = High + + rangeLow = Low + + rangeSize = rangeHigh - rangeLow + + // --- DAILY ATR --- + + dailyATR = AverageTrueRange[atrPeriod](Close) + + // --- LIQUIDITY CANDLE FILTER --- + + IF rangeSize >= dailyATR * riskPercentATR / 100 THEN + + validSetup = 1 + + ELSE + + validSetup = 0 + + ENDIF + + // --- FIB 38.2 LEVEL --- + + fib38 = rangeLow + (rangeHigh - rangeLow) * 0.382 + + // --- DIRECTION OF LIQUIDITY CANDLE --- + + IF Close < Open THEN + + direction = -1 // bearish candle → look for LONG + + ELSE + + direction = 1 // bullish candle → look for SHORT + + ENDIF + +ENDIF + +// === TRADE EXECUTION (Touch & Turn) === + +IF validSetup = 1 AND NOT OnMarket AND Hour >= 9 AND Hour < + +11 THEN + + // === LONG SETUP === + + IF direction = -1 THEN + + entryPrice = rangeLow + + targetDistance = fib38 - entryPrice + + stopDistance = targetDistance / rrRatio + + BUY 1 SHARE AT entryPrice LIMIT + + SET TARGET PRICE fib38 + + SET STOP LOSS stopDistance + + ENDIF + + // === SHORT SETUP === + + IF direction = 1 THEN + + entryPrice = rangeHigh + + targetDistance = entryPrice - fib38 + + stopDistance = targetDistance / rrRatio + + SELLSHORT 1 SHARE AT entryPrice LIMIT + + SET TARGET PRICE fib38 + + SET STOP LOSS stopDistance + + ENDIF + +ENDIF + +// === FORCE EXIT AFTER 11:00 === + +IF Hour >= 11 AND OnMarket THEN + + SELL AT MARKET + + EXITSHORT AT MARKET + +ENDIF + +// === BACKTEST STATISTICS DISPLAY === + +GRAPH StrategyProfit AS "Net Profit" + +GRAPH PositionPerf(1) AS "Last Trade %" diff --git a/main.py b/main.py new file mode 100644 index 0000000..4ad4fd0 --- /dev/null +++ b/main.py @@ -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() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..18f948b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +requests +pandas +pandas-ta +python-dotenv +pytest +yfinance +pytz diff --git a/scripts/backtest.py b/scripts/backtest.py new file mode 100644 index 0000000..b03023d --- /dev/null +++ b/scripts/backtest.py @@ -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.") + diff --git a/scripts/find_isa_candidates.py b/scripts/find_isa_candidates.py new file mode 100644 index 0000000..5e7926f --- /dev/null +++ b/scripts/find_isa_candidates.py @@ -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() diff --git a/scripts/get_available_tickers.py b/scripts/get_available_tickers.py new file mode 100644 index 0000000..db70382 --- /dev/null +++ b/scripts/get_available_tickers.py @@ -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() diff --git a/src/api/client.py b/src/api/client.py new file mode 100644 index 0000000..88a597e --- /dev/null +++ b/src/api/client.py @@ -0,0 +1,85 @@ +import requests +import os +import base64 +from typing import Dict, Any, Optional + +class Trading212Client: + """ + A basic client for interacting with the Trading212 REST API. + """ + def __init__(self, api_key_id: str, api_key: str, base_url: str): + self.api_key_id = api_key_id + self.api_key = api_key + self.base_url = base_url.rstrip('/') + + credentials = f"{self.api_key_id}:{self.api_key}" + encoded_credentials = base64.b64encode(credentials.encode()).decode() + + self.headers = { + "Authorization": f"Basic {encoded_credentials}", + "Content-Type": "application/json" + } + + def _get(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """Performs a GET request to the Trading212 API.""" + url = f"{self.base_url}/{endpoint.lstrip('/')}" + response = requests.get(url, headers=self.headers, params=params) + response.raise_for_status() + return response.json() + + def _post(self, endpoint: str, data: Dict[str, Any]) -> Dict[str, Any]: + """Performs a POST request to the Trading212 API.""" + url = f"{self.base_url}/{endpoint.lstrip('/')}" + response = requests.post(url, headers=self.headers, json=data) + response.raise_for_status() + return response.json() + + def place_market_order(self, ticker: str, quantity: float) -> Dict[str, Any]: + """Places a market order.""" + data = { + "ticker": ticker, + "quantity": quantity + } + return self._post("equity/orders/market", data) + + def place_limit_order(self, ticker: str, quantity: float, limit_price: float, time_validity: str = "DAY") -> Dict[str, Any]: + """Places a limit order.""" + data = { + "ticker": ticker, + "quantity": quantity, + "limitPrice": limit_price, + "timeValidity": time_validity + } + return self._post("equity/orders/limit", data) + + def place_stop_order(self, ticker: str, quantity: float, stop_price: float, time_validity: str = "GOOD_TILL_CANCEL") -> Dict[str, Any]: + """Places a stop order (used for Stop Loss).""" + data = { + "ticker": ticker, + "quantity": quantity, + "stopPrice": stop_price, + "timeValidity": time_validity + } + return self._post("equity/orders/stop", data) + + def get_order_status(self, order_id: str) -> Dict[str, Any]: + """Retrieves the status of a specific order.""" + return self._get(f"equity/orders/{order_id}") + + def cancel_order(self, order_id: str) -> bool: + """Cancels a specific order.""" + url = f"{self.base_url}/equity/orders/{order_id}" + response = requests.delete(url, headers=self.headers) + return response.status_code == 204 + + def get_account_info(self) -> Dict[str, Any]: + """Retrieves general account information.""" + return self._get("equity/account/summary") + + def get_all_open_positions(self) -> Dict[str, Any]: + """Retrieves all currently open positions.""" + return self._get("equity/portfolio") + + def get_instruments(self) -> Any: + """Retrieves a list of all available instruments.""" + return self._get("equity/metadata/instruments") diff --git a/src/execution/manager.py b/src/execution/manager.py new file mode 100644 index 0000000..9fd848e --- /dev/null +++ b/src/execution/manager.py @@ -0,0 +1,177 @@ +import time +import logging +from typing import Dict, Any, Optional +from src.api.client import Trading212Client + +logger = logging.getLogger(__name__) + +class ExecutionManager: + """ + Manages the lifecycle of a trade: Entry, SL/TP placement, and Exit. + """ + def __init__(self, client: Trading212Client): + self.client = client + self.current_order_id = None + self.sl_order_id = None + self.tp_order_id = None + self.is_in_position = False + + def execute_trade(self, params: Dict[str, Any], target_risk_amount: float = 0.0): + """Starts the trade process by placing a limit entry order.""" + self.params = params + ticker = params['ticker'] + direction = params['direction'] + entry_price = params['entry_price'] + stop_loss = params['stop_loss'] + + # Calculate Risk per share + risk_per_share = abs(entry_price - stop_loss) + + # Position Sizing + if target_risk_amount > 0 and risk_per_share > 0: + quantity = round(target_risk_amount / risk_per_share, 4) # T212 allows fractional shares + # Enforce a minimum of 0.01 or whatever the broker allows, but we'll trust the math here + if quantity < 0.01: + quantity = 0.01 + else: + quantity = 1.0 # Fallback + + self.current_quantity = quantity # Save it so monitor_and_bracket can use it + + # Quantity must be negative for Sell (Short) + trade_quantity = -quantity if direction == "SELL" else quantity + + logger.info(f"Calculated Risk/Share: {risk_per_share:.2f}. Sizing position to {quantity} shares to risk ~{target_risk_amount:.2f}") + logger.info(f"Placing entry {direction} limit order for {ticker} at {entry_price:.2f}") + + try: + order = self.client.place_limit_order(ticker, trade_quantity, entry_price) + self.current_order_id = order.get('id') + logger.info(f"Order placed successfully. ID: {self.current_order_id}") + return True + except Exception as e: + logger.error(f"Failed to place entry order: {e}") + return False + + def monitor_and_bracket(self, params: Dict[str, Any]): + """Polls the entry order and places SL/TP once filled.""" + if not self.current_order_id: + return False + + ticker = params['ticker'] + tp_price = params['target_price'] + sl_price = params['stop_loss'] + quantity = getattr(self, 'current_quantity', 1.0) + + + # Wait for entry fill + import pytz + from datetime import datetime + tz = pytz.timezone('US/Eastern') + + while not self.is_in_position: + if datetime.now(tz).hour >= 11: + logger.warning(f"11:00 EST reached without entry fill for {ticker}. Aborting.") + return False + + try: + status_info = self.client.get_order_status(self.current_order_id) + status = status_info.get('status') + logger.info(f"Entry order {self.current_order_id} status: {status}") + + if status == "FILLED": + self.is_in_position = True + logger.info(f"Entry order filled! Placing SL/TP.") + break + elif status in ["CANCELLED", "REJECTED"]: + logger.warning(f"Entry order was {status}. Aborting.") + return False + except Exception as e: + logger.error(f"Error checking order status: {e}") + + time.sleep(10) # Poll every 10 seconds + + # Place SL and TP + # SL is a Stop order in the opposite direction + # TP is a Limit order in the opposite direction + sl_qty = -quantity if params['direction'] == "BUY" else quantity + tp_qty = -quantity if params['direction'] == "BUY" else quantity + + try: + # Place TP (Limit) + tp_order = self.client.place_limit_order(ticker, tp_qty, tp_price, time_validity="GOOD_TILL_CANCEL") + self.tp_order_id = tp_order.get('id') + logger.info(f"TP order placed: {self.tp_order_id}") + + # Place SL (Stop) + sl_order = self.client.place_stop_order(ticker, sl_qty, sl_price, time_validity="GOOD_TILL_CANCEL") + self.sl_order_id = sl_order.get('id') + logger.info(f"SL order placed: {self.sl_order_id}") + + return True + except Exception as e: + logger.error(f"Failed to place SL/TP orders: {e}") + return False + + def check_exit_status(self) -> tuple[bool, str, float]: + """Checks if the SL or TP orders have been filled by the broker.""" + if not self.is_in_position: + return False, "", 0.0 + + try: + if self.tp_order_id: + tp_info = self.client.get_order_status(self.tp_order_id) + if tp_info.get('status') == "FILLED": + fill_price = tp_info.get('filledPrice', tp_info.get('limitPrice', self.params.get('target_price'))) + self.is_in_position = False + return True, "TP Hit", float(fill_price) + + if self.sl_order_id: + sl_info = self.client.get_order_status(self.sl_order_id) + if sl_info.get('status') == "FILLED": + fill_price = sl_info.get('filledPrice', sl_info.get('stopPrice', self.params.get('stop_loss'))) + self.is_in_position = False + return True, "SL Hit", float(fill_price) + except Exception as e: + logger.error(f"Error checking exit status: {e}") + + return False, "", 0.0 + + def close_all(self, ticker: str) -> float: + """Forces a close of all open orders and positions. Returns the exit price (or 0.0).""" + logger.info(f"Closing all orders and positions for {ticker}...") + + if self.current_order_id: + try: self.client.cancel_order(self.current_order_id) + except: pass + if self.sl_order_id: + try: self.client.cancel_order(self.sl_order_id) + except: pass + if self.tp_order_id: + try: self.client.cancel_order(self.tp_order_id) + except: pass + + logger.info("Emergency exit triggered. Cancelling pending orders...") + + exit_price = 0.0 + # Flatten any active position + if self.is_in_position: + try: + positions = self.client.get_all_open_positions() + for pos in positions: + if pos.get('ticker') == ticker: + qty = float(pos.get('quantity', 0)) + exit_price = float(pos.get('currentPrice', 0.0)) # Use current market price as approx fill + if qty != 0: + # To close, we sell if we are long (positive qty), buy if short (negative qty) + exit_qty = -qty + logger.info(f"Flattening position: Placing market order for {exit_qty} shares of {ticker} at approx {exit_price}") + self.client.place_market_order(ticker, exit_qty) + break + except Exception as e: + logger.error(f"Failed to flatten position during emergency exit: {e}") + + self.is_in_position = False + logger.info("Cleanup complete.") + return exit_price + diff --git a/src/strategy/scanner.py b/src/strategy/scanner.py new file mode 100644 index 0000000..0b402e0 --- /dev/null +++ b/src/strategy/scanner.py @@ -0,0 +1,77 @@ +import yfinance as yf +import pandas_ta as ta +import pandas as pd +import logging +from typing import List, Dict + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +DEFAULT_TICKERS = [ + "AAPL", "MSFT", "NVDA", "AMZN", "META", "GOOGL", "TSLA", + "AMD", "NFLX", "QCOM", "INTC", "BA", "DIS", "SPY", "QQQ" +] + +def scan_for_candidates(tickers: List[str] = DEFAULT_TICKERS, min_price: float = 20.0, min_volume: int = 2_000_000) -> pd.DataFrame: + """ + Scans a list of tickers to find the best candidates for the Touch & Turn strategy. + Prioritizes high Average True Range (ATR) as a percentage of price, ensuring adequate volume. + """ + logger.info(f"Scanning {len(tickers)} tickers for high volatility/liquidity...") + results = [] + + # Download daily data for the past 1mo to calculate 14-day ATR and Avg Volume + data = yf.download(tickers, period="1mo", interval="1d", group_by="ticker", progress=False) + + for ticker in tickers: + try: + # Handle single ticker vs multi-ticker dataframe structure from yfinance + if len(tickers) == 1: + df = data.copy() + else: + df = data[ticker].copy() + + if df.empty or len(df) < 15: + continue + + # Clean column names (yfinance multi-index can sometimes leave tuple names) + if isinstance(df.columns, pd.MultiIndex): + df.columns = df.columns.droplevel(1) + + df.ta.atr(length=14, append=True) + + latest = df.iloc[-1] + yesterday_atr = df['ATRr_14'].iloc[-2] + + close_price = latest['Close'] + avg_volume = df['Volume'].tail(14).mean() + + # Filters + if close_price < min_price or avg_volume < min_volume or pd.isna(yesterday_atr): + continue + + atr_percent = (yesterday_atr / close_price) * 100 + + results.append({ + "Ticker": ticker, + "Close": round(float(close_price), 2), + "ATR_14": round(float(yesterday_atr), 2), + "ATR_Percent": round(float(atr_percent), 2), + "Avg_Volume": int(avg_volume) + }) + + except Exception as e: + logger.debug(f"Failed processing {ticker}: {e}") + + results_df = pd.DataFrame(results) + if not results_df.empty: + # Sort by ATR Percentage descending (we want the most volatile stocks) + results_df = results_df.sort_values(by="ATR_Percent", ascending=False).reset_index(drop=True) + + return results_df + +if __name__ == "__main__": + candidates = scan_for_candidates() + print("\nTop Candidates for Touch & Turn Strategy:") + print("-" * 65) + print(candidates.to_string(index=False)) diff --git a/src/strategy/touch_turn.py b/src/strategy/touch_turn.py new file mode 100644 index 0000000..4b2aad1 --- /dev/null +++ b/src/strategy/touch_turn.py @@ -0,0 +1,129 @@ +import pandas as pd +import pandas_ta as ta +import yfinance as yf +from datetime import datetime, time, timedelta +import pytz +import logging + +logger = logging.getLogger(__name__) + +class TouchTurnStrategy: + """ + Implements the 'Touch & Turn' (Opening Range Reversal) strategy. + + 1. Captures the 09:30-09:45 EST 15m candle. + 2. Checks if range > 25% of 14-day ATR. + 3. Bearish candle -> Long at Low, Target @ 38.2% Fib. + 4. Bullish candle -> Short at High, Target @ 38.2% Fib. + """ + + def __init__(self, ticker: str, risk_percent_atr: float = 25.0, rr_ratio: float = 2.0): + self.ticker = ticker + self.risk_percent_atr = risk_percent_atr + self.rr_ratio = rr_ratio + self.tz = pytz.timezone('US/Eastern') + + self.valid_setup = False + self.direction = 0 # 1 for Short (from High), -1 for Long (from Low) + self.entry_price = 0.0 + self.target_price = 0.0 + self.stop_loss = 0.0 + + def get_market_data(self): + """Fetches 15m candles and daily data for ATR.""" + now = datetime.now(self.tz) + + # 1. Fetch Daily Data for ATR (need at least 15+ days) + daily_data = yf.download(self.ticker, period="1mo", interval="1d", progress=False) + if daily_data.empty or len(daily_data) < 15: + logger.error(f"Insufficient daily data for {self.ticker}") + return None, None + + # Clean column names (yfinance multi-index issue) + if isinstance(daily_data.columns, pd.MultiIndex): + daily_data.columns = daily_data.columns.droplevel(1) + + # Calculate ATR + daily_data.ta.atr(length=14, append=True) + daily_atr = daily_data['ATRr_14'].iloc[-2] # Use yesterday's ATR + + # 2. Fetch 15m Candle for today's opening (09:30 - 09:45) + # Note: yfinance 15m candles are labeled by start time. + start_date = now.strftime('%Y-%m-%d') + intraday_data = yf.download(self.ticker, start=start_date, interval="15m", progress=False) + + if intraday_data.empty: + logger.warning(f"No intraday data yet for {self.ticker}") + return daily_atr, None + + if isinstance(intraday_data.columns, pd.MultiIndex): + intraday_data.columns = intraday_data.columns.droplevel(1) + + # The first candle of the session (09:30) + opening_candle = intraday_data.between_time('09:30', '09:30') + if opening_candle.empty: + logger.warning(f"Opening 15m candle (09:30) not yet available for {self.ticker}") + return daily_atr, None + + return daily_atr, opening_candle.iloc[0] + + def check_setup(self): + """Evaluates the strategy criteria based on the opening candle.""" + daily_atr, opening_candle = self.get_market_data() + + if daily_atr is None or opening_candle is None: + return False + + high = opening_candle['High'] + low = opening_candle['Low'] + open_p = opening_candle['Open'] + close_p = opening_candle['Close'] + range_size = high - low + + # 1. Liquidity Filter + if range_size < (daily_atr * self.risk_percent_atr / 100): + logger.info(f"Setup invalid: Range ({range_size:.2f}) < 25% of ATR ({daily_atr:.2f})") + self.valid_setup = False + return False + + # 2. Determine Direction + if close_p < open_p: + self.direction = -1 # Bearish candle -> Long setup + self.entry_price = low + logger.info(f"Bearish opening candle detected. Preparing LONG at {self.entry_price:.2f}") + else: + self.direction = 1 # Bullish candle -> Short setup + self.entry_price = high + logger.info(f"Bullish opening candle detected. Preparing SHORT at {self.entry_price:.2f}") + + # 3. Calculate Fibonacci 38.2% Target + # For LONG (from Low): target is 38.2% up from the Low + # For SHORT (from High): target is 38.2% down from the High + if self.direction == -1: # LONG + self.target_price = low + (range_size * 0.382) + target_distance = self.target_price - self.entry_price + stop_distance = target_distance / self.rr_ratio + self.stop_loss = self.entry_price - stop_distance + else: # SHORT + self.target_price = high - (range_size * 0.382) + target_distance = self.entry_price - self.target_price + stop_distance = target_distance / self.rr_ratio + self.stop_loss = self.entry_price + stop_distance + + + self.valid_setup = True + logger.info(f"Valid Setup! Entry: {self.entry_price:.2f}, Target: {self.target_price:.2f}, SL: {self.stop_loss:.2f}") + return True + + def get_trade_params(self): + """Returns the parameters for the trade execution.""" + if not self.valid_setup: + return None + + return { + "ticker": self.ticker, + "direction": "BUY" if self.direction == -1 else "SELL", + "entry_price": self.entry_price, + "target_price": self.target_price, + "stop_loss": self.stop_loss + } diff --git a/test_api_connection.py b/test_api_connection.py new file mode 100644 index 0000000..8247735 --- /dev/null +++ b/test_api_connection.py @@ -0,0 +1,30 @@ +import os +import logging +from dotenv import load_dotenv +from src.api.client import Trading212Client + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +def test_connection(): + 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(f"Connecting to Trading212 at {base_url}...") + account_info = client.get_account_info() + logger.info("Successfully connected!") + logger.info(f"Account Info: {account_info}") + except Exception as e: + logger.error(f"Connection failed: {e}") + +if __name__ == "__main__": + test_connection()