Files
mt5_python_ea_suite/core/data_providers.py
T
songkunling 769729e610 基本完毕
2025-08-14 10:13:04 +08:00

374 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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