From 4cb4f4a15e6f261856fadfa5337bf1f6d54bdd6c Mon Sep 17 00:00:00 2001 From: silencesdg Date: Mon, 11 May 2026 12:00:45 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=E9=A1=B9=E7=9B=AE=E6=9E=B6?= =?UTF-8?q?=E6=9E=84=EF=BC=8C=E6=96=B0=E5=A2=9E=20MT5=20=E4=BB=A3=E7=90=86?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重构核心模块: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 Co-Authored-By: Happy --- CLAUDE.md | 163 +++--- config.py | 177 +++---- core/data/__init__.py | 6 + core/data/abc.py | 45 ++ core/data/backtest.py | 80 +++ core/data/dryrun.py | 62 +++ core/data/live.py | 172 +++++++ core/data/multi_tf.py | 154 ++++++ core/data/remote.py | 119 +++++ core/data/serializers.py | 46 ++ core/data_providers.py | 373 -------------- core/risk/__init__.py | 35 +- core/risk/controller.py | 37 ++ core/risk/exit_rules.py | 108 ++++ core/risk/market_state.py | 316 +++++++++--- core/risk/position.py | 405 +++++++++++++++ core/risk/position_manager.py | 429 ---------------- core/signal/__init__.py | 0 core/signal/combiner.py | 61 +++ core/signal/registry.py | 70 +++ data/.gitkeep | 0 data/cache/.gitkeep | 0 execution/backtest.py | 192 +++++++ execution/backtest_engine.py | 95 ---- execution/dynamic_weights.py | 67 --- execution/optimize.py | 578 +++++++++++++++++++++ execution/realtime_trader.py | 2 +- execution/weights.py | 78 +++ main.py | 22 - optimizer.py | 721 --------------------------- requirements.txt | 5 +- run/__init__.py | 0 run/backtest.py | 46 ++ run/optimize.py | 28 ++ start_realtime.py => run/realtime.py | 27 +- run/server.py | 143 ++++++ start_backtest.py | 229 --------- strategies/__init__.py | 25 + strategies/daily_breakout.py | 19 +- strategies/kdj.py | 5 +- strategies/mean_reversion.py | 29 +- strategies/momentum_breakout.py | 29 +- strategies/rsi.py | 11 +- strategies/wave_theory.py | 19 +- utils/__init__.py | 0 utils/constants.py | 44 ++ 46 files changed, 2993 insertions(+), 2279 deletions(-) create mode 100644 core/data/__init__.py create mode 100644 core/data/abc.py create mode 100644 core/data/backtest.py create mode 100644 core/data/dryrun.py create mode 100644 core/data/live.py create mode 100644 core/data/multi_tf.py create mode 100644 core/data/remote.py create mode 100644 core/data/serializers.py delete mode 100644 core/data_providers.py create mode 100644 core/risk/controller.py create mode 100644 core/risk/exit_rules.py create mode 100644 core/risk/position.py delete mode 100644 core/risk/position_manager.py create mode 100644 core/signal/__init__.py create mode 100644 core/signal/combiner.py create mode 100644 core/signal/registry.py create mode 100644 data/.gitkeep create mode 100644 data/cache/.gitkeep create mode 100644 execution/backtest.py delete mode 100644 execution/backtest_engine.py delete mode 100644 execution/dynamic_weights.py create mode 100644 execution/optimize.py create mode 100644 execution/weights.py delete mode 100644 main.py delete mode 100644 optimizer.py create mode 100644 run/__init__.py create mode 100644 run/backtest.py create mode 100644 run/optimize.py rename start_realtime.py => run/realtime.py (68%) create mode 100644 run/server.py delete mode 100644 start_backtest.py create mode 100644 strategies/__init__.py create mode 100644 utils/__init__.py create mode 100644 utils/constants.py diff --git a/CLAUDE.md b/CLAUDE.md index 786d68e..1b1dffb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,103 +4,106 @@ ## 项目概述 -这是一个基于Python的MetaTrader 5智能交易系统(EA)套件,用于量化交易策略。系统通过加权投票和阈值过滤组合多个交易策略,生成稳健的交易决策。 +基于Python的MetaTrader 5智能交易系统(EA)套件,集成10种量化策略,通过加权投票和阈值过滤组合信号,支持动态权重管理(根据市场状态调整策略权重)。 -## 架构设计 +## 实际入口点 -### 核心组件 +`main.py` 为空壳,实际入口为: -- **主入口**: `main.py` 提供三种执行模式: - - `run_backtest()`: 使用当前配置权重进行单次回测 - - `run_optimizer()`: 遗传算法优化寻找最佳权重 - - `run_realtime()`: 与MT5集成的实时交易 - -- **配置文件**: `config.py` 包含: - - `STRATEGIES`: (策略实例, 权重) 元组列表 - - 交易参数(品种、时间周期、初始资金) - -- **回测引擎**: `backtest.py` 处理: - - 历史数据策略执行 - - 使用加权和的信号组合 - - 防止未来函数的收益计算 - -- **遗传优化器**: `optimizer.py`: - - 使用DEAP库进行遗传算法 - - 优化策略权重以获得最大收益 - - 支持并行处理提高性能 - -- **工具函数**: `utils.py` 提供MT5连接管理和交易功能 -- **日志系统**: `logger.py` 处理控制台/文件双日志输出,支持UTF-8编码 - -### 策略架构 - -`strategies/` 目录中的所有策略都遵循一致的模式: - -```python -class Strategy: - def __init__(self): # 初始化参数 - def _calculate_indicators(self, df): # 计算技术指标 - def generate_signal(self): # 实时交易信号生成 - def run_backtest(self, df): # 回测信号生成 +```bash +python start_backtest.py # 回测模式 +python start_realtime.py # 实时交易(默认模拟,需输入YES确认实盘) +python optimizer.py # 遗传算法参数优化 ``` +## 核心架构 + +### 依赖注入设计 + +`core/data_providers.py` 定义了 `DataProvider` 抽象基类(9个抽象方法),三种实现: + +- `LiveDataProvider` — 真实MT5 API调用 +- `DryRunDataProvider` — 模拟交易(委托LiveDataProvider获取价格,不发送真实订单) +- `BacktestDataProvider` — 回测(维护DataFrame + `current_index`,通过 `tick()` 推进) + +所有策略和执行模块接收 `DataProvider` 接口,不直接调用MT5。这是理解整个系统解耦的关键。 + +### 风险管理门面 + +`core/risk/__init__.py` 中的 `RiskController` 是门面类,组合了: +- `PositionManager` (`position_manager.py`) — 开仓、监控、止损/止盈/追踪止损/时间退出、资金管理 +- `MarketStateAnalyzer` (`market_state.py`) — 四维度趋势检测(价格突破、成交量确认、动量、均线),返回 `("uptrend"/"downtrend"/"ranging"/"none", confidence)` + +### 动态权重系统 + +`execution/dynamic_weights.py` 中的 `DynamicWeightManager`: +1. 使用 `MarketStateAnalyzer` 判断当前市场状态 +2. 根据状态从 `config.py:MARKET_STATE_WEIGHTS` 获取对应权重 +3. 按置信度决定使用市场状态权重还是 `DEFAULT_WEIGHTS` +4. 返回 `[(策略实例, 权重), ...]` 列表供信号组合使用 + +### 两阶段回测设计 + +`start_backtest.py` 的回测采用两阶段: +1. **预生成阶段**:遍历所有策略 `run_backtest(df)` 生成信号列,加权求和后通过 `SIGNAL_THRESHOLDS` 阈值过滤得到最终信号序列 +2. **模拟执行阶段**:单遍 `iterrows` 逐行推进,通过 `RiskController` 模拟开仓/监控/平仓 + ### 信号组合逻辑 -系统通过以下方式组合策略信号: -1. 加权求和:`sum(信号 * 权重,针对所有策略)` -2. 信号决策:加权和大于0买入,小于0卖出 -3. 返回用于回测的组合信号序列 - -## 开发命令 - -### 环境设置 -```bash -# 创建并激活虚拟环境 -python -m venv myenv -# Windows -myenv\Scripts\activate -# macOS/Linux -source myenv/bin/activate - -# 安装依赖 -pip install -r requirements.txt +``` +加权和 = sum(各策略信号 * 权重) +最终信号 = 1 (买入) if 加权和 > buy_threshold (1.5) + -1 (卖出) if 加权和 < sell_threshold (-1.5) + 0 (无信号) otherwise ``` -### 运行模式 -```bash -# 运行单次回测(使用config.py权重) -python main.py +阈值在 `config.py:SIGNAL_THRESHOLDS` 中配置。 -# 运行遗传优化(寻找最佳权重) -python main.py # 取消注释 run_optimizer() 调用 +## 策略开发规范 -# 运行实时交易(使用config.py权重) -python main.py # 取消注释 run_realtime() 调用 +所有策略继承 `strategies/base_strategy.py` 的 `BaseStrategy`: + +```python +class BaseStrategy: + def __init__(self, data_provider, symbol, timeframe): # 接收 data_provider 而非直接访问 MT5 + def generate_signal(self) -> int: # 实时信号:通过 self.data_provider 获取当前价格 + def run_backtest(self, df) -> pd.Series: # 向量化回测:接收 DataFrame,返回 -1/0/1 序列 + def _log_signal(self, signal, reason): # 基类提供,记录买卖信号到日志 ``` -## 关键开发模式 +**添加新策略步骤:** +1. 在 `strategies/` 下创建新文件,继承 `BaseStrategy` +2. 实现 `generate_signal()` 和 `run_backtest(self, df)` +3. 在 `config.py:STRATEGY_CONFIG` 添加参数字典,在 `config.py:DEFAULT_WEIGHTS` 和 `config.py:MARKET_STATE_WEIGHTS` 添加权重 +4. 在 `execution/dynamic_weights.py` 的 `strategy_blueprints` 列表和 `optimizer.py` 的导入中注册 -### 添加新策略 -1. 在 `strategies/` 目录创建新文件 -2. 实现必需的Strategy类方法 -3. 添加导入和策略实例到 `config.py:STRATEGIES` +## 配置系统 -### 配置管理 -- 策略权重需要从优化器结果手动更新 -- 所有交易参数集中在 `config.py` 中 +`config.py` 包含20+个配置字典,核心: -### 错误处理 -- 策略执行中的全面异常处理 -- 日志文件使用UTF-8编码 -- 操作前进行MT5连接验证 +| 配置 | 用途 | +|---|---| +| `SYMBOL`, `TIMEFRAME`, `INITIAL_CAPITAL` | 基础交易参数(当前: XAUUSD, M1, 20000) | +| `STRATEGY_CONFIG` | 各策略的参数字典(键名为策略简称) | +| `DEFAULT_WEIGHTS` | 默认策略权重(优化后) | +| `MARKET_STATE_WEIGHTS` | 各市场状态(up/down/ranging)下的策略权重 | +| `SIGNAL_THRESHOLDS` | 买卖信号阈值 | +| `RISK_CONFIG` | 止损/止盈/追踪止损/持仓时间等风控参数 | +| `MARKET_STATE_CONFIG` | 趋势检测参数 | +| `GENETIC_OPTIMIZER_CONFIG` | 遗传算法参数(种群、代数、交叉/变异概率) | +| `BACKTEST_CONFIG` / `REALTIME_CONFIG` | 回测/实盘专项配置 | -### 性能考虑 -- 优化器预加载历史数据避免重复I/O -- 遗传算法启用并行处理 -- 日志器使用单例模式防止重复处理器 +## 依赖 + +``` +MetaTrader5 # MT5 Python API +pandas +deap # 遗传算法框架 +tqdm # 进度条 +``` ## 语言要求 -- **所有开发和文档必须使用中文**(根据GEMINI.md) -- 错误日志在 `logs/error.log` -- 策略日志在 `logs/strategy.log` \ No newline at end of file +- 所有代码注释、文档、日志必须使用中文 +- 日志文件: `logs/strategy.log`, `logs/error.log` +- 日志器使用单例模式(`logger.py`) diff --git a/config.py b/config.py index 32192a9..4c86895 100644 --- a/config.py +++ b/config.py @@ -1,19 +1,23 @@ +# MT5 代理服务配置 +SERVER_HOST = "0.0.0.0" +SERVER_PORT = 5555 + +# 远端客户端配置(迁移到其他电脑时填写 MT5 机器的 IP) +REMOTE_SERVER_HOST = "127.0.0.1" +REMOTE_SERVER_PORT = 5555 + # 交易配置 -SYMBOL = "XAUUSD" -INTERVAL = 60 # 秒 -INITIAL_CAPITAL = 20000 # 初始资金 -SPREAD = 32 # 点差(点数),买卖合起来的总共点差成本 +SYMBOL = "XAUUSDz" +INITIAL_CAPITAL = 1937 # 初始资金(2026-05-11 实盘余额) # 时间配置 TIMEFRAME = 1# M1 (1分钟图) - MT5常量值 # 回测时间范围 (格式: "YYYY-MM-DD") -# 注意:这些日期需要确保在MT5服务器上有可用数据 BACKTEST_START_DATE = "2025-05-01" BACKTEST_END_DATE = "2025-08-01" # 优化器时间范围 (格式: "YYYY-MM-DD") -# 注意:确保与回测时间不重叠 OPTIMIZER_START_DATE = "2025-04-01" OPTIMIZER_END_DATE = "2025-05-01" @@ -22,7 +26,7 @@ USE_DATE_RANGE = False # 设置为False可强制使用数据量模式 # 兼容性配置 (如果日期配置不可用,则使用数据量) BACKTEST_COUNT = 30000 # 回测数据量 -OPTIMIZER_COUNT = 15000 # 优化器数据量 +OPTIMIZER_COUNT = 50000 # 优化器数据量 RISK_CONFIG_CONST = { 'enable_time_based_exit': True @@ -74,23 +78,23 @@ DATA_CONFIG = { # 遗传算法优化器配置 GENETIC_OPTIMIZER_CONFIG = { # 算法参数 - "population_size": 200, # 种群大小 - "generations": 50, # 进化代数 + "population_size": 50, # 种群大小 + "generations": 10, # 进化代数 "crossover_probability": 0.7, # 交叉概率 "mutation_probability": 0.3, # 变异概率 - + # 选择算法参数 "tournament_size": 3, # 锦标赛选择大小 - + # 变异算法参数 "mutation_mu": 0, # 变异均值 "mutation_sigma": 0.1, # 变异标准差 "mutation_indpb": 0.1, # 变异概率(每个基因) - + # 并行处理 "enable_multiprocessing": True, # 启用多进程 "processes": None, # 进程数,None表示自动检测 - + # 输出控制 "verbose": True, # 详细输出 "save_generation_info": True, # 保存代数信息 @@ -103,131 +107,113 @@ GENETIC_OPTIMIZER_CONFIG = { 此处下面所有参数,都将进入优化器进行优化 ''' -# 信号阈值配置 +# 信号阈值配置(优化器结果 2026-05-10,5万根M1数据) SIGNAL_THRESHOLDS = { - "buy_threshold": 1.5, # 买入信号阈值 (优化后) - "sell_threshold": -0.87 # 卖出信号阈值 (优化后) + "buy_threshold": 1.344, + "sell_threshold": -2.980 } -# 风险管理参数 +# 风险管理参数(优化器结果 2026-05-10,5万根M1数据) RISK_CONFIG = { - "stop_loss_pct": -0.01, # 固定止损:亏损1% (优化后) - "profit_retracement_pct": 0.1, # 利润回撤10%止盈 (优化后) - "min_profit_for_trailing": 0.01, # 追踪止损激活阈值:利润0.1% (降低阈值以激活追踪止损) - "take_profit_pct": 0.001, # 固定止盈:盈利0.1% (优化后) - "max_position_size": 1, # 最大仓位100% - "max_daily_loss": -0.3, # 最大日亏损30% - "max_holding_minutes": 140, # 持仓超过140分钟 (优化后) - "min_profit_for_time_exit": 0.01, # 且盈利未达到0.1%则平仓 (优化后) + "stop_loss_pct": -0.046, + "profit_retracement_pct": 0.070, + "min_profit_for_trailing": 0.009, + "take_profit_pct": 0.246, + "max_daily_loss": -0.3, + "max_holding_minutes": 133, + "min_profit_for_time_exit": 0.010, + "cooldown_bars": 30 } # 市场状态分析参数 MARKET_STATE_CONFIG = { - "trend_period": 44, - "retracement_tolerance": 0.1382775008306345, - "volume_period": 13, - "volume_ma_period": 12, + "trend_period": 24, + "retracement_tolerance": 0.425, + "volume_period": 21, + "volume_ma_period": 12 } -# 策略参数配置 (从优化器中提取的优化参数) +# 策略参数配置(优化器结果 2026-05-10,5万根M1数据) STRATEGY_CONFIG = { - # MACrossStrategy 参数 "ma_cross": { - "short_window": 5, - "long_window": 39, + "short_window": 12, + "long_window": 30 }, - - # RSIStrategy 参数 "rsi": { - "period": 22, - "overbought": 80, - "oversold": 24, + "period": 21, + "overbought": 75, + "oversold": 26 }, - - # BollingerStrategy 参数 "bollinger": { - "period": 10, - "std_dev": 2.8916513144581044, + "period": 20, + "std_dev": 2.162 }, - - # MACDStrategy 参数 "macd": { - "fast_ema": 17, - "slow_ema": 22, - "signal_period": 13, + "fast_ema": 16, + "slow_ema": 34, + "signal_period": 12 }, - - # MeanReversionStrategy 参数 "mean_reversion": { - "period": 40, - "std_dev": 2.917216289407233, + "period": 29, + "std_dev": 2.149 }, - - # MomentumBreakoutStrategy 参数 "momentum_breakout": { - "period": 27, + "period": 15, + "momentum_period": 17 }, - - # KDJStrategy 参数 "kdj": { - "period": 6, + "period": 21 }, - - # TurtleStrategy 参数 "turtle": { - "period": 16, + "period": 42 }, - - # DailyBreakoutStrategy 参数 "daily_breakout": { - "bars_count": 807, + "bars_count": 746 }, - - # WaveTheoryStrategy 参数 "wave_theory": { "ema_short": 3, - "ema_medium": 15, - "ema_long": 36, - "wave_period": 28, + "ema_medium": 16, + "ema_long": 26, + "wave_period": 32, "range_period": 30, - "adx_period": 21, - "momentum_period": 13, - "range_threshold": -0.02028040971155598, - "adx_threshold": 22, - }, + "adx_period": 23, + "momentum_period": 10, + "range_threshold": 0.002, + "adx_threshold": 23 + } } # 市场趋势判断权重配置 TREND_INDICATOR_WEIGHTS = { - "price_breakout": 0.03552729699860058, # 价格突破权重 - "volume_confirmation": 0.2564191814903339, # 成交量确认权重 - "momentum oscillator": 0.48690789892001296, # 动量震荡指标权重 - "moving_average": 0.3549274308680269, # 移动平均线权重 + "price_breakout": -0.0949, + "volume_confirmation": 0.5277, + "momentum oscillator": 0.3677, + "moving_average": 0.1506 } # 趋势判断阈值 TREND_THRESHOLDS = { - "strong_trend": 0.24063407379490956, # 强趋势阈值 - "weak_trend": 0.3708545035763192, # 弱趋势阈值 - "volume_spike": 1.7781348694952452, # 成交量突增倍数 - "oversold": 29, # 超卖阈值 (RSI) - "overbought": 69, # 超买阈值 (RSI) + "strong_trend": 0.4182, + "weak_trend": 0.2164, + "volume_spike": 1.5843, + "oversold": 24, + "overbought": 80 } -# 动态权重配置(经过优化器优化的最佳权重) +# 动态权重配置(优化器结果 2026-05-10,5万根M1数据) DEFAULT_WEIGHTS = { - "ma_cross": 0.44428771408324463, - "rsi": 1.6518277343219148, - "bollinger": 0.6014474871460991, - "mean_reversion": 0.04509729271515517, - "momentum_breakout": 1.0768245883204248, - "macd": 0.8909161638775971, - "kdj": 1.095784002572773, - "turtle": 1.7942934575029192, - "daily_breakout": 2.4237777692491713, - "wave_theory": 0.7354173414675755, + "ma_cross": 0.809, + "rsi": 1.141, + "bollinger": 0.389, + "mean_reversion": 1.106, + "momentum_breakout": 0.147, + "macd": 1.181, + "kdj": 1.525, + "turtle": 0.559, + "daily_breakout": 1.846, + "wave_theory": 1.361 } # 市场状态策略权重配置 @@ -273,7 +259,6 @@ MARKET_STATE_WEIGHTS = { # 市场趋势置信度阈值配置 CONFIDENCE_THRESHOLDS = { - "high_confidence": 0.941049792261965, # 高置信度阈值 - "medium_confidence": 0.8881796011658835, # 中等置信度阈值 + "high_confidence": 0.8474, + "medium_confidence": 0.4964 } - diff --git a/core/data/__init__.py b/core/data/__init__.py new file mode 100644 index 0000000..feb9b25 --- /dev/null +++ b/core/data/__init__.py @@ -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 diff --git a/core/data/abc.py b/core/data/abc.py new file mode 100644 index 0000000..5ff4464 --- /dev/null +++ b/core/data/abc.py @@ -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): + """平仓""" diff --git a/core/data/backtest.py b/core/data/backtest.py new file mode 100644 index 0000000..6611b1d --- /dev/null +++ b/core/data/backtest.py @@ -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 diff --git a/core/data/dryrun.py b/core/data/dryrun.py new file mode 100644 index 0000000..a56aeb7 --- /dev/null +++ b/core/data/dryrun.py @@ -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 diff --git a/core/data/live.py b/core/data/live.py new file mode 100644 index 0000000..7aadf23 --- /dev/null +++ b/core/data/live.py @@ -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 diff --git a/core/data/multi_tf.py b/core/data/multi_tf.py new file mode 100644 index 0000000..50e1c4e --- /dev/null +++ b/core/data/multi_tf.py @@ -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) diff --git a/core/data/remote.py b/core/data/remote.py new file mode 100644 index 0000000..a2e5104 --- /dev/null +++ b/core/data/remote.py @@ -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 diff --git a/core/data/serializers.py b/core/data/serializers.py new file mode 100644 index 0000000..21751a5 --- /dev/null +++ b/core/data/serializers.py @@ -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] diff --git a/core/data_providers.py b/core/data_providers.py deleted file mode 100644 index 2dc575e..0000000 --- a/core/data_providers.py +++ /dev/null @@ -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 diff --git a/core/risk/__init__.py b/core/risk/__init__.py index e7b7122..cb5e9cd 100644 --- a/core/risk/__init__.py +++ b/core/risk/__init__.py @@ -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 diff --git a/core/risk/controller.py b/core/risk/controller.py new file mode 100644 index 0000000..8fbbb5c --- /dev/null +++ b/core/risk/controller.py @@ -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) diff --git a/core/risk/exit_rules.py b/core/risk/exit_rules.py new file mode 100644 index 0000000..0b176b4 --- /dev/null +++ b/core/risk/exit_rules.py @@ -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", "" diff --git a/core/risk/market_state.py b/core/risk/market_state.py index 235432b..f558775 100644 --- a/core/risk/market_state.py +++ b/core/risk/market_state.py @@ -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) diff --git a/core/risk/position.py b/core/risk/position.py new file mode 100644 index 0000000..ce2406b --- /dev/null +++ b/core/risk/position.py @@ -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}") diff --git a/core/risk/position_manager.py b/core/risk/position_manager.py deleted file mode 100644 index 3c4cadd..0000000 --- a/core/risk/position_manager.py +++ /dev/null @@ -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}") diff --git a/core/signal/__init__.py b/core/signal/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/signal/combiner.py b/core/signal/combiner.py new file mode 100644 index 0000000..2ff95bb --- /dev/null +++ b/core/signal/combiner.py @@ -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 diff --git a/core/signal/registry.py b/core/signal/registry.py new file mode 100644 index 0000000..1ee400f --- /dev/null +++ b/core/signal/registry.py @@ -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 diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/cache/.gitkeep b/data/cache/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/execution/backtest.py b/execution/backtest.py new file mode 100644 index 0000000..11aa4ae --- /dev/null +++ b/execution/backtest.py @@ -0,0 +1,192 @@ +"""统一回测引擎 — 消除 start_backtest.py 和 optimizer.py 中的回测循环重复""" + +import sys +import os +import pandas as pd +import numpy as np +from tqdm import tqdm +from logger import logger + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from core.data import MultiTimeframeDataStore, BacktestDataProvider +from core.risk.controller import RiskController +from core.risk.market_state import MarketStateAnalyzer +from core.signal.registry import StrategyRegistry +from core.signal.combiner import SignalCombiner +from execution.weights import DynamicWeightManager +from config import ( + SYMBOL, TIMEFRAME, INITIAL_CAPITAL, SIGNAL_THRESHOLDS, DEFAULT_WEIGHTS, + BACKTEST_CONFIG, RISK_CONFIG, + MARKET_STATE_CONFIG, TREND_INDICATOR_WEIGHTS, TREND_THRESHOLDS, CONFIDENCE_THRESHOLDS +) +from utils.constants import PERIOD_H1 + + +class BacktestEngine: + """统一回测引擎 + + 支持两种模式: + - standard: 使用 DEFAULT_WEIGHTS 的固定权重回测 + - dynamic: 使用 MarketStateAnalyzer 的动态权重回测 + """ + + def __init__(self, symbol: str = SYMBOL, timeframe: int = TIMEFRAME, + initial_capital: float = INITIAL_CAPITAL, + use_dynamic_weights: bool = False): + self.symbol = symbol + self.timeframe = timeframe + self.initial_capital = initial_capital + self.use_dynamic_weights = use_dynamic_weights + + # 延迟初始化 + self.multi_tf = None + self.data_provider = None + self.risk_controller = None + self.registry = StrategyRegistry() + + def load_data(self, rates: np.ndarray) -> None: + """加载M1数据并初始化组件""" + if rates is None or len(rates) == 0: + raise ValueError("无法加载历史数据") + + self.multi_tf = MultiTimeframeDataStore() + self.multi_tf.load_m1_data(rates) + self.data_provider = BacktestDataProvider(self.multi_tf, self.initial_capital) + + # 预计算市场状态(动态权重模式) + analyzer = None + if self.use_dynamic_weights: + analyzer = MarketStateAnalyzer( + timeframe=PERIOD_H1, + market_state_params=MARKET_STATE_CONFIG, + trend_weights=TREND_INDICATOR_WEIGHTS, + trend_thresholds=TREND_THRESHOLDS, + confidence_thresholds=CONFIDENCE_THRESHOLDS, + ) + analyzer.precompute_from_multitf(self.multi_tf) + + self.risk_controller = RiskController( + self.data_provider, + trade_direction=BACKTEST_CONFIG.get('trade_direction', 'both'), + risk_config=RISK_CONFIG, + market_state_analyzer=analyzer, + ) + + self.weight_manager = DynamicWeightManager( + self.data_provider, + market_state_analyzer=analyzer or MarketStateAnalyzer(), + ) + + def _instantiate_strategies(self, params_dict: dict = None) -> dict: + """使用给定参数实例化所有策略""" + return self.registry.instantiate_all(self.symbol, self.timeframe, params_dict) + + def precompute_signals(self, strategies: dict) -> pd.DataFrame: + """预生成所有策略信号""" + logger.info("开始预生成所有策略信号...") + signals_df = pd.DataFrame(index=self.multi_tf.main_df.index) + + for config_key, strategy in tqdm(strategies.items(), desc="生成策略信号"): + try: + sig = strategy.run_backtest(self.multi_tf.main_df) + if sig is not None and len(sig) > 0: + signals_df[config_key] = sig + logger.debug(f"策略 {config_key} 信号生成成功") + else: + logger.warning(f"策略 {config_key} 返回空信号") + signals_df[config_key] = pd.Series(0, index=signals_df.index) + except Exception as e: + logger.error(f"策略 {config_key} 执行失败: {e}") + signals_df[config_key] = pd.Series(0, index=signals_df.index) + + logger.info(f"信号预生成完成: {len(signals_df.columns)} 个策略") + return signals_df + + def run(self, rates: np.ndarray, + strategy_params: dict = None, + weights: dict = None, + buy_threshold: float = None, + sell_threshold: float = None) -> dict: + """执行完整回测 + + Args: + rates: MT5原始M1数据(numpy数组) + strategy_params: {config_key: {param_name: value}} 策略参数覆写 + weights: {config_key: weight} 策略权重,None则使用 DEFAULT_WEIGHTS + buy_threshold: 买入阈值 + sell_threshold: 卖出阈值 + + Returns: + dict: 交易摘要 + """ + self.load_data(rates) + + buy_th = buy_threshold or SIGNAL_THRESHOLDS.get('buy_threshold', 1.5) + sell_th = sell_threshold or SIGNAL_THRESHOLDS.get('sell_threshold', -1.5) + + # 实例化策略并预计算信号 + strategies = self._instantiate_strategies(strategy_params) + signals_df = self.precompute_signals(strategies) + + # 信号组合 + if weights is not None: + use_weights = weights + else: + use_weights = DEFAULT_WEIGHTS + + combined_signals = SignalCombiner.combine_vectorized( + signals_df, use_weights, buy_th, sell_th + ) + + logger.info("回测主循环开始...") + total_bars = len(self.multi_tf.main_df) + + for bar_index in tqdm(range(total_bars), desc="回测执行"): + try: + current_price = self.data_provider.get_current_price(self.symbol) + if not current_price: + self.data_provider.tick() + continue + + # 动态权重模式:每bar重新计算权重并重新组合信号 + if self.use_dynamic_weights and self.weight_manager.analyzer._precomputed_states is not None: + bar_weights = self.weight_manager.get_weights_for_bar(bar_index) + bar_signals = {col: signals_df[col].iloc[bar_index] for col in signals_df.columns} + current_signal = SignalCombiner.combine_at_bar( + bar_signals, bar_weights, buy_th, sell_th + ) + else: + current_signal = combined_signals.iloc[bar_index] + if current_signal == 1: + self.risk_controller.process_trading_signal("buy", current_price, 1.0) + elif current_signal == -1: + self.risk_controller.process_trading_signal("sell", current_price, 1.0) + + self.risk_controller.monitor_positions(current_price, dry_run=True) + self.data_provider.tick() + + except Exception as e: + logger.error(f"回测循环在索引 {bar_index} 出错: {e}") + self.data_provider.tick() + continue + + # 生成报告 + summary = self.risk_controller.position_manager.get_trade_summary() + self._print_report(summary) + + # 保存交易记录 + try: + self.risk_controller.save_trade_history("backtest_trades") + except Exception as e: + logger.error(f"保存交易记录失败: {e}") + + return summary + + def _print_report(self, summary: dict) -> None: + logger.info("=" * 80) + logger.info("回测性能报告") + logger.info(f"总交易次数: {summary.get('total_trades', 0)}") + logger.info(f"胜率: {summary.get('win_rate', 0):.2f}%") + logger.info(f"总盈亏: ${summary.get('total_profit_loss', 0):.2f}") + logger.info("=" * 80) diff --git a/execution/backtest_engine.py b/execution/backtest_engine.py deleted file mode 100644 index 9711ea2..0000000 --- a/execution/backtest_engine.py +++ /dev/null @@ -1,95 +0,0 @@ -import pandas as pd -from tqdm import tqdm -from core.risk import RiskController -from config import SIGNAL_THRESHOLDS, BACKTEST_CONFIG, SPREAD -from logger import logger - -class BacktestEngine: - def __init__(self, df, trade_direction="both"): - self.df = df - self.trade_direction = trade_direction - self.spread = BACKTEST_CONFIG.get("spread", SPREAD) - - def run_strategy(self, strategy): - signals = strategy.run_backtest(self.df) - buy_count = (signals == 1).sum() - sell_count = (signals == -1).sum() - logger.info(f"策略 {strategy.__class__.__module__} 信号统计 - 买入: {buy_count}, 卖出: {sell_count}") - return signals - - def combine_signals(self, signals_list, weights): - df_signals = pd.concat(signals_list, axis=1).fillna(0) - weighted_signals = df_signals * weights - combined = weighted_signals.sum(axis=1) - - def apply_threshold(score): - if score > SIGNAL_THRESHOLDS["buy_threshold"]: - return 1 - elif score < SIGNAL_THRESHOLDS["sell_threshold"]: - return -1 - else: - return 0 - - combined_signal = combined.apply(apply_threshold) - buy_signals = (combined_signal == 1).sum() - sell_signals = (combined_signal == -1).sum() - logger.info(f"组合信号统计 - 买入: {buy_signals}, 卖出: {sell_signals}") - return combined_signal - - def run_backtest(self, signals, symbol="XAUUSD"): - df = self.df.copy() - df['signal'] = signals.shift(1).fillna(0) - - risk_controller = RiskController(self.trade_direction) - logger.info(f"回测开始: 交易方向={self.trade_direction}") - - # 使用tqdm创建进度条 - for i in tqdm(range(1, len(df)), desc=f"Backtesting ({len(df)} bars)"): - current_signal = df['signal'].iloc[i] - # 计算考虑双向点差的买卖价格 - close_price = df['close'].iloc[i] - spread_points = self.spread - spread_half = spread_points * 0.01 / 2 # XAUUSD: 1点 = 0.01,双向点差各一半 - - current_price = { - 'bid': close_price - spread_half, # 卖出价格(中间价 - 点差/2) - 'ask': close_price + spread_half, # 买入价格(中间价 + 点差/2) - 'last': close_price # 最后成交价(中间价) - } - - direction = None - if current_signal == 1: - direction = "buy" - elif current_signal == -1: - direction = "sell" - - if direction: - risk_controller.process_trading_signal( - direction, current_price, abs(current_signal), dry_run=True - ) - - risk_controller.monitor_positions(current_price, dry_run=True) - - risk_controller.position_manager.force_close_all_positions(df['close'].iloc[-1], dry_run=True) - - logger.info("回测完成,生成性能报告...") - summary = risk_controller.position_manager.get_trade_summary() - - logger.info("=" * 80) - logger.info("回测性能报告") - logger.info("=" * 80) - logger.info(f" 总交易次数: {summary['total_trades']}") - logger.info(f" 盈利次数: {summary['winning_trades']}") - logger.info(f" 亏损次数: {summary['losing_trades']}") - logger.info(f" 胜率: {summary['win_rate']:.2f}%") - logger.info("-" * 40) - logger.info(f" 总盈亏: ${summary['total_profit_loss']:.2f}") - logger.info(f" 平均每笔交易盈亏: ${summary['avg_profit_loss']:.2f}") - logger.info(f" 最大盈利: ${summary['max_profit']:.2f}") - logger.info(f" 最大亏损: ${summary['max_loss']:.2f}") - logger.info("=" * 80) - - risk_controller.position_manager.save_to_csv("backtest_trades.csv") - risk_controller.position_manager.save_to_json("backtest_trades.json") - - return summary diff --git a/execution/dynamic_weights.py b/execution/dynamic_weights.py deleted file mode 100644 index 53800b1..0000000 --- a/execution/dynamic_weights.py +++ /dev/null @@ -1,67 +0,0 @@ -from strategies import ma_cross, rsi, bollinger, mean_reversion, momentum_breakout, macd, kdj, turtle, daily_breakout, wave_theory -from config import DEFAULT_WEIGHTS, SYMBOL, TIMEFRAME -from logger import logger -from core.risk.market_state import MarketStateAnalyzer - -class DynamicWeightManager: - """ - 动态权重管理器 - """ - - def __init__(self, data_provider, market_state_params=None, trend_weights=None, trend_thresholds=None, confidence_thresholds=None): - self.data_provider = data_provider - self.market_state_analyzer = MarketStateAnalyzer( - data_provider, - market_state_params=market_state_params, - trend_weights=trend_weights, - trend_thresholds=trend_thresholds, - confidence_thresholds=confidence_thresholds - ) - - # 策略类和它们的初始化参数的映射 - self.strategy_blueprints = { - 'ma_cross': (ma_cross.MACrossStrategy, {}), - 'rsi': (rsi.RSIStrategy, {}), - 'bollinger': (bollinger.BollingerStrategy, {}), - 'mean_reversion': (mean_reversion.MeanReversionStrategy, {}), - 'momentum_breakout': (momentum_breakout.MomentumBreakoutStrategy, {}), - 'macd': (macd.MACDStrategy, {}), - 'kdj': (kdj.KDJStrategy, {}), - 'turtle': (turtle.TurtleStrategy, {}), - 'daily_breakout': (daily_breakout.DailyBreakoutStrategy, {}), - 'wave_theory': (wave_theory.WaveTheoryStrategy, {}) - } - - self.strategy_instances = self._create_strategy_instances() - - self.current_weights = None - self.current_market_state = "none" - self.current_confidence = 0.0 - - def _create_strategy_instances(self): - instances = {} - for name, (strategy_class, params) in self.strategy_blueprints.items(): - instances[name] = strategy_class(self.data_provider, SYMBOL, TIMEFRAME, **params) - return instances - - def get_current_strategies_and_weights(self): - market_state, confidence = self.market_state_analyzer.get_market_state() - dynamic_weights = self.market_state_analyzer.get_strategy_weights(market_state, confidence) - - self.current_weights = dynamic_weights - self.current_market_state = market_state - self.current_confidence = confidence - - strategies_with_weights = [] - for name, weight in dynamic_weights.items(): - if name in self.strategy_instances: - strategies_with_weights.append((self.strategy_instances[name], weight)) - - return strategies_with_weights - - def get_weight_info(self): - return { - 'market_state': self.current_market_state, - 'confidence': self.current_confidence, - 'weights': self.current_weights - } \ No newline at end of file diff --git a/execution/optimize.py b/execution/optimize.py new file mode 100644 index 0000000..6b194d3 --- /dev/null +++ b/execution/optimize.py @@ -0,0 +1,578 @@ +"""遗传算法优化器(重写版) + +关键改进: + 1. 权重基因通过 individual_weights → MarketStateAnalyzer.get_strategy_weights() 真正生效 + 2. 使用 StrategyRegistry 单例消,除重复的策略列表 + 3. 使用 MultiTimeframeDataStore 支持正确的多周期市场状态分析 + 4. evaluate_fitness 逐 bar 调用 get_market_state(bar_index) 获取每根bar的动态权重 +""" + +import random +import numpy as np +from deap import base, creator, tools, algorithms +import multiprocessing +import pandas as pd +import sys +import os +from datetime import datetime +from tqdm import tqdm + +SEED = 42 +random.seed(SEED) +np.random.seed(SEED) + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from core.data import MultiTimeframeDataStore, BacktestDataProvider +from core.risk.market_state import MarketStateAnalyzer +from core.risk.position import PositionManager +from core.signal.registry import StrategyRegistry +from core.signal.combiner import SignalCombiner +from config import ( + SYMBOL, TIMEFRAME, OPTIMIZER_COUNT, OPTIMIZER_START_DATE, OPTIMIZER_END_DATE, + USE_DATE_RANGE, INITIAL_CAPITAL, SIGNAL_THRESHOLDS, DEFAULT_WEIGHTS, + RISK_CONFIG, GENETIC_OPTIMIZER_CONFIG, + MARKET_STATE_CONFIG, TREND_INDICATOR_WEIGHTS, TREND_THRESHOLDS, CONFIDENCE_THRESHOLDS +) +from utils.constants import PERIOD_H1 +from core.utils import get_rates, initialize, shutdown +from logger import logger + +# 导入策略模块确保 StrategyRegistry 已注册 +import strategies # noqa: F401 + +# ── 全局缓存 ── +_multi_tf: MultiTimeframeDataStore | None = None +_cached_signals: pd.DataFrame | None = None +_registry: StrategyRegistry | None = None + + +def init_worker(): + """多进程worker初始化:抑制日志噪音""" + import logging + logging.getLogger().setLevel(logging.WARNING) + for name in ["StrategyLogger", "PositionManager", "RiskController", "DataProvider"]: + logging.getLogger(name).setLevel(logging.WARNING) + + +# ── 参数定义(与旧版一致,保持兼容)── +PARAMETER_DEFINITIONS = [ + # MACrossStrategy + {'name': 'ma_cross_short_window', 'type': 'int', 'min': 3, 'max': 15, 'strategy': 'MACrossStrategy'}, + {'name': 'ma_cross_long_window', 'type': 'int', 'min': 10, 'max': 60, 'strategy': 'MACrossStrategy'}, + # RSIStrategy + {'name': 'rsi_period', 'type': 'int', 'min': 7, 'max': 21, 'strategy': 'RSIStrategy'}, + {'name': 'rsi_overbought', 'type': 'int', 'min': 65, 'max': 80, 'strategy': 'RSIStrategy'}, + {'name': 'rsi_oversold', 'type': 'int', 'min': 20, 'max': 35, 'strategy': 'RSIStrategy'}, + # BollingerStrategy + {'name': 'bollinger_period', 'type': 'int', 'min': 10, 'max': 50, 'strategy': 'BollingerStrategy'}, + {'name': 'bollinger_std_dev', 'type': 'float', 'min': 1.5, 'max': 3.0, 'strategy': 'BollingerStrategy'}, + # MACDStrategy + {'name': 'macd_fast_ema', 'type': 'int', 'min': 8, 'max': 20, 'strategy': 'MACDStrategy'}, + {'name': 'macd_slow_ema', 'type': 'int', 'min': 20, 'max': 35, 'strategy': 'MACDStrategy'}, + {'name': 'macd_signal_period', 'type': 'int', 'min': 5, 'max': 15, 'strategy': 'MACDStrategy'}, + # MeanReversionStrategy + {'name': 'mean_reversion_period', 'type': 'int', 'min': 10, 'max': 50, 'strategy': 'MeanReversionStrategy'}, + {'name': 'mean_reversion_std_dev', 'type': 'float', 'min': 1.5, 'max': 3.0, 'strategy': 'MeanReversionStrategy'}, + # MomentumBreakoutStrategy + {'name': 'momentum_breakout_period', 'type': 'int', 'min': 10, 'max': 50, 'strategy': 'MomentumBreakoutStrategy'}, + {'name': 'momentum_breakout_momentum_period', 'type': 'int', 'min': 5, 'max': 30, 'strategy': 'MomentumBreakoutStrategy'}, + # KDJStrategy + {'name': 'kdj_period', 'type': 'int', 'min': 5, 'max': 21, 'strategy': 'KDJStrategy'}, + # TurtleStrategy + {'name': 'turtle_period', 'type': 'int', 'min': 10, 'max': 50, 'strategy': 'TurtleStrategy'}, + # WaveTheoryStrategy + {'name': 'wave_ema_short', 'type': 'int', 'min': 3, 'max': 10, 'strategy': 'WaveTheoryStrategy'}, + {'name': 'wave_ema_medium', 'type': 'int', 'min': 8, 'max': 20, 'strategy': 'WaveTheoryStrategy'}, + {'name': 'wave_ema_long', 'type': 'int', 'min': 20, 'max': 50, 'strategy': 'WaveTheoryStrategy'}, + {'name': 'wave_period', 'type': 'int', 'min': 10, 'max': 40, 'strategy': 'WaveTheoryStrategy'}, + {'name': 'wave_range_period', 'type': 'int', 'min': 10, 'max': 40, 'strategy': 'WaveTheoryStrategy'}, + {'name': 'wave_adx_period', 'type': 'int', 'min': 10, 'max': 25, 'strategy': 'WaveTheoryStrategy'}, + {'name': 'wave_momentum_period', 'type': 'int', 'min': 7, 'max': 21, 'strategy': 'WaveTheoryStrategy'}, + {'name': 'wave_range_threshold', 'type': 'float', 'min': 0.002, 'max': 0.01, 'strategy': 'WaveTheoryStrategy'}, + {'name': 'wave_adx_threshold', 'type': 'int', 'min': 15, 'max': 35, 'strategy': 'WaveTheoryStrategy'}, + # DailyBreakoutStrategy + {'name': 'daily_breakout_bars_count', 'type': 'int', 'min': 720, 'max': 2880, 'strategy': 'DailyBreakoutStrategy'}, + # Signal Thresholds + {'name': 'buy_threshold', 'type': 'float', 'min': 0.5, 'max': 3.0, 'strategy': 'signal'}, + {'name': 'sell_threshold', 'type': 'float', 'min': -3.0, 'max': -0.5, 'strategy': 'signal'}, + # Risk Management + {'name': 'stop_loss_pct', 'type': 'float', 'min': -0.05, 'max': -0.01, 'strategy': 'risk'}, + {'name': 'profit_retracement_pct', 'type': 'float', 'min': 0.05, 'max': 0.20, 'strategy': 'risk'}, + {'name': 'min_profit_for_trailing', 'type': 'float', 'min': 0.005, 'max': 0.02, 'strategy': 'risk'}, + {'name': 'take_profit_pct', 'type': 'float', 'min': 0.10, 'max': 0.50, 'strategy': 'risk'}, + {'name': 'max_holding_minutes', 'type': 'int', 'min': 30, 'max': 180, 'strategy': 'risk'}, + {'name': 'min_profit_for_time_exit', 'type': 'float', 'min': 0.002, 'max': 0.01, 'strategy': 'risk'}, + # ★ Strategy Weights — 这些基因现在真正影响适应度 + {'name': 'weight_MACrossStrategy', 'type': 'float', 'min': 0.0, 'max': 2.0, 'strategy': 'weight'}, + {'name': 'weight_RSIStrategy', 'type': 'float', 'min': 0.0, 'max': 2.0, 'strategy': 'weight'}, + {'name': 'weight_BollingerStrategy', 'type': 'float', 'min': 0.0, 'max': 2.0, 'strategy': 'weight'}, + {'name': 'weight_MeanReversionStrategy', 'type': 'float', 'min': 0.0, 'max': 2.0, 'strategy': 'weight'}, + {'name': 'weight_MomentumBreakoutStrategy', 'type': 'float', 'min': 0.0, 'max': 2.0, 'strategy': 'weight'}, + {'name': 'weight_MACDStrategy', 'type': 'float', 'min': 0.0, 'max': 2.0, 'strategy': 'weight'}, + {'name': 'weight_KDJStrategy', 'type': 'float', 'min': 0.0, 'max': 2.0, 'strategy': 'weight'}, + {'name': 'weight_TurtleStrategy', 'type': 'float', 'min': 0.0, 'max': 2.0, 'strategy': 'weight'}, + {'name': 'weight_DailyBreakoutStrategy', 'type': 'float', 'min': 0.0, 'max': 2.0, 'strategy': 'weight'}, + {'name': 'weight_WaveTheoryStrategy', 'type': 'float', 'min': 0.0, 'max': 2.0, 'strategy': 'weight'}, + # Market State Analysis parameters + {'name': 'market_trend_period', 'type': 'int', 'min': 20, 'max': 100, 'strategy': 'market_state'}, + {'name': 'market_retracement_tolerance', 'type': 'float', 'min': 0.1, 'max': 0.5, 'strategy': 'market_state'}, + {'name': 'market_volume_period', 'type': 'int', 'min': 10, 'max': 50, 'strategy': 'market_state'}, + {'name': 'market_volume_ma_period', 'type': 'int', 'min': 5, 'max': 30, 'strategy': 'market_state'}, + # Trend Indicator Weights + {'name': 'trend_price_breakout_weight', 'type': 'float', 'min': 0.1, 'max': 0.5, 'strategy': 'trend_weights'}, + {'name': 'trend_volume_confirmation_weight', 'type': 'float', 'min': 0.1, 'max': 0.5, 'strategy': 'trend_weights'}, + {'name': 'trend_momentum_oscillator_weight', 'type': 'float', 'min': 0.1, 'max': 0.5, 'strategy': 'trend_weights'}, + {'name': 'trend_moving_average_weight', 'type': 'float', 'min': 0.1, 'max': 0.5, 'strategy': 'trend_weights'}, + # Trend Thresholds + {'name': 'trend_strong_threshold', 'type': 'float', 'min': 0.4, 'max': 0.8, 'strategy': 'trend_thresholds'}, + {'name': 'trend_weak_threshold', 'type': 'float', 'min': 0.2, 'max': 0.5, 'strategy': 'trend_thresholds'}, + {'name': 'trend_volume_spike', 'type': 'float', 'min': 1.0, 'max': 3.0, 'strategy': 'trend_thresholds'}, + {'name': 'trend_oversold', 'type': 'int', 'min': 20, 'max': 40, 'strategy': 'trend_thresholds'}, + {'name': 'trend_overbought', 'type': 'int', 'min': 60, 'max': 80, 'strategy': 'trend_thresholds'}, + # Confidence Thresholds + {'name': 'confidence_high', 'type': 'float', 'min': 0.5, 'max': 0.9, 'strategy': 'confidence'}, + {'name': 'confidence_medium', 'type': 'float', 'min': 0.3, 'max': 0.7, 'strategy': 'confidence'}, +] + +# class_name → config_key 映射(从 StrategyRegistry 获取) +_class_name_to_config_key = { + "MACrossStrategy": "ma_cross", + "RSIStrategy": "rsi", + "BollingerStrategy": "bollinger", + "MeanReversionStrategy": "mean_reversion", + "MomentumBreakoutStrategy": "momentum_breakout", + "MACDStrategy": "macd", + "KDJStrategy": "kdj", + "TurtleStrategy": "turtle", + "DailyBreakoutStrategy": "daily_breakout", + "WaveTheoryStrategy": "wave_theory", +} + + +def _parse_individual(individual): + """解析个体基因为命名参数字典,并裁剪到定义范围""" + parsed = {} + for idx, param_def in enumerate(PARAMETER_DEFINITIONS): + val = individual[idx] + if param_def['type'] == 'int': + val = int(round(val)) + # 裁剪到 [min, max] 防止变异越界 + val = max(param_def['min'], min(param_def['max'], val)) + parsed[param_def['name']] = val + return parsed + + +def _extract_strategy_params(parsed: dict) -> dict: + """从解析后的基因提取策略参数字典""" + strategy_params = {} + for param_def in PARAMETER_DEFINITIONS: + strategy_name = param_def.get('strategy') + if strategy_name in ('weight', 'signal', 'risk', 'market_state', + 'trend_weights', 'trend_thresholds', 'confidence'): + continue + if strategy_name not in strategy_params: + strategy_params[strategy_name] = {} + + # 参数名映射(保持与旧版兼容) + param_name = param_def['name'] + if param_name == 'ma_cross_short_window': + param_name = 'short_window' + elif param_name == 'ma_cross_long_window': + param_name = 'long_window' + elif param_name == 'rsi_period': + param_name = 'period' + elif param_name == 'rsi_overbought': + param_name = 'overbought' + elif param_name == 'rsi_oversold': + param_name = 'oversold' + elif param_name == 'bollinger_period': + param_name = 'period' + elif param_name == 'bollinger_std_dev': + param_name = 'std_dev' + elif param_name == 'macd_fast_ema': + param_name = 'fast_ema' + elif param_name == 'macd_slow_ema': + param_name = 'slow_ema' + elif param_name == 'macd_signal_period': + param_name = 'signal_period' + elif param_name == 'mean_reversion_period': + param_name = 'period' + elif param_name == 'mean_reversion_std_dev': + param_name = 'std_dev' + elif param_name == 'momentum_breakout_period': + param_name = 'period' + elif param_name == 'momentum_breakout_momentum_period': + param_name = 'momentum_period' + elif param_name == 'kdj_period': + param_name = 'period' + elif param_name == 'turtle_period': + param_name = 'period' + elif param_name.startswith("wave_"): + param_name = param_name[5:] + if param_name == "period": + param_name = "wave_period" + elif param_name == 'daily_breakout_bars_count': + param_name = 'bars_count' + + strategy_params[strategy_name][param_name] = parsed[param_def['name']] + return strategy_params + + +def _extract_weight_genes(parsed: dict) -> dict: + """★ 提取权重基因 → {config_key: weight} + + 这些权重会通过 individual_weights 参数传递给 + MarketStateAnalyzer.get_strategy_weights(),使权重基因真正生效。 + """ + weight_genes = {} + for param_def in PARAMETER_DEFINITIONS: + if param_def.get('strategy') != 'weight': + continue + class_name = param_def['name'].replace('weight_', '') + config_key = _class_name_to_config_key.get(class_name, class_name.lower()) + weight_genes[config_key] = parsed[param_def['name']] + return weight_genes + + +def _precompute_h1_states(multi_tf: MultiTimeframeDataStore, analyzer: MarketStateAnalyzer) -> list: + """预计算H1市场状态序列 → 映射到M1时间轴""" + h1_df = multi_tf.ensure_timeframe(PERIOD_H1) + if h1_df is None or len(h1_df) == 0: + return [("none", 0.0)] * multi_tf.length + + m1_index = multi_tf.main_df.index + h1_index = h1_df.index + + # 计算每个H1 bar的状态 + h1_states = [] + for i in range(len(h1_df)): + lookback = analyzer.trend_period + 10 + start = max(0, i - lookback) + df_slice = h1_df.iloc[start:i + 1] + state, conf = analyzer._calculate_state_from_df(df_slice) + h1_states.append((state, conf)) + + # 映射到M1时间轴 + m1_states = [] + h1_pos = 0 + first_h1_time = h1_index[0] + + for m1_time in m1_index: + if m1_time < first_h1_time: + m1_states.append(("none", 0.0)) + continue + while h1_pos < len(h1_index) - 1 and m1_time >= h1_index[h1_pos + 1]: + h1_pos += 1 + if h1_pos < len(h1_states): + m1_states.append(h1_states[h1_pos]) + else: + m1_states.append(("none", 0.0)) + + return m1_states + + +def evaluate_fitness(individual, multi_tf: MultiTimeframeDataStore, + _signals_df_unused=None) -> tuple: + """★ 重写的适应度函数 — 所有权重基因、策略参数和风控参数真正生效 + + 三个关键基因全部生效: + 1. 策略参数 → 重新实例化策略并 run_backtest → 影响信号序列 + 2. 权重基因 → per-bar get_strategy_weights(individual_weights=...) → 影响信号组合 + 3. 风控参数 → PositionManager 在回测中真正使用 + """ + parsed = _parse_individual(individual) + + # 1. 提取策略参数 + strategy_params = _extract_strategy_params(parsed) + + # 2. ★ 提取权重基因(核心修复) + weight_genes = _extract_weight_genes(parsed) + + # 3. 提取风控参数 + risk_params = { + 'stop_loss_pct': parsed.get('stop_loss_pct', RISK_CONFIG.get('stop_loss_pct', -0.01)), + 'profit_retracement_pct': parsed.get('profit_retracement_pct', RISK_CONFIG.get('profit_retracement_pct', 0.10)), + 'min_profit_for_trailing': parsed.get('min_profit_for_trailing', RISK_CONFIG.get('min_profit_for_trailing', 0.01)), + 'take_profit_pct': parsed.get('take_profit_pct', RISK_CONFIG.get('take_profit_pct', 0.20)), + 'max_holding_minutes': parsed.get('max_holding_minutes', RISK_CONFIG.get('max_holding_minutes', 60)), + 'min_profit_for_time_exit': parsed.get('min_profit_for_time_exit', RISK_CONFIG.get('min_profit_for_time_exit', 0.005)), + 'max_daily_loss': parsed.get('max_daily_loss', RISK_CONFIG.get('max_daily_loss', -0.30)), + } + + # 4. 提取市场状态和趋势参数 + market_params = { + 'trend_period': parsed.get('market_trend_period', MARKET_STATE_CONFIG.get('trend_period', 50)), + 'retracement_tolerance': parsed.get('market_retracement_tolerance', MARKET_STATE_CONFIG.get('retracement_tolerance', 0.30)), + 'volume_period': parsed.get('market_volume_period', MARKET_STATE_CONFIG.get('volume_period', 20)), + 'volume_ma_period': parsed.get('market_volume_ma_period', MARKET_STATE_CONFIG.get('volume_ma_period', 10)), + 'hourly_data_count': 100, + } + trend_weights = { + 'price_breakout': parsed.get('trend_price_breakout_weight', 0.35), + 'volume_confirmation': parsed.get('trend_volume_confirmation_weight', 0.25), + 'momentum oscillator': parsed.get('trend_momentum_oscillator_weight', 0.20), + 'moving_average': parsed.get('trend_moving_average_weight', 0.20), + } + trend_thresholds = { + 'strong_trend': parsed.get('trend_strong_threshold', 0.6), + 'weak_trend': parsed.get('trend_weak_threshold', 0.3), + 'volume_spike': parsed.get('trend_volume_spike', 1.5), + 'oversold': parsed.get('trend_oversold', 30), + 'overbought': parsed.get('trend_overbought', 70), + } + confidence_thresholds = { + 'high_confidence': parsed.get('confidence_high', 0.7), + 'medium_confidence': parsed.get('confidence_medium', 0.4), + } + + # 5. 构建 MarketStateAnalyzer 并预计算H1状态 + analyzer = MarketStateAnalyzer( + timeframe=PERIOD_H1, + market_state_params=market_params, + trend_weights=trend_weights, + trend_thresholds=trend_thresholds, + confidence_thresholds=confidence_thresholds, + ) + analyzer._precomputed_states = _precompute_h1_states(multi_tf, analyzer) + + # 5.5. ★ 使用个体的策略参数重新实例化策略并重新计算信号 + # 将 class_name → {params} 映射为 config_key → {params} + strategy_params_by_key = { + _class_name_to_config_key.get(cn, cn.lower()): params + for cn, params in strategy_params.items() + } + registry = StrategyRegistry() + sig_instances = registry.instantiate_all(SYMBOL, TIMEFRAME, strategy_params_by_key) + signals_df = pd.DataFrame(index=multi_tf.main_df.index) + for config_key, strat in sig_instances.items(): + try: + sig = strat.run_backtest(multi_tf.main_df) + signals_df[config_key] = sig if sig is not None else pd.Series(0, index=signals_df.index) + except Exception: + signals_df[config_key] = pd.Series(0, index=signals_df.index) + + # 6. 回测环境 + data_provider = BacktestDataProvider(multi_tf, INITIAL_CAPITAL) + pm = PositionManager(data_provider, trade_direction="both", risk_config=risk_params, + persist_peaks=False) + + buy_th = parsed.get('buy_threshold', 1.5) + sell_th = parsed.get('sell_threshold', -1.5) + total_bars = multi_tf.length + + # 7. ★ 逐 bar 回测(权重基因真正影响信号组合) + for bar_index in range(total_bars): + current_price = data_provider.get_current_price(SYMBOL) + if not current_price: + data_provider.tick() + continue + + # 获取当前 bar 的市场状态 + state, conf = analyzer.get_market_state(bar_index) + + # ★ 权重基因在这里生效 + weights = analyzer.get_strategy_weights( + state, conf, individual_weights=weight_genes + ) + + # 组合当前 bar 的信号(使用个体参数重新计算的信号) + bar_signals = { + col: signals_df[col].iloc[bar_index] + for col in signals_df.columns + } + final_signal = SignalCombiner.combine_at_bar(bar_signals, weights, buy_th, sell_th) + + # 执行交易 + if final_signal == 1: + pm.open_position("buy", current_price, 1.0, dry_run=True) + elif final_signal == -1: + pm.open_position("sell", current_price, 1.0, dry_run=True) + + pm.monitor_positions(current_price, dry_run=True) + data_provider.tick() + + # 8. 适应度 = 总盈亏 + total_pnl = pm.total_equity - INITIAL_CAPITAL + return (total_pnl,) + + +def load_historical_data(): + """一次性加载M1数据并返回 MultiTimeframeDataStore""" + initialize() + rates = ( + get_rates(SYMBOL, TIMEFRAME, OPTIMIZER_COUNT, OPTIMIZER_START_DATE, OPTIMIZER_END_DATE) + if USE_DATE_RANGE else + get_rates(SYMBOL, TIMEFRAME, OPTIMIZER_COUNT) + ) + shutdown() + + if rates is None or len(rates) == 0: + raise RuntimeError("获取历史数据失败") + + if len(rates) > OPTIMIZER_COUNT: + rates = rates[-OPTIMIZER_COUNT:] + + store = MultiTimeframeDataStore() + store.load_m1_data(rates) + + # 预生成H1数据 + store.ensure_timeframe(PERIOD_H1) + + return store + + +def precompute_all_signals(multi_tf: MultiTimeframeDataStore, + strategy_params: dict = None) -> pd.DataFrame: + """使用默认参数预计算所有策略的回测信号""" + registry = StrategyRegistry() + strategies = registry.instantiate_all(SYMBOL, TIMEFRAME, strategy_params) + signals_df = pd.DataFrame(index=multi_tf.main_df.index) + + for config_key, strategy in strategies.items(): + try: + sig = strategy.run_backtest(multi_tf.main_df) + signals_df[config_key] = sig if sig is not None else pd.Series(0, index=signals_df.index) + except Exception: + signals_df[config_key] = pd.Series(0, index=signals_df.index) + + return signals_df + + +def run_optimizer(): + """运行遗传算法优化(所有基因真正生效)""" + try: + # 1. 加载数据(一次性) + logger.info("加载历史数据...") + multi_tf = load_historical_data() + logger.info(f"数据加载完成: {multi_tf.length} 条M1数据") + + # 2. DEAP 设置(策略信号在 evaluate_fitness 内部逐个体重新计算) + creator.create("FitnessMax", base.Fitness, weights=(1.0,)) + creator.create("Individual", list, fitness=creator.FitnessMax) + + toolbox = base.Toolbox() + + for i, param_def in enumerate(PARAMETER_DEFINITIONS): + if param_def['type'] == 'int': + toolbox.register(f"attr_param_{i}", random.randint, param_def['min'], param_def['max']) + else: + toolbox.register(f"attr_param_{i}", random.uniform, param_def['min'], param_def['max']) + + toolbox.register("individual", tools.initIterate, creator.Individual, + lambda: [toolbox.__getattribute__(f"attr_param_{j}")() + for j in range(len(PARAMETER_DEFINITIONS))]) + toolbox.register("population", tools.initRepeat, list, toolbox.individual) + + toolbox.register("evaluate", evaluate_fitness, + multi_tf=multi_tf) + toolbox.register("mate", tools.cxTwoPoint) + toolbox.register("mutate", tools.mutGaussian, + mu=GENETIC_OPTIMIZER_CONFIG["mutation_mu"], + sigma=GENETIC_OPTIMIZER_CONFIG["mutation_sigma"], + indpb=GENETIC_OPTIMIZER_CONFIG["mutation_indpb"]) + toolbox.register("select", tools.selTournament, + tournsize=GENETIC_OPTIMIZER_CONFIG["tournament_size"]) + + # 多进程 + if GENETIC_OPTIMIZER_CONFIG["enable_multiprocessing"]: + pool = multiprocessing.Pool( + processes=GENETIC_OPTIMIZER_CONFIG["processes"], + initializer=init_worker + ) + toolbox.register("map", pool.map) + else: + toolbox.register("map", map) + + # 4. 统计 + stats = tools.Statistics(lambda ind: ind.fitness.values) + stats.register("avg", np.mean) + stats.register("std", np.std) + stats.register("min", np.min) + stats.register("max", np.max) + + # 5. 运行 + population = toolbox.population(n=GENETIC_OPTIMIZER_CONFIG["population_size"]) + ngen = GENETIC_OPTIMIZER_CONFIG["generations"] + cxpb = GENETIC_OPTIMIZER_CONFIG["crossover_probability"] + mutpb = GENETIC_OPTIMIZER_CONFIG["mutation_probability"] + + logger.info(f"开始进化: 种群 {len(population)}, {ngen} 代") + generation_info = [] + + for gen in range(ngen): + offspring = toolbox.select(population, len(population)) + offspring = algorithms.varAnd(offspring, toolbox, cxpb, mutpb) + fits = toolbox.map(toolbox.evaluate, offspring) + + for fit, ind in zip(fits, offspring): + ind.fitness.values = fit + + population[:] = offspring + + best = tools.selBest(population, k=1)[0] + best_fit = best.fitness.values[0] + avg_fit = np.mean([ind.fitness.values[0] for ind in population]) + + if GENETIC_OPTIMIZER_CONFIG.get("save_generation_info", True): + generation_info.append({ + 'generation': gen + 1, + 'best_fitness': best_fit, + 'avg_fitness': avg_fit, + }) + + if GENETIC_OPTIMIZER_CONFIG.get("verbose", True): + logger.info(f"代数 {gen + 1}/{ngen}: 最佳={best_fit:.2f}, 平均={avg_fit:.2f}") + + # 6. 结果 + if GENETIC_OPTIMIZER_CONFIG["enable_multiprocessing"]: + pool.close() + pool.join() + + best_individual = tools.selBest(population, k=1)[0] + best_fitness = best_individual.fitness.values[0] + + logger.info(f"优化完成: 最佳适应度={best_fitness:.2f}") + save_optimization_results(best_individual, best_fitness, generation_info) + + return _parse_individual(best_individual), best_fitness + + except Exception as e: + import traceback + logger.error(f"优化器错误: {e}\n{traceback.format_exc()}") + raise + + +def save_optimization_results(best_individual, best_fitness, generation_info): + """保存优化结果""" + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + parsed = _parse_individual(best_individual) + + # JSON + import json + results = { + 'best_fitness': best_fitness, + 'best_params': parsed, + 'generation_info': generation_info, + } + json_path = f"optimization_results_{timestamp}.json" + with open(json_path, 'w', encoding='utf-8') as f: + json.dump(results, f, ensure_ascii=False, indent=2, default=str) + logger.info(f"结果已保存: {json_path}") + + # TXT报告 + txt_path = f"optimization_report_{timestamp}.txt" + with open(txt_path, 'w', encoding='utf-8') as f: + f.write(f"优化完成时间: {datetime.now()}\n") + f.write(f"最佳适应度: {best_fitness:.2f}\n\n") + f.write("最佳参数:\n") + for key, value in parsed.items(): + f.write(f" {key}: {value}\n") + f.write("\n代数统计:\n") + for gi in generation_info: + f.write(f" 代数 {gi['generation']}: 最佳={gi['best_fitness']:.2f}, 平均={gi['avg_fitness']:.2f}\n") + logger.info(f"报告已保存: {txt_path}") + + +if __name__ == "__main__": + run_optimizer() diff --git a/execution/realtime_trader.py b/execution/realtime_trader.py index e83591f..4d0e599 100644 --- a/execution/realtime_trader.py +++ b/execution/realtime_trader.py @@ -5,7 +5,7 @@ from datetime import datetime from logger import logger from config import SYMBOL, TIMEFRAME, REALTIME_CONFIG, SIGNAL_THRESHOLDS from core.risk import RiskController -from execution.dynamic_weights import DynamicWeightManager +from execution.weights import DynamicWeightManager class RealtimeTrader: """实时交易器 (已重构为依赖注入)""" diff --git a/execution/weights.py b/execution/weights.py new file mode 100644 index 0000000..2ec1ebd --- /dev/null +++ b/execution/weights.py @@ -0,0 +1,78 @@ +"""动态权重管理器(重写版) + +关键改进: + 1. 使用 StrategyRegistry(单例)获取策略列表,消除 blueprint 重复 + 2. 回测/优化器模式支持逐 bar 获取权重(get_weights_for_bar) + 3. 优化器模式接受 individual_weights 参数并传递给 MarketStateAnalyzer +""" + +import strategies # noqa: F401 — 触发策略注册 +from logger import logger +from core.risk.market_state import MarketStateAnalyzer +from core.signal.registry import StrategyRegistry +from config import SYMBOL, TIMEFRAME + + +class DynamicWeightManager: + """动态权重管理器""" + + def __init__(self, data_provider, + market_state_analyzer: MarketStateAnalyzer = None): + self.data_provider = data_provider + self.analyzer = market_state_analyzer or MarketStateAnalyzer(data_provider) + self.registry = StrategyRegistry() + + # 初始化策略实例(仅实盘模式首次使用) + self._strategies_initialized = False + + def _ensure_strategies(self): + if not self._strategies_initialized: + self.registry.instantiate_all(SYMBOL, TIMEFRAME, + data_provider=self.data_provider) + self._strategies_initialized = True + + # ── 实盘模式 ── + + def get_current_strategies_and_weights(self) -> list: + """获取当前(实时)策略实例和权重列表""" + self._ensure_strategies() + weights = self.get_current_weights() + + result = [] + for config_key, weight in weights.items(): + instance = self.registry.get_instance(config_key) + if instance is not None: + result.append((instance, weight)) + return result + + def get_current_weights(self) -> dict: + """获取当前实时权重""" + market_state, confidence = self.analyzer.get_market_state() + return self.analyzer.get_strategy_weights(market_state, confidence) + + # ── 回测/优化器模式 ── + + def get_weights_for_bar(self, bar_index: int, + individual_weights: dict = None) -> dict: + """获取第 bar_index 个 bar 的策略权重 + + Args: + bar_index: 当前M1 bar索引 + individual_weights: 优化器传入 {config_key: weight},非None时直接使用 + + Returns: + {config_key: weight} + """ + market_state, confidence = self.analyzer.get_market_state(bar_index) + return self.analyzer.get_strategy_weights( + market_state, confidence, individual_weights + ) + + def get_weight_info(self) -> dict: + market_state, confidence = self.analyzer.get_market_state() + market_weights = self.analyzer.get_strategy_weights(market_state, confidence) + return { + 'market_state': market_state, + 'confidence': confidence, + 'weights': market_weights + } diff --git a/main.py b/main.py deleted file mode 100644 index 39a67cf..0000000 --- a/main.py +++ /dev/null @@ -1,22 +0,0 @@ -import pandas as pd -from core.utils import initialize, shutdown, get_rates -from logger import logger -from optimizer import run_optimizer - -# Note: The run_backtest and run_realtime functions have been moved to -# start_backtest.py and start_realtime.py respectively. -# This file is kept as a reference for the optimizer entry point. - -if __name__ == "__main__": - # --- 选择运行模式 --- - # 1. 运行回测, 请执行 python start_backtest.py - # run_backtest() - - # 2. 运行实盘交易, 请执行 python start_realtime.py - # from realtime_trader import RealtimeTrader - # trader = RealtimeTrader() - # trader.start() - - # 3. 运行遗传算法优化,寻找最佳权重 - # run_optimizer() - pass \ No newline at end of file diff --git a/optimizer.py b/optimizer.py deleted file mode 100644 index 0aa242b..0000000 --- a/optimizer.py +++ /dev/null @@ -1,721 +0,0 @@ -import random -import numpy as np -from deap import base, creator, tools, algorithms -import multiprocessing -import pandas as pd -import sys -import os -from datetime import datetime -from tqdm import tqdm - -# 固定随机种子,确保可复现 -SEED = 42 -random.seed(SEED) -np.random.seed(SEED) - -# Add project root to path -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -# Core components for backtesting -from core.data_providers import BacktestDataProvider -from core.risk import RiskController -from execution.dynamic_weights import DynamicWeightManager -from config import ( - SYMBOL, TIMEFRAME, OPTIMIZER_COUNT, OPTIMIZER_START_DATE, OPTIMIZER_END_DATE, - USE_DATE_RANGE, BACKTEST_CONFIG, INITIAL_CAPITAL, SIGNAL_THRESHOLDS, DEFAULT_WEIGHTS, - RISK_CONFIG, GENETIC_OPTIMIZER_CONFIG, MARKET_STATE_CONFIG, TREND_INDICATOR_WEIGHTS, - TREND_THRESHOLDS, CONFIDENCE_THRESHOLDS -) -from core.utils import get_rates, initialize, shutdown - -# Import all strategy classes -from strategies.ma_cross import MACrossStrategy -from strategies.rsi import RSIStrategy -from strategies.bollinger import BollingerStrategy -from strategies.mean_reversion import MeanReversionStrategy -from strategies.momentum_breakout import MomentumBreakoutStrategy -from strategies.macd import MACDStrategy -from strategies.kdj import KDJStrategy -from strategies.turtle import TurtleStrategy -from strategies.daily_breakout import DailyBreakoutStrategy -from strategies.wave_theory import WaveTheoryStrategy - - -# --- Global Data (Loaded once) --- -df_historical_data = None - -# 模块级别的初始化函数,用于多进程 -def init_worker(): - import logging - logging.getLogger().setLevel(logging.WARNING) - logging.getLogger("StrategyLogger").setLevel(logging.WARNING) - logging.getLogger("PositionManager").setLevel(logging.WARNING) - logging.getLogger("RiskController").setLevel(logging.WARNING) - logging.getLogger("DataProvider").setLevel(logging.WARNING) - -def load_historical_data(): - global df_historical_data - if df_historical_data is not None: - return df_historical_data - - initialize() - rates = ( - get_rates(SYMBOL, TIMEFRAME, OPTIMIZER_COUNT, OPTIMIZER_START_DATE, OPTIMIZER_END_DATE) - if USE_DATE_RANGE else - get_rates(SYMBOL, TIMEFRAME, OPTIMIZER_COUNT) - ) - shutdown() - - if rates is None: - print("获取历史数据失败,退出。") - sys.exit(1) - - df = pd.DataFrame(rates) - df.set_index(pd.to_datetime(df['time'], unit='s'), inplace=True) - - if len(df) > OPTIMIZER_COUNT: - df = df.iloc[-OPTIMIZER_COUNT:] - - df_historical_data = df - return df_historical_data - - -# --- Parameter Definition for DEAP Individual --- -# Define the search space for strategy parameters and weights -# Each entry: (name, type, min_val, max_val, strategy_class_name_if_param, default_val_if_weight) -# Type 'int' for integer parameters, 'float' for float parameters/weights -# Order matters for parsing the individual -PARAMETER_DEFINITIONS = [ - # MACrossStrategy parameters - {'name': 'ma_cross_short_window', 'type': 'int', 'min': 3, 'max': 15, 'strategy': 'MACrossStrategy'}, - {'name': 'ma_cross_long_window', 'type': 'int', 'min': 10, 'max': 60, 'strategy': 'MACrossStrategy'}, - - # RSIStrategy parameters - {'name': 'rsi_period', 'type': 'int', 'min': 7, 'max': 21, 'strategy': 'RSIStrategy'}, - {'name': 'rsi_overbought', 'type': 'int', 'min': 65, 'max': 80, 'strategy': 'RSIStrategy'}, - {'name': 'rsi_oversold', 'type': 'int', 'min': 20, 'max': 35, 'strategy': 'RSIStrategy'}, - - # BollingerStrategy parameters - {'name': 'bollinger_period', 'type': 'int', 'min': 10, 'max': 50, 'strategy': 'BollingerStrategy'}, - {'name': 'bollinger_std_dev', 'type': 'float', 'min': 1.5, 'max': 3.0, 'strategy': 'BollingerStrategy'}, - - # MACDStrategy parameters - {'name': 'macd_fast_ema', 'type': 'int', 'min': 8, 'max': 20, 'strategy': 'MACDStrategy'}, - {'name': 'macd_slow_ema', 'type': 'int', 'min': 20, 'max': 35, 'strategy': 'MACDStrategy'}, - {'name': 'macd_signal_period', 'type': 'int', 'min': 5, 'max': 15, 'strategy': 'MACDStrategy'}, - - # MeanReversionStrategy parameters - {'name': 'mean_reversion_period', 'type': 'int', 'min': 10, 'max': 50, 'strategy': 'MeanReversionStrategy'}, - {'name': 'mean_reversion_std_dev','type': 'float', 'min': 1.5, 'max': 3.0, 'strategy': 'MeanReversionStrategy'}, - - # MomentumBreakoutStrategy parameters - {'name': 'momentum_breakout_period', 'type': 'int', 'min': 10, 'max': 50, 'strategy': 'MomentumBreakoutStrategy'}, - - # KDJStrategy parameters - {'name': 'kdj_period', 'type': 'int', 'min': 5, 'max': 21, 'strategy': 'KDJStrategy'}, - - # TurtleStrategy parameters - {'name': 'turtle_period', 'type': 'int', 'min': 10, 'max': 50, 'strategy': 'TurtleStrategy'}, - - # WaveTheoryStrategy parameters - {'name': 'wave_ema_short', 'type': 'int', 'min': 3, 'max': 10, 'strategy': 'WaveTheoryStrategy'}, - {'name': 'wave_ema_medium', 'type': 'int', 'min': 8, 'max': 20, 'strategy': 'WaveTheoryStrategy'}, - {'name': 'wave_ema_long', 'type': 'int', 'min': 20, 'max': 50, 'strategy': 'WaveTheoryStrategy'}, - {'name': 'wave_period', 'type': 'int', 'min': 10, 'max': 40, 'strategy': 'WaveTheoryStrategy'}, - {'name': 'wave_range_period', 'type': 'int', 'min': 10, 'max': 40, 'strategy': 'WaveTheoryStrategy'}, - {'name': 'wave_adx_period', 'type': 'int', 'min': 10, 'max': 25, 'strategy': 'WaveTheoryStrategy'}, - {'name': 'wave_momentum_period', 'type': 'int', 'min': 7, 'max': 21, 'strategy': 'WaveTheoryStrategy'}, - {'name': 'wave_range_threshold', 'type': 'float', 'min': 0.002, 'max': 0.01, 'strategy': 'WaveTheoryStrategy'}, - {'name': 'wave_adx_threshold', 'type': 'int', 'min': 15, 'max': 35, 'strategy': 'WaveTheoryStrategy'}, - - # DailyBreakoutStrategy parameters - {'name': 'daily_breakout_bars_count', 'type': 'int', 'min': 720, 'max': 2880, 'strategy': 'DailyBreakoutStrategy'}, - - # Signal Thresholds - {'name': 'buy_threshold', 'type': 'float', 'min': 0.5, 'max': 3.0, 'strategy': 'signal'}, - {'name': 'sell_threshold', 'type': 'float', 'min': -3.0, 'max': -0.5, 'strategy': 'signal'}, - - # Risk Management parameters - {'name': 'stop_loss_pct', 'type': 'float', 'min': -0.02, 'max': -0.005, 'strategy': 'risk'}, - {'name': 'profit_retracement_pct','type': 'float', 'min': 0.05, 'max': 0.20, 'strategy': 'risk'}, - {'name': 'min_profit_for_trailing','type': 'float', 'min': 0.005, 'max': 0.02, 'strategy': 'risk'}, - {'name': 'take_profit_pct', 'type': 'float', 'min': 0.10, 'max': 0.50, 'strategy': 'risk'}, - {'name': 'max_holding_minutes', 'type': 'int', 'min': 30, 'max': 180, 'strategy': 'risk'}, - {'name': 'min_profit_for_time_exit','type': 'float', 'min': 0.002, 'max': 0.01, 'strategy': 'risk'}, - - # Strategy Weights (for all strategies) - {'name': 'weight_MACrossStrategy', 'type': 'float', 'min': 0.0, 'max': 2.0, 'strategy': 'weight'}, - {'name': 'weight_RSIStrategy', 'type': 'float', 'min': 0.0, 'max': 2.0, 'strategy': 'weight'}, - {'name': 'weight_BollingerStrategy', 'type': 'float', 'min': 0.0, 'max': 2.0, 'strategy': 'weight'}, - {'name': 'weight_MeanReversionStrategy', 'type': 'float', 'min': 0.0, 'max': 2.0, 'strategy': 'weight'}, - {'name': 'weight_MomentumBreakoutStrategy', 'type': 'float', 'min': 0.0, 'max': 2.0, 'strategy': 'weight'}, - {'name': 'weight_MACDStrategy', 'type': 'float', 'min': 0.0, 'max': 2.0, 'strategy': 'weight'}, - {'name': 'weight_KDJStrategy', 'type': 'float', 'min': 0.0, 'max': 2.0, 'strategy': 'weight'}, - {'name': 'weight_TurtleStrategy', 'type': 'float', 'min': 0.0, 'max': 2.0, 'strategy': 'weight'}, - {'name': 'weight_DailyBreakoutStrategy', 'type': 'float', 'min': 0.0, 'max': 2.0, 'strategy': 'weight'}, - {'name': 'weight_WaveTheoryStrategy', 'type': 'float', 'min': 0.0, 'max': 2.0, 'strategy': 'weight'}, - - # Market State Analysis parameters - {'name': 'market_trend_period', 'type': 'int', 'min': 20, 'max': 100, 'strategy': 'market_state'}, - {'name': 'market_retracement_tolerance', 'type': 'float', 'min': 0.1, 'max': 0.5, 'strategy': 'market_state'}, - {'name': 'market_volume_period', 'type': 'int', 'min': 10, 'max': 50, 'strategy': 'market_state'}, - {'name': 'market_volume_ma_period', 'type': 'int', 'min': 5, 'max': 30, 'strategy': 'market_state'}, - - # Trend Indicator Weights - {'name': 'trend_price_breakout_weight', 'type': 'float', 'min': 0.1, 'max': 0.5, 'strategy': 'trend_weights'}, - {'name': 'trend_volume_confirmation_weight', 'type': 'float', 'min': 0.1, 'max': 0.5, 'strategy': 'trend_weights'}, - {'name': 'trend_momentum_oscillator_weight', 'type': 'float', 'min': 0.1, 'max': 0.5, 'strategy': 'trend_weights'}, - {'name': 'trend_moving_average_weight', 'type': 'float', 'min': 0.1, 'max': 0.5, 'strategy': 'trend_weights'}, - - # Trend Thresholds - {'name': 'trend_strong_threshold', 'type': 'float', 'min': 0.4, 'max': 0.8, 'strategy': 'trend_thresholds'}, - {'name': 'trend_weak_threshold', 'type': 'float', 'min': 0.2, 'max': 0.5, 'strategy': 'trend_thresholds'}, - {'name': 'trend_volume_spike', 'type': 'float', 'min': 1.0, 'max': 3.0, 'strategy': 'trend_thresholds'}, - {'name': 'trend_oversold', 'type': 'int', 'min': 20, 'max': 40, 'strategy': 'trend_thresholds'}, - {'name': 'trend_overbought', 'type': 'int', 'min': 60, 'max': 80, 'strategy': 'trend_thresholds'}, - - # Confidence Thresholds - {'name': 'confidence_high', 'type': 'float', 'min': 0.5, 'max': 0.9, 'strategy': 'confidence'}, - {'name': 'confidence_medium', 'type': 'float', 'min': 0.3, 'max': 0.7, 'strategy': 'confidence'}, -] - -# Map strategy class names to config keys for weights (for logging/display) -strategy_class_name_to_config_key = { - "MACrossStrategy": "ma_cross", - "RSIStrategy": "rsi", - "BollingerStrategy": "bollinger", - "MeanReversionStrategy": "mean_reversion", - "MomentumBreakoutStrategy": "momentum_breakout", - "MACDStrategy": "macd", - "KDJStrategy": "kdj", - "TurtleStrategy": "turtle", - "DailyBreakoutStrategy": "daily_breakout", - "WaveTheoryStrategy": "wave_theory" -} - -# Map config keys to strategy class names (for instantiation) -strategy_config_key_to_class = { - "ma_cross": MACrossStrategy, - "rsi": RSIStrategy, - "bollinger": BollingerStrategy, - "mean_reversion": MeanReversionStrategy, - "momentum_breakout": MomentumBreakoutStrategy, - "macd": MACDStrategy, - "kdj": KDJStrategy, - "turtle": TurtleStrategy, - "daily_breakout": DailyBreakoutStrategy, - "wave_theory": WaveTheoryStrategy -} - - -def evaluate_fitness(individual, df_data): - parsed_params = {} - for idx, param_def in enumerate(PARAMETER_DEFINITIONS): - val = individual[idx] - parsed_params[param_def['name']] = int(round(val)) if param_def['type'] == 'int' else val - - # 构建策略参数字典 - strategy_params_dict = {} - for param_def in PARAMETER_DEFINITIONS: - strategy_name = param_def.get('strategy') - if strategy_name and strategy_name != 'weight' and strategy_name != 'signal' and strategy_name != 'risk': - if strategy_name not in strategy_params_dict: - strategy_params_dict[strategy_name] = {} - # 移除策略前缀并映射到正确的参数名 - param_name = param_def['name'] - if param_name == 'ma_cross_short_window': - param_name = 'short_window' - elif param_name == 'ma_cross_long_window': - param_name = 'long_window' - elif param_name == 'rsi_period': - param_name = 'period' - elif param_name == 'rsi_overbought': - param_name = 'overbought' - elif param_name == 'rsi_oversold': - param_name = 'oversold' - elif param_name == 'bollinger_period': - param_name = 'period' - elif param_name == 'bollinger_std_dev': - param_name = 'std_dev' - elif param_name == 'macd_fast_ema': - param_name = 'fast_ema' - elif param_name == 'macd_slow_ema': - param_name = 'slow_ema' - elif param_name == 'macd_signal_period': - param_name = 'signal_period' - elif param_name == 'mean_reversion_period': - param_name = 'period' - elif param_name == 'mean_reversion_std_dev': - param_name = 'std_dev' - elif param_name == 'momentum_breakout_period': - param_name = 'period' - elif param_name == 'kdj_period': - param_name = 'period' - elif param_name == 'turtle_period': - param_name = 'period' - elif param_name.startswith("wave_"): - param_name = param_name[5:] # 移除 "wave_" 前缀 - # 波浪理论策略特殊处理 - if param_name == "period": - param_name = "wave_period" - elif param_name == 'daily_breakout_bars_count': - param_name = 'bars_count' - - strategy_params_dict[strategy_name][param_name] = parsed_params[param_def['name']] - - # 策略实例化 - strategies_for_backtest_instance = {} - for config_key, strategy_class in strategy_config_key_to_class.items(): - strategy_class_name = strategy_class.__name__ - strat_params = strategy_params_dict.get(strategy_class_name, {}) - - # 实例化策略 - strategies_for_backtest_instance[config_key] = strategy_class(None, SYMBOL, TIMEFRAME, **strat_params) - - # 获取策略权重 - suggested_weights = { - p['name'].replace('weight_', ''): parsed_params[p['name']] - for p in PARAMETER_DEFINITIONS if p.get('strategy') == 'weight' - } - - # 获取信号阈值 - signal_thresholds = { - 'buy_threshold': parsed_params.get('buy_threshold', SIGNAL_THRESHOLDS.get('buy_threshold', 1.0)), - 'sell_threshold': parsed_params.get('sell_threshold', SIGNAL_THRESHOLDS.get('sell_threshold', -1.0)) - } - - # 获取风险管理参数 - risk_params = { - 'stop_loss_pct': parsed_params.get('stop_loss_pct', RISK_CONFIG.get('stop_loss_pct', -0.01)), - 'profit_retracement_pct': parsed_params.get('profit_retracement_pct', RISK_CONFIG.get('profit_retracement_pct', 0.10)), - 'min_profit_for_trailing': parsed_params.get('min_profit_for_trailing', RISK_CONFIG.get('min_profit_for_trailing', 0.01)), - 'take_profit_pct': parsed_params.get('take_profit_pct', RISK_CONFIG.get('take_profit_pct', 0.20)), - 'max_holding_minutes': parsed_params.get('max_holding_minutes', RISK_CONFIG.get('max_holding_minutes', 60)), - 'min_profit_for_time_exit': parsed_params.get('min_profit_for_time_exit', RISK_CONFIG.get('min_profit_for_time_exit', 0.005)) - } - - # 获取市场状态分析参数 - market_state_params = { - 'trend_period': parsed_params.get('market_trend_period', MARKET_STATE_CONFIG.get('trend_period', 50)), - 'retracement_tolerance': parsed_params.get('market_retracement_tolerance', MARKET_STATE_CONFIG.get('retracement_tolerance', 0.30)), - 'volume_period': parsed_params.get('market_volume_period', MARKET_STATE_CONFIG.get('volume_period', 20)), - 'volume_ma_period': parsed_params.get('market_volume_ma_period', MARKET_STATE_CONFIG.get('volume_ma_period', 10)) - } - - # 获取趋势指标权重 - trend_weights = { - 'price_breakout': parsed_params.get('trend_price_breakout_weight', TREND_INDICATOR_WEIGHTS.get('price_breakout', 0.35)), - 'volume_confirmation': parsed_params.get('trend_volume_confirmation_weight', TREND_INDICATOR_WEIGHTS.get('volume_confirmation', 0.25)), - 'momentum oscillator': parsed_params.get('trend_momentum_oscillator_weight', TREND_INDICATOR_WEIGHTS.get('momentum oscillator', 0.20)), - 'moving_average': parsed_params.get('trend_moving_average_weight', TREND_INDICATOR_WEIGHTS.get('moving_average', 0.20)) - } - - # 获取趋势阈值 - trend_thresholds = { - 'strong_trend': parsed_params.get('trend_strong_threshold', TREND_THRESHOLDS.get('strong_trend', 0.6)), - 'weak_trend': parsed_params.get('trend_weak_threshold', TREND_THRESHOLDS.get('weak_trend', 0.3)), - 'volume_spike': parsed_params.get('trend_volume_spike', TREND_THRESHOLDS.get('volume_spike', 1.5)), - 'oversold': parsed_params.get('trend_oversold', TREND_THRESHOLDS.get('oversold', 30)), - 'overbought': parsed_params.get('trend_overbought', TREND_THRESHOLDS.get('overbought', 70)) - } - - # 获取置信度阈值 - confidence_thresholds = { - 'high_confidence': parsed_params.get('confidence_high', CONFIDENCE_THRESHOLDS.get('high_confidence', 0.7)), - 'medium_confidence': parsed_params.get('confidence_medium', CONFIDENCE_THRESHOLDS.get('medium_confidence', 0.4)) - } - - df = df_data.copy() - data_provider = BacktestDataProvider(df, initial_equity=INITIAL_CAPITAL) - - # 创建风险控制器并更新参数 - risk_controller = RiskController(data_provider, trade_direction=BACKTEST_CONFIG['trade_direction']) - - # 更新风险控制器参数 - for key, value in risk_params.items(): - if hasattr(risk_controller, key): - setattr(risk_controller, key, value) - - # 创建动态权重管理器并传入优化参数 - dynamic_weight_manager = DynamicWeightManager( - data_provider, - market_state_params=market_state_params, - trend_weights=trend_weights, - trend_thresholds=trend_thresholds, - confidence_thresholds=confidence_thresholds - ) - - all_signals_df = pd.DataFrame(index=df.index) - for config_key, strat_instance in strategies_for_backtest_instance.items(): - if hasattr(strat_instance, 'run_backtest') and callable(getattr(strat_instance, 'run_backtest')): - all_signals_df[strat_instance.name] = strat_instance.run_backtest(df.copy()) - - if all_signals_df.empty: - return (0,) - - # 使用动态权重管理器获取策略权重 - strategies_with_weights = dynamic_weight_manager.get_current_strategies_and_weights() - - # numpy加权计算 - weights_arr = [] - for name, sig in all_signals_df.items(): - # 从动态权重管理器获取权重,如果没有则使用建议权重 - w = 1.0 - for strat_instance, weight in strategies_with_weights: - if strat_instance.name == name: - w = weight - break - # 如果动态权重中没有找到,使用建议权重 - if w == 1.0: - w = suggested_weights.get(name, 1.0) - weights_arr.append(sig.values * w) - - combined = np.sum(np.column_stack(weights_arr), axis=1) - buy_th, sell_th = signal_thresholds['buy_threshold'], signal_thresholds['sell_threshold'] - final_signals = np.where(combined > buy_th, 1, np.where(combined < sell_th, -1, 0)) - - # 回测循环逻辑 - data_provider.current_index = 0 - for i in range(len(df)): - current_price = data_provider.get_current_price(SYMBOL) - if not current_price: - continue - - current_signal = final_signals[i] - - direction = None - if current_signal == 1: - direction = "buy" - elif current_signal == -1: - direction = "sell" - - if direction: - risk_controller.process_trading_signal(direction, current_price, abs(current_signal)) - - risk_controller.monitor_positions(current_price) - data_provider.tick() - - total_profit_loss = risk_controller.position_manager.get_trade_summary().get('total_profit_loss', 0) - return (total_profit_loss,) - - -# --- Main Optimization Run --- -def run_optimizer(): - try: - # Load data once - df_historical_data = load_historical_data() - - # DEAP setup - creator.create("FitnessMax", base.Fitness, weights=(1.0,)) - creator.create("Individual", list, fitness=creator.FitnessMax) - - toolbox = base.Toolbox() - - # Register attribute generators for each parameter - for i, param_def in enumerate(PARAMETER_DEFINITIONS): - if param_def['type'] == 'int': - toolbox.register(f"attr_param_{i}", random.randint, param_def['min'], param_def['max']) - else: # float - toolbox.register(f"attr_param_{i}", random.uniform, param_def['min'], param_def['max']) - - # Register individual creator - toolbox.register("individual", tools.initIterate, creator.Individual, - lambda: [toolbox.__getattribute__(f"attr_param_{j}")() for j in range(len(PARAMETER_DEFINITIONS))]) - - toolbox.register("population", tools.initRepeat, list, toolbox.individual) - - toolbox.register("evaluate", evaluate_fitness, df_data=df_historical_data) - toolbox.register("mate", tools.cxTwoPoint) - toolbox.register("mutate", tools.mutGaussian, - mu=GENETIC_OPTIMIZER_CONFIG["mutation_mu"], - sigma=GENETIC_OPTIMIZER_CONFIG["mutation_sigma"], - indpb=GENETIC_OPTIMIZER_CONFIG["mutation_indpb"]) - toolbox.register("select", tools.selTournament, - tournsize=GENETIC_OPTIMIZER_CONFIG["tournament_size"]) - - # 多进程处理 - if GENETIC_OPTIMIZER_CONFIG["enable_multiprocessing"]: - pool = multiprocessing.Pool(processes=GENETIC_OPTIMIZER_CONFIG["processes"], - initializer=init_worker) - toolbox.register("map", pool.map) - else: - # 不使用多进程时,使用普通的map函数 - toolbox.register("map", map) - - # Statistics setup - stats = tools.Statistics(lambda ind: ind.fitness.values) - stats.register("avg", np.mean) - stats.register("std", np.std) - stats.register("min", np.min) - stats.register("max", np.max) - - # Run algorithm - 从配置获取参数 - population = toolbox.population(n=GENETIC_OPTIMIZER_CONFIG["population_size"]) - ngen = GENETIC_OPTIMIZER_CONFIG["generations"] - cxpb = GENETIC_OPTIMIZER_CONFIG["crossover_probability"] - mutpb = GENETIC_OPTIMIZER_CONFIG["mutation_probability"] - - if GENETIC_OPTIMIZER_CONFIG["verbose"]: - print(f"开始多进程进化... (种群大小: {len(population)}, 进化代数: {ngen})") - - # 自定义统计回调以打印代数信息 - generation_info = [] - def generation_callback(gen, population): - best_gen_individual = tools.selBest(population, k=1)[0] - best_gen_fitness = best_gen_individual.fitness.values[0] - avg_gen_fitness = sum(ind.fitness.values[0] for ind in population) / len(population) - - # 解析最佳个体的参数 - parsed_params = {} - for idx, param_def in enumerate(PARAMETER_DEFINITIONS): - param_value = best_gen_individual[idx] - if param_def['type'] == 'int': - parsed_params[param_def['name']] = int(round(param_value)) - else: - parsed_params[param_def['name']] = param_value - - # 根据配置决定是否保存代数信息 - if GENETIC_OPTIMIZER_CONFIG["save_generation_info"]: - generation_info.append({ - 'generation': gen, - 'best_fitness': best_gen_fitness, - 'avg_fitness': avg_gen_fitness, - 'best_params': parsed_params.copy() - }) - - # 根据配置决定是否输出详细信息 - if GENETIC_OPTIMIZER_CONFIG["verbose"]: - print(f"\n=== 代数 {gen} ===") - print(f"最佳适应度 = {best_gen_fitness:.2f}, 平均适应度 = {avg_gen_fitness:.2f}") - print("当前最佳参数组合:") - for key, value in parsed_params.items(): - print(f" {key}: {value}") - - # 使用自定义进化循环 - for gen in range(ngen): - offspring = toolbox.select(population, len(population)) - offspring = algorithms.varAnd(offspring, toolbox, cxpb, mutpb) - fits = toolbox.map(toolbox.evaluate, offspring) - - for fit, ind in zip(fits, offspring): - ind.fitness.values = fit - - population[:] = offspring - - # 调用代数信息回调 - generation_callback(gen + 1, population) - - # 关闭进程池(如果使用了多进程) - if GENETIC_OPTIMIZER_CONFIG["enable_multiprocessing"]: - pool.close() - pool.join() # Wait for all processes to finish - - # Results - best_individual = tools.selBest(population, k=1)[0] - best_fitness = best_individual.fitness.values[0] - - print("--- DEAP 优化结束 ---") - print(f"最佳适应度 (总盈亏): {best_fitness:.2f}") - print("最佳参数组合:") - - # 打印详细的代数信息 - print("\n=== 代数统计信息 ===") - for gen_info in generation_info: - print(f"代数 {gen_info['generation']}: 最佳={gen_info['best_fitness']:.2f}, 平均={gen_info['avg_fitness']:.2f}") - - print("\n=== 运行结果总结 ===") - print(f"总进化代数: {ngen}") - print(f"种群大小: {len(population)}") - print(f"交叉概率: {cxpb}") - print(f"变异概率: {mutpb}") - print(f"最终最佳适应度: {best_fitness:.2f}") - - # 解析最佳参数组合 - parsed_best_params = {} - current_idx = 0 - for param_def in PARAMETER_DEFINITIONS: - param_value = best_individual[current_idx] - if param_def['type'] == 'int': - parsed_best_params[param_def['name']] = int(round(param_value)) - else: - parsed_best_params[param_def['name']] = param_value - current_idx += 1 - - print("\n=== 最终最佳参数组合 ===") - # 分组显示参数:策略参数和权重 - print("\n策略参数:") - for param_def in PARAMETER_DEFINITIONS: - if param_def.get('strategy') != 'weight': - print(f" {param_def['name']}: {parsed_best_params[param_def['name']]}") - - print("\n策略权重:") - for param_def in PARAMETER_DEFINITIONS: - if param_def.get('strategy') == 'weight': - strategy_name = param_def['name'].replace('weight_', '') - print(f" {strategy_name}: {parsed_best_params[param_def['name']]:.3f}") - - # 显示权重总和 - weight_sum = sum(parsed_best_params[p['name']] for p in PARAMETER_DEFINITIONS if p.get('strategy') == 'weight') - print(f"\n权重总和: {weight_sum:.3f}") - - # 显示历史最佳参数变化 - print("\n=== 历史最佳参数变化 ===") - for i, gen_info in enumerate(generation_info): - if i % 5 == 0 or i == len(generation_info) - 1: # 每5代显示一次,包括最后一代 - print(f"代数 {gen_info['generation']}: 适应度={gen_info['best_fitness']:.2f}") - # 只显示权重信息 - weights = {k: v for k, v in gen_info['best_params'].items() if k.startswith('weight_')} - for weight_key, weight_value in weights.items(): - strategy_name = weight_key.replace('weight_', '') - print(f" {strategy_name}: {weight_value:.3f}") - print() - - # 保存优化结果到文件 - save_optimization_results(parsed_best_params, best_fitness, generation_info) - - return parsed_best_params, best_fitness - - except Exception as e: - print(f"优化器运行出错: {e}") - import traceback - print(traceback.format_exc()) - - # Ensure pool is terminated on error, only if it was created - if 'pool' in locals() and pool is not None: - pool.terminate() - sys.exit(1) - - -def save_optimization_results(best_params, best_fitness, generation_info): - """保存优化结果到文件""" - try: - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - - # 创建结果数据结构 - results = { - 'optimization_info': { - 'timestamp': timestamp, - 'best_fitness': float(best_fitness), - 'total_generations': len(generation_info), - 'population_size': GENETIC_OPTIMIZER_CONFIG["population_size"], - 'crossover_probability': GENETIC_OPTIMIZER_CONFIG["crossover_probability"], - 'mutation_probability': GENETIC_OPTIMIZER_CONFIG["mutation_probability"], - 'random_seed': SEED - }, - 'best_parameters': best_params, - 'generation_history': generation_info - } - - # 保存为JSON文件 - json_filename = f"optimization_results_{timestamp}.json" - with open(json_filename, 'w', encoding='utf-8') as f: - json.dump(results, f, ensure_ascii=False, indent=2) - print(f"\n优化结果已保存到: {json_filename}") - - # 保存为CSV文件(只保存最佳参数) - csv_filename = f"optimization_best_params_{timestamp}.csv" - import csv - with open(csv_filename, 'w', newline='', encoding='utf-8') as f: - writer = csv.writer(f) - writer.writerow(['Parameter', 'Value', 'Type']) - - for param_def in PARAMETER_DEFINITIONS: - param_name = param_def['name'] - param_value = best_params[param_name] - param_type = param_def['type'] - - # 添加策略分类 - strategy_type = param_def.get('strategy', 'unknown') - writer.writerow([param_name, param_value, f"{param_type}_{strategy_type}"]) - - print(f"最佳参数已保存到: {csv_filename}") - - # 保存代数历史为CSV - history_csv_filename = f"optimization_history_{timestamp}.csv" - with open(history_csv_filename, 'w', newline='', encoding='utf-8') as f: - writer = csv.writer(f) - writer.writerow(['Generation', 'Best_Fitness', 'Avg_Fitness'] + - [p['name'] for p in PARAMETER_DEFINITIONS]) - - for gen_info in generation_info: - row = [ - gen_info['generation'], - gen_info['best_fitness'], - gen_info['avg_fitness'] - ] - # 添加所有参数值 - for param_def in PARAMETER_DEFINITIONS: - row.append(gen_info['best_params'].get(param_def['name'], '')) - writer.writerow(row) - - print(f"代数历史已保存到: {history_csv_filename}") - - # 生成可读的文本报告 - txt_filename = f"optimization_report_{timestamp}.txt" - with open(txt_filename, 'w', encoding='utf-8') as f: - f.write("=" * 80 + "\n") - f.write("遗传算法优化结果报告\n") - f.write("=" * 80 + "\n") - f.write(f"优化时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n") - f.write(f"最佳适应度 (总盈亏): ${best_fitness:.2f}\n") - f.write(f"总进化代数: {len(generation_info)}\n") - f.write(f"种群大小: 50\n") - f.write(f"随机种子: {SEED}\n") - f.write("\n") - - f.write("最佳参数组合:\n") - f.write("-" * 40 + "\n") - - # 分组显示参数 - f.write("\n【策略参数】\n") - for param_def in PARAMETER_DEFINITIONS: - if param_def.get('strategy') not in ['weight', 'signal', 'risk']: - param_name = param_def['name'] - param_value = best_params[param_name] - f.write(f" {param_name}: {param_value}\n") - - f.write("\n【信号阈值】\n") - for param_def in PARAMETER_DEFINITIONS: - if param_def.get('strategy') == 'signal': - param_name = param_def['name'] - param_value = best_params[param_name] - f.write(f" {param_name}: {param_value}\n") - - f.write("\n【风险管理参数】\n") - for param_def in PARAMETER_DEFINITIONS: - if param_def.get('strategy') == 'risk': - param_name = param_def['name'] - param_value = best_params[param_name] - f.write(f" {param_name}: {param_value}\n") - - f.write("\n【策略权重】\n") - weight_sum = 0 - for param_def in PARAMETER_DEFINITIONS: - if param_def.get('strategy') == 'weight': - param_name = param_def['name'] - param_value = best_params[param_name] - strategy_name = param_name.replace('weight_', '') - weight_sum += param_value - f.write(f" {strategy_name}: {param_value:.3f}\n") - f.write(f" 权重总和: {weight_sum:.3f}\n") - - f.write("\n" + "=" * 80 + "\n") - f.write("代数进化历史\n") - f.write("=" * 80 + "\n") - - for i, gen_info in enumerate(generation_info): - if i % 5 == 0 or i == len(generation_info) - 1: - f.write(f"\n代数 {gen_info['generation']}:\n") - f.write(f" 最佳适应度: ${gen_info['best_fitness']:.2f}\n") - f.write(f" 平均适应度: ${gen_info['avg_fitness']:.2f}\n") - - print(f"详细报告已保存到: {txt_filename}") - - except Exception as e: - print(f"保存优化结果时出错: {e}") - import traceback - print(traceback.format_exc()) - - -if __name__ == "__main__": - run_optimizer() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 189d6bd..4024471 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,7 @@ MetaTrader5 pandas deap -tqdm \ No newline at end of file +tqdm +fastapi +uvicorn +requests \ No newline at end of file diff --git a/run/__init__.py b/run/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/run/backtest.py b/run/backtest.py new file mode 100644 index 0000000..d78d72b --- /dev/null +++ b/run/backtest.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""回测入口 — python -m run.backtest""" + +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from core.utils import get_rates, initialize, shutdown +from execution.backtest import BacktestEngine +from config import SYMBOL, TIMEFRAME, BACKTEST_COUNT, BACKTEST_START_DATE, BACKTEST_END_DATE, USE_DATE_RANGE +from logger import logger + + +def main(): + print("=" * 60) + print("MT5 智能交易系统 - 回测") + print("=" * 60) + + try: + initialize() + if USE_DATE_RANGE: + rates = get_rates(SYMBOL, TIMEFRAME, BACKTEST_COUNT, + BACKTEST_START_DATE, BACKTEST_END_DATE) + else: + rates = get_rates(SYMBOL, TIMEFRAME, BACKTEST_COUNT) + shutdown() + + if rates is None or len(rates) == 0: + logger.error("未能获取历史数据,回测终止。") + return + + logger.info(f"获取数据: {len(rates)} 条") + + engine = BacktestEngine(use_dynamic_weights=False) + summary = engine.run(rates) + print(f"\n回测完成!总盈亏: ${summary.get('total_profit_loss', 0):.2f}") + + except Exception as e: + import traceback + logger.error(f"回测出错: {e}") + traceback.print_exc() + + +if __name__ == "__main__": + main() diff --git a/run/optimize.py b/run/optimize.py new file mode 100644 index 0000000..ead5259 --- /dev/null +++ b/run/optimize.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""优化器入口 — python -m run.optimize""" + +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from execution.optimize import run_optimizer +from logger import logger + + +def main(): + print("=" * 60) + print("MT5 智能交易系统 - 遗传算法参数优化") + print("=" * 60) + + try: + best_params, best_fitness = run_optimizer() + print(f"\n优化完成!最佳适应度: {best_fitness:.2f}") + except Exception as e: + import traceback + logger.error(f"优化器运行出错: {e}") + traceback.print_exc() + + +if __name__ == "__main__": + main() diff --git a/start_realtime.py b/run/realtime.py similarity index 68% rename from start_realtime.py rename to run/realtime.py index 3f9be52..e36f607 100644 --- a/start_realtime.py +++ b/run/realtime.py @@ -1,52 +1,45 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -""" -实时交易启动脚本 (已重构) -""" +"""实时交易入口 — python -m run.realtime""" import sys import os from datetime import datetime -# 添加项目根目录到路径 -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from execution.realtime_trader import RealtimeTrader -from core.data_providers import LiveDataProvider, DryRunDataProvider +from core.data import LiveDataProvider, DryRunDataProvider from config import REALTIME_CONFIG, INITIAL_CAPITAL from logger import setup_logger + def main(): - # 设置日志级别 log_level = REALTIME_CONFIG.get('logging_level', 'INFO') setup_logger(log_level) - - # 从配置决定运行模式 + is_dry_run = REALTIME_CONFIG.get('dry_run', True) - + print("=" * 60) - print("MetaTrader 5 智能交易系统") + print("MT5 智能交易系统 - 实时交易") print(f"启动时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") print(f"运行模式: {'模拟运行' if is_dry_run else '实盘交易'}") print("=" * 60) - # 根据模式选择数据提供者 if is_dry_run: data_provider = DryRunDataProvider(initial_equity=INITIAL_CAPITAL) else: - # 安全确认 - print("⚠️ 警告:即将启动实盘交易模式!") + print("WARNING: 即将启动实盘交易模式!") confirm = input("确认启动实盘交易?(输入 'YES' 继续): ") if confirm != 'YES': print("已取消启动。") return data_provider = LiveDataProvider() - # 创建并启动交易器实例 trader = RealtimeTrader(data_provider, update_interval=REALTIME_CONFIG['update_interval']) - print("\n正在启动交易系统... (按 Ctrl+C 可安全停止)") trader.start() + if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/run/server.py b/run/server.py new file mode 100644 index 0000000..fd231bb --- /dev/null +++ b/run/server.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""MT5 代理服务 — python -m run.server""" + +import sys +import os +import threading +from contextlib import asynccontextmanager + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from fastapi import FastAPI +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from core.data import LiveDataProvider +from core.data.serializers import ( + serialize_account_info, + serialize_position, + serialize_symbol_info, + serialize_order_result, + serialize_rates, +) +from config import SERVER_HOST, SERVER_PORT +from logger import setup_logger, logger + +setup_logger("INFO") + +# 全局 MT5 连接和线程锁 +_provider = LiveDataProvider() +_lock = threading.Lock() + + +@asynccontextmanager +async def lifespan(app: FastAPI): + with _lock: + if not _provider.initialize(): + logger.error("MT5 初始化失败,服务启动中止") + sys.exit(1) + logger.info(f"MT5 代理服务启动于 {SERVER_HOST}:{SERVER_PORT}") + yield + with _lock: + _provider.shutdown() + logger.info("MT5 代理服务已关闭") + + +app = FastAPI(title="MT5 Proxy API", lifespan=lifespan) + + +# ── 请求模型 ── + +class OrderRequest(BaseModel): + symbol: str + order_type: str + volume: float + +class CloseRequest(BaseModel): + ticket: int + symbol: str + volume: float + + +# ── 端点 ── + +@app.post("/api/initialize") +def api_initialize(): + with _lock: + return {"success": _provider.initialize()} + + +@app.post("/api/shutdown") +def api_shutdown(): + with _lock: + _provider.shutdown() + return {"success": True} + + +@app.get("/api/price/{symbol}") +def api_price(symbol: str): + with _lock: + data = _provider.get_current_price(symbol) + if data is None: + return JSONResponse(status_code=503, content={"error": "无法获取价格"}) + # time 转为 Unix 时间戳 + data['time'] = int(data['time'].timestamp()) if hasattr(data['time'], 'timestamp') else int(data['time']) + return data + + +@app.get("/api/historical/{symbol}/{timeframe}/{count}") +def api_historical(symbol: str, timeframe: int, count: int): + with _lock: + rates = _provider.get_historical_data(symbol, timeframe, count) + if rates is None: + return JSONResponse(status_code=503, content={"error": "无法获取历史数据"}) + return {"rates": serialize_rates(rates)} + + +@app.get("/api/account") +def api_account(): + with _lock: + info = _provider.get_account_info() + if info is None: + return JSONResponse(status_code=503, content={"error": "无法获取账户信息"}) + return serialize_account_info(info) + + +@app.get("/api/positions/{symbol}") +def api_positions(symbol: str): + with _lock: + positions = _provider.get_positions(symbol) + if positions is None: + return {"positions": []} + return {"positions": [serialize_position(p) for p in positions]} + + +@app.get("/api/symbol/{symbol}") +def api_symbol(symbol: str): + with _lock: + info = _provider.get_symbol_info(symbol) + if info is None: + return JSONResponse(status_code=503, content={"error": "无法获取品种信息"}) + return serialize_symbol_info(info) + + +@app.post("/api/order") +def api_order(req: OrderRequest): + with _lock: + result = _provider.send_order(req.symbol, req.order_type, req.volume) + if result is None: + return JSONResponse(status_code=503, content={"error": "下单失败"}) + return serialize_order_result(result) + + +@app.post("/api/close") +def api_close(req: CloseRequest): + with _lock: + success = _provider.close_position(req.ticket, req.symbol, req.volume) + return {"success": success} + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host=SERVER_HOST, port=SERVER_PORT) diff --git a/start_backtest.py b/start_backtest.py deleted file mode 100644 index 3038481..0000000 --- a/start_backtest.py +++ /dev/null @@ -1,229 +0,0 @@ -import sys -import os -import pandas as pd -from datetime import datetime -from tqdm import tqdm - -# 添加项目根目录到路径 -sys.path.insert(0, os.path.dirname(os.path.abspath(__name__))) - -from core.data_providers import BacktestDataProvider -from core.risk import RiskController -from execution.dynamic_weights import DynamicWeightManager # Still needed for strategy list -from config import SYMBOL, TIMEFRAME, BACKTEST_COUNT, BACKTEST_START_DATE, BACKTEST_END_DATE, USE_DATE_RANGE, BACKTEST_CONFIG, INITIAL_CAPITAL, SIGNAL_THRESHOLDS, DEFAULT_WEIGHTS -from logger import logger - -# Import all strategy classes -from strategies.ma_cross import MACrossStrategy -from strategies.rsi import RSIStrategy -from strategies.bollinger import BollingerStrategy -from strategies.mean_reversion import MeanReversionStrategy -from strategies.momentum_breakout import MomentumBreakoutStrategy -from strategies.macd import MACDStrategy -from strategies.kdj import KDJStrategy -from strategies.turtle import TurtleStrategy -from strategies.daily_breakout import DailyBreakoutStrategy -from strategies.wave_theory import WaveTheoryStrategy - - -def run_full_backtest(): - """完整的、独立的、已重构的回测流程""" - # 1. 加载数据 - from core.utils import get_rates, initialize, shutdown - initialize() - if USE_DATE_RANGE: - rates = get_rates(SYMBOL, TIMEFRAME, BACKTEST_COUNT, BACKTEST_START_DATE, BACKTEST_END_DATE) - else: - rates = get_rates(SYMBOL, TIMEFRAME, BACKTEST_COUNT) - shutdown() - - if rates is None: - logger.error("未能获取历史数据,回测终止。") - return - - logger.info(f"实际获取数据量: {len(rates)} 条 (请求: {BACKTEST_COUNT} 条)") - - # 如果实际获取的数据量超过配置,则截取配置的数据量 - if len(rates) > BACKTEST_COUNT: - rates = rates[-BACKTEST_COUNT:] # 取最新的数据 - logger.info(f"截取数据量为: {len(rates)} 条") - - df = pd.DataFrame(rates) - df.set_index(pd.to_datetime(df['time'], unit='s'), inplace=True) - - # 2. 初始化组件 (data_provider, risk_controller) - data_provider = BacktestDataProvider(df, initial_equity=INITIAL_CAPITAL) - risk_controller = RiskController(data_provider, trade_direction=BACKTEST_CONFIG['trade_direction']) - # weight_manager is not directly used for signal generation in this new architecture, - # but its strategy_blueprints can be used to instantiate strategies. - - # 3. 策略实例化和信号预生成 (NEW STAGE 1) - logger.info("开始预生成所有策略信号...") - - # Define strategy blueprints directly here or get from DynamicWeightManager if it's refactored - # For now, let's define them directly as DynamicWeightManager is for real-time dynamic weights. - strategy_blueprints = { - 'ma_cross': (MACrossStrategy, {}), - 'rsi': (RSIStrategy, {}), - 'bollinger': (BollingerStrategy, {}), - 'mean_reversion': (MeanReversionStrategy, {}), - 'momentum_breakout': (MomentumBreakoutStrategy, {}), - 'macd': (MACDStrategy, {}), - 'kdj': (KDJStrategy, {}), - 'turtle': (TurtleStrategy, {}), - 'daily_breakout': (DailyBreakoutStrategy, {}), - 'wave_theory': (WaveTheoryStrategy, {}) - } - - all_signals_df = pd.DataFrame(index=df.index) - - # Map strategy class names to config keys for weights - strategy_name_to_config_key = { - "MACrossStrategy": "ma_cross", - "RSIStrategy": "rsi", - "BollingerStrategy": "bollinger", - "MeanReversionStrategy": "mean_reversion", - "MomentumBreakoutStrategy": "momentum_breakout", - "MACDStrategy": "macd", - "KDJStrategy": "kdj", - "TurtleStrategy": "turtle", - "DailyBreakoutStrategy": "daily_breakout", - "WaveTheoryStrategy": "wave_theory" - } - - strategies_for_backtest = [] - for name, (strategy_class, params) in strategy_blueprints.items(): - # Pass None for data_provider, symbol, timeframe as run_backtest only needs df - # But strategy __init__ expects them. So, pass dummy values. - strategies_for_backtest.append(strategy_class(None, SYMBOL, TIMEFRAME, **params)) - - for strategy in tqdm(strategies_for_backtest, desc="Generating Strategy Signals"): - if hasattr(strategy, 'run_backtest') and callable(getattr(strategy, 'run_backtest')): - try: - strategy_signals = strategy.run_backtest(df.copy()) # Pass a copy to avoid modifying original df - if strategy_signals is not None: - all_signals_df[strategy.name] = strategy_signals - logger.info(f"策略 {strategy.name} 信号生成成功") - else: - logger.warning(f"策略 {strategy.name} 返回空信号") - except Exception as e: - logger.error(f"策略 {strategy.name} 执行失败: {str(e)}") - # 为失败的策略创建全0信号序列 - all_signals_df[strategy.name] = pd.Series(0, index=df.index) - continue - else: - logger.warning(f"策略 {strategy.name} 没有实现 'run_backtest' 方法,将被跳过。") - - # 组合信号 - logger.info("开始组合策略信号...") - combined_weighted_signals = pd.Series(0, index=df.index) - if not all_signals_df.empty: - weighted_signals_list = [] - for col_name in all_signals_df.columns: - # Extract strategy class name from column name (e.g., 'MACrossStrategy') - strategy_class_name = col_name - config_key = strategy_name_to_config_key.get(strategy_class_name) - - if config_key and config_key in DEFAULT_WEIGHTS: - weight = DEFAULT_WEIGHTS[config_key] - weighted_signals_list.append(all_signals_df[col_name] * weight) - else: - logger.warning(f"未找到策略 {strategy_class_name} 的默认权重,使用权重1.0。") - weighted_signals_list.append(all_signals_df[col_name] * 1.0) - - if weighted_signals_list: - combined_weighted_signals = pd.concat(weighted_signals_list, axis=1).sum(axis=1) - else: - logger.warning("没有生成任何加权信号。") - - # 应用阈值得到最终信号 - final_signals = pd.Series(0, index=df.index) - buy_threshold = SIGNAL_THRESHOLDS.get("buy_threshold", 1.0) - sell_threshold = SIGNAL_THRESHOLDS.get("sell_threshold", -1.0) - - final_signals[combined_weighted_signals > buy_threshold] = 1 - final_signals[combined_weighted_signals < sell_threshold] = -1 - - logger.info("信号预生成和组合完成。") - - # 4. 回测主循环 (NEW STAGE 2) - logger.info(f"回测主循环开始... 数据量: {len(df)} 条") - - # Reset data_provider's internal index to 0 for the loop - data_provider.current_index = 0 - - for i in tqdm(range(len(df)), desc=f"Backtesting ({len(df)} bars)"): - try: - # Get current price from the data_provider (which uses its internal index) - current_price = data_provider.get_current_price(SYMBOL) - if not current_price: - logger.warning(f"无法获取当前价格在索引 {i},跳过。") - continue - - # Get the pre-generated signal for the current bar - current_signal = final_signals.iloc[i] - - direction = None - if current_signal == 1: - direction = "buy" - elif current_signal == -1: - direction = "sell" - - if direction: - risk_controller.process_trading_signal(direction, current_price, abs(current_signal)) - - # Monitor and update positions - risk_controller.monitor_positions(current_price) - - # Advance data provider to the next time step - data_provider.tick() - except Exception as e: - logger.error(f"回测循环中在索引 {i} 发生错误: {str(e)}") - # 继续下一个bar,不中断整个回测 - continue - - # 5. 结束和报告 (Keep as is) - logger.info("回测完成,生成性能报告...") - summary = risk_controller.position_manager.get_trade_summary() - - # 打印报告 - logger.info("=" * 80) - logger.info("回测性能报告") - logger.info(f"总交易次数: {summary.get('total_trades', 0)}") - logger.info(f"胜率: {summary.get('win_rate', 0):.2f}%") - logger.info(f"总盈亏: ${summary.get('total_profit_loss', 0):.2f}") - logger.info("=" * 80) - - # 保存交易记录 - try: - risk_controller.position_manager.save_trade_history("backtest_trades") - logger.info("交易记录保存成功") - except Exception as e: - logger.error(f"保存交易记录失败: {str(e)}") - # 尝试手动保存 - try: - import json - trades = risk_controller.position_manager.closed_trades - if trades: - with open("backtest_trades_manual.json", 'w', encoding='utf-8') as f: - json.dump(trades, f, ensure_ascii=False, indent=2, default=str) - logger.info("手动保存交易记录到 backtest_trades_manual.json") - except Exception as e2: - logger.error(f"手动保存交易记录也失败: {str(e2)}") - - -def main(): - print("=" * 60) - print("MetaTrader 5 智能交易系统 - 回测") - print("=" * 60) - - try: - run_full_backtest() - print("\n回测完成!") - except Exception as e: - import traceback - print(f"\n回测出错: {e}") - traceback.print_exc() - -if __name__ == "__main__": - main() diff --git a/strategies/__init__.py b/strategies/__init__.py new file mode 100644 index 0000000..cc0fab2 --- /dev/null +++ b/strategies/__init__.py @@ -0,0 +1,25 @@ +"""策略模块 — 统一注册所有策略到 StrategyRegistry""" +from core.signal.registry import StrategyRegistry +from strategies.ma_cross import MACrossStrategy +from strategies.rsi import RSIStrategy +from strategies.bollinger import BollingerStrategy +from strategies.macd import MACDStrategy +from strategies.kdj import KDJStrategy +from strategies.turtle import TurtleStrategy +from strategies.mean_reversion import MeanReversionStrategy +from strategies.momentum_breakout import MomentumBreakoutStrategy +from strategies.daily_breakout import DailyBreakoutStrategy +from strategies.wave_theory import WaveTheoryStrategy + +_registry = StrategyRegistry() + +_registry.register('ma_cross', MACrossStrategy) +_registry.register('rsi', RSIStrategy) +_registry.register('bollinger', BollingerStrategy) +_registry.register('macd', MACDStrategy) +_registry.register('kdj', KDJStrategy) +_registry.register('turtle', TurtleStrategy) +_registry.register('mean_reversion', MeanReversionStrategy) +_registry.register('momentum_breakout', MomentumBreakoutStrategy) +_registry.register('daily_breakout', DailyBreakoutStrategy) +_registry.register('wave_theory', WaveTheoryStrategy) diff --git a/strategies/daily_breakout.py b/strategies/daily_breakout.py index 5451652..86ca85c 100644 --- a/strategies/daily_breakout.py +++ b/strategies/daily_breakout.py @@ -38,13 +38,22 @@ class DailyBreakoutStrategy(BaseStrategy): def run_backtest(self, df): df = df.copy() - df['time'] = pd.to_datetime(df['time'], unit='s') + # 'time' 可能是列(来自MT5原始数据)或索引(来自MultiTimeframeDataStore) + if 'time' in df.columns: + df['time'] = pd.to_datetime(df['time'], unit='s') + elif isinstance(df.index, pd.DatetimeIndex): + df['time'] = df.index + else: + df['time'] = pd.to_datetime(df.index, unit='s') df['date'] = df['time'].dt.date - daily_highs = df.groupby('date')['high'].transform('max') - daily_lows = df.groupby('date')['low'].transform('max') + # 用前一日的最高/最低价作为突破基准,避免未来数据泄露 + daily_high = df.groupby('date')['high'].max() + daily_low = df.groupby('date')['low'].min() + prev_high = df['date'].map(daily_high.shift(1)) + prev_low = df['date'].map(daily_low.shift(1)) signals = pd.Series(0, index=df.index) - signals[df['close'] > daily_highs.shift(1)] = 1 - signals[df['close'] < daily_lows.shift(1)] = -1 + signals[df['close'] > prev_high] = 1 + signals[df['close'] < prev_low] = -1 return signals \ No newline at end of file diff --git a/strategies/kdj.py b/strategies/kdj.py index a042d11..75ef5bc 100644 --- a/strategies/kdj.py +++ b/strategies/kdj.py @@ -12,7 +12,10 @@ class KDJStrategy(BaseStrategy): def _calculate_indicators(self, df): low_min = df['low'].rolling(self.period).min() high_max = df['high'].rolling(self.period).max() - rsv = (df['close'] - low_min) / (high_max - low_min) * 100 + # 避免除零:当最高价==最低价时,RSV 取 50(中性) + price_range = high_max - low_min + rsv = (df['close'] - low_min) / price_range.replace(0, float('nan')) * 100 + rsv = rsv.fillna(50) df['k'] = rsv.ewm(com=2).mean() df['d'] = df['k'].ewm(com=2).mean() df['j'] = 3 * df['k'] - 2 * df['d'] diff --git a/strategies/mean_reversion.py b/strategies/mean_reversion.py index 458113d..3e769ce 100644 --- a/strategies/mean_reversion.py +++ b/strategies/mean_reversion.py @@ -3,9 +3,10 @@ from .base_strategy import BaseStrategy from config import STRATEGY_CONFIG class MeanReversionStrategy(BaseStrategy): + """均值回归策略 — 价格突破布林带后等待回归确认再入场(与 BollingerStrategy 的即时入场区分)""" + def __init__(self, data_provider, symbol, timeframe, period=None, std_dev=None): super().__init__(data_provider, symbol, timeframe) - # 从配置中获取参数,如果传入参数则使用传入的参数 config = STRATEGY_CONFIG.get('mean_reversion', {}) self.period = period if period is not None else config.get('period', 20) self.std_dev = std_dev if std_dev is not None else config.get('std_dev', 2.0) @@ -19,21 +20,33 @@ class MeanReversionStrategy(BaseStrategy): def generate_signal(self): rates = self.data_provider.get_historical_data(self.symbol, self.timeframe, self.period + 5) - if rates is None or len(rates) < self.period: + if rates is None or len(rates) < self.period + 1: return 0 df = pd.DataFrame(rates) df = self._calculate_indicators(df) - if df['close'].iloc[-1] > df['upper_band'].iloc[-1]: - return -1 - elif df['close'].iloc[-1] < df['lower_band'].iloc[-1]: + # 回归确认:价格曾突破边界,现已回归内侧 + prev_close = df['close'].iloc[-2] + prev_lower = df['lower_band'].iloc[-2] + prev_upper = df['upper_band'].iloc[-2] + curr_close = df['close'].iloc[-1] + curr_lower = df['lower_band'].iloc[-1] + curr_upper = df['upper_band'].iloc[-1] + + # 买入:上一根K线跌破下轨,当前回升至下轨上方(回归确认) + if prev_close < prev_lower and curr_close >= curr_lower: return 1 + # 卖出:上一根K线突破上轨,当前回落至上轨下方(回归确认) + elif prev_close > prev_upper and curr_close <= curr_upper: + return -1 return 0 def run_backtest(self, df): df = df.copy() df = self._calculate_indicators(df) signals = pd.Series(0, index=df.index) - signals[df['close'] > df['upper_band']] = -1 - signals[df['close'] < df['lower_band']] = 1 - return signals \ No newline at end of file + # 前一根在轨外 + 当前回归轨内 = 买入 + signals[(df['close'].shift(1) < df['lower_band'].shift(1)) & (df['close'] >= df['lower_band'])] = 1 + # 前一根在轨外 + 当前回归轨内 = 卖出 + signals[(df['close'].shift(1) > df['upper_band'].shift(1)) & (df['close'] <= df['upper_band'])] = -1 + return signals diff --git a/strategies/momentum_breakout.py b/strategies/momentum_breakout.py index c63c19a..8743bfb 100644 --- a/strategies/momentum_breakout.py +++ b/strategies/momentum_breakout.py @@ -3,27 +3,38 @@ from .base_strategy import BaseStrategy from config import STRATEGY_CONFIG class MomentumBreakoutStrategy(BaseStrategy): - def __init__(self, data_provider, symbol, timeframe, period=None): + """动量突破策略 — 在通道突破基础上增加动量方向确认(与 TurtleStrategy 的纯突破区分)""" + + def __init__(self, data_provider, symbol, timeframe, period=None, momentum_period=None): super().__init__(data_provider, symbol, timeframe) - # 从配置中获取参数,如果传入参数则使用传入的参数 config = STRATEGY_CONFIG.get('momentum_breakout', {}) self.period = period if period is not None else config.get('period', 20) + self.momentum_period = momentum_period if momentum_period is not None else config.get('momentum_period', 10) def _calculate_indicators(self, df): df['high_period'] = df['high'].rolling(self.period).max() df['low_period'] = df['low'].rolling(self.period).min() + # 计算动量:当前收盘价相对于 N 根前的涨跌幅 + df['momentum'] = df['close'] - df['close'].shift(self.momentum_period) return df def generate_signal(self): - rates = self.data_provider.get_historical_data(self.symbol, self.timeframe, self.period + 2) - if rates is None or len(rates) < self.period + 1: + rates = self.data_provider.get_historical_data(self.symbol, self.timeframe, max(self.period, self.momentum_period) + 5) + if rates is None or len(rates) < max(self.period, self.momentum_period) + 1: return 0 df = pd.DataFrame(rates) df = self._calculate_indicators(df) - if df['close'].iloc[-1] > df['high_period'].iloc[-2]: + close = df['close'].iloc[-1] + breakout_high = df['high_period'].iloc[-2] + breakout_low = df['low_period'].iloc[-2] + momentum = df['momentum'].iloc[-1] + + # 突破上轨 + 正动量确认 + if close > breakout_high and momentum > 0: return 1 - elif df['close'].iloc[-1] < df['low_period'].iloc[-2]: + # 跌破下轨 + 负动量确认 + elif close < breakout_low and momentum < 0: return -1 return 0 @@ -31,6 +42,6 @@ class MomentumBreakoutStrategy(BaseStrategy): df = df.copy() df = self._calculate_indicators(df) signals = pd.Series(0, index=df.index) - signals[df['close'] > df['high_period'].shift(1)] = 1 - signals[df['close'] < df['low_period'].shift(1)] = -1 - return signals \ No newline at end of file + signals[(df['close'] > df['high_period'].shift(1)) & (df['momentum'] > 0)] = 1 + signals[(df['close'] < df['low_period'].shift(1)) & (df['momentum'] < 0)] = -1 + return signals diff --git a/strategies/rsi.py b/strategies/rsi.py index 671f65e..8ac6c16 100644 --- a/strategies/rsi.py +++ b/strategies/rsi.py @@ -18,10 +18,13 @@ class RSIStrategy(BaseStrategy): df = pd.DataFrame(rates) delta = df['close'].diff() - gain = (delta.where(delta > 0, 0)).rolling(window=self.period).mean() - loss = (-delta.where(delta < 0, 0)).rolling(window=self.period).mean() - - rs = gain / loss + gain = delta.where(delta > 0, 0) + loss = -delta.where(delta < 0, 0) + + avg_gain = gain.ewm(com=self.period - 1, min_periods=self.period).mean() + avg_loss = loss.ewm(com=self.period - 1, min_periods=self.period).mean() + + rs = avg_gain / avg_loss rsi = 100 - (100 / (1 + rs)) latest_rsi = rsi.iloc[-1] diff --git a/strategies/wave_theory.py b/strategies/wave_theory.py index 5105c17..ffae65a 100644 --- a/strategies/wave_theory.py +++ b/strategies/wave_theory.py @@ -36,8 +36,9 @@ class WaveTheoryStrategy(BaseStrategy): def _identify_wave_points(self, df): window_size = self.wave_period * 2 + 1 - df['local_high'] = df['high'].rolling(window=window_size, center=False).max().shift(-self.wave_period) - df['local_low'] = df['low'].rolling(window=window_size, center=False).min().shift(-self.wave_period) + # 纯后向窗口,不使用 shift 避免未来函数 + df['local_high'] = df['high'].rolling(window=window_size, center=False).max() + df['local_low'] = df['low'].rolling(window=window_size, center=False).min() wave_points = pd.Series(0, index=df.index) wave_points[df['high'] == df['local_high']] = 1 wave_points[df['low'] == df['local_low']] = -1 @@ -109,12 +110,13 @@ class WaveTheoryStrategy(BaseStrategy): if current_price > upper_bound * 0.98 and current_momentum < 0: return -1 elif current_price < lower_bound * 1.02 and current_momentum > 0: return 1 else: - ema_alignment = (df['ema_short'].iloc[-1] > df['ema_medium'].iloc[-1] > df['ema_long'].iloc[-1]) + ema_bullish = (df['ema_short'].iloc[-1] > df['ema_medium'].iloc[-1] > df['ema_long'].iloc[-1]) + ema_bearish = (df['ema_short'].iloc[-1] < df['ema_medium'].iloc[-1] < df['ema_long'].iloc[-1]) if f'fib_0.618' in df.columns and not pd.isna(df[f'fib_0.618'].iloc[-1]): fib_618 = df[f'fib_0.618'].iloc[-1] if abs(current_price - fib_618) / fib_618 < 0.01: - if ema_alignment and current_momentum > 0: return 1 - elif not ema_alignment and current_momentum < 0: return -1 + if ema_bullish and current_momentum > 0: return 1 + elif ema_bearish and current_momentum < 0: return -1 return 0 def run_backtest(self, df): @@ -132,10 +134,11 @@ class WaveTheoryStrategy(BaseStrategy): if current_price > upper_bound * 0.98 and current_momentum < 0: signals.iat[i] = -1 elif current_price < lower_bound * 1.02 and current_momentum > 0: signals.iat[i] = 1 else: - ema_alignment = (df['ema_short'].iloc[i] > df['ema_medium'].iloc[i] > df['ema_long'].iloc[i]) + ema_bullish = (df['ema_short'].iloc[i] > df['ema_medium'].iloc[i] > df['ema_long'].iloc[i]) + ema_bearish = (df['ema_short'].iloc[i] < df['ema_medium'].iloc[i] < df['ema_long'].iloc[i]) if f'fib_0.618' in df.columns and not pd.isna(df[f'fib_0.618'].iloc[i]): fib_618 = df[f'fib_0.618'].iloc[i] if abs(current_price - fib_618) / fib_618 < 0.01: - if ema_alignment and current_momentum > 0: signals.iat[i] = 1 - elif not ema_alignment and current_momentum < 0: signals.iat[i] = -1 + if ema_bullish and current_momentum > 0: signals.iat[i] = 1 + elif ema_bearish and current_momentum < 0: signals.iat[i] = -1 return signals \ No newline at end of file diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utils/constants.py b/utils/constants.py new file mode 100644 index 0000000..2a65787 --- /dev/null +++ b/utils/constants.py @@ -0,0 +1,44 @@ +# MT5 时间周期常量映射 +# 参考: https://www.mql5.com/en/docs/constants/chartconstants/enum_timeframes + +PERIOD_M1 = 1 +PERIOD_M5 = 5 +PERIOD_M15 = 15 +PERIOD_M30 = 30 +PERIOD_H1 = 16385 +PERIOD_H4 = 16386 +PERIOD_D1 = 16408 +PERIOD_W1 = 32769 +PERIOD_MN1 = 49153 + +# MT5 timeframe → 分钟数 +TIMEFRAME_TO_MINUTES = { + PERIOD_M1: 1, + PERIOD_M5: 5, + PERIOD_M15: 15, + PERIOD_M30: 30, + PERIOD_H1: 60, + PERIOD_H4: 240, + PERIOD_D1: 1440, + PERIOD_W1: 10080, + PERIOD_MN1: 43200, +} + +# MT5 timeframe → pandas resample rule +RESAMPLE_RULES = { + PERIOD_M1: '1min', + PERIOD_M5: '5min', + PERIOD_M15: '15min', + PERIOD_M30: '30min', + PERIOD_H1: '1h', + PERIOD_H4: '4h', + PERIOD_D1: '1D', + PERIOD_W1: '1W', + PERIOD_MN1: '1ME', +} + +# MT5 交易方向常量 +ORDER_TYPE_BUY = 0 +ORDER_TYPE_SELL = 1 +POSITION_TYPE_BUY = 0 +POSITION_TYPE_SELL = 1