Initial commit: Touch & Turn Scalping Bot with fully automated execution, backtesting, and ISA screening
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user