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