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.
This commit is contained in:
Gemini CLI
2026-03-08 20:39:17 +01:00
parent f3b186b01d
commit 2840d9b0b3
3 changed files with 155 additions and 93 deletions

View File

@ -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)