Compare commits

6 Commits

Author SHA1 Message Date
b462651f60 test: add comprehensive Hurst strategy simulations and backtest engine enhancements (v1.7.11)
- Add Hurst_simulations.md with detailed performance data from Jan 2025 to Mar 2026 across 1m-1h TFs.
- Enhance backtest_engine.py:
    - Add --starting_equity for memory-efficient chunked testing.
    - Add Max Drawdown tracking.
    - Add --interval override flag.
    - Add --brake_period/--brake_interval for safety filter testing.
2026-03-10 13:02:29 +01:00
cd66a976de 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.
2026-03-08 22:07:11 +01:00
2840d9b0b3 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.
2026-03-08 20:39:17 +01:00
f3b186b01d feat: add local PC support and setup instructions for backtesting (v1.7.8) 2026-03-08 20:12:16 +01:00
56d0237bbf feat: implement backtesting environment and refine strategy logic (v1.7.5)
- Extract strategy logic into a reusable PingPongStrategy class for bot and backtester.
- Create src/strategies/backtest_engine.py for local historical testing using DB data.
- Add BACKTESTING_GUIDE.md with instructions on how to use the new environment.
- Update dashboard to show Net Realized PnL (including fees).
- Verified bot and backtester compatibility with existing Docker setup.
2026-03-08 19:56:53 +01:00
f544b06753 feat: enhance trade tracking with fees, PnL, and refined logging (v1.7.3)
- Implement real-time fee and realized PnL tracking using get_executions.
- Rename 'side' column to 'trade' in CSV log and dashboard (Enter/Exit labels).
- Add automatic CSV header migration (side -> trade).
- Enhance dashboard with session PnL (USD/BTC), total fees, and used leverage.
- Improve signal detection with candle-internal crossover logic.
- Add robust retry mechanism with failure window tracking.
- Sync exchange leverage automatically based on direction.
- Update config with robustness and mode-specific leverage settings.
2026-03-07 22:57:51 +01:00
6 changed files with 1010 additions and 83 deletions

View File

@ -36,10 +36,10 @@ uvicorn src.api.server:app --reload --host 0.0.0.0 --port 8000
### Testing ### Testing
```bash ```bash
# Test database connection # Test database connection
python test_db.py python -c "from src.data_collector.database import get_db; print('Database connection test successful')"
# Run single test (no existing test framework found but for any future tests) # Run single test (using pytest framework)
python -m pytest <test_file>.py::test_<function_name> -v python -m pytest tests/ -v -k "test_function_name"
``` ```
### Environment Setup ### Environment Setup

84
BACKTESTING_GUIDE.md Normal file
View File

@ -0,0 +1,84 @@
# Ping-Pong Bot: Backtesting & Optimization Guide
This guide explains how to use the local backtesting environment to test and optimize your BTC trading strategy using historical data from your PostgreSQL database.
## 1. Prerequisites
The backtesting engine requires `pandas`, `numpy`, and `asyncpg`. These are already installed in your `btc_ping_pong_bot` Docker container.
To run the backtester, use the following command:
```bash
docker exec -it btc_ping_pong_bot python src/strategies/backtest_engine.py
```
## 2. Local PC Setup (Recommended for Memory Savings)
Running the backtest on your local machine is much faster and saves server memory.
### Steps:
1. **Clone/Sync your repo** to your local machine.
2. **Install dependencies**:
```bash
pip install -r requirements.txt
```
3. **Configure `.env`**: Ensure your local `.env` file has the correct `DB_HOST` (e.g., `20.20.20.20`).
4. **Run the engine**:
```bash
# From the project root
python3 src/strategies/backtest_engine.py --start_date 2024-01-01 --end_date 2024-01-31
```
## 3. Backtest Engine (`backtest_engine.py`)
The backtest engine reuses the core logic from `ping_pong_bot.py` via the `PingPongStrategy` class.
### Key Features:
* **Virtual Exchange:** Simulates a $1,000 account with customizable leverage and fees.
* **Fee Simulation:** Applies a 0.05% taker fee (configurable) to every entry and exit.
* **Mark-to-Market:** Calculates real-time equity based on current price and position size.
* **Data Sourcing:** Automatically pulls the last 10,000 candles from your `btc_data` database.
### How to Run:
```bash
# From the project root
docker exec -it btc_ping_pong_bot python src/strategies/backtest_engine.py
```
## 3. Regime Testing (MA Switching)
You can test different Moving Average (MA) settings to see which regime detector works best for switching between `long` and `short` modes.
### Examples:
* **Test 15m SMA 200:**
```bash
docker exec -it btc_ping_pong_bot python src/strategies/backtest_engine.py --ma_period 200 --ma_interval 15m
```
* **Test 1h SMA 50:**
```bash
docker exec -it btc_ping_pong_bot python src/strategies/backtest_engine.py --ma_period 50 --ma_interval 1h
```
* **Test 4h SMA 100:**
```bash
docker exec -it btc_ping_pong_bot python src/strategies/backtest_engine.py --ma_period 100 --ma_interval 4h --limit 20000
```
### How it works:
When `--ma_period` is provided, the engine:
1. Loads the MA timeframe data from the DB.
2. Merges it with the 1m price data.
3. Switches modes (`long` <=> `short`) whenever the 1m price crosses the MA.
4. **Automatically closes** the existing position on a mode switch, just like the live bot.
## 4. Parameter Overrides
You can quickly override strategy settings without editing the config file:
* `--direction`: Force a specific mode (`long` or `short`).
* `--limit`: Change the number of 1m candles to test (default 10,000).
* `--config`: Use a different configuration file.
## 5. Interpreting Results
* **Final Equity:** Your simulated account balance after all trades.
* **ROI:** Return on Investment (Percentage).
* **Total Fees:** Total cost paid to the "Virtual Exchange". High fees indicate over-trading.
* **Trade Count:** Total number of Enter/Exit signals triggered.
## 6. Next Steps
1. Run the backtester to see baseline performance.
2. Adjust parameters in `config/ping_pong_config.yaml`.
3. Rerun the backtest to see the impact of your changes.
4. (Optional) Ask me to implement the `optimize_strategy.py` script once you have Optuna installed.

180
Hurst_simulations.md Normal file
View File

