Compare commits
14 Commits
ede9933c88
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 143cdcd976 | |||
| 6010f66323 | |||
| 17ad49c22e | |||
| ec65e86bd9 | |||
| e71e833c71 | |||
| 9e26623fc7 | |||
| fc9bb34d6b | |||
| 5a4c99d0d1 | |||
| 0f5d00e292 | |||
| 9448c613ca | |||
| f2180891fc | |||
| 1cfca22ddd | |||
| deba044a7b | |||
| d1a141a669 |
@@ -1,53 +1,46 @@
|
||||
# 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).
|
||||
This project implements the "Touch & Turn" scalping strategy for the Trading212 API, optimized for the UK ISA environment.
|
||||
|
||||
## 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.
|
||||
1. **Identify Opening Candle:** Capture the 15m candle (09:30 to 09:45 EST).
|
||||
2. **Filter for Liquidity:** Opening range must be >= 25% of 14-day ATR.
|
||||
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.
|
||||
- Bearish (Close < Open): Prepare **LONG** (Buy at Low).
|
||||
- Bullish (Close > Open): Prepare **SHORT** (Substitute with **3x Inverse ETP BUY** in ISA).
|
||||
4. **Execution (09:45 EST):**
|
||||
- Entry via **Market Order** for immediate fill.
|
||||
- **Actual Fill Price** fetched from portfolio is used for all bracket calculations.
|
||||
5. **Hybrid Exit Strategy:**
|
||||
- **Broker-Side:** Physical **Stop Loss** order placed immediately for protection.
|
||||
- **Bot-Side:** **Take Profit** monitored manually by polling current market price.
|
||||
- This bypasses ISA restrictions against multiple pending sell orders for the same shares.
|
||||
6. **Automatic Exit (11:00 EST):** Force close via Market Order and cleanup pending SL.
|
||||
|
||||
## Risk & Capital Management
|
||||
|
||||
* **Virtual Balance Simulation:** In demo mode, subtracts £4,750 from total equity to simulate a realistic £250 starting point.
|
||||
* **5% Risk Rule:** Risks exactly 5% of the Virtual Balance per trade.
|
||||
* **Capital Partitioning:** Divides total available capital (£250) and risk budget equally among all active ticker threads for the day (max 3).
|
||||
* **Precision & Minimums:** Automatically detects "precision-mismatch" or "min-quantity-exceeded" errors from T212 and retries with corrected values.
|
||||
|
||||
## 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.
|
||||
* **`main.py`:** Daily orchestrator. Scan -> Backtest -> Select Top 3 -> Spawn Parallel Threads. Handles early API verification and unbuffered logging.
|
||||
* **`src/api/client.py`:** REST wrapper with Basic Auth.
|
||||
* **`src/strategy/touch_turn.py`:** Setup logic, Fibonacci calculation, and timezone conversion (UTC -> Eastern).
|
||||
* **`src/execution/manager.py`:** Handles ticker swapping (Inverse ETPs), market entries, hybrid brackets, and retry loops with jitter.
|
||||
* **`src/strategy/inverse_mapping.py`:** Map of US stocks to 3x Short Inverse ETPs (GraniteShares/Leverage Shares).
|
||||
|
||||
## Getting Started
|
||||
## Resilience Features
|
||||
|
||||
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.
|
||||
* **API Backoff:** Random jitter (1-10s) and exponential retry on 429 errors.
|
||||
* **Order Tracking:** Uses portfolio checks to infer status if order IDs disappear (404).
|
||||
* **Unbuffered Logging:** Force-flushes logs to `logs/bot_*.log` immediately for real-time monitoring.
|
||||
|
||||
## TODOs
|
||||
## Operation
|
||||
|
||||
- [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).
|
||||
1. **Timer:** Service managed by `systemd` timer firing at 09:30 America/New_York.
|
||||
2. **Tracking:** P&L recorded in `pnl_tracking.csv` (R-multiple based).
|
||||
3. **Verification:** Always run `./venv/bin/python3 test_api_connection.py` before live days.
|
||||
|
||||
@@ -12,14 +12,15 @@ This project implements the "Touch & Turn" scalping strategy (Opening Range Liqu
|
||||
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.
|
||||
2. **The Filter:** The range of this opening candle must be at least **25%** of the stock's 14-day Average True Range (ATR).
|
||||
3. **The Trigger (ISA Optimized):**
|
||||
- **LONG (Bearish candle):** Bot places an immediate **Market BUY** order for the stock.
|
||||
- **SHORT (Bullish candle):** Since standard shorting is restricted in UK ISAs, the bot automatically substitutes this with a **Market BUY** order for a **3x Inverse ETP** (e.g., buying `3SLA` if `TSLA` gives a short signal).
|
||||
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.
|
||||
- Brackets are placed **immediately** after the market order is filled, using the **Actual Fill Price** from your portfolio.
|
||||
- **Take Profit (TP):** The 38.2% Fibonacci retracement level.
|
||||
- **Stop Loss (SL):** Placed to ensure a Risk:Reward ratio of 1:2.
|
||||
5. **Time Exit:** All open positions are forcefully closed via Market Order at **11:00 EST**.
|
||||
|
||||
---
|
||||
|
||||
@@ -29,96 +30,51 @@ The strategy capitalizes on the initial liquidity and volatility of the US marke
|
||||
```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`:
|
||||
2. **Configure Environment Variables:**
|
||||
Create a `.env` file in the root directory:
|
||||
```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/
|
||||
ISA_MODE=True
|
||||
```
|
||||
*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".
|
||||
The bot uses dynamic **Risk-Based Position Sizing** to ensure consistent exposure.
|
||||
|
||||
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.
|
||||
- **5% Risk Rule:** By default, the bot risks **5% of your account balance** per trade.
|
||||
- **Virtual Balance simulation:** If you are testing on a demo account with a large balance (e.g., £5,000) but plan to trade live with £250, the bot can maintain perspective. It automatically calculates a "Virtual Balance" by subtracting £4,750 from your actual total, ensuring your risk amount is exactly what it will be in the real world. (e.g. £12.50 risk on a £250 virtual balance).
|
||||
- **Leverage Adjusted:** For Inverse ETPs (3x leverage), the bot adjusts the quantity and bracket percentages to ensure the monetary risk remains identical to a standard 1x stock trade.
|
||||
|
||||
---
|
||||
|
||||
## Logging & PnL Tracking
|
||||
## Automation Workflow
|
||||
|
||||
The bot provides comprehensive monitoring out of the box:
|
||||
The bot is designed to be triggered once per day (e.g., via a **systemd timer** or cron) at exactly **09:30 EST**.
|
||||
|
||||
- **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.
|
||||
1. **Scan:** Runs the ISA candidate filter to find the most volatile US stocks.
|
||||
2. **Backtest:** Runs a 60-day historical backtest on the top 10 candidates.
|
||||
3. **Select:** Picks the **Top 3** tickers that showed a positive historical return (Net PnL > 0 R).
|
||||
4. **Execute:** Spawns parallel threads to monitor and trade the selected assets.
|
||||
5. **Clean:** Shuts down automatically after the 11:00 EST exit and cleanup.
|
||||
|
||||
---
|
||||
|
||||
## Utility Scripts
|
||||
## Monitoring
|
||||
|
||||
- `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.
|
||||
- **Logs:** All activity is recorded in `logs/bot_YYYY-MM-DD.log`.
|
||||
- **PnL Tracking:** A permanent ledger of every trade (including ETP substitutions) is kept in `pnl_tracking.csv` for graphing and analysis.
|
||||
|
||||
## 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.
|
||||
* **`src/api/client.py`:** REST API wrapper with Basic Auth.
|
||||
* **`src/strategy/touch_turn.py`:** Logic engine and Fibonacci calculator.
|
||||
* **`src/strategy/inverse_mapping.py`:** Map of US stocks to 3x Short Inverse ETPs.
|
||||
* **`src/execution/manager.py`:** Handles market entries, actual fill-based bracketing, and ISA substitutions.
|
||||
* **`main.py`:** The morning orchestrator.
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Force unbuffered output for systemd/logging
|
||||
os.environ['PYTHONUNBUFFERED'] = '1'
|
||||
|
||||
import time
|
||||
import logging
|
||||
import pytz
|
||||
import threading
|
||||
import csv
|
||||
import random
|
||||
from datetime import datetime, time as dtime
|
||||
from dotenv import load_dotenv
|
||||
|
||||
@@ -17,35 +23,50 @@ from scripts.backtest import backtest_ticker
|
||||
os.makedirs("logs", exist_ok=True)
|
||||
log_filename = datetime.now().strftime("logs/bot_%Y-%m-%d.log")
|
||||
|
||||
# Configure logging to both console and file
|
||||
# Simple, robust logging setup
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s [%(threadName)s] %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler(log_filename),
|
||||
logging.StreamHandler()
|
||||
logging.FileHandler(log_filename, mode='a'),
|
||||
logging.StreamHandler(sys.stdout)
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def flush_logs():
|
||||
for handler in logging.getLogger().handlers:
|
||||
handler.flush()
|
||||
|
||||
|
||||
PNL_FILE = "pnl_tracking.csv"
|
||||
|
||||
def record_pnl(ticker, direction, entry_price, exit_price, reason, pnl_r):
|
||||
def record_pnl(ticker, direction, entry_price, exit_price, reason, pnl_r, trading_ticker=None):
|
||||
"""Appends the result of a closed trade to the PnL CSV."""
|
||||
file_exists = os.path.isfile(PNL_FILE)
|
||||
|
||||
# Safety: Fix potential 0.0 exit price in logs causing extreme PnL values
|
||||
if exit_price <= 0:
|
||||
exit_price = entry_price
|
||||
|
||||
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)"])
|
||||
writer.writerow(["Date", "Ticker", "Trading 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)])
|
||||
writer.writerow([today, ticker, trading_ticker or 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")
|
||||
label = f"{ticker} ({trading_ticker})" if trading_ticker else ticker
|
||||
logger.info(f"Recorded trade in {PNL_FILE}: {label} {direction} | Result: {reason} | PnL: {pnl_r:.2f} R")
|
||||
flush_logs()
|
||||
|
||||
def calculate_r_multiple(direction, entry_price, exit_price, stop_loss):
|
||||
"""Calculates the PnL in terms of Risk Multiples (R)."""
|
||||
# Safety: Prevent Division by Zero if SL is somehow same as entry
|
||||
if abs(entry_price - stop_loss) < 0.001:
|
||||
return 0.0
|
||||
|
||||
if direction == "BUY": # LONG
|
||||
risk = entry_price - stop_loss
|
||||
return (exit_price - entry_price) / risk if risk != 0 else 0
|
||||
@@ -53,13 +74,18 @@ def calculate_r_multiple(direction, entry_price, exit_price, stop_loss):
|
||||
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):
|
||||
def run_ticker_lifecycle(client, yf_ticker, t212_ticker, tz, num_tickers):
|
||||
"""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}).")
|
||||
|
||||
# Initialize variables outside the retry loop to prevent UnboundLocalError
|
||||
risk_share = 12.50 / num_tickers
|
||||
capital_share = 250.0 / num_tickers
|
||||
|
||||
try:
|
||||
now = datetime.now(tz)
|
||||
target_entry_time = now.replace(hour=9, minute=45, second=0, microsecond=0)
|
||||
|
||||
@@ -91,47 +117,52 @@ def run_ticker_lifecycle(client, yf_ticker, t212_ticker, tz):
|
||||
params['ticker'] = t212_ticker
|
||||
|
||||
# Anti-thundering-herd: Random jitter to prevent 429s from parallel threads
|
||||
import random
|
||||
time.sleep(random.uniform(0.1, 3.0))
|
||||
# Use a larger range (1-10s) to better stagger independent threads
|
||||
time.sleep(random.uniform(1.0, 10.0))
|
||||
|
||||
# Fetch Account Balance to calculate risk with backoff
|
||||
risk_amount = 2.50 # Fallback
|
||||
for attempt in range(3):
|
||||
try:
|
||||
account_info = client.get_account_info()
|
||||
virtual_balance = float(os.getenv("VIRTUAL_STARTING_BALANCE", 0))
|
||||
actual_balance = float(account_info.get('totalValue', 5000.0))
|
||||
virtual_balance = max(0, actual_balance - 4750.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
|
||||
break # Success
|
||||
# Risk 5% of this adjusted virtual balance
|
||||
risk_share = (virtual_balance * 0.05) / num_tickers
|
||||
capital_share = virtual_balance / num_tickers
|
||||
|
||||
logger.info(f"Account: {actual_balance:.2f} | Virtual: {virtual_balance:.2f} | Share: {capital_share:.2f}")
|
||||
break
|
||||
except Exception as e:
|
||||
if '429' in str(e):
|
||||
logger.warning(f"Rate limited on account fetch for {yf_ticker}. Retrying in {2**(attempt+1)}s...")
|
||||
time.sleep(2**(attempt+1))
|
||||
wait_time = (attempt + 1) * 5 + random.uniform(1, 3)
|
||||
logger.warning(f"Rate limited on account fetch for {yf_ticker}. Retrying in {wait_time:.1f}s...")
|
||||
time.sleep(wait_time)
|
||||
else:
|
||||
logger.error(f"Failed to fetch account info: {e}. Defaulting to £2.50 risk.")
|
||||
logger.error(f"Failed to fetch account info: {e}")
|
||||
break
|
||||
|
||||
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.execute_trade(params, target_risk_amount=risk_share, max_capital=capital_share):
|
||||
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
|
||||
final_entry = execution.params.get('final_entry', params['entry_price'])
|
||||
final_sl = execution.params.get('final_sl', params['stop_loss'])
|
||||
trading_ticker = execution.params.get('trading_ticker', yf_ticker)
|
||||
|
||||
pnl_r = calculate_r_multiple("BUY" if execution.is_etp else params['direction'], final_entry, exit_price, final_sl)
|
||||
record_pnl(yf_ticker, params['direction'], final_entry, exit_price, reason, pnl_r, trading_ticker=trading_ticker)
|
||||
break
|
||||
|
||||
time.sleep(15)
|
||||
now = datetime.now(tz)
|
||||
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)
|
||||
# 2. Wait until 11:00 EST for Forced Exit
|
||||
now = datetime.now(tz)
|
||||
target_exit_time = now.replace(hour=11, minute=0, second=0, microsecond=0)
|
||||
|
||||
@@ -140,18 +171,27 @@ def run_ticker_lifecycle(client, yf_ticker, t212_ticker, tz):
|
||||
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.")
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error in {yf_ticker} lifecycle: {e}", exc_info=True)
|
||||
finally:
|
||||
# 3. 11:00 EST - Cleanup (ensures closing even on thread crash)
|
||||
time.sleep(random.uniform(0.1, 5.0))
|
||||
|
||||
logger.info(f"Cleanup phase reached for {yf_ticker}.")
|
||||
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)
|
||||
final_entry = execution.params.get('final_entry', execution.params['entry_price'])
|
||||
final_sl = execution.params.get('final_sl', execution.params['stop_loss'])
|
||||
trading_ticker = execution.params.get('trading_ticker', yf_ticker)
|
||||
|
||||
pnl_r = calculate_r_multiple("BUY" if execution.is_etp else execution.params['direction'], final_entry, exit_price, final_sl)
|
||||
record_pnl(yf_ticker, execution.params['direction'], final_entry, exit_price, "Forced Exit (Final)", pnl_r, trading_ticker=trading_ticker)
|
||||
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.")
|
||||
flush_logs()
|
||||
|
||||
def main():
|
||||
load_dotenv()
|
||||
@@ -162,12 +202,10 @@ def main():
|
||||
|
||||
now = datetime.now(tz)
|
||||
|
||||
# Safety Guard: Check if it's a weekend
|
||||
if now.weekday() >= 5: # 5 = Saturday, 6 = Sunday
|
||||
if now.weekday() >= 5:
|
||||
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
|
||||
@@ -178,7 +216,16 @@ def main():
|
||||
|
||||
client = Trading212Client(api_key_id, api_key, base_url)
|
||||
|
||||
# 1. Morning Routine: Find Candidates
|
||||
# Early verification: Check connection before starting the day
|
||||
try:
|
||||
logger.info("Verifying API connection...")
|
||||
client.get_account_info()
|
||||
logger.info("API Connection verified successfully.")
|
||||
except Exception as e:
|
||||
logger.error(f"API Connection check failed: {e}")
|
||||
logger.error("Please check your API key and permissions in .env. Exiting.")
|
||||
return
|
||||
|
||||
logger.info("Starting Morning Routine: Finding ISA Candidates...")
|
||||
candidates_df = find_best_isa_tickers()
|
||||
|
||||
@@ -186,11 +233,9 @@ def main():
|
||||
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']
|
||||
@@ -203,10 +248,7 @@ def main():
|
||||
'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:
|
||||
@@ -215,12 +257,12 @@ def main():
|
||||
|
||||
logger.info(f"Final Watchlist for today: {[t['yf'] for t in final_watchlist]}")
|
||||
|
||||
# 3. Launch execution threads
|
||||
threads = []
|
||||
num_active = len(final_watchlist)
|
||||
for ticker_info in final_watchlist:
|
||||
t = threading.Thread(
|
||||
target=run_ticker_lifecycle,
|
||||
args=(client, ticker_info['yf'], ticker_info['t212'], tz),
|
||||
args=(client, ticker_info['yf'], ticker_info['t212'], tz, num_active),
|
||||
name=f"Bot-{ticker_info['yf']}"
|
||||
)
|
||||
t.start()
|
||||
@@ -232,6 +274,13 @@ def main():
|
||||
t.join()
|
||||
|
||||
logger.info("All threads completed. Bot shutting down for the day.")
|
||||
flush_logs()
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as e:
|
||||
logger.critical(f"FATAL ERROR in main: {e}", exc_info=True)
|
||||
finally:
|
||||
flush_logs()
|
||||
logger.info("Bot process terminated.")
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
Looking at the logs it appears that specifically SELL orders are not getting through
|
||||
|
||||
When making that first trade are we specifying the price we want? I would expect immediate orders to go through in seconds, but the initial order appears to sit as "NEW" for some time... I understand calculations are made, but make a more approx calculation based on the price we are reacting to, then properly calulate SL and TP from the fullfilled order?
|
||||
in fact are there any indications that other than the initial trade are the SL and TP order going through?
|
||||
+221
-93
@@ -1,13 +1,18 @@
|
||||
import time
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import json
|
||||
from typing import Dict, Any, Optional
|
||||
from src.api.client import Trading212Client
|
||||
from src.strategy.inverse_mapping import INVERSE_TICKER_MAP, LEVERAGE_MAP
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ExecutionManager:
|
||||
"""
|
||||
Manages the lifecycle of a trade: Entry, SL/TP placement, and Exit.
|
||||
Manages the lifecycle of a trade: Entry, SL placement, and Exit.
|
||||
Uses a Hybrid Strategy: Broker-side SL and Bot-side TP monitoring.
|
||||
"""
|
||||
def __init__(self, client: Trading212Client):
|
||||
self.client = client
|
||||
@@ -15,163 +20,286 @@ class ExecutionManager:
|
||||
self.sl_order_id = None
|
||||
self.tp_order_id = None
|
||||
self.is_in_position = False
|
||||
self.leverage = 1.0
|
||||
self.is_etp = False
|
||||
|
||||
def _call_with_retry(self, func, *args, **kwargs):
|
||||
"""Helper to call an API function with retries and jitter."""
|
||||
max_attempts = 5
|
||||
for attempt in range(max_attempts):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
if '429' in str(e):
|
||||
wait = (2 ** attempt) + random.uniform(0.1, 1.0)
|
||||
logger.warning(f"Rate limited. Retrying in {wait:.1f}s...")
|
||||
time.sleep(wait)
|
||||
elif '400' in str(e) or '403' in str(e):
|
||||
# For 400/403, logging the body is crucial
|
||||
if hasattr(e, 'response') and e.response is not None:
|
||||
logger.error(f"API Error Body: {e.response.text}")
|
||||
raise e
|
||||
else:
|
||||
raise e
|
||||
raise Exception(f"Failed after {max_attempts} attempts")
|
||||
|
||||
def execute_trade(self, params: Dict[str, Any], target_risk_amount: float = 0.0, max_capital: float = 0.0):
|
||||
"""Starts the trade process by placing a MARKET entry order for immediate execution."""
|
||||
isa_mode = os.getenv("ISA_MODE", "False").lower() == "true"
|
||||
|
||||
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']
|
||||
base_ticker = ticker.split('_')[0]
|
||||
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)
|
||||
self.is_etp = False
|
||||
self.leverage = 1.0
|
||||
|
||||
# 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
|
||||
if isa_mode and direction == "SELL":
|
||||
if base_ticker in INVERSE_TICKER_MAP:
|
||||
inverse_ticker = INVERSE_TICKER_MAP[base_ticker]
|
||||
self.leverage = LEVERAGE_MAP.get(inverse_ticker, 3.0)
|
||||
logger.info(f"ISA Mode Active: Substituting SELL {ticker} with BUY {inverse_ticker} ({self.leverage}x Inverse ETP)")
|
||||
ticker = inverse_ticker
|
||||
direction = "BUY"
|
||||
self.is_etp = True
|
||||
self.params['trading_ticker'] = ticker
|
||||
else:
|
||||
quantity = 1.0 # Fallback
|
||||
logger.warning(f"ISA Mode Active: Cannot Short {ticker} and no inverse ETP found. Setup ignored.")
|
||||
return False
|
||||
else:
|
||||
self.params['trading_ticker'] = ticker
|
||||
|
||||
self.current_quantity = quantity # Save it so monitor_and_bracket can use it
|
||||
approx_price = params.get('current_price', params['entry_price'])
|
||||
|
||||
# Quantity must be negative for Sell (Short)
|
||||
# 2. Position Sizing
|
||||
if self.is_etp:
|
||||
quantity = 1.0
|
||||
logger.info(f"Sizing for ETP {ticker}: Initial attempt with 1.0 share.")
|
||||
else:
|
||||
stop_loss = round(params['stop_loss'], 2)
|
||||
risk_per_share = abs(approx_price - stop_loss)
|
||||
q_risk = target_risk_amount / risk_per_share if risk_per_share > 0 else 0
|
||||
q_capital = (max_capital * 0.95) / approx_price if approx_price > 0 else 0
|
||||
|
||||
if q_risk > 0 and q_capital > 0:
|
||||
quantity = round(min(q_risk, q_capital), 4)
|
||||
if quantity < 0.01: quantity = 0.01
|
||||
else:
|
||||
quantity = 1.0
|
||||
|
||||
self.current_quantity = quantity
|
||||
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}")
|
||||
logger.info(f"Attempting {direction} market order for {ticker} (Qty: {quantity})...")
|
||||
|
||||
# 3. Execution with Smart Retry for Common Broker Errors
|
||||
try:
|
||||
order = self.client.place_limit_order(ticker, trade_quantity, entry_price)
|
||||
order = self._call_with_retry(self.client.place_market_order, ticker, trade_quantity)
|
||||
self.current_order_id = order.get('id')
|
||||
logger.info(f"Order placed successfully. ID: {self.current_order_id}")
|
||||
logger.info(f"Market order placed successfully. ID: {self.current_order_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to place entry order: {e}")
|
||||
if hasattr(e, 'response') and e.response is not None:
|
||||
try:
|
||||
err_data = e.response.json()
|
||||
err_type = err_data.get('type', '')
|
||||
err_detail = err_data.get('detail', '')
|
||||
|
||||
# Error A: Quantity Precision Mismatch
|
||||
if "precision-mismatch" in err_type or "precision" in err_detail.lower():
|
||||
logger.warning(f"Precision mismatch for {ticker}. Retrying with 2 decimal places...")
|
||||
trade_quantity = round(trade_quantity, 2)
|
||||
self.current_quantity = abs(trade_quantity)
|
||||
order = self._call_with_retry(self.client.place_market_order, ticker, trade_quantity)
|
||||
self.current_order_id = order.get('id')
|
||||
return True
|
||||
|
||||
# Error B: Minimum Quantity Exceeded
|
||||
if "min-quantity-exceeded" in err_type:
|
||||
import re
|
||||
match = re.search(r"at least ([\d.]+)", err_detail)
|
||||
if match:
|
||||
min_qty = float(match.group(1))
|
||||
if (min_qty * approx_price) <= (max_capital * 1.05): # Small buffer
|
||||
logger.warning(f"Quantity too low for {ticker}. Upping to minimum: {min_qty}")
|
||||
trade_quantity = -min_qty if direction == "SELL" else min_qty
|
||||
self.current_quantity = min_qty
|
||||
order = self._call_with_retry(self.client.place_market_order, ticker, trade_quantity)
|
||||
self.current_order_id = order.get('id')
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Required minimum {min_qty} exceeds available capital for {ticker}.")
|
||||
except Exception as retry_e:
|
||||
logger.error(f"Retry logic failed for {ticker}: {retry_e}")
|
||||
|
||||
logger.error(f"Failed to place entry market order for {ticker}: {e}")
|
||||
return False
|
||||
|
||||
def _is_ticker_in_portfolio(self, ticker: str) -> bool:
|
||||
"""Helper to check if a ticker currently has an open position."""
|
||||
try:
|
||||
positions = self._call_with_retry(self.client.get_all_open_positions)
|
||||
for pos in positions:
|
||||
if pos.get('ticker') == ticker:
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking portfolio: {e}")
|
||||
return False
|
||||
|
||||
def monitor_and_bracket(self, params: Dict[str, Any]):
|
||||
"""Polls the entry order and places SL/TP once filled."""
|
||||
"""Polls the entry and places physical Stop Loss at the broker."""
|
||||
if not self.current_order_id:
|
||||
return False
|
||||
|
||||
ticker = params['ticker']
|
||||
tp_price = params['target_price']
|
||||
sl_price = params['stop_loss']
|
||||
ticker = params.get('trading_ticker', params['ticker'])
|
||||
quantity = getattr(self, 'current_quantity', 1.0)
|
||||
direction = params['direction']
|
||||
is_etp = getattr(self, 'is_etp', False)
|
||||
leverage = getattr(self, 'leverage', 1.0)
|
||||
|
||||
|
||||
# Wait for entry fill
|
||||
import pytz
|
||||
from datetime import datetime
|
||||
tz = pytz.timezone('US/Eastern')
|
||||
|
||||
actual_entry_price = 0.0
|
||||
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":
|
||||
positions = self._call_with_retry(self.client.get_all_open_positions)
|
||||
for pos in positions:
|
||||
if pos.get('ticker') == ticker:
|
||||
self.is_in_position = True
|
||||
logger.info(f"Entry order filled! Placing SL/TP.")
|
||||
actual_entry_price = float(pos.get('averagePrice', 0.0))
|
||||
logger.info(f"Market filled! Actual Entry: {actual_entry_price:.2f}")
|
||||
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}")
|
||||
logger.debug(f"Waiting for market fill: {e}")
|
||||
time.sleep(2)
|
||||
|
||||
time.sleep(10) # Poll every 10 seconds
|
||||
target_pct = params.get('target_percent', 1.0) / 100.0
|
||||
|
||||
# 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
|
||||
if is_etp:
|
||||
tp_move_pct = target_pct * leverage
|
||||
sl_move_pct = tp_move_pct / 2.0
|
||||
tp_price = actual_entry_price * (1 + tp_move_pct)
|
||||
sl_price = actual_entry_price * (1 - sl_move_pct)
|
||||
sl_qty = -quantity
|
||||
else:
|
||||
range_size = params.get('range_size', 0)
|
||||
if direction == "BUY": # LONG
|
||||
tp_price = actual_entry_price + (range_size * 0.382)
|
||||
risk_distance = (tp_price - actual_entry_price) / 2.0
|
||||
sl_price = actual_entry_price - risk_distance
|
||||
sl_qty = -quantity
|
||||
else: # SHORT (Normal stock)
|
||||
tp_price = actual_entry_price - (range_size * 0.382)
|
||||
risk_distance = (actual_entry_price - tp_price) / 2.0
|
||||
sl_price = actual_entry_price + risk_distance
|
||||
sl_qty = quantity
|
||||
|
||||
tp_price = round(tp_price, 2)
|
||||
sl_price = round(sl_price, 2)
|
||||
|
||||
self.params['final_sl'] = sl_price
|
||||
self.params['final_tp'] = tp_price
|
||||
self.params['final_entry'] = actual_entry_price
|
||||
|
||||
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")
|
||||
logger.info(f"Hybrid Mode: Placing Broker SL for {ticker} @ {sl_price}. Monitoring TP @ {tp_price} manually.")
|
||||
# Use retry with possible precision fix for SL too
|
||||
try:
|
||||
sl_order = self._call_with_retry(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}")
|
||||
|
||||
except Exception as sl_e:
|
||||
if "precision" in str(sl_e).lower():
|
||||
logger.warning(f"Precision mismatch for {ticker} Stop. Retrying with 2 decimals...")
|
||||
sl_qty = round(sl_qty, 2)
|
||||
sl_order = self._call_with_retry(self.client.place_stop_order, ticker, sl_qty, sl_price, time_validity="GOOD_TILL_CANCEL")
|
||||
self.sl_order_id = sl_order.get('id')
|
||||
else:
|
||||
raise sl_e
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to place SL/TP orders: {e}")
|
||||
logger.error(f"Failed to place SL bracket for {ticker}: {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."""
|
||||
"""Checks if SL triggered at broker OR if TP target was hit in market."""
|
||||
if not self.is_in_position:
|
||||
return False, "", 0.0
|
||||
|
||||
ticker = self.params.get('trading_ticker', self.params.get('ticker'))
|
||||
tp_target = self.params.get('final_tp', 0.0)
|
||||
direction = self.params.get('direction', 'BUY')
|
||||
is_etp = getattr(self, 'is_etp', False)
|
||||
|
||||
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)
|
||||
positions = self._call_with_retry(self.client.get_all_open_positions)
|
||||
current_price = 0.0
|
||||
for pos in positions:
|
||||
if pos.get('ticker') == ticker:
|
||||
current_price = float(pos.get('currentPrice', 0.0))
|
||||
break
|
||||
|
||||
if current_price > 0:
|
||||
if (direction == "BUY" or is_etp) and current_price >= tp_target:
|
||||
logger.info(f"Take Profit Target Reached! Price: {current_price} >= {tp_target}")
|
||||
self.close_all(ticker)
|
||||
return True, "TP Hit (Bot)", current_price
|
||||
elif direction == "SELL" and not is_etp and current_price <= tp_target:
|
||||
logger.info(f"Take Profit Target Reached! Price: {current_price} <= {tp_target}")
|
||||
self.close_all(ticker)
|
||||
return True, "TP Hit (Bot)", current_price
|
||||
|
||||
if self.sl_order_id:
|
||||
sl_info = self.client.get_order_status(self.sl_order_id)
|
||||
try:
|
||||
sl_info = self._call_with_retry(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')))
|
||||
fill_price = sl_info.get('filledPrice', sl_info.get('stopPrice', 0))
|
||||
self.is_in_position = False
|
||||
return True, "SL Hit", float(fill_price)
|
||||
return True, "SL Hit (Broker)", float(fill_price)
|
||||
except Exception as e:
|
||||
if "404" in str(e):
|
||||
if not self._is_ticker_in_portfolio(ticker):
|
||||
self.is_in_position = False
|
||||
fallback_price = float(self.params.get('final_sl', 0.0))
|
||||
return True, "SL Hit (Broker)", fallback_price
|
||||
else:
|
||||
raise e
|
||||
|
||||
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}...")
|
||||
"""Forces a close of all open orders and positions. Returns the exit price."""
|
||||
params = getattr(self, 'params', {})
|
||||
trading_ticker = params.get('trading_ticker', ticker)
|
||||
logger.info(f"Closing all orders and positions for {trading_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)
|
||||
try:
|
||||
self._call_with_retry(self.client.cancel_order, self.sl_order_id)
|
||||
self.sl_order_id = None
|
||||
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()
|
||||
positions = self._call_with_retry(self.client.get_all_open_positions)
|
||||
for pos in positions:
|
||||
if pos.get('ticker') == ticker:
|
||||
if pos.get('ticker') == trading_ticker:
|
||||
qty = float(pos.get('quantity', 0))
|
||||
exit_price = float(pos.get('currentPrice', 0.0)) # Use current market price as approx fill
|
||||
exit_price = float(pos.get('currentPrice', 0.0))
|
||||
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)
|
||||
# Try to close with precision fix
|
||||
try:
|
||||
self._call_with_retry(self.client.place_market_order, trading_ticker, -qty)
|
||||
except Exception as close_e:
|
||||
if "precision" in str(close_e).lower():
|
||||
self._call_with_retry(self.client.place_market_order, trading_ticker, round(-qty, 2))
|
||||
else:
|
||||
raise close_e
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to flatten position during emergency exit: {e}")
|
||||
logger.error(f"Failed to flatten position: {e}")
|
||||
|
||||
self.is_in_position = False
|
||||
logger.info("Cleanup complete.")
|
||||
return exit_price
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Mapping of standard US Stock tickers to their 3x Short Inverse ETP counterparts on Trading212 (USD versions)
|
||||
INVERSE_TICKER_MAP = {
|
||||
"TSLA": "3STSl_EQ", # GraniteShares 3x Short Tesla
|
||||
"NVDA": "3SNVl_EQ", # GraniteShares 3x Short NVIDIA
|
||||
"AAPL": "3SAPl_EQ", # GraniteShares 3x Short Apple
|
||||
"AMZN": "3SAMl_EQ", # GraniteShares 3x Short Amazon
|
||||
"NFLX": "3SNFl_EQ", # GraniteShares 3x Short Netflix
|
||||
"MSFT": "3SMSl_EQ", # Leverage Shares -3x Short Microsoft
|
||||
"GOOGL": "3SGOl_EQ", # Leverage Shares -3x Short Alphabet
|
||||
"META": "3SMEl_EQ", # Leverage Shares -3x Short Facebook META
|
||||
"MSTR": "3SMIl_EQ", # GraniteShares 3x Short MicroStrategy
|
||||
"PLTR": "3SPAl_EQ", # GraniteShares 3x Short Palantir
|
||||
"AMD": "SAMDl_EQ", # Leverage Shares -1x AMD (Note: 3x not available in USD for AMD, using -1x)
|
||||
}
|
||||
|
||||
# Leverage factors for the mapped tickers
|
||||
LEVERAGE_MAP = {
|
||||
"3STSl_EQ": 3.0,
|
||||
"3SNVl_EQ": 3.0,
|
||||
"3SAPl_EQ": 3.0,
|
||||
"3SAMl_EQ": 3.0,
|
||||
"3SNFl_EQ": 3.0,
|
||||
"3SMSl_EQ": 3.0,
|
||||
"3SGOl_EQ": 3.0,
|
||||
"3SMEl_EQ": 3.0,
|
||||
"3SMIl_EQ": 3.0,
|
||||
"3SPAl_EQ": 3.0,
|
||||
"SAMDl_EQ": 1.0,
|
||||
}
|
||||
+31
-14
@@ -17,10 +17,11 @@ class TouchTurnStrategy:
|
||||
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):
|
||||
def __init__(self, ticker: str, risk_percent_atr: float = 25.0, rr_ratio: float = 2.0, min_stop_atr_pct: float = 10.0):
|
||||
self.ticker = ticker
|
||||
self.risk_percent_atr = risk_percent_atr
|
||||
self.rr_ratio = rr_ratio
|
||||
self.min_stop_atr_pct = min_stop_atr_pct
|
||||
self.tz = pytz.timezone('US/Eastern')
|
||||
|
||||
self.valid_setup = False
|
||||
@@ -48,7 +49,6 @@ class TouchTurnStrategy:
|
||||
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)
|
||||
|
||||
@@ -59,13 +59,12 @@ class TouchTurnStrategy:
|
||||
if isinstance(intraday_data.columns, pd.MultiIndex):
|
||||
intraday_data.columns = intraday_data.columns.droplevel(1)
|
||||
|
||||
# Timezone Correction: Convert the index to Eastern Time before filtering
|
||||
# Timezone Correction
|
||||
if intraday_data.index.tz is None:
|
||||
intraday_data.index = intraday_data.index.tz_localize('UTC').tz_convert(self.tz)
|
||||
else:
|
||||
intraday_data.index = intraday_data.index.tz_convert(self.tz)
|
||||
|
||||
# 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}")
|
||||
@@ -84,11 +83,12 @@ class TouchTurnStrategy:
|
||||
low = opening_candle['Low']
|
||||
open_p = opening_candle['Open']
|
||||
close_p = opening_candle['Close']
|
||||
range_size = high - low
|
||||
self.range_size = high - low
|
||||
self.current_price = close_p
|
||||
|
||||
# 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})")
|
||||
if self.range_size < (daily_atr * self.risk_percent_atr / 100):
|
||||
logger.info(f"Setup invalid: Range ({self.range_size:.2f}) < 25% of ATR ({daily_atr:.2f})")
|
||||
self.valid_setup = False
|
||||
return False
|
||||
|
||||
@@ -102,23 +102,37 @@ class TouchTurnStrategy:
|
||||
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
|
||||
# 3. Calculate Fibonacci 38.2% Target and Stop Loss with ATR Padding
|
||||
if self.direction == -1: # LONG
|
||||
self.target_price = low + (range_size * 0.382)
|
||||
self.target_price = low + (self.range_size * 0.382)
|
||||
target_distance = self.target_price - self.entry_price
|
||||
stop_distance = target_distance / self.rr_ratio
|
||||
|
||||
# SL Padding: Ensure SL is at least X% of ATR away to avoid noise
|
||||
min_stop = daily_atr * (self.min_stop_atr_pct / 100.0)
|
||||
if stop_distance < min_stop:
|
||||
logger.info(f"Widening SL for {self.ticker} from {stop_distance:.2f} to min ATR distance {min_stop:.2f}")
|
||||
stop_distance = min_stop
|
||||
|
||||
self.stop_loss = self.entry_price - stop_distance
|
||||
else: # SHORT
|
||||
self.target_price = high - (range_size * 0.382)
|
||||
self.target_price = high - (self.range_size * 0.382)
|
||||
target_distance = self.entry_price - self.target_price
|
||||
stop_distance = target_distance / self.rr_ratio
|
||||
|
||||
min_stop = daily_atr * (self.min_stop_atr_pct / 100.0)
|
||||
if stop_distance < min_stop:
|
||||
logger.info(f"Widening SL for {self.ticker} from {stop_distance:.2f} to min ATR distance {min_stop:.2f}")
|
||||
stop_distance = min_stop
|
||||
|
||||
self.stop_loss = self.entry_price + stop_distance
|
||||
|
||||
# 4. Calculate Percentage Move (needed for ETP scaling)
|
||||
target_distance = abs(self.entry_price - self.target_price)
|
||||
self.target_percent = (target_distance / self.entry_price) * 100
|
||||
|
||||
self.valid_setup = True
|
||||
logger.info(f"Valid Setup! Entry: {self.entry_price:.2f}, Target: {self.target_price:.2f}, SL: {self.stop_loss:.2f}")
|
||||
logger.info(f"Valid Setup! Entry: {self.entry_price:.2f}, Target: {self.target_price:.2f} ({self.target_percent:.2f}%), SL: {self.stop_loss:.2f}")
|
||||
return True
|
||||
|
||||
def get_trade_params(self):
|
||||
@@ -130,6 +144,9 @@ class TouchTurnStrategy:
|
||||
"ticker": self.ticker,
|
||||
"direction": "BUY" if self.direction == -1 else "SELL",
|
||||
"entry_price": self.entry_price,
|
||||
"current_price": self.current_price,
|
||||
"target_price": self.target_price,
|
||||
"stop_loss": self.stop_loss
|
||||
"stop_loss": self.stop_loss,
|
||||
"range_size": self.range_size,
|
||||
"target_percent": self.target_percent
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user