diff --git a/app.py b/app.py index b8c649e..aae3c20 100644 --- a/app.py +++ b/app.py @@ -2,10 +2,12 @@ import os import logging from logging.handlers import RotatingFileHandler +import atexit # <-- 1. Impor modul atexit from flask import Flask, render_template, send_from_directory from dotenv import load_dotenv # Import modul inti yang diperlukan saat startup +import MetaTrader5 as mt5 # <-- 2. Impor MT5 secara langsung from core.utils.mt5 import initialize_mt5 from core.bots.controller import ambil_semua_bot @@ -116,6 +118,22 @@ def favicon(): return send_from_directory(os.path.join(app.root_path, 'static'), 'favicon.ico', mimetype='image/vnd.microsoft.icon') +# --- Fungsi Shutdown --- +def shutdown_handler(): + """Fungsi yang akan dipanggil saat aplikasi akan keluar.""" + logger.info("Menerima sinyal shutdown. Memulai proses pembersihan...") + + # Impor controller di sini untuk menghindari circular import + from core.bots import controller + + # 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) + + mt5.shutdown() + logger.info("Koneksi MetaTrader 5 ditutup. Shutdown selesai.") + # --- Titik Eksekusi Utama --- if __name__ == '__main__': # Memuat kredensial MT5 dari .env dengan aman @@ -146,6 +164,9 @@ if __name__ == '__main__': except Exception as e: logger.error(f"Terjadi kesalahan saat memuat bot: {e}", exc_info=True) + # --- 3. Daftarkan fungsi shutdown --- + atexit.register(shutdown_handler) + # Jalankan aplikasi Flask app.run( debug=os.getenv('FLASK_DEBUG', 'False').lower() == 'true', diff --git a/core/bots/controller.py b/core/bots/controller.py index dc252d4..2e2aa16 100644 --- a/core/bots/controller.py +++ b/core/bots/controller.py @@ -1,5 +1,6 @@ # core/bots/controller.py +import json import logging from core.db import queries from .trading_bot import TradingBot @@ -38,12 +39,16 @@ def mulai_bot(bot_id: int): if not bot_data: return False, f"Bot dengan ID {bot_id} tidak ditemukan." + # Ubah string JSON dari DB menjadi dictionary Python + params_dict = json.loads(bot_data.get('strategy_params', '{}')) + try: bot_thread = TradingBot( id=bot_data['id'], name=bot_data['name'], market=bot_data['market'], lot_size=bot_data['lot_size'], sl_pips=bot_data['sl_pips'], tp_pips=bot_data['tp_pips'], timeframe=bot_data['timeframe'], - check_interval=bot_data['check_interval_seconds'], strategy=bot_data['strategy'] + check_interval=bot_data['check_interval_seconds'], strategy=bot_data['strategy'], + strategy_params=params_dict ) bot_thread.start() active_bots[bot_id] = bot_thread @@ -82,10 +87,23 @@ def perbarui_bot(bot_id: int, data: dict): # menjadi 'interval' yang sesuai dengan kolom database. if 'check_interval_seconds' in data: data['interval'] = data.pop('check_interval_seconds') - # --- AKHIR PERBAIKAN --- + + # Ambil parameter kustom, ubah jadi string JSON, dan simpan + custom_params = data.pop('params', {}) + data['strategy_params'] = json.dumps(custom_params) + + # --- PERBAIKAN BARU: Filter data untuk mencegah TypeError --- + # Hanya teruskan argumen yang diharapkan oleh fungsi queries.update_bot + expected_args = [ + 'name', 'market', 'lot_size', 'sl_pips', 'tp_pips', + 'timeframe', 'interval', 'strategy', 'strategy_params' + ] + + update_data = {key: data[key] for key in expected_args if key in data} try: - success = queries.update_bot(bot_id=bot_id, **data) + # Gunakan dictionary yang sudah difilter + success = queries.update_bot(bot_id=bot_id, **update_data) if success: logger.info(f"Konfigurasi bot {bot_id} berhasil diperbarui di database.") return True, "Bot berhasil diperbarui." diff --git a/core/bots/trading_bot.py b/core/bots/trading_bot.py index 1fb3607..b029510 100644 --- a/core/bots/trading_bot.py +++ b/core/bots/trading_bot.py @@ -12,7 +12,7 @@ logger = logging.getLogger(__name__) class TradingBot(threading.Thread): - def __init__(self, id, name, market, lot_size, sl_pips, tp_pips, timeframe, check_interval, strategy, status='Dijeda'): + def __init__(self, id, name, market, lot_size, sl_pips, tp_pips, timeframe, check_interval, strategy, strategy_params={}, status='Dijeda'): super().__init__() self.id = id self.name = name @@ -23,6 +23,7 @@ class TradingBot(threading.Thread): self.timeframe = timeframe self.check_interval = check_interval self.strategy_name = strategy + self.strategy_params = strategy_params self.market_for_mt5 = self.market.replace('/', '') # Versi simbol yang bersih untuk MT5 self.status = status @@ -43,7 +44,7 @@ class TradingBot(threading.Thread): raise ValueError(f"Strategi '{self.strategy_name}' tidak ditemukan.") # --- PERBAIKAN: Inisialisasi kelas strategi dengan benar --- - self.strategy_instance = strategy_class(bot_instance=self) + self.strategy_instance = strategy_class(bot_instance=self, params=self.strategy_params) except Exception as e: self.log_activity('ERROR', f"Inisialisasi Gagal: {e}") @@ -53,13 +54,17 @@ class TradingBot(threading.Thread): while not self._stop_event.is_set(): try: if not mt5.symbol_select(self.market_for_mt5, True): - self.log_activity('WARNING', f"Gagal mengaktifkan simbol {self.market} (di MT5: {self.market_for_mt5}). Pastikan simbol ada di Market Watch.") + msg = f"Gagal mengaktifkan simbol {self.market} (di MT5: {self.market_for_mt5}). Pastikan simbol ada di Market Watch." + self.log_activity('WARNING', msg) + self.last_analysis = {"signal": "ERROR", "price": None, "explanation": msg} time.sleep(self.check_interval) continue symbol_info = mt5.symbol_info(self.market_for_mt5) if not symbol_info: - self.log_activity('WARNING', f"Tidak dapat mengambil info untuk simbol {self.market} (di MT5: {self.market_for_mt5}).") + msg = f"Tidak dapat mengambil info untuk simbol {self.market} (di MT5: {self.market_for_mt5})." + self.log_activity('WARNING', msg) + self.last_analysis = {"signal": "ERROR", "price": None, "explanation": msg} time.sleep(self.check_interval) continue diff --git a/core/db/connection.py b/core/db/connection.py index b5d7af3..2e1fa6d 100644 --- a/core/db/connection.py +++ b/core/db/connection.py @@ -1,24 +1,15 @@ -# core/db/connection.py - VERSI FINAL - +# core/db/connection.py import sqlite3 import os -# Menentukan path absolut ke file database -# Ini memastikan DB ditemukan dari mana pun skrip dijalankan +# Tentukan nama file database di satu tempat. +DATABASE_FILENAME = 'bots.db' BASE_DIR = os.path.dirname(os.path.abspath(__file__)) -DB_PATH = os.path.join(BASE_DIR, '..', '..', 'bots.db') +DATABASE_PATH = os.path.join(BASE_DIR, '..', '..', DATABASE_FILENAME) def get_db_connection(): - """ - Membuat dan mengembalikan koneksi ke database SQLite. - Fungsi ini adalah satu-satunya sumber koneksi database untuk seluruh aplikasi. - """ - try: - # check_same_thread=False diperlukan untuk aplikasi multi-threaded seperti Flask - conn = sqlite3.connect(DB_PATH, check_same_thread=False) - # 'row_factory' membuat hasil query bisa diakses seperti dictionary - conn.row_factory = sqlite3.Row - return conn - except sqlite3.Error as e: - print(f"FATAL: Gagal koneksi ke database di {DB_PATH}: {e}") - return None + """Membuat dan mengembalikan koneksi ke database SQLite.""" + conn = sqlite3.connect(DATABASE_PATH) + # Mengatur agar hasil query bisa diakses seperti dictionary + conn.row_factory = sqlite3.Row + return conn \ No newline at end of file diff --git a/core/db/queries.py b/core/db/queries.py index 76f4a39..7edf65c 100644 --- a/core/db/queries.py +++ b/core/db/queries.py @@ -26,31 +26,31 @@ def get_bot_by_id(bot_id): logger.error(f"Database error saat mengambil bot {bot_id}: {e}") return None -def add_bot(name, market, lot_size, sl_pips, tp_pips, timeframe, interval, strategy): +def add_bot(name, market, lot_size, sl_pips, tp_pips, timeframe, interval, strategy, strategy_params='{}'): """Menambahkan bot baru ke database.""" try: with get_db_connection() as conn: cursor = conn.cursor() cursor.execute(''' - INSERT INTO bots (name, market, lot_size, sl_pips, tp_pips, timeframe, check_interval_seconds, strategy, status) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'Dijeda') - ''', (name, market, lot_size, sl_pips, tp_pips, timeframe, interval, strategy)) + INSERT INTO bots (name, market, lot_size, sl_pips, tp_pips, timeframe, check_interval_seconds, strategy, strategy_params, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'Dijeda') + ''', (name, market, lot_size, sl_pips, tp_pips, timeframe, interval, strategy, strategy_params)) conn.commit() return cursor.lastrowid except sqlite3.Error as e: logger.error(f"Gagal menambah bot ke DB: {e}", exc_info=True) return None -def update_bot(bot_id, name, market, lot_size, sl_pips, tp_pips, timeframe, interval, strategy): +def update_bot(bot_id, name, market, lot_size, sl_pips, tp_pips, timeframe, interval, strategy, strategy_params='{}'): """Memperbarui data bot yang sudah ada di database.""" try: with get_db_connection() as conn: conn.execute(''' UPDATE bots SET name = ?, market = ?, lot_size = ?, sl_pips = ?, tp_pips = ?, - timeframe = ?, check_interval_seconds = ?, strategy = ? + timeframe = ?, check_interval_seconds = ?, strategy = ?, strategy_params = ? WHERE id = ? - ''', (name, market, lot_size, sl_pips, tp_pips, timeframe, interval, strategy, bot_id)) + ''', (name, market, lot_size, sl_pips, tp_pips, timeframe, interval, strategy, strategy_params, bot_id)) conn.commit() return True except sqlite3.Error as e: diff --git a/core/routes/api_bots.py b/core/routes/api_bots.py index a412483..31f5d5a 100644 --- a/core/routes/api_bots.py +++ b/core/routes/api_bots.py @@ -1,5 +1,6 @@ # core/routes/api_bots.py - VERSI PERBAIKAN LENGKAP +import json import logging from flask import Blueprint, jsonify, request import pandas_ta as ta @@ -28,6 +29,20 @@ def get_strategies_route(): logger.error(f"Gagal memuat daftar strategi: {e}", exc_info=True) return jsonify({"error": "Gagal memuat daftar strategi"}), 500 +@api_bots.route('/api/strategies//params', methods=['GET']) +def get_strategy_params_route(strategy_id): + """Mengembalikan parameter yang bisa diatur untuk sebuah strategi.""" + strategy_class = STRATEGY_MAP.get(strategy_id) + if not strategy_class: + return jsonify({"error": "Strategi tidak ditemukan"}), 404 + + # Panggil metode class untuk mendapatkan parameter + if hasattr(strategy_class, 'get_definable_params'): + params = strategy_class.get_definable_params() + return jsonify(params) + + return jsonify([]) # Kembalikan array kosong jika tidak ada parameter + @api_bots.route('/api/bots', methods=['GET']) def get_bots_route(): """Mengambil semua bot.""" @@ -46,16 +61,22 @@ def get_bots_route(): def get_single_bot_route(bot_id): """Mengambil detail satu bot.""" bot = queries.get_bot_by_id(bot_id) + if bot and bot.get('strategy_params'): + # Ubah string JSON menjadi objek untuk frontend + bot['strategy_params'] = json.loads(bot['strategy_params']) return jsonify(bot) if bot else (jsonify({"error": "Bot tidak ditemukan"}), 404) @api_bots.route('/api/bots', methods=['POST']) def add_bot_route(): """Membuat bot baru.""" data = request.get_json() + params_json = json.dumps(data.get('params', {})) + new_bot_id = queries.add_bot( name=data.get('name'), market=data.get('market'), lot_size=data.get('lot_size'), sl_pips=data.get('sl_pips'), tp_pips=data.get('tp_pips'), timeframe=data.get('timeframe'), - interval=data.get('check_interval_seconds'), strategy=data.get('strategy') + interval=data.get('check_interval_seconds'), strategy=data.get('strategy'), + strategy_params=params_json ) if new_bot_id: controller.add_new_bot_to_controller(new_bot_id) diff --git a/core/routes/api_fundamentals.py b/core/routes/api_fundamentals.py index f18666c..09416e4 100644 --- a/core/routes/api_fundamentals.py +++ b/core/routes/api_fundamentals.py @@ -1,21 +1,14 @@ # core/routes/api_fundamentals.py from flask import Blueprint, jsonify -import sqlite3 - +# Hapus 'import sqlite3' dan gunakan fungsi dari queries +from core.db import queries api_fundamentals = Blueprint('api_fundamentals', __name__) -def get_bot(bot_id): - conn = sqlite3.connect('bots.db') - conn.row_factory = sqlite3.Row - bot = conn.execute('SELECT * FROM bots WHERE id = ?', (bot_id,)).fetchone() - conn.close() - return bot - @api_fundamentals.route('/api/bots//fundamentals') def get_bot_fundamentals(bot_id): - bot = get_bot(bot_id) + bot = queries.get_bot_by_id(bot_id) # Gunakan fungsi terpusat if not bot: return jsonify({'error': 'Bot tidak ditemukan'}), 404 diff --git a/core/strategies/base_strategy.py b/core/strategies/base_strategy.py index cba84cf..a2fbfae 100644 --- a/core/strategies/base_strategy.py +++ b/core/strategies/base_strategy.py @@ -5,18 +5,28 @@ class BaseStrategy: Kelas dasar abstrak untuk semua strategi trading. Setiap strategi harus mewarisi kelas ini dan mengimplementasikan metode `analyze`. """ - def __init__(self, bot_instance): + def __init__(self, bot_instance, params: dict = {}): """ Inisialisasi strategi dengan instance dari bot yang menjalankannya. Args: bot_instance (TradingBot): Instance dari bot yang aktif. + params (dict): Dictionary berisi parameter kustom untuk strategi. """ self.bot = bot_instance + self.params = params def analyze(self): """ Metode inti yang harus di-override oleh setiap strategi turunan. Metode ini harus mengembalikan sebuah dictionary yang berisi hasil analisis. """ - raise NotImplementedError("Setiap strategi harus mengimplementasikan metode `analyze()`.") \ No newline at end of file + raise NotImplementedError("Setiap strategi harus mengimplementasikan metode `analyze()`.") + + @classmethod + def get_definable_params(cls): + """ + Metode kelas yang mengembalikan daftar parameter yang bisa diatur oleh pengguna. + Setiap strategi turunan harus meng-override ini jika memiliki parameter. + """ + return [] \ No newline at end of file diff --git a/core/strategies/bollinger_bands.py b/core/strategies/bollinger_bands.py index b71753f..ff0c987 100644 --- a/core/strategies/bollinger_bands.py +++ b/core/strategies/bollinger_bands.py @@ -7,6 +7,14 @@ class BollingerBandsStrategy(BaseStrategy): name = 'Bollinger Bands Reversion' description = 'Sinyal berdasarkan harga yang menyentuh atau melintasi batas atas atau bawah Bollinger Bands (Mean Reversion).' + @classmethod + def get_definable_params(cls): + """Mengembalikan parameter yang bisa diatur untuk strategi ini.""" + return [ + {"name": "bb_length", "label": "Panjang BB", "type": "number", "default": 20}, + {"name": "bb_std", "label": "Standar Deviasi BB", "type": "number", "default": 2.0, "step": 0.1} + ] + def analyze(self): """ Menganalisis pasar menggunakan strategi Bollinger Bandsยฎ Mean Reversion. @@ -21,7 +29,14 @@ class BollingerBandsStrategy(BaseStrategy): return {"signal": "HOLD", "price": None, "explanation": "Data tidak cukup untuk Bollinger Bands."} # --- Hitung Indikator --- - df.ta.bbands(length=20, std=2.0, append=True) + bb_length = self.params.get('bb_length', 20) + bb_std = self.params.get('bb_std', 2.0) + + # PERBAIKAN: Paksa format float dengan satu desimal pada nama kolom + bbu_col = f'BBU_{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) # pandas-ta akan membuat kolom dengan format yang sama df.dropna(inplace=True) if len(df) < 1: @@ -34,20 +49,20 @@ class BollingerBandsStrategy(BaseStrategy): # --- Logika Sinyal --- # Sinyal Beli: Harga menyentuh atau menembus Band Bawah - if last['low'] <= last['BBL_20_2.0']: + if last['low'] <= last[bbl_col]: signal = "BUY" - explanation = f"Oversold: Harga [{last['low']:.4f}] menyentuh Band Bawah [{last['BBL_20_2.0']:.4f}]" + explanation = f"Oversold: Harga [{last['low']:.4f}] menyentuh Band Bawah [{last[bbl_col]:.4f}]" # Sinyal Jual: Harga menyentuh atau menembus Band Atas - elif last['high'] >= last['BBU_20_2.0']: + elif last['high'] >= last[bbu_col]: signal = "SELL" - explanation = f"Overbought: Harga [{last['high']:.4f}] menyentuh Band Atas [{last['BBU_20_2.0']:.4f}]" + explanation = f"Overbought: Harga [{last['high']:.4f}] menyentuh Band Atas [{last[bbu_col]:.4f}]" analysis_data = { "signal": signal, "price": price, "explanation": explanation, - "BB_Upper": last.get('BBU_20_2.0'), - "BB_Middle": last.get('BBM_20_2.0'), - "BB_Lower": last.get('BBL_20_2.0'), + "BB_Upper": last.get(f'BBU_{bb_length}_{bb_std:.1f}'), + "BB_Middle": last.get(f'BBM_{bb_length}_{bb_std:.1f}'), + "BB_Lower": last.get(f'BBL_{bb_length}_{bb_std:.1f}'), } return analysis_data \ No newline at end of file diff --git a/core/strategies/bollinger_squeeze.py b/core/strategies/bollinger_squeeze.py index 0748794..c70c068 100644 --- a/core/strategies/bollinger_squeeze.py +++ b/core/strategies/bollinger_squeeze.py @@ -9,6 +9,18 @@ class BollingerSqueezeStrategy(BaseStrategy): name = 'Bollinger Squeeze Breakout' description = 'Mencari periode volatilitas rendah (squeeze) sebagai sinyal potensi breakout harga yang kuat.' + @classmethod + def get_definable_params(cls): + """Mengembalikan parameter yang bisa diatur untuk strategi ini.""" + return [ + {"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}, + {"name": "rsi_period", "label": "Periode RSI", "type": "number", "default": 14}, + {"name": "volume_factor", "label": "Faktor Volume", "type": "number", "default": 1.5, "step": 0.1} + ] + def analyze(self): """ Menganalisis pasar menggunakan strategi Bollinger Band Squeeze. @@ -24,25 +36,36 @@ class BollingerSqueezeStrategy(BaseStrategy): return {"signal": "HOLD", "price": None, "explanation": "Data tidak cukup untuk Bollinger Squeeze."} # --- Hitung Indikator --- - df.ta.bbands(length=20, std=2.0, append=True) - df['BB_WIDTH'] = df['BBU_20_2.0'] - df['BBL_20_2.0'] + 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) + 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}' + + df.ta.bbands(length=bb_length, std=bb_std, append=True) + df['BB_WIDTH'] = df[bbu_col] - df[bbl_col] # FIX 1: Mencegah ZeroDivisionError dengan pembagian yang aman df['BB_BANDWIDTH'] = np.where( - df['BBM_20_2.0'] != 0, - (df['BBU_20_2.0'] - df['BBL_20_2.0']) / df['BBM_20_2.0'] * 100, + df[bbm_col] != 0, + (df[bbu_col] - df[bbl_col]) / df[bbm_col] * 100, 0 # Jika middle band 0, anggap bandwidth 0 ) - df['AVG_BANDWIDTH'] = df['BB_BANDWIDTH'].rolling(window=10).mean() - df['SQUEEZE_LEVEL'] = df['AVG_BANDWIDTH'] * 0.7 + 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['RSI'] = ta.rsi(df['close'], length=14) + df['RSI'] = ta.rsi(df['close'], length=rsi_period) # FIX 2: Menggunakan nama kolom volume yang benar ('tick_volume') if 'tick_volume' in df.columns: - df['AVG_VOLUME'] = df['tick_volume'].rolling(window=10).mean() - df['VOLUME_SURGE'] = df['tick_volume'] > df['AVG_VOLUME'] * 1.5 + df['AVG_VOLUME'] = df['tick_volume'].rolling(window=squeeze_window).mean() + df['VOLUME_SURGE'] = df['tick_volume'] > df['AVG_VOLUME'] * volume_factor df['VOLUME_SURGE'] = df['VOLUME_SURGE'].astype(int) else: df['VOLUME_SURGE'] = 0 @@ -68,21 +91,21 @@ class BollingerSqueezeStrategy(BaseStrategy): explanation = f"Squeeze terdeteksi (Lebar: {prev['BB_WIDTH']:.4f}). Menunggu breakout." # Kondisi 2: Jika ya, apakah candle SEKARANG breakout dari Bands SEBELUMNYA? - if last['close'] > prev['BBU_20_2.0'] and last['RSI'] < 70: + if last['close'] > prev[bbu_col] and last['RSI'] < 70: signal = "BUY" - explanation = f"Squeeze & Breakout NAIK! Harga [{last['close']:.2f}] > Band Atas [{prev['BBU_20_2.0']:.2f}]" - elif last['close'] < prev['BBL_20_2.0'] and last['RSI'] > 30: + explanation = f"Squeeze & Breakout NAIK! Harga [{last['close']:.2f}] > Band Atas [{prev[bbu_col]:.2f}]" + elif last['close'] < prev[bbl_col] and last['RSI'] > 30: signal = "SELL" - explanation = f"Squeeze & Breakout TURUN! Harga [{last['close']:.2f}] < Band Bawah [{prev['BBL_20_2.0']:.2f}]" + explanation = f"Squeeze & Breakout TURUN! Harga [{last['close']:.2f}] < Band Bawah [{prev[bbl_col]:.2f}]" else: # Kondisi 3: Post-Squeeze Momentum if prev['BB_BANDWIDTH'] > prev['AVG_BANDWIDTH'] * 1.2: # Bands expanding - if last['close'] > prev['BBU_20_2.0'] and last['VOLUME_SURGE']: + if last['close'] > prev[bbu_col] and last['VOLUME_SURGE']: signal = 'BUY' - explanation = f"Momentum NAIK! Harga [{last['close']:.2f}] > Band Atas [{prev['BBU_20_2.0']:.2f}] dengan volume" - elif last['close'] < prev['BBL_20_2.0'] and last['VOLUME_SURGE']: + explanation = f"Momentum NAIK! Harga [{last['close']:.2f}] > Band Atas [{prev[bbu_col]:.2f}] dengan volume" + elif last['close'] < prev[bbl_col] and last['VOLUME_SURGE']: signal = 'SELL' - explanation = f"Momentum TURUN! Harga [{last['close']:.2f}] < Band Bawah [{prev['BBL_20_2.0']:.2f}] dengan volume" + explanation = f"Momentum TURUN! Harga [{last['close']:.2f}] < Band Bawah [{prev[bbl_col]:.2f}] dengan volume" # --- PERBAIKAN: Kembalikan semua data analisis yang relevan --- analysis_data = { diff --git a/core/strategies/ma_crossover.py b/core/strategies/ma_crossover.py index 854aeb2..f902f44 100644 --- a/core/strategies/ma_crossover.py +++ b/core/strategies/ma_crossover.py @@ -8,6 +8,14 @@ class MACrossoverStrategy(BaseStrategy): name = 'Moving Average Crossover' description = 'Sinyal berdasarkan persilangan antara dua Moving Averages (misal, 20 & 50). Cocok untuk pasar trending.' + @classmethod + def get_definable_params(cls): + """Mengembalikan parameter yang bisa diatur untuk strategi ini.""" + return [ + {"name": "fast_period", "label": "Periode MA Cepat", "type": "number", "default": 20}, + {"name": "slow_period", "label": "Periode MA Lambat", "type": "number", "default": 50} + ] + def analyze(self): """ Menganalisis pasar menggunakan strategi Moving Average Crossover (20/50). @@ -23,8 +31,12 @@ class MACrossoverStrategy(BaseStrategy): return {"signal": "HOLD", "price": None, "explanation": "Data tidak cukup untuk MA Crossover."} # --- Hitung Indikator --- - df["ma_fast"] = ta.sma(df["close"], length=20) - df["ma_slow"] = ta.sma(df["close"], length=50) + # Gunakan parameter dinamis, dengan fallback ke nilai default + fast_period = self.params.get('fast_period', 20) + slow_period = self.params.get('slow_period', 50) + + df["ma_fast"] = ta.sma(df["close"], length=fast_period) + df["ma_slow"] = ta.sma(df["close"], length=slow_period) df.dropna(inplace=True) if len(df) < 2: @@ -35,17 +47,17 @@ class MACrossoverStrategy(BaseStrategy): price = last["close"] signal = "HOLD" - explanation = f"MA(20): {last['ma_fast']:.2f}, MA(50): {last['ma_slow']:.2f}. Tidak ada sinyal." + explanation = f"MA({fast_period}): {last['ma_fast']:.2f}, MA({slow_period}): {last['ma_slow']:.2f}. Tidak ada sinyal." # --- Logika Sinyal --- # Golden Cross (Sinyal Beli) if prev["ma_fast"] <= prev["ma_slow"] and last["ma_fast"] > last["ma_slow"]: signal = "BUY" - explanation = f"Golden Cross: MA(20) [{last['ma_fast']:.2f}] memotong ke atas MA(50) [{last['ma_slow']:.2f}]" + explanation = f"Golden Cross: MA({fast_period}) [{last['ma_fast']:.2f}] memotong ke atas MA({slow_period}) [{last['ma_slow']:.2f}]" # Death Cross (Sinyal Jual) elif prev["ma_fast"] >= prev["ma_slow"] and last["ma_fast"] < last["ma_slow"]: signal = "SELL" - explanation = f"Death Cross: MA(20) [{last['ma_fast']:.2f}] memotong ke bawah MA(50) [{last['ma_slow']:.2f}]" + explanation = f"Death Cross: MA({fast_period}) [{last['ma_fast']:.2f}] memotong ke bawah MA({slow_period}) [{last['ma_slow']:.2f}]" return { "signal": signal, "price": price, "explanation": explanation, diff --git a/core/strategies/mercy_edge.py b/core/strategies/mercy_edge.py index 533c364..da77779 100644 --- a/core/strategies/mercy_edge.py +++ b/core/strategies/mercy_edge.py @@ -2,28 +2,52 @@ import MetaTrader5 as mt5 import pandas_ta as ta from .base_strategy import BaseStrategy -from core.utils.mt5 import get_rates_from_mt5 +from core.data.fetch import get_rates class MercyEdgeStrategy(BaseStrategy): name = 'Mercy Edge (AI)' description = 'Strategi hybrid yang menggabungkan MACD, Stochastic, dan validasi AI untuk sinyal presisi tinggi.' + @classmethod + def get_definable_params(cls): + """Mengembalikan parameter yang bisa diatur untuk strategi ini.""" + return [ + {"name": "macd_fast", "label": "MACD Fast", "type": "number", "default": 12}, + {"name": "macd_slow", "label": "MACD Slow", "type": "number", "default": 26}, + {"name": "macd_signal", "label": "MACD Signal", "type": "number", "default": 9}, + {"name": "stoch_k", "label": "Stoch %K", "type": "number", "default": 14}, + {"name": "stoch_d", "label": "Stoch %D", "type": "number", "default": 3}, + {"name": "stoch_smooth", "label": "Stoch Smooth", "type": "number", "default": 3}, + ] + def analyze(self): - df_d1 = get_rates_from_mt5(self.bot.market_for_mt5, mt5.TIMEFRAME_D1, 200) - df_h1 = get_rates_from_mt5(self.bot.market_for_mt5, mt5.TIMEFRAME_H1, 100) + # Ambil parameter dinamis + macd_fast = self.params.get('macd_fast', 12) + macd_slow = self.params.get('macd_slow', 26) + macd_signal_p = self.params.get('macd_signal', 9) # 'signal' adalah nama variabel yang sudah ada + stoch_k = self.params.get('stoch_k', 14) + stoch_d = self.params.get('stoch_d', 3) + stoch_smooth = self.params.get('stoch_smooth', 3) + + # Gunakan fungsi get_rates yang terstandarisasi + df_d1 = get_rates(self.bot.market_for_mt5, mt5.TIMEFRAME_D1, 200) + df_h1 = get_rates(self.bot.market_for_mt5, mt5.TIMEFRAME_H1, 100) if df_d1 is None or df_h1 is None or len(df_d1) < 50 or len(df_h1) < 30: return {"signal": "HOLD", "price": None, "explanation": "Data tidak cukup"} - macd_d1 = ta.macd(df_d1['close']).rename(columns={'MACDh_12_26_9': 'hist_d1'}) - macd_h1 = ta.macd(df_h1['close']).rename(columns={'MACDh_12_26_9': 'hist_h1'}) - stoch = ta.stoch(df_h1['high'], df_h1['low'], df_h1['close']) + # Buat nama kolom indikator secara dinamis + macd_hist_col = f'MACDh_{macd_fast}_{macd_slow}_{macd_signal_p}' + stoch_k_col = f'STOCHk_{stoch_k}_{stoch_d}_{stoch_smooth}' + stoch_d_col = f'STOCHd_{stoch_k}_{stoch_d}_{stoch_smooth}' - df_d1 = df_d1.join(macd_d1).dropna() - df_h1 = df_h1.join(macd_h1) - df_h1['stoch_k'] = stoch.iloc[:, 0] - df_h1['stoch_d'] = stoch.iloc[:, 1] - df_h1 = df_h1.dropna() + # Hitung indikator dengan parameter dinamis + df_d1.ta.macd(fast=macd_fast, slow=macd_slow, signal=macd_signal_p, append=True) + df_h1.ta.macd(fast=macd_fast, slow=macd_slow, signal=macd_signal_p, append=True) + df_h1.ta.stoch(k=stoch_k, d=stoch_d, smooth_k=stoch_smooth, append=True) + + df_d1.dropna(inplace=True) + df_h1.dropna(inplace=True) if len(df_d1) < 1 or len(df_h1) < 2: return {"signal": "HOLD", "price": None, "explanation": "Data indikator belum matang."} @@ -32,18 +56,13 @@ class MercyEdgeStrategy(BaseStrategy): last_h1 = df_h1.iloc[-1] prev_h1 = df_h1.iloc[-2] + # Gunakan nama kolom dinamis dalam logika ta_suggestion = "HOLD" - if ( - last_d1['hist_d1'] > 0 and last_h1['hist_h1'] > 0 and - last_h1['stoch_k'] > last_h1['stoch_d'] and - prev_h1['stoch_k'] <= prev_h1['stoch_d'] - ): + if (last_d1[macd_hist_col] > 0 and last_h1[macd_hist_col] > 0 and + last_h1[stoch_k_col] > last_h1[stoch_d_col] and prev_h1[stoch_k_col] <= prev_h1[stoch_d_col]): ta_suggestion = "BUY" - elif ( - last_d1['hist_d1'] < 0 and last_h1['hist_h1'] < 0 and - last_h1['stoch_k'] < last_h1['stoch_d'] and - prev_h1['stoch_k'] >= prev_h1['stoch_d'] - ): + elif (last_d1[macd_hist_col] < 0 and last_h1[macd_hist_col] < 0 and + last_h1[stoch_k_col] < last_h1[stoch_d_col] and prev_h1[stoch_k_col] >= prev_h1[stoch_d_col]): ta_suggestion = "SELL" # AI functionality is temporarily disabled. @@ -52,6 +71,6 @@ class MercyEdgeStrategy(BaseStrategy): return { "signal": final_signal, "price": last_h1["close"], "explanation": f"TA: {ta_suggestion} (AI disabled)", - "D1_MACDh": last_d1['hist_d1'], "H1_MACDh": last_h1['hist_h1'], - "H1_STOCHk": last_h1['stoch_k'], "H1_STOCHd": last_h1['stoch_d'] + "D1_MACDh": last_d1[macd_hist_col], "H1_MACDh": last_h1[macd_hist_col], + "H1_STOCHk": last_h1[stoch_k_col], "H1_STOCHd": last_h1[stoch_d_col] } diff --git a/core/strategies/quantumbotx_hybrid.py b/core/strategies/quantumbotx_hybrid.py index adc23b7..e43af73 100644 --- a/core/strategies/quantumbotx_hybrid.py +++ b/core/strategies/quantumbotx_hybrid.py @@ -8,6 +8,18 @@ class QuantumBotXHybridStrategy(BaseStrategy): name = 'QuantumBotX Hybrid' description = 'Strategi eksklusif yang menggabungkan beberapa indikator untuk performa optimal di berbagai kondisi pasar.' + @classmethod + def get_definable_params(cls): + """Mengembalikan parameter yang bisa diatur untuk strategi ini.""" + return [ + {"name": "adx_period", "label": "Periode ADX", "type": "number", "default": 14}, + {"name": "adx_threshold", "label": "Ambang ADX", "type": "number", "default": 25}, + {"name": "ma_fast_period", "label": "Periode MA Cepat", "type": "number", "default": 20}, + {"name": "ma_slow_period", "label": "Periode MA Lambat", "type": "number", "default": 50}, + {"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} + ] + def analyze(self): """ Menganalisis pasar menggunakan strategi Hybrid yang adaptif. @@ -16,21 +28,40 @@ class QuantumBotXHybridStrategy(BaseStrategy): """ tf_const = self.bot.tf_map.get(self.bot.timeframe, mt5.TIMEFRAME_H1) - # Butuh data yang cukup untuk indikator terpanjang (SMA 50) - df = get_rates(self.bot.market_for_mt5, tf_const, 52) + # PERBAIKAN: Minta lebih banyak data. Indikator kompleks seperti ADX + # butuh "pemanasan" lebih lama. 100 bar adalah angka yang lebih aman. + data_points_to_fetch = 100 + required_data_points = 51 # Tetap butuh minimal 51 untuk SMA(50) + df = get_rates(self.bot.market_for_mt5, tf_const, data_points_to_fetch) - if df is None or df.empty or len(df) < 51: - return {"signal": "HOLD", "price": None, "explanation": "Data tidak cukup untuk Hybrid."} + if df is None or df.empty or len(df) < required_data_points: + return {"signal": "HOLD", "price": None, "explanation": f"Data tidak cukup ({len(df) if df is not None else 0}/{required_data_points} bar)."} + + # --- Ambil Parameter Dinamis --- + adx_period = self.params.get('adx_period', 14) + adx_threshold = self.params.get('adx_threshold', 25) + ma_fast_period = self.params.get('ma_fast_period', 20) + ma_slow_period = self.params.get('ma_slow_period', 50) + bb_length = self.params.get('bb_length', 20) + bb_std = self.params.get('bb_std', 2.0) + + # PERBAIKAN: Paksa format float dengan satu desimal pada nama kolom + # untuk mencegah KeyError (misal: 'BBL_20_2' vs 'BBL_20_2.0') + bbu_col = f'BBU_{bb_length}_{bb_std:.1f}' + bbl_col = f'BBL_{bb_length}_{bb_std:.1f}' # --- Hitung SEMUA Indikator yang Dibutuhkan --- - df.ta.adx(length=14, append=True) - df['SMA_20'] = ta.sma(df['close'], length=20) - df['SMA_50'] = ta.sma(df['close'], length=50) - df.ta.bbands(length=20, std=2.0, append=True) + 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) + + # Simpan panjang sebelum dropna untuk debugging + len_before_drop = len(df) df.dropna(inplace=True) if len(df) < 2: - return {"signal": "HOLD", "price": None, "explanation": "Indikator belum matang."} + return {"signal": "HOLD", "price": None, "explanation": f"Indikator belum matang setelah dropna (dari {len_before_drop} menjadi {len(df)} bar)."} last = df.iloc[-1] prev = df.iloc[-2] @@ -40,27 +71,27 @@ class QuantumBotXHybridStrategy(BaseStrategy): market_mode = "N/A" # --- Logika "Wasit Pasar" (ADX) --- - adx_value = last['ADX_14'] + adx_value = last[f'ADX_{adx_period}'] # KONDISI 1: PASAR TRENDING - if adx_value > 25: + if adx_value > adx_threshold: market_mode = "Trending" explanation = f"Mode: {market_mode} (ADX {adx_value:.1f}). Menunggu Crossover." - if prev['SMA_20'] <= prev['SMA_50'] and last['SMA_20'] > last['SMA_50']: + if prev[f'SMA_{ma_fast_period}'] <= prev[f'SMA_{ma_slow_period}'] and last[f'SMA_{ma_fast_period}'] > last[f'SMA_{ma_slow_period}']: signal = "BUY" explanation = f"Mode: {market_mode} (ADX {adx_value:.1f}). Sinyal: Golden Cross." - elif prev['SMA_20'] >= prev['SMA_50'] and last['SMA_20'] < last['SMA_50']: + elif prev[f'SMA_{ma_fast_period}'] >= prev[f'SMA_{ma_slow_period}'] and last[f'SMA_{ma_fast_period}'] < last[f'SMA_{ma_slow_period}']: signal = "SELL" explanation = f"Mode: {market_mode} (ADX {adx_value:.1f}). Sinyal: Death Cross." # KONDISI 2: PASAR SIDEWAYS - elif adx_value < 25: + elif adx_value < adx_threshold: market_mode = "Ranging" explanation = f"Mode: {market_mode} (ADX {adx_value:.1f}). Menunggu pantulan Bands." - if last['low'] <= last['BBL_20_2.0']: + if last['low'] <= last[bbl_col]: signal = "BUY" explanation = f"Mode: {market_mode} (ADX {adx_value:.1f}). Sinyal: Oversold di Band Bawah." - elif last['high'] >= last['BBU_20_2.0']: + elif last['high'] >= last[bbu_col]: signal = "SELL" explanation = f"Mode: {market_mode} (ADX {adx_value:.1f}). Sinyal: Overbought di Band Atas." @@ -69,8 +100,8 @@ class QuantumBotXHybridStrategy(BaseStrategy): "price": price, "explanation": explanation, "Market_Mode": market_mode, - "ADX_14": adx_value, - "SMA_20": last.get('SMA_20'), - "SMA_50": last.get('SMA_50'), + f"ADX_{adx_period}": adx_value, + f"SMA_{ma_fast_period}": last.get(f'SMA_{ma_fast_period}'), + f"SMA_{ma_slow_period}": last.get(f'SMA_{ma_slow_period}'), } return analysis_data \ No newline at end of file diff --git a/core/strategies/rsi_breakout.py b/core/strategies/rsi_breakout.py index 999144c..1f2aab9 100644 --- a/core/strategies/rsi_breakout.py +++ b/core/strategies/rsi_breakout.py @@ -1,41 +1,56 @@ -# core/strategies/rsi_breakout.py +# /core/strategies/rsi_breakout.py import pandas_ta as ta import MetaTrader5 as mt5 from .base_strategy import BaseStrategy -from core.utils.mt5 import get_rates_from_mt5 +from core.data.fetch import get_rates class RSIBreakoutStrategy(BaseStrategy): name = 'RSI Breakout' - description = 'Sinyal berdasarkan level jenuh beli (overbought) dan jenuh jual (oversold) dari Relative Strength Index (RSI).' + description = 'Sinyal berdasarkan RSI yang keluar dari zona jenuh beli (overbought) atau jenuh jual (oversold).' + + @classmethod + def get_definable_params(cls): + """Mengembalikan parameter yang bisa diatur untuk strategi ini.""" + return [ + {"name": "rsi_period", "label": "Periode RSI", "type": "number", "default": 14}, + {"name": "overbought_level", "label": "Level Overbought", "type": "number", "default": 70}, + {"name": "oversold_level", "label": "Level Oversold", "type": "number", "default": 30} + ] def analyze(self): tf_const = self.bot.tf_map.get(self.bot.timeframe, mt5.TIMEFRAME_H1) - df = get_rates_from_mt5(self.bot.market_for_mt5, tf_const, 100) + + rsi_period = self.params.get('rsi_period', 14) + overbought_level = self.params.get('overbought_level', 70) + oversold_level = self.params.get('oversold_level', 30) - if df is None or df.empty or len(df) < 20: - return {"signal": "HOLD", "price": None, "explanation": "Data tidak cukup"} + df = get_rates(self.bot.market_for_mt5, tf_const, rsi_period + 5) - df["RSI"] = ta.rsi(df["close"], length=14) + if df is None or df.empty or len(df) < rsi_period + 2: + return {"signal": "HOLD", "price": None, "explanation": "Data tidak cukup untuk RSI."} + + df['RSI'] = ta.rsi(df['close'], length=rsi_period) df.dropna(inplace=True) - if len(df) < 1: - return {"signal": "HOLD", "price": None, "explanation": "Indikator belum matang."} + if len(df) < 2: + return {"signal": "HOLD", "price": None, "explanation": "Indikator RSI belum matang."} last = df.iloc[-1] + prev = df.iloc[-2] price = last["close"] - rsi = last["RSI"] - signal = "HOLD" - explanation = f"RSI saat ini {rsi:.2f}, dalam zona netral" + explanation = f"RSI ({last['RSI']:.2f}) berada di zona netral." - if rsi > 70: - signal = "SELL" - explanation = f"RSI {rsi:.2f} > 70 (overbought)" - elif rsi < 30: + # Sinyal Beli: RSI melintasi ke atas dari level oversold + if prev['RSI'] < oversold_level and last['RSI'] >= oversold_level: signal = "BUY" - explanation = f"RSI {rsi:.2f} < 30 (oversold)" + explanation = f"RSI Breakout NAIK! RSI ({last['RSI']:.2f}) melintasi ke atas level oversold ({oversold_level})." + # Sinyal Jual: RSI melintasi ke bawah dari level overbought + elif prev['RSI'] > overbought_level and last['RSI'] <= overbought_level: + signal = "SELL" + explanation = f"RSI Breakout TURUN! RSI ({last['RSI']:.2f}) melintasi ke bawah level overbought ({overbought_level})." return { "signal": signal, "price": price, "explanation": explanation, - "rsi": rsi - } + "RSI": last['RSI'], "Overbought_Level": overbought_level, "Oversold_Level": oversold_level + } \ No newline at end of file diff --git a/static/js/bot_detail.js b/static/js/bot_detail.js index d2e441e..e54f178 100644 --- a/static/js/bot_detail.js +++ b/static/js/bot_detail.js @@ -35,16 +35,29 @@ document.addEventListener('DOMContentLoaded', function() { botMarketHeader.textContent = `Pasar: ${botData.market} | Timeframe: ${botData.timeframe}`; botStatusBadge.textContent = botData.status; - // Render Parameter - paramsContainer.innerHTML = ` + // Render Parameter Standar + let paramsHTML = `

