mirror of
https://github.com/silencesdg/mt5_python_ea_suite.git
synced 2026-07-28 03:07:48 +00:00
766b52ca61
- 100x: SL-50%=23点, TP+100%=46点 - 2000x: SL-1000%=23点, TP+2000%=46点(美元风险完全一致) - 所有 margin% 参数(stop/profit/trailing/time_exit)全部自动缩放
522 lines
22 KiB
Python
522 lines
22 KiB
Python
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, SIMULATION_CONFIG
|
||
)
|
||
from core.risk.exit_rules import ExitRuleEngine, ExitContext
|
||
|
||
|
||
class PositionManager:
|
||
"""持仓管理器(重写版)
|
||
|
||
关键改进:
|
||
1. update_equity() 包含浮盈浮亏
|
||
2. max_daily_loss 真正落地生效
|
||
3. 退出规则委托给 ExitRuleEngine(策略模式)
|
||
"""
|
||
|
||
def __init__(self, data_provider, trade_direction="both", risk_config: dict = None,
|
||
persist_peaks: bool = True):
|
||
self.data_provider = data_provider
|
||
self.symbol = SYMBOL
|
||
self.trade_direction = trade_direction
|
||
self._persist_peaks = persist_peaks
|
||
|
||
# 风险管理参数
|
||
self._risk_config = risk_config or RISK_CONFIG
|
||
risk = self._risk_config
|
||
self.stop_loss_pct = risk.get("stop_loss_pct", -0.10)
|
||
self.profit_retracement_pct = risk.get("profit_retracement_pct", 0.10)
|
||
self.min_profit_for_trailing = risk.get("min_profit_for_trailing", 0.01)
|
||
self.take_profit_pct = risk.get("take_profit_pct", 0.20)
|
||
self.max_holding_minutes = risk.get("max_holding_minutes", 60)
|
||
self.min_profit_for_time_exit = risk.get("min_profit_for_time_exit", 0.001)
|
||
self.enable_time_based_exit = RISK_CONFIG_CONST.get("enable_time_based_exit", False)
|
||
self.max_daily_loss = risk.get("max_daily_loss", -0.30)
|
||
|
||
# ★ 硬止损倍率(MT5 服务器端兜底)
|
||
self.hard_sl_mult = risk.get("hard_sl_multiplier", 1.5)
|
||
self.hard_tp_mult = risk.get("hard_tp_multiplier", 1.3)
|
||
|
||
# ★ 计算杠杆(仅影响%基准,不改实际杠杆)
|
||
self.risk_leverage = risk.get("risk_leverage", 0)
|
||
if self.risk_leverage <= 0:
|
||
try:
|
||
acct = self.data_provider.get_account_info()
|
||
self.risk_leverage = acct.leverage if hasattr(acct, 'leverage') else acct.get('leverage', 2000)
|
||
except Exception:
|
||
self.risk_leverage = 2000
|
||
|
||
# ★ 杠杆自适应缩放:config 中的 % 以 100x 为基准
|
||
# 2000x 时倍率 = 20,-50% → -1000% → 美元风险不变
|
||
self._lev_ratio = self.risk_leverage / 100.0
|
||
|
||
# 风险管理参数(已缩放)
|
||
self.stop_loss_pct = risk.get("stop_loss_pct", -0.10) * self._lev_ratio
|
||
self.profit_retracement_pct = risk.get("profit_retracement_pct", 0.10) * self._lev_ratio
|
||
self.min_profit_for_trailing = risk.get("min_profit_for_trailing", 0.01) * self._lev_ratio
|
||
self.take_profit_pct = risk.get("take_profit_pct", 0.20) * self._lev_ratio
|
||
self.min_profit_for_time_exit = risk.get("min_profit_for_time_exit", 0.001) * self._lev_ratio
|
||
|
||
# 持仓和交易记录
|
||
self.positions = []
|
||
self.closed_trades = []
|
||
self.total_equity = self.initial_capital
|
||
|
||
# 冷却期:平仓后N根K线内不重新开仓(防反复进出)
|
||
self.cooldown_bars = risk.get("cooldown_bars", 30)
|
||
self._cooldown_counter = 0
|
||
|
||
# 拒单去重:避免连续刷相同拒绝日志
|
||
self._last_rejected_msg = None
|
||
|
||
# 退出规则引擎
|
||
exit_config = {
|
||
"stop_loss_pct": self.stop_loss_pct,
|
||
"take_profit_pct": self.take_profit_pct,
|
||
"min_profit_for_trailing": self.min_profit_for_trailing,
|
||
"profit_retracement_pct": self.profit_retracement_pct,
|
||
"enable_time_based_exit": self.enable_time_based_exit,
|
||
"max_holding_minutes": self.max_holding_minutes,
|
||
"min_profit_for_time_exit": self.min_profit_for_time_exit,
|
||
"max_daily_loss": self.max_daily_loss,
|
||
}
|
||
self.exit_engine = ExitRuleEngine(exit_config)
|
||
|
||
# 峰值数据持久化(优化器中禁用文件I/O避免多进程竞争)
|
||
self.peak_data_file = "position_peaks.json"
|
||
if self._persist_peaks:
|
||
self._load_peak_data()
|
||
|
||
# 对冲管理器
|
||
from config import HEDGE_CONFIG
|
||
self.hedge_manager = None # 对冲模块已禁用
|
||
|
||
# ★ 一票制并发锁:防止同一周期内多个信号穿透
|
||
# 开仓前+1,完成后-1,持仓检查时累加 pending 计数
|
||
self._pending_long = 0
|
||
self._pending_short = 0
|
||
|
||
# ── 仓位计算 ──
|
||
|
||
def _calculate_position_size(self, capital_to_allocate, current_price):
|
||
price = current_price['last']
|
||
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)
|
||
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
|
||
|
||
value_of_one_lot = price * contract_size
|
||
if value_of_one_lot == 0:
|
||
return 0.0
|
||
|
||
volume = capital_to_allocate / value_of_one_lot
|
||
volume = round(volume / volume_step) * volume_step
|
||
volume = max(min_volume, min(volume, max_volume))
|
||
return volume
|
||
|
||
# ── 去重日志 ──
|
||
|
||
def _reject_log(self, msg):
|
||
"""只在首次出现或拒绝原因变化时打印"""
|
||
if msg != self._last_rejected_msg:
|
||
logger.info(msg)
|
||
self._last_rejected_msg = msg
|
||
|
||
def _clear_reject_log(self):
|
||
"""开仓成功/平仓后重置去重状态"""
|
||
self._last_rejected_msg = None
|
||
|
||
# ── 开仓 ──
|
||
|
||
def open_position(self, direction, current_price, signal_strength=0.0, dry_run=False):
|
||
# 冷却期检查
|
||
if self._cooldown_counter > 0:
|
||
self._cooldown_counter -= 1
|
||
return False
|
||
|
||
position_type = 'long' if direction == 'buy' else 'short'
|
||
|
||
# 开仓前更新权益,确保仓位大小基于最新资产(含浮盈浮亏)
|
||
self.update_equity()
|
||
|
||
if dry_run:
|
||
execution_price = current_price['ask'] if direction == 'buy' else current_price['bid']
|
||
else:
|
||
execution_price = current_price['last']
|
||
|
||
# 交易方向限制
|
||
if self.trade_direction == "long" and direction == "sell":
|
||
self._reject_log("当前配置只允许做多,忽略卖出信号")
|
||
return False
|
||
elif self.trade_direction == "short" and direction == "buy":
|
||
self._reject_log("当前配置只允许做空,忽略买入信号")
|
||
return False
|
||
|
||
# ★ 每日亏损检查(幽灵代码落地)
|
||
current_time = current_price.get('time', pd.Timestamp.now())
|
||
if self._check_max_daily_loss(current_time):
|
||
self._reject_log("当日亏损已达上限,禁止开新仓")
|
||
return False
|
||
|
||
# 最大持仓数检查 — 优先使用 risk_config 传入值,回退到 REALTIME_CONFIG
|
||
# ★ 加入 pending 计数器防并发穿透:同一周期内多信号同时检查时,第一个开仓后
|
||
# 其 pending 计数会让后续信号看到正确数量,不会误开
|
||
max_key = f'max_{position_type}_positions'
|
||
max_positions = self._risk_config.get(max_key, None)
|
||
if max_positions is None:
|
||
from config import REALTIME_CONFIG
|
||
max_positions = REALTIME_CONFIG.get(max_key, 1)
|
||
pending_count = self._pending_long if position_type == 'long' else self._pending_short
|
||
current_count = len([p for p in self.positions if p['position_type'] == position_type]) + pending_count
|
||
if 0 < max_positions <= current_count:
|
||
self._reject_log(f"已达{position_type}最大持仓({max_positions}),忽略信号")
|
||
return False
|
||
|
||
# ★ 占位:标记一个 pending 订单,防止并发穿透
|
||
if position_type == 'long':
|
||
self._pending_long += 1
|
||
else:
|
||
self._pending_short += 1
|
||
|
||
try:
|
||
# 资金分配
|
||
capital_pct = self.long_capital_pct if direction == 'buy' else self.short_capital_pct
|
||
capital_for_trade = self.total_equity * capital_pct
|
||
|
||
position_volume = self._calculate_position_size(capital_for_trade, {'last': execution_price})
|
||
if position_volume <= 0:
|
||
self._reject_log("仓位大小为0,无法开仓")
|
||
return False
|
||
|
||
# ★ 计算 MT5 硬止损/硬止盈(兜底安全网)
|
||
# 公式:保证金% × 开仓保证金 × 倍率 → 美元 → 金价点数 → 价格
|
||
hard_sl_price = None
|
||
hard_tp_price = None
|
||
contract_size = self._get_contract_size()
|
||
points_per_dollar = 1.0 / (position_volume * contract_size) if position_volume > 0 else 0
|
||
|
||
# 开仓保证金
|
||
position_margin = (execution_price * position_volume * contract_size) / self.risk_leverage
|
||
sl_dollars = abs(self.stop_loss_pct) * position_margin * self.hard_sl_mult # 正数
|
||
tp_dollars = abs(self.take_profit_pct) * position_margin * self.hard_tp_mult
|
||
sl_points = sl_dollars * points_per_dollar
|
||
tp_points = tp_dollars * points_per_dollar
|
||
|
||
if direction == 'buy':
|
||
if self.hard_sl_mult > 0:
|
||
hard_sl_price = round(execution_price - sl_points, 2)
|
||
if self.hard_tp_mult > 0:
|
||
hard_tp_price = round(execution_price + tp_points, 2)
|
||
else:
|
||
if self.hard_sl_mult > 0:
|
||
hard_sl_price = round(execution_price + sl_points, 2)
|
||
if self.hard_tp_mult > 0:
|
||
hard_tp_price = round(execution_price - tp_points, 2)
|
||
|
||
order_result = self.data_provider.send_order(
|
||
self.symbol, direction, position_volume,
|
||
sl=hard_sl_price, tp=hard_tp_price
|
||
)
|
||
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
|
||
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_time,
|
||
'position_type': position_type,
|
||
'quantity': position_volume,
|
||
'peak_profit_pct': 0.0,
|
||
}
|
||
self.positions.append(new_position)
|
||
self._clear_reject_log()
|
||
logger.info(f"开仓成功: {direction} @ {execution_price:.2f}, 手数: {position_volume:.2f}, Ticket: {order_id}")
|
||
return True
|
||
else:
|
||
logger.error(f"开仓失败: {direction} @ {execution_price:.2f}")
|
||
return False
|
||
finally:
|
||
# ★ 释放 pending 占位(无论成功/失败/异常)
|
||
if position_type == 'long':
|
||
self._pending_long -= 1
|
||
else:
|
||
self._pending_short -= 1
|
||
|
||
# ── 持仓监控 ──
|
||
|
||
def monitor_positions(self, current_price, dry_run=False, weighted_signal=0.0):
|
||
if not self.positions:
|
||
return
|
||
|
||
current_time = current_price.get('time', pd.Timestamp.now())
|
||
|
||
positions_to_remove = []
|
||
for position in self.positions:
|
||
# 确定平仓执行价格
|
||
if dry_run:
|
||
close_price = current_price['bid'] if position['position_type'] == 'long' else current_price['ask']
|
||
else:
|
||
close_price = current_price['last']
|
||
|
||
# 用执行价格计算盈亏(与平仓时一致,避免中间价偏差)
|
||
pnl_pct = self._calculate_pnl_pct(position, close_price)
|
||
old_peak = position.get('peak_profit_pct', 0)
|
||
new_peak = max(old_peak, pnl_pct)
|
||
position['peak_profit_pct'] = new_peak
|
||
|
||
if new_peak > old_peak:
|
||
self._save_peak_data()
|
||
|
||
# 退出规则检查
|
||
ctx = ExitContext(
|
||
current_profit_pct=pnl_pct,
|
||
peak_profit_pct=new_peak,
|
||
entry_time=position['entry_time'],
|
||
current_time=current_time,
|
||
symbol=self.symbol,
|
||
position=position,
|
||
)
|
||
action, reason = self.exit_engine.check(ctx)
|
||
|
||
if action == "close":
|
||
logger.info(f"平仓信号触发 (Ticket: {position['ticket']}): {reason}")
|
||
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:
|
||
self.positions = [p for p in self.positions if p not in positions_to_remove]
|
||
self._cooldown_counter = self.cooldown_bars
|
||
self._clear_reject_log()
|
||
self.update_equity()
|
||
self.cleanup_peak_data()
|
||
|
||
# ── 对冲评估 ──
|
||
if self.positions and self.hedge_manager:
|
||
hedge_actions = self.hedge_manager.evaluate(weighted_signal, current_price)
|
||
for action, target, reason in hedge_actions:
|
||
if action == "hedge":
|
||
self.hedge_manager.execute_hedge(target, reason, current_price)
|
||
elif action == "unhedge_only":
|
||
self.hedge_manager.execute_unhedge_only(target, reason)
|
||
elif action == "close_original":
|
||
self.hedge_manager.execute_close_original(target, reason)
|
||
elif action == "close_both":
|
||
self.hedge_manager.execute_close_both(target, reason)
|
||
|
||
# ── 盈亏计算 ──
|
||
|
||
def _get_contract_size(self) -> float:
|
||
"""获取合约规格(缓存避免重复API调用)"""
|
||
if not hasattr(self, '_cached_contract_size'):
|
||
symbol_info = self.data_provider.get_symbol_info(self.symbol)
|
||
self._cached_contract_size = (
|
||
symbol_info['trade_contract_size']
|
||
if symbol_info and isinstance(symbol_info, dict)
|
||
else 100
|
||
) if symbol_info else 100
|
||
return self._cached_contract_size
|
||
|
||
def _calculate_pnl_pct(self, position, current_price_value):
|
||
"""★ 保证金百分比盈亏 = 美元盈亏 / 开仓保证金"""
|
||
entry_price = position['entry_price']
|
||
quantity = position.get('quantity', 0.01)
|
||
contract_size = self._get_contract_size()
|
||
|
||
# 美元盈亏
|
||
if position['position_type'] == 'long':
|
||
dollar_pnl = (current_price_value - entry_price) * quantity * contract_size
|
||
else:
|
||
dollar_pnl = (entry_price - current_price_value) * quantity * contract_size
|
||
|
||
# 开仓保证金 = 合约价值 / 计算杠杆
|
||
margin = (entry_price * quantity * contract_size) / self.risk_leverage
|
||
return dollar_pnl / margin if margin > 0 else 0.0
|
||
|
||
def _calculate_unrealized_pnl(self) -> float:
|
||
"""计算所有持仓的浮动盈亏"""
|
||
if not self.positions:
|
||
return 0.0
|
||
current_price_data = self.data_provider.get_current_price(self.symbol)
|
||
if not current_price_data:
|
||
return 0.0
|
||
current_price = current_price_data['last']
|
||
contract_size = self._get_contract_size()
|
||
total = 0.0
|
||
for pos in self.positions:
|
||
if pos['position_type'] == 'long':
|
||
pnl = (current_price - pos['entry_price']) * pos['quantity'] * contract_size
|
||
else:
|
||
pnl = (pos['entry_price'] - current_price) * pos['quantity'] * contract_size
|
||
total += pnl
|
||
return total
|
||
|
||
def _record_closed_trade(self, position, close_price, close_reason):
|
||
contract_size = self._get_contract_size()
|
||
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 update_equity(self):
|
||
"""★ 修正:包含浮盈浮亏"""
|
||
if self.data_provider.is_live:
|
||
account_info = self.data_provider.get_account_info()
|
||
if account_info:
|
||
equity = account_info if isinstance(account_info, dict) else account_info.equity
|
||
if isinstance(equity, dict):
|
||
equity = equity.get('equity', self.total_equity)
|
||
self.total_equity = equity
|
||
else:
|
||
realized_pnl = sum(t['profit_loss'] for t in self.closed_trades)
|
||
unrealized_pnl = self._calculate_unrealized_pnl()
|
||
self.total_equity = self.initial_capital + realized_pnl + unrealized_pnl
|
||
|
||
def _check_max_daily_loss(self, current_time) -> bool:
|
||
"""★ 新增:检查当日最大亏损是否触发"""
|
||
if self.max_daily_loss >= 0:
|
||
return False
|
||
today = current_time.date() if hasattr(current_time, 'date') else pd.Timestamp(current_time).date()
|
||
daily_pnl = sum(
|
||
t['profit_loss'] for t in self.closed_trades
|
||
if hasattr(t.get('close_time'), 'date') and t['close_time'].date() == today
|
||
or hasattr(t.get('entry_time'), 'date') and t['entry_time'].date() == today
|
||
)
|
||
daily_loss_pct = daily_pnl / self.initial_capital if self.initial_capital > 0 else 0
|
||
return daily_loss_pct <= self.max_daily_loss
|
||
|
||
# ── 同步 ──
|
||
|
||
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()
|
||
existing_peaks = {pos['ticket']: pos.get('peak_profit_pct', 0.0) for pos in self.positions}
|
||
merged_peaks = {**existing_peaks, **saved_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)
|
||
if live_positions:
|
||
logger.debug(f"MT5同步: {len(self.positions)}个持仓")
|
||
self.update_equity()
|
||
|
||
# ── 交易摘要 ──
|
||
|
||
def get_trade_summary(self) -> dict:
|
||
if not self.closed_trades:
|
||
return {
|
||
'total_trades': 0, 'winning_trades': 0, 'losing_trades': 0,
|
||
'win_rate': 0, 'total_profit_loss': 0.0,
|
||
'avg_profit_loss': 0.0, 'max_profit': 0.0, 'max_loss': 0.0
|
||
}
|
||
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': float(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:
|
||
return json.load(f)
|
||
except Exception as e:
|
||
logger.error(f"加载峰值数据失败: {e}")
|
||
return {}
|
||
|
||
def _save_peak_data(self):
|
||
try:
|
||
if not self._persist_peaks:
|
||
return
|
||
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)
|
||
except Exception as e:
|
||
logger.error(f"保存峰值数据失败: {e}")
|
||
|
||
def cleanup_peak_data(self):
|
||
try:
|
||
if not self._persist_peaks:
|
||
return
|
||
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)
|
||
current_tickets = {pos['ticket'] for pos in self.positions}
|
||
cleaned = {t: p for t, p in peak_data.items() if t in current_tickets}
|
||
with open(self.peak_data_file, 'w', encoding='utf-8') as f:
|
||
json.dump(cleaned, f, ensure_ascii=False, indent=2)
|
||
except Exception as e:
|
||
logger.error(f"清理峰值数据失败: {e}")
|