Files

375 lines
14 KiB
Python
Raw Permalink Normal View History

2025-08-14 10:13:04 +08:00
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):
2025-08-14 23:17:12 +08:00
# 确保使用MT5标准常量
2025-08-14 10:13:04 +08:00
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