From cd66a976de77687d4919128727008ab14283f8f4 Mon Sep 17 00:00:00 2001 From: Gemini CLI Date: Sun, 8 Mar 2026 22:07:11 +0100 Subject: [PATCH] feat: implement Mid-Price logic and separate error logging (v1.8.6) - Implement Mid-Price calculation (Bid+Ask)/2 for Maker orders to improve fill rates. - Add separate 'logs/ping_pong_errors.log' for WARNING and ERROR messages. - Add RotatingFileHandler for error logs (5MB cap, 2 backups). - Refine 'leverage not modified' (110043) handling from ERROR to INFO. - Improve order verification with explicit status checks and race condition handling. - Verify script syntax and update version to 1.8.6. --- src/strategies/ping_pong_bot.py | 161 ++++++++++++++++++++++---------- 1 file changed, 114 insertions(+), 47 deletions(-) diff --git a/src/strategies/ping_pong_bot.py b/src/strategies/ping_pong_bot.py index 86036c7..36beda8 100644 --- a/src/strategies/ping_pong_bot.py +++ b/src/strategies/ping_pong_bot.py @@ -5,6 +5,7 @@ import hmac import hashlib import json import logging +from logging.handlers import RotatingFileHandler import asyncio import pandas as pd import numpy as np @@ -30,14 +31,28 @@ load_dotenv() log_level = os.getenv("LOG_LEVEL", "INFO") # Setup Logging -logging.basicConfig( - level=getattr(logging, log_level), - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', - handlers=[ - logging.StreamHandler() - ] -) +log_dir = "logs" +os.makedirs(log_dir, exist_ok=True) +error_log_path = os.path.join(log_dir, "ping_pong_errors.log") + +# Create logger logger = logging.getLogger("PingPongBot") +logger.setLevel(logging.DEBUG) # Catch everything, handlers will filter + +# Formatter +formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') + +# Console Handler (Normal logs) +ch = logging.StreamHandler() +ch.setLevel(getattr(logging, log_level)) +ch.setFormatter(formatter) +logger.addHandler(ch) + +# Error File Handler (Warnings and Errors only) +fh = RotatingFileHandler(error_log_path, maxBytes=5*1024*1024, backupCount=2) +fh.setLevel(logging.WARNING) +fh.setFormatter(formatter) +logger.addHandler(fh) class DatabaseManager: """Minimal Database Manager for the bot""" @@ -165,7 +180,7 @@ class PingPongStrategy: class PingPongBot: def __init__(self, config_path="config/ping_pong_config.yaml"): - self.version = "1.8.2" + self.version = "1.8.6" with open(config_path, 'r') as f: self.config = yaml.safe_load(f) @@ -361,14 +376,22 @@ class PingPongBot: buyLeverage=str(self.leverage), sellLeverage=str(self.leverage) ) + + # If pybit returns normally, check the retCode if res['retCode'] == 0: logger.info(f"Leverage successfully set to {self.leverage}x") elif res['retCode'] == 110043: # Leverage not modified logger.info(f"Leverage is already {self.leverage}x") else: logger.warning(f"Bybit Leverage Warning: {res['retMsg']} (Code: {res['retCode']})") + except Exception as e: - logger.error(f"Failed to set leverage on Bybit: {e}") + # Check if exception contains "leverage not modified" or code 110043 + err_str = str(e) + if "110043" in err_str or "leverage not modified" in err_str.lower(): + logger.info(f"Leverage is already correctly set ({self.leverage}x)") + else: + logger.error(f"Failed to set leverage on Bybit: {e}") async def close_all_positions(self): """Closes any active position in the current category/symbol""" @@ -510,13 +533,21 @@ class PingPongBot: max_retries = 5 for attempt in range(1, max_retries + 1): try: - # Fresh market price for limit order + # Fresh Bid/Ask for Mid-Price Limit order ticker = await asyncio.to_thread(self.session.get_tickers, category=self.category, symbol=self.symbol) - if ticker['retCode'] == 0: - self.market_price = float(ticker['result']['list'][0]['lastPrice']) + if ticker['retCode'] == 0 and ticker['result']['list']: + t = ticker['result']['list'][0] + bid = float(t.get('bid1Price', 0)) + ask = float(t.get('ask1Price', 0)) + last = float(t.get('lastPrice', 0)) + + if bid > 0 and ask > 0: + self.market_price = (bid + ask) / 2 + else: + self.market_price = last price_str = str(round(self.market_price, 1)) - self.status_msg = f"Chase {trade}: {attempt}/{max_retries} @ {price_str}" + self.status_msg = f"Chase {trade}: {attempt}/{max_retries} @ {price_str} (Mid)" res = await asyncio.to_thread(self.session.place_order, category=self.category, symbol=self.symbol, side=side, orderType="Limit", @@ -525,6 +556,17 @@ class PingPongBot: ) if res['retCode'] != 0: + # Specific check for race condition: order filled while trying to place/cancel + if res['retCode'] in [110001, 170213, 170210]: + # Check if actually filled + history = await asyncio.to_thread(self.session.get_order_history, + category=self.category, symbol=self.symbol, limit=1) + if history['retCode'] == 0 and history['result']['list']: + latest = history['result']['list'][0] + if latest['orderStatus'] == "Filled" and float(latest['cumExecQty']) > 0: + await self._process_filled_order(latest['orderId'], trade, qty_str, attempts=attempt) + return + logger.warning(f"Maker rejected (Try {attempt}): {res['retMsg']}") await asyncio.sleep(2) continue @@ -534,16 +576,38 @@ class PingPongBot: # Monitor for fill (Wait 10 seconds) for _ in range(10): await asyncio.sleep(1) - open_orders = await asyncio.to_thread(self.session.get_open_orders, - category=self.category, symbol=self.symbol, orderId=order_id) - if open_orders['retCode'] == 0 and not open_orders['result']['list']: - # Order filled (or manually cancelled) - await self._process_filled_order(order_id, trade, qty_str, attempts=attempt) - return + # Check order history for definitive status + history = await asyncio.to_thread(self.session.get_order_history, + category=self.category, symbol=self.symbol, orderId=order_id) + if history['retCode'] == 0 and history['result']['list']: + status = history['result']['list'][0]['orderStatus'] + if status == "Filled": + await self._process_filled_order(order_id, trade, qty_str, attempts=attempt) + return + elif status in ["Cancelled", "Rejected", "Deactivated"]: + break # Go to retry # Timeout: Cancel and retry - await asyncio.to_thread(self.session.cancel_order, category=self.category, symbol=self.symbol, orderId=order_id) - logger.info(f"Maker {trade} timed out, cancelling and retrying ({attempt}/{max_retries})") + try: + cancel_res = await asyncio.to_thread(self.session.cancel_order, category=self.category, symbol=self.symbol, orderId=order_id) + # Even if successful, double check if it filled in the last millisecond + if cancel_res['retCode'] in [0, 110001, 170213]: + history = await asyncio.to_thread(self.session.get_order_history, + category=self.category, symbol=self.symbol, orderId=order_id) + if history['retCode'] == 0 and history['result']['list'] and history['result']['list'][0]['orderStatus'] == "Filled": + await self._process_filled_order(order_id, trade, qty_str, attempts=attempt) + return + except Exception as ce: + # Handle exception for 110001 + if "110001" in str(ce) or "170213" in str(ce): + history = await asyncio.to_thread(self.session.get_order_history, + category=self.category, symbol=self.symbol, orderId=order_id) + if history['retCode'] == 0 and history['result']['list'] and history['result']['list'][0]['orderStatus'] == "Filled": + await self._process_filled_order(order_id, trade, qty_str, attempts=attempt) + return + logger.warning(f"Cancel error during chase: {ce}") + + logger.info(f"Maker {trade} timed out, retrying ({attempt}/{max_retries})") except Exception as e: logger.error(f"Maker Chase Error (Try {attempt}): {e}") @@ -557,34 +621,37 @@ class PingPongBot: self.last_signal = f"{trade} {qty_str}" self.status_msg = f"Order Success: {trade} ({self.exec_type})" - # Wait for Bybit indexing - await asyncio.sleep(1.5) - try: - exec_info = await asyncio.to_thread(self.session.get_executions, - category=self.category, - symbol=self.symbol, - orderId=order_id) - - if exec_info['retCode'] == 0 and exec_info['result']['list']: - fills = exec_info['result']['list'] - exec_fee = sum(float(f.get('execFee', 0)) for f in fills) - exec_pnl = sum(float(f.get('closedPnl', 0)) for f in fills) - exec_price = float(fills[0].get('execPrice', self.market_price)) + # Wait for Bybit indexing (multiple attempts if needed) + for _ in range(3): + await asyncio.sleep(1.5) + try: + exec_info = await asyncio.to_thread(self.session.get_executions, + category=self.category, + symbol=self.symbol, + orderId=order_id) - if self.category == "inverse": - usd_fee = exec_fee * exec_price - usd_pnl = exec_pnl * exec_price - else: - usd_fee = exec_fee - usd_pnl = exec_pnl + if exec_info['retCode'] == 0 and exec_info['result']['list']: + fills = exec_info['result']['list'] + exec_fee = sum(float(f.get('execFee', 0)) for f in fills) + exec_pnl = sum(float(f.get('closedPnl', 0)) for f in fills) + exec_price = float(fills[0].get('execPrice', self.market_price)) - self.total_fees += usd_fee - self.total_realized_pnl += usd_pnl - await self.log_transaction(trade, qty_str, exec_price, pnl=usd_pnl, fee=usd_fee, attempts=attempts, status="Filled") - else: - await self.log_transaction(trade, qty_str, self.market_price, attempts=attempts, status=f"Filled ({self.exec_type})") - except Exception as e: - logger.error(f"Error processing filled order {order_id}: {e}") + if self.category == "inverse": + usd_fee = exec_fee * exec_price + usd_pnl = exec_pnl * exec_price + else: + usd_fee = exec_fee + usd_pnl = exec_pnl + + self.total_fees += usd_fee + self.total_realized_pnl += usd_pnl + await self.log_transaction(trade, qty_str, exec_price, pnl=usd_pnl, fee=usd_fee, attempts=attempts, status="Filled") + return + except Exception as e: + logger.error(f"Execution fetch error: {e}") + + # Fallback if execution list is still empty after retries + await self.log_transaction(trade, qty_str, self.market_price, attempts=attempts, status=f"Filled ({self.exec_type})") def render_dashboard(self): self.console.print("\n" + "="*60)