Sfoglia il codice sorgente

Add RC1 reason metadata and golden regression tests

erwin 1 mese fa
parent
commit
a2f3048c25

+ 83 - 0
research/dragon/v2/MEMORY.md

@@ -963,3 +963,86 @@
 - the useful code changes for `dragon/v2` were already committed in git as `9f7ce16` with message `Add dragon v2 strategy research and reporting code`
 - user explicitly requires workspace memory files to be committed too, not just kept locally
 - follow-up git work should therefore include `MEMORY.md`, `USER.md`, and daily notes under `memory/` when they contain important session context
+
+## 2026-04-07
+
+- Performed a serious code-based overfitting review on the current `dragon/v2` core strategy and parameter surface.
+- Current verdict: not a fake strategy and not a pure one-threshold curve-fit, but still medium-to-high overfitting risk.
+- Main positive evidence:
+- branch-level alpha refinement only changes a small subset of parameters from the base branch
+- walk-forward, local sensitivity, and execution/cost stress remain directionally supportive
+- Main risk evidence:
+- `StrategyConfig` exposes `102` fields
+- `dragon_strategy.py` still contains a large hand-built rule tree with many narrow branches
+- several low-sample special-case families remain, especially `deep_oversold_*`, `predictive_b1_break_*`, and one-off reentry/exit logic
+- Strategic conclusion:
+- strongest stable core remains `glued_buy` plus its refined selective filter family
+- weakest families should be treated as provisional bridge rules or reconstruction residue, not core alpha
+
+- Later on `2026-04-07`, built an external consultation bundle for `dragon/v2`:
+- directory: `dragon_v2_consult_2026-04-07/`
+- zip: `dragon_v2_consult_2026-04-07.zip`
+- bundle includes:
+- a Chinese memo summarizing bottlenecks / risks
+- a suggested ChatGPT Pro review prompt
+- core source code and parameter snapshots
+- the most relevant robustness / fragility reports
+- selected supporting data and workbook exports
+- Main bundle judgment:
+- not a fake strategy, but still medium-to-high overfitting risk
+- architecture bottleneck is the monolithic, order-sensitive rule tree in `dragon_strategy.py`
+- biggest weak families remain `deep_oversold_*` and `predictive_b1_break_*`
+
+## 2026-04-08
+
+- Ran the daily live signal chain on request date `2026-04-08`.
+- Initially, latest available market bar resolved to `2026-04-07` and there was no buy trigger.
+- Later the live signal path was upgraded to support intraday realtime evaluation:
+- if today's official daily bar is missing, append one realtime snapshot and use the current market price as today's provisional `close`
+- implemented in:
+- `dragon/data_fetcher_v2.py`
+- `dragon_indicators.py`
+- `dragon_daily_signal_pipeline.py`
+- Daily report and manifest now explicitly show:
+- `data_mode`
+- `historical_latest_bar_date`
+- `snapshot_timestamp`
+- Verified live run after the change:
+- latest evaluated bar became `2026-04-08` via realtime snapshot while the historical latest official bar remained `2026-04-07`
+- all three tracked branches still remained flat and no new event fired
+- latest real event for `workbook_preserving`, `alpha_first_selective_veto`, and `alpha_first_glued_refined_hot_cap` is still `2026-02-13 SELL knife_take_profit_2_glued`
+- latest realtime snapshot around `2026-04-08T14:47:31` had `KDJ buy=True` and `QL buy=True`, but the buy path was blocked in all three branches by `buy_block_glued_high_weak_rebound`
+- Later analysis on `2026-04-08` confirmed `buy_block_glued_high_weak_rebound` is materially useful:
+- official daily-bar replay through `2026-04-07` shows `17` historical blocks (`15` inside `2016-01-01 -> 2025-12-31`) on each tracked branch
+- disabling only this filter adds `15` trades and weakens all three branches
+- RC1 impact when disabled:
+- trades `91 -> 106`
+- avg_return `3.42% -> 2.80%`
+- profit_factor `5.11 -> 4.08`
+- added trades are weak on average (`-0.99%`, win rate about `26.7%`)
+- current conclusion: keep this filter; it is a real quality-preserving filter rather than cosmetic complexity
+
+## 2026-04-09
+
+- Continued the RC1 restructuring proposal execution after user request to continue.
+- Completed validation pass for the new compatibility-first metadata integration:
+- `dragon_strategy.py` post-run enrichment for `reason_layer/reason_family/reason_code` on events and `buy_reason_*`/`sell_reason_*` on trades.
+- Rebuilt golden baseline and refreshed `dragon_rc1_golden_manifest.json` (`generated_at=2026-04-09T00:57:54`).
+- Golden summary remains stable:
+- trade_count `91`
+- event_count `272`
+- win_rate `52.75%`
+- avg_return `3.42%`
+- New manifest now carries stable core hashes:
+- events core sha256 `8965d1b539a998d7d0aff04432aa2a47cf30ee40df013b9d8b7eb66a3d50a331`
+- trades core sha256 `1298be56b0898266b0b854d62a979c00c20b01629393c82bb8c804faf852cb97`
+- Re-ran layer attribution; `dragon_rule_layer_attribution.md` still reports `no unknown reason mapping`.
+- Added/ran tests and all passed:
+- `tests/test_rc1_golden_regression.py`
+- `tests/test_reason_catalog_mapping.py`
+- `py -3 -m unittest discover -s tests -v` -> `OK` (`3` tests).
+- Smoke-regression on production path also passed:
+- `py -3 dragon_daily_signal_pipeline.py --as-of 2026-04-08` ran successfully after the metadata enrichment.
+- Improved test portability:
+- `tests/test_reason_catalog_mapping.py` now derives trade reasons directly from strategy output (snapshot + engine) instead of relying on `dragon_historical_trade_details.csv`.
+- this removes a local-cache dependency and keeps tests runnable in cleaner environments.

+ 21 - 0
research/dragon/v2/dragon_decision_types.py

@@ -0,0 +1,21 @@
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+
+from dragon_reason_types import DecisionReason
+
+
+@dataclass(frozen=True)
+class StrategyDecision:
+    action: str
+    reason: DecisionReason | None = None
+    metadata: dict[str, object] = field(default_factory=dict)
+
+    @property
+    def legacy_reason(self) -> str:
+        if self.reason is None:
+            return ""
+        return self.reason.legacy_reason
+
+    def to_legacy_tuple(self) -> tuple[str, str]:
+        return self.action, self.legacy_reason

+ 38 - 0
research/dragon/v2/dragon_rc1_frozen_policy.md

