diff --git a/main.py b/main.py index 37af8a3..59d491a 100644 --- a/main.py +++ b/main.py @@ -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() diff --git a/src/execution/manager.py b/src/execution/manager.py index 7f3b325..3d10489 100644 --- a/src/execution/manager.py +++ b/src/execution/manager.py @@ -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,69 +164,70 @@ 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: sl_info = self._call_with_retry(self.client.get_order_status, self.sl_order_id) if sl_info.get('status') == "FILLED": fill_price = sl_info.get('filledPrice', sl_info.get('stopPrice', 0)) self.is_in_position = False - return True, "SL Hit", 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: