feat: implement Split-Account Mode for ISA/CFD hybrid execution
This commit is contained in:
+12
-2
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
|
||||||
|
|||||||
@@ -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,19 +130,27 @@ 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
|
||||||
base_ticker = yf_ticker.split('_')[0]
|
|
||||||
if base_ticker not in INVERSE_TICKER_MAP:
|
|
||||||
logger.warning(f"ISA Mode: Bypassing {yf_ticker} Short (No ETP). Capital will be reallocated.")
|
|
||||||
can_trade = False
|
|
||||||
|
|
||||||
if not can_trade:
|
if params['direction'] == "SELL":
|
||||||
return
|
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]
|
||||||
|
if base_ticker not in INVERSE_TICKER_MAP:
|
||||||
|
logger.warning(f"ISA Mode: Bypassing {yf_ticker} Short (No ETP).")
|
||||||
|
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
@@ -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}")
|
||||||
|
|||||||
Reference in New Issue
Block a user