feat: implement Split-Account Mode for ISA/CFD hybrid execution

This commit is contained in:
pie
2026-07-03 16:52:10 +01:00
parent 7a8f41c57f
commit 41c81bb864
5 changed files with 137 additions and 118 deletions
+12 -2
View File
@@ -1,8 +1,18 @@
TRADING212_API_KEY_ID=your_practice_api_key_id_here # Primary Account (ISA - for LONG trades)
TRADING212_API_KEY=your_practice_api_key_here TRADING212_API_KEY_ID=your_isa_api_key_id_here
TRADING212_API_KEY=your_isa_api_key_here
TRADING212_BASE_URL=https://demo.trading212.com/api/v0/ TRADING212_BASE_URL=https://demo.trading212.com/api/v0/
# Optional: Secondary Account (CFD - for SHORT trades)
# If provided, SPLIT_ACCOUNT_MODE will allow direct shorting of any stock.
CFD_API_KEY_ID=your_cfd_api_key_id_here
CFD_API_KEY=your_cfd_api_key_here
CFD_BASE_URL=https://demo.trading212.com/api/v0/
SPLIT_ACCOUNT_MODE=True
# Optional: Override the demo account's large starting balance (e.g. 5000) # Optional: Override the demo account's large starting balance (e.g. 5000)
# with a smaller amount to keep position sizing realistic for your future live account. # with a smaller amount to keep position sizing realistic for your future live account.
VIRTUAL_STARTING_BALANCE=250 VIRTUAL_STARTING_BALANCE=250
ISA_MODE=True
+14 -12
View File
@@ -1,6 +1,6 @@
# 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 for the Trading212 API, optimized for the UK ISA environment. This project implements the "Touch & Turn" scalping strategy for the Trading212 API, optimized for the UK ISA environment with optional CFD integration for shorting.
## Strategy Logic (The Workflow) ## Strategy Logic (The Workflow)
@@ -8,39 +8,41 @@ This project implements the "Touch & Turn" scalping strategy for the Trading212
2. **Filter for Liquidity:** Opening range must be >= 25% of 14-day ATR. 2. **Filter for Liquidity:** Opening range must be >= 25% of 14-day ATR.
3. **Determine Direction:** 3. **Determine Direction:**
- Bearish (Close < Open): Prepare **LONG** (Buy at Low). - Bearish (Close < Open): Prepare **LONG** (Buy at Low).
- Bullish (Close > Open): Prepare **SHORT** (Substitute with **3x Inverse ETP BUY** in ISA). - Bullish (Close > Open): Prepare **SHORT** (Sell at High).
4. **Execution (09:45 EST):** 4. **Execution (09:45 EST):**
- Entry via **Market Order** for immediate fill. - Entry via **Market Order** for immediate fill.
- **Actual Fill Price** fetched from portfolio is used for all bracket calculations. - **Actual Fill Price** fetched from portfolio is used for all bracket calculations.
5. **Hybrid Exit Strategy:** 5. **Split-Account Routing (ISA / CFD):**
- **ISA Account:** Used for all **LONG** trades and **SHORT** trades where an Inverse ETP is available.
- **CFD Account:** Used for **SHORT** trades on stocks without Inverse ETPs (requires `SPLIT_ACCOUNT_MODE=True`).
6. **Hybrid Exit Strategy:**
- **Broker-Side:** Physical **Stop Loss** order placed immediately for protection. - **Broker-Side:** Physical **Stop Loss** order placed immediately for protection.
- **Bot-Side:** **Take Profit** monitored manually by polling current market price. - **Bot-Side:** **Take Profit** monitored manually by polling current market price.
- This bypasses ISA restrictions against multiple pending sell orders for the same shares. 7. **Automatic Exit (11:00 EST):** Force close via Market Order and cleanup pending SL.
6. **Automatic Exit (11:00 EST):** Force close via Market Order and cleanup pending SL.
## Risk & Capital Management ## Risk & Capital Management
* **Virtual Balance Simulation:** In demo mode, subtracts £4,750 from total equity to simulate a realistic £250 starting point. * **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. * **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). * **Capital Partitioning:** Divides total available capital (£250) and risk budget equally among all active ticker threads for the day (max 3) per account.
* **Precision & Minimums:** Automatically detects "precision-mismatch" or "min-quantity-exceeded" errors from T212 and retries with corrected values. * **Precision & Minimums:** Automatically detects "precision-mismatch" or "min-quantity-exceeded" errors from T212 and retries with corrected values.
## Technical Architecture ## Technical Architecture
* **`main.py`:** Daily orchestrator. Scan -> Backtest -> Select Top 3 -> Spawn Parallel Threads. Handles early API verification and unbuffered logging. * **`main.py`:** Daily orchestrator. Handles dual-account initialization, trade routing, and parallel thread management.
* **`src/api/client.py`:** REST wrapper with Basic Auth. * **`src/api/client.py`:** REST wrapper with Basic Auth.
* **`src/strategy/touch_turn.py`:** Setup logic, Fibonacci calculation, and timezone conversion (UTC -> Eastern). * **`src/strategy/touch_turn.py`:** Setup logic, Fibonacci calculation, and ATR-based SL padding.
* **`src/execution/manager.py`:** Handles ticker swapping (Inverse ETPs), market entries, hybrid brackets, and retry loops with jitter. * **`src/execution/manager.py`:** Handles ticker swapping (Inverse ETPs), market entries, hybrid brackets, and retry loops.
* **`src/strategy/inverse_mapping.py`:** Map of US stocks to 3x Short Inverse ETPs (GraniteShares/Leverage Shares). * **`src/strategy/inverse_mapping.py`:** Map of US stocks to 3x Short Inverse ETPs for ISA shorting.
## Resilience Features ## Resilience Features
* **API Backoff:** Random jitter (1-10s) and exponential retry on 429 errors. * **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). * **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. * **Aggressive Logging:** Custom `HardFlushHandler` uses `os.fsync()` to ensure real-time log writes to disk.
## Operation ## Operation
1. **Timer:** Service managed by `systemd` timer firing at 09:30 America/New_York. 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). 2. **Tracking:** P&L recorded in `pnl_tracking.csv`.
3. **Verification:** Always run `./venv/bin/python3 test_api_connection.py` before live days. 3. **Verification:** Always run `./venv/bin/python3 test_api_connection.py` before live days.
+42 -36
View File
@@ -1,6 +1,6 @@
# Trading212 "Touch & Turn" Scalping Bot # Trading212 "Touch & Turn" Scalping Bot
This project implements the "Touch & Turn" scalping strategy (Opening Range Liquidity Reversal) in Python for the Trading212 API. It is specifically designed to trade US Equities at the 09:30 EST market open. This project implements the "Touch & Turn" scalping strategy (Opening Range Liquidity Reversal) in Python for the Trading212 API. It is optimized for UK traders using ISA and optional CFD accounts.
## ⚠️ Disclaimer ## ⚠️ Disclaimer
**This software is for educational purposes only.** Trading in financial markets involves a high degree of risk. Always use the practice/demo environment (`demo.trading212.com`) to test strategies before using real money. **This software is for educational purposes only.** Trading in financial markets involves a high degree of risk. Always use the practice/demo environment (`demo.trading212.com`) to test strategies before using real money.
@@ -9,72 +9,78 @@ This project implements the "Touch & Turn" scalping strategy (Opening Range Liqu
## Strategy Overview ## Strategy Overview
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 (09:30 EST).
1. **The Setup:** Captures the high and low of the first 15-minute candle (09:30 - 09:45 EST). 1. **The Setup:** Captures the 15-minute opening candle.
2. **The Filter:** The range of this opening candle must be at least **25%** of the stock's 14-day Average True Range (ATR). 2. **The Filter:** Minimum range of 25% of 14-day ATR.
3. **The Trigger (ISA Optimized):** 3. **The Trigger (Split-Account Optimized):**
- **LONG (Bearish candle):** Bot places an immediate **Market BUY** order for the stock. - **LONG (Bearish candle):** Bot executes a **Market BUY** in the ISA account.
- **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). - **SHORT (Bullish candle):**
- **ISA Option:** Buys a **3x Inverse ETP** (if available).
- **CFD Option:** Performs a **Direct SELL** in the CFD account (if `SPLIT_ACCOUNT_MODE=True`).
4. **The Targets:** 4. **The Targets:**
- Brackets are placed **immediately** after the market order is filled, using the **Actual Fill Price** from your portfolio. - **Stop Loss (SL):** Physical broker-side order with ATR-based padding.
- **Take Profit (TP):** The 38.2% Fibonacci retracement level. - **Take Profit (TP):** Manually monitored by the bot to hit 38.2% Fibonacci retracement.
- **Stop Loss (SL):** Placed to ensure a Risk:Reward ratio of 1:2. 5. **Time Exit:** All positions forcefully closed at **11:00 EST**.
5. **Time Exit:** All open positions are forcefully closed via Market Order at **11:00 EST**.
--- ---
## Installation & Setup ## Installation & Setup
1. **Clone the repository and set up a virtual environment:** 1. **Setup environment:**
```bash ```bash
python3 -m venv venv python3 -m venv venv
source venv/bin/activate source venv/bin/activate
pip install -r requirements.txt pip install -r requirements.txt
``` ```
2. **Configure Environment Variables:** 2. **Configure `.env`:**
Create a `.env` file in the root directory:
```ini ```ini
TRADING212_API_KEY_ID=your_key_id_here # Primary Account (ISA)
TRADING212_API_KEY=your_api_key_here TRADING212_API_KEY_ID=...
TRADING212_API_KEY=...
TRADING212_BASE_URL=https://demo.trading212.com/api/v0/ TRADING212_BASE_URL=https://demo.trading212.com/api/v0/
# Secondary Account (CFD - Optional for Shorting)
CFD_API_KEY_ID=...
CFD_API_KEY=...
CFD_BASE_URL=...
SPLIT_ACCOUNT_MODE=True
VIRTUAL_STARTING_BALANCE=250
ISA_MODE=True ISA_MODE=True
``` ```
--- ---
## Risk Management & Position Sizing ## Split-Account Mode
The bot uses dynamic **Risk-Based Position Sizing** to ensure consistent exposure. To overcome the lack of Inverse ETPs for certain stocks, the bot can use a Trading212 CFD account for shorting.
- **5% Risk Rule:** By default, the bot risks **5% of your account balance** per trade. - **How it works:** When a Short signal is found, the bot checks if an Inverse ETP exists. If not (or if Split-Account mode is preferred), it routes the trade to the CFD account as a direct `SELL` order.
- **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). - **Benefit:** 100% coverage of all market opportunities.
- **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.
--- ---
## Automation Workflow ## Risk Management
The bot is designed to be triggered once per day (e.g., via a **systemd timer** or cron) at exactly **09:30 EST**. - **5% Risk Rule:** Risks 5% of the Virtual Balance (£250 starting point) per trade.
- **Capital Partitioning:** Automatically divides capital among active trades to prevent over-exposure.
1. **Scan:** Runs the ISA candidate filter to find the most volatile US stocks. - **ATR Padding:** Stop losses are automatically widened to at least 10% of daily ATR to avoid premature stop-outs from noise.
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.
--- ---
## Monitoring ## Monitoring
- **Logs:** All activity is recorded in `logs/bot_YYYY-MM-DD.log`. - **Journal:** Monitor via `journalctl -u touchturn.service`.
- **PnL Tracking:** A permanent ledger of every trade (including ETP substitutions) is kept in `pnl_tracking.csv` for graphing and analysis. - **Logs:** Real-time mirrored logs in `logs/bot_YYYY-MM-DD.log`.
- **PnL:** Performance ledger in `pnl_tracking.csv`.
---
## Architecture ## Architecture
* **`src/api/client.py`:** REST API wrapper with Basic Auth. * **`main.py`:** Daily orchestrator with dual-account routing.
* **`src/strategy/touch_turn.py`:** Logic engine and Fibonacci calculator. * **`src/execution/manager.py`:** Hybrid exit management (Broker SL / Bot TP).
* **`src/strategy/inverse_mapping.py`:** Map of US stocks to 3x Short Inverse ETPs. * **`src/strategy/touch_turn.py`:** Logic engine with ATR padding.
* **`src/execution/manager.py`:** Handles market entries, actual fill-based bracketing, and ISA substitutions. * **`src/strategy/inverse_mapping.py`:** ISA-specific shorting map.
* **`main.py`:** The morning orchestrator.
+47 -29
View File
@@ -93,10 +93,9 @@ 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(isa_client, cfd_client, yf_ticker, t212_ticker, tz):
"""Handles the full strategy lifecycle for a single ticker.""" """Handles the full strategy lifecycle for a single ticker."""
strategy = TouchTurnStrategy(yf_ticker) strategy = TouchTurnStrategy(yf_ticker)
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}).")
@@ -131,20 +130,28 @@ def run_ticker_lifecycle(client, yf_ticker, t212_ticker, tz):
params = strategy.get_trade_params() params = strategy.get_trade_params()
params['ticker'] = t212_ticker params['ticker'] = t212_ticker
# Check for ISA short restriction # Split-Account Routing Logic
split_mode = os.getenv("SPLIT_ACCOUNT_MODE", "False").lower() == "true"
isa_mode = os.getenv("ISA_MODE", "False").lower() == "true" isa_mode = os.getenv("ISA_MODE", "False").lower() == "true"
from src.strategy.inverse_mapping import INVERSE_TICKER_MAP
can_trade = True client = isa_client
if isa_mode and params['direction'] == "SELL": use_isa_rules = True
if params['direction'] == "SELL":
if split_mode and cfd_client:
logger.info(f"Split-Account Mode: Routing SHORT trade for {yf_ticker} to CFD account.")
client = cfd_client
use_isa_rules = False # Disable Inverse ETP mapping for CFD
elif isa_mode:
# Standard ISA mode check
from src.strategy.inverse_mapping import INVERSE_TICKER_MAP
base_ticker = yf_ticker.split('_')[0] base_ticker = yf_ticker.split('_')[0]
if base_ticker not in INVERSE_TICKER_MAP: if base_ticker not in INVERSE_TICKER_MAP:
logger.warning(f"ISA Mode: Bypassing {yf_ticker} Short (No ETP). Capital will be reallocated.") logger.warning(f"ISA Mode: Bypassing {yf_ticker} Short (No ETP).")
can_trade = False
if not can_trade:
return return
execution = ExecutionManager(client)
# Anti-thundering-herd jitter # Anti-thundering-herd jitter
time.sleep(random.uniform(1.0, 10.0)) time.sleep(random.uniform(1.0, 10.0))
@@ -155,7 +162,7 @@ def run_ticker_lifecycle(client, yf_ticker, t212_ticker, tz):
actual_balance = float(account_info.get('totalValue', 5000.0)) actual_balance = float(account_info.get('totalValue', 5000.0))
virtual_balance = max(0, actual_balance - 4750.0) virtual_balance = max(0, actual_balance - 4750.0)
# Count actively trading threads # Count actively trading threads on THIS account
num_active = 0 num_active = 0
for t in threading.enumerate(): for t in threading.enumerate():
if t.name.startswith("Bot-") and t.is_alive(): if t.name.startswith("Bot-") and t.is_alive():
@@ -165,7 +172,7 @@ def run_ticker_lifecycle(client, yf_ticker, t212_ticker, tz):
risk_share = (virtual_balance * 0.05) / num_active risk_share = (virtual_balance * 0.05) / num_active
capital_share = virtual_balance / num_active capital_share = virtual_balance / num_active
logger.info(f"Active Trades: {num_active} | Virtual: {virtual_balance:.2f} | Share: {capital_share:.2f} | Risk: {risk_share:.2f}") logger.info(f"Account ({'ISA' if use_isa_rules else 'CFD'}): Active Trades: {num_active} | Virtual: {virtual_balance:.2f} | Risk: {risk_share:.2f}")
break break
except Exception as e: except Exception as e:
if '429' in str(e): if '429' in str(e):
@@ -176,7 +183,7 @@ def run_ticker_lifecycle(client, yf_ticker, t212_ticker, tz):
logger.error(f"Failed to fetch account info: {e}") logger.error(f"Failed to fetch account info: {e}")
break break
if execution.execute_trade(params, target_risk_amount=risk_share, max_capital=capital_share): if execution.execute_trade(params, target_risk_amount=risk_share, max_capital=capital_share, isa_rules=use_isa_rules):
if execution.monitor_and_bracket(params): if execution.monitor_and_bracket(params):
# Position is open, monitor for exit via SL/TP # Position is open, monitor for exit via SL/TP
while datetime.now(tz).hour < 11: while datetime.now(tz).hour < 11:
@@ -212,7 +219,7 @@ def run_ticker_lifecycle(client, yf_ticker, t212_ticker, tz):
time.sleep(random.uniform(0.1, 5.0)) time.sleep(random.uniform(0.1, 5.0))
logger.info(f"Cleanup phase reached for {yf_ticker}.") logger.info(f"Cleanup phase reached for {yf_ticker}.")
if execution.is_in_position: if execution and execution.is_in_position:
exit_price = execution.close_all(t212_ticker) exit_price = execution.close_all(t212_ticker)
if hasattr(execution, 'params') and exit_price > 0: if hasattr(execution, 'params') and exit_price > 0:
final_entry = execution.params.get('final_entry', execution.params['entry_price']) final_entry = execution.params.get('final_entry', execution.params['entry_price'])
@@ -221,7 +228,7 @@ def run_ticker_lifecycle(client, yf_ticker, t212_ticker, tz):
pnl_r = calculate_r_multiple("BUY" if execution.is_etp else execution.params['direction'], final_entry, exit_price, final_sl) 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) record_pnl(yf_ticker, execution.params['direction'], final_entry, exit_price, "Forced Exit (Final)", pnl_r, trading_ticker=trading_ticker)
else: elif execution:
execution.close_all(t212_ticker) 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.")
@@ -229,11 +236,18 @@ def run_ticker_lifecycle(client, yf_ticker, t212_ticker, tz):
def main(): def main():
load_dotenv() 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/")
tz = pytz.timezone('US/Eastern')
# Primary Account (ISA)
isa_key_id = os.getenv("TRADING212_API_KEY_ID")
isa_key = os.getenv("TRADING212_API_KEY")
isa_url = os.getenv("TRADING212_BASE_URL", "https://demo.trading212.com/api/v0/")
# Secondary Account (CFD)
cfd_key_id = os.getenv("CFD_API_KEY_ID")
cfd_key = os.getenv("CFD_API_KEY")
cfd_url = os.getenv("CFD_BASE_URL", "https://demo.trading212.com/api/v0/")
tz = pytz.timezone('US/Eastern')
now = datetime.now(tz) now = datetime.now(tz)
if now.weekday() >= 5: if now.weekday() >= 5:
@@ -244,20 +258,25 @@ def main():
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
if not api_key_id or not api_key: if not isa_key_id or not isa_key:
logger.error("API credentials not found in .env") logger.error("Primary API credentials not found in .env")
return return
client = Trading212Client(api_key_id, api_key, base_url) isa_client = Trading212Client(isa_key_id, isa_key, isa_url)
cfd_client = None
if cfd_key_id and cfd_key:
cfd_client = Trading212Client(cfd_key_id, cfd_key, cfd_url)
# Early verification: Check connection before starting the day # Early verification
try: try:
logger.info("Verifying API connection...") logger.info("Verifying Primary API connection...")
client.get_account_info() isa_client.get_account_info()
logger.info("API Connection verified successfully.") if cfd_client:
logger.info("Verifying Secondary API connection...")
cfd_client.get_account_info()
logger.info("API Connections verified successfully.")
except Exception as e: except Exception as e:
logger.error(f"API Connection check failed: {e}") logger.error(f"API Connection check failed: {e}")
logger.error("Please check your API key and permissions in .env. Exiting.")
return return
logger.info("Starting Morning Routine: Finding ISA Candidates...") logger.info("Starting Morning Routine: Finding ISA Candidates...")
@@ -285,7 +304,6 @@ def main():
'pnl': res['Net PnL (R)'] 'pnl': res['Net PnL (R)']
}) })
# Print Leaderboard for transparency
if all_results: if all_results:
from prettytable import PrettyTable from prettytable import PrettyTable
results_df = pd.DataFrame(all_results) results_df = pd.DataFrame(all_results)
@@ -314,7 +332,7 @@ def main():
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=(isa_client, cfd_client, ticker_info['yf'], ticker_info['t212'], tz),
name=f"Bot-{ticker_info['yf']}" name=f"Bot-{ticker_info['yf']}"
) )
t.start() t.start()
+20 -37
View File
@@ -12,7 +12,7 @@ logger = logging.getLogger(__name__)
class ExecutionManager: class ExecutionManager:
""" """
Manages the lifecycle of a trade: Entry, SL 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. Supports Hybrid Exit Strategy and Split-Account routing.
""" """
def __init__(self, client: Trading212Client): def __init__(self, client: Trading212Client):
self.client = client self.client = client
@@ -35,7 +35,6 @@ class ExecutionManager:
logger.warning(f"Rate limited. Retrying in {wait:.1f}s...") logger.warning(f"Rate limited. Retrying in {wait:.1f}s...")
time.sleep(wait) time.sleep(wait)
elif '400' in str(e) or '403' in str(e): 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: if hasattr(e, 'response') and e.response is not None:
logger.error(f"API Error Body: {e.response.text}") logger.error(f"API Error Body: {e.response.text}")
raise e raise e
@@ -43,10 +42,8 @@ class ExecutionManager:
raise e raise e
raise Exception(f"Failed after {max_attempts} attempts") 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): def execute_trade(self, params: Dict[str, Any], target_risk_amount: float = 0.0, max_capital: float = 0.0, isa_rules: bool = True):
"""Starts the trade process by placing a MARKET entry order for immediate execution.""" """Starts the trade process by placing a MARKET entry order."""
isa_mode = os.getenv("ISA_MODE", "False").lower() == "true"
self.params = params self.params = params
ticker = params['ticker'] ticker = params['ticker']
base_ticker = ticker.split('_')[0] base_ticker = ticker.split('_')[0]
@@ -55,7 +52,8 @@ class ExecutionManager:
self.is_etp = False self.is_etp = False
self.leverage = 1.0 self.leverage = 1.0
if isa_mode and direction == "SELL": # 1. ISA Rules Substitution (Only if requested)
if isa_rules and direction == "SELL":
if base_ticker in INVERSE_TICKER_MAP: if base_ticker in INVERSE_TICKER_MAP:
inverse_ticker = INVERSE_TICKER_MAP[base_ticker] inverse_ticker = INVERSE_TICKER_MAP[base_ticker]
self.leverage = LEVERAGE_MAP.get(inverse_ticker, 3.0) self.leverage = LEVERAGE_MAP.get(inverse_ticker, 3.0)
@@ -68,6 +66,7 @@ class ExecutionManager:
logger.warning(f"ISA Mode Active: Cannot Short {ticker} and no inverse ETP found. Setup ignored.") logger.warning(f"ISA Mode Active: Cannot Short {ticker} and no inverse ETP found. Setup ignored.")
return False return False
else: else:
# Direct trading (CFD or Long ISA)
self.params['trading_ticker'] = ticker self.params['trading_ticker'] = ticker
approx_price = params.get('current_price', params['entry_price']) approx_price = params.get('current_price', params['entry_price'])
@@ -93,45 +92,38 @@ class ExecutionManager:
logger.info(f"Attempting {direction} market order for {ticker} (Qty: {quantity})...") logger.info(f"Attempting {direction} market order for {ticker} (Qty: {quantity})...")
# 3. Execution with Smart Retry for Common Broker Errors # 3. Execution
try: try:
order = self._call_with_retry(self.client.place_market_order, ticker, trade_quantity) 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"Market 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:
# Precision/Min Qty Fallback
if hasattr(e, 'response') and e.response is not None: if hasattr(e, 'response') and e.response is not None:
try: try:
err_data = e.response.json() err_data = e.response.json()
err_type = err_data.get('type', '')
err_detail = err_data.get('detail', '') err_detail = err_data.get('detail', '')
if "precision" in err_detail.lower():
# Error A: Quantity Precision Mismatch logger.warning(f"Precision mismatch for {ticker}. Retrying with 2 decimals...")
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) trade_quantity = round(trade_quantity, 2)
self.current_quantity = abs(trade_quantity) self.current_quantity = abs(trade_quantity)
order = self._call_with_retry(self.client.place_market_order, ticker, trade_quantity) 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')
return True return True
if "min-quantity" in err_detail.lower():
# Error B: Minimum Quantity Exceeded
if "min-quantity-exceeded" in err_type:
import re import re
match = re.search(r"at least ([\d.]+)", err_detail) match = re.search(r"at least ([\d.]+)", err_detail)
if match: if match:
min_qty = float(match.group(1)) min_qty = float(match.group(1))
if (min_qty * approx_price) <= (max_capital * 1.05): # Small buffer if (min_qty * approx_price) <= (max_capital * 1.1):
logger.warning(f"Quantity too low for {ticker}. Upping to minimum: {min_qty}") logger.warning(f"Quantity too low for {ticker}. Upping to min: {min_qty}")
trade_quantity = -min_qty if direction == "SELL" else min_qty trade_quantity = -min_qty if direction == "SELL" else min_qty
self.current_quantity = min_qty self.current_quantity = min_qty
order = self._call_with_retry(self.client.place_market_order, ticker, trade_quantity) 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')
return True return True
else: except: pass
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}") logger.error(f"Failed to place entry market order for {ticker}: {e}")
return False return False
@@ -187,11 +179,11 @@ class ExecutionManager:
risk_distance = (tp_price - actual_entry_price) / 2.0 risk_distance = (tp_price - actual_entry_price) / 2.0
sl_price = actual_entry_price - risk_distance sl_price = actual_entry_price - risk_distance
sl_qty = -quantity sl_qty = -quantity
else: # SHORT (Normal stock) else: # SHORT (Direct CFD)
tp_price = actual_entry_price - (range_size * 0.382) tp_price = actual_entry_price - (range_size * 0.382)
risk_distance = (actual_entry_price - tp_price) / 2.0 risk_distance = (actual_entry_price - tp_price) / 2.0
sl_price = actual_entry_price + risk_distance sl_price = actual_entry_price + risk_distance
sl_qty = quantity sl_qty = quantity # Since it's a SELL position, BUY to close
tp_price = round(tp_price, 2) tp_price = round(tp_price, 2)
sl_price = round(sl_price, 2) sl_price = round(sl_price, 2)
@@ -202,7 +194,6 @@ class ExecutionManager:
try: try:
logger.info(f"Hybrid Mode: Placing Broker SL for {ticker} @ {sl_price}. Monitoring TP @ {tp_price} manually.") 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: try:
sl_order = self._call_with_retry(self.client.place_stop_order, ticker, sl_qty, sl_price, time_validity="GOOD_TILL_CANCEL") 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') self.sl_order_id = sl_order.get('id')
@@ -212,8 +203,7 @@ class ExecutionManager:
sl_qty = round(sl_qty, 2) 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") 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') self.sl_order_id = sl_order.get('id')
else: else: raise sl_e
raise sl_e
return True return True
except Exception as e: except Exception as e:
logger.error(f"Failed to place SL bracket for {ticker}: {e}") logger.error(f"Failed to place SL bracket for {ticker}: {e}")
@@ -260,8 +250,7 @@ class ExecutionManager:
self.is_in_position = False self.is_in_position = False
fallback_price = float(self.params.get('final_sl', 0.0)) fallback_price = float(self.params.get('final_sl', 0.0))
return True, "SL Hit (Broker)", fallback_price return True, "SL Hit (Broker)", fallback_price
else: else: raise e
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}")
@@ -289,14 +278,8 @@ class ExecutionManager:
qty = float(pos.get('quantity', 0)) qty = float(pos.get('quantity', 0))
exit_price = float(pos.get('currentPrice', 0.0)) exit_price = float(pos.get('currentPrice', 0.0))
if qty != 0: if qty != 0:
# Try to close with precision fix try: self._call_with_retry(self.client.place_market_order, trading_ticker, -qty)
try: except: self._call_with_retry(self.client.place_market_order, trading_ticker, round(-qty, 2))
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 break
except Exception as e: except Exception as e:
logger.error(f"Failed to flatten position: {e}") logger.error(f"Failed to flatten position: {e}")