Compare commits
6 Commits
8fe8224762
...
bot
| Author | SHA1 | Date | |
|---|---|---|---|
| b462651f60 | |||
| cd66a976de | |||
| 2840d9b0b3 | |||
| f3b186b01d | |||
| 56d0237bbf | |||
| f544b06753 |
@ -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
|
||||||
@ -157,4 +157,4 @@ DB_PASSWORD=your_password
|
|||||||
- Only add dependencies to requirements.txt when necessary
|
- Only add dependencies to requirements.txt when necessary
|
||||||
- Check for conflicts with existing dependencies
|
- Check for conflicts with existing dependencies
|
||||||
- Keep dependency versions pinned to avoid breaking changes
|
- Keep dependency versions pinned to avoid breaking changes
|
||||||
- Avoid adding heavyweight dependencies unless truly required
|
- Avoid adding heavyweight dependencies unless truly required
|
||||||
84
BACKTESTING_GUIDE.md
Normal file
84
BACKTESTING_GUIDE.md
Normal 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
180
Hurst_simulations.md
Normal 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%**
|
||||||
|
|
||||||
@ -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
|
||||||
|
|||||||
292
src/strategies/backtest_engine.py
Normal file
292
src/strategies/backtest_engine.py
Normal 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())
|
||||||
@ -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))
|
||||||
|
|
||||||
res = await asyncio.to_thread(self.session.place_order,
|
if self.exec_type != "maker":
|
||||||
category=self.category, symbol=self.symbol, side=side, orderType="Market",
|
try:
|
||||||
qty=qty_str, reduceOnly=is_close, positionIdx=pos_idx
|
res = await asyncio.to_thread(self.session.place_order,
|
||||||
)
|
category=self.category, symbol=self.symbol, side=side, orderType="Market",
|
||||||
if res['retCode'] == 0:
|
qty=qty_str, reduceOnly=is_close, positionIdx=pos_idx
|
||||||
self.last_signal = f"{side} {qty_str}"
|
)
|
||||||
self.status_msg = f"Order Success: {side}"
|
if res['retCode'] == 0:
|
||||||
else:
|
await self._process_filled_order(res['result']['orderId'], trade, qty_str, attempts=1)
|
||||||
self.status_msg = f"Order Error: {res['retMsg']}"
|
else:
|
||||||
except Exception as e:
|
self.status_msg = f"Order Error: {res['retMsg']}"
|
||||||
logger.error(f"Trade Error: {e}")
|
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 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:
|
||||||
|
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:
|
else:
|
||||||
pos_table.add_row(f"${self.wallet_balance:.2f}", "0", "-", "-")
|
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())
|
|
||||||
|
|||||||
Reference in New Issue
Block a user