todays modifications

This commit is contained in:
pie
2026-05-13 16:49:59 +01:00
parent 5a4c99d0d1
commit fc9bb34d6b
2 changed files with 109 additions and 81 deletions
+38 -15
View File
@@ -19,22 +19,32 @@ os.makedirs("logs", exist_ok=True)
log_filename = datetime.now().strftime("logs/bot_%Y-%m-%d.log")
# Configure logging to both console and file
# Use a specific handler setup to enable manual flushing
file_handler = logging.FileHandler(log_filename)
stream_handler = logging.StreamHandler()
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(threadName)s] %(levelname)s - %(message)s',
handlers=[
logging.FileHandler(log_filename),
logging.StreamHandler()
]
handlers=[file_handler, stream_handler]
)
logger = logging.getLogger(__name__)
# Force flush helper to ensure bot logs are written to disk before thread exit
def flush_logs():
for handler in logging.getLogger().handlers:
handler.flush()
PNL_FILE = "pnl_tracking.csv"
def record_pnl(ticker, direction, entry_price, exit_price, reason, pnl_r, trading_ticker=None):
"""Appends the result of a closed trade to the PnL CSV."""
file_exists = os.path.isfile(PNL_FILE)
# Safety: Fix potential 0.0 exit price in logs causing extreme PnL values
if exit_price <= 0:
exit_price = entry_price
with open(PNL_FILE, mode='a', newline='') as file:
writer = csv.writer(file)
if not file_exists:
@@ -45,9 +55,14 @@ def record_pnl(ticker, direction, entry_price, exit_price, reason, pnl_r, tradin
label = f"{ticker} ({trading_ticker})" if trading_ticker else ticker
logger.info(f"Recorded trade in {PNL_FILE}: {label} {direction} | Result: {reason} | PnL: {pnl_r:.2f} R")
flush_logs()
def calculate_r_multiple(direction, entry_price, exit_price, stop_loss):
"""Calculates the PnL in terms of Risk Multiples (R)."""
# Safety: Prevent Division by Zero if SL is somehow same as entry
if abs(entry_price - stop_loss) < 0.001:
return 0.0
if direction == "BUY": # LONG
risk = entry_price - stop_loss
return (exit_price - entry_price) / risk if risk != 0 else 0
@@ -55,13 +70,17 @@ def calculate_r_multiple(direction, entry_price, exit_price, stop_loss):
risk = stop_loss - entry_price
return (entry_price - exit_price) / risk if risk != 0 else 0
def run_ticker_lifecycle(client, yf_ticker, t212_ticker, tz):
def run_ticker_lifecycle(client, yf_ticker, t212_ticker, tz, num_tickers):
"""Handles the full strategy lifecycle for a single ticker in its own thread, then exits."""
strategy = TouchTurnStrategy(yf_ticker)
execution = ExecutionManager(client)
logger.info(f"Bot thread started for {yf_ticker} ({t212_ticker}).")
# Initialize variables outside the retry loop to prevent UnboundLocalError
risk_share = 2.50 / num_tickers
capital_share = 250.0 / num_tickers
try:
now = datetime.now(tz)
target_entry_time = now.replace(hour=9, minute=45, second=0, microsecond=0)
@@ -97,24 +116,26 @@ def run_ticker_lifecycle(client, yf_ticker, t212_ticker, tz):
time.sleep(random.uniform(0.1, 3.0))
# Fetch Account Balance to calculate risk with backoff
risk_amount = 2.50 # Fallback
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_amount = virtual_balance * 0.01
logger.info(f"Account: {actual_balance:.2f} | Virtual Balance: {virtual_balance:.2f} | Risk (1%): {risk_amount:.2f}")
break # Success
risk_share = (virtual_balance * 0.01) / 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):
logger.warning(f"Rate limited on account fetch for {yf_ticker}. Retrying in {2**(attempt+1)}s...")
logger.warning(f"Rate limited on account fetch for {yf_ticker}. Retrying...")
time.sleep(2**(attempt+1))
else:
logger.error(f"Failed to fetch account info: {e}. Defaulting to £2.50 risk.")
logger.error(f"Failed to fetch account info: {e}")
break
if execution.execute_trade(params, target_risk_amount=risk_amount):
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:
@@ -146,8 +167,7 @@ def run_ticker_lifecycle(client, yf_ticker, t212_ticker, tz):
except Exception as e:
logger.error(f"Unexpected error in {yf_ticker} lifecycle: {e}", exc_info=True)
finally:
# 3. 11:00 EST - Cleanup (with jitter to prevent 429s)
# We put this in finally to ensure it runs even on crash
# 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}.")
@@ -164,6 +184,7 @@ def run_ticker_lifecycle(client, yf_ticker, t212_ticker, tz):
execution.close_all(t212_ticker)
logger.info(f"Lifecycle complete for {yf_ticker}. Thread exiting.")
flush_logs()
def main():
load_dotenv()
@@ -230,10 +251,11 @@ def main():
logger.info(f"Final Watchlist for today: {[t['yf'] for t in final_watchlist]}")
threads = []
num_active = len(final_watchlist)
for ticker_info in final_watchlist:
t = threading.Thread(
target=run_ticker_lifecycle,
args=(client, ticker_info['yf'], ticker_info['t212'], tz),
args=(client, ticker_info['yf'], ticker_info['t212'], tz, num_active),
name=f"Bot-{ticker_info['yf']}"
)
t.start()
@@ -245,6 +267,7 @@ def main():
t.join()
logger.info("All threads completed. Bot shutting down for the day.")
flush_logs()
if __name__ == "__main__":
main()
+70 -65
View File
@@ -10,8 +10,8 @@ logger = logging.getLogger(__name__)
class ExecutionManager:
"""
Manages the lifecycle of a trade: Entry, SL/TP placement, and Exit.
Supports Inverse ETPs for Shorting in ISA mode.
Manages the lifecycle of a trade: Entry, SL placement, and Exit.
Uses a Hybrid Strategy: Broker-side SL and Bot-side TP monitoring.
"""
def __init__(self, client: Trading212Client):
self.client = client
@@ -19,10 +19,11 @@ class ExecutionManager:
self.sl_order_id = None
self.tp_order_id = None
self.is_in_position = False
self.leverage = 1.0
self.is_etp = False
def _call_with_retry(self, func, *args, **kwargs):
"""Helper to call an API function with retries and jitter."""
import random
max_attempts = 5
for attempt in range(max_attempts):
try:
@@ -33,7 +34,6 @@ class ExecutionManager:
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
@@ -41,7 +41,7 @@ class ExecutionManager:
raise e
raise Exception(f"Failed after {max_attempts} attempts")
def execute_trade(self, params: Dict[str, Any], target_risk_amount: float = 0.0):
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"
@@ -53,7 +53,6 @@ class ExecutionManager:
self.is_etp = False
self.leverage = 1.0
# 1. ISA Mode Short Substitution
if isa_mode and direction == "SELL":
if base_ticker in INVERSE_TICKER_MAP:
inverse_ticker = INVERSE_TICKER_MAP[base_ticker]
@@ -69,19 +68,19 @@ class ExecutionManager:
else:
self.params['trading_ticker'] = ticker
# 2. Position Sizing
approx_price = params.get('current_price', params['entry_price'])
if self.is_etp:
# Sizing for ETP: Default to 1.0 share if price unknown, otherwise could fetch.
quantity = 1.0
logger.info(f"Sizing for ETP {ticker}: Defaulting to 1.0 share (Leverage: {self.leverage}x)")
else:
# We must use round() to avoid 400 Bad Request
stop_loss = round(params['stop_loss'], 2)
risk_per_share = abs(approx_price - stop_loss)
if target_risk_amount > 0 and risk_per_share > 0:
quantity = round(target_risk_amount / risk_per_share, 4)
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
@@ -89,7 +88,8 @@ class ExecutionManager:
self.current_quantity = quantity
trade_quantity = -quantity if direction == "SELL" else quantity
logger.info(f"Placing immediate {direction} market order for {ticker} (Qty: {quantity})...")
logger.info(f"Final Quantity: {quantity} shares. Approx Value: {approx_price * quantity:.2f}")
logger.info(f"Placing immediate {direction} market order for {ticker}...")
try:
order = self._call_with_retry(self.client.place_market_order, ticker, trade_quantity)
@@ -97,11 +97,35 @@ class ExecutionManager:
logger.info(f"Market order placed successfully. ID: {self.current_order_id}")
return True
except Exception as e:
# Handle Quantity Precision Error
if "precision-mismatch" in str(e) or "precision" in str(e).lower():
logger.warning(f"Quantity precision mismatch for {ticker}. Retrying with 2 decimal places...")
try:
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')
logger.info(f"Market order (retry) placed. ID: {self.current_order_id}")
return True
except Exception as retry_e:
logger.error(f"Failed entry retry: {retry_e}")
logger.error(f"Failed to place entry market order: {e}")
return False
def _is_ticker_in_portfolio(self, ticker: str) -> bool:
"""Helper to check if a ticker currently has an open position."""
try:
positions = self._call_with_retry(self.client.get_all_open_positions)
for pos in positions:
if pos.get('ticker') == ticker:
return True
except Exception as e:
logger.error(f"Error checking portfolio: {e}")
return False
def monitor_and_bracket(self, params: Dict[str, Any]):
"""Polls the entry and places SL/TP based on ACTUAL fill price."""
"""Polls the entry and places physical Stop Loss at the broker."""
if not self.current_order_id:
return False
@@ -111,17 +135,8 @@ class ExecutionManager:
is_etp = getattr(self, 'is_etp', False)
leverage = getattr(self, 'leverage', 1.0)
# Wait for immediate fill confirmation
import pytz
from datetime import datetime
tz = pytz.timezone('US/Eastern')
actual_entry_price = 0.0
while not self.is_in_position:
if datetime.now(tz).hour >= 11:
return False
try:
positions = self._call_with_retry(self.client.get_all_open_positions)
for pos in positions:
@@ -132,20 +147,15 @@ class ExecutionManager:
break
except Exception as e:
logger.debug(f"Waiting for market fill: {e}")
time.sleep(2)
# Calculate brackets from ACTUAL fill price
target_pct = params.get('target_percent', 1.0) / 100.0 # as decimal
target_pct = params.get('target_percent', 1.0) / 100.0
if is_etp:
tp_move_pct = target_pct * leverage
sl_move_pct = tp_move_pct / 2.0 # 1:2 RR
sl_move_pct = tp_move_pct / 2.0
tp_price = actual_entry_price * (1 + tp_move_pct)
sl_price = actual_entry_price * (1 - sl_move_pct)
tp_qty = -quantity
sl_qty = -quantity
else:
range_size = params.get('range_size', 0)
@@ -154,56 +164,55 @@ class ExecutionManager:
risk_distance = (tp_price - actual_entry_price) / 2.0
sl_price = actual_entry_price - risk_distance
sl_qty = -quantity
tp_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_qty = quantity
tp_price = round(tp_price, 2)
sl_price = round(sl_price, 2)
# Save final calculated prices for PnL recording
self.params['final_sl'] = sl_price
self.params['final_tp'] = tp_price
self.params['final_entry'] = actual_entry_price
# Jitter before placing brackets to avoid 429
time.sleep(random.uniform(0.5, 2.0))
try:
logger.info(f"Placing protection for {ticker} (Fill: {actual_entry_price:.2f}): TP @ {tp_price}, SL @ {sl_price}")
self.tp_order_id = self._call_with_retry(self.client.place_limit_order, ticker, tp_qty, tp_price, time_validity="GOOD_TILL_CANCEL").get('id')
self.sl_order_id = self._call_with_retry(self.client.place_stop_order, ticker, sl_qty, sl_price, time_validity="GOOD_TILL_CANCEL").get('id')
logger.info(f"Hybrid Mode: Placing Broker SL for {ticker} @ {sl_price}. Monitoring TP @ {tp_price} manually.")
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')
return True
except Exception as e:
logger.error(f"Failed to place SL/TP brackets: {e}")
logger.error(f"Failed to place SL bracket: {e}")
return False
def check_exit_status(self) -> tuple[bool, str, float]:
"""Checks if the SL or TP orders have been filled by the broker."""
"""Checks if SL triggered at broker OR if TP target was hit in market."""
if not self.is_in_position:
return False, "", 0.0
ticker = self.params.get('trading_ticker', self.params.get('ticker'))
tp_target = self.params.get('final_tp', 0.0)
direction = self.params.get('direction', 'BUY')
is_etp = getattr(self, 'is_etp', False)
try:
if self.tp_order_id:
try:
tp_info = self._call_with_retry(self.client.get_order_status, self.tp_order_id)
if tp_info.get('status') == "FILLED":
fill_price = tp_info.get('filledPrice', tp_info.get('limitPrice', 0))
self.is_in_position = False
return True, "TP Hit", 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
return True, "TP Hit", 0.0
else:
raise e
positions = self._call_with_retry(self.client.get_all_open_positions)
current_price = 0.0
for pos in positions:
if pos.get('ticker') == ticker:
current_price = float(pos.get('currentPrice', 0.0))
break
if current_price > 0:
if (direction == "BUY" or is_etp) and current_price >= tp_target:
logger.info(f"Take Profit Target Reached! Price: {current_price} >= {tp_target}")
self.close_all(ticker)
return True, "TP Hit (Bot)", current_price
elif direction == "SELL" and not is_etp and current_price <= tp_target:
logger.info(f"Take Profit Target Reached! Price: {current_price} <= {tp_target}")
self.close_all(ticker)
return True, "TP Hit (Bot)", current_price
if self.sl_order_id:
try:
@@ -211,12 +220,14 @@ class ExecutionManager:
if sl_info.get('status') == "FILLED":
fill_price = sl_info.get('filledPrice', sl_info.get('stopPrice', 0))
self.is_in_position = False
return True, "SL Hit", float(fill_price)
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
return True, "SL Hit", 0.0
# Fallback to known SL price to avoid 0.0 PnL math
fallback_price = float(self.params.get('final_sl', 0.0))
return True, "SL Hit (Broker)", fallback_price
else:
raise e
@@ -227,25 +238,19 @@ class ExecutionManager:
def close_all(self, ticker: str) -> float:
"""Forces a close of all open orders and positions. Returns the exit price."""
# Robust lookup in case execute_trade bypassed early
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._call_with_retry(self.client.cancel_order, self.current_order_id)
except: pass
if self.sl_order_id:
try: self._call_with_retry(self.client.cancel_order, self.sl_order_id)
except: pass
if self.tp_order_id:
try: self._call_with_retry(self.client.cancel_order, self.tp_order_id)
try:
self._call_with_retry(self.client.cancel_order, self.sl_order_id)
self.sl_order_id = None
except: pass
exit_price = 0.0
if self.is_in_position:
try:
# Portfolio check with retry and jitter
positions = self._call_with_retry(self.client.get_all_open_positions)
for pos in positions:
if pos.get('ticker') == trading_ticker: