commit dc111abf8cfad854fd4b516b0b50b07d5c6302c6 Author: pie Date: Wed Apr 22 21:19:33 2026 +0100 Initial commit: Touch & Turn Scalping Bot with fully automated execution, backtesting, and ISA screening 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()