docs: add DOCKER_GUIDE.md and fix .env parsing; chore: update docker and script configurations
This commit is contained in:
408
src/strategies/ping_pong_bot.py
Normal file
408
src/strategies/ping_pong_bot.py
Normal file
@ -0,0 +1,408 @@
|
||||
import os
|
||||
import time
|
||||
import yaml
|
||||
import hmac
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import asyncio
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from datetime import datetime, timezone
|
||||
from dotenv import load_dotenv
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
from rich.live import Live
|
||||
from rich.panel import Panel
|
||||
from rich.layout import Layout
|
||||
from rich import box
|
||||
|
||||
# Try to import pybit, if not available, we'll suggest installing it
|
||||
try:
|
||||
from pybit.unified_trading import HTTP
|
||||
except ImportError:
|
||||
print("Error: 'pybit' library not found. Please install it with: pip install pybit")
|
||||
exit(1)
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
# Setup Logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
filename='logs/ping_pong_bot.log'
|
||||
)
|
||||
logger = logging.getLogger("PingPongBot")
|
||||
|
||||
console = Console()
|
||||
|
||||
class PingPongBot:
|
||||
def __init__(self, config_path="config/ping_pong_config.yaml"):
|
||||
with open(config_path, 'r') as f:
|
||||
self.config = yaml.safe_load(f)
|
||||
|
||||
self.api_key = os.getenv("API_KEY")
|
||||
self.api_secret = os.getenv("API_SECRET")
|
||||
|
||||
if not self.api_key or not self.api_secret:
|
||||
raise ValueError("API_KEY and API_SECRET must be set in .env file")
|
||||
|
||||
self.session = HTTP(
|
||||
testnet=False,
|
||||
api_key=self.api_key,
|
||||
api_secret=self.api_secret,
|
||||
)
|
||||
|
||||
self.symbol = self.config['symbol']
|
||||
self.interval = self.config['interval']
|
||||
self.direction = self.config['direction'].lower()
|
||||
|
||||
# State
|
||||
self.last_candle_time = None
|
||||
self.current_indicators = {}
|
||||
self.position = None
|
||||
self.wallet_balance = 0
|
||||
self.status_msg = "Initializing..."
|
||||
self.last_signal = None
|
||||
self.start_time = datetime.now()
|
||||
|
||||
# Grid parameters from config
|
||||
self.tp_pct = self.config['take_profit_pct'] / 100.0
|
||||
self.partial_exit_pct = self.config['partial_exit_pct']
|
||||
self.min_val_usd = self.config['min_position_value_usd']
|
||||
self.pos_size_margin = self.config['pos_size_margin']
|
||||
self.leverage = self.config['exchange_leverage']
|
||||
self.max_eff_lev = self.config['max_effective_leverage']
|
||||
|
||||
def rma(self, series, length):
|
||||
"""Rolling Moving Average (Wilder's Smoothing) - matches Pine Script ta.rma"""
|
||||
alpha = 1 / length
|
||||
return series.ewm(alpha=alpha, adjust=False).mean()
|
||||
|
||||
def calculate_indicators(self, df):
|
||||
"""Calculate RSI and Hurst Bands matching the JS/Dashboard implementation"""
|
||||
# 1. RSI
|
||||
rsi_cfg = self.config['rsi']
|
||||
delta = df['close'].diff()
|
||||
gain = (delta.where(delta > 0, 0))
|
||||
loss = (-delta.where(delta < 0, 0))
|
||||
|
||||
avg_gain = self.rma(gain, rsi_cfg['period'])
|
||||
avg_loss = self.rma(loss, rsi_cfg['period'])
|
||||
|
||||
rs = avg_gain / avg_loss
|
||||
df['rsi'] = 100 - (100 / (1 + rs))
|
||||
|
||||
# 2. Hurst Bands
|
||||
hurst_cfg = self.config['hurst']
|
||||
mcl_t = hurst_cfg['period']
|
||||
mcm = hurst_cfg['multiplier']
|
||||
|
||||
mcl = mcl_t / 2
|
||||
mcl_2 = int(round(mcl / 2))
|
||||
|
||||
# True Range
|
||||
df['h_l'] = df['high'] - df['low']
|
||||
df['h_pc'] = abs(df['high'] - df['close'].shift(1))
|
||||
df['l_pc'] = abs(df['low'] - df['close'].shift(1))
|
||||
df['tr'] = df[['h_l', 'h_pc', 'l_pc']].max(axis=1)
|
||||
|
||||
# RMA of Close and ATR
|
||||
df['ma_mcl'] = self.rma(df['close'], mcl)
|
||||
df['atr_mcl'] = self.rma(df['tr'], mcl)
|
||||
|
||||
# Historical Offset
|
||||
df['center'] = df['ma_mcl'].shift(mcl_2)
|
||||
# Fill first values where shift produces NaN with the MA itself (as done in JS: historical_ma || src)
|
||||
df['center'] = df['center'].fillna(df['ma_mcl'])
|
||||
|
||||
mcm_off = mcm * df['atr_mcl']
|
||||
df['hurst_upper'] = df['center'] + mcm_off
|
||||
df['hurst_lower'] = df['center'] - mcm_off
|
||||
|
||||
return df
|
||||
|
||||
async def fetch_data(self):
|
||||
"""Fetch latest Klines from Bybit V5"""
|
||||
try:
|
||||
# We fetch 200 candles to ensure indicators stabilize
|
||||
response = self.session.get_kline(
|
||||
category="linear",
|
||||
symbol=self.symbol,
|
||||
interval=self.interval,
|
||||
limit=200
|
||||
)
|
||||
|
||||
if response['retCode'] != 0:
|
||||
self.status_msg = f"API Error: {response['retMsg']}"
|
||||
return None
|
||||
|
||||
klines = response['result']['list']
|
||||
# Bybit returns newest first, we need oldest first
|
||||
df = pd.DataFrame(klines, columns=['start_time', 'open', 'high', 'low', 'close', 'volume', 'turnover'])
|
||||
df = df.astype(float)
|
||||
df = df.iloc[::-1].reset_index(drop=True)
|
||||
|
||||
return self.calculate_indicators(df)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching data: {e}")
|
||||
self.status_msg = f"Fetch Error: {str(e)}"
|
||||
return None
|
||||
|
||||
async def update_account_info(self):
|
||||
"""Update position and balance information"""
|
||||
try:
|
||||
# Get Position
|
||||
pos_response = self.session.get_positions(
|
||||
category="linear",
|
||||
symbol=self.symbol
|
||||
)
|
||||
|
||||
if pos_response['retCode'] == 0:
|
||||
positions = pos_response['result']['list']
|
||||
# Filter by side or just take the one with size > 0
|
||||
active_pos = [p for p in positions if float(p['size']) > 0]
|
||||
if active_pos:
|
||||
self.position = active_pos[0]
|
||||
else:
|
||||
self.position = None
|
||||
|
||||
# Get Balance
|
||||
wallet_response = self.session.get_wallet_balance(
|
||||
category="linear",
|
||||
coin="USDT"
|
||||
)
|
||||
|
||||
if wallet_response['retCode'] == 0:
|
||||
self.wallet_balance = float(wallet_response['result']['list'][0]['coin'][0]['walletBalance'])
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating account info: {e}")
|
||||
|
||||
def check_signals(self, df):
|
||||
"""Determine if we should Open or Close based on indicators"""
|
||||
if len(df) < 2:
|
||||
return None
|
||||
|
||||
last = df.iloc[-1]
|
||||
prev = df.iloc[-2]
|
||||
|
||||
rsi_cfg = self.config['rsi']
|
||||
hurst_cfg = self.config['hurst']
|
||||
|
||||
open_signal = False
|
||||
close_signal = False
|
||||
|
||||
# 1. RSI Signals
|
||||
rsi_buy = prev['rsi'] < rsi_cfg['oversold'] and last['rsi'] >= rsi_cfg['oversold']
|
||||
rsi_sell = prev['rsi'] > rsi_cfg['overbought'] and last['rsi'] <= rsi_cfg['overbought']
|
||||
|
||||
# 2. Hurst Signals
|
||||
hurst_buy = prev['close'] > prev['hurst_lower'] and last['close'] <= last['hurst_lower']
|
||||
hurst_sell = prev['close'] > prev['hurst_upper'] and last['close'] <= last['hurst_upper']
|
||||
|
||||
# Logic for LONG
|
||||
if self.direction == 'long':
|
||||
if (rsi_cfg['enabled_for_open'] and rsi_buy) or (hurst_cfg['enabled_for_open'] and hurst_buy):
|
||||
open_signal = True
|
||||
if (rsi_cfg['enabled_for_close'] and rsi_sell) or (hurst_cfg['enabled_for_close'] and hurst_sell):
|
||||
close_signal = True
|
||||
# Logic for SHORT
|
||||
else:
|
||||
if (rsi_cfg['enabled_for_open'] and rsi_sell) or (hurst_cfg['enabled_for_open'] and hurst_sell):
|
||||
open_signal = True
|
||||
if (rsi_cfg['enabled_for_close'] and rsi_buy) or (hurst_cfg['enabled_for_close'] and hurst_buy):
|
||||
close_signal = True
|
||||
|
||||
return "open" if open_signal else ("close" if close_signal else None)
|
||||
|
||||
async def execute_trade_logic(self, df, signal):
|
||||
"""Apply the Ping-Pong strategy logic (Accumulation + TP)"""
|
||||
last_price = float(df.iloc[-1]['close'])
|
||||
|
||||
# 1. Check Take Profit (TP)
|
||||
if self.position:
|
||||
avg_price = float(self.position['avgPrice'])
|
||||
current_qty = float(self.position['size'])
|
||||
|
||||
is_tp = False
|
||||
if self.direction == 'long':
|
||||
if last_price >= avg_price * (1 + self.tp_pct):
|
||||
is_tp = True
|
||||
else:
|
||||
if last_price <= avg_price * (1 - self.tp_pct):
|
||||
is_tp = True
|
||||
|
||||
if is_tp:
|
||||
qty_to_close = current_qty * self.partial_exit_pct
|
||||
remaining_qty = current_qty - qty_to_close
|
||||
|
||||
# Min size check
|
||||
if (remaining_qty * last_price) < self.min_val_usd:
|
||||
qty_to_close = current_qty
|
||||
self.status_msg = "TP: Closing Full Position (Min Size reached)"
|
||||
else:
|
||||
self.status_msg = f"TP: Closing Partial {self.partial_exit_pct*100}%"
|
||||
|
||||
self.place_order(qty_to_close, last_price, is_close=True)
|
||||
return
|
||||
|
||||
# 2. Check Close Signal
|
||||
if signal == "close" and self.position:
|
||||
current_qty = float(self.position['size'])
|
||||
qty_to_close = current_qty * self.partial_exit_pct
|
||||
if (current_qty - qty_to_close) * last_price < self.min_val_usd:
|
||||
qty_to_close = current_qty
|
||||
|
||||
self.status_msg = "Signal: Closing Position (Partial/Full)"
|
||||
self.place_order(qty_to_close, last_price, is_close=True)
|
||||
return
|
||||
|
||||
# 3. Check Open/Accumulate Signal
|
||||
if signal == "open":
|
||||
# Check Max Effective Leverage
|
||||
current_qty = float(self.position['size']) if self.position else 0
|
||||
current_notional = current_qty * last_price
|
||||
|
||||
entry_notional = self.pos_size_margin * self.leverage
|
||||
projected_notional = current_notional + entry_notional
|
||||
|
||||
effective_leverage = projected_notional / max(self.wallet_balance, 1.0)
|
||||
|
||||
if effective_leverage <= self.max_eff_lev:
|
||||
qty_to_open = entry_notional / last_price
|
||||
# Round qty based on symbol precision (simplified)
|
||||
qty_to_open = round(qty_to_open, 3)
|
||||
|
||||
self.status_msg = f"Signal: Opening/Accumulating {qty_to_open} units"
|
||||
self.place_order(qty_to_open, last_price, is_close=False)
|
||||
else:
|
||||
self.status_msg = f"Signal Ignored: Max Leverage {effective_leverage:.2f} > {self.max_eff_lev}"
|
||||
|
||||
def place_order(self, qty, price, is_close=False):
|
||||
"""Send order to Bybit V5"""
|
||||
side = ""
|
||||
if self.direction == "long":
|
||||
side = "Sell" if is_close else "Buy"
|
||||
else:
|
||||
side = "Buy" if is_close else "Sell"
|
||||
|
||||
try:
|
||||
response = self.session.place_order(
|
||||
category="linear",
|
||||
symbol=self.symbol,
|
||||
side=side,
|
||||
orderType="Market",
|
||||
qty=str(qty),
|
||||
timeInForce="GTC",
|
||||
reduceOnly=is_close
|
||||
)
|
||||
|
||||
if response['retCode'] == 0:
|
||||
logger.info(f"Order Placed: {side} {qty} {self.symbol}")
|
||||
self.last_signal = f"{side} {qty} @ Market"
|
||||
else:
|
||||
logger.error(f"Order Failed: {response['retMsg']}")
|
||||
self.status_msg = f"Order Error: {response['retMsg']}"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Execution Error: {e}")
|
||||
self.status_msg = f"Exec Error: {str(e)}"
|
||||
|
||||
def create_dashboard(self, df):
|
||||
"""Create a Rich layout for status display"""
|
||||
layout = Layout()
|
||||
layout.split_column(
|
||||
Layout(name="header", size=3),
|
||||
Layout(name="main", ratio=1),
|
||||
Layout(name="footer", size=3)
|
||||
)
|
||||
|
||||
# Header
|
||||
header_table = Table.grid(expand=True)
|
||||
header_table.add_column(justify="left", ratio=1)
|
||||
header_table.add_column(justify="right", ratio=1)
|
||||
|
||||
runtime = str(datetime.now() - self.start_time).split('.')[0]
|
||||
header_table.add_row(
|
||||
f"[bold cyan]Ping-Pong Bot v1.0[/bold cyan] | Symbol: [yellow]{self.symbol}[/yellow] | TF: [yellow]{self.interval}m[/yellow]",
|
||||
f"Runtime: [green]{runtime}[/green] | Time: {datetime.now().strftime('%H:%M:%S')}"
|
||||
)
|
||||
layout["header"].update(Panel(header_table, style="white on blue"))
|
||||
|
||||
# Main Content
|
||||
main_table = Table(box=box.SIMPLE, expand=True)
|
||||
main_table.add_column("Category", style="cyan")
|
||||
main_table.add_column("Value", style="white")
|
||||
|
||||
# Indicators
|
||||
last = df.iloc[-1]
|
||||
rsi_val = f"{last['rsi']:.2f}"
|
||||
rsi_status = "[green]Oversold[/green]" if last['rsi'] < self.config['rsi']['oversold'] else ("[red]Overbought[/red]" if last['rsi'] > self.config['rsi']['overbought'] else "Neutral")
|
||||
|
||||
main_table.add_row("Price", f"{last['close']:.2f}")
|
||||
main_table.add_row("RSI", f"{rsi_val} ({rsi_status})")
|
||||
main_table.add_row("Hurst Upper", f"{last['hurst_upper']:.2f}")
|
||||
main_table.add_row("Hurst Lower", f"{last['hurst_lower']:.2f}")
|
||||
|
||||
main_table.add_section()
|
||||
|
||||
# Position Info
|
||||
if self.position:
|
||||
size = self.position['size']
|
||||
avg_p = self.position['avgPrice']
|
||||
upnl = float(self.position['unrealisedPnl'])
|
||||
upnl_style = "green" if upnl >= 0 else "red"
|
||||
main_table.add_row("Position Size", f"{size}")
|
||||
main_table.add_row("Avg Entry", f"{avg_p}")
|
||||
main_table.add_row("Unrealized PnL", f"[{upnl_style}]{upnl:.2f} USDT[/{upnl_style}]")
|
||||
else:
|
||||
main_table.add_row("Position", "None")
|
||||
|
||||
main_table.add_row("Wallet Balance", f"{self.wallet_balance:.2f} USDT")
|
||||
|
||||
layout["main"].update(Panel(main_table, title="Current Status", border_style="cyan"))
|
||||
|
||||
# Footer
|
||||
footer_text = f"Status: [bold white]{self.status_msg}[/bold white]"
|
||||
if self.last_signal:
|
||||
footer_text += f" | Last Action: [yellow]{self.last_signal}[/yellow]"
|
||||
|
||||
layout["footer"].update(Panel(footer_text, border_style="yellow"))
|
||||
|
||||
return layout
|
||||
|
||||
async def run(self):
|
||||
"""Main loop"""
|
||||
with Live(console=console, refresh_per_second=1) as live:
|
||||
while True:
|
||||
# 1. Update Account
|
||||
await self.update_account_info()
|
||||
|
||||
# 2. Fetch Data & Calculate Indicators
|
||||
df = await self.fetch_data()
|
||||
|
||||
if df is not None:
|
||||
# 3. Check for New Candle (for signal processing)
|
||||
current_time = df.iloc[-1]['start_time']
|
||||
|
||||
# 4. Strategy Logic
|
||||
signal = self.check_signals(df)
|
||||
await self.execute_trade_logic(df, signal)
|
||||
|
||||
# 5. Update UI
|
||||
live.update(self.create_dashboard(df))
|
||||
|
||||
await asyncio.sleep(self.config.get('loop_interval_seconds', 5))
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
bot = PingPongBot()
|
||||
asyncio.run(bot.run())
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[bold red]Bot Stopped by User[/bold red]")
|
||||
except Exception as e:
|
||||
console.print(f"\n[bold red]Critical Error: {e}[/bold red]")
|
||||
logger.exception("Critical Error in main loop")
|
||||
Reference in New Issue
Block a user