@@ -0,0 +1,38 @@
+# Dragon RC1 Frozen Policy
+
+## Scope
+
+- effective branch: `alpha_first_glued_refined_hot_cap`
+- release tag: `RC1`
+- index: `399673`
+- release window for governance metrics: `2016-01-01` to `2025-12-31`
+
+## Branch Roles
+
+- `alpha_first_glued_refined_hot_cap`
+  - role: frozen forward candidate
+  - rule: no direct core-threshold edits on this branch
+
+- `alpha_first_selective_veto`
+  - role: benchmark control
+  - rule: freeze unless governance decision explicitly changes it
+
+- `workbook_preserving`
+  - role: reconstruction reference
+  - rule: use for reconstruction checks, not as the main optimization target
+
+## Change Rule
+
+- any behavior-changing strategy experiment must run in a new named research branch
+- every behavior-changing branch must ship:
+  - trade/event attribution diff vs RC1
+  - execution-aware stress check
+  - layer-level attribution (`core/secondary/bridge`)
+
+## Required Artifacts
+
+- `dragon_rc1_golden_manifest.json`
+- `dragon_rc1_golden_trades.csv`
+- `dragon_rc1_golden_events.csv`
+- `dragon_rule_layer_attribution.csv`
+- `dragon_rule_layer_attribution_summary.csv`

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

@@ -0,0 +1,163 @@
+from __future__ import annotations
+
+import hashlib
+import json
+from dataclasses import asdict
+from datetime import datetime
+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
+from dragon_strategy import DragonRuleEngine
+
+
+RC_VERSION = "RC1"
+RC_BRANCH = "alpha_first_glued_refined_hot_cap"
+TRADES_OUTPUT = "dragon_rc1_golden_trades.csv"
+EVENTS_OUTPUT = "dragon_rc1_golden_events.csv"
+MANIFEST_OUTPUT = "dragon_rc1_golden_manifest.json"
+EVENTS_CORE_COLUMNS = [
+    "date",
+    "side",
+    "layer",
+    "reason",
+    "close",
+    "a1",
+    "b1",
+    "c1",
+    "kdj_buy",
+    "kdj_sell",
+    "ql_buy",
+    "ql_sell",
+]
+TRADES_CORE_COLUMNS = [
+    "buy_date",
+    "buy_price",
+    "buy_reason",
+    "sell_date",
+    "sell_price",
+    "sell_reason",
+    "holding_days",
+    "return_pct",
+]
+
+
+def _sha256(path: Path) -> str:
+    digest = hashlib.sha256()
+    with path.open("rb") as fp:
+        for chunk in iter(lambda: fp.read(1024 * 1024), b""):
+            digest.update(chunk)
+    return digest.hexdigest()
+
+
+def _df_sha256(df: pd.DataFrame) -> str:
+    digest = hashlib.sha256()
+    payload = df.to_csv(index=False, encoding="utf-8", float_format="%.15g")
+    digest.update(payload.encode("utf-8"))
+    return digest.hexdigest()
+
+
+def _load_indicator_snapshot(base_dir: Path) -> tuple[pd.DataFrame, str]:
+    full_snapshot = base_dir / "dragon_indicator_snapshot_full.csv"
+    if full_snapshot.exists():
+        df = pd.read_csv(full_snapshot, encoding="utf-8-sig")
+        source = full_snapshot.name
+    else:
+        fallback_snapshot = base_dir / "dragon_indicator_snapshot.csv"
+        if fallback_snapshot.exists():
+            df = pd.read_csv(fallback_snapshot, encoding="utf-8-sig")
+            source = fallback_snapshot.name
+        else:
+            engine = DragonIndicatorEngine(DragonIndicatorConfig(start_date="2015-01-01", end_date=None))
+            df = (
+                engine.compute(engine.fetch_daily_data())
+                .reset_index(drop=False)
+                .rename(columns={"index": "date"})
+            )
+            source = "fetched_live_history"
+    df["date"] = pd.to_datetime(df["date"])
+    indexed = df.sort_values("date").set_index("date")
+    return indexed, source
+
+
+def main() -> None:
+    base_dir = Path(__file__).resolve().parent
+    indexed, source = _load_indicator_snapshot(base_dir)
+
+    config = alpha_first_glued_refined_hot_cap_config()
+    engine = DragonRuleEngine(config=config)
+    events, trades = engine.run(indexed)
+
+    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()
+
+    events.insert(0, "branch", RC_BRANCH)
+    trades.insert(0, "branch", RC_BRANCH)
+
+    events.sort_values(["date", "side", "layer", "reason"], inplace=True)
+    trades.sort_values(["buy_date", "sell_date", "buy_reason", "sell_reason"], inplace=True)
+
+    events_path = base_dir / EVENTS_OUTPUT
+    trades_path = base_dir / TRADES_OUTPUT
+    manifest_path = base_dir / MANIFEST_OUTPUT
+
+    events.to_csv(events_path, index=False, encoding="utf-8-sig")
+    trades.to_csv(trades_path, index=False, encoding="utf-8-sig")
+
+    returns = trades["return_pct"].astype(float) if not trades.empty else pd.Series(dtype=float)
+    summary = {
+        "trade_count": int(len(trades)),
+        "event_count": int(len(events)),
+        "win_rate": float((returns > 0).mean()) if not returns.empty else float("nan"),
+        "avg_return": float(returns.mean()) if not returns.empty else float("nan"),
+        "median_return": float(returns.median()) if not returns.empty else float("nan"),
+    }
+
+    manifest = {
+        "generated_at": datetime.now().isoformat(timespec="seconds"),
+        "release_version": RC_VERSION,
+        "branch": RC_BRANCH,
+        "evaluation_window": {"start": START_DATE, "end": END_DATE},
+        "indicator_source": source,
+        "artifacts": {
+            "trades": {
+                "path": TRADES_OUTPUT,
+                "sha256": _sha256(trades_path),
+                "columns": list(trades.columns),
+                "core_columns": TRADES_CORE_COLUMNS,
+                "core_sha256": _df_sha256(trades[TRADES_CORE_COLUMNS]),
+            },
+            "events": {
+                "path": EVENTS_OUTPUT,
+                "sha256": _sha256(events_path),
+                "columns": list(events.columns),
+                "core_columns": EVENTS_CORE_COLUMNS,
+                "core_sha256": _df_sha256(events[EVENTS_CORE_COLUMNS]),
+            },
+        },
+        "summary": summary,
+        "config_snapshot": {
+            **asdict(config),
+            "disabled_rules": sorted(config.disabled_rules),
+        },
+    }
+
+    manifest_path.write_text(
+        json.dumps(manifest, indent=2, ensure_ascii=False) + "\n",
+        encoding="utf-8",
+    )
+
+
+if __name__ == "__main__":
+    main()

