Compare commits

..

16 Commits

Author SHA1 Message Date
pie 143cdcd976 feat: increase risk per trade from 1% to 5% 2026-06-01 16:30:47 +01:00
pie 6010f66323 fix: simplify logging for systemd compatibility and add ATR-based Stop Loss padding 2026-06-01 16:27:30 +01:00
pie 17ad49c22e logging fix 2026-05-18 17:06:01 +01:00
pie ec65e86bd9 docs: finalize gemini.md with full technical specs and resilience logic 2026-05-15 17:56:07 +01:00
pie e71e833c71 fix: enforce unbuffered logging and verify hybrid exit strategy success 2026-05-15 17:40:28 +01:00
pie 9e26623fc7 fix: improve order resilience with precision and minimum quantity fallbacks, and fix rate-limiting on account fetch 2026-05-14 16:36:40 +01:00
pie fc9bb34d6b todays modifications 2026-05-13 16:49:59 +01:00
pie 5a4c99d0d1 fix: add missing random import and ensure cleanup on thread crash 2026-05-11 16:18:25 +01:00
pie 0f5d00e292 more tweaks 2026-05-09 00:18:42 +01:00
pie 9448c613ca fix: add early api connection verification to morning routine 2026-05-07 16:49:23 +01:00
pie f2180891fc fix: make close_all robust for bypassed setups 2026-05-07 12:00:57 +01:00
pie 1cfca22ddd docs: update documentation to reflect Inverse ETP strategy and virtual balance logic 2026-05-07 11:59:50 +01:00
pie deba044a7b fixes 2026-05-06 09:39:57 +01:00
pie d1a141a669 fix: handle 404 errors by checking portfolio for filled/closed positions 2026-05-04 15:37:34 +01:00
pie ede9933c88 fix: resolve 429 rate limit errors with API backoff and correct timezone handling for 09:30 candle 2026-05-01 15:18:33 +01:00
pie be4df42e01 fix: implement api resilience to handle data delays at open 2026-04-28 18:52:36 +01:00
10 changed files with 569 additions and 314 deletions
+33 -40
View File
@@ -1,53 +1,46 @@
# Trading212 Python Scalping Bot - "Touch & Turn" (Opening Range Reversal) # 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. This project implements the "Touch & Turn" scalping strategy for the Trading212 API, optimized for the UK ISA environment.
## 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) ## 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). 1. **Identify Opening Candle:** Capture the 15m candle (09:30 to 09:45 EST).
2. **Filter for Liquidity:** 2. **Filter for Liquidity:** Opening range must be >= 25% of 14-day ATR.
- 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:** 3. **Determine Direction:**
- If the candle is **Bearish** (Close < Open): Prepare for a **LONG** entry at the `Low`. - Bearish (Close < Open): Prepare **LONG** (Buy at Low).
- If the candle is **Bullish** (Close > Open): Prepare for a **SHORT** entry at the `High`. - Bullish (Close > Open): Prepare **SHORT** (Substitute with **3x Inverse ETP BUY** in ISA).
4. **Calculate Targets (Fibonacci):** 4. **Execution (09:45 EST):**
- The target price is the **38.2% Fibonacci level** of the opening candle's range. - Entry via **Market Order** for immediate fill.
5. **Risk Management:** - **Actual Fill Price** fetched from portfolio is used for all bracket calculations.
- **Take Profit (TP):** The distance from the entry to the 38.2% Fib level. 5. **Hybrid Exit Strategy:**
- **Stop Loss (SL):** Half the TP distance (Risk:Reward ratio of 1:2). - **Broker-Side:** Physical **Stop Loss** order placed immediately for protection.
6. **Automatic Exit:** Force close any open positions at 11:00 EST. - **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 ## Technical Architecture
* **API Client (`src/api/client.py`):** Handles REST calls to Trading212. * **`main.py`:** Daily orchestrator. Scan -> Backtest -> Select Top 3 -> Spawn Parallel Threads. Handles early API verification and unbuffered logging.
* **Strategy Engine (`src/strategy/touch_turn.py`):** * **`src/api/client.py`:** REST wrapper with Basic Auth.
- Monitors the clock for the 09:45 EST trigger. * **`src/strategy/touch_turn.py`:** Setup logic, Fibonacci calculation, and timezone conversion (UTC -> Eastern).
- Fetches 14-day ATR and the 09:30-09:45 15m candle. * **`src/execution/manager.py`:** Handles ticker swapping (Inverse ETPs), market entries, hybrid brackets, and retry loops with jitter.
- Calculates entry/TP/SL levels. * **`src/strategy/inverse_mapping.py`:** Map of US stocks to 3x Short Inverse ETPs (GraniteShares/Leverage Shares).
* **Execution Engine (`src/execution/manager.py`):** Places the limit orders and manages the position lifetime.
## Getting Started ## Resilience Features
1. **Setup Environment:** * **API Backoff:** Random jitter (1-10s) and exponential retry on 429 errors.
```bash * **Order Tracking:** Uses portfolio checks to infer status if order IDs disappear (404).
pip install -r requirements.txt * **Unbuffered Logging:** Force-flushes logs to `logs/bot_*.log` immediately for real-time monitoring.
```
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 ## Operation
- [x] Document the strategy logic. 1. **Timer:** Service managed by `systemd` timer firing at 09:30 America/New_York.
- [x] Implement ATR calculation in the strategy engine. 2. **Tracking:** P&L recorded in `pnl_tracking.csv` (R-multiple based).
- [x] Implement the 15m candle capture logic. 3. **Verification:** Always run `./venv/bin/python3 test_api_connection.py` before live days.
- [x] Implement the entry/exit order placement logic in the execution manager.
- [x] Create a backtesting script (optional but recommended).
+30 -74
View File
@@ -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. 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). 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. 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:** 3. **The Trigger (ISA Optimized):**
- If the opening candle closes **Bearish** (Close < Open), the bot prepares a **LONG** entry at the candle's Low. - **LONG (Bearish candle):** Bot places an immediate **Market BUY** order for the stock.
- If the opening candle closes **Bullish** (Close > Open), the bot prepares a **SHORT** entry at the candle's High. - **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:** 4. **The Targets:**
- **Take Profit (TP):** The 38.2% Fibonacci retracement level of the opening candle's range. - Brackets are placed **immediately** after the market order is filled, using the **Actual Fill Price** from your portfolio.
- **Stop Loss (SL):** Placed to ensure a Risk:Reward ratio of 1:2 (Risking 1 unit to make 2). - **Take Profit (TP):** The 38.2% Fibonacci retracement level.
5. **Time Exit:** All open positions are forcefully closed at 11:00 EST to avoid mid-day chop. - **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 ```bash
python3 -m venv venv python3 -m venv venv
source venv/bin/activate source venv/bin/activate
```
2. **Install dependencies:**
```bash
pip install -r requirements.txt 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:** 2. **Configure Environment Variables:**
Create a `.env` file in the root directory based on `.env.example`: Create a `.env` file in the root directory:
```ini ```ini
TRADING212_API_KEY_ID=your_key_id_here TRADING212_API_KEY_ID=your_key_id_here
TRADING212_API_KEY=your_api_key_here TRADING212_API_KEY=your_api_key_here
TRADING212_BASE_URL=https://demo.trading212.com/api/v0/ 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 ## 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. - **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).
**Virtual Balances:** - **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.
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 ## 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`). 1. **Scan:** Runs the ISA candidate filter to find the most volatile US stocks.
- **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. 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 ## Architecture
* **`src/api/client.py`:** Handles REST HTTP basic authentication and request formatting for the Trading212 API. * **`src/api/client.py`:** REST API wrapper with Basic Auth.
* **`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/touch_turn.py`:** Logic engine and Fibonacci calculator.
* **`src/strategy/scanner.py`:** The ranking engine used to sort tickers by ATR volatility. * **`src/strategy/inverse_mapping.py`:** Map of US stocks to 3x Short Inverse ETPs.
* **`src/execution/manager.py`:** Consumes the trade parameters and places the orders via the API client. * **`src/execution/manager.py`:** Handles market entries, actual fill-based bracketing, and ISA substitutions.
* **`main.py`:** The morning orchestrator.
+152 -80
View File
@@ -1,9 +1,15 @@
import os import os
import sys
# Force unbuffered output for systemd/logging
os.environ['PYTHONUNBUFFERED'] = '1'
import time import time
import logging import logging
import pytz import pytz
import threading import threading
import csv import csv
import random
from datetime import datetime, time as dtime from datetime import datetime, time as dtime
from dotenv import load_dotenv from dotenv import load_dotenv
@@ -17,35 +23,50 @@ from scripts.backtest import backtest_ticker
os.makedirs("logs", exist_ok=True) os.makedirs("logs", exist_ok=True)
log_filename = datetime.now().strftime("logs/bot_%Y-%m-%d.log") log_filename = datetime.now().strftime("logs/bot_%Y-%m-%d.log")
# Configure logging to both console and file # Simple, robust logging setup
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
format='%(asctime)s [%(threadName)s] %(levelname)s - %(message)s', format='%(asctime)s [%(threadName)s] %(levelname)s - %(message)s',
handlers=[ handlers=[
logging.FileHandler(log_filename), logging.FileHandler(log_filename, mode='a'),
logging.StreamHandler() logging.StreamHandler(sys.stdout)
] ]
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def flush_logs():
for handler in logging.getLogger().handlers:
handler.flush()
PNL_FILE = "pnl_tracking.csv" 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.""" """Appends the result of a closed trade to the PnL CSV."""
file_exists = os.path.isfile(PNL_FILE) 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: with open(PNL_FILE, mode='a', newline='') as file:
writer = csv.writer(file) writer = csv.writer(file)
if not file_exists: 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") 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): def calculate_r_multiple(direction, entry_price, exit_price, stop_loss):
"""Calculates the PnL in terms of Risk Multiples (R).""" """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 if direction == "BUY": # LONG
risk = entry_price - stop_loss risk = entry_price - stop_loss
return (exit_price - entry_price) / risk if risk != 0 else 0 return (exit_price - entry_price) / risk if risk != 0 else 0
@@ -53,82 +74,124 @@ def calculate_r_multiple(direction, entry_price, exit_price, stop_loss):
risk = stop_loss - entry_price risk = stop_loss - entry_price
return (entry_price - exit_price) / risk if risk != 0 else 0 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.""" """Handles the full strategy lifecycle for a single ticker in its own thread, then exits."""
strategy = TouchTurnStrategy(yf_ticker) strategy = TouchTurnStrategy(yf_ticker)
execution = ExecutionManager(client) execution = ExecutionManager(client)
logger.info(f"Bot thread started for {yf_ticker} ({t212_ticker}).") logger.info(f"Bot thread started for {yf_ticker} ({t212_ticker}).")
now = datetime.now(tz) # Initialize variables outside the retry loop to prevent UnboundLocalError
target_entry_time = now.replace(hour=9, minute=45, second=0, microsecond=0) risk_share = 12.50 / num_tickers
capital_share = 250.0 / num_tickers
# 1. Wait until 09:45 EST try:
if now < target_entry_time: now = datetime.now(tz)
wait_seconds = (target_entry_time - now).total_seconds() target_entry_time = now.replace(hour=9, minute=45, second=0, microsecond=0)
logger.info(f"Waiting {wait_seconds:.0f} seconds until 09:45 EST evaluation...")
time.sleep(wait_seconds)
# Re-evaluate current time # 1. Wait until 09:45 EST
now = datetime.now(tz) 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)
if now.hour == 9 and now.minute >= 45: # Re-evaluate current time
logger.info(f"Evaluating opening candle for {yf_ticker}...") now = datetime.now(tz)
if strategy.check_setup():
params = strategy.get_trade_params()
params['ticker'] = t212_ticker
# Fetch Account Balance to calculate risk if now.hour == 9 and now.minute >= 45:
try: logger.info(f"Evaluating opening candle for {yf_ticker}...")
account_info = client.get_account_info()
virtual_balance = float(os.getenv("VIRTUAL_STARTING_BALANCE", 0))
if virtual_balance > 0: # Retry loop: wait for yfinance to publish the 09:30-09:45 candle
risk_amount = virtual_balance * 0.01 setup_found = False
else: max_retries = 12
available_cash = account_info.get('cash', {}).get('availableToTrade', 1000) for attempt in range(max_retries):
risk_amount = available_cash * 0.01 if strategy.check_setup():
except Exception as e: setup_found = True
logger.error(f"Failed to fetch account info for risk calculation: {e}. Defaulting to £2.50 risk.") break
risk_amount = 2.50 elif attempt < max_retries - 1:
logger.debug(f"Data not ready for {yf_ticker} yet, waiting 15s...")
time.sleep(15)
if execution.execute_trade(params, target_risk_amount=risk_amount): if setup_found:
# monitor_and_bracket is blocking, wait for fill (times out at 11:00) params = strategy.get_trade_params()
if execution.monitor_and_bracket(params): params['ticker'] = t212_ticker
# 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) # Anti-thundering-herd: Random jitter to prevent 429s from parallel threads
# 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
for attempt in range(3):
try:
account_info = client.get_account_info()
actual_balance = float(account_info.get('totalValue', 5000.0))
virtual_balance = max(0, actual_balance - 4750.0)
# 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):
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}")
break
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:
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
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)
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:
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: else:
logger.info(f"No valid setup today for {yf_ticker}. Thread exiting.") execution.close_all(t212_ticker)
return
# 2. Wait until 11:00 EST for Forced Exit (if we are still in a position or have pending orders) logger.info(f"Lifecycle complete for {yf_ticker}. Thread exiting.")
now = datetime.now(tz) flush_logs()
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(): def main():
load_dotenv() load_dotenv()
@@ -139,12 +202,10 @@ def main():
now = datetime.now(tz) now = datetime.now(tz)
# Safety Guard: Check if it's a weekend if now.weekday() >= 5:
if now.weekday() >= 5: # 5 = Saturday, 6 = Sunday
logger.warning("Weekend detected. The market is closed. Exiting cleanly.") logger.warning("Weekend detected. The market is closed. Exiting cleanly.")
return 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: 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.") logger.warning(f"Bot executed at {now.strftime('%H:%M')} EST. Expected launch window is 09:00 - 09:40 EST. Exiting cleanly.")
return return
@@ -155,7 +216,16 @@ def main():
client = Trading212Client(api_key_id, api_key, base_url) 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...") logger.info("Starting Morning Routine: Finding ISA Candidates...")
candidates_df = find_best_isa_tickers() candidates_df = find_best_isa_tickers()
@@ -163,11 +233,9 @@ def main():
logger.error("No candidates found. Exiting.") logger.error("No candidates found. Exiting.")
return return
# 2. Morning Routine: Backtest Candidates to find the 'Edge'
logger.info("Running Backtests on candidates to find current winners...") logger.info("Running Backtests on candidates to find current winners...")
profitable_tickers = [] profitable_tickers = []
# We'll test the top 10 candidates from the scanner
for _, row in candidates_df.head(10).iterrows(): for _, row in candidates_df.head(10).iterrows():
yf_t = row['Ticker'] yf_t = row['Ticker']
t212_t = row['T212_Ticker'] t212_t = row['T212_Ticker']
@@ -180,10 +248,7 @@ def main():
'pnl': res['Net PnL (R)'] 'pnl': res['Net PnL (R)']
}) })
# Sort by best backtest performance
profitable_tickers.sort(key=lambda x: x['pnl'], reverse=True) profitable_tickers.sort(key=lambda x: x['pnl'], reverse=True)
# Select Top 3
final_watchlist = profitable_tickers[:3] final_watchlist = profitable_tickers[:3]
if not final_watchlist: if not final_watchlist:
@@ -192,12 +257,12 @@ def main():
logger.info(f"Final Watchlist for today: {[t['yf'] for t in final_watchlist]}") logger.info(f"Final Watchlist for today: {[t['yf'] for t in final_watchlist]}")
# 3. Launch execution threads
threads = [] threads = []
num_active = len(final_watchlist)
for ticker_info in final_watchlist: for ticker_info in final_watchlist:
t = threading.Thread( t = threading.Thread(
target=run_ticker_lifecycle, 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']}" name=f"Bot-{ticker_info['yf']}"
) )
t.start() t.start()
@@ -209,6 +274,13 @@ def main():
t.join() t.join()
logger.info("All threads completed. Bot shutting down for the day.") logger.info("All threads completed. Bot shutting down for the day.")
flush_logs()
if __name__ == "__main__": if __name__ == "__main__":
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.")
+4
View File
@@ -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?
+8
View File
@@ -0,0 +1,8 @@
import pandas as pd
import yfinance as yf
from scripts.backtest import backtest_ticker
print("Running historical check for the last 5 trading days...")
for ticker in ["NFLX", "AMZN", "AAPL"]:
res = backtest_ticker(ticker, days=5, quiet=False)
print("="*60)
+226 -98
View File
@@ -1,13 +1,18 @@
import time import time
import logging import logging
import os
import random
import json
from typing import Dict, Any, Optional from typing import Dict, Any, Optional
from src.api.client import Trading212Client from src.api.client import Trading212Client
from src.strategy.inverse_mapping import INVERSE_TICKER_MAP, LEVERAGE_MAP
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class ExecutionManager: 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): def __init__(self, client: Trading212Client):
self.client = client self.client = client
@@ -15,163 +20,286 @@ class ExecutionManager:
self.sl_order_id = None self.sl_order_id = None
self.tp_order_id = None self.tp_order_id = None
self.is_in_position = False 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 self.params = params
ticker = params['ticker'] ticker = params['ticker']
base_ticker = ticker.split('_')[0]
direction = params['direction'] direction = params['direction']
entry_price = params['entry_price']
stop_loss = params['stop_loss']
# Calculate Risk per share self.is_etp = False
risk_per_share = abs(entry_price - stop_loss) self.leverage = 1.0
# Position Sizing if isa_mode and direction == "SELL":
if target_risk_amount > 0 and risk_per_share > 0: if base_ticker in INVERSE_TICKER_MAP:
quantity = round(target_risk_amount / risk_per_share, 4) # T212 allows fractional shares inverse_ticker = INVERSE_TICKER_MAP[base_ticker]
# Enforce a minimum of 0.01 or whatever the broker allows, but we'll trust the math here self.leverage = LEVERAGE_MAP.get(inverse_ticker, 3.0)
if quantity < 0.01: logger.info(f"ISA Mode Active: Substituting SELL {ticker} with BUY {inverse_ticker} ({self.leverage}x Inverse ETP)")
quantity = 0.01 ticker = inverse_ticker
direction = "BUY"
self.is_etp = True
self.params['trading_ticker'] = ticker
else:
logger.warning(f"ISA Mode Active: Cannot Short {ticker} and no inverse ETP found. Setup ignored.")
return False
else: else:
quantity = 1.0 # Fallback 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 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"Attempting {direction} market order for {ticker} (Qty: {quantity})...")
logger.info(f"Placing entry {direction} limit order for {ticker} at {entry_price:.2f}")
# 3. Execution with Smart Retry for Common Broker Errors
try: 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') 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 return True
except Exception as e: 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 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]): 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: if not self.current_order_id:
return False return False
ticker = params['ticker'] ticker = params.get('trading_ticker', params['ticker'])
tp_price = params['target_price']
sl_price = params['stop_loss']
quantity = getattr(self, 'current_quantity', 1.0) quantity = getattr(self, 'current_quantity', 1.0)
direction = params['direction']
is_etp = getattr(self, 'is_etp', False)
leverage = getattr(self, 'leverage', 1.0)
actual_entry_price = 0.0
# Wait for entry fill
import pytz
from datetime import datetime
tz = pytz.timezone('US/Eastern')
while not self.is_in_position: 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: try:
status_info = self.client.get_order_status(self.current_order_id) positions = self._call_with_retry(self.client.get_all_open_positions)
status = status_info.get('status') for pos in positions:
logger.info(f"Entry order {self.current_order_id} status: {status}") if pos.get('ticker') == ticker:
self.is_in_position = True
if status == "FILLED": actual_entry_price = float(pos.get('averagePrice', 0.0))
self.is_in_position = True logger.info(f"Market filled! Actual Entry: {actual_entry_price:.2f}")
logger.info(f"Entry order filled! Placing SL/TP.") break
break
elif status in ["CANCELLED", "REJECTED"]:
logger.warning(f"Entry order was {status}. Aborting.")
return False
except Exception as e: 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 if is_etp:
# SL is a Stop order in the opposite direction tp_move_pct = target_pct * leverage
# TP is a Limit order in the opposite direction sl_move_pct = tp_move_pct / 2.0
sl_qty = -quantity if params['direction'] == "BUY" else quantity tp_price = actual_entry_price * (1 + tp_move_pct)
tp_qty = -quantity if params['direction'] == "BUY" else quantity 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: try:
# Place TP (Limit) logger.info(f"Hybrid Mode: Placing Broker SL for {ticker} @ {sl_price}. Monitoring TP @ {tp_price} manually.")
tp_order = self.client.place_limit_order(ticker, tp_qty, tp_price, time_validity="GOOD_TILL_CANCEL") # Use retry with possible precision fix for SL too
self.tp_order_id = tp_order.get('id') try:
logger.info(f"TP order placed: {self.tp_order_id}") 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')
# Place SL (Stop) except Exception as sl_e:
sl_order = self.client.place_stop_order(ticker, sl_qty, sl_price, time_validity="GOOD_TILL_CANCEL") if "precision" in str(sl_e).lower():
self.sl_order_id = sl_order.get('id') logger.warning(f"Precision mismatch for {ticker} Stop. Retrying with 2 decimals...")
logger.info(f"SL order placed: {self.sl_order_id}") 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 return True
except Exception as e: 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 return False
def check_exit_status(self) -> tuple[bool, str, float]: 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: if not self.is_in_position:
return False, "", 0.0 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: try:
if self.tp_order_id: positions = self._call_with_retry(self.client.get_all_open_positions)
tp_info = self.client.get_order_status(self.tp_order_id) current_price = 0.0
if tp_info.get('status') == "FILLED": for pos in positions:
fill_price = tp_info.get('filledPrice', tp_info.get('limitPrice', self.params.get('target_price'))) if pos.get('ticker') == ticker:
self.is_in_position = False current_price = float(pos.get('currentPrice', 0.0))
return True, "TP Hit", float(fill_price) 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: if self.sl_order_id:
sl_info = self.client.get_order_status(self.sl_order_id) try:
if sl_info.get('status') == "FILLED": sl_info = self._call_with_retry(self.client.get_order_status, self.sl_order_id)
fill_price = sl_info.get('filledPrice', sl_info.get('stopPrice', self.params.get('stop_loss'))) if sl_info.get('status') == "FILLED":
self.is_in_position = False fill_price = sl_info.get('filledPrice', sl_info.get('stopPrice', 0))
return True, "SL Hit", float(fill_price) self.is_in_position = False
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: except Exception as e:
logger.error(f"Error checking exit status: {e}") logger.error(f"Error checking exit status: {e}")
return False, "", 0.0 return False, "", 0.0
def close_all(self, ticker: str) -> float: def close_all(self, ticker: str) -> float:
"""Forces a close of all open orders and positions. Returns the exit price (or 0.0).""" """Forces a close of all open orders and positions. Returns the exit price."""
logger.info(f"Closing all orders and positions for {ticker}...") 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: 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 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 exit_price = 0.0
# Flatten any active position
if self.is_in_position: if self.is_in_position:
try: try:
positions = self.client.get_all_open_positions() positions = self._call_with_retry(self.client.get_all_open_positions)
for pos in positions: for pos in positions:
if pos.get('ticker') == ticker: if pos.get('ticker') == trading_ticker:
qty = float(pos.get('quantity', 0)) 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: if qty != 0:
# To close, we sell if we are long (positive qty), buy if short (negative qty) # Try to close with precision fix
exit_qty = -qty try:
logger.info(f"Flattening position: Placing market order for {exit_qty} shares of {ticker} at approx {exit_price}") self._call_with_retry(self.client.place_market_order, trading_ticker, -qty)
self.client.place_market_order(ticker, exit_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 break
except Exception as e: 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 self.is_in_position = False
logger.info("Cleanup complete.")
return exit_price return exit_price
+29
View File
@@ -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,
}
+9 -3
View File
@@ -40,14 +40,20 @@ def scan_for_candidates(tickers: List[str] = DEFAULT_TICKERS, min_price: float =
df.ta.atr(length=14, append=True) df.ta.atr(length=14, append=True)
latest = df.iloc[-1] # Safely get the close price, falling back to yesterday if today's is NaN (common at exactly 09:30)
close_price = df['Close'].iloc[-1]
if pd.isna(close_price) and len(df) > 1:
close_price = df['Close'].iloc[-2]
yesterday_atr = df['ATRr_14'].iloc[-2] yesterday_atr = df['ATRr_14'].iloc[-2]
close_price = latest['Close'] # Safely get avg volume
avg_volume = df['Volume'].tail(14).mean() avg_volume = df['Volume'].tail(14).mean()
if pd.isna(avg_volume):
avg_volume = 0
# Filters # Filters
if close_price < min_price or avg_volume < min_volume or pd.isna(yesterday_atr): if pd.isna(close_price) or close_price < min_price or avg_volume < min_volume or pd.isna(yesterday_atr):
continue continue
atr_percent = (yesterday_atr / close_price) * 100 atr_percent = (yesterday_atr / close_price) * 100
+36 -13
View File
@@ -17,10 +17,11 @@ class TouchTurnStrategy:
4. Bullish candle -> Short at High, 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): 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.ticker = ticker
self.risk_percent_atr = risk_percent_atr self.risk_percent_atr = risk_percent_atr
self.rr_ratio = rr_ratio self.rr_ratio = rr_ratio
self.min_stop_atr_pct = min_stop_atr_pct
self.tz = pytz.timezone('US/Eastern') self.tz = pytz.timezone('US/Eastern')
self.valid_setup = False self.valid_setup = False
@@ -48,7 +49,6 @@ class TouchTurnStrategy:
daily_atr = daily_data['ATRr_14'].iloc[-2] # Use yesterday's ATR daily_atr = daily_data['ATRr_14'].iloc[-2] # Use yesterday's ATR
# 2. Fetch 15m Candle for today's opening (09:30 - 09:45) # 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') start_date = now.strftime('%Y-%m-%d')
intraday_data = yf.download(self.ticker, start=start_date, interval="15m", progress=False) intraday_data = yf.download(self.ticker, start=start_date, interval="15m", progress=False)
@@ -59,7 +59,12 @@ class TouchTurnStrategy:
if isinstance(intraday_data.columns, pd.MultiIndex): if isinstance(intraday_data.columns, pd.MultiIndex):
intraday_data.columns = intraday_data.columns.droplevel(1) intraday_data.columns = intraday_data.columns.droplevel(1)
# The first candle of the session (09:30) # 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)
opening_candle = intraday_data.between_time('09:30', '09:30') opening_candle = intraday_data.between_time('09:30', '09:30')
if opening_candle.empty: if opening_candle.empty:
logger.warning(f"Opening 15m candle (09:30) not yet available for {self.ticker}") logger.warning(f"Opening 15m candle (09:30) not yet available for {self.ticker}")
@@ -78,11 +83,12 @@ class TouchTurnStrategy:
low = opening_candle['Low'] low = opening_candle['Low']
open_p = opening_candle['Open'] open_p = opening_candle['Open']
close_p = opening_candle['Close'] close_p = opening_candle['Close']
range_size = high - low self.range_size = high - low
self.current_price = close_p
# 1. Liquidity Filter # 1. Liquidity Filter
if range_size < (daily_atr * self.risk_percent_atr / 100): if self.range_size < (daily_atr * self.risk_percent_atr / 100):
logger.info(f"Setup invalid: Range ({range_size:.2f}) < 25% of ATR ({daily_atr:.2f})") logger.info(f"Setup invalid: Range ({self.range_size:.2f}) < 25% of ATR ({daily_atr:.2f})")
self.valid_setup = False self.valid_setup = False
return False return False
@@ -96,23 +102,37 @@ class TouchTurnStrategy:
self.entry_price = high self.entry_price = high
logger.info(f"Bullish opening candle detected. Preparing SHORT at {self.entry_price:.2f}") logger.info(f"Bullish opening candle detected. Preparing SHORT at {self.entry_price:.2f}")
# 3. Calculate Fibonacci 38.2% Target # 3. Calculate Fibonacci 38.2% Target and Stop Loss with ATR Padding
# 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 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 target_distance = self.target_price - self.entry_price
stop_distance = target_distance / self.rr_ratio 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 self.stop_loss = self.entry_price - stop_distance
else: # SHORT 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 target_distance = self.entry_price - self.target_price
stop_distance = target_distance / self.rr_ratio 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 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 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 return True
def get_trade_params(self): def get_trade_params(self):
@@ -124,6 +144,9 @@ class TouchTurnStrategy:
"ticker": self.ticker, "ticker": self.ticker,
"direction": "BUY" if self.direction == -1 else "SELL", "direction": "BUY" if self.direction == -1 else "SELL",
"entry_price": self.entry_price, "entry_price": self.entry_price,
"current_price": self.current_price,
"target_price": self.target_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
} }
+36
View File
@@ -0,0 +1,36 @@
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_order():
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/")
client = Trading212Client(api_key_id, api_key, base_url)
logger.info("Attempting to place a test Limit Order (Buy 0.1 AAPL @ $1.00)...")
try:
# We place a limit order far away from the current price so it won't fill
response = client.place_limit_order("AAPL_US_EQ", 0.1, 1.00)
logger.info(f"Success! Response: {response}")
# If successful, immediately cancel it
order_id = response.get('id')
if order_id:
logger.info(f"Cancelling test order {order_id}...")
client.cancel_order(order_id)
logger.info("Cancelled.")
except Exception as e:
logger.error(f"Failed: {e}")
# If it's a requests HTTPError, let's print the raw response text for debugging
if hasattr(e, 'response') and e.response is not None:
logger.error(f"API Response Body: {e.response.text}")
if __name__ == "__main__":
test_order()