Compare commits
16 Commits
dc111abf8c
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 143cdcd976 | |||
| 6010f66323 | |||
| 17ad49c22e | |||
| ec65e86bd9 | |||
| e71e833c71 | |||
| 9e26623fc7 | |||
| fc9bb34d6b | |||
| 5a4c99d0d1 | |||
| 0f5d00e292 | |||
| 9448c613ca | |||
| f2180891fc | |||
| 1cfca22ddd | |||
| deba044a7b | |||
| d1a141a669 | |||
| ede9933c88 | |||
| be4df42e01 |
@@ -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).
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
|||||||
@@ -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?
|
||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user