+ 192 - 0
research/dragon/v2/dragon_rc1_golden_manifest.json

@@ -0,0 +1,192 @@
+{
+  "generated_at": "2026-04-09T00:57:54",
+  "release_version": "RC1",
+  "branch": "alpha_first_glued_refined_hot_cap",
+  "evaluation_window": {
+    "start": "2016-01-01",
+    "end": "2025-12-31"
+  },
+  "indicator_source": "dragon_indicator_snapshot_full.csv",
+  "artifacts": {
+    "trades": {
+      "path": "dragon_rc1_golden_trades.csv",
+      "sha256": "8a0626a538f15de1c08e97a951f4c899ed31b1427cf31c705aaa9a5c2d7445f5",
+      "columns": [
+        "branch",
+        "buy_date",
+        "buy_price",
+        "buy_reason",
+        "sell_date",
+        "sell_price",
+        "sell_reason",
+        "holding_days",
+        "return_pct",
+        "buy_reason_layer",
+        "buy_reason_family",
+        "buy_reason_code",
+        "sell_reason_layer",
+        "sell_reason_family",
+        "sell_reason_code"
+      ],
+      "core_columns": [
+        "buy_date",
+        "buy_price",
+        "buy_reason",
+        "sell_date",
+        "sell_price",
+        "sell_reason",
+        "holding_days",
+        "return_pct"
+      ],
+      "core_sha256": "1298be56b0898266b0b854d62a979c00c20b01629393c82bb8c804faf852cb97"
+    },
+    "events": {
+      "path": "dragon_rc1_golden_events.csv",
+      "sha256": "5fc38170d18e16251e13abb2a8508c23ed84115e6215ffe389b419eb6e8d3117",
+      "columns": [
+        "branch",
+        "date",
+        "side",
+        "layer",
+        "reason",
+        "close",
+        "a1",
+        "b1",
+        "c1",
+        "kdj_buy",
+        "kdj_sell",
+        "ql_buy",
+        "ql_sell",
+        "reason_layer",
+        "reason_family",
+        "reason_code"
+      ],
+      "core_columns": [
+        "date",
+        "side",
+        "layer",
+        "reason",
+        "close",
+        "a1",
+        "b1",
+        "c1",
+        "kdj_buy",
+        "kdj_sell",
+        "ql_buy",
+        "ql_sell"
+      ],
+      "core_sha256": "8965d1b539a998d7d0aff04432aa2a47cf30ee40df013b9d8b7eb66a3d50a331"
+    }
+  },
+  "summary": {
+    "trade_count": 91,
+    "event_count": 272,
+    "win_rate": 0.5274725274725275,
+    "avg_return": 0.03419460023548253,
+    "median_return": 0.0025255098042289426
+  },
+  "config_snapshot": {
+    "disabled_rules": [],
+    "post_exit_confirmation_window_days": 10,
+    "aux_sell_same_side_once_per_cycle": true,
+    "aux_sell_duplicate_cooldown_days": 5,
+    "aux_sell_high_zone_kdj_only_block_c1": 85.0,
+    "aux_sell_high_zone_kdj_only_block_b1": -0.02,
+    "aux_sell_high_zone_warning_c1": 80.0,
+    "aux_sell_strong_break_c1": 60.0,
+    "aux_sell_strong_break_b1": -0.05,
+    "aux_sell_stronger_c1_delta": 8.0,
+    "aux_sell_stronger_b1_delta": 0.05,
+    "aux_sell_high_zone_rearm_c1_delta": 2.0,
+    "state_crash_followthrough_window_days": 5,
+    "state_crash_followthrough_repeat_cooldown_days": 4,
+    "state_crash_followthrough_c1_max": 80.0,
+    "state_crash_followthrough_a1_max": 0.01,
+    "state_crash_followthrough_b1_max": -0.15,
+    "glued_high_weak_rebound_high_c1": 68.0,
+    "glued_high_weak_rebound_high_b1": -0.08,
+    "glued_high_weak_rebound_mid_c1": 50.0,
+    "glued_high_weak_rebound_mid_b1": -0.15,
+    "glued_high_weak_rebound_ql_c1_low": 35.0,
+    "glued_high_weak_rebound_ql_c1_high": 55.0,
+    "glued_high_weak_rebound_ql_b1": -0.06,
+    "glued_high_weak_rebound_ql_a1": -0.013,
+    "glued_selective_hot_c1_min": 40.0,
+    "glued_selective_hot_c1_max": 75.0,
+    "glued_selective_hot_b1_min": 0.1,
+    "glued_selective_low_c1_min": 23.0,
+    "glued_selective_low_c1_max": 28.0,
+    "glued_selective_low_b1_max": 0.02,
+    "deep_oversold_filter1_c1_low": 13.0,
+    "deep_oversold_filter1_c1_high": 15.0,
+    "deep_oversold_filter1_a1_min": -0.04,
+    "deep_oversold_filter1_b1_max": -0.08,
+    "deep_oversold_filter2_c1_low": 13.0,
+    "deep_oversold_filter2_c1_high": 14.5,
+    "deep_oversold_filter2_a1_min": -0.04,
+    "deep_oversold_filter2_b1_min": -0.06,
+    "deep_oversold_entry_c1_max": 16.0,
+    "deep_oversold_entry_a1_min": -0.09,
+    "deep_oversold_entry_b1_min": -0.1,
+    "deep_oversold_shallow_ql_fallback": true,
+    "deep_oversold_positive_b1_fallback_a1_min": -0.02,
+    "deep_oversold_block_positive_b1_rebound": false,
+    "deep_oversold_block_shallow_false_start_without_ql": false,
+    "deep_oversold_confirm_weak_with_ql": false,
+    "deep_oversold_confirm_window_bars": 2,
+    "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,
+    "oversold_recovery_c1_low": 18.0,
+    "oversold_recovery_c1_high": 22.0,
+    "oversold_recovery_a1_min": -0.03,
+    "oversold_recovery_a1_max": 0.0,
+    "oversold_recovery_b1_min": -0.02,
+    "oversold_reversal_after_ql_block_c1_low": 23.0,
+    "oversold_reversal_after_ql_block_c1_high": 26.0,
+    "oversold_reversal_after_ql_block_b1_min": -0.12,
+    "oversold_reversal_after_ql_block_a1_min": -0.035,
+    "oversold_reversal_after_ql_entry_c1_low": 20.0,
+    "oversold_reversal_after_ql_entry_c1_high": 26.0,
+    "oversold_reversal_after_ql_entry_a1_min": -0.04,
+    "oversold_reversal_after_ql_entry_a1_max": 0.0,
+    "oversold_reversal_after_ql_entry_b1_min": -0.22,
+    "oversold_reversal_after_ql_entry_b1_max": 0.0,
+    "post_sell_rebound_block_high_c1": 22.0,
+    "post_sell_rebound_block_high_a1_min": -0.035,
+    "post_sell_rebound_block_high_b1_max": -0.07,
+    "post_sell_rebound_block_low_c1": 15.0,
+    "post_sell_rebound_block_low_a1_min": -0.04,
+    "post_sell_rebound_block_low_b1_max": -0.095,
+    "post_sell_rebound_entry1_c1_low": 18.0,
+    "post_sell_rebound_entry1_c1_high": 30.0,
+    "post_sell_rebound_entry1_a1_min": -0.045,
+    "post_sell_rebound_entry1_a1_max": 0.0,
+    "post_sell_rebound_entry1_b1_low": -0.09,
+    "post_sell_rebound_entry1_b1_high": -0.04,
+    "post_sell_rebound_entry2_c1_high": 19.0,
+    "post_sell_rebound_entry2_a1_min": -0.04,
+    "post_sell_rebound_entry2_a1_max": 0.0,
+    "post_sell_rebound_entry2_b1_low": -0.13,
+    "post_sell_rebound_entry2_b1_high": -0.09,
+    "predictive_b1_break_short_holding_days_max": 2,
+    "predictive_b1_break_short_a1_min": -0.02,
+    "predictive_b1_break_short_a1_max": 0.0,
+    "predictive_b1_break_short_b1_max": -0.13,
+    "predictive_b1_break_short_c1_low": 50.0,
+    "predictive_b1_break_short_c1_high": 70.0,
+    "predictive_b1_break_long_holding_days_min": 40,
+    "predictive_b1_break_long_max_c1": 80.0,
+    "predictive_b1_break_long_max_a1": 0.15,
+    "predictive_b1_break_long_max_b1": 0.3,
+    "predictive_b1_break_long_ql_days_max": 7,
+    "predictive_b1_break_long_a1_min": -0.02,
+    "predictive_b1_break_long_a1_max": 0.0,
+    "predictive_b1_break_long_b1_max": -0.12,
+    "predictive_b1_break_long_c1_low": 60.0,
+    "predictive_b1_break_long_c1_high": 65.0,
+    "enable_knife_take_profit_2_wait_ql": true
+  }
+}

