Initial commit: Touch & Turn Scalping Bot with fully automated execution, backtesting, and ISA screening
This commit is contained in:
@@ -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
|
||||
|
||||
+19
@@ -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/
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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 %"
|
||||
@@ -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()
|
||||
@@ -0,0 +1,7 @@
|
||||
requests
|
||||
pandas
|
||||
pandas-ta
|
||||
python-dotenv
|
||||
pytest
|
||||
yfinance
|
||||
pytz
|
||||
@@ -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.")
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user