@ -0,0 +1,180 @@
# Comprehensive Hurst Strategy Simulations (since 2025-01-01)
Comparison of different Hurst Timeframes and Entry Filters.
# Timeframe: 1m
## Scenario: Without Filter
| Period | Start Bal | End Equity | PnL | Trades | Max DD |
| :--- | :--- | :--- | :--- | :--- | :--- |
| 2025-01-01 to 2025-03-31 | $1000.00 | $3192.86 | $2192.86 | 13239 | 7.40% |
| 2025-04-01 to 2025-06-30 | $3192.86 | $5665.00 | $2472.14 | 13242 | 3.39% |
| 2025-07-01 to 2025-09-30 | $5665.00 | $7520.61 | $1855.61 | 13720 | 1.02% |
| 2025-10-01 to 2025-12-31 | $7520.61 | $8891.62 | $1371.01 | 13584 | 0.56% |
| 2026-01-01 to 2026-03-10 | $8891.62 | $10437.95 | $1546.33 | 10120 | 0.52% |
**Final Results for 1m (Without Filter):**
- Final Equity: **$10437.95**
- Total ROI: **943.80%**
- Total Trades: **63905**
- Max Overall Drawdown: **7.40%**
## Scenario: With 1H SMA 200 Filter
| Period | Start Bal | End Equity | PnL | Trades | Max DD |
| :--- | :--- | :--- | :--- | :--- | :--- |
| 2025-01-01 to 2025-03-31 | $1000.00 | $2124.83 | $1124.83 | 10469 | 7.57% |
| 2025-04-01 to 2025-06-30 | $2124.83 | $3729.53 | $1604.70 | 11016 | 5.09% |
| 2025-07-01 to 2025-09-30 | $3729.53 | $4673.10 | $943.57 | 10923 | 2.25% |
| 2025-10-01 to 2025-12-31 | $4673.10 | $5428.01 | $754.91 | 11319 | 1.49% |
| 2026-01-01 to 2026-03-10 | $5428.01 | $6307.05 | $879.04 | 8485 | 1.24% |
**Final Results for 1m (With 1H SMA 200 Filter):**
- Final Equity: **$6307.05**
- Total ROI: **530.71%**
- Total Trades: **52212**
- Max Overall Drawdown: **7.57%**
# Timeframe: 3m
## Scenario: Without Filter
| Period | Start Bal | End Equity | PnL | Trades | Max DD |
| :--- | :--- | :--- | :--- | :--- | :--- |
| 2025-01-01 to 2025-03-31 | $1000.00 | $2309.37 | $1309.37 | 4233 | 8.10% |
| 2025-04-01 to 2025-06-30 | $2309.37 | $3801.67 | $1492.30 | 4331 | 2.07% |
| 2025-07-01 to 2025-09-30 | $3801.67 | $4988.92 | $1187.25 | 4513 | 1.60% |
| 2025-10-01 to 2025-12-31 | $4988.92 | $5912.62 | $923.70 | 4370 | 1.05% |
| 2026-01-01 to 2026-03-10 | $5912.62 | $6740.90 | $828.28 | 3306 | 1.01% |
**Final Results for 3m (Without Filter):**
- Final Equity: **$6740.90**
- Total ROI: **574.09%**
- Total Trades: **20753**
- Max Overall Drawdown: **8.10%**
## Scenario: With 1H SMA 200 Filter
| Period | Start Bal | End Equity | PnL | Trades | Max DD |
| :--- | :--- | :--- | :--- | :--- | :--- |
| 2025-01-01 to 2025-03-31 | $1000.00 | $1470.40 | $470.40 | 3288 | 10.19% |
| 2025-04-01 to 2025-06-30 | $1470.40 | $2366.47 | $896.07 | 3597 | 4.96% |
| 2025-07-01 to 2025-09-30 | $2366.47 | $2844.79 | $478.32 | 3478 | 3.23% |
| 2025-10-01 to 2025-12-31 | $2844.79 | $3250.87 | $406.08 | 3597 | 3.43% |
| 2026-01-01 to 2026-03-10 | $3250.87 | $3643.24 | $392.37 | 2739 | 1.90% |
**Final Results for 3m (With 1H SMA 200 Filter):**
- Final Equity: **$3643.24**
- Total ROI: **264.32%**
- Total Trades: **16699**
- Max Overall Drawdown: **10.19%**
# Timeframe: 5m
## Scenario: Without Filter
| Period | Start Bal | End Equity | PnL | Trades | Max DD |
| :--- | :--- | :--- | :--- | :--- | :--- |
| 2025-01-01 to 2025-03-31 | $1000.00 | $1878.15 | $878.15 | 2482 | 14.06% |
| 2025-04-01 to 2025-06-30 | $1878.15 | $3053.49 | $1175.34 | 2601 | 3.26% |
| 2025-07-01 to 2025-09-30 | $3053.49 | $3944.73 | $891.24 | 2689 | 2.08% |
| 2025-10-01 to 2025-12-31 | $3944.73 | $4578.13 | $633.40 | 2491 | 1.33% |
| 2026-01-01 to 2026-03-10 | $4578.13 | $5122.25 | $544.12 | 1966 | 1.39% |
**Final Results for 5m (Without Filter):**
- Final Equity: **$5122.25**
- Total ROI: **412.23%**
- Total Trades: **12229**
- Max Overall Drawdown: **14.06%**
## Scenario: With 1H SMA 200 Filter
| Period | Start Bal | End Equity | PnL | Trades | Max DD |
| :--- | :--- | :--- | :--- | :--- | :--- |
| 2025-01-01 to 2025-03-31 | $1000.00 | $1304.55 | $304.55 | 1915 | 14.36% |
| 2025-04-01 to 2025-06-30 | $1304.55 | $1926.70 | $622.15 | 2134 | 7.03% |
| 2025-07-01 to 2025-09-30 | $1926.70 | $2253.45 | $326.75 | 2052 | 4.25% |
| 2025-10-01 to 2025-12-31 | $2253.45 | $2539.78 | $286.33 | 2038 | 2.83% |
| 2026-01-01 to 2026-03-10 | $2539.78 | $2774.98 | $235.20 | 1583 | 2.75% |
**Final Results for 5m (With 1H SMA 200 Filter):**
- Final Equity: **$2774.98**
- Total ROI: **177.50%**
- Total Trades: **9722**
- Max Overall Drawdown: **14.36%**
# Timeframe: 15m
## Scenario: Without Filter
| Period | Start Bal | End Equity | PnL | Trades | Max DD |
| :--- | :--- | :--- | :--- | :--- | :--- |
| 2025-01-01 to 2025-03-31 | $1000.00 | $1373.61 | $373.61 | 785 | 12.40% |
| 2025-04-01 to 2025-06-30 | $1373.61 | $1824.22 | $450.61 | 725 | 6.62% |
| 2025-07-01 to 2025-09-30 | $1824.22 | $2212.35 | $388.13 | 807 | 2.60% |
| 2025-10-01 to 2025-12-31 | $2212.35 | $2535.13 | $322.78 | 765 | 3.14% |
| 2026-01-01 to 2026-03-10 | $2535.13 | $2821.05 | $285.92 | 607 | 1.92% |
**Final Results for 15m (Without Filter):**
- Final Equity: **$2821.05**
- Total ROI: **182.11%**
- Total Trades: **3689**
- Max Overall Drawdown: **12.40%**
## Scenario: With 1H SMA 200 Filter
| Period | Start Bal | End Equity | PnL | Trades | Max DD |
| :--- | :--- | :--- | :--- | :--- | :--- |
| 2025-01-01 to 2025-03-31 | $1000.00 | $993.20 | $-6.80 | 578 | 20.65% |
| 2025-04-01 to 2025-06-30 | $993.20 | $1127.20 | $134.00 | 559 | 10.21% |
| 2025-07-01 to 2025-09-30 | $1127.20 | $1328.46 | $201.26 | 595 | 2.96% |
| 2025-10-01 to 2025-12-31 | $1328.46 | $1394.87 | $66.41 | 606 | 5.36% |
| 2026-01-01 to 2026-03-10 | $1394.87 | $1379.42 | $-15.45 | 455 | 7.15% |
**Final Results for 15m (With 1H SMA 200 Filter):**
- Final Equity: **$1379.42**
- Total ROI: **37.94%**
- Total Trades: **2793**
- Max Overall Drawdown: **20.65%**
# Timeframe: 37m
## Scenario: Without Filter
| Period | Start Bal | End Equity | PnL | Trades | Max DD |
| :--- | :--- | :--- | :--- | :--- | :--- |
| 2025-01-01 to 2025-03-31 | $1000.00 | $1120.15 | $120.15 | 293 | 10.05% |
| 2025-04-01 to 2025-06-30 | $1120.15 | $1487.65 | $367.50 | 282 | 6.98% |
| 2025-07-01 to 2025-09-30 | $1487.65 | $1520.11 | $32.46 | 289 | 4.01% |
| 2025-10-01 to 2025-12-31 | $1520.11 | $1575.24 | $55.13 | 292 | 9.73% |
| 2026-01-01 to 2026-03-10 | $1575.24 | $1748.85 | $173.61 | 246 | 3.27% |
**Final Results for 37m (Without Filter):**
- Final Equity: **$1748.85**
- Total ROI: **74.88%**
- Total Trades: **1402**
- Max Overall Drawdown: **10.05%**
## Scenario: With 1H SMA 200 Filter
| Period | Start Bal | End Equity | PnL | Trades | Max DD |
| :--- | :--- | :--- | :--- | :--- | :--- |
| 2025-01-01 to 2025-03-31 | $1000.00 | $1003.56 | $3.56 | 206 | 10.69% |
| 2025-04-01 to 2025-06-30 | $1003.56 | $1130.86 | $127.30 | 208 | 8.98% |
| 2025-07-01 to 2025-09-30 | $1130.86 | $1154.90 | $24.04 | 202 | 2.81% |
| 2025-10-01 to 2025-12-31 | $1154.90 | $1108.85 | $-46.05 | 224 | 9.50% |
| 2026-01-01 to 2026-03-10 | $1108.85 | $1117.52 | $8.67 | 179 | 4.01% |
**Final Results for 37m (With 1H SMA 200 Filter):**
- Final Equity: **$1117.52**
- Total ROI: **11.75%**
- Total Trades: **1019**
- Max Overall Drawdown: **10.69%**
# Timeframe: 1h
## Scenario: Without Filter
| Period | Start Bal | End Equity | PnL | Trades | Max DD |
| :--- | :--- | :--- | :--- | :--- | :--- |
| 2025-01-01 to 2025-03-31 | $1000.00 | $1109.99 | $109.99 | 162 | 7.36% |
| 2025-04-01 to 2025-06-30 | $1109.99 | $1305.05 | $195.06 | 145 | 7.26% |
| 2025-07-01 to 2025-09-30 | $1305.05 | $1342.48 | $37.43 | 180 | 2.83% |
| 2025-10-01 to 2025-12-31 | $1342.48 | $1448.42 | $105.94 | 166 | 6.25% |
| 2026-01-01 to 2026-03-10 | $1448.42 | $1552.57 | $104.15 | 135 | 4.56% |
**Final Results for 1h (Without Filter):**
- Final Equity: **$1552.57**
- Total ROI: **55.26%**
- Total Trades: **788**
- Max Overall Drawdown: **7.36%**