+ 44 - 0
research/dragon/v2/dragon_reason_types.py

@@ -0,0 +1,44 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from enum import Enum
+
+
+class RuleLayer(str, Enum):
+    CORE = "core"
+    SECONDARY = "secondary"
+    BRIDGE = "bridge"
+    UNKNOWN = "unknown"
+
+
+class RuleFamily(str, Enum):
+    GLUED = "glued"
+    DEEP_OVERSOLD = "deep_oversold"
+    PREDICTIVE_BREAK = "predictive_break"
+    POST_SELL_REBOUND = "post_sell_rebound"
+    DUAL_GOLD = "dual_gold"
+    EARLY_CRASH = "early_crash"
+    OVERSOLD_RECOVERY = "oversold_recovery"
+    HIGH_REGIME = "high_regime"
+    RISK_MANAGEMENT = "risk_management"
+    BRIDGE_REENTRY = "bridge_reentry"
+    AUXILIARY = "auxiliary"
+    UNKNOWN = "unknown"
+
+
+@dataclass(frozen=True)
+class DecisionReason:
+    code: str
+    layer: RuleLayer
+    family: RuleFamily
+    legacy_reason: str
+    tags: tuple[str, ...] = ()
+
+    def as_dict(self) -> dict[str, object]:
+        return {
+            "code": self.code,
+            "layer": self.layer.value,
+            "family": self.family.value,
+            "legacy_reason": self.legacy_reason,
+            "tags": list(self.tags),
+        }

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

