Files
mt5_python_ea_suite/core/risk/position.py
T
silencesdg 766b52ca61 feat: 杠杆自适应缩放——config%以100x为基准,自动×(杠杆/100)
- 100x: SL-50%=23点, TP+100%=46点
- 2000x: SL-1000%=23点, TP+2000%=46点(美元风险完全一致)
- 所有 margin% 参数(stop/profit/trailing/time_exit)全部自动缩放
2026-05-15 13:56:43 +08:00

522 lines
22 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 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}")