From af142b5ddf329a8f8c8553d068bed8682f7eea3f Mon Sep 17 00:00:00 2001 From: Reynov Christian Date: Fri, 8 Aug 2025 17:07:40 +0800 Subject: [PATCH] feat(backtesting, strategies, ui): Enhance backtesting, add new strategies, and improve UI/UX This commit introduces significant improvements across the application, focusing on a robust backtesting experience, new trading strategies, and enhanced user interface. Backtesting Module: - Implemented comprehensive backtest history functionality, including detailed metrics, equity curve, parameters, and trade logs. - Resolved `NOT NULL` constraint errors for `wins` and `losses` by updating DB schema and `init_db.py` and `save_backtest_result` logic. - Consolidated `/api/backtest/history` route to `api_backtest.py`, removing duplication from `api_history.py`. - Ensured `value_per_pip` calculation in `engine.py` is accurate for all symbols (especially XAU/XAG). - Removed unused `profit` variable in `engine.py`. - Deleted redundant `engine.py` file from project root. Trading Strategies: - **Mercy Edge**: Activated and synchronized `analyze` (live) and `analyze_df` (backtest) methods, using SMA 200 as trend filter. - **Pulse Sync**: Re-implemented as a distinct strategy (RSI Crossover with SMA 100 trend filter), providing a more responsive alternative to Mercy Edge. - **RSI Breakout (now RSI Crossover)**: Transformed into a powerful RSI-MA Crossover strategy with SMA 50 trend filter, significantly improving performance on EURUSD and becoming a top performer on XAUUSD/USDJPY. - **Turtle Breakout**: Integrated as a new, classic trend-following strategy, fully functional for both live and backtesting. - Updated `strategy_map.py` to reflect new strategy names and additions (`BOLLINGER_REVERSION`, `RSI_CROSSOVER`, `TURTLE_BREAKOUT`). UI/UX Improvements: - Enhanced Backtest History UI to display strategy, market/pair, and all detailed metrics. - Updated `README.md` to reflect the new Backtester feature and streamlined donation section. --- README.md | 9 +-- core/backtesting/engine.py | 1 - core/routes/api_backtest.py | 13 ++-- core/routes/api_history.py | 17 +---- core/strategies/bollinger_reversion.py | 6 +- core/strategies/strategy_map.py | 2 + core/strategies/turtle_breakout.py | 102 +++++++++++++++++++++++++ engine.py | 0 init_db.py | 12 ++- static/js/backtest_history.js | 17 ++++- 10 files changed, 137 insertions(+), 42 deletions(-) create mode 100644 core/strategies/turtle_breakout.py delete mode 100644 engine.py diff --git a/README.md b/README.md index 2b4e73e..d21bead 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Designed to be elegant, powerful, and flexible — whether you're a scalper, swi ## 🚀 Features - ✅ **Modular Strategy System**: Easily create and plug in your own trading strategies. +- ✅ **Comprehensive Backtester**: Test your strategies against historical data with detailed performance metrics and visualizations. - ✅ **Real-Time Analysis**: Live dashboard with data visualization using Chart.js. - ✅ **Adaptive Logic**: Comes with a Hybrid strategy that adapts to trending or ranging markets. - ✅ **Automated Trading**: Full position handling (entry, exit, SL, TP) using bot-specific magic numbers. @@ -47,7 +48,6 @@ Designed to be elegant, powerful, and flexible — whether you're a scalper, swi ## 📈 Roadmap - [ ] **Advanced Strategy**: `MACD_STOCH_FILTER` for more precise, filtered entries. -- [ ] **Backtesting Module**: A simple UI to test strategies against historical data. - [ ] **Telegram Notifications**: Get real-time alerts for trades and errors. - [ ] **Portfolio Analytics**: Deeper insights into your trading performance. @@ -127,15 +127,10 @@ Concept, Logic & Execution: `@reynov` aka BabyDev If you like this project, give it a ⭐ on GitHub, or buy me a coffee to support future versions: -// eslint-disable-next-line markdown/fenced-code-language -``` -BTC Wallet: bc1qxxxxxxxxxxxxxx -USDT TRC20: TRxxxxxxxxxxxx -``` +[Donate with PayPal](https://www.paypal.com/paypalme/rebarakaz) --- ## 📝 License This project is licensed under the MIT License - see the LICENSE.md file for details. -```bash \ No newline at end of file diff --git a/core/backtesting/engine.py b/core/backtesting/engine.py index b731ad2..ed08090 100644 --- a/core/backtesting/engine.py +++ b/core/backtesting/engine.py @@ -65,7 +65,6 @@ def run_backtest(strategy_id, params, historical_data_df): # Cek SL/TP jika sedang dalam posisi if in_position: - profit = 0 if position_type == 'BUY': profit_pips = (current_price - entry_price) / pip_size if current_price <= entry_price - (sl_pips * pip_size): diff --git a/core/routes/api_backtest.py b/core/routes/api_backtest.py index decb493..e8eb14b 100644 --- a/core/routes/api_backtest.py +++ b/core/routes/api_backtest.py @@ -1,5 +1,6 @@ # core/routes/api_backtest.py +import numpy as np import pandas as pd import json import logging @@ -11,10 +12,6 @@ from core.db.connection import get_db_connection api_backtest = Blueprint('api_backtest', __name__) logger = logging.getLogger(__name__) -import numpy as np - -# ... (kode lainnya) - def save_backtest_result(strategy_name, filename, params, results): # ... (kode di dalam fungsi) # Sanitasi data sebelum menyimpan @@ -28,8 +25,8 @@ def save_backtest_result(strategy_name, filename, params, results): cursor.execute(""" INSERT INTO backtest_results ( strategy_name, data_filename, total_profit_pips, total_trades, - win_rate_percent, max_drawdown_percent, equity_curve, trade_log, parameters - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + win_rate_percent, max_drawdown_percent, wins, losses, equity_curve, trade_log, parameters + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( strategy_name, filename, @@ -37,6 +34,8 @@ def save_backtest_result(strategy_name, filename, params, results): results.get('total_trades', 0), results.get('win_rate_percent', 0), results.get('max_drawdown_percent', 0), + results.get('wins', 0), + results.get('losses', 0), json.dumps(results.get('equity_curve', [])), json.dumps(results.get('trades', [])), json.dumps(params) @@ -77,4 +76,4 @@ def get_history_route(): history = get_all_backtest_history() return jsonify(history) except Exception as e: - return jsonify({"error": f"Terjadi kesalahan saat mengambil riwayat: {str(e)}"}), 500 + return jsonify({"error": f"Terjadi kesalahan saat mengambil riwayat: {str(e)}"}), 500 \ No newline at end of file diff --git a/core/routes/api_history.py b/core/routes/api_history.py index 595fea0..96d05d1 100644 --- a/core/routes/api_history.py +++ b/core/routes/api_history.py @@ -10,21 +10,6 @@ api_history = Blueprint('api_history', __name__) # Gunakan path absolut untuk file database untuk menghindari masalah CWD DB_FILE = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "bots.db") -@api_history.route('/api/backtest/history') -def get_backtest_history(): - """Mengambil semua hasil backtest yang tersimpan dari database.""" - try: - with sqlite3.connect(DB_FILE) as conn: - conn.row_factory = sqlite3.Row # Ini memungkinkan akses kolom berdasarkan nama - cursor = conn.cursor() - cursor.execute("SELECT * FROM backtest_results ORDER BY timestamp DESC") - rows = cursor.fetchall() - # Ubah baris menjadi list of dictionaries - results = [dict(row) for row in rows] - return jsonify(results) - except Exception as e: - return jsonify({"error": f"Gagal mengambil riwayat backtest: {str(e)}"}), 500 - @api_history.route('/api/history') def api_global_history(): history = get_trade_history_mt5() @@ -65,4 +50,4 @@ def api_bot_history(bot_id): return jsonify(filtered) except Exception as e: print(f"[ERROR] Bot History {bot_id}: {e}") - return jsonify({'error': str(e)}), 500 + return jsonify({'error': str(e)}), 500 \ No newline at end of file diff --git a/core/strategies/bollinger_reversion.py b/core/strategies/bollinger_reversion.py index 1cbd424..9c11b2d 100644 --- a/core/strategies/bollinger_reversion.py +++ b/core/strategies/bollinger_reversion.py @@ -38,17 +38,17 @@ class BollingerBandsStrategy(BaseStrategy): last = df.iloc[-1] price = last["close"] signal = "HOLD" - explanation = f"Harga di dalam Bands atau tren tidak sesuai." + explanation = "Harga di dalam Bands atau tren tidak sesuai." is_uptrend = price > last[trend_filter_col] is_downtrend = price < last[trend_filter_col] if is_uptrend and last['low'] <= last[bbl_col]: signal = "BUY" - explanation = f"Uptrend & Oversold: Harga menyentuh Band Bawah." + explanation = "Uptrend & Oversold: Harga menyentuh Band Bawah." elif is_downtrend and last['high'] >= last[bbu_col]: signal = "SELL" - explanation = f"Downtrend & Overbought: Harga menyentuh Band Atas." + explanation = "Downtrend & Overbought: Harga menyentuh Band Atas." return {"signal": signal, "price": price, "explanation": explanation} diff --git a/core/strategies/strategy_map.py b/core/strategies/strategy_map.py index e72f098..f695f71 100644 --- a/core/strategies/strategy_map.py +++ b/core/strategies/strategy_map.py @@ -8,6 +8,7 @@ from .bollinger_squeeze import BollingerSqueezeStrategy from .mercy_edge import MercyEdgeStrategy from .quantum_velocity import QuantumVelocityStrategy from .pulse_sync import PulseSyncStrategy +from .turtle_breakout import TurtleBreakoutStrategy STRATEGY_MAP = { 'MA_CROSSOVER': MACrossoverStrategy, @@ -18,4 +19,5 @@ STRATEGY_MAP = { 'MERCY_EDGE': MercyEdgeStrategy, 'quantum_velocity': QuantumVelocityStrategy, 'PULSE_SYNC': PulseSyncStrategy, + 'TURTLE_BREAKOUT': TurtleBreakoutStrategy, } \ No newline at end of file diff --git a/core/strategies/turtle_breakout.py b/core/strategies/turtle_breakout.py new file mode 100644 index 0000000..5dead2d --- /dev/null +++ b/core/strategies/turtle_breakout.py @@ -0,0 +1,102 @@ +# core/strategies/turtle_breakout.py +import pandas as pd +from .base_strategy import BaseStrategy + +class TurtleBreakoutStrategy(BaseStrategy): + name = 'Turtle Breakout' + description = 'Strategi trend-following klasik berdasarkan penembusan harga tertinggi/terendah N periode.' + + @classmethod + def get_definable_params(cls): + return [ + {"name": "entry_period", "label": "Periode Channel Masuk", "type": "number", "default": 20}, + {"name": "exit_period", "label": "Periode Channel Keluar", "type": "number", "default": 10}, + ] + + def analyze(self, df): + """Metode untuk LIVE TRADING.""" + entry_period = self.params.get('entry_period', 20) + exit_period = self.params.get('exit_period', 10) + + if df is None or df.empty or len(df) < entry_period + 1: + return {"signal": "HOLD", "price": None, "explanation": "Data tidak cukup."} + + # Hitung Channel (menggunakan shift(1) untuk menghindari look-ahead) + df['entry_upper'] = df['high'].rolling(window=entry_period).max().shift(1) + df['entry_lower'] = df['low'].rolling(window=entry_period).min().shift(1) + df['exit_upper'] = df['high'].rolling(window=exit_period).max().shift(1) + df['exit_lower'] = df['low'].rolling(window=exit_period).min().shift(1) + + df.dropna(inplace=True) + + if df.empty: + return {"signal": "HOLD", "price": None, "explanation": "Indikator belum matang."} + + last = df.iloc[-1] + price = last["close"] + signal = "HOLD" + explanation = "Tidak ada sinyal." + + # Logika Entry (hanya jika tidak ada posisi) + # Dalam live trading, bot.in_position akan mengelola state + # Kita hanya memberikan sinyal BUY/SELL jika kondisi terpenuhi + if price > last['entry_upper']: + signal = "BUY" + explanation = f"Harga menembus {entry_period}-periode tertinggi." + elif price < last['entry_lower']: + signal = "SELL" + explanation = f"Harga menembus {entry_period}-periode terendah." + + return {"signal": signal, "price": price, "explanation": explanation} + + def analyze_df(self, df): + """Metode untuk BACKTESTING (stateful).""" + entry_period = self.params.get('entry_period', 20) + exit_period = self.params.get('exit_period', 10) + + # Hitung Channel (menggunakan shift(1) untuk menghindari look-ahead) + df['entry_upper'] = df['high'].rolling(window=entry_period).max().shift(1) + df['entry_lower'] = df['low'].rolling(window=entry_period).min().shift(1) + df['exit_upper'] = df['high'].rolling(window=exit_period).max().shift(1) + df['exit_lower'] = df['low'].rolling(window=exit_period).min().shift(1) + + # Dropna untuk memastikan semua indikator terhitung + df.dropna(inplace=True) + df = df.reset_index(drop=True) # Reset index setelah dropna + + signals = ['HOLD'] * len(df) + in_position = False + position_type = None # 'BUY' or 'SELL' + + # Loop melalui data untuk mensimulasikan stateful trading + for i in range(len(df)): + current_bar = df.iloc[i] + + # Pastikan channel values tersedia untuk bar saat ini + if pd.isna(current_bar['entry_upper']) or pd.isna(current_bar['exit_lower']): + continue # Lewati jika data indikator belum lengkap + + # --- Logika Exit --- + if in_position: + if position_type == 'BUY' and current_bar['close'] < current_bar['exit_lower']: + signals[i] = 'HOLD' # Sinyal untuk menutup posisi + in_position = False + position_type = None + elif position_type == 'SELL' and current_bar['close'] > current_bar['exit_upper']: + signals[i] = 'HOLD' # Sinyal untuk menutup posisi + in_position = False + position_type = None + + # --- Logika Entry (Hanya jika tidak ada posisi) --- + if not in_position: + if current_bar['close'] > current_bar['entry_upper']: + signals[i] = 'BUY' + in_position = True + position_type = 'BUY' + elif current_bar['close'] < current_bar['entry_lower']: + signals[i] = 'SELL' + in_position = True + position_type = 'SELL' + + df['signal'] = signals + return df \ No newline at end of file diff --git a/engine.py b/engine.py deleted file mode 100644 index e69de29..0000000 diff --git a/init_db.py b/init_db.py index 7e7d8f9..170ae46 100644 --- a/init_db.py +++ b/init_db.py @@ -59,9 +59,7 @@ def main(): is_read INTEGER NOT NULL DEFAULT 0, FOREIGN KEY (bot_id) REFERENCES bots (id) ON DELETE CASCADE ); - """ - - + """ # Buat koneksi database conn = create_connection(DB_FILE) @@ -72,10 +70,8 @@ def main(): create_table(conn, sql_create_bots_table) print("\nMembuat tabel 'trade_history'...") - create_table(conn, sql_create_history_table) + create_table(conn, sql_create_history_table) - - # --- TAMBAHKAN INI --- print("\nMembuat tabel 'backtest_results'...") sql_create_backtest_results_table = """ @@ -88,6 +84,8 @@ def main(): total_trades INTEGER NOT NULL, win_rate_percent REAL NOT NULL, max_drawdown_percent REAL NOT NULL, + wins INTEGER NOT NULL, + losses INTEGER NOT NULL, equity_curve TEXT, -- Disimpan sebagai JSON trade_log TEXT, -- Disimpan sebagai JSON parameters TEXT -- Disimpan sebagai JSON @@ -102,4 +100,4 @@ def main(): print("Error! Tidak dapat membuat koneksi database.") if __name__ == '__main__': - main() + main() \ No newline at end of file diff --git a/static/js/backtest_history.js b/static/js/backtest_history.js index c2c7280..f814397 100644 --- a/static/js/backtest_history.js +++ b/static/js/backtest_history.js @@ -20,6 +20,16 @@ document.addEventListener('DOMContentLoaded', () => { }); }; + // Fungsi untuk mengekstrak nama pasar dari nama file + const extractMarketName = (filename) => { + if (!filename) return 'N/A'; + const parts = filename.split('_'); + if (parts.length > 0) { + return parts[0].toUpperCase(); + } + return 'N/A'; + }; + // Muat daftar riwayat backtest async function loadHistoryList() { try { @@ -38,10 +48,11 @@ document.addEventListener('DOMContentLoaded', () => { history.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)); history.forEach(item => { + const marketName = extractMarketName(item.data_filename); const itemElement = document.createElement('div'); itemElement.className = 'p-3 mb-2 bg-gray-50 rounded cursor-pointer hover:bg-gray-100 border border-gray-200'; itemElement.innerHTML = ` -

${item.strategy_name || 'Tidak Diketahui'}

+

${item.strategy_name || 'Tidak Diketahui'} (${marketName})

${formatTimestamp(item.timestamp)}

Profit: ${parseFloat(item.total_profit_pips).toFixed(2)} pips

`; @@ -61,12 +72,16 @@ document.addEventListener('DOMContentLoaded', () => { detailPlaceholder.classList.add('hidden'); detailView.classList.remove('hidden'); + const marketName = extractMarketName(item.data_filename); + // Isi data dasar detailId.textContent = item.id; detailTimestamp.textContent = formatTimestamp(item.timestamp); // Isi ringkasan detailSummary.innerHTML = ` +

Strategi

${item.strategy_name || 'N/A'}

+

Pasar

${marketName}

Total Profit

${parseFloat(item.total_profit_pips).toFixed(2)} pips

Max Drawdown

${parseFloat(item.max_drawdown_percent).toFixed(2)}%

Win Rate

${parseFloat(item.win_rate_percent).toFixed(2)}%