@@ -0,0 +1,123 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from dragon_reason_types import DecisionReason, RuleFamily, RuleLayer
+
+
+@dataclass(frozen=True)
+class _RulePattern:
+    match_type: str  # exact or prefix
+    pattern: str
+    layer: RuleLayer
+    family: RuleFamily
+    code: str
+    tags: tuple[str, ...] = ()
+
+    def matches(self, legacy_reason: str) -> bool:
+        if self.match_type == "exact":
+            return legacy_reason == self.pattern
+        if self.match_type == "prefix":
+            return legacy_reason.startswith(self.pattern)
+        raise ValueError(f"Unknown match_type: {self.match_type}")
+
+
+ENTRY_PATTERNS: tuple[_RulePattern, ...] = (
+    _RulePattern("exact", "glued_buy", RuleLayer.CORE, RuleFamily.GLUED, "entry_glued_buy"),
+    _RulePattern("exact", "early_crash_probe_buy", RuleLayer.CORE, RuleFamily.EARLY_CRASH, "entry_early_crash_probe"),
+    _RulePattern("exact", "oversold_recovery_buy", RuleLayer.CORE, RuleFamily.OVERSOLD_RECOVERY, "entry_oversold_recovery"),
+    _RulePattern("exact", "dual_gold_resonance_buy", RuleLayer.SECONDARY, RuleFamily.DUAL_GOLD, "entry_dual_gold"),
+    _RulePattern("prefix", "deep_oversold_rebound_buy", RuleLayer.SECONDARY, RuleFamily.DEEP_OVERSOLD, "entry_deep_oversold"),
+    _RulePattern("exact", "post_sell_rebound_buy", RuleLayer.SECONDARY, RuleFamily.POST_SELL_REBOUND, "entry_post_sell_rebound"),
+    _RulePattern("exact", "oversold_reversal_after_ql_buy", RuleLayer.SECONDARY, RuleFamily.OVERSOLD_RECOVERY, "entry_oversold_reversal_after_ql"),
+    _RulePattern("exact", "hot_exit_reentry_buy", RuleLayer.BRIDGE, RuleFamily.BRIDGE_REENTRY, "entry_hot_exit_reentry"),
+    _RulePattern("exact", "predictive_error_reentry_buy", RuleLayer.BRIDGE, RuleFamily.PREDICTIVE_BREAK, "entry_predictive_error_reentry"),
+    _RulePattern("exact", "post_washout_kdj_reentry_buy", RuleLayer.BRIDGE, RuleFamily.BRIDGE_REENTRY, "entry_post_washout_reentry"),
+    _RulePattern("exact", "knife_catch_1", RuleLayer.SECONDARY, RuleFamily.DEEP_OVERSOLD, "entry_knife_catch_1"),
+    _RulePattern("exact", "knife_catch_2", RuleLayer.SECONDARY, RuleFamily.DEEP_OVERSOLD, "entry_knife_catch_2"),
+)
+
+
+EXIT_PATTERNS: tuple[_RulePattern, ...] = (
+    _RulePattern("exact", "predictive_b1_break_exit", RuleLayer.BRIDGE, RuleFamily.PREDICTIVE_BREAK, "exit_predictive_b1_break"),
+    _RulePattern("exact", "post_ql_decay_exit", RuleLayer.BRIDGE, RuleFamily.PREDICTIVE_BREAK, "exit_post_ql_decay"),
+    _RulePattern("exact", "crash_protection_exit", RuleLayer.CORE, RuleFamily.RISK_MANAGEMENT, "exit_crash_protection"),
+    _RulePattern("exact", "prewarning_reduction_exit", RuleLayer.CORE, RuleFamily.RISK_MANAGEMENT, "exit_prewarning_reduction"),
+    _RulePattern("exact", "medium_hot_take_profit", RuleLayer.CORE, RuleFamily.HIGH_REGIME, "exit_medium_hot_take_profit"),
+    _RulePattern("exact", "ql_high_zone_take_profit", RuleLayer.CORE, RuleFamily.HIGH_REGIME, "exit_ql_high_zone_take_profit"),
+    _RulePattern("exact", "ql_mid_zone_take_profit", RuleLayer.CORE, RuleFamily.HIGH_REGIME, "exit_ql_mid_zone_take_profit"),
+    _RulePattern("prefix", "high_regime_", RuleLayer.CORE, RuleFamily.HIGH_REGIME, "exit_high_regime"),
+    _RulePattern("prefix", "glued_exit:", RuleLayer.CORE, RuleFamily.GLUED, "exit_glued_signal"),
+    _RulePattern("prefix", "knife_take_profit_", RuleLayer.CORE, RuleFamily.GLUED, "exit_glued_take_profit"),
+    _RulePattern("prefix", "hard_exit:", RuleLayer.CORE, RuleFamily.RISK_MANAGEMENT, "exit_hard_risk"),
+    _RulePattern("prefix", "negative_a1_no_b1_recovery:", RuleLayer.SECONDARY, RuleFamily.POST_SELL_REBOUND, "exit_negative_a1_recovery"),
+    _RulePattern("prefix", "small_positive_a1_declining:", RuleLayer.SECONDARY, RuleFamily.HIGH_REGIME, "exit_small_positive_decline"),
+    _RulePattern("prefix", "low_zone_dual_gold_exit:", RuleLayer.SECONDARY, RuleFamily.DUAL_GOLD, "exit_low_zone_dual_gold"),
+    _RulePattern("exact", "early_failed_rebound_exit", RuleLayer.SECONDARY, RuleFamily.POST_SELL_REBOUND, "exit_early_failed_rebound"),
+    _RulePattern("exact", "good_to_take_profit_2:kdj_sell", RuleLayer.CORE, RuleFamily.HIGH_REGIME, "exit_good_to_take_profit_2"),
+    _RulePattern("prefix", "negative_a1_b1_not_strong:", RuleLayer.SECONDARY, RuleFamily.POST_SELL_REBOUND, "exit_negative_a1_b1_not_strong"),
+    _RulePattern("exact", "early_positive_take_profit", RuleLayer.CORE, RuleFamily.HIGH_REGIME, "exit_early_positive_take_profit"),
+    _RulePattern("exact", "high_zone_post_ql_fade_exit", RuleLayer.CORE, RuleFamily.HIGH_REGIME, "exit_high_zone_post_ql_fade"),
+    _RulePattern("exact", "oversold_rebound_take_profit", RuleLayer.SECONDARY, RuleFamily.DEEP_OVERSOLD, "exit_oversold_rebound_take_profit"),
+)
+
+
+AUX_PATTERNS: tuple[_RulePattern, ...] = (
+    _RulePattern("prefix", "bearish_signal_after_exit:", RuleLayer.BRIDGE, RuleFamily.AUXILIARY, "aux_bearish_after_exit"),
+    _RulePattern("exact", "bullish_signal_while_holding", RuleLayer.BRIDGE, RuleFamily.AUXILIARY, "aux_bullish_while_holding"),
+)
+
+
+def _classify_with_patterns(
+    legacy_reason: str,
+    patterns: tuple[_RulePattern, ...],
+    default_layer: RuleLayer,
+    default_family: RuleFamily,
+    default_code: str,
+) -> DecisionReason:
+    normalized = str(legacy_reason or "").strip()
+    for rule in patterns:
+        if rule.matches(normalized):
+            return DecisionReason(
+                code=rule.code,
+                layer=rule.layer,
+                family=rule.family,
+                legacy_reason=normalized,
+                tags=rule.tags,
+            )
+    return DecisionReason(
+        code=default_code,
+        layer=default_layer,
+        family=default_family,
+        legacy_reason=normalized,
+    )
+
+
+def classify_entry_reason(legacy_reason: str) -> DecisionReason:
+    return _classify_with_patterns(
+        legacy_reason=legacy_reason,
+        patterns=ENTRY_PATTERNS,
+        default_layer=RuleLayer.UNKNOWN,
+        default_family=RuleFamily.UNKNOWN,
+        default_code="entry_unknown",
+    )
+
+
+def classify_exit_reason(legacy_reason: str) -> DecisionReason:
+    return _classify_with_patterns(
+        legacy_reason=legacy_reason,
+        patterns=EXIT_PATTERNS,
+        default_layer=RuleLayer.UNKNOWN,
+        default_family=RuleFamily.UNKNOWN,
+        default_code="exit_unknown",
+    )
+
+
+def classify_aux_reason(legacy_reason: str) -> DecisionReason:
+    return _classify_with_patterns(
+        legacy_reason=legacy_reason,
+        patterns=AUX_PATTERNS,
+        default_layer=RuleLayer.UNKNOWN,
+        default_family=RuleFamily.UNKNOWN,
+        default_code="aux_unknown",
+    )

+ 33 - 0
research/dragon/v2/dragon_rule_layer_attribution.md

