Files
chrisnov-it 24e66e39ae Add risk management: spread guard, break-even SL, ATR multiplier cap
- Spread guard: skip entry when spread > 60 pts (3x normal for XM)
- Break-even: auto-move SL to entry price when profit exceeds 1x ATR
- ATR multiplier cap: SL capped at max 5x ATR, TP at max 10x ATR
  (prevents absurd SL/TP from misconfigured bots)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 15:39:44 +08:00

336 lines
17 KiB
Python

# core/bots/trading_bot.py - VERSI GABUNGAN FINAL
import threading
import time
import logging
from datetime import datetime
import MetaTrader5 as mt5
from core.strategies.strategy_map import resolve_strategy_class
from core.mt5.trade import place_trade, close_trade
from core.utils.mt5 import TIMEFRAME_MAP # <-- Impor dari lokasi terpusat
# AI Mentor Integration
from core.db.models import log_trade_for_ai_analysis
# Holiday and market hours management
from core.seasonal.holiday_manager import holiday_manager
from core.strategies.index_optimizations import get_trading_hours, is_index_symbol
logger = logging.getLogger(__name__)
def _to_log_friendly(value):
"""Convert numpy scalars/containers into plain Python types for cleaner logs."""
if isinstance(value, dict):
return {k: _to_log_friendly(v) for k, v in value.items()}
if isinstance(value, list):
return [_to_log_friendly(v) for v in value]
if isinstance(value, tuple):
return tuple(_to_log_friendly(v) for v in value)
# NumPy scalars usually expose .item() to convert into Python scalar.
if hasattr(value, "item") and value.__class__.__module__.startswith("numpy"):
try:
return value.item()
except Exception:
return value
return value
class TradingBot(threading.Thread):
def __init__(self, id, name, market, risk_percent, sl_pips, tp_pips, timeframe, check_interval, strategy, strategy_params={}, status='Dijeda', enable_strategy_switching=False):
super().__init__()
self.id = id
self.name = name
self.market = market
self.risk_percent = risk_percent
self.sl_pips = sl_pips
self.tp_pips = tp_pips
self.timeframe = timeframe
self.check_interval = check_interval
self.strategy_name = strategy
self.strategy_params = strategy_params
self.enable_strategy_switching = enable_strategy_switching
self.market_for_mt5 = None # Akan diisi setelah verifikasi simbol
self.status = status
self.last_analysis = {"signal": "MEMUAT", "explanation": "Bot sedang memulai, menunggu analisis pertama..."}
self._stop_event = threading.Event()
self.strategy_instance = None
# Gunakan map yang diimpor untuk menjaga konsistensi
self.tf_map = TIMEFRAME_MAP
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.", is_notification=True)
# --- PERBAIKAN: Verifikasi Simbol Cerdas ---
from core.utils.mt5 import find_mt5_symbol
self.market_for_mt5 = find_mt5_symbol(self.market)
if not self.market_for_mt5:
msg = f"Simbol '{self.market}' atau variasinya tidak dapat ditemukan/diaktifkan di Market Watch MT5."
self.log_activity('ERROR', msg, is_notification=True)
self.status = 'Error'
self.last_analysis = {"signal": "ERROR", "explanation": msg}
return # Hentikan eksekusi jika simbol tidak valid
try:
strategy_class = resolve_strategy_class(self.strategy_name)
if not strategy_class:
raise ValueError(f"Strategi '{self.strategy_name}' tidak ditemukan.")
# Inisialisasi kelas strategi dengan benar
self.strategy_instance = strategy_class(bot_instance=self, params=self.strategy_params)
except Exception as e:
self.log_activity('ERROR', f"Inisialisasi Gagal: {e}", is_notification=True)
self.status = 'Error'
return
while not self._stop_event.is_set():
try:
# Simbol sudah diverifikasi, jadi pemeriksaan ini menjadi redundan
# if not mt5.symbol_select(self.market_for_mt5, True): ...
symbol_info = mt5.symbol_info(self.market_for_mt5) # type: ignore
if not symbol_info:
msg = f"Tidak dapat mengambil info untuk simbol {self.market_for_mt5}."
self.log_activity('WARNING', msg)
self.last_analysis = {"signal": "ERROR", "price": None, "explanation": msg}
time.sleep(self.check_interval)
continue
# Bot sekarang yang mengambil data
from core.utils.mt5 import get_rates_mt5
tf_const = self.tf_map.get(self.timeframe, mt5.TIMEFRAME_H1)
df = get_rates_mt5(self.market_for_mt5, tf_const, 250)
if df.empty:
msg = f"Gagal mengambil data harga untuk {self.market_for_mt5}. Periksa koneksi atau ketersediaan data historis."
self.log_activity('WARNING', msg)
self.last_analysis = {"signal": "ERROR", "explanation": msg}
time.sleep(self.check_interval)
continue
self.last_analysis = self.strategy_instance.analyze(df)
log_analysis = _to_log_friendly(self.last_analysis)
logger.info(f"Bot {self.id} [{self.strategy_name}] - Last Analysis: {log_analysis}")
signal = self.last_analysis.get("signal", "HOLD")
current_position = self._get_open_position()
# --- Spread Guard (check even without position) ---
try:
symbol_info = mt5.symbol_info(self.market_for_mt5)
if symbol_info and symbol_info.spread > 60:
logger.info(f"Bot {self.id} [{self.strategy_name}] - Spread {symbol_info.spread} terlalu lebar. Skip entry.")
if not current_position:
time.sleep(self.check_interval)
continue
except Exception:
pass
# --- Break-even: move SL to entry when profit > 1x ATR ---
if current_position:
try:
from core.utils.mt5 import get_rates_mt5
atr_df = get_rates_mt5(self.market_for_mt5, self.tf_map.get(self.timeframe, mt5.TIMEFRAME_H1), 20)
if atr_df is not None and len(atr_df) > 14:
import pandas_ta as ta
atr_val = ta.atr(atr_df['high'], atr_df['low'], atr_df['close'], length=14).iloc[-1]
if atr_val and atr_val > 0:
if current_position.type == mt5.ORDER_TYPE_BUY:
tick = mt5.symbol_info_tick(self.market_for_mt5)
if tick:
profit = tick.bid - current_position.price_open
if profit > atr_val and current_position.sl < current_position.price_open:
req = {"action": mt5.TRADE_ACTION_SLTP, "position": current_position.ticket,
"symbol": self.market_for_mt5, "sl": current_position.price_open,
"tp": current_position.tp, "magic": self.id}
mt5.order_send(req)
logger.info(f"Bot {self.id} [{self.strategy_name}] - Break-even BUY di {current_position.price_open}")
elif current_position.type == mt5.ORDER_TYPE_SELL:
tick = mt5.symbol_info_tick(self.market_for_mt5)
if tick:
profit = current_position.price_open - tick.ask
if profit > atr_val and (current_position.sl == 0 or current_position.sl > current_position.price_open):
req = {"action": mt5.TRADE_ACTION_SLTP, "position": current_position.ticket,
"symbol": self.market_for_mt5, "sl": current_position.price_open,
"tp": current_position.tp, "magic": self.id}
mt5.order_send(req)
logger.info(f"Bot {self.id} [{self.strategy_name}] - Break-even SELL di {current_position.price_open}")
except Exception as e:
logger.debug(f"Bot {self.id} - Break-even check non-fatal: {e}")
# Check if market is open before handling trade signal
if self._is_market_open_for_symbol():
self._handle_trade_signal(signal, current_position)
else:
logger.info(f"Bot {self.id} [{self.strategy_name}] - Market is closed for {self.market_for_mt5}. Skipping trade execution.")
self.log_activity('INFO', f"Market closed for {self.market_for_mt5}. Trade execution skipped.", is_notification=False)
time.sleep(self.check_interval)
except Exception as e:
error_message = f"Error pada loop utama: {e}"
self.log_activity('ERROR', error_message, exc_info=True, is_notification=True)
# PERBAIKAN: Perbarui status analisis agar error terlihat di UI
self.last_analysis = {"signal": "ERROR", "explanation": str(e)}
time.sleep(self.check_interval * 2)
self.status = 'Dijeda'
self.log_activity('STOP', f"Bot '{self.name}' dihentikan.", is_notification=True)
def stop(self):
"""Mengirim sinyal berhenti ke thread."""
self._stop_event.set()
def is_stopped(self):
"""Memeriksa apakah thread sudah diberi sinyal berhenti."""
return self._stop_event.is_set()
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, is_notification)
log_message = f"Bot {self.id} [{action}]: {details}"
if exc_info:
logger.error(log_message, exc_info=True)
else:
logger.info(log_message)
except Exception as e:
logger.error(f"Gagal mencatat riwayat untuk bot {self.id}: {e}")
def _get_open_position(self):
"""Mendapatkan posisi terbuka untuk bot ini berdasarkan magic number (ID bot)."""
try:
positions = mt5.positions_get(symbol=self.market_for_mt5) # type: ignore
if positions:
for pos in positions:
if pos.magic == self.id:
return pos
return None
except Exception as e:
self.log_activity('ERROR', f"Gagal mendapatkan posisi terbuka: {e}", exc_info=True, is_notification=True)
return None
def _is_market_open_for_symbol(self):
"""Check if the market is open for the specific symbol"""
try:
# Check if trading is paused due to holidays
if holiday_manager.is_trading_paused():
return False
# Check if it's weekend and symbol is not crypto
current_time = datetime.now()
is_weekend = current_time.weekday() >= 5 # Saturday=5, Sunday=6
# Crypto markets are always open
if "BTC" in self.market_for_mt5 or "ETH" in self.market_for_mt5:
return True
# Stock indices are only open on weekdays during specific hours
if is_index_symbol(self.market_for_mt5):
if is_weekend:
return False
# Check specific trading hours for indices
trading_hours = get_trading_hours(self.market_for_mt5)
if trading_hours:
market_open = trading_hours.get('market_open', '14:30') # UTC
market_close = trading_hours.get('market_close', '21:00') # UTC
# Convert current time to UTC hours and minutes
utc_hour = current_time.hour
utc_minute = current_time.minute
# Parse market hours
open_hour, open_minute = map(int, market_open.split(':'))
close_hour, close_minute = map(int, market_close.split(':'))
# Check if current time is within market hours
current_minutes = utc_hour * 60 + utc_minute
open_minutes = open_hour * 60 + open_minute
close_minutes = close_hour * 60 + close_minute
# Handle overnight sessions
if open_minutes <= close_minutes:
return open_minutes <= current_minutes <= close_minutes
else:
# Market closes next day
return current_minutes >= open_minutes or current_minutes <= close_minutes
# Forex markets are closed on weekends
if any(forex_pair in self.market_for_mt5 for forex_pair in ['EUR', 'GBP', 'USD', 'JPY', 'AUD', 'NZD', 'CAD', 'CHF']):
return not is_weekend
# For other symbols, allow trading unless it's a holiday
return True
except Exception as e:
logger.error(f"Error checking market hours for {self.market_for_mt5}: {e}")
# Default to allowing trading if we can't determine market hours
return True
def _handle_trade_signal(self, signal, position):
"""Menangani sinyal trading: membuka, menutup, atau tidak melakukan apa-apa."""
# Logika untuk sinyal BUY
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.", is_notification=True)
# Log untuk AI mentor analysis
profit_loss = position.profit if hasattr(position, 'profit') else 0
self._log_trade_for_ai_mentor(position, profit_loss, 'CLOSE_SELL')
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.", is_notification=True)
place_trade(self.market_for_mt5, mt5.ORDER_TYPE_BUY, self.risk_percent, self.sl_pips, self.tp_pips, self.id, self.timeframe)
# 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.", is_notification=True)
# Log untuk AI mentor analysis
profit_loss = position.profit if hasattr(position, 'profit') else 0
self._log_trade_for_ai_mentor(position, profit_loss, 'CLOSE_BUY')
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.", is_notification=True)
place_trade(self.market_for_mt5, mt5.ORDER_TYPE_SELL, self.risk_percent, self.sl_pips, self.tp_pips, self.id, self.timeframe)
def _log_trade_for_ai_mentor(self, position, profit_loss, action_type):
"""Log trade data untuk analisis AI mentor"""
try:
# Hitung apakah stop loss dan take profit digunakan
stop_loss_used = hasattr(position, 'sl') and position.sl > 0
take_profit_used = hasattr(position, 'tp') and position.tp > 0
# Log ke database untuk AI analysis
log_trade_for_ai_analysis(
bot_id=self.id,
symbol=self.market_for_mt5 or self.market,
profit_loss=profit_loss,
lot_size=position.volume if hasattr(position, 'volume') else self.risk_percent,
stop_loss_used=stop_loss_used,
take_profit_used=take_profit_used,
risk_percent=self.risk_percent,
strategy_used=self.strategy_name
)
logger.info(f"[AI MENTOR] Trade logged for bot {self.id}: {action_type} {self.market_for_mt5} P/L: ${profit_loss:.2f}")
except Exception as e:
logger.error(f"[AI MENTOR] Failed to log trade for AI analysis: {e}")