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)
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.
+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.
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.
+155 -83
View File
@@ -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,82 +74,124 @@ 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}).")
now = datetime.now(tz)
target_entry_time = now.replace(hour=9, minute=45, second=0, microsecond=0)
# Initialize variables outside the retry loop to prevent UnboundLocalError
risk_share = 12.50 / num_tickers
capital_share = 250.0 / num_tickers
# 1. Wait until 09:45 EST
if now < target_entry_time:
wait_seconds = (target_entry_time - now).total_seconds()
logger.info(f"Waiting {wait_seconds:.0f} seconds until 09:45 EST evaluation...")
time.sleep(wait_seconds)
# Re-evaluate current time
now = datetime.now(tz)
if now.hour == 9 and now.minute >= 45:
logger.info(f"Evaluating opening candle for {yf_ticker}...")
if strategy.check_setup():
params = strategy.get_trade_params()
params['ticker'] = t212_ticker
try:
now = datetime.now(tz)
target_entry_time = now.replace(hour=9, minute=45, second=0, microsecond=0)
# 1. Wait until 09:45 EST
if now < target_entry_time:
wait_seconds = (target_entry_time - now).total_seconds()
logger.info(f"Waiting {wait_seconds:.0f} seconds until 09:45 EST evaluation...")
time.sleep(wait_seconds)
# Re-evaluate current time
now = datetime.now(tz)
if now.hour == 9 and now.minute >= 45:
logger.info(f"Evaluating opening candle for {yf_ticker}...")
# Fetch Account Balance to calculate risk
try:
account_info = client.get_account_info()
virtual_balance = float(os.getenv("VIRTUAL_STARTING_BALANCE", 0))
# Retry loop: wait for yfinance to publish the 09:30-09:45 candle
setup_found = False
max_retries = 12
for attempt in range(max_retries):
if strategy.check_setup():
setup_found = True
break
elif attempt < max_retries - 1:
logger.debug(f"Data not ready for {yf_ticker} yet, waiting 15s...")
time.sleep(15)
if setup_found:
params = strategy.get_trade_params()
params['ticker'] = t212_ticker
if virtual_balance > 0:
risk_amount = virtual_balance * 0.01
else:
available_cash = account_info.get('cash', {}).get('availableToTrade', 1000)
risk_amount = available_cash * 0.01
except Exception as e:
logger.error(f"Failed to fetch account info for risk calculation: {e}. Defaulting to £2.50 risk.")
risk_amount = 2.50
# 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_amount):
# monitor_and_bracket is blocking, wait for fill (times out at 11:00)
if execution.monitor_and_bracket(params):
# Position is open, monitor for exit via SL/TP
while datetime.now(tz).hour < 11:
is_closed, reason, exit_price = execution.check_exit_status()
if is_closed:
pnl_r = calculate_r_multiple(params['direction'], params['entry_price'], exit_price, params['stop_loss'])
record_pnl(yf_ticker, params['direction'], params['entry_price'], exit_price, reason, pnl_r)
break # Exit the monitoring loop
time.sleep(15)
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:
logger.info(f"No valid setup today for {yf_ticker}. Thread exiting.")
return
execution.close_all(t212_ticker)
# 2. Wait until 11:00 EST for Forced Exit (if we are still in a position or have pending orders)
now = datetime.now(tz)
target_exit_time = now.replace(hour=11, minute=0, second=0, microsecond=0)
if now < target_exit_time and execution.is_in_position:
wait_seconds = (target_exit_time - now).total_seconds()
logger.info(f"Waiting {wait_seconds:.0f} seconds until 11:00 EST forced exit...")
time.sleep(wait_seconds)
# 3. 11:00 EST - Cleanup
logger.info(f"Time exit reached for {yf_ticker}. Cleaning up.")
if execution.is_in_position:
exit_price = execution.close_all(t212_ticker)
if hasattr(execution, 'params') and exit_price > 0:
pnl_r = calculate_r_multiple(execution.params['direction'], execution.params['entry_price'], exit_price, execution.params['stop_loss'])
record_pnl(yf_ticker, execution.params['direction'], execution.params['entry_price'], exit_price, "11:00 Time Exit", pnl_r)
else:
# Cleanup any pending orders if entry wasn't filled
execution.close_all(t212_ticker)
logger.info(f"Lifecycle complete for {yf_ticker}. Thread exiting.")
logger.info(f"Lifecycle complete for {yf_ticker}. Thread exiting.")
flush_logs()
def main():
load_dotenv()
@@ -139,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
@@ -155,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()
@@ -163,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']
@@ -180,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:
@@ -192,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()
@@ -209,6 +274,13 @@ def main():
t.join()
logger.info("All threads completed. Bot shutting down for the day.")
flush_logs()
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)
+228 -100
View File
@@ -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:
logger.warning(f"ISA Mode Active: Cannot Short {ticker} and no inverse ETP found. Setup ignored.")
return False
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
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)
# Wait for entry fill
import pytz
from datetime import datetime
tz = pytz.timezone('US/Eastern')
direction = params['direction']
is_etp = getattr(self, 'is_etp', False)
leverage = getattr(self, 'leverage', 1.0)
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":
self.is_in_position = True
logger.info(f"Entry order filled! Placing SL/TP.")
break
elif status in ["CANCELLED", "REJECTED"]:
logger.warning(f"Entry order was {status}. Aborting.")
return False
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
actual_entry_price = float(pos.get('averagePrice', 0.0))
logger.info(f"Market filled! Actual Entry: {actual_entry_price:.2f}")
break
except Exception as e:
logger.error(f"Error checking order status: {e}")
time.sleep(10) # Poll every 10 seconds
logger.debug(f"Waiting for market fill: {e}")
time.sleep(2)
# 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
target_pct = params.get('target_percent', 1.0) / 100.0
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")
self.sl_order_id = sl_order.get('id')
logger.info(f"SL order placed: {self.sl_order_id}")
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')
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)
if sl_info.get('status') == "FILLED":
fill_price = sl_info.get('filledPrice', sl_info.get('stopPrice', self.params.get('stop_loss')))
self.is_in_position = False
return True, "SL Hit", float(fill_price)
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', 0))
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:
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)
except: pass
if self.tp_order_id:
try: self.client.cancel_order(self.tp_order_id)
try:
self._call_with_retry(self.client.cancel_order, self.sl_order_id)
self.sl_order_id = None
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
+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)
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]
close_price = latest['Close']
# Safely get avg volume
avg_volume = df['Volume'].tail(14).mean()
if pd.isna(avg_volume):
avg_volume = 0
# 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
atr_percent = (yesterday_atr / close_price) * 100
+37 -14
View File
@@ -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,7 +59,12 @@ class TouchTurnStrategy:
if isinstance(intraday_data.columns, pd.MultiIndex):
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')
if opening_candle.empty:
logger.warning(f"Opening 15m candle (09:30) not yet available for {self.ticker}")
@@ -78,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
@@ -96,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):
@@ -123,7 +143,10 @@ class TouchTurnStrategy:
return {
"ticker": self.ticker,
"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,
"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()