From 2840d9b0b3854b971bf72ec2a699fe7221560f67 Mon Sep 17 00:00:00 2001 From: Gemini CLI Date: Sun, 8 Mar 2026 20:39:17 +0100 Subject: [PATCH] feat: implement Maker Chase logic and enhanced order logging (v1.8.2) - Implement 5-try Chase logic for Maker (Limit Post-Only) orders. - Add 'attempts' column to CSV transaction log for performance tracking. - Update backtest engine (v1.7.9) with Stop Loss and Maker fee simulation. - Log failed chase sequences explicitly as "Failed (Chase Timeout)". - Consolidate order processing into internal helper methods. --- config/ping_pong_config.yaml | 1 + src/strategies/backtest_engine.py | 91 ++++++++++------- src/strategies/ping_pong_bot.py | 156 +++++++++++++++++++----------- 3 files changed, 155 insertions(+), 93 deletions(-) diff --git a/config/ping_pong_config.yaml b/config/ping_pong_config.yaml index 7b7f48f..1ffc205 100644 --- a/config/ping_pong_config.yaml +++ b/config/ping_pong_config.yaml @@ -34,6 +34,7 @@ min_position_value_usd: 15.0 # Minimum remaining value to keep position open # Execution Settings loop_interval_seconds: 5 # How often to check for new data +execution_type: "maker" # "maker" (Limit Post-Only) or "taker" (Market) debug_mode: false # Robustness Settings diff --git a/src/strategies/backtest_engine.py b/src/strategies/backtest_engine.py index 4b9ec84..19667ae 100644 --- a/src/strategies/backtest_engine.py +++ b/src/strategies/backtest_engine.py @@ -14,6 +14,7 @@ load_dotenv() class BacktestEngine: def __init__(self, config_path="config/ping_pong_config.yaml"): + self.version = "1.7.9" with open(config_path, 'r') as f: self.config = yaml.safe_load(f) @@ -34,6 +35,10 @@ class BacktestEngine: self.pos_size_margin = float(self.config.get('pos_size_margin', 20.0)) self.partial_exit_pct = float(self.config.get('partial_exit_pct', 0.15)) + # Stop Loss Settings + self.stop_loss_pct = 0.0 # 0.0 = Disabled + self.stop_on_hurst_break = False + self.trades = [] self.equity_curve = [] @@ -58,7 +63,6 @@ class BacktestEngine: query += " ORDER BY time ASC" - # Only use limit if NO dates are provided (to avoid truncating a specific range) if limit and not (start_date or end_date): query += f" LIMIT ${len(params)+1}" params.append(limit) @@ -85,18 +89,15 @@ class BacktestEngine: ma_values = None if ma_df is not None and ma_period: ma_df['ma'] = ma_df['close'].rolling(window=ma_period).mean() - # Merge MA values into the main timeframe using 'ffill' ma_subset = ma_df[['time', 'ma']].rename(columns={'time': 'ma_time'}) df = pd.merge_asof(df.sort_values('time'), ma_subset.sort_values('ma_time'), left_on='time', right_on='ma_time', direction='backward') ma_values = df['ma'].values - print(f"Regime Switching enabled (MA {ma_period} on {ma_df.iloc[0]['time'].strftime('%Y-%m-%d')} interval)") + print(f"Regime Switching enabled (MA {ma_period})") - # Start after enough candles for indicators start_idx = max(self.config['rsi']['period'], self.config['hurst']['period'], 100) - if start_idx >= len(df): - print(f"Error: Not enough candles for indicators. Need {start_idx}, got {len(df)}") + print(f"Error: Not enough candles. Need {start_idx}, got {len(df)}") return for i in range(start_idx, len(df)): @@ -104,28 +105,30 @@ class BacktestEngine: price = df.iloc[i]['close'] time = df.iloc[i]['time'] - # 1. Regime Check (Dynamic Switch) + # 1. Regime Check if ma_values is not None and not np.isnan(ma_values[i]): new_direction = "long" if price > ma_values[i] else "short" if new_direction != self.direction: - # Close existing position on regime change if abs(self.position_size) > 0: self.close_full_position(price, time, reason="Regime Switch") - self.direction = new_direction self.strategy.direction = new_direction - # Update leverage based on mode self.leverage = float(self.config.get('leverage_long' if self.direction == 'long' else 'leverage_short', 5.0)) # 2. Strategy Signal signal = self.strategy.check_signals(current_df) + # 3. Stop Loss Check + if abs(self.position_size) > 0: + is_stopped = self.check_stop_loss(price, time, df.iloc[i]) + if is_stopped: + signal = None + if signal == "open": self.open_position(price, time) elif signal == "close" and abs(self.position_size) > 0: self.close_partial_position(price, time) - # 3. Mark to Market Equity unrealized = 0 if self.direction == "long": unrealized = self.position_size * (price - self.entry_price) if self.position_size > 0 else 0 @@ -141,17 +144,28 @@ class BacktestEngine: qty_usd = self.pos_size_margin * self.leverage qty_btc = qty_usd / price fee = qty_usd * self.fee_rate - self.balance -= fee - - if self.direction == "long": - self.position_size += qty_btc - else: # Short - self.position_size -= qty_btc - - self.entry_price = price # Simplified avg entry + if self.direction == "long": self.position_size += qty_btc + else: self.position_size -= qty_btc + self.entry_price = price self.trades.append({"time": time, "type": f"Enter {self.direction.upper()}", "price": price, "fee": fee}) + def check_stop_loss(self, price, time, row): + """Returns True if Stop Loss was triggered""" + if self.stop_loss_pct > 0: + pnl_pct = (price - self.entry_price) / self.entry_price if self.direction == "long" else (self.entry_price - price) / self.entry_price + if pnl_pct <= -self.stop_loss_pct: + self.close_full_position(price, time, reason=f"Stop Loss ({self.stop_loss_pct*100}%)") + return True + if self.stop_on_hurst_break: + if self.direction == "long" and price < row['hurst_lower']: + self.close_full_position(price, time, reason="Stop Loss (Hurst Break)") + return True + if self.direction == "short" and price > row['hurst_upper']: + self.close_full_position(price, time, reason="Stop Loss (Hurst Break)") + return True + return False + def close_partial_position(self, price, time): qty_btc_exit = abs(self.position_size) * self.partial_exit_pct self._close_qty(qty_btc_exit, price, time, "Partial Exit") @@ -162,14 +176,12 @@ class BacktestEngine: def _close_qty(self, qty_btc_exit, price, time, reason): qty_usd_exit = qty_btc_exit * price fee = qty_usd_exit * self.fee_rate - if self.direction == "long": pnl = qty_btc_exit * (price - self.entry_price) self.position_size -= qty_btc_exit - else: # Short + else: pnl = qty_btc_exit * (self.entry_price - price) self.position_size += qty_btc_exit - self.balance += (pnl - fee) self.trades.append({"time": time, "type": reason, "price": price, "pnl": pnl, "fee": fee}) @@ -177,11 +189,12 @@ class BacktestEngine: total_pnl = self.equity - 1000.0 roi = (total_pnl / 1000.0) * 100 fees = sum(t['fee'] for t in self.trades) - + sl_hits = len([t for t in self.trades if "Stop Loss" in t['type']]) print("\n" + "="*30) print(" BACKTEST RESULTS ") print("="*30) print(f"Total Trades: {len(self.trades)}") + print(f"Stop Loss Hits: {sl_hits}") print(f"Final Equity: ${self.equity:.2f}") print(f"Total PnL: ${total_pnl:.2f}") print(f"ROI: {roi:.2f}%") @@ -190,29 +203,33 @@ class BacktestEngine: async def main(): parser = argparse.ArgumentParser(description='Ping-Pong Strategy Backtester') - parser.add_argument('--config', type=str, default='config/ping_pong_config.yaml', help='Path to config file') - parser.add_argument('--limit', type=int, default=10000, help='Max 1m candles (only if no dates)') - parser.add_argument('--start_date', type=str, help='Start Date (YYYY-MM-DD)') - parser.add_argument('--end_date', type=str, help='End Date (YYYY-MM-DD)') - parser.add_argument('--ma_period', type=int, help='MA Period for regime switching') - parser.add_argument('--ma_interval', type=str, default='1h', help='MA Interval (15m, 1h, 4h, 1d)') - parser.add_argument('--direction', type=str, choices=['long', 'short'], help='Fixed direction override') + parser.add_argument('--config', type=str, default='config/ping_pong_config.yaml') + parser.add_argument('--limit', type=int, default=10000) + parser.add_argument('--start_date', type=str) + parser.add_argument('--end_date', type=str) + parser.add_argument('--ma_period', type=int) + parser.add_argument('--ma_interval', type=str, default='1h') + parser.add_argument('--direction', type=str, choices=['long', 'short']) + parser.add_argument('--stop_loss', type=float, default=0.0, help='Stop Loss % (e.g. 0.02 for 2%)') + parser.add_argument('--hurst_stop', action='store_true', help='Enable Stop Loss on Hurst break') + parser.add_argument('--maker_fee', type=float, help='Override fee rate for Maker simulation (e.g. 0.0002)') args = parser.parse_args() engine = BacktestEngine(config_path=args.config) - # Base Data (1m) + if args.maker_fee: + engine.fee_rate = args.maker_fee + print(f"Fee Rate overridden to: {args.maker_fee} (Maker Simulation)") + + engine.stop_loss_pct = args.stop_loss + engine.stop_on_hurst_break = args.hurst_stop + symbol = engine.config['symbol'].replace("USDT", "").replace("USD", "") df = await engine.load_data(symbol, "1m", limit=args.limit, start_date=args.start_date, end_date=args.end_date) - - if df.empty: - print("No 1m data found for this period.") - return + if df.empty: return - # MA Data (if enabled) ma_df = None if args.ma_period: - # Load slightly more MA candles before the start_date to initialize the MA correctly ma_df = await engine.load_data(symbol, args.ma_interval, limit=5000, start_date=None, end_date=args.end_date) if args.direction: diff --git a/src/strategies/ping_pong_bot.py b/src/strategies/ping_pong_bot.py index fd04369..86036c7 100644 --- a/src/strategies/ping_pong_bot.py +++ b/src/strategies/ping_pong_bot.py @@ -165,7 +165,7 @@ class PingPongStrategy: class PingPongBot: def __init__(self, config_path="config/ping_pong_config.yaml"): - self.version = "1.7.5" + self.version = "1.8.2" with open(config_path, 'r') as f: self.config = yaml.safe_load(f) @@ -249,36 +249,37 @@ class PingPongBot: self.leverage_short = float(self.config.get('leverage_short', 3.0)) self.leverage = 1.0 # Current leverage self.max_eff_lev = float(self.config.get('max_effective_leverage', 1.0)) + self.exec_type = self.config.get('execution_type', 'taker').lower() def _init_tx_log(self): """Ensures CSV header exists and is up to date""" - header = "time,version,direction,symbol,trade,qty,price,leverage,pnl,fee,status\n" + header = "time,version,direction,symbol,trade,qty,price,leverage,pnl,fee,attempts,status\n" if not os.path.exists(self.tx_log_path): os.makedirs(os.path.dirname(self.tx_log_path), exist_ok=True) with open(self.tx_log_path, 'w') as f: f.write(header) else: - # Check if we need to update the header from 'side' to 'trade' + # Check if we need to update the header try: with open(self.tx_log_path, 'r') as f: first_line = f.readline() - if "side" in first_line: + if "attempts" not in first_line: with open(self.tx_log_path, 'r') as f: lines = f.readlines() if lines: lines[0] = header with open(self.tx_log_path, 'w') as f: f.writelines(lines) - logger.info("Updated CSV log header: 'side' -> 'trade'") + logger.info("Updated CSV log header: Added 'attempts' column") except Exception as e: logger.error(f"Failed to update CSV header: {e}") - async def log_transaction(self, trade, qty, price, pnl=0, fee=0, status="Success"): + async def log_transaction(self, trade, qty, price, pnl=0, fee=0, attempts=1, status="Success"): """Appends a trade record to CSV""" try: with open(self.tx_log_path, 'a') as f: t_str = datetime.now().strftime('%Y-%m-%d %H:%M:%S') - f.write(f"{t_str},{self.version},{self.direction},{self.symbol},{trade},{qty},{price},{self.leverage},{pnl},{fee},{status}\n") + f.write(f"{t_str},{self.version},{self.direction},{self.symbol},{trade},{qty},{price},{self.leverage},{pnl},{fee},{attempts},{status}\n") except Exception as e: logger.error(f"Failed to write to CSV log: {e}") @@ -488,59 +489,102 @@ class PingPongBot: if not self.category or not self.symbol: return side = "Sell" if (self.direction == "long" and is_close) or (self.direction == "short" and not is_close) else "Buy" trade = "Exit" if is_close else "Enter" - - # Using positionIdx=0 for One-Way Mode to avoid Error 10001 pos_idx = 0 - try: - qty_str = str(int(qty)) if self.category == "inverse" else str(round(qty, 3)) - - res = await asyncio.to_thread(self.session.place_order, - category=self.category, symbol=self.symbol, side=side, orderType="Market", - qty=qty_str, reduceOnly=is_close, positionIdx=pos_idx - ) - if res['retCode'] == 0: - order_id = res['result']['orderId'] - self.last_signal = f"{trade} {qty_str}" - self.status_msg = f"Order Success: {trade}" - - # Fetch execution details for fees and PnL - await asyncio.sleep(1.5) # Wait for fill and indexing - exec_info = await asyncio.to_thread(self.session.get_executions, - category=self.category, - symbol=self.symbol, - orderId=order_id) - - exec_fee = 0.0 - exec_pnl = 0.0 - exec_price = self.market_price - - if exec_info['retCode'] == 0 and exec_info['result']['list']: - fills = exec_info['result']['list'] - # Fees and closedPnl are in settleCoin (BTC for inverse, USDC for linear) - 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)) - - # Convert to USD if in BTC for consistent tracking - 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, status="Filled") + qty_str = str(int(qty)) if self.category == "inverse" else str(round(qty, 3)) + + if self.exec_type != "maker": + try: + res = await asyncio.to_thread(self.session.place_order, + category=self.category, symbol=self.symbol, side=side, orderType="Market", + qty=qty_str, reduceOnly=is_close, positionIdx=pos_idx + ) + if res['retCode'] == 0: + await self._process_filled_order(res['result']['orderId'], trade, qty_str, attempts=1) else: - await self.log_transaction(trade, qty_str, self.market_price, status="Filled (No Exec Info)") + self.status_msg = f"Order Error: {res['retMsg']}" + except Exception as e: + logger.error(f"Taker Trade Error: {e}") + return + + # Maker Chase Logic (Max 5 tries) + max_retries = 5 + for attempt in range(1, max_retries + 1): + try: + # Fresh market price for 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']) + + price_str = str(round(self.market_price, 1)) + self.status_msg = f"Chase {trade}: {attempt}/{max_retries} @ {price_str}" + + res = await asyncio.to_thread(self.session.place_order, + category=self.category, symbol=self.symbol, side=side, orderType="Limit", + qty=qty_str, price=price_str, timeInForce="PostOnly", + reduceOnly=is_close, positionIdx=pos_idx + ) + + if res['retCode'] != 0: + logger.warning(f"Maker rejected (Try {attempt}): {res['retMsg']}") + await asyncio.sleep(2) + continue + + order_id = res['result']['orderId'] + + # 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 + + # 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})") + + except Exception as e: + logger.error(f"Maker Chase Error (Try {attempt}): {e}") + await asyncio.sleep(2) + + self.status_msg = f"{trade} failed after {max_retries} chase attempts" + await self.log_transaction(trade, qty_str, self.market_price, attempts=max_retries, status="Failed (Chase Timeout)") + + async def _process_filled_order(self, order_id, trade, qty_str, attempts=1): + """Finalizes a successful trade by logging fees and PnL""" + 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)) + + 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") else: - self.status_msg = f"Order Error: {res['retMsg']}" - logger.error(f"Bybit Order Error: {res['retMsg']} (Code: {res['retCode']})") - await self.log_transaction(trade, qty_str, self.market_price, status=f"Error: {res['retMsg']}") + 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"Trade Error: {e}") + logger.error(f"Error processing filled order {order_id}: {e}") def render_dashboard(self): self.console.print("\n" + "="*60)