@@ -0,0 +1,33 @@
+# Dragon Rule Layer Attribution
+
+- branch: `alpha_first_glued_refined_hot_cap`
+- evaluation_window: `2016-01-01` to `2025-12-31`
+- trades: `91`
+
+## Entry Layer Summary
+- `core/glued` trades `50` win_rate `56.00%` avg_return `4.92%` profit_factor `7.15`
+- `secondary/dual_gold` trades `13` win_rate `46.15%` avg_return `1.06%` profit_factor `2.08`
+- `secondary/deep_oversold` trades `10` win_rate `50.00%` avg_return `-0.19%` profit_factor `0.80`
+- `core/early_crash` trades `6` win_rate `66.67%` avg_return `4.62%` profit_factor `10.52`
+- `core/oversold_recovery` trades `4` win_rate `50.00%` avg_return `3.35%` profit_factor `6.65`
+- `secondary/post_sell_rebound` trades `4` win_rate `25.00%` avg_return `1.84%` profit_factor `2.56`
+- `bridge/bridge_reentry` trades `2` win_rate `50.00%` avg_return `1.71%` profit_factor `2.20`
+- `bridge/predictive_break` trades `1` win_rate `100.00%` avg_return `1.99%` profit_factor `inf`
+- `secondary/oversold_recovery` trades `1` win_rate `0.00%` avg_return `-0.77%` profit_factor `0.00`
+
+## Exit Layer Summary
+- `core/glued` trades `54` win_rate `48.15%` avg_return `0.18%` profit_factor `1.22`
+- `core/high_regime` trades `11` win_rate `90.91%` avg_return `11.63%` profit_factor `107.38`
+- `secondary/post_sell_rebound` trades `10` win_rate `10.00%` avg_return `-2.00%` profit_factor `0.02`
+- `core/risk_management` trades `9` win_rate `66.67%` avg_return `15.26%` profit_factor `28.68`
+- `secondary/high_regime` trades `3` win_rate `66.67%` avg_return `2.93%` profit_factor `5.76`
+- `bridge/predictive_break` trades `2` win_rate `100.00%` avg_return `24.46%` profit_factor `inf`
+- `secondary/deep_oversold` trades `1` win_rate `100.00%` avg_return `0.40%` profit_factor `inf`
+- `secondary/dual_gold` trades `1` win_rate `0.00%` avg_return `-2.07%` profit_factor `0.00`
+
+## Unknown Mapping Audit
+- no unknown reason mapping
+
+## Artifacts
+- `dragon_rule_layer_attribution.csv`
+- `dragon_rule_layer_attribution_summary.csv`

+ 153 - 0
research/dragon/v2/dragon_rule_layer_attribution.py

@@ -0,0 +1,153 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pandas as pd
+
+from dragon_rule_catalog import classify_entry_reason, classify_exit_reason
+from dragon_shared import END_DATE, START_DATE, format_num, format_pct, profit_factor
+
+
+DEFAULT_BRANCH = "alpha_first_glued_refined_hot_cap"
+DETAIL_OUTPUT = "dragon_rule_layer_attribution.csv"
+SUMMARY_OUTPUT = "dragon_rule_layer_attribution_summary.csv"
+REPORT_OUTPUT = "dragon_rule_layer_attribution.md"
+
+
+def _aggregate(df: pd.DataFrame, layer_col: str, family_col: str, label: str) -> pd.DataFrame:
+    if df.empty:
+        return pd.DataFrame(columns=["view", "layer", "family", "trades", "win_rate", "avg_return", "median_return", "profit_factor"])
+
+    records: list[dict[str, object]] = []
+    grouped = df.groupby([layer_col, family_col], dropna=False)
+    for (layer_value, family_value), group in grouped:
+        returns = group["return_pct"].astype(float)
+        row: dict[str, object] = {
+            "view": label,
+            "layer": str(layer_value),
+            "family": str(family_value),
+        }
+        row.update(
+            {
+                "trades": int(len(group)),
+                "win_rate": float((returns > 0).mean()),
+                "avg_return": float(returns.mean()),
+                "median_return": float(returns.median()),
+                "profit_factor": profit_factor(returns),
+            }
+        )
+        records.append(row)
+    return pd.DataFrame(records).sort_values(["trades", "avg_return"], ascending=[False, False])
+
+
+def main() -> None:
+    base_dir = Path(__file__).resolve().parent
+    details_path = base_dir / "dragon_historical_trade_details.csv"
+    details = pd.read_csv(details_path, encoding="utf-8-sig")
+
+    details = details[details["branch"] == DEFAULT_BRANCH].copy()
+    details = details[
+        (details["buy_date"] >= START_DATE)
+        & (details["buy_date"] <= END_DATE)
+        & (details["sell_date"] >= START_DATE)
+        & (details["sell_date"] <= END_DATE)
+    ].copy()
+
+    buy_meta = details["buy_reason"].map(classify_entry_reason)
+    sell_meta = details["sell_reason"].map(classify_exit_reason)
+
+    details["buy_layer"] = [meta.layer.value for meta in buy_meta]
+    details["buy_family"] = [meta.family.value for meta in buy_meta]
+    details["buy_code"] = [meta.code for meta in buy_meta]
+    details["sell_layer"] = [meta.layer.value for meta in sell_meta]
+    details["sell_family"] = [meta.family.value for meta in sell_meta]
+    details["sell_code"] = [meta.code for meta in sell_meta]
+
+    detail_cols = [
+        "branch",
+        "buy_date",
+        "buy_reason",
+        "buy_layer",
+        "buy_family",
+        "buy_code",
+        "sell_date",
+        "sell_reason",
+        "sell_layer",
+        "sell_family",
+        "sell_code",
+        "holding_days",
+        "return_pct",
+    ]
+    detail_df = details[detail_cols].copy()
+    detail_df.to_csv(base_dir / DETAIL_OUTPUT, index=False, encoding="utf-8-sig")
+
+    buy_summary = _aggregate(detail_df, "buy_layer", "buy_family", label="entry_layer_family")
+    sell_summary = _aggregate(detail_df, "sell_layer", "sell_family", label="exit_layer_family")
+    summary_df = pd.concat([buy_summary, sell_summary], ignore_index=True)
+    summary_df.to_csv(base_dir / SUMMARY_OUTPUT, index=False, encoding="utf-8-sig")
+
+    unknown_entry = detail_df[detail_df["buy_layer"] == "unknown"]["buy_reason"].value_counts()
+    unknown_exit = detail_df[detail_df["sell_layer"] == "unknown"]["sell_reason"].value_counts()
+
+    lines: list[str] = [
+        "# Dragon Rule Layer Attribution",
+        "",
+        f"- branch: `{DEFAULT_BRANCH}`",
+        f"- evaluation_window: `{START_DATE}` to `{END_DATE}`",
+        f"- trades: `{len(detail_df)}`",
+        "",
+        "## Entry Layer Summary",
+    ]
+
+    if buy_summary.empty:
+        lines.append("- no data")
+    else:
+        for _, row in buy_summary.iterrows():
+            lines.append(
+                "- "
+                f"`{row['layer']}/{row['family']}` "
+                f"trades `{int(row['trades'])}` "
+                f"win_rate `{format_pct(float(row['win_rate']))}` "
+                f"avg_return `{format_pct(float(row['avg_return']))}` "
+                f"profit_factor `{format_num(float(row['profit_factor']))}`"
+            )
+
+    lines.extend(["", "## Exit Layer Summary"])
+    if sell_summary.empty:
+        lines.append("- no data")
+    else:
+        for _, row in sell_summary.iterrows():
+            lines.append(
+                "- "
+                f"`{row['layer']}/{row['family']}` "
+                f"trades `{int(row['trades'])}` "
+                f"win_rate `{format_pct(float(row['win_rate']))}` "
+                f"avg_return `{format_pct(float(row['avg_return']))}` "
+                f"profit_factor `{format_num(float(row['profit_factor']))}`"
+            )
+
+    lines.extend(["", "## Unknown Mapping Audit"])
+    if unknown_entry.empty and unknown_exit.empty:
+        lines.append("- no unknown reason mapping")
+    else:
+        if not unknown_entry.empty:
+            for reason, count in unknown_entry.items():
+                lines.append(f"- unknown entry reason `{reason}`: `{int(count)}`")
+        if not unknown_exit.empty:
+            for reason, count in unknown_exit.items():
+                lines.append(f"- unknown exit reason `{reason}`: `{int(count)}`")
+
+    lines.extend(
+        [
+            "",
+            "## Artifacts",
+            f"- `{DETAIL_OUTPUT}`",
+            f"- `{SUMMARY_OUTPUT}`",
+        ]
+    )
+
+    (base_dir / REPORT_OUTPUT).write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+
+if __name__ == "__main__":
+    main()

