From 73fe9cdca254dbcc2894c1ba9e5bcbbdc5d73609 Mon Sep 17 00:00:00 2001 From: Reynov Christian Date: Fri, 1 Aug 2025 22:47:37 +0800 Subject: [PATCH] feat: Add Quantum Velocity strategy and major stability overhaul" -m " This major commit introduces a new advanced trading strategy, resolves critical trading logic errors, and significantly enhances system stability and UI data accuracy. Key Changes: - **New Features & Strategies**: - **Quantum Velocity Strategy**: Added 'quantum_velocity.py', a new hybrid strategy combining a long-term trend filter (EMA 200) with a volatility-based entry trigger (Bollinger Squeeze). - **Hybrid Pro Backtester**: Added 'lab/backtester_hybrid_pro.py' to facilitate advanced, offline testing and optimization of the QuantumBotX Hybrid strategy. - **Critical Trading Logic Fixes**: - Resolved 'Invalid Stops' and 'Unsupported filling mode' errors by refactoring 'core/mt5/trade.py' to use dynamic point calculation and the FOK fill policy. - Fixed a recurring 'KeyError' in 'BollingerSqueezeStrategy' by ensuring consistent naming for Bollinger Bands columns. - **System Stability & Robustness**: - Hardened the graceful shutdown handler in 'app.py' to prevent crashes from repeated Ctrl+C signals, ensuring a clean shutdown process. - Enhanced the notification system by adding an 'is_notification' flag to logs, allowing for a clear distinction between critical alerts and general activity. - **Dashboard & UI Enhancements**: - Fixed the 'Total Bots' card on the dashboard by correcting the backend API ('api_dashboard.py') to send the complete stats payload. - Replaced static '0' values on dashboard cards with loading spinners for a more professional user experience. - The 'Create/Edit Bot' modal button now dynamically changes its text to 'Ubah Bot' when in edit mode. --- README.md | 1 + app.py | 19 ++- core/bots/trading_bot.py | 22 ++-- core/db/queries.py | 40 +++++- core/mt5/trade.py | 174 +++++++++++++-------------- core/routes/api_dashboard.py | 35 +++--- core/routes/api_notifications.py | 54 ++++----- core/strategies/bollinger_squeeze.py | 8 +- core/strategies/quantum_velocity.py | 100 +++++++++++++++ core/strategies/strategy_map.py | 2 + lab/backtester_hybrid_pro.py | 118 ++++++++++++++++++ static/js/dashboard.js | 48 ++------ static/js/notifications.js | 45 +++---- static/js/trading_bots.js | 3 + templates/index.html | 21 +--- templates/trading_bots.html | 4 +- 16 files changed, 447 insertions(+), 247 deletions(-) create mode 100644 core/strategies/quantum_velocity.py create mode 100644 lab/backtester_hybrid_pro.py diff --git a/README.md b/README.md index 71b8596..2b4e73e 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,7 @@ 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 diff --git a/app.py b/app.py index aae3c20..58f339c 100644 --- a/app.py +++ b/app.py @@ -128,11 +128,22 @@ def shutdown_handler(): # Hentikan semua bot yang aktif active_bot_ids = list(controller.active_bots.keys()) - for bot_id in active_bot_ids: - controller.stop_bot(bot_id) + if active_bot_ids: + logger.info(f"Menghentikan {len(active_bot_ids)} bot yang aktif...") - mt5.shutdown() - logger.info("Koneksi MetaTrader 5 ditutup. Shutdown selesai.") + for bot_id in active_bot_ids: + try: + logger.info(f"Memberi sinyal berhenti untuk bot ID {bot_id}...") + controller.stop_bot(bot_id) + except KeyboardInterrupt: + # Abaikan Ctrl+C tambahan saat proses shutdown sedang berlangsung. + logger.warning(f"KeyboardInterrupt diterima saat menghentikan bot {bot_id}. Melanjutkan proses shutdown.") + continue + except Exception as e: + logger.error(f"Error tak terduga saat menghentikan bot {bot_id} selama shutdown: {e}") + + mt5.shutdown() # Pastikan koneksi MT5 selalu ditutup + logger.info("Koneksi MetaTrader 5 ditutup. Proses shutdown selesai.") # --- Titik Eksekusi Utama --- if __name__ == '__main__': diff --git a/core/bots/trading_bot.py b/core/bots/trading_bot.py index b029510..c57f37d 100644 --- a/core/bots/trading_bot.py +++ b/core/bots/trading_bot.py @@ -36,7 +36,7 @@ class TradingBot(threading.Thread): def run(self): """Metode utama yang dijalankan oleh thread, kini dengan eksekusi trade.""" self.status = 'Aktif' - self.log_activity('START', f"Bot '{self.name}' dimulai dengan strategi {self.strategy_name}.") # <-- Menggunakan log_activity + self.log_activity('START', f"Bot '{self.name}' dimulai.", is_notification=True) try: strategy_class = STRATEGY_MAP.get(self.strategy_name) @@ -47,7 +47,7 @@ class TradingBot(threading.Thread): self.strategy_instance = strategy_class(bot_instance=self, params=self.strategy_params) except Exception as e: - self.log_activity('ERROR', f"Inisialisasi Gagal: {e}") + self.log_activity('ERROR', f"Inisialisasi Gagal: {e}", is_notification=True) self.status = 'Error' return @@ -80,11 +80,11 @@ class TradingBot(threading.Thread): time.sleep(self.check_interval) except Exception as e: - self.log_activity('ERROR', f"Error pada loop utama: {e}", exc_info=True) + self.log_activity('ERROR', f"Error pada loop utama: {e}", exc_info=True, is_notification=True) time.sleep(self.check_interval * 2) self.status = 'Dijeda' - self.log_activity('STOP', f"Bot '{self.name}' dihentikan.") + self.log_activity('STOP', f"Bot '{self.name}' dihentikan.", is_notification=True) def stop(self): """Mengirim sinyal berhenti ke thread.""" @@ -94,11 +94,11 @@ class TradingBot(threading.Thread): """Memeriksa apakah thread sudah diberi sinyal berhenti.""" return self._stop_event.is_set() - def log_activity(self, action, details, exc_info=False): + def log_activity(self, action, details, exc_info=False, is_notification=False): """Mencatat aktivitas bot ke database dan file log.""" try: from core.db.queries import add_history_log - add_history_log(self.id, action, details) + add_history_log(self.id, action, details, is_notification) log_message = f"Bot {self.id} [{action}]: {details}" if exc_info: logger.error(log_message, exc_info=True) @@ -117,7 +117,7 @@ class TradingBot(threading.Thread): return pos return None except Exception as e: - self.log_activity('ERROR', f"Gagal mendapatkan posisi terbuka: {e}", exc_info=True) + self.log_activity('ERROR', f"Gagal mendapatkan posisi terbuka: {e}", exc_info=True, is_notification=True) return None def _handle_trade_signal(self, signal, position): @@ -126,24 +126,24 @@ class TradingBot(threading.Thread): if signal == 'BUY': # Jika ada posisi SELL, tutup dulu if position and position.type == mt5.ORDER_TYPE_SELL: - self.log_activity('CLOSE SELL', "Menutup posisi JUAL untuk membuka posisi BELI.") + self.log_activity('CLOSE SELL', "Menutup posisi JUAL untuk membuka posisi BELI.", is_notification=True) close_trade(position) position = None # Reset posisi setelah ditutup # Jika tidak ada posisi, buka posisi BUY baru if not position: - self.log_activity('OPEN BUY', "Membuka posisi BELI berdasarkan sinyal.") + self.log_activity('OPEN BUY', "Membuka posisi BELI berdasarkan sinyal.", is_notification=True) place_trade(self.market_for_mt5, mt5.ORDER_TYPE_BUY, self.lot_size, self.sl_pips, self.tp_pips, self.id) # Logika untuk sinyal SELL elif signal == 'SELL': # Jika ada posisi BUY, tutup dulu if position and position.type == mt5.ORDER_TYPE_BUY: - self.log_activity('CLOSE BUY', "Menutup posisi BELI untuk membuka posisi JUAL.") + self.log_activity('CLOSE BUY', "Menutup posisi BELI untuk membuka posisi JUAL.", is_notification=True) close_trade(position) position = None # Reset posisi setelah ditutup # Jika tidak ada posisi, buka posisi SELL baru if not position: - self.log_activity('OPEN SELL', "Membuka posisi JUAL berdasarkan sinyal.") + self.log_activity('OPEN SELL', "Membuka posisi JUAL berdasarkan sinyal.", is_notification=True) place_trade(self.market_for_mt5, mt5.ORDER_TYPE_SELL, self.lot_size, self.sl_pips, self.tp_pips, self.id) diff --git a/core/db/queries.py b/core/db/queries.py index 7edf65c..334b9e2 100644 --- a/core/db/queries.py +++ b/core/db/queries.py @@ -77,13 +77,13 @@ def update_bot_status(bot_id, status): except sqlite3.Error as e: logger.error(f"Gagal update status bot {bot_id}: {e}") -def add_history_log(bot_id, action, details): +def add_history_log(bot_id, action, details, is_notification=False): """Menambahkan log aktivitas/riwayat untuk bot tertentu.""" try: with get_db_connection() as conn: conn.execute( - 'INSERT INTO trade_history (bot_id, action, details) VALUES (?, ?, ?)', - (bot_id, action, details) + 'INSERT INTO trade_history (bot_id, action, details, is_notification, is_read) VALUES (?, ?, ?, ?, ?)', + (bot_id, action, details, is_notification, False) # is_read selalu False saat dibuat ) conn.commit() except sqlite3.Error as e: @@ -100,4 +100,36 @@ def get_history_by_bot_id(bot_id): return [dict(row) for row in history] except sqlite3.Error as e: logger.error(f"Database error saat mengambil riwayat bot {bot_id}: {e}") - return [] \ No newline at end of file + return [] + +def get_notifications(): + """Mengambil semua log yang ditandai sebagai notifikasi.""" + try: + with get_db_connection() as conn: + notifications = conn.execute(''' + SELECT h.id, h.action, h.details, h.is_read, h.timestamp, b.name as bot_name + FROM trade_history h + JOIN bots b ON h.bot_id = b.id + WHERE h.is_notification = 1 + ORDER BY h.timestamp DESC + ''').fetchall() + return [dict(row) for row in notifications] + except sqlite3.Error as e: + logger.error(f"Database error saat mengambil notifikasi: {e}") + return [] + +def get_unread_notifications_count(): + """Menghitung jumlah notifikasi yang belum dibaca.""" + try: + with get_db_connection() as conn: + count = conn.execute('SELECT COUNT(id) as unread_count FROM trade_history WHERE is_notification = 1 AND is_read = 0').fetchone() + return dict(count) if count else {'unread_count': 0} + except sqlite3.Error as e: + logger.error(f"Database error saat menghitung notifikasi: {e}") + return {'unread_count': 0} + +def mark_notifications_as_read(): + """Menandai semua notifikasi sebagai sudah dibaca.""" + with get_db_connection() as conn: + conn.execute('UPDATE trade_history SET is_read = 1 WHERE is_notification = 1 AND is_read = 0') + conn.commit() \ No newline at end of file diff --git a/core/mt5/trade.py b/core/mt5/trade.py index 5d42c5a..28bcffe 100644 --- a/core/mt5/trade.py +++ b/core/mt5/trade.py @@ -1,116 +1,104 @@ # core/mt5/trade.py -import MetaTrader5 as mt5 + import logging +import MetaTrader5 as mt5 logger = logging.getLogger(__name__) -def place_trade(symbol, order_type, volume, sl_pips, tp_pips, magic): +def place_trade(symbol, order_type, volume, sl_pips, tp_pips, magic_id): """ - Menempatkan order trading ke MetaTrader 5 dengan logika yang lebih tangguh. + Menempatkan trade dengan perhitungan SL/TP yang dinamis berdasarkan 'point' simbol. """ - symbol_info = mt5.symbol_info(symbol) - if symbol_info is None: - logger.error(f"Gagal mendapatkan info untuk simbol {symbol}, order dibatalkan.") - return None + try: + # 1. Dapatkan informasi simbol untuk point dan digits + symbol_info = mt5.symbol_info(symbol) + if symbol_info is None: + logger.error(f"Gagal mendapatkan info untuk simbol {symbol}. Order dibatalkan.") + return None, "Symbol not found" - # Ambil filling mode yang diizinkan oleh simbol - allowed_filling_mode = symbol_info.filling_mode + point = symbol_info.point + digits = symbol_info.digits - # --- PERBAIKAN: Gunakan nilai literal untuk kompatibilitas --- - # Beberapa versi library MT5 tidak memiliki konstanta SYMBOL_FILLING_*. - # Menggunakan nilai integer langsung lebih aman dan kompatibel mundur. - # FOK_MODE_FLAG = 1 - # IOC_MODE_FLAG = 2 + # 2. Tentukan harga entry (ask untuk BUY, bid untuk SELL) + if order_type == mt5.ORDER_TYPE_BUY: + price = mt5.symbol_info_tick(symbol).ask + elif order_type == mt5.ORDER_TYPE_SELL: + price = mt5.symbol_info_tick(symbol).bid + else: + logger.error(f"Tipe order tidak valid: {order_type}") + return None, "Invalid order type" - filling_type = mt5.ORDER_FILLING_FOK # Default - # Prioritaskan IOC jika didukung oleh broker untuk simbol ini - if allowed_filling_mode & 2: # Cek flag untuk IOC (nilai 2) - filling_type = mt5.ORDER_FILLING_IOC - # Jika tidak, gunakan FOK jika didukung - elif allowed_filling_mode & 1: # Cek flag untuk FOK (nilai 1) - filling_type = mt5.ORDER_FILLING_FOK - else: - logger.warning(f"Simbol {symbol} tidak mendukung FOK atau IOC. Menggunakan FOK sebagai fallback.") + # 3. Hitung SL dan TP berdasarkan pips dan point + # Asumsi umum: 1 pip = 10 points. Ini membuat kode konsisten di semua pair. + pip_value = 10 * point + + sl_level = 0.0 + tp_level = 0.0 - point = symbol_info.point - price = mt5.symbol_info_tick(symbol).ask if order_type == mt5.ORDER_TYPE_BUY else mt5.symbol_info_tick(symbol).bid + if order_type == mt5.ORDER_TYPE_BUY: + sl_level = price - (sl_pips * pip_value) + tp_level = price + (tp_pips * pip_value) + elif order_type == mt5.ORDER_TYPE_SELL: + sl_level = price + (sl_pips * pip_value) + tp_level = price - (tp_pips * pip_value) + + # Bulatkan ke jumlah digit yang benar untuk menghindari error presisi + sl_level = round(sl_level, digits) + tp_level = round(tp_level, digits) - sl = 0.0 - tp = 0.0 + # 4. Siapkan request order + request = { + "action": mt5.TRADE_ACTION_DEAL, + "symbol": symbol, + "volume": float(volume), + "type": order_type, + "price": price, + "sl": sl_level, + "tp": tp_level, + "magic": magic_id, + "comment": "QuantumBotX Trade", + "type_time": mt5.ORDER_TIME_GTC, + "type_filling": mt5.ORDER_FILLING_FOK, # FOK lebih umum didukung oleh broker ECN + } - if order_type == mt5.ORDER_TYPE_BUY: - sl = price - sl_pips * point if sl_pips > 0 else 0.0 - tp = price + tp_pips * point if tp_pips > 0 else 0.0 - elif order_type == mt5.ORDER_TYPE_SELL: - sl = price + sl_pips * point if sl_pips > 0 else 0.0 - tp = price - tp_pips * point if tp_pips > 0 else 0.0 + # 5. Kirim order + result = mt5.order_send(request) - request = { - "action": mt5.TRADE_ACTION_DEAL, - "symbol": symbol, - "volume": float(volume), - "type": order_type, - "price": price, - "sl": sl, - "tp": tp, - "magic": magic, - "comment": "QuantumBotX Trade", - "type_time": mt5.ORDER_TIME_GTC, - "type_filling": filling_type, - } - - logger.info(f"Mengirim order: {request}") - result = mt5.order_send(request) - - if result is None: - logger.error(f"order_send gagal, last_error()={mt5.last_error()}") - return None - - if result.retcode != mt5.TRADE_RETCODE_DONE: - logger.error(f"Order GAGAL, retcode={result.retcode}, comment: {result.comment}") - logger.error(f"Request Gagal: {request}") - else: - logger.info(f"Order BERHASIL, ticket={result.order}, comment: {result.comment}") - - return result + # 6. Cek hasil dan log + if result.retcode != mt5.TRADE_RETCODE_DONE: + logger.error(f"Order GAGAL, retcode={result.retcode}, comment: {result.comment}") + logger.error(f"Request Gagal: {request}") + return None, result.comment + + logger.info(f"Order BERHASIL ditempatkan: Deal #{result.deal}, Order #{result.order}") + return result, "Order placed successfully" + except Exception as e: + logger.error(f"Exception saat menempatkan trade: {e}", exc_info=True) + return None, str(e) def close_trade(position): """ - Menutup posisi trading yang ada. + Menutup posisi yang ada. """ - if position is None: - return + try: + close_order_type = mt5.ORDER_TYPE_SELL if position.type == mt5.ORDER_TYPE_BUY else mt5.ORDER_TYPE_BUY + price = mt5.symbol_info_tick(position.symbol).bid if close_order_type == mt5.ORDER_TYPE_SELL else mt5.symbol_info_tick(position.symbol).ask - symbol = position.symbol - ticket = position.ticket - volume = position.volume - order_type = mt5.ORDER_TYPE_SELL if position.type == mt5.ORDER_TYPE_BUY else mt5.ORDER_TYPE_BUY - price = mt5.symbol_info_tick(symbol).bid if position.type == mt5.ORDER_TYPE_BUY else mt5.symbol_info_tick(symbol).ask + request = { + "action": mt5.TRADE_ACTION_DEAL, "position": position.ticket, "symbol": position.symbol, + "volume": position.volume, "type": close_order_type, "price": price, "magic": position.magic, + "comment": "QuantumBotX Close", "type_time": mt5.ORDER_TIME_GTC, "type_filling": mt5.ORDER_FILLING_FOK, + } - symbol_info = mt5.symbol_info(symbol) - if symbol_info is None: - logger.error(f"Gagal mendapatkan info untuk simbol {symbol} saat menutup posisi, order dibatalkan.") - return None - - allowed_filling_mode = symbol_info.filling_mode - # Terapkan logika yang sama untuk menutup posisi - filling_type = mt5.ORDER_FILLING_FOK - if allowed_filling_mode & 2: # Cek flag untuk IOC (nilai 2) - filling_type = mt5.ORDER_FILLING_IOC - elif allowed_filling_mode & 1: # Cek flag untuk FOK (nilai 1) - filling_type = mt5.ORDER_FILLING_FOK + result = mt5.order_send(request) + if result.retcode != mt5.TRADE_RETCODE_DONE: + logger.error(f"Gagal menutup posisi #{position.ticket}, retcode={result.retcode}, comment: {result.comment}") + return None, result.comment - request = { - "action": mt5.TRADE_ACTION_DEAL, "symbol": symbol, "volume": float(volume), - "type": order_type, "position": ticket, "price": price, "magic": position.magic, - "comment": "QuantumBotX Close", "type_time": mt5.ORDER_TIME_GTC, "type_filling": filling_type, - } + logger.info(f"Posisi #{position.ticket} berhasil ditutup.") + return result, "Position closed successfully" - logger.info(f"Menutup posisi: {request}") - result = mt5.order_send(request) - - if result and result.retcode == mt5.TRADE_RETCODE_DONE: - logger.info(f"Posisi {ticket} berhasil ditutup.") - else: - logger.error(f"Gagal menutup posisi, retcode={result.retcode if result else 'N/A'}, comment: {result.comment if result else mt5.last_error()}") \ No newline at end of file + except Exception as e: + logger.error(f"Exception saat menutup posisi: {e}", exc_info=True) + return None, str(e) \ No newline at end of file diff --git a/core/routes/api_dashboard.py b/core/routes/api_dashboard.py index b7dd14a..ff1f9e0 100644 --- a/core/routes/api_dashboard.py +++ b/core/routes/api_dashboard.py @@ -2,28 +2,27 @@ from flask import Blueprint, jsonify from core.utils.mt5 import get_mt5_account_info, get_todays_profit -from core.db.queries import get_db_connection +from core.db import queries # <-- 1. Impor modul queries api_dashboard = Blueprint('api_dashboard', __name__) @api_dashboard.route('/api/dashboard/stats') def api_dashboard_stats(): - account_info = get_mt5_account_info() - todays_profit = get_todays_profit() + try: + account_info = get_mt5_account_info() + todays_profit = get_todays_profit() - conn = get_db_connection() - active_bots_count = conn.execute("SELECT COUNT(id) FROM bots WHERE status = 'Aktif'").fetchone()[0] - active_bots_data = conn.execute("SELECT name, market FROM bots WHERE status = 'Aktif'").fetchall() - conn.close() + # 2. Ambil semua bot sekali saja untuk efisiensi + all_bots = queries.get_all_bots() + active_bots = [bot for bot in all_bots if bot['status'] == 'Aktif'] - active_bots_list = [{'name': bot['name'], 'market': bot['market']} for bot in active_bots_data] - - stats = { - "balance": account_info.get('balance', 0) if account_info else 0, - "equity": account_info.get('equity', 0) if account_info else 0, - "profit": account_info.get('profit', 0) if account_info else 0, - "active_bots_count": active_bots_count, - "todays_profit": todays_profit, - "active_bots": active_bots_list - } - return jsonify(stats) + stats = { + "equity": account_info.get('equity', 0) if account_info else 0, + "todays_profit": todays_profit, + "active_bots_count": len(active_bots), + "total_bots": len(all_bots), # <-- 3. Tambahkan total bot ke respon + "active_bots": [{'name': bot['name'], 'market': bot['market']} for bot in active_bots] + } + return jsonify(stats) + except Exception as e: + return jsonify({"error": f"Gagal mengambil statistik dashboard: {e}"}), 500 diff --git a/core/routes/api_notifications.py b/core/routes/api_notifications.py index 34117cc..1b3ba54 100644 --- a/core/routes/api_notifications.py +++ b/core/routes/api_notifications.py @@ -1,43 +1,33 @@ # core/routes/api_notifications.py -from flask import Blueprint, jsonify, request -import sqlite3 +from core.db import queries +from flask import Blueprint, jsonify api_notifications = Blueprint('api_notifications', __name__) -def get_db(): - conn = sqlite3.connect('bots.db') - conn.row_factory = sqlite3.Row - return conn - -@api_notifications.route('/api/notifications') -def get_notifications(): - conn = get_db() - cursor = conn.cursor() - cursor.execute(''' - SELECT n.id, n.message, n.is_read, n.timestamp, b.name as bot_name - FROM notifications n - LEFT JOIN bots b ON n.bot_id = b.id - ORDER BY n.timestamp DESC - ''') - notifications = [dict(row) for row in cursor.fetchall()] - conn.close() - return jsonify(notifications) +@api_notifications.route('/api/notifications', methods=['GET']) +def get_notifications_route(): + """Mengembalikan daftar notifikasi penting.""" + try: + notifications = queries.get_notifications() + return jsonify(notifications) + except Exception as e: + return jsonify({"error": f"Gagal mengambil notifikasi: {e}"}), 500 @api_notifications.route('/api/notifications/unread-count') def get_unread_notifications_count(): - conn = get_db() - cursor = conn.cursor() - cursor.execute("SELECT COUNT(id) as unread_count FROM notifications WHERE is_read = 0") - count = dict(cursor.fetchone()) - conn.close() - return jsonify(count) + """Mengembalikan jumlah notifikasi yang belum dibaca.""" + try: + count = queries.get_unread_notifications_count() + return jsonify(count) + except Exception as e: + return jsonify({"error": f"Gagal menghitung notifikasi: {e}"}), 500 @api_notifications.route('/api/notifications/mark-as-read', methods=['POST']) def mark_notifications_as_read(): - conn = get_db() - cursor = conn.cursor() - cursor.execute("UPDATE notifications SET is_read = 1 WHERE is_read = 0") - conn.commit() - conn.close() - return jsonify({'message': 'Semua notifikasi ditandai sudah dibaca.'}) + """Menandai semua notifikasi sebagai sudah dibaca.""" + try: + queries.mark_notifications_as_read() + return jsonify({'message': 'Semua notifikasi ditandai sudah dibaca.'}) + except Exception as e: + return jsonify({"error": f"Gagal menandai notifikasi: {e}"}), 500 diff --git a/core/strategies/bollinger_squeeze.py b/core/strategies/bollinger_squeeze.py index c70c068..2c79536 100644 --- a/core/strategies/bollinger_squeeze.py +++ b/core/strategies/bollinger_squeeze.py @@ -43,9 +43,11 @@ class BollingerSqueezeStrategy(BaseStrategy): rsi_period = self.params.get('rsi_period', 14) volume_factor = self.params.get('volume_factor', 1.5) - bbu_col = f'BBU_{bb_length}_{bb_std}' - bbm_col = f'BBM_{bb_length}_{bb_std}' - bbl_col = f'BBL_{bb_length}_{bb_std}' + # PERBAIKAN: Paksa format float dengan satu desimal pada nama kolom + # untuk mencegah KeyError (misal: 'BBU_20_2' vs 'BBU_20_2.0') + bbu_col = f'BBU_{bb_length}_{bb_std:.1f}' + bbm_col = f'BBM_{bb_length}_{bb_std:.1f}' + bbl_col = f'BBL_{bb_length}_{bb_std:.1f}' df.ta.bbands(length=bb_length, std=bb_std, append=True) df['BB_WIDTH'] = df[bbu_col] - df[bbl_col] diff --git a/core/strategies/quantum_velocity.py b/core/strategies/quantum_velocity.py new file mode 100644 index 0000000..0664d3c --- /dev/null +++ b/core/strategies/quantum_velocity.py @@ -0,0 +1,100 @@ +# d:\dev\quantumbotx\core\strategies\quantum_velocity.py + +import pandas_ta as ta +import numpy as np +import MetaTrader5 as mt5 +from .base_strategy import BaseStrategy +from core.data.fetch import get_rates + +class QuantumVelocityStrategy(BaseStrategy): + name = 'Quantum Velocity' + description = 'Menggabungkan filter tren jangka panjang (EMA 200) dengan pemicu volatilitas (Bollinger Squeeze Breakout).' + + @classmethod + def get_definable_params(cls): + """Mengembalikan parameter yang bisa diatur untuk strategi ini.""" + return [ + {"name": "ema_period", "label": "Periode EMA Tren", "type": "number", "default": 200}, + {"name": "bb_length", "label": "Panjang BB", "type": "number", "default": 20}, + {"name": "bb_std", "label": "Std Dev BB", "type": "number", "default": 2.0, "step": 0.1}, + {"name": "squeeze_window", "label": "Window Squeeze", "type": "number", "default": 10}, + {"name": "squeeze_factor", "label": "Faktor Squeeze", "type": "number", "default": 0.7, "step": 0.1}, + ] + + def analyze(self): + """ + Menganalisis pasar dengan filter tren jangka panjang dan pemicu breakout. + """ + tf_const = self.bot.tf_map.get(self.bot.timeframe, mt5.TIMEFRAME_H1) + + # Butuh data yang cukup untuk EMA 200 + df = get_rates(self.bot.market_for_mt5, tf_const, 250) + + if df is None or df.empty or len(df) < 201: + return {"signal": "HOLD", "price": None, "explanation": "Data tidak cukup untuk Quantum Velocity."} + + # --- Ambil Parameter --- + ema_period = self.params.get('ema_period', 200) + bb_length = self.params.get('bb_length', 20) + bb_std = self.params.get('bb_std', 2.0) + squeeze_window = self.params.get('squeeze_window', 10) + squeeze_factor = self.params.get('squeeze_factor', 0.7) + + # --- Hitung Indikator --- + df[f'EMA_{ema_period}'] = ta.ema(df['close'], length=ema_period) + + bbu_col = f'BBU_{bb_length}_{bb_std:.1f}' + bbm_col = f'BBM_{bb_length}_{bb_std:.1f}' + bbl_col = f'BBL_{bb_length}_{bb_std:.1f}' + + df.ta.bbands(length=bb_length, std=bb_std, append=True) + df['BB_BANDWIDTH'] = np.where(df[bbm_col] != 0, (df[bbu_col] - df[bbl_col]) / df[bbm_col] * 100, 0) + df['AVG_BANDWIDTH'] = df['BB_BANDWIDTH'].rolling(window=squeeze_window).mean() + df['SQUEEZE_LEVEL'] = df['AVG_BANDWIDTH'] * squeeze_factor + df['SQUEEZE'] = df['BB_BANDWIDTH'] < df['SQUEEZE_LEVEL'] + + df.dropna(inplace=True) + + if len(df) < 2: + return {"signal": "HOLD", "price": None, "explanation": "Indikator belum matang."} + + last = df.iloc[-1] + prev = df.iloc[-2] + price = last["close"] + signal = "HOLD" + explanation = "Tidak ada kondisi yang terpenuhi." + trend_regime = "N/A" + + # --- FILTER 1: Tentukan Rezim Tren (EMA 200) --- + if price > last[f'EMA_{ema_period}']: + trend_regime = "Bullish" + elif price < last[f'EMA_{ema_period}']: + trend_regime = "Bearish" + else: + trend_regime = "Choppy" + + # --- FILTER 2: Cari Sinyal Pemicu (Squeeze Breakout) --- + is_in_squeeze = prev['SQUEEZE'] + + if is_in_squeeze: + explanation = f"Rezim: {trend_regime}. Squeeze terdeteksi, menunggu breakout." + + # Hanya cari sinyal BUY jika dalam Rezim Bullish + if trend_regime == "Bullish" and last['close'] > prev[bbu_col]: + signal = "BUY" + explanation = f"Rezim: {trend_regime}. Sinyal: Squeeze & Breakout NAIK!" + + # Hanya cari sinyal SELL jika dalam Rezim Bearish + elif trend_regime == "Bearish" and last['close'] < prev[bbl_col]: + signal = "SELL" + explanation = f"Rezim: {trend_regime}. Sinyal: Squeeze & Breakout TURUN!" + + analysis_data = { + "signal": signal, + "price": price, + "explanation": explanation, + "Trend_Regime": trend_regime, + f"EMA_{ema_period}": last.get(f'EMA_{ema_period}'), + "Is_Squeeze": bool(last.get('SQUEEZE', False)), + } + return analysis_data diff --git a/core/strategies/strategy_map.py b/core/strategies/strategy_map.py index 0311ea0..8af65d5 100644 --- a/core/strategies/strategy_map.py +++ b/core/strategies/strategy_map.py @@ -7,6 +7,7 @@ from .rsi_breakout import RSIBreakoutStrategy from .bollinger_bands import BollingerBandsStrategy from .bollinger_squeeze import BollingerSqueezeStrategy from .mercy_edge import MercyEdgeStrategy +from .quantum_velocity import QuantumVelocityStrategy from .pulse_sync import PulseSyncStrategy STRATEGY_MAP = { @@ -17,6 +18,7 @@ STRATEGY_MAP = { 'BOLLINGER_BANDS': BollingerBandsStrategy, 'BOLLINGER_SQUEEZE': BollingerSqueezeStrategy, 'MERCY_EDGE': MercyEdgeStrategy, + 'quantum_velocity': QuantumVelocityStrategy, # <-- Tambahkan strategi baru 'PULSE_SYNC': PulseSyncStrategy, } diff --git a/lab/backtester_hybrid_pro.py b/lab/backtester_hybrid_pro.py new file mode 100644 index 0000000..d7164b8 --- /dev/null +++ b/lab/backtester_hybrid_pro.py @@ -0,0 +1,118 @@ +# lab/backtester_hybrid_pro.py +import pandas as pd +import pandas_ta as ta +import matplotlib.pyplot as plt +from pathlib import Path + +def get_profit_multiplier(symbol, lot_size=0.01): + if "500" in symbol or "100" in symbol or "30" in symbol: return 1 * lot_size + elif "XAU" in symbol: return 100 * lot_size + elif "JPY" in symbol: return 1000 * lot_size + else: return 100000 * lot_size + +def run_hybrid_pro_backtest(data_path, symbol, strategy_params, initial_balance=10000): + """ + Menjalankan backtest untuk strategi QuantumBotX Hybrid versi Pro + dengan parameter yang dapat dikonfigurasi. + """ + print(f"Memulai backtest HYBRID PRO untuk simbol: {symbol}") + print(f"Parameter yang digunakan: {strategy_params}") + + LOT_SIZE = 0.01 + multiplier = get_profit_multiplier(symbol, LOT_SIZE) + df = pd.read_csv(data_path, parse_dates=['time']) + + # --- Ambil Parameter Dinamis dari Argumen Fungsi --- + adx_period = strategy_params.get('adx_period', 14) + adx_threshold = strategy_params.get('adx_threshold', 25) + ma_fast_period = strategy_params.get('ma_fast_period', 20) + ma_slow_period = strategy_params.get('ma_slow_period', 50) + bb_length = strategy_params.get('bb_length', 20) + bb_std = strategy_params.get('bb_std', 2.0) + + # --- Hitung Indikator --- + df.ta.adx(length=adx_period, append=True) + df[f'SMA_{ma_fast_period}'] = ta.sma(df['close'], length=ma_fast_period) + df[f'SMA_{ma_slow_period}'] = ta.sma(df['close'], length=ma_slow_period) + df.ta.bbands(length=bb_length, std=bb_std, append=True) + + df.dropna(inplace=True) + df = df.reset_index(drop=True) + + # --- Siapkan Nama Kolom Dinamis --- + adx_col = f'ADX_{adx_period}' + ma_fast_col = f'SMA_{ma_fast_period}' + ma_slow_col = f'SMA_{ma_slow_period}' + bbu_col = f'BBU_{bb_length}_{bb_std:.1f}' + bbl_col = f'BBL_{bb_length}_{bb_std:.1f}' + + # --- Siapkan Variabel Simulasi --- + balance, position, trades, equity_curve = initial_balance, None, [], [] + + print("Memulai Loop Backtest...") + for i in range(1, len(df)): + current, prev = df.iloc[i], df.iloc[i-1] + + adx_value = current[adx_col] + + # --- Logika Adaptif --- + if adx_value > adx_threshold: # Mode Trending + if position and position['strategy'] == 'Bollinger': # Keluar dari posisi Bollinger jika tren dimulai + profit = (current['close'] - position['entry_price']) * multiplier if position['type'] == 'BUY' else (position['entry_price'] - current['close']) * multiplier + balance += profit; trades.append({'profit': profit}); position = None + + if not position: # Entry MA Crossover + if prev[ma_fast_col] <= prev[ma_slow_col] and current[ma_fast_col] > current[ma_slow_col]: + position = {'type': 'BUY', 'entry_price': current['close'], 'strategy': 'MA_Crossover'} + elif prev[ma_fast_col] >= prev[ma_slow_col] and current[ma_fast_col] < current[ma_slow_col]: + position = {'type': 'SELL', 'entry_price': current['close'], 'strategy': 'MA_Crossover'} + + elif adx_value < adx_threshold: # Mode Ranging + if position and position['strategy'] == 'MA_Crossover': # Keluar dari posisi MA jika pasar sideways + profit = (current['close'] - position['entry_price']) * multiplier if position['type'] == 'BUY' else (position['entry_price'] - current['close']) * multiplier + balance += profit; trades.append({'profit': profit}); position = None + + if not position: # Entry Bollinger Bands + if current['low'] <= current[bbl_col]: + position = {'type': 'BUY', 'entry_price': current['close'], 'strategy': 'Bollinger'} + elif current['high'] >= current[bbu_col]: + position = {'type': 'SELL', 'entry_price': current['close'], 'strategy': 'Bollinger'} + + equity_curve.append(balance) + + # --- Analisis Hasil --- + print("\n--- Backtest Selesai ---") + print(f"Balance Awal: ${initial_balance:.2f}") + print(f"Balance Akhir: ${balance:.2f}") + print(f"Total Profit/Loss: ${balance - initial_balance:.2f} ({(balance - initial_balance)/initial_balance*100:.2f}%)") + print(f"Total Trades: {len(trades)}") + + plt.figure(figsize=(12, 6)) + plt.plot(df['time'].iloc[1:], equity_curve) + plt.title(f'Equity Curve - HYBRID PRO on {symbol}') + plt.xlabel('Tanggal') + plt.ylabel('Balance ($)') + plt.grid(True) + plt.show() + +if __name__ == '__main__': + # --- PUSAT KONTROL EKSPERIMEN --- + + # Definisikan parameter yang ingin diuji. + # Ini sama seperti `get_definable_params` di strategi Anda. + params_to_test = { + "adx_period": 14, + "adx_threshold": 25, + "ma_fast_period": 20, + "ma_slow_period": 50, + "bb_length": 20, + "bb_std": 2.0 + } + + # Pilih pasar yang ingin diuji + symbol_to_test = "EURUSD" + data_folder = Path(__file__).parent.resolve() # Otomatis mencari di folder 'lab/' + file_name = data_folder / f"{symbol_to_test}_16385_data.csv" + + # Jalankan backtest dengan parameter dan pasar yang dipilih + run_hybrid_pro_backtest(str(file_name), symbol=symbol_to_test, strategy_params=params_to_test) \ No newline at end of file diff --git a/static/js/dashboard.js b/static/js/dashboard.js index 5db0714..0ebf4c4 100644 --- a/static/js/dashboard.js +++ b/static/js/dashboard.js @@ -45,12 +45,12 @@ async function fetchAllBots() { if (activeBots.length === 0) { listEl.innerHTML = '