Lot Size

${botData.lot_size}

Stop Loss

${botData.sl_pips} pips

Take Profit

${botData.tp_pips} pips

Interval

${botData.check_interval_seconds}s

-

Strategi

${botData.strategy}

`; + + // Render Parameter Strategi Kustom jika ada + const customParams = botData.strategy_params || {}; // Backend sudah mengubahnya menjadi objek + const customParamKeys = Object.keys(customParams); + + if (customParamKeys.length > 0) { + paramsHTML += '

Parameter Strategi

'; + customParamKeys.forEach(key => { + const label = key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); + paramsHTML += `

${label}

${customParams[key]}

`; + }); + paramsHTML += '
'; + } + paramsContainer.innerHTML = paramsHTML; } catch (e) { console.error('Error fetching bot details:', e); diff --git a/static/js/trading_bots.js b/static/js/trading_bots.js index 3102243..73e792f 100644 --- a/static/js/trading_bots.js +++ b/static/js/trading_bots.js @@ -8,11 +8,22 @@ document.addEventListener('DOMContentLoaded', function() { const modalTitle = document.getElementById('modal-title'); const createBotBtn = document.getElementById('create-bot-btn'); const cancelBtn = document.getElementById('cancel-create'); + const paramsContainer = document.getElementById('strategy-params-container'); const strategySelect = document.getElementById('strategy'); let currentBotId = null; // Variabel untuk melacak bot yang sedang diedit // --- Fungsi --- + // Fungsi untuk mengisi nilai parameter strategi saat mengedit bot + function fillStrategyParams(params) { + if (!params) return; + Object.entries(params).forEach(([key, value]) => { + const inputElement = document.getElementById(key); + if (inputElement) { + inputElement.value = value; + } + }); + } // Fungsi untuk memuat daftar strategi ke dalam form async function loadStrategies() { try { @@ -95,6 +106,7 @@ document.addEventListener('DOMContentLoaded', function() { // Buka modal untuk membuat bot baru createBotBtn.addEventListener("click", () => { currentBotId = null; + paramsContainer.innerHTML = ''; // Kosongkan parameter form.reset(); modalTitle.textContent = '๐Ÿš€ Buat Bot Baru'; // Set nilai default @@ -125,6 +137,14 @@ document.addEventListener('DOMContentLoaded', function() { } }); + // Kumpulkan parameter strategi dinamis + const params = {}; + const paramInputs = paramsContainer.querySelectorAll('input'); + paramInputs.forEach(input => { + params[input.name] = parseFloat(input.value) || input.value; + }); + data.params = params; + const url = currentBotId ? `/api/bots/${currentBotId}` : '/api/bots'; const method = currentBotId ? 'PUT' : 'POST'; @@ -170,6 +190,12 @@ document.addEventListener('DOMContentLoaded', function() { form.elements[key].value = bot[key]; } } + // Trigger perubahan strategi untuk memuat dan mengisi parameter + strategySelect.dispatchEvent(new Event('change', { 'bubbles': true })); + // Isi nilai parameter yang sudah ada + if (bot.strategy_params) { + setTimeout(() => fillStrategyParams(bot.strategy_params), 200); // Beri waktu untuk form dibuat + } modal.classList.remove('hidden'); } else { alert(`โŒ Gagal memuat data bot: ${bot.error}`); @@ -212,6 +238,39 @@ document.addEventListener('DOMContentLoaded', function() { } }); + // Event listener untuk dropdown strategi + strategySelect.addEventListener('change', async (e) => { + const strategyId = e.target.value; + paramsContainer.innerHTML = '

Memuat parameter...

'; + if (!strategyId) { + paramsContainer.innerHTML = ''; + return; + } + + try { + const res = await fetch(`/api/strategies/${strategyId}/params`); + const params = await res.json(); + paramsContainer.innerHTML = ''; // Kosongkan lagi + + if (params.length > 0) { + params.forEach(param => { + const paramField = ` +
+ + +
+ `; + paramsContainer.innerHTML += paramField; + }); + } else { + paramsContainer.innerHTML = '

Strategi ini tidak memiliki parameter kustom.

'; + } + } catch (err) { + console.error('Gagal memuat parameter strategi:', err); + paramsContainer.innerHTML = '

Gagal memuat parameter.

'; + } + }); // --- Panggilan Awal --- loadStrategies(); // Muat strategi saat halaman pertama kali dibuka diff --git a/templates/trading_bots.html b/templates/trading_bots.html index dda7e5d..78a220e 100644 --- a/templates/trading_bots.html +++ b/templates/trading_bots.html @@ -83,57 +83,90 @@ - -