+ 46 - 1
research/dragon/v2/dragon_strategy.py

@@ -6,6 +6,8 @@ from typing import Optional
 
 import pandas as pd
 
+from dragon_decision_types import StrategyDecision
+from dragon_rule_catalog import classify_aux_reason, classify_entry_reason, classify_exit_reason
 from dragon_strategy_config import StrategyConfig
 
 
@@ -82,6 +84,23 @@ class DragonRuleEngine:
     def _rule_enabled(self, rule_name: str) -> bool:
         return rule_name not in self.config.disabled_rules
 
+    def _classify_reason(self, side: str, layer: str, reason: str):
+        if layer == "aux_signal":
+            return classify_aux_reason(reason)
+        if side == "BUY":
+            return classify_entry_reason(reason)
+        return classify_exit_reason(reason)
+
+    def _build_decision(self, side: str, layer: str, reason: str) -> StrategyDecision:
+        if side == "BUY":
+            action = "BUY" if layer == "real_trade" else "AUX_BUY"
+        else:
+            action = "SELL" if layer == "real_trade" else "AUX_SELL"
+        return StrategyDecision(
+            action=action,
+            reason=self._classify_reason(side, layer, reason),
+        )
+
     def _entry_reason_is(self, *reason_names: str) -> bool:
         entry_reason = self.context.entry_reason
         entry_family = entry_reason.split(":", 1)[0]
@@ -1294,4 +1313,30 @@ class DragonRuleEngine:
 
             prev_row = row
 
-        return pd.DataFrame(events), pd.DataFrame(trades)
+        events_df = pd.DataFrame(events)
+        trades_df = pd.DataFrame(trades)
+
+        if not events_df.empty:
+            event_decisions = [
+                self._build_decision(
+                    side=str(row["side"]),
+                    layer=str(row["layer"]),
+                    reason=str(row["reason"]),
+                )
+                for _, row in events_df.iterrows()
+            ]
+            events_df["reason_layer"] = [d.reason.layer.value if d.reason is not None else "unknown" for d in event_decisions]
+            events_df["reason_family"] = [d.reason.family.value if d.reason is not None else "unknown" for d in event_decisions]
+            events_df["reason_code"] = [d.reason.code if d.reason is not None else "" for d in event_decisions]
+
+        if not trades_df.empty:
+            buy_meta = trades_df["buy_reason"].map(classify_entry_reason)
+            sell_meta = trades_df["sell_reason"].map(classify_exit_reason)
+            trades_df["buy_reason_layer"] = [meta.layer.value for meta in buy_meta]
+            trades_df["buy_reason_family"] = [meta.family.value for meta in buy_meta]
+            trades_df["buy_reason_code"] = [meta.code for meta in buy_meta]
+            trades_df["sell_reason_layer"] = [meta.layer.value for meta in sell_meta]
+            trades_df["sell_reason_family"] = [meta.family.value for meta in sell_meta]
+            trades_df["sell_reason_code"] = [meta.code for meta in sell_meta]
+
+        return events_df, trades_df

+ 28 - 0
research/dragon/v2/memory/2026-04-09.md

@@ -0,0 +1,28 @@
+## 2026-04-09
+
+- Continued implementation of `dragon_v2_rc1_restructuring_proposal_cn.md` after user asked to continue.
+- Validated new structured reason metadata integration in `dragon_strategy.py`:
+- post-run enrichment now appends `reason_layer/reason_family/reason_code` to events
+- and `buy_reason_*` / `sell_reason_*` metadata to trades while preserving legacy `reason` strings.
+- Ran compile check successfully:
+- `py -3 -m py_compile dragon_strategy.py dragon_rc1_golden_baseline.py dragon_rule_catalog.py tests/test_rc1_golden_regression.py tests/test_reason_catalog_mapping.py`
+- Regenerated golden baseline:
+- `py -3 dragon_rc1_golden_baseline.py`
+- refreshed manifest `dragon_rc1_golden_manifest.json` with new `generated_at` (`2026-04-09T00:57:54`) and core hashes:
+- events core sha256 `8965d1b539a998d7d0aff04432aa2a47cf30ee40df013b9d8b7eb66a3d50a331`
+- trades core sha256 `1298be56b0898266b0b854d62a979c00c20b01629393c82bb8c804faf852cb97`
+- summary remains stable at RC1 baseline:
+- trade_count `91`, event_count `272`, win_rate `52.75%`, avg_return `3.42%`.
+- Regenerated layer attribution:
+- `py -3 dragon_rule_layer_attribution.py`
+- `dragon_rule_layer_attribution.md` still reports `no unknown reason mapping`.
+- Ran tests successfully:
+- `py -3 -m unittest discover -s tests -v`
+- result: `3` tests passed (`OK`), covering golden regression and reason catalog mapping.
+- Ran end-to-end daily signal pipeline regression:
+- `py -3 dragon_daily_signal_pipeline.py --as-of 2026-04-08`
+- pipeline executed successfully after strategy output enrichment, confirming no immediate downstream break from the added reason metadata columns.
+- Hardened test portability:
+- `tests/test_reason_catalog_mapping.py` no longer depends on local `dragon_historical_trade_details.csv`.
+- It now uses `_load_indicator_snapshot(...)` + `DragonRuleEngine.run(...)` to derive release-window reasons directly from strategy output.
+- Re-ran tests after this change: all `3` tests still pass.