Tidak ada bot yang sedang aktif.

'; } else { - activeBots.forEach(bot => { + const botsHtml = activeBots.map(bot => { const badge = bot.strategy === 'MERCY_EDGE' ? `AI` : ''; - const html = ` + return `
@@ -72,8 +72,8 @@ async function fetchAllBots() {
`; - listEl.innerHTML += html; - }); + }).join(''); + listEl.innerHTML = botsHtml; } } catch (error) { @@ -119,7 +119,8 @@ async function updatePriceChart(symbol = 'EURUSD') { priceChart.data.datasets[0].data = chartData.data; priceChart.update(); } else { - priceChart = new Chart(ctx, config); + + priceChart = new Chart(ctx, config); // eslint-disable-line no-undef } } catch (error) { @@ -166,49 +167,26 @@ async function updateRsiChart(symbol = 'EURUSD') { rsiChart.data.labels = rsiData.timestamps; rsiChart.data.datasets[0].data = rsiData.rsi_values; rsiChart.update(); - } else { - rsiChart = new Chart(ctx, config); + } else { + rsiChart = new Chart(ctx, config); // eslint-disable-line no-undef } } catch (error) { - console.error('[AI] Gagal memuat sinyal AI:', error); - if (aiSignalSymbolEl) aiSignalSymbolEl.textContent = 'Error'; - if (aiSignalDecisionEl) aiSignalDecisionEl.textContent = 'Error'; - if (aiSignalExplanationEl) aiSignalExplanationEl.textContent = 'Error loading AI signal.'; - if (strategyNameEl) strategyNameEl.textContent = 'Error'; + console.error('[RSI Chart] Gagal update grafik RSI:', error); + const ctx = document.getElementById('rsiChart'); + if (ctx && ctx.parentElement) { + ctx.parentElement.innerHTML = '

Gagal memuat data RSI.

'; + } } } // DOM ready document.addEventListener('DOMContentLoaded', () => { - // --- Elemen Global AI Signal --- - const aiSignalSymbolEl = document.getElementById('ai-signal-symbol'); - const aiSignalDecisionEl = document.getElementById('ai-signal-decision'); - const aiSignalExplanationEl = document.getElementById('ai-signal-explanation'); - const refreshAiSignalButton = document.getElementById('refresh-ai-signal-button'); - updateDashboardStats(); fetchAllBots(); updatePriceChart(); updateRsiChart(); - // Event listener untuk tombol refresh AI Signal - let isAiSignalLoading = false; - if (refreshAiSignalButton) { - refreshAiSignalButton.addEventListener('click', async () => { - if (isAiSignalLoading) return; - isAiSignalLoading = true; - refreshAiSignalButton.disabled = true; - refreshAiSignalButton.textContent = 'Refreshing...'; - - await fetchAiSignal(); - - isAiSignalLoading = false; - refreshAiSignalButton.disabled = false; - refreshAiSignalButton.textContent = 'Refresh AI Signal'; - }); - } - // Interval refresh setInterval(updateDashboardStats, 10000); setInterval(fetchAllBots, 5000); diff --git a/static/js/notifications.js b/static/js/notifications.js index 9249404..a08788f 100644 --- a/static/js/notifications.js +++ b/static/js/notifications.js @@ -9,26 +9,14 @@ document.addEventListener('DOMContentLoaded', function() { }); }; - const getNotificationAppearance = (message) => { - if (message.toLowerCase().includes('berhasil') || message.toLowerCase().includes('dibuka')) { - return { - icon: 'fa-check-circle', - color: 'green' - }; - } - if (message.toLowerCase().includes('gagal')) { - return { - icon: 'fa-exclamation-triangle', - color: 'red' - }; - } - if (message.toLowerCase().includes('ditutup')) { - return { - icon: 'fa-flag-checkered', - color: 'blue' - }; - } - return { icon: 'fa-info-circle', color: 'gray' }; // Default + const getNotificationAppearance = (action) => { + const lowerAction = action.toLowerCase(); + if (lowerAction.includes('buy')) return { icon: 'fa-arrow-up', color: 'green' }; + if (lowerAction.includes('sell')) return { icon: 'fa-arrow-down', color: 'red' }; + if (lowerAction.includes('start')) return { icon: 'fa-play-circle', color: 'blue' }; + if (lowerAction.includes('stop')) return { icon: 'fa-pause-circle', color: 'yellow' }; + if (lowerAction.includes('error')) return { icon: 'fa-exclamation-triangle', color: 'red' }; + return { icon: 'fa-info-circle', color: 'gray' }; }; async function fetchNotifications() { @@ -37,25 +25,26 @@ document.addEventListener('DOMContentLoaded', function() { if (!response.ok) throw new Error('Gagal memuat notifikasi'); const notifications = await response.json(); - container.innerHTML = ''; // Kosongkan pesan "Memuat..." - if (notifications.length === 0) { container.innerHTML = '

Tidak ada notifikasi.

'; return; } - notifications.forEach(notif => { - const appearance = getNotificationAppearance(notif.message); - const notifElement = ` + const notificationsHtml = notifications.map(notif => { + const appearance = getNotificationAppearance(notif.action); + const message = `[${notif.bot_name}] ${notif.details}`; + return `
-

${notif.message}

+

${message}

${formatTimestamp(notif.timestamp)}

`; - container.innerHTML += notifElement; - }); + }).join(''); + + container.innerHTML = notificationsHtml; + } catch (error) { console.error("Error fetching notifications:", error); container.innerHTML = `

Gagal memuat notifikasi: ${error.message}

`; diff --git a/static/js/trading_bots.js b/static/js/trading_bots.js index 73e792f..f5fac97 100644 --- a/static/js/trading_bots.js +++ b/static/js/trading_bots.js @@ -6,6 +6,7 @@ document.addEventListener('DOMContentLoaded', function() { const modal = document.getElementById('create-bot-modal'); const form = document.getElementById('create-bot-form'); const modalTitle = document.getElementById('modal-title'); + const submitBtn = document.getElementById('submit-bot-btn'); // <-- 1. Ambil elemen tombol const createBotBtn = document.getElementById('create-bot-btn'); const cancelBtn = document.getElementById('cancel-create'); const paramsContainer = document.getElementById('strategy-params-container'); @@ -108,6 +109,7 @@ document.addEventListener('DOMContentLoaded', function() { currentBotId = null; paramsContainer.innerHTML = ''; // Kosongkan parameter form.reset(); + submitBtn.textContent = 'Buat Bot'; // <-- 2. Set teks untuk mode 'Create' modalTitle.textContent = '🚀 Buat Bot Baru'; // Set nilai default form.elements.lot_size.value = 0.01; @@ -183,6 +185,7 @@ document.addEventListener('DOMContentLoaded', function() { const bot = await res.json(); if (res.ok) { currentBotId = botId; + submitBtn.textContent = 'Ubah Bot'; // <-- 3. Set teks untuk mode 'Edit' modalTitle.textContent = '✏️ Edit Bot'; // Isi form dengan data bot yang ada for (const key in bot) { diff --git a/templates/index.html b/templates/index.html index 88f0980..eeb8401 100644 --- a/templates/index.html +++ b/templates/index.html @@ -78,19 +78,19 @@

Total Saldo (Equity)

-

$0.00

+

Profit Hari Ini

-

$0.00

+

Bot Aktif

-

0

+

Total Bot

-

0

+

@@ -105,19 +105,6 @@
- -
-

- 💡 AI Signal Loading Strategy... -

- -
-

Symbol: N/A

-

Decision: N/A

-

Click the button above to load AI signal.

-
-
-
diff --git a/templates/trading_bots.html b/templates/trading_bots.html index 78a220e..ebceae0 100644 --- a/templates/trading_bots.html +++ b/templates/trading_bots.html @@ -162,12 +162,12 @@
- +
- + \ No newline at end of file