mirror of
https://github.com/silencesdg/mt5_python_ea_suite.git
synced 2026-07-29 11:47:44 +00:00
374 lines
14 KiB
Python
374 lines
14 KiB
Python
import MetaTrader5 as mt5
|
||
import pandas as pd
|
||
import random
|
||
from logger import logger
|
||
from abc import ABC, abstractmethod
|
||
from collections import namedtuple
|
||
from config import SIMULATION_CONFIG
|
||
|
||
class DataProvider(ABC):
|
||
"""数据提供者抽象基类"""
|
||
@property
|
||
def is_live(self):
|
||
return False
|
||
|
||
@abstractmethod
|
||
def initialize(self):
|
||
pass
|
||
|
||
@abstractmethod
|
||
def shutdown(self):
|
||
pass
|
||
|
||
@abstractmethod
|
||
def get_current_price(self, symbol):
|
||
pass
|
||
|
||
@abstractmethod
|
||
def get_historical_data(self, symbol, timeframe, count, **kwargs):
|
||
pass
|
||
|
||
@abstractmethod
|
||
def get_account_info(self):
|
||
pass
|
||
|
||
@abstractmethod
|
||
def get_positions(self, symbol):
|
||
pass
|
||
|
||
@abstractmethod
|
||
def get_symbol_info(self, symbol):
|
||
pass
|
||
|
||
@abstractmethod
|
||
def send_order(self, symbol, order_type, volume):
|
||
pass
|
||
|
||
@abstractmethod
|
||
def close_position(self, ticket, symbol, volume):
|
||
pass
|
||
|
||
class LiveDataProvider(DataProvider):
|
||
"""实盘数据提供者"""
|
||
@property
|
||
def is_live(self):
|
||
return True
|
||
|
||
def initialize(self):
|
||
if not mt5.initialize():
|
||
logger.error("MT5初始化失败")
|
||
return False
|
||
logger.info("MT5连接成功")
|
||
return True
|
||
|
||
def shutdown(self):
|
||
mt5.shutdown()
|
||
logger.info("MT5连接已关闭")
|
||
|
||
def get_current_price(self, symbol):
|
||
tick = mt5.symbol_info_tick(symbol)
|
||
if tick:
|
||
# 对于某些品种(如XAUUSD),last价格可能为0,使用bid/ask的平均值作为替代
|
||
last_price = tick.last if tick.last != 0 else (tick.bid + tick.ask) / 2
|
||
logger.info(f"获取价格数据 - {symbol}: bid={tick.bid}, ask={tick.ask}, last={tick.last}, 使用last_price={last_price}")
|
||
return {'bid': tick.bid, 'ask': tick.ask, 'last': last_price, 'time': pd.to_datetime(tick.time, unit='s')}
|
||
else:
|
||
logger.error(f"无法获取 {symbol} 的价格数据")
|
||
return None
|
||
|
||
def get_historical_data(self, symbol, timeframe, count, **kwargs):
|
||
return mt5.copy_rates_from_pos(symbol, timeframe, 0, count)
|
||
|
||
def get_account_info(self):
|
||
return mt5.account_info()
|
||
|
||
def get_positions(self, symbol):
|
||
return mt5.positions_get(symbol=symbol)
|
||
|
||
def get_symbol_info(self, symbol):
|
||
return mt5.symbol_info(symbol)
|
||
|
||
def send_order(self, symbol, order_type, volume):
|
||
price_data = self.get_current_price(symbol)
|
||
if not price_data:
|
||
logger.error(f"无法获取 {symbol} 价格,无法下单")
|
||
return None
|
||
|
||
# 对于买入使用ask价格,卖出使用bid价格
|
||
price = price_data['ask'] if order_type == "buy" else price_data['bid']
|
||
logger.info(f"下单价格 - {symbol} {order_type}: 使用价格 {price}")
|
||
|
||
# 检查终端是否允许自动交易
|
||
if not mt5.terminal_info().trade_allowed:
|
||
logger.error("MT5终端未启用自动交易!请在MT5中点击'自动交易'按钮或按Ctrl+E启用")
|
||
return None
|
||
|
||
# 检查账户是否允许交易
|
||
account_info = mt5.account_info()
|
||
if account_info and not account_info.trade_allowed:
|
||
logger.error("当前账户不允许自动交易,请联系 broker")
|
||
return None
|
||
|
||
# 获取品种信息以确定支持的填充模式
|
||
symbol_info = mt5.symbol_info(symbol)
|
||
if not symbol_info:
|
||
logger.error(f"无法获取 {symbol} 的品种信息")
|
||
return None
|
||
|
||
# 确定填充模式:优先使用品种支持的填充模式
|
||
filling_mode = mt5.ORDER_FILLING_IOC # 默认使用IOC
|
||
if symbol_info.filling_mode == 1: # 只支持FILLING模式
|
||
filling_mode = mt5.ORDER_FILLING_FOK
|
||
elif symbol_info.filling_mode == 2: # 只支持RETURN模式
|
||
filling_mode = mt5.ORDER_FILLING_RETURN
|
||
elif symbol_info.filling_mode == 3: # 支持所有模式
|
||
filling_mode = mt5.ORDER_FILLING_RETURN # 优先使用RETURN
|
||
|
||
logger.info(f"使用填充模式: {filling_mode} (品种支持模式: {symbol_info.filling_mode})")
|
||
|
||
order_type_mt5 = mt5.ORDER_TYPE_BUY if order_type == "buy" else mt5.ORDER_TYPE_SELL
|
||
request = {
|
||
"action": mt5.TRADE_ACTION_DEAL,
|
||
"symbol": symbol,
|
||
"volume": volume,
|
||
"type": order_type_mt5,
|
||
"price": price,
|
||
"deviation": 20,
|
||
"magic": 234000,
|
||
"comment": f"{order_type} order",
|
||
"type_filling": filling_mode,
|
||
}
|
||
result = mt5.order_send(request)
|
||
|
||
# 检查常见错误代码
|
||
if result and hasattr(result, 'retcode'):
|
||
if result.retcode == 10027:
|
||
logger.error("自动交易被禁用!请在MT5中:")
|
||
logger.error("1. 点击工具栏的'自动交易'按钮(绿色播放图标)")
|
||
logger.error("2. 或按快捷键 Ctrl+E")
|
||
logger.error("3. 确保按钮变为绿色状态")
|
||
elif result.retcode == 10030:
|
||
logger.error("订单填充模式不支持!尝试其他填充模式...")
|
||
# 如果RETURN模式失败,尝试IOC模式
|
||
if filling_mode != mt5.ORDER_FILLING_IOC:
|
||
logger.info("尝试使用IOC填充模式...")
|
||
request["type_filling"] = mt5.ORDER_FILLING_IOC
|
||
result = mt5.order_send(request)
|
||
# 如果IOC模式也失败,尝试FOK模式
|
||
if result and result.retcode == 10030 and filling_mode != mt5.ORDER_FILLING_FOK:
|
||
logger.info("尝试使用FOK填充模式...")
|
||
request["type_filling"] = mt5.ORDER_FILLING_FOK
|
||
result = mt5.order_send(request)
|
||
elif result.retcode != 10009: # 10009 = 成功
|
||
logger.error(f"下单失败,错误代码: {result.retcode}, 错误信息: {result.comment if hasattr(result, 'comment') else '未知错误'}")
|
||
|
||
return result
|
||
|
||
def close_position(self, ticket, symbol, volume):
|
||
positions = self.get_positions(symbol)
|
||
if not positions:
|
||
logger.error(f"未找到任何持仓")
|
||
return False
|
||
|
||
target_position = None
|
||
for pos in positions:
|
||
if pos.ticket == ticket:
|
||
target_position = pos
|
||
break
|
||
|
||
if not target_position:
|
||
logger.error(f"未找到ticket为 {ticket} 的持仓")
|
||
return False
|
||
|
||
# 获取当前tick价格
|
||
tick = mt5.symbol_info_tick(symbol)
|
||
if not tick:
|
||
logger.error(f"无法获取 {symbol} 的当前价格")
|
||
return False
|
||
|
||
# 根据持仓类型确定平仓价格和订单类型
|
||
if target_position.type == mt5.POSITION_TYPE_BUY: # 多单平仓
|
||
close_price = tick.bid
|
||
order_type = mt5.ORDER_TYPE_SELL
|
||
else: # 空单平仓
|
||
close_price = tick.ask
|
||
order_type = mt5.ORDER_TYPE_BUY
|
||
|
||
# 获取品种信息以确定支持的填充模式
|
||
symbol_info = mt5.symbol_info(symbol)
|
||
if not symbol_info:
|
||
logger.error(f"无法获取 {symbol} 的品种信息")
|
||
return False
|
||
|
||
# 确定填充模式:优先使用IOC(即时成交或取消)
|
||
filling_mode = mt5.ORDER_FILLING_IOC
|
||
if symbol_info.filling_mode == 1: # 只支持FILLING模式
|
||
filling_mode = mt5.ORDER_FILLING_FOK
|
||
elif symbol_info.filling_mode == 2: # 只支持RETURN模式
|
||
filling_mode = mt5.ORDER_FILLING_RETURN
|
||
elif symbol_info.filling_mode == 3: # 支持所有模式
|
||
filling_mode = mt5.ORDER_FILLING_IOC # 优先使用IOC
|
||
|
||
request = {
|
||
"action": mt5.TRADE_ACTION_DEAL,
|
||
"position": target_position.ticket,
|
||
"symbol": symbol,
|
||
"volume": volume,
|
||
"type": order_type,
|
||
"price": close_price,
|
||
"deviation": 20,
|
||
"magic": 234000,
|
||
"comment": f"Close position {ticket}",
|
||
"type_filling": filling_mode,
|
||
}
|
||
|
||
logger.info(f"发送平仓请求: Ticket={ticket}, 价格={close_price}, 类型={order_type}, 填充模式={filling_mode}")
|
||
|
||
result = mt5.order_send(request)
|
||
|
||
if result.retcode == mt5.TRADE_RETCODE_DONE:
|
||
logger.info(f"平仓成功: Ticket {ticket}")
|
||
return True
|
||
else:
|
||
logger.error(f"平仓失败: Ticket={ticket}, 错误码={result.retcode}, 错误信息={result.comment}")
|
||
# 记录常见错误的具体原因
|
||
if result.retcode == 10027:
|
||
logger.error("自动交易被禁用!请在MT5中启用自动交易")
|
||
elif result.retcode == 10006:
|
||
logger.error("请求被拒绝,可能是价格变动过快")
|
||
elif result.retcode == 10013:
|
||
logger.error("无效请求,检查参数是否正确")
|
||
elif result.retcode == 10016:
|
||
logger.error("无效的成交量,检查手数是否符合要求")
|
||
return False
|
||
|
||
class DryRunDataProvider(DataProvider):
|
||
"""
|
||
纸上交易提供者 (Paper Trading / Dry Run)
|
||
- 使用来自MT5的实时价格数据
|
||
- 模拟下单和持仓,不发送真实订单
|
||
- 考虑点差影响
|
||
"""
|
||
def __init__(self, initial_equity=10000, leverage=100):
|
||
self.simulated_ticket_counter = 0
|
||
self.equity = initial_equity
|
||
self.leverage = leverage
|
||
# 使用LiveDataProvider作为获取市场数据的来源
|
||
self._live_data_source = LiveDataProvider()
|
||
# 从配置获取点差
|
||
self.spread = SIMULATION_CONFIG.get("spread", 16)
|
||
|
||
def initialize(self):
|
||
logger.info("纸上交易模式初始化...")
|
||
return self._live_data_source.initialize()
|
||
|
||
def shutdown(self):
|
||
logger.info("纸上交易模式关闭.")
|
||
self._live_data_source.shutdown()
|
||
|
||
# --- 数据获取方法 (委托给LiveDataProvider) ---
|
||
def get_current_price(self, symbol):
|
||
price_data = self._live_data_source.get_current_price(symbol)
|
||
if price_data:
|
||
# 计算双向点差值(各一半)
|
||
spread_half = self.spread * 0.01 / 2 # XAUUSD: 1点 = 0.01,双向点差各一半
|
||
# 使用中间价计算双向点差
|
||
mid_price = price_data['last']
|
||
# 返回考虑双向点差的价格
|
||
return {
|
||
'bid': mid_price - spread_half, # 卖出价格(中间价 - 点差/2)
|
||
'ask': mid_price + spread_half, # 买入价格(中间价 + 点差/2)
|
||
'last': mid_price, # 最后成交价(中间价)
|
||
'time': price_data['time']
|
||
}
|
||
return None
|
||
|
||
def get_historical_data(self, symbol, timeframe, count, **kwargs):
|
||
return self._live_data_source.get_historical_data(symbol, timeframe, count, **kwargs)
|
||
|
||
def get_symbol_info(self, symbol):
|
||
return self._live_data_source.get_symbol_info(symbol)
|
||
|
||
# --- 模拟状态和交易方法 ---
|
||
def get_account_info(self):
|
||
# 账户信息是模拟的,因为没有真实账户活动
|
||
return {'equity': self.equity}
|
||
|
||
def get_positions(self, symbol):
|
||
# 持仓信息是模拟的,由PositionManager在内部管理,这里返回空列表
|
||
return []
|
||
|
||
def send_order(self, symbol, order_type, volume):
|
||
self.simulated_ticket_counter += 1
|
||
price_data = self.get_current_price(symbol)
|
||
price = price_data['last'] if price_data else "N/A"
|
||
logger.info(f"[纸上交易] 模拟下单: {order_type} {volume:.2f}手 {symbol} @ {price}")
|
||
return {'order': self.simulated_ticket_counter}
|
||
|
||
def close_position(self, ticket, symbol, volume):
|
||
price_data = self.get_current_price(symbol)
|
||
price = price_data['last'] if price_data else "N/A"
|
||
logger.info(f"[纸上交易] 模拟平仓: Ticket {ticket} @ {price}")
|
||
return True
|
||
|
||
class BacktestDataProvider(DataProvider):
|
||
"""回测数据提供者"""
|
||
def __init__(self, df, initial_equity=10000, leverage=100):
|
||
self.df = df
|
||
self.current_index = 0
|
||
self.equity = initial_equity
|
||
self.leverage = leverage
|
||
self.simulated_ticket_counter = 0
|
||
# 从配置获取点差
|
||
self.spread = SIMULATION_CONFIG.get("spread", 16)
|
||
|
||
def initialize(self): return True
|
||
def shutdown(self): pass
|
||
def get_account_info(self):
|
||
return {'equity': self.equity}
|
||
|
||
def get_positions(self, symbol): return []
|
||
def get_symbol_info(self, symbol):
|
||
return {
|
||
'trade_contract_size': SIMULATION_CONFIG['contract_size'],
|
||
'volume_step': SIMULATION_CONFIG['volume_step'],
|
||
'volume_min': SIMULATION_CONFIG['volume_min'],
|
||
'volume_max': SIMULATION_CONFIG['volume_max'],
|
||
}
|
||
|
||
def get_current_price(self, symbol):
|
||
if self.current_index >= len(self.df):
|
||
return None
|
||
row = self.df.iloc[self.current_index]
|
||
# 计算双向点差值(各一半)
|
||
spread_half = self.spread * 0.01 / 2 # XAUUSD: 1点 = 0.01,双向点差各一半
|
||
# 返回考虑双向点差的价格
|
||
return {
|
||
'bid': row.close - spread_half, # 卖出价格(中间价 - 点差/2)
|
||
'ask': row.close + spread_half, # 买入价格(中间价 + 点差/2)
|
||
'last': row.close, # 最后成交价(中间价)
|
||
'time': row.name
|
||
}
|
||
|
||
def get_historical_data(self, symbol, timeframe, count, **kwargs):
|
||
if self.current_index < count:
|
||
return None
|
||
end_index = self.current_index + 1
|
||
start_index = max(0, end_index - count)
|
||
return self.df.iloc[start_index:end_index].to_dict('records')
|
||
|
||
def send_order(self, symbol, order_type, volume):
|
||
self.simulated_ticket_counter += 1
|
||
logger.info(f"[回测模式] 下单: {order_type} {volume:.2f}手 {symbol}")
|
||
return {'order': self.simulated_ticket_counter}
|
||
|
||
def close_position(self, ticket, symbol, volume):
|
||
logger.info(f"[回测模式] 平仓: Ticket {ticket}")
|
||
return True
|
||
|
||
def tick(self):
|
||
if self.current_index < len(self.df) - 1:
|
||
self.current_index += 1
|
||
return True
|
||
return False
|