feat: Implement Hybrid Strategy & Major Bug Fixes

Successfully run QuantumBotX Hybrid on XAUUSD after fixing critical bugs (KeyError, data fetching). Improved UI with scrollable modal and implemented graceful shutdown for the server. System is now more robust and user-friendly.
This commit is contained in:
Reynov Christian
2025-08-01 01:16:39 +08:00
parent 4fd46202b7
commit dde4438b86
17 changed files with 466 additions and 187 deletions
+21
View File
@@ -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',
+21 -3
View File
@@ -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."
+9 -4
View File
@@ -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
+9 -18
View File
@@ -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
+7 -7
View File
@@ -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:
+22 -1
View File
@@ -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/<strategy_id>/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)
+3 -10
View File
@@ -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/<int:bot_id>/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
+12 -2
View File
@@ -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()`.")
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 []
+23 -8
View File
@@ -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
+40 -17
View File
@@ -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 = {
+17 -5
View File
@@ -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,
+42 -23
View File
@@ -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]
}
+50 -19
View File
@@ -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
+34 -19
View File
@@ -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
}
+16 -3
View File
@@ -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 = `
<div class="grid grid-cols-2 gap-4">
<div><p class="text-gray-500">Lot Size</p><p class="font-semibold text-gray-800">${botData.lot_size}</p></div>
<div><p class="text-gray-500">Stop Loss</p><p class="font-semibold text-gray-800">${botData.sl_pips} pips</p></div>
<div><p class="text-gray-500">Take Profit</p><p class="font-semibold text-gray-800">${botData.tp_pips} pips</p></div>
<div><p class="text-gray-500">Interval</p><p class="font-semibold text-gray-800">${botData.check_interval_seconds}s</p></div>
<div><p class="text-gray-500">Strategi</p><p class="font-semibold text-gray-800">${botData.strategy}</p></div>
</div>
`;
// 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 += '<div class="border-t mt-4 pt-3"><h4 class="text-sm font-semibold text-gray-700 mb-2">Parameter Strategi</h4><div class="grid grid-cols-2 gap-4">';
customParamKeys.forEach(key => {
const label = key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
paramsHTML += `<div><p class="text-gray-500">${label}</p><p class="font-semibold text-gray-800">${customParams[key]}</p></div>`;
});
paramsHTML += '</div></div>';
}
paramsContainer.innerHTML = paramsHTML;
} catch (e) {
console.error('Error fetching bot details:', e);
+59
View File
@@ -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 = '<p class="text-sm text-gray-500">Memuat parameter...</p>';
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 = `
<div class="col-span-1">
<label for="${param.name}" class="block text-sm font-medium text-gray-700">${param.label}</label>
<input type="${param.type || 'number'}" name="${param.name}" id="${param.name}" value="${param.default}" step="${param.step || 'any'}"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
</div>
`;
paramsContainer.innerHTML += paramField;
});
} else {
paramsContainer.innerHTML = '<p class="text-sm text-gray-500 col-span-2">Strategi ini tidak memiliki parameter kustom.</p>';
}
} catch (err) {
console.error('Gagal memuat parameter strategi:', err);
paramsContainer.innerHTML = '<p class="text-sm text-red-500 col-span-2">Gagal memuat parameter.</p>';
}
});
// --- Panggilan Awal ---
loadStrategies(); // Muat strategi saat halaman pertama kali dibuka
+81 -48
View File
@@ -83,57 +83,90 @@
</table>
</div>
</main>
<!-- Modal Elegan Buat Bot -->
<div id="create-bot-modal" class="fixed inset-0 z-50 hidden items-center justify-center bg-black bg-opacity-60 backdrop-blur-sm flex">
<div class="bg-white shadow-2xl rounded-xl p-6 w-full max-w-md animate-fadeIn border border-blue-300">
<h2 id="modal-title" class="text-xl font-bold mb-4 text-gray-800 border-b pb-2">🚀 Buat Bot Baru</h2>
<form id="create-bot-form" class="space-y-4 text-sm">
<div>
<label for="name" class="block text-xs font-medium text-gray-600 mb-1">Nama Bot</label>
<input type="text" id="name" name="name" placeholder="Contoh: Scalper Emas" class="w-full p-2 border rounded focus:ring focus:ring-blue-300" required>
</div>
<div>
<label for="market" class="block text-xs font-medium text-gray-600 mb-1">Pasar</label>
<input type="text" id="market" name="market" placeholder="Contoh: XAU/USD atau BTC/USDT" class="w-full p-2 border rounded focus:ring focus:ring-blue-300" required>
</div>
<div>
<label for="strategy" class="block text-xs font-medium text-gray-600 mb-1">Strategi</label>
<select name="strategy" id="strategy" class="w-full p-2 border rounded focus:ring focus:ring-blue-300">
<!-- Options will be populated by JavaScript -->
</select>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="lot_size" class="block text-xs font-medium text-gray-600 mb-1">Ukuran Lot</label>
<input type="number" id="lot_size" name="lot_size" placeholder="0.01" step="0.01" value="0.01" class="w-full p-2 border rounded focus:ring focus:ring-blue-300" required>
</div>
<div>
<label for="timeframe" class="block text-xs font-medium text-gray-600 mb-1">Timeframe</label>
<input type="text" id="timeframe" name="timeframe" placeholder="H1" value="H1" class="w-full p-2 border rounded focus:ring focus:ring-blue-300" required>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="sl_pips" class="block text-xs font-medium text-gray-600 mb-1">Stop Loss (pips)</label>
<input type="number" id="sl_pips" name="sl_pips" placeholder="100" value="100" class="w-full p-2 border rounded focus:ring focus:ring-blue-300" required>
</div>
<div>
<label for="tp_pips" class="block text-xs font-medium text-gray-600 mb-1">Take Profit (pips)</label>
<input type="number" id="tp_pips" name="tp_pips" placeholder="200" value="200" class="w-full p-2 border rounded focus:ring focus:ring-blue-300" required>
</div>
</div>
<div>
<label for="check_interval_seconds" class="block text-xs font-medium text-gray-600 mb-1">Interval Cek (detik)</label>
<input type="number" id="check_interval_seconds" name="check_interval_seconds" placeholder="60" value="60" class="w-full p-2 border rounded focus:ring focus:ring-blue-300" required>
</div>
<div class="flex justify-end gap-3 pt-2">
<button type="button" id="cancel-create" class="bg-gray-200 hover:bg-gray-300 text-sm px-4 py-2 rounded">Batal</button>
<button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white text-sm px-4 py-2 rounded shadow-lg">Buat Bot</button>
<!-- Modal untuk Membuat/Mengedit Bot -->
<div id="create-bot-modal" class="hidden fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full flex items-center justify-center z-50 p-4">
<!-- PERBAIKAN: Panel modal kini menggunakan flexbox vertikal dan tinggi maksimal -->
<div class="relative bg-white rounded-lg shadow-xl w-full max-w-2xl flex flex-col max-h-[90vh]">
<!-- Header Modal (Tidak akan ikut ter-scroll) -->
<div class="flex-shrink-0 flex items-start justify-between p-5 border-b rounded-t">
<h3 id="modal-title" class="text-xl font-semibold text-gray-900">
🚀 Buat Bot Baru
</h3>
<button type="button" id="cancel-create" class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 ml-auto inline-flex items-center">
<i class="fas fa-times"></i>
<span class="sr-only">Tutup modal</span>
</button>
</div>
<!-- Form (Area ini yang akan bisa di-scroll) -->
<form id="create-bot-form" class="flex-grow overflow-y-auto">
<div class="p-6 space-y-6">
<!-- Baris 1: Nama & Pasar -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="name" class="block mb-2 text-sm font-medium text-gray-900">Nama Bot</label>
<input type="text" name="name" id="name" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5" placeholder="Contoh: XAUUSD Hybrid H1" required>
</div>
<div>
<label for="market" class="block mb-2 text-sm font-medium text-gray-900">Pasar</label>
<input type="text" name="market" id="market" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5" placeholder="Contoh: XAUUSD atau EURUSD" required>
</div>
</div>
<!-- Baris 2: Lot, SL, TP -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<label for="lot_size" class="block mb-2 text-sm font-medium text-gray-900">Ukuran Lot</label>
<input type="number" name="lot_size" id="lot_size" value="0.01" step="0.01" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5" required>
</div>
<div>
<label for="sl_pips" class="block mb-2 text-sm font-medium text-gray-900">Stop Loss (pips)</label>
<input type="number" name="sl_pips" id="sl_pips" value="100" step="1" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5" required>
</div>
<div>
<label for="tp_pips" class="block mb-2 text-sm font-medium text-gray-900">Take Profit (pips)</label>
<input type="number" name="tp_pips" id="tp_pips" value="200" step="1" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5" required>
</div>
</div>
<!-- Baris 3: Timeframe & Interval -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="timeframe" class="block mb-2 text-sm font-medium text-gray-900">Timeframe</label>
<select id="timeframe" name="timeframe" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5">
<option value="M1">1 Menit</option>
<option value="M5">5 Menit</option>
<option value="M15">15 Menit</option>
<option value="M30">30 Menit</option>
<option value="H1" selected>1 Jam</option>
<option value="H4">4 Jam</option>
<option value="D1">1 Hari</option>
</select>
</div>
<div>
<label for="check_interval_seconds" class="block mb-2 text-sm font-medium text-gray-900">Interval Cek (detik)</label>
<input type="number" name="check_interval_seconds" id="check_interval_seconds" value="60" step="1" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5" required>
</div>
</div>
<!-- Baris 4: Strategi & Parameternya -->
<div>
<label for="strategy" class="block mb-2 text-sm font-medium text-gray-900">Strategi</label>
<select id="strategy" name="strategy" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5" required>
<option value="" disabled selected>Pilih sebuah strategi</option>
</select>
</div>
<div id="strategy-params-container" class="grid grid-cols-1 md:grid-cols-2 gap-6 pt-4 border-t">
<!-- Parameter strategi akan dimuat di sini oleh JavaScript -->
</div>
</div>
</form>
<!-- Footer Modal (Tidak akan ikut ter-scroll) -->
<div class="flex-shrink-0 flex items-center p-6 space-x-2 border-t border-gray-200 rounded-b">
<button type="submit" form="create-bot-form" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center">Buat Bot</button>
<button type="button" id="cancel-create-footer" class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-blue-300 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10">Batal</button>
</div>
</div>
</div>
</div>
<script src="{{ url_for('static', filename='js/trading_bots.js') }}"></script>
</body>