基本完毕

This commit is contained in:
songkunling
2025-08-14 10:13:04 +08:00
parent 21ce1831ec
commit 769729e610
40 changed files with 3177 additions and 2372 deletions
View File
+373
View File
@@ -0,0 +1,373 @@
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
+7
View File
@@ -0,0 +1,7 @@
# 风险管理模块
此模块包含所有风险管理相关的组件:
- `market_state.py`: 市场状态分析器,基于resilient_trend逻辑
- `position_manager.py`: 仓位管理器,基于profit_protect逻辑
- `__init__.py`: 风险管理控制器,统一接口
+32
View File
@@ -0,0 +1,32 @@
from .market_state import MarketStateAnalyzer
from .position_manager import PositionManager
class RiskController:
"""
风险管理控制器 (已重构为依赖注入)
"""
def __init__(self, data_provider, trade_direction="both"):
self.data_provider = data_provider
self.position_manager = PositionManager(data_provider, trade_direction)
self.market_state_analyzer = MarketStateAnalyzer(data_provider)
def process_trading_signal(self, direction, current_price, signal_strength=0.0):
return self.position_manager.open_position(direction, current_price, signal_strength)
def monitor_positions(self, current_price, dry_run=False):
self.position_manager.monitor_positions(current_price, dry_run)
def sync_state(self):
"""从数据源同步权益和持仓"""
self.position_manager.update_equity()
self.position_manager.sync_positions()
def get_account_status(self):
return self.position_manager.get_account_status()
def get_positions(self):
return self.position_manager.get_positions()
def save_trade_history(self, base_filename):
self.position_manager.save_trade_history(base_filename)
+151
View File
@@ -0,0 +1,151 @@
import pandas as pd
import numpy as np
from logger import logger
from config import MARKET_STATE_CONFIG, SYMBOL, DEFAULT_WEIGHTS, TREND_INDICATOR_WEIGHTS, TREND_THRESHOLDS, MARKET_STATE_WEIGHTS, CONFIDENCE_THRESHOLDS
class MarketStateAnalyzer:
"""
市场状态分析器 (支持参数优化)
"""
def __init__(self, data_provider, market_state_params=None, trend_weights=None, trend_thresholds=None, confidence_thresholds=None):
self.data_provider = data_provider
self.symbol = SYMBOL
self.timeframe = 16385 # TIMEFRAME_H1
self.hourly_data_count = 100
# 使用传入的参数或默认配置
market_state_config = market_state_params or MARKET_STATE_CONFIG
self.trend_period = market_state_config.get("trend_period", 50)
self.retracement_tolerance = market_state_config.get("retracement_tolerance", 0.30)
self.volume_period = market_state_config.get("volume_period", 20)
self.volume_ma_period = market_state_config.get("volume_ma_period", 10)
self.indicator_weights = trend_weights or TREND_INDICATOR_WEIGHTS
self.thresholds = trend_thresholds or TREND_THRESHOLDS
self.confidence_thresholds = confidence_thresholds or CONFIDENCE_THRESHOLDS
self.current_trend = "none"
self.trend_peak = 0.0
self.trend_trough = float('inf')
def _calculate_volume_indicators(self, df):
df['volume'] = df['tick_volume']
df['volume_ma'] = df['volume'].rolling(self.volume_ma_period).mean()
df['volume_ratio'] = df['volume'] / df['volume_ma']
volume_corr = df['close'].rolling(self.volume_period).corr(df['volume'])
return df, volume_corr.iloc[-1] if len(volume_corr) > 0 and not pd.isna(volume_corr.iloc[-1]) else 0
def _calculate_momentum_indicators(self, df):
delta = df['close'].diff()
gain = (delta.where(delta > 0, 0)).rolling(window=14).mean()
loss = (-delta.where(delta < 0, 0)).rolling(window=14).mean()
rs = gain / loss
df['rsi'] = 100 - (100 / (1 + rs))
exp1 = df['close'].ewm(span=12).mean()
exp2 = df['close'].ewm(span=26).mean()
df['macd'] = exp1 - exp2
df['macd_signal'] = df['macd'].ewm(span=9).mean()
df['macd_histogram'] = df['macd'] - df['macd_signal']
df['ma20'] = df['close'].rolling(20).mean()
df['ma50'] = df['close'].rolling(50).mean()
return df
def _calculate_price_breakout_score(self, df):
current_price = df['close'].iloc[-1]
high_period = df['high'].rolling(self.trend_period).max().iloc[-2]
low_period = df['low'].rolling(self.trend_period).min().iloc[-2]
price_range = high_period - low_period
if price_range == 0: return 0.5
price_position = (current_price - low_period) / price_range
if current_price > high_period: return 0.8
elif current_price < low_period: return 0.2
else: return np.clip(price_position, 0, 1)
def _calculate_volume_score(self, df, volume_corr):
current_volume = df['volume'].iloc[-1]
volume_ma = df['volume_ma'].iloc[-1]
volume_ratio = current_volume / volume_ma if volume_ma > 0 else 1
score = 0.5
if volume_ratio > self.thresholds['volume_spike']: score += 0.3
if volume_corr > 0.5: score += 0.2
elif volume_corr < -0.5: score -= 0.2
return np.clip(score, 0, 1)
def _calculate_momentum_score(self, df):
current_rsi = df['rsi'].iloc[-1] if not pd.isna(df['rsi'].iloc[-1]) else 50
current_macd_hist = df['macd_histogram'].iloc[-1] if not pd.isna(df['macd_histogram'].iloc[-1]) else 0
score = 0.5
if current_rsi > self.thresholds['overbought']: score += 0.2
elif current_rsi < self.thresholds['oversold']: score -= 0.2
if current_macd_hist > 0: score += 0.2
else: score -= 0.2
return np.clip(score, 0, 1)
def _calculate_ma_score(self, df):
current_price = df['close'].iloc[-1]
ma20 = df['ma20'].iloc[-1] if not pd.isna(df['ma20'].iloc[-1]) else current_price
ma50 = df['ma50'].iloc[-1] if not pd.isna(df['ma50'].iloc[-1]) else current_price
score = 0.5
if current_price > ma20 > ma50: score += 0.3
elif current_price < ma20 < ma50: score -= 0.3
if len(df) >= 3:
ma20_slope = df['ma20'].iloc[-1] - df['ma20'].iloc[-3]
ma50_slope = df['ma50'].iloc[-1] - df['ma50'].iloc[-3]
if ma20_slope > 0 and ma50_slope > 0: score += 0.2
elif ma20_slope < 0 and ma50_slope < 0: score -= 0.2
return np.clip(score, 0, 1)
def _update_trend_state(self, df, new_state):
current_price = df['close'].iloc[-1]
if new_state == "uptrend":
self.current_trend = "uptrend"
self.trend_peak = max(self.trend_peak, current_price)
elif new_state == "downtrend":
self.current_trend = "downtrend"
self.trend_trough = min(self.trend_trough, current_price)
else:
if self.current_trend == "uptrend" and current_price < self.trend_peak * (1 - self.retracement_tolerance):
self.current_trend = "none"
elif self.current_trend == "downtrend" and current_price > self.trend_trough * (1 + self.retracement_tolerance):
self.current_trend = "none"
def get_market_state(self):
rates = self.data_provider.get_historical_data(self.symbol, self.timeframe, self.hourly_data_count)
if rates is None or len(rates) < max(self.trend_period, 50):
return "none", 0.0
df = pd.DataFrame(rates)
df, volume_corr = self._calculate_volume_indicators(df)
df = self._calculate_momentum_indicators(df)
price_score = self._calculate_price_breakout_score(df)
volume_score = self._calculate_volume_score(df, volume_corr)
momentum_score = self._calculate_momentum_score(df)
ma_score = self._calculate_ma_score(df)
trend_strength = (price_score * self.indicator_weights['price_breakout'] +
volume_score * self.indicator_weights['volume_confirmation'] +
momentum_score * self.indicator_weights['momentum oscillator'] +
ma_score * self.indicator_weights['moving_average'])
if trend_strength >= self.thresholds['strong_trend']: state, confidence = "uptrend", min(0.95, trend_strength)
elif trend_strength <= (1 - self.thresholds['strong_trend']): state, confidence = "downtrend", min(0.95, 1 - trend_strength)
elif trend_strength >= self.thresholds['weak_trend']: state, confidence = "ranging", 0.6
else: state, confidence = "none", 0.4
self._update_trend_state(df, state)
return state, confidence
def get_strategy_weights(self, market_state, confidence):
base_weights = MARKET_STATE_WEIGHTS.get(market_state, DEFAULT_WEIGHTS)
high_confidence_threshold = self.confidence_thresholds.get("high_confidence", 0.7)
medium_confidence_threshold = self.confidence_thresholds.get("medium_confidence", 0.4)
if confidence > high_confidence_threshold:
return {k: v * confidence for k, v in base_weights.items()}
elif confidence > medium_confidence_threshold:
return {k: (v * confidence + DEFAULT_WEIGHTS.get(k, 1.0) * (1 - confidence)) for k, v in base_weights.items()}
else:
return DEFAULT_WEIGHTS
+429
View File
@@ -0,0 +1,429 @@
import pandas as pd
import numpy as np
import json
import os
from logger import logger
from config import RISK_CONFIG, SYMBOL, INITIAL_CAPITAL, CAPITAL_ALLOCATION, RISK_CONFIG_CONST
class PositionManager:
"""
持仓管理器 - 统一管理所有持仓操作和交易记录 (已重构为依赖注入)
"""
def __init__(self, data_provider, trade_direction="both"):
self.data_provider = data_provider
self.symbol = SYMBOL
self.trade_direction = trade_direction
# 风险管理参数
self.stop_loss_pct = RISK_CONFIG.get("stop_loss_pct", -0.10)
self.profit_retracement_pct = RISK_CONFIG.get("profit_retracement_pct", 0.10)
self.min_profit_for_trailing = RISK_CONFIG.get("min_profit_for_trailing", 0.0003)
self.take_profit_pct = RISK_CONFIG.get("take_profit_pct", 0.20)
self.enable_time_based_exit = RISK_CONFIG_CONST.get("enable_time_based_exit", False)
self.max_holding_minutes = RISK_CONFIG.get("max_holding_minutes", 60)
self.min_profit_for_time_exit = RISK_CONFIG.get("min_profit_for_time_exit", 0.0005)
# 资金管理参数
self.initial_capital = INITIAL_CAPITAL
self.long_capital_pct = CAPITAL_ALLOCATION.get("long_pct", 0.5)
self.short_capital_pct = CAPITAL_ALLOCATION.get("short_pct", 0.5)
self.max_daily_loss = RISK_CONFIG.get("max_daily_loss", -0.10)
# 持仓和交易记录
self.positions = []
self.closed_trades = []
self.total_equity = self.initial_capital
# 持久化文件路径
self.peak_data_file = "position_peaks.json"
# 初始化时加载峰值数据
self._load_peak_data()
def _calculate_position_size(self, capital_to_allocate, current_price):
price = current_price['last']
logger.info(f"当前价格: {price}")
if not isinstance(price, (int, float)) or price == 0:
logger.error(f"价格无效: {price}")
return 0.0
symbol_info = self.data_provider.get_symbol_info(self.symbol)
logger.info(f"合约信息: {symbol_info}")
if not symbol_info:
logger.error(f"无法获取 {self.symbol} 的合约信息")
return 0.0
is_dict = isinstance(symbol_info, dict)
contract_size = symbol_info['trade_contract_size'] if is_dict else symbol_info.trade_contract_size
volume_step = symbol_info['volume_step'] if is_dict else symbol_info.volume_step
min_volume = symbol_info['volume_min'] if is_dict else symbol_info.volume_min
max_volume = symbol_info['volume_max'] if is_dict else symbol_info.volume_max
logger.info(f"合约大小: {contract_size}, 步长: {volume_step}, 最小: {min_volume}, 最大: {max_volume}")
value_of_one_lot = price * contract_size
logger.info(f"一手价值: {value_of_one_lot}")
if value_of_one_lot == 0:
logger.error("一手价值为0")
return 0.0
volume = capital_to_allocate / value_of_one_lot
logger.info(f"原始手数: {volume}")
volume = round(volume / volume_step) * volume_step
logger.info(f"调整后手数: {volume}")
volume = max(min_volume, min(volume, max_volume))
logger.info(f"最终手数: {volume}")
return volume
def open_position(self, direction, current_price, signal_strength=0.0, dry_run=False):
logger.info(f"开始处理{direction}开仓请求")
position_type_to_open = 'long' if direction == 'buy' else 'short'
# 根据交易方向选择合适的成交价格
if dry_run:
# 在回测/模拟模式中,考虑点差
if direction == 'buy':
execution_price = current_price['ask'] # 买入用卖方价(ask
else:
execution_price = current_price['bid'] # 卖出用买方价(bid
else:
# 实盘模式使用最后价格
execution_price = current_price['last']
# 检查交易方向限制
if self.trade_direction == "long" and direction == "sell":
logger.info("当前配置只允许做多,忽略卖出信号")
return False
elif self.trade_direction == "short" and direction == "buy":
logger.info("当前配置只允许做空,忽略买入信号")
return False
# 从配置中获取最大持仓限制
from config import REALTIME_CONFIG
max_positions = REALTIME_CONFIG.get(f'max_{position_type_to_open}_positions', 1)
logger.info(f"配置文件中的max_{position_type_to_open}_positions: {REALTIME_CONFIG.get(f'max_{position_type_to_open}_positions', 'NOT_FOUND')}")
logger.info(f"最大{position_type_to_open}持仓数限制: {max_positions}")
# 检查当前同向持仓数量
current_positions = [p for p in self.positions if p['position_type'] == position_type_to_open]
logger.info(f"当前{position_type_to_open}持仓数: {len(current_positions)}")
# 如果配置为0或负数,表示无限制
if max_positions <= 0:
logger.info(f"{position_type_to_open}持仓数无限制")
elif len(current_positions) >= max_positions:
logger.info(f"已达到最大{position_type_to_open}持仓数 ({max_positions}),忽略信号")
return False
capital_pct = self.long_capital_pct if direction == 'buy' else self.short_capital_pct
capital_for_this_trade = self.total_equity * capital_pct
logger.info(f"分配资金: {capital_for_this_trade:.2f} (总权益: {self.total_equity:.2f}, 比例: {capital_pct:.2%})")
position_volume = self._calculate_position_size(capital_for_this_trade, {'last': execution_price})
logger.info(f"计算仓位大小: {position_volume:.2f}")
if position_volume <= 0:
logger.info("仓位大小为0,无法开仓")
return False
logger.info(f"发送订单: {direction} {position_volume:.2f}{self.symbol}")
order_result = self.data_provider.send_order(self.symbol, direction, position_volume)
logger.info(f"订单结果: {order_result}")
if order_result is None:
logger.error("订单返回None,可能是数据提供者问题")
return False
try:
order_id = order_result['order'] if isinstance(order_result, dict) else order_result.order
logger.info(f"订单ID: {order_id}")
except Exception as e:
logger.error(f"解析订单ID失败: {e}")
return False
if order_result and order_id > 0:
new_position = {
'ticket': order_id,
'symbol': self.symbol,
'entry_price': execution_price,
'entry_time': current_price['time'],
'position_type': position_type_to_open,
'quantity': position_volume,
'peak_profit_pct': 0.0,
}
self.positions.append(new_position)
logger.info(f"开仓成功: {direction} @ {execution_price:.2f}, 手数: {position_volume:.2f}, Ticket: {order_id}")
logger.info(f"持仓 {order_id}: 初始化峰值盈利为 0.0%")
return True
else:
logger.error(f"开仓失败: {direction} @ {current_price['last']:.2f}, 订单结果: {order_result}")
return False
def monitor_positions(self, current_price, dry_run=False):
if not self.positions: return
# 打印所有持仓的当前状态
logger.info(f"当前持仓数量: {len(self.positions)}")
for pos in self.positions:
# 计算持仓时间
holding_time = current_price['time'] - pos['entry_time']
holding_minutes = holding_time.total_seconds() / 60
logger.info(f"持仓 {pos['ticket']}: 持仓时间={holding_minutes:.1f}分钟, 当前峰值={pos.get('peak_profit_pct', 0):.6%}")
positions_to_remove = []
for position in self.positions:
pnl_pct = self._calculate_pnl_pct(position, current_price['last'])
old_peak = position.get('peak_profit_pct', 0)
# 确保正确更新峰值
new_peak = max(old_peak, pnl_pct)
position['peak_profit_pct'] = new_peak
# 打印详细的追踪止损计算信息
# logger.info(f"持仓 {position['ticket']}: 当前盈利={pnl_pct:.6%}, 原峰值={old_peak:.6%}, 新峰值={new_peak:.6%}")
# 如果有新的峰值,打印详细信息并保存到文件
if new_peak > old_peak:
logger.info(f"持仓 {position['ticket']}: 新的峰值盈利: {new_peak:.6%}")
# 确认保存到持仓对象
logger.info(f"持仓 {position['ticket']}: 确认保存的峰值: {position.get('peak_profit_pct', 0):.6%}")
# 保存到文件
self._save_peak_data()
action, reason = self._check_risk_conditions(
pnl_pct,
new_peak,
position['entry_time'],
current_price['time'],
position
)
if action == "close":
logger.info(f"平仓信号触发 (Ticket: {position['ticket']}): {reason}")
# 在dry_run模式下,计算考虑点差的平仓价格
if dry_run:
# 从配置获取点差
from config import SIMULATION_CONFIG
spread_points = SIMULATION_CONFIG.get("spread", 16)
spread_value = spread_points * 0.01 # XAUUSD: 1点 = 0.01
if position['position_type'] == 'long':
# 多头平仓用bid价(卖出价)
close_price = current_price['bid']
else:
# 空头平仓用ask价(买入价)
close_price = current_price['ask']
else:
close_price = current_price['last']
success = self.data_provider.close_position(position['ticket'], position['symbol'], position['quantity'])
if success:
self._record_closed_trade(position, close_price, reason)
positions_to_remove.append(position)
else:
logger.error(f"平仓失败, Ticket: {position['ticket']}")
if positions_to_remove:
# 记录平仓的持仓ticket
closed_tickets = [pos['ticket'] for pos in positions_to_remove]
logger.info(f"平仓持仓: {closed_tickets}")
self.positions = [p for p in self.positions if p not in positions_to_remove]
self.update_equity()
# 清理已平仓持仓的峰值数据
self.cleanup_peak_data()
def _calculate_pnl_pct(self, position, current_price_value):
entry_price = position['entry_price']
if position['position_type'] == 'long':
return (current_price_value - entry_price) / entry_price if entry_price != 0 else 0.0
else:
return (entry_price - current_price_value) / entry_price if entry_price != 0 else 0.0
def _record_closed_trade(self, position, close_price, close_reason):
symbol_info = self.data_provider.get_symbol_info(position['symbol'])
contract_size = (symbol_info['trade_contract_size'] if isinstance(symbol_info, dict)
else symbol_info.trade_contract_size) if symbol_info else 100
pnl = 0
if position['position_type'] == 'long':
pnl = (close_price - position['entry_price']) * position['quantity'] * contract_size
else:
pnl = (position['entry_price'] - close_price) * position['quantity'] * contract_size
trade_record = position.copy()
trade_record.update({
'status': 'closed', 'close_price': close_price, 'close_time': pd.Timestamp.now(),
'close_reason': close_reason, 'profit_loss': pnl
})
self.closed_trades.append(trade_record)
logger.info(f"平仓记录 #{position['ticket']}: 盈亏: ${pnl:.2f}")
def _check_risk_conditions(self, current_profit_pct, peak_profit_pct, entry_time, current_time, position=None):
# 记录详细的追踪止损计算过程
logger.debug(f"追踪止损计算: 当前盈利={current_profit_pct:.4%}, 峰值盈利={peak_profit_pct:.4%}, 阈值={self.min_profit_for_trailing:.4%}")
if current_profit_pct <= self.stop_loss_pct:
logger.debug(f"触发止损: {current_profit_pct:.4%} <= {self.stop_loss_pct:.4%}")
return "close", f"止损触发"
if current_profit_pct >= self.take_profit_pct:
logger.debug(f"触发止盈: {current_profit_pct:.4%} >= {self.take_profit_pct:.4%}")
return "close", f"止盈触发"
# 修正后的追踪止损逻辑
if peak_profit_pct > self.min_profit_for_trailing:
stop_level = peak_profit_pct * (1 - self.profit_retracement_pct)
retracement_amount = peak_profit_pct - current_profit_pct
retracement_pct = (retracement_amount / peak_profit_pct) if peak_profit_pct > 0 else 0
# 计算实际回撤金额
if position:
symbol_info = self.data_provider.get_symbol_info(self.symbol)
contract_size = (symbol_info['trade_contract_size'] if isinstance(symbol_info, dict)
else symbol_info.trade_contract_size) if symbol_info else 100
# 使用持仓的entry_price和quantity来计算实际回撤金额
position_value = position['entry_price'] * position['quantity'] * contract_size
actual_retracement_amount = position_value * retracement_amount
else:
actual_retracement_amount = 0
logger.info(f"追踪止损详细计算:")
logger.info(f" 峰值盈利: {peak_profit_pct:.4%}")
logger.info(f" 当前盈利: {current_profit_pct:.4%}")
logger.info(f" 回撤阈值: {self.profit_retracement_pct:.2%}")
logger.info(f" 计算止损位: {peak_profit_pct:.4%} × (1 - {self.profit_retracement_pct:.2%}) = {stop_level:.4%}")
logger.info(f" 实际回撤: {retracement_pct:.2%} (回撤金额: ${actual_retracement_amount:.2f})")
logger.info(f" 触发条件: 当前盈利 {current_profit_pct:.4%} <= 止损位 {stop_level:.4%} = {current_profit_pct <= stop_level}")
if current_profit_pct <= stop_level:
logger.info(f"触发追踪止损: 当前盈利{current_profit_pct:.4%} <= 止损位{stop_level:.4%}")
return "close", f"追踪止损触发 (盈利从 {peak_profit_pct:.2%} 回落至 {current_profit_pct:.2%}, 回撤{retracement_pct:.2%})"
else:
logger.info(f"未触发追踪止损: 当前盈利{current_profit_pct:.4%} > 止损位{stop_level:.4%}")
else:
pass # 未达到追踪止损条件
if self.enable_time_based_exit:
holding_duration = current_time - entry_time
if (holding_duration.total_seconds() / 60) > self.max_holding_minutes:
if current_profit_pct < self.min_profit_for_time_exit:
return "close", f"超时平仓 (持仓超过{self.max_holding_minutes}分钟且盈利未达标)"
return "none", ""
def update_equity(self):
# In live mode, always trust the broker's account info
if self.data_provider.is_live:
account_info = self.data_provider.get_account_info()
if account_info:
self.total_equity = account_info.equity
# In simulation/backtest mode, calculate equity based on trade history
else:
self.total_equity = self.initial_capital + sum(t['profit_loss'] for t in self.closed_trades)
def sync_positions(self):
# 只在实盘模式下执行同步
if not self.data_provider.is_live:
return
live_positions = self.data_provider.get_positions(self.symbol)
if live_positions is None: return
# 从文件加载保存的峰值数据
saved_peaks = self._load_peak_data()
logger.info(f"从文件加载的峰值数据: {saved_peaks}")
# 保存现有的峰值数据(内存中的)
existing_peaks = {pos['ticket']: pos.get('peak_profit_pct', 0.0) for pos in self.positions}
# 合并数据:优先使用文件中的数据,如果没有则使用内存中的数据
merged_peaks = {**existing_peaks, **saved_peaks}
# logger.info(f"合并后的峰值数据: {merged_peaks}")
self.positions.clear()
for pos in live_positions:
# 获取该持仓之前记录的峰值,优先从文件中获取
restored_peak = merged_peaks.get(pos.ticket, 0.0)
new_position = {
'ticket': pos.ticket, 'symbol': pos.symbol, 'entry_price': pos.price_open,
'entry_time': pd.to_datetime(pos.time, unit='s'),
'position_type': 'long' if pos.type == 0 else 'short',
'quantity': pos.volume, 'peak_profit_pct': restored_peak
}
self.positions.append(new_position)
logger.info(f"同步持仓 {pos.ticket}: 恢复峰值={restored_peak:.6%}")
logger.info(f"持仓已从MT5同步: {len(self.positions)}")
def get_trade_summary(self):
if not self.closed_trades: return {}
profits = [t['profit_loss'] for t in self.closed_trades]
return {
'total_trades': len(self.closed_trades),
'winning_trades': len([p for p in profits if p > 0]),
'losing_trades': len([p for p in profits if p < 0]),
'win_rate': (len([p for p in profits if p > 0]) / len(profits) * 100) if profits else 0,
'total_profit_loss': sum(profits),
'avg_profit_loss': np.mean(profits) if profits else 0,
'max_profit': max(profits) if profits else 0,
'max_loss': min(profits) if profits else 0
}
def save_trade_history(self, base_filename):
if not self.closed_trades: return
df = pd.DataFrame(self.closed_trades)
df.to_csv(f"{base_filename}.csv", index=False, encoding='utf-8-sig')
with open(f"{base_filename}.json", 'w', encoding='utf-8') as f:
json.dump(self.closed_trades, f, ensure_ascii=False, indent=2, default=str)
logger.info(f"交易记录已保存到 {base_filename}.csv/.json")
def _load_peak_data(self):
"""从文件加载峰值数据"""
try:
if os.path.exists(self.peak_data_file):
with open(self.peak_data_file, 'r', encoding='utf-8') as f:
peak_data = json.load(f)
# logger.info(f"从文件加载峰值数据: {peak_data}")
return peak_data
else:
logger.info("峰值数据文件不存在,使用空数据")
return {}
except Exception as e:
logger.error(f"加载峰值数据失败: {e}")
return {}
def _save_peak_data(self):
"""保存峰值数据到文件"""
try:
peak_data = {pos['ticket']: pos.get('peak_profit_pct', 0.0) for pos in self.positions}
with open(self.peak_data_file, 'w', encoding='utf-8') as f:
json.dump(peak_data, f, ensure_ascii=False, indent=2)
#logger.info(f"峰值数据已保存到文件: {peak_data}")
except Exception as e:
logger.error(f"保存峰值数据失败: {e}")
def cleanup_peak_data(self):
"""清理已平仓持仓的峰值数据"""
try:
if os.path.exists(self.peak_data_file):
with open(self.peak_data_file, 'r', encoding='utf-8') as f:
peak_data = json.load(f)
# 获取当前持仓的ticket列表
current_tickets = {pos['ticket'] for pos in self.positions}
# 清理已平仓持仓的数据
cleaned_peak_data = {ticket: peak for ticket, peak in peak_data.items()
if ticket in current_tickets}
# 保存清理后的数据
with open(self.peak_data_file, 'w', encoding='utf-8') as f:
json.dump(cleaned_peak_data, f, ensure_ascii=False, indent=2)
logger.info(f"清理峰值数据完成,保留 {len(cleaned_peak_data)} 个持仓数据")
except Exception as e:
logger.error(f"清理峰值数据失败: {e}")
+202
View File
@@ -0,0 +1,202 @@
import MetaTrader5 as mt5
import pandas as pd
from logger import setup_logger
logger = setup_logger()
def initialize():
if not mt5.initialize():
logger.error("MT5初始化失败,错误代码:%d", mt5.last_error())
return False
return True
def shutdown():
mt5.shutdown()
def get_rates(symbol, timeframe, count, start_date=None, end_date=None):
"""
获取历史数据
参数:
- symbol: 交易品种
- timeframe: 时间周期
- count: 数据量 (当start_date和end_date都为None时使用)
- start_date: 开始日期 (格式: "YYYY-MM-DD" 或 datetime对象)
- end_date: 结束日期 (格式: "YYYY-MM-DD" 或 datetime对象)
"""
# 检查MT5连接状态
if not mt5.terminal_info():
logger.warning("MT5终端未连接,尝试重新连接...")
if not initialize():
logger.error("MT5重新连接失败")
return None
# 检查交易品种是否可用
symbol_info = mt5.symbol_info(symbol)
if symbol_info is None:
logger.error(f"交易品种 {symbol} 不可用")
return None
if not symbol_info.visible:
logger.info(f"交易品种 {symbol} 不可见,尝试启用...")
if not mt5.symbol_select(symbol, True):
logger.error(f"无法启用交易品种 {symbol}")
return None
if start_date is not None and end_date is not None:
# 使用日期范围获取数据
import datetime
# 转换字符串为datetime对象
if isinstance(start_date, str):
start_date = datetime.datetime.strptime(start_date, "%Y-%m-%d")
if isinstance(end_date, str):
end_date = datetime.datetime.strptime(end_date, "%Y-%m-%d")
# MetaTrader5需要UTC时间,且copy_rates_range需要timezone-aware的datetime对象
# 转换为UTC timezone
utc_timezone = datetime.timezone.utc
start_utc = start_date.replace(tzinfo=utc_timezone)
end_utc = end_date.replace(hour=23, minute=59, second=59, tzinfo=utc_timezone)
try:
rates = mt5.copy_rates_range(symbol, timeframe, start_utc, end_utc)
if rates is None:
logger.info(f"获取{symbol}{start_date.date()}{end_date.date()}的历史数据失败")
return None
logger.debug(f"成功获取{symbol}{start_date.date()}{end_date.date()}的历史数据,共{len(rates)}")
except Exception as e:
logger.error(f"使用日期范围获取数据失败: {e}")
logger.info("回退到使用数据量获取数据")
rates = mt5.copy_rates_from_pos(symbol, timeframe, 0, count)
if rates is None:
logger.info(f"获取{symbol}历史数据失败")
return None
logger.debug(f"成功获取{symbol}历史数据,共{len(rates)}")
else:
# 使用数据量获取数据
rates = mt5.copy_rates_from_pos(symbol, timeframe, 0, count)
if rates is None:
logger.info(f"获取{symbol}历史数据失败")
return None
logger.debug(f"成功获取{symbol}历史数据,共{len(rates)}")
return rates
def has_open_position(symbol):
positions = mt5.positions_get(symbol=symbol)
return positions is not None and len(positions) > 0
def close_all(symbol):
positions = mt5.positions_get(symbol=symbol)
if positions is None:
return
for pos in positions:
request = {
"action": mt5.TRADE_ACTION_DEAL,
"position": pos.ticket,
"symbol": symbol,
"volume": pos.volume,
"type": mt5.ORDER_TYPE_SELL if pos.type == 0 else mt5.ORDER_TYPE_BUY,
"price": mt5.symbol_info_tick(symbol).bid if pos.type == 0 else mt5.symbol_info_tick(symbol).ask,
"deviation": 20,
"magic": 234000,
"comment": "Close position",
"type_filling": mt5.ORDER_FILLING_RETURN,
}
mt5.order_send(request)
def send_order(symbol, order_type, volume=0.01):
symbol_info_tick = mt5.symbol_info_tick(symbol)
if symbol_info_tick is None:
logger.error(f"无法获取{symbol}行情")
return None
price = symbol_info_tick.ask if order_type == "buy" else symbol_info_tick.bid
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": mt5.ORDER_FILLING_RETURN,
}
result = mt5.order_send(request)
if result.retcode != mt5.TRADE_RETCODE_DONE:
logger.error(f"下单失败,retcode={result.retcode}")
return None
else:
logger.info(f"下单成功: {order_type} {symbol} {volume}, ticket: {result.order}")
return result
def close_position(ticket, symbol, volume):
"""根据ticket平掉一个特定的仓位"""
# In MT5, you close a position by creating an opposite order.
# We need to get the position details first.
positions = mt5.positions_get(ticket=ticket)
if not positions:
logger.error(f"无法找到ticket为 {ticket} 的持仓")
return False
pos = positions[0] # positions_get returns a tuple of objects
request = {
"action": mt5.TRADE_ACTION_DEAL,
"position": pos.ticket,
"symbol": symbol,
"volume": volume,
"type": mt5.ORDER_TYPE_SELL if pos.type == 0 else mt5.ORDER_TYPE_BUY, # pos.type == 0 is a BUY position
"price": mt5.symbol_info_tick(symbol).bid if pos.type == 0 else mt5.symbol_info_tick(symbol).ask,
"deviation": 20,
"magic": 234000,
"comment": f"Close position {ticket}",
"type_filling": mt5.ORDER_FILLING_RETURN,
}
result = mt5.order_send(request)
if result.retcode != mt5.TRADE_RETCODE_DONE:
logger.error(f"平仓失败 ticket {ticket}, retcode={result.retcode}")
return False
else:
logger.info(f"平仓成功 ticket {ticket}")
return True
def get_current_price(symbol):
"""获取当前价格"""
try:
# 检查MT5连接状态
if not mt5.terminal_info():
logger.warning("MT5终端未连接,尝试重新连接...")
if not initialize():
logger.error("MT5重新连接失败")
return None
# 检查交易品种是否可用
symbol_info = mt5.symbol_info(symbol)
if symbol_info is None:
logger.error(f"交易品种 {symbol} 不可用")
return None
if not symbol_info.visible:
logger.info(f"交易品种 {symbol} 不可见,尝试启用...")
if not mt5.symbol_select(symbol, True):
logger.error(f"无法启用交易品种 {symbol}")
return None
tick = mt5.symbol_info_tick(symbol)
if tick is None:
logger.error(f"无法获取 {symbol} 的价格信息")
return None
return {
'bid': tick.bid,
'ask': tick.ask,
'last': tick.last,
'time': pd.to_datetime(tick.time, unit='s')
}
except Exception as e:
logger.error(f"获取当前价格失败: {e}")
return None