706 lines
No EOL
37 KiB
Python
706 lines
No EOL
37 KiB
Python
import MetaTrader5 as mt5
|
|
import pandas as pd
|
|
import pytz
|
|
import time as sleep_timer
|
|
from datetime import datetime, timedelta, time
|
|
import logging
|
|
import json
|
|
import requests
|
|
import sqlite3
|
|
import threading
|
|
|
|
# ==============================================================================
|
|
# == CONFIGURATION & LOGGING
|
|
# ==============================================================================
|
|
def load_config(filename='config.json'):
|
|
try:
|
|
with open(filename, 'r') as f: return json.load(f)
|
|
except FileNotFoundError:
|
|
logging.critical(f"CRITICAL: Config file '{filename}' not found.")
|
|
quit()
|
|
except json.JSONDecodeError:
|
|
logging.critical(f"CRITICAL: Config file '{filename}' is not valid JSON.")
|
|
quit()
|
|
|
|
def setup_logging():
|
|
if logging.getLogger().hasHandlers(): return
|
|
class ESTFormatter(logging.Formatter):
|
|
def formatTime(self, record, datefmt=None):
|
|
dt = datetime.fromtimestamp(record.created, tz=pytz.timezone('America/New_York'))
|
|
if datefmt: return dt.strftime(datefmt)
|
|
return dt.isoformat()
|
|
logger = logging.getLogger()
|
|
logger.setLevel(logging.INFO)
|
|
formatter = ESTFormatter('%(asctime)s - %(levelname)s - %(message)s', datefmt='%Y-%m-%d %H:%M:%S')
|
|
console_handler = logging.StreamHandler()
|
|
console_handler.setFormatter(formatter)
|
|
file_handler = logging.FileHandler('trading_bot.log', encoding='utf-8')
|
|
file_handler.setFormatter(formatter)
|
|
logger.addHandler(console_handler)
|
|
logger.addHandler(file_handler)
|
|
|
|
# ==============================================================================
|
|
# == STATE MANAGER CLASS (SQLite VERSION)
|
|
# ==============================================================================
|
|
class StateManager:
|
|
def __init__(self, db_file='bot_state.db'):
|
|
self.db_file = db_file
|
|
self.conn = sqlite3.connect(self.db_file, detect_types=sqlite3.PARSE_DECLTYPES | sqlite3.PARSE_COLNAMES, check_same_thread=False)
|
|
self._create_tables()
|
|
|
|
def _create_tables(self):
|
|
cursor = self.conn.cursor()
|
|
cursor.execute("CREATE TABLE IF NOT EXISTS traded_setups (id INTEGER PRIMARY KEY AUTOINCREMENT, setup_id TEXT UNIQUE NOT NULL)")
|
|
cursor.execute("CREATE TABLE IF NOT EXISTS pending_orders (ticket INTEGER PRIMARY KEY, symbol TEXT NOT NULL, expiry_time TEXT NOT NULL)")
|
|
cursor.execute('''
|
|
CREATE TABLE IF NOT EXISTS trades (
|
|
ticket INTEGER PRIMARY KEY, symbol TEXT NOT NULL, type TEXT NOT NULL,
|
|
volume REAL NOT NULL, open_time TIMESTAMP NOT NULL, open_price REAL NOT NULL,
|
|
sl REAL NOT NULL, tp REAL NOT NULL, close_time TIMESTAMP, close_price REAL,
|
|
profit REAL, partials_taken INTEGER DEFAULT 0
|
|
)''')
|
|
cursor.execute("CREATE TABLE IF NOT EXISTS bot_settings (key TEXT PRIMARY KEY, value TEXT)")
|
|
self.conn.commit()
|
|
|
|
def get_setting(self, key):
|
|
cursor = self.conn.cursor()
|
|
cursor.execute("SELECT value FROM bot_settings WHERE key = ?", (key,))
|
|
row = cursor.fetchone()
|
|
return row[0] if row else None
|
|
|
|
def set_setting(self, key, value):
|
|
cursor = self.conn.cursor()
|
|
cursor.execute("INSERT OR REPLACE INTO bot_settings (key, value) VALUES (?, ?)", (key, value))
|
|
self.conn.commit()
|
|
|
|
def get_performance_report(self):
|
|
try:
|
|
df = pd.read_sql_query("SELECT * FROM trades WHERE close_time IS NOT NULL", self.conn)
|
|
if df.empty: return None
|
|
total_trades, wins, losses = len(df), len(df[df['profit'] > 0.01]), len(df[df['profit'] < -0.01])
|
|
breakevens = total_trades - wins - losses
|
|
win_rate = (wins / (wins + losses) * 100) if (wins + losses) > 0 else 0
|
|
total_profit, gross_profit, gross_loss = df['profit'].sum(), df[df['profit'] > 0]['profit'].sum(), abs(df[df['profit'] < 0]['profit'].sum())
|
|
profit_factor = gross_profit / gross_loss if gross_loss > 0 else float('inf')
|
|
return {"total_trades": total_trades, "wins": wins, "losses": losses, "breakevens": breakevens, "win_rate": f"{win_rate:.2f}%", "net_profit": f"${total_profit:,.2f}", "profit_factor": f"{profit_factor:.2f}"}
|
|
except Exception as e:
|
|
logging.error(f"Error generating performance report: {e}")
|
|
return None
|
|
|
|
def get_open_trade_tickets(self):
|
|
cursor = self.conn.cursor()
|
|
cursor.execute("SELECT ticket FROM trades WHERE close_time IS NULL")
|
|
return [row[0] for row in cursor.fetchall()]
|
|
|
|
def log_new_trade(self, pos):
|
|
cursor = self.conn.cursor()
|
|
trade_type = "BUY" if pos.type == mt5.ORDER_TYPE_BUY else "SELL"
|
|
cursor.execute("INSERT OR IGNORE INTO trades (ticket, symbol, type, volume, open_time, open_price, sl, tp) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
(pos.ticket, pos.symbol, trade_type, pos.volume, datetime.fromtimestamp(pos.time, tz=pytz.utc), pos.price_open, pos.sl, pos.tp))
|
|
self.conn.commit()
|
|
|
|
def update_trade_partials_taken(self, ticket):
|
|
cursor = self.conn.cursor()
|
|
cursor.execute("UPDATE trades SET partials_taken = 1 WHERE ticket = ?", (ticket,))
|
|
self.conn.commit()
|
|
|
|
def update_closed_trade(self, ticket, close_time, close_price, profit):
|
|
cursor = self.conn.cursor()
|
|
cursor.execute("UPDATE trades SET close_time = ?, close_price = ?, profit = ? WHERE ticket = ?",
|
|
(close_time, close_price, profit, ticket))
|
|
self.conn.commit()
|
|
|
|
def get_trade_info(self, ticket):
|
|
cursor = self.conn.cursor()
|
|
cursor.execute("SELECT partials_taken FROM trades WHERE ticket = ?", (ticket,))
|
|
row = cursor.fetchone()
|
|
if row: return {'partials_taken': row[0]}
|
|
return None
|
|
|
|
def get_traded_setups(self):
|
|
cursor = self.conn.cursor()
|
|
cursor.execute("SELECT setup_id FROM traded_setups")
|
|
return [row[0] for row in cursor.fetchall()]
|
|
|
|
def add_traded_setup(self, setup_id):
|
|
cursor = self.conn.cursor()
|
|
cursor.execute("INSERT OR IGNORE INTO traded_setups (setup_id) VALUES (?)", (setup_id,))
|
|
cursor.execute("DELETE FROM traded_setups WHERE id NOT IN (SELECT id FROM traded_setups ORDER BY id DESC LIMIT 10)")
|
|
self.conn.commit()
|
|
|
|
def get_pending_orders(self):
|
|
cursor = self.conn.cursor()
|
|
cursor.execute("SELECT ticket, symbol, expiry_time FROM pending_orders")
|
|
orders = {}
|
|
ny_tz = pytz.timezone('America/New_York')
|
|
for row in cursor.fetchall():
|
|
try:
|
|
expiry_dt_naive = datetime.fromisoformat(row[2])
|
|
orders[str(row[0])] = {"symbol": row[1], "expiry_time": ny_tz.localize(expiry_dt_naive)}
|
|
except (ValueError, TypeError):
|
|
logging.error(f"Could not parse expiry time '{row[2]}' for ticket {row[0]}")
|
|
return orders
|
|
|
|
def add_pending_order(self, ticket, symbol, expiry_time):
|
|
cursor = self.conn.cursor()
|
|
cursor.execute("INSERT OR IGNORE INTO pending_orders (ticket, symbol, expiry_time) VALUES (?, ?, ?)",
|
|
(ticket, symbol, expiry_time.strftime('%Y-%m-%dT%H:%M:%S')))
|
|
self.conn.commit()
|
|
|
|
def remove_pending_order(self, ticket):
|
|
cursor = self.conn.cursor()
|
|
cursor.execute("DELETE FROM pending_orders WHERE ticket = ?", (int(ticket),))
|
|
self.conn.commit()
|
|
|
|
def close_connection(self):
|
|
self.conn.close()
|
|
logging.info("\nDatabase connection closed.")
|
|
|
|
# ==============================================================================
|
|
# == STRATEGY CLASS
|
|
# ==============================================================================
|
|
class BreakerStrategy:
|
|
def __init__(self, config):
|
|
self.config = config
|
|
|
|
def _find_swing_points(self, df):
|
|
df = df.copy()
|
|
df['is_swing_high'] = (df['High'] > df['High'].shift(1)) & (df['High'] > df['High'].shift(-1)) & (df['High'] > df['High'].shift(-2))
|
|
df['is_swing_low'] = (df['Low'] < df['Low'].shift(1)) & (df['Low'] < df['Low'].shift(-1)) & (df['Low'] < df['Low'].shift(-2))
|
|
return df
|
|
|
|
def find_htf_zones(self, h1_df):
|
|
h1_df_recent = h1_df.tail(20)
|
|
zones = []
|
|
df_swing = self._find_swing_points(h1_df_recent.copy())
|
|
|
|
for i in range(4, len(df_swing) - 1):
|
|
c1, c2, c3, v_candle = df_swing.iloc[i-4], df_swing.iloc[i-3], df_swing.iloc[i-2], df_swing.iloc[i-1]
|
|
ob_candle = None
|
|
if c2['Close']>c2['Open'] and c3['Close']<c3['Open']: ob_candle=c3
|
|
elif c2['Close']<c2['Open'] and c3['Close']<c3['Open'] and c2['is_swing_high']: ob_candle=c3
|
|
if ob_candle is not None and v_candle['Close']>ob_candle['Open']:
|
|
zones.append({'type': 'Bullish OB', 'time': v_candle.name, 'low': ob_candle['Low'], 'high': ob_candle['High']})
|
|
ob_candle=None
|
|
if c2['Close']<c2['Open'] and c3['Close']>c3['Open']: ob_candle=c3
|
|
elif c2['Close']>c2['Open'] and c3['Close']>c3['Open'] and c2['is_swing_low']: ob_candle=c3
|
|
if ob_candle is not None and v_candle['Close']<ob_candle['Open']:
|
|
zones.append({'type': 'Bearish OB', 'time': v_candle.name, 'low': ob_candle['Low'], 'high': ob_candle['High']})
|
|
|
|
for i in range(2, len(h1_df_recent) - 1):
|
|
c1, _, c3 = h1_df_recent.iloc[i-2], h1_df_recent.iloc[i-1], h1_df_recent.iloc[i]
|
|
if c3['Low']>c1['High']:
|
|
zones.append({'type': 'Bullish FVG', 'time': c3.name, 'low': c1['High'], 'high': c3['Low']})
|
|
if c3['High']<c1['Low']:
|
|
zones.append({'type': 'Bearish FVG', 'time': c3.name, 'low': c3['High'], 'high': c1['Low']})
|
|
return zones
|
|
|
|
def _find_breaker_setup(self, df_slice):
|
|
df_with_swings = self._find_swing_points(df_slice)
|
|
swing_highs = df_with_swings[df_with_swings['is_swing_high']]
|
|
swing_lows = df_with_swings[df_with_swings['is_swing_low']]
|
|
|
|
for i in range(len(swing_highs)): # Bearish
|
|
pointA = swing_highs.iloc[i]
|
|
potential_Bs = swing_lows[swing_lows.index > pointA.name]
|
|
if not potential_Bs.empty:
|
|
pointB = potential_Bs.iloc[0]
|
|
breaker_range = df_with_swings.loc[pointA.name:pointB.name]
|
|
down_candles = breaker_range[breaker_range['Close'] < breaker_range['Open']]
|
|
if down_candles.empty: continue
|
|
breaker_candle = down_candles.loc[down_candles['Low'].idxmin()]
|
|
sweep_candidates = df_slice[(df_slice.index > pointB.name) & (df_slice['High'] > pointA.High)]
|
|
if not sweep_candidates.empty:
|
|
pointC = sweep_candidates.iloc[0]
|
|
if not df_with_swings.loc[pointB.name:pointC.name].iloc[1:-1]['is_swing_high'].any():
|
|
confirmation_window = df_slice[df_slice.index > pointC.name].iloc[:2]
|
|
if not confirmation_window.empty and confirmation_window.iloc[0]['Close'] < breaker_candle.Low:
|
|
pointD = confirmation_window.iloc[0]
|
|
if not breaker_candle.is_swing_high:
|
|
return {'type': 'Bearish', 'breaker_candle': breaker_candle, 'pointA': pointA, 'pointB': pointB, 'pointC': pointC, 'pointD': pointD}
|
|
for i in range(len(swing_lows)): # Bullish
|
|
pointA = swing_lows.iloc[i]
|
|
potential_Bs = swing_highs[swing_highs.index > pointA.name]
|
|
if not potential_Bs.empty:
|
|
pointB = potential_Bs.iloc[0]
|
|
breaker_range = df_with_swings.loc[pointA.name:pointB.name]
|
|
up_candles = breaker_range[breaker_range['Close'] > breaker_range['Open']]
|
|
if up_candles.empty: continue
|
|
breaker_candle = up_candles.loc[up_candles['High'].idxmax()]
|
|
sweep_candidates = df_slice[(df_slice.index > pointB.name) & (df_slice['Low'] < pointA.Low)]
|
|
if not sweep_candidates.empty:
|
|
pointC = sweep_candidates.iloc[0]
|
|
if not df_with_swings.loc[pointB.name:pointC.name].iloc[1:-1]['is_swing_low'].any():
|
|
confirmation_window = df_slice[df_slice.index > pointC.name].iloc[:2]
|
|
if not confirmation_window.empty and confirmation_window.iloc[0]['Close'] > breaker_candle.High:
|
|
pointD = confirmation_window.iloc[0]
|
|
if not breaker_candle.is_swing_low:
|
|
return {'type': 'Bullish', 'breaker_candle': breaker_candle, 'pointA': pointA, 'pointB': pointB, 'pointC': pointC, 'pointD': pointD}
|
|
return None
|
|
|
|
def find_setups(self, all_tf_data):
|
|
search_end_time = all_tf_data[1].index[-1]
|
|
search_start_time = search_end_time - timedelta(minutes=120)
|
|
for tf in [5, 1]:
|
|
df = all_tf_data.get(tf)
|
|
if df is None: continue
|
|
analysis_slice = df.loc[search_start_time:search_end_time]
|
|
if analysis_slice.empty or len(analysis_slice) < 5: continue
|
|
breaker_setup = self._find_breaker_setup(analysis_slice)
|
|
if breaker_setup:
|
|
pointD_time = breaker_setup['pointD'].name
|
|
if pointD_time >= df.index[-3]:
|
|
return breaker_setup, tf
|
|
return None, None
|
|
|
|
# ==============================================================================
|
|
# == MT5 HANDLER CLASS
|
|
# ==============================================================================
|
|
class MT5Handler:
|
|
def __init__(self, config):
|
|
self.config = config
|
|
self.magic_number = self.config.get('magic_number')
|
|
|
|
def connect(self):
|
|
if not mt5.initialize():
|
|
logging.critical(f"initialize() failed, error code = {mt5.last_error()}")
|
|
return False
|
|
logging.info("✅ MT5 Bot Connected!")
|
|
return True
|
|
|
|
def disconnect(self):
|
|
mt5.shutdown()
|
|
logging.info("\n🔌 Bot stopped and disconnected.")
|
|
|
|
def check_connection(self):
|
|
if not mt5.terminal_info():
|
|
logging.warning("\nMT5 connection lost. Attempting to reconnect...")
|
|
mt5.shutdown()
|
|
sleep_timer.sleep(10)
|
|
if self.connect():
|
|
logging.info("\nSuccessfully reconnected to MT5.")
|
|
return True
|
|
else:
|
|
logging.error("\nFailed to reconnect to MT5. Will retry next cycle.")
|
|
return False
|
|
return True
|
|
|
|
def get_all_timeframe_data(self, symbol):
|
|
all_tf_data = {}
|
|
timeframe_map = {1: mt5.TIMEFRAME_M1, 2: mt5.TIMEFRAME_M2, 3: mt5.TIMEFRAME_M3, 5: mt5.TIMEFRAME_M5, 60: mt5.TIMEFRAME_H1}
|
|
for tf_int, tf_mt5 in timeframe_map.items():
|
|
num_candles = 500 if tf_int == 60 else 300
|
|
df = self._get_candles(symbol, tf_mt5, num_candles)
|
|
if df is None or df.empty:
|
|
logging.warning(f"Could not fetch M{tf_int} data for {symbol}.")
|
|
return None
|
|
all_tf_data[tf_int] = df
|
|
return all_tf_data
|
|
|
|
def _get_candles(self, symbol, timeframe, num_candles):
|
|
rates = mt5.copy_rates_from_pos(symbol, timeframe, 0, num_candles)
|
|
if rates is None: return None
|
|
df = pd.DataFrame(rates)
|
|
df['time'] = pd.to_datetime(df['time'], unit='s')
|
|
df.set_index('time', inplace=True)
|
|
df.index = df.index.tz_localize('Europe/Athens').tz_convert('America/New_York')
|
|
df.rename(columns={'open': 'Open', 'high': 'High', 'low': 'Low', 'close': 'Close'}, inplace=True)
|
|
return df
|
|
|
|
def place_pending_order(self, symbol, order_type, volume, entry_price, sl_price, tp_price):
|
|
symbol_info = mt5.symbol_info(symbol)
|
|
if symbol_info is None:
|
|
logging.error(f"Failed to get symbol info for {symbol}.")
|
|
return None
|
|
point = symbol_info.point
|
|
stops_level = symbol_info.trade_stops_level
|
|
min_distance = stops_level * point
|
|
if abs(entry_price - sl_price) < min_distance or abs(entry_price - tp_price) < min_distance:
|
|
logging.warning(f"\nSL/TP for {symbol} is too close to entry price (Broker minimum: {min_distance}). Trade skipped.")
|
|
return None
|
|
request = {
|
|
"action": mt5.TRADE_ACTION_PENDING, "symbol": symbol, "volume": volume,
|
|
"type": order_type, "price": entry_price, "sl": sl_price, "tp": tp_price,
|
|
"magic": self.magic_number, "comment": "BreakerBot v3.2",
|
|
"type_time": mt5.ORDER_TIME_GTC, "type_filling": mt5.ORDER_FILLING_FOK,
|
|
}
|
|
result = mt5.order_send(request)
|
|
if result.retcode != mt5.TRADE_RETCODE_DONE:
|
|
logging.warning(f"\nPending order failed for {symbol}: {result.comment}")
|
|
else:
|
|
logging.info(f"\n✅ Pending order placed for {symbol}! Order #{result.order}")
|
|
return result
|
|
|
|
def place_partial_close_order(self, position, volume_to_close):
|
|
if position.type == mt5.ORDER_TYPE_BUY:
|
|
order_type, price = mt5.ORDER_TYPE_SELL, mt5.symbol_info_tick(position.symbol).bid
|
|
else:
|
|
order_type, price = mt5.ORDER_TYPE_BUY, mt5.symbol_info_tick(position.symbol).ask
|
|
request = {
|
|
"action": mt5.TRADE_ACTION_DEAL, "position": position.ticket, "symbol": position.symbol,
|
|
"volume": volume_to_close, "type": order_type, "price": price,
|
|
"magic": self.magic_number, "comment": "Partial Take Profit"
|
|
}
|
|
result = mt5.order_send(request)
|
|
if result and result.retcode == mt5.TRADE_RETCODE_DONE:
|
|
logging.info(f"\n✅ Successfully closed {volume_to_close} lots of position #{position.ticket}.")
|
|
return True
|
|
else:
|
|
logging.error(f"\n❌ Failed to close partial volume for position #{position.ticket}: {result.comment if result else 'No result'}")
|
|
return False
|
|
|
|
def modify_position_sl(self, ticket, new_sl):
|
|
request = {"action": mt5.TRADE_ACTION_SLTP, "position": ticket, "sl": new_sl}
|
|
result = mt5.order_send(request)
|
|
if result and result.retcode == mt5.TRADE_RETCODE_DONE:
|
|
logging.info(f"\n✅ Successfully moved SL for position #{ticket}.")
|
|
return True
|
|
else:
|
|
logging.error(f"\n❌ Failed to modify SL for position #{ticket}: {result.comment if result else 'No result'}")
|
|
return False
|
|
|
|
def manage_open_trades(self, state_manager):
|
|
positions = mt5.positions_get(magic=self.magic_number)
|
|
if not positions: return
|
|
for pos in positions:
|
|
trade_info = state_manager.get_trade_info(pos.ticket)
|
|
if trade_info and trade_info['partials_taken'] == 1:
|
|
continue
|
|
initial_risk = abs(pos.price_open - pos.sl)
|
|
if initial_risk == 0: continue
|
|
if pos.type == mt5.ORDER_TYPE_BUY:
|
|
breakeven_trigger_price = pos.price_open + (initial_risk * 2)
|
|
if mt5.symbol_info_tick(pos.symbol).bid >= breakeven_trigger_price:
|
|
self.handle_partial_tp_and_be(pos, state_manager)
|
|
elif pos.type == mt5.ORDER_TYPE_SELL:
|
|
breakeven_trigger_price = pos.price_open - (initial_risk * 2)
|
|
if mt5.symbol_info_tick(pos.symbol).ask <= breakeven_trigger_price:
|
|
self.handle_partial_tp_and_be(pos, state_manager)
|
|
|
|
def handle_partial_tp_and_be(self, position, state_manager):
|
|
logging.info(f"\nPosition #{position.ticket} reached 2R. Taking partials and moving to BE.")
|
|
symbol_info = mt5.symbol_info(position.symbol)
|
|
partial_volume = round(position.volume * 0.3, 2)
|
|
if partial_volume < symbol_info.volume_min: partial_volume = symbol_info.volume_min
|
|
partial_volume = round(partial_volume / symbol_info.volume_step) * symbol_info.volume_step
|
|
|
|
if partial_volume >= position.volume:
|
|
logging.warning("\nPartial volume is >= position volume. Moving to BE only.")
|
|
if self.modify_position_sl(position.ticket, position.price_open):
|
|
state_manager.update_trade_partials_taken(position.ticket)
|
|
message = f"📈 *Stop Loss Moved to Breakeven*\nSymbol: `{position.symbol}`\nPosition: `#{position.ticket}`"
|
|
send_telegram_alert(message, self.config)
|
|
else:
|
|
if self.place_partial_close_order(position, partial_volume):
|
|
self.modify_position_sl(position.ticket, position.price_open)
|
|
state_manager.update_trade_partials_taken(position.ticket)
|
|
message = f"💰 *Partial Profit Taken & BE*\nSymbol: `{position.symbol}`\nClosed: `{partial_volume}` lots\nPosition: `#{position.ticket}`"
|
|
send_telegram_alert(message, self.config)
|
|
|
|
def manage_pending_orders(self, state_manager):
|
|
pending_orders = state_manager.get_pending_orders()
|
|
if not pending_orders: return
|
|
now_ny = datetime.now(pytz.timezone('America/New_York'))
|
|
for ticket, order_data in list(pending_orders.items()):
|
|
if now_ny > order_data['expiry_time']:
|
|
logging.info(f"\nPending order #{ticket} for {order_data['symbol']} has expired. Attempting to cancel.")
|
|
request = {"action": mt5.TRADE_ACTION_REMOVE, "order": int(ticket)}
|
|
result = mt5.order_send(request)
|
|
if result.retcode == mt5.TRADE_RETCODE_DONE:
|
|
logging.info(f"\n✅ Successfully cancelled expired order #{ticket}.")
|
|
state_manager.remove_pending_order(ticket)
|
|
else:
|
|
if not mt5.orders_get(ticket=int(ticket)):
|
|
logging.info(f"\nOrder #{ticket} no longer exists. Removing from tracking.")
|
|
state_manager.remove_pending_order(ticket)
|
|
else:
|
|
logging.error(f"\nFailed to cancel expired order #{ticket}. Reason: {result.comment}")
|
|
|
|
# ==============================================================================
|
|
# == HELPER FUNCTIONS
|
|
# ==============================================================================
|
|
def send_telegram_alert(message, config):
|
|
bot_token = config.get('telegram_bot_token')
|
|
chat_id = config.get('telegram_chat_id')
|
|
if not bot_token or not chat_id or 'YOUR_BOT_TOKEN' in bot_token: return
|
|
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
|
|
payload = {'chat_id': chat_id, 'text': message, 'parse_mode': 'Markdown'}
|
|
try:
|
|
response = requests.post(url, json=payload, timeout=10)
|
|
if response.status_code != 200:
|
|
logging.error(f"Failed to send Telegram alert: {response.text}")
|
|
except Exception as e:
|
|
logging.error(f"Exception while sending Telegram alert: {e}")
|
|
|
|
def format_breaker_details(breaker_setup):
|
|
bc, pointA, pointB, pointC, pointD = breaker_setup['breaker_candle'], breaker_setup['pointA'], breaker_setup['pointB'], breaker_setup['pointC'], breaker_setup['pointD']
|
|
a_marker, b_marker = ("-", "+") if breaker_setup['type'] == 'Bullish' else ("+", "-")
|
|
c_marker = "+" if pointC['Close'] > pointC['Open'] else "-"
|
|
details = (
|
|
f"*Breaker Time:* `{bc.name.strftime('%H:%M:%S')}`\n"
|
|
f"*Breaker Candle:* `[O:{bc['Open']:.5f} H:{bc['High']:.5f} L:{bc['Low']:.5f} C:{bc['Close']:.5f}]`\n"
|
|
f"*Point A{a_marker}:* `{pointA.name.strftime('%H:%M:%S')}`\n"
|
|
f"*Point B{b_marker}:* `{pointB.name.strftime('%H:%M:%S')}`\n"
|
|
f"*Point C (Sweep){c_marker}:* `{pointC.name.strftime('%H:%M:%S')}`\n"
|
|
f"*Point D (Confirmation):* `{pointD.name.strftime('%H:%M:%S')}`\n"
|
|
f"*Confirmation Candle:* `[O:{pointD['Open']:.5f} H:{pointD['High']:.5f} L:{pointD['Low']:.5f} C:{pointD['Close']:.5f}]`"
|
|
)
|
|
return details
|
|
|
|
def calculate_dynamic_lot_size(symbol, risk_percent, sl_price, entry_price):
|
|
try:
|
|
account_info = mt5.account_info()
|
|
if account_info is None: return None
|
|
balance = account_info.balance
|
|
risk_amount_account_currency = balance * risk_percent
|
|
sl_distance_price = abs(entry_price - sl_price)
|
|
if sl_distance_price == 0: return None
|
|
symbol_info = mt5.symbol_info(symbol)
|
|
if symbol_info is None: return None
|
|
quote_currency = symbol_info.currency_profit
|
|
account_currency = account_info.currency
|
|
conversion_rate = 1.0
|
|
if quote_currency != account_currency:
|
|
pair_forward, pair_backward = f"{quote_currency}{account_currency}", f"{account_currency}{quote_currency}"
|
|
tick_forward = mt5.symbol_info_tick(pair_forward)
|
|
if tick_forward and tick_forward.ask != 0:
|
|
conversion_rate = tick_forward.ask
|
|
else:
|
|
tick_backward = mt5.symbol_info_tick(pair_backward)
|
|
if tick_backward and tick_backward.bid != 0:
|
|
conversion_rate = 1 / tick_backward.bid
|
|
else:
|
|
logging.warning(f"\nCannot find conversion rate for {quote_currency} to {account_currency}.")
|
|
return None
|
|
loss_in_quote_currency_per_lot = sl_distance_price * symbol_info.trade_contract_size
|
|
loss_in_account_currency_per_lot = loss_in_quote_currency_per_lot * conversion_rate
|
|
if loss_in_account_currency_per_lot == 0: return None
|
|
lot_size = risk_amount_account_currency / loss_in_account_currency_per_lot
|
|
volume_step, min_volume, max_volume = symbol_info.volume_step, symbol_info.volume_min, symbol_info.volume_max
|
|
lot_size = max(min_volume, min(max_volume, lot_size))
|
|
lot_size = round(lot_size / volume_step) * volume_step
|
|
return round(lot_size, 2)
|
|
except Exception as e:
|
|
logging.error(f"\nError in lot size calculation: {e}", exc_info=True)
|
|
return None
|
|
|
|
def is_in_trading_session(config):
|
|
ny_tz = pytz.timezone('America/New_York')
|
|
now_ny = datetime.now(ny_tz)
|
|
current_time = now_ny.time()
|
|
if now_ny.weekday() >= 5: return False
|
|
sessions = config.get('trading_sessions', [])
|
|
for session in sessions:
|
|
try:
|
|
start = time.fromisoformat(session['start'])
|
|
end = time.fromisoformat(session['end'])
|
|
if start <= current_time <= end:
|
|
return True
|
|
except (ValueError, KeyError):
|
|
logging.warning(f"\nInvalid session format in config.json: {session}")
|
|
continue
|
|
return False
|
|
|
|
def check_and_log_new_trades(state_manager, magic_number):
|
|
open_positions = mt5.positions_get(magic=magic_number)
|
|
if not open_positions: return
|
|
logged_open_tickets = state_manager.get_open_trade_tickets()
|
|
for pos in open_positions:
|
|
if pos.ticket not in logged_open_tickets:
|
|
logging.info(f"\nNew trade detected: Position #{pos.ticket}. Logging to database.")
|
|
state_manager.log_new_trade(pos)
|
|
|
|
def check_for_closed_trades(state_manager, config):
|
|
logged_open_tickets = state_manager.get_open_trade_tickets()
|
|
if not logged_open_tickets: return
|
|
magic_number = config.get('magic_number')
|
|
open_positions = mt5.positions_get(magic=magic_number)
|
|
open_position_tickets = {pos.ticket for pos in open_positions} if open_positions else set()
|
|
|
|
for ticket in logged_open_tickets:
|
|
if ticket not in open_position_tickets:
|
|
logging.info(f"\nClosed trade detected: Position #{ticket}. Fetching history...")
|
|
from_date = datetime.now(pytz.utc) - timedelta(days=3)
|
|
deals = mt5.history_deals_get(from_date, datetime.now(pytz.utc))
|
|
if deals is None: continue
|
|
|
|
profit = 0; close_time = None; close_price = None; symbol = ""
|
|
for deal in deals:
|
|
if deal.position_id == ticket:
|
|
symbol = deal.symbol
|
|
if deal.entry == 1:
|
|
profit += deal.profit
|
|
close_time = datetime.fromtimestamp(deal.time, tz=pytz.utc)
|
|
close_price = deal.price
|
|
|
|
if close_time:
|
|
state_manager.update_closed_trade(ticket, close_time, close_price, profit)
|
|
outcome = "Win" if profit > 0.01 else "Loss" if profit < -0.01 else "Breakeven"
|
|
message = (f"{'🏆' if outcome == 'Win' else '❌'} *Trade Closed ({outcome})*\n"
|
|
f"Symbol: `{symbol}`\n"
|
|
f"Ticket: `#{ticket}`\n"
|
|
f"Profit: `${profit:.2f}`")
|
|
send_telegram_alert(message, config)
|
|
|
|
# ==============================================================================
|
|
# == MAIN BOT LOGIC
|
|
# ==============================================================================
|
|
def run_bot(config, state_manager):
|
|
logging.info("🚀 Breaker Bot v3.2 is running...")
|
|
|
|
mt5_handler = MT5Handler(config)
|
|
strategy = BreakerStrategy(config)
|
|
symbols_to_trade = config['symbols_to_trade']
|
|
risk_percent = config['risk_percent']
|
|
fixed_rr = config['fixed_rr']
|
|
|
|
active_htf_zones = {symbol: [] for symbol in symbols_to_trade}
|
|
last_h1_scan_times = {}
|
|
loop_count = 0
|
|
spinner_chars = ['|', '/', '-', '\\']
|
|
|
|
while True:
|
|
try:
|
|
now_ny = datetime.now(pytz.timezone('America/New_York'))
|
|
|
|
if not mt5_handler.check_connection():
|
|
sleep_timer.sleep(30)
|
|
continue
|
|
|
|
check_and_log_new_trades(state_manager, config.get('magic_number'))
|
|
check_for_closed_trades(state_manager, config)
|
|
mt5_handler.manage_pending_orders(state_manager)
|
|
mt5_handler.manage_open_trades(state_manager)
|
|
|
|
if not is_in_trading_session(config):
|
|
status_message = f"[{now_ny.strftime('%H:%M:%S')} EST] Outside of trading sessions. Pausing... {spinner_chars[loop_count % len(spinner_chars)]}"
|
|
print(status_message, end='\r')
|
|
loop_count += 1
|
|
sleep_timer.sleep(900)
|
|
continue
|
|
|
|
for symbol in symbols_to_trade:
|
|
all_tf_data = mt5_handler.get_all_timeframe_data(symbol)
|
|
if not all_tf_data: continue
|
|
|
|
h1_df = all_tf_data.get(60)
|
|
last_scan_time = last_h1_scan_times.get(symbol)
|
|
|
|
if h1_df is not None and (last_scan_time is None or now_ny.hour != last_scan_time.hour):
|
|
print()
|
|
logging.info(f"Scanning for new H1 zones on {symbol}...")
|
|
active_htf_zones[symbol] = strategy.find_htf_zones(h1_df)
|
|
last_h1_scan_times[symbol] = now_ny
|
|
|
|
h1_filter_passed = False
|
|
aligned_zone = None
|
|
if not active_htf_zones.get(symbol):
|
|
h1_filter_passed = True
|
|
else:
|
|
fifteen_mins_ago = now_ny - timedelta(minutes=15)
|
|
last_15m_data = all_tf_data[1].loc[fifteen_mins_ago:]
|
|
if not last_15m_data.empty:
|
|
for zone in active_htf_zones[symbol]:
|
|
if (last_15m_data['Low'].min() <= zone['high'] and last_15m_data['High'].max() >= zone['low']):
|
|
h1_filter_passed = True
|
|
aligned_zone = zone
|
|
break
|
|
|
|
if not h1_filter_passed:
|
|
continue
|
|
|
|
breaker_setup, tf_found = strategy.find_setups(all_tf_data)
|
|
|
|
if breaker_setup:
|
|
if aligned_zone and \
|
|
(('Bullish' in breaker_setup['type'] and 'Bearish' in aligned_zone['type']) or \
|
|
('Bearish' in breaker_setup['type'] and 'Bullish' in aligned_zone['type'])):
|
|
continue
|
|
|
|
setup_id_str = str((symbol, str(breaker_setup['pointD'].name)))
|
|
if setup_id_str in state_manager.get_traded_setups():
|
|
continue
|
|
|
|
print()
|
|
details_log = format_breaker_details(breaker_setup)
|
|
bc, C = breaker_setup['breaker_candle'], breaker_setup['pointC']
|
|
|
|
if breaker_setup['type'] == 'Bullish':
|
|
pending_entry_price, order_type = bc['Low'], mt5.ORDER_TYPE_BUY_LIMIT
|
|
else:
|
|
pending_entry_price, order_type = bc['High'], mt5.ORDER_TYPE_SELL_LIMIT
|
|
|
|
tick = mt5.symbol_info_tick(symbol)
|
|
if tick is None: continue
|
|
if order_type == mt5.ORDER_TYPE_BUY_LIMIT and pending_entry_price >= tick.ask:
|
|
continue
|
|
if order_type == mt5.ORDER_TYPE_SELL_LIMIT and pending_entry_price <= tick.bid:
|
|
continue
|
|
|
|
strategy_sl_price = C['High'] if breaker_setup['type'] == 'Bearish' else C['Low']
|
|
volume = calculate_dynamic_lot_size(symbol, risk_percent, strategy_sl_price, pending_entry_price)
|
|
if volume is None or volume == 0:
|
|
continue
|
|
|
|
symbol_info = mt5.symbol_info(symbol)
|
|
spread_in_price = symbol_info.ask - symbol_info.bid
|
|
sl_tp_buffer = spread_in_price * 1.5
|
|
|
|
if breaker_setup['type'] == 'Bearish':
|
|
final_sl_price = strategy_sl_price + sl_tp_buffer
|
|
risk_distance = final_sl_price - pending_entry_price
|
|
final_tp_price = pending_entry_price - (risk_distance * fixed_rr)
|
|
else:
|
|
final_sl_price = strategy_sl_price - sl_tp_buffer
|
|
risk_distance = pending_entry_price - final_sl_price
|
|
final_tp_price = pending_entry_price + (risk_distance * fixed_rr)
|
|
|
|
digits = symbol_info.digits
|
|
final_sl_price, final_tp_price = round(final_sl_price, digits), round(final_tp_price, digits)
|
|
|
|
price_details = (f"*Pending Entry:* `{pending_entry_price:.{digits}f}`\n"
|
|
f"*Stop Loss:* `{final_sl_price:.{digits}f}`\n"
|
|
f"*Take Profit:* `{final_tp_price:.{digits}f}`\n"
|
|
f"*Volume:* `{volume:.2f}`")
|
|
|
|
message = f"*✅ VALID {breaker_setup['type']} Setup Found on {symbol} (M{tf_found})*\n{details_log}\n\n{price_details}"
|
|
logging.info(message.replace('*', '').replace('`', ''))
|
|
send_telegram_alert(message, config)
|
|
|
|
result = mt5_handler.place_pending_order(symbol, order_type, volume, pending_entry_price, final_sl_price, final_tp_price)
|
|
if result and result.retcode == mt5.TRADE_RETCODE_DONE:
|
|
state_manager.add_traded_setup(setup_id_str)
|
|
order_ticket = result.order
|
|
expiry_time = datetime.now(pytz.timezone('America/New_York')) + timedelta(minutes=(tf_found * 3))
|
|
state_manager.add_pending_order(order_ticket, symbol, expiry_time)
|
|
logging.info(f"Order #{order_ticket} will expire at {expiry_time.strftime('%H:%M:%S')}")
|
|
|
|
status_message = f"[{now_ny.strftime('%H:%M:%S')} EST] Bot is running... Last check complete. {spinner_chars[loop_count % len(spinner_chars)]}"
|
|
print(status_message, end='\r')
|
|
loop_count += 1
|
|
sleep_timer.sleep(60)
|
|
except Exception as e:
|
|
logging.error("\n A critical error occurred in the main loop:", exc_info=True)
|
|
sleep_timer.sleep(60)
|
|
|
|
# ==============================================================================
|
|
# == SCRIPT EXECUTION
|
|
# ==============================================================================
|
|
if __name__ == "__main__":
|
|
setup_logging()
|
|
|
|
config = load_config()
|
|
state_manager = StateManager()
|
|
|
|
mt5_handler = MT5Handler(config)
|
|
|
|
if not mt5_handler.connect():
|
|
quit()
|
|
|
|
try:
|
|
run_bot(config, state_manager)
|
|
except KeyboardInterrupt:
|
|
print("\nBot stopped by user.")
|
|
finally:
|
|
state_manager.close_connection()
|
|
mt5_handler.disconnect() |