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

@ -34,6 +34,7 @@ min_position_value_usd: 15.0 # Minimum remaining value to keep position open
# Execution Settings # Execution Settings
loop_interval_seconds: 5 # How often to check for new data loop_interval_seconds: 5 # How often to check for new data
execution_type: "maker" # "maker" (Limit Post-Only) or "taker" (Market)
debug_mode: false debug_mode: false
# Robustness Settings # Robustness Settings

View File

@ -14,6 +14,7 @@ load_dotenv()
class BacktestEngine: class BacktestEngine:
def __init__(self, config_path="config/ping_pong_config.yaml"): def __init__(self, config_path="config/ping_pong_config.yaml"):
self.version = "1.7.9"
with open(config_path, 'r') as f: with open(config_path, 'r') as f:
self.config = yaml.safe_load(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.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)) 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.trades = []
self.equity_curve = [] self.equity_curve = []
@ -58,7 +63,6 @@ class BacktestEngine:
query += " ORDER BY time ASC" 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): if limit and not (start_date or end_date):
query += f" LIMIT ${len(params)+1}" query += f" LIMIT ${len(params)+1}"
params.append(limit) params.append(limit)
@ -85,18 +89,15 @@ class BacktestEngine:
ma_values = None ma_values = None
if ma_df is not None and ma_period: if ma_df is not None and ma_period:
ma_df['ma'] = ma_df['close'].rolling(window=ma_period).mean() 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'}) 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'), df = pd.merge_asof(df.sort_values('time'), ma_subset.sort_values('ma_time'),
left_on='time', right_on='ma_time', direction='backward') left_on='time', right_on='ma_time', direction='backward')
ma_values = df['ma'].values 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) start_idx = max(self.config['rsi']['period'], self.config['hurst']['period'], 100)
if start_idx >= len(df): 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 return
for i in range(start_idx, len(df)): for i in range(start_idx, len(df)):
@ -104,28 +105,30 @@ class BacktestEngine:
price = df.iloc[i]['close'] price = df.iloc[i]['close']
time = df.iloc[i]['time'] 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]): if ma_values is not None and not np.isnan(ma_values[i]):
new_direction = "long" if price > ma_values[i] else "short" new_direction = "long" if price > ma_values[i] else "short"
if new_direction != self.direction: if new_direction != self.direction:
# Close existing position on regime change
if abs(self.position_size) > 0: if abs(self.position_size) > 0:
self.close_full_position(price, time, reason="Regime Switch") self.close_full_position(price, time, reason="Regime Switch")
self.direction = new_direction self.direction = new_direction
self.strategy.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)) self.leverage = float(self.config.get('leverage_long' if self.direction == 'long' else 'leverage_short', 5.0))
# 2. Strategy Signal # 2. Strategy Signal
signal = self.strategy.check_signals(current_df) 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": if signal == "open":
self.open_position(price, time) self.open_position(price, time)
elif signal == "close" and abs(self.position_size) > 0: elif signal == "close" and abs(self.position_size) > 0:
self.close_partial_position(price, time) self.close_partial_position(price, time)
# 3. Mark to Market Equity
unrealized = 0 unrealized = 0
if self.direction == "long": if self.direction == "long":
unrealized = self.position_size * (price - self.entry_price) if self.position_size > 0 else 0 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_usd = self.pos_size_margin * self.leverage
qty_btc = qty_usd / price qty_btc = qty_usd / price
fee = qty_usd * self.fee_rate fee = qty_usd * self.fee_rate
self.balance -= fee self.balance -= fee
if self.direction == "long": self.position_size += qty_btc
if self.direction == "long": else: self.position_size -= qty_btc
self.position_size += qty_btc self.entry_price = price
else: # Short
self.position_size -= qty_btc
self.entry_price = price # Simplified avg entry
self.trades.append({"time": time, "type": f"Enter {self.direction.upper()}", "price": price, "fee": fee}) 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): def close_partial_position(self, price, time):
qty_btc_exit = abs(self.position_size) * self.partial_exit_pct qty_btc_exit = abs(self.position_size) * self.partial_exit_pct
self._close_qty(qty_btc_exit, price, time, "Partial Exit") 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): def _close_qty(self, qty_btc_exit, price, time, reason):
qty_usd_exit = qty_btc_exit * price qty_usd_exit = qty_btc_exit * price
fee = qty_usd_exit * self.fee_rate fee = qty_usd_exit * self.fee_rate
if self.direction == "long": if self.direction == "long":
pnl = qty_btc_exit * (price - self.entry_price) pnl = qty_btc_exit * (price - self.entry_price)
self.position_size -= qty_btc_exit self.position_size -= qty_btc_exit
else: # Short else:
pnl = qty_btc_exit * (self.entry_price - price) pnl = qty_btc_exit * (self.entry_price - price)
self.position_size += qty_btc_exit self.position_size += qty_btc_exit
self.balance += (pnl - fee) self.balance += (pnl - fee)
self.trades.append({"time": time, "type": reason, "price": price, "pnl": pnl, "fee": 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 total_pnl = self.equity - 1000.0
roi = (total_pnl / 1000.0) * 100 roi = (total_pnl / 1000.0) * 100
fees = sum(t['fee'] for t in self.trades) 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("\n" + "="*30)
print(" BACKTEST RESULTS ") print(" BACKTEST RESULTS ")
print("="*30) print("="*30)
print(f"Total Trades: {len(self.trades)}") print(f"Total Trades: {len(self.trades)}")
print(f"Stop Loss Hits: {sl_hits}")
print(f"Final Equity: ${self.equity:.2f}") print(f"Final Equity: ${self.equity:.2f}")
print(f"Total PnL: ${total_pnl:.2f}") print(f"Total PnL: ${total_pnl:.2f}")
print(f"ROI: {roi:.2f}%") print(f"ROI: {roi:.2f}%")
@ -190,29 +203,33 @@ class BacktestEngine:
async def main(): async def main():
parser = argparse.ArgumentParser(description='Ping-Pong Strategy Backtester') 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('--config', type=str, default='config/ping_pong_config.yaml')
parser.add_argument('--limit', type=int, default=10000, help='Max 1m candles (only if no dates)') parser.add_argument('--limit', type=int, default=10000)
parser.add_argument('--start_date', type=str, help='Start Date (YYYY-MM-DD)') parser.add_argument('--start_date', type=str)
parser.add_argument('--end_date', type=str, help='End Date (YYYY-MM-DD)') parser.add_argument('--end_date', type=str)
parser.add_argument('--ma_period', type=int, help='MA Period for regime switching') parser.add_argument('--ma_period', type=int)
parser.add_argument('--ma_interval', type=str, default='1h', help='MA Interval (15m, 1h, 4h, 1d)') parser.add_argument('--ma_interval', type=str, default='1h')
parser.add_argument('--direction', type=str, choices=['long', 'short'], help='Fixed direction override') 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() args = parser.parse_args()
engine = BacktestEngine(config_path=args.config) 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", "") 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) df = await engine.load_data(symbol, "1m", limit=args.limit, start_date=args.start_date, end_date=args.end_date)
if df.empty: return
if df.empty:
print("No 1m data found for this period.")
return
# MA Data (if enabled)
ma_df = None ma_df = None
if args.ma_period: 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) ma_df = await engine.load_data(symbol, args.ma_interval, limit=5000, start_date=None, end_date=args.end_date)
if args.direction: if args.direction:

View File

@ -165,7 +165,7 @@ class PingPongStrategy:
class PingPongBot: class PingPongBot:
def __init__(self, config_path="config/ping_pong_config.yaml"): 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: with open(config_path, 'r') as f:
self.config = yaml.safe_load(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_short = float(self.config.get('leverage_short', 3.0))
self.leverage = 1.0 # Current leverage self.leverage = 1.0 # Current leverage
self.max_eff_lev = float(self.config.get('max_effective_leverage', 1.0)) 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): def _init_tx_log(self):
"""Ensures CSV header exists and is up to date""" """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): if not os.path.exists(self.tx_log_path):
os.makedirs(os.path.dirname(self.tx_log_path), exist_ok=True) os.makedirs(os.path.dirname(self.tx_log_path), exist_ok=True)
with open(self.tx_log_path, 'w') as f: with open(self.tx_log_path, 'w') as f:
f.write(header) f.write(header)
else: else:
# Check if we need to update the header from 'side' to 'trade' # Check if we need to update the header
try: try:
with open(self.tx_log_path, 'r') as f: with open(self.tx_log_path, 'r') as f:
first_line = f.readline() first_line = f.readline()
if "side" in first_line: if "attempts" not in first_line:
with open(self.tx_log_path, 'r') as f: with open(self.tx_log_path, 'r') as f:
lines = f.readlines() lines = f.readlines()
if lines: if lines:
lines[0] = header lines[0] = header
with open(self.tx_log_path, 'w') as f: with open(self.tx_log_path, 'w') as f:
f.writelines(lines) f.writelines(lines)
logger.info("Updated CSV log header: 'side' -> 'trade'") logger.info("Updated CSV log header: Added 'attempts' column")
except Exception as e: except Exception as e:
logger.error(f"Failed to update CSV header: {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""" """Appends a trade record to CSV"""
try: try:
with open(self.tx_log_path, 'a') as f: with open(self.tx_log_path, 'a') as f:
t_str = datetime.now().strftime('%Y-%m-%d %H:%M:%S') 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: except Exception as e:
logger.error(f"Failed to write to CSV log: {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 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" 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" trade = "Exit" if is_close else "Enter"
# Using positionIdx=0 for One-Way Mode to avoid Error 10001
pos_idx = 0 pos_idx = 0
try: qty_str = str(int(qty)) if self.category == "inverse" else str(round(qty, 3))
qty_str = str(int(qty)) if self.category == "inverse" else str(round(qty, 3))
if self.exec_type != "maker":
res = await asyncio.to_thread(self.session.place_order, try:
category=self.category, symbol=self.symbol, side=side, orderType="Market", res = await asyncio.to_thread(self.session.place_order,
qty=qty_str, reduceOnly=is_close, positionIdx=pos_idx 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'] if res['retCode'] == 0:
self.last_signal = f"{trade} {qty_str}" await self._process_filled_order(res['result']['orderId'], trade, qty_str, attempts=1)
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")
else: 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: else:
self.status_msg = f"Order Error: {res['retMsg']}" await self.log_transaction(trade, qty_str, self.market_price, attempts=attempts, status=f"Filled ({self.exec_type})")
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']}")
except Exception as e: except Exception as e:
logger.error(f"Trade Error: {e}") logger.error(f"Error processing filled order {order_id}: {e}")
def render_dashboard(self): def render_dashboard(self):
self.console.print("\n" + "="*60) self.console.print("\n" + "="*60)