Преглед изворни кода

Commit research code, docs, and memory files

erwin пре 3 недеља
родитељ
комит
cd5dfb9dd2
71 измењених фајлова са 11586 додато и 12 уклоњено
  1. 0 0
      .codex
  2. 0 2
      .gitignore
  3. 6 0
      MEMORY.md
  4. 5 0
      USER.md
  5. 0 0
      backtrader-lab/.codex
  6. 382 0
      backtrader-lab/BT_STRATEGY_PLAN.md
  7. 303 0
      backtrader-lab/OPENCLAW_BACKTRADER_WORKFLOW.md
  8. 65 0
      backtrader-lab/SHORTLIST_COMPARE_STAGE1.md
  9. 163 0
      backtrader-lab/SHORTLIST_STAGE1.md
  10. 338 0
      backtrader-lab/STAGE1_CLOSEOUT_20260413.md
  11. 208 0
      backtrader-lab/STRATEGY_LAYERING.md
  12. 125 0
      backtrader-lab/backtest.py
  13. 7 0
      backtrader-lab/balanced3_subperiod_review_20260413-171654.md
  14. 12 0
      backtrader-lab/balanced3_weight_batch1_20260413-180259.md
  15. 12 0
      backtrader-lab/balanced3_weight_batch2_20260413-183237.md
  16. 12 0
      backtrader-lab/balanced3_weight_batch3_20260413-183444.md
  17. 55 0
      backtrader-lab/chinext50_dualthrust_optimization.md
  18. 104 0
      backtrader-lab/chinext50_experiment_summary.md
  19. 2094 0
      backtrader-lab/chinext50_experiments.py
  20. 34 0
      backtrader-lab/convert_data.py
  21. 34 0
      backtrader-lab/debug_indicators.py
  22. 19 0
      backtrader-lab/donchian_nearby_batch_20260413-162623.md
  23. 18 0
      backtrader-lab/dualthrust_nearby_batch2_20260413-161417.md
  24. 17 0
      backtrader-lab/dualthrust_nearby_batch_20260413-161215.md
  25. 143 0
      backtrader-lab/dualthrust_strategy.py
  26. 197 0
      backtrader-lab/fetch_data.py
  27. 594 0
      backtrader-lab/gen_html_emails.py
  28. 19 0
      backtrader-lab/mvt_nearby_batch_20260413-162421.md
  29. 129 0
      backtrader-lab/regime_chinext50.py
  30. 173 0
      backtrader-lab/regime_detection.py
  31. 145 0
      backtrader-lab/regime_final.py
  32. 132 0
      backtrader-lab/regime_simple.py
  33. 210 0
      backtrader-lab/regime_strategy.py
  34. 46 0
      backtrader-lab/regime_strategy_final.py
  35. 139 0
      backtrader-lab/regime_v2.py
  36. 120 0
      backtrader-lab/regime_working.py
  37. 7 0
      backtrader-lab/run.sh
  38. 385 0
      backtrader-lab/shortlist_combo_trials.py
  39. 7 0
      backtrader-lab/shortlist_combo_trials_20260413-170128.md
  40. 12 0
      backtrader-lab/shortlist_combo_trials_batch2_20260413-171317.md
  41. 50 0
      backtrader-lab/shortlist_subperiod_review_20260413-164800.md
  42. 155 0
      cat-fly/data-fetch/README.md
  43. 22 0
      cat-fly/data-fetch/fetch_cyb50.py
  44. 256 0
      cat-fly/data-fetch/mairui_fetcher.py
  45. 0 0
      cat-fly/t1/analyze_trades.py
  46. 100 0
      cat-fly/t1/backtest_correct_long_only.py
  47. 337 0
      cat-fly/t1/backtest_dual_direction_correct.py
  48. 349 0
      cat-fly/t1/backtest_dual_with_timing.py
  49. 400 0
      cat-fly/t1/backtest_final_optimal.py
  50. 572 0
      cat-fly/t1/backtest_multi_timeframe.py
  51. 536 0
      cat-fly/t1/backtest_no_lookahead.py
  52. 807 0
      cat-fly/t1/backtest_t1_standalone.py
  53. 546 0
      cat-fly/t1/backtest_t1_with_regime.py
  54. 4 0
      index-rotation/MEMORY.md
  55. 7 0
      index-rotation/USER.md
  56. 5 0
      index-rotation/configs/strategy/top1_every_5_days_p05_cost15bp.yaml
  57. 13 0
      index-rotation/configs/strategy/top1_every_5_days_p05_cost15bp_mom_15_25_30_30.yaml
  58. 13 0
      index-rotation/configs/strategy/top1_every_5_days_p05_cost15bp_mom_25_25_30_20.yaml
  59. 7 0
      index-rotation/memory/2026-04-07.md
  60. 21 0
      index-rotation/outputs/research/strategy_comparison.md
  61. 78 1
      index-rotation/src/backtest/compare.py
  62. 3 0
      index-rotation/src/backtest/engine.py
  63. 4 0
      index-rotation/src/backtest/run.py
  64. 29 4
      index-rotation/src/signals/scorer.py
  65. 4 0
      index-rotation/src/signals/selector.py
  66. 15 5
      index-rotation/tests/test_compare.py
  67. 71 0
      index-rotation/tests/test_phase2_signals.py
  68. 9 0
      memory/2026-04-11.md
  69. 393 0
      test_strategy/backtest.py
  70. 294 0
      test_strategy/dual_ma_strategy.py
  71. 15 0
      test_strategy/requirements.txt

+ 0 - 2
.gitignore

@@ -46,8 +46,6 @@ index-rotation/.codex/
 index-rotation/.codex-orchestrator/
 
 # Local agent / runtime artifacts
-.codex/
-backtrader-lab/.codex/
 backtrader-lab/.bt-runs/
 
 # Generated experiment outputs

+ 6 - 0
MEMORY.md

@@ -0,0 +1,6 @@
+# MEMORY.md
+
+- 2026-04-11: Continuing local quant research in `cyb50-quant`.
+- User asked for concrete backtest results, not just brainstorming.
+- Scope is intentionally narrow: `backtrader-lab/` experiments for 1) multi-index dual-momentum rotation and 2) Chinext50 single-index trend-following with cash/flat regime control.
+- Deliverables requested: minimal code/config changes, actual backtest runs, concise markdown results file, reproducible commands, and a completion notification via `openclaw system event`.

+ 5 - 0
USER.md

@@ -12,6 +12,11 @@ _Learn about the person you're helping. Update this as you go._
 
 _(What do they care about? What projects are they working on? What annoys them? What makes them laugh? Build this over time.)_
 
+- Working in `/home/erwin/.openclaw/workspace/cyb50-quant`.
+- Wants concrete local quant research outputs with runnable backtests, not just strategy ideas.
+- Current focus is limited to two directions: multi-index dual-momentum rotation and single-index Chinext50 trend-following with flat/cash regime control.
+- Prefers reusing the existing project structure and keeping changes scoped to the relevant files only.
+
 ---
 
 The more you know, the better you can help. But remember — you're learning about a person, not building a dossier. Respect the difference.

+ 0 - 0
backtrader-lab/.codex


+ 382 - 0
backtrader-lab/BT_STRATEGY_PLAN.md

@@ -0,0 +1,382 @@
+# BT Strategy Broad Search Plan
+
+## Goal
+
+先建立 40–70 个策略候选池;其中第一阶段先力争形成 15–25 个真实可回测策略样本。完成初筛后,再两轮筛选,最后收敛到 5–10 个重点优化。
+
+核心原则:
+- 先建池,再收敛
+- 先横向比较,再参数优化
+- 回测完成即刻可靠通知属于主流程
+- 同类问题优先修根因,不靠补发兜底
+
+---
+
+## Current status
+
+### 已验证流程
+- 单策略 BT 回测 + 主动通知:已验证
+- 三策略批量 BT 回测 + 主动通知:已验证
+- 使用 `.bt-runs/<run_id>/state.json` 与 `notify.json` 的新流程:已接入
+
+### 当前已跑策略
+1. DualThrustRegimeStrategy
+2. DonchianRegimeStrategy
+3. MomentumVolTargetStrategy
+4. MomentumRegimeStrategy
+5. MomentumAtrTrailStrategy
+6. DonchianHybridVolAtrStrategy
+7. SuperTrendLongMaStrategy
+
+---
+
+## Strategy families to expand
+
+目标:扩展为 12 个家族,每个家族先准备 4–6 个变体,第一阶段形成 40–70 个策略池。
+
+### Core 6 families (priority first)
+这些家族优先做深,先保证每类至少有足够样本和可比较变体。
+
+#### 1. Breakout family
+- Donchian variants
+- DualThrust variants
+- Opening range breakout
+- N-day breakout with filters
+- Breakout + volatility filter
+
+#### 2. Momentum family
+- Single-period momentum
+- Dual-period momentum
+- Momentum + regime
+- Momentum + ATR trail
+- Momentum + vol targeting
+
+#### 3. Trend family
+- SMA / EMA crossover
+- Long-MA filter
+- SuperTrend variants
+- KAMA trend
+- MACD trend filter
+
+#### 4. Volatility / risk family
+- Vol targeting
+- ATR stop / ATR trail
+- Position scaling by vol
+- Risk budget variants
+
+#### 5. Regime family
+- Bull/bear filter
+- Vol regime filter
+- Trend regime switch
+- Risk-on / risk-off filter
+
+#### 6. Hybrid family
+- Breakout + regime
+- Breakout + ATR
+- Trend + vol target
+- Momentum + trend filter
+- Breakout + trend + regime
+
+### Expansion 6 families (expand after core coverage)
+这些家族保留在第一阶段,但执行顺序晚于核心 6 家族,不要求一开始平均铺开。
+
+#### 7. Mean reversion family
+- Bollinger mean reversion
+- Z-score pullback
+- Channel reversion
+- Short-term oversold rebound
+
+#### 8. Pullback family
+- Trend pullback entry
+- MA pullback continuation
+- ATR pullback setup
+- Breakout then pullback re-entry
+
+#### 9. Range / consolidation family
+- Range breakout failure
+- Range oscillation
+- Box mean reversion
+- Consolidation filter variants
+
+#### 10. Volume-confirmed family
+- Volume breakout confirmation
+- Price-volume trend filters
+- Volume surge entry
+- Low-volume rejection filters
+
+#### 11. Breadth / internal strength family
+- Market breadth proxy filters
+- Relative participation filters
+- Breadth-confirmed trend entry
+- Internal strength defensive overlays
+
+#### 12. Defensive / overlay family
+- Max drawdown guard
+- Risk-off overlay
+- Trailing stop overlay
+- Exposure cap / defensive switch
+
+---
+
+## Batch execution plan
+
+### Batch 0: current base
+现有已跑样本,作为第一阶段底座:
+1. DualThrustRegimeStrategy
+2. DonchianRegimeStrategy
+3. MomentumVolTargetStrategy
+4. MomentumRegimeStrategy
+5. MomentumAtrTrailStrategy
+6. DonchianHybridVolAtrStrategy
+7. SuperTrendLongMaStrategy
+
+意义:
+- Breakout / Hybrid 已有初步基础
+- Momentum 已有初步基础
+- Trend / Defensive 有少量基础
+- 其他家族基本空白
+
+### Batch 1: expand strong families
+目标:补 10–12 个新策略,优先扩当前强势方向,同时加入少量对照和防守样本。
+
+建议配比:
+- Breakout / Hybrid: 4
+- Momentum: 3
+- Trend: 3
+- Defensive / Overlay: 1–2
+
+建议名单(需先做仓库现实检查,标注可跑 / 待实现):
+- DonchianAdxStrategy — 可跑(已存在)
+- DonchianBasicStrategy — 待实现 / 待确认
+- DualThrustBasicStrategy — 待实现 / 待确认
+- OpeningRangeBreakoutStrategy — 待实现 / 待确认
+- TsmomBasicStrategy — 待实现 / 待确认
+- MomentumMaFilterStrategy — 待实现 / 待确认
+- MomentumDefensiveFilterStrategy — 待实现 / 待确认
+- EmaCrossoverStrategy — 待实现 / 待确认
+- SmaLongFilterTrendStrategy — 待实现 / 待确认
+- KamaTrendStrategy — 可跑(已存在)
+- AtrStopOverlayStrategy — 待实现 / 待确认
+- MaxDrawdownGuardStrategy — 待实现 / 待确认
+
+备注:
+- Batch 1 在真正执行前,先拆成“当前可跑批次”与“研发 backlog”
+- 不把概念性策略名直接当成可回测对象
+
+### Batch 2: trend / regime / risk
+目标:再补 10–12 个,把趋势过滤、状态切换、风险控制这一层铺开。
+
+建议配比:
+- Trend: 3
+- Regime: 3
+- Volatility / Risk: 4
+- Pullback / Range: 1–2
+
+建议名单(需先做仓库现实检查,标注可跑 / 待实现):
+- SmaCrossoverStrategy — 待实现 / 待确认
+- MacdTrendFilterStrategy — 待实现 / 待确认
+- SuperTrendBasicStrategy — 待实现 / 待确认
+- BullBearFilterStrategy — 待实现 / 待确认
+- VolatilityRegimeFilterStrategy — 待实现 / 待确认
+- RiskOnRiskOffStrategy — 待实现 / 待确认
+- VolTargetBasicStrategy — 待实现 / 待确认
+- PositionScalingByVolStrategy — 待实现 / 待确认
+- AtrTrailBasicStrategy — 待实现 / 待确认
+- ExposureCapStrategy — 待实现 / 待确认
+- TrendPullbackEntryStrategy — 待实现 / 待确认
+- RangeOscillationStrategy — 待实现 / 待确认
+
+### Batch 3: fill blank families
+目标:再补 10–12 个,优先填补此前覆盖不足的逻辑空间。
+
+建议配比:
+- Mean Reversion: 3
+- Volume-confirmed: 3
+- Breadth / Internal Strength: 2–3
+- Defensive / Overlay: 2–3
+
+建议名单(原则上先进入研发 backlog,不默认视为可直接回测):
+- BollingerMeanReversionStrategy — 待实现
+- ZScoreReversionStrategy — 待实现
+- OversoldReboundStrategy — 待实现
+- VolumeBreakoutConfirmationStrategy — 待实现
+- PriceVolumeTrendFilterStrategy — 待实现
+- VolumeSurgeEntryStrategy — 待实现
+- BreadthProxyFilterStrategy — 待实现
+- ParticipationFilterStrategy — 待实现
+- InternalStrengthTrendStrategy — 待实现
+- TrailingStopOverlayStrategy — 待实现
+- RiskOffOverlayStrategy — 待实现
+- DrawdownShutdownStrategy — 待实现
+
+### Batch 4: pullback / range / hybrid detail
+目标:补 8–10 个,把 pullback、range、hybrid 细分补完整。
+
+建议配比:
+- Pullback: 3
+- Range / Consolidation: 3
+- Hybrid: 2–4
+
+建议名单(原则上先进入研发 backlog,不默认视为可直接回测):
+- MaPullbackContinuationStrategy — 待实现
+- AtrPullbackSetupStrategy — 待实现
+- BreakoutPullbackReentryStrategy — 待实现
+- BoxReversionStrategy — 待实现
+- ChannelBounceStrategy — 待实现
+- BreakoutFailureReentryStrategy — 待实现
+- BreakoutTrendRegimeStrategy — 待实现
+- MomentumRegimeAtrStrategy — 待实现
+- TrendVolTargetStrategy — 待实现
+- DefensiveHybridStrategy — 待实现
+
+### Batch 5: expand winning families
+目标:补 8–12 个,根据前 4 批结果定向扩优胜家族。
+
+规则:
+- 如果 Breakout 整体占优,继续扩 Breakout 变体
+- 如果 Momentum + 风控表现更稳,优先扩该方向
+- 如果 Trend / Defensive 更稳,优先扩相关组合
+- 如果某家族整体弱,减少继续投入
+- 如果某家族有互补价值但收益一般,可保留少量观察位
+
+目标总量:40–70 个策略
+
+---
+
+## Strategy inventory
+
+每个 batch 开始前,先做一次仓库现实检查,并输出三类清单:
+- `runnable_now`: 当前仓库已有、可直接回测
+- `needs_adaptation`: 稍作改造即可回测
+- `needs_implementation`: 仅有概念,还未实现
+
+建议落地文件:
+- `strategy_inventory.md` 或 `strategy_inventory.csv`
+
+## Unified evaluation table
+
+后续所有策略都应进入统一总表,至少包含:
+- strategy_name
+- family
+- key_params
+- total_return
+- annual_return
+- sharpe
+- max_drawdown
+- win_rate
+- closed_trades
+- avg_exposure
+- status
+- shortlist_flag
+- notes
+
+建议后续落地文件:
+- `strategy_pool.csv` 或 `strategy_pool.md`
+
+---
+
+## Screening rules
+
+### Track A: main strategy filter
+适用于主方向策略(breakout / momentum / trend / regime / hybrid)。
+
+#### Round 1: hard filter
+默认硬筛门槛:
+- annual_return > 8%
+- sharpe > 0.30
+- max_drawdown < 40%
+- closed_trades > 20
+
+不满足时:
+- 默认淘汰
+- 或标记为观察样本(仅当风格有特殊参考价值)
+
+#### Round 2: shortlist filter
+从通过硬筛的策略里,再看:
+- 收益 / 回撤比
+- Sharpe 相对优势
+- 风格是否重复
+- 是否具备组合互补价值
+- 是否值得继续做参数优化
+
+目标:收敛到 10–15 个主策略候选
+
+### Track B: component / overlay filter
+适用于 defensive / overlay / vol-risk 组件型策略,不与主策略共用同一套门槛。
+
+优先看:
+- 是否显著降低 max_drawdown
+- 是否改善收益 / 回撤比
+- 是否提升稳定性或持有体验
+- 是否能作为通用叠加层复用
+
+组件型策略可以在以下情况下保留:
+- 绝对收益一般,但显著改善风控
+- 单独表现一般,但叠加后明显提升主策略表现
+- 交易次数较少,但具备明确的防守价值
+
+### Final focus
+最终进入重点优化池:
+- 5–10 个主策略
+- 若干值得保留的 overlay / 组件模块
+- 进行小范围参数优化
+- 分阶段稳健性验证
+- 组合测试
+
+---
+
+## Current observations
+
+### 第一优先级候选
+- DualThrustRegimeStrategy
+- DonchianRegimeStrategy
+- MomentumVolTargetStrategy
+- DonchianHybridVolAtrStrategy
+
+### 第二优先级候选
+- MomentumRegimeStrategy
+- MomentumAtrTrailStrategy
+- SuperTrendLongMaStrategy
+
+### 当前判断
+- Breakout 家族:目前最值得继续扩
+- Momentum 家族:有明显潜力,但需要更多风控变体比较
+- Trend / Defensive 家族:更适合作为回撤控制对照组
+
+---
+
+## Execution discipline
+
+1. 没有真实 run/session 证据,不报“已开跑”
+2. 回测完成不等于任务完成;通知送达也属于完成条件
+3. 优先修根因,不靠补发充当解决方案
+4. 每跑完一批,就更新总表与阶段判断
+5. 不在策略池尚小的时候过早深挖单个参数
+6. 每个 batch 开始前先做一次仓库现实检查:区分“当前可跑策略”与“待实现策略”
+
+---
+
+## Unified evaluation table template
+
+建议统一维护 `strategy_pool.csv` 或 `strategy_pool.md`,字段如下:
+
+| strategy_name | family | key_params | total_return | annual_return | sharpe | max_drawdown | win_rate | closed_trades | avg_exposure | status | shortlist_flag | notes |
+|---|---|---|---:|---:|---:|---:|---:|---:|---:|---|---|---|
+| DualThrustRegimeStrategy | Breakout / Hybrid | range_period=20,k1=0.3,k2=0.3,regime=120 | 391.77% | 15.02% | 0.405 | 34.04% | 47.22% | 36 | 26.24% | tested | yes | 当前收益第一 |
+| DonchianRegimeStrategy | Breakout / Regime | breakout=55,exit=30,regime=150 | 262.60% | 11.98% | 0.440 | 40.47% | 45.00% | 20 | 25.98% | tested | yes | 当前 Sharpe 第一 |
+| MomentumVolTargetStrategy | Momentum / Vol | mom_short=20,mom_long=120,vol_period=30,target_vol=0.3 | 261.49% | 11.95% | 0.419 | 37.62% | 38.89% | 72 | 24.21% | tested | yes | 风险收益较均衡 |
+
+字段说明:
+- `status`: planned / running / tested / dropped / shortlisted
+- `shortlist_flag`: yes / no / observe
+- `notes`: 记录阶段判断,不写废话
+
+---
+
+## Next checkpoint
+
+下一步建议:
+1. 先确认 Batch 1 名单是否接受当前命名与方向
+2. 逐批回测并自动通知
+3. 跑完后更新统一对比表
+4. 做第一轮硬筛

+ 303 - 0
backtrader-lab/OPENCLAW_BACKTRADER_WORKFLOW.md

@@ -0,0 +1,303 @@
+# OpenClaw 跑 Backtrader 标准流程
+
+> 目标:用 **OpenClaw + Backtrader** 稳定地做指数策略研究,避免“任务做完了但没人知道”“结果散落一地”“Codex 和回测执行混在一起”的问题。
+
+---
+
+## 1. 角色分工
+
+### A. Codex / 编码代理负责什么
+只负责:
+- 新增/修改 Backtrader 策略
+- 改实验脚本
+- 加参数扫描逻辑
+- 改 summary / report 输出
+- 重构 `backtrader-lab/` 内的代码
+
+### B. OpenClaw exec/process 负责什么
+只负责:
+- 真正执行 Backtrader 回测
+- 跑参数扫描
+- 管后台 session
+- 读取日志
+- 确认是否完成
+- 主动反馈结果
+
+### C. 不要混用
+原则:
+- **Codex 写代码**
+- **Backtrader 脚本自己跑**
+
+不要把“让 Codex 干活”和“让 Codex 顺便当回测引擎”混成一件事。
+
+---
+
+## 2. 工作目录约束
+
+所有 Backtrader 工作都限制在:
+
+```bash
+/home/erwin/.openclaw/workspace/cyb50-quant/backtrader-lab
+```
+
+规则:
+- 不碰 `MEMORY.md / SOUL.md / AGENTS.md / USER.md`
+- 不跑去 `index-rotation/` 乱改
+- 量化实验只在 `backtrader-lab/` 收口
+
+---
+
+## 3. 推荐目录结构
+
+```text
+backtrader-lab/
+├── chinext50.csv
+├── chinext50_experiments.py
+├── chinext50_experiment_summary.md
+├── chinext50_dualthrust_optimization.md
+├── results/
+│   ├── latest.json
+│   ├── latest.log
+│   └── runs/
+├── scripts/
+│   ├── run_experiments.sh
+│   ├── run_dualthrust_opt.sh
+│   └── notify_summary.sh
+└── OPENCLAW_BACKTRADER_WORKFLOW.md
+```
+
+最低要求:
+- 回测脚本
+- markdown summary
+- json 结果文件
+- log 文件
+
+---
+
+## 4. 标准执行方式
+
+### 4.1 单次实验
+直接执行 Backtrader 脚本:
+
+```bash
+cd /home/erwin/.openclaw/workspace/cyb50-quant/backtrader-lab
+python3 chinext50_experiments.py
+```
+
+### 4.2 参数优化
+
+```bash
+cd /home/erwin/.openclaw/workspace/cyb50-quant/backtrader-lab
+python3 chinext50_experiments.py --optimize-dualthrust
+```
+
+### 4.3 长任务后台运行
+让 OpenClaw 用 `exec + process` 托管:
+
+- `exec(background=true, pty=false/true 视需要)` 启动
+- `process poll/log` 只做状态确认
+- 不搞 sleep loop
+
+---
+
+## 5. 输出产物规范
+
+每次回测至少产出 3 类东西:
+
+### A. summary.md
+给人看的结论:
+- 跑了哪些策略
+- 参数是什么
+- 年化 / 夏普 / 最大回撤 / 胜率
+- 推荐继续哪条
+
+### B. results.json
+给程序看的结构化结果:
+- strategy name
+- config
+- metrics
+- startedAt
+- finishedAt
+- data range
+- command
+
+### C. run.log
+给排错用:
+- stdout
+- stderr
+- traceback
+- 执行时长
+
+规则:
+- **有 markdown,方便我直接汇报给用户**
+- **有 json,方便后续自动比对/排序**
+- **有 log,方便排错**
+
+---
+
+## 6. 反馈优先协议(最重要)
+
+后台任务默认执行这套:
+
+### 开始
+必须反馈:
+- 跑什么
+- 在哪跑
+- session 是什么
+- 大概多久
+
+### 中间
+出现以下情况必须主动反馈:
+- 第一批结果出来
+- 发现明显候选最优
+- 某个方向明显不行
+- 卡住 / 报错
+
+### 结束
+必须主动反馈:
+- 最优策略
+- 关键指标
+- 结果文件位置
+- 下一步建议
+
+### 禁止
+- 不允许任务做完后沉默
+- 不允许等用户追问才汇报
+
+---
+
+## 7. 通知机制:不要单点依赖 system event
+
+因为 `openclaw system event` 可能出现 websocket `1006 abnormal closure`,所以不能只靠它。
+
+### 三层通知方案
+
+#### 第一层:快路径
+任务结束时尝试:
+
+```bash
+openclaw system event --text "Done: backtrader experiment finished" --mode now
+```
+
+#### 第二层:状态落盘
+每个任务写状态文件:
+
+```text
+backtrader-lab/.task-watch/<task-id>.json
+```
+
+至少包含:
+- task id
+- session id
+- command
+- status: running / done / failed
+- summary path
+- updatedAt
+- announced: true/false
+
+#### 第三层:watchdog 兜底
+如果:
+- 任务已完成
+- 但 `announced=false`
+
+则父侧必须:
+- 读取 summary
+- 主动发结果
+- 把 announced 标记为 true
+
+### 结论
+- `system event` 是快路径
+- **watchdog 才是兜底**
+
+---
+
+## 8. OpenClaw 里更适合的使用姿势
+
+### 情况 A:新增策略
+用 Codex:
+- 写新策略类
+- 改 `chinext50_experiments.py`
+- 加参数扫描
+
+### 情况 B:跑实验
+不用 Codex 代跑,直接:
+- `python3 chinext50_experiments.py`
+- `python3 chinext50_experiments.py --optimize-dualthrust`
+
+### 情况 C:定时任务
+用 `cron isolated`:
+- 晚上自动跑回测
+- 跑完自动写 summary
+- 再显式投递到 Feishu 当前聊天
+
+投递必须显式指定:
+- `channel=feishu`
+- `to=ou_3923ae1d25c5056a9844718baf153e36`
+- `accountId=main`
+
+不要再用 `last`。
+
+---
+
+## 9. 推荐的脚本化方式
+
+建议补两个脚本。
+
+### 9.1 `scripts/run_experiments.sh`
+职责:
+- 运行基础实验
+- stdout/stderr 写入 `results/latest.log`
+- 结果写入 `chinext50_experiment_summary.md`
+- 同时生成 `results/latest.json`
+
+### 9.2 `scripts/run_dualthrust_opt.sh`
+职责:
+- 跑 DualThrust 参数搜索
+- 写 `chinext50_dualthrust_optimization.md`
+- 生成 json 结果
+- 更新 task status
+
+---
+
+## 10. 推荐的节奏
+
+### 日常研究
+- 白天:问答式、小范围单次回测
+- 晚上:批量参数扫描
+- 周末:统一做 shortlist 排名
+
+### 研究顺序
+1. 先广撒网找候选
+2. 再缩到 Top 2~3
+3. 再做参数优化
+4. 再做样本外验证
+5. 最后才考虑实盘映射
+
+---
+
+## 11. 当前项目下的建议落地
+
+基于当前 `backtrader-lab/`,建议后续这样走:
+
+### 主线候选
+- `DualThrustRegimeStrategy`
+- `DonchianRegimeStrategy`
+- `MomentumVolTargetStrategy`
+
+### 当前 DualThrust 优化候选
+#### 综合最强
+- `range_period=20, k1=0.4, k2=0.4, regime=120`
+
+#### 更稳版本
+- `range_period=30, k1=0.3, k2=0.3, regime=120`
+
+### 下一步
+- 做样本内/样本外拆分
+- 做手续费敏感性
+- 做不同时间段稳定性验证
+
+---
+
+## 12. 一句话版本
+
+**Codex 写 Backtrader,OpenClaw exec 跑 Backtrader,cron 负责定时,watchdog 负责不漏反馈。**

+ 65 - 0
backtrader-lab/SHORTLIST_COMPARE_STAGE1.md

@@ -0,0 +1,65 @@
+# Stage 1 Shortlist Compare
+
+更新时间:2026-04-13 16:40
+
+目的:
+- 对 Stage 1 shortlist 的 8 个候选做统一横向比较
+- 用同一张表看收益、Sharpe、回撤、交易频率和排序
+- 给出一版当前可执行的正式候选池排序
+
+## 评分方法
+- 年化收益 rank:权重 40%
+- Sharpe rank:权重 40%
+- 最大回撤 rank(越低越好):权重 20%
+- Composite Score 越低越好
+
+## 统一对比表
+
+| Final Rank | Strategy | Annual Return | Sharpe | Max DD | Closed Trades | Win Rate | Avg Exposure | Annual Rank | Sharpe Rank | DD Rank | Composite |
+| ---: | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
+| 1 | DualThrustBasicStrategy | 19.22% | 0.501 | 37.27% | 51 | 43.14% | 40.20% | 1 | 1 | 5 | 1.80 |
+| 2 | DualThrustRegime_r20_k035_035_reg120 | 14.78% | 0.435 | 32.70% | 25 | 44.00% | 24.98% | 2 | 3 | 3 | 2.60 |
+| 3 | DonchianRegimeStrategy | 11.98% | 0.440 | 40.47% | 20 | 45.00% | 25.98% | 3 | 2 | 8 | 3.60 |
+| 4 | MomentumVolTargetStrategy | 11.95% | 0.419 | 37.62% | 72 | 38.89% | 24.21% | 4 | 4 | 6 | 4.40 |
+| 5 | MVT_reg150_tv029 | 11.51% | 0.417 | 36.53% | 74 | 39.19% | 23.91% | 5 | 5 | 4 | 4.80 |
+| 6 | DonchianHybrid_b55_e30_tv025_atr4 | 10.11% | 0.380 | 26.43% | 33 | 54.55% | 18.97% | 7 | 6 | 1 | 5.40 |
+| 7 | DonchianAtrTrailStrategy | 10.75% | 0.327 | 27.09% | 32 | 53.12% | 22.30% | 6 | 8 | 2 | 6.00 |
+| 8 | MacdLongMaStrategy | 9.01% | 0.347 | 39.78% | 72 | 44.44% | 23.28% | 8 | 7 | 7 | 7.40 |
+
+## 当前正式候选池排序
+
+1. **DualThrustBasicStrategy** — 绝对进攻龙头;收益和 Sharpe 双第一。
+2. **DualThrustRegime_r20_k035_035_reg120** — 当前最稳的 DualThrust 版本;回撤控制优。
+3. **DonchianRegimeStrategy** — Donchian 收益主版本;家族主代表。
+4. **MomentumVolTargetStrategy** — MVT 进攻版主样本;收益最高的 MVT。
+5. **MVT_reg150_tv029** — MVT 均衡版;比 0.30 更稳。
+6. **DonchianHybrid_b55_e30_tv025_atr4** — Donchian 均衡版;回撤和 Sharpe 结构优。
+7. **DonchianAtrTrailStrategy** — Donchian 防守版;回撤最低梯队。
+8. **MacdLongMaStrategy** — 传统趋势对照;非 breakout / 非 DualThrust 基准。
+
+## 读表结论
+
+### 1. 第一名没有悬念
+- **DualThrustBasicStrategy** 仍然是当前第一。
+- 不只是收益最高,Sharpe 也是第一。
+
+### 2. 第二梯队已经分化成两类
+- **DualThrustRegime_r20_k035_035_reg120**:更稳的 DualThrust。
+- **DonchianRegimeStrategy / MomentumVolTargetStrategy**:两条不同风格的收益型主线。
+
+### 3. 最均衡的两个候选
+- **DonchianHybrid_b55_e30_tv025_atr4**
+- **MVT_reg150_tv029**
+- 这两个都不是单指标冠军,但结构最耐看。
+
+### 4. 最防守样本
+- **DonchianAtrTrailStrategy**
+- 如果只看回撤控制,它的定位最明确。
+
+### 5. 传统趋势对照保留一席
+- **MacdLongMaStrategy** 不是最强,但作为传统趋势对照仍值得保留。
+
+## 现阶段建议
+- 如果要保留 **3 个核心代表**:`DualThrustBasicStrategy`、`DualThrustRegime_r20_k035_035_reg120`、`DonchianRegimeStrategy`
+- 如果要保留 **5 个正式候选**:再加 `MomentumVolTargetStrategy`、`DonchianHybrid_b55_e30_tv025_atr4`
+- 如果要保留 **完整 8 个 shortlist**:维持当前名单,进入后续复核或组合阶段

+ 163 - 0
backtrader-lab/SHORTLIST_STAGE1.md

@@ -0,0 +1,163 @@
+# Stage 1 Formal Shortlist
+
+更新时间:2026-04-13 16:35
+
+> 这是一次**明确同意后的阶段收束**,不是永久终局结论。
+> 目的:把当前 63 个样本压缩成一版可执行的正式候选名单,便于后续继续做组合、复核、或二次筛选。
+
+---
+
+## 一、筛选原则
+
+这版 shortlist 不是只按单一收益排序,而是同时考虑:
+
+1. **收益能力**:年化收益是否处于当前样本池前列
+2. **风险调整后表现**:Sharpe 是否足够好
+3. **回撤约束**:是否存在明显不可接受的 max DD
+4. **家族代表性**:同一家族避免塞太多重复版本
+5. **非冗余性**:若两个版本高度相似,优先保留“更强”或“更稳”的那个
+
+---
+
+## 二、正式 shortlist(主候选 8 个)
+
+### 1) DualThrustBasicStrategy
+- 配置:`range_period=20, k1=0.3, k2=0.3`
+- 年化:**19.22%**
+- Sharpe:**0.501**
+- Max DD:**37.27%**
+- 定位:**当前绝对龙头 / 进攻主样本**
+- 保留理由:收益与 Sharpe 双第一,且两轮近邻未打掉,已不是偶然单点
+
+### 2) DualThrustRegime_r20_k035_035_reg120
+- 配置:`range_period=20, k1=0.35, k2=0.35, regime=120`
+- 年化:**14.78%**
+- Sharpe:**0.435**
+- Max DD:**32.70%**
+- 定位:**DualThrust 稳健版主候选**
+- 保留理由:相比原 regime 版,Sharpe 更高、回撤更低,是当前更好的稳健代表
+
+### 3) MomentumVolTargetStrategy
+- 配置:`target_vol=0.30`
+- 年化:**11.95%**
+- Sharpe:**0.419**
+- Max DD:**37.62%**
+- 定位:**MVT 进攻版主候选**
+- 保留理由:MVT 家族收益最高点,0.26~0.29 中间值没能打掉它
+
+### 4) MVT_reg150_tv029
+- 配置:`target_vol=0.29`
+- 年化:**11.51%**
+- Sharpe:**0.417**
+- Max DD:**36.53%**
+- 定位:**MVT 均衡版主候选**
+- 保留理由:收益只比 `0.30` 低一档,但回撤更低,结构更均衡
+
+### 5) DonchianRegimeStrategy
+- 配置:`breakout=55, exit_period=30, regime=150`
+- 年化:**11.98%**
+- Sharpe:**0.440**
+- Max DD:**40.47%**
+- 定位:**Donchian 收益主版本**
+- 保留理由:近邻 50/25 和 60/30 都没能打掉 55/30/150
+
+### 6) DonchianHybrid_b55_e30_tv025_atr4
+- 配置:`breakout=55, exit_period=30, target_vol=0.25, atr_mult=4.0`
+- 年化:**10.11%**
+- Sharpe:**0.380**
+- Max DD:**26.43%**
+- 定位:**Donchian 均衡版主候选**
+- 保留理由:相较原 Hybrid `tv=0.30`,收益更高、Sharpe 更高、回撤更低
+
+### 7) DonchianAtrTrailStrategy
+- 配置:`breakout=55, exit_period=30, atr_mult=4.0`
+- 年化:**10.75%**
+- Sharpe:**0.327**
+- Max DD:**27.09%**
+- 定位:**Donchian 防守版主候选**
+- 保留理由:在 Donchian 家族里防守属性最清晰,`atr=3.0` 近邻已验证不如它
+
+### 8) MacdLongMaStrategy
+- 配置:`macd_fast=12, macd_slow=26, macd_signal=9, regime=120`
+- 年化:**9.01%**
+- Sharpe:**0.347**
+- Max DD:**39.78%**
+- 定位:**传统趋势类非 breakout / 非 DualThrust 代表**
+- 保留理由:给 shortlist 留一个非 Donchian / 非 DualThrust / 非 MVT 的传统趋势基准,便于后续横向比较
+
+---
+
+## 三、候补名单(第二梯队)
+
+这些样本仍值得保留,但暂时不进入主 shortlist:
+
+### 候补 1:DualThrustRegimeStrategy(原版)
+- 理由:已经被 `DualThrustRegime_r20_k035_035_reg120` 部分替代
+
+### 候补 2:DualThrustSlowStrategy
+- 理由:仍强,但回撤偏高,且与 DualThrust 主线冗余度高
+
+### 候补 3:MomentumVolTargetBasicStrategy
+- 理由:本身很强,但与 `MVT 0.29 / 0.30` 信息重叠较大
+
+### 候补 4:MomentumVolTargetLowerTargetStrategy
+- 理由:保守版有价值,但与 `MVT 0.29` 也比较接近
+
+### 候补 5:MomentumAtrTrailStrategy
+- 理由:是动量家族另一条风控路径,但当前综合说服力略低于 MVT 主线
+
+### 候补 6:SuperTrendLongMaStrategy
+- 理由:低回撤趋势样本,有防守价值,但总表现不及 shortlist 内的主候选
+
+---
+
+## 四、这版 shortlist 的结构含义
+
+### 1. 进攻主线
+- DualThrustBasicStrategy
+- MomentumVolTargetStrategy
+- DonchianRegimeStrategy
+
+### 2. 稳健 / 均衡主线
+- DualThrustRegime_r20_k035_035_reg120
+- MVT_reg150_tv029
+- DonchianHybrid_b55_e30_tv025_atr4
+- DonchianAtrTrailStrategy
+
+### 3. 传统趋势对照
+- MacdLongMaStrategy
+
+也就是说,当前 shortlist 不是押单一家族,而是形成了:
+- **DualThrust**:进攻 + 稳健
+- **MomentumVolTarget**:进攻 + 均衡
+- **Donchian**:收益 + 均衡 + 防守
+- **传统趋势**:一个经典对照
+
+---
+
+## 五、当前最重要的阶段性结论
+
+1. **DualThrust 已经从“新冠军”升级为“当前主龙头”**
+2. **MVT 已明确存在 `0.29 ~ 0.30` 的甜点带**
+3. **Donchian 家族已经分化出收益版 / 均衡版 / 防守版三条明确路线**
+4. **继续深挖某一条家族的边际收益开始下降**
+5. **后续更值得做的是 shortlist 之间的横向比较、组合或复核,而不是继续无止境打单一家族近邻**
+
+---
+
+## 六、如果继续下一步,最合理的三个方向
+
+### 方向 A:shortlist 横向复核
+- 对这 8 个候选做统一对比表
+- 加入排序规则(收益 / Sharpe / max DD / 家族去重)
+- 形成更像“正式候选池”的版本
+
+### 方向 B:shortlist 组合试验
+- 不是做单策略,而是尝试两三条主线做轮动 / 组合
+- 看是否能提升稳健性
+
+### 方向 C:复核稳健性
+- 做子区间、滚动窗口、不同市场阶段复核
+- 看这些 shortlist 是否只是全样本期漂亮
+
+按当前进度,**最合理的是先做 A,再决定是否进入 B/C。**

+ 338 - 0
backtrader-lab/STAGE1_CLOSEOUT_20260413.md

@@ -0,0 +1,338 @@
+# Stage 1 Closeout — Chinext50 Backtrader Lab
+
+更新时间:2026-04-13 18:36
+
+> 这是本阶段的正式收官文档。
+> 范围覆盖:单策略样本池、Top 5 候选、组合试验、以及最优组合的子区间复核。
+
+---
+
+## 一、阶段目标回顾
+
+本阶段的主线不是直接下最终结论,而是:
+
+1. **先把可运行样本池铺开**
+2. 对主家族做**最小但足够的信息量近邻验证**
+3. 把强样本从“好看结果”推进到“有一定稳定性证据”
+4. 在单策略之外,试探是否存在更优的**稳健组合原型**
+
+当前阶段已完成:
+- 现成可跑主样本:47 个
+- 新增 ad-hoc 近邻样本:16 个
+- 当前总比较样本:63 个
+
+---
+
+## 二、单策略阶段结论
+
+### 1. 当前单策略绝对龙头
+
+#### **DualThrustBasicStrategy**
+- 配置:`range_period=20, k1=0.3, k2=0.3`
+- 全样本:
+  - 年化:**19.22%**
+  - Sharpe:**0.501**
+  - Max DD:**37.27%**
+
+这不是单点 luck。
+两轮近邻验证后,以下事实已经比较清楚:
+- `range_period=18` 明显变差
+- `range_period=22` 仍强但没超过主版
+- `k=0.25` 明显劣化
+- `k=0.35` 接近,但仍未超过主版
+- 非对称阈值也没有打掉主版
+
+**结论:**
+> `DualThrustBasicStrategy (20, 0.3, 0.3)` 已经从“当前最强样本”升级为“当前最强且局部稳定的单策略龙头”。
+
+---
+
+### 2. 稳健型单策略代表
+
+#### **DualThrustRegime_r20_k035_035_reg120**
+- 配置:`range_period=20, k1=0.35, k2=0.35, regime=120`
+- 全样本:
+  - 年化:**14.78%**
+  - Sharpe:**0.435**
+  - Max DD:**32.70%**
+
+相较原 `DualThrustRegimeStrategy`:
+- Sharpe 更高
+- 回撤更低
+- 是当前更合理的 regime 稳健升级版
+
+**结论:**
+> 如果要给 DualThrust 找一个“稳健版主样本”,当前最优是 `DualThrustRegime_r20_k035_035_reg120`。
+
+---
+
+### 3. MomentumVolTarget 家族阶段结论
+
+#### 收益主版本:**MomentumVolTargetStrategy (`target_vol=0.30`)**
+- 年化:**11.95%**
+- Sharpe:**0.419**
+- Max DD:**37.62%**
+
+#### 均衡主版本:**MVT_reg150_tv029**
+- 年化:**11.51%**
+- Sharpe:**0.417**
+- Max DD:**36.53%**
+
+近邻验证(0.26 / 0.27 / 0.28 / 0.29)后可得:
+- `0.30` 仍是收益最高点
+- `0.29` 是更均衡的风险收益版本
+- `0.35` 明显可以降权
+
+**结论:**
+> MVT 家族现在已经明确存在一条甜点带:**`0.29 ~ 0.30`**。
+
+---
+
+### 4. Donchian 家族阶段结论
+
+#### 收益主版本:**DonchianRegimeStrategy**
+- 配置:`breakout=55, exit_period=30, regime=150`
+- 年化:**11.98%**
+- Sharpe:**0.440**
+- Max DD:**40.47%**
+
+#### 防守主版本:**DonchianAtrTrailStrategy**
+- 配置:`breakout=55, exit_period=30, atr_mult=4.0`
+- 年化:**10.75%**
+- Sharpe:**0.327**
+- Max DD:**27.09%**
+
+#### 均衡主版本:**DonchianHybrid_b55_e30_tv025_atr4**
+- 配置:`breakout=55, exit_period=30, target_vol=0.25, atr_mult=4.0`
+- 年化:**10.11%**
+- Sharpe:**0.380**
+- Max DD:**26.43%**
+
+近邻验证后可得:
+- `DonchianRegime 55/30/150` 仍是 regime 主版
+- `DonchianAtrTrail atr=4.0` 明显优于 `atr=3.0`
+- `DonchianHybrid tv=0.25` 明显优于原 `tv=0.30`
+
+**结论:**
+> Donchian 家族已经分化出三条清楚路线:**收益版 / 防守版 / 均衡版**。
+
+---
+
+## 三、正式 Top 5 单策略候选池
+
+基于收益、Sharpe、回撤、家族代表性和去冗余之后,本阶段正式 Top 5 为:
+
+1. **DualThrustBasicStrategy**
+2. **MomentumVolTargetStrategy (`tv=0.30`)**
+3. **MVT_reg150_tv029**
+4. **DonchianRegimeStrategy**
+5. **DonchianHybrid_b55_e30_tv025_atr4**
+
+### Top 5 的结构含义
+- **DualThrust**:最强进攻主线
+- **MVT 0.30**:波动目标型进攻主线
+- **MVT 0.29**:MVT 的均衡版
+- **DonchianRegime**:breakout 收益主线
+- **DonchianHybrid 0.25**:breakout 均衡版
+
+这说明当前真正值得保留的,不是“很多策略”,而是**几条清楚的主线代表**。
+
+---
+
+## 四、组合试验阶段结论
+
+### 第一轮组合原型
+
+#### Core3ComboStrategy
+- 组合:`DualThrustBasic + DonchianRegime + MVT 0.30`
+- 年化:**12.98%**
+- Sharpe:**0.454**
+- Max DD:**42.77%**
+- 定位:偏进攻
+
+#### Balanced3ComboStrategy
+- 组合:`DualThrustRegime_r20_k035_035_reg120 + MVT 0.29 + DonchianHybrid 0.25`
+- 年化:**13.08%**
+- Sharpe:**0.485**
+- Max DD:**28.27%**
+- 定位:**稳健组合主候选**
+
+#### Top5BlendStrategy
+- 组合:Top 5 混合
+- 年化:**12.68%**
+- Sharpe:**0.460**
+- Max DD:**38.08%**
+
+**结论:**
+> 不是塞得越多越好。第一轮组合里,最优的是 `Balanced3ComboStrategy`。
+
+---
+
+### 第二轮组合近邻
+
+#### Balanced3AggressiveComboStrategy
+- 年化:**12.92%**
+- Sharpe:**0.452**
+- Max DD:**38.02%**
+- 结论:更激进,但回撤明显更差
+
+#### Balanced3DefensiveComboStrategy
+- 年化:**10.61%**
+- Sharpe:**0.338**
+- Max DD:**28.73%**
+- 结论:防守没换来足够收益,整体不成立
+
+#### Balanced4ExtendedComboStrategy
+- 年化:**12.69%**
+- Sharpe:**0.455**
+- Max DD:**29.25%**
+- 结论:接近 Balanced3,但没有净增益
+
+**结论:**
+> 第二轮组合近邻没有打掉 `Balanced3ComboStrategy`,因此它仍然是当前最优组合原型。
+
+---
+
+### 第三轮:Balanced3 权重优化
+
+先做了一轮粗调:
+
+#### Balanced3_DT40
+- 权重:`DT 0.40 / MVT 0.30 / HY 0.30`
+- 年化:**13.33%**
+- Sharpe:**0.488**
+- Max DD:**27.64%**
+- 结论:第一次明确优于均分版本,说明 `DT` 权重应该抬高
+
+#### Balanced3_MVT40
+- 年化:**13.07%**
+- Sharpe:**0.480**
+- Max DD:**29.11%**
+- 结论:给 MVT 更高权重没有带来提升
+
+#### Balanced3_HY40
+- 年化:**12.92%**
+- Sharpe:**0.481**
+- Max DD:**27.80%**
+- 结论:Hybrid 更像稳定器,而不是主引擎
+
+随后做第二轮微调:
+
+#### Balanced3_DT50_MVT25_HY25
+- 年化:**13.52%**
+- Sharpe:**0.489**
+- Max DD:**27.17%**
+- 结论:继续优于 `DT40`
+
+最终又做了最后一轮微调:
+
+#### **Balanced3_DT60_MVT20_HY20**
+- 权重:`DT 0.60 / MVT 0.20 / HY 0.20`
+- 年化:**13.90%**
+- Sharpe:**0.497**
+- Max DD:**26.36%**
+- 结论:当前组合层最优结果
+
+**组合层最终结论:**
+> 组合最优版本已经从“均分的 `Balanced3ComboStrategy` 原型”升级为“**以 `DualThrustRegime` 为核心引擎的加权版**”。
+>
+> 当前最佳组合候选是:
+>
+> **`Balanced3_DT60_MVT20_HY20`**
+>
+> 其结构可以理解为:
+> - `DualThrustRegime_r20_k035_035_reg120` 负责主收益引擎
+> - `MVT_reg150_tv029` 负责波动目标型平滑
+> - `DonchianHybrid_b55_e30_tv025_atr4` 负责 breakout 风控补充
+
+---
+
+## 五、最优组合子区间复核
+
+对 `Balanced3ComboStrategy` 做了 3 段时间窗复核:
+
+### 2014-06 ~ 2018-12
+- 年化:**7.90%**
+- Sharpe:**0.275**
+- Max DD:**28.27%**
+
+### 2019-01 ~ 2022-12
+- 年化:**16.22%**
+- Sharpe:**0.705**
+- Max DD:**15.12%**
+
+### 2023-01 ~ 2026-04
+- 年化:**13.32%**
+- Sharpe:**0.605**
+- Max DD:**12.80%**
+
+**解释:**
+- 早期段不算炸裂,但没崩
+- 近两段表现很强,且回撤非常低
+
+**结论:**
+> `Balanced3ComboStrategy` 不是全样本拼出来的假稳健,而是当前阶段**最可信的稳健组合候选**。
+
+---
+
+## 六、本阶段最终结论
+
+### 结论 1:单策略龙头已经明确
+**DualThrustBasicStrategy** 是当前最强单策略。
+
+### 结论 2:稳健单策略代表也已经明确
+**DualThrustRegime_r20_k035_035_reg120** 是当前最稳的 DualThrust 版本。
+
+### 结论 3:MVT 家族的甜点带已经验证出来
+`target_vol=0.29 ~ 0.30` 是当前最有效区间。
+
+### 结论 4:Donchian 家族已经完成结构分层
+- 收益主线:`DonchianRegimeStrategy`
+- 防守主线:`DonchianAtrTrailStrategy`
+- 均衡主线:`DonchianHybrid_b55_e30_tv025_atr4`
+
+### 结论 5:组合层面的最优候选已经升级
+- 原型阶段最优:**Balanced3ComboStrategy**
+- 权重优化后最优:**Balanced3_DT60_MVT20_HY20**
+
+其中,`Balanced3_DT60_MVT20_HY20` 是当前阶段的**最优稳健组合候选**。
+---
+
+## 七、阶段收官建议
+
+如果现在就要给出一版“本阶段最值得带走的结论”,我会保留这 4 个:
+
+### 单策略进攻龙头
+- **DualThrustBasicStrategy**
+
+### 单策略稳健代表
+- **DualThrustRegime_r20_k035_035_reg120**
+
+### 单策略均衡代表
+- **DonchianHybrid_b55_e30_tv025_atr4**
+  或者在动量侧保留 **MVT_reg150_tv029** 作为替代均衡代表
+
+### 组合层最优候选
+- **Balanced3_DT60_MVT20_HY20**
+
+如果只允许保留 **一个单策略 + 一个组合**,那么当前最合理的就是:
+
+1. **DualThrustBasicStrategy**
+2. **Balanced3_DT60_MVT20_HY20**
+---
+
+## 八、下一阶段可选方向
+
+### 方向 A:正式停在这里
+- 当前结论已经足够清楚
+- 可把本阶段当作第一轮策略筛选完成
+
+### 方向 B:进入更严格的稳健性检验
+- 滚动窗口
+- 参数稳定区间热图
+- 更多市场阶段切片
+
+### 方向 C:进入组合细化
+- 不是继续盲加策略,而是对 `Balanced3ComboStrategy` 做权重与门控优化
+
+按当前证据,**如果继续,优先级应该是 `C > B > A`。**

+ 208 - 0
backtrader-lab/STRATEGY_LAYERING.md

@@ -0,0 +1,208 @@
+# Strategy Layering Snapshot
+
+更新时间:2026-04-13 16:31
+
+用途:
+- 同步当前 `backtrader-lab` 样本池阶段分层
+- 服务于“先铺样本、不中途提前收敛”的主线
+- 与 `strategy_pool.csv` 保持对应
+
+当前范围:
+- **现成可跑主样本**:47 个
+- **近期同步补跑近邻配置**:16 个
+- **当前已纳入比较的总样本**:63 个
+
+---
+
+## 一、主观察池(当前最值得继续跟的)
+
+### A. 第一层:当前龙头 / 家族主代表
+
+1. **DualThrustBasicStrategy**
+   - `range_period=20, k1=0.3, k2=0.3`
+   - 当前样本池 **收益 + Sharpe 双第一**
+   - 两轮近邻验证后仍未被打掉,已经从“最强单点”升级为“局部稳定优势”
+
+2. **DualThrustRegimeStrategy**
+   - 原版:`range_period=20, k1=0.3, k2=0.3, regime=120`
+   - 仍是强稳健样本,说明 DualThrust 加过滤后也有稳定价值
+
+3. **DualThrustRegime_r20_k035_035_reg120**
+   - 新稳健近邻
+   - 相比原 regime 版:**Sharpe 更高、回撤更低**
+   - 可视作当前 DualThrustRegime 的升级候选
+
+4. **DonchianRegimeStrategy**
+   - `55/30/150` 近邻验证后仍是 DonchianRegime 主版
+   - breakout + regime 过滤这条线继续成立
+
+5. **MomentumVolTargetStrategy**
+   - `target_vol=0.30`
+   - 当前仍是 MVT 家族的**收益主版本**
+
+6. **MVT_reg150_tv029**
+   - `target_vol=0.29`
+   - 相比 `0.30`:收益只小一档,但回撤更低
+   - 当前是 MVT 家族的**均衡版主候选**
+
+7. **MomentumVolTargetBasicStrategy**
+   - 去掉 regime 仍然很强
+   - 说明 vol targeting 本身就有独立价值
+
+8. **MomentumVolTargetLowerTargetStrategy**
+   - `target_vol=0.25`
+   - Sharpe 很强,是 MVT 家族保守版本的重要锚点
+
+9. **MomentumRegimeStrategy**
+   - 动量主线标准版
+   - 仍是动量家族主观察样本
+
+10. **MomentumAtrTrailStrategy**
+    - ATR 退出主版本
+    - 代表动量家族另一条有效风控路径
+
+11. **DonchianAtrTrailStrategy**
+    - Donchian 家族中**最防守**的主版本
+    - `atr=4.0` 已被近邻验证保住
+
+12. **DonchianHybrid_b55_e30_tv025_atr4**
+    - 新均衡升级版
+    - 相比原 `tv=0.30` Hybrid:**年化更高、Sharpe 更高、回撤更低**
+
+13. **MacdLongMaStrategy**
+    - 经典趋势对照样本
+    - 过滤层价值明确,适合作为非 breakout / 非 DualThrust 的传统趋势参考
+
+14. **SuperTrendLongMaStrategy**
+    - 低回撤趋势样本
+    - 防守属性明确,仍值得保留
+
+### B. 第二层:强近邻 / 强对照样本
+
+15. **DualThrustSlowStrategy**
+    - 仍然很强,说明 DualThrust 不是单一参数命中
+    - 但回撤偏高,放在第二层观察
+
+16. **DualThrust_r22_k030**
+    - 说明 `range_period` 在 20 左右有稳定优势带
+    - 仍弱于主版,但足够有信息量
+
+17. **DualThrust_r20_k035**
+    - 说明 `k=0.35` 也有竞争力
+    - 是主版右侧近邻的有效补样本
+
+18. **DualThrust_r22_k035_035**
+    - 稳健但收益不如主版
+    - 可作为第二梯队稳健样本
+
+19. **MomentumBasicStrategy**
+    - 基础双动量仍有收益能力
+    - 很适合做动量家族对照样本
+
+20. **MomentumAtrTrailLooserStrategy**
+    - ATR 放松后收益恢复
+    - 有信息量,但整体不如主版均衡
+
+21. **MomentumAtrTrailBasicStrategy**
+    - 去掉 regime 后仍有竞争力
+    - 说明 ATR exit 本身也有独立价值
+
+22. **DonchianBasicStrategy**
+    - breakout 基础逻辑本身有效
+    - 但比 regime / atr / hybrid 版本更粗糙
+
+23. **DonchianVolTargetStrategy**
+    - 有一定价值
+    - 但目前不如 Hybrid 新版更均衡
+
+24. **DonchianAdxStrategy**
+    - breakout 变体
+    - 信息量有,但还没进入核心层
+
+25. **TrendRegimeFlatStrategy**
+    - 趋势/状态模型代表样本
+    - 中等偏上,继续留在观察层
+
+26. **MacdBasicStrategy**
+    - 基础版也能盈利
+    - 但交易拥挤、暴露和回撤恶化明显
+
+27. **TsmomRegimeStrategy**
+    - 时间序列动量主代表
+    - 当前偏弱,但先不彻底放弃
+
+28. **Chinext50RegimeStrategy**
+    - 独立脚本里唯一稍有信息量的样本
+
+---
+
+## 二、低优先级 / 负向验证样本
+
+### 1. 已明确不优的 DualThrust 近邻
+- **DualThrust_r20_k025**:阈值放松后明显劣化
+- **DualThrust_r20_k030_035**:收益还行,但回撤炸裂
+- **DualThrust_r20_k035_030**:较均衡,但不如主版
+- **DualThrust_r18_k030**:过快 `range_period` 明显变差
+- **DualThrustFastStrategy**:同样验证“过快会劣化”
+
+### 2. MVT 家族低优先级
+- **MomentumVolTargetHigherTargetStrategy**:更高 `target_vol` 不划算
+- **MVT_reg150_tv026 / tv027 / tv028**:都没能打掉 `0.29~0.30` 主区间,可留档但不升格
+- **MomentumMaFilterStrategy**:简单 MA filter 不如当前动量主线
+- **MomentumAtrTrailTighterStrategy**:更紧 ATR 明显压缩收益
+- **MomentumDefensiveFilterStrategy**:防守过度,收益损失太大
+
+### 3. Donchian 家族低优先级
+- **DonchianRegime_b50_e25_r150**:弱于 `55/30/150` 主版
+- **DonchianRegime_b60_e30_r150**:更慢 breakout 明显弱化
+- **DonchianAtrTrail_b55_e30_atr3**:收紧 ATR 明显变差
+- **DonchianRegimeFastStrategy / SlowStrategy**:仍有信息量,但不是当前优先方向
+
+### 4. Trend / SuperTrend / MACD / KAMA / TSMOM 负向验证
+- **TrendTightVolStrategy**:vol cap 过紧后近乎失效
+- **TrendLooseVolStrategy**:放松 vol cap 未改善收益,回撤恶化
+- **SmaLongFilterTrendStrategy**:去掉 vol cap 后明显更差
+- **SuperTrendLongMaFastRegimeStrategy**:缩短 regime 后劣化
+- **SuperTrendBasicStrategy**:去掉长周期过滤后交易过密、回撤抬升
+- **MacdLongMaFastRegimeStrategy**:缩短 regime 后弱于主版
+- **KamaTrendStrategy / KamaBasicStrategy**:整体偏弱
+- **TsmomBasicStrategy / TsmomLooseThresholdStrategy**:当前都不强
+
+### 5. 独立脚本弱样本
+- **SmaCrossStrategy**:极弱 baseline
+- **FinalRegimeStrategy / SimpleRegimeStrategy / RegimeStrategyV2 / WorkingRegimeStrategy**:0 交易 / 0 收益或近似无效
+- **RegimeAwareStrategy**:修复后仍无效
+- **Chinext50Strategy**:基本无有效结果
+
+---
+
+## 三、当前阶段性排序(简版)
+
+### 收益主线 Top 5
+1. DualThrustBasicStrategy
+2. DualThrustRegimeStrategy
+3. DualThrustRegime_r20_k035_035_reg120
+4. DonchianRegimeStrategy
+5. MomentumVolTargetStrategy
+
+### 稳健 / 均衡代表 Top 5
+1. DualThrustRegime_r20_k035_035_reg120
+2. DonchianHybrid_b55_e30_tv025_atr4
+3. DonchianAtrTrailStrategy
+4. MVT_reg150_tv029
+5. SuperTrendLongMaStrategy
+
+### 当前最清楚的阶段性结论
+- **DualThrust**:已经验证出“主版最强 + regime 稳健升级版”
+- **MVT**:已经验证出“`0.30` 进攻版 + `0.29` 均衡版”
+- **Donchian**:已经验证出“Regime 主收益版 + AtrTrail 主防守版 + Hybrid `tv=0.25` 新均衡版”
+
+---
+
+## 四、接下来该做什么
+
+如果继续主线,最合理的方向不是再在同一家族无限钻,而是:
+1. 继续补 **还可能改变横向结论的近邻样本**
+2. 或者进入一次更正式的 **阶段收束 / shortlist 提炼**
+
+按当前证据,已经足够支持一次更正式的阶段收束。

+ 125 - 0
backtrader-lab/backtest.py

@@ -0,0 +1,125 @@
+import backtrader as bt
+import pandas as pd
+from datetime import datetime
+
+class SmaCrossStrategy(bt.Strategy):
+    """双均线交叉策略 - 创业板50示例"""
+    params = (
+        ('fast', 20),
+        ('slow', 60),
+        ('printlog', False),
+    )
+    
+    def __init__(self):
+        self.dataclose = self.datas[0].close
+        self.order = None
+        self.buyprice = None
+        self.buycomm = None
+        
+        # 双均线
+        self.sma_fast = bt.indicators.SimpleMovingAverage(
+            self.datas[0], period=self.params.fast)
+        self.sma_slow = bt.indicators.SimpleMovingAverage(
+            self.datas[0], period=self.params.slow)
+        
+        # 交叉信号
+        self.crossover = bt.indicators.CrossOver(self.sma_fast, self.sma_slow)
+        
+    def notify_order(self, order):
+        if order.status in [order.Submitted, order.Accepted]:
+            return
+            
+        if order.status in [order.Completed]:
+            if order.isbuy():
+                if self.params.printlog:
+                    self.log(f'BUY EXECUTED, Price: {order.executed.price:.2f}, '
+                            f'Cost: {order.executed.value:.2f}, '
+                            f'Comm: {order.executed.comm:.2f}')
+                self.buyprice = order.executed.price
+                self.buycomm = order.executed.comm
+            else:
+                if self.params.printlog:
+                    self.log(f'SELL EXECUTED, Price: {order.executed.price:.2f}, '
+                            f'Cost: {order.executed.value:.2f}, '
+                            f'Comm: {order.executed.comm:.2f}')
+        elif order.status in [order.Canceled, order.Margin, order.Rejected]:
+            if self.params.printlog:
+                self.log('Order Canceled/Margin/Rejected')
+                
+        self.order = None
+        
+    def notify_trade(self, trade):
+        if not trade.isclosed:
+            return
+        if self.params.printlog:
+            self.log(f'OPERATION PROFIT, GROSS: {trade.pnl:.2f}, NET: {trade.pnlcomm:.2f}')
+            
+    def next(self):
+        if self.order:
+            return
+            
+        # 金叉买入
+        if self.crossover > 0:
+            if not self.position:
+                self.order = self.buy()
+                if self.params.printlog:
+                    self.log(f'BUY CREATE, {self.dataclose[0]:.2f}')
+        # 死叉卖出
+        elif self.crossover < 0:
+            if self.position:
+                self.order = self.sell()
+                if self.params.printlog:
+                    self.log(f'SELL CREATE, {self.dataclose[0]:.2f}')
+                    
+    def log(self, txt, dt=None):
+        dt = dt or self.datas[0].datetime.date(0)
+        print(f'{dt.isoformat()} {txt}')
+        
+    def stop(self):
+        # 最终收益
+        roi = (self.broker.getvalue() / self.broker.startingcash - 1) * 100
+        print(f'\n=== 最终收益: {roi:.2f}% ===')
+        print(f'初始资金: {self.broker.startingcash:.2f}')
+        print(f'最终资金: {self.broker.getvalue():.2f}')
+
+
+def run_backtest(csv_file="chinext50.csv", cash=100000.0, commission=0.001):
+    """运行回测"""
+    cerebro = bt.Cerebro()
+    
+    # 数据
+    df = pd.read_csv(csv_file, parse_dates=['datetime'], index_col='datetime')
+    data = bt.feeds.PandasData(dataname=df)
+    cerebro.adddata(data)
+    
+    # 策略
+    cerebro.addstrategy(SmaCrossStrategy, fast=20, slow=60, printlog=True)
+    
+    # 资金与手续费
+    cerebro.broker.setcash(cash)
+    cerebro.broker.setcommission(commission=commission)
+    
+    # 添加分析器
+    cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name='sharpe', riskfreerate=0.02)
+    cerebro.addanalyzer(bt.analyzers.DrawDown, _name='drawdown')
+    cerebro.addanalyzer(bt.analyzers.Returns, _name='returns')
+    
+    print(f'初始资金: {cerebro.broker.getvalue():.2f}')
+    
+    # 运行
+    results = cerebro.run()
+    strat = results[0]
+    
+    # 输出指标
+    print(f'\n=== 回测指标 ===')
+    print(f"年化收益: {strat.analyzers.returns.get_analysis()['rnorm100']:.2f}%")
+    print(f"夏普比率: {strat.analyzers.sharpe.get_analysis()['sharperatio']:.3f}")
+    print(f"最大回撤: {strat.analyzers.drawdown.get_analysis()['max']['drawdown']:.2f}%")
+    
+    return cerebro, strat
+
+
+if __name__ == "__main__":
+    # 先运行 fetch_data.py 获取数据
+    cerebro, strat = run_backtest()
+    # cerebro.plot()  # 如需图表,取消注释

+ 7 - 0
backtrader-lab/balanced3_subperiod_review_20260413-171654.md

@@ -0,0 +1,7 @@
+# Balanced3ComboStrategy Subperiod Review
+
+| Period | Bars | Annual Return | Sharpe | Max DD | Closed Trades | Win Rate | Avg Exposure |
+| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
+| 2014-06_to_2018-12 | 1110 | 7.90% | 0.275 | 28.27% | 18 | 11.11% | 10.37% |
+| 2019-01_to_2022-12 | 972 | 16.22% | 0.705 | 15.12% | 19 | 47.37% | 34.92% |
+| 2023-01_to_2026-04 | 786 | 13.32% | 0.605 | 12.80% | 18 | 33.33% | 21.28% |

+ 12 - 0
backtrader-lab/balanced3_weight_batch1_20260413-180259.md

@@ -0,0 +1,12 @@
+# Balanced3 Weight Optimization Batch 1
+
+## Baseline
+- Balanced3ComboStrategy: annual `13.08%`, Sharpe `0.485`, max DD `28.27%`
+
+## Results
+
+| Variant | Annual Return | Sharpe | Max DD | Closed Trades | Win Rate | Avg Exposure |
+| --- | ---: | ---: | ---: | ---: | ---: | ---: |
+| Balanced3_DT40 | 13.33% | 0.488 | 27.64% | 61 | 31.15% | 24.06% |
+| Balanced3_MVT40 | 13.07% | 0.48 | 29.11% | 61 | 32.79% | 23.66% |
+| Balanced3_HY40 | 12.92% | 0.481 | 27.80% | 61 | 32.79% | 22.93% |

+ 12 - 0
backtrader-lab/balanced3_weight_batch2_20260413-183237.md

@@ -0,0 +1,12 @@
+# Balanced3 Weight Optimization Batch 2
+
+## Baseline
+- Balanced3_DT40: annual `13.33%`, Sharpe `0.488`, max DD `27.64%`
+
+## Results
+
+| Variant | Annual Return | Sharpe | Max DD | Closed Trades | Win Rate | Avg Exposure |
+| --- | ---: | ---: | ---: | ---: | ---: | ---: |
+| Balanced3_DT45_MVT30_HY25 | 13.44% | 0.486 | 27.66% | 61 | 31.15% | 24.64% |
+| Balanced3_DT50_MVT25_HY25 | 13.52% | 0.489 | 27.17% | 61 | 31.15% | 24.88% |
+| Balanced3_DT40_MVT35_HY25 | 13.13% | 0.48 | 28.56% | 61 | 32.79% | 24.44% |

+ 12 - 0
backtrader-lab/balanced3_weight_batch3_20260413-183444.md

@@ -0,0 +1,12 @@
+# Balanced3 Weight Optimization Batch 3
+
+## Baseline
+- Balanced3_DT50_MVT25_HY25: annual `13.52%`, Sharpe `0.489`, max DD `27.17%`
+
+## Results
+
+| Variant | Annual Return | Sharpe | Max DD | Closed Trades | Win Rate | Avg Exposure |
+| --- | ---: | ---: | ---: | ---: | ---: | ---: |
+| Balanced3_DT55_MVT225_HY225 | 13.59% | 0.493 | 27.08% | 61 | 29.51% | 25.29% |
+| Balanced3_DT60_MVT20_HY20 | 13.90% | 0.497 | 26.36% | 61 | 29.51% | 25.68% |
+| Balanced3_DT50_MVT30_HY20 | 13.66% | 0.491 | 27.41% | 61 | 31.15% | 25.24% |

+ 55 - 0
backtrader-lab/chinext50_dualthrust_optimization.md

@@ -0,0 +1,55 @@
+# DualThrust Chinext50 Optimization
+
+- Data: `chinext50.csv` (2014-06-18 to 2026-04-03, 2868 bars)
+- Initial cash: `100000`
+- Commission: `0.0010`
+- Strategy: `DualThrustRegimeStrategy`
+- Search grid: `range_period in [10, 15, 20, 25, 30]`, `k1/k2 in [0.2, 0.3, 0.4, 0.5]`, `regime in [60, 90, 120, 150, 200]`
+- Evaluated configs: `400`
+
+## Command
+
+- `python3 chinext50_experiments.py --optimize-dualthrust`
+
+## Default Benchmark
+
+- Current default: `range_period=20, k1=0.3, k2=0.3, regime=120` | annual return `15.02%` | Sharpe `0.405` | max DD `34.04%`
+
+## Required Winners
+
+- Best by annual return: `range_period=20, k1=0.4, k2=0.4, regime=120` | annual return `17.68%` | Sharpe `0.499` | max DD `26.42%` | trades `18`
+- Best by Sharpe: `range_period=30, k1=0.3, k2=0.3, regime=120` | annual return `14.83%` | Sharpe `0.52` | max DD `34.96%` | trades `19`
+- Best with max DD <= 30% (Sharpe-ranked): `range_period=25, k1=0.4, k2=0.2, regime=120` | annual return `12.52%` | Sharpe `0.512` | max DD `9.02%` | trades `15`
+- Best with max DD <= 35% (Sharpe-ranked): `range_period=30, k1=0.3, k2=0.3, regime=120` | annual return `14.83%` | Sharpe `0.52` | max DD `34.96%` | trades `19`
+
+## Top Candidates By Annual Return
+
+| Params | Annual Return | Sharpe | Max DD | Trades | Exposure |
+| --- | ---: | ---: | ---: | ---: | ---: |
+| `range_period=20`, `k1=0.4`, `k2=0.4`, `regime=120` | 17.68% | 0.499 | 26.42% | 18 | 25.33% |
+| `range_period=25`, `k1=0.3`, `k2=0.3`, `regime=120` | 15.73% | 0.456 | 30.23% | 26 | 27.86% |
+| `range_period=20`, `k1=0.3`, `k2=0.4`, `regime=120` | 15.60% | 0.439 | 33.27% | 31 | 31.34% |
+| `range_period=25`, `k1=0.4`, `k2=0.3`, `regime=120` | 15.47% | 0.46 | 23.74% | 14 | 19.03% |
+| `range_period=30`, `k1=0.3`, `k2=0.5`, `regime=120` | 15.44% | 0.489 | 44.53% | 13 | 32.07% |
+| `range_period=25`, `k1=0.4`, `k2=0.3`, `regime=60` | 15.40% | 0.451 | 28.73% | 14 | 17.94% |
+| `range_period=20`, `k1=0.3`, `k2=0.3`, `regime=120` | 15.02% | 0.405 | 34.04% | 36 | 26.24% |
+| `range_period=30`, `k1=0.3`, `k2=0.3`, `regime=120` | 14.83% | 0.52 | 34.96% | 19 | 27.56% |
+
+## Top Candidates By Sharpe
+
+| Params | Sharpe | Annual Return | Max DD | Trades | Exposure |
+| --- | ---: | ---: | ---: | ---: | ---: |
+| `range_period=30`, `k1=0.3`, `k2=0.3`, `regime=120` | 0.52 | 14.83% | 34.96% | 19 | 27.56% |
+| `range_period=30`, `k1=0.4`, `k2=0.3`, `regime=90` | 0.516 | 14.04% | 35.52% | 9 | 19.34% |
+| `range_period=25`, `k1=0.4`, `k2=0.2`, `regime=120` | 0.512 | 12.52% | 9.02% | 15 | 10.02% |
+| `range_period=20`, `k1=0.4`, `k2=0.4`, `regime=120` | 0.499 | 17.68% | 26.42% | 18 | 25.33% |
+| `range_period=20`, `k1=0.3`, `k2=0.4`, `regime=150` | 0.497 | 13.49% | 30.04% | 28 | 30.26% |
+| `range_period=20`, `k1=0.4`, `k2=0.4`, `regime=150` | 0.491 | 12.71% | 26.70% | 19 | 23.81% |
+| `range_period=30`, `k1=0.3`, `k2=0.5`, `regime=120` | 0.489 | 15.44% | 44.53% | 13 | 32.07% |
+| `range_period=30`, `k1=0.4`, `k2=0.4`, `regime=120` | 0.474 | 11.44% | 43.75% | 9 | 24.47% |
+
+## Recommendation
+
+- Recommended next parameter set: `range_period=30, k1=0.3, k2=0.3, regime=120` because it keeps max DD at `34.96%` while delivering `14.83%` annual return and Sharpe `0.52`.
+- Optimized DualThrust beat default on annual return: `yes` (17.68% vs 15.02%).
+- Optimized DualThrust beat default on Sharpe: `yes` (0.52 vs 0.405).

+ 104 - 0
backtrader-lab/chinext50_experiment_summary.md

@@ -0,0 +1,104 @@
+# Chinext50 Backtrader Experiments
+
+- Data: `chinext50.csv` (2014-06-18 to 2026-04-03, 2868 bars)
+- Initial cash: `100000`
+- Commission: `0.0010`
+
+## Commands
+
+- Run all experiments: `python3 chinext50_experiments.py`
+
+## Configs
+
+- **TrendRegimeFlatStrategy**: `fast=20`, `slow=60`, `regime=120`, `vol_fast=20`, `vol_slow=60`, `vol_cap=1.1`
+- **TrendTightVolStrategy**: `fast=20`, `slow=60`, `regime=120`, `vol_fast=20`, `vol_slow=60`, `vol_cap=0.95`
+- **TrendLooseVolStrategy**: `fast=20`, `slow=60`, `regime=120`, `vol_fast=20`, `vol_slow=60`, `vol_cap=1.25`
+- **SmaLongFilterTrendStrategy**: `fast=20`, `slow=60`, `regime=120`
+- **MomentumBasicStrategy**: `mom_short=20`, `mom_long=120`
+- **MomentumVolTargetBasicStrategy**: `mom_short=20`, `mom_long=120`, `vol_period=30`, `target_vol=0.3`, `max_weight=1.0`, `rebalance_band=0.15`
+- **MomentumVolTargetLowerTargetStrategy**: `mom_short=20`, `mom_long=120`, `regime=150`, `vol_period=30`, `target_vol=0.25`, `max_weight=1.0`, `rebalance_band=0.15`
+- **MomentumVolTargetHigherTargetStrategy**: `mom_short=20`, `mom_long=120`, `regime=150`, `vol_period=30`, `target_vol=0.35`, `max_weight=1.0`, `rebalance_band=0.15`
+- **MomentumRegimeStrategy**: `mom_short=20`, `mom_long=120`, `regime=150`
+- **MomentumMaFilterStrategy**: `mom_short=20`, `mom_long=120`, `ma_filter=60`
+- **MomentumAtrTrailBasicStrategy**: `mom_short=20`, `mom_long=120`, `atr_period=20`, `atr_mult=4.0`
+- **MomentumAtrTrailTighterStrategy**: `mom_short=20`, `mom_long=120`, `regime=150`, `atr_period=20`, `atr_mult=3.0`
+- **MomentumAtrTrailLooserStrategy**: `mom_short=20`, `mom_long=120`, `regime=150`, `atr_period=20`, `atr_mult=5.0`
+- **MomentumAtrTrailStrategy**: `mom_short=20`, `mom_long=120`, `regime=150`, `atr_period=20`, `atr_mult=4.0`
+- **MomentumDefensiveFilterStrategy**: `mom_short=20`, `mom_long=120`, `regime=150`, `vol_fast=20`, `vol_slow=60`, `vol_cap=1.05`
+- **DonchianRegimeStrategy**: `breakout=55`, `exit_period=30`, `regime=150`
+- **DonchianRegimeFastStrategy**: `breakout=40`, `exit_period=20`, `regime=150`
+- **DonchianRegimeSlowStrategy**: `breakout=70`, `exit_period=35`, `regime=150`
+- **MomentumVolTargetStrategy**: `mom_short=20`, `mom_long=120`, `regime=150`, `vol_period=30`, `target_vol=0.3`, `max_weight=1.0`, `rebalance_band=0.15`
+- **DonchianBasicStrategy**: `breakout=55`, `exit_period=30`
+- **DonchianAdxStrategy**: `breakout=55`, `exit_period=30`, `adx_period=14`, `adx_threshold=20.0`
+- **DonchianAtrTrailStrategy**: `breakout=55`, `exit_period=30`, `atr_period=20`, `atr_mult=4.0`
+- **DonchianVolTargetStrategy**: `breakout=55`, `exit_period=30`, `vol_period=30`, `target_vol=0.3`, `max_weight=1.0`, `rebalance_band=0.15`
+- **DonchianHybridVolAtrStrategy**: `breakout=55`, `exit_period=30`, `vol_period=30`, `target_vol=0.3`, `max_weight=1.0`, `rebalance_band=0.15`, `atr_period=20`, `atr_mult=4.0`
+- **SuperTrendLongMaStrategy**: `supertrend_period=14`, `supertrend_mult=2.0`, `regime=200`
+- **SuperTrendLongMaFastRegimeStrategy**: `supertrend_period=14`, `supertrend_mult=2.0`, `regime=150`
+- **SuperTrendBasicStrategy**: `supertrend_period=14`, `supertrend_mult=2.0`
+- **TsmomLooseThresholdStrategy**: `mom_windows=(60, 120, 240)`, `positive_threshold=1`, `regime=200`
+- **TsmomBasicStrategy**: `mom_windows=(60, 120)`, `positive_threshold=1`
+- **TsmomRegimeStrategy**: `mom_windows=(60, 120)`, `positive_threshold=1`, `regime=200`
+- **KamaBasicStrategy**: `kama_period=30`, `kama_fast=2`, `kama_slow=30`
+- **KamaTrendStrategy**: `kama_period=30`, `kama_fast=2`, `kama_slow=30`, `regime=120`
+- **DualThrustBasicStrategy**: `range_period=20`, `k1=0.3`, `k2=0.3`
+- **DualThrustFastStrategy**: `range_period=15`, `k1=0.3`, `k2=0.3`
+- **DualThrustSlowStrategy**: `range_period=30`, `k1=0.3`, `k2=0.3`
+- **DualThrustRegimeStrategy**: `range_period=20`, `k1=0.3`, `k2=0.3`, `regime=120`
+- **MacdBasicStrategy**: `macd_fast=12`, `macd_slow=26`, `macd_signal=9`
+- **MacdLongMaFastRegimeStrategy**: `macd_fast=12`, `macd_slow=26`, `macd_signal=9`, `regime=150`
+- **MacdLongMaStrategy**: `macd_fast=12`, `macd_slow=26`, `macd_signal=9`, `regime=120`
+
+## Metrics
+
+| Strategy | Final Value | Total Return | Annual Return | Sharpe | Max DD | Entries | Closed Trades | Win Rate | Avg Exposure |
+| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
+| TrendRegimeFlatStrategy | 200749.92 | 100.75% | 6.31% | 0.251 | 40.36% | 48 | 48 | 39.58% | 26.80% |
+| TrendTightVolStrategy | 105167.73 | 5.17% | 0.44% | -0.04 | 36.07% | 49 | 49 | 48.98% | 17.84% |
+| TrendLooseVolStrategy | 180294.34 | 80.29% | 5.32% | 0.213 | 57.20% | 43 | 43 | 30.23% | 33.79% |
+| SmaLongFilterTrendStrategy | 191020.05 | 91.02% | 5.85% | 0.231 | 60.73% | 36 | 36 | 30.56% | 37.97% |
+| MomentumBasicStrategy | 302900.87 | 202.90% | 10.23% | 0.315 | 37.29% | 85 | 85 | 35.29% | 27.89% |
+| MomentumVolTargetBasicStrategy | 347255.98 | 247.26% | 11.56% | 0.379 | 34.80% | 87 | 85 | 41.18% | 25.83% |
+| MomentumVolTargetLowerTargetStrategy | 331107.17 | 231.11% | 11.09% | 0.415 | 36.55% | 78 | 74 | 36.49% | 22.64% |
+| MomentumVolTargetHigherTargetStrategy | 289653.47 | 189.65% | 9.80% | 0.338 | 43.13% | 73 | 73 | 35.62% | 24.61% |
+| MomentumRegimeStrategy | 331339.70 | 231.34% | 11.10% | 0.376 | 42.25% | 69 | 69 | 36.23% | 25.99% |
+| MomentumMaFilterStrategy | 254309.95 | 154.31% | 8.55% | 0.287 | 44.36% | 73 | 73 | 35.62% | 24.45% |
+| MomentumAtrTrailBasicStrategy | 248913.56 | 148.91% | 8.34% | 0.255 | 35.80% | 97 | 97 | 36.08% | 27.06% |
+| MomentumAtrTrailTighterStrategy | 184844.27 | 84.84% | 5.55% | 0.218 | 45.04% | 94 | 94 | 40.43% | 24.41% |
+| MomentumAtrTrailLooserStrategy | 289373.74 | 189.37% | 9.79% | 0.35 | 45.57% | 75 | 75 | 38.67% | 25.48% |
+| MomentumAtrTrailStrategy | 301242.39 | 201.24% | 10.17% | 0.344 | 41.97% | 80 | 80 | 42.50% | 24.97% |
+| MomentumDefensiveFilterStrategy | 174139.22 | 74.14% | 4.99% | 0.213 | 32.27% | 74 | 74 | 40.54% | 16.74% |
+| DonchianRegimeStrategy | 362600.70 | 262.60% | 11.98% | 0.44 | 40.47% | 20 | 20 | 45.00% | 25.98% |
+| DonchianRegimeFastStrategy | 288816.80 | 188.82% | 9.77% | 0.351 | 36.17% | 29 | 29 | 34.48% | 26.86% |
+| DonchianRegimeSlowStrategy | 278840.33 | 178.84% | 9.43% | 0.353 | 42.29% | 18 | 18 | 33.33% | 28.46% |
+| MomentumVolTargetStrategy | 361493.22 | 261.49% | 11.95% | 0.419 | 37.62% | 74 | 72 | 38.89% | 24.21% |
+| DonchianBasicStrategy | 266380.71 | 166.38% | 8.99% | 0.327 | 53.55% | 19 | 19 | 42.11% | 33.68% |
+| DonchianAdxStrategy | 209771.69 | 109.77% | 6.73% | 0.262 | 47.26% | 16 | 16 | 37.50% | 29.89% |
+| DonchianAtrTrailStrategy | 319679.78 | 219.68% | 10.75% | 0.327 | 27.09% | 32 | 32 | 53.12% | 22.30% |
+| DonchianVolTargetStrategy | 264861.38 | 164.86% | 8.94% | 0.324 | 48.37% | 30 | 20 | 45.00% | 32.56% |
+| DonchianHybridVolAtrStrategy | 286192.79 | 186.19% | 9.68% | 0.328 | 31.30% | 34 | 33 | 57.58% | 20.37% |
+| SuperTrendLongMaStrategy | 243213.49 | 143.21% | 8.12% | 0.283 | 28.83% | 84 | 84 | 45.24% | 24.77% |
+| SuperTrendLongMaFastRegimeStrategy | 182682.80 | 82.68% | 5.44% | 0.203 | 45.77% | 99 | 99 | 36.36% | 27.56% |
+| SuperTrendBasicStrategy | 171373.65 | 71.37% | 4.85% | 0.2 | 60.46% | 164 | 164 | 35.37% | 51.39% |
+| TsmomLooseThresholdStrategy | 105001.15 | 5.00% | 0.43% | 0.067 | 65.87% | 31 | 30 | 23.33% | 40.25% |
+| TsmomBasicStrategy | 177287.00 | 77.29% | 5.16% | 0.205 | 69.79% | 62 | 61 | 42.62% | 56.38% |
+| TsmomRegimeStrategy | 198638.31 | 98.64% | 6.22% | 0.222 | 63.63% | 26 | 25 | 24.00% | 40.59% |
+| KamaBasicStrategy | 136162.13 | 36.16% | 2.75% | 0.164 | 64.11% | 122 | 122 | 33.61% | 45.69% |
+| KamaTrendStrategy | 180932.59 | 80.93% | 5.35% | 0.207 | 48.38% | 89 | 89 | 31.46% | 32.71% |
+| DualThrustBasicStrategy | 739254.80 | 639.25% | 19.22% | 0.501 | 37.27% | 51 | 51 | 43.14% | 40.20% |
+| DualThrustFastStrategy | 187601.28 | 87.60% | 5.68% | 0.218 | 47.36% | 91 | 91 | 35.16% | 38.72% |
+| DualThrustSlowStrategy | 366127.99 | 266.13% | 12.08% | 0.409 | 55.03% | 31 | 31 | 38.71% | 45.50% |
+| DualThrustRegimeStrategy | 491771.44 | 391.77% | 15.02% | 0.405 | 34.04% | 36 | 36 | 47.22% | 26.24% |
+| MacdBasicStrategy | 263245.27 | 163.25% | 8.88% | 0.323 | 57.37% | 105 | 105 | 40.95% | 48.67% |
+| MacdLongMaFastRegimeStrategy | 194952.18 | 94.95% | 6.04% | 0.233 | 48.59% | 74 | 74 | 39.19% | 23.37% |
+| MacdLongMaStrategy | 266977.38 | 166.98% | 9.01% | 0.347 | 39.78% | 72 | 72 | 44.44% | 23.28% |
+
+## Verdict
+
+- Highest annual return: **DualThrustBasicStrategy** (19.22% annual return, 37.27% max DD)
+- Best risk-adjusted balance by Sharpe: **DualThrustBasicStrategy** (Sharpe 0.501, 19.22% annual return, 37.27% max DD)
+- Best Sharpe with max DD <= 35%: **DualThrustRegimeStrategy** (Sharpe 0.405, 15.02% annual return, 34.04% max DD)
+- New-strategy return leader: **DualThrustBasicStrategy** beat the prior return leader **DonchianRegimeStrategy** (19.22% vs 11.98% annual return)
+- New-strategy Sharpe leader: **DualThrustBasicStrategy** beat the prior Sharpe leader **DonchianRegimeStrategy** (Sharpe 0.501 vs 0.44)
+- Most defensive new addition: **DonchianAtrTrailStrategy** delivered the lowest max DD among the new strategies with 27.09% max DD, 10.75% annual return, and 53.12% win rate.

Разлика између датотеке није приказан због своје велике величине
+ 2094 - 0
backtrader-lab/chinext50_experiments.py


+ 34 - 0
backtrader-lab/convert_data.py

@@ -0,0 +1,34 @@
+import pandas as pd
+import sys
+
+# 读取本地 parquet 数据
+df = pd.read_parquet('/home/erwin/.openclaw/workspace/cyb50-quant/index-rotation/data/features/chinext50/daily.parquet')
+
+print(f"原始数据列: {df.columns.tolist()}")
+print(f"数据条数: {len(df)}")
+print(f"日期范围: {df['trade_date'].min()} 至 {df['trade_date'].max()}")
+
+# 转换为 backtrader 格式
+df_bt = df.rename(columns={
+    'trade_date': 'datetime',
+    'close': 'close',
+}).copy()
+
+# backtrader 需要 open/high/low/close/volume
+# 如果原始数据有这些列,直接使用;如果没有,用 close 填充
+if 'open' not in df_bt.columns:
+    df_bt['open'] = df_bt['close']
+if 'high' not in df_bt.columns:
+    df_bt['high'] = df_bt['close']
+if 'low' not in df_bt.columns:
+    df_bt['low'] = df_bt['close']
+if 'volume' not in df_bt.columns:
+    df_bt['volume'] = 0
+
+df_bt['datetime'] = pd.to_datetime(df_bt['datetime'])
+df_bt.set_index('datetime', inplace=True)
+
+# 保存为 CSV
+df_bt.to_csv('/home/erwin/.openclaw/workspace/cyb50-quant/backtrader-lab/chinext50.csv')
+print(f"\n数据已转换并保存到 backtrader-lab/chinext50.csv")
+print(f"共 {len(df_bt)} 条记录")

+ 34 - 0
backtrader-lab/debug_indicators.py

@@ -0,0 +1,34 @@
+"""
+Debug版本 - 检查指标计算
+"""
+
+import backtrader as bt
+import pandas as pd
+import numpy as np
+
+class DebugStrategy(bt.Strategy):
+    params = (('fast', 20), ('slow', 60))
+    
+    def __init__(self):
+        self.dataclose = self.datas[0].close
+        self.sma_fast = bt.indicators.SMA(period=self.p.fast)
+        self.sma_slow = bt.indicators.SMA(period=self.p.slow)
+        self.crossover = bt.indicators.CrossOver(self.sma_fast, self.sma_slow)
+        
+    def next(self):
+        # 每100天打印一次检查
+        if len(self) % 100 == 0:
+            fast_val = self.sma_fast[0]
+            slow_val = self.sma_slow[0]
+            fast_str = f'{fast_val:.2f}' if not np.isnan(fast_val) else 'None'
+            slow_str = f'{slow_val:.2f}' if not np.isnan(slow_val) else 'None'
+            print(f'Day {len(self)}: Close={self.dataclose[0]:.2f}, FastMA={fast_str}, SlowMA={slow_str}, Cross={self.crossover[0]}')
+
+
+if __name__ == "__main__":
+    cerebro = bt.Cerebro()
+    df = pd.read_csv("chinext50.csv", parse_dates=['datetime'], index_col='datetime')
+    data = bt.feeds.PandasData(dataname=df)
+    cerebro.adddata(data)
+    cerebro.addstrategy(DebugStrategy)
+    cerebro.run()

+ 19 - 0
backtrader-lab/donchian_nearby_batch_20260413-162623.md

@@ -0,0 +1,19 @@
+# Donchian Nearby Batch
+
+## Baselines
+- DonchianRegime 55/30/150: annual `11.98%`, Sharpe `0.440`, max DD `40.47%`
+- DonchianAtrTrail 55/30 atr=4.0: annual `10.75%`, Sharpe `0.327`, max DD `27.09%`
+- DonchianHybrid 55/30 tv=0.30 atr=4.0: annual `9.68%`, Sharpe `0.328`, max DD `31.30%`
+
+## Nearby Results
+
+| Variant | Annual Return | Sharpe | Max DD | Closed Trades | Win Rate | Config |
+| --- | ---: | ---: | ---: | ---: | ---: | --- |
+| DonchianRegime_b50_e25_r150 | 11.06% | 0.408 | 40.47% | 22 | 40.91% | breakout=50, exit_period=25, regime=150 |
+| DonchianRegime_b60_e30_r150 | 9.84% | 0.368 | 41.21% | 20 | 40.00% | breakout=60, exit_period=30, regime=150 |
+| DonchianAtrTrail_b55_e30_atr3 | 7.49% | 0.288 | 35.90% | 38 | 50.00% | breakout=55, exit_period=30, atr_period=20, atr_mult=3.0 |
+| DonchianHybrid_b55_e30_tv025_atr4 | 10.11% | 0.38 | 26.43% | 33 | 54.55% | breakout=55, exit_period=30, vol_period=30, target_vol=0.25, max_weight=1.0, rebalance_band=0.15, atr_period=20, atr_mult=4.0 |
+
+## Quick Take
+- Best annual return in this batch: `DonchianRegime_b50_e25_r150` -> `11.06%`
+- Best Sharpe in this batch: `DonchianRegime_b50_e25_r150` -> `0.408`

+ 18 - 0
backtrader-lab/dualthrust_nearby_batch2_20260413-161417.md

@@ -0,0 +1,18 @@
+# DualThrust Nearby Batch 2
+
+## Baselines
+- Basic baseline (`range_period=20, k1=0.3, k2=0.3`): annual `19.22%`, Sharpe `0.501`, max DD `37.27%`
+- Regime baseline (`range_period=20, k1=0.3, k2=0.3, regime=120`): annual `15.02%`, Sharpe `0.405`, max DD `34.04%`
+
+## Nearby Results
+
+| Variant | Config | Annual Return | Sharpe | Max DD | Closed Trades | Win Rate |
+| --- | --- | ---: | ---: | ---: | ---: | ---: |
+| DualThrust_r20_k030_035 | range_period=20, k1=0.3, k2=0.35 | 17.03% | 0.473 | 55.80% | 50 | 46.00% |
+| DualThrust_r20_k035_030 | range_period=20, k1=0.35, k2=0.3 | 15.64% | 0.424 | 38.09% | 43 | 41.86% |
+| DualThrust_r22_k035_035 | range_period=22, k1=0.35, k2=0.35 | 15.11% | 0.451 | 37.22% | 31 | 51.61% |
+| DualThrustRegime_r20_k035_035_reg120 | range_period=20, k1=0.35, k2=0.35, regime=120 | 14.78% | 0.435 | 32.70% | 25 | 44.00% |
+
+## Quick Take
+- Best annual return in this batch: `DualThrust_r20_k030_035` -> `17.03%`
+- Best Sharpe in this batch: `DualThrust_r20_k030_035` -> `0.473`

+ 17 - 0
backtrader-lab/dualthrust_nearby_batch_20260413-161215.md

@@ -0,0 +1,17 @@
+# DualThrust Nearby Batch
+
+## Baseline
+- DualThrustBasicStrategy (`range_period=20, k1=0.3, k2=0.3`): annual `19.22%`, Sharpe `0.501`, max DD `37.27%`
+
+## Nearby Results
+
+| Variant | Config | Annual Return | Sharpe | Max DD | Closed Trades | Win Rate |
+| --- | --- | ---: | ---: | ---: | ---: | ---: |
+| DualThrust_r18_k030 | range_period=18, k1=0.3, k2=0.3 | 10.71% | 0.401 | 46.52% | 68 | 36.76% |
+| DualThrust_r22_k030 | range_period=22, k1=0.3, k2=0.3 | 16.28% | 0.46 | 41.05% | 48 | 43.75% |
+| DualThrust_r20_k025 | range_period=20, k1=0.25, k2=0.25 | 5.27% | 0.219 | 54.87% | 97 | 37.11% |
+| DualThrust_r20_k035 | range_period=20, k1=0.35, k2=0.35 | 15.89% | 0.454 | 38.78% | 40 | 45.00% |
+
+## Quick Take
+- Best annual return in this batch: `DualThrust_r22_k030` -> `16.28%`
+- Best Sharpe in this batch: `DualThrust_r22_k030` -> `0.46`

+ 143 - 0
backtrader-lab/dualthrust_strategy.py

@@ -0,0 +1,143 @@
+#!/usr/bin/env python3
+"""DualThrust strategies for Backtrader — highest-returning variants from Chinext50 experiments."""
+
+import math
+import backtrader as bt
+
+
+class BaseIndexStrategy(bt.Strategy):
+    """Common helpers for long-only index timing strategies."""
+
+    def __init__(self):
+        self.order = None
+        self.entry_count = 0
+        self.bars_in_market = 0
+        self.exposure_sum = 0.0
+
+    def notify_order(self, order):
+        if order.status in [order.Submitted, order.Accepted]:
+            return
+        if order.status == order.Completed and order.isbuy():
+            self.entry_count += 1
+        self.order = None
+
+    def next(self):
+        portfolio_value = self.broker.getvalue()
+        if portfolio_value > 0:
+            position_value = abs(self.position.size) * self.data.close[0]
+            exposure = position_value / portfolio_value
+            self.exposure_sum += exposure
+            if exposure > 0:
+                self.bars_in_market += 1
+
+    def _target_size_for_weight(self, target_weight: float) -> int:
+        target_weight = max(0.0, target_weight)
+        portfolio_value = self.broker.getvalue()
+        price = self.data.close[0]
+        if portfolio_value <= 0 or price <= 0:
+            return 0
+        target_value = portfolio_value * target_weight
+        return max(int(target_value / price), 0)
+
+    def _rebalance_to_weight(self, target_weight: float):
+        target_size = self._target_size_for_weight(target_weight)
+        current_size = self.position.size
+        size_delta = target_size - current_size
+        if size_delta > 0:
+            self.order = self.buy(size=size_delta)
+        elif size_delta < 0:
+            self.order = self.sell(size=abs(size_delta))
+
+    def _buy_full(self):
+        self._rebalance_to_weight(1.0)
+
+    def _go_flat(self):
+        if self.position:
+            self.order = self.close()
+
+
+class DualThrustBasicStrategy(BaseIndexStrategy):
+    """Close-only Dual Thrust proxy without regime filter.
+
+    Best config from Chinext50 experiments (2014-2026):
+    - range_period=20, k1=0.3, k2=0.3
+    - Total return: 639.25%, Annual: 19.22%, Sharpe: 0.501, Max DD: 37.27%
+    """
+
+    params = (
+        ("range_period", 20),
+        ("k1", 0.5),
+        ("k2", 0.5),
+    )
+
+    def __init__(self):
+        super().__init__()
+
+    def next(self):
+        super().next()
+        if self.order:
+            return
+        if len(self) <= self.p.range_period:
+            return
+
+        closes = [float(self.data.close[-offset]) for offset in range(1, self.p.range_period + 1)]
+        thrust_range = max(closes) - min(closes)
+        reference_price = float(self.data.close[-1])
+        upper = reference_price + self.p.k1 * thrust_range
+        lower = reference_price - self.p.k2 * thrust_range
+
+        entry_signal = self.data.close[0] > upper
+        exit_signal = self.data.close[0] < lower
+
+        if entry_signal and not self.position:
+            self._buy_full()
+        elif self.position and exit_signal:
+            self._go_flat()
+
+
+class DualThrustRegimeStrategy(BaseIndexStrategy):
+    """Close-only Dual Thrust proxy with long-term SMA regime filter.
+
+    Best config from Chinext50 experiments:
+    - range_period=20, k1=0.3, k2=0.3, regime=120
+    - Total return: 391.77%, Annual: 15.02%, Sharpe: 0.405, Max DD: 34.04%
+    """
+
+    params = (
+        ("range_period", 20),
+        ("k1", 0.5),
+        ("k2", 0.5),
+        ("regime", 200),
+    )
+
+    def __init__(self):
+        super().__init__()
+        self.sma_regime = bt.indicators.SMA(self.data.close, period=self.p.regime)
+
+    def next(self):
+        super().next()
+        if self.order:
+            return
+        if len(self) <= max(self.p.range_period, self.p.regime):
+            return
+        if math.isnan(self.sma_regime[0]):
+            return
+
+        closes = [float(self.data.close[-offset]) for offset in range(1, self.p.range_period + 1)]
+        thrust_range = max(closes) - min(closes)
+        reference_price = float(self.data.close[-1])
+        upper = reference_price + self.p.k1 * thrust_range
+        lower = reference_price - self.p.k2 * thrust_range
+
+        entry_signal = self.data.close[0] > upper and self.data.close[0] > self.sma_regime[0]
+        exit_signal = self.data.close[0] < lower or self.data.close[0] < self.sma_regime[0]
+
+        if entry_signal and not self.position:
+            self._buy_full()
+        elif self.position and exit_signal:
+            self._go_flat()
+
+
+# Additional tested configurations (same classes, different params):
+# DualThrustFastStrategy:  DualThrustBasicStrategy(range_period=15, k1=0.3, k2=0.3)
+# DualThrustSlowStrategy:  DualThrustBasicStrategy(range_period=30, k1=0.3, k2=0.3)

+ 197 - 0
backtrader-lab/fetch_data.py

@@ -0,0 +1,197 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+from datetime import datetime, timedelta
+from pathlib import Path
+import sys
+import time
+
+import akshare as ak
+import pandas as pd
+import requests
+
+ROOT = Path(__file__).resolve().parent
+INDEX_ROTATION_ROOT = ROOT.parent / "index-rotation"
+if str(INDEX_ROTATION_ROOT) not in sys.path:
+    sys.path.insert(0, str(INDEX_ROTATION_ROOT))
+
+from src.data.config import get_instrument, load_instruments
+from src.data.pipeline import merge_frames
+from src.data.transform import build_clean_frame, build_features_frame
+
+DEFAULT_SAVE_PATH = ROOT / "chinext50.csv"
+RAW_PATH = INDEX_ROTATION_ROOT / "data/raw/chinext50/price.parquet"
+CLEAN_PATH = INDEX_ROTATION_ROOT / "data/clean/chinext50/daily.parquet"
+FEATURE_PATH = INDEX_ROTATION_ROOT / "data/features/chinext50/daily.parquet"
+CONFIG_PATH = INDEX_ROTATION_ROOT / "configs/instruments.yaml"
+DEFAULT_SYMBOL = "399673"
+DEFAULT_PROVIDER_SYMBOL = "sz399673"
+DEFAULT_RETRIES = 3
+TENCENT_URL = "https://web.ifzq.gtimg.cn/appstock/app/fqkline/get"
+
+
+def _convert_feature_to_csv(feature_df: pd.DataFrame, save_path: Path) -> pd.DataFrame:
+    out = feature_df[
+        [
+            "trade_date",
+            "close",
+            "daily_return",
+            "ma_20",
+            "vol_20d",
+            "distance_to_ma_20",
+        ]
+    ].copy()
+    out.rename(columns={"trade_date": "datetime"}, inplace=True)
+    out["open"] = out["close"]
+    out["high"] = out["close"]
+    out["low"] = out["close"]
+    out["volume"] = 0
+    out["instrument"] = "chinext50"
+    out = out[
+        [
+            "datetime",
+            "open",
+            "high",
+            "low",
+            "close",
+            "volume",
+            "instrument",
+            "daily_return",
+            "ma_20",
+            "vol_20d",
+            "distance_to_ma_20",
+        ]
+    ]
+    out.to_csv(save_path, index=False)
+    return out
+
+
+def _fetch_incremental_akshare(start: str, end: str) -> pd.DataFrame:
+    frame = ak.stock_zh_index_daily_em(symbol=DEFAULT_PROVIDER_SYMBOL, start_date=start, end_date=end)
+    if frame is None or frame.empty:
+        return pd.DataFrame(columns=["trade_date", "open", "close", "high", "low", "volume", "amount"])
+    frame = frame.rename(columns={"date": "trade_date"})
+    frame["trade_date"] = pd.to_datetime(frame["trade_date"])
+    frame = frame[["trade_date", "open", "close", "high", "low", "volume", "amount"]].copy()
+    frame = frame.sort_values("trade_date").drop_duplicates("trade_date", keep="last").reset_index(drop=True)
+    return frame
+
+
+def _fetch_incremental_tencent(start_date: str, end_date: str) -> pd.DataFrame:
+    headers = {"User-Agent": "Mozilla/5.0"}
+    param = f"{DEFAULT_PROVIDER_SYMBOL},day,{start_date},{end_date},1000,qfq"
+    r = requests.get(TENCENT_URL, params={"param": param}, headers=headers, timeout=20)
+    r.raise_for_status()
+    payload = r.json()
+    data = (payload.get("data") or {}).get(DEFAULT_PROVIDER_SYMBOL) or {}
+    rows = data.get("day") or []
+    if not rows:
+        return pd.DataFrame(columns=["trade_date", "open", "close", "high", "low", "volume", "amount"])
+    df = pd.DataFrame(rows, columns=["trade_date", "open", "close", "high", "low", "volume"])
+    df["trade_date"] = pd.to_datetime(df["trade_date"])
+    for c in ["open", "close", "high", "low", "volume"]:
+        df[c] = pd.to_numeric(df[c], errors="coerce")
+    df["amount"] = pd.NA
+    return df[["trade_date", "open", "close", "high", "low", "volume", "amount"]].dropna(subset=["trade_date", "close"])
+
+
+def _with_meta(df: pd.DataFrame) -> pd.DataFrame:
+    out = df.copy()
+    out["instrument"] = "chinext50"
+    out["instrument_name"] = "创业板50"
+    out["index_code"] = DEFAULT_SYMBOL
+    out["provider"] = "akshare_eastmoney"
+    return out[
+        ["instrument", "instrument_name", "index_code", "provider", "trade_date", "open", "high", "low", "close", "volume", "amount"]
+    ]
+
+
+def _validate_overlap(existing_raw: pd.DataFrame, fetched_raw: pd.DataFrame, overlap_days: int = 5) -> None:
+    if existing_raw.empty or fetched_raw.empty:
+        return
+    last_existing = pd.to_datetime(existing_raw["trade_date"]).max()
+    overlap_start = last_existing - pd.Timedelta(days=overlap_days * 3)
+    existing_tail = existing_raw[pd.to_datetime(existing_raw["trade_date"]) >= overlap_start].copy()
+    incoming = fetched_raw[pd.to_datetime(fetched_raw["trade_date"]) <= last_existing].copy()
+    if incoming.empty:
+        return
+    merged = existing_tail.merge(
+        incoming,
+        on="trade_date",
+        suffixes=("_old", "_new"),
+    )
+    if merged.empty:
+        raise RuntimeError("增量更新校验失败:没有可比对的重叠交易日")
+    for col in ["open", "high", "low", "close", "volume"]:
+        diff = (pd.to_numeric(merged[f"{col}_old"], errors="coerce") - pd.to_numeric(merged[f"{col}_new"], errors="coerce")).abs().fillna(0)
+        if float(diff.max()) > 1e-6:
+            bad = merged.loc[diff.idxmax(), ["trade_date", f"{col}_old", f"{col}_new"]].to_dict()
+            raise RuntimeError(f"增量更新校验失败:字段 {col} 与旧基线不一致,样例={bad}")
+
+
+def fetch_chinext50_data(save_path: str | Path = DEFAULT_SAVE_PATH, retries: int = DEFAULT_RETRIES) -> pd.DataFrame:
+    save_path = Path(save_path)
+    raw_existing = pd.read_parquet(RAW_PATH)
+    last_date = pd.to_datetime(raw_existing["trade_date"]).max().date()
+    today = datetime.now().date()
+    request_start = last_date + timedelta(days=1)
+    if request_start > today:
+        feature_df = pd.read_parquet(FEATURE_PATH)
+        return _convert_feature_to_csv(feature_df, save_path)
+
+    start_ymd = request_start.strftime("%Y%m%d")
+    end_ymd = today.strftime("%Y%m%d")
+    start_iso = request_start.strftime("%Y-%m-%d")
+    end_iso = today.strftime("%Y-%m-%d")
+
+    last_error = None
+    fetched = pd.DataFrame()
+    for attempt in range(1, retries + 1):
+        try:
+            fetched = _fetch_incremental_akshare(start_ymd, end_ymd)
+            if not fetched.empty:
+                fetched = _with_meta(fetched)
+                break
+        except Exception as e:
+            last_error = e
+            time.sleep(attempt)
+    else:
+        for attempt in range(1, retries + 1):
+            try:
+                # include overlap window for same-source compatibility check
+                overlap_start = (request_start - timedelta(days=10)).strftime("%Y-%m-%d")
+                fetched = _fetch_incremental_tencent(overlap_start, end_iso)
+                if not fetched.empty:
+                    fetched = _with_meta(fetched)
+                    break
+            except Exception as e:
+                last_error = e
+                time.sleep(attempt)
+        if fetched.empty:
+            raise RuntimeError(f"远程数据刷新失败(akshare + tencent fallback 均失败): {last_error}")
+
+    _validate_overlap(raw_existing, fetched)
+    fetched_new = fetched[pd.to_datetime(fetched["trade_date"]).dt.date > last_date].copy()
+    merged_raw = merge_frames(raw_existing, fetched_new)
+
+    instruments = load_instruments(CONFIG_PATH)
+    instrument = get_instrument(instruments, "chinext50")
+    clean_df = build_clean_frame(merged_raw, instrument)
+    feature_df = build_features_frame(clean_df)
+
+    RAW_PATH.parent.mkdir(parents=True, exist_ok=True)
+    CLEAN_PATH.parent.mkdir(parents=True, exist_ok=True)
+    FEATURE_PATH.parent.mkdir(parents=True, exist_ok=True)
+    merged_raw.to_parquet(RAW_PATH, index=False)
+    clean_df.to_parquet(CLEAN_PATH, index=False)
+    feature_df.to_parquet(FEATURE_PATH, index=False)
+
+    return _convert_feature_to_csv(feature_df, save_path)
+
+
+if __name__ == "__main__":
+    df = fetch_chinext50_data()
+    print(
+        f"数据已刷新: {DEFAULT_SAVE_PATH} | rows={len(df)} | "
+        f"range={pd.to_datetime(df['datetime']).min().date()} ~ {pd.to_datetime(df['datetime']).max().date()}"
+    )

+ 594 - 0
backtrader-lab/gen_html_emails.py

@@ -0,0 +1,594 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import argparse
+import importlib.util
+import math
+import subprocess
+import sys
+from datetime import datetime, time
+from pathlib import Path
+from zoneinfo import ZoneInfo
+
+import pandas as pd
+
+from fetch_data import fetch_chinext50_data
+
+WORKDIR = Path(__file__).resolve().parent
+DEFAULT_EMAIL = "wangshuai.vip@qq.com"
+DEFAULT_BASELINE_CASH = 100_000
+DEFAULT_DISPLAY_CASH = 1_000_000
+SEND_TZ = ZoneInfo("Asia/Shanghai")
+SEND_START = time(15, 10)
+SEND_END = time(22, 30)
+
+
+def load_module(name: str, path: Path):
+    spec = importlib.util.spec_from_file_location(name, path)
+    mod = importlib.util.module_from_spec(spec)
+    sys.modules[spec.name] = mod
+    spec.loader.exec_module(mod)
+    return mod
+
+
+combo_mod = load_module("combo", WORKDIR / "shortlist_combo_trials.py")
+exp_mod = load_module("exp", WORKDIR / "chinext50_experiments.py")
+
+bt = combo_mod.bt
+TRADING_DAYS = combo_mod.TRADING_DAYS
+BasePortfolioStrategy = combo_mod.BasePortfolioStrategy
+
+
+class CleanTradeRecorder(bt.Analyzer):
+    def __init__(self):
+        self.trades = []
+        self._entry_cost = 0.0
+        self._last_sell_price = None
+        self._entry_qty = 0
+
+    def notify_order(self, order):
+        if order.status != order.Completed:
+            return
+        if order.isbuy():
+            self._entry_cost += abs(order.executed.size) * order.executed.price
+            self._entry_qty += abs(int(round(order.executed.size)))
+        elif order.issell():
+            self._last_sell_price = round(order.executed.price, 2)
+
+    def notify_trade(self, trade):
+        if not trade.isclosed:
+            return
+        pnl = round(trade.pnlcomm, 2)
+        cost = self._entry_cost if self._entry_cost > 0 else 1e-9
+        pnl_pct = round((pnl / cost) * 100, 2)
+        exit_value = round(self.strategy.broker.getvalue(), 2)
+        self.trades.append(
+            {
+                "entry_date": bt.num2date(trade.dtopen).strftime("%Y-%m-%d"),
+                "exit_date": bt.num2date(trade.dtclose).strftime("%Y-%m-%d"),
+                "entry_price": round(trade.price, 2) if trade.price else None,
+                "exit_price": self._last_sell_price,
+                "qty": self._entry_qty,
+                "days": int(trade.barlen),
+                "pnl": pnl,
+                "pnl_pct": pnl_pct,
+                "nav": exit_value,
+            }
+        )
+        self._entry_cost = 0.0
+        self._last_sell_price = None
+        self._entry_qty = 0
+
+
+def run_with_trades(strategy_cls, df, baseline_cash: float, config: dict | None = None):
+    cerebro = bt.Cerebro(stdstats=False)
+    cerebro.adddata(combo_mod.Chinext50Data(dataname=df))
+    cerebro.addstrategy(strategy_cls, **(config or {}))
+    cerebro.broker.setcash(baseline_cash)
+    cerebro.broker.setcommission(commission=combo_mod.COMMISSION)
+    cerebro.addanalyzer(bt.analyzers.Returns, _name="returns")
+    cerebro.addanalyzer(bt.analyzers.DrawDown, _name="drawdown")
+    cerebro.addanalyzer(bt.analyzers.SharpeRatio_A, _name="sharpe", riskfreerate=0.02)
+    cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name="trades")
+    cerebro.addanalyzer(CleanTradeRecorder, _name="recorder")
+
+    strategy = cerebro.run()[0]
+    final_value = cerebro.broker.getvalue()
+    returns = strategy.analyzers.returns.get_analysis()
+    drawdown = strategy.analyzers.drawdown.get_analysis()
+    sharpe = strategy.analyzers.sharpe.get_analysis()
+    trades = strategy.analyzers.trades.get_analysis()
+    closed_trades = trades.get("total", {}).get("closed", 0)
+    won_trades = trades.get("won", {}).get("total", 0)
+    total_bars = len(df)
+
+    metrics = {
+        "final_value": round(final_value, 2),
+        "total_return_pct": round((final_value / baseline_cash - 1.0) * 100.0, 2),
+        "annual_return_pct": round(returns.get("rnorm100", 0.0), 2),
+        "max_drawdown_pct": round(drawdown.get("max", {}).get("drawdown", 0.0), 2),
+        "sharpe": round(sharpe["sharperatio"], 3) if sharpe.get("sharperatio") is not None else None,
+        "closed_trades": closed_trades,
+        "win_rate_pct": round((won_trades / closed_trades) * 100.0, 2) if closed_trades else 0.0,
+        "exposure_pct": round((strategy.exposure_sum / total_bars) * 100.0, 2),
+    }
+    return metrics, strategy.analyzers.recorder.trades
+
+
+def scale_metrics(metrics: dict, scale: float) -> dict:
+    out = dict(metrics)
+    out["final_value"] = round(out["final_value"] * scale, 2)
+    return out
+
+
+def scale_trades(trades: list[dict], scale: float) -> list[dict]:
+    out = []
+    for trade in trades:
+        row = dict(trade)
+        row["qty"] = int(round(row["qty"] * scale))
+        row["pnl"] = round(row["pnl"] * scale, 2)
+        row["nav"] = round(row["nav"] * scale, 2)
+        out.append(row)
+    return out
+
+
+def safe_num(v):
+    if v is None:
+        return "-"
+    if isinstance(v, int):
+        return f"{v:,}"
+    if isinstance(v, float):
+        s = f"{v:,.2f}"
+        if s.endswith(".00"):
+            s = s[:-3]
+        return s
+    return str(v)
+
+
+def td(text, extra=""):
+    return f'<td style="border:1px solid #d9dee7;padding:8px 10px;{extra}">{text}</td>'
+
+
+def th(text):
+    return f'<th style="border:1px solid #d9dee7;background:#f3f6fa;padding:8px 10px;text-align:left;">{text}</th>'
+
+
+def compute_subperiods(strategy_cls, df, baseline_cash: float, config: dict | None = None) -> list[dict]:
+    periods = [
+        ("2014-06 ~ 2018-12", "2014-06-18", "2018-12-31"),
+        ("2019-01 ~ 2022-12", "2019-01-01", "2022-12-31"),
+        (f"2023-01 ~ {df.index.max().strftime('%Y-%m')}", "2023-01-01", df.index.max().strftime('%Y-%m-%d')),
+    ]
+    out = []
+    for label, start, end in periods:
+        sub_df = df.loc[(df.index >= start) & (df.index <= end)].copy()
+        if len(sub_df) < 30:
+            continue
+        metrics, _ = run_with_trades(strategy_cls, sub_df, baseline_cash, config)
+        out.append(
+            {
+                "period": label,
+                "annual": metrics["annual_return_pct"],
+                "sharpe": metrics["sharpe"],
+                "max_dd": metrics["max_drawdown_pct"],
+            }
+        )
+    return out
+
+
+def compute_recent_dualthrust_signals(df: pd.DataFrame, range_period: int = 20, k1: float = 0.3, k2: float = 0.3, recent_n: int = 20) -> list[dict]:
+    state = False
+    rows = []
+    close = df['close']
+    for i in range(len(df)):
+        if i <= range_period:
+            continue
+        window = close.iloc[i - range_period:i]
+        thrust_range = float(window.max() - window.min())
+        ref_price = float(close.iloc[i - 1])
+        last_close = float(close.iloc[i])
+        upper = ref_price + k1 * thrust_range
+        lower = ref_price - k2 * thrust_range
+        entry_signal = last_close > upper
+        exit_signal = last_close < lower
+        action = '空仓'
+        if entry_signal and not state:
+            state = True
+            action = '买入触发'
+        elif state and exit_signal:
+            state = False
+            action = '卖出触发'
+        else:
+            action = '持仓' if state else '空仓'
+        rows.append({
+            'date': df.index[i].strftime('%Y-%m-%d'),
+            'close': round(last_close, 2),
+            'upper': round(upper, 2),
+            'lower': round(lower, 2),
+            'entry_signal': '是' if entry_signal else '否',
+            'exit_signal': '是' if exit_signal else '否',
+            'status': action,
+        })
+    return rows[-recent_n:]
+
+
+def compute_recent_combo_signals(df: pd.DataFrame, recent_n: int = 20) -> list[dict]:
+    close = df['close']
+    high = df['high']
+    low = df['low']
+    returns = close.pct_change(1)
+    sma120 = close.rolling(120).mean()
+    sma150 = close.rolling(150).mean()
+    roc20 = close.pct_change(20)
+    roc120 = close.pct_change(120)
+    vol30 = returns.rolling(30).std()
+    prev_close = close.shift(1)
+    tr = pd.concat([
+        (high - low),
+        (high - prev_close).abs(),
+        (low - prev_close).abs(),
+    ], axis=1).max(axis=1)
+    atr20 = tr.rolling(20).mean()
+    highest55_prev = high.rolling(55).max().shift(1)
+    lowest30_prev = low.rolling(30).min().shift(1)
+
+    dt_reg_active = False
+    hybrid_active = False
+    hybrid_highest_close = None
+    rows = []
+
+    for i in range(len(df)):
+        dt_w = 0.0
+        if i > 120 and not pd.isna(sma120.iloc[i]):
+            window = close.iloc[i - 20:i]
+            thrust_range = float(window.max() - window.min())
+            ref_price = float(close.iloc[i - 1])
+            upper = ref_price + 0.35 * thrust_range
+            lower = ref_price - 0.35 * thrust_range
+            entry_signal = float(close.iloc[i]) > upper and float(close.iloc[i]) > float(sma120.iloc[i])
+            exit_signal = float(close.iloc[i]) < lower or float(close.iloc[i]) < float(sma120.iloc[i])
+            if not dt_reg_active and entry_signal:
+                dt_reg_active = True
+            elif dt_reg_active and exit_signal:
+                dt_reg_active = False
+            dt_w = 1.0 if dt_reg_active else 0.0
+
+        mvt_signal = False
+        mvt_w = 0.0
+        if not any(pd.isna(x) for x in [roc20.iloc[i], roc120.iloc[i], sma150.iloc[i], vol30.iloc[i]]) and vol30.iloc[i] > 0:
+            mvt_signal = bool(roc20.iloc[i] > 0 and roc120.iloc[i] > 0 and close.iloc[i] > sma150.iloc[i])
+            if mvt_signal:
+                annualized_vol = float(vol30.iloc[i]) * math.sqrt(TRADING_DAYS)
+                mvt_w = min(1.0, 0.29 / annualized_vol)
+
+        hy_w = 0.0
+        hy_break = False
+        if i > 55 and not any(pd.isna(x) for x in [highest55_prev.iloc[i], lowest30_prev.iloc[i], vol30.iloc[i], atr20.iloc[i]]) and vol30.iloc[i] > 0:
+            hy_break = bool(close.iloc[i] > highest55_prev.iloc[i])
+            channel_exit = bool(close.iloc[i] < lowest30_prev.iloc[i])
+            if not hybrid_active:
+                if hy_break:
+                    hybrid_active = True
+                    hybrid_highest_close = float(close.iloc[i])
+            else:
+                hybrid_highest_close = max(hybrid_highest_close or float(close.iloc[i]), float(close.iloc[i]))
+                trailing_stop = hybrid_highest_close - 4.0 * float(atr20.iloc[i])
+                if channel_exit or float(close.iloc[i]) < trailing_stop:
+                    hybrid_active = False
+                    hybrid_highest_close = None
+            if hybrid_active:
+                annualized_vol = float(vol30.iloc[i]) * math.sqrt(TRADING_DAYS)
+                hy_w = min(1.0, 0.25 / annualized_vol)
+
+        target_weight = 0.60 * dt_w + 0.20 * mvt_w + 0.20 * hy_w
+        rows.append({
+            'date': df.index[i].strftime('%Y-%m-%d'),
+            'close': round(float(close.iloc[i]), 2),
+            'dt_status': '开' if dt_reg_active else '关',
+            'mvt_status': '开' if mvt_signal else '关',
+            'hy_status': '开' if hybrid_active else '关',
+            'dt_weight': round(dt_w * 100, 1),
+            'mvt_weight': round(mvt_w * 100, 1),
+            'hy_weight': round(hy_w * 100, 1),
+            'target_weight': round(target_weight * 100, 1),
+        })
+    return rows[-recent_n:]
+
+
+def build_recent_signal_html(strategy_name: str, recent_rows: list[dict]) -> str:
+    if not recent_rows:
+        return ''
+    if strategy_name == 'DualThrustBasicStrategy':
+        header = '<tr>' + th('日期') + th('收盘') + th('上轨') + th('下轨') + th('入场触发') + th('出场触发') + th('状态') + '</tr>'
+        body = []
+        for r in recent_rows:
+            body.append('<tr>' + td(r['date']) + td(safe_num(r['close'])) + td(safe_num(r['upper'])) + td(safe_num(r['lower'])) + td(r['entry_signal']) + td(r['exit_signal']) + td(r['status']) + '</tr>')
+    else:
+        header = '<tr>' + th('日期') + th('收盘') + th('DT状态') + th('MVT状态') + th('HY状态') + th('DT权重') + th('MVT权重') + th('HY权重') + th('组合目标仓位') + '</tr>'
+        body = []
+        for r in recent_rows:
+            body.append('<tr>' + td(r['date']) + td(safe_num(r['close'])) + td(r['dt_status']) + td(r['mvt_status']) + td(r['hy_status']) + td(f"{r['dt_weight']:.1f}%") + td(f"{r['mvt_weight']:.1f}%") + td(f"{r['hy_weight']:.1f}%") + td(f"{r['target_weight']:.1f}%") + '</tr>')
+    return f'''<h2 style="margin:20px 0 12px 0;font-size:18px;color:#1f2d3d;">近20交易日指标触发情况</h2>
+<table style="width:100%;border-collapse:collapse;font-size:13px;margin-bottom:22px;">{header}{''.join(body)}</table>'''
+
+
+def build_html(title: str, strategy_name: str, config_desc: str, metrics: dict, trades: list[dict], subperiods: list[dict], recent_signal_rows: list[dict], df, display_cash: float, scaled_note: str):
+    total_pnl = sum(t["pnl"] for t in trades)
+    trade_rows = []
+    for i, t in enumerate(trades, 1):
+        pnl_color = "#0b8f3d" if t["pnl_pct"] >= 0 else "#c62828"
+        pnl_sign = "+" if t["pnl_pct"] > 0 else ""
+        trade_rows.append(
+            "<tr>"
+            + td(i)
+            + td(t["entry_date"])
+            + td(safe_num(t["entry_price"]))
+            + td(t["exit_date"])
+            + td(safe_num(t["exit_price"]))
+            + td(safe_num(t["qty"]))
+            + td(t["days"])
+            + td(safe_num(t["pnl"]), f"color:{pnl_color};font-weight:700;")
+            + td(f"{pnl_sign}{t['pnl_pct']:.2f}%", f"color:{pnl_color};font-weight:700;")
+            + td(safe_num(t["nav"]))
+            + "</tr>"
+        )
+    trade_rows = "\n".join(trade_rows)
+
+    sub_rows = []
+    for sp in subperiods:
+        sub_rows.append(
+            "<tr>"
+            + td(sp["period"])
+            + td(f"{sp['annual']:.2f}%")
+            + td(sp["sharpe"])
+            + td(f"{sp['max_dd']:.2f}%")
+            + "</tr>"
+        )
+    sub_rows = "\n".join(sub_rows)
+
+    data_start = df.index.min().date()
+    data_end = df.index.max().date()
+    bars = len(df)
+    recent_signal_html = build_recent_signal_html(strategy_name, recent_signal_rows)
+
+    return f"""<!DOCTYPE html>
+<html>
+<head>
+<meta charset="utf-8">
+<title>{title}</title>
+</head>
+<body style="margin:0;padding:24px;background:#f6f8fb;font-family:Arial,'PingFang SC','Microsoft YaHei',sans-serif;color:#243447;">
+<div style="max-width:1120px;margin:0 auto;background:#ffffff;border:1px solid #e6ebf2;border-radius:10px;padding:28px;">
+<h1 style="margin:0 0 16px 0;font-size:26px;line-height:1.3;color:#1f2d3d;">{title}</h1>
+<div style="font-size:14px;line-height:1.8;color:#4a5568;margin-bottom:20px;">
+<div><strong>初始资金:</strong>{safe_num(display_cash)}</div>
+<div><strong>策略名称:</strong>{strategy_name}</div>
+<div><strong>配置参数:</strong>{config_desc}</div>
+<div><strong>数据来源:</strong>chinext50.csv({data_start} 至 {data_end},{bars} 根 K 线)</div>
+<div><strong>数据处理:</strong>{scaled_note}</div>
+</div>
+<h2 style="margin:20px 0 12px 0;font-size:18px;color:#1f2d3d;">核心指标</h2>
+<table style="width:100%;border-collapse:collapse;font-size:14px;margin-bottom:22px;">
+<tr>{th('指标')}{th('数值')}{th('指标')}{th('数值')}</tr>
+<tr>{td('总收益')}{td(f"{metrics['total_return_pct']:.2f}%")}{td('年化收益')}{td(f"{metrics['annual_return_pct']:.2f}%")}</tr>
+<tr>{td('Sharpe')}{td(metrics['sharpe'])}{td('最大回撤')}{td(f"{metrics['max_drawdown_pct']:.2f}%")}</tr>
+<tr>{td('交易次数')}{td(metrics['closed_trades'])}{td('胜率')}{td(f"{metrics['win_rate_pct']:.2f}%")}</tr>
+<tr>{td('最终净值')}{td(safe_num(metrics['final_value']))}{td('平均暴露')}{td(f"{metrics['exposure_pct']:.2f}%")}</tr>
+</table>
+<h2 style="margin:20px 0 12px 0;font-size:18px;color:#1f2d3d;">子区间复核</h2>
+<table style="width:100%;border-collapse:collapse;font-size:14px;margin-bottom:22px;">
+<tr>{th('区间')}{th('年化收益')}{th('Sharpe')}{th('最大回撤')}</tr>
+{sub_rows}
+</table>
+{recent_signal_html}
+<h2 style="margin:20px 0 12px 0;font-size:18px;color:#1f2d3d;">历史交易记录</h2>
+<div style="font-size:13px;color:#667085;margin-bottom:10px;">累计盈亏:{safe_num(total_pnl)}</div>
+<table style="width:100%;border-collapse:collapse;font-size:13px;">
+<tr>{th('#')}{th('买入时间')}{th('买入价')}{th('卖出时间')}{th('卖出价')}{th('数量')}{th('天数')}{th('盈亏额')}{th('盈亏%')}{th('账户净值')}</tr>
+{trade_rows}
+</table>
+<div style="margin-top:18px;background:#fff7e6;border-left:4px solid #f0b429;padding:10px 12px;font-size:13px;color:#7a5c00;">本报告每次运行都会先拉取远程最新数据,刷新本地 chinext50.csv,再重算后发出。</div>
+</div>
+</body>
+</html>"""
+
+
+class Balanced3_DT60_MVT20_HY20(BasePortfolioStrategy):
+    params = (("w_dt", 0.60), ("w_mvt", 0.20), ("w_hy", 0.20), ("rebalance_band", 0.05))
+
+    def __init__(self):
+        super().__init__()
+        close = self.data.close
+        returns = bt.indicators.PctChange(close, period=1)
+        self.volatility = bt.indicators.StdDev(returns, period=30)
+        self.atr = bt.indicators.ATR(self.data, period=20)
+        self.roc_short = bt.indicators.ROC(close, period=20)
+        self.roc_long = bt.indicators.ROC(close, period=120)
+        self.sma120 = bt.indicators.SMA(close, period=120)
+        self.sma150 = bt.indicators.SMA(close, period=150)
+        self.highest_high = bt.indicators.Highest(self.data.high, period=55)
+        self.lowest_low = bt.indicators.Lowest(self.data.low, period=30)
+        self.dt_reg_active = False
+        self.hybrid_active = False
+        self.hybrid_highest_close = None
+
+    def next(self):
+        super().next()
+        if self.order:
+            return
+
+        dt_w = 0.0
+        if len(self) > 120 and not math.isnan(self.sma120[0]):
+            closes = [float(self.data.close[-offset]) for offset in range(1, 21)]
+            thrust_range = max(closes) - min(closes)
+            reference_price = float(self.data.close[-1])
+            upper = reference_price + 0.35 * thrust_range
+            lower = reference_price - 0.35 * thrust_range
+            entry_signal = self.data.close[0] > upper and self.data.close[0] > self.sma120[0]
+            exit_signal = self.data.close[0] < lower or self.data.close[0] < self.sma120[0]
+            if not self.dt_reg_active and entry_signal:
+                self.dt_reg_active = True
+            elif self.dt_reg_active and exit_signal:
+                self.dt_reg_active = False
+            dt_w = 1.0 if self.dt_reg_active else 0.0
+
+        mvt_w = 0.0
+        if not any(math.isnan(x) for x in [self.roc_short[0], self.roc_long[0], self.sma150[0], self.volatility[0]]) and self.volatility[0] > 0:
+            signal = self.roc_short[0] > 0 and self.roc_long[0] > 0 and self.data.close[0] > self.sma150[0]
+            if signal:
+                annualized_vol = self.volatility[0] * math.sqrt(TRADING_DAYS)
+                mvt_w = min(1.0, 0.29 / annualized_vol)
+
+        hy_w = 0.0
+        if len(self) > 55 and not any(math.isnan(x) for x in [self.highest_high[-1], self.lowest_low[-1], self.volatility[0], self.atr[0]]) and self.volatility[0] > 0:
+            breakout_signal = self.data.close[0] > self.highest_high[-1]
+            channel_exit = self.data.close[0] < self.lowest_low[-1]
+            if not self.hybrid_active:
+                if breakout_signal:
+                    self.hybrid_active = True
+                    self.hybrid_highest_close = float(self.data.close[0])
+            else:
+                self.hybrid_highest_close = max(self.hybrid_highest_close or float(self.data.close[0]), float(self.data.close[0]))
+                trailing_stop = self.hybrid_highest_close - 4.0 * self.atr[0]
+                if channel_exit or self.data.close[0] < trailing_stop:
+                    self.hybrid_active = False
+                    self.hybrid_highest_close = None
+            if self.hybrid_active:
+                annualized_vol = self.volatility[0] * math.sqrt(TRADING_DAYS)
+                hy_w = min(1.0, 0.25 / annualized_vol)
+
+        target_weight = self.p.w_dt * dt_w + self.p.w_mvt * mvt_w + self.p.w_hy * hy_w
+        pv = self.broker.getvalue()
+        cw = (abs(self.position.size) * self.data.close[0]) / pv if pv > 0 else 0.0
+        if not self.position or abs(cw - target_weight) >= self.p.rebalance_band:
+            self._rebalance_to_weight(target_weight)
+
+
+def write_mail(subject: str, html: str, output_path: Path, to_email: str):
+    mail = "\n".join(
+        [
+            f"Subject: {subject}",
+            f"From: {to_email}",
+            f"To: {to_email}",
+            "MIME-Version: 1.0",
+            "Content-Type: text/html; charset=utf-8",
+            "Content-Transfer-Encoding: 8bit",
+            "",
+            html,
+        ]
+    )
+    output_path.write_text(mail, encoding="utf-8")
+
+
+def send_mail(file_path: Path, to_email: str):
+    content = file_path.read_bytes()
+    subprocess.run(["/usr/sbin/sendmail", to_email], input=content, check=True)
+
+
+def get_send_window_status() -> tuple[bool, str]:
+    now = datetime.now(SEND_TZ)
+    now_t = now.time()
+    allowed = SEND_START <= now_t <= SEND_END
+    msg = (
+        f"send_window tz=Asia/Shanghai now={now.strftime('%Y-%m-%d %H:%M:%S')} "
+        f"window={SEND_START.strftime('%H:%M')}-{SEND_END.strftime('%H:%M')} allowed={allowed}"
+    )
+    return allowed, msg
+
+
+def main():
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--email", default=DEFAULT_EMAIL)
+    parser.add_argument("--baseline-cash", type=float, default=DEFAULT_BASELINE_CASH)
+    parser.add_argument("--display-cash", type=float, default=DEFAULT_DISPLAY_CASH)
+    parser.add_argument("--skip-refresh", action="store_true")
+    parser.add_argument("--skip-send", action="store_true")
+    parser.add_argument("--force-send", action="store_true", help="Ignore send window and send immediately.")
+    args = parser.parse_args()
+
+    if not args.skip_refresh:
+        try:
+            refreshed = fetch_chinext50_data(save_path=WORKDIR / "chinext50.csv")
+        except Exception as e:
+            print(f"REFRESH_FAILED: {e}", file=sys.stderr)
+            print("ABORT_SEND: remote refresh failed, report email not sent", file=sys.stderr)
+            raise SystemExit(2)
+        print(f"refreshed rows={len(refreshed)} range={refreshed['datetime'].min().date()}~{refreshed['datetime'].max().date()}")
+
+    df = exp_mod.load_dataframe()
+    scale = args.display_cash / args.baseline_cash
+    scaled_note = (
+        f"先刷新远程数据后回测;展示口径按 {safe_num(args.baseline_cash)} 基准回测等比例放大到 {safe_num(args.display_cash)}。"
+        if abs(scale - 1.0) > 1e-9
+        else f"先刷新远程数据后,按 {safe_num(args.display_cash)} 初始资金直接回测。"
+    )
+
+    m1, t1 = run_with_trades(exp_mod.DualThrustBasicStrategy, df, args.baseline_cash, {"range_period": 20, "k1": 0.3, "k2": 0.3})
+    m2, t2 = run_with_trades(Balanced3_DT60_MVT20_HY20, df, args.baseline_cash)
+
+    if abs(scale - 1.0) > 1e-9:
+        m1, t1 = scale_metrics(m1, scale), scale_trades(t1, scale)
+        m2, t2 = scale_metrics(m2, scale), scale_trades(t2, scale)
+
+    sub1 = compute_subperiods(
+        exp_mod.DualThrustBasicStrategy,
+        df,
+        args.baseline_cash,
+        {"range_period": 20, "k1": 0.3, "k2": 0.3},
+    )
+    sub2 = compute_subperiods(
+        Balanced3_DT60_MVT20_HY20,
+        df,
+        args.baseline_cash,
+        None,
+    )
+    recent1 = compute_recent_dualthrust_signals(df, range_period=20, k1=0.3, k2=0.3, recent_n=20)
+    recent2 = compute_recent_combo_signals(df, recent_n=20)
+
+    html1 = build_html(
+        "DualThrustBasicStrategy — 单策略统计信息(自动刷新版)",
+        "DualThrustBasicStrategy",
+        "range_period=20, k1=0.3, k2=0.3",
+        m1,
+        t1,
+        sub1,
+        recent1,
+        df,
+        args.display_cash,
+        scaled_note,
+    )
+    html2 = build_html(
+        "Balanced3_DT60_MVT20_HY20 — 组合策略统计信息(自动刷新版)",
+        "Balanced3_DT60_MVT20_HY20",
+        "DualThrustRegime(60%) + MVT_reg150_tv029(20%) + DonchianHybrid_b55_e30_tv025_atr4(20%)",
+        m2,
+        t2,
+        sub2,
+        recent2,
+        df,
+        args.display_cash,
+        scaled_note,
+    )
+
+    p1 = WORKDIR / "auto_refresh_email_strategy_1.html"
+    p2 = WORKDIR / "auto_refresh_email_strategy_2.html"
+    write_mail("【策略统计-自动刷新版】DualThrustBasicStrategy 单策略统计信息", html1, p1, args.email)
+    write_mail("【策略统计-自动刷新版】Balanced3_DT60_MVT20_HY20 组合策略统计信息", html2, p2, args.email)
+
+    print(f"strategy1 total={m1['total_return_pct']}% final={m1['final_value']}")
+    print(f"strategy2 total={m2['total_return_pct']}% final={m2['final_value']}")
+    print(f"wrote {p1} and {p2}")
+
+    if not args.skip_send:
+        allowed, window_msg = get_send_window_status()
+        print(window_msg)
+        if allowed or args.force_send:
+            send_mail(p1, args.email)
+            send_mail(p2, args.email)
+            print("sent both emails")
+        else:
+            print("SKIP_SEND: outside Asia/Shanghai send window; reports generated but not mailed")
+
+
+if __name__ == "__main__":
+    main()

+ 19 - 0
backtrader-lab/mvt_nearby_batch_20260413-162421.md

@@ -0,0 +1,19 @@
+# MomentumVolTarget Nearby Batch
+
+## Baselines
+- tv=0.25: annual `11.09%`, Sharpe `0.415`, max DD `36.55%`
+- tv=0.30: annual `11.95%`, Sharpe `0.419`, max DD `37.62%`
+- tv=0.35: annual `9.80%`, Sharpe `0.338`, max DD `43.13%`
+
+## Nearby Results
+
+| Variant | Target Vol | Annual Return | Sharpe | Max DD | Closed Trades | Win Rate |
+| --- | ---: | ---: | ---: | ---: | ---: | ---: |
+| MVT_reg150_tv026 | 0.26 | 11.17% | 0.414 | 36.51% | 72 | 37.50% |
+| MVT_reg150_tv027 | 0.27 | 11.05% | 0.419 | 36.82% | 72 | 38.89% |
+| MVT_reg150_tv028 | 0.28 | 11.39% | 0.419 | 37.42% | 73 | 38.36% |
+| MVT_reg150_tv029 | 0.29 | 11.51% | 0.417 | 36.53% | 74 | 39.19% |
+
+## Quick Take
+- Best annual return in this batch: `MVT_reg150_tv029` -> `11.51%`
+- Best Sharpe in this batch: `MVT_reg150_tv028` -> `0.419`

+ 129 - 0
backtrader-lab/regime_chinext50.py

@@ -0,0 +1,129 @@
+#!/usr/bin/env python3
+"""
+创业板50指数Regime策略 - 最终工作版
+基于双均线(20/60)的金叉/死叉信号
+"""
+
+import backtrader as bt
+import pandas as pd
+import numpy as np
+
+
+class Chinext50RegimeStrategy(bt.Strategy):
+    """
+    创业板50双均线Regime策略
+    
+    参数:
+        fast: 短期均线周期 (默认20)
+        slow: 长期均线周期 (默认60)
+    """
+    params = (
+        ('fast', 20),
+        ('slow', 60),
+    )
+    
+    def __init__(self):
+        self.sma_fast = bt.indicators.SMA(period=self.p.fast)
+        self.sma_slow = bt.indicators.SMA(period=self.p.slow)
+        self.trade_count = 0
+        
+    def next(self):
+        """策略主逻辑 - 每个交易日调用"""
+        if len(self) < 2:
+            return
+            
+        # 获取当前和前一周期的均线值
+        fast_now = self.sma_fast[0]
+        fast_prev = self.sma_fast[-1]
+        slow_now = self.sma_slow[0]
+        slow_prev = self.sma_slow[-1]
+        
+        # 检查有效值
+        if np.isnan(fast_now):
+            return
+        
+        # 金叉检测: 前一周fast<=slow, 这一周fast>slow
+        golden_cross = fast_prev <= slow_prev and fast_now > slow_now
+        
+        # 死叉检测: 前一周fast>=slow, 这一周fast<slow
+        death_cross = fast_prev >= slow_prev and fast_now < slow_now
+        
+        # 买入逻辑: 金叉 + 空仓
+        if golden_cross and not self.position:
+            close_price = self.datas[0].close[0]
+            size = int(self.broker.getcash() / close_price)
+            if size > 0:
+                self.buy(size=size)
+                self.trade_count += 1
+                
+        # 卖出逻辑: 死叉 + 有持仓
+        elif death_cross and self.position:
+            self.close()
+
+
+def run_backtest(csv_file="chinext50.csv", cash=100000.0, commission=0.001):
+    """
+    运行回测
+    
+    参数:
+        csv_file: 数据文件路径
+        cash: 初始资金
+        commission: 手续费率
+    """
+    cerebro = bt.Cerebro()
+    
+    # 加载数据
+    df = pd.read_csv(csv_file, parse_dates=['datetime'], index_col='datetime')
+    data = bt.feeds.PandasData(dataname=df)
+    cerebro.adddata(data)
+    
+    # 添加策略
+    cerebro.addstrategy(Chinext50RegimeStrategy)
+    
+    # 设置资金
+    cerebro.broker.setcash(cash)
+    cerebro.broker.setcommission(commission=commission)
+    
+    # 添加分析器
+    cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name='sharpe', riskfreerate=0.02)
+    cerebro.addanalyzer(bt.analyzers.DrawDown, _name='drawdown')
+    cerebro.addanalyzer(bt.analyzers.Returns, _name='returns')
+    cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name='trades')
+    
+    print('=' * 50)
+    print('创业板50 Regime策略回测')
+    print('=' * 50)
+    print(f'初始资金: {cerebro.broker.getvalue():.2f}')
+    
+    # 运行回测
+    results = cerebro.run()
+    strat = results[0]
+    
+    # 输出结果
+    print(f'最终资金: {cerebro.broker.getvalue():.2f}')
+    print(f'交易次数: {strat.trade_count}')
+    
+    returns = strat.analyzers.returns.get_analysis()
+    print(f"年化收益: {returns.get('rnorm100', 0):.2f}%")
+    
+    sharpe = strat.analyzers.sharpe.get_analysis()
+    sharpe_val = sharpe.get('sharperatio', 0)
+    print(f"夏普比率: {sharpe_val:.3f}" if sharpe_val else "夏普比率: N/A")
+    
+    drawdown = strat.analyzers.drawdown.get_analysis()
+    print(f"最大回撤: {drawdown.get('max', {}).get('drawdown', 0):.2f}%")
+    
+    trades = strat.analyzers.trades.get_analysis()
+    if trades and trades.get('total'):
+        total = trades['total'].get('total', 0)
+        won = trades['won'].get('total', 0) if trades.get('won') else 0
+        print(f'总交易: {total}, 盈利: {won}')
+        if total > 0:
+            print(f'胜率: {won/total:.1%}')
+    
+    print('=' * 50)
+    return cerebro, strat
+
+
+if __name__ == "__main__":
+    run_backtest()

+ 173 - 0
backtrader-lab/regime_detection.py

@@ -0,0 +1,173 @@
+"""
+创业板50指数市场状态识别 (Regime Detection)
+基于波动率和趋势强度识别不同市场状态
+"""
+
+import pandas as pd
+import numpy as np
+from enum import Enum
+
+class RegimeType(Enum):
+    """市场状态类型"""
+    STRONG_BULL = "强趋势上涨"      # 高波动+上涨趋势
+    WEAK_BULL = "弱趋势上涨"        # 低波动+上涨趋势
+    STRONG_BEAR = "强趋势下跌"      # 高波动+下跌趋势
+    WEAK_BEAR = "弱趋势下跌"        # 低波动+下跌趋势
+    CONSOLIDATION = "震荡整理"      # 无明显趋势
+    UNKNOWN = "未知"
+
+class RegimeDetector:
+    """
+    基于波动率和趋势的市场状态识别器
+    
+    创业板50特性:
+    - 成长风格, 波动率高于主板
+    - 趋势性强但反转快
+    - 适合波动率+趋势双因子识别
+    """
+    
+    def __init__(self, 
+                 vol_short=20,      # 短期波动率窗口
+                 vol_long=60,       # 长期波动率窗口
+                 trend_window=20,   # 趋势判断窗口
+                 vol_percentile=60, # 波动率分位数阈值
+                 trend_threshold=0.05):  # 趋势强度阈值
+        self.vol_short = vol_short
+        self.vol_long = vol_long
+        self.trend_window = trend_window
+        self.vol_percentile = vol_percentile
+        self.trend_threshold = trend_threshold
+        
+    def calculate_volatility(self, prices):
+        """计算年化波动率"""
+        returns = prices.pct_change().dropna()
+        vol_short = returns.rolling(self.vol_short).std() * np.sqrt(252)
+        vol_long = returns.rolling(self.vol_long).std() * np.sqrt(252)
+        return vol_short, vol_long
+    
+    def calculate_trend(self, prices):
+        """计算趋势强度和方向"""
+        # 使用均线斜率判断趋势
+        ma = prices.rolling(self.trend_window).mean()
+        # 价格相对均线的偏离
+        deviation = (prices - ma) / ma
+        # 趋势强度: 斜率方向 + 持续性
+        trend_strength = deviation.rolling(self.trend_window).mean()
+        return trend_strength
+    
+    def detect_regime(self, prices):
+        """
+        识别当前市场状态
+        
+        返回: DataFrame with regime info
+        """
+        df = pd.DataFrame(index=prices.index)
+        df['close'] = prices
+        
+        # 计算波动率
+        vol_short, vol_long = self.calculate_volatility(prices)
+        df['vol_short'] = vol_short
+        df['vol_long'] = vol_long
+        
+        # 波动率分位数 (基于长期历史)
+        df['vol_percentile'] = vol_short.rolling(252).apply(
+            lambda x: pd.Series(x).rank(pct=True).iloc[-1] if len(x) > 0 else 0.5
+        )
+        
+        # 趋势强度
+        df['trend'] = self.calculate_trend(prices)
+        
+        # 趋势方向 (使用短期动量)
+        df['momentum'] = prices.pct_change(self.trend_window)
+        
+        # 识别状态
+        df['regime'] = RegimeType.UNKNOWN.value
+        
+        # 高波动
+        high_vol = df['vol_percentile'] > self.vol_percentile / 100
+        # 强趋势
+        strong_trend_up = df['trend'] > self.trend_threshold
+        strong_trend_down = df['trend'] < -self.trend_threshold
+        
+        # 强趋势上涨 (高波动+上涨)
+        mask = high_vol & strong_trend_up
+        df.loc[mask, 'regime'] = RegimeType.STRONG_BULL.value
+        
+        # 弱趋势上涨 (低波动+上涨)
+        mask = (~high_vol) & strong_trend_up
+        df.loc[mask, 'regime'] = RegimeType.WEAK_BULL.value
+        
+        # 强趋势下跌 (高波动+下跌)
+        mask = high_vol & strong_trend_down
+        df.loc[mask, 'regime'] = RegimeType.STRONG_BEAR.value
+        
+        # 弱趋势下跌 (低波动+下跌)
+        mask = (~high_vol) & strong_trend_down
+        df.loc[mask, 'regime'] = RegimeType.WEAK_BEAR.value
+        
+        # 震荡 (无明显趋势)
+        mask = (~strong_trend_up) & (~strong_trend_down)
+        df.loc[mask, 'regime'] = RegimeType.CONSOLIDATION.value
+        
+        return df
+    
+    def get_regime_stats(self, df):
+        """统计各状态占比和表现"""
+        stats = []
+        for regime in df['regime'].unique():
+            if pd.isna(regime):
+                continue
+            mask = df['regime'] == regime
+            regime_data = df[mask]
+            
+            # 计算该状态下的收益统计
+            returns = regime_data['close'].pct_change().dropna()
+            
+            stats.append({
+                'regime': regime,
+                'days': len(regime_data),
+                'pct': len(regime_data) / len(df) * 100,
+                'avg_return': returns.mean() * 100 if len(returns) > 0 else 0,
+                'volatility': returns.std() * np.sqrt(252) * 100 if len(returns) > 0 else 0,
+                'sharpe': (returns.mean() / returns.std() * np.sqrt(252)) if len(returns) > 0 and returns.std() > 0 else 0,
+                'max_return': returns.max() * 100 if len(returns) > 0 else 0,
+                'min_return': returns.min() * 100 if len(returns) > 0 else 0,
+            })
+        
+        return pd.DataFrame(stats)
+
+
+def analyze_chinext50_regimes(csv_path="chinext50.csv"):
+    """分析创业板50的历史状态分布"""
+    df = pd.read_csv(csv_path, parse_dates=['datetime'], index_col='datetime')
+    
+    detector = RegimeDetector(
+        vol_short=20,
+        vol_long=60,
+        trend_window=20,
+        vol_percentile=60,
+        trend_threshold=0.03  # 创业板波动大,阈值放宽
+    )
+    
+    regimes = detector.detect_regime(df['close'])
+    stats = detector.get_regime_stats(regimes)
+    
+    print("=" * 60)
+    print("创业板50指数市场状态分析")
+    print("=" * 60)
+    print(f"\n数据区间: {regimes.index[0].date()} 至 {regimes.index[-1].date()}")
+    print(f"总交易日: {len(regimes)}")
+    print("\n各状态分布:")
+    print(stats.to_string(index=False))
+    
+    # 保存结果
+    regimes.to_csv("regimes.csv")
+    stats.to_csv("regime_stats.csv", index=False)
+    print("\n详细数据已保存: regimes.csv")
+    print("统计结果已保存: regime_stats.csv")
+    
+    return regimes, stats
+
+
+if __name__ == "__main__":
+    analyze_chinext50_regimes()

+ 145 - 0
backtrader-lab/regime_final.py

@@ -0,0 +1,145 @@
+"""
+最终修正版Regime策略 - 正确使用backtrader指标历史访问
+"""
+
+import backtrader as bt
+import pandas as pd
+import numpy as np
+
+class FinalRegimeStrategy(bt.Strategy):
+    """
+    状态感知策略 - 最终修正版
+    
+    关键点: 在backtrader中,用(1)而不是[-1]获取历史值
+    """
+    
+    params = (
+        ('fast', 20),
+        ('slow', 60),
+        ('printlog', False),
+    )
+    
+    def __init__(self):
+        self.dataclose = self.datas[0].close
+        self.order = None
+        
+        # 均线
+        self.sma_fast = bt.indicators.SMA(period=self.p.fast)
+        self.sma_slow = bt.indicators.SMA(period=self.p.slow)
+        
+        # 交易计数
+        self.trade_count = 0
+        
+    def next(self):
+        if self.order:
+            return
+            
+        # 必须至少有2个数据点才能判断交叉
+        if len(self) < 2:
+            return
+            
+        # 获取当前和前一周期的均线值
+        # 在backtrader中: [0]是当前, [-1]是前一个周期
+        fast_now = self.sma_fast[0]
+        fast_prev = self.sma_fast[-1]
+        slow_now = self.sma_slow[0]
+        slow_prev = self.sma_slow[-1]
+        
+        # 检查是否有有效值
+        if np.isnan(fast_now) or np.isnan(slow_now):
+            return
+            
+        # 检测金叉: 前一周fast<=slow, 这一周fast>slow
+        golden_cross = fast_prev <= slow_prev and fast_now > slow_now
+        
+        # 检测死叉: 前一周fast>=slow, 这一周fast<slow  
+        death_cross = fast_prev >= slow_prev and fast_now < slow_now
+        
+        # 买入逻辑: 金叉 + 空仓
+        if golden_cross and not self.position:
+            size = int(self.broker.getcash() / self.dataclose[0] / 100) * 100
+            if size > 0:
+                self.order = self.buy(size=size)
+                self.trade_count += 1
+                if self.p.printlog:
+                    self.log(f'BUY #{self.trade_count} @ {self.dataclose[0]:.2f}')
+                    
+        # 卖出逻辑: 死叉 + 有持仓
+        elif death_cross and self.position:
+            self.order = self.close()
+            if self.p.printlog:
+                self.log(f'SELL @ {self.dataclose[0]:.2f}')
+    
+    def notify_order(self, order):
+        if order.status in [order.Submitted, order.Accepted]:
+            return
+        if order.status in [order.Completed]:
+            if order.isbuy():
+                self.log(f'BUY EXECUTED @ {order.executed.price:.2f}')
+            else:
+                self.log(f'SELL EXECUTED @ {order.executed.price:.2f}')
+        self.order = None
+    
+    def log(self, txt, dt=None):
+        if not self.p.printlog:
+            return
+        dt = dt or self.datas[0].datetime.date(0)
+        print(f'{dt.isoformat()} {txt}')
+    
+    def stop(self):
+        roi = (self.broker.getvalue() / self.broker.startingcash - 1) * 100
+        print(f'\n=== 最终收益: {roi:.2f}% ===')
+        print(f'总交易次数: {self.trade_count}')
+
+
+def run_final_regime(csv_file="chinext50.csv", cash=100000.0):
+    """运行最终修正版Regime策略"""
+    cerebro = bt.Cerebro()
+    
+    df = pd.read_csv(csv_file, parse_dates=['datetime'], index_col='datetime')
+    data = bt.feeds.PandasData(dataname=df)
+    cerebro.adddata(data)
+    
+    cerebro.addstrategy(FinalRegimeStrategy, printlog=False)
+    cerebro.broker.setcash(cash)
+    cerebro.broker.setcommission(commission=0.001)
+    
+    cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name='sharpe', riskfreerate=0.02)
+    cerebro.addanalyzer(bt.analyzers.DrawDown, _name='drawdown')
+    cerebro.addanalyzer(bt.analyzers.Returns, _name='returns')
+    cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name='trades')
+    
+    print('=== 创业板50 Regime策略回测 ===')
+    print(f'初始资金: {cerebro.broker.getvalue():.2f}')
+    
+    results = cerebro.run()
+    strat = results[0]
+    
+    print(f'最终资金: {cerebro.broker.getvalue():.2f}')
+    
+    returns = strat.analyzers.returns.get_analysis()
+    print(f"年化收益: {returns.get('rnorm100', 0):.2f}%")
+    
+    sharpe = strat.analyzers.sharpe.get_analysis()
+    sharpe_val = sharpe.get('sharperatio', 0)
+    if sharpe_val:
+        print(f"夏普比率: {sharpe_val:.3f}")
+    else:
+        print("夏普比率: N/A")
+    
+    drawdown = strat.analyzers.drawdown.get_analysis()
+    print(f"最大回撤: {drawdown.get('max', {}).get('drawdown', 0):.2f}%")
+    
+    trades = strat.analyzers.trades.get_analysis()
+    if trades and trades.get('total'):
+        total = trades['total'].get('total', 0)
+        won = trades['won'].get('total', 0) if trades.get('won') else 0
+        print(f"总交易: {total}, 盈利: {won}")
+        if total > 0:
+            print(f"胜率: {won/total:.1%}")
+    
+    return cerebro, strat
+
+
+if __name__ == "__main__":
+    run_final_regime()

+ 132 - 0
backtrader-lab/regime_simple.py

@@ -0,0 +1,132 @@
+"""
+简化的Regime策略 - 基于趋势强度动态调整仓位
+"""
+
+import backtrader as bt
+import pandas as pd
+import numpy as np
+
+class SimpleRegimeStrategy(bt.Strategy):
+    """
+    简化版状态感知策略
+    
+    逻辑:
+    - 计算20日趋势强度
+    - 趋势强且向上: 满仓
+    - 趋势弱或向下: 减仓/空仓
+    - 均线金叉入场,死叉出场
+    """
+    
+    params = (
+        ('fast', 20),
+        ('slow', 60),
+        ('trend_threshold', 0.02),
+        ('printlog', False),
+    )
+    
+    def __init__(self):
+        self.dataclose = self.datas[0].close
+        self.order = None
+        
+        # 均线
+        self.sma_fast = bt.indicators.SMA(period=self.p.fast)
+        self.sma_slow = bt.indicators.SMA(period=self.p.slow)
+        self.crossover = bt.indicators.CrossOver(self.sma_fast, self.sma_slow)
+        
+        # 趋势: 价格相对快均线的偏离
+        self.trend = (self.dataclose - self.sma_fast) / self.sma_fast
+        
+    def next(self):
+        if self.order:
+            return
+            
+        # 当前趋势
+        trend_val = self.trend[0] if not np.isnan(self.trend[0]) else 0
+        
+        # 金叉 + 趋势向上 = 买入
+        if self.crossover > 0 and trend_val > -self.p.trend_threshold:
+            if not self.position:
+                size = int(self.broker.getcash() / self.dataclose[0] / 100) * 100
+                if size > 0:
+                    self.order = self.buy(size=size)
+                    if self.p.printlog:
+                        self.log(f'BUY @ {self.dataclose[0]:.2f}, Trend: {trend_val:.4f}')
+                        
+        # 死叉 或 趋势转弱 = 卖出
+        elif self.crossover < 0 or (self.position and trend_val < -self.p.trend_threshold * 2):
+            if self.position:
+                self.order = self.close()
+                if self.p.printlog:
+                    reason = 'Death Cross' if self.crossover < 0 else 'Weak Trend'
+                    self.log(f'SELL @ {self.dataclose[0]:.2f}, Reason: {reason}')
+    
+    def notify_order(self, order):
+        if order.status in [order.Submitted, order.Accepted]:
+            return
+        if order.status in [order.Completed]:
+            if order.isbuy():
+                self.log(f'BUY EXECUTED @ {order.executed.price:.2f}')
+            else:
+                self.log(f'SELL EXECUTED @ {order.executed.price:.2f}')
+        self.order = None
+    
+    def log(self, txt, dt=None):
+        if not self.p.printlog:
+            return
+        dt = dt or self.datas[0].datetime.date(0)
+        print(f'{dt.isoformat()} {txt}')
+    
+    def stop(self):
+        roi = (self.broker.getvalue() / self.broker.startingcash - 1) * 100
+        print(f'\n=== 最终收益: {roi:.2f}% ===')
+
+
+def run_simple_regime(csv_file="chinext50.csv", cash=100000.0):
+    """运行简化Regime策略"""
+    cerebro = bt.Cerebro()
+    
+    df = pd.read_csv(csv_file, parse_dates=['datetime'], index_col='datetime')
+    data = bt.feeds.PandasData(dataname=df)
+    cerebro.adddata(data)
+    
+    cerebro.addstrategy(SimpleRegimeStrategy, printlog=False)
+    cerebro.broker.setcash(cash)
+    cerebro.broker.setcommission(commission=0.001)
+    
+    cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name='sharpe', riskfreerate=0.02)
+    cerebro.addanalyzer(bt.analyzers.DrawDown, _name='drawdown')
+    cerebro.addanalyzer(bt.analyzers.Returns, _name='returns')
+    cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name='trades')
+    
+    print('=== 简化Regime策略回测 ===')
+    print(f'初始资金: {cerebro.broker.getvalue():.2f}')
+    
+    results = cerebro.run()
+    strat = results[0]
+    
+    print(f'\n最终资金: {cerebro.broker.getvalue():.2f}')
+    
+    returns = strat.analyzers.returns.get_analysis()
+    print(f"年化收益: {returns.get('rnorm100', 0):.2f}%")
+    
+    sharpe = strat.analyzers.sharpe.get_analysis()
+    sharpe_val = sharpe.get('sharperatio', 0)
+    print(f"夏普比率: {sharpe_val:.3f}" if sharpe_val else "夏普比率: N/A")
+    
+    drawdown = strat.analyzers.drawdown.get_analysis()
+    print(f"最大回撤: {drawdown.get('max', {}).get('drawdown', 0):.2f}%")
+    
+    trades = strat.analyzers.trades.get_analysis()
+    if trades.get('total'):
+        total_trades = trades['total'].get('total', 0)
+        won_trades = trades['won'].get('total', 0) if trades.get('won') else 0
+        print(f"总交易次数: {total_trades}")
+        print(f"盈利次数: {won_trades}")
+        if total_trades > 0:
+            print(f"胜率: {won_trades/total_trades:.1%}")
+    
+    return cerebro, strat
+
+
+if __name__ == "__main__":
+    run_simple_regime()

+ 210 - 0
backtrader-lab/regime_strategy.py

@@ -0,0 +1,210 @@
+"""
+市场状态感知策略 (Regime-Aware Strategy)
+基于Regime Detection的动态仓位管理
+"""
+
+import backtrader as bt
+import pandas as pd
+import numpy as np
+
+class RegimeAwareStrategy(bt.Strategy):
+    """
+    创业板50状态感知策略
+    
+    核心逻辑:
+    - 强趋势上涨: 满仓持有
+    - 弱趋势上涨: 半仓持有
+    - 震荡整理: 轻仓或空仓
+    - 弱趋势下跌: 空仓或轻仓做空(如允许)
+    - 强趋势下跌: 空仓观望
+    
+    入场信号: 20/60日均线金叉
+    出场信号: 20/60日均线死叉 或 状态恶化
+    """
+    
+    params = (
+        ('fast_ma', 20),
+        ('slow_ma', 60),
+        ('vol_short', 20),
+        ('vol_long', 60),
+        ('trend_threshold', 0.03),
+        ('vol_percentile_threshold', 0.6),
+        ('strong_bull_pct', 1.0),    # 强趋势上涨仓位
+        ('weak_bull_pct', 0.5),      # 弱趋势上涨仓位
+        ('consolidation_pct', 0.2),  # 震荡仓位
+        ('weak_bear_pct', 0.0),      # 弱趋势下跌仓位
+        ('strong_bear_pct', 0.0),    # 强趋势下跌仓位
+        ('printlog', True),
+    )
+    
+    def __init__(self):
+        self.dataclose = self.datas[0].close
+        self.order = None
+        
+        # 双均线
+        self.sma_fast = bt.indicators.SMA(period=self.p.fast_ma)
+        self.sma_slow = bt.indicators.SMA(period=self.p.slow_ma)
+        self.crossover = bt.indicators.CrossOver(self.sma_fast, self.sma_slow)
+        
+        # 波动率计算
+        self.returns = bt.indicators.PctChange(self.dataclose, period=1)
+        self.vol_short = bt.indicators.StdDev(self.returns, period=self.p.vol_short) * np.sqrt(252)
+        self.vol_long = bt.indicators.StdDev(self.returns, period=self.p.vol_long) * np.sqrt(252)
+        
+        # 趋势强度
+        self.ma_deviation = (self.dataclose - self.sma_fast) / self.sma_fast
+        self.trend_strength = bt.indicators.SMA(self.ma_deviation, period=self.p.fast_ma)
+        
+        # 存储状态历史
+        self.regime_history = []
+        
+    def get_current_regime(self):
+        """判断当前市场状态"""
+        # 获取当前值
+        vol_pct = self.vol_short[0] / self.vol_long[0] if self.vol_long[0] != 0 else 1.0
+        trend = self.trend_strength[0]
+        
+        # 高波动判断
+        high_vol = vol_pct > 1.2  # 短期波动率高于长期20%
+        
+        # 趋势判断
+        strong_up = trend > self.p.trend_threshold
+        strong_down = trend < -self.p.trend_threshold
+        
+        if high_vol and strong_up:
+            return 'strong_bull'
+        elif (not high_vol) and strong_up:
+            return 'weak_bull'
+        elif high_vol and strong_down:
+            return 'strong_bear'
+        elif (not high_vol) and strong_down:
+            return 'weak_bear'
+        else:
+            return 'consolidation'
+    
+    def get_target_position(self, regime):
+        """根据状态确定目标仓位"""
+        position_map = {
+            'strong_bull': self.p.strong_bull_pct,
+            'weak_bull': self.p.weak_bull_pct,
+            'consolidation': self.p.consolidation_pct,
+            'weak_bear': self.p.weak_bear_pct,
+            'strong_bear': self.p.strong_bear_pct,
+        }
+        return position_map.get(regime, 0)
+    
+    def next(self):
+        # 当前状态
+        regime = self.get_current_regime()
+        self.regime_history.append(regime)
+        
+        # 目标仓位
+        target_pct = self.get_target_position(regime)
+        
+        # 金叉信号
+        golden_cross = self.crossover > 0
+        # 死叉信号
+        death_cross = self.crossover < 0
+        
+        # 当前仓位
+        current_value = self.broker.getvalue()
+        cash = self.broker.getcash()
+        position_size = self.position.size if self.position else 0
+        current_pct = (position_size * self.dataclose[0]) / current_value if current_value > 0 else 0
+        
+        # 交易逻辑
+        if target_pct > 0 and position_size == 0 and golden_cross:
+            # 入场: 有仓位空间 + 空仓 + 金叉
+            size = int((current_value * target_pct) / self.dataclose[0] / 100) * 100
+            if size > 0:
+                self.order = self.buy(size=size)
+                if self.p.printlog:
+                    self.log(f'BUY [{regime}], Size: {size}, Target: {target_pct:.0%}')
+                    
+        elif position_size > 0 and (death_cross or target_pct == 0):
+            # 出场: 有持仓 + (死叉 或 状态恶化到0仓位)
+            self.order = self.close()
+            if self.p.printlog:
+                reason = 'Death Cross' if death_cross else f'Regime: {regime}'
+                self.log(f'SELL [{reason}], Size: {position_size}')
+                
+        elif position_size > 0 and target_pct < current_pct * 0.8:
+            # 减仓: 状态恶化但未到0
+            new_size = int((current_value * target_pct) / self.dataclose[0] / 100) * 100
+            if new_size < position_size:
+                close_size = position_size - new_size
+                self.order = self.sell(size=close_size)
+                if self.p.printlog:
+                    self.log(f'REDUCE [{regime}], From {current_pct:.1%} to {target_pct:.0%}')
+    
+    def notify_order(self, order):
+        if order.status in [order.Submitted, order.Accepted]:
+            return
+        if order.status in [order.Completed]:
+            if order.isbuy():
+                self.log(f'BUY EXECUTED @ {order.executed.price:.2f}')
+            else:
+                self.log(f'SELL EXECUTED @ {order.executed.price:.2f}')
+        self.order = None
+    
+    def log(self, txt, dt=None):
+        dt = dt or self.datas[0].datetime.date(0)
+        print(f'{dt.isoformat()} {txt}')
+    
+    def stop(self):
+        # 统计状态分布
+        if self.regime_history:
+            from collections import Counter
+            regime_counts = Counter(self.regime_history)
+            print('\n=== 状态分布 ===')
+            for regime, count in regime_counts.most_common():
+                print(f'{regime}: {count} 天 ({count/len(self.regime_history):.1%})')
+        
+        roi = (self.broker.getvalue() / self.broker.startingcash - 1) * 100
+        print(f'\n=== 最终收益: {roi:.2f}% ===')
+
+
+def run_regime_backtest(csv_file="chinext50.csv", cash=100000.0):
+    """运行状态感知策略回测"""
+    cerebro = bt.Cerebro()
+    
+    # 数据
+    df = pd.read_csv(csv_file, parse_dates=['datetime'], index_col='datetime')
+    data = bt.feeds.PandasData(dataname=df)
+    cerebro.adddata(data)
+    
+    # 策略
+    cerebro.addstrategy(RegimeAwareStrategy)
+    
+    # 设置
+    cerebro.broker.setcash(cash)
+    cerebro.broker.setcommission(commission=0.001)
+    
+    # 分析器
+    cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name='sharpe', riskfreerate=0.02)
+    cerebro.addanalyzer(bt.analyzers.DrawDown, _name='drawdown')
+    cerebro.addanalyzer(bt.analyzers.Returns, _name='returns')
+    
+    print('=== 状态感知策略回测 ===')
+    print(f'初始资金: {cerebro.broker.getvalue():.2f}')
+    
+    results = cerebro.run()
+    strat = results[0]
+    
+    print(f'\n=== 回测指标 ===')
+    returns = strat.analyzers.returns.get_analysis()
+    print(f"年化收益: {returns.get('rnorm100', 0):.2f}%")
+    
+    sharpe = strat.analyzers.sharpe.get_analysis()
+    sharpe_ratio = sharpe.get('sharperatio')
+    sharpe_text = 'N/A' if sharpe_ratio is None else f"{sharpe_ratio:.3f}"
+    print(f"夏普比率: {sharpe_text}")
+    
+    drawdown = strat.analyzers.drawdown.get_analysis()
+    print(f"最大回撤: {drawdown.get('max', {}).get('drawdown', 0):.2f}%")
+    
+    return cerebro, strat
+
+
+if __name__ == "__main__":
+    run_regime_backtest()

+ 46 - 0
backtrader-lab/regime_strategy_final.py

@@ -0,0 +1,46 @@
+"""创业板50双均线策略 - 最终工作版"""
+import backtrader as bt
+import pandas as pd
+import numpy as np
+
+class Chinext50Strategy(bt.Strategy):
+    params = (('fast', 20), ('slow', 60))
+    
+    def __init__(self):
+        self.sma_fast = bt.indicators.SMA(period=self.p.fast)
+        self.sma_slow = bt.indicators.SMA(period=self.p.slow)
+        
+    def next(self):
+        if len(self) < 2:
+            return
+        fast_now, fast_prev = self.sma_fast[0], self.sma_fast[-1]
+        slow_now, slow_prev = self.sma_slow[0], self.sma_slow[-1]
+        if np.isnan(fast_now):
+            return
+        # 金叉买入
+        if fast_prev <= slow_prev and fast_now > slow_now and not self.position:
+            close = self.datas[0].close[0]
+            size = (int(self.broker.getcash() / close) // 100) * 100
+            if size > 0:
+                self.buy(size=size)
+        # 死叉卖出
+        elif fast_prev >= slow_prev and fast_now < slow_now and self.position:
+            self.close()
+
+def run_strategy():
+    cerebro = bt.Cerebro()
+    df = pd.read_csv('chinext50.csv', parse_dates=['datetime'], index_col='datetime')
+    cerebro.adddata(bt.feeds.PandasData(dataname=df))
+    cerebro.addstrategy(Chinext50Strategy)
+    cerebro.broker.setcash(100000.0)
+    cerebro.broker.setcommission(commission=0.001)
+    cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name='sharpe', riskfreerate=0.02)
+    cerebro.addanalyzer(bt.analyzers.DrawDown, _name='drawdown')
+    cerebro.addanalyzer(bt.analyzers.Returns, _name='returns')
+    print('初始资金: %.2f' % cerebro.broker.getvalue())
+    results = cerebro.run()
+    print('最终资金: %.2f' % cerebro.broker.getvalue())
+    return cerebro, results[0]
+
+if __name__ == '__main__':
+    run_strategy()

+ 139 - 0
backtrader-lab/regime_v2.py

@@ -0,0 +1,139 @@
+"""
+修正版Regime策略 - 使用正确的CrossOver判断
+"""
+
+import backtrader as bt
+import pandas as pd
+import numpy as np
+
+class RegimeStrategyV2(bt.Strategy):
+    """
+    状态感知策略V2
+    
+    修复:
+    - CrossOver只在穿越当天返回1/-1,需要检测这个变化
+    - 加入趋势强度过滤
+    """
+    
+    params = (
+        ('fast', 20),
+        ('slow', 60),
+        ('trend_threshold', 0.02),
+        ('printlog', True),
+    )
+    
+    def __init__(self):
+        self.dataclose = self.datas[0].close
+        self.order = None
+        
+        # 均线
+        self.sma_fast = bt.indicators.SMA(period=self.p.fast)
+        self.sma_slow = bt.indicators.SMA(period=self.p.slow)
+        
+        # 趋势强度
+        self.trend = (self.dataclose - self.sma_fast) / self.sma_fast
+        
+        # 记录上一个cross状态
+        self.last_cross = 0
+        
+    def next(self):
+        if self.order:
+            return
+            
+        # 当前cross状态: 1=金叉(快上穿慢), -1=死叉(快下穿慢), 0=无变化
+        cross_now = 0
+        if self.sma_fast[0] > self.sma_slow[0] and self.sma_fast[-1] <= self.sma_slow[-1]:
+            cross_now = 1  # 金叉
+        elif self.sma_fast[0] < self.sma_slow[0] and self.sma_fast[-1] >= self.sma_slow[-1]:
+            cross_now = -1  # 死叉
+            
+        trend_val = self.trend[0] if not np.isnan(self.trend[0]) else 0
+        
+        # 金叉入场
+        if cross_now == 1:
+            if not self.position:
+                size = int(self.broker.getcash() / self.dataclose[0] / 100) * 100
+                if size > 0:
+                    self.order = self.buy(size=size)
+                    if self.p.printlog:
+                        self.log(f'BUY @ {self.dataclose[0]:.2f}, Trend: {trend_val:.4f}')
+                        
+        # 死叉出场
+        elif cross_now == -1:
+            if self.position:
+                self.order = self.close()
+                if self.p.printlog:
+                    self.log(f'SELL @ {self.dataclose[0]:.2f}, Trend: {trend_val:.4f}')
+    
+    def notify_order(self, order):
+        if order.status in [order.Submitted, order.Accepted]:
+            return
+        if order.status in [order.Completed]:
+            if order.isbuy():
+                self.log(f'BUY EXECUTED @ {order.executed.price:.2f}')
+            else:
+                self.log(f'SELL EXECUTED @ {order.executed.price:.2f}')
+        self.order = None
+    
+    def log(self, txt, dt=None):
+        if not self.p.printlog:
+            return
+        dt = dt or self.datas[0].datetime.date(0)
+        print(f'{dt.isoformat()} {txt}')
+    
+    def stop(self):
+        roi = (self.broker.getvalue() / self.broker.startingcash - 1) * 100
+        print(f'\n=== 最终收益: {roi:.2f}% ===')
+
+
+def run_regime_v2(csv_file="chinext50.csv", cash=100000.0):
+    """运行修正版Regime策略"""
+    cerebro = bt.Cerebro()
+    
+    df = pd.read_csv(csv_file, parse_dates=['datetime'], index_col='datetime')
+    data = bt.feeds.PandasData(dataname=df)
+    cerebro.adddata(data)
+    
+    cerebro.addstrategy(RegimeStrategyV2, printlog=False)
+    cerebro.broker.setcash(cash)
+    cerebro.broker.setcommission(commission=0.001)
+    
+    cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name='sharpe', riskfreerate=0.02)
+    cerebro.addanalyzer(bt.analyzers.DrawDown, _name='drawdown')
+    cerebro.addanalyzer(bt.analyzers.Returns, _name='returns')
+    cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name='trades')
+    
+    print('=== Regime策略V2回测 ===')
+    print(f'初始资金: {cerebro.broker.getvalue():.2f}')
+    
+    results = cerebro.run()
+    strat = results[0]
+    
+    print(f'最终资金: {cerebro.broker.getvalue():.2f}')
+    
+    returns = strat.analyzers.returns.get_analysis()
+    print(f"年化收益: {returns.get('rnorm100', 0):.2f}%")
+    
+    sharpe = strat.analyzers.sharpe.get_analysis()
+    sharpe_val = sharpe.get('sharperatio', 0)
+    if sharpe_val:
+        print(f"夏普比率: {sharpe_val:.3f}")
+    else:
+        print("夏普比率: N/A")
+    
+    drawdown = strat.analyzers.drawdown.get_analysis()
+    print(f"最大回撤: {drawdown.get('max', {}).get('drawdown', 0):.2f}%")
+    
+    trades = strat.analyzers.trades.get_analysis()
+    if trades and trades.get('total'):
+        total = trades['total'].get('total', 0)
+        won = trades['won'].get('total', 0) if trades.get('won') else 0
+        print(f"总交易: {total}, 盈利: {won}")
+        if total > 0:
+            print(f"胜率: {won/total:.1%}")
+    
+    return cerebro, strat
+
+
+if __name__ == "__main__":
+    run_regime_v2()

+ 120 - 0
backtrader-lab/regime_working.py

@@ -0,0 +1,120 @@
+"""
+Working Regime Strategy - 基于已成功验证的cross检测逻辑
+"""
+
+import backtrader as bt
+import pandas as pd
+import numpy as np
+
+class WorkingRegimeStrategy(bt.Strategy):
+    params = (
+        ('fast', 20),
+        ('slow', 60),
+        ('printlog', False),
+    )
+    
+    def __init__(self):
+        self.dataclose = self.datas[0].close
+        self.order = None
+        self.sma_fast = bt.indicators.SMA(period=self.p.fast)
+        self.sma_slow = bt.indicators.SMA(period=self.p.slow)
+        self.trade_count = 0
+        
+    def next(self):
+        if self.order:
+            return
+            
+        if len(self) < 2:
+            return
+            
+        fast_now = self.sma_fast[0]
+        fast_prev = self.sma_fast[-1]
+        slow_now = self.sma_slow[0]
+        slow_prev = self.sma_slow[-1]
+        
+        if np.isnan(fast_now) or np.isnan(fast_prev):
+            return
+            
+        # 金叉检测
+        if fast_prev <= slow_prev and fast_now > slow_now:
+            if not self.position:
+                size = int(self.broker.getcash() / self.dataclose[0] / 100) * 100
+                if size > 0:
+                    self.order = self.buy(size=size)
+                    self.trade_count += 1
+                    if self.p.printlog:
+                        self.log(f'BUY #{self.trade_count} @ {self.dataclose[0]:.2f}')
+                        
+        # 死叉检测
+        elif fast_prev >= slow_prev and fast_now < slow_now:
+            if self.position:
+                self.order = self.close()
+                if self.p.printlog:
+                    self.log(f'SELL @ {self.dataclose[0]:.2f}')
+    
+    def notify_order(self, order):
+        if order.status in [order.Completed]:
+            if order.isbuy():
+                self.log(f'BUY EXECUTED @ {order.executed.price:.2f}')
+            else:
+                self.log(f'SELL EXECUTED @ {order.executed.price:.2f}')
+        self.order = None
+    
+    def log(self, txt, dt=None):
+        if self.p.printlog:
+            dt = dt or self.datas[0].datetime.date(0)
+            print(f'{dt.isoformat()} {txt}')
+    
+    def stop(self):
+        roi = (self.broker.getvalue() / self.broker.startingcash - 1) * 100
+        print(f'\n=== 最终收益: {roi:.2f}% ===')
+        print(f'总交易: {self.trade_count}')
+
+
+def run_working_regime(csv_file="chinext50.csv"):
+    cerebro = bt.Cerebro()
+    
+    df = pd.read_csv(csv_file, parse_dates=['datetime'], index_col='datetime')
+    data = bt.feeds.PandasData(dataname=df)
+    cerebro.adddata(data)
+    
+    cerebro.addstrategy(WorkingRegimeStrategy, printlog=False)
+    cerebro.broker.setcash(100000.0)
+    cerebro.broker.setcommission(commission=0.001)
+    
+    cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name='sharpe', riskfreerate=0.02)
+    cerebro.addanalyzer(bt.analyzers.DrawDown, _name='drawdown')
+    cerebro.addanalyzer(bt.analyzers.Returns, _name='returns')
+    cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name='trades')
+    
+    print('=== 创业板50 Regime策略回测 ===')
+    print(f'初始资金: {cerebro.broker.getvalue():.2f}')
+    
+    results = cerebro.run()
+    strat = results[0]
+    
+    print(f'最终资金: {cerebro.broker.getvalue():.2f}')
+    
+    returns = strat.analyzers.returns.get_analysis()
+    print(f"年化收益: {returns.get('rnorm100', 0):.2f}%")
+    
+    sharpe = strat.analyzers.sharpe.get_analysis()
+    sharpe_val = sharpe.get('sharperatio', 0)
+    print(f"夏普比率: {sharpe_val:.3f}" if sharpe_val else "夏普比率: N/A")
+    
+    drawdown = strat.analyzers.drawdown.get_analysis()
+    print(f"最大回撤: {drawdown.get('max', {}).get('drawdown', 0):.2f}%")
+    
+    trades = strat.analyzers.trades.get_analysis()
+    if trades and trades.get('total'):
+        total = trades['total'].get('total', 0)
+        won = trades['won'].get('total', 0) if trades.get('won') else 0
+        print(f"总交易: {total}, 盈利: {won}")
+        if total > 0:
+            print(f"胜率: {won/total:.1%}")
+    
+    return cerebro, strat
+
+
+if __name__ == "__main__":
+    run_working_regime()

+ 7 - 0
backtrader-lab/run.sh

@@ -0,0 +1,7 @@
+#!/bin/bash
+set -euo pipefail
+
+cd "$(dirname "$0")"
+
+echo "=== 刷新远程创业板50数据并发送报告 ==="
+python3 gen_html_emails.py

+ 385 - 0
backtrader-lab/shortlist_combo_trials.py

@@ -0,0 +1,385 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import json
+import math
+from datetime import datetime
+from pathlib import Path
+
+import backtrader as bt
+import pandas as pd
+
+ROOT = Path(__file__).resolve().parent
+DATA_FILE = ROOT / "chinext50.csv"
+INITIAL_CASH = 100000.0
+COMMISSION = 0.001
+TRADING_DAYS = 252
+
+
+class Chinext50Data(bt.feeds.PandasData):
+    params = (
+        ("datetime", None),
+        ("open", "open"),
+        ("high", "high"),
+        ("low", "low"),
+        ("close", "close"),
+        ("volume", "volume"),
+        ("openinterest", None),
+    )
+
+
+class BasePortfolioStrategy(bt.Strategy):
+    params = (("rebalance_band", 0.05),)
+
+    def __init__(self):
+        self.order = None
+        self.entry_count = 0
+        self.bars_in_market = 0
+        self.exposure_sum = 0.0
+
+    def notify_order(self, order):
+        if order.status in [order.Submitted, order.Accepted]:
+            return
+        if order.status == order.Completed and order.isbuy():
+            self.entry_count += 1
+        self.order = None
+
+    def next(self):
+        portfolio_value = self.broker.getvalue()
+        if portfolio_value > 0:
+            position_value = abs(self.position.size) * self.data.close[0]
+            exposure = position_value / portfolio_value
+            self.exposure_sum += exposure
+            if exposure > 0:
+                self.bars_in_market += 1
+
+    def _target_size_for_weight(self, target_weight: float) -> int:
+        target_weight = max(0.0, min(1.0, target_weight))
+        portfolio_value = self.broker.getvalue()
+        price = self.data.close[0]
+        if portfolio_value <= 0 or price <= 0:
+            return 0
+        target_value = portfolio_value * target_weight
+        return max(int(target_value / price), 0)
+
+    def _rebalance_to_weight(self, target_weight: float):
+        target_size = self._target_size_for_weight(target_weight)
+        current_size = self.position.size
+        size_delta = target_size - current_size
+        if size_delta > 0:
+            self.order = self.buy(size=size_delta)
+        elif size_delta < 0:
+            self.order = self.sell(size=abs(size_delta))
+
+
+class Core3ComboStrategy(BasePortfolioStrategy):
+    """Equal-weight average of DualThrustBasic + DonchianRegime + MVT(0.30)."""
+
+    def __init__(self):
+        super().__init__()
+        close = self.data.close
+        self.roc_short = bt.indicators.ROC(close, period=20)
+        self.roc_long = bt.indicators.ROC(close, period=120)
+        self.sma150 = bt.indicators.SMA(close, period=150)
+        returns = bt.indicators.PctChange(close, period=1)
+        self.volatility = bt.indicators.StdDev(returns, period=30)
+        self.highest_high = bt.indicators.Highest(self.data.high, period=55)
+        self.lowest_low = bt.indicators.Lowest(self.data.low, period=30)
+        self.dual_active = False
+        self.don_active = False
+
+    def next(self):
+        super().next()
+        if self.order:
+            return
+
+        weights = []
+
+        # DualThrustBasic (20, 0.3, 0.3)
+        dual_w = 0.0
+        if len(self) > 20:
+            closes = [float(self.data.close[-offset]) for offset in range(1, 21)]
+            thrust_range = max(closes) - min(closes)
+            reference_price = float(self.data.close[-1])
+            upper = reference_price + 0.3 * thrust_range
+            lower = reference_price - 0.3 * thrust_range
+            if not self.dual_active and self.data.close[0] > upper:
+                self.dual_active = True
+            elif self.dual_active and self.data.close[0] < lower:
+                self.dual_active = False
+            dual_w = 1.0 if self.dual_active else 0.0
+        weights.append(dual_w)
+
+        # DonchianRegime (55, 30, 150)
+        don_w = 0.0
+        if len(self) > 150 and not any(math.isnan(x) for x in [self.highest_high[-1], self.lowest_low[-1], self.sma150[0]]):
+            breakout_signal = self.data.close[0] > self.highest_high[-1] and self.data.close[0] > self.sma150[0]
+            exit_signal = self.data.close[0] < self.lowest_low[-1] or self.data.close[0] < self.sma150[0]
+            if not self.don_active and breakout_signal:
+                self.don_active = True
+            elif self.don_active and exit_signal:
+                self.don_active = False
+            don_w = 1.0 if self.don_active else 0.0
+        weights.append(don_w)
+
+        # MomentumVolTarget (0.30)
+        mvt_w = 0.0
+        if not any(math.isnan(x) for x in [self.roc_short[0], self.roc_long[0], self.sma150[0], self.volatility[0]]) and self.volatility[0] > 0:
+            signal = self.roc_short[0] > 0 and self.roc_long[0] > 0 and self.data.close[0] > self.sma150[0]
+            if signal:
+                annualized_vol = self.volatility[0] * math.sqrt(TRADING_DAYS)
+                mvt_w = min(1.0, 0.30 / annualized_vol)
+        weights.append(mvt_w)
+
+        target_weight = sum(weights) / len(weights)
+        portfolio_value = self.broker.getvalue()
+        current_weight = 0.0
+        if portfolio_value > 0:
+            current_weight = (abs(self.position.size) * self.data.close[0]) / portfolio_value
+        if not self.position or abs(current_weight - target_weight) >= self.p.rebalance_band:
+            self._rebalance_to_weight(target_weight)
+
+
+class Balanced3ComboStrategy(BasePortfolioStrategy):
+    """Equal-weight average of DualThrustRegime035 + MVT(0.29) + DonchianHybrid(0.25)."""
+
+    def __init__(self):
+        super().__init__()
+        close = self.data.close
+        returns = bt.indicators.PctChange(close, period=1)
+        self.volatility = bt.indicators.StdDev(returns, period=30)
+        self.atr = bt.indicators.ATR(self.data, period=20)
+        self.roc_short = bt.indicators.ROC(close, period=20)
+        self.roc_long = bt.indicators.ROC(close, period=120)
+        self.sma120 = bt.indicators.SMA(close, period=120)
+        self.sma150 = bt.indicators.SMA(close, period=150)
+        self.highest_high = bt.indicators.Highest(self.data.high, period=55)
+        self.lowest_low = bt.indicators.Lowest(self.data.low, period=30)
+        self.dt_reg_active = False
+        self.hybrid_active = False
+        self.hybrid_highest_close = None
+
+    def next(self):
+        super().next()
+        if self.order:
+            return
+        weights = []
+
+        # DualThrustRegime (20, 0.35, 0.35, 120)
+        dt_w = 0.0
+        if len(self) > 120 and not math.isnan(self.sma120[0]):
+            closes = [float(self.data.close[-offset]) for offset in range(1, 21)]
+            thrust_range = max(closes) - min(closes)
+            reference_price = float(self.data.close[-1])
+            upper = reference_price + 0.35 * thrust_range
+            lower = reference_price - 0.35 * thrust_range
+            entry_signal = self.data.close[0] > upper and self.data.close[0] > self.sma120[0]
+            exit_signal = self.data.close[0] < lower or self.data.close[0] < self.sma120[0]
+            if not self.dt_reg_active and entry_signal:
+                self.dt_reg_active = True
+            elif self.dt_reg_active and exit_signal:
+                self.dt_reg_active = False
+            dt_w = 1.0 if self.dt_reg_active else 0.0
+        weights.append(dt_w)
+
+        # MVT (0.29)
+        mvt_w = 0.0
+        if not any(math.isnan(x) for x in [self.roc_short[0], self.roc_long[0], self.sma150[0], self.volatility[0]]) and self.volatility[0] > 0:
+            signal = self.roc_short[0] > 0 and self.roc_long[0] > 0 and self.data.close[0] > self.sma150[0]
+            if signal:
+                annualized_vol = self.volatility[0] * math.sqrt(TRADING_DAYS)
+                mvt_w = min(1.0, 0.29 / annualized_vol)
+        weights.append(mvt_w)
+
+        # DonchianHybrid (55,30,tv=0.25,atr=4)
+        hy_w = 0.0
+        if len(self) > 55 and not any(math.isnan(x) for x in [self.highest_high[-1], self.lowest_low[-1], self.volatility[0], self.atr[0]]) and self.volatility[0] > 0:
+            breakout_signal = self.data.close[0] > self.highest_high[-1]
+            channel_exit = self.data.close[0] < self.lowest_low[-1]
+            if not self.hybrid_active:
+                if breakout_signal:
+                    self.hybrid_active = True
+                    self.hybrid_highest_close = float(self.data.close[0])
+            else:
+                self.hybrid_highest_close = max(self.hybrid_highest_close or float(self.data.close[0]), float(self.data.close[0]))
+                trailing_stop = self.hybrid_highest_close - 4.0 * self.atr[0]
+                if channel_exit or self.data.close[0] < trailing_stop:
+                    self.hybrid_active = False
+                    self.hybrid_highest_close = None
+            if self.hybrid_active:
+                annualized_vol = self.volatility[0] * math.sqrt(TRADING_DAYS)
+                hy_w = min(1.0, 0.25 / annualized_vol)
+        weights.append(hy_w)
+
+        target_weight = sum(weights) / len(weights)
+        portfolio_value = self.broker.getvalue()
+        current_weight = 0.0
+        if portfolio_value > 0:
+            current_weight = (abs(self.position.size) * self.data.close[0]) / portfolio_value
+        if not self.position or abs(current_weight - target_weight) >= self.p.rebalance_band:
+            self._rebalance_to_weight(target_weight)
+
+
+class Top5BlendStrategy(BasePortfolioStrategy):
+    """Average of DTBasic + DonchianRegime + MVT0.30 + MVT0.29 + DonchianHybrid0.25."""
+
+    def __init__(self):
+        super().__init__()
+        close = self.data.close
+        returns = bt.indicators.PctChange(close, period=1)
+        self.volatility = bt.indicators.StdDev(returns, period=30)
+        self.atr = bt.indicators.ATR(self.data, period=20)
+        self.roc_short = bt.indicators.ROC(close, period=20)
+        self.roc_long = bt.indicators.ROC(close, period=120)
+        self.sma150 = bt.indicators.SMA(close, period=150)
+        self.highest_high = bt.indicators.Highest(self.data.high, period=55)
+        self.lowest_low = bt.indicators.Lowest(self.data.low, period=30)
+        self.dt_active = False
+        self.don_active = False
+        self.hybrid_active = False
+        self.hybrid_highest_close = None
+
+    def next(self):
+        super().next()
+        if self.order:
+            return
+        weights = []
+
+        # DTBasic
+        dt_w = 0.0
+        if len(self) > 20:
+            closes = [float(self.data.close[-offset]) for offset in range(1, 21)]
+            thrust_range = max(closes) - min(closes)
+            reference_price = float(self.data.close[-1])
+            upper = reference_price + 0.3 * thrust_range
+            lower = reference_price - 0.3 * thrust_range
+            if not self.dt_active and self.data.close[0] > upper:
+                self.dt_active = True
+            elif self.dt_active and self.data.close[0] < lower:
+                self.dt_active = False
+            dt_w = 1.0 if self.dt_active else 0.0
+        weights.append(dt_w)
+
+        # DonchianRegime
+        don_w = 0.0
+        if len(self) > 150 and not any(math.isnan(x) for x in [self.highest_high[-1], self.lowest_low[-1], self.sma150[0]]):
+            breakout_signal = self.data.close[0] > self.highest_high[-1] and self.data.close[0] > self.sma150[0]
+            exit_signal = self.data.close[0] < self.lowest_low[-1] or self.data.close[0] < self.sma150[0]
+            if not self.don_active and breakout_signal:
+                self.don_active = True
+            elif self.don_active and exit_signal:
+                self.don_active = False
+            don_w = 1.0 if self.don_active else 0.0
+        weights.append(don_w)
+
+        # MVT 0.30 and 0.29
+        for tv in (0.30, 0.29):
+            mvt_w = 0.0
+            if not any(math.isnan(x) for x in [self.roc_short[0], self.roc_long[0], self.sma150[0], self.volatility[0]]) and self.volatility[0] > 0:
+                signal = self.roc_short[0] > 0 and self.roc_long[0] > 0 and self.data.close[0] > self.sma150[0]
+                if signal:
+                    annualized_vol = self.volatility[0] * math.sqrt(TRADING_DAYS)
+                    mvt_w = min(1.0, tv / annualized_vol)
+            weights.append(mvt_w)
+
+        # DonchianHybrid 0.25
+        hy_w = 0.0
+        if len(self) > 55 and not any(math.isnan(x) for x in [self.highest_high[-1], self.lowest_low[-1], self.volatility[0], self.atr[0]]) and self.volatility[0] > 0:
+            breakout_signal = self.data.close[0] > self.highest_high[-1]
+            channel_exit = self.data.close[0] < self.lowest_low[-1]
+            if not self.hybrid_active:
+                if breakout_signal:
+                    self.hybrid_active = True
+                    self.hybrid_highest_close = float(self.data.close[0])
+            else:
+                self.hybrid_highest_close = max(self.hybrid_highest_close or float(self.data.close[0]), float(self.data.close[0]))
+                trailing_stop = self.hybrid_highest_close - 4.0 * self.atr[0]
+                if channel_exit or self.data.close[0] < trailing_stop:
+                    self.hybrid_active = False
+                    self.hybrid_highest_close = None
+            if self.hybrid_active:
+                annualized_vol = self.volatility[0] * math.sqrt(TRADING_DAYS)
+                hy_w = min(1.0, 0.25 / annualized_vol)
+        weights.append(hy_w)
+
+        target_weight = sum(weights) / len(weights)
+        portfolio_value = self.broker.getvalue()
+        current_weight = 0.0
+        if portfolio_value > 0:
+            current_weight = (abs(self.position.size) * self.data.close[0]) / portfolio_value
+        if not self.position or abs(current_weight - target_weight) >= self.p.rebalance_band:
+            self._rebalance_to_weight(target_weight)
+
+
+def load_dataframe() -> pd.DataFrame:
+    df = pd.read_csv(DATA_FILE, parse_dates=['datetime'], index_col='datetime')
+    df = df.sort_index()
+    return df[['open', 'high', 'low', 'close', 'volume']].copy()
+
+
+def run_strategy(strategy_cls, df: pd.DataFrame) -> dict:
+    cerebro = bt.Cerebro(stdstats=False)
+    cerebro.adddata(Chinext50Data(dataname=df))
+    cerebro.addstrategy(strategy_cls)
+    cerebro.broker.setcash(INITIAL_CASH)
+    cerebro.broker.setcommission(commission=COMMISSION)
+    cerebro.addanalyzer(bt.analyzers.Returns, _name='returns')
+    cerebro.addanalyzer(bt.analyzers.DrawDown, _name='drawdown')
+    cerebro.addanalyzer(bt.analyzers.SharpeRatio_A, _name='sharpe', riskfreerate=0.02)
+    cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name='trades')
+    strategy = cerebro.run()[0]
+    final_value = cerebro.broker.getvalue()
+    returns = strategy.analyzers.returns.get_analysis()
+    drawdown = strategy.analyzers.drawdown.get_analysis()
+    sharpe = strategy.analyzers.sharpe.get_analysis()
+    trades = strategy.analyzers.trades.get_analysis()
+    closed_trades = trades.get('total', {}).get('closed', 0)
+    won_trades = trades.get('won', {}).get('total', 0)
+    total_bars = len(df)
+    return {
+        'final_value': round(final_value, 2),
+        'total_return_pct': round((final_value / INITIAL_CASH - 1.0) * 100.0, 2),
+        'annual_return_pct': round(returns.get('rnorm100', 0.0), 2),
+        'max_drawdown_pct': round(drawdown.get('max', {}).get('drawdown', 0.0), 2),
+        'sharpe': round(sharpe['sharperatio'], 3) if sharpe.get('sharperatio') is not None else None,
+        'entries': strategy.entry_count,
+        'closed_trades': closed_trades,
+        'win_rate_pct': round((won_trades / closed_trades) * 100.0, 2) if closed_trades else 0.0,
+        'exposure_pct': round((strategy.exposure_sum / total_bars) * 100.0, 2),
+    }
+
+
+def main():
+    df = load_dataframe()
+    combos = [
+        ('Core3ComboStrategy', Core3ComboStrategy),
+        ('Balanced3ComboStrategy', Balanced3ComboStrategy),
+        ('Top5BlendStrategy', Top5BlendStrategy),
+    ]
+    results = []
+    for name, cls in combos:
+        metrics = run_strategy(cls, df)
+        results.append({'name': name, 'metrics': metrics})
+        print(f"{name}: final={metrics['final_value']}, total_return={metrics['total_return_pct']}%, annual_return={metrics['annual_return_pct']}%, sharpe={metrics['sharpe']}, max_dd={metrics['max_drawdown_pct']}%, closed_trades={metrics['closed_trades']}, win_rate={metrics['win_rate_pct']}%, avg_exposure={metrics['exposure_pct']}%")
+
+    stamp = datetime.now().strftime('%Y%m%d-%H%M%S')
+    json_path = ROOT / f'shortlist_combo_trials_{stamp}.json'
+    md_path = ROOT / f'shortlist_combo_trials_{stamp}.md'
+    json_path.write_text(json.dumps({'results': results}, ensure_ascii=False, indent=2))
+
+    lines = [
+        '# Shortlist Combo Trials',
+        '',
+        '| Combo | Annual Return | Sharpe | Max DD | Closed Trades | Win Rate | Avg Exposure |',
+        '| --- | ---: | ---: | ---: | ---: | ---: | ---: |',
+    ]
+    for item in results:
+        m = item['metrics']
+        lines.append(f"| {item['name']} | {m['annual_return_pct']:.2f}% | {m['sharpe']} | {m['max_drawdown_pct']:.2f}% | {m['closed_trades']} | {m['win_rate_pct']:.2f}% | {m['exposure_pct']:.2f}% |")
+    md_path.write_text('\n'.join(lines))
+    print(f'JSON_RESULT={json_path}')
+    print(f'MD_RESULT={md_path}')
+
+
+if __name__ == '__main__':
+    main()

+ 7 - 0
backtrader-lab/shortlist_combo_trials_20260413-170128.md

@@ -0,0 +1,7 @@
+# Shortlist Combo Trials
+
+| Combo | Annual Return | Sharpe | Max DD | Closed Trades | Win Rate | Avg Exposure |
+| --- | ---: | ---: | ---: | ---: | ---: | ---: |
+| Core3ComboStrategy | 12.98% | 0.454 | 42.77% | 64 | 31.25% | 33.85% |
+| Balanced3ComboStrategy | 13.08% | 0.485 | 28.27% | 61 | 32.79% | 23.51% |
+| Top5BlendStrategy | 12.68% | 0.46 | 38.08% | 63 | 28.57% | 28.58% |

+ 12 - 0
backtrader-lab/shortlist_combo_trials_batch2_20260413-171317.md

@@ -0,0 +1,12 @@
+# Shortlist Combo Trials Batch 2
+
+## Baseline
+- Balanced3ComboStrategy: annual `13.08%`, Sharpe `0.485`, max DD `28.27%`
+
+## Results
+
+| Combo | Annual Return | Sharpe | Max DD | Closed Trades | Win Rate | Avg Exposure |
+| --- | ---: | ---: | ---: | ---: | ---: | ---: |
+| Balanced3AggressiveComboStrategy | 12.92% | 0.452 | 38.02% | 81 | 33.33% | 29.91% |
+| Balanced3DefensiveComboStrategy | 10.61% | 0.338 | 28.73% | 42 | 33.33% | 22.83% |
+| Balanced4ExtendedComboStrategy | 12.69% | 0.455 | 29.25% | 61 | 31.15% | 22.76% |

+ 50 - 0
backtrader-lab/shortlist_subperiod_review_20260413-164800.md

@@ -0,0 +1,50 @@
+# Shortlist Subperiod Review
+
+## Time Windows
+- **2014-06_to_2018-12**: `2014-06-18` ~ `2018-12-31` (1110 bars)
+- **2019-01_to_2022-12**: `2019-01-01` ~ `2022-12-31` (972 bars)
+- **2023-01_to_2026-04**: `2023-01-01` ~ `2026-04-03` (786 bars)
+
+## 2014-06_to_2018-12
+
+| Strategy | Annual Return | Sharpe | Max DD | Closed Trades | Win Rate |
+| --- | ---: | ---: | ---: | ---: | ---: |
+| DualThrustRegime_r20_k035_035_reg120 | 18.26% | 0.445 | 19.96% | 2 | 50.00% |
+| DualThrustBasicStrategy | 15.39% | 0.373 | 35.17% | 17 | 29.41% |
+| DonchianAtrTrailStrategy | 12.22% | 0.329 | 27.09% | 10 | 40.00% |
+| DonchianRegimeStrategy | 8.12% | 0.296 | 37.28% | 7 | 42.86% |
+| DonchianHybrid_b55_e30_tv025_atr4 | 6.28% | 0.226 | 26.43% | 11 | 27.27% |
+| MomentumVolTargetStrategy | 6.14% | 0.225 | 35.55% | 15 | 20.00% |
+| MVT_reg150_tv029 | 5.65% | 0.212 | 35.47% | 15 | 20.00% |
+| MacdLongMaStrategy | 0.44% | 0.021 | 39.72% | 21 | 33.33% |
+
+## 2019-01_to_2022-12
+
+| Strategy | Annual Return | Sharpe | Max DD | Closed Trades | Win Rate |
+| --- | ---: | ---: | ---: | ---: | ---: |
+| DualThrustBasicStrategy | 17.86% | 0.721 | 29.40% | 20 | 50.00% |
+| MomentumVolTargetStrategy | 17.25% | 0.678 | 16.09% | 25 | 40.00% |
+| MacdLongMaStrategy | 17.00% | 0.617 | 14.36% | 31 | 58.06% |
+| MVT_reg150_tv029 | 16.17% | 0.654 | 17.20% | 25 | 40.00% |
+| DonchianRegimeStrategy | 13.86% | 0.663 | 18.51% | 7 | 57.14% |
+| DualThrustRegime_r20_k035_035_reg120 | 12.50% | 0.528 | 23.94% | 12 | 58.33% |
+| DonchianHybrid_b55_e30_tv025_atr4 | 8.94% | 0.506 | 16.97% | 14 | 50.00% |
+| DonchianAtrTrailStrategy | 7.87% | 0.462 | 19.98% | 14 | 64.29% |
+
+## 2023-01_to_2026-04
+
+| Strategy | Annual Return | Sharpe | Max DD | Closed Trades | Win Rate |
+| --- | ---: | ---: | ---: | ---: | ---: |
+| DualThrustBasicStrategy | 16.65% | 0.694 | 21.11% | 18 | 38.89% |
+| MomentumVolTargetStrategy | 12.87% | 0.598 | 15.76% | 23 | 39.13% |
+| MVT_reg150_tv029 | 12.54% | 0.628 | 15.36% | 24 | 41.67% |
+| DonchianRegimeStrategy | 11.91% | 0.388 | 14.67% | 4 | 25.00% |
+| DonchianHybrid_b55_e30_tv025_atr4 | 10.60% | 0.486 | 11.31% | 6 | 66.67% |
+| DonchianAtrTrailStrategy | 7.52% | 0.335 | 16.29% | 6 | 50.00% |
+| MacdLongMaStrategy | 6.53% | 0.226 | 18.36% | 15 | 40.00% |
+| DualThrustRegime_r20_k035_035_reg120 | 4.62% | 0.165 | 18.26% | 12 | 16.67% |
+
+## Quick Winners by Period
+- **2014-06_to_2018-12**: best annual `DualThrustRegime_r20_k035_035_reg120` (18.26%), best Sharpe `DualThrustRegime_r20_k035_035_reg120` (0.445), lowest DD `DualThrustRegime_r20_k035_035_reg120` (19.96%)
+- **2019-01_to_2022-12**: best annual `DualThrustBasicStrategy` (17.86%), best Sharpe `DualThrustBasicStrategy` (0.721), lowest DD `MacdLongMaStrategy` (14.36%)
+- **2023-01_to_2026-04**: best annual `DualThrustBasicStrategy` (16.65%), best Sharpe `DualThrustBasicStrategy` (0.694), lowest DD `DonchianHybrid_b55_e30_tv025_atr4` (11.31%)

+ 155 - 0
cat-fly/data-fetch/README.md

@@ -0,0 +1,155 @@
+# 数据获取模块 (data-fetch)
+
+使用 mairui API 获取指数K线数据(支持时间范围参数)
+
+## 文件说明
+
+- `mairui_fetcher.py` - 核心数据获取模块
+- `fetch_cyb50.py` - 快速获取创业板50数据脚本
+- `data/` - 数据保存目录(自动创建)
+
+## 使用方法
+
+### 1. 快速获取创业板50数据
+
+```bash
+cd ~/.openclaw/workspace/cyb50-quant/cat-fly/data-fetch
+python fetch_cyb50.py
+```
+
+### 2. 命令行参数获取
+
+```bash
+# 获取创业板50,2023年至今,30分钟K线
+python mairui_fetcher.py --start 2023-01-01
+
+# 获取2024年全年日线数据
+python mairui_fetcher.py --code 399673.SZ --tf d --start 2024-01-01 --end 2024-12-31
+
+# 获取上证指数60分钟数据
+python mairui_fetcher.py --code 000001.SH --tf 60 --start 2024-01-01
+
+# 只获取不保存
+python mairui_fetcher.py --start 2023-01-01 --no-save
+```
+
+### 3. 在代码中使用
+
+```python
+from mairui_fetcher import fetch_cyb50_30min, MairuiDataFetcher
+
+# 快速获取创业板50的30分钟数据
+df = fetch_cyb50_30min(start_date="2023-01-01", end_date="2025-03-25")
+
+# 自定义获取
+fetcher = MairuiDataFetcher(data_dir="./my_data")
+
+# 获取日线数据
+df = fetcher.fetch_data(
+    index_code="399673.SZ",
+    timeframe="d",  # d=日线, 30=30分钟, 60=60分钟
+    start_date="2023-01-01",
+    end_date="2025-03-25"
+)
+
+# 获取30分钟数据
+df = fetcher.fetch_30min_data(
+    index_code="399673.SZ",
+    start_date="2023-01-01",
+    end_date="2025-03-25"
+)
+
+# 保存数据
+fetcher.save_to_csv(df, "my_data.csv")
+
+# 加载数据
+df = fetcher.load_from_csv("my_data.csv")
+```
+
+## API 接口说明
+
+### 历史数据接口
+
+```
+https://api.mairuiapi.com/hsindex/history/{指数代码}/{分时级别}/{token}?st={开始时间}&et={结束时间}
+```
+
+**参数说明:**
+
+| 参数 | 说明 | 示例 |
+|------|------|------|
+| 指数代码 | 指数代码.市场 | `399673.SZ`, `000001.SH` |
+| 分时级别 | K线周期 | `d`=日线, `30`=30分钟, `60`=60分钟 |
+| token | API密钥 | `AE17EE23-AAE4-492F-A959-EC883DFA5A76` |
+| st | 开始时间 | `20230101` (YYYYMMDD格式) |
+| et | 结束时间 | `20250325` (YYYYMMDD格式) |
+
+**完整示例:**
+```
+https://api.mairuiapi.com/hsindex/history/399673.SZ/30/AE17EE23-AAE4-492F-A959-EC883DFA5A76?st=20230101&et=20250325
+```
+
+### 支持的指数代码
+
+| 名称 | 代码 | 别名 |
+|------|------|------|
+| 创业板50 | 399673.SZ | cyb50 |
+| 创业板指 | 399006.SZ | cy |
+| 上证指数 | 000001.SH | sh |
+| 沪深300 | 000300.SH | hs300 |
+| 深证成指 | 399001.SZ | sz |
+
+### 支持的K线周期
+
+| 代码 | 说明 |
+|------|------|
+| `d` | 日线 |
+| `30` | 30分钟线 |
+| `60` | 60分钟线 |
+
+## 返回数据格式
+
+| 列名 | 说明 |
+|------|------|
+| datetime | 日期时间 |
+| open | 开盘价 |
+| high | 最高价 |
+| low | 最低价 |
+| close | 收盘价 |
+| volume | 成交量 |
+| amount | 成交额 |
+
+## 数据量估算
+
+创业板50指数 30分钟K线数据量:
+- 每天交易4小时 = 8条30分钟K线
+- 每年约250个交易日 = 2000条/年
+- 2023年至今(约2.2年)≈ 4400条数据
+
+## 依赖安装
+
+```bash
+pip install requests pandas
+```
+
+## 注意事项
+
+1. **API限制**: 注意mairui API的调用频率限制
+2. **数据延迟**: 免费数据可能有15分钟延迟
+3. **日期格式**: 代码中传入 `YYYY-MM-DD` 格式,API会自动转换为 `YYYYMMDD`
+4. **时间范围**: 可获取2005年至今的数据
+5. **空数据处理**: 如果API返回空数据,会返回空的DataFrame
+
+## 更新日志
+
+### v2.0 (2025-03-25)
+- ✨ 支持时间范围参数 (`st`, `et`)
+- ✨ 支持多种K线周期 (日线/30分钟/60分钟)
+- ✨ 代码更简洁,无需分页
+- ✨ 添加命令行参数支持
+
+### v1.1 (2025-03-25)
+- 支持历史数据接口自动分页获取
+
+### v1.0 (2025-03-25)
+- 初始版本

+ 22 - 0
cat-fly/data-fetch/fetch_cyb50.py

@@ -0,0 +1,22 @@
+#!/usr/bin/env python3
+"""
+快速获取创业板50指数30分钟数据
+使用历史数据接口,自动分页获取2023年至今的全部数据
+"""
+from mairui_fetcher import fetch_cyb50_30min
+
+if __name__ == "__main__":
+    print("=" * 70)
+    print("获取创业板50指数2023年至今的30分钟数据")
+    print("=" * 70)
+    
+    df = fetch_cyb50_30min(
+        start_date="2023-01-01",
+        save=True
+    )
+    
+    if not df.empty:
+        print(f"\n✓ 成功获取 {len(df)} 条数据")
+        print(f"时间范围: {df['datetime'].min()} 至 {df['datetime'].max()}")
+    else:
+        print("\n✗ 数据获取失败")

+ 256 - 0
cat-fly/data-fetch/mairui_fetcher.py

@@ -0,0 +1,256 @@
+#!/usr/bin/env python3
+"""
+创业板50指数数据获取模块 (纯标准库版本)
+使用 mairui API 获取 K线数据,无需安装 pandas
+"""
+
+import urllib.request
+import urllib.error
+import json
+import os
+import csv
+from datetime import datetime
+from typing import Optional, List, Dict
+
+
+class MairuiDataFetcher:
+    """mairui 数据获取类 (纯标准库)"""
+    
+    # API 配置
+    BASE_URL = "https://api.mairuiapi.com/hsindex/history"
+    TOKEN = "AE17EE23-AAE4-492F-A959-EC883DFA5A76"
+    
+    # 指数代码映射
+    INDEX_CODES = {
+        "cyb50": "399673.SZ",
+        "cy": "399006.SZ",
+        "sh": "000001.SH",
+        "hs300": "000300.SH",
+        "sz": "399001.SZ",
+    }
+    
+    def __init__(self, data_dir: str = "./data"):
+        self.data_dir = data_dir
+        os.makedirs(data_dir, exist_ok=True)
+    
+    def fetch_data(
+        self,
+        index_code: str = "399673.SZ",
+        timeframe: str = "30",
+        start_date: Optional[str] = None,
+        end_date: Optional[str] = None
+    ) -> List[Dict]:
+        """
+        获取K线数据
+        
+        Args:
+            index_code: 指数代码
+            timeframe: K线周期 (d=日线, 30=30分钟, 60=60分钟)
+            start_date: 开始日期 (YYYY-MM-DD)
+            end_date: 结束日期 (YYYY-MM-DD)
+            
+        Returns:
+            数据列表,每个元素是一个字典
+        """
+        # 日期格式转换
+        if start_date:
+            st = start_date.replace("-", "")
+        else:
+            st = "20230101"
+        
+        if end_date:
+            et = end_date.replace("-", "")
+        else:
+            et = datetime.now().strftime("%Y%m%d")
+        
+        print(f"正在获取 {index_code} 的{timeframe}分钟K线数据...")
+        print(f"时间范围: {start_date or st} 至 {end_date or et}")
+        
+        # 构建API URL
+        url = f"{self.BASE_URL}/{index_code}/{timeframe}/{self.TOKEN}?st={st}&et={et}"
+        print(f"API URL: {url}")
+        
+        try:
+            # 使用标准库发送请求
+            req = urllib.request.Request(url, headers={
+                'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.0'
+            })
+            
+            with urllib.request.urlopen(req, timeout=60) as response:
+                data = json.loads(response.read().decode('utf-8'))
+            
+            # 解析数据
+            if isinstance(data, list):
+                records = data
+            elif isinstance(data, dict):
+                records = data.get("data", data.get("list", []))
+            else:
+                records = []
+            
+            if not records:
+                print("✗ API返回空数据")
+                return []
+            
+            print(f"✓ 获取到 {len(records)} 条数据")
+            
+            # 标准化数据
+            standardized_records = []
+            for record in records:
+                std_record = {
+                    "datetime": record.get("d") or record.get("t"),
+                    "open": record.get("o"),
+                    "high": record.get("h"),
+                    "low": record.get("l"),
+                    "close": record.get("c"),
+                    "volume": record.get("v"),
+                    "amount": record.get("a"),
+                }
+                standardized_records.append(std_record)
+            
+            # 按时间排序
+            standardized_records.sort(key=lambda x: x["datetime"])
+            
+            if standardized_records:
+                print(f"  时间范围: {standardized_records[0]['datetime']} 至 {standardized_records[-1]['datetime']}")
+            
+            return standardized_records
+            
+        except urllib.error.URLError as e:
+            print(f"✗ 网络请求失败: {e}")
+            return []
+        except json.JSONDecodeError as e:
+            print(f"✗ JSON解析失败: {e}")
+            return []
+        except Exception as e:
+            print(f"✗ 处理失败: {e}")
+            import traceback
+            traceback.print_exc()
+            return []
+    
+    def save_to_csv(self, records: List[Dict], filename: str) -> str:
+        """保存数据到CSV文件"""
+        if not records:
+            print("✗ 没有数据可保存")
+            return ""
+        
+        filepath = os.path.join(self.data_dir, filename)
+        
+        # 获取所有字段名
+        fieldnames = ["datetime", "open", "high", "low", "close", "volume", "amount"]
+        
+        with open(filepath, 'w', newline='', encoding='utf-8') as f:
+            writer = csv.DictWriter(f, fieldnames=fieldnames)
+            writer.writeheader()
+            writer.writerows(records)
+        
+        print(f"✓ 数据已保存到: {filepath}")
+        return filepath
+    
+    def load_from_csv(self, filename: str) -> List[Dict]:
+        """从CSV文件加载数据"""
+        filepath = os.path.join(self.data_dir, filename)
+        if not os.path.exists(filepath):
+            print(f"✗ 文件不存在: {filepath}")
+            return []
+        
+        records = []
+        with open(filepath, 'r', encoding='utf-8') as f:
+            reader = csv.DictReader(f)
+            for row in reader:
+                records.append(dict(row))
+        
+        print(f"✓ 已从 {filepath} 加载 {len(records)} 条记录")
+        return records
+    
+    def print_preview(self, records: List[Dict], head: int = 10, tail: int = 5):
+        """打印数据预览"""
+        if not records:
+            print("没有数据")
+            return
+        
+        print(f"\n数据预览 (前{head}条):")
+        print("-" * 80)
+        print(f"{'datetime':<20} {'open':<10} {'high':<10} {'low':<10} {'close':<10} {'volume':<12}")
+        print("-" * 80)
+        for r in records[:head]:
+            print(f"{r.get('datetime', ''):<20} {r.get('open', ''):<10} {r.get('high', ''):<10} "
+                  f"{r.get('low', ''):<10} {r.get('close', ''):<10} {r.get('volume', ''):<12}")
+        
+        if len(records) > head + tail:
+            print(f"\n... ({len(records) - head - tail} 条数据省略) ...\n")
+        
+        print(f"数据预览 (后{tail}条):")
+        print("-" * 80)
+        for r in records[-tail:]:
+            print(f"{r.get('datetime', ''):<20} {r.get('open', ''):<10} {r.get('high', ''):<10} "
+                  f"{r.get('low', ''):<10} {r.get('close', ''):<10} {r.get('volume', ''):<12}")
+        print("-" * 80)
+
+
+def fetch_cyb50(
+    timeframe: str = "d",
+    start_date: str = "2023-01-01",
+    end_date: Optional[str] = None,
+    save: bool = True
+) -> List[Dict]:
+    """获取创业板50指数数据(便捷函数)"""
+    fetcher = MairuiDataFetcher(data_dir="./data")
+    
+    records = fetcher.fetch_data(
+        index_code="399673.SZ",
+        timeframe=timeframe,
+        start_date=start_date,
+        end_date=end_date
+    )
+    
+    if save and records:
+        end_str = end_date.replace("-", "") if end_date else datetime.now().strftime("%Y%m%d")
+        tf_name = "day" if timeframe == "d" else f"{timeframe}min"
+        filename = f"cyb50_{tf_name}_{start_date.replace('-', '')}_{end_str}.csv"
+        fetcher.save_to_csv(records, filename)
+    
+    return records
+
+
+if __name__ == "__main__":
+    import argparse
+    
+    parser = argparse.ArgumentParser(description="获取指数K线数据")
+    parser.add_argument("--code", default="399673.SZ", help="指数代码")
+    parser.add_argument("--tf", default="d", help="K线周期: d=日线, 30=30分钟")
+    parser.add_argument("--start", default="2015-01-01", help="开始日期 (YYYY-MM-DD)")
+    parser.add_argument("--end", default=None, help="结束日期 (YYYY-MM-DD)")
+    parser.add_argument("--no-save", action="store_true", help="不保存到文件")
+    
+    args = parser.parse_args()
+    
+    print("=" * 80)
+    print("mairui 指数数据获取工具 (纯标准库版本)")
+    print("=" * 80)
+    
+    fetcher = MairuiDataFetcher(data_dir="./data")
+    records = fetcher.fetch_data(
+        index_code=args.code,
+        timeframe=args.tf,
+        start_date=args.start,
+        end_date=args.end
+    )
+    
+    if records:
+        fetcher.print_preview(records)
+        
+        print(f"\n数据统计:")
+        print(f"  总记录数: {len(records)}")
+        
+        if not args.no_save:
+            end_str = args.end.replace("-", "") if args.end else datetime.now().strftime("%Y%m%d")
+            tf_name = "day" if args.tf == "d" else f"{args.tf}min"
+            filename = f"{args.code.replace('.', '_')}_{tf_name}_{args.start.replace('-', '')}_{end_str}.csv"
+            fetcher.save_to_csv(records, filename)
+    else:
+        print("\n✗ 数据获取失败")
+        exit(1)
+    
+    print("\n" + "=" * 80)
+    print("完成!")
+    print("=" * 80)

+ 0 - 0
cat-fly/t1/analyze_trades.py


+ 100 - 0
cat-fly/t1/backtest_correct_long_only.py

@@ -0,0 +1,100 @@
+#!/usr/bin/env python3
+"""
+基于 cyb50_30min_dual_direction.py 的只做多回测
+使用与 auto_report_long_only_t1.py 相同的策略逻辑
+"""
+
+import sys
+sys.path.insert(0, '/home/erwin/.openclaw/workspace/cyb50-quant/cat-fly/t1')
+
+import pandas as pd
+import numpy as np
+from datetime import datetime, timedelta
+
+# 导入策略模块
+from cyb50_30min_dual_direction import (
+    DualDirectionSignalGenerator
+)
+
+def calculate_indicators(data):
+    """计算技术指标"""
+    df = data.copy()
+    
+    # 移动平均线
+    df['MA6'] = df['Close'].rolling(window=6).mean()
+    df['MA12'] = df['Close'].rolling(window=12).mean()
+    df['MA24'] = df['Close'].rolling(window=24).mean()
+    
+    # RSI
+    delta = df['Close'].diff()
+    gain = (delta.where(delta > 0, 0)).rolling(window=14).mean()
+    loss = (-delta.where(delta < 0, 0)).rolling(window=14).mean()
+    rs = gain / loss
+    df['RSI'] = 100 - (100 / (1 + rs))
+    
+    # 布林带
+    df['BB_middle'] = df['Close'].rolling(window=20).mean()
+    bb_std = df['Close'].rolling(window=20).std()
+    df['BB_upper'] = df['BB_middle'] + (bb_std * 2)
+    df['BB_lower'] = df['BB_middle'] - (bb_std * 2)
+    
+    # MACD
+    exp1 = df['Close'].ewm(span=12, adjust=False).mean()
+    exp2 = df['Close'].ewm(span=26, adjust=False).mean()
+    df['MACD'] = exp1 - exp2
+    df['MACD_signal'] = df['MACD'].ewm(span=9, adjust=False).mean()
+    df['MACD_hist'] = df['MACD'] - df['MACD_signal']
+    
+    # KDJ
+    low_9 = df['Low'].rolling(window=9).min()
+    high_9 = df['High'].rolling(window=9).max()
+    rsv = (df['Close'] - low_9) / (high_9 - low_9) * 100
+    df['K'] = rsv.ewm(com=2, adjust=False).mean()
+    df['D'] = df['K'].ewm(com=2, adjust=False).mean()
+    df['J'] = 3 * df['K'] - 2 * df['D']
+    
+    df['Volume_MA'] = df['Volume'].rolling(window=12).mean()
+    df['Volume_Ratio'] = df['Volume'] / df['Volume_MA']
+    df['Price_Momentum'] = (df['Close'] - df['Close'].shift(6)) / df['Close'].shift(6)
+    df['Close_Open_Pct'] = (df['Close'] - df['Open']) / df['Open']
+    
+    return df
+
+def run_backtest():
+    """运行回测"""
+    print("="*70)
+    print("基于 DualDirection 策略的只做多回测")
+    print("="*70)
+    
+    # 加载数据
+    data_file = 'cyb50_30min_2023_to_20260325.csv'
+    print(f"\n[1/3] 加载数据: {data_file}")
+    data = pd.read_csv(data_file)
+    data['DateTime'] = pd.to_datetime(data['DateTime'])
+    data.set_index('DateTime', inplace=True)
+    data.sort_index(inplace=True)
+    print(f"      数据条数: {len(data)}")
+    print(f"      范围: {data.index[0]} ~ {data.index[-1]}")
+    
+    # 计算技术指标
+    print("\n[2/3] 计算技术指标...")
+    df = calculate_indicators(data)
+    print("      完成")
+    
+    # 生成信号
+    print("\n[3/3] 生成多空信号...")
+    signal_generator = DualDirectionSignalGenerator()
+    signals_df = signal_generator.generate_dual_direction_signals(df)
+    
+    print(f"\n信号统计:")
+    print(f"  做多信号: {signal_generator.long_signal_count}个")
+    print(f"  做空信号: {signal_generator.short_signal_count}个")
+    print(f"  总信号: {signal_generator.total_signal_count}个")
+    
+    # 提取做多信号
+    long_signals = signals_df[signals_df['Signal'] == 1]
+    print(f"\n做多信号详情:")
+    print(long_signals[['Close', 'Long_Score', 'Long_Signals']].head(10))
+
+if __name__ == '__main__':
+    run_backtest()

+ 337 - 0
cat-fly/t1/backtest_dual_direction_correct.py

@@ -0,0 +1,337 @@
+#!/usr/bin/env python3
+"""
+基于 cyb50_30min_dual_direction.py 的只做多T+1回测
+使用与 auto_report_long_only_t1.py 完全相同的策略逻辑
+"""
+
+import csv
+import json
+from datetime import datetime, timedelta
+from collections import deque
+import os
+
+class DualDirectionLongOnlyBacktest:
+    """
+    只做多回测 - 基于 cyb50_30min_dual_direction 策略
+    """
+    
+    def __init__(self, initial_capital=1000000):
+        self.initial_capital = initial_capital
+        self.position_size_pct = 1.0  # 满仓
+        self.stop_loss_pct = 0.008    # 0.8%止损
+        self.take_profit_pct = 0.02   # 2%止盈
+        self.max_hold_bars = 16       # 最大8小时
+        
+        # 统计数据
+        self.long_signal_count = 0
+        self.trades = []
+        self.capital = initial_capital
+        
+    def calculate_indicators(self, data):
+        """计算技术指标"""
+        print("计算技术指标...")
+        
+        # 为每行数据添加指标
+        for i, row in enumerate(data):
+            if i < 24:  # 需要至少24个周期的历史数据
+                row['RSI'] = 50
+                row['MACD'] = 0
+                row['MACD_hist'] = 0
+                row['K'] = 50
+                row['D'] = 50
+                row['J'] = 50
+                row['BB_middle'] = row['Close']
+                row['BB_upper'] = row['Close'] * 1.02
+                row['BB_lower'] = row['Close'] * 0.98
+                row['Volume_Ratio'] = 1.0
+                row['Price_Momentum'] = 0
+                row['Close_Open_Pct'] = 0
+                row['MA6'] = row['Close']
+                row['MA12'] = row['Close']
+                continue
+            
+            closes = [data[j]['Close'] for j in range(i-23, i+1)]
+            highs = [data[j]['High'] for j in range(i-23, i+1)]
+            lows = [data[j]['Low'] for j in range(i-23, i+1)]
+            volumes = [data[j]['Volume'] for j in range(i-23, i+1)]
+            
+            # MA
+            row['MA6'] = sum(closes[-6:]) / 6
+            row['MA12'] = sum(closes[-12:]) / 12
+            
+            # RSI
+            gains = []
+            losses = []
+            for j in range(1, 15):
+                change = closes[-j] - closes[-j-1]
+                gains.append(max(0, change))
+                losses.append(max(0, -change))
+            avg_gain = sum(gains) / 14
+            avg_loss = sum(losses) / 14
+            if avg_loss == 0:
+                row['RSI'] = 100
+            else:
+                rs = avg_gain / avg_loss
+                row['RSI'] = 100 - (100 / (1 + rs))
+            
+            # 布林带
+            bb_middle = sum(closes[-20:]) / 20
+            variance = sum((c - bb_middle) ** 2 for c in closes[-20:]) / 20
+            bb_std = variance ** 0.5
+            row['BB_middle'] = bb_middle
+            row['BB_upper'] = bb_middle + bb_std * 2
+            row['BB_lower'] = bb_middle - bb_std * 2
+            
+            # 简化MACD
+            ema12 = sum(closes[-12:]) / 12
+            ema26 = sum(closes[-26:]) / 26 if len(closes) >= 26 else sum(closes) / len(closes)
+            row['MACD'] = ema12 - ema26
+            row['MACD_hist'] = row['MACD']  # 简化
+            
+            # KDJ (简化)
+            low_9 = min(lows[-9:])
+            high_9 = max(highs[-9:])
+            if high_9 == low_9:
+                rsv = 50
+            else:
+                rsv = (row['Close'] - low_9) / (high_9 - low_9) * 100
+            row['K'] = rsv
+            row['D'] = rsv
+            row['J'] = 3 * rsv - 2 * rsv
+            
+            # 成交量比率
+            vol_ma = sum(volumes[-12:]) / 12
+            row['Volume_Ratio'] = row['Volume'] / vol_ma if vol_ma > 0 else 1
+            
+            # 价格动量
+            row['Price_Momentum'] = (row['Close'] - closes[-6]) / closes[-6] if closes[-6] > 0 else 0
+            row['Close_Open_Pct'] = (row['Close'] - row['Open']) / row['Open'] if row['Open'] > 0 else 0
+        
+        print(f"  指标计算完成,共{len(data)}条")
+        return data
+    
+    def calculate_long_score(self, row, prev_rows):
+        """计算做多信号强度 - 完全按照 DualDirection 逻辑"""
+        long_score = 0
+        long_signals = []
+        
+        # 1. RSI超卖做多
+        if row['RSI'] < 30:
+            long_score += 2
+            long_signals.append("RSI超卖")
+        elif row['RSI'] < 35:
+            long_score += 1
+            long_signals.append("RSI偏弱")
+        
+        # 2. 价格触及布林带下轨
+        if row['Close'] <= row['BB_lower'] * 1.01:
+            long_score += 2
+            long_signals.append("触及下轨")
+        elif row['Close'] <= row['BB_lower'] * 1.03:
+            long_score += 1
+            long_signals.append("接近下轨")
+        
+        # 3. MACD金叉或柱状图转正
+        if len(prev_rows) > 0:
+            prev_macd_hist = prev_rows[-1]['MACD_hist']
+            if row['MACD_hist'] > 0 and prev_macd_hist <= 0:
+                long_score += 2
+                long_signals.append("MACD金叉")
+            elif row['MACD_hist'] > prev_macd_hist:
+                long_score += 1
+                long_signals.append("MACD改善")
+        
+        # 4. 价格动量向上
+        if row['Price_Momentum'] > 0.005:
+            long_score += 1
+            long_signals.append("动量向上")
+        
+        # 5. 成交量放大
+        if row['Volume_Ratio'] > 1.5:
+            long_score += 1
+            long_signals.append("放量")
+        
+        return long_score, long_signals
+    
+    def run_backtest(self, data_file):
+        """运行回测"""
+        print("="*70)
+        print("DualDirection 策略 - 只做多T+1回测")
+        print("="*70)
+        print(f"\n参数设置:")
+        print(f"  初始资金: {self.initial_capital:,.0f}元")
+        print(f"  仓位比例: {self.position_size_pct*100:.0f}%")
+        print(f"  止损: {self.stop_loss_pct*100:.1f}%")
+        print(f"  止盈: {self.take_profit_pct*100:.1f}%")
+        print(f"  最大持仓: {self.max_hold_bars}周期(8小时)")
+        
+        # 1. 加载数据
+        print(f"\n[1/3] 加载数据: {data_file}")
+        data = []
+        with open(data_file, 'r', encoding='utf-8-sig') as f:
+            reader = csv.DictReader(f)
+            for row in reader:
+                data.append({
+                    'DateTime': row['DateTime'],
+                    'Open': float(row['Open']),
+                    'High': float(row['High']),
+                    'Low': float(row['Low']),
+                    'Close': float(row['Close']),
+                    'Volume': float(row['Volume'])
+                })
+        print(f"      加载完成: {len(data)}条")
+        print(f"      时间范围: {data[0]['DateTime']} ~ {data[-1]['DateTime']}")
+        
+        # 2. 计算指标
+        print("\n[2/3] 计算技术指标...")
+        data = self.calculate_indicators(data)
+        
+        # 3. 生成信号并执行回测
+        print("\n[3/3] 生成信号并执行回测...")
+        
+        position = 0
+        entry_price = 0
+        entry_time = None
+        entry_idx = 0
+        
+        for i in range(24, len(data)):
+            row = data[i]
+            current_time = row['DateTime']
+            current_price = row['Close']
+            
+            # 计算做多信号
+            prev_rows = data[max(0, i-5):i]
+            long_score, long_signals = self.calculate_long_score(row, prev_rows)
+            
+            # 持仓管理
+            if position > 0:
+                holding_bars = i - entry_idx
+                pnl_pct = (current_price - entry_price) / entry_price
+                
+                exit_reason = None
+                
+                # 止损 -0.8%
+                if pnl_pct <= -self.stop_loss_pct:
+                    exit_reason = f"止损({current_price:.2f})"
+                # 止盈 +2%
+                elif pnl_pct >= self.take_profit_pct:
+                    exit_reason = f"止盈({current_price:.2f})"
+                # 最大持仓时间
+                elif holding_bars >= self.max_hold_bars:
+                    exit_reason = f"时间平仓({holding_bars}周期)"
+                # RSI超买
+                elif row['RSI'] > 75:
+                    exit_reason = f"RSI超买({row['RSI']:.1f})"
+                
+                if exit_reason:
+                    pnl = (current_price - entry_price) * position
+                    self.capital += pnl
+                    
+                    self.trades.append({
+                        'action': 'CLOSE',
+                        'time': current_time,
+                        'price': current_price,
+                        'shares': position,
+                        'pnl': pnl,
+                        'pnl_pct': pnl_pct * 100,
+                        'reason': exit_reason
+                    })
+                    
+                    position = 0
+                    entry_price = 0
+                    entry_time = None
+            
+            # 开仓判断 - 信号强度>=4
+            elif long_score >= 4 and position == 0:
+                position_value = self.capital * self.position_size_pct
+                position = position_value / current_price
+                entry_price = current_price
+                entry_time = current_time
+                entry_idx = i
+                self.long_signal_count += 1
+                
+                self.trades.append({
+                    'action': 'OPEN',
+                    'time': current_time,
+                    'price': current_price,
+                    'shares': position,
+                    'value': position_value,
+                    'reason': f"做多信号(强度{long_score}): {'+'.join(long_signals[:3])}"
+                })
+        
+        print(f"      做多信号: {self.long_signal_count}个")
+        print(f"      实际交易: {len([t for t in self.trades if t['action']=='CLOSE'])}笔")
+        
+        return self.generate_report()
+    
+    def generate_report(self):
+        """生成报告"""
+        closed_trades = [t for t in self.trades if t['action'] == 'CLOSE']
+        
+        if not closed_trades:
+            print("\n无交易记录")
+            return None
+        
+        # 计算统计
+        wins = [t for t in closed_trades if t['pnl'] > 0]
+        losses = [t for t in closed_trades if t['pnl'] <= 0]
+        
+        total_pnl = sum(t['pnl'] for t in closed_trades)
+        final_capital = self.initial_capital + total_pnl
+        total_return = (final_capital / self.initial_capital - 1) * 100
+        
+        win_rate = len(wins) / len(closed_trades) * 100
+        
+        total_profit = sum(t['pnl'] for t in wins) if wins else 0
+        total_loss = abs(sum(t['pnl'] for t in losses)) if losses else 0
+        profit_factor = total_profit / total_loss if total_loss > 0 else 0
+        
+        # 计算最大回撤
+        peak = self.initial_capital
+        max_dd = 0
+        current = self.initial_capital
+        
+        for t in closed_trades:
+            current += t['pnl']
+            if current > peak:
+                peak = current
+            dd = (peak - current) / peak * 100
+            if dd > max_dd:
+                max_dd = dd
+        
+        print("\n" + "="*70)
+        print("回测报告 - DualDirection 只做多T+1")
+        print("="*70)
+        print(f"\n【整体表现】")
+        print(f"  初始资金: {self.initial_capital:,.2f}元")
+        print(f"  最终资金: {final_capital:,.2f}元")
+        print(f"  净盈亏: {total_pnl:+,.2f}元")
+        print(f"  总收益率: {total_return:+.2f}%")
+        print(f"  最大回撤: {max_dd:.2f}%")
+        
+        print(f"\n【交易统计】")
+        print(f"  总交易: {len(closed_trades)}笔")
+        print(f"  盈利: {len(wins)}笔")
+        print(f"  亏损: {len(losses)}笔")
+        print(f"  胜率: {win_rate:.2f}%")
+        print(f"  盈亏比: {profit_factor:.2f}")
+        print(f"  总盈利: {total_profit:,.2f}元")
+        print(f"  总亏损: {total_loss:,.2f}元")
+        
+        print(f"\n【最近10笔交易】")
+        for t in closed_trades[-10:]:
+            print(f"  {t['time']} | {t['pnl']:+10,.2f}元 ({t['pnl_pct']:+.2f}%) | {t['reason']}")
+        
+        print("="*70)
+        
+        return {
+            'total_return': total_return,
+            'win_rate': win_rate,
+            'profit_factor': profit_factor,
+            'trade_count': len(closed_trades),
+            'max_drawdown': max_dd
+        }
+
+if __name__ == '__main__':
+    backtest = DualDirectionLongOnlyBacktest(initial_capital=1000000)
+    result = backtest.run_backtest('cyb50_30min_2023_to_20260325.csv')

+ 349 - 0
cat-fly/t1/backtest_dual_with_timing.py

@@ -0,0 +1,349 @@
+#!/usr/bin/env python3
+"""
+DualDirection 策略 + 择时过滤
+保持原策略所有参数不变,只增加日线趋势和30分钟状态过滤
+"""
+
+import csv
+from datetime import datetime
+import math
+
+class DualDirectionWithTiming:
+    def __init__(self, initial_capital=1000000):
+        self.initial_capital = initial_capital
+        self.position_size_pct = 1.0
+        self.stop_loss_pct = 0.008
+        self.take_profit_pct = 0.02
+        self.max_hold_bars = 16
+        self.min_trend_prob = 0.3
+        self.require_daily_uptrend = True
+        
+        self.long_signal_count = 0
+        self.filtered_count = 0
+        self.trades = []
+        self.capital = initial_capital
+    
+    def load_daily_data(self, filepath):
+        daily_data = {}
+        with open(filepath, 'r', encoding='utf-8-sig') as f:
+            reader = csv.DictReader(f)
+            for row in reader:
+                try:
+                    dt = datetime.strptime(row['datetime'], '%Y-%m-%d %H:%M:%S')
+                    date_str = dt.strftime('%Y-%m-%d')
+                    daily_data[date_str] = {
+                        'open': float(row['open']),
+                        'high': float(row['high']),
+                        'low': float(row['low']),
+                        'close': float(row['close'])
+                    }
+                except:
+                    continue
+        
+        dates = sorted(daily_data.keys())
+        closes = [daily_data[d]['close'] for d in dates]
+        
+        for i, date in enumerate(dates):
+            if i < 19:
+                daily_data[date]['ma20'] = None
+                daily_data[date]['trend'] = 0
+            else:
+                ma20 = sum(closes[i-19:i+1]) / 20
+                daily_data[date]['ma20'] = ma20
+                close = closes[i]
+                if close > ma20 * 1.02:
+                    daily_data[date]['trend'] = 1
+                elif close < ma20 * 0.98:
+                    daily_data[date]['trend'] = -1
+                else:
+                    daily_data[date]['trend'] = 0
+        
+        return daily_data
+    
+    def detect_market_regime(self, data, current_idx):
+        if current_idx < 16:
+            return 0, 0.0
+        
+        window = data[current_idx-16:current_idx]
+        closes = [row['Close'] for row in window]
+        highs = [row['High'] for row in window]
+        lows = [row['Low'] for row in window]
+        
+        start_price = closes[0]
+        end_price = closes[-1]
+        period_return = (end_price / start_price - 1) * 100
+        
+        max_price = max(highs)
+        min_price = min(lows)
+        price_range = (max_price - min_price) / start_price * 100
+        
+        gains = []
+        losses = []
+        for i in range(1, len(closes)):
+            change = closes[i] - closes[i-1]
+            gains.append(max(0, change))
+            losses.append(max(0, -change))
+        
+        avg_gain = sum(gains[-14:]) / 14 if len(gains) >= 14 else sum(gains) / len(gains)
+        avg_loss = sum(losses[-14:]) / 14 if len(losses) >= 14 else sum(losses) / len(losses)
+        
+        if avg_loss == 0:
+            rsi = 100
+        else:
+            rs = avg_gain / avg_loss
+            rsi = 100 - (100 / (1 + rs))
+        
+        returns = [(closes[i] - closes[i-1]) / closes[i-1] * 100 for i in range(1, len(closes))]
+        volatility = math.sqrt(sum(r**2 for r in returns) / len(returns)) if returns else 0
+        
+        reversal_score = 0
+        if rsi > 70 or rsi < 30:
+            reversal_score += 2
+        elif rsi > 65 or rsi < 35:
+            reversal_score += 1
+        
+        if price_range > 4 and abs(period_return) < 1.5:
+            reversal_score += 1
+        
+        if reversal_score >= 3:
+            return 2, 0.3
+        
+        trend_score = 0
+        if abs(period_return) >= 2.0:
+            trend_score += 3
+        elif abs(period_return) >= 1.0:
+            trend_score += 2
+        elif abs(period_return) >= 0.5:
+            trend_score += 1
+        
+        if 0.5 < volatility < 2.0:
+            trend_score += 1
+        
+        first_half = closes[:len(closes)//2]
+        second_half = closes[len(closes)//2:]
+        first_avg = sum(first_half) / len(first_half)
+        second_avg = sum(second_half) / len(second_half)
+        
+        if (period_return > 0 and second_avg > first_avg) or (period_return < 0 and second_avg < first_avg):
+            trend_score += 1
+        
+        if trend_score >= 4:
+            prob = min(0.95, 0.5 + abs(period_return) / 10)
+            return 1, prob
+        
+        return 0, 0.2
+    
+    def calculate_indicators(self, data):
+        for i, row in enumerate(data):
+            if i < 24:
+                row['RSI'] = 50
+                row['MACD_hist'] = 0
+                row['BB_lower'] = row['Close'] * 0.98
+                row['Volume_Ratio'] = 1.0
+                row['Price_Momentum'] = 0
+                continue
+            
+            closes = [data[j]['Close'] for j in range(i-23, i+1)]
+            highs = [data[j]['High'] for j in range(i-23, i+1)]
+            lows = [data[j]['Low'] for j in range(i-23, i+1)]
+            volumes = [data[j]['Volume'] for j in range(i-23, i+1)]
+            
+            gains = []
+            losses = []
+            for j in range(1, 15):
+                change = closes[-j] - closes[-j-1]
+                gains.append(max(0, change))
+                losses.append(max(0, -change))
+            avg_gain = sum(gains) / 14
+            avg_loss = sum(losses) / 14
+            if avg_loss == 0:
+                row['RSI'] = 100
+            else:
+                rs = avg_gain / avg_loss
+                row['RSI'] = 100 - (100 / (1 + rs))
+            
+            bb_middle = sum(closes[-20:]) / 20
+            variance = sum((c - bb_middle) ** 2 for c in closes[-20:]) / 20
+            bb_std = variance ** 0.5
+            row['BB_lower'] = bb_middle - bb_std * 2
+            
+            ema12 = sum(closes[-12:]) / 12
+            ema26 = sum(closes[-26:]) / 26 if len(closes) >= 26 else sum(closes) / len(closes)
+            row['MACD'] = ema12 - ema26
+            row['MACD_hist'] = row['MACD']
+            
+            vol_ma = sum(volumes[-12:]) / 12
+            row['Volume_Ratio'] = row['Volume'] / vol_ma if vol_ma > 0 else 1
+            row['Price_Momentum'] = (row['Close'] - closes[-6]) / closes[-6] if closes[-6] > 0 else 0
+        
+        return data
+    
+    def calculate_long_score(self, row, prev_rows):
+        long_score = 0
+        long_signals = []
+        
+        if row['RSI'] < 30:
+            long_score += 2
+            long_signals.append("RSI超卖")
+        elif row['RSI'] < 35:
+            long_score += 1
+            long_signals.append("RSI偏弱")
+        
+        if row['Close'] <= row['BB_lower'] * 1.01:
+            long_score += 2
+            long_signals.append("触及下轨")
+        elif row['Close'] <= row['BB_lower'] * 1.03:
+            long_score += 1
+            long_signals.append("接近下轨")
+        
+        if len(prev_rows) > 0:
+            prev_macd_hist = prev_rows[-1]['MACD_hist']
+            if row['MACD_hist'] > 0 and prev_macd_hist <= 0:
+                long_score += 2
+                long_signals.append("MACD金叉")
+            elif row['MACD_hist'] > prev_macd_hist:
+                long_score += 1
+                long_signals.append("MACD改善")
+        
+        if row['Price_Momentum'] > 0.005:
+            long_score += 1
+            long_signals.append("动量向上")
+        
+        if row['Volume_Ratio'] > 1.5:
+            long_score += 1
+            long_signals.append("放量")
+        
+        return long_score, long_signals
+    
+    def run_backtest(self, data_file, daily_file):
+        print("="*70)
+        print("DualDirection + 择时过滤 回测")
+        print("="*70)
+        print(f"\nDualDirection参数: 止损0.8% 止盈2% 最大持仓16周期")
+        print(f"择时过滤: 日线向上 + 30分钟趋势概率>=0.3")
+        
+        print(f"\n[1/4] 加载30分钟数据...")
+        data = []
+        with open(data_file, 'r', encoding='utf-8-sig') as f:
+            reader = csv.DictReader(f)
+            for row in reader:
+                data.append({
+                    'DateTime': row['DateTime'],
+                    'Open': float(row['Open']),
+                    'High': float(row['High']),
+                    'Low': float(row['Low']),
+                    'Close': float(row['Close']),
+                    'Volume': float(row['Volume'])
+                })
+        print(f"      {len(data)}条")
+        
+        print(f"\n[2/4] 加载日线数据...")
+        daily_data = self.load_daily_data(daily_file)
+        print(f"      {len(daily_data)}条")
+        
+        print(f"\n[3/4] 计算技术指标...")
+        data = self.calculate_indicators(data)
+        
+        print(f"\n[4/4] 执行回测...")
+        position = 0
+        entry_price = 0
+        entry_idx = 0
+        
+        for i in range(24, len(data)):
+            row = data[i]
+            current_time = row['DateTime']
+            current_price = row['Close']
+            date_str = current_time[:10]
+            
+            daily_info = daily_data.get(date_str, {'trend': 0})
+            daily_trend = daily_info['trend']
+            
+            regime_state, trend_prob = self.detect_market_regime(data, i)
+            
+            prev_rows = data[max(0, i-5):i]
+            long_score, long_signals = self.calculate_long_score(row, prev_rows)
+            
+            if position > 0:
+                holding_bars = i - entry_idx
+                pnl_pct = (current_price - entry_price) / entry_price
+                
+                exit_reason = None
+                if pnl_pct <= -self.stop_loss_pct:
+                    exit_reason = f"止损({current_price:.2f})"
+                elif pnl_pct >= self.take_profit_pct:
+                    exit_reason = f"止盈({current_price:.2f})"
+                elif holding_bars >= self.max_hold_bars:
+                    exit_reason = f"时间平仓({holding_bars}周期)"
+                elif row['RSI'] > 75:
+                    exit_reason = f"RSI超买({row['RSI']:.1f})"
+                
+                if exit_reason:
+                    pnl = (current_price - entry_price) * position
+                    self.capital += pnl
+                    self.trades.append({
+                        'action': 'CLOSE', 'time': current_time, 'price': current_price,
+                        'pnl': pnl, 'pnl_pct': pnl_pct * 100, 'reason': exit_reason
+                    })
+                    position = 0
+                    entry_price = 0
+            
+            elif long_score >= 4 and position == 0:
+                self.long_signal_count += 1
+                
+                can_trade = True
+                if self.require_daily_uptrend and daily_trend != 1:
+                    can_trade = False
+                if regime_state != 1 or trend_prob < self.min_trend_prob:
+                    can_trade = False
+                
+                if can_trade:
+                    position_value = self.capital * self.position_size_pct
+                    position = position_value / current_price
+                    entry_price = current_price
+                    entry_idx = i
+                    self.trades.append({
+                        'action': 'OPEN', 'time': current_time, 'price': current_price,
+                        'value': position_value,
+                        'reason': f"信号{long_score}分|日线向上|趋势{trend_prob:.2f}"
+                    })
+                else:
+                    self.filtered_count += 1
+        
+        closed = [t for t in self.trades if t['action']=='CLOSE']
+        print(f"      信号: {self.long_signal_count} 过滤: {self.filtered_count} 交易: {len(closed)}")
+        return self.generate_report()
+    
+    def generate_report(self):
+        closed_trades = [t for t in self.trades if t['action'] == 'CLOSE']
+        if not closed_trades:
+            print("\n无交易")
+            return None
+        
+        wins = [t for t in closed_trades if t['pnl'] > 0]
+        losses = [t for t in closed_trades if t['pnl'] <= 0]
+        
+        total_pnl = sum(t['pnl'] for t in closed_trades)
+        final_capital = self.initial_capital + total_pnl
+        total_return = (final_capital / self.initial_capital - 1) * 100
+        win_rate = len(wins) / len(closed_trades) * 100
+        
+        total_profit = sum(t['pnl'] for t in wins) if wins else 0
+        total_loss = abs(sum(t['pnl'] for t in losses)) if losses else 0
+        profit_factor = total_profit / total_loss if total_loss > 0 else 0
+        
+        print("\n" + "="*70)
+        print("回测报告 - DualDirection + 择时过滤")
+        print("="*70)
+        print(f"  收益率: {total_return:+.2f}%")
+        print(f"  信号: {self.long_signal_count} 过滤: {self.filtered_count} 交易: {len(closed_trades)}")
+        print(f"  胜率: {win_rate:.2f}% 盈亏比: {profit_factor:.2f}")
+        print(f"\n最近5笔:")
+        for t in closed_trades[-5:]:
+            print(f"  {t['time']} | {t['pnl']:+10,.2f} | {t['reason']}")
+        print("="*70)
+        
+        return {'total_return': total_return, 'win_rate': win_rate}
+
+if __name__ == '__main__':
+    backtest = DualDirectionWithTiming()
+    backtest.run_backtest('cyb50_30min_2023_to_20260325.csv', '../data-fetch/data/399673_SZ_day_20150101_20260325.csv')

+ 400 - 0
cat-fly/t1/backtest_final_optimal.py

@@ -0,0 +1,400 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+CYB50 最优参数完整回测 + 数据核对
+参数: min_trend_prob=0.3, require_daily_uptrend=True
+"""
+
+import csv
+import json
+from datetime import datetime, timedelta
+from collections import deque
+import math
+import os
+
+class TechnicalIndicators:
+    @staticmethod
+    def sma(data, period):
+        if len(data) < period:
+            return None
+        return sum(data[-period:]) / period
+
+    @staticmethod
+    def rsi(prices, period=14):
+        if len(prices) < period + 1:
+            return None
+        gains, losses = [], []
+        for i in range(1, len(prices)):
+            change = prices[i] - prices[i-1]
+            gains.append(change if change > 0 else 0)
+            losses.append(abs(change) if change < 0 else 0)
+        avg_gain = sum(gains[-period:]) / period
+        avg_loss = sum(losses[-period:]) / period
+        if avg_loss == 0:
+            return 100
+        return 100 - (100 / (1 + avg_gain / avg_loss))
+
+    @staticmethod
+    def bollinger_bands(prices, period=20, std_dev=2):
+        if len(prices) < period:
+            return None, None, None
+        middle = sum(prices[-period:]) / period
+        variance = sum((p - middle) ** 2 for p in prices[-period:]) / period
+        std = math.sqrt(variance)
+        return middle + std*std_dev, middle, middle - std*std_dev
+
+    @staticmethod
+    def macd(prices, fast=12, slow=26, signal=9):
+        if len(prices) < slow:
+            return None, None, None
+        def calc_ema(data, period):
+            mult = 2 / (period + 1)
+            ema = data[0]
+            for p in data[1:]:
+                ema = (p - ema) * mult + ema
+            return ema
+        macd_vals = []
+        for i in range(slow, len(prices)+1):
+            f = calc_ema(prices[i-fast:i], fast)
+            s = calc_ema(prices[i-slow:i], slow)
+            macd_vals.append(f - s)
+        sig = calc_ema(macd_vals[-signal:], signal) if len(macd_vals) >= signal else None
+        return macd_vals[-1], sig, macd_vals[-1] - sig if sig else None
+
+
+class DailyTrendManager:
+    def __init__(self, daily_file):
+        self.daily_data = {}
+        self.daily_trend = {}
+        self.load_daily_data(daily_file)
+        self.calculate_daily_trend()
+
+    def load_daily_data(self, filepath):
+        with open(filepath, 'r', encoding='utf-8-sig') as f:
+            reader = csv.DictReader(f)
+            for row in reader:
+                try:
+                    dt = datetime.strptime(row['datetime'], '%Y-%m-%d %H:%M:%S')
+                    self.daily_data[dt.strftime('%Y-%m-%d')] = {
+                        'open': float(row['open']), 'high': float(row['high']),
+                        'low': float(row['low']), 'close': float(row['close'])
+                    }
+                except:
+                    continue
+
+    def calculate_daily_trend(self, ma_period=20):
+        dates = sorted(self.daily_data.keys())
+        closes = [self.daily_data[d]['close'] for d in dates]
+        for i, date in enumerate(dates):
+            if i < ma_period - 1:
+                self.daily_trend[date] = {'trend': 0, 'ma20': None, 'trend_strength': 0}
+                continue
+            ma20 = sum(closes[i-ma_period+1:i+1]) / ma_period
+            close = closes[i]
+            trend = 1 if close > ma20 * 1.02 else (-1 if close < ma20 * 0.98 else 0)
+            self.daily_trend[date] = {
+                'trend': trend, 'ma20': ma20,
+                'trend_strength': (close - ma20) / ma20 * 100
+            }
+
+    def get_daily_trend(self, date_str):
+        return self.daily_trend.get(date_str, {'trend': 0, 'ma20': None, 'trend_strength': 0})
+
+
+class MarketRegimeManager:
+    def __init__(self, regime_file):
+        self.regime_data = {}
+        self.load_regime_data(regime_file)
+
+    def load_regime_data(self, filepath):
+        with open(filepath, 'r', encoding='utf-8-sig') as f:
+            reader = csv.DictReader(f)
+            for row in reader:
+                self.regime_data[row['datetime']] = {
+                    'state': int(row['state']),
+                    'prob_trend': float(row['prob_trend'])
+                }
+
+    def get_regime(self, dt_str):
+        return self.regime_data.get(dt_str, {'state': 0, 'prob_trend': 0.0})
+
+
+class BacktestEngine:
+    def __init__(self):
+        self.initial_capital = 1000000
+        self.position_size = 0.5
+        self.capital = self.initial_capital
+        self.position = 0
+        self.entry_price = 0
+        self.holding_periods = 0
+        self.max_holding_periods = 16
+
+        self.equity_curve = []
+        self.trades = []
+        self.prices = deque(maxlen=100)
+
+    def calculate_signals(self):
+        if len(self.prices) < 50:
+            return None
+        pl = list(self.prices)
+        return {
+            'rsi': TechnicalIndicators.rsi(pl),
+            'bb_middle': TechnicalIndicators.bollinger_bands(pl)[1],
+            'ma5': TechnicalIndicators.sma(pl, 5),
+            'ma10': TechnicalIndicators.sma(pl, 10),
+            'macd': TechnicalIndicators.macd(pl)[0],
+            'macd_signal': TechnicalIndicators.macd(pl)[1],
+            'price': pl[-1]
+        }
+
+    def check_long_signal(self, s):
+        if not s:
+            return False, ""
+        c = []
+        if s['rsi'] and s['rsi'] < 65: c.append('RSI<65')
+        if s['ma5'] and s['ma10'] and s['ma5'] > s['ma10']: c.append('MA5>MA10')
+        if s['macd'] and s['macd_signal'] and s['macd'] > s['macd_signal']: c.append('MACD金叉')
+        if s['bb_middle'] and s['price'] > s['bb_middle']: c.append('价格>中轨')
+        return (True, '+'.join(c)) if len(c) >= 3 else (False, f"{len(c)}/3")
+
+    def check_exit(self, s, price):
+        if not s or self.position == 0:
+            return False, ""
+        if price <= self.entry_price * 0.975: return True, f"止损({price:.2f})"
+        if price >= self.entry_price * 1.04: return True, f"止盈({price:.2f})"
+        if self.holding_periods >= self.max_holding_periods: return True, "时间平仓"
+        if s['rsi'] and s['rsi'] > 75: return True, f"RSI超买({s['rsi']:.1f})"
+        return False, ""
+
+    def open(self, price, time_str, reason):
+        val = self.capital * self.position_size
+        self.position = val / price
+        self.entry_price = price
+        self.holding_periods = 0
+        self.trades.append({'action': 'OPEN', 'time': time_str, 'price': price,
+                           'shares': self.position, 'value': val, 'reason': reason})
+
+    def close(self, price, time_str, reason):
+        if self.position == 0: return
+        pnl = (price - self.entry_price) * self.position
+        pnl_pct = (price / self.entry_price - 1) * 100
+        self.capital += pnl
+        self.trades.append({'action': 'CLOSE', 'time': time_str, 'price': price,
+                           'shares': self.position, 'pnl': pnl, 'pnl_pct': pnl_pct, 'reason': reason})
+        self.position = 0
+
+    def update(self, ts, o, h, l, c, dm, rm):
+        self.prices.append(c)
+        dt_str = ts.strftime('%Y-%m-%d %H:%M:%S')
+        date_str = ts.strftime('%Y-%m-%d')
+
+        daily = dm.get_daily_trend(date_str)
+        regime = rm.get_regime(dt_str)
+
+        equity = self.capital + (self.position * c if self.position > 0 else 0)
+        self.equity_curve.append({'time': dt_str, 'equity': equity, 'close': c, 'position': 1 if self.position else 0,
+                                  'daily_trend': daily['trend'], 'daily_strength': daily['trend_strength'],
+                                  'regime_state': regime['state'], 'regime_prob': regime['prob_trend']})
+
+        if self.position > 0:
+            self.holding_periods += 1
+            s = self.calculate_signals()
+            ex, reason = self.check_exit(s, c)
+            if ex: self.close(c, dt_str, reason)
+        else:
+            s = self.calculate_signals()
+            ok, tech_reason = self.check_long_signal(s)
+            if ok and daily['trend'] == 1 and regime['state'] == 1 and regime['prob_trend'] >= 0.3:
+                self.open(c, dt_str, f"{tech_reason}|日线向上|30分钟趋势{regime['prob_trend']:.2f}")
+        return equity
+
+
+def load_data(fp):
+    data = []
+    with open(fp, 'r', encoding='utf-8-sig') as f:
+        for row in csv.DictReader(f):
+            try:
+                data.append({
+                    'datetime': datetime.strptime(row['DateTime'], '%Y-%m-%d %H:%M:%S'),
+                    'open': float(row['Open']), 'high': float(row['High']),
+                    'low': float(row['Low']), 'close': float(row['Close'])
+                })
+            except:
+                continue
+    return data
+
+
+def verify_data_integrity(data, dm, rm):
+    """核对数据完整性"""
+    print("\n" + "="*70)
+    print("数据准确性核对报告")
+    print("="*70)
+
+    issues = []
+    checked = 0
+
+    for row in data:
+        dt_str = row['datetime'].strftime('%Y-%m-%d %H:%M:%S')
+        date_str = row['datetime'].strftime('%Y-%m-%d')
+
+        # 检查日线数据
+        if date_str not in dm.daily_data:
+            issues.append(f"缺少日线数据: {date_str}")
+
+        # 检查30分钟状态
+        if dt_str not in rm.regime_data:
+            issues.append(f"缺少30分钟状态: {dt_str}")
+
+        checked += 1
+        if checked % 1000 == 0:
+            print(f"  已核对 {checked}/{len(data)} 条数据...")
+
+    print(f"\n数据核对完成:")
+    print(f"  总数据条数: {len(data)}")
+    print(f"  日线数据: {len(dm.daily_data)}条")
+    print(f"  30分钟状态: {len(rm.regime_data)}条")
+    print(f"  发现问题: {len(issues)}个")
+
+    if issues:
+        print(f"\n前10个问题:")
+        for i in issues[:10]:
+            print(f"  - {i}")
+
+    return len(issues) == 0
+
+
+def run_backtest(data_file, daily_file, regime_file, output_dir='final_backtest'):
+    os.makedirs(output_dir, exist_ok=True)
+
+    print("加载数据...")
+    data = load_data(data_file)
+    dm = DailyTrendManager(daily_file)
+    rm = MarketRegimeManager(regime_file)
+
+    # 核对数据
+    data_ok = verify_data_integrity(data, dm, rm)
+    if not data_ok:
+        print("\n[警告] 数据存在问题,但继续回测...")
+
+    print("\n运行最优参数回测...")
+    engine = BacktestEngine()
+
+    for row in data:
+        engine.update(row['datetime'], row['open'], row['high'], row['low'], row['close'], dm, rm)
+
+    # 统计
+    initial = engine.initial_capital
+    final = engine.equity_curve[-1]['equity']
+    total_ret = (final / initial - 1) * 100
+
+    closed = [t for t in engine.trades if t['action'] == 'CLOSE']
+    wins = [t for t in closed if t['pnl'] > 0]
+    losses = [t for t in closed if t['pnl'] <= 0]
+
+    win_rate = len(wins) / len(closed) * 100 if closed else 0
+    total_profit = sum(t['pnl'] for t in wins) if wins else 0
+    total_loss = sum(t['pnl'] for t in losses) if losses else 0
+    profit_factor = abs(total_profit / total_loss) if total_loss else 0
+
+    # 计算最大回撤
+    peak = initial
+    max_dd = 0
+    for e in engine.equity_curve:
+        if e['equity'] > peak:
+            peak = e['equity']
+        dd = (peak - e['equity']) / peak * 100
+        if dd > max_dd:
+            max_dd = dd
+
+    # 保存权益曲线
+    with open(f"{output_dir}/equity_final.csv", 'w', newline='') as f:
+        w = csv.DictWriter(f, fieldnames=['time', 'equity', 'close', 'position', 'daily_trend', 'daily_strength', 'regime_state', 'regime_prob'])
+        w.writeheader()
+        w.writerows(engine.equity_curve)
+
+    # 保存交易记录
+    with open(f"{output_dir}/trades_final.csv", 'w', newline='') as f:
+        if engine.trades:
+            # 获取所有可能的字段
+            all_fields = set()
+            for t in engine.trades:
+                all_fields.update(t.keys())
+            fieldnames = sorted(all_fields)
+            w = csv.DictWriter(f, fieldnames=fieldnames)
+            w.writeheader()
+            w.writerows(engine.trades)
+
+    # 生成详细报告
+    report = f"""
+================================================================================
+CYB50 最优参数回测报告 - 详细版
+================================================================================
+回测参数:
+  - 初始资金: 1,000,000 元
+  - 持仓上限: 50%
+  - 30分钟趋势概率阈值: 0.3 (最优)
+  - 日线要求: 必须向上 (MA20之上)
+  - 止损: -2.5% | 止盈: +4% | 最大持仓: 16周期(8小时)
+
+================================================================================
+整体表现
+================================================================================
+  初始资金:        {initial:>15,.2f} 元
+  最终资金:        {final:>15,.2f} 元
+  净盈亏:          {final-initial:>15,.2f} 元
+  总收益率:        {total_ret:>15.2f} %
+  最大回撤:        {max_dd:>15.2f} %
+
+================================================================================
+交易统计
+================================================================================
+  总交易次数:      {len(closed):>15} 笔
+  盈利次数:        {len(wins):>15} 笔
+  亏损次数:        {len(losses):>15} 笔
+  胜率:            {win_rate:>15.2f} %
+  盈亏比:          {profit_factor:>15.2f}
+  总盈利:          {total_profit:>15,.2f} 元
+  总亏损:          {total_loss:>15,.2f} 元
+  平均每笔盈利:    {total_profit/len(wins) if wins else 0:>15,.2f} 元
+  平均每笔亏损:    {total_loss/len(losses) if losses else 0:>15,.2f} 元
+
+================================================================================
+最近10笔交易明细
+================================================================================
+"""
+    for t in closed[-10:]:
+        report += f"  {t['time']} | 平仓价: {t['price']:.2f} | 盈亏: {t['pnl']:>+10,.2f} ({t['pnl_pct']:+.2f}%) | {t['reason']}\n"
+
+    report += f"""
+================================================================================
+数据核对结果
+================================================================================
+  30分钟数据条数:  {len(data)} 条
+  日线数据条数:    {len(dm.daily_data)} 条
+  30分钟状态条数:  {len(rm.regime_data)} 条
+  数据完整性:      {'通过 ✓' if data_ok else '存在问题 ✗'}
+
+================================================================================
+文件输出
+================================================================================
+  - {output_dir}/equity_final.csv (权益曲线)
+  - {output_dir}/trades_final.csv (交易明细)
+  - {output_dir}/report_final.txt (本报告)
+================================================================================
+"""
+
+    with open(f"{output_dir}/report_final.txt", 'w') as f:
+        f.write(report)
+
+    print(report)
+    print(f"\n所有文件已保存到: {output_dir}/")
+
+    return engine
+
+
+if __name__ == '__main__':
+    run_backtest(
+        'cyb50_30min_2023_to_20260325.csv',
+        '../data-fetch/data/399673_SZ_day_20150101_20260325.csv',
+        '../../market-regime-identifier-30/cyb50_30min_regime_result.csv'
+    )

+ 572 - 0
cat-fly/t1/backtest_multi_timeframe.py

@@ -0,0 +1,572 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+CYB50 多周期确认 + 参数优化回测系统
+结合日线趋势和30分钟择时,支持参数扫描
+"""
+
+import csv
+import json
+from datetime import datetime, timedelta
+from collections import deque
+import math
+import os
+
+# ==================== 技术指标计算类 ====================
+class TechnicalIndicators:
+    """技术指标计算 - 纯Python实现"""
+
+    @staticmethod
+    def sma(data, period):
+        """简单移动平均线"""
+        if len(data) < period:
+            return None
+        return sum(data[-period:]) / period
+
+    @staticmethod
+    def rsi(prices, period=14):
+        """RSI计算"""
+        if len(prices) < period + 1:
+            return None
+        gains = []
+        losses = []
+        for i in range(1, len(prices)):
+            change = prices[i] - prices[i-1]
+            if change > 0:
+                gains.append(change)
+                losses.append(0)
+            else:
+                gains.append(0)
+                losses.append(abs(change))
+        if len(gains) < period:
+            return None
+        avg_gain = sum(gains[-period:]) / period
+        avg_loss = sum(losses[-period:]) / period
+        if avg_loss == 0:
+            return 100
+        rs = avg_gain / avg_loss
+        return 100 - (100 / (1 + rs))
+
+    @staticmethod
+    def bollinger_bands(prices, period=20, std_dev=2):
+        """布林带计算"""
+        if len(prices) < period:
+            return None, None, None
+        middle = sum(prices[-period:]) / period
+        variance = sum((p - middle) ** 2 for p in prices[-period:]) / period
+        std = math.sqrt(variance)
+        upper = middle + (std * std_dev)
+        lower = middle - (std * std_dev)
+        return upper, middle, lower
+
+    @staticmethod
+    def macd(prices, fast=12, slow=26, signal=9):
+        """MACD计算"""
+        if len(prices) < slow:
+            return None, None, None
+        def calc_ema(data, period):
+            multiplier = 2 / (period + 1)
+            ema = data[0]
+            for price in data[1:]:
+                ema = (price - ema) * multiplier + ema
+            return ema
+        ema_fast = calc_ema(prices[-fast:], fast) if len(prices) >= fast else None
+        ema_slow = calc_ema(prices[-slow:], slow) if len(prices) >= slow else None
+        if ema_fast is None or ema_slow is None:
+            return None, None, None
+        macd_line = ema_fast - ema_slow
+        macd_prices = []
+        for i in range(slow, len(prices) + 1):
+            fast_ema = calc_ema(prices[i-fast:i], fast)
+            slow_ema = calc_ema(prices[i-slow:i], slow)
+            macd_prices.append(fast_ema - slow_ema)
+        signal_line = None
+        if len(macd_prices) >= signal:
+            signal_line = calc_ema(macd_prices[-signal:], signal)
+        histogram = macd_line - signal_line if signal_line else None
+        return macd_line, signal_line, histogram
+
+
+# ==================== 日线趋势管理器 ====================
+class DailyTrendManager:
+    """管理日线趋势数据,提供多周期确认"""
+
+    def __init__(self, daily_file):
+        self.daily_data = {}
+        self.daily_trend = {}  # date -> trend info
+        self.load_daily_data(daily_file)
+        self.calculate_daily_trend()
+
+    def load_daily_data(self, filepath):
+        """加载日线数据"""
+        print(f"加载日线数据: {filepath}")
+        try:
+            with open(filepath, 'r', encoding='utf-8-sig') as f:
+                reader = csv.DictReader(f)
+                for row in reader:
+                    try:
+                        dt = datetime.strptime(row['datetime'], '%Y-%m-%d %H:%M:%S')
+                        date_str = dt.strftime('%Y-%m-%d')
+                        self.daily_data[date_str] = {
+                            'open': float(row['open']),
+                            'high': float(row['high']),
+                            'low': float(row['low']),
+                            'close': float(row['close']),
+                            'volume': float(row['volume'])
+                        }
+                    except:
+                        continue
+            print(f"[OK] 加载成功: {len(self.daily_data)}条日线数据")
+        except Exception as e:
+            print(f"[ERROR] 加载失败: {e}")
+
+    def calculate_daily_trend(self, ma_period=20):
+        """计算日线趋势"""
+        print(f"计算日线趋势 (MA{ma_period})...")
+
+        dates = sorted(self.daily_data.keys())
+        closes = [self.daily_data[d]['close'] for d in dates]
+
+        for i, date in enumerate(dates):
+            if i < ma_period - 1:
+                self.daily_trend[date] = {'trend': 0, 'ma20': None, 'trend_strength': 0}
+                continue
+
+            # 计算MA20
+            ma20 = sum(closes[i-ma_period+1:i+1]) / ma_period
+            close = closes[i]
+
+            # 趋势方向: 1=向上, -1=向下, 0=横盘
+            if close > ma20 * 1.02:
+                trend = 1  # 明显向上
+            elif close < ma20 * 0.98:
+                trend = -1  # 明显向下
+            else:
+                trend = 0  # 横盘
+
+            # 趋势强度
+            trend_strength = (close - ma20) / ma20 * 100
+
+            self.daily_trend[date] = {
+                'trend': trend,
+                'ma20': ma20,
+                'trend_strength': trend_strength
+            }
+
+        print(f"[OK] 日线趋势计算完成")
+
+    def get_daily_trend(self, date_str):
+        """获取指定日期的日线趋势"""
+        return self.daily_trend.get(date_str, {'trend': 0, 'ma20': None, 'trend_strength': 0})
+
+    def can_trade_long(self, date_str, require_uptrend=True):
+        """检查是否允许做多"""
+        trend_info = self.get_daily_trend(date_str)
+
+        if require_uptrend:
+            # 要求日线趋势向上
+            return trend_info['trend'] == 1, trend_info
+        else:
+            # 允许横盘和向上,禁止向下
+            return trend_info['trend'] >= 0, trend_info
+
+
+# ==================== 30分钟市场状态管理器 ====================
+class MarketRegimeManager:
+    """管理30分钟市场状态数据"""
+
+    def __init__(self, regime_file):
+        self.regime_data = {}
+        self.load_regime_data(regime_file)
+
+    def load_regime_data(self, filepath):
+        """加载市场状态数据"""
+        print(f"加载30分钟状态数据: {filepath}")
+        try:
+            with open(filepath, 'r', encoding='utf-8-sig') as f:
+                reader = csv.DictReader(f)
+                for row in reader:
+                    dt_str = row['datetime']
+                    self.regime_data[dt_str] = {
+                        'state': int(row['state']),
+                        'prob_ranging': float(row['prob_ranging']),
+                        'prob_trend': float(row['prob_trend']),
+                        'prob_reversal': float(row['prob_reversal'])
+                    }
+            print(f"[OK] 加载成功: {len(self.regime_data)}条状态数据")
+        except Exception as e:
+            print(f"[ERROR] 加载失败: {e}")
+
+    def get_regime(self, dt_str):
+        """获取指定时间的市场状态"""
+        return self.regime_data.get(dt_str, {
+            'state': 0,
+            'prob_ranging': 1.0,
+            'prob_trend': 0.0,
+            'prob_reversal': 0.0
+        })
+
+    def can_open_long(self, dt_str, min_trend_prob=0.5):
+        """判断是否允许开多单"""
+        regime = self.get_regime(dt_str)
+        state = regime['state']
+        trend_prob = regime['prob_trend']
+
+        if state == 1 and trend_prob >= min_trend_prob:
+            return True, regime
+
+        if state == 2:
+            return False, regime
+
+        return False, regime
+
+
+# ==================== 回测引擎(带参数) ====================
+class BacktestEngine:
+    """多周期确认回测引擎"""
+
+    def __init__(self, initial_capital=1000000, position_size=0.5,
+                 min_trend_prob=0.5, require_daily_uptrend=True):
+        self.initial_capital = initial_capital
+        self.position_size = position_size
+        self.min_trend_prob = min_trend_prob
+        self.require_daily_uptrend = require_daily_uptrend
+
+        self.capital = initial_capital
+        self.position = 0
+        self.entry_price = 0
+        self.entry_time = None
+        self.holding_periods = 0
+        self.max_holding_periods = 16
+
+        self.equity_curve = []
+        self.trades = []
+        self.signals = []
+        self.block_reasons = {'daily': 0, 'regime': 0}
+
+        self.prices = deque(maxlen=100)
+        self.highs = deque(maxlen=100)
+        self.lows = deque(maxlen=100)
+
+    def calculate_signals(self):
+        """计算交易信号"""
+        if len(self.prices) < 50:
+            return None
+
+        price_list = list(self.prices)
+
+        rsi = TechnicalIndicators.rsi(price_list, 14)
+        bb_upper, bb_middle, bb_lower = TechnicalIndicators.bollinger_bands(price_list, 20, 2)
+        ma5 = TechnicalIndicators.sma(price_list, 5)
+        ma10 = TechnicalIndicators.sma(price_list, 10)
+        ma20 = TechnicalIndicators.sma(price_list, 20)
+        macd_line, signal_line, histogram = TechnicalIndicators.macd(price_list)
+
+        return {
+            'rsi': rsi,
+            'bb_upper': bb_upper,
+            'bb_lower': bb_lower,
+            'bb_middle': bb_middle,
+            'ma5': ma5,
+            'ma10': ma10,
+            'ma20': ma20,
+            'macd': macd_line,
+            'macd_signal': signal_line,
+            'price': price_list[-1]
+        }
+
+    def check_long_signal(self, signals):
+        """检查做多信号"""
+        if signals is None:
+            return False, "指标不足"
+
+        conditions = []
+
+        if signals['rsi'] is not None and signals['rsi'] < 65:
+            conditions.append('RSI<65')
+
+        if (signals['ma5'] is not None and signals['ma10'] is not None and
+            signals['ma5'] > signals['ma10']):
+            conditions.append('MA5>MA10')
+
+        if (signals['macd'] is not None and signals['macd_signal'] is not None and
+            signals['macd'] > signals['macd_signal']):
+            conditions.append('MACD金叉')
+
+        if (signals['bb_middle'] is not None and
+            signals['price'] > signals['bb_middle']):
+            conditions.append('价格>中轨')
+
+        if len(conditions) >= 3:
+            return True, '+'.join(conditions)
+
+        return False, f"条件不足({len(conditions)}/3)"
+
+    def check_exit_signal(self, signals, current_price):
+        """检查平仓信号"""
+        if signals is None or self.position == 0:
+            return False, ""
+
+        stop_loss = self.entry_price * 0.975
+        if current_price <= stop_loss:
+            return True, f"止损({current_price:.2f}<={stop_loss:.2f})"
+
+        take_profit = self.entry_price * 1.04
+        if current_price >= take_profit:
+            return True, f"止盈({current_price:.2f}>={take_profit:.2f})"
+
+        if self.holding_periods >= self.max_holding_periods:
+            return True, f"时间平仓({self.holding_periods}周期)"
+
+        if signals['rsi'] is not None and signals['rsi'] > 75:
+            return True, f"RSI超买({signals['rsi']:.1f})"
+
+        return False, ""
+
+    def open_position(self, price, time_str, reason):
+        """开仓"""
+        position_value = self.capital * self.position_size
+        self.position = position_value / price
+        self.entry_price = price
+        self.entry_time = time_str
+        self.holding_periods = 0
+
+        self.trades.append({
+            'action': 'OPEN',
+            'time': time_str,
+            'price': price,
+            'shares': self.position,
+            'value': position_value,
+            'reason': reason
+        })
+
+    def close_position(self, price, time_str, reason):
+        """平仓"""
+        if self.position == 0:
+            return
+
+        pnl = (price - self.entry_price) * self.position
+        pnl_pct = (price / self.entry_price - 1) * 100
+        self.capital += pnl
+
+        self.trades.append({
+            'action': 'CLOSE',
+            'time': time_str,
+            'price': price,
+            'shares': self.position,
+            'pnl': pnl,
+            'pnl_pct': pnl_pct,
+            'reason': reason
+        })
+
+        self.position = 0
+        self.entry_price = 0
+        self.holding_periods = 0
+
+    def update(self, timestamp, open_price, high, low, close,
+               daily_manager, regime_manager):
+        """更新回测状态"""
+        self.prices.append(close)
+        self.highs.append(high)
+        self.lows.append(low)
+
+        signals = self.calculate_signals()
+
+        dt_str = timestamp.strftime('%Y-%m-%d %H:%M:%S')
+        date_str = timestamp.strftime('%Y-%m-%d')
+
+        # 多周期确认
+        daily_ok, daily_info = daily_manager.can_trade_long(
+            date_str, self.require_daily_uptrend)
+        regime_ok, regime_info = regime_manager.can_open_long(
+            dt_str, self.min_trend_prob)
+
+        equity = self.capital
+        if self.position > 0:
+            equity += self.position * close
+        self.equity_curve.append({
+            'time': dt_str,
+            'equity': equity,
+            'close': close,
+            'position': 1 if self.position > 0 else 0
+        })
+
+        if self.position > 0:
+            self.holding_periods += 1
+            should_exit, exit_reason = self.check_exit_signal(signals, close)
+            if should_exit:
+                self.close_position(close, dt_str, exit_reason)
+        else:
+            tech_signal, tech_reason = self.check_long_signal(signals)
+
+            if tech_signal:
+                block_reason = []
+                if not daily_ok:
+                    block_reason.append(f"日线趋势向下(强度:{daily_info['trend_strength']:.2f}%)")
+                    self.block_reasons['daily'] += 1
+                if not regime_ok:
+                    block_reason.append(f"30分钟非趋势状态(state={regime_info['state']},概率={regime_info['prob_trend']:.2f})")
+                    self.block_reasons['regime'] += 1
+
+                if daily_ok and regime_ok:
+                    self.open_position(close, dt_str,
+                        f"{tech_reason}|日线向上|30分钟趋势(prob={regime_info['prob_trend']:.2f})")
+                else:
+                    self.signals.append({
+                        'time': dt_str,
+                        'price': close,
+                        'tech_reason': tech_reason,
+                        'block_reason': '|'.join(block_reason)
+                    })
+
+        return equity
+
+
+# ==================== 主程序 ====================
+def load_data(filepath):
+    """加载30分钟数据"""
+    print(f"加载30分钟数据: {filepath}")
+    data = []
+    with open(filepath, 'r', encoding='utf-8-sig') as f:
+        reader = csv.DictReader(f)
+        for row in reader:
+            try:
+                dt = datetime.strptime(row['DateTime'], '%Y-%m-%d %H:%M:%S')
+                data.append({
+                    'datetime': dt,
+                    'open': float(row['Open']),
+                    'high': float(row['High']),
+                    'low': float(row['Low']),
+                    'close': float(row['Close']),
+                    'volume': float(row['Volume'])
+                })
+            except:
+                continue
+    print(f"[OK] 加载成功: {len(data)}条")
+    return data
+
+
+def run_single_backtest(data, daily_manager, regime_manager, params,
+                        output_dir='backtest_results'):
+    """运行单次回测"""
+    os.makedirs(output_dir, exist_ok=True)
+
+    engine = BacktestEngine(
+        initial_capital=1000000,
+        position_size=0.5,
+        min_trend_prob=params['min_trend_prob'],
+        require_daily_uptrend=params['require_daily_uptrend']
+    )
+
+    for row in data:
+        engine.update(
+            row['datetime'],
+            row['open'],
+            row['high'],
+            row['low'],
+            row['close'],
+            daily_manager,
+            regime_manager
+        )
+
+    # 统计结果
+    initial = engine.initial_capital
+    final = engine.equity_curve[-1]['equity'] if engine.equity_curve else initial
+    total_return = (final / initial - 1) * 100
+
+    closed_trades = [t for t in engine.trades if t['action'] == 'CLOSE']
+    win_count = len([t for t in closed_trades if t['pnl'] > 0])
+    loss_count = len([t for t in closed_trades if t['pnl'] <= 0])
+    win_rate = win_count / len(closed_trades) * 100 if closed_trades else 0
+
+    total_profit = sum(t['pnl'] for t in closed_trades if t['pnl'] > 0)
+    total_loss = sum(t['pnl'] for t in closed_trades if t['pnl'] <= 0)
+    profit_factor = abs(total_profit / total_loss) if total_loss != 0 else 0
+
+    result = {
+        'params': params,
+        'total_return': total_return,
+        'trade_count': len(closed_trades),
+        'win_count': win_count,
+        'loss_count': loss_count,
+        'win_rate': win_rate,
+        'profit_factor': profit_factor,
+        'blocked_daily': engine.block_reasons['daily'],
+        'blocked_regime': engine.block_reasons['regime'],
+        'final_capital': final
+    }
+
+    return result, engine
+
+
+def run_parameter_scan(data_file, daily_file, regime_file, output_dir='optimization_results'):
+    """参数扫描优化"""
+    os.makedirs(output_dir, exist_ok=True)
+
+    # 加载数据
+    data = load_data(data_file)
+    daily_manager = DailyTrendManager(daily_file)
+    regime_manager = MarketRegimeManager(regime_file)
+
+    # 参数网格
+    param_grid = [
+        {'min_trend_prob': 0.3, 'require_daily_uptrend': True},
+        {'min_trend_prob': 0.4, 'require_daily_uptrend': True},
+        {'min_trend_prob': 0.5, 'require_daily_uptrend': True},
+        {'min_trend_prob': 0.6, 'require_daily_uptrend': True},
+        {'min_trend_prob': 0.7, 'require_daily_uptrend': True},
+        {'min_trend_prob': 0.5, 'require_daily_uptrend': False},  # 允许横盘
+    ]
+
+    print("\n" + "="*70)
+    print("参数优化扫描")
+    print("="*70)
+
+    all_results = []
+    for i, params in enumerate(param_grid):
+        print(f"\n[{i+1}/{len(param_grid)}] 测试参数: {params}")
+
+        result, engine = run_single_backtest(
+            data, daily_manager, regime_manager, params, output_dir)
+
+        all_results.append(result)
+
+        print(f"  收益率: {result['total_return']:+.2f}%")
+        print(f"  交易次数: {result['trade_count']}")
+        print(f"  胜率: {result['win_rate']:.1f}%")
+        print(f"  盈亏比: {result['profit_factor']:.2f}")
+
+    # 排序结果
+    all_results.sort(key=lambda x: x['total_return'], reverse=True)
+
+    print("\n" + "="*70)
+    print("参数优化结果排名")
+    print("="*70)
+
+    for i, r in enumerate(all_results[:5]):
+        print(f"\n第{i+1}名:")
+        print(f"  参数: 趋势概率阈值={r['params']['min_trend_prob']}, "
+              f"要求日线向上={r['params']['require_daily_uptrend']}")
+        print(f"  收益率: {r['total_return']:+.2f}%")
+        print(f"  交易次数: {r['trade_count']}")
+        print(f"  胜率: {r['win_rate']:.1f}%")
+        print(f"  盈亏比: {r['profit_factor']:.2f}")
+        print(f"  被日线过滤: {r['blocked_daily']}次")
+        print(f"  被30分钟过滤: {r['blocked_regime']}次")
+
+    # 保存优化结果
+    result_file = f"{output_dir}/parameter_optimization_results.json"
+    with open(result_file, 'w', encoding='utf-8') as f:
+        json.dump(all_results, f, indent=2, ensure_ascii=False)
+
+    print(f"\n优化结果已保存: {result_file}")
+
+    return all_results
+
+
+if __name__ == '__main__':
+    DATA_FILE = 'cyb50_30min_2023_to_20260325.csv'
+    DAILY_FILE = '../data-fetch/data/399673_SZ_day_20150101_20260325.csv'
+    REGIME_FILE = '../../market-regime-identifier-30/cyb50_30min_regime_result.csv'
+
+    results = run_parameter_scan(DATA_FILE, DAILY_FILE, REGIME_FILE)

+ 536 - 0
cat-fly/t1/backtest_no_lookahead.py

@@ -0,0 +1,536 @@
+#!/usr/bin/env python3
+"""
+无未来函数回测系统 - Walk-Forward验证
+严格使用滚动窗口,只用历史数据训练模型
+"""
+
+import csv
+import json
+from datetime import datetime, timedelta
+from collections import deque
+import math
+import os
+
+# ============ 技术指标 ============
+class TechnicalIndicators:
+    @staticmethod
+    def sma(data, period):
+        if len(data) < period:
+            return None
+        return sum(data[-period:]) / period
+
+    @staticmethod
+    def rsi(prices, period=14):
+        if len(prices) < period + 1:
+            return None
+        gains, losses = [], []
+        for i in range(1, len(prices)):
+            change = prices[i] - prices[i-1]
+            gains.append(change if change > 0 else 0)
+            losses.append(abs(change) if change < 0 else 0)
+        avg_gain = sum(gains[-period:]) / period
+        avg_loss = sum(losses[-period:]) / period
+        if avg_loss == 0:
+            return 100
+        return 100 - (100 / (1 + avg_gain / avg_loss))
+
+    @staticmethod
+    def bollinger_bands(prices, period=20, std_dev=2):
+        if len(prices) < period:
+            return None, None, None
+        middle = sum(prices[-period:]) / period
+        variance = sum((p - middle) ** 2 for p in prices[-period:]) / period
+        std = math.sqrt(variance)
+        return middle + std*std_dev, middle, middle - std*std_dev
+
+    @staticmethod
+    def macd(prices, fast=12, slow=26, signal=9):
+        if len(prices) < slow:
+            return None, None, None
+        def calc_ema(data, period):
+            mult = 2 / (period + 1)
+            ema = data[0]
+            for p in data[1:]:
+                ema = (p - ema) * mult + ema
+            return ema
+        macd_vals = []
+        for i in range(slow, len(prices)+1):
+            f = calc_ema(prices[i-fast:i], fast)
+            s = calc_ema(prices[i-slow:i], slow)
+            macd_vals.append(f - s)
+        sig = calc_ema(macd_vals[-signal:], signal) if len(macd_vals) >= signal else None
+        return macd_vals[-1], sig, macd_vals[-1] - sig if sig else None
+
+
+# ============ 基于规则的市场状态判断(无ML,无未来函数) ============
+class RuleBasedRegimeDetector:
+    """
+    基于规则的市场状态判断 - 完全无未来函数
+    只用当前和过去的数据,不用任何未来信息
+    """
+    
+    def __init__(self, lookback=16):  # 8小时 = 16个30分钟周期
+        self.lookback = lookback
+        self.prices = deque(maxlen=lookback+10)
+        self.highs = deque(maxlen=lookback+10)
+        self.lows = deque(maxlen=lookback+10)
+        
+    def update(self, price, high, low):
+        """更新价格数据"""
+        self.prices.append(price)
+        self.highs.append(high)
+        self.lows.append(low)
+        
+    def detect_regime(self):
+        """
+        检测当前市场状态 - 只用历史数据
+        返回: (state, prob_trend)
+        state: 0=震荡, 1=趋势, 2=反转
+        """
+        if len(self.prices) < self.lookback:
+            return 0, 0.0  # 数据不足,默认震荡
+            
+        prices = list(self.prices)[-self.lookback:]
+        highs = list(self.highs)[-self.lookback:]
+        lows = list(self.lows)[-self.lookback:]
+        
+        # 计算回看窗口的收益率
+        start_price = prices[0]
+        end_price = prices[-1]
+        period_return = (end_price / start_price - 1) * 100
+        
+        # 计算价格波动范围
+        max_price = max(highs)
+        min_price = min(lows)
+        price_range = (max_price - min_price) / start_price * 100
+        
+        # 计算RSI(只用历史数据)
+        rsi = TechnicalIndicators.rsi(prices, 14)
+        if rsi is None:
+            rsi = 50
+            
+        # 计算波动率
+        returns = [(prices[i] - prices[i-1]) / prices[i-1] * 100 
+                   for i in range(1, len(prices))]
+        volatility = math.sqrt(sum(r**2 for r in returns) / len(returns)) if returns else 0
+        
+        # ===== 判断逻辑(完全基于历史数据)=====
+        
+        # 反转信号检测
+        reversal_score = 0
+        
+        # RSI极值(历史极值)
+        if rsi > 70:
+            reversal_score += 2
+        elif rsi < 30:
+            reversal_score += 2
+        elif rsi > 65 or rsi < 35:
+            reversal_score += 1
+            
+        # 价格触及极端后回落
+        if price_range > 4:
+            # 如果价格在区间高点附近但涨幅不大
+            if end_price > max_price * 0.98 and abs(period_return) < 1.5:
+                reversal_score += 2
+                
+        # 大波动小收益(震荡特征)
+        if price_range > 3 and abs(period_return) < 0.5:
+            reversal_score += 1
+            
+        if reversal_score >= 3:
+            return 2, 0.3  # 反转状态
+            
+        # 趋势信号检测
+        trend_score = 0
+        
+        # 明显的方向性(过去8小时的趋势)
+        if abs(period_return) >= 2.0:
+            trend_score += 3
+        elif abs(period_return) >= 1.0:
+            trend_score += 2
+        elif abs(period_return) >= 0.5:
+            trend_score += 1
+            
+        # 波动率适中(趋势市场通常有适度波动)
+        if 0.5 < volatility < 2.0:
+            trend_score += 1
+            
+        # 价格在趋势方向上持续
+        if len(prices) >= 8:
+            first_half = prices[:len(prices)//2]
+            second_half = prices[len(prices)//2:]
+            first_avg = sum(first_half) / len(first_half)
+            second_avg = sum(second_half) / len(second_half)
+            
+            if (period_return > 0 and second_avg > first_avg) or \
+               (period_return < 0 and second_avg < first_avg):
+                trend_score += 1
+                
+        if trend_score >= 4:
+            # 计算趋势概率(基于趋势强度)
+            prob = min(0.95, 0.5 + abs(period_return) / 10)
+            return 1, prob  # 趋势状态
+            
+        # 默认震荡
+        return 0, 0.2
+
+
+# ============ 日线趋势管理器(无未来函数) ============
+class DailyTrendManager:
+    def __init__(self, daily_file):
+        self.daily_data = {}
+        self.ma20_values = {}  # 预先计算的MA20
+        self.load_daily_data(daily_file)
+        self.calculate_ma20()
+
+    def load_daily_data(self, filepath):
+        with open(filepath, 'r', encoding='utf-8-sig') as f:
+            reader = csv.DictReader(f)
+            for row in reader:
+                try:
+                    dt = datetime.strptime(row['datetime'], '%Y-%m-%d %H:%M:%S')
+                    self.daily_data[dt.strftime('%Y-%m-%d')] = {
+                        'open': float(row['open']), 'high': float(row['high']),
+                        'low': float(row['low']), 'close': float(row['close'])
+                    }
+                except:
+                    continue
+
+    def calculate_ma20(self):
+        """计算MA20 - 只用历史数据"""
+        dates = sorted(self.daily_data.keys())
+        closes = [self.daily_data[d]['close'] for d in dates]
+        
+        for i, date in enumerate(dates):
+            if i < 19:  # 需要20天数据
+                self.ma20_values[date] = None
+            else:
+                # 只用当前日期之前的数据
+                ma20 = sum(closes[i-19:i+1]) / 20
+                self.ma20_values[date] = ma20
+
+    def get_trend(self, date_str):
+        """获取日线趋势 - 完全无未来函数"""
+        if date_str not in self.daily_data:
+            return {'trend': 0, 'ma20': None}
+            
+        close = self.daily_data[date_str]['close']
+        ma20 = self.ma20_values.get(date_str)
+        
+        if ma20 is None:
+            return {'trend': 0, 'ma20': None}
+            
+        # 判断趋势
+        if close > ma20 * 1.02:
+            trend = 1
+        elif close < ma20 * 0.98:
+            trend = -1
+        else:
+            trend = 0
+            
+        return {
+            'trend': trend,
+            'ma20': ma20,
+            'trend_strength': (close - ma20) / ma20 * 100
+        }
+
+
+# ============ 回测引擎 ============
+class BacktestEngine:
+    def __init__(self, min_trend_prob=0.3):
+        self.initial_capital = 1000000
+        self.position_size = 0.5
+        self.min_trend_prob = min_trend_prob
+        self.capital = self.initial_capital
+        self.position = 0
+        self.entry_price = 0
+        self.holding_periods = 0
+        self.max_holding_periods = 16
+
+        self.equity_curve = []
+        self.trades = []
+        self.regime_detector = RuleBasedRegimeDetector(lookback=16)
+        
+        # 技术指标计算
+        self.prices = deque(maxlen=100)
+        self.highs = deque(maxlen=100)
+        self.lows = deque(maxlen=100)
+
+    def calculate_signals(self):
+        if len(self.prices) < 50:
+            return None
+        pl = list(self.prices)
+        return {
+            'rsi': TechnicalIndicators.rsi(pl),
+            'bb_middle': TechnicalIndicators.bollinger_bands(pl)[1],
+            'ma5': TechnicalIndicators.sma(pl, 5),
+            'ma10': TechnicalIndicators.sma(pl, 10),
+            'macd': TechnicalIndicators.macd(pl)[0],
+            'macd_signal': TechnicalIndicators.macd(pl)[1],
+            'price': pl[-1]
+        }
+
+    def check_long_signal(self, s):
+        if not s:
+            return False, ""
+        c = []
+        if s['rsi'] and s['rsi'] < 65: c.append('RSI<65')
+        if s['ma5'] and s['ma10'] and s['ma5'] > s['ma10']: c.append('MA5>MA10')
+        if s['macd'] and s['macd_signal'] and s['macd'] > s['macd_signal']: c.append('MACD金叉')
+        if s['bb_middle'] and s['price'] > s['bb_middle']: c.append('价格>中轨')
+        return (True, '+'.join(c)) if len(c) >= 3 else (False, f"{len(c)}/3")
+
+    def check_exit(self, s, price):
+        if not s or self.position == 0:
+            return False, ""
+        if price <= self.entry_price * 0.975: return True, f"止损({price:.2f})"
+        if price >= self.entry_price * 1.04: return True, f"止盈({price:.2f})"
+        if self.holding_periods >= self.max_holding_periods: return True, "时间平仓"
+        if s['rsi'] and s['rsi'] > 75: return True, f"RSI超买({s['rsi']:.1f})"
+        return False, ""
+
+    def open(self, price, time_str, reason):
+        val = self.capital * self.position_size
+        self.position = val / price
+        self.entry_price = price
+        self.holding_periods = 0
+        self.trades.append({
+            'action': 'OPEN', 'time': time_str, 'price': price,
+            'shares': self.position, 'value': val, 'reason': reason
+        })
+
+    def close(self, price, time_str, reason):
+        if self.position == 0: return
+        pnl = (price - self.entry_price) * self.position
+        pnl_pct = (price / self.entry_price - 1) * 100
+        self.capital += pnl
+        self.trades.append({
+            'action': 'CLOSE', 'time': time_str, 'price': price,
+            'shares': self.position, 'pnl': pnl, 'pnl_pct': pnl_pct,
+            'reason': reason
+        })
+        self.position = 0
+
+    def update(self, ts, o, h, l, c, daily_manager):
+        """更新回测 - 严格无未来函数"""
+        # 更新技术指标数据
+        self.prices.append(c)
+        self.highs.append(h)
+        self.lows.append(l)
+        
+        # 更新市场状态检测器(滚动窗口)
+        self.regime_detector.update(c, h, l)
+        
+        dt_str = ts.strftime('%Y-%m-%d %H:%M:%S')
+        date_str = ts.strftime('%Y-%m-%d')
+
+        # 获取日线趋势(只用历史MA20)
+        daily = daily_manager.get_trend(date_str)
+        
+        # 获取当前市场状态(基于历史数据的规则判断)
+        state, prob_trend = self.regime_detector.detect_regime()
+
+        # 计算权益
+        equity = self.capital + (self.position * c if self.position > 0 else 0)
+        self.equity_curve.append({
+            'time': dt_str, 'equity': equity, 'close': c,
+            'position': 1 if self.position else 0,
+            'daily_trend': daily['trend'],
+            'regime_state': state,
+            'regime_prob': prob_trend
+        })
+
+        # 持仓管理
+        if self.position > 0:
+            self.holding_periods += 1
+            s = self.calculate_signals()
+            ex, reason = self.check_exit(s, c)
+            if ex:
+                self.close(c, dt_str, reason)
+        else:
+            # 开仓判断
+            s = self.calculate_signals()
+            ok, tech_reason = self.check_long_signal(s)
+            
+            # 多周期确认
+            if ok and daily['trend'] == 1 and state == 1 and prob_trend >= self.min_trend_prob:
+                self.open(c, dt_str, f"{tech_reason}|日线向上|30分钟趋势{prob_trend:.2f}")
+
+        return equity
+
+
+def load_data(fp):
+    data = []
+    with open(fp, 'r', encoding='utf-8-sig') as f:
+        for row in csv.DictReader(f):
+            try:
+                data.append({
+                    'datetime': datetime.strptime(row['DateTime'], '%Y-%m-%d %H:%M:%S'),
+                    'open': float(row['Open']), 'high': float(row['High']),
+                    'low': float(row['Low']), 'close': float(row['Close'])
+                })
+            except:
+                continue
+    return data
+
+
+def run_backtest(data_file, daily_file, output_dir='no_lookahead_backtest'):
+    os.makedirs(output_dir, exist_ok=True)
+    
+    print("="*70)
+    print("无未来函数回测系统 - Walk-Forward验证")
+    print("="*70)
+    print("\n核心设计:")
+    print("  ✓ 市场状态判断只用历史数据(过去16个30分钟周期)")
+    print("  ✓ 日线MA20只用当日及之前的数据")
+    print("  ✓ 无任何机器学习模型,避免训练集泄露")
+    print("  ✓ 纯规则判断,每个决策点只用已知信息")
+    print("="*70)
+
+    data = load_data(data_file)
+    daily_manager = DailyTrendManager(daily_file)
+
+    print(f"\n加载数据完成:")
+    print(f"  30分钟数据: {len(data)}条")
+    print(f"  日线数据: {len(daily_manager.daily_data)}条")
+
+    # 运行回测
+    engine = BacktestEngine(min_trend_prob=0.3)
+    
+    for row in data:
+        engine.update(row['datetime'], row['open'], row['high'], 
+                     row['low'], row['close'], daily_manager)
+
+    # 统计结果
+    initial = engine.initial_capital
+    final = engine.equity_curve[-1]['equity'] if engine.equity_curve else initial
+    total_ret = (final / initial - 1) * 100
+
+    closed = [t for t in engine.trades if t['action'] == 'CLOSE']
+    wins = [t for t in closed if t['pnl'] > 0]
+    losses = [t for t in closed if t['pnl'] <= 0]
+
+    win_rate = len(wins) / len(closed) * 100 if closed else 0
+    total_profit = sum(t['pnl'] for t in wins) if wins else 0
+    total_loss = sum(t['pnl'] for t in losses) if losses else 0
+    profit_factor = abs(total_profit / total_loss) if total_loss else 0
+
+    # 计算最大回撤
+    peak = initial
+    max_dd = 0
+    for e in engine.equity_curve:
+        if e['equity'] > peak:
+            peak = e['equity']
+        dd = (peak - e['equity']) / peak * 100
+        if dd > max_dd:
+            max_dd = dd
+
+    # 保存结果
+    with open(f"{output_dir}/equity_no_lookahead.csv", 'w', newline='') as f:
+        w = csv.DictWriter(f, fieldnames=['time', 'equity', 'close', 'position', 
+                                           'daily_trend', 'regime_state', 'regime_prob'])
+        w.writeheader()
+        w.writerows(engine.equity_curve)
+
+    with open(f"{output_dir}/trades_no_lookahead.csv", 'w', newline='') as f:
+        if engine.trades:
+            all_fields = set()
+            for t in engine.trades:
+                all_fields.update(t.keys())
+            w = csv.DictWriter(f, fieldnames=sorted(all_fields))
+            w.writeheader()
+            w.writerows(engine.trades)
+
+    # 生成报告
+    report = f"""
+================================================================================
+无未来函数回测报告(严格Walk-Forward)
+================================================================================
+【回测原则】
+  1. 市场状态判断:只用过去16个30分钟周期的数据
+  2. 日线趋势:只用当日及之前的数据计算MA20
+  3. 无机器学习:避免训练集泄露
+  4. 纯规则驱动:每个决策只用当前已知信息
+
+【回测参数】
+  初始资金: 1,000,000元
+  持仓上限: 50%
+  30分钟趋势概率阈值: 0.3
+  日线要求: 必须向上(MA20之上)
+  止损: -2.5% | 止盈: +4% | 最大持仓: 16周期(8小时)
+
+================================================================================
+整体表现
+================================================================================
+  初始资金:        {initial:>15,.2f}元
+  最终资金:        {final:>15,.2f}元
+  净盈亏:          {final-initial:>15,.2f}元
+  总收益率:        {total_ret:>15.2f}%
+  最大回撤:        {max_dd:>15.2f}%
+
+================================================================================
+交易统计
+================================================================================
+  总交易次数:      {len(closed):>15}笔
+  盈利次数:        {len(wins):>15}笔
+  亏损次数:        {len(losses):>15}笔
+  胜率:            {win_rate:>15.2f}%
+  盈亏比:          {profit_factor:>15.2f}
+  总盈利:          {total_profit:>15,.2f}元
+  总亏损:          {total_loss:>15,.2f}元
+  平均每笔盈利:    {total_profit/len(wins) if wins else 0:>15,.2f}元
+  平均每笔亏损:    {total_loss/len(losses) if losses else 0:>15,.2f}元
+
+================================================================================
+最近5笔交易
+================================================================================
+"""
+    for t in closed[-5:]:
+        report += f"  {t['time']} | 平仓{t['price']:.2f} | 盈亏{t['pnl']:+10,.2f} | {t['reason']}\n"
+
+    report += f"""
+================================================================================
+文件输出
+================================================================================
+  - {output_dir}/equity_no_lookahead.csv
+  - {output_dir}/trades_no_lookahead.csv
+  - {output_dir}/report_no_lookahead.txt
+================================================================================
+"""
+
+    with open(f"{output_dir}/report_no_lookahead.txt", 'w') as f:
+        f.write(report)
+
+    print(report)
+    
+    return {
+        'total_return': total_ret,
+        'win_rate': win_rate,
+        'profit_factor': profit_factor,
+        'trade_count': len(closed),
+        'max_drawdown': max_dd
+    }
+
+
+if __name__ == '__main__':
+    result = run_backtest(
+        'cyb50_30min_2023_to_20260325.csv',
+        '../data-fetch/data/399673_SZ_day_20150101_20260325.csv'
+    )
+    
+    print("\n" + "="*70)
+    print("对比说明")
+    print("="*70)
+    print("""
+【有未来函数版本(之前)】
+  - 使用预训练的ML模型(用2024-2025所有数据训练)
+  - 模型"看到"了未来的模式,准确率被人为抬高
+  - 结果:+25.34%收益,68.75%胜率
+
+【无未来函数版本(本次)】
+  - 只用历史数据做规则判断
+  - 每个决策点只用已知信息
+  - 结果:更真实,但可能表现更差
+
+差异越大,说明原模型过拟合越严重。
+""")

+ 807 - 0
cat-fly/t1/backtest_t1_standalone.py

@@ -0,0 +1,807 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+CYB50 只做多T+1回测系统
+不依赖pandas,使用纯Python实现
+"""
+
+import csv
+import json
+from datetime import datetime, timedelta
+from collections import deque
+import math
+
+# ==================== 技术指标计算类 ====================
+class TechnicalIndicators:
+    """技术指标计算 - 纯Python实现"""
+
+    @staticmethod
+    def sma(data, period):
+        """简单移动平均线"""
+        if len(data) < period:
+            return None
+        return sum(data[-period:]) / period
+
+    @staticmethod
+    def ema(data, period):
+        """指数移动平均线"""
+        if len(data) < period:
+            return None
+        multiplier = 2 / (period + 1)
+        ema = data[0]
+        for price in data[1:]:
+            ema = (price - ema) * multiplier + ema
+        return ema
+
+    @staticmethod
+    def rsi(prices, period=14):
+        """RSI计算"""
+        if len(prices) < period + 1:
+            return None
+
+        gains = []
+        losses = []
+
+        for i in range(1, len(prices)):
+            change = prices[i] - prices[i-1]
+            if change > 0:
+                gains.append(change)
+                losses.append(0)
+            else:
+                gains.append(0)
+                losses.append(abs(change))
+
+        if len(gains) < period:
+            return None
+
+        avg_gain = sum(gains[-period:]) / period
+        avg_loss = sum(losses[-period:]) / period
+
+        if avg_loss == 0:
+            return 100
+
+        rs = avg_gain / avg_loss
+        return 100 - (100 / (1 + rs))
+
+    @staticmethod
+    def bollinger_bands(prices, period=20, std_dev=2):
+        """布林带计算"""
+        if len(prices) < period:
+            return None, None, None
+
+        middle = sum(prices[-period:]) / period
+        variance = sum((p - middle) ** 2 for p in prices[-period:]) / period
+        std = math.sqrt(variance)
+
+        upper = middle + (std * std_dev)
+        lower = middle - (std * std_dev)
+
+        return upper, middle, lower
+
+    @staticmethod
+    def macd(prices, fast=12, slow=26, signal=9):
+        """MACD计算"""
+        if len(prices) < slow:
+            return None, None, None
+
+        # 计算EMA
+        def calc_ema(data, period):
+            multiplier = 2 / (period + 1)
+            ema = data[0]
+            for price in data[1:]:
+                ema = (price - ema) * multiplier + ema
+            return ema
+
+        ema_fast = calc_ema(prices[-fast:], fast) if len(prices) >= fast else None
+        ema_slow = calc_ema(prices[-slow:], slow) if len(prices) >= slow else None
+
+        if ema_fast is None or ema_slow is None:
+            return None, None, None
+
+        macd_line = ema_fast - ema_slow
+
+        # 简化:使用当前MACD作为信号线近似
+        signal_line = macd_line * 0.8  # 近似值
+        histogram = macd_line - signal_line
+
+        return macd_line, signal_line, histogram
+
+    @staticmethod
+    def kdj(highs, lows, closes, period=9):
+        """KDJ计算"""
+        if len(closes) < period:
+            return None, None, None
+
+        low_n = min(lows[-period:])
+        high_n = max(highs[-period:])
+        close = closes[-1]
+
+        if high_n == low_n:
+            rsv = 50
+        else:
+            rsv = (close - low_n) / (high_n - low_n) * 100
+
+        # 简化KDJ计算
+        k = rsv
+        d = k
+        j = 3 * k - 2 * d
+
+        return k, d, j
+
+    @staticmethod
+    def atr(highs, lows, closes, period=14):
+        """ATR计算"""
+        if len(closes) < period + 1:
+            return None
+
+        tr_values = []
+        for i in range(1, len(closes)):
+            high_low = highs[i] - lows[i]
+            high_close = abs(highs[i] - closes[i-1])
+            low_close = abs(lows[i] - closes[i-1])
+            tr = max(high_low, high_close, low_close)
+            tr_values.append(tr)
+
+        if len(tr_values) < period:
+            return None
+
+        return sum(tr_values[-period:]) / period
+
+
+# ==================== 数据加载类 ====================
+class DataLoader:
+    """CSV数据加载器"""
+
+    def __init__(self, file_path):
+        self.file_path = file_path
+        self.data = []
+
+    def load(self):
+        """加载CSV数据"""
+        print(f"正在加载数据文件: {self.file_path}")
+
+        with open(self.file_path, 'r', encoding='utf-8-sig') as f:
+            reader = csv.DictReader(f)
+
+            for row in reader:
+                # 解析时间
+                dt_str = row['DateTime']
+                dt = datetime.strptime(dt_str, '%Y-%m-%d %H:%M:%S')
+
+                self.data.append({
+                    'datetime': dt,
+                    'date': dt.date(),
+                    'time': dt.time(),
+                    'open': float(row['Open']),
+                    'high': float(row['High']),
+                    'low': float(row['Low']),
+                    'close': float(row['Close']),
+                    'volume': float(row['Volume']),
+                    'a': float(row['a']) if row['a'] else 0,
+                    'pc': float(row['pc']) if row['pc'] else 0,
+                    'sf': float(row['sf']) if row['sf'] else 0
+                })
+
+        print(f"✅ 数据加载完成: {len(self.data)}条K线")
+        print(f"   数据区间: {self.data[0]['datetime']} ~ {self.data[-1]['datetime']}")
+        return self.data
+
+
+# ==================== 信号生成器 ====================
+class SignalGenerator:
+    """只做多信号生成器"""
+
+    def __init__(self):
+        self.prices = []
+        self.highs = []
+        self.lows = []
+        self.volumes = []
+        self.macd_histograms = []
+
+    def update(self, bar):
+        """更新数据"""
+        self.prices.append(bar['close'])
+        self.highs.append(bar['high'])
+        self.lows.append(bar['low'])
+        self.volumes.append(bar['volume'])
+
+    def calculate_indicators(self):
+        """计算所有技术指标"""
+        if len(self.prices) < 26:
+            return None
+
+        ti = TechnicalIndicators()
+
+        # 移动平均线
+        ma6 = ti.sma(self.prices, 6)
+        ma12 = ti.sma(self.prices, 12)
+        ma24 = ti.sma(self.prices, 24)
+
+        # RSI
+        rsi = ti.rsi(self.prices, 14)
+
+        # 布林带
+        bb_upper, bb_middle, bb_lower = ti.bollinger_bands(self.prices, 20)
+
+        # MACD
+        macd_line, macd_signal, macd_hist = ti.macd(self.prices, 12, 26, 9)
+        if macd_hist is not None:
+            self.macd_histograms.append(macd_hist)
+
+        # KDJ
+        k, d, j = ti.kdj(self.highs, self.lows, self.prices, 9)
+
+        # ATR
+        atr = ti.atr(self.highs, self.lows, self.prices, 14)
+        atr_pct = atr / self.prices[-1] if atr else None
+
+        # 成交量比率
+        volume_ma = ti.sma(self.volumes, 12)
+        volume_ratio = self.volumes[-1] / volume_ma if volume_ma else 1.0
+
+        # 价格动量
+        price_momentum = (self.prices[-1] - self.prices[-6]) / self.prices[-6] if len(self.prices) >= 6 else 0
+
+        # 涨跌幅
+        returns = (self.prices[-1] - self.prices[-2]) / self.prices[-2] if len(self.prices) >= 2 else 0
+        close_open_pct = (self.prices[-1] - self.highs[-1]) / self.highs[-1]  # 简化计算
+
+        return {
+            'ma6': ma6,
+            'ma12': ma12,
+            'ma24': ma24,
+            'rsi': rsi,
+            'bb_upper': bb_upper,
+            'bb_middle': bb_middle,
+            'bb_lower': bb_lower,
+            'macd': macd_line,
+            'macd_signal': macd_signal,
+            'macd_hist': macd_hist,
+            'k': k,
+            'd': d,
+            'j': j,
+            'atr_pct': atr_pct,
+            'volume_ratio': volume_ratio,
+            'price_momentum': price_momentum,
+            'returns': returns,
+            'close_open_pct': close_open_pct
+        }
+
+    def generate_long_signal(self, indicators, bar_idx):
+        """生成做多信号"""
+        if indicators is None:
+            return 0, []
+
+        score = 0
+        signals = []
+
+        # 1. RSI超卖
+        if indicators['rsi'] < 30:
+            score += 2
+            signals.append("RSI超卖")
+        elif indicators['rsi'] < 35:
+            score += 1
+            signals.append("RSI偏弱")
+
+        # 2. KDJ超卖
+        if indicators['k'] < 20 and indicators['d'] < 20:
+            score += 2
+            signals.append("KDJ超卖")
+        elif indicators['j'] < 0:
+            score += 1
+            signals.append("KDJ极端超卖")
+
+        # 3. MACD金叉
+        if len(self.macd_histograms) >= 2:
+            if indicators['macd_hist'] > 0 and self.macd_histograms[-2] <= 0:
+                score += 2
+                signals.append("MACD金叉")
+            elif indicators['macd_hist'] > self.macd_histograms[-2]:
+                score += 1
+                signals.append("MACD改善")
+
+        # 4. 价格触及布林带下轨
+        current_price = self.prices[-1]
+        if indicators['bb_lower'] and current_price <= indicators['bb_lower'] * 1.005:
+            score += 2
+            signals.append("触及下轨")
+        elif indicators['bb_lower'] and current_price <= indicators['bb_lower'] * 1.01:
+            score += 1
+            signals.append("接近下轨")
+
+        # 5. 连续下跌后的反转
+        if len(self.prices) >= 7:
+            recent_returns = [(self.prices[i] - self.prices[i-1]) / self.prices[i-1]
+                             for i in range(len(self.prices)-6, len(self.prices))]
+            if min(recent_returns) < -0.015:
+                consecutive_decline = sum(1 for r in recent_returns if r < 0)
+                if consecutive_decline >= 4:
+                    score += 2
+                    signals.append("连续下跌反转")
+
+        # 6. 价格动量反转
+        if indicators['price_momentum'] < -0.02:
+            score += 1
+            signals.append("动量超卖")
+
+        # 7. 成交量配合
+        if indicators['volume_ratio'] > 1.2:
+            score += 1
+            signals.append("放量配合")
+
+        # 8. MA趋势过滤
+        if indicators['ma6'] and indicators['ma12'] and indicators['ma24']:
+            if indicators['ma6'] < indicators['ma12'] < indicators['ma24']:
+                score -= 1
+                signals.append("MA下降趋势惩罚")
+            elif indicators['ma6'] > indicators['ma12']:
+                score += 1
+                signals.append("MA短期上行")
+
+        return score, signals
+
+
+# ==================== T+1交易执行器 ====================
+class T1BacktestExecutor:
+    """T+1回测执行器"""
+
+    def __init__(self, initial_capital=1000000):
+        self.initial_capital = initial_capital
+        self.capital = initial_capital
+        self.position = 0
+        self.entry_price = 0
+        self.entry_time = None
+        self.entry_date = None
+        self.entry_signals = []
+        self.holding_bars = 0
+
+        # 参数
+        self.commission_rate = 0.0001  # 万分之一
+        self.stop_loss_pct = 0.008     # 0.8%止损
+        self.take_profit_pct = 0.02    # 2%止盈
+        self.max_hold_bars = 16        # 最大持仓8小时
+
+        # 交易记录
+        self.trades = []
+        self.equity_curve = []
+
+        # 待平仓队列 (T+1规则:当天买入的次日才能卖出)
+        self.pending_positions = []  # 存储不能当天卖出的持仓信息
+
+    def can_trade(self, current_date):
+        """检查是否可以交易(T+1限制)"""
+        # 检查是否有前一天买入的持仓可以卖出
+        available_to_sell = []
+        still_pending = []
+
+        for pos in self.pending_positions:
+            if pos['entry_date'] < current_date:
+                # 可以卖出了
+                available_to_sell.append(pos)
+            else:
+                # 还不能卖出
+                still_pending.append(pos)
+
+        self.pending_positions = still_pending
+        return available_to_sell
+
+    def check_exit(self, bar, position_info):
+        """检查是否需要平仓"""
+        price = bar['close']
+        entry_price = position_info['entry_price']
+        holding_bars = position_info['holding_bars']
+
+        stop_loss = entry_price * (1 - self.stop_loss_pct)
+        take_profit = entry_price * (1 + self.take_profit_pct)
+
+        # 止损
+        if price <= stop_loss:
+            return True, f"止损({price:.2f}<={stop_loss:.2f})", price
+
+        # 止盈
+        if price >= take_profit:
+            return True, f"止盈({price:.2f}>={take_profit:.2f})", price
+
+        # 最大持仓时间
+        if holding_bars >= self.max_hold_bars:
+            return True, f"时间平仓({holding_bars}周期)", price
+
+        return False, "", price
+
+    def execute_buy(self, bar, score, signals):
+        """执行买入"""
+        price = bar['close']
+        date = bar['date']
+        dt = bar['datetime']
+
+        # 计算仓位(全仓)
+        position_value = self.capital
+        position_size = int(position_value / price)
+
+        if position_size <= 0:
+            return False
+
+        cost = position_size * price * (1 + self.commission_rate)
+
+        if cost > self.capital:
+            position_size = int(self.capital / (price * (1 + self.commission_rate)))
+            cost = position_size * price * (1 + self.commission_rate)
+
+        self.capital -= cost
+
+        # 记录持仓信息(T+1规则下,当天不能卖出)
+        position_info = {
+            'entry_price': price,
+            'entry_time': dt,
+            'entry_date': date,
+            'position_size': position_size,
+            'holding_bars': 0,
+            'entry_signals': signals,
+            'score': score,
+            'stop_loss': price * (1 - self.stop_loss_pct),
+            'take_profit': price * (1 + self.take_profit_pct)
+        }
+
+        self.pending_positions.append(position_info)
+
+        print(f"\n[开仓] {dt} 价格:{price:.2f} 数量:{position_size} 信号分数:{score}")
+        print(f"      信号: {', '.join(signals)}")
+
+        return True
+
+    def execute_sell(self, bar, position_info, exit_reason):
+        """执行卖出"""
+        price = bar['close']
+        dt = bar['datetime']
+
+        entry_price = position_info['entry_price']
+        position_size = position_info['position_size']
+        entry_time = position_info['entry_time']
+        holding_bars = position_info['holding_bars']
+
+        # 计算盈亏
+        gross_pnl = (price - entry_price) * position_size
+        open_cost = position_size * entry_price * self.commission_rate
+        close_revenue = position_size * price
+        close_cost = close_revenue * self.commission_rate
+        pnl = gross_pnl - open_cost - close_cost
+        pnl_pct = (price - entry_price) / entry_price * 100
+
+        # 更新资金
+        self.capital += close_revenue - close_cost
+
+        # 记录交易
+        trade = {
+            'entry_time': entry_time.strftime('%Y-%m-%d %H:%M:%S'),
+            'exit_time': dt.strftime('%Y-%m-%d %H:%M:%S'),
+            'entry_price': round(entry_price, 2),
+            'exit_price': round(price, 2),
+            'position': position_size,
+            'pnl': round(pnl, 2),
+            'pnl_pct': round(pnl_pct, 2),
+            'exit_reason': exit_reason,
+            'holding_bars': holding_bars,
+            'holding_hours': round(holding_bars * 0.5, 1),
+            'entry_signals': '|'.join(position_info['entry_signals']),
+            'capital': round(self.capital, 2),
+            'position_value': round(position_size * entry_price, 2)
+        }
+
+        self.trades.append(trade)
+
+        status = "盈利" if pnl > 0 else "亏损"
+        print(f"[平仓] {dt} 价格:{price:.2f} 盈亏:{pnl:+.2f}({pnl_pct:+.2f}%) [{status}] 原因:{exit_reason}")
+
+        return trade
+
+    def update_equity(self, bar, active_position=None):
+        """更新权益曲线"""
+        price = bar['close']
+        dt = bar['datetime']
+
+        total_value = self.capital
+        if active_position:
+            total_value += active_position['position_size'] * price
+
+        self.equity_curve.append({
+            'datetime': dt.strftime('%Y-%m-%d %H:%M:%S'),
+            'price': round(price, 2),
+            'capital': round(self.capital, 2),
+            'total_value': round(total_value, 2),
+            'return_pct': round((total_value / self.initial_capital - 1) * 100, 2)
+        })
+
+    def run_backtest(self, data):
+        """运行回测"""
+        print("\n" + "="*80)
+        print("开始T+1回测")
+        print("="*80)
+
+        signal_gen = SignalGenerator()
+        active_position = None  # 当前活跃持仓(可以卖出的)
+
+        for i, bar in enumerate(data):
+            current_date = bar['date']
+
+            # 更新信号生成器
+            signal_gen.update(bar)
+
+            # 检查T+1限制,获取可以卖出的持仓
+            available_positions = self.can_trade(current_date)
+
+            # 如果有可卖出的持仓,选择第一个作为活跃持仓
+            if available_positions and active_position is None:
+                active_position = available_positions[0]
+                for pos in available_positions[1:]:
+                    self.pending_positions.append(pos)
+
+            # 更新活跃持仓的持仓时间
+            if active_position:
+                active_position['holding_bars'] += 1
+
+            # 检查是否需要平仓(只有活跃持仓可以平仓)
+            if active_position:
+                should_exit, exit_reason, exit_price = self.check_exit(bar, active_position)
+
+                if should_exit:
+                    self.execute_sell(bar, active_position, exit_reason)
+                    active_position = None
+
+            # 检查是否开新仓(无持仓时)
+            if active_position is None and len(self.pending_positions) == 0 and i >= 26:
+                indicators = signal_gen.calculate_indicators()
+                score, signals = signal_gen.generate_long_signal(indicators, i)
+
+                # 信号分数>=4且开仓
+                if score >= 4:
+                    self.execute_buy(bar, score, signals)
+
+            # 更新权益曲线
+            self.update_equity(bar, active_position)
+
+        # 回测结束,强制平仓所有持仓
+        print("\n" + "="*80)
+        print("回测结束,强制平仓")
+        print("="*80)
+
+        if active_position:
+            self.execute_sell(data[-1], active_position, "回测结束")
+
+        # 处理pending中的持仓(如果数据结束但还有持仓)
+        for pos in self.pending_positions:
+            pos['holding_bars'] = self.max_hold_bars  # 强制达到平仓条件
+            self.execute_sell(data[-1], pos, "回测结束(T+1)")
+
+        return self.trades, self.equity_curve
+
+
+# ==================== 回测报告生成器 ====================
+class BacktestReport:
+    """生成回测报告"""
+
+    def __init__(self, trades, equity_curve, initial_capital=1000000):
+        self.trades = trades
+        self.equity_curve = equity_curve
+        self.initial_capital = initial_capital
+
+    def calculate_metrics(self):
+        """计算回测指标"""
+        if not self.trades:
+            return {
+                'total_trades': 0,
+                'win_rate': 0,
+                'profit_factor': 0,
+                'total_return': 0,
+                'max_drawdown': 0,
+                'sharpe_ratio': 0
+            }
+
+        total_trades = len(self.trades)
+        winning_trades = [t for t in self.trades if t['pnl'] > 0]
+        losing_trades = [t for t in self.trades if t['pnl'] <= 0]
+
+        win_count = len(winning_trades)
+        loss_count = len(losing_trades)
+
+        win_rate = (win_count / total_trades * 100) if total_trades > 0 else 0
+
+        total_profit = sum(t['pnl'] for t in winning_trades)
+        total_loss = abs(sum(t['pnl'] for t in losing_trades))
+        profit_factor = total_profit / total_loss if total_loss > 0 else 0
+
+        # 总收益
+        final_capital = self.trades[-1]['capital'] if self.trades else self.initial_capital
+        total_return = (final_capital - self.initial_capital) / self.initial_capital * 100
+
+        # 最大回撤
+        max_drawdown = self._calculate_max_drawdown()
+
+        # 夏普比率(简化计算)
+        sharpe_ratio = self._calculate_sharpe()
+
+        return {
+            'total_trades': total_trades,
+            'win_count': win_count,
+            'loss_count': loss_count,
+            'win_rate': round(win_rate, 2),
+            'profit_factor': round(profit_factor, 2),
+            'total_profit': round(total_profit, 2),
+            'total_loss': round(total_loss, 2),
+            'total_return': round(total_return, 2),
+            'max_drawdown': round(max_drawdown, 2),
+            'sharpe_ratio': round(sharpe_ratio, 2),
+            'initial_capital': self.initial_capital,
+            'final_capital': round(final_capital, 2),
+            'net_profit': round(final_capital - self.initial_capital, 2)
+        }
+
+    def _calculate_max_drawdown(self):
+        """计算最大回撤"""
+        if not self.equity_curve:
+            return 0
+
+        max_dd = 0
+        peak = self.equity_curve[0]['total_value']
+
+        for point in self.equity_curve:
+            value = point['total_value']
+            if value > peak:
+                peak = value
+            dd = (peak - value) / peak * 100
+            if dd > max_dd:
+                max_dd = dd
+
+        return max_dd
+
+    def _calculate_sharpe(self):
+        """计算夏普比率(简化版)"""
+        if len(self.equity_curve) < 2:
+            return 0
+
+        # 计算收益率序列
+        returns = []
+        for i in range(1, len(self.equity_curve)):
+            prev = self.equity_curve[i-1]['total_value']
+            curr = self.equity_curve[i]['total_value']
+            if prev > 0:
+                returns.append((curr - prev) / prev)
+
+        if not returns:
+            return 0
+
+        avg_return = sum(returns) / len(returns)
+
+        # 计算标准差
+        variance = sum((r - avg_return) ** 2 for r in returns) / len(returns)
+        std = math.sqrt(variance) if variance > 0 else 0
+
+        # 年化夏普(简化:假设每个bar代表30分钟)
+        if std > 0:
+            sharpe = (avg_return * 48 * 252) / (std * math.sqrt(48))  # 48个30分钟/天,252交易日/年
+            return sharpe
+        return 0
+
+    def generate_report(self):
+        """生成文字报告"""
+        metrics = self.calculate_metrics()
+
+        report = []
+        report.append("="*80)
+        report.append("CYB50 只做多T+1策略回测报告")
+        report.append("="*80)
+        report.append("")
+        report.append("【回测参数】")
+        report.append(f"  初始资金: {metrics['initial_capital']:,.0f} 元")
+        report.append(f"  最终资金: {metrics['final_capital']:,.2f} 元")
+        report.append(f"  净盈亏: {metrics['net_profit']:+,.2f} 元")
+        report.append(f"  总收益率: {metrics['total_return']:+.2f}%")
+        report.append("")
+        report.append("【交易统计】")
+        report.append(f"  总交易次数: {metrics['total_trades']} 笔")
+        report.append(f"  盈利次数: {metrics['win_count']} 笔")
+        report.append(f"  亏损次数: {metrics['loss_count']} 笔")
+        report.append(f"  胜率: {metrics['win_rate']}%")
+        report.append(f"  盈亏比: {metrics['profit_factor']}")
+        report.append(f"  总盈利: {metrics['total_profit']:,.2f} 元")
+        report.append(f"  总亏损: {metrics['total_loss']:,.2f} 元")
+        report.append("")
+        report.append("【风险指标】")
+        report.append(f"  最大回撤: {metrics['max_drawdown']}%")
+        report.append(f"  夏普比率: {metrics['sharpe_ratio']}")
+        report.append("")
+
+        if self.trades:
+            report.append("【最近20笔交易明细】")
+            report.append("-"*120)
+            report.append(f"{'开仓时间':<20} {'平仓时间':<20} {'开仓价':>10} {'平仓价':>10} {'盈亏':>12} {'盈亏%':>8} {'持仓h':>6} {'原因':<20}")
+            report.append("-"*120)
+
+            for t in self.trades[-20:]:
+                report.append(f"{t['entry_time']:<20} {t['exit_time']:<20} {t['entry_price']:>10.2f} {t['exit_price']:>10.2f} "
+                            f"{t['pnl']:>+12.2f} {t['pnl_pct']:>+7.2f}% {t['holding_hours']:>6.1f} {t['exit_reason']:<20}")
+
+            report.append("-"*120)
+
+        report.append("")
+        report.append("="*80)
+
+        return "\n".join(report), metrics
+
+    def save_results(self, output_dir="."):
+        """保存结果到文件"""
+        import os
+        os.makedirs(output_dir, exist_ok=True)
+
+        timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
+
+        # 1. 保存交易明细
+        trades_file = os.path.join(output_dir, f"trades_{timestamp}.csv")
+        if self.trades:
+            with open(trades_file, 'w', newline='', encoding='utf-8-sig') as f:
+                writer = csv.DictWriter(f, fieldnames=self.trades[0].keys())
+                writer.writeheader()
+                writer.writerows(self.trades)
+            print(f"✅ 交易明细已保存: {trades_file}")
+
+        # 2. 保存权益曲线
+        equity_file = os.path.join(output_dir, f"equity_{timestamp}.csv")
+        if self.equity_curve:
+            with open(equity_file, 'w', newline='', encoding='utf-8-sig') as f:
+                writer = csv.DictWriter(f, fieldnames=self.equity_curve[0].keys())
+                writer.writeheader()
+                writer.writerows(self.equity_curve)
+            print(f"✅ 权益曲线已保存: {equity_file}")
+
+        # 3. 保存报告
+        report_text, metrics = self.generate_report()
+        report_file = os.path.join(output_dir, f"report_{timestamp}.txt")
+        with open(report_file, 'w', encoding='utf-8') as f:
+            f.write(report_text)
+        print(f"✅ 回测报告已保存: {report_file}")
+
+        # 4. 保存指标JSON
+        json_file = os.path.join(output_dir, f"metrics_{timestamp}.json")
+        with open(json_file, 'w', encoding='utf-8') as f:
+            json.dump(metrics, f, indent=2, ensure_ascii=False)
+        print(f"✅ 指标数据已保存: {json_file}")
+
+        return trades_file, equity_file, report_file, json_file
+
+
+# ==================== 主函数 ====================
+def main():
+    """主程序"""
+    print("="*80)
+    print("CYB50 只做多T+1回测系统")
+    print("="*80)
+
+    # 数据文件路径
+    data_file = "/home/erwin/.openclaw/workspace/cyb50-quant/cat-fly/t1/cyb50_30min_2023_to_20260325.csv"
+
+    # 1. 加载数据
+    loader = DataLoader(data_file)
+    data = loader.load()
+
+    # 2. 运行回测
+    executor = T1BacktestExecutor(initial_capital=1000000)
+    trades, equity_curve = executor.run_backtest(data)
+
+    # 3. 生成报告
+    report = BacktestReport(trades, equity_curve, initial_capital=1000000)
+    report_text, metrics = report.generate_report()
+
+    # 4. 打印报告
+    print("\n" + report_text)
+
+    # 5. 保存结果
+    output_dir = "/home/erwin/.openclaw/workspace/cyb50-quant/cat-fly/t1/backtest_results"
+    report.save_results(output_dir)
+
+    print(f"\n✅ 回测完成!")
+    print(f"   总收益率: {metrics['total_return']:+.2f}%")
+    print(f"   交易次数: {metrics['total_trades']} 笔")
+    print(f"   胜率: {metrics['win_rate']}%")
+
+
+if __name__ == "__main__":
+    main()

+ 546 - 0
cat-fly/t1/backtest_t1_with_regime.py

@@ -0,0 +1,546 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+CYB50 择时过滤T+1回测系统 - 结合市场状态
+只做多,使用市场状态过滤开仓信号
+"""
+
+import csv
+import json
+from datetime import datetime, timedelta
+from collections import deque
+import math
+
+# ==================== 技术指标计算类 ====================
+class TechnicalIndicators:
+    """技术指标计算 - 纯Python实现"""
+
+    @staticmethod
+    def sma(data, period):
+        """简单移动平均线"""
+        if len(data) < period:
+            return None
+        return sum(data[-period:]) / period
+
+    @staticmethod
+    def ema(data, period):
+        """指数移动平均线"""
+        if len(data) < period:
+            return None
+        multiplier = 2 / (period + 1)
+        ema = data[0]
+        for price in data[1:]:
+            ema = (price - ema) * multiplier + ema
+        return ema
+
+    @staticmethod
+    def rsi(prices, period=14):
+        """RSI计算"""
+        if len(prices) < period + 1:
+            return None
+
+        gains = []
+        losses = []
+
+        for i in range(1, len(prices)):
+            change = prices[i] - prices[i-1]
+            if change > 0:
+                gains.append(change)
+                losses.append(0)
+            else:
+                gains.append(0)
+                losses.append(abs(change))
+
+        if len(gains) < period:
+            return None
+
+        avg_gain = sum(gains[-period:]) / period
+        avg_loss = sum(losses[-period:]) / period
+
+        if avg_loss == 0:
+            return 100
+
+        rs = avg_gain / avg_loss
+        return 100 - (100 / (1 + rs))
+
+    @staticmethod
+    def bollinger_bands(prices, period=20, std_dev=2):
+        """布林带计算"""
+        if len(prices) < period:
+            return None, None, None
+
+        middle = sum(prices[-period:]) / period
+        variance = sum((p - middle) ** 2 for p in prices[-period:]) / period
+        std = math.sqrt(variance)
+
+        upper = middle + (std * std_dev)
+        lower = middle - (std * std_dev)
+
+        return upper, middle, lower
+
+    @staticmethod
+    def macd(prices, fast=12, slow=26, signal=9):
+        """MACD计算"""
+        if len(prices) < slow:
+            return None, None, None
+
+        def calc_ema(data, period):
+            multiplier = 2 / (period + 1)
+            ema = data[0]
+            for price in data[1:]:
+                ema = (price - ema) * multiplier + ema
+            return ema
+
+        ema_fast = calc_ema(prices[-fast:], fast) if len(prices) >= fast else None
+        ema_slow = calc_ema(prices[-slow:], slow) if len(prices) >= slow else None
+
+        if ema_fast is None or ema_slow is None:
+            return None, None, None
+
+        macd_line = ema_fast - ema_slow
+
+        # 计算信号线 (EMA of MACD)
+        macd_prices = []
+        for i in range(slow, len(prices) + 1):
+            fast_ema = calc_ema(prices[i-fast:i], fast)
+            slow_ema = calc_ema(prices[i-slow:i], slow)
+            macd_prices.append(fast_ema - slow_ema)
+
+        signal_line = None
+        if len(macd_prices) >= signal:
+            signal_line = calc_ema(macd_prices[-signal:], signal)
+
+        histogram = macd_line - signal_line if signal_line else None
+
+        return macd_line, signal_line, histogram
+
+
+# ==================== 市场状态管理器 ====================
+class MarketRegimeManager:
+    """管理市场状态数据,提供择时过滤"""
+
+    def __init__(self, regime_file):
+        self.regime_data = {}
+        self.load_regime_data(regime_file)
+
+    def load_regime_data(self, filepath):
+        """加载市场状态数据"""
+        print(f"加载市场状态数据: {filepath}")
+        try:
+            with open(filepath, 'r', encoding='utf-8') as f:
+                reader = csv.DictReader(f)
+                for row in reader:
+                    # 解析datetime
+                    dt_str = row['datetime']
+                    self.regime_data[dt_str] = {
+                        'state': int(row['state']),
+                        'prob_ranging': float(row['prob_ranging']),
+                        'prob_trend': float(row['prob_trend']),
+                        'prob_reversal': float(row['prob_reversal'])
+                    }
+            print(f"[OK] 加载成功: {len(self.regime_data)}条状态数据")
+        except Exception as e:
+            print(f"[ERROR] 加载失败: {e}")
+            self.regime_data = {}
+
+    def get_regime(self, dt_str):
+        """获取指定时间的市场状态"""
+        return self.regime_data.get(dt_str, {
+            'state': 0,  # 默认震荡
+            'prob_ranging': 1.0,
+            'prob_trend': 0.0,
+            'prob_reversal': 0.0
+        })
+
+    def can_open_long(self, dt_str, min_trend_prob=0.5):
+        """
+        判断是否允许开多单
+        规则:
+        - 趋势状态(state=1) + 趋势概率 > min_trend_prob -> 允许
+        - 其他状态 -> 禁止
+        """
+        regime = self.get_regime(dt_str)
+        state = regime['state']
+        trend_prob = regime['prob_trend']
+
+        # 只在趋势状态且概率足够高时允许开仓
+        if state == 1 and trend_prob >= min_trend_prob:
+            return True, f"趋势状态(概率{trend_prob:.2f})"
+
+        # 反转状态 - 禁止开仓
+        if state == 2:
+            return False, f"反转状态(概率{regime['prob_reversal']:.2f})"
+
+        # 震荡状态 - 观望
+        return False, f"震荡状态(概率{regime['prob_ranging']:.2f})"
+
+
+# ==================== 回测引擎 ====================
+class BacktestEngine:
+    """择时过滤T+1回测引擎"""
+
+    def __init__(self, initial_capital=1000000, position_size=0.5):
+        self.initial_capital = initial_capital
+        self.position_size = position_size
+        self.capital = initial_capital
+        self.position = 0  # 持仓数量
+        self.entry_price = 0
+        self.entry_time = None
+        self.holding_periods = 0
+        self.max_holding_periods = 16  # 最大持仓周期(8小时)
+
+        # 记录
+        self.equity_curve = []
+        self.trades = []
+        self.signals = []
+
+        # 指标
+        self.prices = deque(maxlen=100)
+        self.highs = deque(maxlen=100)
+        self.lows = deque(maxlen=100)
+
+    def calculate_signals(self):
+        """计算交易信号"""
+        if len(self.prices) < 50:
+            return None
+
+        price_list = list(self.prices)
+        high_list = list(self.highs)
+        low_list = list(self.lows)
+
+        # 技术指标
+        rsi = TechnicalIndicators.rsi(price_list, 14)
+        bb_upper, bb_middle, bb_lower = TechnicalIndicators.bollinger_bands(price_list, 20, 2)
+
+        # 均线
+        ma5 = TechnicalIndicators.sma(price_list, 5)
+        ma10 = TechnicalIndicators.sma(price_list, 10)
+        ma20 = TechnicalIndicators.sma(price_list, 20)
+
+        # MACD
+        macd_line, signal_line, histogram = TechnicalIndicators.macd(price_list)
+
+        return {
+            'rsi': rsi,
+            'bb_upper': bb_upper,
+            'bb_lower': bb_lower,
+            'bb_middle': bb_middle,
+            'ma5': ma5,
+            'ma10': ma10,
+            'ma20': ma20,
+            'macd': macd_line,
+            'macd_signal': signal_line,
+            'price': price_list[-1]
+        }
+
+    def check_long_signal(self, signals):
+        """检查做多信号"""
+        if signals is None:
+            return False, "指标不足"
+
+        conditions = []
+
+        # RSI条件 - 避免超买
+        if signals['rsi'] is not None and signals['rsi'] < 65:
+            conditions.append('RSI<65')
+
+        # 均线条件 - 短期在长期之上
+        if (signals['ma5'] is not None and signals['ma10'] is not None and
+            signals['ma5'] > signals['ma10']):
+            conditions.append('MA5>MA10')
+
+        # MACD条件
+        if (signals['macd'] is not None and signals['macd_signal'] is not None and
+            signals['macd'] > signals['macd_signal']):
+            conditions.append('MACD金叉')
+
+        # 布林带条件 - 价格在布林带中轨之上
+        if (signals['bb_middle'] is not None and
+            signals['price'] > signals['bb_middle']):
+            conditions.append('价格>中轨')
+
+        # 至少需要3个条件满足
+        if len(conditions) >= 3:
+            return True, '+'.join(conditions)
+
+        return False, f"条件不足({len(conditions)}/3)"
+
+    def check_exit_signal(self, signals, current_price):
+        """检查平仓信号"""
+        if signals is None or self.position == 0:
+            return False, ""
+
+        # 止损 2.5%
+        stop_loss = self.entry_price * 0.975
+        if current_price <= stop_loss:
+            return True, f"止损({current_price:.2f}<={stop_loss:.2f})"
+
+        # 止盈 4%
+        take_profit = self.entry_price * 1.04
+        if current_price >= take_profit:
+            return True, f"止盈({current_price:.2f}>={take_profit:.2f})"
+
+        # 最大持仓时间
+        if self.holding_periods >= self.max_holding_periods:
+            return True, f"时间平仓({self.holding_periods}周期)"
+
+        # RSI超买平仓
+        if signals['rsi'] is not None and signals['rsi'] > 75:
+            return True, f"RSI超买({signals['rsi']:.1f})"
+
+        return False, ""
+
+    def open_position(self, price, time_str, reason):
+        """开仓"""
+        position_value = self.capital * self.position_size
+        self.position = position_value / price
+        self.entry_price = price
+        self.entry_time = time_str
+        self.holding_periods = 0
+
+        self.trades.append({
+            'action': 'OPEN',
+            'time': time_str,
+            'price': price,
+            'shares': self.position,
+            'value': position_value,
+            'reason': reason
+        })
+
+    def close_position(self, price, time_str, reason):
+        """平仓"""
+        if self.position == 0:
+            return
+
+        pnl = (price - self.entry_price) * self.position
+        pnl_pct = (price / self.entry_price - 1) * 100
+        self.capital += pnl
+
+        self.trades.append({
+            'action': 'CLOSE',
+            'time': time_str,
+            'price': price,
+            'shares': self.position,
+            'pnl': pnl,
+            'pnl_pct': pnl_pct,
+            'reason': reason
+        })
+
+        self.position = 0
+        self.entry_price = 0
+        self.holding_periods = 0
+
+    def update(self, timestamp, open_price, high, low, close, regime_manager):
+        """更新回测状态"""
+        self.prices.append(close)
+        self.highs.append(high)
+        self.lows.append(low)
+
+        # 计算信号
+        signals = self.calculate_signals()
+
+        # 获取市场状态
+        dt_str = timestamp.strftime('%Y-%m-%d %H:%M:%S')
+        can_open, regime_reason = regime_manager.can_open_long(dt_str)
+
+        # 记录权益
+        equity = self.capital
+        if self.position > 0:
+            equity += self.position * close
+        self.equity_curve.append({
+            'time': dt_str,
+            'equity': equity,
+            'close': close,
+            'position': 1 if self.position > 0 else 0
+        })
+
+        # 持仓更新
+        if self.position > 0:
+            self.holding_periods += 1
+
+            # 检查平仓
+            should_exit, exit_reason = self.check_exit_signal(signals, close)
+            if should_exit:
+                self.close_position(close, dt_str, exit_reason)
+
+        else:
+            # 空仓 - 检查开仓
+            # 先检查技术信号
+            tech_signal, tech_reason = self.check_long_signal(signals)
+
+            if tech_signal:
+                # 技术信号满足,再检查择时过滤
+                if can_open:
+                    self.open_position(close, dt_str, f"{tech_reason}|{regime_reason}")
+                else:
+                    # 技术信号满足但被择时过滤
+                    self.signals.append({
+                        'time': dt_str,
+                        'price': close,
+                        'tech_reason': tech_reason,
+                        'block_reason': regime_reason
+                    })
+
+        return equity
+
+
+# ==================== 主程序 ====================
+def load_data(filepath):
+    """加载30分钟数据"""
+    print(f"加载数据: {filepath}")
+    data = []
+
+    with open(filepath, 'r', encoding='utf-8-sig') as f:  # utf-8-sig handles BOM
+        reader = csv.DictReader(f)
+        for row in reader:
+            try:
+                dt = datetime.strptime(row['DateTime'], '%Y-%m-%d %H:%M:%S')
+                data.append({
+                    'datetime': dt,
+                    'open': float(row['Open']),
+                    'high': float(row['High']),
+                    'low': float(row['Low']),
+                    'close': float(row['Close']),
+                    'volume': float(row['Volume'])
+                })
+            except Exception as e:
+                continue
+
+    print(f"[OK] 加载成功: {len(data)}条")
+    return data
+
+
+def run_backtest(data_file, regime_file, output_dir='backtest_results'):
+    """运行回测"""
+    import os
+    os.makedirs(output_dir, exist_ok=True)
+
+    # 加载数据
+    data = load_data(data_file)
+    regime_manager = MarketRegimeManager(regime_file)
+
+    # 创建回测引擎
+    engine = BacktestEngine(initial_capital=1000000, position_size=0.5)
+
+    print("\n" + "="*70)
+    print("开始回测 - 择时过滤T+1策略")
+    print("="*70)
+    print("策略规则:")
+    print("  - 只做多,持仓上限50%")
+    print("  - 技术信号: RSI<65 + MA5>MA10 + MACD金叉 + 价格>布林带中轨")
+    print("  - 择时过滤: 只在趋势状态(state=1)且趋势概率>0.5时开仓")
+    print("  - 止损: -2.5% | 止盈: +4% | 最大持仓: 16周期(8小时)")
+    print("="*70)
+
+    # 运行回测
+    for row in data:
+        engine.update(
+            row['datetime'],
+            row['open'],
+            row['high'],
+            row['low'],
+            row['close'],
+            regime_manager
+        )
+
+    # 统计结果
+    print("\n" + "="*70)
+    print("回测结果")
+    print("="*70)
+
+    initial = engine.initial_capital
+    final = engine.equity_curve[-1]['equity'] if engine.equity_curve else initial
+    total_return = (final / initial - 1) * 100
+
+    print(f"初始资金: {initial:,.2f} 元")
+    print(f"最终资金: {final:,.2f} 元")
+    print(f"总收益率: {total_return:+.2f}%")
+
+    # 交易统计
+    trades = engine.trades
+    closed_trades = [t for t in trades if t['action'] == 'CLOSE']
+
+    print(f"\n总交易次数: {len(closed_trades)}")
+
+    if closed_trades:
+        wins = [t for t in closed_trades if t['pnl'] > 0]
+        losses = [t for t in closed_trades if t['pnl'] <= 0]
+
+        win_count = len(wins)
+        loss_count = len(losses)
+        win_rate = win_count / len(closed_trades) * 100
+
+        total_profit = sum(t['pnl'] for t in wins) if wins else 0
+        total_loss = sum(t['pnl'] for t in losses) if losses else 0
+
+        avg_win = total_profit / win_count if win_count > 0 else 0
+        avg_loss = total_loss / loss_count if loss_count > 0 else 0
+
+        profit_factor = abs(total_profit / total_loss) if total_loss != 0 else 0
+
+        print(f"  盈利: {win_count} | 亏损: {loss_count}")
+        print(f"  胜率: {win_rate:.2f}%")
+        print(f"  盈亏比: {profit_factor:.2f}")
+        print(f"  平均每笔盈利: {avg_win:,.2f}")
+        print(f"  平均每笔亏损: {avg_loss:,.2f}")
+
+    # 过滤掉的信号统计
+    blocked = engine.signals
+    print(f"\n被择时过滤的信号: {len(blocked)}次")
+    if blocked:
+        print("  (技术信号满足但市场状态不允许开仓)")
+
+    # 保存结果
+    timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
+
+    # 保存权益曲线
+    equity_file = f"{output_dir}/equity_with_regime_{timestamp}.csv"
+    with open(equity_file, 'w', newline='', encoding='utf-8') as f:
+        writer = csv.DictWriter(f, fieldnames=['time', 'equity', 'close', 'position'])
+        writer.writeheader()
+        writer.writerows(engine.equity_curve)
+
+    # 保存交易记录
+    trades_file = f"{output_dir}/trades_with_regime_{timestamp}.csv"
+    with open(trades_file, 'w', newline='', encoding='utf-8') as f:
+        if trades and len(trades) > 0:
+            writer = csv.DictWriter(f, fieldnames=trades[0].keys())
+            writer.writeheader()
+            writer.writerows(trades)
+
+    # 保存过滤信号
+    if blocked:
+        blocked_file = f"{output_dir}/blocked_signals_{timestamp}.csv"
+        with open(blocked_file, 'w', newline='', encoding='utf-8') as f:
+            writer = csv.DictWriter(f, fieldnames=blocked[0].keys())
+            writer.writeheader()
+            writer.writerows(blocked)
+
+    # 保存报告
+    report_file = f"{output_dir}/report_with_regime_{timestamp}.txt"
+    with open(report_file, 'w', encoding='utf-8') as f:
+        f.write("="*70 + "\n")
+        f.write("CYB50 择时过滤T+1策略回测报告\n")
+        f.write("="*70 + "\n\n")
+        f.write(f"初始资金: {initial:,.2f} 元\n")
+        f.write(f"最终资金: {final:,.2f} 元\n")
+        f.write(f"总收益率: {total_return:+.2f}%\n")
+        f.write(f"总交易次数: {len(closed_trades)}\n")
+        if closed_trades:
+            f.write(f"胜率: {win_rate:.2f}%\n")
+            f.write(f"盈亏比: {profit_factor:.2f}\n")
+        f.write(f"\n被择时过滤的信号: {len(blocked)}次\n")
+
+    print(f"\n结果已保存到: {output_dir}/")
+    print(f"  - {equity_file}")
+    print(f"  - {trades_file}")
+    print(f"  - {report_file}")
+
+    return engine
+
+
+if __name__ == '__main__':
+    DATA_FILE = 'cyb50_30min_2023_to_20260325.csv'
+    REGIME_FILE = '../../market-regime-identifier-30/cyb50_30min_regime_result.csv'
+
+    engine = run_backtest(DATA_FILE, REGIME_FILE)

+ 4 - 0
index-rotation/MEMORY.md

@@ -0,0 +1,4 @@
+# MEMORY.md
+
+- 2026-04-07: 当前项目在做 `Top1 + every_5_days + risk_penalty_multiplier=0.5` 的小范围动量权重实验,优先看 `base=15bp`,并要求统一对比输出能清楚展示该组结果。
+- 2026-04-07: `base=15bp` 的三组动量权重实验已完成;默认权重 `5d=0.20,10d=0.25,20d=0.30,60d=0.25` 在该小范围里表现最好,两个邻近变体都略弱。

+ 7 - 0
index-rotation/USER.md

@@ -0,0 +1,7 @@
+# USER.md
+
+- Name: Erwin
+- Timezone: Asia/Shanghai
+- Notes:
+  - 偏好小步、低侵入改动,不希望无关大重构。
+  - 做实验类任务时,希望最后给出简洁总结:改了哪些文件、怎么跑、结果如何。

+ 5 - 0
index-rotation/configs/strategy/top1_every_5_days_p05_cost15bp.yaml

@@ -5,4 +5,9 @@ commission_bps: 7.5
 slippage_bps: 7.5
 cash_return: 0.0
 risk_penalty_multiplier: 0.5
+momentum_weights:
+  ret_5d: 0.20
+  ret_10d: 0.25
+  ret_20d: 0.30
+  ret_60d: 0.25
 start_date: "2019-12-31"

+ 13 - 0
index-rotation/configs/strategy/top1_every_5_days_p05_cost15bp_mom_15_25_30_30.yaml

@@ -0,0 +1,13 @@
+name: top1_every_5_days_p05_cost15bp_mom_15_25_30_30
+top_n: 1
+rebalance_frequency: every_5_days
+commission_bps: 7.5
+slippage_bps: 7.5
+cash_return: 0.0
+risk_penalty_multiplier: 0.5
+momentum_weights:
+  ret_5d: 0.15
+  ret_10d: 0.25
+  ret_20d: 0.30
+  ret_60d: 0.30
+start_date: "2019-12-31"

+ 13 - 0
index-rotation/configs/strategy/top1_every_5_days_p05_cost15bp_mom_25_25_30_20.yaml

@@ -0,0 +1,13 @@
+name: top1_every_5_days_p05_cost15bp_mom_25_25_30_20
+top_n: 1
+rebalance_frequency: every_5_days
+commission_bps: 7.5
+slippage_bps: 7.5
+cash_return: 0.0
+risk_penalty_multiplier: 0.5
+momentum_weights:
+  ret_5d: 0.25
+  ret_10d: 0.25
+  ret_20d: 0.30
+  ret_60d: 0.20
+start_date: "2019-12-31"

+ 7 - 0
index-rotation/memory/2026-04-07.md

@@ -0,0 +1,7 @@
+# 2026-04-07
+
+- 用户要求在当前项目里围绕 `Top1 + every_5_days + risk_penalty_multiplier=0.5` 做一轮小范围动量权重实验。
+- 约束:优先完成 `base=15bp`,尽量小改动,不做无关重构,需要补必要测试和统一对比输出。
+- 交付要求:最终总结要包含修改文件、运行方式、实验结果;终端最后一行要输出 `DONE: momentum weight experiment finished`。
+- 实现方式:给策略配置增加可选 `momentum_weights`,默认行为不变;统一对比输出增加动量权重列,并单独抽出 `Top1 Every 5 Days P05 Base 15bp Momentum Experiment` 小节。
+- 实验结果:在 `base=15bp` 下,基线权重 `5d=0.20,10d=0.25,20d=0.30,60d=0.25` 的 `cumulative_return=0.2549`、`annual_return=0.0385`、`max_drawdown=-0.4351`、`sharpe=0.1648`,优于两个相邻变体。

+ 21 - 0
index-rotation/outputs/research/strategy_comparison.md

@@ -0,0 +1,21 @@
+# Strategy Comparison
+
+| name                                           | cost_scenario   |   total_cost_bps |   top_n | rebalance_frequency   |   risk_penalty_multiplier | momentum_profile                      |   cumulative_return |   annual_return |   max_drawdown |   sharpe |   vs_equal_weight_cum |   vs_hs300_cum |   vs_chinext50_cum |
+|:-----------------------------------------------|:----------------|-----------------:|--------:|:----------------------|--------------------------:|:--------------------------------------|--------------------:|----------------:|---------------:|---------:|----------------------:|---------------:|-------------------:|
+| top1_every_5_days_p06                          | none            |                0 |       1 | every_5_days          |                       0.6 | 5d=0.20, 10d=0.25, 20d=0.30, 60d=0.25 |              0.5433 |          0.0748 |        -0.3481 |   0.3397 |                0.1911 |         0.4593 |            -0.651  |
+| top1_every_5_days_p05                          | none            |                0 |       1 | every_5_days          |                       0.5 | 5d=0.20, 10d=0.25, 20d=0.30, 60d=0.25 |              0.5694 |          0.0779 |        -0.3592 |   0.3332 |                0.2172 |         0.4854 |            -0.6248 |
+| top1_every_5_days_p05_trend3                   | none            |                0 |       1 | every_5_days          |                       0.5 | 5d=0.20, 10d=0.25, 20d=0.30, 60d=0.25 |              0.3888 |          0.0562 |        -0.4354 |   0.2559 |                0.0366 |         0.3048 |            -0.8054 |
+| top2_every_5_days                              | none            |                0 |       2 | every_5_days          |                       0.3 | 5d=0.20, 10d=0.25, 20d=0.30, 60d=0.25 |              0.3511 |          0.0513 |        -0.4005 |   0.2362 |               -0.0011 |         0.2671 |            -0.8432 |
+| top1_every_5_days_p05_cost10bp                 | optimistic      |               10 |       1 | every_5_days          |                       0.5 | 5d=0.20, 10d=0.25, 20d=0.30, 60d=0.25 |              0.3521 |          0.0515 |        -0.4108 |   0.2203 |               -0.0001 |         0.2681 |            -0.8422 |
+| top1_every_5_days_p05_cost15bp                 | base            |               15 |       1 | every_5_days          |                       0.5 | 5d=0.20, 10d=0.25, 20d=0.30, 60d=0.25 |              0.2549 |          0.0385 |        -0.4351 |   0.1648 |               -0.0973 |         0.1709 |            -0.9394 |
+| top1_every_5_days_p05_cost15bp_mom_25_25_30_20 | base            |               15 |       1 | every_5_days          |                       0.5 | 5d=0.25, 10d=0.25, 20d=0.30, 60d=0.20 |              0.2318 |          0.0353 |        -0.4651 |   0.1508 |               -0.1204 |         0.1478 |            -0.9625 |
+| top1_every_5_days_p05_cost15bp_mom_15_25_30_30 | base            |               15 |       1 | every_5_days          |                       0.5 | 5d=0.15, 10d=0.25, 20d=0.30, 60d=0.30 |              0.2286 |          0.0348 |        -0.4481 |   0.1482 |               -0.1236 |         0.1446 |            -0.9657 |
+| top1_every_5_days_p05_cost20bp                 | conservative    |               20 |       1 | every_5_days          |                       0.5 | 5d=0.20, 10d=0.25, 20d=0.30, 60d=0.25 |              0.1647 |          0.0257 |        -0.4584 |   0.1099 |               -0.1876 |         0.0806 |            -1.0296 |
+
+## Top1 Every 5 Days P05 Base 15bp Momentum Experiment
+
+| name                                           | momentum_profile                      |   cumulative_return |   annual_return |   max_drawdown |   sharpe |   turnover |   rebalance_count |   vs_equal_weight_cum |   vs_hs300_cum |   vs_chinext50_cum |
+|:-----------------------------------------------|:--------------------------------------|--------------------:|----------------:|---------------:|---------:|-----------:|------------------:|----------------------:|---------------:|-------------------:|
+| top1_every_5_days_p05_cost15bp                 | 5d=0.20, 10d=0.25, 20d=0.30, 60d=0.25 |              0.2549 |          0.0385 |        -0.4351 |   0.1648 |        149 |               303 |               -0.0973 |         0.1709 |            -0.9394 |
+| top1_every_5_days_p05_cost15bp_mom_25_25_30_20 | 5d=0.25, 10d=0.25, 20d=0.30, 60d=0.20 |              0.2318 |          0.0353 |        -0.4651 |   0.1508 |        150 |               303 |               -0.1204 |         0.1478 |            -0.9625 |
+| top1_every_5_days_p05_cost15bp_mom_15_25_30_30 | 5d=0.15, 10d=0.25, 20d=0.30, 60d=0.30 |              0.2286 |          0.0348 |        -0.4481 |   0.1482 |        148 |               303 |               -0.1236 |         0.1446 |            -0.9657 |

+ 78 - 1
index-rotation/src/backtest/compare.py

@@ -8,6 +8,8 @@ from typing import Any
 import pandas as pd
 import yaml
 
+from src.signals.scorer import DEFAULT_MOMENTUM_WEIGHTS, resolve_momentum_weights
+
 
 def repo_root() -> Path:
     return Path(__file__).resolve().parents[2]
@@ -48,6 +50,17 @@ def load_strategy_configs(root: Path) -> dict[str, dict[str, Any]]:
     return configs
 
 
+def format_momentum_profile(momentum_weights: dict[str, float]) -> str:
+    return ", ".join(
+        [
+            f"5d={momentum_weights['ret_5d']:.2f}",
+            f"10d={momentum_weights['ret_10d']:.2f}",
+            f"20d={momentum_weights['ret_20d']:.2f}",
+            f"60d={momentum_weights['ret_60d']:.2f}",
+        ]
+    )
+
+
 def build_rows(
     *,
     backtests_root: Path,
@@ -65,12 +78,19 @@ def build_rows(
         summary = json.loads(summary_path.read_text(encoding="utf-8"))
         benchmark_summary = json.loads(benchmark_path.read_text(encoding="utf-8"))
         cfg = strategy_configs.get(name, {})
+        momentum_weights = resolve_momentum_weights(cfg.get("momentum_weights"))
         total_cost_bps = float(cfg.get("commission_bps", 0.0)) + float(cfg.get("slippage_bps", 0.0))
         row = {
             "name": name,
             "top_n": cfg.get("top_n"),
             "rebalance_frequency": cfg.get("rebalance_frequency"),
             "risk_penalty_multiplier": cfg.get("risk_penalty_multiplier", 0.30),
+            "momentum_profile": format_momentum_profile(momentum_weights),
+            "momentum_weight_ret_5d": momentum_weights["ret_5d"],
+            "momentum_weight_ret_10d": momentum_weights["ret_10d"],
+            "momentum_weight_ret_20d": momentum_weights["ret_20d"],
+            "momentum_weight_ret_60d": momentum_weights["ret_60d"],
+            "is_default_momentum": momentum_weights == DEFAULT_MOMENTUM_WEIGHTS,
             "total_cost_bps": total_cost_bps,
             "cost_scenario": cost_scenarios.get(total_cost_bps, "custom" if total_cost_bps else "none"),
             "cumulative_return": summary.get("cumulative_return"),
@@ -97,6 +117,24 @@ def build_rows(
     ).reset_index(drop=True)
 
 
+def build_momentum_experiment_rows(frame: pd.DataFrame) -> pd.DataFrame:
+    if frame.empty:
+        return frame
+
+    subset = frame.loc[
+        (frame["top_n"] == 1)
+        & (frame["rebalance_frequency"] == "every_5_days")
+        & (frame["risk_penalty_multiplier"].astype(float) == 0.5)
+        & (frame["total_cost_bps"].astype(float) == 15.0)
+    ].copy()
+    if subset.empty:
+        return subset
+    return subset.sort_values(
+        ["sharpe", "annual_return", "cumulative_return"],
+        ascending=[False, False, False],
+    ).reset_index(drop=True)
+
+
 def render_markdown_table(frame: pd.DataFrame) -> str:
     if frame.empty:
         return "# Strategy Comparison\n\n_No comparison rows available._\n"
@@ -109,6 +147,7 @@ def render_markdown_table(frame: pd.DataFrame) -> str:
             "top_n",
             "rebalance_frequency",
             "risk_penalty_multiplier",
+            "momentum_profile",
             "cumulative_return",
             "annual_return",
             "max_drawdown",
@@ -128,7 +167,45 @@ def render_markdown_table(frame: pd.DataFrame) -> str:
         "vs_chinext50_cum",
     ]:
         display[column] = display[column].map(lambda value: f"{float(value):.4f}")
-    return "# Strategy Comparison\n\n" + display.to_markdown(index=False) + "\n"
+
+    lines = ["# Strategy Comparison", "", display.to_markdown(index=False), ""]
+    experiment = build_momentum_experiment_rows(frame)
+    if not experiment.empty:
+        focused = experiment[
+            [
+                "name",
+                "momentum_profile",
+                "cumulative_return",
+                "annual_return",
+                "max_drawdown",
+                "sharpe",
+                "turnover",
+                "rebalance_count",
+                "vs_equal_weight_cum",
+                "vs_hs300_cum",
+                "vs_chinext50_cum",
+            ]
+        ].copy()
+        for column in [
+            "cumulative_return",
+            "annual_return",
+            "max_drawdown",
+            "sharpe",
+            "turnover",
+            "vs_equal_weight_cum",
+            "vs_hs300_cum",
+            "vs_chinext50_cum",
+        ]:
+            focused[column] = focused[column].map(lambda value: f"{float(value):.4f}")
+        lines.extend(
+            [
+                "## Top1 Every 5 Days P05 Base 15bp Momentum Experiment",
+                "",
+                focused.to_markdown(index=False),
+                "",
+            ]
+        )
+    return "\n".join(lines)
 
 
 def main(argv: list[str] | None = None) -> int:

+ 3 - 0
index-rotation/src/backtest/engine.py

@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+from collections.abc import Mapping
 from dataclasses import dataclass
 from pathlib import Path
 
@@ -48,6 +49,7 @@ class BacktestConfig:
     annualization: int = 252
     risk_penalty_multiplier: float = 0.30
     trend_min_rules: int = 2
+    momentum_weights: Mapping[str, float] | None = None
 
 
 def load_feature_panel(
@@ -98,6 +100,7 @@ def run_backtest(features_panel: pd.DataFrame, config: BacktestConfig) -> dict[s
         top_n=config.top_n,
         risk_penalty_multiplier=config.risk_penalty_multiplier,
         trend_min_rules=config.trend_min_rules,
+        momentum_weights=config.momentum_weights,
     )
     allocated = allocate_weights(signal_panel, top_n=config.top_n)
     rebalance_plan = build_rebalance_plan(allocated, frequency=config.rebalance_frequency)

+ 4 - 0
index-rotation/src/backtest/run.py

@@ -42,6 +42,7 @@ def load_strategy_config(path: Path) -> dict[str, Any]:
 
 def build_backtest_config(payload: dict[str, Any]) -> tuple[str, BacktestConfig, str | None, str | None]:
     name = str(payload.get("name") or "index_rotation_backtest")
+    momentum_weights = payload.get("momentum_weights")
     return (
         name,
         BacktestConfig(
@@ -52,6 +53,9 @@ def build_backtest_config(payload: dict[str, Any]) -> tuple[str, BacktestConfig,
             cash_return=float(payload.get("cash_return", 0.0)),
             risk_penalty_multiplier=float(payload.get("risk_penalty_multiplier", 0.30)),
             trend_min_rules=int(payload.get("trend_min_rules", 2)),
+            momentum_weights=None
+            if momentum_weights is None
+            else {str(column): float(weight) for column, weight in dict(momentum_weights).items()},
         ),
         payload.get("start_date"),
         payload.get("end_date"),

+ 29 - 4
index-rotation/src/signals/scorer.py

@@ -1,10 +1,12 @@
 from __future__ import annotations
 
+from collections.abc import Mapping
+
 import pandas as pd
 
 from src.signals.ranker import add_cross_sectional_ranks
 
-MOMENTUM_WEIGHTS = {
+DEFAULT_MOMENTUM_WEIGHTS = {
     "ret_5d": 0.20,
     "ret_10d": 0.25,
     "ret_20d": 0.30,
@@ -19,18 +21,41 @@ RISK_WEIGHTS = {
 DEFAULT_RISK_PENALTY_MULTIPLIER = 0.30
 
 
+def resolve_momentum_weights(momentum_weights: Mapping[str, float] | None = None) -> dict[str, float]:
+    if momentum_weights is None:
+        return dict(DEFAULT_MOMENTUM_WEIGHTS)
+
+    expected_columns = set(DEFAULT_MOMENTUM_WEIGHTS)
+    provided_columns = set(momentum_weights)
+    missing = sorted(expected_columns - provided_columns)
+    extra = sorted(provided_columns - expected_columns)
+    if missing or extra:
+        raise ValueError(
+            "momentum_weights must define exactly ret_5d, ret_10d, ret_20d, ret_60d; "
+            f"missing={missing}, extra={extra}"
+        )
+
+    resolved = {column: float(momentum_weights[column]) for column in DEFAULT_MOMENTUM_WEIGHTS}
+    total_weight = sum(resolved.values())
+    if abs(total_weight - 1.0) > 1e-6:
+        raise ValueError(f"momentum_weights must sum to 1.0, got {total_weight:.6f}")
+    return resolved
+
+
 def add_composite_scores(
     frame: pd.DataFrame,
     *,
     risk_penalty_multiplier: float = DEFAULT_RISK_PENALTY_MULTIPLIER,
+    momentum_weights: Mapping[str, float] | None = None,
 ) -> pd.DataFrame:
+    resolved_momentum_weights = resolve_momentum_weights(momentum_weights)
     scored = add_cross_sectional_ranks(
         frame,
-        columns=[*MOMENTUM_WEIGHTS.keys(), *RISK_WEIGHTS.keys()],
-        ascending_by_column={column: True for column in [*MOMENTUM_WEIGHTS.keys(), *RISK_WEIGHTS.keys()]},
+        columns=[*resolved_momentum_weights.keys(), *RISK_WEIGHTS.keys()],
+        ascending_by_column={column: True for column in [*resolved_momentum_weights.keys(), *RISK_WEIGHTS.keys()]},
     )
     scored["score_mom"] = 0.0
-    for column, weight in MOMENTUM_WEIGHTS.items():
+    for column, weight in resolved_momentum_weights.items():
         scored["score_mom"] = scored["score_mom"] + scored[f"{column}_rank"] * weight
     scored["score_risk_penalty"] = 0.0
     for column, weight in RISK_WEIGHTS.items():

+ 4 - 0
index-rotation/src/signals/selector.py

@@ -1,5 +1,7 @@
 from __future__ import annotations
 
+from collections.abc import Mapping
+
 import pandas as pd
 
 from src.signals.scorer import add_composite_scores
@@ -12,6 +14,7 @@ def build_signal_panel(
     top_n: int,
     risk_penalty_multiplier: float = 0.30,
     trend_min_rules: int = 2,
+    momentum_weights: Mapping[str, float] | None = None,
 ) -> pd.DataFrame:
     if top_n < 1:
         raise ValueError("top_n must be >= 1")
@@ -19,6 +22,7 @@ def build_signal_panel(
     signals = add_composite_scores(
         apply_trend_filter(features_frame, min_rules=trend_min_rules),
         risk_penalty_multiplier=risk_penalty_multiplier,
+        momentum_weights=momentum_weights,
     )
     signals = signals.sort_values(["trade_date", "instrument"]).reset_index(drop=True)
     signals["eligible_for_selection"] = signals["trend_pass"] & signals["final_score"].notna()

+ 15 - 5
index-rotation/tests/test_compare.py

@@ -5,11 +5,11 @@ import tempfile
 import unittest
 from pathlib import Path
 
-from src.backtest.compare import build_rows
+from src.backtest.compare import build_rows, render_markdown_table
 
 
 class CompareTests(unittest.TestCase):
-    def test_build_rows_includes_cost_label_and_relative_columns(self) -> None:
+    def test_build_rows_includes_momentum_profile_cost_label_and_relative_columns(self) -> None:
         temp_dir = tempfile.TemporaryDirectory()
         self.addCleanup(temp_dir.cleanup)
         root = Path(temp_dir.name)
@@ -52,8 +52,14 @@ class CompareTests(unittest.TestCase):
                     "top_n": 1,
                     "rebalance_frequency": "every_5_days",
                     "risk_penalty_multiplier": 0.5,
-                    "commission_bps": 5.0,
-                    "slippage_bps": 5.0,
+                    "commission_bps": 7.5,
+                    "slippage_bps": 7.5,
+                    "momentum_weights": {
+                        "ret_5d": 0.25,
+                        "ret_10d": 0.25,
+                        "ret_20d": 0.30,
+                        "ret_60d": 0.20,
+                    },
                 }
             },
             cost_scenarios={10.0: "optimistic", 15.0: "base", 20.0: "conservative"},
@@ -61,10 +67,14 @@ class CompareTests(unittest.TestCase):
 
         self.assertEqual(len(frame.index), 1)
         row = frame.iloc[0]
-        self.assertEqual(row["cost_scenario"], "optimistic")
+        self.assertEqual(row["cost_scenario"], "base")
+        self.assertEqual(row["momentum_profile"], "5d=0.25, 10d=0.25, 20d=0.30, 60d=0.20")
         self.assertAlmostEqual(row["vs_equal_weight_cum"], 0.05)
         self.assertAlmostEqual(row["vs_hs300_cum"], 0.12)
         self.assertAlmostEqual(row["vs_chinext50_cum"], -0.10)
+        markdown = render_markdown_table(frame)
+        self.assertIn("Top1 Every 5 Days P05 Base 15bp Momentum Experiment", markdown)
+        self.assertIn("5d=0.25, 10d=0.25, 20d=0.30, 60d=0.20", markdown)
 
 
 if __name__ == "__main__":

+ 71 - 0
index-rotation/tests/test_phase2_signals.py

@@ -162,6 +162,77 @@ class SignalLayerTests(unittest.TestCase):
         self.assertEqual(low_penalty.loc["high_beta", "selection_rank"], 1)
         self.assertEqual(high_penalty.loc["defensive", "selection_rank"], 1)
 
+    def test_custom_momentum_weights_can_change_selection_order(self) -> None:
+        frame = pd.DataFrame(
+            [
+                {
+                    "instrument": "short_burst",
+                    "trade_date": pd.Timestamp("2020-01-10"),
+                    "close": 110,
+                    "daily_return": 0.01,
+                    "ret_5d": 0.20,
+                    "ret_10d": 0.20,
+                    "ret_20d": 0.05,
+                    "ret_60d": 0.01,
+                    "ma_20": 100,
+                    "ma_60": 95,
+                    "vol_10d": 0.10,
+                    "vol_20d": 0.10,
+                },
+                {
+                    "instrument": "long_runner",
+                    "trade_date": pd.Timestamp("2020-01-10"),
+                    "close": 108,
+                    "daily_return": 0.01,
+                    "ret_5d": 0.10,
+                    "ret_10d": 0.10,
+                    "ret_20d": 0.15,
+                    "ret_60d": 0.18,
+                    "ma_20": 100,
+                    "ma_60": 95,
+                    "vol_10d": 0.10,
+                    "vol_20d": 0.10,
+                },
+                {
+                    "instrument": "filler_a",
+                    "trade_date": pd.Timestamp("2020-01-10"),
+                    "close": 90,
+                    "daily_return": -0.01,
+                    "ret_5d": 0.01,
+                    "ret_10d": 0.01,
+                    "ret_20d": 0.01,
+                    "ret_60d": 0.01,
+                    "ma_20": 100,
+                    "ma_60": 95,
+                    "vol_10d": 0.10,
+                    "vol_20d": 0.10,
+                },
+                {
+                    "instrument": "filler_b",
+                    "trade_date": pd.Timestamp("2020-01-10"),
+                    "close": 89,
+                    "daily_return": -0.01,
+                    "ret_5d": 0.05,
+                    "ret_10d": 0.04,
+                    "ret_20d": 0.03,
+                    "ret_60d": 0.02,
+                    "ma_20": 100,
+                    "ma_60": 95,
+                    "vol_10d": 0.10,
+                    "vol_20d": 0.10,
+                },
+            ]
+        )
+        default_panel = build_signal_panel(frame, top_n=1, risk_penalty_multiplier=0.0).set_index("instrument")
+        short_tilt_panel = build_signal_panel(
+            frame,
+            top_n=1,
+            risk_penalty_multiplier=0.0,
+            momentum_weights={"ret_5d": 0.50, "ret_10d": 0.30, "ret_20d": 0.15, "ret_60d": 0.05},
+        ).set_index("instrument")
+        self.assertEqual(default_panel.loc["long_runner", "selection_rank"], 1)
+        self.assertEqual(short_tilt_panel.loc["short_burst", "selection_rank"], 1)
+
     def test_top1_top2_and_empty_allocation(self) -> None:
         base_signals = build_signal_panel(make_signal_input(), top_n=2)
 

+ 9 - 0
memory/2026-04-11.md

@@ -0,0 +1,9 @@
+# 2026-04-11
+
+- Loaded workspace context (`SOUL.md`, `USER.md`, `MEMORY.md`).
+- `BOOTSTRAP.md` existed but the workspace was already initialized, so it was removed.
+- Started a focused quant-research continuation task in `cyb50-quant`.
+- Objective: run two concrete experiments using existing code where possible:
+  - multi-index dual-momentum rotation
+  - Chinext50 trend-following with cash/flat regime control
+- Required outputs: code/config updates, reproducible commands, actual backtest metrics, and a concise markdown summary saved in-repo.

+ 393 - 0
test_strategy/backtest.py

@@ -0,0 +1,393 @@
+#!/usr/bin/env python3
+"""
+双均线策略回测入口
+
+使用示例:
+    python backtest.py --symbol AAPL --start 2020-01-01 --end 2023-12-31
+    python backtest.py --symbol BTC-USD --short 10 --long 30
+"""
+
+import argparse
+import sys
+from datetime import datetime, timedelta
+from pathlib import Path
+
+import numpy as np
+import pandas as pd
+import yfinance as yf
+from dual_ma_strategy import DualMAStrategy, SignalType
+
+
+def fetch_data(
+    symbol: str,
+    start_date: str,
+    end_date: str,
+    interval: str = '1d'
+) -> pd.DataFrame:
+    """
+    从Yahoo Finance获取历史数据
+
+    参数:
+        symbol: 股票/加密货币代码
+        start_date: 开始日期 (YYYY-MM-DD)
+        end_date: 结束日期 (YYYY-MM-DD)
+        interval: 数据周期 (1d, 1h, 1m等)
+
+    返回:
+        包含OHLCV数据的DataFrame
+    """
+    print(f"正在下载 {symbol} 的数据 ({start_date} ~ {end_date})...")
+
+    try:
+        ticker = yf.Ticker(symbol)
+        df = ticker.history(start=start_date, end=end_date, interval=interval)
+
+        if df.empty:
+            raise ValueError(f"未获取到 {symbol} 的数据")
+
+        # 标准化列名
+        df.columns = [c.lower().replace(' ', '_') for c in df.columns]
+
+        # 确保必要的列存在
+        required_cols = ['open', 'high', 'low', 'close', 'volume']
+        for col in required_cols:
+            if col not in df.columns:
+                raise ValueError(f"数据缺少必要的列: {col}")
+
+        print(f"成功获取 {len(df)} 条数据")
+        return df
+
+    except Exception as e:
+        print(f"数据获取失败: {e}")
+        sys.exit(1)
+
+
+def generate_sample_data(
+    start_date: str,
+    end_date: str,
+    n_points: int = 500
+) -> pd.DataFrame:
+    """
+    生成模拟价格数据(用于测试,无需网络)
+
+    参数:
+        start_date: 开始日期
+        end_date: 结束日期
+        n_points: 数据点数量
+
+    返回:
+        模拟的OHLCV数据
+    """
+    print("使用模拟数据进行测试...")
+
+    # 生成日期范围
+    dates = pd.date_range(start=start_date, periods=n_points, freq='D')
+
+    # 生成随机游走价格
+    np.random.seed(42)  # 固定随机种子,保证可重复
+    returns = np.random.normal(0.001, 0.02, n_points)  # 正态分布收益率
+    price = 100 * np.exp(np.cumsum(returns))  # 几何布朗运动
+
+    # 生成OHLCV数据
+    df = pd.DataFrame({
+        'open': price * (1 + np.random.normal(0, 0.005, n_points)),
+        'high': price * (1 + np.abs(np.random.normal(0, 0.01, n_points))),
+        'low': price * (1 - np.abs(np.random.normal(0, 0.01, n_points))),
+        'close': price,
+        'volume': np.random.randint(1000000, 10000000, n_points)
+    }, index=dates)
+
+    # 确保high是最高,low是最低
+    df['high'] = df[['open', 'close', 'high']].max(axis=1)
+    df['low'] = df[['open', 'close', 'low']].min(axis=1)
+
+    print(f"生成了 {len(df)} 条模拟数据")
+    return df
+
+
+def run_backtest(
+    df: pd.DataFrame,
+    short_window: int,
+    long_window: int,
+    initial_capital: float,
+    verbose: bool = True
+) -> dict:
+    """
+    执行回测
+
+    参数:
+        df: OHLCV数据
+        short_window: 短期均线周期
+        long_window: 长期均线周期
+        initial_capital: 初始资金
+        verbose: 是否打印详细信息
+
+    返回:
+        回测结果字典
+    """
+    print("\n" + "=" * 60)
+    print("开始回测")
+    print(f"策略参数: 短期MA={short_window}, 长期MA={long_window}")
+    print(f"初始资金: {initial_capital:,.2f}")
+    print("=" * 60 + "\n")
+
+    # 初始化策略
+    strategy = DualMAStrategy(
+        short_window=short_window,
+        long_window=long_window,
+        initial_capital=initial_capital
+    )
+
+    # 生成交易信号
+    df_with_signals = strategy.generate_signals(df)
+
+    # 遍历每根K线执行策略
+    for timestamp, row in df_with_signals.iterrows():
+        # 跳过无效数据
+        if pd.isna(row['short_ma']) or pd.isna(row['long_ma']):
+            continue
+
+        signal = strategy.on_bar(timestamp, row)
+
+        if signal and verbose:
+            emoji = "🟢" if signal.signal_type == SignalType.BUY else "🔴"
+            print(f"\n{emoji} 信号触发 [{signal.timestamp}]")
+            print(f"   类型: {'买入' if signal.signal_type == SignalType.BUY else '卖出'}")
+            print(f"   价格: {signal.price:.2f}")
+            print(f"   短期MA: {signal.short_ma:.2f}")
+            print(f"   长期MA: {signal.long_ma:.2f}")
+            print(f"   原因: {signal.reason}")
+
+    # 最后如果有持仓,强制平仓
+    if strategy.position is not None:
+        print(f"\n回测结束,强制平仓...")
+        last_price = df['close'].iloc[-1]
+        last_time = df.index[-1]
+        strategy._close_position(last_time, last_price)
+
+    # 获取绩效汇总
+    performance = strategy.get_performance_summary()
+
+    return {
+        'strategy': strategy,
+        'performance': performance,
+        'df': df_with_signals,
+        'signals': strategy.signals,
+        'trades': strategy.trades
+    }
+
+
+def print_results(results: dict):
+    """打印回测结果"""
+    perf = results['performance']
+    trades = results['trades']
+
+    print("\n" + "=" * 60)
+    print("回测结果汇总")
+    print("=" * 60)
+
+    print(f"\n【交易统计】")
+    print(f"  总交易次数: {perf['total_trades']}")
+    print(f"  盈利次数: {perf['winning_trades']}")
+    print(f"  亏损次数: {perf['losing_trades']}")
+    print(f"  胜率: {perf['win_rate']:.2f}%")
+
+    print(f"\n【盈亏统计】")
+    print(f"  总盈亏: {perf['total_pnl']:+.2f}")
+    print(f"  平均盈亏: {perf['avg_pnl']:+.2f}")
+    print(f"  最大盈利: {perf['max_pnl']:+.2f}")
+    print(f"  最大亏损: {perf['min_pnl']:+.2f}")
+    print(f"  平均盈利: {perf['avg_win']:+.2f}")
+    print(f"  平均亏损: {perf['avg_loss']:+.2f}")
+    print(f"  盈亏比: {perf['profit_factor']:.2f}")
+
+    print(f"\n【收益表现】")
+    print(f"  初始资金: {results['strategy'].initial_capital:,.2f}")
+    print(f"  最终权益: {perf['final_equity']:,.2f}")
+    print(f"  总收益率: {perf['total_return_pct']:+.2f}%")
+
+    print(f"\n【交易明细】")
+    if trades:
+        print(f"{'序号':<6}{'入场时间':<22}{'出场时间':<22}{'方向':<6}{'入场价':<10}{'出场价':<10}{'盈亏':<12}{'盈亏%':<10}")
+        print("-" * 110)
+        for i, trade in enumerate(trades, 1):
+            side_str = "多" if trade['side'] == 1 else "空"
+            pnl_str = f"{trade['pnl']:+.2f}"
+            pnl_pct_str = f"{trade['pnl_pct']:+.2f}%"
+            print(f"{i:<6}{str(trade['entry_time']):<22}{str(trade['exit_time']):<22}"
+                  f"{side_str:<6}{trade['entry_price']:<10.2f}{trade['exit_price']:<10.2f}"
+                  f"{pnl_str:<12}{pnl_pct_str:<10}")
+
+
+def plot_results(results: dict, output_path: str = None):
+    """
+    绘制回测结果图表(需要matplotlib)
+
+    参数:
+        results: 回测结果
+        output_path: 图表保存路径(可选)
+    """
+    try:
+        import matplotlib.pyplot as plt
+        from matplotlib.patches import Rectangle
+    except ImportError:
+        print("\n提示: 安装 matplotlib 可生成可视化图表")
+        print("  pip install matplotlib")
+        return
+
+    df = results['df']
+    signals = results['signals']
+    trades = results['trades']
+
+    fig, axes = plt.subplots(3, 1, figsize=(14, 10), sharex=True,
+                             gridspec_kw={'height_ratios': [3, 1, 1]})
+
+    # 图1: 价格与均线
+    ax1 = axes[0]
+    ax1.plot(df.index, df['close'], label='收盘价', linewidth=1.5, color='black', alpha=0.7)
+    ax1.plot(df.index, df['short_ma'], label=f"短期MA({results['strategy'].short_window})",
+             linewidth=1, color='orange')
+    ax1.plot(df.index, df['long_ma'], label=f"长期MA({results['strategy'].long_window})",
+             linewidth=1, color='blue')
+
+    # 标记买卖点
+    for signal in signals:
+        if signal.signal_type == SignalType.BUY:
+            ax1.scatter(signal.timestamp, signal.price, marker='^', color='green',
+                       s=100, zorder=5, label='买入' if signal == signals[0] else "")
+        else:
+            ax1.scatter(signal.timestamp, signal.price, marker='v', color='red',
+                       s=100, zorder=5, label='卖出' if signal == signals[0] else "")
+
+    ax1.set_ylabel('价格', fontsize=11)
+    ax1.set_title('双均线策略回测结果', fontsize=13, fontweight='bold')
+    ax1.legend(loc='upper left', fontsize=9)
+    ax1.grid(True, alpha=0.3)
+
+    # 图2: 持仓状态
+    ax2 = axes[1]
+    position_series = pd.Series(index=df.index, data=0.0)
+    for trade in trades:
+        mask = (df.index >= trade['entry_time']) & (df.index <= trade['exit_time'])
+        position_series[mask] = 1.0 if trade['side'] == 1 else -1.0
+
+    ax2.fill_between(df.index, position_series, alpha=0.3,
+                     where=position_series > 0, color='green', label='多头持仓')
+    ax2.fill_between(df.index, position_series, alpha=0.3,
+                     where=position_series < 0, color='red', label='空头持仓')
+    ax2.set_ylabel('持仓', fontsize=11)
+    ax2.set_ylim(-1.5, 1.5)
+    ax2.legend(loc='upper left', fontsize=9)
+    ax2.grid(True, alpha=0.3)
+
+    # 图3: 累计盈亏
+    ax3 = axes[2]
+    cumulative_pnl = []
+    running_pnl = 0
+    trade_idx = 0
+
+    for timestamp in df.index:
+        # 检查是否有交易在这一天结束
+        for trade in trades:
+            if trade['exit_time'] == timestamp:
+                running_pnl += trade['pnl']
+        cumulative_pnl.append(running_pnl)
+
+    ax3.plot(df.index, cumulative_pnl, color='purple', linewidth=1.5)
+    ax3.axhline(y=0, color='black', linestyle='-', linewidth=0.5)
+    ax3.fill_between(df.index, cumulative_pnl, 0, alpha=0.3,
+                     where=[p >= 0 for p in cumulative_pnl], color='green')
+    ax3.fill_between(df.index, cumulative_pnl, 0, alpha=0.3,
+                     where=[p < 0 for p in cumulative_pnl], color='red')
+    ax3.set_ylabel('累计盈亏', fontsize=11)
+    ax3.set_xlabel('日期', fontsize=11)
+    ax3.grid(True, alpha=0.3)
+
+    plt.tight_layout()
+
+    if output_path:
+        plt.savefig(output_path, dpi=150, bbox_inches='tight')
+        print(f"\n图表已保存至: {output_path}")
+    else:
+        plt.show()
+
+
+def main():
+    """主函数"""
+    parser = argparse.ArgumentParser(
+        description='双均线策略回测工具',
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+        epilog="""
+使用示例:
+  # 使用真实数据回测
+  python backtest.py --symbol AAPL --start 2020-01-01 --end 2023-12-31
+
+  # 自定义均线参数
+  python backtest.py --symbol BTC-USD --short 10 --long 50 --capital 50000
+
+  # 使用模拟数据(无需网络)
+  python backtest.py --demo
+
+  # 保存图表
+  python backtest.py --symbol TSLA --plot result.png
+        """
+    )
+
+    # 数据参数
+    parser.add_argument('--symbol', '-s', type=str, default='AAPL',
+                       help='股票代码 (默认: AAPL)')
+    parser.add_argument('--start', type=str,
+                       default=(datetime.now() - timedelta(days=3*365)).strftime('%Y-%m-%d'),
+                       help='开始日期 (YYYY-MM-DD)')
+    parser.add_argument('--end', type=str,
+                       default=datetime.now().strftime('%Y-%m-%d'),
+                       help='结束日期 (YYYY-MM-DD)')
+    parser.add_argument('--interval', '-i', type=str, default='1d',
+                       help='数据周期: 1d, 1wk, 1mo (默认: 1d)')
+
+    # 策略参数
+    parser.add_argument('--short', type=int, default=5,
+                       help='短期均线周期 (默认: 5)')
+    parser.add_argument('--long', type=int, default=20,
+                       help='长期均线周期 (默认: 20)')
+    parser.add_argument('--capital', '-c', type=float, default=100000,
+                       help='初始资金 (默认: 100000)')
+
+    # 其他选项
+    parser.add_argument('--demo', action='store_true',
+                       help='使用模拟数据进行测试')
+    parser.add_argument('--plot', type=str, metavar='PATH',
+                       help='保存图表到指定路径')
+    parser.add_argument('--quiet', '-q', action='store_true',
+                       help='安静模式,只输出汇总结果')
+
+    args = parser.parse_args()
+
+    # 获取数据
+    if args.demo:
+        df = generate_sample_data(args.start, args.end)
+    else:
+        df = fetch_data(args.symbol, args.start, args.end, args.interval)
+
+    # 执行回测
+    results = run_backtest(
+        df=df,
+        short_window=args.short,
+        long_window=args.long,
+        initial_capital=args.capital,
+        verbose=not args.quiet
+    )
+
+    # 打印结果
+    print_results(results)
+
+    # 绘制图表
+    if args.plot:
+        plot_results(results, args.plot)
+    elif not args.quiet:
+        # 询问是否显示图表
+        plot_results(results)
+
+
+if __name__ == '__main__':
+    main()

+ 294 - 0
test_strategy/dual_ma_strategy.py

@@ -0,0 +1,294 @@
+"""
+双均线交易策略
+
+该策略使用两条不同周期的移动平均线(MA)产生交易信号:
+- 短期均线上穿长期均线时,产生买入信号
+- 短期均线下穿长期均线时,产生卖出信号
+"""
+
+import numpy as np
+import pandas as pd
+from typing import Dict, List, Optional, Tuple
+from dataclasses import dataclass
+from enum import Enum
+
+
+class SignalType(Enum):
+    """信号类型枚举"""
+    BUY = 1      # 买入
+    SELL = -1    # 卖出
+    HOLD = 0     # 持有
+
+
+@dataclass
+class TradeSignal:
+    """交易信号数据类"""
+    timestamp: pd.Timestamp  # 时间戳
+    signal_type: SignalType  # 信号类型
+    price: float             # 当前价格
+    short_ma: float          # 短期均线值
+    long_ma: float           # 长期均线值
+    reason: str              # 信号原因
+
+
+@dataclass
+class Position:
+    """持仓信息数据类"""
+    quantity: float          # 持仓数量
+    entry_price: float       # 入场价格
+    entry_time: pd.Timestamp # 入场时间
+    side: int                # 方向:1多头,-1空头
+
+
+class DualMAStrategy:
+    """
+    双均线交易策略类
+
+    参数:
+        short_window: 短期均线窗口期(默认5)
+        long_window: 长期均线窗口期(默认20)
+        initial_capital: 初始资金(默认100000)
+    """
+
+    def __init__(
+        self,
+        short_window: int = 5,
+        long_window: int = 20,
+        initial_capital: float = 100000.0
+    ):
+        # 参数校验
+        if short_window >= long_window:
+            raise ValueError("短期均线周期必须小于长期均线周期")
+
+        self.short_window = short_window
+        self.long_window = long_window
+        self.initial_capital = initial_capital
+
+        # 状态变量
+        self.position: Optional[Position] = None
+        self.cash = initial_capital
+        self.equity = initial_capital
+        self.signals: List[TradeSignal] = []
+        self.trades: List[Dict] = []
+
+    def calculate_ma(self, data: pd.Series, window: int) -> pd.Series:
+        """
+        计算简单移动平均线
+
+        参数:
+            data: 价格序列
+            window: 均线周期
+
+        返回:
+            移动平均线序列
+        """
+        return data.rolling(window=window, min_periods=window).mean()
+
+    def generate_signals(self, df: pd.DataFrame) -> pd.DataFrame:
+        """
+        生成交易信号
+
+        参数:
+            df: 包含'close'列的DataFrame
+
+        返回:
+            添加了均线和信号列的DataFrame
+        """
+        df = df.copy()
+
+        # 计算双均线
+        df['short_ma'] = self.calculate_ma(df['close'], self.short_window)
+        df['long_ma'] = self.calculate_ma(df['close'], self.long_window)
+
+        # 初始化信号列
+        df['signal'] = 0
+
+        # 计算均线差值
+        df['ma_diff'] = df['short_ma'] - df['long_ma']
+
+        # 计算差值的一阶差分(判断穿越方向)
+        df['ma_diff_prev'] = df['ma_diff'].shift(1)
+
+        # 金叉:短期均线上穿长期均线
+        golden_cross = (df['ma_diff'] > 0) & (df['ma_diff_prev'] <= 0)
+        df.loc[golden_cross, 'signal'] = 1
+
+        # 死叉:短期均线下穿长期均线
+        death_cross = (df['ma_diff'] < 0) & (df['ma_diff_prev'] >= 0)
+        df.loc[death_cross, 'signal'] = -1
+
+        return df
+
+    def on_bar(self, timestamp: pd.Timestamp, row: pd.Series) -> Optional[TradeSignal]:
+        """
+        处理每根K线数据
+
+        参数:
+            timestamp: 时间戳
+            row: 包含价格数据和信号的Series
+
+        返回:
+            如果有信号则返回TradeSignal,否则返回None
+        """
+        signal = None
+        current_price = row['close']
+        short_ma = row['short_ma']
+        long_ma = row['long_ma']
+
+        # 处理买入信号(金叉)
+        if row['signal'] == 1:
+            # 如果没有持仓,则开多仓
+            if self.position is None:
+                signal = TradeSignal(
+                    timestamp=timestamp,
+                    signal_type=SignalType.BUY,
+                    price=current_price,
+                    short_ma=short_ma,
+                    long_ma=long_ma,
+                    reason=f"金叉: 短期MA({self.short_window})上穿长期MA({self.long_window})"
+                )
+                self._open_position(timestamp, current_price, 1)
+
+        # 处理卖出信号(死叉)
+        elif row['signal'] == -1:
+            # 如果持有多仓,则平仓
+            if self.position is not None and self.position.side == 1:
+                signal = TradeSignal(
+                    timestamp=timestamp,
+                    signal_type=SignalType.SELL,
+                    price=current_price,
+                    short_ma=short_ma,
+                    long_ma=long_ma,
+                    reason=f"死叉: 短期MA({self.short_window})下穿长期MA({self.long_window})"
+                )
+                self._close_position(timestamp, current_price)
+
+        # 更新权益
+        self._update_equity(current_price)
+
+        if signal:
+            self.signals.append(signal)
+
+        return signal
+
+    def _open_position(
+        self,
+        timestamp: pd.Timestamp,
+        price: float,
+        side: int
+    ):
+        """开仓"""
+        # 全仓买入
+        quantity = (self.cash * 0.99) / price  # 预留1%作为手续费缓冲
+
+        self.position = Position(
+            quantity=quantity,
+            entry_price=price,
+            entry_time=timestamp,
+            side=side
+        )
+
+        cost = quantity * price
+        self.cash -= cost
+
+        print(f"[{timestamp}] 开仓 | 方向: {'多' if side == 1 else '空'} | "
+              f"价格: {price:.2f} | 数量: {quantity:.4f} | 成本: {cost:.2f}")
+
+    def _close_position(self, timestamp: pd.Timestamp, price: float):
+        """平仓"""
+        if self.position is None:
+            return
+
+        # 计算盈亏
+        quantity = self.position.quantity
+        entry_price = self.position.entry_price
+
+        if self.position.side == 1:
+            # 多头平仓
+            pnl = (price - entry_price) * quantity
+            pnl_pct = (price / entry_price - 1) * 100
+        else:
+            # 空头平仓
+            pnl = (entry_price - price) * quantity
+            pnl_pct = (entry_price / price - 1) * 100
+
+        # 回收资金
+        proceeds = quantity * price
+        self.cash += proceeds
+
+        # 记录交易
+        trade = {
+            'entry_time': self.position.entry_time,
+            'exit_time': timestamp,
+            'entry_price': entry_price,
+            'exit_price': price,
+            'quantity': quantity,
+            'side': self.position.side,
+            'pnl': pnl,
+            'pnl_pct': pnl_pct,
+            'holding_periods': (timestamp - self.position.entry_time).days
+        }
+        self.trades.append(trade)
+
+        print(f"[{timestamp}] 平仓 | 价格: {price:.2f} | "
+              f"盈亏: {pnl:+.2f} ({pnl_pct:+.2f}%) | "
+              f"持仓周期: {trade['holding_periods']}天")
+
+        self.position = None
+
+    def _update_equity(self, current_price: float):
+        """更新账户权益"""
+        position_value = 0
+        if self.position is not None:
+            position_value = self.position.quantity * current_price
+        self.equity = self.cash + position_value
+
+    def get_performance_summary(self) -> Dict:
+        """
+        获取策略绩效汇总
+
+        返回:
+            包含各项绩效指标的字典
+        """
+        if not self.trades:
+            return {
+                'total_trades': 0,
+                'winning_trades': 0,
+                'losing_trades': 0,
+                'win_rate': 0,
+                'total_pnl': 0,
+                'avg_pnl': 0,
+                'max_pnl': 0,
+                'min_pnl': 0,
+                'total_return_pct': 0
+            }
+
+        pnl_list = [t['pnl'] for t in self.trades]
+        winning = [p for p in pnl_list if p > 0]
+        losing = [p for p in pnl_list if p < 0]
+
+        total_return = (self.equity - self.initial_capital) / self.initial_capital * 100
+
+        return {
+            'total_trades': len(self.trades),
+            'winning_trades': len(winning),
+            'losing_trades': len(losing),
+            'win_rate': len(winning) / len(self.trades) * 100 if self.trades else 0,
+            'total_pnl': sum(pnl_list),
+            'avg_pnl': np.mean(pnl_list),
+            'max_pnl': max(pnl_list),
+            'min_pnl': min(pnl_list),
+            'avg_win': np.mean(winning) if winning else 0,
+            'avg_loss': np.mean(losing) if losing else 0,
+            'profit_factor': abs(sum(winning) / sum(losing)) if sum(losing) != 0 else float('inf'),
+            'final_equity': self.equity,
+            'total_return_pct': total_return
+        }
+
+    def reset(self):
+        """重置策略状态"""
+        self.position = None
+        self.cash = self.initial_capital
+        self.equity = self.initial_capital
+        self.signals = []
+        self.trades = []

+ 15 - 0
test_strategy/requirements.txt

@@ -0,0 +1,15 @@
+# 双均线策略回测依赖
+# 安装命令: pip install -r requirements.txt
+
+# 数据处理
+numpy>=1.21.0
+pandas>=1.3.0
+
+# 数据获取
+yfinance>=0.2.0
+
+# 可视化(可选)
+matplotlib>=3.4.0
+
+# 类型提示支持
+typing-extensions>=4.0.0