+ 1 - 0
research/dragon/v2/tests/__init__.py

@@ -0,0 +1 @@
+# Test package for dragon v2 governance and regression checks.

+ 63 - 0
research/dragon/v2/tests/test_rc1_golden_regression.py

@@ -0,0 +1,63 @@
+from __future__ import annotations
+
+import json
+import unittest
+from pathlib import Path
+
+from dragon_branch_configs import alpha_first_glued_refined_hot_cap_config
+from dragon_rc1_golden_baseline import (
+    EVENTS_CORE_COLUMNS,
+    MANIFEST_OUTPUT,
+    TRADES_CORE_COLUMNS,
+    _df_sha256,
+    _load_indicator_snapshot,
+    main as build_golden_baseline,
+)
+from dragon_shared import END_DATE, START_DATE
+from dragon_strategy import DragonRuleEngine
+
+
+class TestRC1GoldenRegression(unittest.TestCase):
+    def setUp(self) -> None:
+        self.base_dir = Path(__file__).resolve().parents[1]
+        self.manifest_path = self.base_dir / MANIFEST_OUTPUT
+        if not self.manifest_path.exists():
+            build_golden_baseline()
+
+    def test_rc1_core_hash_and_summary_match_manifest(self) -> None:
+        manifest = json.loads(self.manifest_path.read_text(encoding="utf-8"))
+
+        indexed, _ = _load_indicator_snapshot(self.base_dir)
+        engine = DragonRuleEngine(config=alpha_first_glued_refined_hot_cap_config())
+        events, trades = engine.run(indexed)
+
+        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()
+
+        events.sort_values(["date", "side", "layer", "reason"], inplace=True)
+        trades.sort_values(["buy_date", "sell_date", "buy_reason", "sell_reason"], inplace=True)
+
+        events_core_hash = _df_sha256(events[EVENTS_CORE_COLUMNS])
+        trades_core_hash = _df_sha256(trades[TRADES_CORE_COLUMNS])
+
+        self.assertEqual(events_core_hash, manifest["artifacts"]["events"]["core_sha256"])
+        self.assertEqual(trades_core_hash, manifest["artifacts"]["trades"]["core_sha256"])
+
+        returns = trades["return_pct"].astype(float)
+        self.assertEqual(int(len(trades)), int(manifest["summary"]["trade_count"]))
+        self.assertEqual(int(len(events)), int(manifest["summary"]["event_count"]))
+        self.assertAlmostEqual(float((returns > 0).mean()), float(manifest["summary"]["win_rate"]), places=12)
+        self.assertAlmostEqual(float(returns.mean()), float(manifest["summary"]["avg_return"]), places=12)
+        self.assertAlmostEqual(float(returns.median()), float(manifest["summary"]["median_return"]), places=12)
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 66 - 0
research/dragon/v2/tests/test_reason_catalog_mapping.py

@@ -0,0 +1,66 @@
+from __future__ import annotations
+
+import unittest
+from pathlib import Path
+
+from dragon_branch_configs import alpha_first_glued_refined_hot_cap_config
+from dragon_rc1_golden_baseline import _load_indicator_snapshot
+from dragon_reason_types import RuleFamily, RuleLayer
+from dragon_rule_catalog import classify_aux_reason, classify_entry_reason, classify_exit_reason
+from dragon_shared import END_DATE, START_DATE
+from dragon_strategy import DragonRuleEngine
+
+
+class TestReasonCatalogMapping(unittest.TestCase):
+    def setUp(self) -> None:
+        self.base_dir = Path(__file__).resolve().parents[1]
+
+    def test_key_reason_examples(self) -> None:
+        entry = classify_entry_reason("glued_buy")
+        self.assertEqual(entry.layer, RuleLayer.CORE)
+        self.assertEqual(entry.family, RuleFamily.GLUED)
+
+        entry_bridge = classify_entry_reason("predictive_error_reentry_buy")
+        self.assertEqual(entry_bridge.layer, RuleLayer.BRIDGE)
+        self.assertEqual(entry_bridge.family, RuleFamily.PREDICTIVE_BREAK)
+
+        exit_bridge = classify_exit_reason("predictive_b1_break_exit")
+        self.assertEqual(exit_bridge.layer, RuleLayer.BRIDGE)
+        self.assertEqual(exit_bridge.family, RuleFamily.PREDICTIVE_BREAK)
+
+        aux = classify_aux_reason("bearish_signal_after_exit:ql_sell")
+        self.assertEqual(aux.layer, RuleLayer.BRIDGE)
+        self.assertEqual(aux.family, RuleFamily.AUXILIARY)
+
+    def test_release_window_trade_reasons_are_mapped(self) -> None:
+        indexed, _ = _load_indicator_snapshot(self.base_dir)
+        engine = DragonRuleEngine(config=alpha_first_glued_refined_hot_cap_config())
+        _, details = engine.run(indexed)
+        details = details[
+            (details["buy_date"] >= START_DATE)
+            & (details["buy_date"] <= END_DATE)
+            & (details["sell_date"] >= START_DATE)
+            & (details["sell_date"] <= END_DATE)
+        ].copy()
+
+        buy_unknown = sorted(
+            {
+                reason
+                for reason in details["buy_reason"].astype(str).unique()
+                if classify_entry_reason(reason).layer == RuleLayer.UNKNOWN
+            }
+        )
+        sell_unknown = sorted(
+            {
+                reason
+                for reason in details["sell_reason"].astype(str).unique()
+                if classify_exit_reason(reason).layer == RuleLayer.UNKNOWN
+            }
+        )
+
+        self.assertEqual(buy_unknown, [], msg=f"Unknown entry reasons: {buy_unknown}")
+        self.assertEqual(sell_unknown, [], msg=f"Unknown exit reasons: {sell_unknown}")
+
+
+if __name__ == "__main__":
+    unittest.main()