Explorar o código

Add dragon v2 strategy research and reporting code

erwin hai 1 mes
pai
achega
9f7ce161cd
Modificáronse 51 ficheiros con 13674 adicións e 0 borrados
  1. 274 0
      research/dragon/v2/dragon_alpha_branch_governance.py
  2. 319 0
      research/dragon/v2/dragon_alpha_first_baseline.py
  3. 222 0
      research/dragon/v2/dragon_aux_sell_cycle_audit.py
  4. 184 0
      research/dragon/v2/dragon_aux_signal_audit.py
  5. 112 0
      research/dragon/v2/dragon_backtest.py
  6. 38 0
      research/dragon/v2/dragon_branch_configs.py
  7. 135 0
      research/dragon/v2/dragon_cost_stress_test.py
  8. 428 0
      research/dragon/v2/dragon_daily_signal_pipeline.py
  9. 152 0
      research/dragon/v2/dragon_deep_oversold_audit.py
  10. 210 0
      research/dragon/v2/dragon_deep_oversold_confirmation_experiments.py
  11. 128 0
      research/dragon/v2/dragon_deep_oversold_experiments.py
  12. 216 0
      research/dragon/v2/dragon_deep_oversold_selective_veto_experiments.py
  13. 141 0
      research/dragon/v2/dragon_equity_curve_review.py
  14. 140 0
      research/dragon/v2/dragon_execution_common.py
  15. 516 0
      research/dragon/v2/dragon_forward_observation_pipeline.py
  16. 408 0
      research/dragon/v2/dragon_glued_alpha_candidate.py
  17. 245 0
      research/dragon/v2/dragon_glued_refine_experiments.py
  18. 379 0
      research/dragon/v2/dragon_glued_refined_branch_review.py
  19. 280 0
      research/dragon/v2/dragon_glued_refined_removed_trade_attribution.py
  20. 174 0
      research/dragon/v2/dragon_glued_refined_sensitivity.py
  21. 110 0
      research/dragon/v2/dragon_glued_refined_year_regime_review.py
  22. 343 0
      research/dragon/v2/dragon_glued_veto_attribution.py
  23. 1959 0
      research/dragon/v2/dragon_html_reports.py
  24. 110 0
      research/dragon/v2/dragon_indicators.py
  25. 117 0
      research/dragon/v2/dragon_mismatch_diagnostics.py
  26. 149 0
      research/dragon/v2/dragon_predictive_break_audit.py
  27. 103 0
      research/dragon/v2/dragon_predictive_break_experiments.py
  28. 129 0
      research/dragon/v2/dragon_rc1_release.py
  29. 586 0
      research/dragon/v2/dragon_refined_alpha_attribution.py
  30. 163 0
      research/dragon/v2/dragon_refined_execution_validation.py
  31. 230 0
      research/dragon/v2/dragon_research_baseline.py
  32. 279 0
      research/dragon/v2/dragon_residual_attribution.py
  33. 335 0
      research/dragon/v2/dragon_robustness_report.py
  34. 254 0
      research/dragon/v2/dragon_rule_ablation.py
  35. 35 0
      research/dragon/v2/dragon_shared.py
  36. 273 0
      research/dragon/v2/dragon_short_holding_audit.py
  37. 222 0
      research/dragon/v2/dragon_short_holding_experiments.py
  38. 109 0
      research/dragon/v2/dragon_short_holding_family_pressure.py
  39. 122 0
      research/dragon/v2/dragon_short_holding_master_review.py
  40. 148 0
      research/dragon/v2/dragon_stability_report.py
  41. 59 0
      research/dragon/v2/dragon_state_machine.py
  42. 1297 0
      research/dragon/v2/dragon_strategy.py
  43. 123 0
      research/dragon/v2/dragon_strategy_config.py
  44. 147 0
      research/dragon/v2/dragon_strategy_overview.py
  45. 455 0
      research/dragon/v2/dragon_system_review.py
  46. 214 0
      research/dragon/v2/dragon_threshold_perturbation.py
  47. 298 0
      research/dragon/v2/dragon_trade_path_trace.py
  48. 159 0
      research/dragon/v2/dragon_validate.py
  49. 242 0
      research/dragon/v2/dragon_walk_forward_validation.py
  50. 193 0
      research/dragon/v2/dragon_workbook.py
  51. 10 0
      research/dragon/v2/update_dragon_reports.ps1

+ 274 - 0
research/dragon/v2/dragon_alpha_branch_governance.py

@@ -0,0 +1,274 @@
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import pandas as pd
+
+
+def _load_csv(base_dir: Path, name: str) -> pd.DataFrame:
+    return pd.read_csv(base_dir / name, encoding="utf-8-sig")
+
+
+def _format_pct(value: float) -> str:
+    if pd.isna(value):
+        return "NA"
+    if value == float("inf"):
+        return "inf"
+    return f"{value:.2%}"
+
+
+def _format_num(value: float) -> str:
+    if pd.isna(value):
+        return "NA"
+    if value == float("inf"):
+        return "inf"
+    return f"{value:.2f}"
+
+
+def _wf_stats(df: pd.DataFrame, branch: str, scheme: str) -> tuple[int, int, float]:
+    view = df[(df["branch"] == branch) & (df["scheme"] == scheme)].copy()
+    positive = int((view["test_avg_return"] > 0).sum()) if not view.empty else 0
+    total = int(len(view))
+    avg_test = float(view["test_avg_return"].mean()) if not view.empty else float("nan")
+    return positive, total, avg_test
+
+
+def main() -> None:
+    base_dir = Path(__file__).resolve().parent
+
+    branch_summary = _load_csv(base_dir, "dragon_glued_refined_branch_summary.csv")
+    walk_forward = _load_csv(base_dir, "dragon_glued_refined_branch_walk_forward.csv")
+    removed = _load_csv(base_dir, "dragon_glued_refined_removed_trade_attribution.csv")
+
+    alpha = branch_summary[branch_summary["branch"] == "alpha_first_selective_veto"].iloc[0]
+    refined = branch_summary[branch_summary["branch"] == "alpha_first_glued_refined_hot_cap"].iloc[0]
+
+    af_anchor_pos, af_anchor_total, af_anchor_avg = _wf_stats(walk_forward, "alpha_first_selective_veto", "anchored_expanding")
+    ref_anchor_pos, ref_anchor_total, ref_anchor_avg = _wf_stats(walk_forward, "alpha_first_glued_refined_hot_cap", "anchored_expanding")
+    af_roll_pos, af_roll_total, af_roll_avg = _wf_stats(walk_forward, "alpha_first_selective_veto", "rolling_3y")
+    ref_roll_pos, ref_roll_total, ref_roll_avg = _wf_stats(walk_forward, "alpha_first_glued_refined_hot_cap", "rolling_3y")
+
+    removed_trades = int(len(removed))
+    removed_avg_return = float(removed["return_pct"].mean()) if not removed.empty else float("nan")
+    removed_win_rate = float((removed["return_pct"] > 0).mean()) if not removed.empty else float("nan")
+    removed_keep = int((removed["recommendation"] == "KEEP_REMOVAL").sum())
+    removed_observe = int((removed["recommendation"] == "OBSERVE_REMOVAL").sum())
+    removed_over = int((removed["recommendation"] == "OVER_REMOVAL").sum())
+
+    avg_return_delta = float(refined["avg_return"] - alpha["avg_return"])
+    profit_factor_delta = float(refined["profit_factor"] - alpha["profit_factor"])
+    buy_overlap_delta = int(refined["real_buy_overlap"] - alpha["real_buy_overlap"])
+    sell_overlap_delta = int(refined["real_sell_overlap"] - alpha["real_sell_overlap"])
+    short_00_05d_delta = float(refined["short_00_05d_avg_return"] - alpha["short_00_05d_avg_return"])
+    short_06_10d_delta = float(refined["short_06_10d_avg_return"] - alpha["short_06_10d_avg_return"])
+
+    headline_quality_gate = (
+        avg_return_delta >= 0.003
+        and profit_factor_delta >= 0.50
+        and short_00_05d_delta >= 0
+        and short_06_10d_delta >= 0
+    )
+    stability_gate = (
+        ref_anchor_pos >= af_anchor_pos
+        and ref_roll_pos >= af_roll_pos
+        and ref_anchor_avg >= af_anchor_avg
+        and ref_roll_avg >= af_roll_avg
+    )
+    removal_quality_gate = removed.empty or (
+        removed_over == 0
+        and removed_observe <= 1
+        and removed_win_rate <= 0.05
+        and removed_avg_return < 0
+    )
+    # Automatic promotion should not occur if the candidate loses more than 8
+    # additional aligned BUYs or SELLs versus the current formal alpha branch.
+    # Positive deltas are improvements and should not block promotion.
+    alignment_cost_gate = (buy_overlap_delta >= -8 and sell_overlap_delta >= -8)
+
+    if headline_quality_gate and stability_gate and removal_quality_gate and alignment_cost_gate:
+        final_decision = "PROMOTE_REFINED_ALPHA_BASELINE"
+    elif headline_quality_gate and stability_gate and removal_quality_gate:
+        final_decision = "DUAL_TRACK_GOVERNANCE"
+    else:
+        final_decision = "KEEP_CURRENT_ALPHA_BASELINE"
+
+    matrix_rows = [
+        {
+            "branch": "alpha_first_selective_veto",
+            "role": "current_formal_alpha",
+            "trades": int(alpha["trades"]),
+            "win_rate": float(alpha["win_rate"]),
+            "avg_return": float(alpha["avg_return"]),
+            "median_return": float(alpha["median_return"]),
+            "profit_factor": float(alpha["profit_factor"]),
+            "avg_mfe": float(alpha["avg_mfe"]),
+            "avg_mae": float(alpha["avg_mae"]),
+            "short_00_05d_avg_return": float(alpha["short_00_05d_avg_return"]),
+            "short_06_10d_avg_return": float(alpha["short_06_10d_avg_return"]),
+            "real_buy_overlap": int(alpha["real_buy_overlap"]),
+            "real_sell_overlap": int(alpha["real_sell_overlap"]),
+            "anchored_positive_years": af_anchor_pos,
+            "anchored_total_years": af_anchor_total,
+            "anchored_avg_test_return": af_anchor_avg,
+            "rolling_positive_years": af_roll_pos,
+            "rolling_total_years": af_roll_total,
+            "rolling_avg_test_return": af_roll_avg,
+            "removed_trades_vs_current_alpha": 0,
+            "removed_avg_return_vs_current_alpha": float("nan"),
+            "removed_win_rate_vs_current_alpha": float("nan"),
+            "over_removal_count_vs_current_alpha": 0,
+            "observe_removal_count_vs_current_alpha": 0,
+            "keep_removal_count_vs_current_alpha": 0,
+            "headline_quality_gate": None,
+            "stability_gate": None,
+            "removal_quality_gate": None,
+            "alignment_cost_gate": None,
+            "governance_decision": "CURRENT_BASELINE",
+        },
+        {
+            "branch": "alpha_first_glued_refined_hot_cap",
+            "role": "leading_candidate_alpha",
+            "trades": int(refined["trades"]),
+            "win_rate": float(refined["win_rate"]),
+            "avg_return": float(refined["avg_return"]),
+            "median_return": float(refined["median_return"]),
+            "profit_factor": float(refined["profit_factor"]),
+            "avg_mfe": float(refined["avg_mfe"]),
+            "avg_mae": float(refined["avg_mae"]),
+            "short_00_05d_avg_return": float(refined["short_00_05d_avg_return"]),
+            "short_06_10d_avg_return": float(refined["short_06_10d_avg_return"]),
+            "real_buy_overlap": int(refined["real_buy_overlap"]),
+            "real_sell_overlap": int(refined["real_sell_overlap"]),
+            "anchored_positive_years": ref_anchor_pos,
+            "anchored_total_years": ref_anchor_total,
+            "anchored_avg_test_return": ref_anchor_avg,
+            "rolling_positive_years": ref_roll_pos,
+            "rolling_total_years": ref_roll_total,
+            "rolling_avg_test_return": ref_roll_avg,
+            "removed_trades_vs_current_alpha": removed_trades,
+            "removed_avg_return_vs_current_alpha": removed_avg_return,
+            "removed_win_rate_vs_current_alpha": removed_win_rate,
+            "over_removal_count_vs_current_alpha": removed_over,
+            "observe_removal_count_vs_current_alpha": removed_observe,
+            "keep_removal_count_vs_current_alpha": removed_keep,
+            "headline_quality_gate": headline_quality_gate,
+            "stability_gate": stability_gate,
+            "removal_quality_gate": removal_quality_gate,
+            "alignment_cost_gate": alignment_cost_gate,
+            "governance_decision": final_decision,
+        },
+    ]
+    matrix = pd.DataFrame(matrix_rows)
+    matrix.to_csv(base_dir / "dragon_alpha_branch_governance_matrix.csv", index=False, encoding="utf-8-sig")
+
+    decision_payload = {
+        "current_formal_alpha": "alpha_first_selective_veto",
+        "leading_candidate_alpha": "alpha_first_glued_refined_hot_cap",
+        "avg_return_delta_vs_current": avg_return_delta,
+        "profit_factor_delta_vs_current": profit_factor_delta,
+        "buy_overlap_delta_vs_current": buy_overlap_delta,
+        "sell_overlap_delta_vs_current": sell_overlap_delta,
+        "headline_quality_gate": headline_quality_gate,
+        "stability_gate": stability_gate,
+        "removal_quality_gate": removal_quality_gate,
+        "alignment_cost_gate": alignment_cost_gate,
+        "final_decision": final_decision,
+    }
+    (base_dir / "dragon_alpha_branch_governance_decision.json").write_text(
+        json.dumps(decision_payload, indent=2, ensure_ascii=False) + "\n",
+        encoding="utf-8",
+    )
+
+    lines = [
+        "# Dragon Alpha Branch Governance",
+        "",
+        "## Scope",
+        "- Current formal alpha branch: `alpha_first_selective_veto`.",
+        "- Leading candidate branch: `alpha_first_glued_refined_hot_cap`.",
+        "- Goal: decide whether the refined branch should replace the current formal alpha branch or remain a governed candidate.",
+        "",
+        "## Headline Metrics",
+        f"- current alpha: trades `{int(alpha['trades'])}`, avg_return `{_format_pct(float(alpha['avg_return']))}`, profit_factor `{_format_num(float(alpha['profit_factor']))}`, real BUY / SELL `{int(alpha['real_buy_overlap'])}/{int(alpha['real_sell_overlap'])}`",
+        f"- refined candidate: trades `{int(refined['trades'])}`, avg_return `{_format_pct(float(refined['avg_return']))}`, profit_factor `{_format_num(float(refined['profit_factor']))}`, real BUY / SELL `{int(refined['real_buy_overlap'])}/{int(refined['real_sell_overlap'])}`",
+        f"- delta: avg_return `{_format_pct(avg_return_delta)}`, profit_factor `{_format_num(profit_factor_delta)}`, BUY overlap `{buy_overlap_delta}`, SELL overlap `{sell_overlap_delta}`",
+        "",
+        "## Risk And Quality",
+        f"- avg MFE / MAE: current `{_format_pct(float(alpha['avg_mfe']))}` / `{_format_pct(float(alpha['avg_mae']))}` vs refined `{_format_pct(float(refined['avg_mfe']))}` / `{_format_pct(float(refined['avg_mae']))}`",
+        f"- short `00-05d`: current `{_format_pct(float(alpha['short_00_05d_avg_return']))}` vs refined `{_format_pct(float(refined['short_00_05d_avg_return']))}`",
+        f"- short `06-10d`: current `{_format_pct(float(alpha['short_06_10d_avg_return']))}` vs refined `{_format_pct(float(refined['short_06_10d_avg_return']))}`",
+        "",
+        "## Walk-Forward",
+        f"- anchored expanding: current `{af_anchor_pos}/{af_anchor_total}`, avg `{_format_pct(af_anchor_avg)}` vs refined `{ref_anchor_pos}/{ref_anchor_total}`, avg `{_format_pct(ref_anchor_avg)}`",
+        f"- rolling 3Y: current `{af_roll_pos}/{af_roll_total}`, avg `{_format_pct(af_roll_avg)}` vs refined `{ref_roll_pos}/{ref_roll_total}`, avg `{_format_pct(ref_roll_avg)}`",
+        "",
+        "## Removed-Trade Attribution",
+        f"- removed trades vs current alpha: `{removed_trades}`",
+        f"- removed-set avg_return / win_rate: `{_format_pct(removed_avg_return)}` / `{_format_pct(removed_win_rate)}`",
+        f"- recommendation mix KEEP / OBSERVE / OVER: `{removed_keep}/{removed_observe}/{removed_over}`",
+        "- Interpretation: the refined branch now improves by removing only weak, losing short-holding glued trades; it does not rely on deleting profitable samples.",
+        "",
+        "## Upgrade Gates",
+        "- `headline_quality_gate`: requires avg_return delta `>= +0.30%`, profit_factor delta `>= +0.50`, and no short-bucket deterioration.",
+        f"  result: `{'PASS' if headline_quality_gate else 'FAIL'}`",
+        "- `stability_gate`: requires anchored and rolling walk-forward to be no worse than the current formal alpha branch.",
+        f"  result: `{'PASS' if stability_gate else 'FAIL'}`",
+        "- `removal_quality_gate`: requires `OVER_REMOVAL = 0`, `OBSERVE_REMOVAL <= 1`, removed-set win_rate `<= 5%`, and removed-set avg_return `< 0`.",
+        f"  result: `{'PASS' if removal_quality_gate else 'FAIL'}`",
+        "- `alignment_cost_gate`: automatic promotion only if incremental overlap loss is no more than `8` additional BUYs and `8` additional SELLs versus the current formal alpha branch.",
+        f"  result: `{'PASS' if alignment_cost_gate else 'FAIL'}`",
+        "",
+        "## Final Decision",
+        f"- governance_decision: `{final_decision}`",
+    ]
+
+    if final_decision == "PROMOTE_REFINED_ALPHA_BASELINE":
+        lines.extend(
+            [
+                "- Decision: promote `alpha_first_glued_refined_hot_cap` to the new formal alpha-first baseline.",
+                "- Reason: quality, stability, removal quality, and alignment cost all pass together.",
+            ]
+        )
+    elif final_decision == "DUAL_TRACK_GOVERNANCE":
+        lines.extend(
+            [
+                "- Decision: keep `alpha_first_selective_veto` as the formal alpha branch and keep `alpha_first_glued_refined_hot_cap` as the governed leading candidate.",
+                "- Reason: quality, stability, and removal quality all pass, but alignment loss is still large enough that promotion should be explicit rather than automatic.",
+            ]
+        )
+    else:
+        lines.extend(
+            [
+                "- Decision: keep `alpha_first_selective_veto` as the formal alpha branch.",
+                "- Reason: the refined candidate does not pass enough upgrade gates to justify even governed promotion.",
+            ]
+        )
+
+    lines.extend(["", "## Recommendation"])
+    if final_decision == "PROMOTE_REFINED_ALPHA_BASELINE":
+        lines.extend(
+            [
+                "- Promote `alpha_first_glued_refined_hot_cap` into the formal alpha branch and freeze a new governed release snapshot.",
+                "- Keep `alpha_first_selective_veto` as the immediate control branch for post-promotion monitoring.",
+            ]
+        )
+    elif final_decision == "DUAL_TRACK_GOVERNANCE":
+        lines.extend(
+            [
+                "- Keep `alpha_first_selective_veto` as the current formal alpha branch for now.",
+                "- Keep `alpha_first_glued_refined_hot_cap` as the first governed promotion candidate if the governance objective explicitly shifts toward stronger alpha.",
+            ]
+        )
+    else:
+        lines.extend(
+            [
+                "- Keep `alpha_first_selective_veto` as the formal alpha branch.",
+                "- Re-open refined promotion only after a new candidate improves quality without failing the current governance gates.",
+            ]
+        )
+
+    (base_dir / "dragon_alpha_branch_governance.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+
+if __name__ == "__main__":
+    main()

+ 319 - 0
research/dragon/v2/dragon_alpha_first_baseline.py

@@ -0,0 +1,319 @@
+from __future__ import annotations
+
+import json
+from dataclasses import asdict
+from pathlib import Path
+
+import pandas as pd
+
+from dragon_shared import END_DATE, START_DATE, format_num as _format_num, format_pct as _format_pct, profit_factor
+from dragon_strategy import DragonRuleEngine
+from dragon_strategy_config import StrategyConfig
+
+
+def _load_indicator_snapshot(base_dir: Path) -> pd.DataFrame:
+    df = pd.read_csv(base_dir / "dragon_indicator_snapshot.csv", encoding="utf-8-sig")
+    df["date"] = pd.to_datetime(df["date"])
+    return df.set_index("date", drop=False)
+
+
+def _load_true_trade_events(base_dir: Path) -> pd.DataFrame:
+    return pd.read_csv(base_dir / "true_trade_events.csv", encoding="utf-8-sig")
+
+
+def _holding_bucket(days: int) -> str:
+    if days <= 5:
+        return "00-05d"
+    if days <= 10:
+        return "06-10d"
+    if days <= 20:
+        return "11-20d"
+    if days <= 40:
+        return "21-40d"
+    return "41d+"
+
+
+def _event_match(strategy_events: pd.DataFrame, workbook_events: pd.DataFrame, side: str) -> tuple[int, int, int]:
+    wb = set(workbook_events[(workbook_events["side"] == side) & (workbook_events["layer"] == "real_trade")]["date"])
+    st = set(strategy_events[(strategy_events["side"] == side) & (strategy_events["layer"] == "real_trade")]["date"])
+    return len(wb & st), len(wb - st), len(st - wb)
+
+
+def _segment_stats(df: pd.DataFrame) -> dict[str, float | int]:
+    if df.empty:
+        return {
+            "trades": 0,
+            "win_rate": float("nan"),
+            "avg_return": float("nan"),
+            "profit_factor": float("nan"),
+            "compounded_return": float("nan"),
+        }
+    returns = df["return_pct"].astype(float)
+    return {
+        "trades": int(len(df)),
+        "win_rate": float((returns > 0).mean()),
+        "avg_return": float(returns.mean()),
+        "profit_factor": profit_factor(returns),
+        "compounded_return": float((1.0 + returns).prod() - 1.0),
+    }
+
+
+def _build_walk_forward(trades: pd.DataFrame, branch_name: str) -> pd.DataFrame:
+    years = sorted(int(year) for year in trades["sell_year"].unique())
+    rows: list[dict[str, object]] = []
+    for idx, test_year in enumerate(years):
+        if idx >= 1:
+            train_years = years[:idx]
+            train_df = trades[trades["sell_year"].isin(train_years)]
+            test_df = trades[trades["sell_year"] == test_year]
+            rows.append(
+                {
+                    "branch": branch_name,
+                    "scheme": "anchored_expanding",
+                    "train_start_year": train_years[0],
+                    "train_end_year": train_years[-1],
+                    "test_year": test_year,
+                    **{f"train_{k}": v for k, v in _segment_stats(train_df).items()},
+                    **{f"test_{k}": v for k, v in _segment_stats(test_df).items()},
+                }
+            )
+        if idx >= 3:
+            train_years = years[idx - 3 : idx]
+            train_df = trades[trades["sell_year"].isin(train_years)]
+            test_df = trades[trades["sell_year"] == test_year]
+            rows.append(
+                {
+                    "branch": branch_name,
+                    "scheme": "rolling_3y",
+                    "train_start_year": train_years[0],
+                    "train_end_year": train_years[-1],
+                    "test_year": test_year,
+                    **{f"train_{k}": v for k, v in _segment_stats(train_df).items()},
+                    **{f"test_{k}": v for k, v in _segment_stats(test_df).items()},
+                }
+            )
+    return pd.DataFrame(rows)
+
+
+def _run_branch(
+    name: str,
+    config: StrategyConfig,
+    indicator_df: pd.DataFrame,
+    workbook_events: pd.DataFrame,
+    first_date: str,
+    last_date: str,
+) -> tuple[dict[str, object], pd.DataFrame, pd.DataFrame, pd.DataFrame]:
+    engine = DragonRuleEngine(config=config)
+    events, trades = engine.run(indicator_df)
+    start = max(first_date, START_DATE)
+    end = min(last_date, END_DATE)
+    events = events[(events["date"] >= start) & (events["date"] <= end)].copy()
+    trades = trades[
+        (trades["buy_date"] >= start)
+        & (trades["buy_date"] <= end)
+        & (trades["sell_date"] >= start)
+        & (trades["sell_date"] <= end)
+    ].copy()
+
+    buy_overlap, buy_missing, buy_extra = _event_match(events, workbook_events, "BUY")
+    sell_overlap, sell_missing, sell_extra = _event_match(events, workbook_events, "SELL")
+
+    trades["branch"] = name
+    trades["sell_dt"] = pd.to_datetime(trades["sell_date"])
+    trades["sell_year"] = trades["sell_dt"].dt.year.astype(int)
+    trades["holding_bucket"] = trades["holding_days"].astype(int).map(_holding_bucket)
+
+    returns = trades["return_pct"].astype(float) if not trades.empty else pd.Series(dtype=float)
+    summary = {
+        "branch": name,
+        "trades": int(len(trades)),
+        "win_rate": float((returns > 0).mean()) if not trades.empty else float("nan"),
+        "avg_return": float(returns.mean()) if not trades.empty else float("nan"),
+        "median_return": float(returns.median()) if not trades.empty else float("nan"),
+        "profit_factor": profit_factor(returns) if not trades.empty else float("nan"),
+        "real_buy_overlap": int(buy_overlap),
+        "real_buy_missing": int(buy_missing),
+        "real_buy_extra": int(buy_extra),
+        "real_sell_overlap": int(sell_overlap),
+        "real_sell_missing": int(sell_missing),
+        "real_sell_extra": int(sell_extra),
+        "short_00_05d_avg_return": float(trades[trades["holding_bucket"] == "00-05d"]["return_pct"].mean()),
+        "short_06_10d_avg_return": float(trades[trades["holding_bucket"] == "06-10d"]["return_pct"].mean()),
+    }
+
+    bucket_rows: list[dict[str, object]] = []
+    for bucket, group in trades.groupby("holding_bucket", dropna=False):
+        bucket_rows.append(
+            {
+                "branch": name,
+                "holding_bucket": bucket,
+                "trades": int(len(group)),
+                "win_rate": float((group["return_pct"] > 0).mean()),
+                "avg_return": float(group["return_pct"].mean()),
+                "profit_factor": profit_factor(group["return_pct"]),
+            }
+        )
+    holding_df = pd.DataFrame(bucket_rows).sort_values("holding_bucket")
+    walk_forward_df = _build_walk_forward(trades, name)
+    return summary, trades, holding_df, walk_forward_df
+
+
+def _config_snapshot(config: StrategyConfig) -> dict[str, object]:
+    snapshot = asdict(config)
+    snapshot["disabled_rules"] = sorted(config.disabled_rules)
+    return snapshot
+
+
+def main() -> None:
+    base_dir = Path(__file__).resolve().parent
+    indicator_df = _load_indicator_snapshot(base_dir)
+    workbook_events = _load_true_trade_events(base_dir)
+    first_date = workbook_events["date"].min()
+    last_date = workbook_events["date"].max()
+
+    workbook_config = StrategyConfig()
+    alpha_config = workbook_config.with_updates(
+        deep_oversold_selective_positive_b1_c1_max=15.3,
+        deep_oversold_selective_shallow_c1_min=12.0,
+        deep_oversold_selective_shallow_b1_min=-0.025,
+        deep_oversold_selective_mixed_c1_max=10.2,
+        deep_oversold_selective_mixed_require_no_ql=True,
+    )
+
+    workbook_summary, workbook_trades, workbook_holding, workbook_walk = _run_branch(
+        "workbook_preserving",
+        workbook_config,
+        indicator_df,
+        workbook_events,
+        first_date,
+        last_date,
+    )
+    alpha_summary, alpha_trades, alpha_holding, alpha_walk = _run_branch(
+        "alpha_first_selective_veto",
+        alpha_config,
+        indicator_df,
+        workbook_events,
+        first_date,
+        last_date,
+    )
+
+    summary_df = pd.DataFrame([workbook_summary, alpha_summary])
+    baseline_row = summary_df[summary_df["branch"] == "workbook_preserving"].iloc[0]
+    alpha_row = summary_df[summary_df["branch"] == "alpha_first_selective_veto"].iloc[0]
+
+    comparison = pd.DataFrame(
+        [
+            {
+                "metric": col,
+                "workbook_preserving": baseline_row[col],
+                "alpha_first_selective_veto": alpha_row[col],
+                "delta_alpha_minus_workbook": alpha_row[col] - baseline_row[col]
+                if isinstance(alpha_row[col], (int, float)) and isinstance(baseline_row[col], (int, float))
+                else None,
+            }
+            for col in [
+                "trades",
+                "win_rate",
+                "avg_return",
+                "median_return",
+                "profit_factor",
+                "real_buy_overlap",
+                "real_sell_overlap",
+                "short_00_05d_avg_return",
+                "short_06_10d_avg_return",
+            ]
+        ]
+    )
+
+    baseline_set = set(zip(workbook_trades["buy_date"], workbook_trades["sell_date"], workbook_trades["buy_reason"], workbook_trades["sell_reason"]))
+    alpha_set = set(zip(alpha_trades["buy_date"], alpha_trades["sell_date"], alpha_trades["buy_reason"], alpha_trades["sell_reason"]))
+    trade_diff_rows: list[dict[str, object]] = []
+    for row in sorted(baseline_set - alpha_set):
+        trade_diff_rows.append(
+            {
+                "change_type": "removed_from_alpha",
+                "buy_date": row[0],
+                "sell_date": row[1],
+                "buy_reason": row[2],
+                "sell_reason": row[3],
+            }
+        )
+    for row in sorted(alpha_set - baseline_set):
+        trade_diff_rows.append(
+            {
+                "change_type": "added_in_alpha",
+                "buy_date": row[0],
+                "sell_date": row[1],
+                "buy_reason": row[2],
+                "sell_reason": row[3],
+            }
+        )
+    trade_diff_df = pd.DataFrame(trade_diff_rows)
+
+    combined_holding = pd.concat([workbook_holding, alpha_holding], ignore_index=True)
+    combined_walk = pd.concat([workbook_walk, alpha_walk], ignore_index=True)
+
+    summary_df.to_csv(base_dir / "dragon_alpha_first_branch_summary.csv", index=False, encoding="utf-8-sig")
+    comparison.to_csv(base_dir / "dragon_alpha_first_branch_comparison.csv", index=False, encoding="utf-8-sig")
+    combined_holding.to_csv(base_dir / "dragon_alpha_first_branch_holding_buckets.csv", index=False, encoding="utf-8-sig")
+    combined_walk.to_csv(base_dir / "dragon_alpha_first_branch_walk_forward.csv", index=False, encoding="utf-8-sig")
+    trade_diff_df.to_csv(base_dir / "dragon_alpha_first_branch_trade_diff.csv", index=False, encoding="utf-8-sig")
+    (base_dir / "dragon_alpha_first_config_snapshot.json").write_text(
+        json.dumps(_config_snapshot(alpha_config), indent=2, ensure_ascii=False) + "\n",
+        encoding="utf-8",
+    )
+
+    def _wf_stats(df: pd.DataFrame, scheme: str) -> tuple[int, int, float]:
+        view = df[df["scheme"] == scheme]
+        positive = int((view["test_avg_return"] > 0).sum()) if not view.empty else 0
+        total = int(len(view))
+        avg_oos = float(view["test_avg_return"].mean()) if not view.empty else float("nan")
+        return positive, total, avg_oos
+
+    wb_anchor_pos, wb_anchor_total, wb_anchor_avg = _wf_stats(workbook_walk, "anchored_expanding")
+    af_anchor_pos, af_anchor_total, af_anchor_avg = _wf_stats(alpha_walk, "anchored_expanding")
+    wb_roll_pos, wb_roll_total, wb_roll_avg = _wf_stats(workbook_walk, "rolling_3y")
+    af_roll_pos, af_roll_total, af_roll_avg = _wf_stats(alpha_walk, "rolling_3y")
+
+    lines = [
+        "# Dragon Alpha-First Branch Report",
+        "",
+        "## Branches",
+        f"- Evaluation window: `{START_DATE}` to `{END_DATE}`.",
+        "- `workbook_preserving`: official formal baseline, preserves workbook structure as much as possible.",
+        "- `alpha_first_selective_veto`: research branch using the current best narrow deep-oversold veto package.",
+        "",
+        "## Headline Comparison",
+        f"- workbook_preserving: trades `{int(baseline_row['trades'])}`, avg_return `{_format_pct(float(baseline_row['avg_return']))}`, profit_factor `{_format_num(float(baseline_row['profit_factor']))}`, real BUY / SELL `{int(baseline_row['real_buy_overlap'])}/{int(baseline_row['real_sell_overlap'])}`",
+        f"- alpha_first_selective_veto: trades `{int(alpha_row['trades'])}`, avg_return `{_format_pct(float(alpha_row['avg_return']))}`, profit_factor `{_format_num(float(alpha_row['profit_factor']))}`, real BUY / SELL `{int(alpha_row['real_buy_overlap'])}/{int(alpha_row['real_sell_overlap'])}`",
+        "",
+        "## Short-Holding Impact",
+        f"- `00-05d` avg_return: workbook `{_format_pct(float(baseline_row['short_00_05d_avg_return']))}` vs alpha-first `{_format_pct(float(alpha_row['short_00_05d_avg_return']))}`",
+        f"- `06-10d` avg_return: workbook `{_format_pct(float(baseline_row['short_06_10d_avg_return']))}` vs alpha-first `{_format_pct(float(alpha_row['short_06_10d_avg_return']))}`",
+        "",
+        "## Walk-Forward Comparison",
+        f"- Anchored expanding: workbook positive `{wb_anchor_pos}/{wb_anchor_total}`, avg test return `{_format_pct(wb_anchor_avg)}`; alpha-first positive `{af_anchor_pos}/{af_anchor_total}`, avg test return `{_format_pct(af_anchor_avg)}`",
+        f"- Rolling 3Y: workbook positive `{wb_roll_pos}/{wb_roll_total}`, avg test return `{_format_pct(wb_roll_avg)}`; alpha-first positive `{af_roll_pos}/{af_roll_total}`, avg test return `{_format_pct(af_roll_avg)}`",
+        "",
+        "## Trade-Diff Summary",
+        f"- trades removed from alpha-first vs workbook: `{int((trade_diff_df['change_type'] == 'removed_from_alpha').sum())}`",
+        f"- trades added in alpha-first vs workbook: `{int((trade_diff_df['change_type'] == 'added_in_alpha').sum())}`",
+        "- Key removed deep-oversold trades are the narrow pathological subset identified in Track A, not the full weak-subtype family.",
+        "",
+        "## Governance",
+        "- Keep `workbook_preserving` as the official reconstruction baseline.",
+        "- Keep `alpha_first_selective_veto` as the leading performance-oriented research branch.",
+        "- Do not merge alpha-first veto rules back into the official baseline unless the objective explicitly changes from workbook preservation to alpha-first optimization.",
+        "",
+        "## Quant Judgment",
+        "- Stage 3 is complete once both baselines are explicitly separated and reproducible.",
+        "- The workbook-preserving baseline remains the authoritative reconstruction target.",
+        "- The alpha-first branch now has a concrete candidate baseline with better trade quality and better short-holding behavior, at the cost of expected workbook alignment loss.",
+        "- Future work should choose one branch explicitly before optimizing further; the main unresolved technical decision is governance, not missing analysis.",
+    ]
+
+    (base_dir / "dragon_alpha_first_baseline.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+
+if __name__ == "__main__":
+    main()

+ 222 - 0
research/dragon/v2/dragon_aux_sell_cycle_audit.py

@@ -0,0 +1,222 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pandas as pd
+
+
+def _load_csv(base_dir: Path, name: str) -> pd.DataFrame:
+    return pd.read_csv(base_dir / name, encoding="utf-8-sig")
+
+
+def _build_real_trade_lookup(strategy_events: pd.DataFrame, side: str) -> pd.DataFrame:
+    real = strategy_events[(strategy_events["layer"] == "real_trade") & (strategy_events["side"] == side)].copy()
+    real["dt"] = pd.to_datetime(real["date"])
+    real = real.sort_values("dt")
+    return real[["dt", "date", "reason", "c1"]]
+
+
+def _annotate_sell_cluster(row: pd.Series) -> str:
+    reason = str(row["reason"])
+    a1 = float(row["a1"])
+    b1 = float(row["b1"])
+    c1 = float(row["c1"])
+    days_from_prev_real_sell = row["days_from_prev_real_sell"]
+    ql_sell = bool(row["ql_sell"])
+    kdj_sell = bool(row["kdj_sell"])
+    if reason.endswith("state_crash_followthrough"):
+        return "crash_followthrough"
+    if pd.notna(days_from_prev_real_sell) and 0 < float(days_from_prev_real_sell) <= 10:
+        return "post_exit_confirmation"
+    if c1 > 80 and (ql_sell or kdj_sell):
+        return "high_zone_warning"
+    if b1 < -0.12 and (ql_sell or kdj_sell):
+        return "strong_break_warning"
+    if ql_sell and c1 < 20 and a1 < -0.02:
+        return "deep_oversold_followthrough"
+    return "other_bearish_followthrough"
+
+
+def _attach_cycle_context(aux_sell: pd.DataFrame, real_buy: pd.DataFrame, real_sell: pd.DataFrame) -> pd.DataFrame:
+    aux_sell = aux_sell.copy()
+    aux_sell["dt"] = pd.to_datetime(aux_sell["date"])
+
+    sell_dates = real_sell["dt"].tolist()
+    buy_dates = real_buy["dt"].tolist()
+
+    prev_real_sell_dates: list[str | None] = []
+    days_from_prev_real_sell: list[int | None] = []
+    next_real_buy_dates: list[str | None] = []
+    days_to_next_real_buy: list[int | None] = []
+
+    for dt in aux_sell["dt"]:
+        earlier_sells = [x for x in sell_dates if x < dt]
+        later_buys = [x for x in buy_dates if x > dt]
+
+        if earlier_sells:
+            prev_sell = earlier_sells[-1]
+            prev_real_sell_dates.append(prev_sell.date().isoformat())
+            days_from_prev_real_sell.append((dt - prev_sell).days)
+        else:
+            prev_real_sell_dates.append(None)
+            days_from_prev_real_sell.append(None)
+
+        if later_buys:
+            next_buy = later_buys[0]
+            next_real_buy_dates.append(next_buy.date().isoformat())
+            days_to_next_real_buy.append((next_buy - dt).days)
+        else:
+            next_real_buy_dates.append(None)
+            days_to_next_real_buy.append(None)
+
+    aux_sell["prev_real_sell_date"] = prev_real_sell_dates
+    aux_sell["days_from_prev_real_sell"] = days_from_prev_real_sell
+    aux_sell["next_real_buy_date"] = next_real_buy_dates
+    aux_sell["days_to_next_real_buy"] = days_to_next_real_buy
+    aux_sell["cycle_id"] = aux_sell.apply(
+        lambda row: f"{row['prev_real_sell_date']} -> {row['next_real_buy_date']}"
+        if pd.notna(row["prev_real_sell_date"]) and pd.notna(row["next_real_buy_date"])
+        else "unbounded_cycle",
+        axis=1,
+    )
+    return aux_sell
+
+
+def main() -> None:
+    base_dir = Path(__file__).resolve().parent
+    strategy_events = _load_csv(base_dir, "dragon_strategy_events.csv")
+    workbook_layers = _load_csv(base_dir, "dragon_workbook_layers.csv")
+
+    real_buy = _build_real_trade_lookup(strategy_events, "BUY")
+    real_sell = _build_real_trade_lookup(strategy_events, "SELL")
+
+    aux_sell = strategy_events[
+        (strategy_events["layer"] == "aux_signal") & (strategy_events["side"] == "SELL")
+    ].copy()
+    aux_sell = aux_sell.sort_values("date").reset_index(drop=True)
+    aux_sell = _attach_cycle_context(aux_sell, real_buy, real_sell)
+    aux_sell["cluster"] = aux_sell.apply(_annotate_sell_cluster, axis=1)
+
+    workbook_aux_sell = workbook_layers[
+        (workbook_layers["layer"] == "aux_signal") & (workbook_layers["side"] == "SELL")
+    ][["date", "signal_reason", "note"]].copy()
+    workbook_aux_sell = workbook_aux_sell.rename(
+        columns={"signal_reason": "workbook_signal_reason", "note": "workbook_note"}
+    )
+    aux_sell = aux_sell.merge(workbook_aux_sell, on="date", how="left")
+    aux_sell["matched_workbook_aux"] = aux_sell["workbook_signal_reason"].notna()
+
+    cycle_summary = (
+        aux_sell.groupby(["cycle_id", "prev_real_sell_date", "next_real_buy_date"])
+        .agg(
+            aux_sell_count=("date", "count"),
+            matched_count=("matched_workbook_aux", "sum"),
+            post_exit_confirmation_count=("cluster", lambda s: int((s == "post_exit_confirmation").sum())),
+            post_exit_confirmation_matched=(
+                "matched_workbook_aux",
+                lambda s: int(
+                    (
+                        aux_sell.loc[s.index, "matched_workbook_aux"]
+                        & (aux_sell.loc[s.index, "cluster"] == "post_exit_confirmation")
+                    ).sum()
+                ),
+            ),
+            unique_clusters=("cluster", lambda s: " | ".join(sorted(set(s)))),
+            cycle_dates=("date", lambda s: " | ".join(s)),
+        )
+        .reset_index()
+    )
+
+    matched_dates: list[str] = []
+    unmatched_post_exit_dates: list[str] = []
+    for _, row in cycle_summary.iterrows():
+        cycle_rows = aux_sell[aux_sell["cycle_id"] == row["cycle_id"]]
+        matched_cycle_dates = cycle_rows[cycle_rows["matched_workbook_aux"]]["date"].tolist()
+        unmatched_post_exit_cycle_dates = cycle_rows[
+            (cycle_rows["cluster"] == "post_exit_confirmation") & (~cycle_rows["matched_workbook_aux"])
+        ]["date"].tolist()
+        matched_dates.append(" | ".join(matched_cycle_dates))
+        unmatched_post_exit_dates.append(" | ".join(unmatched_post_exit_cycle_dates))
+
+    cycle_summary["matched_dates"] = matched_dates
+    cycle_summary["unmatched_post_exit_dates"] = unmatched_post_exit_dates
+    cycle_summary["compression_candidate"] = cycle_summary.apply(
+        lambda row: bool(row["post_exit_confirmation_count"] >= 2 and row["post_exit_confirmation_matched"] == 0),
+        axis=1,
+    )
+
+    detail_cols = [
+        "date",
+        "reason",
+        "cluster",
+        "matched_workbook_aux",
+        "prev_real_sell_date",
+        "days_from_prev_real_sell",
+        "next_real_buy_date",
+        "days_to_next_real_buy",
+        "cycle_id",
+        "a1",
+        "b1",
+        "c1",
+        "kdj_sell",
+        "ql_sell",
+        "workbook_signal_reason",
+        "workbook_note",
+    ]
+    aux_sell[detail_cols].to_csv(base_dir / "dragon_aux_sell_cycle_audit.csv", index=False, encoding="utf-8-sig")
+    cycle_summary.sort_values(
+        ["post_exit_confirmation_count", "aux_sell_count", "cycle_id"], ascending=[False, False, True]
+    ).to_csv(base_dir / "dragon_aux_sell_cycle_summary.csv", index=False, encoding="utf-8-sig")
+
+    lines = [
+        "# Dragon Aux Sell Cycle Review",
+        "",
+        f"- Strategy aux SELL signals: `{len(aux_sell)}`",
+        f"- Distinct sell cycles: `{cycle_summary['cycle_id'].nunique()}`",
+        f"- Cycles with repeated `post_exit_confirmation` >= 2: `{int((cycle_summary['post_exit_confirmation_count'] >= 2).sum())}`",
+        f"- Conservative compression candidates (repeated post-exit confirmations with zero workbook anchor): `{int(cycle_summary['compression_candidate'].sum())}`",
+        "",
+        "## Repeated Post-Exit Confirmation Cycles",
+    ]
+
+    repeated = cycle_summary[cycle_summary["post_exit_confirmation_count"] >= 2].sort_values(
+        ["compression_candidate", "post_exit_confirmation_count", "aux_sell_count"],
+        ascending=[False, False, False],
+    )
+    for _, row in repeated.iterrows():
+        lines.append(
+            f"- `{row['cycle_id']}` | aux SELL `{int(row['aux_sell_count'])}` | "
+            f"`post_exit_confirmation={int(row['post_exit_confirmation_count'])}` | "
+            f"matched `{int(row['matched_count'])}` | "
+            f"candidate `{bool(row['compression_candidate'])}`"
+        )
+        if row["matched_dates"]:
+            lines.append(f"  matched dates: `{row['matched_dates']}`")
+        if row["unmatched_post_exit_dates"]:
+            lines.append(f"  unmatched post-exit dates: `{row['unmatched_post_exit_dates']}`")
+
+    lines.extend(["", "## Candidate Cycles To Review First"])
+    candidates = repeated[repeated["compression_candidate"]].head(12)
+    if candidates.empty:
+        lines.append("- None. Current repeated cycles all contain workbook anchors or are singletons.")
+    else:
+        for _, row in candidates.iterrows():
+            lines.append(
+                f"- `{row['cycle_id']}` -> unmatched repeated post-exit dates `{row['unmatched_post_exit_dates']}`"
+            )
+
+    lines.extend(["", "## Protected Cycles With Workbook Anchors"])
+    protected = repeated[~repeated["compression_candidate"]].head(12)
+    if protected.empty:
+        lines.append("- None.")
+    else:
+        for _, row in protected.iterrows():
+            lines.append(
+                f"- `{row['cycle_id']}` -> matched dates `{row['matched_dates']}`; unmatched post-exit dates `{row['unmatched_post_exit_dates']}`"
+            )
+
+    (base_dir / "dragon_aux_sell_cycle_review.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+
+if __name__ == "__main__":
+    main()

+ 184 - 0
research/dragon/v2/dragon_aux_signal_audit.py

@@ -0,0 +1,184 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pandas as pd
+
+
+def _load_csv(base_dir: Path, name: str) -> pd.DataFrame:
+    return pd.read_csv(base_dir / name, encoding="utf-8-sig")
+
+
+def _annotate_buy_cluster(row: pd.Series) -> str:
+    a1 = float(row["a1"])
+    b1 = float(row["b1"])
+    c1 = float(row["c1"])
+    ql_buy = bool(row["ql_buy"])
+    if ql_buy and a1 > 0.03 and b1 > 0.15:
+        return "strong_dual_gold_reconfirm"
+    if b1 > 0.24 and c1 > 45:
+        return "early_strength_reconfirm"
+    if a1 > 0.07 and c1 > 90:
+        return "super_hot_trend_reconfirm"
+    return "other_hold_reconfirm"
+
+
+def _annotate_sell_cluster(row: pd.Series) -> str:
+    reason = str(row["reason"])
+    a1 = float(row["a1"])
+    b1 = float(row["b1"])
+    c1 = float(row["c1"])
+    days_from_prev_real_sell = row["days_from_prev_real_sell"]
+    ql_sell = bool(row["ql_sell"])
+    kdj_sell = bool(row["kdj_sell"])
+    if reason.endswith("state_crash_followthrough"):
+        return "crash_followthrough"
+    if pd.notna(days_from_prev_real_sell) and 0 < float(days_from_prev_real_sell) <= 10:
+        return "post_exit_confirmation"
+    if c1 > 80 and (ql_sell or kdj_sell):
+        return "high_zone_warning"
+    if b1 < -0.12 and (ql_sell or kdj_sell):
+        return "strong_break_warning"
+    if ql_sell and c1 < 20 and a1 < -0.02:
+        return "deep_oversold_followthrough"
+    return "other_bearish_followthrough"
+
+
+def _build_real_trade_lookup(strategy_events: pd.DataFrame, side: str) -> pd.DataFrame:
+    real = strategy_events[(strategy_events["layer"] == "real_trade") & (strategy_events["side"] == side)].copy()
+    real["dt"] = pd.to_datetime(real["date"])
+    real = real.sort_values("dt")
+    return real[["dt", "date", "reason", "c1"]]
+
+
+def _attach_nearest_real_context(aux: pd.DataFrame, real_buy: pd.DataFrame, real_sell: pd.DataFrame) -> pd.DataFrame:
+    aux = aux.copy()
+    aux["dt"] = pd.to_datetime(aux["date"])
+
+    prev_buy_date: list[str | None] = []
+    days_from_prev_real_buy: list[int | None] = []
+    prev_sell_date: list[str | None] = []
+    days_from_prev_real_sell: list[int | None] = []
+
+    buy_dates = real_buy["dt"].tolist()
+    sell_dates = real_sell["dt"].tolist()
+
+    for dt in aux["dt"]:
+        earlier_buys = [x for x in buy_dates if x < dt]
+        earlier_sells = [x for x in sell_dates if x < dt]
+
+        if earlier_buys:
+            prev_buy = earlier_buys[-1]
+            prev_buy_date.append(prev_buy.date().isoformat())
+            days_from_prev_real_buy.append((dt - prev_buy).days)
+        else:
+            prev_buy_date.append(None)
+            days_from_prev_real_buy.append(None)
+
+        if earlier_sells:
+            prev_sell = earlier_sells[-1]
+            prev_sell_date.append(prev_sell.date().isoformat())
+            days_from_prev_real_sell.append((dt - prev_sell).days)
+        else:
+            prev_sell_date.append(None)
+            days_from_prev_real_sell.append(None)
+
+    aux["prev_real_buy_date"] = prev_buy_date
+    aux["days_from_prev_real_buy"] = days_from_prev_real_buy
+    aux["prev_real_sell_date"] = prev_sell_date
+    aux["days_from_prev_real_sell"] = days_from_prev_real_sell
+    return aux
+
+
+def main() -> None:
+    base_dir = Path(__file__).resolve().parent
+    strategy_events = _load_csv(base_dir, "dragon_strategy_events.csv")
+    workbook_layers = _load_csv(base_dir, "dragon_workbook_layers.csv")
+
+    aux = strategy_events[strategy_events["layer"] == "aux_signal"].copy()
+    aux = aux.sort_values(["side", "date"]).reset_index(drop=True)
+
+    workbook_aux = workbook_layers[workbook_layers["layer"] == "aux_signal"][
+        ["date", "side", "signal_reason", "note"]
+    ].copy()
+    workbook_aux = workbook_aux.rename(columns={"signal_reason": "workbook_signal_reason", "note": "workbook_note"})
+
+    real_buy = _build_real_trade_lookup(strategy_events, "BUY")
+    real_sell = _build_real_trade_lookup(strategy_events, "SELL")
+    aux = _attach_nearest_real_context(aux, real_buy, real_sell)
+    aux = aux.merge(workbook_aux, on=["date", "side"], how="left")
+    aux["matched_workbook_aux"] = aux["workbook_signal_reason"].notna()
+
+    buy_mask = aux["side"] == "BUY"
+    sell_mask = aux["side"] == "SELL"
+    aux.loc[buy_mask, "cluster"] = aux.loc[buy_mask].apply(_annotate_buy_cluster, axis=1)
+    aux.loc[sell_mask, "cluster"] = aux.loc[sell_mask].apply(_annotate_sell_cluster, axis=1)
+
+    audit_cols = [
+        "date",
+        "side",
+        "reason",
+        "cluster",
+        "matched_workbook_aux",
+        "prev_real_buy_date",
+        "days_from_prev_real_buy",
+        "prev_real_sell_date",
+        "days_from_prev_real_sell",
+        "a1",
+        "b1",
+        "c1",
+        "kdj_buy",
+        "kdj_sell",
+        "ql_buy",
+        "ql_sell",
+        "workbook_signal_reason",
+        "workbook_note",
+    ]
+    aux[audit_cols].to_csv(base_dir / "dragon_aux_signal_audit.csv", index=False, encoding="utf-8-sig")
+
+    lines = [
+        "# Dragon Aux Signal Audit",
+        "",
+        f"- Strategy aux signals: `{len(aux)}`",
+        f"- Aux BUY: `{int(buy_mask.sum())}`",
+        f"- Aux SELL: `{int(sell_mask.sum())}`",
+        f"- Workbook overlap: `{int(aux['matched_workbook_aux'].sum())}`",
+        "",
+        "## Cluster Summary",
+    ]
+
+    summary = (
+        aux.groupby(["side", "cluster", "matched_workbook_aux"])
+        .size()
+        .reset_index(name="count")
+        .sort_values(["side", "cluster", "matched_workbook_aux"])
+    )
+    for _, row in summary.iterrows():
+        lines.append(
+            f"- {row['side']} / {row['cluster']} / matched `{bool(row['matched_workbook_aux'])}`: `{int(row['count'])}`"
+        )
+
+    lines.extend(["", "## Workbook-Matched Aux Rows"])
+    matched_rows = aux[aux["matched_workbook_aux"]].copy()
+    for _, row in matched_rows.iterrows():
+        lines.append(
+            f"- {row['date']} {row['side']} `{row['reason']}` -> `{row['cluster']}` | a1 `{row['a1']:.4f}` b1 `{row['b1']:.4f}` c1 `{row['c1']:.2f}`"
+        )
+
+    lines.extend(["", "## Top Unmatched Buckets"])
+    unmatched_summary = (
+        aux[~aux["matched_workbook_aux"]]
+        .groupby(["side", "cluster"])
+        .size()
+        .reset_index(name="count")
+        .sort_values("count", ascending=False)
+        .head(12)
+    )
+    for _, row in unmatched_summary.iterrows():
+        lines.append(f"- {row['side']} / {row['cluster']}: `{int(row['count'])}`")
+
+    (base_dir / "dragon_aux_signal_review.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+
+if __name__ == "__main__":
+    main()

+ 112 - 0
research/dragon/v2/dragon_backtest.py

@@ -0,0 +1,112 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pandas as pd
+
+from dragon_indicators import DragonIndicatorConfig, DragonIndicatorEngine
+from dragon_strategy import DragonRuleEngine
+from dragon_workbook import DragonWorkbook
+
+
+def _find_workbook(base_dir: Path) -> Path:
+    matches = sorted(base_dir.glob("*.xlsx"))
+    if not matches:
+        raise FileNotFoundError(f"No workbook found in {base_dir}")
+    return matches[0]
+
+
+def _event_match_report(workbook_events: pd.DataFrame, strategy_events: pd.DataFrame, side: str, layer: str) -> dict[str, object]:
+    wb = set(workbook_events[(workbook_events["side"] == side) & (workbook_events["layer"] == layer)]["date"])
+    st = set(strategy_events[(strategy_events["side"] == side) & (strategy_events["layer"] == layer)]["date"])
+    hit = wb & st
+    return {
+        "side": side,
+        "layer": layer,
+        "workbook": len(wb),
+        "strategy": len(st),
+        "overlap": len(hit),
+        "missing_from_strategy": sorted(wb - st),
+        "extra_in_strategy": sorted(st - wb),
+    }
+
+
+def main() -> None:
+    base_dir = Path(__file__).resolve().parent
+    workbook_path = _find_workbook(base_dir)
+    workbook = DragonWorkbook(workbook_path)
+    workbook_events = pd.DataFrame(
+        [
+            {
+                "date": event.date.isoformat(),
+                "side": event.side,
+                "layer": event.layer,
+                "signal_reason": event.signal_reason,
+                "note": event.note,
+            }
+            for event in workbook.split_layers()
+        ]
+    )
+
+    engine = DragonIndicatorEngine(DragonIndicatorConfig(start_date="2015-01-01", end_date="2026-01-31"))
+    indicator_df = engine.compute(engine.fetch_daily_data())
+
+    strategy = DragonRuleEngine()
+    strategy_events, strategy_trades = strategy.run(indicator_df)
+    first_workbook_date = pd.to_datetime(workbook_events["date"]).min().date().isoformat()
+    last_workbook_date = pd.to_datetime(workbook_events["date"]).max().date().isoformat()
+    strategy_events = strategy_events[
+        (strategy_events["date"] >= first_workbook_date) & (strategy_events["date"] <= last_workbook_date)
+    ].copy()
+    strategy_trades = strategy_trades[
+        (strategy_trades["buy_date"] >= first_workbook_date)
+        & (strategy_trades["buy_date"] <= last_workbook_date)
+        & (strategy_trades["sell_date"] >= first_workbook_date)
+        & (strategy_trades["sell_date"] <= last_workbook_date)
+    ].copy()
+    strategy_events.to_csv(base_dir / "dragon_strategy_events.csv", index=False, encoding="utf-8-sig")
+    strategy_trades.to_csv(base_dir / "dragon_strategy_trades.csv", index=False, encoding="utf-8-sig")
+
+    comparisons = [
+        _event_match_report(workbook_events, strategy_events, side="BUY", layer="real_trade"),
+        _event_match_report(workbook_events, strategy_events, side="SELL", layer="real_trade"),
+        _event_match_report(workbook_events, strategy_events, side="BUY", layer="aux_signal"),
+        _event_match_report(workbook_events, strategy_events, side="SELL", layer="aux_signal"),
+    ]
+
+    lines = [
+        "# Dragon Baseline Backtest",
+        "",
+        f"- Source workbook: `{workbook_path.name}`",
+        f"- Strategy events: `{len(strategy_events)}`",
+        f"- Strategy trades: `{len(strategy_trades)}`",
+        "",
+        "## Event Fit",
+    ]
+    for item in comparisons:
+        lines.append(
+            f"- {item['layer']} {item['side']}: workbook `{item['workbook']}`, strategy `{item['strategy']}`, overlap `{item['overlap']}`"
+        )
+        lines.append(f"- missing_from_strategy: `{item['missing_from_strategy'][:20]}`")
+        lines.append(f"- extra_in_strategy: `{item['extra_in_strategy'][:20]}`")
+
+    if not strategy_trades.empty:
+        wins = (strategy_trades["return_pct"] > 0).sum()
+        avg_ret = strategy_trades["return_pct"].mean()
+        med_ret = strategy_trades["return_pct"].median()
+        lines.extend(
+            [
+                "",
+                "## Strategy Trade Stats",
+                f"- trades: `{len(strategy_trades)}`",
+                f"- win_rate: `{wins}/{len(strategy_trades)} = {wins / len(strategy_trades):.2%}`",
+                f"- avg_return: `{avg_ret:.2%}`",
+                f"- median_return: `{med_ret:.2%}`",
+            ]
+        )
+
+    (base_dir / "dragon_strategy_fit.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+
+if __name__ == "__main__":
+    main()

+ 38 - 0
research/dragon/v2/dragon_branch_configs.py

@@ -0,0 +1,38 @@
+from __future__ import annotations
+
+from dragon_strategy_config import StrategyConfig
+
+
+def workbook_preserving_config() -> StrategyConfig:
+    return StrategyConfig()
+
+
+def alpha_first_selective_veto_config() -> StrategyConfig:
+    return workbook_preserving_config().with_updates(
+        deep_oversold_selective_positive_b1_c1_max=15.3,
+        deep_oversold_selective_shallow_c1_min=12.0,
+        deep_oversold_selective_shallow_b1_min=-0.025,
+        deep_oversold_selective_mixed_c1_max=10.2,
+        deep_oversold_selective_mixed_require_no_ql=True,
+    )
+
+
+def alpha_first_glued_selective_veto_config() -> StrategyConfig:
+    return alpha_first_selective_veto_config().with_updates(
+        glued_selective_hot_c1_min=40.0,
+        glued_selective_hot_b1_min=0.10,
+        glued_selective_low_c1_min=23.0,
+        glued_selective_low_c1_max=28.0,
+        glued_selective_low_b1_max=0.02,
+    )
+
+
+def alpha_first_glued_refined_hot_cap_config() -> StrategyConfig:
+    return alpha_first_selective_veto_config().with_updates(
+        glued_selective_hot_c1_min=40.0,
+        glued_selective_hot_c1_max=75.0,
+        glued_selective_hot_b1_min=0.10,
+        glued_selective_low_c1_min=23.0,
+        glued_selective_low_c1_max=28.0,
+        glued_selective_low_b1_max=0.02,
+    )

+ 135 - 0
research/dragon/v2/dragon_cost_stress_test.py

@@ -0,0 +1,135 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pandas as pd
+
+from dragon_branch_configs import (
+    alpha_first_glued_refined_hot_cap_config,
+    alpha_first_selective_veto_config,
+    workbook_preserving_config,
+)
+from dragon_shared import END_DATE, START_DATE, evaluation_years, profit_factor
+from dragon_strategy import DragonRuleEngine
+
+
+def _load_indicator_snapshot(base_dir: Path) -> pd.DataFrame:
+    df = pd.read_csv(base_dir / "dragon_indicator_snapshot.csv", encoding="utf-8-sig")
+    df["date"] = pd.to_datetime(df["date"])
+    return df.set_index("date", drop=False)
+
+
+def _holding_bucket(days: int) -> str:
+    if days <= 5:
+        return "00-05d"
+    if days <= 10:
+        return "06-10d"
+    if days <= 20:
+        return "11-20d"
+    if days <= 40:
+        return "21-40d"
+    return "41d+"
+
+
+def _run_branch(indicator_df: pd.DataFrame, config) -> pd.DataFrame:
+    engine = DragonRuleEngine(config=config)
+    _, trades = engine.run(indicator_df)
+    trades = trades[
+        (trades["buy_date"] >= START_DATE)
+        & (trades["buy_date"] <= END_DATE)
+        & (trades["sell_date"] >= START_DATE)
+        & (trades["sell_date"] <= END_DATE)
+    ].copy()
+    trades["holding_bucket"] = trades["holding_days"].astype(int).map(_holding_bucket)
+    return trades
+
+
+def _apply_costs(trades: pd.DataFrame, per_side_bps: float) -> pd.DataFrame:
+    out = trades.copy()
+    cost = per_side_bps / 10000.0
+    out["net_return_pct"] = (
+        (out["sell_price"].astype(float) * (1.0 - cost)) / (out["buy_price"].astype(float) * (1.0 + cost))
+    ) - 1.0
+    return out
+
+
+def _summarize(branch: str, per_side_bps: float, trades: pd.DataFrame) -> dict[str, object]:
+    returns = trades["net_return_pct"].astype(float)
+    compounded = float((1.0 + returns).prod() - 1.0) if not trades.empty else float("nan")
+    years = evaluation_years(START_DATE, END_DATE)
+    cagr = float((1.0 + compounded) ** (1.0 / years) - 1.0) if not trades.empty else float("nan")
+    return {
+        "branch": branch,
+        "per_side_bps": per_side_bps,
+        "trades": int(len(trades)),
+        "win_rate": float((returns > 0).mean()) if not trades.empty else float("nan"),
+        "avg_return": float(returns.mean()) if not trades.empty else float("nan"),
+        "median_return": float(returns.median()) if not trades.empty else float("nan"),
+        "profit_factor": profit_factor(returns) if not trades.empty else float("nan"),
+        "compounded_return": compounded,
+        "cagr": cagr,
+        "short_00_05d_avg_return": float(trades[trades["holding_bucket"] == "00-05d"]["net_return_pct"].mean()),
+        "short_06_10d_avg_return": float(trades[trades["holding_bucket"] == "06-10d"]["net_return_pct"].mean()),
+    }
+
+
+def main() -> None:
+    base_dir = Path(__file__).resolve().parent
+    indicator_df = _load_indicator_snapshot(base_dir)
+
+    branches = {
+        "workbook_preserving": workbook_preserving_config(),
+        "alpha_first_selective_veto": alpha_first_selective_veto_config(),
+        "alpha_first_glued_refined_hot_cap": alpha_first_glued_refined_hot_cap_config(),
+    }
+    cost_levels = [0.0, 5.0, 10.0, 20.0]
+
+    gross_trades = {name: _run_branch(indicator_df, cfg) for name, cfg in branches.items()}
+
+    rows: list[dict[str, object]] = []
+    for branch, trades in gross_trades.items():
+        for bps in cost_levels:
+            net_trades = _apply_costs(trades, bps)
+            rows.append(_summarize(branch, bps, net_trades))
+
+    result = pd.DataFrame(rows)
+    result.to_csv(base_dir / "dragon_cost_stress_test.csv", index=False, encoding="utf-8-sig")
+
+    lines = [
+        "# Dragon Cost Stress Test",
+        "",
+        f"- Evaluation window: `{START_DATE}` to `{END_DATE}`.",
+        "- Cost convention: symmetric per-side cost on entry and exit.",
+        "",
+        "## Summary",
+    ]
+
+    for branch in branches:
+        subset = result[result["branch"] == branch].sort_values("per_side_bps")
+        lines.append(f"### {branch}")
+        for _, row in subset.iterrows():
+            lines.append(
+                f"- `{int(row['per_side_bps'])} bps/side`: CAGR `{row['cagr']:.2%}`, compounded `{row['compounded_return']:.2%}`, "
+                f"avg_return `{row['avg_return']:.2%}`, PF `{row['profit_factor']:.2f}`, "
+                f"`00-05d` `{row['short_00_05d_avg_return']:.2%}`, `06-10d` `{row['short_06_10d_avg_return']:.2%}`"
+            )
+        lines.append("")
+
+    zero = result[result["per_side_bps"] == 0.0].set_index("branch")
+    twenty = result[result["per_side_bps"] == 20.0].set_index("branch")
+    lines.extend(
+        [
+            "## Quant Judgment",
+            f"- At `20 bps/side`, current alpha branch CAGR = `{twenty.loc['alpha_first_selective_veto', 'cagr']:.2%}`.",
+            f"- At `20 bps/side`, refined candidate CAGR = `{twenty.loc['alpha_first_glued_refined_hot_cap', 'cagr']:.2%}`.",
+            f"- CAGR delta refined minus current alpha at `0 bps/side` = `{(zero.loc['alpha_first_glued_refined_hot_cap', 'cagr'] - zero.loc['alpha_first_selective_veto', 'cagr']):.2%}`.",
+            f"- CAGR delta refined minus current alpha at `20 bps/side` = `{(twenty.loc['alpha_first_glued_refined_hot_cap', 'cagr'] - twenty.loc['alpha_first_selective_veto', 'cagr']):.2%}`.",
+            "- If the refined branch remains ahead under cost pressure, its edge is not just a no-cost backtest artifact.",
+        ]
+    )
+
+    (base_dir / "dragon_cost_stress_test.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+
+if __name__ == "__main__":
+    main()

+ 428 - 0
research/dragon/v2/dragon_daily_signal_pipeline.py

@@ -0,0 +1,428 @@
+from __future__ import annotations
+
+from dataclasses import asdict
+from datetime import date
+import json
+from pathlib import Path
+
+import pandas as pd
+
+from dragon_branch_configs import (
+    alpha_first_glued_refined_hot_cap_config,
+    alpha_first_selective_veto_config,
+    workbook_preserving_config,
+)
+from dragon_execution_common import apply_execution_model as _apply_execution_model, risk_cluster as _risk_cluster, summary as _summary
+from dragon_indicators import DragonIndicatorConfig, DragonIndicatorEngine
+from dragon_shared import START_DATE, format_num as _format_num, format_pct as _format_pct
+from dragon_strategy import DragonRuleEngine
+
+BRANCH_CONFIGS = [
+    ("workbook_preserving", workbook_preserving_config),
+    ("alpha_first_selective_veto", alpha_first_selective_veto_config),
+    ("alpha_first_glued_refined_hot_cap", alpha_first_glued_refined_hot_cap_config),
+]
+
+
+def _entry_family(reason: str) -> str:
+    return str(reason).split(":", 1)[0]
+
+
+def _load_monitor_template(base_dir: Path) -> pd.DataFrame:
+    return pd.read_csv(base_dir / "dragon_strategy_monitoring_template.csv", encoding="utf-8-sig")
+
+
+def _load_removed_trade_over_removal_count(base_dir: Path) -> float:
+    path = base_dir / "dragon_glued_refined_removed_trade_attribution.csv"
+    if not path.exists():
+        return float("nan")
+    df = pd.read_csv(path, encoding="utf-8-sig")
+    if "recommendation" not in df.columns:
+        return float("nan")
+    return float((df["recommendation"].astype(str) == "OVER_REMOVAL").sum())
+
+
+def _load_local_sensitivity_robust_case_count(base_dir: Path) -> float:
+    path = base_dir / "dragon_glued_refined_sensitivity.csv"
+    if not path.exists():
+        return float("nan")
+    df = pd.read_csv(path, encoding="utf-8-sig")
+    if df.empty or "label" not in df.columns:
+        return float("nan")
+    candidate = df[df["label"] == "refined_candidate_baseline"].copy()
+    if candidate.empty:
+        return float("nan")
+    candidate_row = candidate.iloc[0]
+    neighborhood = df[~df["label"].isin(["current_alpha_control", "refined_candidate_baseline"])].copy()
+    if neighborhood.empty:
+        return float("nan")
+    robust = neighborhood[
+        (neighborhood["avg_return"] >= float(candidate_row["avg_return"]) - 0.0015)
+        & (neighborhood["profit_factor"] >= float(candidate_row["profit_factor"]) - 0.20)
+        & (neighborhood["real_buy_overlap"] >= int(candidate_row["real_buy_overlap"]) - 1)
+        & (neighborhood["real_sell_overlap"] >= int(candidate_row["real_sell_overlap"]) - 1)
+    ]
+    return float(len(robust))
+
+
+def _infer_initial_capital(base_dir: Path) -> float:
+    workbook_trades = pd.read_csv(base_dir / "true_trades.csv", encoding="utf-8-sig")
+    if workbook_trades.empty:
+        return 55450.0
+    first = workbook_trades.iloc[0]
+    return float(first["ending_capital"]) / (1.0 + float(first["return_pct"]))
+
+
+def _build_branch_state(branch: str, config, indicators: pd.DataFrame) -> tuple[pd.DataFrame, pd.DataFrame, dict[str, object]]:
+    engine = DragonRuleEngine(config=config)
+    events, trades = engine.run(indicators.set_index("date", drop=False))
+    latest_date = indicators["date"].max().date().isoformat()
+    latest_row = indicators.iloc[-1]
+    real_events = events[events["layer"] == "real_trade"].copy()
+    latest_events = events[events["date"] == latest_date].copy()
+    last_real = real_events.iloc[-1] if not real_events.empty else None
+    in_position = bool(engine.context.in_position)
+
+    open_trade = None
+    if in_position and engine.context.entry_date is not None:
+        open_trade = {
+            "entry_date": engine.context.entry_date.isoformat(),
+            "entry_price": float(engine.context.entry_price) if engine.context.entry_price is not None else None,
+            "entry_reason": engine.context.entry_reason,
+            "current_return_pct": float(latest_row["close"]) / float(engine.context.entry_price) - 1.0
+            if engine.context.entry_price
+            else None,
+            "holding_days": (latest_row["date"].date() - engine.context.entry_date).days,
+        }
+
+    state = {
+        "branch": branch,
+        "as_of_date": latest_date,
+        "latest_close": float(latest_row["close"]),
+        "latest_a1": float(latest_row["a1"]),
+        "latest_b1": float(latest_row["b1"]),
+        "latest_c1": float(latest_row["c1"]),
+        "latest_kdj_buy": bool(latest_row["kdj_buy"]),
+        "latest_kdj_sell": bool(latest_row["kdj_sell"]),
+        "latest_ql_buy": bool(latest_row["ql_buy"]),
+        "latest_ql_sell": bool(latest_row["ql_sell"]),
+        "latest_real_event_date": "" if last_real is None else str(last_real["date"]),
+        "latest_real_event_side": "" if last_real is None else str(last_real["side"]),
+        "latest_real_event_reason": "" if last_real is None else str(last_real["reason"]),
+        "events_today_count": int(len(latest_events)),
+        "events_today": " | ".join(
+            f"{row['side']}:{row['layer']}:{row['reason']}" for _, row in latest_events.iterrows()
+        ),
+        "in_position": in_position,
+        "open_entry_date": "" if open_trade is None else str(open_trade["entry_date"]),
+        "open_entry_reason": "" if open_trade is None else str(open_trade["entry_reason"]),
+        "open_entry_price": float("nan") if open_trade is None else float(open_trade["entry_price"]),
+        "open_holding_days": float("nan") if open_trade is None else int(open_trade["holding_days"]),
+        "open_return_pct": float("nan") if open_trade is None else float(open_trade["current_return_pct"]),
+    }
+    return events, trades, state
+
+
+def _build_historical_trade_details(branch: str, trades: pd.DataFrame, initial_capital: float) -> pd.DataFrame:
+    trades = trades.copy()
+    trades = trades[trades["buy_date"] >= START_DATE].copy()
+    if trades.empty:
+        return pd.DataFrame(
+            columns=[
+                "branch",
+                "trade_no",
+                "buy_date",
+                "buy_price",
+                "buy_reason",
+                "sell_date",
+                "sell_price",
+                "sell_reason",
+                "holding_days",
+                "return_pct",
+                "capital_before",
+                "pnl_amount",
+                "capital_after",
+            ]
+        )
+
+    capital_before: list[float] = []
+    pnl_amount: list[float] = []
+    capital_after: list[float] = []
+    running_capital = float(initial_capital)
+    for _, row in trades.iterrows():
+        trade_ret = float(row["return_pct"])
+        capital_before.append(running_capital)
+        pnl = running_capital * trade_ret
+        pnl_amount.append(pnl)
+        running_capital = running_capital + pnl
+        capital_after.append(running_capital)
+
+    trades = trades.reset_index(drop=True)
+    trades.insert(0, "trade_no", trades.index + 1)
+    trades.insert(0, "branch", branch)
+    trades["capital_before"] = capital_before
+    trades["pnl_amount"] = pnl_amount
+    trades["capital_after"] = capital_after
+    return trades[
+        [
+            "branch",
+            "trade_no",
+            "buy_date",
+            "buy_price",
+            "buy_reason",
+            "sell_date",
+            "sell_price",
+            "sell_reason",
+            "holding_days",
+            "return_pct",
+            "capital_before",
+            "pnl_amount",
+            "capital_after",
+        ]
+    ].copy()
+
+
+def _add_execution_prices(trades: pd.DataFrame, indicators: pd.DataFrame) -> pd.DataFrame:
+    trades = trades.copy()
+    indicators = indicators.sort_values("date").reset_index(drop=True)
+    lookup = indicators.set_index(indicators["date"].dt.date)
+    next_by_date = {
+        indicators.iloc[idx]["date"].date().isoformat(): indicators.iloc[idx + 1]
+        for idx in range(len(indicators) - 1)
+    }
+
+    same_entry: list[float] = []
+    same_exit: list[float] = []
+    next_open_entry: list[float] = []
+    next_open_exit: list[float] = []
+
+    for _, trade in trades.iterrows():
+        buy_row = lookup.loc[pd.Timestamp(trade["buy_date"]).date()]
+        sell_row = lookup.loc[pd.Timestamp(trade["sell_date"]).date()]
+        buy_next = next_by_date.get(trade["buy_date"])
+        sell_next = next_by_date.get(trade["sell_date"])
+        same_entry.append(float(buy_row["close"]))
+        same_exit.append(float(sell_row["close"]))
+        next_open_entry.append(float("nan") if buy_next is None else float(buy_next["open"]))
+        next_open_exit.append(float("nan") if sell_next is None else float(sell_next["open"]))
+
+    trades["exec_same_close_entry"] = same_entry
+    trades["exec_same_close_exit"] = same_exit
+    trades["exec_next_open_entry"] = next_open_entry
+    trades["exec_next_open_exit"] = next_open_exit
+    return trades
+
+
+def _metric_actuals(indicators: pd.DataFrame, control_trades: pd.DataFrame, refined_trades: pd.DataFrame) -> dict[str, object]:
+    control_eval = _apply_execution_model(_add_execution_prices(control_trades, indicators), "next_open", 0.0)
+    refined_eval = _apply_execution_model(_add_execution_prices(refined_trades, indicators), "next_open", 0.0)
+    control_stress = _apply_execution_model(_add_execution_prices(control_trades, indicators), "next_open", 20.0)
+    refined_stress = _apply_execution_model(_add_execution_prices(refined_trades, indicators), "next_open", 20.0)
+
+    control_sum = _summary("control", control_eval)
+    refined_sum = _summary("refined", refined_eval)
+    control_stress_sum = _summary("control", control_stress)
+    refined_stress_sum = _summary("refined", refined_stress)
+    refined_risk = _risk_cluster("refined", refined_eval)
+
+    return {
+        "next_open_avg_return_delta_vs_control": float(refined_sum["avg_return"] - control_sum["avg_return"]),
+        "next_open_profit_factor_delta_vs_control": float(refined_sum["profit_factor"] - control_sum["profit_factor"]),
+        "next_open_max_drawdown": float(refined_sum["max_drawdown"]),
+        "next_open_max_loss_streak": int(refined_risk["max_loss_streak"]),
+        "worst_5trade_sum_next_open": float(refined_risk["worst_5trade_sum"]),
+        "short_loss_share": float(refined_risk["short_loss_share"]),
+        "removed_trade_over_removal_count": _load_removed_trade_over_removal_count(Path(__file__).resolve().parent),
+        "local_sensitivity_robust_case_count": _load_local_sensitivity_robust_case_count(Path(__file__).resolve().parent),
+        "headline_avg_return_delta_vs_control": float(refined_trades["return_pct"].mean() - control_trades["return_pct"].mean()),
+        "headline_profit_factor_delta_vs_control": float(
+            (refined_trades[refined_trades["return_pct"] > 0]["return_pct"].sum() / -refined_trades[refined_trades["return_pct"] < 0]["return_pct"].sum())
+            - (control_trades[control_trades["return_pct"] > 0]["return_pct"].sum() / -control_trades[control_trades["return_pct"] < 0]["return_pct"].sum())
+        ),
+        "next_open_20bps_cagr_refined": float(refined_stress_sum["cagr"]),
+        "next_open_20bps_cagr_control": float(control_stress_sum["cagr"]),
+        "next_open_20bps_pf_refined": float(refined_stress_sum["profit_factor"]),
+        "next_open_20bps_pf_control": float(control_stress_sum["profit_factor"]),
+    }
+
+
+def _compare_numeric(actual: float, warning_rule: str, hard_rule: str) -> str:
+    if pd.isna(actual):
+        return "missing_data"
+
+    def parse(rule: str) -> tuple[str, float]:
+        rule = str(rule).strip()
+        if rule.startswith(("<=", ">=")):
+            op = rule[:2]
+            body = rule[2:]
+        elif rule.startswith(("<", ">")):
+            op = rule[:1]
+            body = rule[1:]
+        else:
+            raise ValueError(rule)
+        if body.endswith("%"):
+            threshold = float(body[:-1]) / 100.0
+        else:
+            threshold = float(body)
+        return op, threshold
+
+    hard_op, hard_val = parse(hard_rule)
+    warn_op, warn_val = parse(warning_rule)
+
+    def hit(op: str, threshold: float) -> bool:
+        if op == "<=":
+            return actual <= threshold
+        if op == ">=":
+            return actual >= threshold
+        if op == "<":
+            return actual < threshold
+        if op == ">":
+            return actual > threshold
+        raise ValueError(op)
+
+    if hit(hard_op, hard_val):
+        return "hard_breach"
+    if hit(warn_op, warn_val):
+        return "warning"
+    return "ok"
+
+
+def main() -> None:
+    base_dir = Path(__file__).resolve().parent
+    output_dir = base_dir / "daily_reports"
+    output_dir.mkdir(exist_ok=True)
+    initial_capital = _infer_initial_capital(base_dir)
+
+    as_of_request_date = date.today().isoformat()
+    engine = DragonIndicatorEngine(DragonIndicatorConfig(start_date="2015-01-01", end_date=as_of_request_date))
+    raw = engine.fetch_daily_data()
+    indicators = engine.compute(raw.reset_index(drop=False).rename(columns={"index": "date"}))
+    indicators["date"] = pd.to_datetime(indicators["date"])
+    latest_bar_date = indicators["date"].max().date().isoformat()
+
+    branch_runs = [
+        _build_branch_state(branch_name, config_factory(), indicators)
+        for branch_name, config_factory in BRANCH_CONFIGS
+    ]
+    branch_payload = {
+        state["branch"]: {"events": events, "trades": trades, "state": state}
+        for events, trades, state in branch_runs
+    }
+
+    refined_trades = branch_payload["alpha_first_glued_refined_hot_cap"]["trades"]
+    control_trades = branch_payload["alpha_first_selective_veto"]["trades"]
+
+    refined_trades = refined_trades[refined_trades["buy_date"] >= START_DATE].copy()
+    control_trades = control_trades[control_trades["buy_date"] >= START_DATE].copy()
+    refined_trades["entry_family"] = refined_trades["buy_reason"].map(_entry_family)
+    control_trades["entry_family"] = control_trades["buy_reason"].map(_entry_family)
+
+    recent_indicators = indicators.tail(15).copy()
+    recent_indicators["date"] = recent_indicators["date"].dt.date.astype(str)
+    recent_indicators.to_csv(base_dir / "dragon_daily_signal_snapshot.csv", index=False, encoding="utf-8-sig")
+    recent_indicators.to_csv(output_dir / f"dragon_daily_signal_snapshot_{latest_bar_date}.csv", index=False, encoding="utf-8-sig")
+
+    branch_status = pd.DataFrame([payload["state"] for payload in branch_payload.values()])
+    branch_status.to_csv(base_dir / "dragon_daily_branch_status.csv", index=False, encoding="utf-8-sig")
+    branch_status.to_csv(output_dir / f"dragon_daily_branch_status_{latest_bar_date}.csv", index=False, encoding="utf-8-sig")
+
+    historical_detail = pd.concat(
+        [
+            _build_historical_trade_details(branch, payload["trades"], initial_capital)
+            for branch, payload in branch_payload.items()
+        ],
+        ignore_index=True,
+        sort=False,
+    )
+    historical_detail.to_csv(base_dir / "dragon_historical_trade_details.csv", index=False, encoding="utf-8-sig")
+    historical_detail.to_csv(
+        output_dir / f"dragon_historical_trade_details_{latest_bar_date}.csv",
+        index=False,
+        encoding="utf-8-sig",
+    )
+
+    actuals = _metric_actuals(indicators, control_trades, refined_trades)
+    template = _load_monitor_template(base_dir)
+    template["actual_value"] = template["metric"].map(actuals)
+    template["status"] = template.apply(
+        lambda row: _compare_numeric(row["actual_value"], str(row["warning_threshold"]), str(row["hard_threshold"])),
+        axis=1,
+    )
+    template.to_csv(base_dir / "dragon_daily_monitor_snapshot.csv", index=False, encoding="utf-8-sig")
+    template.to_csv(output_dir / f"dragon_daily_monitor_snapshot_{latest_bar_date}.csv", index=False, encoding="utf-8-sig")
+
+    config_snapshot = {
+        "release_version": "RC1",
+        "branch": "alpha_first_glued_refined_hot_cap",
+        "config": {**asdict(alpha_first_glued_refined_hot_cap_config()), "disabled_rules": sorted(alpha_first_glued_refined_hot_cap_config().disabled_rules)},
+        "as_of_request_date": as_of_request_date,
+        "latest_bar_date": latest_bar_date,
+    }
+    (base_dir / "dragon_daily_rc1_manifest.json").write_text(
+        json.dumps(config_snapshot, indent=2, ensure_ascii=False) + "\n",
+        encoding="utf-8",
+    )
+    (output_dir / f"dragon_daily_rc1_manifest_{latest_bar_date}.json").write_text(
+        json.dumps(config_snapshot, indent=2, ensure_ascii=False) + "\n",
+        encoding="utf-8",
+    )
+
+    warning_count = int((template["status"] == "warning").sum())
+    missing_data_count = int((template["status"] == "missing_data").sum())
+    hard_count = int(template["status"].isin(["hard_breach", "missing_data"]).sum())
+    lines = [
+        "# Dragon Daily Signal Report",
+        "",
+        f"- Request date: `{as_of_request_date}`",
+        f"- Latest available market bar: `{latest_bar_date}`",
+        "- Instrument: `399673`",
+        "- Forward default branch: `alpha_first_glued_refined_hot_cap`",
+        "- Benchmark control branch: `alpha_first_selective_veto`",
+        "",
+        "## Latest Branch Status",
+    ]
+    for state in branch_status.to_dict("records"):
+        lines.extend(
+            [
+                f"### {state['branch']}",
+                f"- latest_close `{state['latest_close']:.3f}` | a1 `{state['latest_a1']:.4f}` | b1 `{state['latest_b1']:.4f}` | c1 `{state['latest_c1']:.2f}`",
+                f"- latest markers: `KDJ buy={state['latest_kdj_buy']}` `KDJ sell={state['latest_kdj_sell']}` `QL buy={state['latest_ql_buy']}` `QL sell={state['latest_ql_sell']}`",
+                f"- latest real event: `{state['latest_real_event_date']}` `{state['latest_real_event_side']}` `{state['latest_real_event_reason']}`",
+                f"- events on latest bar: `{state['events_today'] if state['events_today'] else 'none'}`",
+                f"- in_position: `{state['in_position']}`",
+                (
+                    f"- open trade: `{state['open_entry_date']}` `{state['open_entry_reason']}` | "
+                    f"holding `{int(state['open_holding_days'])}`d | open_return `{_format_pct(float(state['open_return_pct']))}`"
+                    if bool(state["in_position"])
+                    else "- open trade: `none`"
+                ),
+                "",
+            ]
+        )
+
+    lines.extend(
+        [
+            "## Monitor Snapshot",
+            f"- warnings: `{warning_count}`",
+            f"- hard breaches: `{hard_count}`",
+            f"- missing data metrics: `{missing_data_count}`",
+            f"- next_open avg_return delta vs control: `{_format_pct(float(actuals['next_open_avg_return_delta_vs_control']))}`",
+            f"- next_open PF delta vs control: `{_format_num(float(actuals['next_open_profit_factor_delta_vs_control']))}`",
+            f"- next_open max_drawdown refined: `{_format_pct(float(actuals['next_open_max_drawdown']))}`",
+            f"- next_open max loss streak refined: `{int(actuals['next_open_max_loss_streak'])}`",
+            f"- next_open + 20bps CAGR refined/control: `{_format_pct(float(actuals['next_open_20bps_cagr_refined']))}` / `{_format_pct(float(actuals['next_open_20bps_cagr_control']))}`",
+            "",
+            "## Outputs",
+            "- `dragon_daily_signal_snapshot.csv`",
+            "- `dragon_daily_branch_status.csv`",
+            "- `dragon_daily_monitor_snapshot.csv`",
+            "- `dragon_historical_trade_details.csv`",
+            "- `dragon_daily_rc1_manifest.json`",
+        ]
+    )
+    (base_dir / "dragon_daily_signal_report.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
+    (output_dir / f"dragon_daily_signal_report_{latest_bar_date}.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+
+if __name__ == "__main__":
+    main()

+ 152 - 0
research/dragon/v2/dragon_deep_oversold_audit.py

@@ -0,0 +1,152 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pandas as pd
+
+
+def _load_csv(base_dir: Path, name: str) -> pd.DataFrame:
+    return pd.read_csv(base_dir / name, encoding="utf-8-sig")
+
+
+def _classify_entry(row: pd.Series) -> str:
+    a1 = float(row["buy_a1"])
+    b1 = float(row["buy_b1"])
+    c1 = float(row["buy_c1"])
+    if b1 > 0:
+        return "positive_b1_rebound"
+    if c1 < 11 and a1 < -0.05 and b1 < -0.08:
+        return "deep_capitulation"
+    if c1 < 12 and b1 < -0.06:
+        return "classic_oversold"
+    if c1 >= 15 or a1 > -0.03 or b1 > -0.03:
+        return "shallow_false_start"
+    return "mixed_oversold"
+
+
+def _quality_bucket(row: pd.Series) -> str:
+    ret = float(row["return_pct"])
+    mfe = float(row["mfe_pct"])
+    mae = float(row["mae_pct"])
+    if ret > 0.02:
+        return "strong_positive"
+    if ret > 0 and mfe > 0.03:
+        return "weak_positive"
+    if ret <= -0.03 and mfe < 0.01:
+        return "immediate_failure"
+    if ret <= -0.03:
+        return "rebound_then_fail"
+    if mae < -0.04:
+        return "high_volatility_noise"
+    return "flat_noise"
+
+
+def main() -> None:
+    base_dir = Path(__file__).resolve().parent
+    quality = _load_csv(base_dir, "dragon_trade_quality.csv")
+    path_trace = _load_csv(base_dir, "dragon_trade_path_trace.csv")
+    rows = quality[quality["buy_reason"].astype(str).str.startswith("deep_oversold_rebound_buy")].copy()
+    rows = rows.merge(
+        path_trace[
+            [
+                "buy_date",
+                "sell_date",
+                "buy_a1",
+                "buy_b1",
+                "buy_c1",
+                "buy_aligned_with_workbook",
+                "sell_aligned_with_workbook",
+            ]
+        ],
+        on=["buy_date", "sell_date"],
+        how="left",
+    )
+    rows["entry_subtype"] = rows.apply(_classify_entry, axis=1)
+    rows["quality_bucket"] = rows.apply(_quality_bucket, axis=1)
+    rows["is_winner"] = rows["return_pct"] > 0
+    rows["is_fast_failure"] = (rows["holding_days"] <= 6) & (rows["return_pct"] < 0)
+    rows["is_shallow_like"] = (
+        (rows["buy_c1"] >= 15)
+        | (rows["buy_a1"] > -0.03)
+        | (rows["buy_b1"] > -0.03)
+    )
+
+    audit_cols = [
+        "buy_date",
+        "sell_date",
+        "buy_a1",
+        "buy_b1",
+        "buy_c1",
+        "sell_reason",
+        "holding_days",
+        "return_pct",
+        "mfe_pct",
+        "mae_pct",
+        "giveback_from_peak_pct",
+        "entry_subtype",
+        "quality_bucket",
+        "is_winner",
+        "is_fast_failure",
+        "is_shallow_like",
+        "buy_aligned_with_workbook",
+        "sell_aligned_with_workbook",
+    ]
+    rows[audit_cols].to_csv(base_dir / "dragon_deep_oversold_audit.csv", index=False, encoding="utf-8-sig")
+
+    subtype_summary = (
+        rows.groupby("entry_subtype")
+        .agg(
+            trades=("buy_date", "count"),
+            win_rate=("is_winner", "mean"),
+            avg_return=("return_pct", "mean"),
+            avg_mfe=("mfe_pct", "mean"),
+            avg_mae=("mae_pct", "mean"),
+            fast_failures=("is_fast_failure", "sum"),
+            shallow_like=("is_shallow_like", "sum"),
+        )
+        .reset_index()
+        .sort_values(["avg_return", "trades"], ascending=[True, False])
+    )
+    subtype_summary.to_csv(base_dir / "dragon_deep_oversold_subtype_summary.csv", index=False, encoding="utf-8-sig")
+
+    lines = [
+        "# Dragon Deep Oversold Audit",
+        "",
+        f"- audited trades: `{len(rows)}`",
+        f"- all buy dates aligned with workbook: `{bool(rows['buy_aligned_with_workbook'].all())}`",
+        f"- all sell dates aligned with workbook: `{bool(rows['sell_aligned_with_workbook'].all())}`",
+        f"- winners: `{int(rows['is_winner'].sum())}` / `{len(rows)}`",
+        f"- fast failures (holding <= 6d and negative return): `{int(rows['is_fast_failure'].sum())}`",
+        "",
+        "## Entry Subtype Summary",
+    ]
+    for _, row in subtype_summary.iterrows():
+        lines.append(
+            f"- `{row['entry_subtype']}`: trades `{int(row['trades'])}`, win_rate `{float(row['win_rate']):.2%}`, "
+            f"avg_return `{float(row['avg_return']):.2%}`, avg_mfe `{float(row['avg_mfe']):.2%}`, "
+            f"avg_mae `{float(row['avg_mae']):.2%}`, fast_failures `{int(row['fast_failures'])}`"
+        )
+
+    lines.extend(["", "## Candidate Pressure Points"])
+    for _, row in rows.sort_values(["return_pct", "holding_days"]).head(8).iterrows():
+        lines.append(
+            f"- `{row['buy_date']} -> {row['sell_date']}`: `{row['entry_subtype']}` / `{row['quality_bucket']}` | "
+            f"buy a1 `{float(row['buy_a1']):.4f}` b1 `{float(row['buy_b1']):.4f}` c1 `{float(row['buy_c1']):.2f}` | "
+            f"return `{float(row['return_pct']):.2%}`"
+        )
+
+    lines.extend(
+        [
+            "",
+            "## Quant Judgment",
+            "- This rule family cannot be bluntly removed because every current deep-oversold trade is workbook-aligned.",
+            "- The weakest local pattern is not the deepest capitulation bucket; it is the shallow or positive-B1 rebound subset.",
+            "- Any redesign should therefore prefer subtype gating or delayed confirmation for shallow rebounds rather than tighter global oversold thresholds.",
+        ]
+    )
+
+    (base_dir / "dragon_deep_oversold_review.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+
+if __name__ == "__main__":
+    main()

+ 210 - 0
research/dragon/v2/dragon_deep_oversold_confirmation_experiments.py

@@ -0,0 +1,210 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pandas as pd
+
+from dragon_strategy import DragonRuleEngine
+from dragon_strategy_config import StrategyConfig
+
+
+def _load_indicator_snapshot(base_dir: Path) -> pd.DataFrame:
+    df = pd.read_csv(base_dir / "dragon_indicator_snapshot.csv", encoding="utf-8-sig")
+    df["date"] = pd.to_datetime(df["date"])
+    df = df.set_index("date", drop=False)
+    return df
+
+
+def _load_workbook_events(base_dir: Path) -> pd.DataFrame:
+    return pd.read_csv(base_dir / "true_trade_events.csv", encoding="utf-8-sig")
+
+
+def _profit_factor(series: pd.Series) -> float:
+    gross_profit = series[series > 0].sum()
+    gross_loss = -series[series < 0].sum()
+    if gross_loss == 0:
+        return float("inf") if gross_profit > 0 else 0.0
+    return float(gross_profit / gross_loss)
+
+
+def _event_match(strategy_events: pd.DataFrame, workbook_events: pd.DataFrame, side: str) -> tuple[int, int, int]:
+    wb = set(workbook_events[(workbook_events["side"] == side) & (workbook_events["layer"] == "real_trade")]["date"])
+    st = set(strategy_events[(strategy_events["side"] == side) & (strategy_events["layer"] == "real_trade")]["date"])
+    return len(wb & st), len(wb - st), len(st - wb)
+
+
+def _run_experiment(
+    label: str,
+    config: StrategyConfig,
+    indicator_df: pd.DataFrame,
+    workbook_events: pd.DataFrame,
+    first_date: str,
+    last_date: str,
+) -> tuple[dict[str, object], pd.DataFrame]:
+    engine = DragonRuleEngine(config=config)
+    events, trades = engine.run(indicator_df)
+    events = events[(events["date"] >= first_date) & (events["date"] <= last_date)].copy()
+    trades = trades[
+        (trades["buy_date"] >= first_date)
+        & (trades["buy_date"] <= last_date)
+        & (trades["sell_date"] >= first_date)
+        & (trades["sell_date"] <= last_date)
+    ].copy()
+
+    buy_overlap, buy_missing, buy_extra = _event_match(events, workbook_events, "BUY")
+    sell_overlap, sell_missing, sell_extra = _event_match(events, workbook_events, "SELL")
+
+    deep_trades = trades[trades["buy_reason"].str.startswith("deep_oversold_rebound_buy")].copy()
+    weak_trades = deep_trades[
+        deep_trades["buy_reason"].isin(
+            [
+                "deep_oversold_rebound_buy:positive_b1_rebound",
+                "deep_oversold_rebound_buy:shallow_false_start",
+                "deep_oversold_rebound_buy:confirmed_positive_b1_rebound",
+                "deep_oversold_rebound_buy:confirmed_shallow_false_start",
+            ]
+        )
+    ].copy()
+    changed_events = events[
+        (events["layer"] == "real_trade")
+        & (
+            events["reason"].str.startswith("deep_oversold_rebound_buy")
+            | events["reason"].eq("predictive_error_reentry_buy")
+        )
+    ].copy()
+    changed_events["experiment"] = label
+
+    row = {
+        "experiment": label,
+        "confirm_window_bars": config.deep_oversold_confirm_window_bars,
+        "trades": int(len(trades)),
+        "win_rate": float((trades["return_pct"] > 0).mean()) if not trades.empty else float("nan"),
+        "avg_return": float(trades["return_pct"].mean()) if not trades.empty else float("nan"),
+        "median_return": float(trades["return_pct"].median()) if not trades.empty else float("nan"),
+        "profit_factor": _profit_factor(trades["return_pct"]) if not trades.empty else float("nan"),
+        "real_buy_overlap": int(buy_overlap),
+        "real_buy_missing": int(buy_missing),
+        "real_buy_extra": int(buy_extra),
+        "real_sell_overlap": int(sell_overlap),
+        "real_sell_missing": int(sell_missing),
+        "real_sell_extra": int(sell_extra),
+        "deep_trade_count": int(len(deep_trades)),
+        "deep_weak_trade_count": int(len(weak_trades)),
+        "deep_confirmed_trade_count": int(
+            deep_trades["buy_reason"].str.contains(":confirmed_", regex=False).sum()
+        ),
+        "deep_avg_return": float(deep_trades["return_pct"].mean()) if not deep_trades.empty else float("nan"),
+        "deep_weak_avg_return": float(weak_trades["return_pct"].mean()) if not weak_trades.empty else float("nan"),
+    }
+    return row, changed_events
+
+
+def main() -> None:
+    base_dir = Path(__file__).resolve().parent
+    indicator_df = _load_indicator_snapshot(base_dir)
+    workbook_events = _load_workbook_events(base_dir)
+    first_date = workbook_events["date"].min()
+    last_date = workbook_events["date"].max()
+
+    baseline_config = StrategyConfig()
+    experiments = [
+        ("baseline", baseline_config),
+        (
+            "confirm_weak_with_ql_1bar",
+            baseline_config.with_updates(
+                deep_oversold_confirm_weak_with_ql=True,
+                deep_oversold_confirm_window_bars=1,
+            ),
+        ),
+        (
+            "confirm_weak_with_ql_2bar",
+            baseline_config.with_updates(
+                deep_oversold_confirm_weak_with_ql=True,
+                deep_oversold_confirm_window_bars=2,
+            ),
+        ),
+        (
+            "confirm_weak_with_ql_3bar",
+            baseline_config.with_updates(
+                deep_oversold_confirm_weak_with_ql=True,
+                deep_oversold_confirm_window_bars=3,
+            ),
+        ),
+    ]
+
+    rows: list[dict[str, object]] = []
+    event_frames: list[pd.DataFrame] = []
+    for label, config in experiments:
+        row, events = _run_experiment(label, config, indicator_df, workbook_events, first_date, last_date)
+        rows.append(row)
+        event_frames.append(events)
+
+    result_df = pd.DataFrame(rows)
+    baseline = result_df[result_df["experiment"] == "baseline"].iloc[0]
+    for col in [
+        "trades",
+        "win_rate",
+        "avg_return",
+        "median_return",
+        "profit_factor",
+        "real_buy_overlap",
+        "real_sell_overlap",
+        "deep_trade_count",
+        "deep_weak_trade_count",
+        "deep_confirmed_trade_count",
+        "deep_avg_return",
+        "deep_weak_avg_return",
+    ]:
+        result_df[f"delta_{col}"] = result_df[col] - baseline[col]
+
+    event_df = pd.concat(event_frames, ignore_index=True)
+    result_df.to_csv(base_dir / "dragon_deep_oversold_confirmation_experiments.csv", index=False, encoding="utf-8-sig")
+    event_df.to_csv(base_dir / "dragon_deep_oversold_confirmation_event_changes.csv", index=False, encoding="utf-8-sig")
+
+    lines = [
+        "# Dragon Deep Oversold Confirmation Experiments",
+        "",
+        "- Goal: test an alpha-first branch for weak deep-oversold subtypes by replacing immediate entry with QL confirmation inside a short waiting window.",
+        "- Scope: only `positive_b1_rebound` and `shallow_false_start`; default baseline behavior remains unchanged.",
+        "",
+        "## Summary",
+    ]
+    for _, row in result_df.iterrows():
+        lines.append(
+            f"- `{row['experiment']}`: trades `{int(row['trades'])}`, avg_return `{row['avg_return']:.2%}`, "
+            f"profit_factor `{row['profit_factor']:.2f}`, real BUY `{int(row['real_buy_overlap'])}`, real SELL `{int(row['real_sell_overlap'])}`, "
+            f"deep weak trades `{int(row['deep_weak_trade_count'])}`, confirmed deep trades `{int(row['deep_confirmed_trade_count'])}`"
+        )
+
+    lines.extend(["", "## Delta Vs Baseline"])
+    for _, row in result_df[result_df["experiment"] != "baseline"].iterrows():
+        lines.append(
+            f"- `{row['experiment']}`: delta_avg_return `{row['delta_avg_return']:.2%}`, "
+            f"delta_profit_factor `{row['delta_profit_factor']:.2f}`, "
+            f"delta_deep_weak_avg_return `{row['delta_deep_weak_avg_return']:.2%}`, "
+            f"real BUY `{int(row['real_buy_overlap'])}`, real SELL `{int(row['real_sell_overlap'])}`"
+        )
+
+    best = result_df[result_df["experiment"] != "baseline"].sort_values(
+        ["avg_return", "profit_factor"], ascending=[False, False]
+    ).head(1)
+    if not best.empty:
+        row = best.iloc[0]
+        lines.extend(
+            [
+                "",
+                "## Quant Judgment",
+                f"- Best alpha-first branch in this pack: `{row['experiment']}` with avg_return `{row['avg_return']:.2%}` and profit_factor `{row['profit_factor']:.2f}`.",
+                "- This pack is expected to lose workbook date alignment because entries are delayed by confirmation; treat it as a research branch, not a baseline patch.",
+                "- If the best branch improves weak-subtype trade quality materially, the next step should be to audit shifted dates trade-by-trade rather than porting it directly into the baseline.",
+            ]
+        )
+
+    (base_dir / "dragon_deep_oversold_confirmation_experiments.md").write_text(
+        "\n".join(lines) + "\n",
+        encoding="utf-8",
+    )
+
+
+if __name__ == "__main__":
+    main()

+ 128 - 0
research/dragon/v2/dragon_deep_oversold_experiments.py

@@ -0,0 +1,128 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pandas as pd
+
+from dragon_indicators import DragonIndicatorConfig, DragonIndicatorEngine
+from dragon_strategy import DragonRuleEngine
+from dragon_strategy_config import StrategyConfig
+from dragon_workbook import DragonWorkbook
+
+
+def _find_workbook(base_dir: Path) -> Path:
+    matches = sorted(base_dir.glob("*.xlsx"))
+    if not matches:
+        raise FileNotFoundError(f"No workbook found in {base_dir}")
+    return matches[0]
+
+
+def _load_workbook_events(workbook_path: Path) -> pd.DataFrame:
+    workbook = DragonWorkbook(workbook_path)
+    return pd.DataFrame(
+        [{"date": e.date.isoformat(), "side": e.side, "layer": e.layer} for e in workbook.split_layers()]
+    )
+
+
+def _overlap(workbook_events: pd.DataFrame, strategy_events: pd.DataFrame, side: str, layer: str) -> tuple[int, int, int]:
+    wb = set(workbook_events[(workbook_events["side"] == side) & (workbook_events["layer"] == layer)]["date"])
+    st = set(strategy_events[(strategy_events["side"] == side) & (strategy_events["layer"] == layer)]["date"])
+    hit = wb & st
+    return len(hit), len(wb - st), len(st - wb)
+
+
+def _profit_factor(series: pd.Series) -> float:
+    gross_profit = series[series > 0].sum()
+    gross_loss = -series[series < 0].sum()
+    if gross_loss == 0:
+        return float("inf") if gross_profit > 0 else 0.0
+    return float(gross_profit / gross_loss)
+
+
+def _run(label: str, config: StrategyConfig, workbook_events: pd.DataFrame, indicator_df: pd.DataFrame, first_date: str, last_date: str) -> dict[str, object]:
+    events, trades = DragonRuleEngine(config=config).run(indicator_df)
+    events = events[(events["date"] >= first_date) & (events["date"] <= last_date)].copy()
+    trades = trades[
+        (trades["buy_date"] >= first_date)
+        & (trades["buy_date"] <= last_date)
+        & (trades["sell_date"] >= first_date)
+        & (trades["sell_date"] <= last_date)
+    ].copy()
+
+    buy_overlap, buy_missing, buy_extra = _overlap(workbook_events, events, "BUY", "real_trade")
+    sell_overlap, sell_missing, sell_extra = _overlap(workbook_events, events, "SELL", "real_trade")
+
+    deep_trades = trades[trades["buy_reason"].astype(str).str.startswith("deep_oversold_rebound_buy")]
+
+    return {
+        "experiment": label,
+        "trades": int(len(trades)),
+        "avg_return": float(trades["return_pct"].mean()),
+        "profit_factor": _profit_factor(trades["return_pct"]),
+        "real_buy_overlap": int(buy_overlap),
+        "real_buy_missing": int(buy_missing),
+        "real_buy_extra": int(buy_extra),
+        "real_sell_overlap": int(sell_overlap),
+        "real_sell_missing": int(sell_missing),
+        "real_sell_extra": int(sell_extra),
+        "deep_oversold_trade_count": int(len(deep_trades)),
+        "deep_oversold_avg_return": float(deep_trades["return_pct"].mean()) if not deep_trades.empty else float("nan"),
+    }
+
+
+def main() -> None:
+    base_dir = Path(__file__).resolve().parent
+    workbook_events = _load_workbook_events(_find_workbook(base_dir))
+    first_date = pd.to_datetime(workbook_events["date"]).min().date().isoformat()
+    last_date = pd.to_datetime(workbook_events["date"]).max().date().isoformat()
+
+    engine = DragonIndicatorEngine(DragonIndicatorConfig(start_date="2015-01-01", end_date="2026-01-31"))
+    indicator_df = engine.compute(engine.fetch_daily_data())
+
+    baseline = StrategyConfig()
+    experiments = [
+        ("baseline", baseline),
+        (
+            "block_positive_b1_rebound",
+            baseline.with_updates(deep_oversold_block_positive_b1_rebound=True),
+        ),
+        (
+            "block_shallow_false_start_without_ql",
+            baseline.with_updates(deep_oversold_block_shallow_false_start_without_ql=True),
+        ),
+        (
+            "block_both_remaining_weak_subtypes",
+            baseline.with_updates(
+                deep_oversold_block_positive_b1_rebound=True,
+                deep_oversold_block_shallow_false_start_without_ql=True,
+            ),
+        ),
+    ]
+
+    rows = [_run(label, cfg, workbook_events, indicator_df, first_date, last_date) for label, cfg in experiments]
+    df = pd.DataFrame(rows)
+    base_row = df[df["experiment"] == "baseline"].iloc[0]
+    for col in ["trades", "avg_return", "profit_factor", "real_buy_overlap", "real_sell_overlap", "deep_oversold_trade_count", "deep_oversold_avg_return"]:
+        df[f"delta_{col}"] = df[col] - base_row[col]
+
+    df.to_csv(base_dir / "dragon_deep_oversold_experiments.csv", index=False, encoding="utf-8-sig")
+
+    lines = [
+        "# Dragon Deep Oversold Experiments",
+        "",
+        f"- Baseline deep-oversold trade count: `{int(base_row['deep_oversold_trade_count'])}`",
+        f"- Baseline real BUY / SELL overlap: `{int(base_row['real_buy_overlap'])}` / `{int(base_row['real_sell_overlap'])}`",
+        "",
+        "## Experiment Summary",
+    ]
+    for _, row in df.iterrows():
+        lines.append(
+            f"- `{row['experiment']}`: deep trades `{int(row['deep_oversold_trade_count'])}`, "
+            f"delta_avg_return `{row['delta_avg_return']:.2%}`, real BUY `{int(row['real_buy_overlap'])}`, real SELL `{int(row['real_sell_overlap'])}`"
+        )
+
+    (base_dir / "dragon_deep_oversold_experiments.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+
+if __name__ == "__main__":
+    main()

+ 216 - 0
research/dragon/v2/dragon_deep_oversold_selective_veto_experiments.py

@@ -0,0 +1,216 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pandas as pd
+
+from dragon_strategy import DragonRuleEngine
+from dragon_strategy_config import StrategyConfig
+
+
+def _load_indicator_snapshot(base_dir: Path) -> pd.DataFrame:
+    df = pd.read_csv(base_dir / "dragon_indicator_snapshot.csv", encoding="utf-8-sig")
+    df["date"] = pd.to_datetime(df["date"])
+    return df.set_index("date", drop=False)
+
+
+def _load_true_trade_events(base_dir: Path) -> pd.DataFrame:
+    return pd.read_csv(base_dir / "true_trade_events.csv", encoding="utf-8-sig")
+
+
+def _profit_factor(series: pd.Series) -> float:
+    gross_profit = series[series > 0].sum()
+    gross_loss = -series[series < 0].sum()
+    if gross_loss == 0:
+        return float("inf") if gross_profit > 0 else 0.0
+    return float(gross_profit / gross_loss)
+
+
+def _event_match(strategy_events: pd.DataFrame, workbook_events: pd.DataFrame, side: str) -> tuple[int, int, int]:
+    wb = set(workbook_events[(workbook_events["side"] == side) & (workbook_events["layer"] == "real_trade")]["date"])
+    st = set(strategy_events[(strategy_events["side"] == side) & (strategy_events["layer"] == "real_trade")]["date"])
+    return len(wb & st), len(wb - st), len(st - wb)
+
+
+def _run_experiment(
+    label: str,
+    config: StrategyConfig,
+    indicator_df: pd.DataFrame,
+    workbook_events: pd.DataFrame,
+    first_date: str,
+    last_date: str,
+) -> tuple[dict[str, object], pd.DataFrame]:
+    engine = DragonRuleEngine(config=config)
+    events, trades = engine.run(indicator_df)
+    events = events[(events["date"] >= first_date) & (events["date"] <= last_date)].copy()
+    trades = trades[
+        (trades["buy_date"] >= first_date)
+        & (trades["buy_date"] <= last_date)
+        & (trades["sell_date"] >= first_date)
+        & (trades["sell_date"] <= last_date)
+    ].copy()
+
+    buy_overlap, buy_missing, buy_extra = _event_match(events, workbook_events, "BUY")
+    sell_overlap, sell_missing, sell_extra = _event_match(events, workbook_events, "SELL")
+
+    deep_trades = trades[trades["buy_reason"].str.startswith("deep_oversold_rebound_buy")].copy()
+    weak_trades = deep_trades[
+        deep_trades["buy_reason"].isin(
+            [
+                "deep_oversold_rebound_buy:positive_b1_rebound",
+                "deep_oversold_rebound_buy:shallow_false_start",
+                "deep_oversold_rebound_buy:mixed_oversold",
+            ]
+        )
+    ].copy()
+
+    event_slice = events[
+        (events["layer"] == "real_trade")
+        & events["reason"].str.startswith("deep_oversold_rebound_buy")
+    ].copy()
+    event_slice["experiment"] = label
+
+    row = {
+        "experiment": label,
+        "trades": int(len(trades)),
+        "win_rate": float((trades["return_pct"] > 0).mean()) if not trades.empty else float("nan"),
+        "avg_return": float(trades["return_pct"].mean()) if not trades.empty else float("nan"),
+        "median_return": float(trades["return_pct"].median()) if not trades.empty else float("nan"),
+        "profit_factor": _profit_factor(trades["return_pct"]) if not trades.empty else float("nan"),
+        "real_buy_overlap": int(buy_overlap),
+        "real_buy_missing": int(buy_missing),
+        "real_buy_extra": int(buy_extra),
+        "real_sell_overlap": int(sell_overlap),
+        "real_sell_missing": int(sell_missing),
+        "real_sell_extra": int(sell_extra),
+        "deep_trade_count": int(len(deep_trades)),
+        "deep_weak_trade_count": int(len(weak_trades)),
+        "deep_avg_return": float(deep_trades["return_pct"].mean()) if not deep_trades.empty else float("nan"),
+        "deep_weak_avg_return": float(weak_trades["return_pct"].mean()) if not weak_trades.empty else float("nan"),
+    }
+    return row, event_slice
+
+
+def main() -> None:
+    base_dir = Path(__file__).resolve().parent
+    indicator_df = _load_indicator_snapshot(base_dir)
+    workbook_events = _load_true_trade_events(base_dir)
+    first_date = workbook_events["date"].min()
+    last_date = workbook_events["date"].max()
+
+    baseline = StrategyConfig()
+    experiments = [
+        ("baseline", baseline),
+        (
+            "selective_veto_positive_c1_lt_15_3",
+            baseline.with_updates(deep_oversold_selective_positive_b1_c1_max=15.3),
+        ),
+        (
+            "selective_veto_shallow_jan_style",
+            baseline.with_updates(
+                deep_oversold_selective_shallow_c1_min=12.0,
+                deep_oversold_selective_shallow_b1_min=-0.025,
+            ),
+        ),
+        (
+            "selective_veto_positive_and_shallow",
+            baseline.with_updates(
+                deep_oversold_selective_positive_b1_c1_max=15.3,
+                deep_oversold_selective_shallow_c1_min=12.0,
+                deep_oversold_selective_shallow_b1_min=-0.025,
+            ),
+        ),
+        (
+            "selective_veto_plus_mixed_c1_lt_10_2_no_ql",
+            baseline.with_updates(
+                deep_oversold_selective_positive_b1_c1_max=15.3,
+                deep_oversold_selective_shallow_c1_min=12.0,
+                deep_oversold_selective_shallow_b1_min=-0.025,
+                deep_oversold_selective_mixed_c1_max=10.2,
+                deep_oversold_selective_mixed_require_no_ql=True,
+            ),
+        ),
+        (
+            "block_all_remaining_weak_subtypes",
+            baseline.with_updates(
+                deep_oversold_block_positive_b1_rebound=True,
+                deep_oversold_block_shallow_false_start_without_ql=True,
+            ),
+        ),
+    ]
+
+    rows: list[dict[str, object]] = []
+    event_frames: list[pd.DataFrame] = []
+    for label, config in experiments:
+        row, event_slice = _run_experiment(label, config, indicator_df, workbook_events, first_date, last_date)
+        rows.append(row)
+        event_frames.append(event_slice)
+
+    result_df = pd.DataFrame(rows)
+    baseline_row = result_df[result_df["experiment"] == "baseline"].iloc[0]
+    for col in [
+        "trades",
+        "win_rate",
+        "avg_return",
+        "median_return",
+        "profit_factor",
+        "real_buy_overlap",
+        "real_sell_overlap",
+        "deep_trade_count",
+        "deep_weak_trade_count",
+        "deep_avg_return",
+        "deep_weak_avg_return",
+    ]:
+        result_df[f"delta_{col}"] = result_df[col] - baseline_row[col]
+
+    event_df = pd.concat(event_frames, ignore_index=True)
+    result_df.to_csv(base_dir / "dragon_deep_oversold_selective_veto_experiments.csv", index=False, encoding="utf-8-sig")
+    event_df.to_csv(base_dir / "dragon_deep_oversold_selective_veto_event_changes.csv", index=False, encoding="utf-8-sig")
+
+    lines = [
+        "# Dragon Deep Oversold Selective Veto Experiments",
+        "",
+        "- Goal: test whether weak deep-oversold alpha can be improved by vetoing only the most pathological local patterns, instead of blocking whole subtypes.",
+        "- Baseline objective is not preserved here; this is an alpha-first research pack.",
+        "",
+        "## Summary",
+    ]
+    for _, row in result_df.iterrows():
+        lines.append(
+            f"- `{row['experiment']}`: trades `{int(row['trades'])}`, avg_return `{row['avg_return']:.2%}`, "
+            f"profit_factor `{row['profit_factor']:.2f}`, real BUY `{int(row['real_buy_overlap'])}`, real SELL `{int(row['real_sell_overlap'])}`, "
+            f"deep weak trades `{int(row['deep_weak_trade_count'])}`"
+        )
+
+    lines.extend(["", "## Delta Vs Baseline"])
+    for _, row in result_df[result_df["experiment"] != "baseline"].iterrows():
+        lines.append(
+            f"- `{row['experiment']}`: delta_avg_return `{row['delta_avg_return']:.2%}`, "
+            f"delta_profit_factor `{row['delta_profit_factor']:.2f}`, "
+            f"delta_deep_weak_avg_return `{row['delta_deep_weak_avg_return']:.2%}`, "
+            f"real BUY `{int(row['real_buy_overlap'])}`, real SELL `{int(row['real_sell_overlap'])}`"
+        )
+
+    best = result_df[result_df["experiment"] != "baseline"].sort_values(
+        ["avg_return", "profit_factor"], ascending=[False, False]
+    ).head(1)
+    if not best.empty:
+        row = best.iloc[0]
+        lines.extend(
+            [
+                "",
+                "## Quant Judgment",
+                f"- Best branch in this pack: `{row['experiment']}` with avg_return `{row['avg_return']:.2%}` and profit_factor `{row['profit_factor']:.2f}`.",
+                "- Compare this result to `block_all_remaining_weak_subtypes` to see whether narrow veto meaningfully preserves useful edge while still removing obvious losers.",
+                "- If a narrow veto matches most of the broad-block benefit with smaller date loss, it is the better alpha-first redesign candidate.",
+            ]
+        )
+
+    (base_dir / "dragon_deep_oversold_selective_veto_experiments.md").write_text(
+        "\n".join(lines) + "\n",
+        encoding="utf-8",
+    )
+
+
+if __name__ == "__main__":
+    main()

+ 141 - 0
research/dragon/v2/dragon_equity_curve_review.py

@@ -0,0 +1,141 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pandas as pd
+
+from dragon_branch_configs import (
+    alpha_first_glued_refined_hot_cap_config,
+    alpha_first_selective_veto_config,
+    workbook_preserving_config,
+)
+from dragon_shared import END_DATE, START_DATE, evaluation_years
+from dragon_strategy import DragonRuleEngine
+
+
+def _load_indicator_snapshot(base_dir: Path) -> pd.DataFrame:
+    df = pd.read_csv(base_dir / "dragon_indicator_snapshot.csv", encoding="utf-8-sig")
+    df["date"] = pd.to_datetime(df["date"])
+    return df.set_index("date", drop=False)
+
+
+def _max_drawdown(equity: pd.Series) -> float:
+    running_max = equity.cummax()
+    drawdown = equity / running_max - 1.0
+    return float(drawdown.min())
+
+
+def _max_drawdown_duration(equity: pd.Series) -> int:
+    running_max = equity.cummax()
+    underwater = equity < running_max
+    best = cur = 0
+    for flag in underwater.tolist():
+        cur = cur + 1 if flag else 0
+        best = max(best, cur)
+    return int(best)
+
+
+def _run_branch(indicator_df: pd.DataFrame, config) -> pd.DataFrame:
+    engine = DragonRuleEngine(config=config)
+    _, trades = engine.run(indicator_df)
+    trades = trades[
+        (trades["buy_date"] >= START_DATE)
+        & (trades["buy_date"] <= END_DATE)
+        & (trades["sell_date"] >= START_DATE)
+        & (trades["sell_date"] <= END_DATE)
+    ].copy()
+    trades["sell_dt"] = pd.to_datetime(trades["sell_date"])
+    trades = trades.sort_values("sell_dt").reset_index(drop=True)
+    trades["equity"] = (1.0 + trades["return_pct"].astype(float)).cumprod()
+    trades["drawdown"] = trades["equity"] / trades["equity"].cummax() - 1.0
+    trades["sell_year"] = trades["sell_dt"].dt.year.astype(int)
+    trades["sell_month"] = trades["sell_dt"].dt.to_period("M").astype(str)
+    return trades
+
+
+def _summarize(branch: str, trades: pd.DataFrame) -> dict[str, object]:
+    years = evaluation_years(START_DATE, END_DATE)
+    compounded = float(trades["equity"].iloc[-1] - 1.0) if not trades.empty else float("nan")
+    cagr = float((1.0 + compounded) ** (1.0 / years) - 1.0) if not trades.empty else float("nan")
+    max_dd = _max_drawdown(trades["equity"]) if not trades.empty else float("nan")
+    calmar = float(cagr / abs(max_dd)) if trades.shape[0] and max_dd < 0 else float("inf")
+    return {
+        "branch": branch,
+        "trades": int(len(trades)),
+        "compounded_return": compounded,
+        "cagr": cagr,
+        "max_drawdown": max_dd,
+        "drawdown_duration_trades": _max_drawdown_duration(trades["equity"]) if not trades.empty else 0,
+        "calmar": calmar,
+        "best_trade": float(trades["return_pct"].max()) if not trades.empty else float("nan"),
+        "worst_trade": float(trades["return_pct"].min()) if not trades.empty else float("nan"),
+    }
+
+
+def main() -> None:
+    base_dir = Path(__file__).resolve().parent
+    indicator_df = _load_indicator_snapshot(base_dir)
+
+    branches = {
+        "workbook_preserving": workbook_preserving_config(),
+        "alpha_first_selective_veto": alpha_first_selective_veto_config(),
+        "alpha_first_glued_refined_hot_cap": alpha_first_glued_refined_hot_cap_config(),
+    }
+
+    equity_frames: list[pd.DataFrame] = []
+    summary_rows: list[dict[str, object]] = []
+    monthly_rows: list[dict[str, object]] = []
+    yearly_rows: list[dict[str, object]] = []
+
+    for name, cfg in branches.items():
+        trades = _run_branch(indicator_df, cfg)
+        trades["branch"] = name
+        equity_frames.append(trades[["branch", "buy_date", "sell_date", "return_pct", "equity", "drawdown", "sell_year", "sell_month"]].copy())
+        summary_rows.append(_summarize(name, trades))
+
+        month = trades.groupby("sell_month", dropna=False)["return_pct"].apply(lambda s: float((1.0 + s).prod() - 1.0)).reset_index()
+        month["branch"] = name
+        monthly_rows.append(month)
+
+        year = trades.groupby("sell_year", dropna=False)["return_pct"].apply(lambda s: float((1.0 + s).prod() - 1.0)).reset_index()
+        year["branch"] = name
+        yearly_rows.append(year)
+
+    equity = pd.concat(equity_frames, ignore_index=True)
+    summary = pd.DataFrame(summary_rows)
+    monthly = pd.concat(monthly_rows, ignore_index=True)
+    yearly = pd.concat(yearly_rows, ignore_index=True)
+
+    equity.to_csv(base_dir / "dragon_equity_curve_review.csv", index=False, encoding="utf-8-sig")
+    summary.to_csv(base_dir / "dragon_drawdown_review.csv", index=False, encoding="utf-8-sig")
+    monthly.to_csv(base_dir / "dragon_monthly_return_review.csv", index=False, encoding="utf-8-sig")
+    yearly.to_csv(base_dir / "dragon_yearly_return_review.csv", index=False, encoding="utf-8-sig")
+
+    lines = [
+        "# Dragon Equity Curve Review",
+        "",
+        f"- Evaluation window: `{START_DATE}` to `{END_DATE}`.",
+        "- Equity is compounded in trade sequence order.",
+        "",
+        "## Summary",
+    ]
+
+    for _, row in summary.iterrows():
+        lines.append(
+            f"- `{row['branch']}`: compounded `{row['compounded_return']:.2%}`, CAGR `{row['cagr']:.2%}`, "
+            f"max_drawdown `{row['max_drawdown']:.2%}`, drawdown_duration `{int(row['drawdown_duration_trades'])}` trades, "
+            f"Calmar `{row['calmar']:.2f}`"
+        )
+
+    lines.extend(["", "## Quant Judgment"])
+    best = summary.sort_values(["cagr", "calmar"], ascending=[False, False]).iloc[0]
+    lines.append(
+        f"- Best growth profile in this pack: `{best['branch']}` with CAGR `{best['cagr']:.2%}`, max_drawdown `{best['max_drawdown']:.2%}`, Calmar `{best['calmar']:.2f}`."
+    )
+    lines.append("- Use this review to judge whether higher alpha is being purchased with unacceptable drawdown concentration.")
+
+    (base_dir / "dragon_equity_curve_review.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+
+if __name__ == "__main__":
+    main()

+ 140 - 0
research/dragon/v2/dragon_execution_common.py

@@ -0,0 +1,140 @@
+from __future__ import annotations
+
+import pandas as pd
+
+from dragon_shared import END_DATE, START_DATE, evaluation_years, profit_factor
+
+
+def apply_execution_model(trades: pd.DataFrame, model: str, cost_bps_side: float) -> pd.DataFrame:
+    trades = trades.copy()
+    entry_col = f"exec_{model}_entry"
+    exit_col = f"exec_{model}_exit"
+    cost = cost_bps_side / 10000.0
+    trades["entry_exec_price"] = trades[entry_col].astype(float)
+    trades["exit_exec_price"] = trades[exit_col].astype(float)
+    trades = trades.dropna(subset=["entry_exec_price", "exit_exec_price"]).copy()
+    trades["return_pct"] = (trades["exit_exec_price"] * (1.0 - cost)) / (trades["entry_exec_price"] * (1.0 + cost)) - 1.0
+    trades["execution_model"] = model
+    trades["cost_bps_side"] = cost_bps_side
+    return trades
+
+
+def _max_drawdown(returns: pd.Series) -> tuple[float, int]:
+    equity = (1.0 + returns.astype(float)).cumprod()
+    peak = equity.cummax()
+    dd = equity / peak - 1.0
+    max_dd = float(dd.min()) if not dd.empty else float("nan")
+
+    duration = 0
+    max_duration = 0
+    for value in dd:
+        if value < 0:
+            duration += 1
+            max_duration = max(max_duration, duration)
+        else:
+            duration = 0
+    return max_dd, max_duration
+
+
+def summary(branch: str, trades: pd.DataFrame) -> dict[str, object]:
+    if trades.empty:
+        return {
+            "branch": branch,
+            "execution_model": "",
+            "cost_bps_side": float("nan"),
+            "trades": 0,
+            "win_rate": float("nan"),
+            "avg_return": float("nan"),
+            "profit_factor": float("nan"),
+            "compounded_return": float("nan"),
+            "cagr": float("nan"),
+            "max_drawdown": float("nan"),
+            "drawdown_duration_trades": 0,
+        }
+    returns = trades["return_pct"].astype(float)
+    compounded = float((1.0 + returns).prod() - 1.0)
+    years = evaluation_years(START_DATE, END_DATE)
+    cagr = float((1.0 + compounded) ** (1.0 / years) - 1.0) if pd.notna(compounded) and compounded > -1.0 else float("nan")
+    max_dd, dd_duration = _max_drawdown(returns)
+    return {
+        "branch": branch,
+        "execution_model": str(trades["execution_model"].iloc[0]),
+        "cost_bps_side": float(trades["cost_bps_side"].iloc[0]),
+        "trades": int(len(trades)),
+        "win_rate": float((returns > 0).mean()),
+        "avg_return": float(returns.mean()),
+        "profit_factor": profit_factor(returns),
+        "compounded_return": compounded,
+        "cagr": cagr,
+        "max_drawdown": max_dd,
+        "drawdown_duration_trades": dd_duration,
+    }
+
+
+def _loss_streak(flags: pd.Series) -> int:
+    best = 0
+    cur = 0
+    for flag in flags.astype(bool):
+        if flag:
+            cur += 1
+            best = max(best, cur)
+        else:
+            cur = 0
+    return best
+
+
+def _worst_rolling_sum(series: pd.Series, window: int) -> float:
+    if len(series) < window:
+        return float(series.sum()) if not series.empty else float("nan")
+    return float(series.rolling(window).sum().min())
+
+
+def risk_cluster(branch: str, trades: pd.DataFrame) -> dict[str, object]:
+    if trades.empty:
+        return {
+            "branch": branch,
+            "execution_model": "",
+            "cost_bps_side": float("nan"),
+            "max_loss_streak": 0,
+            "worst_3trade_sum": float("nan"),
+            "worst_5trade_sum": float("nan"),
+            "worst_10trade_sum": float("nan"),
+            "avg_losing_trade": float("nan"),
+            "tail_20pct_avg": float("nan"),
+            "max_drawdown": float("nan"),
+            "drawdown_duration_trades": 0,
+            "short_loss_share": float("nan"),
+            "worst_loss_family": "",
+            "worst_loss_family_sum": 0.0,
+        }
+    returns = trades["return_pct"].astype(float).reset_index(drop=True)
+    losses = returns[returns < 0]
+    max_dd, dd_duration = _max_drawdown(returns)
+    abs_losses = -losses.sum()
+    short_losses = -trades.loc[(trades["holding_days"] <= 10) & (trades["return_pct"] < 0), "return_pct"].sum()
+
+    family_losses = (
+        trades[trades["return_pct"] < 0]
+        .groupby("entry_family")["return_pct"]
+        .sum()
+        .sort_values()
+    )
+    worst_family = "" if family_losses.empty else str(family_losses.index[0])
+    worst_family_loss = 0.0 if family_losses.empty else float(family_losses.iloc[0])
+
+    return {
+        "branch": branch,
+        "execution_model": str(trades["execution_model"].iloc[0]),
+        "cost_bps_side": float(trades["cost_bps_side"].iloc[0]),
+        "max_loss_streak": _loss_streak(returns < 0),
+        "worst_3trade_sum": _worst_rolling_sum(returns, 3),
+        "worst_5trade_sum": _worst_rolling_sum(returns, 5),
+        "worst_10trade_sum": _worst_rolling_sum(returns, 10),
+        "avg_losing_trade": float(losses.mean()) if not losses.empty else float("nan"),
+        "tail_20pct_avg": float(returns.nsmallest(max(1, int(len(returns) * 0.2))).mean()) if not returns.empty else float("nan"),
+        "max_drawdown": max_dd,
+        "drawdown_duration_trades": dd_duration,
+        "short_loss_share": float(short_losses / abs_losses) if abs_losses > 0 else float("nan"),
+        "worst_loss_family": worst_family,
+        "worst_loss_family_sum": worst_family_loss,
+    }

+ 516 - 0
research/dragon/v2/dragon_forward_observation_pipeline.py

@@ -0,0 +1,516 @@
+from __future__ import annotations
+
+from datetime import datetime
+import json
+from pathlib import Path
+
+import pandas as pd
+
+import dragon_daily_signal_pipeline as daily
+import dragon_html_reports as html_reports
+
+
+def _load_csv(path: Path) -> pd.DataFrame:
+    if not path.exists():
+        return pd.DataFrame()
+    return pd.read_csv(path, encoding="utf-8-sig")
+
+
+def _write_csv(df: pd.DataFrame, path: Path) -> None:
+    df.to_csv(path, index=False, encoding="utf-8-sig")
+
+
+def _append_dedup(existing: pd.DataFrame, new_rows: pd.DataFrame, key_cols: list[str]) -> pd.DataFrame:
+    if existing.empty:
+        out = new_rows.copy()
+    else:
+        out = pd.concat([existing, new_rows], ignore_index=True, sort=False)
+    out = out.drop_duplicates(subset=key_cols, keep="last")
+    return out.sort_values(key_cols).reset_index(drop=True)
+
+
+def _json_default(value: object) -> object:
+    if isinstance(value, (pd.Timestamp, datetime)):
+        return value.isoformat()
+    if pd.isna(value):
+        return None
+    return value
+
+
+def _load_state(path: Path) -> dict[str, object]:
+    if not path.exists():
+        return {}
+    return json.loads(path.read_text(encoding="utf-8"))
+
+
+def _build_monitor_summary(monitor_df: pd.DataFrame) -> dict[str, object]:
+    return {
+        "warning_count": int((monitor_df["status"] == "warning").sum()),
+        "missing_data_count": int((monitor_df["status"] == "missing_data").sum()),
+        "hard_breach_count": int(monitor_df["status"].isin(["hard_breach", "missing_data"]).sum()),
+        "warning_metrics": monitor_df.loc[monitor_df["status"] == "warning", "metric"].tolist(),
+        "missing_data_metrics": monitor_df.loc[monitor_df["status"] == "missing_data", "metric"].tolist(),
+        "hard_breach_metrics": monitor_df.loc[monitor_df["status"].isin(["hard_breach", "missing_data"]), "metric"].tolist(),
+    }
+
+
+def _branch_event_signature(row: pd.Series) -> str:
+    return f"{row['latest_real_event_date']}|{row['latest_real_event_side']}|{row['latest_real_event_reason']}"
+
+
+def _build_signal_changes(
+    branch_status: pd.DataFrame,
+    prev_state: dict[str, object],
+    latest_bar_date: str,
+    monitor_summary: dict[str, object],
+) -> pd.DataFrame:
+    prev_branches = prev_state.get("branches", {}) if isinstance(prev_state, dict) else {}
+    prev_monitor = prev_state.get("monitor_summary", {}) if isinstance(prev_state, dict) else {}
+    rows: list[dict[str, object]] = []
+
+    for _, row in branch_status.iterrows():
+        branch = str(row["branch"])
+        prev = prev_branches.get(branch, {})
+        indicator_context = (
+            f"close={float(row['latest_close']):.3f},"
+            f"a1={float(row['latest_a1']):.4f},"
+            f"b1={float(row['latest_b1']):.4f},"
+            f"c1={float(row['latest_c1']):.2f}"
+        )
+        event_context = str(row["events_today"]) if isinstance(row["events_today"], str) and row["events_today"] else "none"
+
+        if not prev:
+            rows.append(
+                {
+                    "latest_bar_date": latest_bar_date,
+                    "branch": branch,
+                    "change_type": "branch_initialized",
+                    "old_value": "",
+                    "new_value": _branch_event_signature(row),
+                    "reason": "first forward-observation snapshot for this branch",
+                    "event_context": event_context,
+                    "indicator_context": indicator_context,
+                }
+            )
+            continue
+
+        if bool(prev.get("in_position")) != bool(row["in_position"]):
+            rows.append(
+                {
+                    "latest_bar_date": latest_bar_date,
+                    "branch": branch,
+                    "change_type": "position_changed",
+                    "old_value": str(prev.get("in_position")),
+                    "new_value": str(bool(row["in_position"])),
+                    "reason": "position state changed between forward snapshots",
+                    "event_context": event_context,
+                    "indicator_context": indicator_context,
+                }
+            )
+
+        prev_sig = f"{prev.get('latest_real_event_date', '')}|{prev.get('latest_real_event_side', '')}|{prev.get('latest_real_event_reason', '')}"
+        new_sig = _branch_event_signature(row)
+        if prev_sig != new_sig:
+            rows.append(
+                {
+                    "latest_bar_date": latest_bar_date,
+                    "branch": branch,
+                    "change_type": "latest_real_event_changed",
+                    "old_value": prev_sig,
+                    "new_value": new_sig,
+                    "reason": "latest real-trade event changed",
+                    "event_context": event_context,
+                    "indicator_context": indicator_context,
+                }
+            )
+
+        if int(row["events_today_count"]) > 0:
+            rows.append(
+                {
+                    "latest_bar_date": latest_bar_date,
+                    "branch": branch,
+                    "change_type": "new_event_on_latest_bar",
+                    "old_value": "",
+                    "new_value": str(row["events_today"]),
+                    "reason": "new signal fired on the latest market bar",
+                    "event_context": event_context,
+                    "indicator_context": indicator_context,
+                }
+            )
+
+    prev_warn = int(prev_monitor.get("warning_count", 0)) if isinstance(prev_monitor, dict) else 0
+    prev_hard = int(prev_monitor.get("hard_breach_count", 0)) if isinstance(prev_monitor, dict) else 0
+    prev_missing = int(prev_monitor.get("missing_data_count", 0)) if isinstance(prev_monitor, dict) else 0
+    warn = int(monitor_summary["warning_count"])
+    hard = int(monitor_summary["hard_breach_count"])
+    missing = int(monitor_summary["missing_data_count"])
+    if warn != prev_warn:
+        rows.append(
+            {
+                "latest_bar_date": latest_bar_date,
+                "branch": "system",
+                "change_type": "monitor_warning_count_changed",
+                "old_value": str(prev_warn),
+                "new_value": str(warn),
+                "reason": "warning count changed versus prior forward snapshot",
+                "event_context": "",
+                "indicator_context": "",
+            }
+        )
+    if hard != prev_hard:
+        rows.append(
+            {
+                "latest_bar_date": latest_bar_date,
+                "branch": "system",
+                "change_type": "monitor_hard_breach_count_changed",
+                "old_value": str(prev_hard),
+                "new_value": str(hard),
+                "reason": "hard breach count changed versus prior forward snapshot",
+                "event_context": "",
+                "indicator_context": "",
+            }
+        )
+    if missing != prev_missing:
+        rows.append(
+            {
+                "latest_bar_date": latest_bar_date,
+                "branch": "system",
+                "change_type": "monitor_missing_data_count_changed",
+                "old_value": str(prev_missing),
+                "new_value": str(missing),
+                "reason": "missing-data metric count changed versus prior forward snapshot",
+                "event_context": "",
+                "indicator_context": "",
+            }
+        )
+
+    return pd.DataFrame(rows)
+
+
+def _divergence_level(row: dict[str, object]) -> str:
+    if int(row["hard_breach_count"]) > 0:
+        return "review_required"
+    if not bool(row["same_position_flag"]):
+        return "review_required"
+    if not bool(row["same_latest_real_event_flag"]):
+        return "material"
+    if int(row["warning_count"]) > 0:
+        return "mild"
+    return "none"
+
+
+def _update_monitor_history(existing: pd.DataFrame, current: pd.DataFrame, latest_bar_date: str) -> pd.DataFrame:
+    current = current.copy()
+    current["latest_bar_date"] = latest_bar_date
+    cols = [
+        "latest_bar_date",
+        "metric",
+        "actual_value",
+        "status",
+        "warning_threshold",
+        "hard_threshold",
+        "scope",
+        "cadence",
+        "action_on_warning",
+        "action_on_hard_breach",
+        "rationale",
+    ]
+    current = current[cols]
+    history = _append_dedup(existing, current, ["latest_bar_date", "metric"])
+    history["warning_streak"] = 0
+    history["hard_breach_streak"] = 0
+
+    for metric, idx in history.groupby("metric", sort=False).groups.items():
+        subset = history.loc[list(idx)].sort_values("latest_bar_date")
+        warn = 0
+        hard = 0
+        for row_idx, row in subset.iterrows():
+            warn = warn + 1 if row["status"] == "warning" else 0
+            hard = hard + 1 if row["status"] in {"hard_breach", "missing_data"} else 0
+            history.at[row_idx, "warning_streak"] = warn
+            history.at[row_idx, "hard_breach_streak"] = hard
+    return history.sort_values(["latest_bar_date", "metric"]).reset_index(drop=True)
+
+
+def _build_weekly_summary(observation_log: pd.DataFrame, change_log: pd.DataFrame, divergence_log: pd.DataFrame, monitor_history: pd.DataFrame) -> pd.DataFrame:
+    if observation_log.empty:
+        return pd.DataFrame()
+    unique_dates = sorted(observation_log["latest_bar_date"].unique())[-5:]
+    obs = observation_log[observation_log["latest_bar_date"].isin(unique_dates)].copy()
+    changes = change_log[change_log["latest_bar_date"].isin(unique_dates)].copy() if not change_log.empty else pd.DataFrame()
+    divergence = divergence_log[divergence_log["latest_bar_date"].isin(unique_dates)].copy() if not divergence_log.empty else pd.DataFrame()
+    monitor = monitor_history[monitor_history["latest_bar_date"].isin(unique_dates)].copy() if not monitor_history.empty else pd.DataFrame()
+
+    rows: list[dict[str, object]] = []
+    for branch, group in obs.groupby("branch"):
+        rows.append(
+            {
+                "window_start": unique_dates[0],
+                "window_end": unique_dates[-1],
+                "observation_days": int(len(group)),
+                "branch": branch,
+                "days_in_position": int(group["in_position"].astype(bool).sum()),
+                "latest_real_event_changed_count": int(
+                    0
+                    if changes.empty
+                    else len(changes[(changes["branch"] == branch) & (changes["change_type"] == "latest_real_event_changed")])
+                ),
+                "new_event_days": int(group["events_today_count"].fillna(0).astype(int).gt(0).sum()),
+                "warning_days": 0,
+                "hard_breach_days": 0,
+                "material_divergence_days": 0,
+            }
+        )
+    rows.append(
+        {
+            "window_start": unique_dates[0],
+            "window_end": unique_dates[-1],
+            "observation_days": int(len(unique_dates)),
+            "branch": "system_monitor",
+            "days_in_position": 0,
+            "latest_real_event_changed_count": 0,
+            "new_event_days": 0,
+            "warning_days": int(0 if monitor.empty else monitor[monitor["status"] == "warning"]["latest_bar_date"].nunique()),
+            "hard_breach_days": int(0 if monitor.empty else monitor[monitor["status"] == "hard_breach"]["latest_bar_date"].nunique()),
+            "material_divergence_days": int(
+                0 if divergence.empty else divergence["divergence_level"].isin(["material", "review_required"]).sum()
+            ),
+        }
+    )
+    return pd.DataFrame(rows)
+
+
+def main() -> None:
+    base_dir = Path(__file__).resolve().parent
+    forward_dir = base_dir / "forward_reports"
+    forward_dir.mkdir(exist_ok=True)
+
+    daily.main()
+
+    branch_status = _load_csv(base_dir / "dragon_daily_branch_status.csv")
+    monitor_snapshot = _load_csv(base_dir / "dragon_daily_monitor_snapshot.csv")
+    manifest_path = base_dir / "dragon_daily_rc1_manifest.json"
+    manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
+    latest_bar_date = str(manifest["latest_bar_date"])
+    request_date = str(manifest["as_of_request_date"])
+    run_ts = datetime.now().isoformat(timespec="seconds")
+
+    monitor_summary = _build_monitor_summary(monitor_snapshot)
+
+    observation_rows = branch_status.copy()
+    observation_rows["run_timestamp"] = run_ts
+    observation_rows["request_date"] = request_date
+    observation_rows["latest_bar_date"] = latest_bar_date
+    observation_rows["monitor_warning_count"] = int(monitor_summary["warning_count"])
+    observation_rows["monitor_hard_breach_count"] = int(monitor_summary["hard_breach_count"])
+    observation_rows["monitor_missing_data_count"] = int(monitor_summary["missing_data_count"])
+    observation_rows["latest_real_event"] = observation_rows.apply(_branch_event_signature, axis=1)
+
+    observation_cols = [
+        "run_timestamp",
+        "request_date",
+        "latest_bar_date",
+        "branch",
+        "in_position",
+        "latest_real_event_date",
+        "latest_real_event_side",
+        "latest_real_event_reason",
+        "latest_real_event",
+        "events_today_count",
+        "events_today",
+        "latest_close",
+        "latest_a1",
+        "latest_b1",
+        "latest_c1",
+        "open_entry_date",
+        "open_entry_reason",
+        "open_holding_days",
+        "open_return_pct",
+        "monitor_warning_count",
+        "monitor_hard_breach_count",
+        "monitor_missing_data_count",
+    ]
+    observation_rows = observation_rows[observation_cols]
+
+    observation_log_path = base_dir / "dragon_forward_observation_log.csv"
+    existing_obs = _load_csv(observation_log_path)
+    observation_log = _append_dedup(existing_obs, observation_rows, ["latest_bar_date", "branch"])
+    _write_csv(observation_log, observation_log_path)
+
+    state_path = base_dir / "dragon_forward_observation_state.json"
+    prev_state = _load_state(state_path)
+
+    change_log_path = base_dir / "dragon_signal_change_log.csv"
+    existing_changes = _load_csv(change_log_path)
+    change_rows = _build_signal_changes(branch_status, prev_state, latest_bar_date, monitor_summary)
+    if not change_rows.empty:
+        change_log = _append_dedup(existing_changes, change_rows, ["latest_bar_date", "branch", "change_type", "new_value"])
+    else:
+        change_log = existing_changes
+    _write_csv(change_log, change_log_path)
+
+    refined = branch_status[branch_status["branch"] == "alpha_first_glued_refined_hot_cap"].iloc[0]
+    control = branch_status[branch_status["branch"] == "alpha_first_selective_veto"].iloc[0]
+    actuals = {
+        row["metric"]: row["actual_value"]
+        for _, row in monitor_snapshot.iterrows()
+        if pd.notna(row["actual_value"])
+    }
+    divergence_row = {
+        "latest_bar_date": latest_bar_date,
+        "request_date": request_date,
+        "same_position_flag": bool(refined["in_position"]) == bool(control["in_position"]),
+        "same_latest_real_event_flag": _branch_event_signature(refined) == _branch_event_signature(control),
+        "refined_in_position": bool(refined["in_position"]),
+        "control_in_position": bool(control["in_position"]),
+        "refined_latest_event": _branch_event_signature(refined),
+        "control_latest_event": _branch_event_signature(control),
+        "next_open_avg_return_delta": float(actuals.get("next_open_avg_return_delta_vs_control", float("nan"))),
+        "next_open_pf_delta": float(actuals.get("next_open_profit_factor_delta_vs_control", float("nan"))),
+        "next_open_max_drawdown_refined": float(actuals.get("next_open_max_drawdown", float("nan"))),
+        "next_open_max_loss_streak_refined": int(actuals.get("next_open_max_loss_streak", 0)),
+        "warning_count": int(monitor_summary["warning_count"]),
+        "hard_breach_count": int(monitor_summary["hard_breach_count"]),
+        "missing_data_count": int(monitor_summary["missing_data_count"]),
+    }
+    divergence_row["divergence_level"] = _divergence_level(divergence_row)
+    divergence_df = pd.DataFrame([divergence_row])
+    divergence_log_path = base_dir / "dragon_branch_divergence_log.csv"
+    existing_div = _load_csv(divergence_log_path)
+    divergence_log = _append_dedup(existing_div, divergence_df, ["latest_bar_date"])
+    _write_csv(divergence_log, divergence_log_path)
+
+    monitor_history_path = base_dir / "dragon_monitor_history.csv"
+    existing_monitor = _load_csv(monitor_history_path)
+    monitor_history = _update_monitor_history(existing_monitor, monitor_snapshot, latest_bar_date)
+    _write_csv(monitor_history, monitor_history_path)
+
+    weekly_summary = _build_weekly_summary(observation_log, change_log, divergence_log, monitor_history)
+    weekly_summary_path = base_dir / "dragon_forward_weekly_summary.csv"
+    _write_csv(weekly_summary, weekly_summary_path)
+
+    latest_changes = change_log[change_log["latest_bar_date"] == latest_bar_date].copy() if not change_log.empty else pd.DataFrame()
+    change_lines = [
+        "# Dragon Signal Change Review",
+        "",
+        f"- latest_bar_date: `{latest_bar_date}`",
+        f"- change_count: `{len(latest_changes)}`",
+        "",
+    ]
+    if latest_changes.empty:
+        change_lines.append("- No state-change record was generated for the latest bar.")
+    else:
+        for _, row in latest_changes.iterrows():
+            change_lines.extend(
+                [
+                    f"## {row['branch']} / {row['change_type']}",
+                    f"- old: `{row['old_value']}`",
+                    f"- new: `{row['new_value']}`",
+                    f"- reason: {row['reason']}",
+                    f"- event_context: `{row['event_context']}`",
+                    f"- indicator_context: `{row['indicator_context']}`",
+                    "",
+                ]
+            )
+    (base_dir / "dragon_signal_change_review.md").write_text("\n".join(change_lines) + "\n", encoding="utf-8")
+
+    div_lines = [
+        "# Dragon Branch Divergence Report",
+        "",
+        f"- latest_bar_date: `{latest_bar_date}`",
+        f"- divergence_level: `{divergence_row['divergence_level']}`",
+        f"- same_position_flag: `{divergence_row['same_position_flag']}`",
+        f"- same_latest_real_event_flag: `{divergence_row['same_latest_real_event_flag']}`",
+        f"- refined_latest_event: `{divergence_row['refined_latest_event']}`",
+        f"- control_latest_event: `{divergence_row['control_latest_event']}`",
+        f"- next_open_avg_return_delta: `{daily._format_pct(float(divergence_row['next_open_avg_return_delta']))}`",
+        f"- next_open_pf_delta: `{daily._format_num(float(divergence_row['next_open_pf_delta']))}`",
+        f"- warning_count: `{divergence_row['warning_count']}`",
+        f"- hard_breach_count: `{divergence_row['hard_breach_count']}`",
+        f"- missing_data_count: `{divergence_row['missing_data_count']}`",
+        "",
+        "## Recent Log",
+    ]
+    for _, row in divergence_log.tail(10).iterrows():
+        div_lines.append(
+            f"- `{row['latest_bar_date']}`: level `{row['divergence_level']}`, same_position `{row['same_position_flag']}`, same_event `{row['same_latest_real_event_flag']}`"
+        )
+    (base_dir / "dragon_branch_divergence_report.md").write_text("\n".join(div_lines) + "\n", encoding="utf-8")
+
+    health_lines = [
+        "# Dragon Monitor Health Report",
+        "",
+        f"- latest_bar_date: `{latest_bar_date}`",
+        f"- warning_count: `{monitor_summary['warning_count']}`",
+        f"- hard_breach_count: `{monitor_summary['hard_breach_count']}`",
+        f"- missing_data_count: `{monitor_summary['missing_data_count']}`",
+        "",
+        "## Latest Metrics",
+    ]
+    latest_monitor = monitor_history[monitor_history["latest_bar_date"] == latest_bar_date].copy()
+    for _, row in latest_monitor.iterrows():
+        health_lines.append(
+            f"- `{row['metric']}`: actual `{row['actual_value']}` | status `{row['status']}` | warning_streak `{int(row['warning_streak'])}` | hard_breach_streak `{int(row['hard_breach_streak'])}`"
+        )
+    (base_dir / "dragon_monitor_health_report.md").write_text("\n".join(health_lines) + "\n", encoding="utf-8")
+
+    weekly_lines = [
+        "# Dragon Forward Weekly Review",
+        "",
+        f"- latest_window_end: `{latest_bar_date}`",
+        "",
+    ]
+    if weekly_summary.empty:
+        weekly_lines.append("- Weekly summary is empty.")
+    else:
+        for _, row in weekly_summary.iterrows():
+            weekly_lines.extend(
+                [
+                    f"## {row['branch']}",
+                    f"- window: `{row['window_start']}` -> `{row['window_end']}`",
+                    f"- observation_days: `{int(row['observation_days'])}`",
+                    f"- days_in_position: `{int(row['days_in_position'])}`",
+                    f"- latest_real_event_changed_count: `{int(row['latest_real_event_changed_count'])}`",
+                    f"- new_event_days: `{int(row['new_event_days'])}`",
+                    f"- warning_days: `{int(row['warning_days'])}`",
+                    f"- hard_breach_days: `{int(row['hard_breach_days'])}`",
+                    f"- material_divergence_days: `{int(row['material_divergence_days'])}`",
+                    "",
+                ]
+            )
+    (base_dir / "dragon_forward_weekly_review.md").write_text("\n".join(weekly_lines) + "\n", encoding="utf-8")
+
+    state_payload = {
+        "last_run_timestamp": run_ts,
+        "request_date": request_date,
+        "latest_bar_date": latest_bar_date,
+        "branches": {
+            str(row["branch"]): {
+                key: _json_default(row[key])
+                for key in branch_status.columns
+            }
+            for _, row in branch_status.iterrows()
+        },
+        "monitor_summary": monitor_summary,
+        "divergence": {key: _json_default(value) for key, value in divergence_row.items()},
+    }
+    state_path.write_text(json.dumps(state_payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
+
+    archive_paths = [
+        observation_log_path,
+        change_log_path,
+        divergence_log_path,
+        monitor_history_path,
+        weekly_summary_path,
+    ]
+    for path in archive_paths:
+        archived = forward_dir / f"{path.stem}_{latest_bar_date}{path.suffix}"
+        if path.exists():
+            path_df = _load_csv(path)
+            _write_csv(path_df, archived)
+
+    html_reports.main()
+
+
+if __name__ == "__main__":
+    main()

+ 408 - 0
research/dragon/v2/dragon_glued_alpha_candidate.py

@@ -0,0 +1,408 @@
+from __future__ import annotations
+
+import json
+from dataclasses import asdict
+from pathlib import Path
+
+import pandas as pd
+
+from dragon_branch_configs import (
+    alpha_first_glued_selective_veto_config,
+    alpha_first_selective_veto_config,
+    workbook_preserving_config,
+)
+from dragon_strategy import DragonRuleEngine
+from dragon_strategy_config import StrategyConfig
+
+
+def _load_indicator_snapshot(base_dir: Path) -> pd.DataFrame:
+    df = pd.read_csv(base_dir / "dragon_indicator_snapshot.csv", encoding="utf-8-sig")
+    df["date"] = pd.to_datetime(df["date"])
+    return df.set_index("date", drop=False)
+
+
+def _load_true_trade_events(base_dir: Path) -> pd.DataFrame:
+    return pd.read_csv(base_dir / "true_trade_events.csv", encoding="utf-8-sig")
+
+
+def _profit_factor(series: pd.Series) -> float:
+    gross_profit = series[series > 0].sum()
+    gross_loss = -series[series < 0].sum()
+    if gross_loss == 0:
+        return float("inf") if gross_profit > 0 else 0.0
+    return float(gross_profit / gross_loss)
+
+
+def _holding_bucket(days: int) -> str:
+    if days <= 5:
+        return "00-05d"
+    if days <= 10:
+        return "06-10d"
+    if days <= 20:
+        return "11-20d"
+    if days <= 40:
+        return "21-40d"
+    return "41d+"
+
+
+def _format_pct(value: float) -> str:
+    if pd.isna(value):
+        return "NA"
+    if value == float("inf"):
+        return "inf"
+    return f"{value:.2%}"
+
+
+def _format_num(value: float) -> str:
+    if pd.isna(value):
+        return "NA"
+    if value == float("inf"):
+        return "inf"
+    return f"{value:.2f}"
+
+
+def _event_match(strategy_events: pd.DataFrame, workbook_events: pd.DataFrame, side: str) -> tuple[int, int, int]:
+    wb = set(workbook_events[(workbook_events["side"] == side) & (workbook_events["layer"] == "real_trade")]["date"])
+    st = set(strategy_events[(strategy_events["side"] == side) & (strategy_events["layer"] == "real_trade")]["date"])
+    return len(wb & st), len(wb - st), len(st - wb)
+
+
+def _segment_stats(df: pd.DataFrame) -> dict[str, float | int]:
+    if df.empty:
+        return {
+            "trades": 0,
+            "win_rate": float("nan"),
+            "avg_return": float("nan"),
+            "profit_factor": float("nan"),
+            "compounded_return": float("nan"),
+        }
+    returns = df["return_pct"].astype(float)
+    return {
+        "trades": int(len(df)),
+        "win_rate": float((returns > 0).mean()),
+        "avg_return": float(returns.mean()),
+        "profit_factor": _profit_factor(returns),
+        "compounded_return": float((1.0 + returns).prod() - 1.0),
+    }
+
+
+def _build_walk_forward(trades: pd.DataFrame, branch_name: str) -> pd.DataFrame:
+    years = sorted(int(year) for year in trades["sell_year"].unique())
+    rows: list[dict[str, object]] = []
+    for idx, test_year in enumerate(years):
+        if idx >= 1:
+            train_years = years[:idx]
+            train_df = trades[trades["sell_year"].isin(train_years)]
+            test_df = trades[trades["sell_year"] == test_year]
+            rows.append(
+                {
+                    "branch": branch_name,
+                    "scheme": "anchored_expanding",
+                    "train_start_year": train_years[0],
+                    "train_end_year": train_years[-1],
+                    "test_year": test_year,
+                    **{f"train_{k}": v for k, v in _segment_stats(train_df).items()},
+                    **{f"test_{k}": v for k, v in _segment_stats(test_df).items()},
+                }
+            )
+        if idx >= 3:
+            train_years = years[idx - 3 : idx]
+            train_df = trades[trades["sell_year"].isin(train_years)]
+            test_df = trades[trades["sell_year"] == test_year]
+            rows.append(
+                {
+                    "branch": branch_name,
+                    "scheme": "rolling_3y",
+                    "train_start_year": train_years[0],
+                    "train_end_year": train_years[-1],
+                    "test_year": test_year,
+                    **{f"train_{k}": v for k, v in _segment_stats(train_df).items()},
+                    **{f"test_{k}": v for k, v in _segment_stats(test_df).items()},
+                }
+            )
+    return pd.DataFrame(rows)
+
+
+def _run_branch(
+    name: str,
+    config: StrategyConfig,
+    indicator_df: pd.DataFrame,
+    workbook_events: pd.DataFrame,
+    first_date: str,
+    last_date: str,
+) -> tuple[dict[str, object], pd.DataFrame, pd.DataFrame, pd.DataFrame]:
+    engine = DragonRuleEngine(config=config)
+    events, trades = engine.run(indicator_df)
+    events = events[(events["date"] >= first_date) & (events["date"] <= last_date)].copy()
+    trades = trades[
+        (trades["buy_date"] >= first_date)
+        & (trades["buy_date"] <= last_date)
+        & (trades["sell_date"] >= first_date)
+        & (trades["sell_date"] <= last_date)
+    ].copy()
+
+    buy_overlap, buy_missing, buy_extra = _event_match(events, workbook_events, "BUY")
+    sell_overlap, sell_missing, sell_extra = _event_match(events, workbook_events, "SELL")
+
+    trades["branch"] = name
+    trades["sell_dt"] = pd.to_datetime(trades["sell_date"])
+    trades["sell_year"] = trades["sell_dt"].dt.year.astype(int)
+    trades["holding_bucket"] = trades["holding_days"].astype(int).map(_holding_bucket)
+
+    returns = trades["return_pct"].astype(float) if not trades.empty else pd.Series(dtype=float)
+    summary = {
+        "branch": name,
+        "trades": int(len(trades)),
+        "win_rate": float((returns > 0).mean()) if not trades.empty else float("nan"),
+        "avg_return": float(returns.mean()) if not trades.empty else float("nan"),
+        "median_return": float(returns.median()) if not trades.empty else float("nan"),
+        "profit_factor": _profit_factor(returns) if not trades.empty else float("nan"),
+        "real_buy_overlap": int(buy_overlap),
+        "real_buy_missing": int(buy_missing),
+        "real_buy_extra": int(buy_extra),
+        "real_sell_overlap": int(sell_overlap),
+        "real_sell_missing": int(sell_missing),
+        "real_sell_extra": int(sell_extra),
+        "short_00_05d_avg_return": float(trades[trades["holding_bucket"] == "00-05d"]["return_pct"].mean()),
+        "short_06_10d_avg_return": float(trades[trades["holding_bucket"] == "06-10d"]["return_pct"].mean()),
+    }
+
+    bucket_rows: list[dict[str, object]] = []
+    for bucket, group in trades.groupby("holding_bucket", dropna=False):
+        bucket_rows.append(
+            {
+                "branch": name,
+                "holding_bucket": bucket,
+                "trades": int(len(group)),
+                "win_rate": float((group["return_pct"] > 0).mean()),
+                "avg_return": float(group["return_pct"].mean()),
+                "profit_factor": _profit_factor(group["return_pct"]),
+            }
+        )
+    holding_df = pd.DataFrame(bucket_rows).sort_values("holding_bucket")
+    walk_forward_df = _build_walk_forward(trades, name)
+    return summary, trades, holding_df, walk_forward_df
+
+
+def _config_snapshot(config: StrategyConfig) -> dict[str, object]:
+    snapshot = asdict(config)
+    snapshot["disabled_rules"] = sorted(config.disabled_rules)
+    return snapshot
+
+
+def _trade_set(df: pd.DataFrame) -> set[tuple[str, str, str, str]]:
+    return set(zip(df["buy_date"], df["sell_date"], df["buy_reason"], df["sell_reason"]))
+
+
+def _trade_diff(source: pd.DataFrame, target: pd.DataFrame, removed_label: str, added_label: str) -> pd.DataFrame:
+    source_set = _trade_set(source)
+    target_set = _trade_set(target)
+    rows: list[dict[str, object]] = []
+    for row in sorted(source_set - target_set):
+        rows.append(
+            {
+                "change_type": removed_label,
+                "buy_date": row[0],
+                "sell_date": row[1],
+                "buy_reason": row[2],
+                "sell_reason": row[3],
+            }
+        )
+    for row in sorted(target_set - source_set):
+        rows.append(
+            {
+                "change_type": added_label,
+                "buy_date": row[0],
+                "sell_date": row[1],
+                "buy_reason": row[2],
+                "sell_reason": row[3],
+            }
+        )
+    return pd.DataFrame(rows)
+
+
+def _wf_stats(df: pd.DataFrame, scheme: str) -> tuple[int, int, float]:
+    view = df[df["scheme"] == scheme]
+    positive = int((view["test_avg_return"] > 0).sum()) if not view.empty else 0
+    total = int(len(view))
+    avg_oos = float(view["test_avg_return"].mean()) if not view.empty else float("nan")
+    return positive, total, avg_oos
+
+
+def main() -> None:
+    base_dir = Path(__file__).resolve().parent
+    indicator_df = _load_indicator_snapshot(base_dir)
+    workbook_events = _load_true_trade_events(base_dir)
+    first_date = workbook_events["date"].min()
+    last_date = workbook_events["date"].max()
+
+    branches = [
+        ("workbook_preserving", workbook_preserving_config()),
+        ("alpha_first_selective_veto", alpha_first_selective_veto_config()),
+        ("alpha_first_glued_selective_veto", alpha_first_glued_selective_veto_config()),
+    ]
+
+    summaries: list[dict[str, object]] = []
+    trades_by_branch: dict[str, pd.DataFrame] = {}
+    holding_frames: list[pd.DataFrame] = []
+    walk_frames: list[pd.DataFrame] = []
+
+    for name, config in branches:
+        summary, trades, holding_df, walk_df = _run_branch(
+            name,
+            config,
+            indicator_df,
+            workbook_events,
+            first_date,
+            last_date,
+        )
+        summaries.append(summary)
+        trades_by_branch[name] = trades
+        holding_frames.append(holding_df)
+        walk_frames.append(walk_df)
+
+    summary_df = pd.DataFrame(summaries)
+    summary_df.to_csv(base_dir / "dragon_glued_alpha_candidate_summary.csv", index=False, encoding="utf-8-sig")
+
+    branch_lookup = {row["branch"]: row for row in summaries}
+    workbook_row = branch_lookup["workbook_preserving"]
+    alpha_row = branch_lookup["alpha_first_selective_veto"]
+    glued_row = branch_lookup["alpha_first_glued_selective_veto"]
+
+    comparison_rows: list[dict[str, object]] = []
+    for metric in [
+        "trades",
+        "win_rate",
+        "avg_return",
+        "median_return",
+        "profit_factor",
+        "real_buy_overlap",
+        "real_sell_overlap",
+        "short_00_05d_avg_return",
+        "short_06_10d_avg_return",
+    ]:
+        comparison_rows.append(
+            {
+                "metric": metric,
+                "workbook_preserving": workbook_row[metric],
+                "alpha_first_selective_veto": alpha_row[metric],
+                "alpha_first_glued_selective_veto": glued_row[metric],
+                "delta_glued_minus_alpha": glued_row[metric] - alpha_row[metric],
+                "delta_glued_minus_workbook": glued_row[metric] - workbook_row[metric],
+            }
+        )
+    pd.DataFrame(comparison_rows).to_csv(
+        base_dir / "dragon_glued_alpha_candidate_comparison.csv",
+        index=False,
+        encoding="utf-8-sig",
+    )
+
+    pd.concat(holding_frames, ignore_index=True).to_csv(
+        base_dir / "dragon_glued_alpha_candidate_holding_buckets.csv",
+        index=False,
+        encoding="utf-8-sig",
+    )
+    combined_walk = pd.concat(walk_frames, ignore_index=True)
+    combined_walk.to_csv(
+        base_dir / "dragon_glued_alpha_candidate_walk_forward.csv",
+        index=False,
+        encoding="utf-8-sig",
+    )
+
+    diff_vs_alpha = _trade_diff(
+        trades_by_branch["alpha_first_selective_veto"],
+        trades_by_branch["alpha_first_glued_selective_veto"],
+        "removed_from_glued_candidate_vs_alpha",
+        "added_in_glued_candidate_vs_alpha",
+    )
+    diff_vs_workbook = _trade_diff(
+        trades_by_branch["workbook_preserving"],
+        trades_by_branch["alpha_first_glued_selective_veto"],
+        "removed_from_glued_candidate_vs_workbook",
+        "added_in_glued_candidate_vs_workbook",
+    )
+    diff_vs_alpha.to_csv(
+        base_dir / "dragon_glued_alpha_candidate_trade_diff_vs_alpha.csv",
+        index=False,
+        encoding="utf-8-sig",
+    )
+    diff_vs_workbook.to_csv(
+        base_dir / "dragon_glued_alpha_candidate_trade_diff_vs_workbook.csv",
+        index=False,
+        encoding="utf-8-sig",
+    )
+
+    (base_dir / "dragon_glued_alpha_candidate_config_snapshot.json").write_text(
+        json.dumps(_config_snapshot(alpha_first_glued_selective_veto_config()), indent=2, ensure_ascii=False) + "\n",
+        encoding="utf-8",
+    )
+
+    wb_anchor_pos, wb_anchor_total, wb_anchor_avg = _wf_stats(combined_walk[combined_walk["branch"] == "workbook_preserving"], "anchored_expanding")
+    af_anchor_pos, af_anchor_total, af_anchor_avg = _wf_stats(
+        combined_walk[combined_walk["branch"] == "alpha_first_selective_veto"],
+        "anchored_expanding",
+    )
+    glued_anchor_pos, glued_anchor_total, glued_anchor_avg = _wf_stats(
+        combined_walk[combined_walk["branch"] == "alpha_first_glued_selective_veto"],
+        "anchored_expanding",
+    )
+    wb_roll_pos, wb_roll_total, wb_roll_avg = _wf_stats(combined_walk[combined_walk["branch"] == "workbook_preserving"], "rolling_3y")
+    af_roll_pos, af_roll_total, af_roll_avg = _wf_stats(
+        combined_walk[combined_walk["branch"] == "alpha_first_selective_veto"],
+        "rolling_3y",
+    )
+    glued_roll_pos, glued_roll_total, glued_roll_avg = _wf_stats(
+        combined_walk[combined_walk["branch"] == "alpha_first_glued_selective_veto"],
+        "rolling_3y",
+    )
+
+    removed_vs_alpha = diff_vs_alpha[diff_vs_alpha["change_type"] == "removed_from_glued_candidate_vs_alpha"].copy()
+    added_vs_alpha = diff_vs_alpha[diff_vs_alpha["change_type"] == "added_in_glued_candidate_vs_alpha"].copy()
+    removed_glued_count = int((removed_vs_alpha["buy_reason"] == "glued_buy").sum()) if not removed_vs_alpha.empty else 0
+    added_replacement_text = "none"
+    if not added_vs_alpha.empty:
+        added_row = added_vs_alpha.iloc[0]
+        added_replacement_text = (
+            f"{added_row['buy_date']} -> {added_row['sell_date']} / "
+            f"{added_row['buy_reason']} -> {added_row['sell_reason']}"
+        )
+
+    lines = [
+        "# Dragon Glued Alpha Candidate Review",
+        "",
+        "## Branches",
+        "- `workbook_preserving`: official reconstruction baseline.",
+        "- `alpha_first_selective_veto`: current formal alpha-first branch.",
+        "- `alpha_first_glued_selective_veto`: alpha-first branch plus narrow glued hot/low veto.",
+        "",
+        "## Headline Comparison",
+        f"- workbook_preserving: trades `{int(workbook_row['trades'])}`, avg_return `{_format_pct(float(workbook_row['avg_return']))}`, profit_factor `{_format_num(float(workbook_row['profit_factor']))}`, real BUY / SELL `{int(workbook_row['real_buy_overlap'])}/{int(workbook_row['real_sell_overlap'])}`",
+        f"- alpha_first_selective_veto: trades `{int(alpha_row['trades'])}`, avg_return `{_format_pct(float(alpha_row['avg_return']))}`, profit_factor `{_format_num(float(alpha_row['profit_factor']))}`, real BUY / SELL `{int(alpha_row['real_buy_overlap'])}/{int(alpha_row['real_sell_overlap'])}`",
+        f"- alpha_first_glued_selective_veto: trades `{int(glued_row['trades'])}`, avg_return `{_format_pct(float(glued_row['avg_return']))}`, profit_factor `{_format_num(float(glued_row['profit_factor']))}`, real BUY / SELL `{int(glued_row['real_buy_overlap'])}/{int(glued_row['real_sell_overlap'])}`",
+        "",
+        "## Short-Holding Impact",
+        f"- `00-05d`: workbook `{_format_pct(float(workbook_row['short_00_05d_avg_return']))}`, alpha `{_format_pct(float(alpha_row['short_00_05d_avg_return']))}`, glued candidate `{_format_pct(float(glued_row['short_00_05d_avg_return']))}`",
+        f"- `06-10d`: workbook `{_format_pct(float(workbook_row['short_06_10d_avg_return']))}`, alpha `{_format_pct(float(alpha_row['short_06_10d_avg_return']))}`, glued candidate `{_format_pct(float(glued_row['short_06_10d_avg_return']))}`",
+        "",
+        "## Walk-Forward Comparison",
+        f"- Anchored expanding: workbook `{wb_anchor_pos}/{wb_anchor_total}` positive, avg `{_format_pct(wb_anchor_avg)}`; alpha `{af_anchor_pos}/{af_anchor_total}`, avg `{_format_pct(af_anchor_avg)}`; glued `{glued_anchor_pos}/{glued_anchor_total}`, avg `{_format_pct(glued_anchor_avg)}`",
+        f"- Rolling 3Y: workbook `{wb_roll_pos}/{wb_roll_total}` positive, avg `{_format_pct(wb_roll_avg)}`; alpha `{af_roll_pos}/{af_roll_total}`, avg `{_format_pct(af_roll_avg)}`; glued `{glued_roll_pos}/{glued_roll_total}`, avg `{_format_pct(glued_roll_avg)}`",
+        "",
+        "## Trade-Diff Summary",
+        f"- glued candidate vs alpha-first: removed `{int((diff_vs_alpha['change_type'] == 'removed_from_glued_candidate_vs_alpha').sum())}`, added `{int((diff_vs_alpha['change_type'] == 'added_in_glued_candidate_vs_alpha').sum())}`",
+        f"- glued candidate vs workbook: removed `{int((diff_vs_workbook['change_type'] == 'removed_from_glued_candidate_vs_workbook').sum())}`, added `{int((diff_vs_workbook['change_type'] == 'added_in_glued_candidate_vs_workbook').sum())}`",
+        f"- Removed vs alpha-first are almost entirely the intended target: `{removed_glued_count}` of `{int(len(removed_vs_alpha))}` are `glued_buy` trades.",
+        f"- Added vs alpha-first is only a small fallback reroute: `{added_replacement_text}`.",
+        "",
+        "## Quant Judgment",
+        "- The glued candidate clearly improves in-sample trade quality and short-holding drag beyond the current alpha-first branch.",
+        "- The cost is no longer narrow: overlap drops materially from `102/101` to `90/89`, which is a much larger governance step than the current deep-oversold selective veto branch.",
+        "- This means the glued candidate is a credible research branch, but not yet a clean replacement for the current formal alpha-first baseline.",
+        "- Recommended governance: keep `alpha_first_selective_veto` as the official alpha-first baseline; treat `alpha_first_glued_selective_veto` as the next research branch for further residual attribution and out-of-sample stability review.",
+    ]
+
+    (base_dir / "dragon_glued_alpha_candidate_review.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+
+if __name__ == "__main__":
+    main()

+ 245 - 0
research/dragon/v2/dragon_glued_refine_experiments.py

@@ -0,0 +1,245 @@
+from __future__ import annotations
+
+import json
+from dataclasses import asdict
+from pathlib import Path
+
+import pandas as pd
+
+from dragon_branch_configs import alpha_first_selective_veto_config
+from dragon_strategy import DragonRuleEngine
+from dragon_strategy_config import StrategyConfig
+
+
+def _load_indicator_snapshot(base_dir: Path) -> pd.DataFrame:
+    df = pd.read_csv(base_dir / "dragon_indicator_snapshot.csv", encoding="utf-8-sig")
+    df["date"] = pd.to_datetime(df["date"])
+    return df.set_index("date", drop=False)
+
+
+def _load_true_trade_events(base_dir: Path) -> pd.DataFrame:
+    return pd.read_csv(base_dir / "true_trade_events.csv", encoding="utf-8-sig")
+
+
+def _profit_factor(series: pd.Series) -> float:
+    gross_profit = series[series > 0].sum()
+    gross_loss = -series[series < 0].sum()
+    if gross_loss == 0:
+        return float("inf") if gross_profit > 0 else 0.0
+    return float(gross_profit / gross_loss)
+
+
+def _holding_bucket(days: int) -> str:
+    if days <= 5:
+        return "00-05d"
+    if days <= 10:
+        return "06-10d"
+    if days <= 20:
+        return "11-20d"
+    if days <= 40:
+        return "21-40d"
+    return "41d+"
+
+
+def _event_match(strategy_events: pd.DataFrame, workbook_events: pd.DataFrame, side: str) -> tuple[int, int]:
+    wb = set(workbook_events[(workbook_events["side"] == side) & (workbook_events["layer"] == "real_trade")]["date"])
+    st = set(strategy_events[(strategy_events["side"] == side) & (strategy_events["layer"] == "real_trade")]["date"])
+    return len(wb & st), len(st - wb)
+
+
+def _run_branch(
+    label: str,
+    config: StrategyConfig,
+    indicator_df: pd.DataFrame,
+    workbook_events: pd.DataFrame,
+    first_date: str,
+    last_date: str,
+) -> tuple[dict[str, object], pd.DataFrame]:
+    engine = DragonRuleEngine(config)
+    events, trades = engine.run(indicator_df)
+    events = events[(events["date"] >= first_date) & (events["date"] <= last_date)].copy()
+    trades = trades[
+        (trades["buy_date"] >= first_date)
+        & (trades["buy_date"] <= last_date)
+        & (trades["sell_date"] >= first_date)
+        & (trades["sell_date"] <= last_date)
+    ].copy()
+    trades["holding_bucket"] = trades["holding_days"].astype(int).map(_holding_bucket)
+    short = trades[trades["holding_bucket"].isin({"00-05d", "06-10d"})].copy()
+
+    buy_overlap, buy_extra = _event_match(events, workbook_events, "BUY")
+    sell_overlap, sell_extra = _event_match(events, workbook_events, "SELL")
+
+    row = {
+        "experiment": label,
+        "trades": int(len(trades)),
+        "win_rate": float((trades["return_pct"] > 0).mean()) if not trades.empty else float("nan"),
+        "avg_return": float(trades["return_pct"].mean()) if not trades.empty else float("nan"),
+        "median_return": float(trades["return_pct"].median()) if not trades.empty else float("nan"),
+        "profit_factor": _profit_factor(trades["return_pct"]) if not trades.empty else float("nan"),
+        "real_buy_overlap": int(buy_overlap),
+        "real_buy_extra": int(buy_extra),
+        "real_sell_overlap": int(sell_overlap),
+        "real_sell_extra": int(sell_extra),
+        "short_trade_count": int(len(short)),
+        "short_avg_return": float(short["return_pct"].mean()) if not short.empty else float("nan"),
+        "short_00_05d_avg_return": float(short[short["holding_bucket"] == "00-05d"]["return_pct"].mean()),
+        "short_06_10d_avg_return": float(short[short["holding_bucket"] == "06-10d"]["return_pct"].mean()),
+    }
+    diff = trades[["buy_date", "sell_date", "buy_reason", "sell_reason", "holding_days", "return_pct"]].copy()
+    diff["experiment"] = label
+    return row, diff
+
+
+def main() -> None:
+    base_dir = Path(__file__).resolve().parent
+    indicator_df = _load_indicator_snapshot(base_dir)
+    workbook_events = _load_true_trade_events(base_dir)
+    first_date = workbook_events["date"].min()
+    last_date = workbook_events["date"].max()
+
+    baseline = alpha_first_selective_veto_config()
+    experiments = [
+        ("baseline_alpha_first", baseline),
+        (
+            "glued_veto_low_weak_range",
+            baseline.with_updates(
+                glued_selective_low_c1_min=23.0,
+                glued_selective_low_c1_max=28.0,
+                glued_selective_low_b1_max=0.02,
+            ),
+        ),
+        (
+            "glued_veto_hot_and_low",
+            baseline.with_updates(
+                glued_selective_hot_c1_min=40.0,
+                glued_selective_hot_b1_min=0.10,
+                glued_selective_low_c1_min=23.0,
+                glued_selective_low_c1_max=28.0,
+                glued_selective_low_b1_max=0.02,
+            ),
+        ),
+        (
+            "glued_veto_hot_cap75_and_low",
+            baseline.with_updates(
+                glued_selective_hot_c1_min=40.0,
+                glued_selective_hot_c1_max=75.0,
+                glued_selective_hot_b1_min=0.10,
+                glued_selective_low_c1_min=23.0,
+                glued_selective_low_c1_max=28.0,
+                glued_selective_low_b1_max=0.02,
+            ),
+        ),
+    ]
+    experiment_configs = {label: config for label, config in experiments}
+
+    rows: list[dict[str, object]] = []
+    diffs: list[pd.DataFrame] = []
+    trade_sets: dict[str, set[tuple[str, str, str, str]]] = {}
+    for label, config in experiments:
+        row, diff = _run_branch(label, config, indicator_df, workbook_events, first_date, last_date)
+        rows.append(row)
+        diffs.append(diff)
+        trade_sets[label] = set(map(tuple, diff[["buy_date", "sell_date", "buy_reason", "sell_reason"]].values.tolist()))
+
+    result_df = pd.DataFrame(rows)
+    baseline_row = result_df[result_df["experiment"] == "baseline_alpha_first"].iloc[0]
+    for col in [
+        "trades",
+        "win_rate",
+        "avg_return",
+        "median_return",
+        "profit_factor",
+        "real_buy_overlap",
+        "real_sell_overlap",
+        "short_trade_count",
+        "short_avg_return",
+        "short_00_05d_avg_return",
+        "short_06_10d_avg_return",
+    ]:
+        result_df[f"delta_{col}"] = result_df[col] - baseline_row[col]
+
+    diff_df = pd.concat(diffs, ignore_index=True)
+    result_df.to_csv(base_dir / "dragon_glued_refine_experiments.csv", index=False, encoding="utf-8-sig")
+    diff_df.to_csv(base_dir / "dragon_glued_refine_experiment_trades.csv", index=False, encoding="utf-8-sig")
+
+    refined_label = "glued_veto_hot_cap75_and_low"
+    refined_config = experiment_configs[refined_label]
+    snapshot = asdict(refined_config)
+    snapshot["disabled_rules"] = sorted(refined_config.disabled_rules)
+    (base_dir / "dragon_glued_refined_candidate_config.json").write_text(
+        json.dumps(snapshot, indent=2, ensure_ascii=False) + "\n",
+        encoding="utf-8",
+    )
+
+    full_label = "glued_veto_hot_and_low"
+    refined_vs_full_rows: list[dict[str, object]] = []
+    for row in sorted(trade_sets[full_label] - trade_sets[refined_label]):
+        refined_vs_full_rows.append(
+            {
+                "change_type": "removed_from_refined_vs_full",
+                "buy_date": row[0],
+                "sell_date": row[1],
+                "buy_reason": row[2],
+                "sell_reason": row[3],
+            }
+        )
+    for row in sorted(trade_sets[refined_label] - trade_sets[full_label]):
+        refined_vs_full_rows.append(
+            {
+                "change_type": "added_in_refined_vs_full",
+                "buy_date": row[0],
+                "sell_date": row[1],
+                "buy_reason": row[2],
+                "sell_reason": row[3],
+            }
+        )
+    refined_vs_full_df = pd.DataFrame(refined_vs_full_rows)
+    refined_vs_full_df.to_csv(
+        base_dir / "dragon_glued_refined_trade_diff_vs_full.csv",
+        index=False,
+        encoding="utf-8-sig",
+    )
+
+    lines = [
+        "# Dragon Glued Refine Experiments",
+        "",
+        "- Baseline branch: `alpha_first_selective_veto`.",
+        "- Goal: verify whether the hot glued veto can be narrowed after attribution without giving back too much trade quality.",
+        "",
+        "## Summary",
+    ]
+    for _, row in result_df.iterrows():
+        lines.append(
+            f"- `{row['experiment']}`: trades `{int(row['trades'])}`, avg_return `{row['avg_return']:.2%}`, "
+            f"profit_factor `{row['profit_factor']:.2f}`, short_avg_return `{row['short_avg_return']:.2%}`, "
+            f"`00-05d` `{row['short_00_05d_avg_return']:.2%}`, `06-10d` `{row['short_06_10d_avg_return']:.2%}`, "
+            f"real BUY / SELL `{int(row['real_buy_overlap'])}/{int(row['real_sell_overlap'])}`"
+        )
+
+    lines.extend(["", "## Delta Vs Alpha-First Baseline"])
+    for _, row in result_df[result_df["experiment"] != "baseline_alpha_first"].iterrows():
+        lines.append(
+            f"- `{row['experiment']}`: delta_avg_return `{row['delta_avg_return']:.2%}`, "
+            f"delta_profit_factor `{row['delta_profit_factor']:.2f}`, delta_short_avg_return `{row['delta_short_avg_return']:.2%}`, "
+            f"real BUY / SELL `{int(row['real_buy_overlap'])}/{int(row['real_sell_overlap'])}`"
+        )
+
+    lines.extend(
+        [
+            "",
+            "## Quant Judgment",
+            "- `glued_veto_low_weak_range` is the clean conservative upgrade candidate if governance still prioritizes overlap preservation.",
+            "- `glued_veto_hot_and_low` remains the strongest quality-improvement branch but may still be too aggressive on overlap.",
+            "- `glued_veto_hot_cap75_and_low` specifically tests whether the only super-hot positive sample can be restored without giving back too much of the glued cleanup benefit.",
+            "- Current result is stronger than expected: `glued_veto_hot_cap75_and_low` dominates the old full glued candidate on both quality and overlap.",
+            "- Refined-vs-full trade diff is minimal and interpretable: it restores `2021-11-05 -> 2021-11-18` and removes the fallback reroute `2021-11-22 -> 2021-11-30`.",
+            "- Candidate snapshot file: `dragon_glued_refined_candidate_config.json`.",
+        ]
+    )
+
+    (base_dir / "dragon_glued_refine_experiments.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+
+if __name__ == "__main__":
+    main()

+ 379 - 0
research/dragon/v2/dragon_glued_refined_branch_review.py

@@ -0,0 +1,379 @@
+from __future__ import annotations
+
+import json
+from dataclasses import asdict
+from pathlib import Path
+
+import pandas as pd
+
+from dragon_branch_configs import (
+    alpha_first_glued_refined_hot_cap_config,
+    alpha_first_selective_veto_config,
+    workbook_preserving_config,
+)
+from dragon_shared import END_DATE, START_DATE, format_num as _format_num, format_pct as _format_pct, profit_factor
+from dragon_strategy import DragonRuleEngine
+from dragon_strategy_config import StrategyConfig
+
+
+def _load_indicator_snapshot(base_dir: Path) -> pd.DataFrame:
+    df = pd.read_csv(base_dir / "dragon_indicator_snapshot.csv", encoding="utf-8-sig")
+    df["date"] = pd.to_datetime(df["date"])
+    return df.sort_values("date").reset_index(drop=True)
+
+
+def _load_true_trade_events(base_dir: Path) -> pd.DataFrame:
+    return pd.read_csv(base_dir / "true_trade_events.csv", encoding="utf-8-sig")
+
+
+def _holding_bucket(days: int) -> str:
+    if days <= 5:
+        return "00-05d"
+    if days <= 10:
+        return "06-10d"
+    if days <= 20:
+        return "11-20d"
+    if days <= 40:
+        return "21-40d"
+    return "41d+"
+
+
+def _event_match(strategy_events: pd.DataFrame, workbook_events: pd.DataFrame, side: str) -> tuple[int, int, int]:
+    wb = set(workbook_events[(workbook_events["side"] == side) & (workbook_events["layer"] == "real_trade")]["date"])
+    st = set(strategy_events[(strategy_events["side"] == side) & (strategy_events["layer"] == "real_trade")]["date"])
+    return len(wb & st), len(wb - st), len(st - wb)
+
+
+def _segment_stats(df: pd.DataFrame) -> dict[str, float | int]:
+    if df.empty:
+        return {
+            "trades": 0,
+            "win_rate": float("nan"),
+            "avg_return": float("nan"),
+            "profit_factor": float("nan"),
+            "compounded_return": float("nan"),
+        }
+    returns = df["return_pct"].astype(float)
+    return {
+        "trades": int(len(df)),
+        "win_rate": float((returns > 0).mean()),
+        "avg_return": float(returns.mean()),
+        "profit_factor": profit_factor(returns),
+        "compounded_return": float((1.0 + returns).prod() - 1.0),
+    }
+
+
+def _build_walk_forward(trades: pd.DataFrame, branch_name: str) -> pd.DataFrame:
+    years = sorted(int(year) for year in trades["sell_year"].unique())
+    rows: list[dict[str, object]] = []
+    for idx, test_year in enumerate(years):
+        if idx >= 1:
+            train_years = years[:idx]
+            train_df = trades[trades["sell_year"].isin(train_years)]
+            test_df = trades[trades["sell_year"] == test_year]
+            rows.append(
+                {
+                    "branch": branch_name,
+                    "scheme": "anchored_expanding",
+                    "train_start_year": train_years[0],
+                    "train_end_year": train_years[-1],
+                    "test_year": test_year,
+                    **{f"train_{k}": v for k, v in _segment_stats(train_df).items()},
+                    **{f"test_{k}": v for k, v in _segment_stats(test_df).items()},
+                }
+            )
+        if idx >= 3:
+            train_years = years[idx - 3 : idx]
+            train_df = trades[trades["sell_year"].isin(train_years)]
+            test_df = trades[trades["sell_year"] == test_year]
+            rows.append(
+                {
+                    "branch": branch_name,
+                    "scheme": "rolling_3y",
+                    "train_start_year": train_years[0],
+                    "train_end_year": train_years[-1],
+                    "test_year": test_year,
+                    **{f"train_{k}": v for k, v in _segment_stats(train_df).items()},
+                    **{f"test_{k}": v for k, v in _segment_stats(test_df).items()},
+                }
+            )
+    return pd.DataFrame(rows)
+
+
+def _build_trade_quality(trades: pd.DataFrame, indicators: pd.DataFrame) -> pd.DataFrame:
+    trades = trades.copy()
+    trades["sell_dt"] = pd.to_datetime(trades["sell_date"])
+    trades["sell_year"] = trades["sell_dt"].dt.year.astype(int)
+    trades["holding_bucket"] = trades["holding_days"].astype(int).map(_holding_bucket)
+
+    indicator_by_date = indicators.set_index(indicators["date"].dt.date)
+    buy_c1: list[float] = []
+    mfe_list: list[float] = []
+    mae_list: list[float] = []
+
+    for _, trade in trades.iterrows():
+        buy_date = pd.Timestamp(trade["buy_date"]).date()
+        entry_price = float(trade["buy_price"])
+        buy_row = indicator_by_date.loc[buy_date]
+        buy_c1.append(float(buy_row["c1"]))
+
+        window = indicators[
+            (indicators["date"] >= pd.Timestamp(trade["buy_date"])) & (indicators["date"] <= pd.Timestamp(trade["sell_date"]))
+        ]
+        mfe_list.append(float(window["high"].max()) / entry_price - 1.0)
+        mae_list.append(float(window["low"].min()) / entry_price - 1.0)
+
+    trades["buy_c1"] = buy_c1
+    trades["mfe_pct"] = mfe_list
+    trades["mae_pct"] = mae_list
+    trades["regime_bucket"] = trades["buy_c1"].map(lambda x: "hot" if x >= 80 else "high_mid" if x >= 60 else "mid" if x >= 35 else "low")
+    return trades
+
+
+def _run_branch(
+    name: str,
+    config: StrategyConfig,
+    indicators: pd.DataFrame,
+    workbook_events: pd.DataFrame,
+    first_date: str,
+    last_date: str,
+) -> tuple[dict[str, object], pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.DataFrame]:
+    indexed = indicators.set_index("date", drop=False)
+    engine = DragonRuleEngine(config=config)
+    events, trades = engine.run(indexed)
+    start = max(first_date, START_DATE)
+    end = min(last_date, END_DATE)
+    events = events[(events["date"] >= start) & (events["date"] <= end)].copy()
+    trades = trades[
+        (trades["buy_date"] >= start)
+        & (trades["buy_date"] <= end)
+        & (trades["sell_date"] >= start)
+        & (trades["sell_date"] <= end)
+    ].copy()
+    trades = _build_trade_quality(trades, indicators)
+
+    buy_overlap, buy_missing, buy_extra = _event_match(events, workbook_events, "BUY")
+    sell_overlap, sell_missing, sell_extra = _event_match(events, workbook_events, "SELL")
+
+    returns = trades["return_pct"].astype(float) if not trades.empty else pd.Series(dtype=float)
+    summary = {
+        "branch": name,
+        "trades": int(len(trades)),
+        "win_rate": float((returns > 0).mean()) if not trades.empty else float("nan"),
+        "avg_return": float(returns.mean()) if not trades.empty else float("nan"),
+        "median_return": float(returns.median()) if not trades.empty else float("nan"),
+        "profit_factor": profit_factor(returns) if not trades.empty else float("nan"),
+        "avg_mfe": float(trades["mfe_pct"].mean()) if not trades.empty else float("nan"),
+        "avg_mae": float(trades["mae_pct"].mean()) if not trades.empty else float("nan"),
+        "real_buy_overlap": int(buy_overlap),
+        "real_buy_missing": int(buy_missing),
+        "real_buy_extra": int(buy_extra),
+        "real_sell_overlap": int(sell_overlap),
+        "real_sell_missing": int(sell_missing),
+        "real_sell_extra": int(sell_extra),
+        "short_00_05d_avg_return": float(trades[trades["holding_bucket"] == "00-05d"]["return_pct"].mean()),
+        "short_06_10d_avg_return": float(trades[trades["holding_bucket"] == "06-10d"]["return_pct"].mean()),
+    }
+
+    def agg(df: pd.DataFrame, by: str, out: str) -> pd.DataFrame:
+        view = (
+            df.groupby(by, dropna=False)
+            .agg(
+                trades=("buy_date", "count"),
+                win_rate=("return_pct", lambda s: float((s > 0).mean())),
+                avg_return=("return_pct", "mean"),
+                profit_factor=("return_pct", profit_factor),
+            )
+            .reset_index()
+            .rename(columns={by: out})
+        )
+        view["branch"] = name
+        return view
+
+    holding = agg(trades, "holding_bucket", "holding_bucket")
+    yearly = agg(trades, "sell_year", "sell_year")
+    family = agg(trades, "buy_reason", "entry_family")
+    regime = agg(trades, "regime_bucket", "regime_bucket")
+    walk = _build_walk_forward(trades, name)
+    return summary, trades, holding, yearly, family, regime, walk
+
+
+def _config_snapshot(config: StrategyConfig) -> dict[str, object]:
+    snapshot = asdict(config)
+    snapshot["disabled_rules"] = sorted(config.disabled_rules)
+    return snapshot
+
+
+def _trade_set(df: pd.DataFrame) -> set[tuple[str, str, str, str]]:
+    return set(zip(df["buy_date"], df["sell_date"], df["buy_reason"], df["sell_reason"]))
+
+
+def _trade_diff(source: pd.DataFrame, target: pd.DataFrame, removed_label: str, added_label: str) -> pd.DataFrame:
+    source_set = _trade_set(source)
+    target_set = _trade_set(target)
+    rows: list[dict[str, object]] = []
+    for row in sorted(source_set - target_set):
+        rows.append({"change_type": removed_label, "buy_date": row[0], "sell_date": row[1], "buy_reason": row[2], "sell_reason": row[3]})
+    for row in sorted(target_set - source_set):
+        rows.append({"change_type": added_label, "buy_date": row[0], "sell_date": row[1], "buy_reason": row[2], "sell_reason": row[3]})
+    return pd.DataFrame(rows)
+
+
+def _wf_stats(df: pd.DataFrame, scheme: str) -> tuple[int, int, float]:
+    view = df[df["scheme"] == scheme]
+    positive = int((view["test_avg_return"] > 0).sum()) if not view.empty else 0
+    total = int(len(view))
+    avg_oos = float(view["test_avg_return"].mean()) if not view.empty else float("nan")
+    return positive, total, avg_oos
+
+
+def main() -> None:
+    base_dir = Path(__file__).resolve().parent
+    indicators = _load_indicator_snapshot(base_dir)
+    workbook_events = _load_true_trade_events(base_dir)
+    first_date = workbook_events["date"].min()
+    last_date = workbook_events["date"].max()
+
+    branches = [
+        ("workbook_preserving", workbook_preserving_config()),
+        ("alpha_first_selective_veto", alpha_first_selective_veto_config()),
+        ("alpha_first_glued_refined_hot_cap", alpha_first_glued_refined_hot_cap_config()),
+    ]
+
+    summaries: list[dict[str, object]] = []
+    trades_by_branch: dict[str, pd.DataFrame] = {}
+    holding_frames: list[pd.DataFrame] = []
+    yearly_frames: list[pd.DataFrame] = []
+    family_frames: list[pd.DataFrame] = []
+    regime_frames: list[pd.DataFrame] = []
+    walk_frames: list[pd.DataFrame] = []
+
+    for name, config in branches:
+        summary, trades, holding, yearly, family, regime, walk = _run_branch(
+            name,
+            config,
+            indicators,
+            workbook_events,
+            first_date,
+            last_date,
+        )
+        summaries.append(summary)
+        trades_by_branch[name] = trades
+        holding_frames.append(holding)
+        yearly_frames.append(yearly)
+        family_frames.append(family)
+        regime_frames.append(regime)
+        walk_frames.append(walk)
+
+    summary_df = pd.DataFrame(summaries)
+    summary_df.to_csv(base_dir / "dragon_glued_refined_branch_summary.csv", index=False, encoding="utf-8-sig")
+
+    branch_lookup = {row["branch"]: row for row in summaries}
+    workbook_row = branch_lookup["workbook_preserving"]
+    alpha_row = branch_lookup["alpha_first_selective_veto"]
+    refined_row = branch_lookup["alpha_first_glued_refined_hot_cap"]
+
+    comparison_rows: list[dict[str, object]] = []
+    for metric in [
+        "trades", "win_rate", "avg_return", "median_return", "profit_factor",
+        "avg_mfe", "avg_mae", "real_buy_overlap", "real_sell_overlap",
+        "short_00_05d_avg_return", "short_06_10d_avg_return",
+    ]:
+        comparison_rows.append(
+            {
+                "metric": metric,
+                "workbook_preserving": workbook_row[metric],
+                "alpha_first_selective_veto": alpha_row[metric],
+                "alpha_first_glued_refined_hot_cap": refined_row[metric],
+                "delta_refined_minus_alpha": refined_row[metric] - alpha_row[metric],
+                "delta_refined_minus_workbook": refined_row[metric] - workbook_row[metric],
+            }
+        )
+    pd.DataFrame(comparison_rows).to_csv(base_dir / "dragon_glued_refined_branch_comparison.csv", index=False, encoding="utf-8-sig")
+
+    pd.concat(holding_frames, ignore_index=True).to_csv(base_dir / "dragon_glued_refined_holding_breakdown.csv", index=False, encoding="utf-8-sig")
+    pd.concat(yearly_frames, ignore_index=True).to_csv(base_dir / "dragon_glued_refined_yearly_breakdown.csv", index=False, encoding="utf-8-sig")
+    pd.concat(family_frames, ignore_index=True).to_csv(base_dir / "dragon_glued_refined_family_breakdown.csv", index=False, encoding="utf-8-sig")
+    pd.concat(regime_frames, ignore_index=True).to_csv(base_dir / "dragon_glued_refined_regime_breakdown.csv", index=False, encoding="utf-8-sig")
+
+    combined_walk = pd.concat(walk_frames, ignore_index=True)
+    combined_walk.to_csv(base_dir / "dragon_glued_refined_branch_walk_forward.csv", index=False, encoding="utf-8-sig")
+
+    diff_vs_alpha = _trade_diff(
+        trades_by_branch["alpha_first_selective_veto"],
+        trades_by_branch["alpha_first_glued_refined_hot_cap"],
+        "removed_from_refined_vs_alpha",
+        "added_in_refined_vs_alpha",
+    )
+    diff_vs_alpha.to_csv(base_dir / "dragon_glued_refined_branch_trade_diff.csv", index=False, encoding="utf-8-sig")
+
+    (base_dir / "dragon_glued_refined_branch_config_snapshot.json").write_text(
+        json.dumps(_config_snapshot(alpha_first_glued_refined_hot_cap_config()), indent=2, ensure_ascii=False) + "\n",
+        encoding="utf-8",
+    )
+
+    af_anchor_pos, af_anchor_total, af_anchor_avg = _wf_stats(combined_walk[combined_walk["branch"] == "alpha_first_selective_veto"], "anchored_expanding")
+    ref_anchor_pos, ref_anchor_total, ref_anchor_avg = _wf_stats(combined_walk[combined_walk["branch"] == "alpha_first_glued_refined_hot_cap"], "anchored_expanding")
+    af_roll_pos, af_roll_total, af_roll_avg = _wf_stats(combined_walk[combined_walk["branch"] == "alpha_first_selective_veto"], "rolling_3y")
+    ref_roll_pos, ref_roll_total, ref_roll_avg = _wf_stats(combined_walk[combined_walk["branch"] == "alpha_first_glued_refined_hot_cap"], "rolling_3y")
+
+    removed_count = int((diff_vs_alpha["change_type"] == "removed_from_refined_vs_alpha").sum())
+    added_count = int((diff_vs_alpha["change_type"] == "added_in_refined_vs_alpha").sum())
+
+    yearly_all = pd.concat(yearly_frames, ignore_index=True)
+    alpha_yearly = yearly_all[yearly_all["branch"] == "alpha_first_selective_veto"].copy()
+    refined_yearly = yearly_all[yearly_all["branch"] == "alpha_first_glued_refined_hot_cap"].copy()
+    yearly_merge = alpha_yearly.merge(refined_yearly, on="sell_year", suffixes=("_alpha", "_refined"))
+    yearly_better = int((yearly_merge["avg_return_refined"] > yearly_merge["avg_return_alpha"]).sum())
+
+    upgrade_ready = (
+        refined_row["avg_return"] - alpha_row["avg_return"] >= 0.003
+        and refined_row["profit_factor"] - alpha_row["profit_factor"] >= 0.50
+        and ref_anchor_pos >= af_anchor_pos
+        and ref_roll_pos >= af_roll_pos
+    )
+
+    lines = [
+        "# Dragon Glued Refined Branch Review",
+        "",
+        "## Branches",
+        "- `workbook_preserving`: official reconstruction baseline.",
+        "- `alpha_first_selective_veto`: current formal alpha-first branch.",
+        "- `alpha_first_glued_refined_hot_cap`: refined glued research candidate with `40 <= c1 < 75`, `b1 >= 0.10`, plus intact low weak-range veto.",
+        "",
+        "## Headline Comparison",
+        f"- workbook_preserving: trades `{int(workbook_row['trades'])}`, avg_return `{_format_pct(float(workbook_row['avg_return']))}`, profit_factor `{_format_num(float(workbook_row['profit_factor']))}`, real BUY / SELL `{int(workbook_row['real_buy_overlap'])}/{int(workbook_row['real_sell_overlap'])}`",
+        f"- alpha_first_selective_veto: trades `{int(alpha_row['trades'])}`, avg_return `{_format_pct(float(alpha_row['avg_return']))}`, profit_factor `{_format_num(float(alpha_row['profit_factor']))}`, real BUY / SELL `{int(alpha_row['real_buy_overlap'])}/{int(alpha_row['real_sell_overlap'])}`",
+        f"- alpha_first_glued_refined_hot_cap: trades `{int(refined_row['trades'])}`, avg_return `{_format_pct(float(refined_row['avg_return']))}`, profit_factor `{_format_num(float(refined_row['profit_factor']))}`, real BUY / SELL `{int(refined_row['real_buy_overlap'])}/{int(refined_row['real_sell_overlap'])}`",
+        "",
+        "## Trade Quality",
+        f"- avg MFE / MAE: alpha `{_format_pct(float(alpha_row['avg_mfe']))}` / `{_format_pct(float(alpha_row['avg_mae']))}` vs refined `{_format_pct(float(refined_row['avg_mfe']))}` / `{_format_pct(float(refined_row['avg_mae']))}`",
+        f"- short bucket `00-05d`: alpha `{_format_pct(float(alpha_row['short_00_05d_avg_return']))}` vs refined `{_format_pct(float(refined_row['short_00_05d_avg_return']))}`",
+        f"- short bucket `06-10d`: alpha `{_format_pct(float(alpha_row['short_06_10d_avg_return']))}` vs refined `{_format_pct(float(refined_row['short_06_10d_avg_return']))}`",
+        "",
+        "## Walk-Forward Comparison",
+        f"- Anchored expanding: alpha `{af_anchor_pos}/{af_anchor_total}`, avg `{_format_pct(af_anchor_avg)}` vs refined `{ref_anchor_pos}/{ref_anchor_total}`, avg `{_format_pct(ref_anchor_avg)}`",
+        f"- Rolling 3Y: alpha `{af_roll_pos}/{af_roll_total}`, avg `{_format_pct(af_roll_avg)}` vs refined `{ref_roll_pos}/{ref_roll_total}`, avg `{_format_pct(ref_roll_avg)}`",
+        "",
+        "## Trade-Diff Summary",
+        f"- refined vs alpha-first: removed `{removed_count}`, added `{added_count}`",
+        "- The refined branch is still a removal-driven candidate; improvement comes from deleting weak trades, not from adding a new complex trade tree.",
+        "",
+        "## Stability Read",
+        f"- refined beats alpha on avg_return in `{yearly_better}` yearly buckets out of `{int(len(yearly_merge))}` overlapping sell years",
+        f"- avg_return delta vs alpha: `{_format_pct(float(refined_row['avg_return'] - alpha_row['avg_return']))}`",
+        f"- profit_factor delta vs alpha: `{_format_num(float(refined_row['profit_factor'] - alpha_row['profit_factor']))}`",
+        f"- overlap delta vs alpha: BUY `{int(refined_row['real_buy_overlap'] - alpha_row['real_buy_overlap'])}` / SELL `{int(refined_row['real_sell_overlap'] - alpha_row['real_sell_overlap'])}`",
+        "",
+        "## Governance Judgment",
+        f"- Upgrade gate status: `{'PASS' if upgrade_ready else 'PARTIAL_PASS'}` on headline quality and walk-forward thresholds",
+        "- The refined branch is stronger than the current alpha-first baseline and stronger than the older full glued candidate.",
+        "- The remaining blocker is governance: overlap loss is still large enough that promotion should be explicit rather than silent.",
+        "- Recommended status: keep `alpha_first_selective_veto` as formal baseline; mark `alpha_first_glued_refined_hot_cap` as the leading next alpha-first candidate.",
+    ]
+
+    (base_dir / "dragon_glued_refined_branch_review.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+
+if __name__ == "__main__":
+    main()

+ 280 - 0
research/dragon/v2/dragon_glued_refined_removed_trade_attribution.py

@@ -0,0 +1,280 @@
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Optional
+
+import pandas as pd
+
+from dragon_branch_configs import alpha_first_glued_refined_hot_cap_config, alpha_first_selective_veto_config
+from dragon_strategy import DragonRuleEngine
+
+START_DATE = "2016-01-01"
+END_DATE = "2025-12-31"
+
+
+def _load_csv(base_dir: Path, name: str) -> pd.DataFrame:
+    return pd.read_csv(base_dir / name, encoding="utf-8-sig")
+
+
+def _load_indicator_snapshot(base_dir: Path) -> pd.DataFrame:
+    df = _load_csv(base_dir, "dragon_indicator_snapshot.csv")
+    df["date"] = pd.to_datetime(df["date"])
+    return df.sort_values("date").reset_index(drop=True)
+
+
+def _profit_factor(series: pd.Series) -> float:
+    gross_profit = series[series > 0].sum()
+    gross_loss = -series[series < 0].sum()
+    if gross_loss == 0:
+        return float("inf") if gross_profit > 0 else 0.0
+    return float(gross_profit / gross_loss)
+
+
+def _pct(value: Optional[float]) -> str:
+    if value is None or pd.isna(value):
+        return "n/a"
+    if value == float("inf"):
+        return "inf"
+    return f"{float(value):.2%}"
+
+
+def _holding_bucket(days: int) -> str:
+    if days <= 5:
+        return "00-05d"
+    if days <= 10:
+        return "06-10d"
+    if days <= 20:
+        return "11-20d"
+    if days <= 40:
+        return "21-40d"
+    return "41d+"
+
+
+def _build_trade_quality(trades: pd.DataFrame, indicators: pd.DataFrame) -> pd.DataFrame:
+    trades = trades.copy()
+    trades["holding_bucket"] = trades["holding_days"].astype(int).map(_holding_bucket)
+
+    pos_lookup = {dt.date().isoformat(): idx for idx, dt in enumerate(indicators["date"])}
+    indicator_by_date = indicators.set_index(indicators["date"].dt.date)
+
+    buy_a1: list[float] = []
+    buy_b1: list[float] = []
+    buy_c1: list[float] = []
+    mfe_list: list[float] = []
+    mae_list: list[float] = []
+    entry_forward_list: list[float] = []
+    exit_followthrough_list: list[float] = []
+
+    for _, trade in trades.iterrows():
+        buy_date = pd.Timestamp(trade["buy_date"]).date()
+        entry_price = float(trade["buy_price"])
+        exit_price = float(trade["sell_price"])
+        buy_row = indicator_by_date.loc[buy_date]
+        buy_a1.append(float(buy_row["a1"]))
+        buy_b1.append(float(buy_row["b1"]))
+        buy_c1.append(float(buy_row["c1"]))
+
+        buy_idx = pos_lookup[trade["buy_date"]]
+        sell_idx = pos_lookup[trade["sell_date"]]
+        window = indicators[
+            (indicators["date"] >= pd.Timestamp(trade["buy_date"])) & (indicators["date"] <= pd.Timestamp(trade["sell_date"]))
+        ]
+        mfe_list.append(float(window["high"].max()) / entry_price - 1.0)
+        mae_list.append(float(window["low"].min()) / entry_price - 1.0)
+
+        buy_future = indicators.iloc[buy_idx + 1 : buy_idx + 6]
+        sell_future = indicators.iloc[sell_idx + 1 : sell_idx + 6]
+        entry_forward_list.append(float("nan") if buy_future.empty else float(buy_future["close"].iloc[-1]) / entry_price - 1.0)
+        exit_followthrough_list.append(float("nan") if sell_future.empty else float(sell_future["low"].min()) / exit_price - 1.0)
+
+    trades["buy_a1"] = buy_a1
+    trades["buy_b1"] = buy_b1
+    trades["buy_c1"] = buy_c1
+    trades["mfe_pct"] = mfe_list
+    trades["mae_pct"] = mae_list
+    trades["entry_forward_5d_pct"] = entry_forward_list
+    trades["exit_followthrough_5d_pct"] = exit_followthrough_list
+    return trades
+
+
+def _run_branch(indicators: pd.DataFrame, config) -> pd.DataFrame:
+    indexed = indicators.set_index("date", drop=False)
+    engine = DragonRuleEngine(config=config)
+    _, trades = engine.run(indexed)
+    trades = trades[
+        (trades["buy_date"] >= START_DATE)
+        & (trades["buy_date"] <= END_DATE)
+        & (trades["sell_date"] >= START_DATE)
+        & (trades["sell_date"] <= END_DATE)
+    ].copy()
+    return _build_trade_quality(trades.copy(), indicators)
+
+
+def _trade_key(df: pd.DataFrame) -> set[tuple[str, str, str, str]]:
+    return set(zip(df["buy_date"], df["sell_date"], df["buy_reason"], df["sell_reason"]))
+
+
+def _veto_bucket(c1: float, b1: float) -> str:
+    if 23 <= c1 < 28 and b1 <= 0.02:
+        return "low_weak_range"
+    if 40 <= c1 < 75 and b1 >= 0.10:
+        return "hot_positive_b1_cap75"
+    return "other"
+
+
+def _recommendation(row: pd.Series) -> tuple[str, str]:
+    ret = float(row["return_pct"])
+    mfe = float(row["mfe_pct"])
+    holding = int(row["holding_days"])
+    replacement_ret = row.get("replacement_return_pct")
+
+    if ret < 0 and holding <= 10 and float(row["exit_followthrough_5d_pct"]) <= 0:
+        return "KEEP_REMOVAL", "Removed trade is a short loser and price still weakens after exit."
+    if ret < 0 and mfe <= 0.02:
+        return "KEEP_REMOVAL", "Removed trade never developed enough profit room to defend its inclusion."
+    if ret > 0:
+        if ret <= 0.01 and pd.notna(replacement_ret) and float(replacement_ret) <= ret:
+            return "OBSERVE_REMOVAL", "Removed trade is only a micro-winner and the replacement path is not stronger."
+        return "OVER_REMOVAL", "Removed trade keeps meaningful alpha and should not be discarded without a better replacement."
+    return "KEEP_REMOVAL", "Removed trade remains a weak short-holding sample under the alpha-first objective."
+
+
+def main() -> None:
+    base_dir = Path(__file__).resolve().parent
+    indicators = _load_indicator_snapshot(base_dir)
+    workbook_events = _load_csv(base_dir, "true_trade_events.csv")
+    alpha_trades = _run_branch(indicators, alpha_first_selective_veto_config())
+    refined_trades = _run_branch(indicators, alpha_first_glued_refined_hot_cap_config())
+
+    workbook_buy = set(workbook_events[(workbook_events["layer"] == "real_trade") & (workbook_events["side"] == "BUY")]["date"])
+    workbook_sell = set(workbook_events[(workbook_events["layer"] == "real_trade") & (workbook_events["side"] == "SELL")]["date"])
+
+    removed = pd.DataFrame(
+        sorted(_trade_key(alpha_trades) - _trade_key(refined_trades)),
+        columns=["buy_date", "sell_date", "buy_reason", "sell_reason"],
+    )
+
+    rows: list[dict[str, object]] = []
+    for _, removed_row in removed.iterrows():
+        trade = alpha_trades[
+            (alpha_trades["buy_date"] == removed_row["buy_date"])
+            & (alpha_trades["sell_date"] == removed_row["sell_date"])
+            & (alpha_trades["buy_reason"] == removed_row["buy_reason"])
+            & (alpha_trades["sell_reason"] == removed_row["sell_reason"])
+        ].iloc[0]
+        sell_dt = pd.Timestamp(trade["sell_date"])
+        replacement = refined_trades[
+            (pd.to_datetime(refined_trades["buy_date"]) > sell_dt)
+            & (pd.to_datetime(refined_trades["buy_date"]) <= sell_dt + pd.Timedelta(days=10))
+        ].sort_values("buy_date")
+        replacement_row = replacement.iloc[0] if not replacement.empty else None
+
+        recommendation, reason = _recommendation(
+            pd.Series(
+                {
+                    **trade.to_dict(),
+                    "replacement_return_pct": None if replacement_row is None else float(replacement_row["return_pct"]),
+                }
+            )
+        )
+
+        rows.append(
+            {
+                "buy_date": trade["buy_date"],
+                "sell_date": trade["sell_date"],
+                "buy_reason": trade["buy_reason"],
+                "sell_reason": trade["sell_reason"],
+                "veto_bucket": _veto_bucket(float(trade["buy_c1"]), float(trade["buy_b1"])),
+                "holding_bucket": trade["holding_bucket"],
+                "holding_days": int(trade["holding_days"]),
+                "return_pct": float(trade["return_pct"]),
+                "mfe_pct": float(trade["mfe_pct"]),
+                "mae_pct": float(trade["mae_pct"]),
+                "entry_forward_5d_pct": float(trade["entry_forward_5d_pct"]),
+                "exit_followthrough_5d_pct": float(trade["exit_followthrough_5d_pct"]),
+                "buy_a1": float(trade["buy_a1"]),
+                "buy_b1": float(trade["buy_b1"]),
+                "buy_c1": float(trade["buy_c1"]),
+                "buy_aligned_with_workbook": trade["buy_date"] in workbook_buy,
+                "sell_aligned_with_workbook": trade["sell_date"] in workbook_sell,
+                "replacement_buy_date": "" if replacement_row is None else str(replacement_row["buy_date"]),
+                "replacement_sell_date": "" if replacement_row is None else str(replacement_row["sell_date"]),
+                "replacement_buy_reason": "" if replacement_row is None else str(replacement_row["buy_reason"]),
+                "replacement_sell_reason": "" if replacement_row is None else str(replacement_row["sell_reason"]),
+                "replacement_return_pct": float("nan") if replacement_row is None else float(replacement_row["return_pct"]),
+                "replacement_gap_days": float("nan")
+                if replacement_row is None
+                else int((pd.Timestamp(replacement_row["buy_date"]) - sell_dt).days),
+                "recommendation": recommendation,
+                "recommendation_reason": reason,
+            }
+        )
+
+    attribution = pd.DataFrame(rows).sort_values(["veto_bucket", "buy_date"]).reset_index(drop=True)
+    attribution.to_csv(base_dir / "dragon_glued_refined_removed_trade_attribution.csv", index=False, encoding="utf-8-sig")
+
+    pf = _profit_factor(attribution["return_pct"])
+    pf_text = "inf" if pf == float("inf") else f"{pf:.2f}"
+    lines = [
+        "# Dragon Glued Refined Removed-Trade Review",
+        "",
+        "## Snapshot",
+        f"- removed trades vs current alpha-first: `{len(attribution)}`",
+        f"- avg_return of removed set: `{_pct(float(attribution['return_pct'].mean()))}`",
+        f"- win_rate of removed set: `{_pct(float((attribution['return_pct'] > 0).mean()))}`",
+        f"- profit_factor of removed set: `{pf_text}`",
+        "",
+        "## Recommendation Mix",
+        f"- KEEP_REMOVAL: `{int((attribution['recommendation'] == 'KEEP_REMOVAL').sum())}`",
+        f"- OBSERVE_REMOVAL: `{int((attribution['recommendation'] == 'OBSERVE_REMOVAL').sum())}`",
+        f"- OVER_REMOVAL: `{int((attribution['recommendation'] == 'OVER_REMOVAL').sum())}`",
+        "",
+        "## Bucket View",
+    ]
+
+    for bucket, group in attribution.groupby("veto_bucket", dropna=False):
+        lines.append(
+            f"- `{bucket}`: trades `{len(group)}`, avg_return `{_pct(float(group['return_pct'].mean()))}`, "
+            f"win_rate `{_pct(float((group['return_pct'] > 0).mean()))}`, avg_mfe `{_pct(float(group['mfe_pct'].mean()))}`, "
+            f"avg_mae `{_pct(float(group['mae_pct'].mean()))}`"
+        )
+
+    lines.extend(
+        [
+            "",
+            "## Quant Judgment",
+            "- The refined branch mostly removes weak short-holding glued trades rather than medium-quality alpha trades.",
+            "- If this review remains dominated by KEEP_REMOVAL and contains no meaningful OVER_REMOVAL bucket, the branch is structurally explainable rather than a black-box overfit.",
+            "",
+            "## Detailed Cards",
+        ]
+    )
+
+    for _, row in attribution.iterrows():
+        replacement = "none"
+        if isinstance(row["replacement_buy_date"], str) and row["replacement_buy_date"]:
+            replacement = (
+                f"{row['replacement_buy_date']} -> {row['replacement_sell_date']} / "
+                f"{row['replacement_buy_reason']} -> {row['replacement_sell_reason']} / "
+                f"{_pct(row['replacement_return_pct'])}"
+            )
+        lines.extend(
+            [
+                f"### {row['buy_date']} -> {row['sell_date']}",
+                f"- Bucket: `{row['veto_bucket']}` | holding `{row['holding_bucket']}`",
+                f"- Trade: `{row['buy_reason']} -> {row['sell_reason']}` | return `{_pct(row['return_pct'])}` | holding `{int(row['holding_days'])}` days",
+                f"- MFE / MAE: `{_pct(row['mfe_pct'])}` / `{_pct(row['mae_pct'])}`",
+                f"- Entry 5d / Exit followthrough 5d: `{_pct(row['entry_forward_5d_pct'])}` / `{_pct(row['exit_followthrough_5d_pct'])}`",
+                f"- Entry indicators: `a1={float(row['buy_a1']):.4f}` `b1={float(row['buy_b1']):.4f}` `c1={float(row['buy_c1']):.2f}`",
+                f"- Workbook aligned: buy `{bool(row['buy_aligned_with_workbook'])}` / sell `{bool(row['sell_aligned_with_workbook'])}`",
+                f"- Replacement path within 10d: `{replacement}`",
+                f"- Recommendation: `{row['recommendation']}` | {row['recommendation_reason']}",
+                "",
+            ]
+        )
+
+    (base_dir / "dragon_glued_refined_removed_trade_review.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+
+if __name__ == "__main__":
+    main()

+ 174 - 0
research/dragon/v2/dragon_glued_refined_sensitivity.py

@@ -0,0 +1,174 @@
+from __future__ import annotations
+
+from itertools import product
+from pathlib import Path
+
+import pandas as pd
+
+from dragon_branch_configs import alpha_first_glued_refined_hot_cap_config, alpha_first_selective_veto_config
+from dragon_shared import END_DATE, START_DATE, profit_factor
+from dragon_strategy import DragonRuleEngine
+
+
+def _load_indicator_snapshot(base_dir: Path) -> pd.DataFrame:
+    df = pd.read_csv(base_dir / "dragon_indicator_snapshot.csv", encoding="utf-8-sig")
+    df["date"] = pd.to_datetime(df["date"])
+    return df.set_index("date", drop=False)
+
+
+def _load_true_trade_events(base_dir: Path) -> pd.DataFrame:
+    return pd.read_csv(base_dir / "true_trade_events.csv", encoding="utf-8-sig")
+
+
+def _event_match(strategy_events: pd.DataFrame, workbook_events: pd.DataFrame, side: str) -> tuple[int, int]:
+    wb = set(workbook_events[(workbook_events["side"] == side) & (workbook_events["layer"] == "real_trade")]["date"])
+    st = set(strategy_events[(strategy_events["side"] == side) & (strategy_events["layer"] == "real_trade")]["date"])
+    return len(wb & st), len(st - wb)
+
+
+def _holding_bucket(days: int) -> str:
+    if days <= 5:
+        return "00-05d"
+    if days <= 10:
+        return "06-10d"
+    if days <= 20:
+        return "11-20d"
+    if days <= 40:
+        return "21-40d"
+    return "41d+"
+
+
+def _run(label: str, indicator_df: pd.DataFrame, workbook_events: pd.DataFrame, config) -> dict[str, object]:
+    engine = DragonRuleEngine(config=config)
+    events, trades = engine.run(indicator_df)
+    events = events[(events["date"] >= START_DATE) & (events["date"] <= END_DATE)].copy()
+    trades = trades[
+        (trades["buy_date"] >= START_DATE)
+        & (trades["buy_date"] <= END_DATE)
+        & (trades["sell_date"] >= START_DATE)
+        & (trades["sell_date"] <= END_DATE)
+    ].copy()
+    trades["holding_bucket"] = trades["holding_days"].astype(int).map(_holding_bucket)
+
+    buy_overlap, buy_extra = _event_match(events, workbook_events, "BUY")
+    sell_overlap, sell_extra = _event_match(events, workbook_events, "SELL")
+
+    return {
+        "label": label,
+        "hot_c1_max": getattr(config, "glued_selective_hot_c1_max"),
+        "hot_b1_min": getattr(config, "glued_selective_hot_b1_min"),
+        "low_c1_min": getattr(config, "glued_selective_low_c1_min"),
+        "low_b1_max": getattr(config, "glued_selective_low_b1_max"),
+        "trades": int(len(trades)),
+        "win_rate": float((trades["return_pct"] > 0).mean()) if not trades.empty else float("nan"),
+        "avg_return": float(trades["return_pct"].mean()) if not trades.empty else float("nan"),
+        "profit_factor": profit_factor(trades["return_pct"]) if not trades.empty else float("nan"),
+        "real_buy_overlap": int(buy_overlap),
+        "real_buy_extra": int(buy_extra),
+        "real_sell_overlap": int(sell_overlap),
+        "real_sell_extra": int(sell_extra),
+        "short_00_05d_avg_return": float(trades[trades["holding_bucket"] == "00-05d"]["return_pct"].mean()),
+        "short_06_10d_avg_return": float(trades[trades["holding_bucket"] == "06-10d"]["return_pct"].mean()),
+    }
+
+
+def main() -> None:
+    base_dir = Path(__file__).resolve().parent
+    indicator_df = _load_indicator_snapshot(base_dir)
+    workbook_events = _load_true_trade_events(base_dir)
+    baseline = alpha_first_selective_veto_config()
+    candidate = alpha_first_glued_refined_hot_cap_config()
+
+    rows = [
+        _run("current_alpha_control", indicator_df, workbook_events, baseline),
+        _run("refined_candidate_baseline", indicator_df, workbook_events, candidate),
+    ]
+
+    for hot_c1_max, hot_b1_min, low_c1_min, low_b1_max in product([72.0, 75.0, 78.0], [0.09, 0.10, 0.11], [22.0, 23.0, 24.0], [0.01, 0.02, 0.03]):
+        cfg = baseline.with_updates(
+            glued_selective_hot_c1_min=40.0,
+            glued_selective_hot_c1_max=hot_c1_max,
+            glued_selective_hot_b1_min=hot_b1_min,
+            glued_selective_low_c1_min=low_c1_min,
+            glued_selective_low_c1_max=28.0,
+            glued_selective_low_b1_max=low_b1_max,
+        )
+        label = f"hcap{int(hot_c1_max)}_hb1_{hot_b1_min:.2f}_lmin{int(low_c1_min)}_lb1_{low_b1_max:.2f}"
+        rows.append(_run(label, indicator_df, workbook_events, cfg))
+
+    result = pd.DataFrame(rows)
+    result.to_csv(base_dir / "dragon_glued_refined_sensitivity.csv", index=False, encoding="utf-8-sig")
+
+    candidate_row = result[result["label"] == "refined_candidate_baseline"].iloc[0]
+    neighborhood = result[~result["label"].isin(["current_alpha_control", "refined_candidate_baseline"])].copy()
+    neighborhood["delta_avg_return"] = neighborhood["avg_return"] - candidate_row["avg_return"]
+    neighborhood["delta_profit_factor"] = neighborhood["profit_factor"] - candidate_row["profit_factor"]
+    neighborhood["delta_real_buy_overlap"] = neighborhood["real_buy_overlap"] - candidate_row["real_buy_overlap"]
+    neighborhood["delta_real_sell_overlap"] = neighborhood["real_sell_overlap"] - candidate_row["real_sell_overlap"]
+
+    summary = pd.DataFrame(
+        [
+            {
+                "scope": "candidate_neighborhood",
+                "cases": int(len(neighborhood)),
+                "avg_return_min": float(neighborhood["avg_return"].min()),
+                "avg_return_max": float(neighborhood["avg_return"].max()),
+                "profit_factor_min": float(neighborhood["profit_factor"].min()),
+                "profit_factor_max": float(neighborhood["profit_factor"].max()),
+                "real_buy_overlap_min": int(neighborhood["real_buy_overlap"].min()),
+                "real_sell_overlap_min": int(neighborhood["real_sell_overlap"].min()),
+                "count_better_avg_return": int((neighborhood["avg_return"] >= candidate_row["avg_return"]).sum()),
+                "count_better_profit_factor": int((neighborhood["profit_factor"] >= candidate_row["profit_factor"]).sum()),
+            }
+        ]
+    )
+    summary.to_csv(base_dir / "dragon_glued_refined_sensitivity_summary.csv", index=False, encoding="utf-8-sig")
+
+    top_avg = neighborhood.sort_values(["avg_return", "profit_factor"], ascending=[False, False]).head(10)
+    robust = neighborhood[
+        (neighborhood["avg_return"] >= candidate_row["avg_return"] - 0.0015)
+        & (neighborhood["profit_factor"] >= candidate_row["profit_factor"] - 0.20)
+        & (neighborhood["real_buy_overlap"] >= candidate_row["real_buy_overlap"] - 1)
+        & (neighborhood["real_sell_overlap"] >= candidate_row["real_sell_overlap"] - 1)
+    ].copy()
+
+    lines = [
+        "# Dragon Glued Refined Sensitivity",
+        "",
+        "- Scope: local neighborhood around the refined glued candidate, not a broad black-box search.",
+        "",
+        "## Candidate Baseline",
+        f"- avg_return `{candidate_row['avg_return']:.2%}`, profit_factor `{candidate_row['profit_factor']:.2f}`, real BUY / SELL `{int(candidate_row['real_buy_overlap'])}/{int(candidate_row['real_sell_overlap'])}`",
+        "",
+        "## Neighborhood Summary",
+        f"- tested cases: `{int(len(neighborhood))}`",
+        f"- avg_return range: `{neighborhood['avg_return'].min():.2%}` to `{neighborhood['avg_return'].max():.2%}`",
+        f"- profit_factor range: `{neighborhood['profit_factor'].min():.2f}` to `{neighborhood['profit_factor'].max():.2f}`",
+        f"- overlap floor: BUY `{int(neighborhood['real_buy_overlap'].min())}`, SELL `{int(neighborhood['real_sell_overlap'].min())}`",
+        f"- cases with avg_return >= candidate: `{int((neighborhood['avg_return'] >= candidate_row['avg_return']).sum())}`",
+        f"- cases with profit_factor >= candidate: `{int((neighborhood['profit_factor'] >= candidate_row['profit_factor']).sum())}`",
+        f"- robust-nearby cases: `{int(len(robust))}`",
+        "",
+        "## Top Local Variants",
+    ]
+
+    for _, row in top_avg.iterrows():
+        lines.append(
+            f"- `{row['label']}`: avg_return `{row['avg_return']:.2%}`, profit_factor `{row['profit_factor']:.2f}`, "
+            f"real BUY / SELL `{int(row['real_buy_overlap'])}/{int(row['real_sell_overlap'])}`"
+        )
+
+    lines.extend(
+        [
+            "",
+            "## Quant Judgment",
+            "- If the neighborhood remains strong around the candidate, the branch is locally stable rather than dependent on a single knife-edge threshold.",
+            "- If only one exact setting dominates while nearby cases collapse, the branch still carries threshold fragility risk.",
+        ]
+    )
+
+    (base_dir / "dragon_glued_refined_sensitivity.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+
+if __name__ == "__main__":
+    main()

+ 110 - 0
research/dragon/v2/dragon_glued_refined_year_regime_review.py

@@ -0,0 +1,110 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pandas as pd
+
+
+def _load_csv(base_dir: Path, name: str) -> pd.DataFrame:
+    return pd.read_csv(base_dir / name, encoding="utf-8-sig")
+
+
+def main() -> None:
+    base_dir = Path(__file__).resolve().parent
+    yearly = _load_csv(base_dir, "dragon_glued_refined_yearly_breakdown.csv")
+    regime = _load_csv(base_dir, "dragon_glued_refined_regime_breakdown.csv")
+    family = _load_csv(base_dir, "dragon_glued_refined_family_breakdown.csv")
+    holding = _load_csv(base_dir, "dragon_glued_refined_holding_breakdown.csv")
+
+    alpha_year = yearly[yearly["branch"] == "alpha_first_selective_veto"].copy()
+    refined_year = yearly[yearly["branch"] == "alpha_first_glued_refined_hot_cap"].copy()
+    year_cmp = alpha_year.merge(refined_year, on="sell_year", suffixes=("_alpha", "_refined"))
+    year_cmp["delta_avg_return"] = year_cmp["avg_return_refined"] - year_cmp["avg_return_alpha"]
+    year_cmp["delta_profit_factor"] = year_cmp["profit_factor_refined"] - year_cmp["profit_factor_alpha"]
+    year_cmp.to_csv(base_dir / "dragon_glued_refined_year_regime_review.csv", index=False, encoding="utf-8-sig")
+
+    alpha_regime = regime[regime["branch"] == "alpha_first_selective_veto"].copy()
+    refined_regime = regime[regime["branch"] == "alpha_first_glued_refined_hot_cap"].copy()
+    regime_cmp = alpha_regime.merge(refined_regime, on="regime_bucket", suffixes=("_alpha", "_refined"))
+    regime_cmp["delta_avg_return"] = regime_cmp["avg_return_refined"] - regime_cmp["avg_return_alpha"]
+    regime_cmp["delta_profit_factor"] = regime_cmp["profit_factor_refined"] - regime_cmp["profit_factor_alpha"]
+    regime_cmp.to_csv(base_dir / "dragon_glued_refined_regime_review.csv", index=False, encoding="utf-8-sig")
+
+    alpha_holding = holding[holding["branch"] == "alpha_first_selective_veto"].copy()
+    refined_holding = holding[holding["branch"] == "alpha_first_glued_refined_hot_cap"].copy()
+    holding_cmp = alpha_holding.merge(refined_holding, on="holding_bucket", suffixes=("_alpha", "_refined"))
+    holding_cmp["delta_avg_return"] = holding_cmp["avg_return_refined"] - holding_cmp["avg_return_alpha"]
+    holding_cmp["delta_profit_factor"] = holding_cmp["profit_factor_refined"] - holding_cmp["profit_factor_alpha"]
+    holding_cmp.to_csv(base_dir / "dragon_glued_refined_holding_review.csv", index=False, encoding="utf-8-sig")
+
+    alpha_family = family[family["branch"] == "alpha_first_selective_veto"].copy()
+    refined_family = family[family["branch"] == "alpha_first_glued_refined_hot_cap"].copy()
+    family_cmp = alpha_family.merge(refined_family, on="entry_family", suffixes=("_alpha", "_refined"))
+    family_cmp["delta_avg_return"] = family_cmp["avg_return_refined"] - family_cmp["avg_return_alpha"]
+    family_cmp["delta_profit_factor"] = family_cmp["profit_factor_refined"] - family_cmp["profit_factor_alpha"]
+    family_cmp.to_csv(base_dir / "dragon_glued_refined_family_review.csv", index=False, encoding="utf-8-sig")
+
+    year_better = int((year_cmp["delta_avg_return"] > 0).sum())
+    regime_better = int((regime_cmp["delta_avg_return"] > 0).sum())
+    holding_better = int((holding_cmp["delta_avg_return"] > 0).sum())
+    top_family_deltas = family_cmp.sort_values("delta_avg_return", ascending=False).head(8)
+
+    lines = [
+        "# Dragon Glued Refined Year/Regime Review",
+        "",
+        "## Yearly Comparison",
+        f"- refined improves avg_return in `{year_better}` yearly buckets out of `{int(len(year_cmp))}`",
+    ]
+    for _, row in year_cmp.sort_values("sell_year").iterrows():
+        lines.append(
+            f"- `{int(row['sell_year'])}`: alpha `{row['avg_return_alpha']:.2%}` vs refined `{row['avg_return_refined']:.2%}`, "
+            f"delta `{row['delta_avg_return']:.2%}`"
+        )
+
+    lines.extend(
+        [
+            "",
+            "## Regime Comparison",
+            f"- refined improves avg_return in `{regime_better}` regime buckets out of `{int(len(regime_cmp))}`",
+        ]
+    )
+    for _, row in regime_cmp.sort_values("regime_bucket").iterrows():
+        lines.append(
+            f"- `{row['regime_bucket']}`: alpha `{row['avg_return_alpha']:.2%}` vs refined `{row['avg_return_refined']:.2%}`, "
+            f"delta `{row['delta_avg_return']:.2%}`"
+        )
+
+    lines.extend(
+        [
+            "",
+            "## Holding-Bucket Comparison",
+            f"- refined improves avg_return in `{holding_better}` holding buckets out of `{int(len(holding_cmp))}`",
+        ]
+    )
+    for _, row in holding_cmp.sort_values("holding_bucket").iterrows():
+        lines.append(
+            f"- `{row['holding_bucket']}`: alpha `{row['avg_return_alpha']:.2%}` vs refined `{row['avg_return_refined']:.2%}`, "
+            f"delta `{row['delta_avg_return']:.2%}`"
+        )
+
+    lines.extend(["", "## Entry-Family Comparison"])
+    for _, row in top_family_deltas.iterrows():
+        lines.append(
+            f"- `{row['entry_family']}`: alpha `{row['avg_return_alpha']:.2%}` vs refined `{row['avg_return_refined']:.2%}`, "
+            f"delta `{row['delta_avg_return']:.2%}`, alpha trades `{int(row['trades_alpha'])}`, refined trades `{int(row['trades_refined'])}`"
+        )
+
+    lines.extend(
+        [
+            "",
+            "## Quant Judgment",
+            "- This review checks whether improvement is broad-based or concentrated in a narrow slice of the sample.",
+            "- If refined mainly wins through short-holding cleanup while not damaging medium and long holding buckets, the branch is behaving as intended.",
+        ]
+    )
+
+    (base_dir / "dragon_glued_refined_year_regime_review.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+
+if __name__ == "__main__":
+    main()

+ 343 - 0
research/dragon/v2/dragon_glued_veto_attribution.py

@@ -0,0 +1,343 @@
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Optional
+
+import pandas as pd
+
+from dragon_branch_configs import alpha_first_glued_selective_veto_config, alpha_first_selective_veto_config
+from dragon_strategy import DragonRuleEngine
+
+
+def _load_csv(base_dir: Path, name: str) -> pd.DataFrame:
+    return pd.read_csv(base_dir / name, encoding="utf-8-sig")
+
+
+def _load_indicator_snapshot(base_dir: Path) -> pd.DataFrame:
+    df = _load_csv(base_dir, "dragon_indicator_snapshot.csv")
+    df["date"] = pd.to_datetime(df["date"])
+    return df.sort_values("date").reset_index(drop=True)
+
+
+def _profit_factor(series: pd.Series) -> float:
+    gross_profit = series[series > 0].sum()
+    gross_loss = -series[series < 0].sum()
+    if gross_loss == 0:
+        return float("inf") if gross_profit > 0 else 0.0
+    return float(gross_profit / gross_loss)
+
+
+def _pct(value: Optional[float]) -> str:
+    if value is None or pd.isna(value):
+        return "n/a"
+    if value == float("inf"):
+        return "inf"
+    return f"{float(value):.2%}"
+
+
+def _fmt_num(value: Optional[float]) -> str:
+    if value is None or pd.isna(value):
+        return "n/a"
+    if value == float("inf"):
+        return "inf"
+    return f"{float(value):.2f}"
+
+
+def _holding_bucket(days: int) -> str:
+    if days <= 5:
+        return "00-05d"
+    if days <= 10:
+        return "06-10d"
+    if days <= 20:
+        return "11-20d"
+    if days <= 40:
+        return "21-40d"
+    return "41d+"
+
+
+def _build_trade_quality(trades: pd.DataFrame, indicators: pd.DataFrame) -> pd.DataFrame:
+    trades = trades.copy()
+    trades["buy_dt"] = pd.to_datetime(trades["buy_date"])
+    trades["sell_dt"] = pd.to_datetime(trades["sell_date"])
+    trades["holding_bucket"] = trades["holding_days"].astype(int).map(_holding_bucket)
+
+    pos_lookup = {dt.date().isoformat(): idx for idx, dt in enumerate(indicators["date"])}
+
+    buy_a1: list[float] = []
+    buy_b1: list[float] = []
+    buy_c1: list[float] = []
+    sell_a1: list[float] = []
+    sell_b1: list[float] = []
+    sell_c1: list[float] = []
+    mfe_list: list[float] = []
+    mae_list: list[float] = []
+    exit_followthrough_list: list[float] = []
+    exit_rebound_list: list[float] = []
+    entry_forward_5d_list: list[float] = []
+
+    indicator_by_date = indicators.set_index(indicators["date"].dt.date)
+
+    for _, trade in trades.iterrows():
+        buy_date = pd.Timestamp(trade["buy_date"]).date()
+        sell_date = pd.Timestamp(trade["sell_date"]).date()
+        entry_price = float(trade["buy_price"])
+        exit_price = float(trade["sell_price"])
+
+        buy_row = indicator_by_date.loc[buy_date]
+        sell_row = indicator_by_date.loc[sell_date]
+        buy_a1.append(float(buy_row["a1"]))
+        buy_b1.append(float(buy_row["b1"]))
+        buy_c1.append(float(buy_row["c1"]))
+        sell_a1.append(float(sell_row["a1"]))
+        sell_b1.append(float(sell_row["b1"]))
+        sell_c1.append(float(sell_row["c1"]))
+
+        window = indicators[(indicators["date"] >= pd.Timestamp(trade["buy_date"])) & (indicators["date"] <= pd.Timestamp(trade["sell_date"]))]
+        max_high = float(window["high"].max())
+        min_low = float(window["low"].min())
+        mfe_list.append(max_high / entry_price - 1.0)
+        mae_list.append(min_low / entry_price - 1.0)
+
+        sell_idx = pos_lookup[trade["sell_date"]]
+        future = indicators.iloc[sell_idx + 1 : sell_idx + 6]
+        if future.empty:
+            exit_followthrough_list.append(float("nan"))
+            exit_rebound_list.append(float("nan"))
+        else:
+            exit_followthrough_list.append(float(future["low"].min()) / exit_price - 1.0)
+            exit_rebound_list.append(float(future["high"].max()) / exit_price - 1.0)
+
+        buy_idx = pos_lookup[trade["buy_date"]]
+        entry_future = indicators.iloc[buy_idx + 1 : buy_idx + 6]
+        if entry_future.empty:
+            entry_forward_5d_list.append(float("nan"))
+        else:
+            entry_forward_5d_list.append(float(entry_future["close"].iloc[-1]) / entry_price - 1.0)
+
+    trades["buy_a1"] = buy_a1
+    trades["buy_b1"] = buy_b1
+    trades["buy_c1"] = buy_c1
+    trades["sell_a1"] = sell_a1
+    trades["sell_b1"] = sell_b1
+    trades["sell_c1"] = sell_c1
+    trades["mfe_pct"] = mfe_list
+    trades["mae_pct"] = mae_list
+    trades["exit_followthrough_5d_pct"] = exit_followthrough_list
+    trades["exit_rebound_5d_pct"] = exit_rebound_list
+    trades["entry_forward_5d_pct"] = entry_forward_5d_list
+    return trades
+
+
+def _run_branch(indicators: pd.DataFrame, config) -> pd.DataFrame:
+    indicator_indexed = indicators.set_index("date", drop=False)
+    engine = DragonRuleEngine(config=config)
+    _, trades = engine.run(indicator_indexed)
+    trades = trades.copy()
+    return _build_trade_quality(trades, indicators)
+
+
+def _trade_key(df: pd.DataFrame) -> set[tuple[str, str, str, str]]:
+    return set(zip(df["buy_date"], df["sell_date"], df["buy_reason"], df["sell_reason"]))
+
+
+def _veto_bucket(c1: float, b1: float) -> str:
+    if c1 >= 40 and b1 >= 0.10:
+        return "hot_positive_b1"
+    if 23 <= c1 < 28 and b1 <= 0.02:
+        return "low_weak_range"
+    return "other"
+
+
+def _recommendation(row: pd.Series) -> tuple[str, str]:
+    ret = float(row["return_pct"])
+    holding = int(row["holding_days"])
+    mfe = float(row["mfe_pct"])
+    replacement_ret = row.get("replacement_return_pct")
+
+    if ret > 0:
+        if ret <= 0.01 and holding <= 15:
+            if pd.notna(replacement_ret) and float(replacement_ret) <= ret:
+                return "OBSERVE_VETO", "原交易仅微利,但当前替代路径没有更强,建议进一步细化 hot 过滤而不是直接全盘保留。"
+            return "OBSERVE_VETO", "原交易为微利短单,暂不应视为强 alpha,先保留为观察样本。"
+        return "OVER_VETO", "原交易本身为明显盈利单,当前过滤过度。"
+
+    if holding <= 10 and ret < 0 and float(row["exit_followthrough_5d_pct"]) <= 0:
+        return "KEEP_VETO", "短持仓亏损且卖出后继续走弱,属于应优先清理的噪音单。"
+    if ret < 0 and mfe <= 0.02:
+        return "KEEP_VETO", "持仓期间几乎没有有效盈利空间,删除逻辑合理。"
+    return "KEEP_VETO", "总体为负收益短单,保留 veto 更符合当前 alpha-first 目标。"
+
+
+def _summary_line(group: pd.DataFrame) -> str:
+    if group.empty:
+        return "n/a"
+    return (
+        f"trades `{len(group)}`, win_rate `{_pct(float((group['return_pct'] > 0).mean()))}`, "
+        f"avg_return `{_pct(float(group['return_pct'].mean()))}`, avg_mfe `{_pct(float(group['mfe_pct'].mean()))}`, "
+        f"avg_mae `{_pct(float(group['mae_pct'].mean()))}`, avg_holding `{float(group['holding_days'].mean()):.2f}`"
+    )
+
+
+def main() -> None:
+    base_dir = Path(__file__).resolve().parent
+    indicators = _load_indicator_snapshot(base_dir)
+    workbook_events = _load_csv(base_dir, "true_trade_events.csv")
+    alpha_trades = _run_branch(indicators, alpha_first_selective_veto_config())
+    glued_trades = _run_branch(indicators, alpha_first_glued_selective_veto_config())
+
+    workbook_buy = set(workbook_events[(workbook_events["layer"] == "real_trade") & (workbook_events["side"] == "BUY")]["date"])
+    workbook_sell = set(workbook_events[(workbook_events["layer"] == "real_trade") & (workbook_events["side"] == "SELL")]["date"])
+
+    alpha_set = _trade_key(alpha_trades)
+    glued_set = _trade_key(glued_trades)
+    removed = pd.DataFrame(
+        sorted(alpha_set - glued_set),
+        columns=["buy_date", "sell_date", "buy_reason", "sell_reason"],
+    )
+
+    rows: list[dict[str, object]] = []
+    for _, removed_row in removed.iterrows():
+        match = alpha_trades[
+            (alpha_trades["buy_date"] == removed_row["buy_date"])
+            & (alpha_trades["sell_date"] == removed_row["sell_date"])
+            & (alpha_trades["buy_reason"] == removed_row["buy_reason"])
+            & (alpha_trades["sell_reason"] == removed_row["sell_reason"])
+        ]
+        if match.empty:
+            continue
+        trade = match.iloc[0]
+        sell_dt = pd.Timestamp(trade["sell_date"])
+        replacement = glued_trades[
+            (pd.to_datetime(glued_trades["buy_date"]) > sell_dt)
+            & (pd.to_datetime(glued_trades["buy_date"]) <= sell_dt + pd.Timedelta(days=10))
+        ].sort_values("buy_date")
+        replacement_row = replacement.iloc[0] if not replacement.empty else None
+
+        rec, rec_reason = _recommendation(
+            pd.Series(
+                {
+                    **trade.to_dict(),
+                    "replacement_return_pct": None if replacement_row is None else float(replacement_row["return_pct"]),
+                }
+            )
+        )
+
+        rows.append(
+            {
+                "buy_date": trade["buy_date"],
+                "sell_date": trade["sell_date"],
+                "buy_reason": trade["buy_reason"],
+                "sell_reason": trade["sell_reason"],
+                "holding_bucket": trade["holding_bucket"],
+                "holding_days": int(trade["holding_days"]),
+                "return_pct": float(trade["return_pct"]),
+                "mfe_pct": float(trade["mfe_pct"]),
+                "mae_pct": float(trade["mae_pct"]),
+                "entry_forward_5d_pct": float(trade["entry_forward_5d_pct"]),
+                "exit_followthrough_5d_pct": float(trade["exit_followthrough_5d_pct"]),
+                "exit_rebound_5d_pct": float(trade["exit_rebound_5d_pct"]),
+                "buy_a1": float(trade["buy_a1"]),
+                "buy_b1": float(trade["buy_b1"]),
+                "buy_c1": float(trade["buy_c1"]),
+                "sell_a1": float(trade["sell_a1"]),
+                "sell_b1": float(trade["sell_b1"]),
+                "sell_c1": float(trade["sell_c1"]),
+                "veto_bucket": _veto_bucket(float(trade["buy_c1"]), float(trade["buy_b1"])),
+                "buy_aligned_with_workbook": trade["buy_date"] in workbook_buy,
+                "sell_aligned_with_workbook": trade["sell_date"] in workbook_sell,
+                "replacement_buy_date": "" if replacement_row is None else str(replacement_row["buy_date"]),
+                "replacement_sell_date": "" if replacement_row is None else str(replacement_row["sell_date"]),
+                "replacement_buy_reason": "" if replacement_row is None else str(replacement_row["buy_reason"]),
+                "replacement_sell_reason": "" if replacement_row is None else str(replacement_row["sell_reason"]),
+                "replacement_return_pct": float("nan") if replacement_row is None else float(replacement_row["return_pct"]),
+                "replacement_gap_days": float("nan")
+                if replacement_row is None
+                else int((pd.Timestamp(replacement_row["buy_date"]) - sell_dt).days),
+                "recommendation": rec,
+                "recommendation_reason": rec_reason,
+            }
+        )
+
+    attribution = pd.DataFrame(rows).sort_values(["veto_bucket", "buy_date"]).reset_index(drop=True)
+    attribution.to_csv(base_dir / "dragon_glued_veto_attribution.csv", index=False, encoding="utf-8-sig")
+
+    bucket_summary = (
+        attribution.groupby("veto_bucket", dropna=False)
+        .agg(
+            trades=("buy_date", "count"),
+            win_rate=("return_pct", lambda s: float((s > 0).mean())),
+            avg_return=("return_pct", "mean"),
+            avg_mfe=("mfe_pct", "mean"),
+            avg_mae=("mae_pct", "mean"),
+            avg_holding_days=("holding_days", "mean"),
+            keep_veto_count=("recommendation", lambda s: int((s == "KEEP_VETO").sum())),
+            observe_veto_count=("recommendation", lambda s: int((s == "OBSERVE_VETO").sum())),
+            over_veto_count=("recommendation", lambda s: int((s == "OVER_VETO").sum())),
+        )
+        .reset_index()
+        .sort_values(["trades", "avg_return"], ascending=[False, True])
+    )
+    bucket_summary.to_csv(base_dir / "dragon_glued_veto_bucket_summary.csv", index=False, encoding="utf-8-sig")
+
+    lines = [
+        "# Dragon Glued Veto Attribution Review",
+        "",
+        "## Snapshot",
+        f"- removed trades vs current alpha-first: `{len(attribution)}`",
+        f"- total avg_return of removed set: `{_pct(float(attribution['return_pct'].mean()))}`",
+        f"- total win_rate of removed set: `{_pct(float((attribution['return_pct'] > 0).mean()))}`",
+        f"- removed-set profit_factor: `{_fmt_num(_profit_factor(attribution['return_pct']))}`",
+        "",
+        "## Bucket Summary",
+    ]
+
+    for _, row in bucket_summary.iterrows():
+        lines.append(
+            f"- `{row['veto_bucket']}`: trades `{int(row['trades'])}`, win_rate `{_pct(float(row['win_rate']))}`, "
+            f"avg_return `{_pct(float(row['avg_return']))}`, avg_mfe `{_pct(float(row['avg_mfe']))}`, "
+            f"avg_mae `{_pct(float(row['avg_mae']))}`, avg_holding `{float(row['avg_holding_days']):.2f}`, "
+            f"KEEP/OBSERVE/OVER = `{int(row['keep_veto_count'])}/{int(row['observe_veto_count'])}/{int(row['over_veto_count'])}`"
+        )
+
+    lines.extend(
+        [
+            "",
+            "## Quant Judgment",
+            f"- `low_weak_range`: {_summary_line(attribution[attribution['veto_bucket'] == 'low_weak_range'])}.",
+            f"- `hot_positive_b1`: {_summary_line(attribution[attribution['veto_bucket'] == 'hot_positive_b1'])}.",
+            "- `low_weak_range` is now a clean promotion candidate: all removed trades are short, losing, and there is no positive sample in this bucket.",
+            "- `hot_positive_b1` is directionally correct but not fully clean: most removed trades are weak, but one micro-profit sample remains and should be used as the first refinement target.",
+            "- Immediate next research action: keep the low bucket intact, and narrow the hot bucket rather than rolling back the whole glued veto branch.",
+            "",
+            "## Detailed Cards",
+        ]
+    )
+
+    for _, row in attribution.iterrows():
+        replacement = "none"
+        if isinstance(row["replacement_buy_date"], str) and row["replacement_buy_date"]:
+            replacement = (
+                f"{row['replacement_buy_date']} -> {row['replacement_sell_date']} / "
+                f"{row['replacement_buy_reason']} -> {row['replacement_sell_reason']} / "
+                f"{_pct(row['replacement_return_pct'])}"
+            )
+        lines.extend(
+            [
+                f"### {row['buy_date']} -> {row['sell_date']}",
+                f"- Bucket: `{row['veto_bucket']}`",
+                f"- Trade: `{row['buy_reason']} -> {row['sell_reason']}` | `{int(row['holding_days'])}` days | return `{_pct(row['return_pct'])}`",
+                f"- MFE / MAE: `{_pct(row['mfe_pct'])}` / `{_pct(row['mae_pct'])}`",
+                f"- Entry 5d / Exit followthrough 5d: `{_pct(row['entry_forward_5d_pct'])}` / `{_pct(row['exit_followthrough_5d_pct'])}`",
+                f"- Entry indicators: `a1={float(row['buy_a1']):.4f}` `b1={float(row['buy_b1']):.4f}` `c1={float(row['buy_c1']):.2f}`",
+                f"- Workbook aligned: buy `{bool(row['buy_aligned_with_workbook'])}` / sell `{bool(row['sell_aligned_with_workbook'])}`",
+                f"- Candidate replacement within 10d after exit: `{replacement}`",
+                f"- Recommendation: `{row['recommendation']}` | {row['recommendation_reason']}",
+                "",
+            ]
+        )
+
+    (base_dir / "dragon_glued_veto_review.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+
+if __name__ == "__main__":
+    main()

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 1959 - 0
research/dragon/v2/dragon_html_reports.py


+ 110 - 0
research/dragon/v2/dragon_indicators.py

@@ -0,0 +1,110 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import datetime
+from pathlib import Path
+from typing import Optional
+
+import numpy as np
+import pandas as pd
+
+import sys
+
+REPO_ROOT = Path(__file__).resolve().parents[3]
+ROOT_DRAGON_DIR = REPO_ROOT / "dragon"
+if not ROOT_DRAGON_DIR.exists():
+    raise ModuleNotFoundError(f"Expected dragon dependency directory at {ROOT_DRAGON_DIR}")
+if str(ROOT_DRAGON_DIR) not in sys.path:
+    sys.path.append(str(ROOT_DRAGON_DIR))
+
+import MyTT  # noqa: E402
+from data_fetcher_v2 import DataFetcherV2  # noqa: E402
+
+
+@dataclass
+class DragonIndicatorConfig:
+    symbol: str = "399673"
+    start_date: str = "2015-01-01"
+    end_date: Optional[str] = None
+
+
+def _cross_up(left: np.ndarray, right: np.ndarray) -> np.ndarray:
+    left_prev = np.roll(left, 1)
+    right_prev = np.roll(right, 1)
+    result = (left > right) & (left_prev <= right_prev)
+    result[0] = False
+    return result
+
+
+class DragonIndicatorEngine:
+    def __init__(self, config: Optional[DragonIndicatorConfig] = None):
+        self.config = config or DragonIndicatorConfig()
+        self.fetcher = DataFetcherV2()
+
+    def fetch_daily_data(self) -> pd.DataFrame:
+        end_date = self.config.end_date or datetime.now().strftime("%Y-%m-%d")
+        df = self.fetcher.fetch_index_data_v2(
+            symbol=self.config.symbol,
+            start_date=self.config.start_date,
+            end_date=end_date,
+        )
+        if df.empty:
+            raise RuntimeError(f"Failed to fetch daily data for {self.config.symbol}")
+        return df.sort_index().copy()
+
+    def compute(self, df: pd.DataFrame) -> pd.DataFrame:
+        if df.empty:
+            return df.copy()
+
+        result = df.copy()
+        close = result["close"].to_numpy(dtype=float)
+        high = result["high"].to_numpy(dtype=float)
+        low = result["low"].to_numpy(dtype=float)
+        open_ = result["open"].to_numpy(dtype=float)
+
+        h1_5 = np.nan_to_num(MyTT.EMA(close, 8), nan=0.0)
+        h2_5 = np.nan_to_num(MyTT.EMA(h1_5, 20), nan=0.0)
+        rsv = np.nan_to_num((close - MyTT.LLV(low, 7)) / (MyTT.HHV(high, 7) - MyTT.LLV(low, 7)) * 100)
+        y0 = np.nan_to_num(MyTT.SMA(rsv, 3, 1), nan=0.0)
+        y1 = np.nan_to_num(MyTT.SMA(y0, 3, 1), nan=0.0)
+        rsv1 = np.nan_to_num((close - MyTT.LLV(low, 38)) / (MyTT.HHV(high, 38) - MyTT.LLV(low, 38)) * 100)
+        y2 = np.nan_to_num(MyTT.SMA(rsv1, 5, 1), nan=0.0)
+        y3 = np.nan_to_num(MyTT.SMA(y2, 10, 1), nan=0.0)
+
+        avg_h = (h1_5 + h2_5) / 2.0
+        a1 = np.divide(h1_5 - h2_5, avg_h, out=np.zeros_like(avg_h), where=avg_h != 0)
+        b1 = (y2 - y3) / 100.0
+        c1 = (y2 + y3) / 2.0
+
+        xopen = (MyTT.REF(open_, 1) + MyTT.REF(close, 1)) / 2.0
+        xopen = np.nan_to_num(xopen, nan=close)
+        xclose = close
+        xhigh = np.maximum(high, xopen)
+        xlow = np.minimum(low, xopen)
+        ql_volatility = np.nan_to_num(MyTT.MA(xhigh - xlow, 8), nan=0.0)
+        ql_mid = np.nan_to_num(MyTT.MA(xclose, 5), nan=0.0)
+        ql_upper = ql_mid + ql_volatility / 2.0
+        ql_lower = ql_mid - ql_volatility / 2.0
+
+        kdj_buy = _cross_up(y0, y1)
+        kdj_sell = _cross_up(y1, y0)
+        ql_buy = _cross_up(xclose, ql_upper)
+        ql_sell = _cross_up(ql_lower, xclose)
+
+        result["h1_5"] = h1_5
+        result["h2_5"] = h2_5
+        result["a1"] = a1
+        result["y0"] = y0
+        result["y1"] = y1
+        result["kdj_buy"] = kdj_buy
+        result["kdj_sell"] = kdj_sell
+        result["y2"] = y2
+        result["y3"] = y3
+        result["b1"] = b1
+        result["c1"] = c1
+        result["ql_xopen"] = xopen
+        result["ql_upper"] = ql_upper
+        result["ql_lower"] = ql_lower
+        result["ql_buy"] = ql_buy
+        result["ql_sell"] = ql_sell
+        return result

+ 117 - 0
research/dragon/v2/dragon_mismatch_diagnostics.py

@@ -0,0 +1,117 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pandas as pd
+
+
+def _find_workbook(base_dir: Path) -> Path:
+    matches = sorted(base_dir.glob("*.xlsx"))
+    if not matches:
+        raise FileNotFoundError(f"No workbook found in {base_dir}")
+    return matches[0]
+
+
+def _collect_gap_rows(workbook_df: pd.DataFrame, strategy_df: pd.DataFrame, indicators_df: pd.DataFrame, side: str, layer: str, gap_type: str) -> pd.DataFrame:
+    wb = workbook_df[(workbook_df["side"] == side) & (workbook_df["layer"] == layer)].copy()
+    st = strategy_df[(strategy_df["side"] == side) & (strategy_df["layer"] == layer)].copy()
+    wb_dates = set(wb["date"])
+    st_dates = set(st["date"])
+
+    if gap_type == "missing_from_strategy":
+        target_dates = sorted(wb_dates - st_dates)
+        base = wb[wb["date"].isin(target_dates)].copy()
+        base["diagnostic_type"] = gap_type
+        base["source_note"] = base.get("note", "")
+        base["source_reason"] = base.get("signal_reason", "")
+    else:
+        target_dates = sorted(st_dates - wb_dates)
+        base = st[st["date"].isin(target_dates)].copy()
+        base["diagnostic_type"] = gap_type
+        base["source_note"] = ""
+        base["source_reason"] = base.get("reason", "")
+
+    if base.empty:
+        return base
+
+    merged = base.merge(
+        indicators_df[
+            ["date", "close", "a1", "b1", "c1", "kdj_buy", "kdj_sell", "ql_buy", "ql_sell"]
+        ],
+        on="date",
+        how="left",
+        suffixes=("", "_ind"),
+    )
+    merged["target_side"] = side
+    merged["target_layer"] = layer
+    return merged[
+        [
+            "diagnostic_type",
+            "target_layer",
+            "target_side",
+            "date",
+            "source_reason",
+            "source_note",
+            "close",
+            "a1",
+            "b1",
+            "c1",
+            "kdj_buy",
+            "kdj_sell",
+            "ql_buy",
+            "ql_sell",
+        ]
+    ]
+
+
+def main() -> None:
+    base_dir = Path(__file__).resolve().parent
+    workbook_path = _find_workbook(base_dir)
+    workbook_layers = pd.read_csv(base_dir / "dragon_workbook_layers.csv", encoding="utf-8-sig")
+    strategy_events = pd.read_csv(base_dir / "dragon_strategy_events.csv", encoding="utf-8-sig")
+    indicators = pd.read_csv(base_dir / "dragon_indicator_snapshot.csv", encoding="utf-8-sig")
+
+    parts = []
+    for side in ("BUY", "SELL"):
+        for layer in ("real_trade", "aux_signal"):
+            parts.append(_collect_gap_rows(workbook_layers, strategy_events, indicators, side, layer, "missing_from_strategy"))
+            parts.append(_collect_gap_rows(workbook_layers, strategy_events, indicators, side, layer, "extra_in_strategy"))
+
+    gaps = pd.concat(parts, ignore_index=True)
+    gaps.to_csv(base_dir / "dragon_event_gaps.csv", index=False, encoding="utf-8-sig")
+
+    lines = [
+        "# Dragon Event Gap Diagnostics",
+        "",
+        f"- Workbook: `{workbook_path.name}`",
+        f"- Gap rows: `{len(gaps)}`",
+        "",
+        "## Counts",
+    ]
+    summary = (
+        gaps.groupby(["diagnostic_type", "target_layer", "target_side"])
+        .size()
+        .reset_index(name="count")
+        .sort_values(["diagnostic_type", "target_layer", "target_side"])
+    )
+    for _, row in summary.iterrows():
+        lines.append(
+            f"- {row['diagnostic_type']} / {row['target_layer']} / {row['target_side']}: `{int(row['count'])}`"
+        )
+
+    top_missing_real_sell = gaps[
+        (gaps["diagnostic_type"] == "missing_from_strategy")
+        & (gaps["target_layer"] == "real_trade")
+        & (gaps["target_side"] == "SELL")
+    ].head(20)
+    lines.extend(["", "## Sample Missing Real SELL Rows"])
+    for _, row in top_missing_real_sell.iterrows():
+        lines.append(
+            f"- {row['date']} reason `{row['source_reason']}` note `{row['source_note']}` a1 `{row['a1']:.4f}` b1 `{row['b1']:.4f}` c1 `{row['c1']:.2f}`"
+        )
+
+    (base_dir / "dragon_event_gaps.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+
+if __name__ == "__main__":
+    main()

+ 149 - 0
research/dragon/v2/dragon_predictive_break_audit.py

@@ -0,0 +1,149 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pandas as pd
+
+from dragon_indicators import DragonIndicatorConfig, DragonIndicatorEngine
+from dragon_strategy import DragonRuleEngine
+from dragon_strategy_config import StrategyConfig
+
+
+def _profit_factor(series: pd.Series) -> float:
+    gross_profit = series[series > 0].sum()
+    gross_loss = -series[series < 0].sum()
+    if gross_loss == 0:
+        return float("inf") if gross_profit > 0 else 0.0
+    return float(gross_profit / gross_loss)
+
+
+def main() -> None:
+    base_dir = Path(__file__).resolve().parent
+    engine = DragonIndicatorEngine(DragonIndicatorConfig(start_date="2015-01-01", end_date="2026-01-31"))
+    indicator_df = engine.compute(engine.fetch_daily_data())
+
+    baseline_events, baseline_trades = DragonRuleEngine(config=StrategyConfig()).run(indicator_df)
+    disabled_events, disabled_trades = DragonRuleEngine(
+        config=StrategyConfig(disabled_rules=frozenset({"predictive_b1_break_exit"}))
+    ).run(indicator_df)
+
+    predictive = baseline_trades[baseline_trades["sell_reason"] == "predictive_b1_break_exit"].copy()
+    if predictive.empty:
+        raise RuntimeError("No predictive_b1_break_exit trades found in baseline.")
+
+    predictive["buy_dt"] = pd.to_datetime(predictive["buy_date"])
+    predictive["sell_dt"] = pd.to_datetime(predictive["sell_date"])
+    indicator_df["dt"] = indicator_df.index
+    indicator_df = indicator_df.reset_index(drop=True)
+    pos_lookup = {dt.date().isoformat(): idx for idx, dt in enumerate(pd.to_datetime(indicator_df["dt"]))}
+
+    rows: list[dict[str, object]] = []
+    for _, trade in predictive.iterrows():
+        sell_idx = pos_lookup[trade["sell_date"]]
+        entry_price = float(trade["buy_price"])
+        exit_price = float(trade["sell_price"])
+
+        future_3 = indicator_df.iloc[sell_idx + 1 : sell_idx + 4]
+        future_5 = indicator_df.iloc[sell_idx + 1 : sell_idx + 6]
+        future_10 = indicator_df.iloc[sell_idx + 1 : sell_idx + 11]
+
+        alt_trade = disabled_trades[
+            (disabled_trades["buy_date"] == trade["buy_date"]) & (disabled_trades["buy_reason"] == trade["buy_reason"])
+        ].copy()
+        alt_sell_date = alt_trade["sell_date"].iloc[0] if not alt_trade.empty else ""
+        alt_sell_reason = alt_trade["sell_reason"].iloc[0] if not alt_trade.empty else ""
+        alt_return = float(alt_trade["return_pct"].iloc[0]) if not alt_trade.empty else float("nan")
+
+        resumed_trade = baseline_trades[baseline_trades["buy_date"] > trade["sell_date"]].head(1).copy()
+        resumed_buy_date = resumed_trade["buy_date"].iloc[0] if not resumed_trade.empty else ""
+        resumed_buy_reason = resumed_trade["buy_reason"].iloc[0] if not resumed_trade.empty else ""
+        resumed_sell_date = resumed_trade["sell_date"].iloc[0] if not resumed_trade.empty else ""
+        resumed_return = float(resumed_trade["return_pct"].iloc[0]) if not resumed_trade.empty else float("nan")
+
+        combined_return = (1.0 + float(trade["return_pct"])) * (1.0 + resumed_return) - 1.0 if resumed_trade.shape[0] == 1 else float("nan")
+
+        rows.append(
+            {
+                "buy_date": trade["buy_date"],
+                "buy_reason": trade["buy_reason"],
+                "predictive_sell_date": trade["sell_date"],
+                "predictive_return_pct": float(trade["return_pct"]),
+                "predictive_holding_days": int(trade["holding_days"]),
+                "exit_a1": float(
+                    baseline_events[
+                        (baseline_events["date"] == trade["sell_date"])
+                        & (baseline_events["layer"] == "real_trade")
+                        & (baseline_events["side"] == "SELL")
+                    ]["a1"].iloc[0]
+                ),
+                "exit_b1": float(
+                    baseline_events[
+                        (baseline_events["date"] == trade["sell_date"])
+                        & (baseline_events["layer"] == "real_trade")
+                        & (baseline_events["side"] == "SELL")
+                    ]["b1"].iloc[0]
+                ),
+                "exit_c1": float(
+                    baseline_events[
+                        (baseline_events["date"] == trade["sell_date"])
+                        & (baseline_events["layer"] == "real_trade")
+                        & (baseline_events["side"] == "SELL")
+                    ]["c1"].iloc[0]
+                ),
+                "future_3d_low_pct": float(future_3["low"].min()) / exit_price - 1.0 if not future_3.empty else float("nan"),
+                "future_3d_high_pct": float(future_3["high"].max()) / exit_price - 1.0 if not future_3.empty else float("nan"),
+                "future_5d_low_pct": float(future_5["low"].min()) / exit_price - 1.0 if not future_5.empty else float("nan"),
+                "future_5d_high_pct": float(future_5["high"].max()) / exit_price - 1.0 if not future_5.empty else float("nan"),
+                "future_10d_low_pct": float(future_10["low"].min()) / exit_price - 1.0 if not future_10.empty else float("nan"),
+                "future_10d_high_pct": float(future_10["high"].max()) / exit_price - 1.0 if not future_10.empty else float("nan"),
+                "disabled_alt_sell_date": alt_sell_date,
+                "disabled_alt_sell_reason": alt_sell_reason,
+                "disabled_alt_return_pct": alt_return,
+                "resumed_buy_date": resumed_buy_date,
+                "resumed_buy_reason": resumed_buy_reason,
+                "resumed_sell_date": resumed_sell_date,
+                "resumed_return_pct": resumed_return,
+                "combined_split_return_pct": combined_return,
+            }
+        )
+
+    audit_df = pd.DataFrame(rows)
+    audit_df.to_csv(base_dir / "dragon_predictive_break_audit.csv", index=False, encoding="utf-8-sig")
+
+    lines = [
+        "# Dragon Predictive Break Review",
+        "",
+        f"- predictive exit trades in baseline: `{len(audit_df)}`",
+        f"- predictive exit avg return: `{audit_df['predictive_return_pct'].mean():.2%}`",
+        f"- disabled-rule alt avg return: `{audit_df['disabled_alt_return_pct'].mean():.2%}`",
+        f"- split-chain combined avg return: `{audit_df['combined_split_return_pct'].mean():.2%}`",
+        "",
+        "## Trade Cards",
+    ]
+    for _, row in audit_df.iterrows():
+        lines.append(
+            f"- `{row['buy_date']} -> {row['predictive_sell_date']}` `{row['buy_reason']}` | "
+            f"exit a1 `{row['exit_a1']:.4f}` b1 `{row['exit_b1']:.4f}` c1 `{row['exit_c1']:.2f}` | "
+            f"3d low `{row['future_3d_low_pct']:.2%}` / 5d high `{row['future_5d_high_pct']:.2%}`"
+        )
+        lines.append(
+            f"  disabled path -> `{row['disabled_alt_sell_date']}` `{row['disabled_alt_sell_reason']}` `{row['disabled_alt_return_pct']:.2%}`"
+        )
+        lines.append(
+            f"  split path reentry -> `{row['resumed_buy_date']}` `{row['resumed_buy_reason']}` to `{row['resumed_sell_date']}` `{row['resumed_return_pct']:.2%}`; combined `{row['combined_split_return_pct']:.2%}`"
+        )
+
+    lines.extend(
+        [
+            "",
+            "## Quant Judgment",
+            "- The remaining predictive break is a bridge-style exit, not a generic stop-loss bucket.",
+            "- Disabling it improves the single uninterrupted trade return, but destroys the workbook-aligned split chain.",
+            "- Under the current reconstruction objective, this rule should be frozen unless the user explicitly accepts lower workbook alignment.",
+        ]
+    )
+    (base_dir / "dragon_predictive_break_review.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+
+if __name__ == "__main__":
+    main()

+ 103 - 0
research/dragon/v2/dragon_predictive_break_experiments.py

@@ -0,0 +1,103 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pandas as pd
+
+from dragon_indicators import DragonIndicatorConfig, DragonIndicatorEngine
+from dragon_strategy import DragonRuleEngine
+from dragon_strategy_config import StrategyConfig
+from dragon_workbook import DragonWorkbook
+
+
+def _find_workbook(base_dir: Path) -> Path:
+    matches = sorted(base_dir.glob("*.xlsx"))
+    if not matches:
+        raise FileNotFoundError(f"No workbook found in {base_dir}")
+    return matches[0]
+
+
+def _load_workbook_events(workbook_path: Path) -> pd.DataFrame:
+    workbook = DragonWorkbook(workbook_path)
+    return pd.DataFrame([{"date": e.date.isoformat(), "side": e.side, "layer": e.layer} for e in workbook.split_layers()])
+
+
+def _overlap(workbook_events: pd.DataFrame, strategy_events: pd.DataFrame, side: str, layer: str) -> tuple[int, int]:
+    wb = set(workbook_events[(workbook_events["side"] == side) & (workbook_events["layer"] == layer)]["date"])
+    st = set(strategy_events[(strategy_events["side"] == side) & (strategy_events["layer"] == layer)]["date"])
+    return len(wb & st), len(st - wb)
+
+
+def _run(label: str, config: StrategyConfig, workbook_events: pd.DataFrame, indicator_df: pd.DataFrame, first_date: str, last_date: str) -> dict[str, object]:
+    events, trades = DragonRuleEngine(config=config).run(indicator_df)
+    events = events[(events["date"] >= first_date) & (events["date"] <= last_date)].copy()
+    trades = trades[
+        (trades["buy_date"] >= first_date)
+        & (trades["buy_date"] <= last_date)
+        & (trades["sell_date"] >= first_date)
+        & (trades["sell_date"] <= last_date)
+    ].copy()
+
+    buy_overlap, buy_extra = _overlap(workbook_events, events, "BUY", "real_trade")
+    sell_overlap, sell_extra = _overlap(workbook_events, events, "SELL", "real_trade")
+    predictive = trades[trades["sell_reason"] == "predictive_b1_break_exit"]
+
+    return {
+        "experiment": label,
+        "short_b1_max": config.predictive_b1_break_short_b1_max,
+        "long_b1_max": config.predictive_b1_break_long_b1_max,
+        "trades": int(len(trades)),
+        "avg_return": float(trades["return_pct"].mean()),
+        "real_buy_overlap": int(buy_overlap),
+        "real_sell_overlap": int(sell_overlap),
+        "real_buy_extra": int(buy_extra),
+        "real_sell_extra": int(sell_extra),
+        "predictive_trade_count": int(len(predictive)),
+    }
+
+
+def main() -> None:
+    base_dir = Path(__file__).resolve().parent
+    workbook_events = _load_workbook_events(_find_workbook(base_dir))
+    first_date = pd.to_datetime(workbook_events["date"]).min().date().isoformat()
+    last_date = pd.to_datetime(workbook_events["date"]).max().date().isoformat()
+
+    engine = DragonIndicatorEngine(DragonIndicatorConfig(start_date="2015-01-01", end_date="2026-01-31"))
+    indicator_df = engine.compute(engine.fetch_daily_data())
+
+    baseline = StrategyConfig()
+    experiments = [
+        ("baseline", baseline),
+        ("short_b1_looser", baseline.with_updates(predictive_b1_break_short_b1_max=-0.11)),
+        ("short_b1_tighter", baseline.with_updates(predictive_b1_break_short_b1_max=-0.15)),
+        ("long_b1_looser", baseline.with_updates(predictive_b1_break_long_b1_max=-0.10)),
+        ("long_b1_tighter", baseline.with_updates(predictive_b1_break_long_b1_max=-0.14)),
+    ]
+
+    rows = [_run(label, cfg, workbook_events, indicator_df, first_date, last_date) for label, cfg in experiments]
+    df = pd.DataFrame(rows)
+    base_row = df[df["experiment"] == "baseline"].iloc[0]
+    for col in ["avg_return", "real_buy_overlap", "real_sell_overlap", "predictive_trade_count"]:
+        df[f"delta_{col}"] = df[col] - base_row[col]
+
+    df.to_csv(base_dir / "dragon_predictive_break_experiments.csv", index=False, encoding="utf-8-sig")
+
+    lines = [
+        "# Dragon Predictive Break Experiments",
+        "",
+        f"- baseline predictive trade count: `{int(base_row['predictive_trade_count'])}`",
+        f"- baseline real BUY / SELL overlap: `{int(base_row['real_buy_overlap'])}` / `{int(base_row['real_sell_overlap'])}`",
+        "",
+        "## Experiment Summary",
+    ]
+    for _, row in df.iterrows():
+        lines.append(
+            f"- `{row['experiment']}`: predictive trades `{int(row['predictive_trade_count'])}`, "
+            f"delta_avg_return `{row['delta_avg_return']:.2%}`, real BUY `{int(row['real_buy_overlap'])}`, real SELL `{int(row['real_sell_overlap'])}`"
+        )
+
+    (base_dir / "dragon_predictive_break_experiments.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+
+if __name__ == "__main__":
+    main()

+ 129 - 0
research/dragon/v2/dragon_rc1_release.py

@@ -0,0 +1,129 @@
+from __future__ import annotations
+
+import json
+from dataclasses import asdict
+from pathlib import Path
+
+import pandas as pd
+
+from dragon_branch_configs import alpha_first_glued_refined_hot_cap_config
+from dragon_indicators import DragonIndicatorConfig, DragonIndicatorEngine
+from dragon_shared import END_DATE, START_DATE, evaluation_years, format_num as _format_num, format_pct as _format_pct, profit_factor
+from dragon_strategy import DragonRuleEngine
+RC_VERSION = "RC1"
+RC_BRANCH = "alpha_first_glued_refined_hot_cap"
+
+
+def _max_drawdown(returns: pd.Series) -> tuple[float, int]:
+    equity = (1.0 + returns.astype(float)).cumprod()
+    peak = equity.cummax()
+    dd = equity / peak - 1.0
+    max_dd = float(dd.min()) if not dd.empty else float("nan")
+
+    duration = 0
+    max_duration = 0
+    for value in dd:
+        if value < 0:
+            duration += 1
+            max_duration = max(max_duration, duration)
+        else:
+            duration = 0
+    return max_dd, max_duration
+
+
+def _load_indicator_snapshot(base_dir: Path) -> pd.DataFrame:
+    path = base_dir / "dragon_indicator_snapshot.csv"
+    if path.exists():
+        df = pd.read_csv(path, encoding="utf-8-sig")
+        df["date"] = pd.to_datetime(df["date"])
+        return df.sort_values("date").reset_index(drop=True)
+
+    engine = DragonIndicatorEngine(DragonIndicatorConfig(start_date="2015-01-01", end_date="2026-01-31"))
+    df = engine.compute(engine.fetch_daily_data()).reset_index(drop=False).rename(columns={"index": "date"})
+    df.to_csv(path, index=False, encoding="utf-8-sig")
+    return df
+
+
+def main() -> None:
+    base_dir = Path(__file__).resolve().parent
+    indicators = _load_indicator_snapshot(base_dir)
+    indexed = indicators.set_index("date", drop=False)
+
+    config = alpha_first_glued_refined_hot_cap_config()
+    engine = DragonRuleEngine(config=config)
+    events, trades = engine.run(indexed)
+    trades = trades[
+        (trades["buy_date"] >= START_DATE)
+        & (trades["buy_date"] <= END_DATE)
+        & (trades["sell_date"] >= START_DATE)
+        & (trades["sell_date"] <= END_DATE)
+    ].copy()
+    returns = trades["return_pct"].astype(float)
+    compounded = float((1.0 + returns).prod() - 1.0)
+    years = evaluation_years(START_DATE, END_DATE)
+    cagr = float((1.0 + compounded) ** (1.0 / years) - 1.0)
+    max_dd, dd_duration = _max_drawdown(returns)
+
+    snapshot = {
+        "release_version": RC_VERSION,
+        "branch_name": RC_BRANCH,
+        "evaluation_window": {"start": START_DATE, "end": END_DATE},
+        "trade_count": int(len(trades)),
+        "win_rate": float((returns > 0).mean()),
+        "avg_return": float(returns.mean()),
+        "median_return": float(returns.median()),
+        "profit_factor": profit_factor(returns),
+        "compounded_return": compounded,
+        "cagr": cagr,
+        "max_drawdown": max_dd,
+        "drawdown_duration_trades": dd_duration,
+        "config": {
+            **asdict(config),
+            "disabled_rules": sorted(config.disabled_rules),
+        },
+    }
+
+    (base_dir / "dragon_rc1_config_snapshot.json").write_text(
+        json.dumps(snapshot, indent=2, ensure_ascii=False) + "\n",
+        encoding="utf-8",
+    )
+
+    lines = [
+        "# Dragon RC1 Release",
+        "",
+        f"- Release version: `{RC_VERSION}`",
+        f"- Strategy branch: `{RC_BRANCH}`",
+        f"- Freeze date: `{pd.Timestamp.now().date().isoformat()}`",
+        f"- Evaluation window: `{START_DATE}` to `{END_DATE}`",
+        "",
+        "## Freeze Intent",
+        "- `RC1` is the first frozen release candidate of the refined alpha branch.",
+        "- Its purpose is not to continue blind optimization, but to serve as the tracked strategy candidate for daily signal production and monitoring.",
+        "",
+        "## Frozen Headline",
+        f"- trades: `{int(len(trades))}`",
+        f"- win_rate: `{_format_pct(float((returns > 0).mean()))}`",
+        f"- avg_return: `{_format_pct(float(returns.mean()))}`",
+        f"- median_return: `{_format_pct(float(returns.median()))}`",
+        f"- profit_factor: `{_format_num(profit_factor(returns))}`",
+        f"- compounded_return: `{_format_pct(compounded)}`",
+        f"- CAGR: `{_format_pct(cagr)}`",
+        f"- max_drawdown: `{_format_pct(max_dd)}`",
+        f"- drawdown_duration_trades: `{dd_duration}`",
+        "",
+        "## Operating Rule",
+        "- Treat `RC1` as the forward default branch.",
+        "- Do not change frozen core parameters inside `RC1` directly.",
+        "- Any future core-threshold change must create a new named branch and go through attribution plus execution-aware stress again.",
+        "",
+        "## Artifacts",
+        "- `dragon_rc1_config_snapshot.json`",
+        "- `dragon_formal_strategy_governance.md`",
+        "- `dragon_parameter_governance.md`",
+        "- `dragon_formal_strategy_memo.md`",
+    ]
+    (base_dir / "dragon_rc1_release.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+
+if __name__ == "__main__":
+    main()

+ 586 - 0
research/dragon/v2/dragon_refined_alpha_attribution.py

@@ -0,0 +1,586 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pandas as pd
+
+from dragon_branch_configs import alpha_first_glued_refined_hot_cap_config, alpha_first_selective_veto_config
+from dragon_strategy import DragonRuleEngine
+
+START_DATE = "2016-01-01"
+END_DATE = "2025-12-31"
+
+
+BUY_REASON_STATE = {
+    "deep_oversold_rebound_buy": "low_oversold_regime",
+    "oversold_recovery_buy": "low_oversold_regime",
+    "oversold_reversal_after_ql_buy": "rebound_after_sell_regime",
+    "post_sell_rebound_buy": "rebound_after_sell_regime",
+    "post_washout_kdj_reentry_buy": "rebound_after_sell_regime",
+    "predictive_error_reentry_buy": "rebound_after_sell_regime",
+    "hot_exit_reentry_buy": "rebound_after_sell_regime",
+    "early_crash_probe_buy": "crash_probe_regime",
+    "dual_gold_resonance_buy": "low_oversold_regime",
+    "glued_buy": "mid_regime",
+    "non_glued_positive_expansion_buy": "high_regime",
+}
+
+
+SELL_REASON_MANAGEMENT = {
+    "crash_protection_exit": "predictive_risk_exit",
+    "predictive_b1_break_exit": "predictive_risk_exit",
+    "prewarning_reduction_exit": "prewarning_exit",
+    "high_regime_momentum_break": "prewarning_exit",
+    "high_regime_confirmed_exit:kdj_sell": "confirmed_trend_exit",
+    "ql_high_zone_take_profit": "high_regime_take_profit",
+    "ql_mid_zone_take_profit": "high_regime_take_profit",
+    "medium_hot_take_profit": "high_regime_take_profit",
+    "high_zone_post_ql_fade_exit": "ql_followthrough_exit",
+    "post_ql_decay_exit": "ql_followthrough_exit",
+    "post_dual_sell_decay_exit": "ql_followthrough_exit",
+    "knife_take_profit_1": "first_take_profit",
+    "knife_take_profit_2_glued": "first_take_profit",
+    "knife_take_profit_2_wait_ql_s": "first_take_profit",
+    "early_positive_take_profit": "first_take_profit",
+    "oversold_rebound_take_profit": "first_take_profit",
+    "glued_exit:kdj_sell": "confirmed_trend_exit",
+    "small_positive_a1_declining:ql_sell": "confirmed_trend_exit",
+    "negative_a1_no_b1_recovery:kdj_sell": "negative_a1_exit",
+    "negative_a1_no_b1_recovery:ql_sell": "negative_a1_exit",
+    "negative_a1_b1_not_strong:kdj_sell": "negative_a1_exit",
+    "low_zone_dual_gold_exit:kdj_sell": "negative_a1_exit",
+    "hard_exit:kdj_sell": "hard_risk_exit",
+    "hard_exit:ql_sell": "hard_risk_exit",
+    "early_failed_rebound_exit": "predictive_risk_exit",
+}
+
+
+def _load_csv(base_dir: Path, name: str) -> pd.DataFrame:
+    return pd.read_csv(base_dir / name, encoding="utf-8-sig")
+
+
+def _load_indicator_snapshot(base_dir: Path) -> pd.DataFrame:
+    df = _load_csv(base_dir, "dragon_indicator_snapshot.csv")
+    df["date"] = pd.to_datetime(df["date"])
+    return df.sort_values("date").reset_index(drop=True)
+
+
+def _profit_factor(series: pd.Series) -> float:
+    gross_profit = series[series > 0].sum()
+    gross_loss = -series[series < 0].sum()
+    if gross_loss == 0:
+        return float("inf") if gross_profit > 0 else 0.0
+    return float(gross_profit / gross_loss)
+
+
+def _holding_bucket(days: int) -> str:
+    if days <= 5:
+        return "00-05d"
+    if days <= 10:
+        return "06-10d"
+    if days <= 20:
+        return "11-20d"
+    if days <= 40:
+        return "21-40d"
+    return "41d+"
+
+
+def _format_pct(value: float) -> str:
+    if pd.isna(value):
+        return "NA"
+    if value == float("inf"):
+        return "inf"
+    return f"{value:.2%}"
+
+
+def _format_num(value: float) -> str:
+    if pd.isna(value):
+        return "NA"
+    if value == float("inf"):
+        return "inf"
+    return f"{value:.2f}"
+
+
+def _entry_family(reason: str) -> str:
+    return str(reason).split(":", 1)[0]
+
+
+def _entry_variant(reason: str) -> str:
+    parts = str(reason).split(":", 1)
+    return "base" if len(parts) == 1 else parts[1]
+
+
+def _infer_state_layer(buy_reason: str, buy_c1: float) -> str:
+    state = BUY_REASON_STATE.get(_entry_family(buy_reason))
+    if state == "mid_regime":
+        if buy_c1 < 20:
+            return "low_oversold_regime"
+        if buy_c1 >= 70:
+            return "high_regime"
+    return state or "mid_regime"
+
+
+def _infer_exit_management_layer(sell_reason: str) -> str:
+    return SELL_REASON_MANAGEMENT.get(sell_reason, "default_exit_management")
+
+
+def _entry_role(reason: str) -> str:
+    family = _entry_family(reason)
+    if family in {"glued_buy", "early_crash_probe_buy", "oversold_recovery_buy"}:
+        return "core_alpha_family"
+    if reason in {"deep_oversold_rebound_buy:classic_oversold", "dual_gold_resonance_buy"}:
+        return "support_alpha_family"
+    if family in {"predictive_error_reentry_buy", "hot_exit_reentry_buy"}:
+        return "bridge_reentry_family"
+    if family == "post_washout_kdj_reentry_buy":
+        return "workbook_restart_family"
+    if family in {"post_sell_rebound_buy", "oversold_reversal_after_ql_buy"}:
+        return "secondary_research_family"
+    if family == "deep_oversold_rebound_buy":
+        return "weak_research_family"
+    return "other_family"
+
+
+def _group_stats(df: pd.DataFrame, group_cols: list[str]) -> pd.DataFrame:
+    rows: list[dict[str, object]] = []
+    for key, group in df.groupby(group_cols, dropna=False):
+        if not isinstance(key, tuple):
+            key = (key,)
+        row = {col: val for col, val in zip(group_cols, key)}
+        returns = group["return_pct"].astype(float)
+        row.update(
+            {
+                "trades": int(len(group)),
+                "trade_share": float(len(group) / len(df)),
+                "win_rate": float((returns > 0).mean()),
+                "avg_return": float(returns.mean()),
+                "median_return": float(returns.median()),
+                "sum_return_pct": float(returns.sum()),
+                "profit_factor": _profit_factor(returns),
+                "avg_holding_days": float(group["holding_days"].mean()),
+                "avg_mfe_pct": float(group["mfe_pct"].mean()),
+                "avg_mae_pct": float(group["mae_pct"].mean()),
+                "avg_giveback_from_peak_pct": float(group["giveback_from_peak_pct"].mean()),
+                "avg_entry_forward_5d_pct": float(group["entry_forward_5d_pct"].mean()),
+                "avg_exit_followthrough_5d_pct": float(group["exit_followthrough_5d_pct"].mean()),
+                "avg_exit_rebound_5d_pct": float(group["exit_rebound_5d_pct"].mean()),
+            }
+        )
+        rows.append(row)
+    return pd.DataFrame(rows)
+
+
+def _top_value(group: pd.DataFrame, col: str) -> str:
+    counts = group[col].value_counts(dropna=False)
+    if counts.empty:
+        return ""
+    return str(counts.index[0])
+
+
+def _veto_bucket(c1: float, b1: float) -> str:
+    if 23 <= c1 < 28 and b1 <= 0.02:
+        return "low_weak_range"
+    if 40 <= c1 < 75 and b1 >= 0.10:
+        return "hot_positive_b1_cap75"
+    return "other"
+
+
+def _recheck_verdict(row: pd.Series) -> tuple[str, str]:
+    ret = float(row["return_pct"])
+    mfe = float(row["mfe_pct"])
+    holding = int(row["holding_days"])
+    forward = float(row["entry_forward_5d_pct"])
+    follow = float(row["exit_followthrough_5d_pct"])
+    replacement_ret = row["replacement_return_pct"]
+
+    if ret < 0 and holding <= 10 and follow <= 0:
+        return "KEEP_REMOVAL", "short loser and price still weakened after the exit"
+    if ret < 0 and mfe <= 0.02:
+        return "KEEP_REMOVAL", "trade never developed enough profit room to defend inclusion"
+    if ret < 0 and forward <= 0:
+        return "KEEP_REMOVAL", "entry had no useful short-term follow-through and remained weak"
+    if ret > 0 and ret <= 0.01 and pd.notna(replacement_ret) and float(replacement_ret) <= ret:
+        return "OBSERVE_REMOVAL", "micro-winner but replacement path is not clearly worse"
+    if ret > 0:
+        return "OVER_REMOVAL", "removed trade kept meaningful alpha and should not be deleted silently"
+    return "KEEP_REMOVAL", "removed trade remains weak under the alpha-first objective"
+
+
+def _build_trade_quality(trades: pd.DataFrame, indicators: pd.DataFrame) -> pd.DataFrame:
+    trades = trades.copy()
+    trades["buy_dt"] = pd.to_datetime(trades["buy_date"])
+    trades["sell_dt"] = pd.to_datetime(trades["sell_date"])
+    trades["sell_year"] = trades["sell_dt"].dt.year.astype(int)
+    trades["holding_bucket"] = trades["holding_days"].astype(int).map(_holding_bucket)
+
+    indicator_by_date = indicators.set_index(indicators["date"].dt.date)
+    pos_lookup = {dt.date().isoformat(): idx for idx, dt in enumerate(indicators["date"])}
+
+    buy_a1: list[float] = []
+    buy_b1: list[float] = []
+    buy_c1: list[float] = []
+    sell_a1: list[float] = []
+    sell_b1: list[float] = []
+    sell_c1: list[float] = []
+    mfe_list: list[float] = []
+    mae_list: list[float] = []
+    giveback_list: list[float] = []
+    entry_forward_list: list[float] = []
+    exit_followthrough_list: list[float] = []
+    exit_rebound_list: list[float] = []
+
+    for _, trade in trades.iterrows():
+        buy_date = pd.Timestamp(trade["buy_date"]).date()
+        sell_date = pd.Timestamp(trade["sell_date"]).date()
+        buy_row = indicator_by_date.loc[buy_date]
+        sell_row = indicator_by_date.loc[sell_date]
+
+        buy_a1.append(float(buy_row["a1"]))
+        buy_b1.append(float(buy_row["b1"]))
+        buy_c1.append(float(buy_row["c1"]))
+        sell_a1.append(float(sell_row["a1"]))
+        sell_b1.append(float(sell_row["b1"]))
+        sell_c1.append(float(sell_row["c1"]))
+
+        entry_price = float(trade["buy_price"])
+        exit_price = float(trade["sell_price"])
+        buy_idx = pos_lookup[trade["buy_date"]]
+        sell_idx = pos_lookup[trade["sell_date"]]
+
+        window = indicators[(indicators["date"] >= trade["buy_dt"]) & (indicators["date"] <= trade["sell_dt"])]
+        max_high = float(window["high"].max())
+        min_low = float(window["low"].min())
+        mfe_list.append(max_high / entry_price - 1.0)
+        mae_list.append(min_low / entry_price - 1.0)
+        giveback_list.append(exit_price / max_high - 1.0)
+
+        buy_future = indicators.iloc[buy_idx + 1 : buy_idx + 6]
+        sell_future = indicators.iloc[sell_idx + 1 : sell_idx + 6]
+        entry_forward_list.append(float("nan") if buy_future.empty else float(buy_future["close"].iloc[-1]) / entry_price - 1.0)
+        exit_followthrough_list.append(float("nan") if sell_future.empty else float(sell_future["low"].min()) / exit_price - 1.0)
+        exit_rebound_list.append(float("nan") if sell_future.empty else float(sell_future["high"].max()) / exit_price - 1.0)
+
+    trades["buy_a1"] = buy_a1
+    trades["buy_b1"] = buy_b1
+    trades["buy_c1"] = buy_c1
+    trades["sell_a1"] = sell_a1
+    trades["sell_b1"] = sell_b1
+    trades["sell_c1"] = sell_c1
+    trades["mfe_pct"] = mfe_list
+    trades["mae_pct"] = mae_list
+    trades["giveback_from_peak_pct"] = giveback_list
+    trades["entry_forward_5d_pct"] = entry_forward_list
+    trades["exit_followthrough_5d_pct"] = exit_followthrough_list
+    trades["exit_rebound_5d_pct"] = exit_rebound_list
+    trades["entry_family"] = trades["buy_reason"].map(_entry_family)
+    trades["entry_variant"] = trades["buy_reason"].map(_entry_variant)
+    trades["entry_role"] = trades["buy_reason"].map(_entry_role)
+    trades["market_state_layer"] = trades.apply(lambda row: _infer_state_layer(str(row["buy_reason"]), float(row["buy_c1"])), axis=1)
+    trades["exit_management_layer"] = trades["sell_reason"].map(_infer_exit_management_layer)
+    return trades
+
+
+def _run_branch(indicators: pd.DataFrame, config) -> pd.DataFrame:
+    indexed = indicators.set_index("date", drop=False)
+    engine = DragonRuleEngine(config=config)
+    _, trades = engine.run(indexed)
+    trades = trades[
+        (trades["buy_date"] >= START_DATE)
+        & (trades["buy_date"] <= END_DATE)
+        & (trades["sell_date"] >= START_DATE)
+        & (trades["sell_date"] <= END_DATE)
+    ].copy()
+    return _build_trade_quality(trades, indicators)
+
+
+def _trade_key(df: pd.DataFrame) -> set[tuple[str, str, str, str]]:
+    return set(zip(df["buy_date"], df["sell_date"], df["buy_reason"], df["sell_reason"]))
+
+
+def _branch_snapshot(df: pd.DataFrame) -> dict[str, float]:
+    returns = df["return_pct"].astype(float)
+    return {
+        "trades": float(len(df)),
+        "win_rate": float((returns > 0).mean()),
+        "avg_return": float(returns.mean()),
+        "profit_factor": _profit_factor(returns),
+        "avg_mfe": float(df["mfe_pct"].mean()),
+        "avg_mae": float(df["mae_pct"].mean()),
+    }
+
+
+def _family_decomposition(refined: pd.DataFrame) -> pd.DataFrame:
+    level_frames: list[pd.DataFrame] = []
+    for level_name, group_cols in [
+        ("entry_family", ["entry_family", "entry_role"]),
+        ("entry_reason", ["buy_reason", "entry_role", "market_state_layer"]),
+    ]:
+        frame = _group_stats(refined, group_cols)
+        if "entry_family" in frame.columns:
+            frame["group_name"] = frame["entry_family"]
+        else:
+            frame["group_name"] = frame["buy_reason"]
+        frame["decomposition_level"] = level_name
+        top_exit = []
+        for _, row in frame.iterrows():
+            if level_name == "entry_family":
+                group = refined[refined["entry_family"] == row["group_name"]]
+            else:
+                group = refined[refined["buy_reason"] == row["group_name"]]
+            top_exit.append(_top_value(group, "sell_reason"))
+        frame["top_exit_reason"] = top_exit
+        level_frames.append(frame)
+
+    result = pd.concat(level_frames, ignore_index=True, sort=False)
+    result = result.sort_values(["decomposition_level", "sum_return_pct", "trades"], ascending=[True, False, False]).reset_index(drop=True)
+    result["contribution_rank"] = result.groupby("decomposition_level")["sum_return_pct"].rank(method="dense", ascending=False).astype(int)
+    cols = [
+        "decomposition_level",
+        "group_name",
+        "entry_role",
+        "market_state_layer",
+        "buy_reason",
+        "entry_family",
+        "top_exit_reason",
+        "trades",
+        "trade_share",
+        "win_rate",
+        "avg_return",
+        "median_return",
+        "sum_return_pct",
+        "profit_factor",
+        "avg_holding_days",
+        "avg_mfe_pct",
+        "avg_mae_pct",
+        "avg_giveback_from_peak_pct",
+        "avg_entry_forward_5d_pct",
+        "avg_exit_followthrough_5d_pct",
+        "avg_exit_rebound_5d_pct",
+        "contribution_rank",
+    ]
+    return result[[col for col in cols if col in result.columns]].copy()
+
+
+def _alpha_attribution(refined: pd.DataFrame) -> pd.DataFrame:
+    frame = _group_stats(
+        refined,
+        ["market_state_layer", "entry_family", "buy_reason", "exit_management_layer", "sell_reason"],
+    )
+    frame["attribution_label"] = frame.apply(
+        lambda row: "core_alpha_source"
+        if row["sum_return_pct"] > 0.20 and row["avg_return"] > 0
+        else "drag_source"
+        if row["sum_return_pct"] < 0
+        else "mixed_source",
+        axis=1,
+    )
+    frame = frame.sort_values(["sum_return_pct", "trades"], ascending=[False, False]).reset_index(drop=True)
+    frame["sum_return_rank"] = frame["sum_return_pct"].rank(method="dense", ascending=False).astype(int)
+    return frame
+
+
+def _removed_trade_recheck(alpha: pd.DataFrame, refined: pd.DataFrame, workbook_events: pd.DataFrame) -> pd.DataFrame:
+    workbook_buy = set(workbook_events[(workbook_events["layer"] == "real_trade") & (workbook_events["side"] == "BUY")]["date"])
+    workbook_sell = set(workbook_events[(workbook_events["layer"] == "real_trade") & (workbook_events["side"] == "SELL")]["date"])
+    removed = pd.DataFrame(
+        sorted(_trade_key(alpha) - _trade_key(refined)),
+        columns=["buy_date", "sell_date", "buy_reason", "sell_reason"],
+    )
+
+    rows: list[dict[str, object]] = []
+    for _, removed_row in removed.iterrows():
+        trade = alpha[
+            (alpha["buy_date"] == removed_row["buy_date"])
+            & (alpha["sell_date"] == removed_row["sell_date"])
+            & (alpha["buy_reason"] == removed_row["buy_reason"])
+            & (alpha["sell_reason"] == removed_row["sell_reason"])
+        ].iloc[0]
+
+        sell_dt = pd.Timestamp(trade["sell_date"])
+        replacement = refined[
+            (pd.to_datetime(refined["buy_date"]) > sell_dt)
+            & (pd.to_datetime(refined["buy_date"]) <= sell_dt + pd.Timedelta(days=10))
+        ].sort_values("buy_date")
+        replacement_row = replacement.iloc[0] if not replacement.empty else None
+        replacement_ret = float("nan") if replacement_row is None else float(replacement_row["return_pct"])
+        verdict, verdict_reason = _recheck_verdict(
+            pd.Series({**trade.to_dict(), "replacement_return_pct": replacement_ret})
+        )
+
+        rows.append(
+            {
+                "buy_date": trade["buy_date"],
+                "sell_date": trade["sell_date"],
+                "buy_reason": trade["buy_reason"],
+                "sell_reason": trade["sell_reason"],
+                "entry_family": trade["entry_family"],
+                "entry_role": trade["entry_role"],
+                "market_state_layer": trade["market_state_layer"],
+                "exit_management_layer": trade["exit_management_layer"],
+                "veto_bucket": _veto_bucket(float(trade["buy_c1"]), float(trade["buy_b1"])),
+                "holding_bucket": trade["holding_bucket"],
+                "holding_days": int(trade["holding_days"]),
+                "return_pct": float(trade["return_pct"]),
+                "mfe_pct": float(trade["mfe_pct"]),
+                "mae_pct": float(trade["mae_pct"]),
+                "giveback_from_peak_pct": float(trade["giveback_from_peak_pct"]),
+                "entry_forward_5d_pct": float(trade["entry_forward_5d_pct"]),
+                "exit_followthrough_5d_pct": float(trade["exit_followthrough_5d_pct"]),
+                "exit_rebound_5d_pct": float(trade["exit_rebound_5d_pct"]),
+                "buy_a1": float(trade["buy_a1"]),
+                "buy_b1": float(trade["buy_b1"]),
+                "buy_c1": float(trade["buy_c1"]),
+                "buy_aligned_with_workbook": trade["buy_date"] in workbook_buy,
+                "sell_aligned_with_workbook": trade["sell_date"] in workbook_sell,
+                "replacement_buy_date": "" if replacement_row is None else str(replacement_row["buy_date"]),
+                "replacement_sell_date": "" if replacement_row is None else str(replacement_row["sell_date"]),
+                "replacement_buy_reason": "" if replacement_row is None else str(replacement_row["buy_reason"]),
+                "replacement_sell_reason": "" if replacement_row is None else str(replacement_row["sell_reason"]),
+                "replacement_return_pct": replacement_ret,
+                "replacement_gap_days": float("nan")
+                if replacement_row is None
+                else int((pd.Timestamp(replacement_row["buy_date"]) - sell_dt).days),
+                "verdict": verdict,
+                "verdict_reason": verdict_reason,
+            }
+        )
+
+    return pd.DataFrame(rows).sort_values(["veto_bucket", "buy_date"]).reset_index(drop=True)
+
+
+def _winner_structure(refined: pd.DataFrame) -> pd.DataFrame:
+    winners = refined[refined["return_pct"] > 0].copy()
+    if winners.empty:
+        return pd.DataFrame()
+    frame = _group_stats(winners, ["entry_family", "holding_bucket"])
+    frame = frame.sort_values(["sum_return_pct", "trades"], ascending=[False, False]).reset_index(drop=True)
+    return frame
+
+
+def main() -> None:
+    base_dir = Path(__file__).resolve().parent
+    indicators = _load_indicator_snapshot(base_dir)
+    workbook_events = _load_csv(base_dir, "true_trade_events.csv")
+
+    alpha = _run_branch(indicators, alpha_first_selective_veto_config())
+    refined = _run_branch(indicators, alpha_first_glued_refined_hot_cap_config())
+
+    family_decomposition = _family_decomposition(refined)
+    alpha_attribution = _alpha_attribution(refined)
+    removed_recheck = _removed_trade_recheck(alpha, refined, workbook_events)
+    winner_structure = _winner_structure(refined)
+
+    family_decomposition.to_csv(base_dir / "dragon_refined_family_decomposition.csv", index=False, encoding="utf-8-sig")
+    alpha_attribution.to_csv(base_dir / "dragon_refined_alpha_attribution.csv", index=False, encoding="utf-8-sig")
+    removed_recheck.to_csv(base_dir / "dragon_refined_removed_trade_recheck.csv", index=False, encoding="utf-8-sig")
+
+    refined_snapshot = _branch_snapshot(refined)
+    alpha_snapshot = _branch_snapshot(alpha)
+
+    removed_pf = _profit_factor(removed_recheck["return_pct"]) if not removed_recheck.empty else float("nan")
+    removed_pf_text = _format_num(removed_pf)
+
+    top_family = family_decomposition[
+        (family_decomposition["decomposition_level"] == "entry_family") & (family_decomposition["trades"] >= 3)
+    ].head(5)
+    weak_family = family_decomposition[
+        (family_decomposition["decomposition_level"] == "entry_reason")
+        & (family_decomposition["trades"] >= 1)
+        & (family_decomposition["entry_role"].isin(["weak_research_family", "secondary_research_family"]))
+    ].sort_values(["avg_return", "sum_return_pct"]).head(5)
+    top_combo = alpha_attribution.head(8)
+    drag_combo = alpha_attribution[alpha_attribution["sum_return_pct"] < 0].head(8)
+    winner_top = winner_structure.head(5)
+
+    lines = [
+        "# Dragon Refined Edge Review",
+        "",
+        "## Scope",
+        "- Target branch: `alpha_first_glued_refined_hot_cap`",
+        "- Control branch: `alpha_first_selective_veto`",
+        "- Evaluation window: `2016-01-01` to `2025-12-31`",
+        "",
+        "## Headline",
+        f"- control: trades `{int(alpha_snapshot['trades'])}`, win_rate `{_format_pct(alpha_snapshot['win_rate'])}`, avg_return `{_format_pct(alpha_snapshot['avg_return'])}`, profit_factor `{_format_num(alpha_snapshot['profit_factor'])}`",
+        f"- refined: trades `{int(refined_snapshot['trades'])}`, win_rate `{_format_pct(refined_snapshot['win_rate'])}`, avg_return `{_format_pct(refined_snapshot['avg_return'])}`, profit_factor `{_format_num(refined_snapshot['profit_factor'])}`",
+        f"- refined minus control: trades `{int(refined_snapshot['trades'] - alpha_snapshot['trades'])}`, avg_return `{_format_pct(refined_snapshot['avg_return'] - alpha_snapshot['avg_return'])}`, profit_factor `{_format_num(refined_snapshot['profit_factor'] - alpha_snapshot['profit_factor'])}`",
+        "",
+        "## Main Edge Source",
+        "- Refined alpha is still primarily a `glued_buy` story, but now with stricter removal of weak short-holding glued trades.",
+        "- The branch is not winning by adding new complex trade paths; it is winning by deleting low-quality short trades while preserving the medium and long-holding winners.",
+        "",
+        "## Entry Family Decomposition",
+    ]
+
+    for _, row in top_family.iterrows():
+        lines.append(
+            f"- `{row['group_name']}` [{row['entry_role']}]: trades `{int(row['trades'])}`, share `{_format_pct(float(row['trade_share']))}`, "
+            f"avg_return `{_format_pct(float(row['avg_return']))}`, sum_return `{_format_pct(float(row['sum_return_pct']))}`, "
+            f"profit_factor `{_format_num(float(row['profit_factor']))}`, top_exit `{row['top_exit_reason']}`"
+        )
+
+    lines.extend(["", "## Weak Research Pockets"])
+    for _, row in weak_family.iterrows():
+        lines.append(
+            f"- `{row['group_name']}`: trades `{int(row['trades'])}`, avg_return `{_format_pct(float(row['avg_return']))}`, "
+            f"sum_return `{_format_pct(float(row['sum_return_pct']))}`, profit_factor `{_format_num(float(row['profit_factor']))}`"
+        )
+
+    lines.extend(["", "## Kept Winner Structure"])
+    for _, row in winner_top.iterrows():
+        lines.append(
+            f"- `{row['entry_family']} / {row['holding_bucket']}`: winners `{int(row['trades'])}`, avg_return `{_format_pct(float(row['avg_return']))}`, "
+            f"sum_return `{_format_pct(float(row['sum_return_pct']))}`"
+        )
+
+    lines.extend(["", "## Entry / Exit Interaction Attribution"])
+    for _, row in top_combo.iterrows():
+        lines.append(
+            f"- positive `{row['market_state_layer']} / {row['buy_reason']} -> {row['sell_reason']}`: trades `{int(row['trades'])}`, "
+            f"sum_return `{_format_pct(float(row['sum_return_pct']))}`, avg_return `{_format_pct(float(row['avg_return']))}`, "
+            f"PF `{_format_num(float(row['profit_factor']))}`"
+        )
+
+    lines.extend(["", "## Drag Interaction Pockets"])
+    for _, row in drag_combo.iterrows():
+        lines.append(
+            f"- drag `{row['market_state_layer']} / {row['buy_reason']} -> {row['sell_reason']}`: trades `{int(row['trades'])}`, "
+            f"sum_return `{_format_pct(float(row['sum_return_pct']))}`, avg_return `{_format_pct(float(row['avg_return']))}`, "
+            f"PF `{_format_num(float(row['profit_factor']))}`"
+        )
+
+    lines.extend(
+        [
+            "",
+            "## Removed-Trade Recheck",
+            f"- removed trades vs control: `{int(len(removed_recheck))}`",
+            f"- removed-set avg_return `{_format_pct(float(removed_recheck['return_pct'].mean()))}`",
+            f"- removed-set win_rate `{_format_pct(float((removed_recheck['return_pct'] > 0).mean()))}`",
+            f"- removed-set profit_factor `{removed_pf_text}`",
+            f"- KEEP_REMOVAL `{int((removed_recheck['verdict'] == 'KEEP_REMOVAL').sum())}` | OBSERVE_REMOVAL `{int((removed_recheck['verdict'] == 'OBSERVE_REMOVAL').sum())}` | OVER_REMOVAL `{int((removed_recheck['verdict'] == 'OVER_REMOVAL').sum())}`",
+        ]
+    )
+
+    for bucket, group in removed_recheck.groupby("veto_bucket", dropna=False):
+        lines.append(
+            f"- `{bucket}`: trades `{len(group)}`, avg_return `{_format_pct(float(group['return_pct'].mean()))}`, "
+            f"avg_holding `{group['holding_days'].mean():.1f}`, avg_mfe `{_format_pct(float(group['mfe_pct'].mean()))}`"
+        )
+
+    lines.extend(
+        [
+            "",
+            "## Quant Judgment",
+            "- Core alpha remains concentrated in `glued_buy`, `early_crash_probe_buy`, and the preserved medium/long holding structure.",
+            "- `dual_gold_resonance_buy` and `deep_oversold_rebound_buy:classic_oversold` remain support families, not the main alpha engine.",
+            "- Weak pockets still exist in secondary rebound / weak deep-oversold variants, but they are not where the refined branch gets its headline improvement.",
+            "- The refined branch improves mainly by deleting low-quality short glued trades; this remains explainable and not dependent on deleting profitable samples.",
+            "- The next step should therefore move to execution-aware robustness, not back to workbook-style residual tuning.",
+        ]
+    )
+
+    (base_dir / "dragon_refined_edge_review.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+
+if __name__ == "__main__":
+    main()

+ 163 - 0
research/dragon/v2/dragon_refined_execution_validation.py

@@ -0,0 +1,163 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pandas as pd
+
+from dragon_branch_configs import alpha_first_glued_refined_hot_cap_config, alpha_first_selective_veto_config
+from dragon_execution_common import apply_execution_model as _apply_execution_model, risk_cluster as _risk_cluster, summary as _summary
+from dragon_shared import END_DATE, START_DATE, format_num as _format_num, format_pct as _format_pct
+from dragon_strategy import DragonRuleEngine
+
+
+def _load_indicator_snapshot(base_dir: Path) -> pd.DataFrame:
+    df = pd.read_csv(base_dir / "dragon_indicator_snapshot.csv", encoding="utf-8-sig")
+    df["date"] = pd.to_datetime(df["date"])
+    return df.sort_values("date").reset_index(drop=True)
+
+
+def _entry_family(reason: str) -> str:
+    return str(reason).split(":", 1)[0]
+
+
+def _run_branch(indicators: pd.DataFrame, config) -> pd.DataFrame:
+    indexed = indicators.set_index("date", drop=False)
+    engine = DragonRuleEngine(config=config)
+    _, trades = engine.run(indexed)
+    trades = trades[
+        (trades["buy_date"] >= START_DATE)
+        & (trades["buy_date"] <= END_DATE)
+        & (trades["sell_date"] >= START_DATE)
+        & (trades["sell_date"] <= END_DATE)
+    ].copy()
+    trades["buy_dt"] = pd.to_datetime(trades["buy_date"])
+    trades["sell_dt"] = pd.to_datetime(trades["sell_date"])
+    trades["sell_year"] = trades["sell_dt"].dt.year.astype(int)
+    trades["entry_family"] = trades["buy_reason"].map(_entry_family)
+    return trades
+
+
+def _add_execution_prices(trades: pd.DataFrame, indicators: pd.DataFrame) -> pd.DataFrame:
+    trades = trades.copy()
+    lookup = indicators.set_index(indicators["date"].dt.date)
+    next_by_date = {
+        indicators.iloc[idx]["date"].date().isoformat(): indicators.iloc[idx + 1]
+        for idx in range(len(indicators) - 1)
+    }
+
+    same_entry: list[float] = []
+    same_exit: list[float] = []
+    next_open_entry: list[float] = []
+    next_open_exit: list[float] = []
+    next_close_entry: list[float] = []
+    next_close_exit: list[float] = []
+
+    for _, trade in trades.iterrows():
+        buy_key = trade["buy_date"]
+        sell_key = trade["sell_date"]
+        buy_row = lookup.loc[pd.Timestamp(trade["buy_date"]).date()]
+        sell_row = lookup.loc[pd.Timestamp(trade["sell_date"]).date()]
+        buy_next = next_by_date.get(buy_key)
+        sell_next = next_by_date.get(sell_key)
+
+        same_entry.append(float(buy_row["close"]))
+        same_exit.append(float(sell_row["close"]))
+        next_open_entry.append(float("nan") if buy_next is None else float(buy_next["open"]))
+        next_open_exit.append(float("nan") if sell_next is None else float(sell_next["open"]))
+        next_close_entry.append(float("nan") if buy_next is None else float(buy_next["close"]))
+        next_close_exit.append(float("nan") if sell_next is None else float(sell_next["close"]))
+
+    trades["exec_same_close_entry"] = same_entry
+    trades["exec_same_close_exit"] = same_exit
+    trades["exec_next_open_entry"] = next_open_entry
+    trades["exec_next_open_exit"] = next_open_exit
+    trades["exec_next_close_entry"] = next_close_entry
+    trades["exec_next_close_exit"] = next_close_exit
+    return trades
+
+
+def main() -> None:
+    base_dir = Path(__file__).resolve().parent
+    indicators = _load_indicator_snapshot(base_dir)
+
+    branches = {
+        "alpha_first_selective_veto": _add_execution_prices(
+            _run_branch(indicators, alpha_first_selective_veto_config()),
+            indicators,
+        ),
+        "alpha_first_glued_refined_hot_cap": _add_execution_prices(
+            _run_branch(indicators, alpha_first_glued_refined_hot_cap_config()),
+            indicators,
+        ),
+    }
+
+    execution_models = ["same_close", "next_open", "next_close"]
+    cost_levels = [0.0, 5.0, 10.0, 20.0]
+
+    stress_rows: list[dict[str, object]] = []
+    latency_rows: list[dict[str, object]] = []
+    risk_rows: list[dict[str, object]] = []
+
+    for branch, trades in branches.items():
+        for model in execution_models:
+            model_trades = _apply_execution_model(trades, model, 0.0)
+            latency_rows.append(_summary(branch, model_trades))
+
+            if model in {"same_close", "next_open"}:
+                risk_rows.append(_risk_cluster(branch, model_trades))
+
+            for cost in cost_levels:
+                stressed = _apply_execution_model(trades, model, cost)
+                stress_rows.append(_summary(branch, stressed))
+
+    stress_df = pd.DataFrame(stress_rows).sort_values(["execution_model", "cost_bps_side", "branch"]).reset_index(drop=True)
+    latency_df = pd.DataFrame(latency_rows).sort_values(["execution_model", "branch"]).reset_index(drop=True)
+    risk_df = pd.DataFrame(risk_rows).sort_values(["execution_model", "branch"]).reset_index(drop=True)
+
+    stress_df.to_csv(base_dir / "dragon_refined_execution_stress.csv", index=False, encoding="utf-8-sig")
+    latency_df.to_csv(base_dir / "dragon_refined_latency_review.csv", index=False, encoding="utf-8-sig")
+    risk_df.to_csv(base_dir / "dragon_refined_risk_cluster_review.csv", index=False, encoding="utf-8-sig")
+
+    same_close = latency_df[latency_df["execution_model"] == "same_close"].set_index("branch")
+    next_open = latency_df[latency_df["execution_model"] == "next_open"].set_index("branch")
+    next_close = latency_df[latency_df["execution_model"] == "next_close"].set_index("branch")
+    stress_20 = stress_df[(stress_df["execution_model"] == "next_open") & (stress_df["cost_bps_side"] == 20.0)].set_index("branch")
+    risk_next_open = risk_df[risk_df["execution_model"] == "next_open"].set_index("branch")
+
+    refined_key = "alpha_first_glued_refined_hot_cap"
+    control_key = "alpha_first_selective_veto"
+
+    lines = [
+        "# Dragon Refined Stability Review",
+        "",
+        "## Scope",
+        "- branches: `alpha_first_selective_veto` vs `alpha_first_glued_refined_hot_cap`",
+        "- execution models: `same_close`, `next_open`, `next_close`",
+        "- costs: `0`, `5`, `10`, `20 bps/side`",
+        "",
+        "## Latency Review",
+        f"- same_close control vs refined: avg_return `{_format_pct(float(same_close.loc[control_key, 'avg_return']))}` -> `{_format_pct(float(same_close.loc[refined_key, 'avg_return']))}`, PF `{_format_num(float(same_close.loc[control_key, 'profit_factor']))}` -> `{_format_num(float(same_close.loc[refined_key, 'profit_factor']))}`",
+        f"- next_open control vs refined: avg_return `{_format_pct(float(next_open.loc[control_key, 'avg_return']))}` -> `{_format_pct(float(next_open.loc[refined_key, 'avg_return']))}`, PF `{_format_num(float(next_open.loc[control_key, 'profit_factor']))}` -> `{_format_num(float(next_open.loc[refined_key, 'profit_factor']))}`",
+        f"- next_close control vs refined: avg_return `{_format_pct(float(next_close.loc[control_key, 'avg_return']))}` -> `{_format_pct(float(next_close.loc[refined_key, 'avg_return']))}`, PF `{_format_num(float(next_close.loc[control_key, 'profit_factor']))}` -> `{_format_num(float(next_close.loc[refined_key, 'profit_factor']))}`",
+        "",
+        "## Cost + Next-Open Stress",
+        f"- next_open + 20 bps/side control CAGR `{_format_pct(float(stress_20.loc[control_key, 'cagr']))}` vs refined `{_format_pct(float(stress_20.loc[refined_key, 'cagr']))}`",
+        f"- next_open + 20 bps/side control PF `{_format_num(float(stress_20.loc[control_key, 'profit_factor']))}` vs refined `{_format_num(float(stress_20.loc[refined_key, 'profit_factor']))}`",
+        f"- next_open + 20 bps/side control max DD `{_format_pct(float(stress_20.loc[control_key, 'max_drawdown']))}` vs refined `{_format_pct(float(stress_20.loc[refined_key, 'max_drawdown']))}`",
+        "",
+        "## Risk Cluster Review",
+        f"- next_open control max loss streak `{int(risk_next_open.loc[control_key, 'max_loss_streak'])}` vs refined `{int(risk_next_open.loc[refined_key, 'max_loss_streak'])}`",
+        f"- next_open control worst 5-trade sum `{_format_pct(float(risk_next_open.loc[control_key, 'worst_5trade_sum']))}` vs refined `{_format_pct(float(risk_next_open.loc[refined_key, 'worst_5trade_sum']))}`",
+        f"- next_open control short-loss share `{_format_pct(float(risk_next_open.loc[control_key, 'short_loss_share']))}` vs refined `{_format_pct(float(risk_next_open.loc[refined_key, 'short_loss_share']))}`",
+        f"- next_open control worst loss family `{risk_next_open.loc[control_key, 'worst_loss_family']}` vs refined `{risk_next_open.loc[refined_key, 'worst_loss_family']}`",
+        "",
+        "## Judgment",
+        "- If refined still leads after next-bar execution and cost drag, its edge is less likely to be a same-bar backtest artifact.",
+        "- If refined also keeps loss clustering and drawdown no worse than control, the branch is moving closer to a deployable research baseline.",
+    ]
+
+    (base_dir / "dragon_refined_stability_review.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+
+if __name__ == "__main__":
+    main()

+ 230 - 0
research/dragon/v2/dragon_research_baseline.py

@@ -0,0 +1,230 @@
+from __future__ import annotations
+
+import json
+from dataclasses import asdict
+from pathlib import Path
+
+import pandas as pd
+
+from dragon_strategy_config import StrategyConfig
+
+
+def _load_csv(base_dir: Path, name: str) -> pd.DataFrame:
+    return pd.read_csv(base_dir / name, encoding="utf-8-sig")
+
+
+def _profit_factor(series: pd.Series) -> float:
+    gross_profit = series[series > 0].sum()
+    gross_loss = -series[series < 0].sum()
+    if gross_loss == 0:
+        return float("inf") if gross_profit > 0 else 0.0
+    return float(gross_profit / gross_loss)
+
+
+def _format_pct(value: float) -> str:
+    if pd.isna(value):
+        return "NA"
+    if value == float("inf"):
+        return "inf"
+    return f"{value:.2%}"
+
+
+def _format_num(value: float) -> str:
+    if pd.isna(value):
+        return "NA"
+    if value == float("inf"):
+        return "inf"
+    return f"{value:.2f}"
+
+
+def _baseline_snapshot(config: StrategyConfig) -> dict[str, object]:
+    snapshot = asdict(config)
+    snapshot["disabled_rules"] = sorted(config.disabled_rules)
+    return snapshot
+
+
+def main() -> None:
+    base_dir = Path(__file__).resolve().parent
+
+    trades = _load_csv(base_dir, "dragon_strategy_trades.csv")
+    fit = (base_dir / "dragon_strategy_fit.md").read_text(encoding="utf-8")
+    entry_contrib = _load_csv(base_dir, "dragon_rule_contribution_entry.csv")
+    ablation = _load_csv(base_dir, "dragon_rule_ablation.csv")
+    sensitivity = _load_csv(base_dir, "dragon_threshold_sensitivity_summary.csv")
+    walk_forward = _load_csv(base_dir, "dragon_walk_forward_summary.csv")
+    family_stability = _load_csv(base_dir, "dragon_walk_forward_family_stability.csv")
+
+    config = StrategyConfig()
+    snapshot = _baseline_snapshot(config)
+    (base_dir / "dragon_baseline_config_snapshot.json").write_text(
+        json.dumps(snapshot, indent=2, ensure_ascii=False) + "\n",
+        encoding="utf-8",
+    )
+
+    baseline_ablation = ablation[ablation["experiment"] == "baseline"].iloc[0]
+    returns = trades["return_pct"].astype(float)
+    overall_profit_factor = _profit_factor(returns)
+
+    core_alpha_names = ["glued_buy", "early_crash_probe_buy", "oversold_recovery_buy"]
+    structural_support_names = ["dual_gold_resonance_buy", "deep_oversold_rebound_buy:classic_oversold"]
+    active_research_names = [
+        "deep_oversold_rebound_buy:positive_b1_rebound",
+        "deep_oversold_rebound_buy:shallow_false_start",
+        "deep_oversold_rebound_buy:mixed_oversold",
+        "deep_oversold_rebound_buy:deep_capitulation",
+        "post_washout_kdj_reentry_buy",
+        "oversold_reversal_after_ql_buy",
+        "post_sell_rebound_buy",
+    ]
+
+    core_alpha = entry_contrib[entry_contrib["buy_reason"].isin(core_alpha_names)].copy()
+    structural_support = entry_contrib[entry_contrib["buy_reason"].isin(structural_support_names)].copy()
+    weak_research = entry_contrib[entry_contrib["buy_reason"].isin(active_research_names)].copy()
+    weak_research = weak_research.sort_values(["avg_return", "trades"], ascending=[True, False])
+
+    fragile = sensitivity[sensitivity["stable_real_alignment"] == False].sort_values("avg_return_range", ascending=False)
+    robust = sensitivity[sensitivity["stable_real_alignment"] == True].sort_values("avg_return_range")
+
+    anchored = walk_forward[walk_forward["scheme"] == "anchored_expanding"].copy()
+    rolling = walk_forward[walk_forward["scheme"] == "rolling_3y"].copy()
+    anchored_positive = int((anchored["test_avg_return"] > 0).sum()) if not anchored.empty else 0
+    anchored_total = int(len(anchored))
+    rolling_positive = int((rolling["test_avg_return"] > 0).sum()) if not rolling.empty else 0
+    rolling_total = int(len(rolling))
+
+    stable_families = family_stability[
+        (family_stability["avg_yearly_avg_return"] > 0)
+        & (family_stability["positive_years"] >= family_stability["negative_years"])
+    ].sort_values(["avg_yearly_avg_return", "total_trades"], ascending=[False, False])
+    unstable_families = family_stability[
+        (family_stability["avg_yearly_avg_return"] < 0)
+        | (family_stability["negative_years"] > family_stability["positive_years"])
+    ].sort_values(["avg_yearly_avg_return", "min_yearly_avg_return"])
+
+    lines = [
+        "# Dragon Formal Research Baseline",
+        "",
+        "## Scope",
+        "- Universe: `399673` only.",
+        "- Objective: preserve workbook real-trade alignment while upgrading the strategy into a researchable, testable, parameter-aware baseline.",
+        "- Current baseline type: `workbook-preserving baseline`.",
+        "",
+        "## Locked Baseline Metrics",
+        f"- real BUY overlap: `{int(baseline_ablation['real_buy_overlap'])}/106`",
+        f"- real SELL overlap: `{int(baseline_ablation['real_sell_overlap'])}/105`",
+        f"- aux BUY overlap: `{int(baseline_ablation['aux_buy_overlap'])}/1`",
+        f"- aux SELL overlap: `{int(baseline_ablation['aux_sell_overlap'])}/21`",
+        f"- strategy trades: `{int(baseline_ablation['trades'])}`",
+        f"- win_rate: `{_format_pct(float(baseline_ablation['win_rate']))}`",
+        f"- avg_return: `{_format_pct(float(baseline_ablation['avg_return']))}`",
+        f"- median_return: `{_format_pct(float(baseline_ablation['median_return']))}`",
+        f"- profit_factor: `{_format_num(overall_profit_factor)}`",
+        "",
+        "## Baseline Config Snapshot",
+        "- Snapshot file: `dragon_baseline_config_snapshot.json`.",
+        "- Rule switches default to the current aligned strategy baseline; any future research branch should fork from this snapshot rather than editing against memory.",
+        "",
+        "## Core Alpha Families",
+    ]
+    for _, row in core_alpha.iterrows():
+        lines.append(
+            f"- `{row['buy_reason']}`: trades `{int(row['trades'])}`, avg_return `{_format_pct(float(row['avg_return']))}`, "
+            f"win_rate `{_format_pct(float(row['win_rate']))}`"
+        )
+
+    lines.extend(["", "## Structural Support Families"])
+    for _, row in structural_support.iterrows():
+        lines.append(
+            f"- `{row['buy_reason']}`: trades `{int(row['trades'])}`, avg_return `{_format_pct(float(row['avg_return']))}`, "
+            f"win_rate `{_format_pct(float(row['win_rate']))}`"
+        )
+
+    lines.extend(
+        [
+            "",
+            "## Frozen Bridge Rules",
+            "- `predictive_b1_break_exit`: bridge-style split-chain exit; loosening worsens results, tightening breaks workbook alignment.",
+            "- `predictive_error_reentry_buy`: part of the same bridge chain; should be evaluated together with the predictive-break exit, not as an isolated entry.",
+            "- Any internal hold gates added only to preserve workbook-aligned split paths should remain frozen unless the objective explicitly changes away from workbook preservation.",
+            "",
+            "## Redundant Or Label-Only Families",
+            "- `non_glued_positive_expansion_buy`: now absorbed by `dual_gold_resonance_buy` on the same in-sample dates; treat as redundant label, not independent alpha.",
+            "- Auxiliary same-side post-exit sell compression: keep as hygiene logic, not as a primary optimization frontier.",
+            "",
+            "## Active Research Families",
+        ]
+    )
+    for _, row in weak_research.iterrows():
+        lines.append(
+            f"- `{row['buy_reason']}`: trades `{int(row['trades'])}`, avg_return `{_format_pct(float(row['avg_return']))}`, "
+            f"win_rate `{_format_pct(float(row['win_rate']))}`"
+        )
+
+    lines.extend(
+        [
+            "",
+            "## Threshold Classification",
+            "- Fragile parameters: change them only inside explicit experiment branches and always rerun full alignment diagnostics.",
+        ]
+    )
+    for _, row in fragile.iterrows():
+        lines.append(
+            f"- `{row['parameter']}`: avg_return_range `{_format_pct(float(row['avg_return_range']))}`, "
+            f"min real BUY `{int(row['real_buy_overlap_min'])}`, min real SELL `{int(row['real_sell_overlap_min'])}`"
+        )
+
+    lines.append("- Relatively robust parameters: acceptable first candidates for future controlled sweeps.")
+    for _, row in robust.head(4).iterrows():
+        lines.append(
+            f"- `{row['parameter']}`: avg_return_range `{_format_pct(float(row['avg_return_range']))}`, "
+            f"profit_factor_range `{_format_num(float(row['profit_factor_range']))}`"
+        )
+
+    lines.extend(
+        [
+            "",
+            "## Temporal Stability",
+            f"- Anchored expanding windows: positive out-of-sample years `{anchored_positive}/{anchored_total}`.",
+            f"- Rolling 3Y windows: positive out-of-sample years `{rolling_positive}/{rolling_total}`.",
+            "- This validation holds the strategy fixed; it is a time-stability audit, not a refit-based optimizer.",
+            "- Strong family persistence candidates:",
+        ]
+    )
+    for _, row in stable_families.head(5).iterrows():
+        lines.append(
+            f"- `{row['entry_family']}`: years_active `{int(row['years_active'])}`, positive_years `{int(row['positive_years'])}`, "
+            f"negative_years `{int(row['negative_years'])}`, avg_yearly_avg_return `{_format_pct(float(row['avg_yearly_avg_return']))}`"
+        )
+
+    lines.append("- Weak family persistence candidates:")
+    for _, row in unstable_families.head(5).iterrows():
+        lines.append(
+            f"- `{row['entry_family']}`: years_active `{int(row['years_active'])}`, positive_years `{int(row['positive_years'])}`, "
+            f"negative_years `{int(row['negative_years'])}`, avg_yearly_avg_return `{_format_pct(float(row['avg_yearly_avg_return']))}`"
+        )
+
+    lines.extend(
+        [
+            "",
+            "## Operating Rules For Future Research",
+            "- Do not trade off `106/106` and `105/105` alignment silently. Any alignment loss must be treated as a branch with an explicit objective change.",
+            "- Do not blind-tune predictive-break thresholds. That family is frozen under the current baseline objective.",
+            "- Do not optimize the auxiliary layer first. The main leverage is now in weak entry-family redesign and short-holding loss control.",
+            "- New ideas should first be tested as local attribution experiments, then full-sample reruns, then temporal-stability checks.",
+            "",
+            "## Next Research Track",
+            "- Track A: redesign remaining `deep_oversold_rebound_buy` weak subtypes with delayed confirmation or fallback routing, not blunt deletion.",
+            "- Track B: explicitly target short holding buckets `00-05d` and `06-10d`, which remain the main quality drag.",
+            "- Track C: separate a future `alpha-first` research branch from this workbook-preserving baseline if the goal later changes from reconstruction to pure performance.",
+        ]
+    )
+
+    (base_dir / "dragon_formal_research_baseline.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+    # Keep the baseline fit markdown referenced by this script as an explicit dependency.
+    if "real_trade BUY: workbook `106`" not in fit:
+        raise RuntimeError("Unexpected baseline fit file contents; baseline report expects the aligned workbook-preserving version.")
+
+
+if __name__ == "__main__":
+    main()

+ 279 - 0
research/dragon/v2/dragon_residual_attribution.py

@@ -0,0 +1,279 @@
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Optional
+
+import pandas as pd
+
+
+def _load_csv(path: Path) -> pd.DataFrame:
+    return pd.read_csv(path, encoding="utf-8-sig")
+
+
+def _pct(value: Optional[float]) -> str:
+    if value is None or pd.isna(value):
+        return "n/a"
+    return f"{float(value):.2%}"
+
+
+def _regime_label(c1: float) -> str:
+    if c1 < 12:
+        return "deep_oversold"
+    if c1 < 20:
+        return "oversold"
+    if c1 < 35:
+        return "rebound_low"
+    if c1 < 60:
+        return "rebound_mid"
+    if c1 < 80:
+        return "high_mid"
+    return "hot"
+
+
+def _trade_chain_type(entry_aligned: bool, exit_aligned: bool) -> str:
+    if not entry_aligned and not exit_aligned:
+        return "isolated_extra_trade"
+    if not entry_aligned and exit_aligned:
+        return "bridge_to_aligned_sell"
+    if entry_aligned and not exit_aligned:
+        return "premature_exit_of_aligned_trade"
+    return "aligned_trade"
+
+
+def _event_context(events: pd.DataFrame, center_date: str, window_days: int = 10) -> str:
+    center = pd.Timestamp(center_date)
+    start = (center - pd.Timedelta(days=window_days)).date().isoformat()
+    end = (center + pd.Timedelta(days=window_days)).date().isoformat()
+    subset = events[(events["date"] >= start) & (events["date"] <= end)].copy()
+    if subset.empty:
+        return ""
+    labels = []
+    for _, row in subset.sort_values("date").iterrows():
+        layer = row.get("layer", "")
+        side = row.get("side", "")
+        note = row.get("note", "") if "note" in row else ""
+        note_text = f":{note}" if isinstance(note, str) and note else ""
+        labels.append(f"{row['date']} {side}({layer}){note_text}")
+    return " | ".join(labels)
+
+
+def _calc_mfe_mae(indicators: pd.DataFrame, buy_date: str, sell_date: str, entry_price: float) -> tuple[Optional[float], Optional[float]]:
+    window = indicators[(indicators["date"] >= buy_date) & (indicators["date"] <= sell_date)]
+    if window.empty or entry_price is None or pd.isna(entry_price):
+        return None, None
+    mfe = window["high"].max() / entry_price - 1
+    mae = window["low"].min() / entry_price - 1
+    return float(mfe), float(mae)
+
+
+def _calc_forward_backward_returns(indicators: pd.DataFrame, event_date: str, days: int = 5) -> tuple[Optional[float], Optional[float]]:
+    row = indicators[indicators["date"] == event_date]
+    if row.empty:
+        return None, None
+    idx = row.index[0]
+    close_now = float(indicators.loc[idx, "close"])
+    pre = None
+    post = None
+    if idx - days >= indicators.index.min():
+        close_prev = float(indicators.loc[idx - days, "close"])
+        pre = close_now / close_prev - 1
+    if idx + days <= indicators.index.max():
+        close_next = float(indicators.loc[idx + days, "close"])
+        post = close_next / close_now - 1
+    return pre, post
+
+
+def _recommendation(
+    chain_type: str,
+    return_pct: float,
+    mfe_pct: Optional[float],
+    holding_days: int,
+) -> tuple[str, str]:
+    if chain_type == "premature_exit_of_aligned_trade":
+        if return_pct <= 0.02:
+            return "DELETE_CANDIDATE", "额外卖点提前截断了已对齐持仓,且收益补偿不足。"
+        return "OBSERVE", "额外卖点发生在已对齐持仓内,但收益贡献不差,需要和风险控制一起评估。"
+    if chain_type == "bridge_to_aligned_sell":
+        if return_pct > 0.01:
+            return "KEEP_BRIDGE", "额外买点承接到了后续对齐卖点,且桥接段本身有正收益。"
+        return "OBSERVE_BRIDGE", "额外买点承接了后续对齐卖点,但桥接段收益一般,需要替代方案后再删。"
+    if chain_type == "isolated_extra_trade":
+        if return_pct > 0.05 and (mfe_pct is not None and mfe_pct > 0.08):
+            return "KEEP_ALPHA", "虽然是额外交易,但收益明显,可能代表工作簿未显式记录的顺势 alpha。"
+        if return_pct <= 0.02 and holding_days <= 15:
+            return "DELETE_CANDIDATE", "额外交易自成闭环,且收益/持有质量偏弱,优先删除。"
+        return "OBSERVE", "额外交易自成闭环,但收益质量不算差,先保留观察。"
+    return "OBSERVE", "需要结合上下文进一步人工判断。"
+
+
+def _impact_text(chain_type: str, side: str, buy_date: str, sell_date: str, paired_aligned: bool) -> str:
+    if side == "BUY" and chain_type == "bridge_to_aligned_sell":
+        return f"若删除,最直接风险是丢失后续对齐卖点 {sell_date} 的持仓承接。"
+    if side == "SELL" and chain_type == "premature_exit_of_aligned_trade":
+        return f"若删除,理论上更接近工作簿原始持仓路径,可继续观察后续卖点是否自然保留。"
+    if chain_type == "isolated_extra_trade":
+        return f"该点与配对交易构成局部闭环,删除通常应连同 {buy_date}->{sell_date} 一并评估。"
+    if paired_aligned:
+        return "该点与对齐事件相连,删除需检查下游状态转移。"
+    return "该点对下游对齐影响有限,更偏向局部收益质量问题。"
+
+
+def main() -> None:
+    base_dir = Path(__file__).resolve().parent
+
+    gaps = _load_csv(base_dir / "dragon_event_gaps.csv")
+    trades = _load_csv(base_dir / "dragon_strategy_trades.csv")
+    strategy_events = _load_csv(base_dir / "dragon_strategy_events.csv")
+    workbook_layers = _load_csv(base_dir / "dragon_workbook_layers.csv")
+    indicators = _load_csv(base_dir / "dragon_indicator_snapshot.csv")
+
+    indicators = indicators.sort_values("date").reset_index(drop=True)
+    workbook_real_buy = set(
+        workbook_layers[(workbook_layers["layer"] == "real_trade") & (workbook_layers["side"] == "BUY")]["date"]
+    )
+    workbook_real_sell = set(
+        workbook_layers[(workbook_layers["layer"] == "real_trade") & (workbook_layers["side"] == "SELL")]["date"]
+    )
+
+    residuals = gaps[
+        (gaps["diagnostic_type"] == "extra_in_strategy")
+        & (gaps["target_layer"] == "real_trade")
+    ].copy()
+    residuals["side"] = residuals["target_side"]
+    residuals["rule"] = residuals["source_reason"]
+    residuals = residuals[["date", "side", "rule", "a1", "b1", "c1"]].sort_values(["date", "side"])
+
+    rows: list[dict[str, object]] = []
+    for _, residual in residuals.iterrows():
+        side = residual["side"]
+        event_date = residual["date"]
+        trade = None
+        if side == "BUY":
+            match = trades[trades["buy_date"] == event_date]
+        else:
+            match = trades[trades["sell_date"] == event_date]
+        if not match.empty:
+            trade = match.iloc[0]
+
+        if trade is None:
+            continue
+
+        buy_date = str(trade["buy_date"])
+        sell_date = str(trade["sell_date"])
+        buy_reason = str(trade["buy_reason"])
+        sell_reason = str(trade["sell_reason"])
+        holding_days = int(trade["holding_days"])
+        return_pct = float(trade["return_pct"])
+        buy_price = float(trade["buy_price"])
+
+        entry_aligned = buy_date in workbook_real_buy
+        exit_aligned = sell_date in workbook_real_sell
+        chain_type = _trade_chain_type(entry_aligned, exit_aligned)
+        paired_aligned = exit_aligned if side == "BUY" else entry_aligned
+        regime = _regime_label(float(residual["c1"]))
+
+        mfe_pct, mae_pct = _calc_mfe_mae(indicators, buy_date, sell_date, buy_price)
+        pre_5d_return, post_5d_return = _calc_forward_backward_returns(indicators, event_date, days=5)
+
+        recommendation, recommendation_reason = _recommendation(
+            chain_type=chain_type,
+            return_pct=return_pct,
+            mfe_pct=mfe_pct,
+            holding_days=holding_days,
+        )
+
+        impact_text = _impact_text(
+            chain_type=chain_type,
+            side=side,
+            buy_date=buy_date,
+            sell_date=sell_date,
+            paired_aligned=paired_aligned,
+        )
+
+        workbook_context = _event_context(workbook_layers, event_date, window_days=10)
+        strategy_context = _event_context(strategy_events, event_date, window_days=10)
+
+        rows.append(
+            {
+                "date": event_date,
+                "side": side,
+                "rule": residual["rule"],
+                "regime": regime,
+                "buy_date": buy_date,
+                "buy_reason": buy_reason,
+                "sell_date": sell_date,
+                "sell_reason": sell_reason,
+                "holding_days": holding_days,
+                "return_pct": return_pct,
+                "mfe_pct": mfe_pct,
+                "mae_pct": mae_pct,
+                "event_a1": float(residual["a1"]),
+                "event_b1": float(residual["b1"]),
+                "event_c1": float(residual["c1"]),
+                "pre_5d_return": pre_5d_return,
+                "post_5d_return": post_5d_return,
+                "entry_is_workbook_real": entry_aligned,
+                "exit_is_workbook_real": exit_aligned,
+                "chain_type": chain_type,
+                "paired_aligned_event_date": sell_date if side == "BUY" and exit_aligned else buy_date if side == "SELL" and entry_aligned else "",
+                "delete_impact": impact_text,
+                "recommendation": recommendation,
+                "recommendation_reason": recommendation_reason,
+                "workbook_context": workbook_context,
+                "strategy_context": strategy_context,
+            }
+        )
+
+    attribution = pd.DataFrame(rows).sort_values(["recommendation", "date"]).reset_index(drop=True)
+    attribution.to_csv(base_dir / "dragon_residual_trade_attribution.csv", index=False, encoding="utf-8-sig")
+
+    lines = [
+        "# Dragon Residual Trade Review",
+        "",
+        "## Snapshot",
+        f"- Residual real-trade rows reviewed: `{len(attribution)}`",
+        f"- DELETE_CANDIDATE: `{int((attribution['recommendation'] == 'DELETE_CANDIDATE').sum())}`",
+        f"- KEEP_BRIDGE / KEEP_ALPHA: `{int(attribution['recommendation'].isin(['KEEP_BRIDGE', 'KEEP_ALPHA']).sum())}`",
+        f"- OBSERVE / OBSERVE_BRIDGE: `{int(attribution['recommendation'].isin(['OBSERVE', 'OBSERVE_BRIDGE']).sum())}`",
+        "",
+        "## Recommendation Summary",
+    ]
+
+    for label in ["DELETE_CANDIDATE", "KEEP_BRIDGE", "KEEP_ALPHA", "OBSERVE_BRIDGE", "OBSERVE"]:
+        subset = attribution[attribution["recommendation"] == label]
+        if subset.empty:
+            continue
+        lines.append(f"### {label}")
+        for _, row in subset.iterrows():
+            lines.append(
+                f"- `{row['date']}` `{row['side']}` `{row['rule']}` | trade `{row['buy_date']} -> {row['sell_date']}` | "
+                f"ret `{_pct(row['return_pct'])}` mfe `{_pct(row['mfe_pct'])}` mae `{_pct(row['mae_pct'])}` | "
+                f"{row['recommendation_reason']}"
+            )
+        lines.append("")
+
+    lines.extend(["## Detailed Cards", ""])
+    for _, row in attribution.sort_values("date").iterrows():
+        lines.extend(
+            [
+                f"### {row['date']} {row['side']} {row['rule']}",
+                f"- Regime: `{row['regime']}`",
+                f"- Trade: `{row['buy_date']} -> {row['sell_date']}` | buy `{row['buy_reason']}` | sell `{row['sell_reason']}`",
+                f"- Holding / Return: `{int(row['holding_days'])}` days / `{_pct(row['return_pct'])}`",
+                f"- MFE / MAE: `{_pct(row['mfe_pct'])}` / `{_pct(row['mae_pct'])}`",
+                f"- Event indicators: `a1={row['event_a1']:.4f}` `b1={row['event_b1']:.4f}` `c1={row['event_c1']:.2f}`",
+                f"- Pre/Post 5d return: `{_pct(row['pre_5d_return'])}` / `{_pct(row['post_5d_return'])}`",
+                f"- Chain type: `{row['chain_type']}` | entry aligned `{bool(row['entry_is_workbook_real'])}` | exit aligned `{bool(row['exit_is_workbook_real'])}`",
+                f"- Delete impact: {row['delete_impact']}",
+                f"- Recommendation: `{row['recommendation']}` | {row['recommendation_reason']}",
+                f"- Workbook context: {row['workbook_context'] or 'n/a'}",
+                f"- Strategy context: {row['strategy_context'] or 'n/a'}",
+                "",
+            ]
+        )
+
+    (base_dir / "dragon_residual_trade_review.md").write_text("\n".join(lines), encoding="utf-8")
+
+
+if __name__ == "__main__":
+    main()

+ 335 - 0
research/dragon/v2/dragon_robustness_report.py

@@ -0,0 +1,335 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pandas as pd
+
+
+def _load_csv(base_dir: Path, name: str) -> pd.DataFrame:
+    return pd.read_csv(base_dir / name, encoding="utf-8-sig")
+
+
+def _profit_factor(series: pd.Series) -> float:
+    gross_profit = series[series > 0].sum()
+    gross_loss = -series[series < 0].sum()
+    if gross_loss == 0:
+        return float("inf") if gross_profit > 0 else 0.0
+    return float(gross_profit / gross_loss)
+
+
+def _holding_bucket(days: int) -> str:
+    if days <= 5:
+        return "00-05d"
+    if days <= 10:
+        return "06-10d"
+    if days <= 20:
+        return "11-20d"
+    if days <= 40:
+        return "21-40d"
+    return "41d+"
+
+
+def _summarize(df: pd.DataFrame, group_cols: list[str]) -> pd.DataFrame:
+    if group_cols:
+        grouped = df.groupby(group_cols, dropna=False)
+    else:
+        grouped = df.groupby(lambda _: "ALL")
+
+    rows: list[dict[str, object]] = []
+    for key, group in grouped:
+        if not isinstance(key, tuple):
+            key = (key,)
+        row = {col: val for col, val in zip(group_cols or ["scope"], key)}
+        row["trades"] = int(len(group))
+        row["win_rate"] = float((group["return_pct"] > 0).mean())
+        row["avg_return"] = float(group["return_pct"].mean())
+        row["median_return"] = float(group["return_pct"].median())
+        row["profit_factor"] = _profit_factor(group["return_pct"])
+        row["expectancy"] = float(group["return_pct"].mean())
+        row["avg_holding_days"] = float(group["holding_days"].mean())
+        row["avg_mfe_pct"] = float(group["mfe_pct"].mean())
+        row["avg_mae_pct"] = float(group["mae_pct"].mean())
+        row["avg_giveback_from_peak_pct"] = float(group["giveback_from_peak_pct"].mean())
+        row["avg_exit_followthrough_5d_pct"] = float(group["exit_followthrough_5d_pct"].mean())
+        row["avg_exit_rebound_5d_pct"] = float(group["exit_rebound_5d_pct"].mean())
+        rows.append(row)
+    return pd.DataFrame(rows)
+
+
+def _format_pct(value: float) -> str:
+    if pd.isna(value):
+        return "NA"
+    if value == float("inf"):
+        return "inf"
+    return f"{value:.2%}"
+
+
+def _safe_value(df: pd.DataFrame, col: str) -> str:
+    if df.empty:
+        return "NA"
+    value = df.iloc[0][col]
+    if isinstance(value, float):
+        return _format_pct(value) if "rate" in col or "return" in col or "pct" in col else f"{value:.2f}"
+    return str(value)
+
+
+def _build_trade_quality(trades: pd.DataFrame, path_trace: pd.DataFrame, indicators: pd.DataFrame) -> pd.DataFrame:
+    trades = trades.copy()
+    path_trace = path_trace.copy()
+    indicators = indicators.copy()
+
+    trades["buy_dt"] = pd.to_datetime(trades["buy_date"])
+    trades["sell_dt"] = pd.to_datetime(trades["sell_date"])
+    trades["sell_year"] = trades["sell_dt"].dt.year
+    trades["holding_bucket"] = trades["holding_days"].astype(int).map(_holding_bucket)
+
+    indicators["dt"] = pd.to_datetime(indicators["date"])
+    indicators = indicators.sort_values("dt").reset_index(drop=True)
+    pos_lookup = {dt.date().isoformat(): idx for idx, dt in enumerate(indicators["dt"])}
+
+    mfe_list: list[float] = []
+    mae_list: list[float] = []
+    giveback_list: list[float] = []
+    exit_followthrough_list: list[float] = []
+    exit_rebound_list: list[float] = []
+
+    for _, trade in trades.iterrows():
+        buy_date = trade["buy_date"]
+        sell_date = trade["sell_date"]
+        entry_price = float(trade["buy_price"])
+        exit_price = float(trade["sell_price"])
+
+        window = indicators[(indicators["dt"] >= trade["buy_dt"]) & (indicators["dt"] <= trade["sell_dt"])]
+        max_high = float(window["high"].max())
+        min_low = float(window["low"].min())
+        mfe_list.append(max_high / entry_price - 1.0)
+        mae_list.append(min_low / entry_price - 1.0)
+        giveback_list.append(exit_price / max_high - 1.0)
+
+        sell_idx = pos_lookup.get(sell_date)
+        if sell_idx is None:
+            exit_followthrough_list.append(float("nan"))
+            exit_rebound_list.append(float("nan"))
+            continue
+        future = indicators.iloc[sell_idx + 1 : sell_idx + 6]
+        if future.empty:
+            exit_followthrough_list.append(float("nan"))
+            exit_rebound_list.append(float("nan"))
+            continue
+        exit_followthrough_list.append(float(future["low"].min()) / exit_price - 1.0)
+        exit_rebound_list.append(float(future["high"].max()) / exit_price - 1.0)
+
+    trades["mfe_pct"] = mfe_list
+    trades["mae_pct"] = mae_list
+    trades["giveback_from_peak_pct"] = giveback_list
+    trades["exit_followthrough_5d_pct"] = exit_followthrough_list
+    trades["exit_rebound_5d_pct"] = exit_rebound_list
+
+    merge_cols = [
+        "buy_date",
+        "sell_date",
+        "market_state_layer",
+        "entry_qualification_layer",
+        "position_management_layer",
+        "aux_context_layer",
+        "aux_signal_count",
+        "hold_aux_buy_count",
+        "post_exit_aux_sell_count",
+        "next_buy_date",
+        "layer_path",
+    ]
+    return trades.merge(path_trace[merge_cols], on=["buy_date", "sell_date"], how="left")
+
+
+def _build_rule_stability(df: pd.DataFrame) -> pd.DataFrame:
+    baseline = {
+        "trades": int(len(df)),
+        "win_rate": float((df["return_pct"] > 0).mean()),
+        "avg_return": float(df["return_pct"].mean()),
+        "profit_factor": _profit_factor(df["return_pct"]),
+    }
+    rows: list[dict[str, object]] = []
+
+    for rule_type, col in [("entry_rule", "buy_reason"), ("exit_rule", "sell_reason")]:
+        for rule_name, group in df.groupby(col):
+            remaining = df[df[col] != rule_name]
+            row = {
+                "rule_type": rule_type,
+                "rule_name": rule_name,
+                "removed_trades": int(len(group)),
+                "remaining_trades": int(len(remaining)),
+                "baseline_trades": baseline["trades"],
+                "baseline_win_rate": baseline["win_rate"],
+                "baseline_avg_return": baseline["avg_return"],
+                "baseline_profit_factor": baseline["profit_factor"],
+            }
+            if remaining.empty:
+                row["remaining_win_rate"] = float("nan")
+                row["remaining_avg_return"] = float("nan")
+                row["remaining_profit_factor"] = float("nan")
+            else:
+                row["remaining_win_rate"] = float((remaining["return_pct"] > 0).mean())
+                row["remaining_avg_return"] = float(remaining["return_pct"].mean())
+                row["remaining_profit_factor"] = _profit_factor(remaining["return_pct"])
+            row["delta_win_rate"] = row["remaining_win_rate"] - baseline["win_rate"]
+            row["delta_avg_return"] = row["remaining_avg_return"] - baseline["avg_return"]
+            row["delta_profit_factor"] = row["remaining_profit_factor"] - baseline["profit_factor"]
+            rows.append(row)
+
+    return pd.DataFrame(rows)
+
+
+def main() -> None:
+    base_dir = Path(__file__).resolve().parent
+    trades = _load_csv(base_dir, "dragon_strategy_trades.csv")
+    path_trace = _load_csv(base_dir, "dragon_trade_path_trace.csv")
+    indicators = _load_csv(base_dir, "dragon_indicator_snapshot.csv")
+
+    quality = _build_trade_quality(trades, path_trace, indicators)
+    quality.to_csv(base_dir / "dragon_trade_quality.csv", index=False, encoding="utf-8-sig")
+
+    baseline_summary = _summarize(quality, [])
+    holding_summary = _summarize(quality, ["holding_bucket"]).sort_values("holding_bucket")
+    yearly_summary = _summarize(quality, ["sell_year"]).sort_values("sell_year")
+    state_summary = _summarize(quality, ["market_state_layer"]).sort_values("trades", ascending=False)
+    entry_summary = _summarize(quality, ["buy_reason"]).sort_values("trades", ascending=False)
+    exit_summary = _summarize(quality, ["sell_reason"]).sort_values("trades", ascending=False)
+    path_summary = _summarize(quality, ["market_state_layer", "entry_qualification_layer", "position_management_layer"]).sort_values(
+        "trades", ascending=False
+    )
+    split_summary = _summarize(
+        quality.assign(sample_split=quality["sell_year"].apply(lambda x: "2016-2020" if x <= 2020 else "2021-2025")),
+        ["sample_split"],
+    ).sort_values("sample_split")
+    stability = _build_rule_stability(quality).sort_values(["rule_type", "delta_avg_return"])
+
+    group_frames = []
+    for group_type, df in [
+        ("holding_bucket", holding_summary),
+        ("sell_year", yearly_summary),
+        ("market_state_layer", state_summary),
+        ("buy_reason", entry_summary),
+        ("sell_reason", exit_summary),
+        ("path_core", path_summary),
+        ("sample_split", split_summary),
+    ]:
+        group_frames.append(df.assign(group_type=group_type))
+    group_summary = pd.concat(group_frames, ignore_index=True, sort=False)
+
+    group_summary.to_csv(base_dir / "dragon_trade_group_summary.csv", index=False, encoding="utf-8-sig")
+    yearly_summary.to_csv(base_dir / "dragon_yearly_performance.csv", index=False, encoding="utf-8-sig")
+    entry_summary.assign(rule_type="entry_rule").to_csv(
+        base_dir / "dragon_rule_contribution_entry.csv", index=False, encoding="utf-8-sig"
+    )
+    exit_summary.assign(rule_type="exit_rule").to_csv(
+        base_dir / "dragon_rule_contribution_exit.csv", index=False, encoding="utf-8-sig"
+    )
+    stability.to_csv(base_dir / "dragon_rule_stability.csv", index=False, encoding="utf-8-sig")
+
+    best_entry = entry_summary[entry_summary["trades"] >= 3].sort_values("avg_return", ascending=False).head(3)
+    weakest_entry = entry_summary[entry_summary["trades"] >= 3].sort_values("avg_return", ascending=True).head(3)
+    best_exit = exit_summary[exit_summary["trades"] >= 3].sort_values("avg_exit_followthrough_5d_pct").head(3)
+    weakest_exit = exit_summary[exit_summary["trades"] >= 3].sort_values("avg_exit_followthrough_5d_pct", ascending=False).head(3)
+    worst_rule_removal = stability.sort_values("delta_avg_return").head(5)
+    best_rule_removal = stability.sort_values("delta_avg_return", ascending=False).head(5)
+
+    lines = [
+        "# Dragon Robustness Report",
+        "",
+        "## Baseline",
+        f"- trades: `{int(baseline_summary.iloc[0]['trades'])}`",
+        f"- win_rate: `{_format_pct(float(baseline_summary.iloc[0]['win_rate']))}`",
+        f"- avg_return: `{_format_pct(float(baseline_summary.iloc[0]['avg_return']))}`",
+        f"- median_return: `{_format_pct(float(baseline_summary.iloc[0]['median_return']))}`",
+        f"- profit_factor: `{baseline_summary.iloc[0]['profit_factor']:.2f}`",
+        f"- avg_mfe: `{_format_pct(float(baseline_summary.iloc[0]['avg_mfe_pct']))}`",
+        f"- avg_mae: `{_format_pct(float(baseline_summary.iloc[0]['avg_mae_pct']))}`",
+        f"- avg_exit_followthrough_5d: `{_format_pct(float(baseline_summary.iloc[0]['avg_exit_followthrough_5d_pct']))}`",
+        "",
+        "## Holding-Bucket View",
+    ]
+    for _, row in holding_summary.iterrows():
+        lines.append(
+            f"- `{row['holding_bucket']}`: trades `{int(row['trades'])}`, win_rate `{_format_pct(float(row['win_rate']))}`, "
+            f"avg_return `{_format_pct(float(row['avg_return']))}`, avg_mfe `{_format_pct(float(row['avg_mfe_pct']))}`, "
+            f"avg_mae `{_format_pct(float(row['avg_mae_pct']))}`"
+        )
+
+    lines.extend(["", "## Yearly View"])
+    for _, row in yearly_summary.iterrows():
+        lines.append(
+            f"- `{int(row['sell_year'])}`: trades `{int(row['trades'])}`, win_rate `{_format_pct(float(row['win_rate']))}`, "
+            f"avg_return `{_format_pct(float(row['avg_return']))}`, profit_factor `{row['profit_factor']:.2f}`"
+        )
+
+    lines.extend(["", "## Sample Split"])
+    for _, row in split_summary.iterrows():
+        lines.append(
+            f"- `{row['sample_split']}`: trades `{int(row['trades'])}`, win_rate `{_format_pct(float(row['win_rate']))}`, "
+            f"avg_return `{_format_pct(float(row['avg_return']))}`, profit_factor `{row['profit_factor']:.2f}`"
+        )
+
+    lines.extend(["", "## Regime View"])
+    for _, row in state_summary.iterrows():
+        lines.append(
+            f"- `{row['market_state_layer']}`: trades `{int(row['trades'])}`, avg_return `{_format_pct(float(row['avg_return']))}`, "
+            f"profit_factor `{row['profit_factor']:.2f}`, avg_mae `{_format_pct(float(row['avg_mae_pct']))}`"
+        )
+
+    lines.extend(["", "## Best Entry Rules"])
+    for _, row in best_entry.iterrows():
+        lines.append(
+            f"- `{row['buy_reason']}`: trades `{int(row['trades'])}`, avg_return `{_format_pct(float(row['avg_return']))}`, "
+            f"win_rate `{_format_pct(float(row['win_rate']))}`, avg_mfe `{_format_pct(float(row['avg_mfe_pct']))}`"
+        )
+
+    lines.extend(["", "## Weakest Entry Rules"])
+    for _, row in weakest_entry.iterrows():
+        lines.append(
+            f"- `{row['buy_reason']}`: trades `{int(row['trades'])}`, avg_return `{_format_pct(float(row['avg_return']))}`, "
+            f"win_rate `{_format_pct(float(row['win_rate']))}`, avg_mae `{_format_pct(float(row['avg_mae_pct']))}`"
+        )
+
+    lines.extend(["", "## Best Exit Rules"])
+    for _, row in best_exit.iterrows():
+        lines.append(
+            f"- `{row['sell_reason']}`: trades `{int(row['trades'])}`, avg_exit_followthrough_5d `{_format_pct(float(row['avg_exit_followthrough_5d_pct']))}`, "
+            f"avg_return `{_format_pct(float(row['avg_return']))}`"
+        )
+
+    lines.extend(["", "## Weakest Exit Rules"])
+    for _, row in weakest_exit.iterrows():
+        lines.append(
+            f"- `{row['sell_reason']}`: trades `{int(row['trades'])}`, avg_exit_followthrough_5d `{_format_pct(float(row['avg_exit_followthrough_5d_pct']))}`, "
+            f"avg_return `{_format_pct(float(row['avg_return']))}`"
+        )
+
+    lines.extend(["", "## Realized Contribution Stress Test"])
+    lines.append("- Interpretation: this removes realized trades by rule from the current trade set; it is not yet a full re-run stability test.")
+    lines.append("- Worst removals for average return:")
+    for _, row in worst_rule_removal.iterrows():
+        lines.append(
+            f"- `{row['rule_type']} / {row['rule_name']}`: removed `{int(row['removed_trades'])}` trades, "
+            f"delta_avg_return `{_format_pct(float(row['delta_avg_return']))}`, delta_profit_factor `{row['delta_profit_factor']:.2f}`"
+        )
+    lines.append("- Best removals for average return:")
+    for _, row in best_rule_removal.iterrows():
+        lines.append(
+            f"- `{row['rule_type']} / {row['rule_name']}`: removed `{int(row['removed_trades'])}` trades, "
+            f"delta_avg_return `{_format_pct(float(row['delta_avg_return']))}`, delta_profit_factor `{row['delta_profit_factor']:.2f}`"
+        )
+
+    lines.extend(
+        [
+            "",
+            "## Next Stage-3 Gaps",
+            "- Threshold perturbation is not yet formalized because the current strategy logic is still hard-coded, not parameterized.",
+            "- A true leave-one-rule-out stability test still needs rerun-able switches in `dragon_strategy.py` rather than ex-post trade deletion only.",
+        ]
+    )
+
+    (base_dir / "dragon_robustness_report.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+
+if __name__ == "__main__":
+    main()

+ 254 - 0
research/dragon/v2/dragon_rule_ablation.py

@@ -0,0 +1,254 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pandas as pd
+
+from dragon_indicators import DragonIndicatorConfig, DragonIndicatorEngine
+from dragon_strategy import DragonRuleEngine
+from dragon_strategy_config import StrategyConfig
+from dragon_workbook import DragonWorkbook
+
+
+def _find_workbook(base_dir: Path) -> Path:
+    matches = sorted(base_dir.glob("*.xlsx"))
+    if not matches:
+        raise FileNotFoundError(f"No workbook found in {base_dir}")
+    return matches[0]
+
+
+def _load_workbook_events(workbook_path: Path) -> pd.DataFrame:
+    workbook = DragonWorkbook(workbook_path)
+    return pd.DataFrame(
+        [
+            {
+                "date": event.date.isoformat(),
+                "side": event.side,
+                "layer": event.layer,
+                "signal_reason": event.signal_reason,
+                "note": event.note,
+            }
+            for event in workbook.split_layers()
+        ]
+    )
+
+
+def _event_match_report(workbook_events: pd.DataFrame, strategy_events: pd.DataFrame, side: str, layer: str) -> dict[str, object]:
+    wb = set(workbook_events[(workbook_events["side"] == side) & (workbook_events["layer"] == layer)]["date"])
+    st = set(strategy_events[(strategy_events["side"] == side) & (strategy_events["layer"] == layer)]["date"])
+    hit = wb & st
+    return {
+        "workbook": len(wb),
+        "strategy": len(st),
+        "overlap": len(hit),
+        "missing": len(wb - st),
+        "extra": len(st - wb),
+    }
+
+
+def _profit_factor(series: pd.Series) -> float:
+    gross_profit = series[series > 0].sum()
+    gross_loss = -series[series < 0].sum()
+    if gross_loss == 0:
+        return float("inf") if gross_profit > 0 else 0.0
+    return float(gross_profit / gross_loss)
+
+
+def _trade_quality(trades: pd.DataFrame, indicator_df: pd.DataFrame) -> tuple[float, float]:
+    if trades.empty:
+        return float("nan"), float("nan")
+
+    lookup = indicator_df.reset_index().rename(columns={"index": "dt"})
+    lookup["date_str"] = lookup["date"].dt.date.astype(str)
+    pos_lookup = {date_str: idx for idx, date_str in enumerate(lookup["date_str"])}
+
+    mfe_values: list[float] = []
+    mae_values: list[float] = []
+    for _, trade in trades.iterrows():
+        buy_idx = pos_lookup.get(trade["buy_date"])
+        sell_idx = pos_lookup.get(trade["sell_date"])
+        if buy_idx is None or sell_idx is None or sell_idx < buy_idx:
+            continue
+        window = lookup.iloc[buy_idx : sell_idx + 1]
+        entry_price = float(trade["buy_price"])
+        mfe_values.append(float(window["high"].max()) / entry_price - 1.0)
+        mae_values.append(float(window["low"].min()) / entry_price - 1.0)
+    return (
+        float(pd.Series(mfe_values).mean()) if mfe_values else float("nan"),
+        float(pd.Series(mae_values).mean()) if mae_values else float("nan"),
+    )
+
+
+def _run_single_experiment(
+    label: str,
+    config: StrategyConfig,
+    workbook_events: pd.DataFrame,
+    indicator_df: pd.DataFrame,
+    first_workbook_date: str,
+    last_workbook_date: str,
+) -> dict[str, object]:
+    strategy = DragonRuleEngine(config=config)
+    events, trades = strategy.run(indicator_df)
+    events = events[(events["date"] >= first_workbook_date) & (events["date"] <= last_workbook_date)].copy()
+    trades = trades[
+        (trades["buy_date"] >= first_workbook_date)
+        & (trades["buy_date"] <= last_workbook_date)
+        & (trades["sell_date"] >= first_workbook_date)
+        & (trades["sell_date"] <= last_workbook_date)
+    ].copy()
+
+    buy_match = _event_match_report(workbook_events, events, "BUY", "real_trade")
+    sell_match = _event_match_report(workbook_events, events, "SELL", "real_trade")
+    aux_buy_match = _event_match_report(workbook_events, events, "BUY", "aux_signal")
+    aux_sell_match = _event_match_report(workbook_events, events, "SELL", "aux_signal")
+
+    avg_mfe, avg_mae = _trade_quality(trades, indicator_df)
+    win_rate = float((trades["return_pct"] > 0).mean()) if not trades.empty else float("nan")
+    avg_return = float(trades["return_pct"].mean()) if not trades.empty else float("nan")
+    median_return = float(trades["return_pct"].median()) if not trades.empty else float("nan")
+    profit_factor = _profit_factor(trades["return_pct"]) if not trades.empty else float("nan")
+
+    return {
+        "experiment": label,
+        "disabled_rules": "|".join(sorted(config.disabled_rules)),
+        "aux_sell_same_side_once_per_cycle": config.aux_sell_same_side_once_per_cycle,
+        "enable_knife_take_profit_2_wait_ql": config.enable_knife_take_profit_2_wait_ql,
+        "trades": int(len(trades)),
+        "win_rate": win_rate,
+        "avg_return": avg_return,
+        "median_return": median_return,
+        "profit_factor": profit_factor,
+        "avg_mfe_pct": avg_mfe,
+        "avg_mae_pct": avg_mae,
+        "real_buy_overlap": int(buy_match["overlap"]),
+        "real_buy_missing": int(buy_match["missing"]),
+        "real_buy_extra": int(buy_match["extra"]),
+        "real_sell_overlap": int(sell_match["overlap"]),
+        "real_sell_missing": int(sell_match["missing"]),
+        "real_sell_extra": int(sell_match["extra"]),
+        "aux_buy_overlap": int(aux_buy_match["overlap"]),
+        "aux_buy_missing": int(aux_buy_match["missing"]),
+        "aux_buy_extra": int(aux_buy_match["extra"]),
+        "aux_sell_overlap": int(aux_sell_match["overlap"]),
+        "aux_sell_missing": int(aux_sell_match["missing"]),
+        "aux_sell_extra": int(aux_sell_match["extra"]),
+    }
+
+
+def main() -> None:
+    base_dir = Path(__file__).resolve().parent
+    workbook_path = _find_workbook(base_dir)
+    workbook_events = _load_workbook_events(workbook_path)
+    first_workbook_date = pd.to_datetime(workbook_events["date"]).min().date().isoformat()
+    last_workbook_date = pd.to_datetime(workbook_events["date"]).max().date().isoformat()
+
+    engine = DragonIndicatorEngine(DragonIndicatorConfig(start_date="2015-01-01", end_date="2026-01-31"))
+    indicator_df = engine.compute(engine.fetch_daily_data())
+
+    baseline_config = StrategyConfig()
+    experiments: list[tuple[str, StrategyConfig]] = [
+        ("baseline", baseline_config),
+        ("disable_entry_glued_buy", baseline_config.with_updates(disabled_rules={"glued_buy"})),
+        ("disable_entry_deep_oversold_rebound_buy", baseline_config.with_updates(disabled_rules={"deep_oversold_rebound_buy"})),
+        ("disable_entry_oversold_recovery_buy", baseline_config.with_updates(disabled_rules={"oversold_recovery_buy"})),
+        ("disable_entry_post_sell_rebound_buy", baseline_config.with_updates(disabled_rules={"post_sell_rebound_buy"})),
+        ("disable_entry_oversold_reversal_after_ql_buy", baseline_config.with_updates(disabled_rules={"oversold_reversal_after_ql_buy"})),
+        ("disable_entry_non_glued_positive_expansion_buy", baseline_config.with_updates(disabled_rules={"non_glued_positive_expansion_buy"})),
+        ("disable_entry_early_crash_probe_buy", baseline_config.with_updates(disabled_rules={"early_crash_probe_buy"})),
+        ("disable_entry_dual_gold_resonance_buy", baseline_config.with_updates(disabled_rules={"dual_gold_resonance_buy"})),
+        ("disable_exit_knife_take_profit_1", baseline_config.with_updates(disabled_rules={"knife_take_profit_1"})),
+        ("disable_exit_knife_take_profit_2_glued", baseline_config.with_updates(disabled_rules={"knife_take_profit_2_glued"})),
+        ("disable_exit_ql_mid_zone_take_profit", baseline_config.with_updates(disabled_rules={"ql_mid_zone_take_profit"})),
+        ("disable_exit_high_regime_confirmed_exit_kdj", baseline_config.with_updates(disabled_rules={"high_regime_confirmed_exit:kdj_sell"})),
+        ("disable_exit_predictive_b1_break_exit", baseline_config.with_updates(disabled_rules={"predictive_b1_break_exit"})),
+        ("disable_exit_prewarning_reduction_exit", baseline_config.with_updates(disabled_rules={"prewarning_reduction_exit"})),
+        ("disable_exit_crash_protection_exit", baseline_config.with_updates(disabled_rules={"crash_protection_exit"})),
+        ("disable_aux_same_side_cycle_cap", baseline_config.with_updates(aux_sell_same_side_once_per_cycle=False)),
+        ("disable_knife_take_profit_2_wait_ql", baseline_config.with_updates(enable_knife_take_profit_2_wait_ql=False)),
+    ]
+
+    rows = [
+        _run_single_experiment(
+            label,
+            config,
+            workbook_events,
+            indicator_df,
+            first_workbook_date,
+            last_workbook_date,
+        )
+        for label, config in experiments
+    ]
+    result_df = pd.DataFrame(rows)
+    baseline_row = result_df[result_df["experiment"] == "baseline"].iloc[0]
+    for col in [
+        "trades",
+        "win_rate",
+        "avg_return",
+        "median_return",
+        "profit_factor",
+        "avg_mfe_pct",
+        "avg_mae_pct",
+        "real_buy_overlap",
+        "real_sell_overlap",
+        "aux_sell_overlap",
+    ]:
+        result_df[f"delta_{col}"] = result_df[col] - baseline_row[col]
+
+    result_df.to_csv(base_dir / "dragon_rule_ablation.csv", index=False, encoding="utf-8-sig")
+
+    protected = result_df[
+        (result_df["experiment"] != "baseline")
+        & (result_df["real_buy_overlap"] == baseline_row["real_buy_overlap"])
+        & (result_df["real_sell_overlap"] == baseline_row["real_sell_overlap"])
+    ].copy()
+    protected_sorted = protected.sort_values("delta_avg_return", ascending=False)
+    harmful_sorted = result_df[result_df["experiment"] != "baseline"].sort_values("delta_avg_return")
+
+    lines = [
+        "# Dragon Rule Ablation",
+        "",
+        "## Baseline",
+        f"- trades: `{int(baseline_row['trades'])}`",
+        f"- win_rate: `{baseline_row['win_rate']:.2%}`",
+        f"- avg_return: `{baseline_row['avg_return']:.2%}`",
+        f"- profit_factor: `{baseline_row['profit_factor']:.2f}`",
+        f"- real BUY overlap: `{int(baseline_row['real_buy_overlap'])}`",
+        f"- real SELL overlap: `{int(baseline_row['real_sell_overlap'])}`",
+        "",
+        "## Protected Experiments",
+        "- Interpretation: these experiments preserved current real-trade overlap and only changed quality or auxiliary behavior.",
+    ]
+    if protected_sorted.empty:
+        lines.append("- None.")
+    else:
+        for _, row in protected_sorted.head(8).iterrows():
+            lines.append(
+                f"- `{row['experiment']}`: delta_avg_return `{row['delta_avg_return']:.2%}`, "
+                f"delta_profit_factor `{row['delta_profit_factor']:.2f}`, delta_aux_sell_overlap `{int(row['delta_aux_sell_overlap'])}`"
+            )
+
+    lines.extend(["", "## Most Harmful Removals"])
+    for _, row in harmful_sorted.head(8).iterrows():
+        lines.append(
+            f"- `{row['experiment']}`: delta_avg_return `{row['delta_avg_return']:.2%}`, "
+            f"real BUY `{int(row['real_buy_overlap'])}`, real SELL `{int(row['real_sell_overlap'])}`, "
+            f"delta_trades `{int(row['delta_trades'])}`"
+        )
+
+    lines.extend(["", "## Best Removal Candidates"])
+    best_candidates = result_df[
+        (result_df["experiment"] != "baseline")
+        & (result_df["delta_avg_return"] > 0)
+    ].sort_values(["real_buy_overlap", "real_sell_overlap", "delta_avg_return"], ascending=[False, False, False])
+    for _, row in best_candidates.head(8).iterrows():
+        lines.append(
+            f"- `{row['experiment']}`: delta_avg_return `{row['delta_avg_return']:.2%}`, "
+            f"delta_profit_factor `{row['delta_profit_factor']:.2f}`, "
+            f"real BUY `{int(row['real_buy_overlap'])}`, real SELL `{int(row['real_sell_overlap'])}`"
+        )
+
+    (base_dir / "dragon_rule_ablation.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+
+if __name__ == "__main__":
+    main()

+ 35 - 0
research/dragon/v2/dragon_shared.py

@@ -0,0 +1,35 @@
+from __future__ import annotations
+
+import pandas as pd
+
+
+START_DATE = "2016-01-01"
+END_DATE = "2025-12-31"
+
+
+def evaluation_years(start_date: str = START_DATE, end_date: str = END_DATE) -> float:
+    return (pd.Timestamp(end_date) - pd.Timestamp(start_date)).days / 365.25
+
+
+def profit_factor(series: pd.Series) -> float:
+    gross_profit = series[series > 0].sum()
+    gross_loss = -series[series < 0].sum()
+    if gross_loss == 0:
+        return float("inf") if gross_profit > 0 else 0.0
+    return float(gross_profit / gross_loss)
+
+
+def format_pct(value: float) -> str:
+    if pd.isna(value):
+        return "NA"
+    if value == float("inf"):
+        return "inf"
+    return f"{value:.2%}"
+
+
+def format_num(value: float, digits: int = 2) -> str:
+    if pd.isna(value):
+        return "NA"
+    if value == float("inf"):
+        return "inf"
+    return f"{value:.{digits}f}"

+ 273 - 0
research/dragon/v2/dragon_short_holding_audit.py

@@ -0,0 +1,273 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pandas as pd
+
+from dragon_branch_configs import alpha_first_selective_veto_config
+from dragon_strategy import DragonRuleEngine
+
+
+SHORT_BUCKETS = {"00-05d", "06-10d"}
+
+
+def _load_csv(base_dir: Path, name: str) -> pd.DataFrame:
+    return pd.read_csv(base_dir / name, encoding="utf-8-sig")
+
+
+def _holding_bucket(days: int) -> str:
+    if days <= 5:
+        return "00-05d"
+    if days <= 10:
+        return "06-10d"
+    if days <= 20:
+        return "11-20d"
+    if days <= 40:
+        return "21-40d"
+    return "41d+"
+
+
+def _entry_family(reason: str) -> str:
+    return reason.split(":", 1)[0]
+
+
+def _exit_family(reason: str) -> str:
+    return reason.split(":", 1)[0]
+
+
+def _compute_forward_return(indicators: pd.DataFrame, date_str: str, base_price: float, days: int) -> float:
+    row = indicators[indicators["date_str"] == date_str]
+    if row.empty:
+        return float("nan")
+    idx = int(row.iloc[0]["row_id"])
+    target_idx = idx + days
+    if target_idx >= len(indicators):
+        return float("nan")
+    return float(indicators.iloc[target_idx]["close"]) / base_price - 1.0
+
+
+def _compute_metrics(trade: pd.Series, indicators: pd.DataFrame) -> dict[str, float]:
+    buy_date = trade["buy_date"]
+    sell_date = trade["sell_date"]
+    entry_price = float(trade["buy_price"])
+    exit_price = float(trade["sell_price"])
+
+    buy_row = indicators[indicators["date_str"] == buy_date]
+    sell_row = indicators[indicators["date_str"] == sell_date]
+    if buy_row.empty or sell_row.empty:
+        return {}
+
+    buy_idx = int(buy_row.iloc[0]["row_id"])
+    sell_idx = int(sell_row.iloc[0]["row_id"])
+    window = indicators.iloc[buy_idx : sell_idx + 1].copy()
+
+    peak_close = float(window["close"].max())
+    trough_close = float(window["close"].min())
+    peak_before_exit = peak_close / entry_price - 1.0
+    drawdown_before_exit = trough_close / entry_price - 1.0
+    mfe_pct = float(window["high"].max()) / entry_price - 1.0
+    mae_pct = float(window["low"].min()) / entry_price - 1.0
+
+    sell_plus_3d = float("nan")
+    sell_plus_5d = float("nan")
+    if sell_idx + 3 < len(indicators):
+        sell_plus_3d = float(indicators.iloc[sell_idx + 3]["close"]) / exit_price - 1.0
+    if sell_idx + 5 < len(indicators):
+        sell_plus_5d = float(indicators.iloc[sell_idx + 5]["close"]) / exit_price - 1.0
+
+    return {
+        "buy_plus_1d_return": _compute_forward_return(indicators, buy_date, entry_price, 1),
+        "buy_plus_2d_return": _compute_forward_return(indicators, buy_date, entry_price, 2),
+        "buy_plus_3d_return": _compute_forward_return(indicators, buy_date, entry_price, 3),
+        "sell_plus_3d_followthrough": sell_plus_3d,
+        "sell_plus_5d_followthrough": sell_plus_5d,
+        "peak_before_exit": peak_before_exit,
+        "drawdown_before_exit": drawdown_before_exit,
+        "mfe_pct": mfe_pct,
+        "mae_pct": mae_pct,
+    }
+
+
+def _failure_shape(row: pd.Series) -> str:
+    if bool(row["is_predictive_bridge_chain"]):
+        return "bridge_trade"
+    if row["return_pct"] >= 0 and row["sell_plus_3d_followthrough"] > 0.02:
+        return "exit_too_early"
+    if row["return_pct"] < 0:
+        if row["holding_days"] <= 5 and row["peak_before_exit"] <= 0.01:
+            return "immediate_failure"
+        if row["peak_before_exit"] >= 0.02:
+            return "rebound_then_fail"
+        if 0 < row["peak_before_exit"] < 0.02:
+            return "small_profit_reversal"
+    if row["sell_plus_3d_followthrough"] > 0.03 or row["sell_plus_5d_followthrough"] > 0.04:
+        return "exit_too_early"
+    return "flat_noise"
+
+
+def _failure_root(row: pd.Series) -> str:
+    if bool(row["is_predictive_bridge_chain"]):
+        return "bridge_trade"
+    if row["failure_shape"] == "exit_too_early":
+        return "exit_too_fast"
+    if row["failure_shape"] == "immediate_failure":
+        return "entry_bad"
+    if row["failure_shape"] in {"rebound_then_fail", "small_profit_reversal"}:
+        return "hold_bad"
+    return "mixed"
+
+
+def main() -> None:
+    base_dir = Path(__file__).resolve().parent
+    indicators = _load_csv(base_dir, "dragon_indicator_snapshot.csv")
+    indicators["date"] = pd.to_datetime(indicators["date"])
+    indicators = indicators.sort_values("date").reset_index(drop=True)
+    indicators["date_str"] = indicators["date"].dt.date.astype(str)
+    indicators["row_id"] = indicators.index
+
+    path_trace = _load_csv(base_dir, "dragon_trade_path_trace.csv")
+    workbook_events = _load_csv(base_dir, "true_trade_events.csv")
+    first_date = workbook_events["date"].min()
+    last_date = workbook_events["date"].max()
+
+    engine = DragonRuleEngine(alpha_first_selective_veto_config())
+    events, trades = engine.run(indicators.set_index("date", drop=False))
+    trades = trades[
+        (trades["buy_date"] >= first_date)
+        & (trades["buy_date"] <= last_date)
+        & (trades["sell_date"] >= first_date)
+        & (trades["sell_date"] <= last_date)
+    ].copy()
+
+    trades["entry_family"] = trades["buy_reason"].astype(str).map(_entry_family)
+    trades["exit_family"] = trades["sell_reason"].astype(str).map(_exit_family)
+    trades["holding_bucket"] = trades["holding_days"].astype(int).map(_holding_bucket)
+
+    audit = trades[trades["holding_bucket"].isin(SHORT_BUCKETS)].copy()
+    merge_cols = [
+        "buy_date",
+        "sell_date",
+        "market_state_layer",
+        "entry_qualification_layer",
+        "position_management_layer",
+        "aux_context_layer",
+    ]
+    audit = audit.merge(path_trace[merge_cols], on=["buy_date", "sell_date"], how="left")
+
+    metric_rows = []
+    for _, trade in audit.iterrows():
+        metrics = _compute_metrics(trade, indicators)
+        metric_rows.append(metrics)
+    metric_df = pd.DataFrame(metric_rows)
+    audit = pd.concat([audit.reset_index(drop=True), metric_df], axis=1)
+
+    audit["is_deep_oversold_family"] = audit["entry_family"].eq("deep_oversold_rebound_buy")
+    audit["is_post_sell_rebound_family"] = audit["entry_family"].eq("post_sell_rebound_buy")
+    audit["is_predictive_bridge_chain"] = audit["buy_reason"].eq("predictive_error_reentry_buy") | audit["sell_reason"].eq("predictive_b1_break_exit")
+    audit["failure_shape"] = audit.apply(_failure_shape, axis=1)
+    audit["failure_root"] = audit.apply(_failure_root, axis=1)
+
+    audit = audit[
+        [
+            "buy_date",
+            "sell_date",
+            "buy_reason",
+            "sell_reason",
+            "entry_family",
+            "exit_family",
+            "holding_days",
+            "holding_bucket",
+            "return_pct",
+            "mfe_pct",
+            "mae_pct",
+            "market_state_layer",
+            "entry_qualification_layer",
+            "position_management_layer",
+            "aux_context_layer",
+            "is_deep_oversold_family",
+            "is_post_sell_rebound_family",
+            "is_predictive_bridge_chain",
+            "buy_plus_1d_return",
+            "buy_plus_2d_return",
+            "buy_plus_3d_return",
+            "sell_plus_3d_followthrough",
+            "sell_plus_5d_followthrough",
+            "peak_before_exit",
+            "drawdown_before_exit",
+            "failure_shape",
+            "failure_root",
+        ]
+    ].sort_values(["holding_bucket", "return_pct", "buy_date"])
+
+    audit.to_csv(base_dir / "dragon_short_holding_audit.csv", index=False, encoding="utf-8-sig")
+
+    failure_summary = (
+        audit.groupby(["holding_bucket", "failure_root", "failure_shape"], dropna=False)
+        .agg(
+            trades=("buy_date", "count"),
+            avg_return=("return_pct", "mean"),
+            avg_mfe=("mfe_pct", "mean"),
+            avg_mae=("mae_pct", "mean"),
+        )
+        .reset_index()
+        .sort_values(["holding_bucket", "trades", "avg_return"], ascending=[True, False, True])
+    )
+    family_summary = (
+        audit.groupby(["holding_bucket", "entry_family"], dropna=False)
+        .agg(
+            trades=("buy_date", "count"),
+            win_rate=("return_pct", lambda s: float((s > 0).mean())),
+            avg_return=("return_pct", "mean"),
+            avg_mfe=("mfe_pct", "mean"),
+            avg_mae=("mae_pct", "mean"),
+        )
+        .reset_index()
+        .sort_values(["holding_bucket", "avg_return", "trades"], ascending=[True, True, False])
+    )
+
+    lines = [
+        "# Dragon Short Holding Review",
+        "",
+        "- Branch: `alpha_first_selective_veto`.",
+        "- Scope: only `00-05d` and `06-10d` trades.",
+        f"- audited short trades: `{int(len(audit))}`",
+        f"- `00-05d` avg_return: `{audit[audit['holding_bucket'] == '00-05d']['return_pct'].mean():.2%}`",
+        f"- `06-10d` avg_return: `{audit[audit['holding_bucket'] == '06-10d']['return_pct'].mean():.2%}`",
+        "",
+        "## Failure Root Summary",
+    ]
+    for _, row in failure_summary.iterrows():
+        lines.append(
+            f"- `{row['holding_bucket']} / {row['failure_root']} / {row['failure_shape']}`: trades `{int(row['trades'])}`, "
+            f"avg_return `{row['avg_return']:.2%}`, avg_mfe `{row['avg_mfe']:.2%}`, avg_mae `{row['avg_mae']:.2%}`"
+        )
+
+    worst_families = family_summary.groupby("holding_bucket", group_keys=False).head(5)
+    lines.extend(["", "## Weak Entry Families"])
+    for _, row in worst_families.iterrows():
+        lines.append(
+            f"- `{row['holding_bucket']} / {row['entry_family']}`: trades `{int(row['trades'])}`, win_rate `{row['win_rate']:.2%}`, "
+            f"avg_return `{row['avg_return']:.2%}`, avg_mfe `{row['avg_mfe']:.2%}`, avg_mae `{row['avg_mae']:.2%}`"
+        )
+
+    bridge_count = int(audit["is_predictive_bridge_chain"].sum())
+    deep_count = int(audit["is_deep_oversold_family"].sum())
+    post_sell_count = int(audit["is_post_sell_rebound_family"].sum())
+    entry_bad = int((audit["failure_root"] == "entry_bad").sum())
+    hold_bad = int((audit["failure_root"] == "hold_bad").sum())
+    exit_fast = int((audit["failure_root"] == "exit_too_fast").sum())
+    lines.extend(
+        [
+            "",
+            "## Quant Judgment",
+            f"- Deep-oversold short trades: `{deep_count}`; post-sell-rebound short trades: `{post_sell_count}`; bridge trades: `{bridge_count}`.",
+            f"- Root split: entry_bad `{entry_bad}`, hold_bad `{hold_bad}`, exit_too_fast `{exit_fast}`.",
+            "- The next experiment pack should prioritize the dominant drag family and separate bad-entry veto from early-exit extension.",
+        ]
+    )
+
+    (base_dir / "dragon_short_holding_review.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+
+if __name__ == "__main__":
+    main()

+ 222 - 0
research/dragon/v2/dragon_short_holding_experiments.py

@@ -0,0 +1,222 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pandas as pd
+
+from dragon_branch_configs import alpha_first_selective_veto_config
+from dragon_strategy import DragonRuleEngine
+from dragon_strategy_config import StrategyConfig
+
+
+def _load_indicator_snapshot(base_dir: Path) -> pd.DataFrame:
+    df = pd.read_csv(base_dir / "dragon_indicator_snapshot.csv", encoding="utf-8-sig")
+    df["date"] = pd.to_datetime(df["date"])
+    return df.set_index("date", drop=False)
+
+
+def _load_true_trade_events(base_dir: Path) -> pd.DataFrame:
+    return pd.read_csv(base_dir / "true_trade_events.csv", encoding="utf-8-sig")
+
+
+def _profit_factor(series: pd.Series) -> float:
+    gross_profit = series[series > 0].sum()
+    gross_loss = -series[series < 0].sum()
+    if gross_loss == 0:
+        return float("inf") if gross_profit > 0 else 0.0
+    return float(gross_profit / gross_loss)
+
+
+def _holding_bucket(days: int) -> str:
+    if days <= 5:
+        return "00-05d"
+    if days <= 10:
+        return "06-10d"
+    if days <= 20:
+        return "11-20d"
+    if days <= 40:
+        return "21-40d"
+    return "41d+"
+
+
+def _event_match(strategy_events: pd.DataFrame, workbook_events: pd.DataFrame, side: str) -> tuple[int, int]:
+    wb = set(workbook_events[(workbook_events["side"] == side) & (workbook_events["layer"] == "real_trade")]["date"])
+    st = set(strategy_events[(strategy_events["side"] == side) & (strategy_events["layer"] == "real_trade")]["date"])
+    return len(wb & st), len(st - wb)
+
+
+def _run_branch(
+    label: str,
+    config: StrategyConfig,
+    indicator_df: pd.DataFrame,
+    workbook_events: pd.DataFrame,
+    first_date: str,
+    last_date: str,
+) -> tuple[dict[str, object], pd.DataFrame]:
+    engine = DragonRuleEngine(config)
+    events, trades = engine.run(indicator_df)
+    events = events[(events["date"] >= first_date) & (events["date"] <= last_date)].copy()
+    trades = trades[
+        (trades["buy_date"] >= first_date)
+        & (trades["buy_date"] <= last_date)
+        & (trades["sell_date"] >= first_date)
+        & (trades["sell_date"] <= last_date)
+    ].copy()
+    trades["holding_bucket"] = trades["holding_days"].astype(int).map(_holding_bucket)
+    short = trades[trades["holding_bucket"].isin({"00-05d", "06-10d"})].copy()
+    glued_short = short[short["buy_reason"] == "glued_buy"].copy()
+
+    buy_overlap, buy_extra = _event_match(events, workbook_events, "BUY")
+    sell_overlap, sell_extra = _event_match(events, workbook_events, "SELL")
+
+    row = {
+        "experiment": label,
+        "trades": int(len(trades)),
+        "win_rate": float((trades["return_pct"] > 0).mean()) if not trades.empty else float("nan"),
+        "avg_return": float(trades["return_pct"].mean()) if not trades.empty else float("nan"),
+        "median_return": float(trades["return_pct"].median()) if not trades.empty else float("nan"),
+        "profit_factor": _profit_factor(trades["return_pct"]) if not trades.empty else float("nan"),
+        "real_buy_overlap": int(buy_overlap),
+        "real_buy_extra": int(buy_extra),
+        "real_sell_overlap": int(sell_overlap),
+        "real_sell_extra": int(sell_extra),
+        "short_trade_count": int(len(short)),
+        "short_avg_return": float(short["return_pct"].mean()) if not short.empty else float("nan"),
+        "short_00_05d_avg_return": float(short[short["holding_bucket"] == "00-05d"]["return_pct"].mean()),
+        "short_06_10d_avg_return": float(short[short["holding_bucket"] == "06-10d"]["return_pct"].mean()),
+        "glued_short_count": int(len(glued_short)),
+        "glued_short_avg_return": float(glued_short["return_pct"].mean()) if not glued_short.empty else float("nan"),
+        "post_sell_rebound_short_count": int(short[short["buy_reason"] == "post_sell_rebound_buy"].shape[0]),
+    }
+    diff = trades[trades["holding_bucket"].isin({"00-05d", "06-10d"})][
+        ["buy_date", "sell_date", "buy_reason", "sell_reason", "holding_days", "return_pct"]
+    ].copy()
+    diff["experiment"] = label
+    return row, diff
+
+
+def main() -> None:
+    base_dir = Path(__file__).resolve().parent
+    indicator_df = _load_indicator_snapshot(base_dir)
+    workbook_events = _load_true_trade_events(base_dir)
+    first_date = workbook_events["date"].min()
+    last_date = workbook_events["date"].max()
+
+    baseline = alpha_first_selective_veto_config()
+    experiments = [
+        ("baseline_alpha_first", baseline),
+        ("disable_post_sell_rebound_buy", baseline.with_updates(disabled_rules={"post_sell_rebound_buy"})),
+        (
+            "glued_veto_hot_positive_b1",
+            baseline.with_updates(
+                glued_selective_hot_c1_min=40.0,
+                glued_selective_hot_b1_min=0.10,
+            ),
+        ),
+        (
+            "glued_veto_low_weak_range",
+            baseline.with_updates(
+                glued_selective_low_c1_min=23.0,
+                glued_selective_low_c1_max=28.0,
+                glued_selective_low_b1_max=0.02,
+            ),
+        ),
+        (
+            "glued_veto_hot_and_low",
+            baseline.with_updates(
+                glued_selective_hot_c1_min=40.0,
+                glued_selective_hot_b1_min=0.10,
+                glued_selective_low_c1_min=23.0,
+                glued_selective_low_c1_max=28.0,
+                glued_selective_low_b1_max=0.02,
+            ),
+        ),
+        (
+            "glued_veto_hot_low_and_disable_post_sell",
+            baseline.with_updates(
+                disabled_rules={"post_sell_rebound_buy"},
+                glued_selective_hot_c1_min=40.0,
+                glued_selective_hot_b1_min=0.10,
+                glued_selective_low_c1_min=23.0,
+                glued_selective_low_c1_max=28.0,
+                glued_selective_low_b1_max=0.02,
+            ),
+        ),
+    ]
+
+    rows: list[dict[str, object]] = []
+    diffs: list[pd.DataFrame] = []
+    for label, config in experiments:
+        row, diff = _run_branch(label, config, indicator_df, workbook_events, first_date, last_date)
+        rows.append(row)
+        diffs.append(diff)
+
+    result_df = pd.DataFrame(rows)
+    baseline_row = result_df[result_df["experiment"] == "baseline_alpha_first"].iloc[0]
+    for col in [
+        "trades",
+        "win_rate",
+        "avg_return",
+        "median_return",
+        "profit_factor",
+        "real_buy_overlap",
+        "real_sell_overlap",
+        "short_trade_count",
+        "short_avg_return",
+        "short_00_05d_avg_return",
+        "short_06_10d_avg_return",
+        "glued_short_count",
+        "glued_short_avg_return",
+        "post_sell_rebound_short_count",
+    ]:
+        result_df[f"delta_{col}"] = result_df[col] - baseline_row[col]
+
+    diff_df = pd.concat(diffs, ignore_index=True)
+    result_df.to_csv(base_dir / "dragon_short_holding_experiments.csv", index=False, encoding="utf-8-sig")
+    diff_df.to_csv(base_dir / "dragon_short_holding_experiment_trades.csv", index=False, encoding="utf-8-sig")
+
+    lines = [
+        "# Dragon Short Holding Experiments",
+        "",
+        "- Baseline branch: `alpha_first_selective_veto`.",
+        "- Goal: reduce the main short-holding drag with the smallest possible extra complexity.",
+        "",
+        "## Summary",
+    ]
+    for _, row in result_df.iterrows():
+        lines.append(
+            f"- `{row['experiment']}`: trades `{int(row['trades'])}`, avg_return `{row['avg_return']:.2%}`, "
+            f"profit_factor `{row['profit_factor']:.2f}`, short_avg_return `{row['short_avg_return']:.2%}`, "
+            f"`00-05d` `{row['short_00_05d_avg_return']:.2%}`, `06-10d` `{row['short_06_10d_avg_return']:.2%}`, "
+            f"real BUY / SELL `{int(row['real_buy_overlap'])}/{int(row['real_sell_overlap'])}`"
+        )
+
+    lines.extend(["", "## Delta Vs Alpha-First Baseline"])
+    for _, row in result_df[result_df["experiment"] != "baseline_alpha_first"].iterrows():
+        lines.append(
+            f"- `{row['experiment']}`: delta_avg_return `{row['delta_avg_return']:.2%}`, "
+            f"delta_profit_factor `{row['delta_profit_factor']:.2f}`, delta_short_avg_return `{row['delta_short_avg_return']:.2%}`, "
+            f"delta_glued_short_avg_return `{row['delta_glued_short_avg_return']:.2%}`, "
+            f"real BUY / SELL `{int(row['real_buy_overlap'])}/{int(row['real_sell_overlap'])}`"
+        )
+
+    best = result_df[result_df["experiment"] != "baseline_alpha_first"].sort_values(
+        ["avg_return", "profit_factor"], ascending=[False, False]
+    ).head(1)
+    if not best.empty:
+        row = best.iloc[0]
+        lines.extend(
+            [
+                "",
+                "## Quant Judgment",
+                f"- Best branch in this pack: `{row['experiment']}` with avg_return `{row['avg_return']:.2%}` and profit_factor `{row['profit_factor']:.2f}`.",
+                "- Compare the winning branch to the audit: if glued short trades fall materially while overlap loss stays controlled, the next optimization should stay on the glued-entry side.",
+                "- If disabling `post_sell_rebound_buy` contributes little, that family is secondary for this stage.",
+            ]
+        )
+
+    (base_dir / "dragon_short_holding_experiments.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+
+if __name__ == "__main__":
+    main()

+ 109 - 0
research/dragon/v2/dragon_short_holding_family_pressure.py

@@ -0,0 +1,109 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pandas as pd
+
+
+def _load_csv(base_dir: Path, name: str) -> pd.DataFrame:
+    return pd.read_csv(base_dir / name, encoding="utf-8-sig")
+
+
+def _profit_factor(series: pd.Series) -> float:
+    gross_profit = series[series > 0].sum()
+    gross_loss = -series[series < 0].sum()
+    if gross_loss == 0:
+        return float("inf") if gross_profit > 0 else 0.0
+    return float(gross_profit / gross_loss)
+
+
+def _summarize(df: pd.DataFrame, group_cols: list[str]) -> pd.DataFrame:
+    rows: list[dict[str, object]] = []
+    for key, group in df.groupby(group_cols, dropna=False):
+        if not isinstance(key, tuple):
+            key = (key,)
+        row = {col: val for col, val in zip(group_cols, key)}
+        row["trades"] = int(len(group))
+        row["loss_trades"] = int((group["return_pct"] < 0).sum())
+        row["win_rate"] = float((group["return_pct"] > 0).mean())
+        row["avg_return"] = float(group["return_pct"].mean())
+        row["avg_mfe"] = float(group["mfe_pct"].mean())
+        row["avg_mae"] = float(group["mae_pct"].mean())
+        row["avg_buy_plus_3d_return"] = float(group["buy_plus_3d_return"].mean())
+        row["avg_sell_plus_3d_followthrough"] = float(group["sell_plus_3d_followthrough"].mean())
+        row["profit_factor"] = _profit_factor(group["return_pct"])
+        row["drag_score"] = float(len(group) * abs(min(float(group["return_pct"].mean()), 0.0)))
+        rows.append(row)
+    return pd.DataFrame(rows)
+
+
+def main() -> None:
+    base_dir = Path(__file__).resolve().parent
+    audit = _load_csv(base_dir, "dragon_short_holding_audit.csv")
+
+    group_specs = [
+        ("entry_family", ["holding_bucket", "entry_family"]),
+        ("buy_reason", ["holding_bucket", "buy_reason"]),
+        ("sell_reason", ["holding_bucket", "sell_reason"]),
+        ("market_state", ["holding_bucket", "market_state_layer"]),
+        ("entry_gate", ["holding_bucket", "entry_qualification_layer"]),
+        ("path_combo", ["holding_bucket", "entry_family", "sell_reason"]),
+    ]
+
+    frames = []
+    for group_type, cols in group_specs:
+        frames.append(_summarize(audit, cols).assign(group_type=group_type))
+    pressure = pd.concat(frames, ignore_index=True, sort=False)
+    pressure = pressure.sort_values(["drag_score", "trades"], ascending=[False, False])
+    pressure.to_csv(base_dir / "dragon_short_holding_family_pressure.csv", index=False, encoding="utf-8-sig")
+
+    top_entry = pressure[pressure["group_type"] == "entry_family"].sort_values("drag_score", ascending=False).head(8)
+    top_buy = pressure[pressure["group_type"] == "buy_reason"].sort_values("drag_score", ascending=False).head(8)
+    top_path = pressure[pressure["group_type"] == "path_combo"].sort_values("drag_score", ascending=False).head(8)
+
+    lines = [
+        "# Dragon Short Holding Family Review",
+        "",
+        "- Scope: `alpha_first_selective_veto` short trades only.",
+        "- Drag score definition: `trades * abs(min(avg_return, 0))`.",
+        "",
+        "## Top Entry-Family Drag",
+    ]
+    for _, row in top_entry.iterrows():
+        lines.append(
+            f"- `{row['holding_bucket']} / {row['entry_family']}`: trades `{int(row['trades'])}`, loss_trades `{int(row['loss_trades'])}`, "
+            f"avg_return `{row['avg_return']:.2%}`, drag_score `{row['drag_score']:.4f}`"
+        )
+
+    lines.extend(["", "## Top Buy-Reason Drag"])
+    for _, row in top_buy.iterrows():
+        lines.append(
+            f"- `{row['holding_bucket']} / {row['buy_reason']}`: trades `{int(row['trades'])}`, loss_trades `{int(row['loss_trades'])}`, "
+            f"avg_return `{row['avg_return']:.2%}`, avg_buy_plus_3d `{row['avg_buy_plus_3d_return']:.2%}`, drag_score `{row['drag_score']:.4f}`"
+        )
+
+    lines.extend(["", "## Top Path Drag"])
+    for _, row in top_path.iterrows():
+        lines.append(
+            f"- `{row['holding_bucket']} / {row['entry_family']} -> {row['sell_reason']}`: trades `{int(row['trades'])}`, "
+            f"avg_return `{row['avg_return']:.2%}`, avg_sell_plus_3d `{row['avg_sell_plus_3d_followthrough']:.2%}`, drag_score `{row['drag_score']:.4f}`"
+        )
+
+    lead_entry = top_entry.iloc[0] if not top_entry.empty else None
+    lead_path = top_path.iloc[0] if not top_path.empty else None
+    lines.extend(["", "## Quant Judgment"])
+    if lead_entry is not None:
+        lines.append(
+            f"- Lead short-holding drag family: `{lead_entry['holding_bucket']} / {lead_entry['entry_family']}` with drag_score `{lead_entry['drag_score']:.4f}`."
+        )
+    if lead_path is not None:
+        lines.append(
+            f"- Lead path-level drag: `{lead_path['holding_bucket']} / {lead_path['entry_family']} -> {lead_path['sell_reason']}`."
+        )
+    lines.append("- The next experiment pack should attack the highest drag family first and decide whether the issue is bad entry selection or premature exit handling.")
+
+    (base_dir / "dragon_short_holding_family_review.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+
+if __name__ == "__main__":
+    main()

+ 122 - 0
research/dragon/v2/dragon_short_holding_master_review.py

@@ -0,0 +1,122 @@
+from __future__ import annotations
+
+import json
+from dataclasses import asdict
+from pathlib import Path
+
+import pandas as pd
+
+from dragon_branch_configs import alpha_first_selective_veto_config
+
+
+def _load_csv(base_dir: Path, name: str) -> pd.DataFrame:
+    return pd.read_csv(base_dir / name, encoding="utf-8-sig")
+
+
+def _format_pct(value: float) -> str:
+    if pd.isna(value):
+        return "NA"
+    if value == float("inf"):
+        return "inf"
+    return f"{value:.2%}"
+
+
+def _format_num(value: float) -> str:
+    if pd.isna(value):
+        return "NA"
+    if value == float("inf"):
+        return "inf"
+    return f"{value:.2f}"
+
+
+def main() -> None:
+    base_dir = Path(__file__).resolve().parent
+    audit = _load_csv(base_dir, "dragon_short_holding_audit.csv")
+    pressure = _load_csv(base_dir, "dragon_short_holding_family_pressure.csv")
+    experiments = _load_csv(base_dir, "dragon_short_holding_experiments.csv")
+
+    best = experiments[experiments["experiment"] != "baseline_alpha_first"].sort_values(
+        ["avg_return", "profit_factor"], ascending=[False, False]
+    ).iloc[0]
+
+    winner_config = alpha_first_selective_veto_config().with_updates(
+        glued_selective_hot_c1_min=40.0,
+        glued_selective_hot_b1_min=0.10,
+        glued_selective_low_c1_min=23.0,
+        glued_selective_low_c1_max=28.0,
+        glued_selective_low_b1_max=0.02,
+    )
+    snapshot = asdict(winner_config)
+    snapshot["disabled_rules"] = sorted(winner_config.disabled_rules)
+    (base_dir / "dragon_short_holding_candidate_config.json").write_text(
+        json.dumps(snapshot, indent=2, ensure_ascii=False) + "\n",
+        encoding="utf-8",
+    )
+
+    root_summary = (
+        audit.groupby("failure_root")
+        .agg(trades=("buy_date", "count"), avg_return=("return_pct", "mean"))
+        .reset_index()
+        .sort_values("trades", ascending=False)
+    )
+    top_entry_drag = pressure[pressure["group_type"] == "entry_family"].sort_values("drag_score", ascending=False).head(5)
+    top_path_drag = pressure[pressure["group_type"] == "path_combo"].sort_values("drag_score", ascending=False).head(5)
+
+    lines = [
+        "# Dragon Short Holding Master Review",
+        "",
+        "- Branch under audit: `alpha_first_selective_veto`.",
+        "- Goal: identify the dominant short-holding drag and the next narrow optimization target.",
+        "",
+        "## Audit Conclusions",
+        f"- audited short trades: `{int(len(audit))}`",
+    ]
+    for _, row in root_summary.iterrows():
+        lines.append(
+            f"- failure_root `{row['failure_root']}`: trades `{int(row['trades'])}`, avg_return `{_format_pct(float(row['avg_return']))}`"
+        )
+
+    lines.extend(["", "## Lead Drag Families"])
+    for _, row in top_entry_drag.iterrows():
+        lines.append(
+            f"- `{row['holding_bucket']} / {row['entry_family']}`: trades `{int(row['trades'])}`, "
+            f"avg_return `{_format_pct(float(row['avg_return']))}`, drag_score `{row['drag_score']:.4f}`"
+        )
+
+    lines.extend(["", "## Lead Drag Paths"])
+    for _, row in top_path_drag.iterrows():
+        lines.append(
+            f"- `{row['holding_bucket']} / {row['entry_family']} -> {row['sell_reason']}`: trades `{int(row['trades'])}`, "
+            f"avg_return `{_format_pct(float(row['avg_return']))}`, drag_score `{row['drag_score']:.4f}`"
+        )
+
+    lines.extend(
+        [
+            "",
+            "## Experiment Winner",
+            f"- best branch: `{best['experiment']}`",
+            f"- trades: `{int(best['trades'])}`",
+            f"- avg_return: `{_format_pct(float(best['avg_return']))}`",
+            f"- profit_factor: `{_format_num(float(best['profit_factor']))}`",
+            f"- short_avg_return: `{_format_pct(float(best['short_avg_return']))}`",
+            f"- `00-05d`: `{_format_pct(float(best['short_00_05d_avg_return']))}`",
+            f"- `06-10d`: `{_format_pct(float(best['short_06_10d_avg_return']))}`",
+            f"- real BUY / SELL overlap: `{int(best['real_buy_overlap'])}/{int(best['real_sell_overlap'])}`",
+            "",
+            "## Interpretation",
+            "- `post_sell_rebound_buy` is not the main short-holding problem in this pack; disabling it hurts or adds little value.",
+            "- The dominant short-holding drag is `glued_buy`, especially in mid-regime short exits.",
+            "- The winning branch confirms that narrow glued-entry veto is more valuable than attacking `post_sell_rebound_buy` first.",
+            "- The most useful next alpha-first direction is now a glued-focused selective-veto branch, not a post-sell-rebound branch.",
+            "",
+            "## Candidate Config",
+            "- Snapshot file: `dragon_short_holding_candidate_config.json`.",
+            "- This candidate should remain alpha-first research only until a branch-level governance decision upgrades it.",
+        ]
+    )
+
+    (base_dir / "dragon_short_holding_master_review.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+
+if __name__ == "__main__":
+    main()

+ 148 - 0
research/dragon/v2/dragon_stability_report.py

@@ -0,0 +1,148 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pandas as pd
+
+
+def _load_csv(base_dir: Path, name: str) -> pd.DataFrame:
+    return pd.read_csv(base_dir / name, encoding="utf-8-sig")
+
+
+def main() -> None:
+    base_dir = Path(__file__).resolve().parent
+    group_summary = _load_csv(base_dir, "dragon_trade_group_summary.csv")
+    ablation = _load_csv(base_dir, "dragon_rule_ablation.csv")
+    sensitivity = _load_csv(base_dir, "dragon_threshold_sensitivity_summary.csv")
+    deep_oversold = _load_csv(base_dir, "dragon_deep_oversold_subtype_summary.csv")
+    deep_oversold_exp = _load_csv(base_dir, "dragon_deep_oversold_experiments.csv")
+    predictive_exp = _load_csv(base_dir, "dragon_predictive_break_experiments.csv")
+
+    baseline = ablation[ablation["experiment"] == "baseline"].iloc[0]
+    holding = group_summary[group_summary["group_type"] == "holding_bucket"].copy()
+    sample_split = group_summary[group_summary["group_type"] == "sample_split"].copy()
+    regime = group_summary[group_summary["group_type"] == "market_state_layer"].copy()
+
+    protected = ablation[
+        (ablation["experiment"] != "baseline")
+        & (ablation["real_buy_overlap"] == baseline["real_buy_overlap"])
+        & (ablation["real_sell_overlap"] == baseline["real_sell_overlap"])
+    ].copy()
+    fragile = sensitivity[sensitivity["stable_real_alignment"] == False].copy()
+    robust = sensitivity[sensitivity["stable_real_alignment"] == True].copy()
+
+    lines = [
+        "# Dragon Stage 3 Stability Report",
+        "",
+        "## Baseline",
+        f"- trades: `{int(baseline['trades'])}`",
+        f"- win_rate: `{baseline['win_rate']:.2%}`",
+        f"- avg_return: `{baseline['avg_return']:.2%}`",
+        f"- profit_factor: `{baseline['profit_factor']:.2f}`",
+        f"- real BUY overlap: `{int(baseline['real_buy_overlap'])}`",
+        f"- real SELL overlap: `{int(baseline['real_sell_overlap'])}`",
+        "",
+        "## Holding Structure",
+    ]
+    for _, row in holding.sort_values("holding_bucket").iterrows():
+        lines.append(
+            f"- `{row['holding_bucket']}`: trades `{int(row['trades'])}`, win_rate `{row['win_rate']:.2%}`, "
+            f"avg_return `{row['avg_return']:.2%}`, avg_mfe `{row['avg_mfe_pct']:.2%}`, avg_mae `{row['avg_mae_pct']:.2%}`"
+        )
+
+    lines.extend(["", "## Sample Split"])
+    for _, row in sample_split.sort_values("sample_split").iterrows():
+        lines.append(
+            f"- `{row['sample_split']}`: trades `{int(row['trades'])}`, avg_return `{row['avg_return']:.2%}`, "
+            f"profit_factor `{row['profit_factor']:.2f}`"
+        )
+
+    lines.extend(["", "## Regime Structure"])
+    for _, row in regime.sort_values("trades", ascending=False).iterrows():
+        key = row["market_state_layer"]
+        lines.append(
+            f"- `{key}`: trades `{int(row['trades'])}`, avg_return `{row['avg_return']:.2%}`, profit_factor `{row['profit_factor']:.2f}`"
+        )
+
+    lines.extend(["", "## Rule Ablation"])
+    if protected.empty:
+        lines.append("- No protected experiment preserved full real-trade alignment.")
+    else:
+        protected_sorted = protected.sort_values("delta_avg_return", ascending=False)
+        for _, row in protected_sorted.head(8).iterrows():
+            lines.append(
+                f"- `{row['experiment']}`: delta_avg_return `{row['delta_avg_return']:.2%}`, "
+                f"delta_profit_factor `{row['delta_profit_factor']:.2f}`, delta_aux_sell_overlap `{int(row['delta_aux_sell_overlap'])}`"
+            )
+
+    best_degrade = ablation[(ablation["experiment"] != "baseline")].sort_values("delta_avg_return", ascending=False).head(6)
+    lines.extend(["", "## Candidate Pressure Points"])
+    for _, row in best_degrade.iterrows():
+        lines.append(
+            f"- `{row['experiment']}`: delta_avg_return `{row['delta_avg_return']:.2%}`, "
+            f"real BUY `{int(row['real_buy_overlap'])}`, real SELL `{int(row['real_sell_overlap'])}`"
+        )
+
+    lines.extend(["", "## Deep Oversold Subtypes"])
+    for _, row in deep_oversold.iterrows():
+        lines.append(
+            f"- `{row['entry_subtype']}`: trades `{int(row['trades'])}`, win_rate `{row['win_rate']:.2%}`, "
+            f"avg_return `{row['avg_return']:.2%}`, fast_failures `{int(row['fast_failures'])}`"
+        )
+
+    lines.extend(["", "## Focused Deep Oversold Experiments"])
+    for _, row in deep_oversold_exp.iterrows():
+        lines.append(
+            f"- `{row['experiment']}`: deep trades `{int(row['deep_oversold_trade_count'])}`, "
+            f"delta_avg_return `{row['delta_avg_return']:.2%}`, real BUY `{int(row['real_buy_overlap'])}`, real SELL `{int(row['real_sell_overlap'])}`"
+        )
+
+    lines.extend(["", "## Predictive Break Experiments"])
+    for _, row in predictive_exp.iterrows():
+        lines.append(
+            f"- `{row['experiment']}`: predictive trades `{int(row['predictive_trade_count'])}`, "
+            f"delta_avg_return `{row['delta_avg_return']:.2%}`, real BUY `{int(row['real_buy_overlap'])}`, real SELL `{int(row['real_sell_overlap'])}`"
+        )
+
+    lines.extend(["", "## Threshold Sensitivity"])
+    for _, row in sensitivity.iterrows():
+        lines.append(
+            f"- `{row['parameter']}`: stable_real_alignment `{bool(row['stable_real_alignment'])}`, "
+            f"avg_return_range `{row['avg_return_range']:.2%}`, profit_factor_range `{row['profit_factor_range']:.2f}`"
+        )
+
+    lines.extend(["", "## Fragile Parameters"])
+    if fragile.empty:
+        lines.append("- None in this pack.")
+    else:
+        for _, row in fragile.iterrows():
+            lines.append(
+                f"- `{row['parameter']}`: minimum real BUY overlap `{int(row['real_buy_overlap_min'])}`, minimum real SELL overlap `{int(row['real_sell_overlap_min'])}`"
+            )
+
+    lines.extend(["", "## Robust Parameters"])
+    for _, row in robust.sort_values("avg_return_range").head(6).iterrows():
+        lines.append(
+            f"- `{row['parameter']}`: avg_return_range `{row['avg_return_range']:.2%}`, aux_sell_overlap_range `{int(row['aux_sell_overlap_range'])}`"
+        )
+
+    lines.extend(
+        [
+            "",
+            "## Quant Judgment",
+            "- `glued_buy` remains the structural backbone. Disabling it destroys alignment and does not produce a credible upgrade path.",
+            "- `non_glued_positive_expansion_buy` is redundant in the current sample window: its aligned dates are now absorbed by `dual_gold_resonance_buy`, so it should not be treated as an independent alpha family.",
+            "- `deep_oversold_rebound_buy` is the clearest weak entry family: removing it improves average return materially, but at a significant alignment cost.",
+            "- Within the deep-oversold family, the weakest subtypes are `positive_b1_rebound` and `shallow_false_start`; this is now a subtype redesign problem, not a whole-rule deletion problem.",
+            "- Safe default improvement was limited to rerouting 4 weak subtype dates to same-day fallback rules; blocking the remaining weak subtypes raises return modestly but degrades real-trade overlap too much for the current objective.",
+            "- `knife_take_profit_2_glued` did not improve results when disabled in rerun form, which implies the current drag is partly replaced by alternative same-cycle exits rather than removed cleanly.",
+            "- `predictive_b1_break` is now effectively a frozen bridge rule: loosening it worsens results, tightening it marginally helps but breaks workbook alignment.",
+            "- Auxiliary sell compression parameters are relatively robust; major remaining optimization leverage is not in the aux layer.",
+        ]
+    )
+
+    (base_dir / "dragon_stage3_stability_report.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+
+if __name__ == "__main__":
+    main()

+ 59 - 0
research/dragon/v2/dragon_state_machine.py

@@ -0,0 +1,59 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Iterable
+
+
+@dataclass(frozen=True)
+class CandidateSignal:
+    date: str
+    side: str
+    source: str
+    reason: str
+
+
+@dataclass(frozen=True)
+class LayeredSignal:
+    date: str
+    side: str
+    layer: str
+    source: str
+    reason: str
+
+
+class DragonStateMachine:
+    """
+    Baseline signal splitter.
+
+    This intentionally does not encode the full Dragon trading rules yet.
+    It only turns upstream candidate entry/exit signals into:
+    - real trades
+    - auxiliary bullish/bearish signals
+    """
+
+    def split(self, candidates: Iterable[CandidateSignal]) -> list[LayeredSignal]:
+        state = "flat"
+        layered: list[LayeredSignal] = []
+        for signal in candidates:
+            layer = "aux_signal"
+            reason = signal.reason
+            if state == "flat" and signal.side == "BUY":
+                layer = "real_trade"
+                state = "long"
+            elif state == "long" and signal.side == "SELL":
+                layer = "real_trade"
+                state = "flat"
+            elif state == "flat" and signal.side == "SELL":
+                reason = "bearish_signal_after_exit"
+            elif state == "long" and signal.side == "BUY":
+                reason = "bullish_signal_while_holding"
+            layered.append(
+                LayeredSignal(
+                    date=signal.date,
+                    side=signal.side,
+                    layer=layer,
+                    source=signal.source,
+                    reason=reason,
+                )
+            )
+        return layered

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 1297 - 0
research/dragon/v2/dragon_strategy.py


+ 123 - 0
research/dragon/v2/dragon_strategy_config.py

@@ -0,0 +1,123 @@
+from __future__ import annotations
+
+from dataclasses import dataclass, field, replace
+
+
+@dataclass(frozen=True)
+class StrategyConfig:
+    disabled_rules: frozenset[str] = field(default_factory=frozenset)
+
+    post_exit_confirmation_window_days: int = 10
+    aux_sell_same_side_once_per_cycle: bool = True
+    aux_sell_duplicate_cooldown_days: int = 5
+    aux_sell_high_zone_kdj_only_block_c1: float = 85.0
+    aux_sell_high_zone_kdj_only_block_b1: float = -0.02
+    aux_sell_high_zone_warning_c1: float = 80.0
+    aux_sell_strong_break_c1: float = 60.0
+    aux_sell_strong_break_b1: float = -0.05
+    aux_sell_stronger_c1_delta: float = 8.0
+    aux_sell_stronger_b1_delta: float = 0.05
+    aux_sell_high_zone_rearm_c1_delta: float = 2.0
+
+    state_crash_followthrough_window_days: int = 5
+    state_crash_followthrough_repeat_cooldown_days: int = 4
+    state_crash_followthrough_c1_max: float = 80.0
+    state_crash_followthrough_a1_max: float = 0.01
+    state_crash_followthrough_b1_max: float = -0.15
+
+    glued_high_weak_rebound_high_c1: float = 68.0
+    glued_high_weak_rebound_high_b1: float = -0.08
+    glued_high_weak_rebound_mid_c1: float = 50.0
+    glued_high_weak_rebound_mid_b1: float = -0.15
+    glued_high_weak_rebound_ql_c1_low: float = 35.0
+    glued_high_weak_rebound_ql_c1_high: float = 55.0
+    glued_high_weak_rebound_ql_b1: float = -0.06
+    glued_high_weak_rebound_ql_a1: float = -0.013
+    glued_selective_hot_c1_min: float = 0.0
+    glued_selective_hot_c1_max: float = 999.0
+    glued_selective_hot_b1_min: float = 1.0
+    glued_selective_low_c1_min: float = 0.0
+    glued_selective_low_c1_max: float = 0.0
+    glued_selective_low_b1_max: float = -999.0
+
+    deep_oversold_filter1_c1_low: float = 13.0
+    deep_oversold_filter1_c1_high: float = 15.0
+    deep_oversold_filter1_a1_min: float = -0.04
+    deep_oversold_filter1_b1_max: float = -0.08
+    deep_oversold_filter2_c1_low: float = 13.0
+    deep_oversold_filter2_c1_high: float = 14.5
+    deep_oversold_filter2_a1_min: float = -0.04
+    deep_oversold_filter2_b1_min: float = -0.06
+    deep_oversold_entry_c1_max: float = 16.0
+    deep_oversold_entry_a1_min: float = -0.09
+    deep_oversold_entry_b1_min: float = -0.10
+    deep_oversold_shallow_ql_fallback: bool = True
+    deep_oversold_positive_b1_fallback_a1_min: float = -0.02
+    deep_oversold_block_positive_b1_rebound: bool = False
+    deep_oversold_block_shallow_false_start_without_ql: bool = False
+    deep_oversold_confirm_weak_with_ql: bool = False
+    deep_oversold_confirm_window_bars: int = 2
+    deep_oversold_selective_positive_b1_c1_max: float = 0.0
+    deep_oversold_selective_shallow_c1_min: float = 0.0
+    deep_oversold_selective_shallow_b1_min: float = 1.0
+    deep_oversold_selective_mixed_c1_max: float = 0.0
+    deep_oversold_selective_mixed_require_no_ql: bool = False
+
+    oversold_recovery_c1_low: float = 18.0
+    oversold_recovery_c1_high: float = 22.0
+    oversold_recovery_a1_min: float = -0.03
+    oversold_recovery_a1_max: float = 0.0
+    oversold_recovery_b1_min: float = -0.02
+
+    oversold_reversal_after_ql_block_c1_low: float = 23.0
+    oversold_reversal_after_ql_block_c1_high: float = 26.0
+    oversold_reversal_after_ql_block_b1_min: float = -0.12
+    oversold_reversal_after_ql_block_a1_min: float = -0.035
+    oversold_reversal_after_ql_entry_c1_low: float = 20.0
+    oversold_reversal_after_ql_entry_c1_high: float = 26.0
+    oversold_reversal_after_ql_entry_a1_min: float = -0.04
+    oversold_reversal_after_ql_entry_a1_max: float = 0.0
+    oversold_reversal_after_ql_entry_b1_min: float = -0.22
+    oversold_reversal_after_ql_entry_b1_max: float = 0.0
+
+    post_sell_rebound_block_high_c1: float = 22.0
+    post_sell_rebound_block_high_a1_min: float = -0.035
+    post_sell_rebound_block_high_b1_max: float = -0.07
+    post_sell_rebound_block_low_c1: float = 15.0
+    post_sell_rebound_block_low_a1_min: float = -0.04
+    post_sell_rebound_block_low_b1_max: float = -0.095
+    post_sell_rebound_entry1_c1_low: float = 18.0
+    post_sell_rebound_entry1_c1_high: float = 30.0
+    post_sell_rebound_entry1_a1_min: float = -0.045
+    post_sell_rebound_entry1_a1_max: float = 0.0
+    post_sell_rebound_entry1_b1_low: float = -0.09
+    post_sell_rebound_entry1_b1_high: float = -0.04
+    post_sell_rebound_entry2_c1_high: float = 19.0
+    post_sell_rebound_entry2_a1_min: float = -0.04
+    post_sell_rebound_entry2_a1_max: float = 0.0
+    post_sell_rebound_entry2_b1_low: float = -0.13
+    post_sell_rebound_entry2_b1_high: float = -0.09
+
+    predictive_b1_break_short_holding_days_max: int = 2
+    predictive_b1_break_short_a1_min: float = -0.02
+    predictive_b1_break_short_a1_max: float = 0.0
+    predictive_b1_break_short_b1_max: float = -0.13
+    predictive_b1_break_short_c1_low: float = 50.0
+    predictive_b1_break_short_c1_high: float = 70.0
+    predictive_b1_break_long_holding_days_min: int = 40
+    predictive_b1_break_long_max_c1: float = 80.0
+    predictive_b1_break_long_max_a1: float = 0.15
+    predictive_b1_break_long_max_b1: float = 0.30
+    predictive_b1_break_long_ql_days_max: int = 7
+    predictive_b1_break_long_a1_min: float = -0.02
+    predictive_b1_break_long_a1_max: float = 0.0
+    predictive_b1_break_long_b1_max: float = -0.12
+    predictive_b1_break_long_c1_low: float = 60.0
+    predictive_b1_break_long_c1_high: float = 65.0
+
+    enable_knife_take_profit_2_wait_ql: bool = True
+
+    def with_updates(self, **kwargs) -> "StrategyConfig":
+        if "disabled_rules" in kwargs and not isinstance(kwargs["disabled_rules"], frozenset):
+            kwargs["disabled_rules"] = frozenset(kwargs["disabled_rules"])
+        return replace(self, **kwargs)

+ 147 - 0
research/dragon/v2/dragon_strategy_overview.py

@@ -0,0 +1,147 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pandas as pd
+
+from dragon_branch_configs import (
+    alpha_first_glued_refined_hot_cap_config,
+    alpha_first_selective_veto_config,
+    workbook_preserving_config,
+)
+from dragon_shared import END_DATE, START_DATE, evaluation_years, format_num as _format_num, format_pct as _format_pct, profit_factor
+from dragon_strategy import DragonRuleEngine
+
+
+def _load_indicator_snapshot(base_dir: Path) -> pd.DataFrame:
+    df = pd.read_csv(base_dir / "dragon_indicator_snapshot.csv", encoding="utf-8-sig")
+    df["date"] = pd.to_datetime(df["date"])
+    return df.set_index("date", drop=False)
+
+
+def _event_overlap(events: pd.DataFrame, workbook: pd.DataFrame, side: str) -> tuple[int, int]:
+    wb = set(workbook[(workbook["side"] == side) & (workbook["layer"] == "real_trade")]["date"])
+    st = set(events[(events["side"] == side) & (events["layer"] == "real_trade")]["date"])
+    return len(wb & st), len(st)
+
+
+def _run_branch(name: str, config, indicator_df: pd.DataFrame, workbook_events: pd.DataFrame) -> dict[str, object]:
+    engine = DragonRuleEngine(config=config)
+    events, trades = engine.run(indicator_df)
+    events = events[(events["date"] >= START_DATE) & (events["date"] <= END_DATE)].copy()
+    trades = trades[
+        (trades["buy_date"] >= START_DATE)
+        & (trades["buy_date"] <= END_DATE)
+        & (trades["sell_date"] >= START_DATE)
+        & (trades["sell_date"] <= END_DATE)
+    ].copy()
+
+    returns = trades["return_pct"].astype(float)
+    compounded_return = float((1.0 + returns).prod() - 1.0) if not trades.empty else float("nan")
+    years = evaluation_years(START_DATE, END_DATE)
+    cagr = float((1.0 + compounded_return) ** (1.0 / years) - 1.0) if pd.notna(compounded_return) else float("nan")
+
+    buy_overlap, buy_strategy_total = _event_overlap(events, workbook_events, "BUY")
+    sell_overlap, sell_strategy_total = _event_overlap(events, workbook_events, "SELL")
+
+    short_00_05d = float(trades[trades["holding_days"] <= 5]["return_pct"].mean())
+    short_06_10d = float(trades[(trades["holding_days"] > 5) & (trades["holding_days"] <= 10)]["return_pct"].mean())
+
+    return {
+        "branch": name,
+        "trades": int(len(trades)),
+        "win_rate": float((returns > 0).mean()) if not trades.empty else float("nan"),
+        "avg_return": float(returns.mean()) if not trades.empty else float("nan"),
+        "median_return": float(returns.median()) if not trades.empty else float("nan"),
+        "profit_factor": profit_factor(returns) if not trades.empty else float("nan"),
+        "compounded_return": compounded_return,
+        "cagr": cagr,
+        "real_buy_overlap": int(buy_overlap),
+        "real_sell_overlap": int(sell_overlap),
+        "real_buy_strategy_total": int(buy_strategy_total),
+        "real_sell_strategy_total": int(sell_strategy_total),
+        "short_00_05d_avg_return": short_00_05d,
+        "short_06_10d_avg_return": short_06_10d,
+    }
+
+
+def main() -> None:
+    base_dir = Path(__file__).resolve().parent
+    indicator_df = _load_indicator_snapshot(base_dir)
+    workbook_events = pd.read_csv(base_dir / "true_trade_events.csv", encoding="utf-8-sig")
+
+    rows = [
+        _run_branch("workbook_preserving", workbook_preserving_config(), indicator_df, workbook_events),
+        _run_branch("alpha_first_selective_veto", alpha_first_selective_veto_config(), indicator_df, workbook_events),
+        _run_branch("alpha_first_glued_refined_hot_cap", alpha_first_glued_refined_hot_cap_config(), indicator_df, workbook_events),
+    ]
+    df = pd.DataFrame(rows)
+    df.to_csv(base_dir / "dragon_strategy_overview.csv", index=False, encoding="utf-8-sig")
+
+    role_map = {
+        "workbook_preserving": "official reconstruction baseline",
+        "alpha_first_selective_veto": "current formal alpha branch",
+        "alpha_first_glued_refined_hot_cap": "leading high-alpha candidate",
+    }
+    style_map = {
+        "workbook_preserving": "most like workbook",
+        "alpha_first_selective_veto": "balanced",
+        "alpha_first_glued_refined_hot_cap": "most aggressive",
+    }
+    suit_map = {
+        "workbook_preserving": "适合优先保留原表结构",
+        "alpha_first_selective_veto": "适合兼顾原表和收益质量",
+        "alpha_first_glued_refined_hot_cap": "适合更偏实战 alpha",
+    }
+
+    lines = [
+        "# Dragon Strategy Overview",
+        "",
+        f"- Evaluation window: `{START_DATE}` to `{END_DATE}`.",
+        "- Return metrics use compounded trade returns without extra slippage/fee adjustments.",
+        "",
+        "## Headline Table",
+    ]
+    for _, row in df.iterrows():
+        branch = row["branch"]
+        lines.extend(
+            [
+                f"### {branch}",
+                f"- Role: `{role_map[branch]}`",
+                f"- Style: `{style_map[branch]}`",
+                f"- Trades: `{int(row['trades'])}`",
+                f"- Win rate: `{_format_pct(float(row['win_rate']))}`",
+                f"- Avg / Median trade: `{_format_pct(float(row['avg_return']))}` / `{_format_pct(float(row['median_return']))}`",
+                f"- Profit factor: `{_format_num(float(row['profit_factor']))}`",
+                f"- Compounded return: `{_format_pct(float(row['compounded_return']))}`",
+                f"- CAGR: `{_format_pct(float(row['cagr']))}`",
+                f"- Real BUY / SELL overlap: `{int(row['real_buy_overlap'])}/{int(row['real_sell_overlap'])}`",
+                f"- Short `00-05d` / `06-10d`: `{_format_pct(float(row['short_00_05d_avg_return']))}` / `{_format_pct(float(row['short_06_10d_avg_return']))}`",
+                f"- Suitable for: {suit_map[branch]}",
+                "",
+            ]
+        )
+
+    workbook = df[df["branch"] == "workbook_preserving"].iloc[0]
+    alpha = df[df["branch"] == "alpha_first_selective_veto"].iloc[0]
+    refined = df[df["branch"] == "alpha_first_glued_refined_hot_cap"].iloc[0]
+
+    lines.extend(
+        [
+            "## Quick Read",
+            f"- If you want the version most like the workbook, use `workbook_preserving`: CAGR `{_format_pct(float(workbook['cagr']))}`, overlap `{int(workbook['real_buy_overlap'])}/{int(workbook['real_sell_overlap'])}`.",
+            f"- If you want the balanced version, use `alpha_first_selective_veto`: CAGR `{_format_pct(float(alpha['cagr']))}`, profit factor `{_format_num(float(alpha['profit_factor']))}`, overlap `{int(alpha['real_buy_overlap'])}/{int(alpha['real_sell_overlap'])}`.",
+            f"- If you want the strongest alpha candidate, use `alpha_first_glued_refined_hot_cap`: CAGR `{_format_pct(float(refined['cagr']))}`, profit factor `{_format_num(float(refined['profit_factor']))}`, overlap `{int(refined['real_buy_overlap'])}/{int(refined['real_sell_overlap'])}`.",
+            "",
+            "## Quant Take",
+            f"- `alpha_first_selective_veto` vs workbook: CAGR `+{(float(alpha['cagr']) - float(workbook['cagr'])):.2%}`, profit factor `+{(float(alpha['profit_factor']) - float(workbook['profit_factor'])):.2f}`, BUY/SELL overlap delta `{int(alpha['real_buy_overlap'] - workbook['real_buy_overlap'])}/{int(alpha['real_sell_overlap'] - workbook['real_sell_overlap'])}`.",
+            f"- `alpha_first_glued_refined_hot_cap` vs current alpha: CAGR `+{(float(refined['cagr']) - float(alpha['cagr'])):.2%}`, profit factor `+{(float(refined['profit_factor']) - float(alpha['profit_factor'])):.2f}`, BUY/SELL overlap delta `{int(refined['real_buy_overlap'] - alpha['real_buy_overlap'])}/{int(refined['real_sell_overlap'] - alpha['real_sell_overlap'])}`.",
+            "- Operationally, the refined branch is the best-performing candidate, but the current formal alpha branch remains the governance default because it gives up fewer workbook-aligned dates.",
+        ]
+    )
+
+    (base_dir / "dragon_strategy_overview.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+
+if __name__ == "__main__":
+    main()

+ 455 - 0
research/dragon/v2/dragon_system_review.py

@@ -0,0 +1,455 @@
+from __future__ import annotations
+
+import json
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Iterable
+
+import pandas as pd
+
+
+@dataclass(frozen=True)
+class WindowScript:
+    path: str
+    scope: str
+    category: str
+    expectation: str
+
+
+WINDOW_SCRIPTS = [
+    WindowScript("dragon_backtest.py", "workbook window", "evaluation", "dual_bound"),
+    WindowScript("dragon_cost_stress_test.py", "fixed release window", "evaluation", "dual_bound"),
+    WindowScript("dragon_deep_oversold_confirmation_experiments.py", "workbook window", "experiment", "dual_bound"),
+    WindowScript("dragon_deep_oversold_experiments.py", "workbook window", "experiment", "dual_bound"),
+    WindowScript("dragon_deep_oversold_selective_veto_experiments.py", "workbook window", "experiment", "dual_bound"),
+    WindowScript("dragon_equity_curve_review.py", "fixed release window", "evaluation", "dual_bound"),
+    WindowScript("dragon_glued_alpha_candidate.py", "workbook window", "evaluation", "dual_bound"),
+    WindowScript("dragon_glued_refined_branch_review.py", "hybrid bounded window", "evaluation", "dual_bound"),
+    WindowScript("dragon_glued_refined_removed_trade_attribution.py", "fixed release window", "attribution", "dual_bound"),
+    WindowScript("dragon_glued_refined_sensitivity.py", "fixed release window", "evaluation", "dual_bound"),
+    WindowScript("dragon_glued_refine_experiments.py", "workbook window", "experiment", "dual_bound"),
+    WindowScript("dragon_predictive_break_experiments.py", "workbook window", "experiment", "dual_bound"),
+    WindowScript("dragon_rc1_release.py", "fixed release window", "release", "dual_bound"),
+    WindowScript("dragon_refined_alpha_attribution.py", "fixed release window", "attribution", "dual_bound"),
+    WindowScript("dragon_refined_execution_validation.py", "fixed release window", "evaluation", "dual_bound"),
+    WindowScript("dragon_rule_ablation.py", "workbook window", "evaluation", "dual_bound"),
+    WindowScript("dragon_short_holding_audit.py", "workbook window", "audit", "dual_bound"),
+    WindowScript("dragon_short_holding_experiments.py", "workbook window", "experiment", "dual_bound"),
+    WindowScript("dragon_strategy_overview.py", "fixed release window", "overview", "dual_bound"),
+    WindowScript("dragon_threshold_perturbation.py", "workbook window", "evaluation", "dual_bound"),
+    WindowScript("dragon_daily_signal_pipeline.py", "live / forward window", "live", "live_exception"),
+]
+
+
+def _load_csv(path: Path) -> pd.DataFrame:
+    return pd.DataFrame() if not path.exists() else pd.read_csv(path, encoding="utf-8-sig")
+
+
+def _fmt_pct(value: object) -> str:
+    if value is None or pd.isna(value):
+        return "NA"
+    return f"{float(value):.2%}"
+
+
+def _fmt_num(value: object, digits: int = 2) -> str:
+    if value is None or pd.isna(value):
+        return "NA"
+    return f"{float(value):.{digits}f}"
+
+
+def _window_filter_status(text: str, expectation: str) -> tuple[str, str]:
+    has_buy = 'trades["buy_date"]' in text
+    has_sell = 'trades["sell_date"]' in text
+    if expectation == "live_exception":
+        return "EXEMPT", "Live / forward pipeline intentionally keeps open-ended trade coverage."
+    if has_buy and has_sell:
+        return "PASS", "Trade filter constrains both buy_date and sell_date."
+    if has_buy and not has_sell:
+        return "FAIL", "Trade filter constrains buy_date only."
+    return "FAIL", "No recognizable trade-window filter found."
+
+
+def build_window_consistency_report(base_dir: Path) -> pd.DataFrame:
+    rows: list[dict[str, object]] = []
+    for item in WINDOW_SCRIPTS:
+        text = (base_dir / item.path).read_text(encoding="utf-8")
+        status, note = _window_filter_status(text, item.expectation)
+        rows.append(
+            {
+                "script": item.path,
+                "scope": item.scope,
+                "category": item.category,
+                "expectation": item.expectation,
+                "status": status,
+                "note": note,
+            }
+        )
+    df = pd.DataFrame(rows)
+    passed = int((df["status"] == "PASS").sum())
+    exempt = int((df["status"] == "EXEMPT").sum())
+    failed = int((df["status"] == "FAIL").sum())
+    lines = [
+        "# Dragon Review - Window Consistency",
+        "",
+        "## Scope",
+        "- Goal: verify that in-sample / workbook-window trade statistics do not keep window-external exits.",
+        "- Rule: for bounded research windows, trade filters must constrain both `buy_date` and `sell_date`.",
+        "",
+        "## Summary",
+        f"- PASS: `{passed}`",
+        f"- EXEMPT: `{exempt}`",
+        f"- FAIL: `{failed}`",
+        "",
+        "## Result Table",
+    ]
+    for _, row in df.iterrows():
+        lines.append(
+            f"- `{row['script']}` | {row['category']} | {row['scope']} | `{row['status']}` | {row['note']}"
+        )
+    if failed == 0:
+        lines.extend(
+            [
+                "",
+                "## Judgment",
+                "- The bounded research/evaluation pack is now on a consistent dual-bound trade-window rule.",
+                "- `dragon_daily_signal_pipeline.py` remains intentionally exempt because it serves the live / forward chain rather than workbook-window evaluation.",
+            ]
+        )
+    (base_dir / "dragon_review_window_consistency.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
+    return df
+
+
+def _branch_source_frames(base_dir: Path) -> dict[str, pd.DataFrame]:
+    frames: dict[str, pd.DataFrame] = {}
+    overview = _load_csv(base_dir / "dragon_strategy_overview.csv")
+    if not overview.empty:
+        frames["dragon_strategy_overview.csv"] = overview
+    glued = _load_csv(base_dir / "dragon_glued_refined_branch_summary.csv")
+    if not glued.empty:
+        frames["dragon_glued_refined_branch_summary.csv"] = glued
+    alpha = _load_csv(base_dir / "dragon_alpha_first_branch_summary.csv")
+    if not alpha.empty:
+        frames["dragon_alpha_first_branch_summary.csv"] = alpha
+    return frames
+
+
+def _iter_metric_values(base_dir: Path) -> Iterable[dict[str, object]]:
+    metrics = [
+        "trades",
+        "win_rate",
+        "avg_return",
+        "median_return",
+        "profit_factor",
+        "compounded_return",
+        "cagr",
+        "real_buy_overlap",
+        "real_sell_overlap",
+    ]
+    for source, df in _branch_source_frames(base_dir).items():
+        for _, row in df.iterrows():
+            branch = str(row["branch"])
+            for metric in metrics:
+                if metric in row.index:
+                    yield {
+                        "source": source,
+                        "branch": branch,
+                        "metric": metric,
+                        "value": row[metric],
+                    }
+    rc1_path = base_dir / "dragon_rc1_config_snapshot.json"
+    if rc1_path.exists():
+        payload = json.loads(rc1_path.read_text(encoding="utf-8"))
+        for metric, source_metric in [
+            ("trades", "trade_count"),
+            ("win_rate", "win_rate"),
+            ("avg_return", "avg_return"),
+            ("median_return", "median_return"),
+            ("profit_factor", "profit_factor"),
+            ("compounded_return", "compounded_return"),
+            ("cagr", "cagr"),
+        ]:
+            yield {
+                "source": "dragon_rc1_config_snapshot.json",
+                "branch": str(payload["branch_name"]),
+                "metric": metric,
+                "value": payload[source_metric],
+            }
+
+
+def build_branch_metric_consistency(base_dir: Path) -> pd.DataFrame:
+    raw = pd.DataFrame(list(_iter_metric_values(base_dir)))
+    if raw.empty:
+        raw.to_csv(base_dir / "dragon_review_branch_metric_consistency.csv", index=False, encoding="utf-8-sig")
+        (base_dir / "dragon_review_branch_metric_consistency.md").write_text(
+            "# Dragon Review - Branch Metric Consistency\n\n- No source data found.\n",
+            encoding="utf-8",
+        )
+        return raw
+
+    raw["value"] = pd.to_numeric(raw["value"], errors="coerce")
+    pivot = raw.pivot_table(index=["branch", "metric"], columns="source", values="value", aggfunc="last").reset_index()
+    source_cols = [col for col in pivot.columns if col not in {"branch", "metric"}]
+    records: list[dict[str, object]] = []
+    for _, row in pivot.iterrows():
+        values = [row[col] for col in source_cols if pd.notna(row[col])]
+        max_abs_diff = float(max(values) - min(values)) if values else float("nan")
+        if len(values) <= 1:
+            status = "single_source"
+        else:
+            tol = 1e-12 if row["metric"] in {"trades", "real_buy_overlap", "real_sell_overlap"} else 1e-9
+            status = "match" if max_abs_diff <= tol else "mismatch"
+        rec = {
+            "branch": row["branch"],
+            "metric": row["metric"],
+            "source_count": int(len(values)),
+            "min_value": float(min(values)) if values else float("nan"),
+            "max_value": float(max(values)) if values else float("nan"),
+            "max_abs_diff": max_abs_diff,
+            "status": status,
+        }
+        for col in source_cols:
+            rec[col] = row[col]
+        records.append(rec)
+    result = pd.DataFrame(records).sort_values(["branch", "metric"]).reset_index(drop=True)
+    result.to_csv(base_dir / "dragon_review_branch_metric_consistency.csv", index=False, encoding="utf-8-sig")
+
+    mismatches = result[result["status"] == "mismatch"].copy()
+    lines = [
+        "# Dragon Review - Branch Metric Consistency",
+        "",
+        "## Scope",
+        "- Sources compared:",
+        "- `dragon_strategy_overview.csv`",
+        "- `dragon_glued_refined_branch_summary.csv`",
+        "- `dragon_alpha_first_branch_summary.csv`",
+        "- `dragon_rc1_config_snapshot.json`",
+        "",
+        f"- Compared rows: `{len(result)}`",
+        f"- Mismatches: `{len(mismatches)}`",
+        "",
+    ]
+    if mismatches.empty:
+        lines.append("- All compared branch metrics are consistent across current outputs.")
+    else:
+        lines.extend(["## Mismatches"])
+        for _, row in mismatches.iterrows():
+            parts = []
+            for col in source_cols:
+                if pd.notna(row[col]):
+                    parts.append(f"{col}={row[col]}")
+            lines.append(
+                f"- `{row['branch']}` / `{row['metric']}` | spread `{row['max_abs_diff']}` | " + "; ".join(parts)
+            )
+    lines.extend(
+        [
+            "",
+            "## Judgment",
+            "- `match` means the same branch/metric agrees across all currently available source files.",
+            "- `mismatch` means at least one report family is using a different metric definition or evaluation window.",
+            "- `single_source` means only one current artifact exposes that metric, so cross-check confidence is lower.",
+        ]
+    )
+    (base_dir / "dragon_review_branch_metric_consistency.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
+    return result
+
+
+def _calc_removed_trade_over_removal_count(base_dir: Path) -> float:
+    df = _load_csv(base_dir / "dragon_glued_refined_removed_trade_attribution.csv")
+    if df.empty or "recommendation" not in df.columns:
+        return float("nan")
+    return float((df["recommendation"].astype(str) == "OVER_REMOVAL").sum())
+
+
+def _calc_local_sensitivity_robust_case_count(base_dir: Path) -> float:
+    df = _load_csv(base_dir / "dragon_glued_refined_sensitivity.csv")
+    if df.empty or "label" not in df.columns:
+        return float("nan")
+    candidate = df[df["label"] == "refined_candidate_baseline"].copy()
+    if candidate.empty:
+        return float("nan")
+    candidate_row = candidate.iloc[0]
+    neighborhood = df[~df["label"].isin(["current_alpha_control", "refined_candidate_baseline"])].copy()
+    robust = neighborhood[
+        (neighborhood["avg_return"] >= float(candidate_row["avg_return"]) - 0.0015)
+        & (neighborhood["profit_factor"] >= float(candidate_row["profit_factor"]) - 0.20)
+        & (neighborhood["real_buy_overlap"] >= int(candidate_row["real_buy_overlap"]) - 1)
+        & (neighborhood["real_sell_overlap"] >= int(candidate_row["real_sell_overlap"]) - 1)
+    ]
+    return float(len(robust))
+
+
+def build_execution_monitor_review(base_dir: Path) -> None:
+    monitor = _load_csv(base_dir / "dragon_daily_monitor_snapshot.csv")
+    weekly = _load_csv(base_dir / "dragon_forward_weekly_summary.csv")
+    html_text = (base_dir / "dragon_forward_weekly_review.html").read_text(encoding="utf-8")
+    daily_text = (base_dir / "dragon_daily_signal_pipeline.py").read_text(encoding="utf-8")
+    exec_text = (base_dir / "dragon_refined_execution_validation.py").read_text(encoding="utf-8")
+
+    actual_removed = _calc_removed_trade_over_removal_count(base_dir)
+    actual_robust = _calc_local_sensitivity_robust_case_count(base_dir)
+    monitor_map = {
+        str(row["metric"]): float(row["actual_value"])
+        for _, row in monitor.iterrows()
+        if pd.notna(row["actual_value"])
+    }
+    removed_match = pd.notna(actual_removed) and abs(monitor_map.get("removed_trade_over_removal_count", float("nan")) - actual_removed) < 1e-12
+    robust_match = pd.notna(actual_robust) and abs(monitor_map.get("local_sensitivity_robust_case_count", float("nan")) - actual_robust) < 1e-12
+    daily_uses_nan = 'float("nan") if buy_next is None' in daily_text and 'float("nan") if sell_next is None' in daily_text
+    exec_uses_nan = 'float("nan") if buy_next is None' in exec_text and 'float("nan") if sell_next is None' in exec_text
+    weekly_has_system_monitor = not weekly.empty and "system_monitor" in set(weekly["branch"].astype(str))
+    weekly_html_has_system_monitor = "system_monitor" in html_text
+
+    lines = [
+        "# Dragon Review - Execution And Monitor Consistency",
+        "",
+        "## Governance Metrics",
+        f"- `removed_trade_over_removal_count`: monitor `{monitor_map.get('removed_trade_over_removal_count')}` vs source-derived `{actual_removed}` -> `{'match' if removed_match else 'mismatch'}`",
+        f"- `local_sensitivity_robust_case_count`: monitor `{monitor_map.get('local_sensitivity_robust_case_count')}` vs source-derived `{actual_robust}` -> `{'match' if robust_match else 'mismatch'}`",
+        "",
+        "## Execution Fallback Rule",
+        f"- `dragon_daily_signal_pipeline.py` uses NaN on missing next-bar execution prices: `{daily_uses_nan}`",
+        f"- `dragon_refined_execution_validation.py` uses NaN on missing next-bar execution prices: `{exec_uses_nan}`",
+        "",
+        "## Weekly Monitor Separation",
+        f"- `dragon_forward_weekly_summary.csv` includes `system_monitor` row: `{weekly_has_system_monitor}`",
+        f"- `dragon_forward_weekly_review.html` includes `system_monitor` text: `{weekly_html_has_system_monitor}`",
+        "",
+        "## Judgment",
+        "- The monitor chain is trustworthy only if governance metrics are derived from current source artifacts rather than hard-coded constants.",
+        "- The execution-aware chain is trustworthy only if missing next-bar prices do not silently fall back to same-bar close.",
+        "- Weekly summary is cleaner now because branch rows and system-level monitor counts are separated.",
+    ]
+    (base_dir / "dragon_review_execution_monitor.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+
+def build_reporting_integrity_review(base_dir: Path) -> None:
+    latest_bar = json.loads((base_dir / "dragon_forward_observation_state.json").read_text(encoding="utf-8")).get("latest_bar_date", "latest")
+    files_to_check = [
+        base_dir / "dragon_reports_index.html",
+        base_dir / "dragon_daily_signal_report.html",
+        base_dir / "dragon_forward_weekly_review.html",
+        base_dir / "dragon_historical_trade_details.html",
+        base_dir / "dragon_indicator_strategy_guide_cn.html",
+        base_dir / "html_reports" / "index.html",
+        base_dir / "html_reports" / f"dragon_daily_signal_report_{latest_bar}.html",
+        base_dir / "html_reports" / f"dragon_forward_weekly_review_{latest_bar}.html",
+        base_dir / "html_reports" / f"dragon_historical_trade_details_{latest_bar}.html",
+    ]
+    existence_rows = [{"path": str(path.relative_to(base_dir)), "exists": path.exists()} for path in files_to_check]
+
+    index_text = (base_dir / "dragon_reports_index.html").read_text(encoding="utf-8")
+    archive_index_text = (base_dir / "html_reports" / "index.html").read_text(encoding="utf-8")
+    detail_text = (base_dir / "dragon_historical_trade_details.html").read_text(encoding="utf-8")
+    daily_text = (base_dir / "dragon_daily_signal_report.html").read_text(encoding="utf-8")
+
+    checks = [
+        ("root index links to root daily report", 'href="dragon_daily_signal_report.html"' in index_text),
+        (
+            "root index links to archived daily report",
+            f'href="html_reports/dragon_daily_signal_report_{latest_bar}.html"' in index_text,
+        ),
+        (
+            "archive index links locally inside html_reports",
+            f'href="dragon_daily_signal_report_{latest_bar}.html"' in archive_index_text,
+        ),
+        ("detail page contains snapshot summary strip", "snapshot-summary" in detail_text),
+        ("detail page contains event summary labels", "总事件" in detail_text and "前一条:" in detail_text),
+        ("detail page contains query filters", "branch-filter" in detail_text and "keyword-filter" in detail_text),
+        ("daily report links to historical detail page", 'href="dragon_historical_trade_details.html"' in daily_text),
+    ]
+
+    lines = [
+        "# Dragon Review - Reporting Integrity",
+        "",
+        "## File Existence",
+    ]
+    for row in existence_rows:
+        lines.append(f"- `{row['path']}` -> `{row['exists']}`")
+    lines.extend(["", "## Link / Feature Checks"])
+    for label, passed in checks:
+        lines.append(f"- {label}: `{passed}`")
+    lines.extend(
+        [
+            "",
+            "## Judgment",
+            "- Root and archive HTML outputs are present and linked through the expected root-vs-archive relative paths.",
+            "- Historical detail reporting currently includes the new indicator snapshot event-summary strip and deep-link filter controls.",
+            "- Terminal mojibake remains a shell-display issue; these checks only validate file presence and embedded text markers, not browser rendering fidelity.",
+        ]
+    )
+    (base_dir / "dragon_review_reporting_integrity.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+
+def build_system_final(base_dir: Path, branch_consistency: pd.DataFrame) -> None:
+    mismatch_branches = branch_consistency[branch_consistency["status"] == "mismatch"].copy()
+    trusted_direct = [
+        "dragon_alpha_first_branch_summary.csv",
+        "dragon_glued_refined_branch_summary.csv",
+        "dragon_rc1_config_snapshot.json",
+        "dragon_daily_monitor_snapshot.csv",
+    ]
+    if mismatch_branches.empty:
+        trusted_direct.append("dragon_strategy_overview.csv")
+    trusted_labeled = [
+        "dragon_refined_execution_stress.csv",
+        "dragon_cost_stress_test.csv",
+        "dragon_forward_weekly_summary.csv",
+        "dragon_historical_trade_details.html",
+    ]
+    not_recommended = []
+    if not mismatch_branches.empty:
+        not_recommended.append("dragon_strategy_overview.csv")
+
+    lines = [
+        "# Dragon System Review Final",
+        "",
+        "## Overall Judgment",
+        "- The current workspace is much closer to a trustworthy research pack after the window-consistency sweep and monitor-fallback fixes.",
+        "",
+        "## Trust Tiers",
+        "- 可信可直接使用:",
+    ]
+    if mismatch_branches.empty:
+        lines.insert(4, "- Cross-report headline metrics are currently aligned across the main branch-summary, release, and overview artifacts.")
+        lines.insert(5, "- The remaining cautions are about report interpretation and forward monitoring, not internal metric drift.")
+    else:
+        lines.insert(4, "- The main remaining risk is not strategy logic but metric-definition drift between a few report families.")
+    lines.extend([f"- `{name}`" for name in trusted_direct])
+    lines.append("- 可信但需标注口径:")
+    lines.extend([f"- `{name}`" for name in trusted_labeled])
+    lines.append("- 暂不建议直接引用:")
+    if not_recommended:
+        lines.extend([f"- `{name}`" for name in not_recommended])
+    else:
+        lines.append("- `none`")
+
+    if not mismatch_branches.empty:
+        lines.extend(["", "## Remaining Review Findings"])
+        for _, row in mismatch_branches.iterrows():
+            lines.append(
+                f"- `{row['branch']}` / `{row['metric']}` remains inconsistent across report families; see `dragon_review_branch_metric_consistency.csv`."
+            )
+
+    lines.extend(
+        [
+            "",
+            "## Practical Meaning",
+            "- Use the branch-specific summary/release artifacts as the primary basis for governance decisions.",
+            "- Use the consistency reports as an audit trail before external distribution of top-line metrics.",
+            "- `dragon_strategy_overview.csv` is now aligned with the main branch artifacts and can be used as the compact comparison view.",
+        ]
+    )
+    (base_dir / "dragon_system_review_final.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+
+def main() -> None:
+    base_dir = Path(__file__).resolve().parent
+    build_window_consistency_report(base_dir)
+    branch_consistency = build_branch_metric_consistency(base_dir)
+    build_execution_monitor_review(base_dir)
+    build_reporting_integrity_review(base_dir)
+    build_system_final(base_dir, branch_consistency)
+
+
+if __name__ == "__main__":
+    main()

+ 214 - 0
research/dragon/v2/dragon_threshold_perturbation.py

@@ -0,0 +1,214 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pandas as pd
+
+from dragon_indicators import DragonIndicatorConfig, DragonIndicatorEngine
+from dragon_strategy import DragonRuleEngine
+from dragon_strategy_config import StrategyConfig
+from dragon_workbook import DragonWorkbook
+
+
+def _find_workbook(base_dir: Path) -> Path:
+    matches = sorted(base_dir.glob("*.xlsx"))
+    if not matches:
+        raise FileNotFoundError(f"No workbook found in {base_dir}")
+    return matches[0]
+
+
+def _load_workbook_events(workbook_path: Path) -> pd.DataFrame:
+    workbook = DragonWorkbook(workbook_path)
+    return pd.DataFrame(
+        [
+            {
+                "date": event.date.isoformat(),
+                "side": event.side,
+                "layer": event.layer,
+            }
+            for event in workbook.split_layers()
+        ]
+    )
+
+
+def _event_overlap(workbook_events: pd.DataFrame, strategy_events: pd.DataFrame, side: str, layer: str) -> tuple[int, int, int]:
+    wb = set(workbook_events[(workbook_events["side"] == side) & (workbook_events["layer"] == layer)]["date"])
+    st = set(strategy_events[(strategy_events["side"] == side) & (strategy_events["layer"] == layer)]["date"])
+    hit = wb & st
+    return len(hit), len(wb - st), len(st - wb)
+
+
+def _profit_factor(series: pd.Series) -> float:
+    gross_profit = series[series > 0].sum()
+    gross_loss = -series[series < 0].sum()
+    if gross_loss == 0:
+        return float("inf") if gross_profit > 0 else 0.0
+    return float(gross_profit / gross_loss)
+
+
+def _run_experiment(
+    label: str,
+    parameter: str,
+    variant: str,
+    config: StrategyConfig,
+    workbook_events: pd.DataFrame,
+    indicator_df: pd.DataFrame,
+    first_workbook_date: str,
+    last_workbook_date: str,
+) -> dict[str, object]:
+    strategy = DragonRuleEngine(config=config)
+    events, trades = strategy.run(indicator_df)
+    events = events[(events["date"] >= first_workbook_date) & (events["date"] <= last_workbook_date)].copy()
+    trades = trades[
+        (trades["buy_date"] >= first_workbook_date)
+        & (trades["buy_date"] <= last_workbook_date)
+        & (trades["sell_date"] >= first_workbook_date)
+        & (trades["sell_date"] <= last_workbook_date)
+    ].copy()
+
+    real_buy_overlap, real_buy_missing, real_buy_extra = _event_overlap(workbook_events, events, "BUY", "real_trade")
+    real_sell_overlap, real_sell_missing, real_sell_extra = _event_overlap(workbook_events, events, "SELL", "real_trade")
+    aux_sell_overlap, aux_sell_missing, aux_sell_extra = _event_overlap(workbook_events, events, "SELL", "aux_signal")
+
+    return {
+        "experiment": label,
+        "parameter": parameter,
+        "variant": variant,
+        "value": getattr(config, parameter) if parameter != "baseline" else "baseline",
+        "trades": int(len(trades)),
+        "win_rate": float((trades["return_pct"] > 0).mean()) if not trades.empty else float("nan"),
+        "avg_return": float(trades["return_pct"].mean()) if not trades.empty else float("nan"),
+        "profit_factor": _profit_factor(trades["return_pct"]) if not trades.empty else float("nan"),
+        "real_buy_overlap": int(real_buy_overlap),
+        "real_buy_missing": int(real_buy_missing),
+        "real_buy_extra": int(real_buy_extra),
+        "real_sell_overlap": int(real_sell_overlap),
+        "real_sell_missing": int(real_sell_missing),
+        "real_sell_extra": int(real_sell_extra),
+        "aux_sell_overlap": int(aux_sell_overlap),
+        "aux_sell_missing": int(aux_sell_missing),
+        "aux_sell_extra": int(aux_sell_extra),
+    }
+
+
+def main() -> None:
+    base_dir = Path(__file__).resolve().parent
+    workbook_path = _find_workbook(base_dir)
+    workbook_events = _load_workbook_events(workbook_path)
+    first_workbook_date = pd.to_datetime(workbook_events["date"]).min().date().isoformat()
+    last_workbook_date = pd.to_datetime(workbook_events["date"]).max().date().isoformat()
+
+    engine = DragonIndicatorEngine(DragonIndicatorConfig(start_date="2015-01-01", end_date="2026-01-31"))
+    indicator_df = engine.compute(engine.fetch_daily_data())
+
+    baseline = StrategyConfig()
+    perturbations = {
+        "post_exit_confirmation_window_days": [8, 10, 12],
+        "aux_sell_high_zone_kdj_only_block_c1": [83.0, 85.0, 87.0],
+        "glued_high_weak_rebound_high_c1": [66.0, 68.0, 70.0],
+        "glued_high_weak_rebound_high_b1": [-0.10, -0.08, -0.06],
+        "deep_oversold_entry_c1_max": [15.0, 16.0, 17.0],
+        "deep_oversold_entry_b1_min": [-0.12, -0.10, -0.08],
+        "predictive_b1_break_short_b1_max": [-0.15, -0.13, -0.11],
+        "predictive_b1_break_long_b1_max": [-0.14, -0.12, -0.10],
+    }
+
+    rows = [
+        _run_experiment(
+            "baseline",
+            "baseline",
+            "baseline",
+            baseline,
+            workbook_events,
+            indicator_df,
+            first_workbook_date,
+            last_workbook_date,
+        )
+    ]
+
+    for parameter, values in perturbations.items():
+        for idx, value in enumerate(values):
+            variant = "lower" if idx == 0 else "baseline" if idx == 1 else "upper"
+            config = baseline.with_updates(**{parameter: value})
+            rows.append(
+                _run_experiment(
+                    f"{parameter}:{variant}",
+                    parameter,
+                    variant,
+                    config,
+                    workbook_events,
+                    indicator_df,
+                    first_workbook_date,
+                    last_workbook_date,
+                )
+            )
+
+    result_df = pd.DataFrame(rows)
+    baseline_row = result_df[result_df["experiment"] == "baseline"].iloc[0]
+    for col in ["trades", "win_rate", "avg_return", "profit_factor", "real_buy_overlap", "real_sell_overlap", "aux_sell_overlap"]:
+        result_df[f"delta_{col}"] = result_df[col] - baseline_row[col]
+
+    result_df.to_csv(base_dir / "dragon_threshold_perturbation.csv", index=False, encoding="utf-8-sig")
+
+    summary_rows: list[dict[str, object]] = []
+    for parameter in perturbations:
+        subset = result_df[result_df["parameter"] == parameter].copy()
+        summary_rows.append(
+            {
+                "parameter": parameter,
+                "baseline_value": subset[subset["variant"] == "baseline"].iloc[0]["value"],
+                "real_buy_overlap_min": int(subset["real_buy_overlap"].min()),
+                "real_sell_overlap_min": int(subset["real_sell_overlap"].min()),
+                "avg_return_range": float(subset["avg_return"].max() - subset["avg_return"].min()),
+                "profit_factor_range": float(subset["profit_factor"].max() - subset["profit_factor"].min()),
+                "aux_sell_overlap_range": int(subset["aux_sell_overlap"].max() - subset["aux_sell_overlap"].min()),
+                "stable_real_alignment": bool(
+                    (subset["real_buy_overlap"] == baseline_row["real_buy_overlap"]).all()
+                    and (subset["real_sell_overlap"] == baseline_row["real_sell_overlap"]).all()
+                ),
+            }
+        )
+    summary_df = pd.DataFrame(summary_rows).sort_values(["stable_real_alignment", "avg_return_range"], ascending=[True, False])
+    summary_df.to_csv(base_dir / "dragon_threshold_sensitivity_summary.csv", index=False, encoding="utf-8-sig")
+
+    lines = [
+        "# Dragon Threshold Perturbation",
+        "",
+        "## Baseline",
+        f"- trades: `{int(baseline_row['trades'])}`",
+        f"- avg_return: `{baseline_row['avg_return']:.2%}`",
+        f"- profit_factor: `{baseline_row['profit_factor']:.2f}`",
+        f"- real BUY overlap: `{int(baseline_row['real_buy_overlap'])}`",
+        f"- real SELL overlap: `{int(baseline_row['real_sell_overlap'])}`",
+        "",
+        "## Sensitivity Summary",
+    ]
+    for _, row in summary_df.iterrows():
+        lines.append(
+            f"- `{row['parameter']}`: stable_real_alignment `{bool(row['stable_real_alignment'])}`, "
+            f"avg_return_range `{row['avg_return_range']:.2%}`, profit_factor_range `{row['profit_factor_range']:.2f}`, "
+            f"aux_sell_overlap_range `{int(row['aux_sell_overlap_range'])}`"
+        )
+
+    fragile = summary_df[~summary_df["stable_real_alignment"]]
+    lines.extend(["", "## Fragile Parameters"])
+    if fragile.empty:
+        lines.append("- None in this first perturbation pack.")
+    else:
+        for _, row in fragile.iterrows():
+            lines.append(
+                f"- `{row['parameter']}`: minimum real BUY overlap `{int(row['real_buy_overlap_min'])}`, minimum real SELL overlap `{int(row['real_sell_overlap_min'])}`"
+            )
+
+    robust = summary_df[summary_df["stable_real_alignment"]].sort_values("avg_return_range").head(6)
+    lines.extend(["", "## Relatively Robust Parameters"])
+    for _, row in robust.iterrows():
+        lines.append(
+            f"- `{row['parameter']}`: avg_return_range `{row['avg_return_range']:.2%}`, profit_factor_range `{row['profit_factor_range']:.2f}`"
+        )
+
+    (base_dir / "dragon_threshold_perturbation.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+
+if __name__ == "__main__":
+    main()

+ 298 - 0
research/dragon/v2/dragon_trade_path_trace.py

@@ -0,0 +1,298 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pandas as pd
+
+
+BUY_REASON_STATE = {
+    "deep_oversold_rebound_buy": "low_oversold_regime",
+    "oversold_recovery_buy": "low_oversold_regime",
+    "oversold_reversal_after_ql_buy": "rebound_after_sell_regime",
+    "post_sell_rebound_buy": "rebound_after_sell_regime",
+    "post_washout_kdj_reentry_buy": "rebound_after_sell_regime",
+    "predictive_error_reentry_buy": "rebound_after_sell_regime",
+    "hot_exit_reentry_buy": "rebound_after_sell_regime",
+    "early_crash_probe_buy": "crash_probe_regime",
+    "dual_gold_resonance_buy": "low_oversold_regime",
+    "glued_buy": "mid_regime",
+    "non_glued_positive_expansion_buy": "high_regime",
+}
+
+BUY_REASON_QUALIFICATION = {
+    "deep_oversold_rebound_buy": "oversold_reversal_entry",
+    "oversold_recovery_buy": "oversold_reversal_entry",
+    "oversold_reversal_after_ql_buy": "oversold_reversal_entry",
+    "post_sell_rebound_buy": "rebound_reentry",
+    "post_washout_kdj_reentry_buy": "workbook_special_restart",
+    "predictive_error_reentry_buy": "bridge_reentry",
+    "hot_exit_reentry_buy": "bridge_reentry",
+    "early_crash_probe_buy": "crash_probe_entry",
+    "dual_gold_resonance_buy": "dual_gold_entry",
+    "glued_buy": "glued_base_entry",
+    "non_glued_positive_expansion_buy": "momentum_expansion_entry",
+}
+
+SELL_REASON_MANAGEMENT = {
+    "crash_protection_exit": "predictive_risk_exit",
+    "predictive_b1_break_exit": "predictive_risk_exit",
+    "prewarning_reduction_exit": "prewarning_exit",
+    "high_regime_momentum_break": "prewarning_exit",
+    "high_regime_confirmed_exit:kdj_sell": "confirmed_trend_exit",
+    "ql_high_zone_take_profit": "high_regime_take_profit",
+    "ql_mid_zone_take_profit": "high_regime_take_profit",
+    "medium_hot_take_profit": "high_regime_take_profit",
+    "high_zone_post_ql_fade_exit": "ql_followthrough_exit",
+    "post_ql_decay_exit": "ql_followthrough_exit",
+    "post_dual_sell_decay_exit": "ql_followthrough_exit",
+    "knife_take_profit_1": "first_take_profit",
+    "knife_take_profit_2_glued": "first_take_profit",
+    "knife_take_profit_2_wait_ql_s": "first_take_profit",
+    "early_positive_take_profit": "first_take_profit",
+    "oversold_rebound_take_profit": "first_take_profit",
+    "glued_exit:kdj_sell": "confirmed_trend_exit",
+    "small_positive_a1_declining:ql_sell": "confirmed_trend_exit",
+    "negative_a1_no_b1_recovery:kdj_sell": "negative_a1_exit",
+    "negative_a1_no_b1_recovery:ql_sell": "negative_a1_exit",
+    "negative_a1_b1_not_strong:kdj_sell": "negative_a1_exit",
+    "low_zone_dual_gold_exit:kdj_sell": "negative_a1_exit",
+    "hard_exit:kdj_sell": "hard_risk_exit",
+    "hard_exit:ql_sell": "hard_risk_exit",
+    "early_failed_rebound_exit": "predictive_risk_exit",
+}
+
+
+def _infer_state_layer(buy_reason: str, buy_c1: float) -> str:
+    normalized_reason = buy_reason.split(":", 1)[0]
+    state = BUY_REASON_STATE.get(normalized_reason)
+    if state == "mid_regime":
+        if buy_c1 < 20:
+            return "low_oversold_regime"
+        if buy_c1 >= 70:
+            return "high_regime"
+    return state or "mid_regime"
+
+
+def _infer_entry_layer(buy_reason: str) -> str:
+    normalized_reason = buy_reason.split(":", 1)[0]
+    return BUY_REASON_QUALIFICATION.get(normalized_reason, "base_entry")
+
+
+def _infer_management_layer(sell_reason: str) -> str:
+    return SELL_REASON_MANAGEMENT.get(sell_reason, "default_exit_management")
+
+
+def _infer_aux_context(hold_aux_buy: pd.DataFrame, post_exit_aux_sell: pd.DataFrame) -> tuple[str, str]:
+    if hold_aux_buy.empty and post_exit_aux_sell.empty:
+        return "no_aux_signal", ""
+
+    if not hold_aux_buy.empty and not post_exit_aux_sell.empty:
+        aux_layer = "aux_buy_and_aux_sell"
+    elif not hold_aux_buy.empty:
+        aux_layer = "aux_buy_only"
+    else:
+        aux_layer = "aux_sell_only"
+
+    detail_frames = []
+    if not hold_aux_buy.empty:
+        detail_frames.append(hold_aux_buy)
+    if not post_exit_aux_sell.empty:
+        detail_frames.append(post_exit_aux_sell)
+    aux_detail_df = pd.concat(detail_frames, ignore_index=True).sort_values("dt")
+    detail = " | ".join(
+        f"{row['date']}:{row['side']}:{row['reason']}" for _, row in aux_detail_df.iterrows()
+    )
+    return aux_layer, detail
+
+
+def _build_taxonomy_markdown(base_dir: Path, path_df: pd.DataFrame) -> None:
+    lines = [
+        "# Dragon Rule Taxonomy",
+        "",
+        "## Layer 1 Market State",
+        "- `high_regime`: entries born in hot / high-C1 continuation or late-trend expansion states.",
+        "- `mid_regime`: classic glued or middle-zone continuation entries.",
+        "- `low_oversold_regime`: deep oversold, dual-gold reversal, low-C1 rebound, oversold recovery entries.",
+        "- `rebound_after_sell_regime`: reentries after a prior sell, predictive error recovery, post-washout restart.",
+        "- `crash_probe_regime`: early crash probe entries that intentionally test panic states.",
+        "",
+        "## Layer 2 Entry Qualification",
+    ]
+
+    qualification_map = (
+        path_df[["buy_reason", "entry_qualification_layer"]]
+        .drop_duplicates()
+        .sort_values(["entry_qualification_layer", "buy_reason"])
+    )
+    for _, row in qualification_map.iterrows():
+        lines.append(f"- `{row['buy_reason']}` -> `{row['entry_qualification_layer']}`")
+
+    lines.extend(["", "## Layer 3 Position Management"])
+    management_map = (
+        path_df[["sell_reason", "position_management_layer"]]
+        .drop_duplicates()
+        .sort_values(["position_management_layer", "sell_reason"])
+    )
+    for _, row in management_map.iterrows():
+        lines.append(f"- `{row['sell_reason']}` -> `{row['position_management_layer']}`")
+
+    lines.extend(
+        [
+            "",
+            "## Layer 4 Auxiliary Signal Context",
+            "- `no_aux_signal`: no auxiliary confirmation inside the holding window.",
+            "- `aux_buy_only`: only holding-period bullish confirmation appeared.",
+            "- `aux_sell_only`: only post-exit or in-trade bearish confirmation appeared.",
+            "- `aux_buy_and_aux_sell`: both auxiliary bullish and bearish signals appeared within the trade path.",
+            "",
+            "## Path Summary",
+        ]
+    )
+
+    path_summary = (
+        path_df.groupby(
+            [
+                "market_state_layer",
+                "entry_qualification_layer",
+                "position_management_layer",
+                "aux_context_layer",
+            ]
+        )
+        .size()
+        .reset_index(name="count")
+        .sort_values("count", ascending=False)
+    )
+    for _, row in path_summary.head(30).iterrows():
+        lines.append(
+            f"- `{row['market_state_layer']}` -> `{row['entry_qualification_layer']}` -> "
+            f"`{row['position_management_layer']}` -> `{row['aux_context_layer']}`: `{int(row['count'])}`"
+        )
+
+    (base_dir / "dragon_rule_taxonomy.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+
+def main() -> None:
+    base_dir = Path(__file__).resolve().parent
+    trades = pd.read_csv(base_dir / "dragon_strategy_trades.csv", encoding="utf-8-sig")
+    events = pd.read_csv(base_dir / "dragon_strategy_events.csv", encoding="utf-8-sig")
+    workbook = pd.read_csv(base_dir / "dragon_workbook_layers.csv", encoding="utf-8-sig")
+
+    trades["buy_dt"] = pd.to_datetime(trades["buy_date"])
+    trades["sell_dt"] = pd.to_datetime(trades["sell_date"])
+    events["dt"] = pd.to_datetime(events["date"])
+    trades["next_buy_dt"] = trades["buy_dt"].shift(-1)
+    trades["next_buy_date"] = trades["buy_date"].shift(-1)
+
+    buy_events = (
+        events[(events["layer"] == "real_trade") & (events["side"] == "BUY")]
+        .rename(columns={"date": "buy_date", "close": "buy_close", "a1": "buy_a1", "b1": "buy_b1", "c1": "buy_c1"})
+        [["buy_date", "buy_close", "buy_a1", "buy_b1", "buy_c1", "kdj_buy", "ql_buy"]]
+        .copy()
+    )
+    sell_events = (
+        events[(events["layer"] == "real_trade") & (events["side"] == "SELL")]
+        .rename(columns={"date": "sell_date", "close": "sell_close", "a1": "sell_a1", "b1": "sell_b1", "c1": "sell_c1"})
+        [["sell_date", "sell_close", "sell_a1", "sell_b1", "sell_c1", "kdj_sell", "ql_sell"]]
+        .copy()
+    )
+
+    path_df = trades.merge(buy_events, on="buy_date", how="left").merge(sell_events, on="sell_date", how="left")
+    path_df["market_state_layer"] = path_df.apply(lambda row: _infer_state_layer(str(row["buy_reason"]), float(row["buy_c1"])), axis=1)
+    path_df["entry_qualification_layer"] = path_df["buy_reason"].map(_infer_entry_layer)
+    path_df["position_management_layer"] = path_df["sell_reason"].map(_infer_management_layer)
+
+    workbook_real_buy = set(workbook[(workbook["layer"] == "real_trade") & (workbook["side"] == "BUY")]["date"])
+    workbook_real_sell = set(workbook[(workbook["layer"] == "real_trade") & (workbook["side"] == "SELL")]["date"])
+    path_df["buy_aligned_with_workbook"] = path_df["buy_date"].isin(workbook_real_buy)
+    path_df["sell_aligned_with_workbook"] = path_df["sell_date"].isin(workbook_real_sell)
+
+    aux_layers: list[str] = []
+    aux_details: list[str] = []
+    aux_counts: list[int] = []
+    hold_aux_buy_counts: list[int] = []
+    hold_aux_buy_details: list[str] = []
+    post_exit_aux_sell_counts: list[int] = []
+    post_exit_aux_sell_details: list[str] = []
+    for _, trade in path_df.iterrows():
+        hold_aux_buy = events[
+            (events["layer"] == "aux_signal")
+            & (events["side"] == "BUY")
+            & (events["dt"] >= trade["buy_dt"])
+            & (events["dt"] <= trade["sell_dt"])
+        ].sort_values("dt")
+
+        post_exit_mask = (
+            (events["layer"] == "aux_signal")
+            & (events["side"] == "SELL")
+            & (events["dt"] > trade["sell_dt"])
+        )
+        if pd.notna(trade["next_buy_dt"]):
+            post_exit_mask = post_exit_mask & (events["dt"] < trade["next_buy_dt"])
+        post_exit_aux_sell = events[post_exit_mask].sort_values("dt")
+
+        aux_layer, aux_detail = _infer_aux_context(hold_aux_buy, post_exit_aux_sell)
+        aux_layers.append(aux_layer)
+        aux_details.append(aux_detail)
+        aux_counts.append(len(hold_aux_buy) + len(post_exit_aux_sell))
+        hold_aux_buy_counts.append(len(hold_aux_buy))
+        hold_aux_buy_details.append(
+            " | ".join(f"{row['date']}:BUY:{row['reason']}" for _, row in hold_aux_buy.iterrows())
+        )
+        post_exit_aux_sell_counts.append(len(post_exit_aux_sell))
+        post_exit_aux_sell_details.append(
+            " | ".join(f"{row['date']}:SELL:{row['reason']}" for _, row in post_exit_aux_sell.iterrows())
+        )
+
+    path_df["aux_context_layer"] = aux_layers
+    path_df["aux_signal_count"] = aux_counts
+    path_df["aux_signal_detail"] = aux_details
+    path_df["hold_aux_buy_count"] = hold_aux_buy_counts
+    path_df["hold_aux_buy_detail"] = hold_aux_buy_details
+    path_df["post_exit_aux_sell_count"] = post_exit_aux_sell_counts
+    path_df["post_exit_aux_sell_detail"] = post_exit_aux_sell_details
+    path_df["layer_path"] = path_df.apply(
+        lambda row: " > ".join(
+            [
+                row["market_state_layer"],
+                row["entry_qualification_layer"],
+                row["position_management_layer"],
+                row["aux_context_layer"],
+            ]
+        ),
+        axis=1,
+    )
+
+    output_cols = [
+        "buy_date",
+        "sell_date",
+        "holding_days",
+        "return_pct",
+        "buy_reason",
+        "sell_reason",
+        "buy_a1",
+        "buy_b1",
+        "buy_c1",
+        "sell_a1",
+        "sell_b1",
+        "sell_c1",
+        "market_state_layer",
+        "entry_qualification_layer",
+        "position_management_layer",
+        "aux_context_layer",
+        "aux_signal_count",
+        "hold_aux_buy_count",
+        "hold_aux_buy_detail",
+        "post_exit_aux_sell_count",
+        "post_exit_aux_sell_detail",
+        "aux_signal_detail",
+        "buy_aligned_with_workbook",
+        "sell_aligned_with_workbook",
+        "next_buy_date",
+        "layer_path",
+    ]
+    path_df[output_cols].to_csv(base_dir / "dragon_trade_path_trace.csv", index=False, encoding="utf-8-sig")
+    _build_taxonomy_markdown(base_dir, path_df)
+
+
+if __name__ == "__main__":
+    main()

+ 159 - 0
research/dragon/v2/dragon_validate.py

@@ -0,0 +1,159 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pandas as pd
+
+from dragon_indicators import DragonIndicatorConfig, DragonIndicatorEngine
+from dragon_workbook import DragonWorkbook
+
+
+KDJ_BUY_MARKER = chr(0x91D1)
+KDJ_SELL_MARKER = chr(0x6B7B)
+QL_BUY_MARKER = "B"
+QL_SELL_MARKER = "S"
+
+
+def _find_workbook(base_dir: Path) -> Path:
+    matches = sorted(base_dir.glob("*.xlsx"))
+    if not matches:
+        raise FileNotFoundError(f"No workbook found in {base_dir}")
+    return matches[0]
+
+
+def _marker_match(workbook_value: object, computed_value: bool, positive: str) -> bool:
+    if workbook_value is None:
+        return not computed_value
+    if not isinstance(workbook_value, str):
+        return not computed_value
+    return (positive in workbook_value) == bool(computed_value)
+
+
+def _explicit_marker(value: object, allowed: set[str]) -> str | None:
+    if not isinstance(value, str):
+        return None
+    stripped = value.strip()
+    return stripped if stripped in allowed else None
+
+
+def main() -> None:
+    base_dir = Path(__file__).resolve().parent
+    workbook_path = _find_workbook(base_dir)
+    workbook = DragonWorkbook(workbook_path)
+    split_events = workbook.split_layers()
+    annual_summary = workbook.load_annual_summary()
+
+    config = DragonIndicatorConfig(start_date="2015-01-01", end_date="2026-01-31")
+    engine = DragonIndicatorEngine(config)
+    raw_df = engine.fetch_daily_data()
+    indicators = engine.compute(raw_df)
+
+    indicators_out = indicators.reset_index().rename(columns={"index": "date"})
+    indicators_out["date"] = pd.to_datetime(indicators_out["date"]).dt.date.astype(str)
+    indicators_out.to_csv(base_dir / "dragon_indicator_snapshot.csv", index=False, encoding="utf-8-sig")
+
+    benchmark_df = pd.DataFrame(
+        [
+            {
+                "date": event.date.isoformat(),
+                "side": event.side,
+                "layer": event.layer,
+                "signal_reason": event.signal_reason,
+                "index_close": event.index_close,
+                "prev_index": event.prev_index,
+                "capital": event.capital,
+                "pnl": event.pnl,
+                "kdj": event.kdj,
+                "ql": event.ql,
+                "note": event.note,
+            }
+            for event in split_events
+        ]
+    )
+    benchmark_df.to_csv(base_dir / "dragon_workbook_layers.csv", index=False, encoding="utf-8-sig")
+
+    workbook_markers = benchmark_df[["date", "kdj", "ql"]].drop_duplicates(subset=["date"]).copy()
+    workbook_markers["kdj_marker"] = workbook_markers["kdj"].apply(
+        lambda value: _explicit_marker(value, {KDJ_BUY_MARKER, KDJ_SELL_MARKER})
+    )
+    workbook_markers["ql_marker"] = workbook_markers["ql"].apply(
+        lambda value: _explicit_marker(value, {QL_BUY_MARKER, QL_SELL_MARKER})
+    )
+
+    compare = workbook_markers.merge(
+        indicators_out[
+            ["date", "a1", "b1", "c1", "kdj_buy", "kdj_sell", "ql_buy", "ql_sell", "close"]
+        ],
+        on="date",
+        how="left",
+    )
+    compare["kdj_buy_match"] = compare.apply(
+        lambda row: _marker_match(row["kdj_marker"], bool(row["kdj_buy"]), KDJ_BUY_MARKER),
+        axis=1,
+    )
+    compare["kdj_sell_match"] = compare.apply(
+        lambda row: _marker_match(row["kdj_marker"], bool(row["kdj_sell"]), KDJ_SELL_MARKER),
+        axis=1,
+    )
+    compare["ql_buy_match"] = compare.apply(
+        lambda row: _marker_match(row["ql_marker"], bool(row["ql_buy"]), QL_BUY_MARKER),
+        axis=1,
+    )
+    compare["ql_sell_match"] = compare.apply(
+        lambda row: _marker_match(row["ql_marker"], bool(row["ql_sell"]), QL_SELL_MARKER),
+        axis=1,
+    )
+    compare.to_csv(base_dir / "dragon_signal_alignment.csv", index=False, encoding="utf-8-sig")
+
+    real_events = benchmark_df[benchmark_df["layer"] == "real_trade"]
+    aux_events = benchmark_df[benchmark_df["layer"] == "aux_signal"]
+    kdj_rows = compare[compare["kdj_marker"].notna()]
+    ql_rows = compare[compare["ql_marker"].notna()]
+    kdj_sell_mismatch_dates = compare.loc[
+        compare["kdj_marker"].eq(KDJ_SELL_MARKER) & ~compare["kdj_sell_match"], "date"
+    ].tolist()
+    ql_buy_mismatch_dates = compare.loc[
+        compare["ql_marker"].eq(QL_BUY_MARKER) & ~compare["ql_buy_match"], "date"
+    ].tolist()
+
+    lines = [
+        "# Dragon V2 Validation",
+        "",
+        f"- Source workbook: `{workbook_path.name}`",
+        f"- Indicator snapshot rows: `{len(indicators_out)}`",
+        f"- Workbook layered events: `{len(benchmark_df)}`",
+        f"- Real trade events: `{len(real_events)}`",
+        f"- Auxiliary events: `{len(aux_events)}`",
+        f"- Annual summary rows: `{len(annual_summary)}`",
+        "",
+        "## Formula Scope",
+        "- `A1`: EMA(8) vs EMA(EMA(8),20) normalized spread",
+        "- `B1`: ((Y2 - Y3) / 100), where Y2/Y3 come from 38-day RSV smoothed by SMA(5,1) and SMA(10,1)",
+        "- `C1`: (Y2 + Y3) / 2",
+        "- `QL phoenix line`: approximated from the existing Dragon code as `B = CROSS(close, upper_line)` and `S = CROSS(lower_line, close)`",
+        "",
+        "## Alignment",
+        f"- KDJ rows in workbook: `{len(kdj_rows)}`",
+        f"- QL rows in workbook: `{len(ql_rows)}`",
+        f"- KDJ buy marker matches: `{int(kdj_rows['kdj_buy_match'].sum())}/{len(kdj_rows)}`",
+        f"- KDJ sell marker matches: `{int(kdj_rows['kdj_sell_match'].sum())}/{len(kdj_rows)}`",
+        f"- QL buy marker matches: `{int(ql_rows['ql_buy_match'].sum())}/{len(ql_rows)}`",
+        f"- QL sell marker matches: `{int(ql_rows['ql_sell_match'].sum())}/{len(ql_rows)}`",
+        f"- KDJ sell mismatch dates: `{kdj_sell_mismatch_dates}`",
+        f"- QL buy mismatch dates: `{ql_buy_mismatch_dates}`",
+        "",
+        "## Output Files",
+        "- `dragon_indicator_snapshot.csv`",
+        "- `dragon_workbook_layers.csv`",
+        "- `dragon_signal_alignment.csv`",
+        "",
+        "## Notes",
+        "- This validation stage checks indicator reconstruction and marker alignment only.",
+        "- It does not yet implement the full Dragon entry/exit rule tree from the workbook narrative.",
+        "- Repeated SELL rows after exit and repeated BUY rows during holding remain auxiliary signals by confirmed user semantics.",
+    ]
+    (base_dir / "dragon_validation.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+
+if __name__ == "__main__":
+    main()

+ 242 - 0
research/dragon/v2/dragon_walk_forward_validation.py

@@ -0,0 +1,242 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pandas as pd
+
+
+def _load_csv(base_dir: Path, name: str) -> pd.DataFrame:
+    return pd.read_csv(base_dir / name, encoding="utf-8-sig")
+
+
+def _profit_factor(series: pd.Series) -> float:
+    gross_profit = series[series > 0].sum()
+    gross_loss = -series[series < 0].sum()
+    if gross_loss == 0:
+        return float("inf") if gross_profit > 0 else 0.0
+    return float(gross_profit / gross_loss)
+
+
+def _max_drawdown(series: pd.Series) -> float:
+    if series.empty:
+        return float("nan")
+    equity = (1.0 + series).cumprod()
+    running_max = equity.cummax()
+    drawdown = equity / running_max - 1.0
+    return float(drawdown.min())
+
+
+def _segment_stats(df: pd.DataFrame) -> dict[str, float | int]:
+    if df.empty:
+        return {
+            "trades": 0,
+            "win_rate": float("nan"),
+            "avg_return": float("nan"),
+            "median_return": float("nan"),
+            "profit_factor": float("nan"),
+            "compounded_return": float("nan"),
+            "max_drawdown": float("nan"),
+        }
+    returns = df["return_pct"].astype(float)
+    return {
+        "trades": int(len(df)),
+        "win_rate": float((returns > 0).mean()),
+        "avg_return": float(returns.mean()),
+        "median_return": float(returns.median()),
+        "profit_factor": _profit_factor(returns),
+        "compounded_return": float((1.0 + returns).prod() - 1.0),
+        "max_drawdown": _max_drawdown(returns),
+    }
+
+
+def _format_pct(value: float) -> str:
+    if pd.isna(value):
+        return "NA"
+    if value == float("inf"):
+        return "inf"
+    return f"{value:.2%}"
+
+
+def _format_num(value: float) -> str:
+    if pd.isna(value):
+        return "NA"
+    if value == float("inf"):
+        return "inf"
+    return f"{value:.2f}"
+
+
+def _build_walk_forward(trades: pd.DataFrame) -> pd.DataFrame:
+    years = sorted(int(year) for year in trades["sell_year"].unique())
+    rows: list[dict[str, object]] = []
+
+    for idx, test_year in enumerate(years):
+        if idx >= 1:
+            train_years = years[:idx]
+            train_df = trades[trades["sell_year"].isin(train_years)]
+            test_df = trades[trades["sell_year"] == test_year]
+            train_stats = _segment_stats(train_df)
+            test_stats = _segment_stats(test_df)
+            rows.append(
+                {
+                    "scheme": "anchored_expanding",
+                    "train_start_year": train_years[0],
+                    "train_end_year": train_years[-1],
+                    "test_year": test_year,
+                    **{f"train_{k}": v for k, v in train_stats.items()},
+                    **{f"test_{k}": v for k, v in test_stats.items()},
+                }
+            )
+
+        if idx >= 3:
+            train_years = years[idx - 3 : idx]
+            train_df = trades[trades["sell_year"].isin(train_years)]
+            test_df = trades[trades["sell_year"] == test_year]
+            train_stats = _segment_stats(train_df)
+            test_stats = _segment_stats(test_df)
+            rows.append(
+                {
+                    "scheme": "rolling_3y",
+                    "train_start_year": train_years[0],
+                    "train_end_year": train_years[-1],
+                    "test_year": test_year,
+                    **{f"train_{k}": v for k, v in train_stats.items()},
+                    **{f"test_{k}": v for k, v in test_stats.items()},
+                }
+            )
+    return pd.DataFrame(rows)
+
+
+def _build_family_stability(trades: pd.DataFrame) -> tuple[pd.DataFrame, pd.DataFrame]:
+    df = trades.copy()
+    df["entry_family"] = df["buy_reason"].astype(str).str.split(":").str[0]
+
+    family_year = (
+        df.groupby(["entry_family", "sell_year"], dropna=False)
+        .apply(
+            lambda g: pd.Series(
+                {
+                    "trades": int(len(g)),
+                    "win_rate": float((g["return_pct"] > 0).mean()),
+                    "avg_return": float(g["return_pct"].mean()),
+                    "profit_factor": _profit_factor(g["return_pct"]),
+                    "compounded_return": float((1.0 + g["return_pct"]).prod() - 1.0),
+                }
+            )
+        )
+        .reset_index()
+    )
+
+    eligible_families = (
+        df.groupby("entry_family")
+        .size()
+        .reset_index(name="total_trades")
+        .query("total_trades >= 3")["entry_family"]
+        .tolist()
+    )
+    family_year = family_year[family_year["entry_family"].isin(eligible_families)].copy()
+
+    family_summary = (
+        family_year.groupby("entry_family", dropna=False)
+        .apply(
+            lambda g: pd.Series(
+                {
+                    "years_active": int(len(g)),
+                    "total_trades": int(g["trades"].sum()),
+                    "positive_years": int((g["avg_return"] > 0).sum()),
+                    "negative_years": int((g["avg_return"] < 0).sum()),
+                    "avg_yearly_avg_return": float(g["avg_return"].mean()),
+                    "min_yearly_avg_return": float(g["avg_return"].min()),
+                    "max_yearly_avg_return": float(g["avg_return"].max()),
+                }
+            )
+        )
+        .reset_index()
+        .sort_values(["avg_yearly_avg_return", "total_trades"], ascending=[False, False])
+    )
+
+    return family_year, family_summary
+
+
+def main() -> None:
+    base_dir = Path(__file__).resolve().parent
+    trades = _load_csv(base_dir, "dragon_strategy_trades.csv").copy()
+    trades["sell_dt"] = pd.to_datetime(trades["sell_date"])
+    trades["sell_year"] = trades["sell_dt"].dt.year.astype(int)
+
+    walk_forward = _build_walk_forward(trades)
+    family_year, family_summary = _build_family_stability(trades)
+
+    walk_forward.to_csv(base_dir / "dragon_walk_forward_summary.csv", index=False, encoding="utf-8-sig")
+    family_year.to_csv(base_dir / "dragon_walk_forward_family_year.csv", index=False, encoding="utf-8-sig")
+    family_summary.to_csv(base_dir / "dragon_walk_forward_family_stability.csv", index=False, encoding="utf-8-sig")
+
+    anchored = walk_forward[walk_forward["scheme"] == "anchored_expanding"].copy()
+    rolling = walk_forward[walk_forward["scheme"] == "rolling_3y"].copy()
+
+    lines = [
+        "# Dragon Walk-Forward Validation",
+        "",
+        "- Method: fixed current baseline rules, no refit, evaluate temporal stability by yearly out-of-sample slices.",
+        "- Goal: verify whether the workbook-preserving baseline still behaves coherently outside any single full-sample summary.",
+        "",
+        "## Anchored Expanding Windows",
+    ]
+    for _, row in anchored.iterrows():
+        lines.append(
+            f"- train `{int(row['train_start_year'])}-{int(row['train_end_year'])}` -> test `{int(row['test_year'])}`: "
+            f"test trades `{int(row['test_trades'])}`, test avg_return `{_format_pct(float(row['test_avg_return']))}`, "
+            f"test profit_factor `{_format_num(float(row['test_profit_factor']))}`, "
+            f"test compounded_return `{_format_pct(float(row['test_compounded_return']))}`, "
+            f"test max_drawdown `{_format_pct(float(row['test_max_drawdown']))}`"
+        )
+
+    lines.extend(["", "## Rolling 3Y Windows"])
+    for _, row in rolling.iterrows():
+        lines.append(
+            f"- train `{int(row['train_start_year'])}-{int(row['train_end_year'])}` -> test `{int(row['test_year'])}`: "
+            f"test trades `{int(row['test_trades'])}`, test avg_return `{_format_pct(float(row['test_avg_return']))}`, "
+            f"test profit_factor `{_format_num(float(row['test_profit_factor']))}`, "
+            f"test compounded_return `{_format_pct(float(row['test_compounded_return']))}`, "
+            f"test max_drawdown `{_format_pct(float(row['test_max_drawdown']))}`"
+        )
+
+    lines.extend(["", "## Entry-Family Stability"])
+    for _, row in family_summary.head(8).iterrows():
+        lines.append(
+            f"- `{row['entry_family']}`: years_active `{int(row['years_active'])}`, total_trades `{int(row['total_trades'])}`, "
+            f"positive_years `{int(row['positive_years'])}`, negative_years `{int(row['negative_years'])}`, "
+            f"avg_yearly_avg_return `{_format_pct(float(row['avg_yearly_avg_return']))}`, "
+            f"min_yearly_avg_return `{_format_pct(float(row['min_yearly_avg_return']))}`"
+        )
+
+    weakest = family_summary.sort_values(["avg_yearly_avg_return", "min_yearly_avg_return"]).head(5)
+    lines.extend(["", "## Weak Entry-Family Stability"])
+    for _, row in weakest.iterrows():
+        lines.append(
+            f"- `{row['entry_family']}`: years_active `{int(row['years_active'])}`, total_trades `{int(row['total_trades'])}`, "
+            f"positive_years `{int(row['positive_years'])}`, negative_years `{int(row['negative_years'])}`, "
+            f"avg_yearly_avg_return `{_format_pct(float(row['avg_yearly_avg_return']))}`, "
+            f"min_yearly_avg_return `{_format_pct(float(row['min_yearly_avg_return']))}`"
+        )
+
+    positive_anchored = int((anchored["test_avg_return"] > 0).sum()) if not anchored.empty else 0
+    negative_anchored = int((anchored["test_avg_return"] < 0).sum()) if not anchored.empty else 0
+    positive_rolling = int((rolling["test_avg_return"] > 0).sum()) if not rolling.empty else 0
+    negative_rolling = int((rolling["test_avg_return"] < 0).sum()) if not rolling.empty else 0
+
+    lines.extend(
+        [
+            "",
+            "## Quant Judgment",
+            f"- Anchored walk-forward windows: positive years `{positive_anchored}`, negative years `{negative_anchored}`.",
+            f"- Rolling 3Y windows: positive years `{positive_rolling}`, negative years `{negative_rolling}`.",
+            "- This is a stability audit, not a parameter-search walk-forward. The strategy was held fixed throughout.",
+            "- Families with repeated negative yearly averages are research candidates; families with broad multi-year persistence are baseline keepers.",
+        ]
+    )
+
+    (base_dir / "dragon_walk_forward_report.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+
+if __name__ == "__main__":
+    main()

+ 193 - 0
research/dragon/v2/dragon_workbook.py

@@ -0,0 +1,193 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import date, datetime
+from pathlib import Path
+from typing import Iterable, Optional
+
+from openpyxl import load_workbook
+
+
+BUY_CODEPOINT = 0x4E70
+SELL_CODEPOINT = 0x5356
+
+
+@dataclass(frozen=True)
+class WorkbookEvent:
+    date: date
+    index_close: Optional[float]
+    prev_index: Optional[float]
+    side: str
+    capital: Optional[float]
+    pnl: Optional[float]
+    kdj: Optional[str]
+    ql: Optional[str]
+    note: str
+
+
+@dataclass(frozen=True)
+class SplitEvent:
+    date: date
+    side: str
+    layer: str
+    signal_reason: str
+    index_close: Optional[float]
+    prev_index: Optional[float]
+    capital: Optional[float]
+    pnl: Optional[float]
+    kdj: Optional[str]
+    ql: Optional[str]
+    note: str
+
+
+def _normalize_optional_float(value: object) -> Optional[float]:
+    if value is None:
+        return None
+    if isinstance(value, str):
+        stripped = value.strip()
+        if not stripped or stripped.startswith("#"):
+            return None
+    try:
+        return float(value)
+    except (TypeError, ValueError):
+        return None
+
+
+def _normalize_side(value: object) -> Optional[str]:
+    if value is None:
+        return None
+    if isinstance(value, str):
+        stripped = value.strip()
+        if not stripped:
+            return None
+        if len(stripped) == 1:
+            codepoint = ord(stripped)
+            if codepoint == BUY_CODEPOINT:
+                return "BUY"
+            if codepoint == SELL_CODEPOINT:
+                return "SELL"
+        if stripped in {"买", "BUY"}:
+            return "BUY"
+        if stripped in {"卖", "SELL"}:
+            return "SELL"
+    return None
+
+
+class DragonWorkbook:
+    def __init__(self, workbook_path: Path):
+        self.workbook_path = Path(workbook_path)
+        self._events: Optional[list[WorkbookEvent]] = None
+        self._annual_summary: Optional[list[dict[str, Optional[float]]]] = None
+
+    def load_events(self) -> list[WorkbookEvent]:
+        if self._events is not None:
+            return self._events
+
+        wb = load_workbook(self.workbook_path, data_only=True, read_only=True)
+        ws = wb.worksheets[0]
+        events: list[WorkbookEvent] = []
+        for row in ws.iter_rows(min_row=4, values_only=True):
+            raw_date = row[0]
+            if not isinstance(raw_date, datetime):
+                continue
+            side = _normalize_side(row[3])
+            if side is None:
+                continue
+            events.append(
+                WorkbookEvent(
+                    date=raw_date.date(),
+                    index_close=_normalize_optional_float(row[1]),
+                    prev_index=_normalize_optional_float(row[2]),
+                    side=side,
+                    capital=_normalize_optional_float(row[4]),
+                    pnl=_normalize_optional_float(row[5]),
+                    kdj=(row[6].strip() if isinstance(row[6], str) else row[6]),
+                    ql=(row[7].strip() if isinstance(row[7], str) else row[7]),
+                    note=(row[8].strip() if isinstance(row[8], str) else ""),
+                )
+            )
+        self._events = events
+        return events
+
+    def load_annual_summary(self) -> list[dict[str, Optional[float]]]:
+        if self._annual_summary is not None:
+            return self._annual_summary
+
+        wb = load_workbook(self.workbook_path, data_only=True, read_only=True)
+        ws = wb.worksheets[0]
+        annual: list[dict[str, Optional[float]]] = []
+        for row in ws.iter_rows(values_only=True):
+            year = row[0]
+            if not isinstance(year, int) or year < 2000 or year > 2100:
+                continue
+            if not isinstance(row[4], (int, float)):
+                continue
+            annual.append(
+                {
+                    "year": year,
+                    "index_return": _normalize_optional_float(row[2]),
+                    "strategy_return": _normalize_optional_float(row[4]),
+                }
+            )
+        self._annual_summary = annual
+        return annual
+
+    def split_layers(self) -> list[SplitEvent]:
+        events = self.load_events()
+        state = "flat"
+        split: list[SplitEvent] = []
+        for event in events:
+            layer = "aux_signal"
+            reason = "unclassified"
+            if state == "flat" and event.side == "BUY":
+                layer = "real_trade"
+                reason = "entry"
+                state = "long"
+            elif state == "long" and event.side == "SELL":
+                layer = "real_trade"
+                reason = "exit"
+                state = "flat"
+            elif state == "flat" and event.side == "SELL":
+                reason = "bearish_signal_after_exit"
+            elif state == "long" and event.side == "BUY":
+                reason = "bullish_signal_while_holding"
+
+            split.append(
+                SplitEvent(
+                    date=event.date,
+                    side=event.side,
+                    layer=layer,
+                    signal_reason=reason,
+                    index_close=event.index_close,
+                    prev_index=event.prev_index,
+                    capital=event.capital,
+                    pnl=event.pnl,
+                    kdj=event.kdj,
+                    ql=event.ql,
+                    note=event.note,
+                )
+            )
+        return split
+
+    def iter_real_trade_pairs(self) -> Iterable[dict[str, object]]:
+        open_buy: Optional[SplitEvent] = None
+        for event in self.split_layers():
+            if event.layer != "real_trade":
+                continue
+            if event.side == "BUY":
+                open_buy = event
+                continue
+            if open_buy is None:
+                continue
+            yield {
+                "buy_date": open_buy.date.isoformat(),
+                "buy_index": open_buy.index_close,
+                "buy_note": open_buy.note,
+                "sell_date": event.date.isoformat(),
+                "sell_index": event.index_close,
+                "sell_note": event.note,
+                "holding_days": (event.date - open_buy.date).days,
+                "return_pct": event.pnl,
+                "ending_capital": event.capital,
+            }
+            open_buy = None

+ 10 - 0
research/dragon/v2/update_dragon_reports.ps1

@@ -0,0 +1,10 @@
+$ErrorActionPreference = "Stop"
+Set-Location -LiteralPath $PSScriptRoot
+
+Write-Host "[dragon] running forward observation pipeline..."
+py .\dragon_forward_observation_pipeline.py
+
+Write-Host "[dragon] reports updated:"
+Write-Host "  - dragon_reports_index.html"
+Write-Host "  - dragon_daily_signal_report.html"
+Write-Host "  - dragon_forward_weekly_review.html"