mirror of
https://github.com/silencesdg/mt5_python_ea_suite.git
synced 2026-07-31 20:57:45 +00:00
重构项目架构,新增 MT5 代理服务
- 重构核心模块:DataProvider 依赖注入、RiskController 门面、信号注册表 - 新增 FastAPI 代理服务 (run/server.py),支持局域网远程调用 MT5 - 新增 RemoteDataProvider + AttrDict,远端无缝替代 LiveDataProvider - 新增序列化模块,MT5 对象转 JSON 兼容格式 - 重构入口点至 run/ 包,支持 python -m run.realtime/server/backtest/optimize - 更新 CLAUDE.md 文档 Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
from core.data.abc import DataProvider
|
||||
from core.data.live import LiveDataProvider
|
||||
from core.data.dryrun import DryRunDataProvider
|
||||
from core.data.backtest import BacktestDataProvider
|
||||
from core.data.multi_tf import MultiTimeframeDataStore
|
||||
from core.data.remote import RemoteDataProvider
|
||||
@@ -0,0 +1,45 @@
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class DataProvider(ABC):
|
||||
"""数据提供者抽象基类 — 定义所有数据访问的统一接口"""
|
||||
|
||||
@property
|
||||
def is_live(self):
|
||||
return False
|
||||
|
||||
@abstractmethod
|
||||
def initialize(self):
|
||||
"""初始化连接"""
|
||||
|
||||
@abstractmethod
|
||||
def shutdown(self):
|
||||
"""关闭连接"""
|
||||
|
||||
@abstractmethod
|
||||
def get_current_price(self, symbol):
|
||||
"""获取当前价格: {'bid', 'ask', 'last', 'time'}"""
|
||||
|
||||
@abstractmethod
|
||||
def get_historical_data(self, symbol, timeframe, count, **kwargs):
|
||||
"""获取历史K线数据"""
|
||||
|
||||
@abstractmethod
|
||||
def get_account_info(self):
|
||||
"""获取账户信息"""
|
||||
|
||||
@abstractmethod
|
||||
def get_positions(self, symbol):
|
||||
"""获取当前持仓"""
|
||||
|
||||
@abstractmethod
|
||||
def get_symbol_info(self, symbol):
|
||||
"""获取品种信息(合约规格等)"""
|
||||
|
||||
@abstractmethod
|
||||
def send_order(self, symbol, order_type, volume):
|
||||
"""发送订单"""
|
||||
|
||||
@abstractmethod
|
||||
def close_position(self, ticket, symbol, volume):
|
||||
"""平仓"""
|
||||
@@ -0,0 +1,80 @@
|
||||
import numpy as np
|
||||
from logger import logger
|
||||
from core.data.abc import DataProvider
|
||||
from core.data.multi_tf import MultiTimeframeDataStore
|
||||
from config import SIMULATION_CONFIG
|
||||
|
||||
|
||||
class BacktestDataProvider(DataProvider):
|
||||
"""回测数据提供者(重写版) — 通过 MultiTimeframeDataStore 支持多周期数据
|
||||
|
||||
关键改进:
|
||||
- get_historical_data() 的 timeframe 参数现在真正生效
|
||||
- 内部持有 MultiTimeframeDataStore,按需从M1生成更高周期数据
|
||||
- current_index 推进时各周期数据指针同步
|
||||
"""
|
||||
|
||||
def __init__(self, multi_tf_store: MultiTimeframeDataStore,
|
||||
initial_equity: float = 10000):
|
||||
self.multi_tf = multi_tf_store
|
||||
self.current_index = 0
|
||||
self.equity = initial_equity
|
||||
self.simulated_ticket_counter = 0
|
||||
self.spread = SIMULATION_CONFIG.get("spread", 16)
|
||||
|
||||
@property
|
||||
def total_length(self) -> int:
|
||||
return self.multi_tf.length
|
||||
|
||||
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 >= self.total_length:
|
||||
return None
|
||||
row = self.multi_tf.main_df.iloc[self.current_index]
|
||||
spread_half = self.spread * 0.01 / 2
|
||||
return {
|
||||
'bid': row.close - spread_half,
|
||||
'ask': row.close + spread_half,
|
||||
'last': row.close,
|
||||
'time': row.name
|
||||
}
|
||||
|
||||
def get_historical_data(self, symbol, timeframe, count, **kwargs):
|
||||
"""获取历史数据 — timeframe 参数现在真正生效"""
|
||||
return self.multi_tf.get_historical_data(
|
||||
symbol, timeframe, count, self.current_index
|
||||
)
|
||||
|
||||
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) -> bool:
|
||||
if self.current_index < self.total_length - 1:
|
||||
self.current_index += 1
|
||||
return True
|
||||
return False
|
||||
@@ -0,0 +1,62 @@
|
||||
import pandas as pd
|
||||
from logger import logger
|
||||
from core.data.abc import DataProvider
|
||||
from core.data.live import LiveDataProvider
|
||||
from config import SIMULATION_CONFIG
|
||||
|
||||
|
||||
class DryRunDataProvider(DataProvider):
|
||||
"""纸上交易数据提供者 — 使用实时价格但不发送真实订单"""
|
||||
|
||||
def __init__(self, initial_equity=10000, leverage=100):
|
||||
self.simulated_ticket_counter = 0
|
||||
self.equity = initial_equity
|
||||
self.leverage = leverage
|
||||
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()
|
||||
|
||||
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
|
||||
mid_price = price_data['last']
|
||||
return {
|
||||
'bid': mid_price - spread_half,
|
||||
'ask': mid_price + spread_half,
|
||||
'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):
|
||||
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
|
||||
@@ -0,0 +1,172 @@
|
||||
import MetaTrader5 as mt5
|
||||
import pandas as pd
|
||||
from logger import logger
|
||||
from core.data.abc import DataProvider
|
||||
|
||||
|
||||
class LiveDataProvider(DataProvider):
|
||||
"""实盘数据提供者 — 封装真实MT5 API调用"""
|
||||
|
||||
@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:
|
||||
last_price = tick.last if tick.last != 0 else (tick.bid + tick.ask) / 2
|
||||
return {
|
||||
'bid': tick.bid, 'ask': tick.ask, 'last': last_price,
|
||||
'time': pd.to_datetime(tick.time, unit='s')
|
||||
}
|
||||
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
|
||||
|
||||
price = price_data['ask'] if order_type == "buy" else price_data['bid']
|
||||
|
||||
if not mt5.terminal_info().trade_allowed:
|
||||
logger.error("MT5终端未启用自动交易")
|
||||
return None
|
||||
|
||||
account_info = mt5.account_info()
|
||||
if account_info and not account_info.trade_allowed:
|
||||
logger.error("当前账户不允许自动交易")
|
||||
return None
|
||||
|
||||
symbol_info = mt5.symbol_info(symbol)
|
||||
if not symbol_info:
|
||||
logger.error(f"无法获取 {symbol} 的品种信息")
|
||||
return None
|
||||
|
||||
# MT5 filling_mode 是位图,用位与判断
|
||||
fm = symbol_info.filling_mode
|
||||
# MQL5 filling_mode 位图: FOK=1, IOC=2
|
||||
if fm & 1:
|
||||
filling_mode = mt5.ORDER_FILLING_FOK
|
||||
elif fm & 2:
|
||||
filling_mode = mt5.ORDER_FILLING_IOC
|
||||
else:
|
||||
filling_mode = mt5.ORDER_FILLING_RETURN
|
||||
|
||||
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("自动交易被禁用")
|
||||
elif result.retcode == 10030:
|
||||
if filling_mode != mt5.ORDER_FILLING_IOC:
|
||||
request["type_filling"] = mt5.ORDER_FILLING_IOC
|
||||
result = mt5.order_send(request)
|
||||
if result and result.retcode == 10030 and filling_mode != mt5.ORDER_FILLING_FOK:
|
||||
request["type_filling"] = mt5.ORDER_FILLING_FOK
|
||||
result = mt5.order_send(request)
|
||||
elif result.retcode != 10009:
|
||||
logger.error(f"下单失败,错误代码: {result.retcode}")
|
||||
|
||||
return result
|
||||
|
||||
def close_position(self, ticket, symbol, volume):
|
||||
positions = self.get_positions(symbol)
|
||||
if not positions:
|
||||
logger.error("未找到任何持仓")
|
||||
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 = 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:
|
||||
return False
|
||||
|
||||
fm = symbol_info.filling_mode
|
||||
# MQL5 filling_mode 位图: FOK=1, IOC=2
|
||||
if fm & 1:
|
||||
filling_mode = mt5.ORDER_FILLING_FOK
|
||||
elif fm & 2:
|
||||
filling_mode = mt5.ORDER_FILLING_IOC
|
||||
else:
|
||||
filling_mode = mt5.ORDER_FILLING_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,
|
||||
}
|
||||
|
||||
result = mt5.order_send(request)
|
||||
if result is None:
|
||||
logger.error(f"平仓请求返回None: Ticket={ticket}")
|
||||
return False
|
||||
if result.retcode == mt5.TRADE_RETCODE_DONE:
|
||||
logger.info(f"平仓成功: Ticket {ticket}")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"平仓失败: Ticket={ticket}, 错误码={result.retcode}")
|
||||
return False
|
||||
@@ -0,0 +1,154 @@
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from utils.constants import TIMEFRAME_TO_MINUTES, RESAMPLE_RULES, PERIOD_M1
|
||||
|
||||
|
||||
class MultiTimeframeDataStore:
|
||||
"""多周期数据存储 — 从M1数据按需生成更高周期的OHLC数据
|
||||
|
||||
回测/优化器模式:加载M1数据后,通过 pandas resample 生成 H1/H4/D1 等。
|
||||
实盘模式:不需要此组件(DataProvider 直接返回 MT5 原生数据)。
|
||||
|
||||
用法:
|
||||
store = MultiTimeframeDataStore()
|
||||
store.load_m1_data(m1_rates_array)
|
||||
h1_data = store.get_historical_data("XAUUSD", PERIOD_H1, 100, current_index=5000)
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._main_df = None
|
||||
self._main_timeframe = PERIOD_M1
|
||||
self._cache = {} # timeframe: DataFrame
|
||||
|
||||
def load_m1_data(self, rates: np.ndarray) -> None:
|
||||
"""加载M1原始数据并初始化缓存"""
|
||||
df = pd.DataFrame(rates)
|
||||
df['time'] = pd.to_datetime(df['time'], unit='s')
|
||||
df.set_index('time', inplace=True)
|
||||
df.sort_index(inplace=True)
|
||||
self._main_df = df
|
||||
self._cache = {PERIOD_M1: df}
|
||||
|
||||
@property
|
||||
def main_df(self) -> pd.DataFrame:
|
||||
return self._main_df
|
||||
|
||||
@property
|
||||
def length(self) -> int:
|
||||
if self._main_df is None:
|
||||
return 0
|
||||
return len(self._main_df)
|
||||
|
||||
def _resample(self, timeframe: int) -> pd.DataFrame:
|
||||
"""从M1数据 resample 到目标周期"""
|
||||
if timeframe == PERIOD_M1:
|
||||
return self._main_df
|
||||
|
||||
rule = RESAMPLE_RULES.get(timeframe)
|
||||
if rule is None:
|
||||
raise ValueError(f"不支持的周期: {timeframe}")
|
||||
|
||||
# 确保 M1 DataFrame 包含 OHLCV 列
|
||||
m1 = self._main_df[['open', 'high', 'low', 'close']].copy()
|
||||
if 'tick_volume' in self._main_df.columns:
|
||||
m1['tick_volume'] = self._main_df['tick_volume']
|
||||
|
||||
resampled = m1.resample(rule, label='right', closed='right').agg({
|
||||
'open': 'first',
|
||||
'high': 'max',
|
||||
'low': 'min',
|
||||
'close': 'last',
|
||||
})
|
||||
|
||||
if 'tick_volume' in m1.columns:
|
||||
resampled['tick_volume'] = m1['tick_volume'].resample(
|
||||
rule, label='right', closed='right').sum()
|
||||
|
||||
# 生成 time 列(Unix秒)
|
||||
resampled['time'] = [int(ts.timestamp()) for ts in resampled.index]
|
||||
|
||||
# 丢掉由未来数据填充出来的最后一个未完成 bar
|
||||
last_m1_time = self._main_df.index[-1]
|
||||
last_complete = resampled[resampled.index <= last_m1_time]
|
||||
if len(last_complete) > 0:
|
||||
resampled = last_complete
|
||||
elif len(resampled) > 0:
|
||||
logger_warning = None
|
||||
try:
|
||||
from logger import logger
|
||||
logger_warning = logger
|
||||
except Exception:
|
||||
pass
|
||||
if logger_warning:
|
||||
logger_warning.warning(
|
||||
f"resample {rule}: 所有bar都在最后M1时间之后,返回空DataFrame"
|
||||
)
|
||||
resampled = resampled.iloc[0:0]
|
||||
|
||||
return resampled
|
||||
|
||||
def ensure_timeframe(self, timeframe: int) -> pd.DataFrame:
|
||||
"""确保指定周期的数据已缓存,若未缓存则从M1生成"""
|
||||
if timeframe in self._cache:
|
||||
return self._cache[timeframe]
|
||||
|
||||
if self._main_df is None:
|
||||
raise RuntimeError("必须先调用 load_m1_data()")
|
||||
|
||||
tf_df = self._resample(timeframe)
|
||||
self._cache[timeframe] = tf_df
|
||||
return tf_df
|
||||
|
||||
def _find_tf_index(self, timeframe: int, m1_bar_index: int) -> int:
|
||||
"""将M1 bar索引映射到目标周期的已完成bar索引
|
||||
|
||||
返回目标周期中已经完成(不会看到未来)的最后一个 bar 的索引。
|
||||
即:目标周期 bar 的结束时间 <= 当前M1 bar 的时间。
|
||||
"""
|
||||
if self._main_df is None:
|
||||
return -1
|
||||
|
||||
tf_df = self.ensure_timeframe(timeframe)
|
||||
current_time = self._main_df.index[m1_bar_index]
|
||||
|
||||
# 找到结束时间 <= current_time 的最后一个目标周期 bar
|
||||
positions = tf_df.index.get_indexer([current_time], method='bfill')
|
||||
tf_idx = positions[0]
|
||||
|
||||
# bfill 返回的 bar 可能结束时间 > current_time(未完成),需要退回一个
|
||||
if tf_idx >= 0 and tf_idx < len(tf_df):
|
||||
if tf_df.index[tf_idx] > current_time:
|
||||
tf_idx -= 1
|
||||
|
||||
return tf_idx
|
||||
|
||||
def get_historical_data(self, symbol: str, timeframe: int,
|
||||
count: int, current_index: int) -> np.ndarray | None:
|
||||
"""从 current_index 位置向前获取 count 条指定周期的历史数据
|
||||
|
||||
Args:
|
||||
symbol: 品种名(当前未使用,为接口一致性保留)
|
||||
timeframe: MT5周期常量
|
||||
count: 需要的bar数量
|
||||
current_index: 当前M1 bar的索引(0-based)
|
||||
|
||||
Returns:
|
||||
numpy recarray 或 None(数据不足时)
|
||||
"""
|
||||
if self._main_df is None or current_index >= self.length:
|
||||
return None
|
||||
|
||||
tf_idx = self._find_tf_index(timeframe, current_index)
|
||||
|
||||
if tf_idx < count - 1:
|
||||
return None
|
||||
|
||||
tf_df = self.ensure_timeframe(timeframe)
|
||||
|
||||
start = tf_idx - count + 1
|
||||
end = tf_idx + 1
|
||||
|
||||
# 转换为MT5兼容的记录数组格式
|
||||
# 避免 index name 与 'time' 列冲突
|
||||
records = tf_df.iloc[start:end].reset_index(drop=True).to_records(index=False)
|
||||
return np.array(records)
|
||||
@@ -0,0 +1,119 @@
|
||||
import pandas as pd
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
from core.data.abc import DataProvider
|
||||
|
||||
|
||||
class AttrDict(dict):
|
||||
"""支持属性访问的 dict,兼容 isinstance(x, dict) 检查"""
|
||||
|
||||
def __getattr__(self, key):
|
||||
try:
|
||||
return self[key]
|
||||
except KeyError:
|
||||
raise AttributeError(key)
|
||||
|
||||
|
||||
class RemoteDataProvider(DataProvider):
|
||||
"""远端数据提供者 — 通过 HTTP 调用 MT5 代理服务"""
|
||||
|
||||
def __init__(self, host="127.0.0.1", port=5555):
|
||||
self.base_url = f"http://{host}:{port}/api"
|
||||
self._session = requests.Session()
|
||||
retry = Retry(total=3, backoff_factor=0.5, status_forcelist=[502, 503, 504])
|
||||
self._session.mount("http://", HTTPAdapter(max_retries=retry))
|
||||
|
||||
@property
|
||||
def is_live(self):
|
||||
return True
|
||||
|
||||
def initialize(self):
|
||||
try:
|
||||
resp = self._session.post(f"{self.base_url}/initialize", timeout=10)
|
||||
return resp.json().get("success", False)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def shutdown(self):
|
||||
try:
|
||||
self._session.post(f"{self.base_url}/shutdown", timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def get_current_price(self, symbol):
|
||||
try:
|
||||
resp = self._session.get(f"{self.base_url}/price/{symbol}", timeout=10)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
data = resp.json()
|
||||
data['time'] = pd.to_datetime(data['time'], unit='s')
|
||||
return data
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def get_historical_data(self, symbol, timeframe, count, **kwargs):
|
||||
try:
|
||||
resp = self._session.get(
|
||||
f"{self.base_url}/historical/{symbol}/{timeframe}/{count}",
|
||||
timeout=30,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
return resp.json()["rates"]
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def get_account_info(self):
|
||||
try:
|
||||
resp = self._session.get(f"{self.base_url}/account", timeout=10)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
return AttrDict(resp.json())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def get_positions(self, symbol):
|
||||
try:
|
||||
resp = self._session.get(f"{self.base_url}/positions/{symbol}", timeout=10)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
return [AttrDict(p) for p in resp.json()["positions"]]
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def get_symbol_info(self, symbol):
|
||||
try:
|
||||
resp = self._session.get(f"{self.base_url}/symbol/{symbol}", timeout=10)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
return AttrDict(resp.json())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def send_order(self, symbol, order_type, volume):
|
||||
try:
|
||||
resp = self._session.post(
|
||||
f"{self.base_url}/order",
|
||||
json={"symbol": symbol, "order_type": order_type, "volume": volume},
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
return AttrDict(resp.json())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def close_position(self, ticket, symbol, volume):
|
||||
try:
|
||||
resp = self._session.post(
|
||||
f"{self.base_url}/close",
|
||||
json={"ticket": ticket, "symbol": symbol, "volume": volume},
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return False
|
||||
return resp.json().get("success", False)
|
||||
except Exception:
|
||||
return False
|
||||
@@ -0,0 +1,46 @@
|
||||
import numpy as np
|
||||
|
||||
|
||||
def _to_native(val):
|
||||
"""numpy 类型转 Python 原生类型"""
|
||||
if isinstance(val, (np.integer,)):
|
||||
return int(val)
|
||||
if isinstance(val, (np.floating,)):
|
||||
return float(val)
|
||||
if isinstance(val, np.ndarray):
|
||||
return val.tolist()
|
||||
return val
|
||||
|
||||
|
||||
def _mt5_to_dict(obj):
|
||||
"""将 MT5 对象转为 dict,兼容 _asdict() / _fields / __dict__"""
|
||||
if hasattr(obj, '_asdict'):
|
||||
return {k: _to_native(v) for k, v in obj._asdict().items()}
|
||||
if hasattr(obj, '_fields'):
|
||||
return {f: _to_native(getattr(obj, f)) for f in obj._fields}
|
||||
if hasattr(obj, '__dict__'):
|
||||
return {k: _to_native(v) for k, v in obj.__dict__.items()}
|
||||
return {}
|
||||
|
||||
|
||||
def serialize_account_info(info):
|
||||
return _mt5_to_dict(info) if info else None
|
||||
|
||||
|
||||
def serialize_position(pos):
|
||||
return _mt5_to_dict(pos) if pos else None
|
||||
|
||||
|
||||
def serialize_symbol_info(info):
|
||||
return _mt5_to_dict(info) if info else None
|
||||
|
||||
|
||||
def serialize_order_result(result):
|
||||
return _mt5_to_dict(result) if result else None
|
||||
|
||||
|
||||
def serialize_rates(rates):
|
||||
if rates is None:
|
||||
return None
|
||||
names = rates.dtype.names
|
||||
return [{name: _to_native(row[name]) for name in names} for row in rates]
|
||||
@@ -1,373 +0,0 @@
|
||||
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
|
||||
+3
-32
@@ -1,32 +1,3 @@
|
||||
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)
|
||||
from core.risk.market_state import MarketStateAnalyzer
|
||||
from core.risk.position import PositionManager
|
||||
from core.risk.controller import RiskController
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
from core.risk.market_state import MarketStateAnalyzer
|
||||
from core.risk.position import PositionManager
|
||||
|
||||
|
||||
class RiskController:
|
||||
"""风险管理控制器 — 门面模式,组合 PositionManager 和 MarketStateAnalyzer"""
|
||||
|
||||
def __init__(self, data_provider, trade_direction="both",
|
||||
risk_config: dict = None,
|
||||
market_state_analyzer: MarketStateAnalyzer = None):
|
||||
self.data_provider = data_provider
|
||||
self.position_manager = PositionManager(data_provider, trade_direction, risk_config)
|
||||
self.market_state_analyzer = market_state_analyzer or MarketStateAnalyzer(data_provider)
|
||||
self.trade_direction = trade_direction
|
||||
|
||||
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 {
|
||||
'equity': self.position_manager.total_equity,
|
||||
'open_positions': len(self.position_manager.positions),
|
||||
'trade_summary': self.position_manager.get_trade_summary(),
|
||||
}
|
||||
|
||||
def get_positions(self):
|
||||
return self.position_manager.positions
|
||||
|
||||
def save_trade_history(self, base_filename):
|
||||
self.position_manager.save_trade_history(base_filename)
|
||||
@@ -0,0 +1,108 @@
|
||||
"""退出规则 — 策略模式,每个规则负责判断是否需要平仓"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExitContext:
|
||||
"""退出规则所需的上下文"""
|
||||
current_profit_pct: float
|
||||
peak_profit_pct: float
|
||||
entry_time: datetime
|
||||
current_time: datetime
|
||||
symbol: str = ""
|
||||
position: dict = None
|
||||
|
||||
|
||||
class BaseExitRule:
|
||||
"""退出规则基类"""
|
||||
|
||||
def __init__(self, config: dict):
|
||||
self.config = config
|
||||
|
||||
def check(self, ctx: ExitContext) -> tuple[str, str]:
|
||||
"""返回 (action, reason),action 为 "close" 或 "none" """
|
||||
return "none", ""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self.__class__.__name__
|
||||
|
||||
|
||||
class StopLossRule(BaseExitRule):
|
||||
"""固定止损"""
|
||||
|
||||
def check(self, ctx: ExitContext) -> tuple[str, str]:
|
||||
if ctx.current_profit_pct <= self.config.get("stop_loss_pct", -0.10):
|
||||
return "close", "止损触发"
|
||||
return "none", ""
|
||||
|
||||
|
||||
class TakeProfitRule(BaseExitRule):
|
||||
"""固定止盈"""
|
||||
|
||||
def check(self, ctx: ExitContext) -> tuple[str, str]:
|
||||
if ctx.current_profit_pct >= self.config.get("take_profit_pct", 0.20):
|
||||
return "close", "止盈触发"
|
||||
return "none", ""
|
||||
|
||||
|
||||
class TrailingStopRule(BaseExitRule):
|
||||
"""追踪止损"""
|
||||
|
||||
def check(self, ctx: ExitContext) -> tuple[str, str]:
|
||||
min_profit = self.config.get("min_profit_for_trailing", 0.01)
|
||||
retracement_pct = self.config.get("profit_retracement_pct", 0.10)
|
||||
|
||||
if ctx.peak_profit_pct <= min_profit:
|
||||
return "none", ""
|
||||
|
||||
stop_level = ctx.peak_profit_pct * (1 - retracement_pct)
|
||||
if ctx.current_profit_pct <= stop_level:
|
||||
return "close", (
|
||||
f"追踪止损触发 "
|
||||
f"(峰值 {ctx.peak_profit_pct:.2%} 回落至 {ctx.current_profit_pct:.2%})"
|
||||
)
|
||||
return "none", ""
|
||||
|
||||
|
||||
class TimeExitRule(BaseExitRule):
|
||||
"""时间退出"""
|
||||
|
||||
def check(self, ctx: ExitContext) -> tuple[str, str]:
|
||||
if not self.config.get("enable_time_based_exit", False):
|
||||
return "none", ""
|
||||
|
||||
max_minutes = self.config.get("max_holding_minutes", 60)
|
||||
min_profit = self.config.get("min_profit_for_time_exit", 0.001)
|
||||
|
||||
holding_seconds = (ctx.current_time - ctx.entry_time).total_seconds()
|
||||
holding_minutes = holding_seconds / 60
|
||||
|
||||
if holding_minutes > max_minutes and ctx.current_profit_pct < min_profit:
|
||||
return "close", f"超时平仓 (持仓超过{max_minutes}分钟且盈利未达标)"
|
||||
return "none", ""
|
||||
|
||||
|
||||
class ExitRuleEngine:
|
||||
"""退出规则引擎 — 按顺序执行所有注册的规则,返回第一个触发的退出
|
||||
|
||||
注意:每日亏损上限检查在 PositionManager._check_max_daily_loss 中处理(开仓时),
|
||||
不在退出规则中重复,避免两套计算逻辑不一致。
|
||||
"""
|
||||
|
||||
def __init__(self, config: dict):
|
||||
self.rules = [
|
||||
StopLossRule(config),
|
||||
TakeProfitRule(config),
|
||||
TrailingStopRule(config),
|
||||
TimeExitRule(config),
|
||||
]
|
||||
|
||||
def check(self, ctx: ExitContext) -> tuple[str, str]:
|
||||
for rule in self.rules:
|
||||
action, reason = rule.check(ctx)
|
||||
if action == "close":
|
||||
return action, reason
|
||||
return "none", ""
|
||||
+235
-81
@@ -1,46 +1,75 @@
|
||||
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
|
||||
from config import (
|
||||
MARKET_STATE_CONFIG, SYMBOL, DEFAULT_WEIGHTS,
|
||||
TREND_INDICATOR_WEIGHTS, TREND_THRESHOLDS,
|
||||
MARKET_STATE_WEIGHTS, CONFIDENCE_THRESHOLDS
|
||||
)
|
||||
from utils.constants import PERIOD_H1
|
||||
from core.data.multi_tf import MultiTimeframeDataStore
|
||||
|
||||
|
||||
class MarketStateAnalyzer:
|
||||
"""市场状态分析器(重写版)
|
||||
|
||||
两种工作模式:
|
||||
- 实盘模式:get_market_state() 实时从 DataProvider 获取H1数据计算
|
||||
- 回测模式:precompute_states() 一次性预计算全序列,get_market_state(bar_index) 从缓存读取
|
||||
|
||||
关键改进:
|
||||
1. timeframe 可配置,不再硬编码
|
||||
2. precompute_states() 使用 MultiTimeframeDataStore 获取正确周期的数据
|
||||
3. get_strategy_weights() 接受 individual_weights 参数使优化器权重基因真正生效
|
||||
"""
|
||||
市场状态分析器 (支持参数优化)
|
||||
"""
|
||||
|
||||
def __init__(self, data_provider, market_state_params=None, trend_weights=None, trend_thresholds=None, confidence_thresholds=None):
|
||||
|
||||
def __init__(self, data_provider=None,
|
||||
timeframe: int = PERIOD_H1,
|
||||
market_state_params: dict = None,
|
||||
trend_weights: dict = None,
|
||||
trend_thresholds: dict = None,
|
||||
confidence_thresholds: dict = 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.timeframe = timeframe
|
||||
|
||||
# 参数合并
|
||||
ms_config = market_state_params or MARKET_STATE_CONFIG
|
||||
self.trend_period = ms_config.get("trend_period", 50)
|
||||
self.retracement_tolerance = ms_config.get("retracement_tolerance", 0.30)
|
||||
self.volume_period = ms_config.get("volume_period", 20)
|
||||
self.volume_ma_period = ms_config.get("volume_ma_period", 10)
|
||||
self.hourly_data_count = ms_config.get("hourly_data_count", 100)
|
||||
|
||||
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')
|
||||
|
||||
# 回测模式缓存
|
||||
self._precomputed_states: list | None = None
|
||||
|
||||
# ── 指标计算(从现有逻辑提取)──
|
||||
|
||||
def _calculate_volume_indicators(self, df):
|
||||
df['volume'] = df['tick_volume']
|
||||
df['volume'] = df.get('tick_volume', df.get('volume', pd.Series(0, index=df.index)))
|
||||
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
|
||||
|
||||
last_corr = volume_corr.iloc[-1] if len(volume_corr) > 0 and not pd.isna(volume_corr.iloc[-1]) else 0
|
||||
return df, last_corr
|
||||
|
||||
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
|
||||
gain = delta.where(delta > 0, 0)
|
||||
loss = -delta.where(delta < 0, 0)
|
||||
avg_gain = gain.ewm(com=13, min_periods=14).mean()
|
||||
avg_loss = loss.ewm(com=13, min_periods=14).mean()
|
||||
rs = avg_gain / avg_loss.replace(0, float('nan')).fillna(0)
|
||||
df['rsi'] = 100 - (100 / (1 + rs))
|
||||
exp1 = df['close'].ewm(span=12).mean()
|
||||
exp2 = df['close'].ewm(span=26).mean()
|
||||
@@ -50,51 +79,100 @@ class MarketStateAnalyzer:
|
||||
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]
|
||||
lookback = min(self.trend_period, len(df) - 1)
|
||||
if lookback < 1:
|
||||
return 0.5
|
||||
high_period = df['high'].rolling(self.trend_period).max().iloc[-2] if len(df) >= 2 else df['high'].iloc[-1]
|
||||
low_period = df['low'].rolling(self.trend_period).min().iloc[-2] if len(df) >= 2 else df['low'].iloc[-1]
|
||||
price_range = high_period - low_period
|
||||
if price_range == 0: return 0.5
|
||||
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)
|
||||
|
||||
if current_price > high_period:
|
||||
return 0.8
|
||||
elif current_price < low_period:
|
||||
return 0.2
|
||||
return float(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
|
||||
volume_ratio = current_volume / volume_ma if (not pd.isna(volume_ma) and 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)
|
||||
|
||||
if volume_ratio > self.thresholds.get('volume_spike', 1.5):
|
||||
score += 0.3
|
||||
if volume_corr > 0.5:
|
||||
score += 0.2
|
||||
elif volume_corr < -0.5:
|
||||
score -= 0.2
|
||||
return float(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
|
||||
rsi_val = df['rsi'].iloc[-1] if not pd.isna(df['rsi'].iloc[-1]) else 50
|
||||
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)
|
||||
|
||||
if rsi_val > self.thresholds.get('overbought', 70):
|
||||
score += 0.2
|
||||
elif rsi_val < self.thresholds.get('oversold', 30):
|
||||
score -= 0.2
|
||||
if macd_hist > 0:
|
||||
score += 0.2
|
||||
else:
|
||||
score -= 0.2
|
||||
return float(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 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)
|
||||
if ma20_slope > 0 and ma50_slope > 0:
|
||||
score += 0.2
|
||||
elif ma20_slope < 0 and ma50_slope < 0:
|
||||
score -= 0.2
|
||||
return float(np.clip(score, 0, 1))
|
||||
|
||||
def _calculate_state_from_df(self, df):
|
||||
"""从 DataFrame 计算市场状态 — 提取自原 get_market_state() 的 DataFrame 处理部分"""
|
||||
df = df.copy()
|
||||
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.get('price_breakout', 0.35) +
|
||||
volume_score * self.indicator_weights.get('volume_confirmation', 0.25) +
|
||||
momentum_score * self.indicator_weights.get('momentum oscillator', 0.20) +
|
||||
ma_score * self.indicator_weights.get('moving_average', 0.20)
|
||||
)
|
||||
|
||||
strong = self.thresholds.get('strong_trend', 0.6)
|
||||
weak = self.thresholds.get('weak_trend', 0.3)
|
||||
|
||||
if trend_strength >= strong:
|
||||
state, confidence = "uptrend", min(0.95, trend_strength)
|
||||
elif trend_strength <= (1 - strong):
|
||||
state, confidence = "downtrend", min(0.95, 1 - trend_strength)
|
||||
elif trend_strength >= weak:
|
||||
state, confidence = "ranging", 0.6
|
||||
else:
|
||||
state, confidence = "none", 0.4
|
||||
|
||||
return state, confidence
|
||||
|
||||
def _update_trend_state(self, df, new_state):
|
||||
current_price = df['close'].iloc[-1]
|
||||
@@ -110,42 +188,118 @@ class MarketStateAnalyzer:
|
||||
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)
|
||||
# ── 预计算(回测/优化器模式)──
|
||||
|
||||
def precompute_from_multitf(self, multi_tf: MultiTimeframeDataStore) -> None:
|
||||
"""从 MultiTimeframeDataStore 预计算全序列市场状态
|
||||
|
||||
这是回测和优化器模式的核心入口。调用后,get_market_state(bar_index)
|
||||
直接从缓存读取,不再需要实时计算。
|
||||
|
||||
Args:
|
||||
multi_tf: 已加载M1数据的 MultiTimeframeDataStore 实例
|
||||
"""
|
||||
h1_df = multi_tf.ensure_timeframe(self.timeframe)
|
||||
if h1_df is None or len(h1_df) == 0:
|
||||
logger.warning("H1数据为空,所有市场状态设为 'none'")
|
||||
self._precomputed_states = [("none", 0.0)] * multi_tf.length
|
||||
return
|
||||
|
||||
m1_index = multi_tf.main_df.index
|
||||
h1_index = h1_df.index
|
||||
m1_length = len(m1_index)
|
||||
|
||||
# 计算每个H1 bar的状态
|
||||
h1_states = []
|
||||
for i in range(len(h1_df)):
|
||||
lookback = self.trend_period + 10
|
||||
start = max(0, i - lookback)
|
||||
df_slice = h1_df.iloc[start:i + 1]
|
||||
state, conf = self._calculate_state_from_df(df_slice)
|
||||
h1_states.append((state, conf))
|
||||
|
||||
# 映射到M1时间轴
|
||||
self._precomputed_states = []
|
||||
h1_pos = 0
|
||||
for m1_time in m1_index:
|
||||
while h1_pos < len(h1_index) - 1 and m1_time >= h1_index[h1_pos + 1]:
|
||||
h1_pos += 1
|
||||
if h1_pos < len(h1_states):
|
||||
self._precomputed_states.append(h1_states[h1_pos])
|
||||
else:
|
||||
self._precomputed_states.append(("none", 0.0))
|
||||
|
||||
# 前几个H1 bar之前的M1 bar标记为 "none"
|
||||
first_h1_time = h1_index[0] if len(h1_index) > 0 else None
|
||||
if first_h1_time is not None:
|
||||
for idx, m1_time in enumerate(m1_index):
|
||||
if m1_time < first_h1_time:
|
||||
self._precomputed_states[idx] = ("none", 0.0)
|
||||
|
||||
logger.info(f"市场状态预计算完成: {len(h1_df)} 个H1 bar → {len(self._precomputed_states)} 个M1 bar")
|
||||
|
||||
# ── 状态获取 ──
|
||||
|
||||
def get_market_state(self, bar_index: int = None) -> tuple:
|
||||
"""获取市场状态
|
||||
|
||||
回测模式(有预计算缓存):从缓存读取第 bar_index 个状态
|
||||
实盘模式(无缓存):通过 DataProvider 实时计算
|
||||
"""
|
||||
if self._precomputed_states is not None:
|
||||
idx = bar_index if bar_index is not None else 0
|
||||
if idx >= len(self._precomputed_states):
|
||||
return "none", 0.0
|
||||
return self._precomputed_states[idx]
|
||||
|
||||
# 实盘模式
|
||||
if self.data_provider is None:
|
||||
return "none", 0.0
|
||||
|
||||
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
|
||||
|
||||
state, confidence = self._calculate_state_from_df(df)
|
||||
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()}
|
||||
# ── 权重计算 ──
|
||||
|
||||
def get_strategy_weights(self, market_state: str, confidence: float,
|
||||
individual_weights: dict = None) -> dict:
|
||||
"""获取策略权重
|
||||
|
||||
关键改进:individual_weights 参数使优化器的权重基因真正生效。
|
||||
|
||||
Args:
|
||||
market_state: "uptrend" / "downtrend" / "ranging" / "none"
|
||||
confidence: 置信度 0~1
|
||||
individual_weights: 优化器传入 {config_key: weight},非None时优先使用
|
||||
|
||||
Returns:
|
||||
{config_key: weight} 用于信号加权组合
|
||||
"""
|
||||
# ★ 优化器模式:使用基因中的权重
|
||||
if individual_weights is not None:
|
||||
base_weights = dict(individual_weights)
|
||||
else:
|
||||
return DEFAULT_WEIGHTS
|
||||
# 正常模式:从配置获取市场状态对应权重
|
||||
base_weights = dict(MARKET_STATE_WEIGHTS.get(market_state, DEFAULT_WEIGHTS))
|
||||
|
||||
high_conf = self.confidence_thresholds.get("high_confidence", 0.7)
|
||||
medium_conf = self.confidence_thresholds.get("medium_confidence", 0.4)
|
||||
|
||||
if confidence > high_conf:
|
||||
return {k: v * confidence for k, v in base_weights.items()}
|
||||
elif confidence > medium_conf:
|
||||
return {
|
||||
k: (v * confidence + DEFAULT_WEIGHTS.get(k, 1.0) * (1 - confidence))
|
||||
for k, v in base_weights.items()
|
||||
}
|
||||
else:
|
||||
# 低置信度:individual_weights 优先(优化器模式),否则回退到 DEFAULT_WEIGHTS
|
||||
return dict(individual_weights) if individual_weights is not None else dict(DEFAULT_WEIGHTS)
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
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)
|
||||
|
||||
# 资金管理
|
||||
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.positions = []
|
||||
self.closed_trades = []
|
||||
self.total_equity = self.initial_capital
|
||||
|
||||
# 冷却期:平仓后N根K线内不重新开仓(防反复进出)
|
||||
self.cooldown_bars = risk.get("cooldown_bars", 30)
|
||||
self._cooldown_counter = 0
|
||||
|
||||
# 退出规则引擎
|
||||
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()
|
||||
|
||||
# ── 仓位计算 ──
|
||||
|
||||
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 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":
|
||||
logger.info("当前配置只允许做多,忽略卖出信号")
|
||||
return False
|
||||
elif self.trade_direction == "short" and direction == "buy":
|
||||
logger.info("当前配置只允许做空,忽略买入信号")
|
||||
return False
|
||||
|
||||
# ★ 每日亏损检查(幽灵代码落地)
|
||||
current_time = current_price.get('time', pd.Timestamp.now())
|
||||
if self._check_max_daily_loss(current_time):
|
||||
logger.warning("当日亏损已达上限,禁止开新仓")
|
||||
return False
|
||||
|
||||
# 最大持仓数检查 — 优先使用 risk_config 传入值,回退到 REALTIME_CONFIG
|
||||
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)
|
||||
current_count = len([p for p in self.positions if p['position_type'] == position_type])
|
||||
if 0 < max_positions <= current_count:
|
||||
logger.info(f"已达到最大{position_type}持仓数 ({max_positions}),忽略信号")
|
||||
return False
|
||||
|
||||
# 资金分配
|
||||
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:
|
||||
logger.info("仓位大小为0,无法开仓")
|
||||
return False
|
||||
|
||||
order_result = self.data_provider.send_order(self.symbol, direction, position_volume)
|
||||
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)
|
||||
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
|
||||
|
||||
# ── 持仓监控 ──
|
||||
|
||||
def monitor_positions(self, current_price, dry_run=False):
|
||||
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.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 _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']
|
||||
symbol_info = self.data_provider.get_symbol_info(self.symbol)
|
||||
contract_size = (symbol_info['trade_contract_size']
|
||||
if symbol_info and isinstance(symbol_info, dict)
|
||||
else 100) if symbol_info else 100
|
||||
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):
|
||||
symbol_info = self.data_provider.get_symbol_info(position['symbol'])
|
||||
contract_size = (symbol_info['trade_contract_size']
|
||||
if symbol_info and isinstance(symbol_info, dict)
|
||||
else 100) if symbol_info else 100
|
||||
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)
|
||||
logger.info(f"同步持仓 {pos.ticket}: 恢复峰值={restored_peak:.6%}")
|
||||
logger.info(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}")
|
||||
@@ -1,429 +0,0 @@
|
||||
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}")
|
||||
@@ -0,0 +1,61 @@
|
||||
import pandas as pd
|
||||
|
||||
|
||||
class SignalCombiner:
|
||||
"""信号组合器 — 统一加权求和和阈值过滤逻辑
|
||||
|
||||
消除 start_backtest.py / optimizer.py / backtest_engine.py 中的重复。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def combine_vectorized(signals_df: pd.DataFrame,
|
||||
weights: dict,
|
||||
buy_threshold: float,
|
||||
sell_threshold: float) -> pd.Series:
|
||||
"""向量化信号组合 — 用于回测预生成阶段
|
||||
|
||||
Args:
|
||||
signals_df: 每列是一个策略的回测信号序列,列名为策略类名
|
||||
weights: {config_key 或 class_name: 权重}
|
||||
buy_threshold: 加权和大于此值 → 买入信号(1)
|
||||
sell_threshold: 加权和小于此值 → 卖出信号(-1)
|
||||
|
||||
Returns:
|
||||
最终信号序列 (-1/0/1)
|
||||
"""
|
||||
combined = pd.Series(0.0, index=signals_df.index)
|
||||
for col in signals_df.columns:
|
||||
# 权重匹配:先尝试类名,再尝试config_key
|
||||
w = weights.get(col, 1.0)
|
||||
combined += signals_df[col] * w
|
||||
|
||||
final = pd.Series(0, index=combined.index)
|
||||
final[combined > buy_threshold] = 1
|
||||
final[combined < sell_threshold] = -1
|
||||
return final
|
||||
|
||||
@staticmethod
|
||||
def combine_at_bar(signals: dict,
|
||||
weights: dict,
|
||||
buy_threshold: float,
|
||||
sell_threshold: float) -> int:
|
||||
"""单 bar 信号组合 — 用于回测/优化器逐 bar 循环
|
||||
|
||||
Args:
|
||||
signals: {策略标识: signal_value (-1/0/1 或 0.0~1.0)}
|
||||
weights: {策略标识: 权重}
|
||||
buy_threshold: 加权和买入阈值
|
||||
sell_threshold: 加权和卖出阈值
|
||||
|
||||
Returns:
|
||||
-1/0/1
|
||||
"""
|
||||
weighted_sum = sum(
|
||||
signals.get(name, 0) * weights.get(name, 1.0)
|
||||
for name in set(signals) | set(weights)
|
||||
)
|
||||
if weighted_sum > buy_threshold:
|
||||
return 1
|
||||
elif weighted_sum < sell_threshold:
|
||||
return -1
|
||||
return 0
|
||||
@@ -0,0 +1,70 @@
|
||||
"""策略注册表 — 单例,消除代码中多处的策略列表重复
|
||||
|
||||
用法:
|
||||
registry = StrategyRegistry()
|
||||
registry.register('ma_cross', MACrossStrategy)
|
||||
registry.instantiate_all(SYMBOL, TIMEFRAME) # 或传入参数字典
|
||||
config_key = registry.class_name_to_config_key('MACrossStrategy')
|
||||
"""
|
||||
|
||||
from config import STRATEGY_CONFIG
|
||||
|
||||
|
||||
class StrategyRegistry:
|
||||
"""策略注册表单例 — 所有策略信息的唯一数据源"""
|
||||
|
||||
_instance = None
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._strategies = {} # config_key → StrategyClass
|
||||
cls._instance._instances = {} # config_key → instance
|
||||
return cls._instance
|
||||
|
||||
def register(self, config_key: str, strategy_class: type) -> None:
|
||||
self._strategies[config_key] = strategy_class
|
||||
|
||||
def instantiate_all(self, symbol: str, timeframe: int,
|
||||
params_dict: dict = None,
|
||||
data_provider=None) -> dict:
|
||||
"""实例化所有已注册策略
|
||||
|
||||
Args:
|
||||
data_provider: 回测传None(run_backtest不需要),实盘传DataProvider实例
|
||||
Returns:
|
||||
{config_key: strategy_instance}
|
||||
"""
|
||||
if params_dict is None:
|
||||
params_dict = STRATEGY_CONFIG
|
||||
|
||||
instances = {}
|
||||
for key, cls in self._strategies.items():
|
||||
params = params_dict.get(key, {})
|
||||
instances[key] = cls(data_provider, symbol, timeframe, **params)
|
||||
self._instances = instances
|
||||
return instances
|
||||
|
||||
@property
|
||||
def instances(self) -> dict:
|
||||
return self._instances
|
||||
|
||||
@property
|
||||
def all_strategies(self) -> dict:
|
||||
"""返回 {config_key: StrategyClass}"""
|
||||
return dict(self._strategies)
|
||||
|
||||
def class_name_to_config_key(self, class_name: str) -> str | None:
|
||||
for key, cls in self._strategies.items():
|
||||
if cls.__name__ == class_name:
|
||||
return key
|
||||
return None
|
||||
|
||||
def config_key_to_class_name(self, config_key: str) -> str:
|
||||
return self._strategies[config_key].__name__
|
||||
|
||||
def get_instance(self, config_key: str):
|
||||
return self._instances.get(config_key)
|
||||
|
||||
def __contains__(self, config_key: str) -> bool:
|
||||
return config_key in self._strategies
|
||||
Reference in New Issue
Block a user