View File

@ -1,7 +1,7 @@
# Ping-Pong Strategy Configuration # Ping-Pong Strategy Configuration
# Trading Pair & Timeframe # Trading Pair & Timeframe
symbol: BTCUSDT symbol: BTCUSD
interval: "1" # Minutes (1, 3, 5, 15, 30, 60, 120, 240, 360, 720, D, W, M) interval: "1" # Minutes (1, 3, 5, 15, 30, 60, 120, 240, 360, 720, D, W, M)
# Indicator Settings # Indicator Settings
@ -9,25 +9,37 @@ rsi:
period: 14 period: 14
overbought: 70 overbought: 70
oversold: 30 oversold: 30
TF: 1 # same as symbol's interval
enabled_for_open: true enabled_for_open: true
enabled_for_close: true enabled_for_close: true
hurst: hurst:
period: 30 period: 30
multiplier: 1.8 multiplier: 1.8
TF: 1 # same as symbol's interval
enabled_for_open: true enabled_for_open: true
enabled_for_close: true enabled_for_close: true
# Strategy Settings # Strategy Settings
direction: "long" # "long" or "short" direction: "long" # "long" or "short"
capital: 1000.0 # Initial capital for calculations (informational) capital: 1000.0 # Initial capital for calculations (informational)
exchange_leverage: 3.0 # Multiplier for each 'ping' size leverage_long: 10.0 # Leverage for LONG mode
max_effective_leverage: 1.0 # Cap on total position size relative to equity leverage_short: 5.0 # Leverage for SHORT mode
max_effective_leverage: 2.5 # Cap on total position size relative to equity
pos_size_margin: 20.0 # Margin per 'ping' (USD) pos_size_margin: 20.0 # Margin per 'ping' (USD)
take_profit_pct: 1.5 # Target profit percentage per exit (1.5 = 1.5%) #take_profit_pct: 1.5 # Target profit percentage per exit (1.5 = 1.5%)
partial_exit_pct: 0.15 # 15% of position closed on each TP hit partial_exit_pct: 0.15 # 15% of position closed on each TP hit
min_position_value_usd: 15.0 # Minimum remaining value to keep position open min_position_value_usd: 15.0 # Minimum remaining value to keep position open
# Execution Settings # Execution Settings
loop_interval_seconds: 10 # 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:
enabled: true
max_retries: 3
retry_window_seconds: 300 # 5 minutes
autostart_on_reboot: true

View File

@ -0,0 +1,292 @@
import pandas as pd
import numpy as np
import yaml
import os
import asyncio
import asyncpg
import argparse
from datetime import datetime
from dotenv import load_dotenv
from ping_pong_bot import PingPongStrategy
# Load environment variables from .env
load_dotenv()
class BacktestEngine:
def __init__(self, config_path="config/ping_pong_config.yaml", starting_equity=1000.0):
self.version = "1.7.11"
with open(config_path, 'r') as f:
self.config = yaml.safe_load(f)
self.strategy = PingPongStrategy(self.config)
self.direction = self.config.get('direction', 'long')
self.strategy.direction = self.direction
# Virtual Exchange State
self.start_equity = starting_equity
self.balance = starting_equity
self.equity = starting_equity
self.position_size = 0.0 # BTC
self.position_value = 0.0 # USD
self.entry_price = 0.0
# Performance Tracking
self.max_equity = starting_equity
self.max_drawdown = 0.0
# Settings
self.fee_rate = 0.0005 # 0.05% Taker
self.leverage = 5.0 # Will be updated based on mode
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
# Safety Brake Settings
self.use_brake = False
self.trades = []
self.equity_curve = []
async def load_data(self, symbol, interval, limit=None, start_date=None, end_date=None):
conn = await asyncpg.connect(
host=os.getenv('DB_HOST', '20.20.20.20'),
port=int(os.getenv('DB_PORT', 5433)),
user=os.getenv('DB_USER', 'btc_bot'),
password=os.getenv('DB_PASSWORD', ''),
database=os.getenv('DB_NAME', 'btc_data')
)
query = "SELECT time, open, high, low, close, volume FROM candles WHERE symbol = $1 AND interval = $2"
params = [symbol, interval]
if start_date:
query += f" AND time >= ${len(params)+1}"
params.append(datetime.fromisoformat(start_date))
if end_date:
query += f" AND time <= ${len(params)+1}"
params.append(datetime.fromisoformat(end_date))
query += " ORDER BY time ASC"
if limit and not (start_date or end_date):
query += f" LIMIT ${len(params)+1}"
params.append(limit)
rows = await conn.fetch(query, *params)
await conn.close()
df = pd.DataFrame([dict(r) for r in rows])
if df.empty: return df
df[['open', 'high', 'low', 'close', 'volume']] = df[['open', 'high', 'low', 'close', 'volume']].astype(float)
return df
def run(self, df, ma_df=None, ma_period=None, brake_df=None, brake_period=None):
if df.empty:
print("No data to run backtest.")
return
print(f"Starting backtest on {len(df)} candles...")
print(f"Period: {df.iloc[0]['time']} to {df.iloc[-1]['time']}")
df = self.strategy.calculate_indicators(df)
# Prepare MA for regime switching
ma_values = None
if ma_df is not None and ma_period:
ma_df['ma'] = ma_df['close'].rolling(window=ma_period).mean()
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})")
# Prepare Brake MA
brake_values = None
if brake_df is not None and brake_period:
brake_df['brake_ma'] = brake_df['close'].rolling(window=brake_period).mean()
brake_subset = brake_df[['time', 'brake_ma']].rename(columns={'time': 'brake_time'})
df = pd.merge_asof(df.sort_values('time'), brake_subset.sort_values('brake_time'),
left_on='time', right_on='brake_time', direction='backward')
brake_values = df['brake_ma'].values
self.use_brake = True
print(f"Safety Brake enabled (MA {brake_period})")
start_idx = max(self.config['rsi']['period'], self.config['hurst']['period'], 100)
if start_idx >= len(df):
print(f"Error: Not enough candles. Need {start_idx}, got {len(df)}")
return
for i in range(start_idx, len(df)):
current_df = df.iloc[:i+1]
price = df.iloc[i]['close']
time = df.iloc[i]['time']
# 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:
if abs(self.position_size) > 0:
self.close_full_position(price, time, reason="Regime Switch")
self.direction = new_direction
self.strategy.direction = new_direction
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":
# Apply Safety Brake
if self.use_brake and brake_values is not None and not np.isnan(brake_values[i]):
if self.direction == "short" and price > brake_values[i]:
signal = None # Brake: Don't short in uptrend
elif self.direction == "long" and price < brake_values[i]:
signal = None # Brake: Don't long in downtrend
if signal == "open":
self.open_position(price, time)
elif signal == "close" and abs(self.position_size) > 0:
self.close_partial_position(price, time)
# 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
else:
unrealized = abs(self.position_size) * (self.entry_price - price) if self.position_size < 0 else 0
self.equity = self.balance + unrealized
# Max Drawdown Tracking
if self.equity > self.max_equity:
self.max_equity = self.equity
dd = (self.max_equity - self.equity) / self.max_equity
if dd > self.max_drawdown:
self.max_drawdown = dd
self.equity_curve.append({"time": time, "equity": self.equity})
self.print_results()
def open_position(self, price, time):
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: 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")
def close_full_position(self, price, time, reason="Exit"):
self._close_qty(abs(self.position_size), price, time, reason)
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:
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})
def print_results(self):
total_pnl = self.equity - self.start_equity
roi = (total_pnl / self.start_equity) * 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}%")
print(f"Max Drawdown: {self.max_drawdown*100:.2f}%")
print(f"Total Fees: ${fees:.2f}")
print("="*30)
async def main():
parser = argparse.ArgumentParser(description='Ping-Pong Strategy Backtester')
parser.add_argument('--config', type=str, default='config/ping_pong_config.yaml')
parser.add_argument('--limit', type=int, default=10000)
parser.add_argument('--interval', type=str, help='Strategy Interval (e.g. 5m, 15m)')
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('--brake_period', type=int, help='Safety Brake MA Period')
parser.add_argument('--brake_interval', type=str, default='1h', help='Safety Brake MA Interval')
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)')
parser.add_argument('--starting_equity', type=float, default=1000.0, help='Initial balance')
args = parser.parse_args()
engine = BacktestEngine(config_path=args.config, starting_equity=args.starting_equity)
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
# Base Data
symbol = engine.config['symbol'].replace("USDT", "").replace("USD", "")
data_interval = args.interval if args.interval else engine.config['interval']
if data_interval.isdigit(): data_interval += "m"
df = await engine.load_data(symbol, data_interval, limit=args.limit, start_date=args.start_date, end_date=args.end_date)
if df.empty: return
ma_df = None
if args.ma_period:
ma_df = await engine.load_data(symbol, args.ma_interval, limit=5000, start_date=None, end_date=args.end_date)
brake_df = None
if args.brake_period:
brake_df = await engine.load_data(symbol, args.brake_interval, limit=5000, start_date=None, end_date=args.end_date)
if args.direction:
engine.direction = args.direction
engine.strategy.direction = args.direction
engine.run(df, ma_df=ma_df, ma_period=args.ma_period, brake_df=brake_df, brake_period=args.brake_period)
if __name__ == "__main__":
asyncio.run(main())

View File

@ -5,6 +5,7 @@ import hmac
import hashlib import hashlib
import json import json
import logging import logging
from logging.handlers import RotatingFileHandler
import asyncio import asyncio
import pandas as pd import pandas as pd
import numpy as np import numpy as np
@ -30,14 +31,28 @@ load_dotenv()
log_level = os.getenv("LOG_LEVEL", "INFO") log_level = os.getenv("LOG_LEVEL", "INFO")
# Setup Logging # Setup Logging
logging.basicConfig( log_dir = "logs"
level=getattr(logging, log_level), os.makedirs(log_dir, exist_ok=True)
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', error_log_path = os.path.join(log_dir, "ping_pong_errors.log")
handlers=[
logging.StreamHandler() # Create logger
]
)
logger = logging.getLogger("PingPongBot") 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: class DatabaseManager:
"""Minimal Database Manager for the bot""" """Minimal Database Manager for the bot"""
@ -84,12 +99,93 @@ class DatabaseManager:
logger.error(f"DB Query Error for {symbol} {interval}: {e}") logger.error(f"DB Query Error for {symbol} {interval}: {e}")
return [] return []
class PingPongStrategy:
"""Core Strategy Logic for Ping-Pong Scalping"""
def __init__(self, config):
self.config = config
self.direction = config.get('direction', 'long')
def rma(self, series, length):
alpha = 1 / length
return series.ewm(alpha=alpha, adjust=False).mean()
def calculate_indicators(self, df):
# RSI
rsi_cfg = self.config['rsi']
delta = df['close'].diff()
gain = delta.where(delta > 0, 0)
loss = -delta.where(delta < 0, 0)
df['rsi'] = 100 - (100 / (1 + (self.rma(gain, rsi_cfg['period']) / self.rma(loss, rsi_cfg['period']))))
# Hurst
hurst_cfg = self.config['hurst']
mcl = hurst_cfg['period'] / 2
mcl_2 = int(round(mcl / 2))
df['tr'] = np.maximum(df['high'] - df['low'], np.maximum(abs(df['high'] - df['close'].shift(1)), abs(df['low'] - df['close'].shift(1))))
df['ma_mcl'] = self.rma(df['close'], mcl)
df['atr_mcl'] = self.rma(df['tr'], mcl)
df['center'] = df['ma_mcl'].shift(mcl_2).fillna(df['ma_mcl'])
mcm_off = hurst_cfg['multiplier'] * df['atr_mcl']
df['hurst_upper'] = df['center'] + mcm_off
df['hurst_lower'] = df['center'] - mcm_off
return df
def check_signals(self, df):
if len(df) < 3: return None
# finished = candle that just closed (e.g. 10:30)
# prev = candle before that (e.g. 10:29)
finished = df.iloc[-2]
prev = df.iloc[-3]
rsi_cfg, hurst_cfg = self.config['rsi'] or {}, self.config['hurst'] or {}
def is_crossing_up(p_val, p_band, c_open, c_close, c_band):
# 1. Crossed up BETWEEN candles
between = p_val < p_band and c_close >= c_band
# 2. Crossed up WITHIN this candle
within = c_open is not None and c_open < c_band and c_close >= c_band
return between or within
def is_crossing_down(p_val, p_band, c_open, c_close, c_band):
# 1. Crossed down BETWEEN candles
between = p_val > p_band and c_close <= c_band
# 2. Crossed down WITHIN this candle
within = c_open is not None and c_open > c_band and c_close <= c_band
return between or within
# Hurst Signals
h_upper_cross_down = is_crossing_down(prev['close'], prev['hurst_upper'], finished['open'], finished['close'], finished['hurst_upper'])
h_lower_cross_down = is_crossing_down(prev['close'], prev['hurst_lower'], finished['open'], finished['close'], finished['hurst_lower'])
# RSI Signals
rsi_cross_up = is_crossing_up(prev['rsi'], rsi_cfg.get('oversold', 30), None, finished['rsi'], rsi_cfg.get('oversold', 30))
rsi_cross_down = is_crossing_down(prev['rsi'], rsi_cfg.get('overbought', 70), None, finished['rsi'], rsi_cfg.get('overbought', 70))
l_open = (rsi_cfg.get('enabled_for_open') and rsi_cross_up) or \
(hurst_cfg.get('enabled_for_open') and h_lower_cross_down)
l_close = (rsi_cfg.get('enabled_for_close') and rsi_cross_down) or \
(hurst_cfg.get('enabled_for_close') and h_upper_cross_down)
s_open = (rsi_cfg.get('enabled_for_open') and rsi_cross_down) or \
(hurst_cfg.get('enabled_for_open') and h_upper_cross_down)
s_close = (rsi_cfg.get('enabled_for_close') and rsi_cross_up) or \
(hurst_cfg.get('enabled_for_close') and h_lower_cross_down)
if self.direction == 'long':
return "open" if l_open else ("close" if l_close else None)
else:
return "open" if s_open else ("close" if s_close else None)
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.5.7" self.version = "1.8.6"
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)
self.strategy = PingPongStrategy(self.config)
# Explicitly load from ENV to ensure they are available # Explicitly load from ENV to ensure they are available
self.api_key = os.getenv("BYBIT_API_KEY") or os.getenv("API_KEY") self.api_key = os.getenv("BYBIT_API_KEY") or os.getenv("API_KEY")
self.api_secret = os.getenv("BYBIT_API_SECRET") or os.getenv("API_SECRET") self.api_secret = os.getenv("BYBIT_API_SECRET") or os.getenv("API_SECRET")
@ -132,51 +228,78 @@ class PingPongBot:
# Bot State # Bot State
self.last_candle_time = None self.last_candle_time = None
self.last_candle_open = 0.0
self.last_candle_close = 0.0
self.last_candle_price = 0.0 self.last_candle_price = 0.0
self.current_indicators = { self.current_indicators = {
"rsi": {"value": 0.0, "timestamp": "N/A"}, "rsi": {"value": 0.0, "timestamp": "N/A"},
"hurst_lower": {"value": 0.0, "timestamp": "N/A"}, "hurst_lower": {"value": 0.0, "timestamp": "N/A"},
"hurst_upper": {"value": 0.0, "timestamp": "N/A"} "hurst_upper": {"value": 0.0, "timestamp": "N/A"}
} }
self.failure_history = []
self.position = None self.position = None
self.wallet_balance = 0 self.wallet_balance = 0
self.available_balance = 0
self.start_equity = 0.0
self.start_equity_btc = 0.0
self.session_pnl = 0.0
self.session_pnl_btc = 0.0
self.total_fees = 0.0
self.total_realized_pnl = 0.0
self.market_price = 0.0 self.market_price = 0.0
self.status_msg = "Initializing..." self.status_msg = "Initializing..."
self.last_signal = None self.last_signal = None
self.start_time = datetime.now() self.start_time = datetime.now()
self.console = Console() self.console = Console()
# Transaction Logging
self.tx_log_path = "logs/ping_pong_transactions.csv"
self._init_tx_log()
# Fixed Parameters from Config # Fixed Parameters from Config
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))
self.min_val_usd = float(self.config.get('min_position_value_usd', 15.0)) self.min_val_usd = float(self.config.get('min_position_value_usd', 15.0))
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.leverage = float(self.config.get('exchange_leverage', 3.0)) self.leverage_long = float(self.config.get('leverage_long', 10.0))
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.max_eff_lev = float(self.config.get('max_effective_leverage', 1.0))
self.exec_type = self.config.get('execution_type', 'taker').lower()
def rma(self, series, length): def _init_tx_log(self):
alpha = 1 / length """Ensures CSV header exists and is up to date"""
return series.ewm(alpha=alpha, adjust=False).mean() 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
try:
with open(self.tx_log_path, 'r') as f:
first_line = f.readline()
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: 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, 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},{attempts},{status}\n")
except Exception as e:
logger.error(f"Failed to write to CSV log: {e}")
def calculate_indicators(self, df): def calculate_indicators(self, df):
# RSI df = self.strategy.calculate_indicators(df)
rsi_cfg = self.config['rsi']
delta = df['close'].diff()
gain = delta.where(delta > 0, 0)
loss = -delta.where(delta < 0, 0)
df['rsi'] = 100 - (100 / (1 + (self.rma(gain, rsi_cfg['period']) / self.rma(loss, rsi_cfg['period']))))
# Hurst
hurst_cfg = self.config['hurst']
mcl = hurst_cfg['period'] / 2
mcl_2 = int(round(mcl / 2))
df['tr'] = np.maximum(df['high'] - df['low'], np.maximum(abs(df['high'] - df['close'].shift(1)), abs(df['low'] - df['close'].shift(1))))
df['ma_mcl'] = self.rma(df['close'], mcl)
df['atr_mcl'] = self.rma(df['tr'], mcl)
df['center'] = df['ma_mcl'].shift(mcl_2).fillna(df['ma_mcl'])
mcm_off = hurst_cfg['multiplier'] * df['atr_mcl']
df['hurst_upper'] = df['center'] + mcm_off
df['hurst_lower'] = df['center'] - mcm_off
last_row = df.iloc[-1] last_row = df.iloc[-1]
now_str = datetime.now().strftime("%H:%M:%S") now_str = datetime.now().strftime("%H:%M:%S")
self.current_indicators["rsi"] = {"value": float(last_row['rsi']), "timestamp": now_str} self.current_indicators["rsi"] = {"value": float(last_row['rsi']), "timestamp": now_str}
@ -214,19 +337,25 @@ class PingPongBot:
await self.close_all_positions() await self.close_all_positions()
self.direction = new_direction self.direction = new_direction
self.strategy.direction = new_direction
if self.direction == "long": if self.direction == "long":
self.category = "inverse" self.category = "inverse"
self.symbol = f"{self.base_coin}USD" self.symbol = f"{self.base_coin}USD"
self.settle_coin = self.base_coin self.settle_coin = self.base_coin
self.leverage = self.leverage_long
else: else:
self.category = "linear" self.category = "linear"
self.symbol = "BTCPERP" if self.base_coin == "BTC" else f"{self.base_coin}USDC" self.symbol = "BTCPERP" if self.base_coin == "BTC" else f"{self.base_coin}USDC"
self.settle_coin = "USDC" self.settle_coin = "USDC"
self.leverage = self.leverage_short
# Perform swap # Perform swap
await self.swap_assets(new_direction) await self.swap_assets(new_direction)
logger.info(f"Bot configured for {self.direction.upper()} | Symbol: {self.symbol} | Category: {self.category}") # Sync Leverage with Bybit
await self.set_exchange_leverage()
logger.info(f"Bot configured for {self.direction.upper()} | Symbol: {self.symbol} | Category: {self.category} | Leverage: {self.leverage}")
self.last_candle_time = None self.last_candle_time = None
return True return True
@ -236,6 +365,34 @@ class PingPongBot:
self.status_msg = f"Dir Error: {str(e)[:20]}" self.status_msg = f"Dir Error: {str(e)[:20]}"
return False return False
async def set_exchange_leverage(self):
"""Points Bybit API to set account leverage for current category/symbol"""
try:
if not self.category or not self.symbol: return
logger.info(f"Setting exchange leverage to {self.leverage}x for {self.symbol}...")
res = await asyncio.to_thread(self.session.set_leverage,
category=self.category,
symbol=self.symbol,
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:
# 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): async def close_all_positions(self):
"""Closes any active position in the current category/symbol""" """Closes any active position in the current category/symbol"""
try: try:
@ -305,30 +462,25 @@ class PingPongBot:
if wallet['retCode'] == 0: if wallet['retCode'] == 0:
res_list = wallet['result']['list'] res_list = wallet['result']['list']
if res_list: if res_list:
self.wallet_balance = float(res_list[0].get('totalWalletBalance', 0)) # Use totalEquity for NAV (Net Asset Value) tracking
current_equity = float(res_list[0].get('totalEquity', 0))
self.wallet_balance = current_equity
self.available_balance = float(res_list[0].get('totalAvailableBalance', 0))
# Calculate BTC-equivalent equity
current_equity_btc = current_equity / max(self.market_price, 1)
if self.start_equity == 0.0:
self.start_equity = current_equity
self.start_equity_btc = current_equity_btc
self.session_pnl = current_equity - self.start_equity
self.session_pnl_btc = current_equity_btc - self.start_equity_btc
except Exception as e: except Exception as e:
logger.error(f"Exchange Sync Error: {e}") logger.error(f"Exchange Sync Error: {e}")
def check_signals(self, df): def check_signals(self, df):
if len(df) < 2: return None return self.strategy.check_signals(df)
last, prev = df.iloc[-1], df.iloc[-2]
rsi_cfg, hurst_cfg = self.config['rsi'] or {}, self.config['hurst'] or {}
# Signals defined by crossover
l_open = (rsi_cfg.get('enabled_for_open') and prev['rsi'] < rsi_cfg.get('oversold', 30) and last['rsi'] >= rsi_cfg.get('oversold', 30)) or \
(hurst_cfg.get('enabled_for_open') and prev['close'] > prev['hurst_lower'] and last['close'] <= last['hurst_lower'])
l_close = (rsi_cfg.get('enabled_for_close') and prev['rsi'] > rsi_cfg.get('overbought', 70) and last['rsi'] <= rsi_cfg.get('overbought', 70)) or \
(hurst_cfg.get('enabled_for_close') and prev['close'] < prev['hurst_upper'] and last['close'] >= last['hurst_upper'])
s_open = (rsi_cfg.get('enabled_for_open') and prev['rsi'] > rsi_cfg.get('overbought', 70) and last['rsi'] <= rsi_cfg.get('overbought', 70)) or \
(hurst_cfg.get('enabled_for_open') and prev['close'] < prev['hurst_upper'] and last['close'] >= last['hurst_upper'])
s_close = (rsi_cfg.get('enabled_for_close') and prev['rsi'] < rsi_cfg.get('oversold', 30) and last['rsi'] >= rsi_cfg.get('oversold', 30)) or \
(hurst_cfg.get('enabled_for_close') and prev['close'] > prev['hurst_lower'] and last['close'] <= last['hurst_lower'])
if self.direction == 'long':
return "open" if l_open else ("close" if l_close else None)
else:
return "open" if s_open else ("close" if s_close else None)
async def execute_trade(self, signal): async def execute_trade(self, signal):
if not signal or not self.market_price: return if not signal or not self.market_price: return
@ -359,21 +511,147 @@ class PingPongBot:
async def place_order(self, qty, is_close=False): async def place_order(self, qty, is_close=False):
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"
pos_idx = 1 if self.direction == "long" else 2 trade = "Exit" if is_close else "Enter"
try: pos_idx = 0
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":
try:
res = await asyncio.to_thread(self.session.place_order, res = await asyncio.to_thread(self.session.place_order,
category=self.category, symbol=self.symbol, side=side, orderType="Market", category=self.category, symbol=self.symbol, side=side, orderType="Market",
qty=qty_str, reduceOnly=is_close, positionIdx=pos_idx qty=qty_str, reduceOnly=is_close, positionIdx=pos_idx
) )
if res['retCode'] == 0: if res['retCode'] == 0:
self.last_signal = f"{side} {qty_str}" await self._process_filled_order(res['result']['orderId'], trade, qty_str, attempts=1)
self.status_msg = f"Order Success: {side}"
else: else:
self.status_msg = f"Order Error: {res['retMsg']}" self.status_msg = f"Order Error: {res['retMsg']}"
except Exception as e: except Exception as e:
logger.error(f"Trade Error: {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 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 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} (Mid)"
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:
# 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
order_id = res['result']['orderId']
# Monitor for fill (Wait 10 seconds)
for _ in range(10):
await asyncio.sleep(1)
# 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
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}")
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 (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 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")
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): def render_dashboard(self):
self.console.print("\n" + "="*60) self.console.print("\n" + "="*60)
@ -382,7 +660,24 @@ class PingPongBot:
cfg_table.add_column("Property"); cfg_table.add_column("Value") cfg_table.add_column("Property"); cfg_table.add_column("Value")
cfg_table.add_row("Symbol", self.symbol or "N/A"); cfg_table.add_row("Category", self.category or "N/A") cfg_table.add_row("Symbol", self.symbol or "N/A"); cfg_table.add_row("Category", self.category or "N/A")
cfg_table.add_row("Market Price", f"${self.market_price:.2f}"); cfg_table.add_row("SMA(44, 1D)", f"${self.ma_44_val:.2f}") cfg_table.add_row("Market Price", f"${self.market_price:.2f}"); cfg_table.add_row("SMA(44, 1D)", f"${self.ma_44_val:.2f}")
cfg_table.add_row("Last Candle", f"{self.last_candle_time} (@${self.last_candle_price:.2f})") cfg_table.add_row("Last Candle", f"{self.last_candle_time}")
cfg_table.add_row("Candle O / C", f"${self.last_candle_open:.2f} / ${self.last_candle_close:.2f}")
cfg_table.add_row("Leverage", f"{self.leverage}x")
# Running Stats
runtime = datetime.now() - self.start_time
runtime_str = str(runtime).split('.')[0] # Remove microseconds
pnl_color = "green" if self.session_pnl >= 0 else "red"
pnl_btc_color = "green" if self.session_pnl_btc >= 0 else "red"
net_realized_pnl = self.total_realized_pnl - self.total_fees
cfg_table.add_row("Running Time", runtime_str)
cfg_table.add_row("Session PnL (USD)", f"[bold {pnl_color}]{'$' if self.session_pnl >= 0 else '-$'}{abs(self.session_pnl):.2f}[/]")
cfg_table.add_row("Session PnL (BTC)", f"[bold {pnl_btc_color}]{'{:+.6f}'.format(self.session_pnl_btc)} BTC[/]")
cfg_table.add_row("Total Fees", f"[bold red]-${self.total_fees:.2f}[/]")
cfg_table.add_row("Gross Realized PnL", f"[bold {'green' if self.total_realized_pnl >= 0 else 'red'}]${self.total_realized_pnl:.2f}[/]")
cfg_table.add_row("Net Realized PnL", f"[bold {'green' if net_realized_pnl >= 0 else 'red'}]${net_realized_pnl:.2f}[/]")
ind_table = Table(title="INDICATORS", box=box.ROUNDED, expand=True) ind_table = Table(title="INDICATORS", box=box.ROUNDED, expand=True)
ind_table.add_column("Indicator"); ind_table.add_column("Value"); ind_table.add_column("Updated") ind_table.add_column("Indicator"); ind_table.add_column("Value"); ind_table.add_column("Updated")
@ -391,12 +686,31 @@ class PingPongBot:
ind_table.add_row(k.upper().replace("_", " "), f"{v['value']:.2f}", v['timestamp']) ind_table.add_row(k.upper().replace("_", " "), f"{v['value']:.2f}", v['timestamp'])
pos_table = Table(title="POSITION", box=box.ROUNDED, expand=True) pos_table = Table(title="POSITION", box=box.ROUNDED, expand=True)
pos_table.add_column("Account Equity"); pos_table.add_column("Size"); pos_table.add_column("Entry"); pos_table.add_column("PnL") pos_table.add_column("Account Equity"); pos_table.add_column("Available"); pos_table.add_column("Size (BTC/USD)"); pos_table.add_column("Used Lev"); pos_table.add_column("PnL")
if self.position: if self.position:
p_size = float(self.position['size'])
pnl = float(self.position['unrealisedPnl']) pnl = float(self.position['unrealisedPnl'])
pos_table.add_row(f"${self.wallet_balance:.2f}", self.position['size'], self.position['avgPrice'], f"[bold {'green' if pnl>=0 else 'red'}]${pnl:.2f}")
# Categorize by Inverse (BTCUSD) vs Linear (BTCPERP)
if self.category == "inverse":
size_usd = p_size
size_btc = size_usd / max(self.market_price, 1)
else: else:
pos_table.add_row(f"${self.wallet_balance:.2f}", "0", "-", "-") size_btc = p_size
size_usd = size_btc * self.market_price
used_lev = size_usd / max(self.wallet_balance, 1)
pnl_str = f"[bold {'green' if pnl>=0 else 'red'}]${pnl:.2f}[/]"
pos_table.add_row(
f"${self.wallet_balance:.2f}",
f"${self.available_balance:.2f}",
f"{size_btc:.3f} / ${size_usd:.1f}",
f"{used_lev:.2f}x ({self.max_eff_lev}x)",
pnl_str
)
else:
pos_table.add_row(f"${self.wallet_balance:.2f}", f"${self.available_balance:.2f}", "0 / $0", f"0.00x ({self.max_eff_lev}x)", "-")
self.console.print(cfg_table); self.console.print(ind_table); self.console.print(pos_table) self.console.print(cfg_table); self.console.print(ind_table); self.console.print(pos_table)
self.console.print(f"[dim]Status: {self.status_msg} | Last Signal: {self.last_signal}[/]") self.console.print(f"[dim]Status: {self.status_msg} | Last Signal: {self.last_signal}[/]")
@ -433,7 +747,9 @@ class PingPongBot:
signal = self.check_signals(df) signal = self.check_signals(df)
if signal: await self.execute_trade(signal) if signal: await self.execute_trade(signal)
self.last_candle_time = latest['time'] self.last_candle_time = latest['time']
self.last_candle_price = latest['close'] self.last_candle_open = float(latest['open'])
self.last_candle_close = float(latest['close'])
self.last_candle_price = self.last_candle_close
self.status_msg = f"New Candle: {latest['time'].strftime('%H:%M:%S')}" self.status_msg = f"New Candle: {latest['time'].strftime('%H:%M:%S')}"
self.render_dashboard() self.render_dashboard()
@ -444,6 +760,49 @@ class PingPongBot:
await asyncio.sleep(5) await asyncio.sleep(5)
from math import floor from math import floor
import sys
async def run_with_retries():
config_path = "config/ping_pong_config.yaml"
# Load config to see robustness settings
try:
with open(config_path, 'r') as f:
config = yaml.safe_load(f)
except Exception as e:
print(f"CRITICAL: Failed to load config: {e}")
sys.exit(1)
robust_cfg = config.get('robustness', {})
if not robust_cfg.get('enabled', True):
bot = PingPongBot(config_path)
await bot.run()
return
max_retries = robust_cfg.get('max_retries', 3)
window = robust_cfg.get('retry_window_seconds', 300)
failure_history = []
while True:
try:
bot = PingPongBot(config_path)
await bot.run()
# If run() returns normally, it means the bot stopped gracefully
break
except Exception as e:
now = time.time()
failure_history.append(now)
# Keep only failures within the window
failure_history = [t for t in failure_history if now - t <= window]
if len(failure_history) > max_retries:
logger.error(f"FATAL: Too many failures ({len(failure_history)}) within {window}s. Stopping bot.")
sys.exit(1)
wait_time = min(30, 5 * len(failure_history))
logger.warning(f"Bot crashed! Retry {len(failure_history)}/{max_retries} in {wait_time}s... Error: {e}")
await asyncio.sleep(wait_time)
if __name__ == "__main__": if __name__ == "__main__":
bot = PingPongBot() asyncio.run(run_with_retries())
asyncio.run(bot.run())