dragon_daily_signal_pipeline.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464
  1. from __future__ import annotations
  2. from dataclasses import asdict
  3. from datetime import date
  4. import json
  5. from pathlib import Path
  6. import pandas as pd
  7. from dragon_branch_configs import (
  8. alpha_first_glued_followthrough_probe_config,
  9. alpha_first_glued_followthrough_mid_exit_probe_config,
  10. alpha_first_glued_refined_hot_cap_config,
  11. alpha_first_selective_veto_config,
  12. workbook_preserving_config,
  13. )
  14. from dragon_execution_common import apply_execution_model as _apply_execution_model, risk_cluster as _risk_cluster, summary as _summary
  15. from dragon_indicators import DragonIndicatorConfig, DragonIndicatorEngine
  16. from dragon_shared import START_DATE, format_num as _format_num, format_pct as _format_pct
  17. from dragon_strategy import DragonRuleEngine
  18. BRANCH_CONFIGS = [
  19. ("workbook_preserving", workbook_preserving_config),
  20. ("alpha_first_selective_veto", alpha_first_selective_veto_config),
  21. ("alpha_first_glued_refined_hot_cap", alpha_first_glued_refined_hot_cap_config),
  22. ("alpha_first_glued_followthrough_probe", alpha_first_glued_followthrough_probe_config),
  23. ("alpha_first_glued_followthrough_mid_exit_probe", alpha_first_glued_followthrough_mid_exit_probe_config),
  24. ]
  25. def _entry_family(reason: str) -> str:
  26. return str(reason).split(":", 1)[0]
  27. def _load_monitor_template(base_dir: Path) -> pd.DataFrame:
  28. return pd.read_csv(base_dir / "dragon_strategy_monitoring_template.csv", encoding="utf-8-sig")
  29. def _load_removed_trade_over_removal_count(base_dir: Path) -> float:
  30. path = base_dir / "dragon_glued_refined_removed_trade_attribution.csv"
  31. if not path.exists():
  32. return float("nan")
  33. df = pd.read_csv(path, encoding="utf-8-sig")
  34. if "recommendation" not in df.columns:
  35. return float("nan")
  36. return float((df["recommendation"].astype(str) == "OVER_REMOVAL").sum())
  37. def _load_local_sensitivity_robust_case_count(base_dir: Path) -> float:
  38. path = base_dir / "dragon_glued_refined_sensitivity.csv"
  39. if not path.exists():
  40. return float("nan")
  41. df = pd.read_csv(path, encoding="utf-8-sig")
  42. if df.empty or "label" not in df.columns:
  43. return float("nan")
  44. candidate = df[df["label"] == "refined_candidate_baseline"].copy()
  45. if candidate.empty:
  46. return float("nan")
  47. candidate_row = candidate.iloc[0]
  48. neighborhood = df[~df["label"].isin(["current_alpha_control", "refined_candidate_baseline"])].copy()
  49. if neighborhood.empty:
  50. return float("nan")
  51. robust = neighborhood[
  52. (neighborhood["avg_return"] >= float(candidate_row["avg_return"]) - 0.0015)
  53. & (neighborhood["profit_factor"] >= float(candidate_row["profit_factor"]) - 0.20)
  54. & (neighborhood["real_buy_overlap"] >= int(candidate_row["real_buy_overlap"]) - 1)
  55. & (neighborhood["real_sell_overlap"] >= int(candidate_row["real_sell_overlap"]) - 1)
  56. ]
  57. return float(len(robust))
  58. def _infer_initial_capital(base_dir: Path) -> float:
  59. workbook_trades = pd.read_csv(base_dir / "true_trades.csv", encoding="utf-8-sig")
  60. if workbook_trades.empty:
  61. return 55450.0
  62. first = workbook_trades.iloc[0]
  63. return float(first["ending_capital"]) / (1.0 + float(first["return_pct"]))
  64. def _build_branch_state(branch: str, config, indicators: pd.DataFrame) -> tuple[pd.DataFrame, pd.DataFrame, dict[str, object]]:
  65. engine = DragonRuleEngine(config=config)
  66. events, trades = engine.run(indicators.set_index("date", drop=False))
  67. latest_date = indicators["date"].max().date().isoformat()
  68. latest_row = indicators.iloc[-1]
  69. latest_timestamp = pd.Timestamp(latest_row["date"]).isoformat(timespec="seconds")
  70. real_events = events[events["layer"] == "real_trade"].copy()
  71. latest_events = events[events["date"] == latest_date].copy()
  72. last_real = real_events.iloc[-1] if not real_events.empty else None
  73. in_position = bool(engine.context.in_position)
  74. open_trade = None
  75. if in_position and engine.context.entry_date is not None:
  76. open_trade = {
  77. "entry_date": engine.context.entry_date.isoformat(),
  78. "entry_price": float(engine.context.entry_price) if engine.context.entry_price is not None else None,
  79. "entry_reason": engine.context.entry_reason,
  80. "current_return_pct": float(latest_row["close"]) / float(engine.context.entry_price) - 1.0
  81. if engine.context.entry_price
  82. else None,
  83. "holding_days": (latest_row["date"].date() - engine.context.entry_date).days,
  84. }
  85. state = {
  86. "branch": branch,
  87. "as_of_date": latest_date,
  88. "as_of_timestamp": latest_timestamp,
  89. "latest_close": float(latest_row["close"]),
  90. "latest_a1": float(latest_row["a1"]),
  91. "latest_b1": float(latest_row["b1"]),
  92. "latest_c1": float(latest_row["c1"]),
  93. "latest_kdj_buy": bool(latest_row["kdj_buy"]),
  94. "latest_kdj_sell": bool(latest_row["kdj_sell"]),
  95. "latest_ql_buy": bool(latest_row["ql_buy"]),
  96. "latest_ql_sell": bool(latest_row["ql_sell"]),
  97. "latest_real_event_date": "" if last_real is None else str(last_real["date"]),
  98. "latest_real_event_side": "" if last_real is None else str(last_real["side"]),
  99. "latest_real_event_reason": "" if last_real is None else str(last_real["reason"]),
  100. "events_today_count": int(len(latest_events)),
  101. "events_today": " | ".join(
  102. f"{row['side']}:{row['layer']}:{row['reason']}" for _, row in latest_events.iterrows()
  103. ),
  104. "in_position": in_position,
  105. "open_entry_date": "" if open_trade is None else str(open_trade["entry_date"]),
  106. "open_entry_reason": "" if open_trade is None else str(open_trade["entry_reason"]),
  107. "open_entry_price": float("nan") if open_trade is None else float(open_trade["entry_price"]),
  108. "open_holding_days": float("nan") if open_trade is None else int(open_trade["holding_days"]),
  109. "open_return_pct": float("nan") if open_trade is None else float(open_trade["current_return_pct"]),
  110. }
  111. return events, trades, state
  112. def _build_historical_trade_details(branch: str, trades: pd.DataFrame, initial_capital: float) -> pd.DataFrame:
  113. trades = trades.copy()
  114. trades = trades[trades["buy_date"] >= START_DATE].copy()
  115. if trades.empty:
  116. return pd.DataFrame(
  117. columns=[
  118. "branch",
  119. "trade_no",
  120. "buy_date",
  121. "buy_price",
  122. "buy_reason",
  123. "sell_date",
  124. "sell_price",
  125. "sell_reason",
  126. "holding_days",
  127. "return_pct",
  128. "capital_before",
  129. "pnl_amount",
  130. "capital_after",
  131. ]
  132. )
  133. capital_before: list[float] = []
  134. pnl_amount: list[float] = []
  135. capital_after: list[float] = []
  136. running_capital = float(initial_capital)
  137. for _, row in trades.iterrows():
  138. trade_ret = float(row["return_pct"])
  139. capital_before.append(running_capital)
  140. pnl = running_capital * trade_ret
  141. pnl_amount.append(pnl)
  142. running_capital = running_capital + pnl
  143. capital_after.append(running_capital)
  144. trades = trades.reset_index(drop=True)
  145. trades.insert(0, "trade_no", trades.index + 1)
  146. trades.insert(0, "branch", branch)
  147. trades["capital_before"] = capital_before
  148. trades["pnl_amount"] = pnl_amount
  149. trades["capital_after"] = capital_after
  150. return trades[
  151. [
  152. "branch",
  153. "trade_no",
  154. "buy_date",
  155. "buy_price",
  156. "buy_reason",
  157. "sell_date",
  158. "sell_price",
  159. "sell_reason",
  160. "holding_days",
  161. "return_pct",
  162. "capital_before",
  163. "pnl_amount",
  164. "capital_after",
  165. ]
  166. ].copy()
  167. def _add_execution_prices(trades: pd.DataFrame, indicators: pd.DataFrame) -> pd.DataFrame:
  168. trades = trades.copy()
  169. indicators = indicators.sort_values("date").reset_index(drop=True)
  170. lookup = indicators.set_index(indicators["date"].dt.date)
  171. next_by_date = {
  172. indicators.iloc[idx]["date"].date().isoformat(): indicators.iloc[idx + 1]
  173. for idx in range(len(indicators) - 1)
  174. }
  175. same_entry: list[float] = []
  176. same_exit: list[float] = []
  177. next_open_entry: list[float] = []
  178. next_open_exit: list[float] = []
  179. for _, trade in trades.iterrows():
  180. buy_row = lookup.loc[pd.Timestamp(trade["buy_date"]).date()]
  181. sell_row = lookup.loc[pd.Timestamp(trade["sell_date"]).date()]
  182. buy_next = next_by_date.get(trade["buy_date"])
  183. sell_next = next_by_date.get(trade["sell_date"])
  184. same_entry.append(float(buy_row["close"]))
  185. same_exit.append(float(sell_row["close"]))
  186. next_open_entry.append(float("nan") if buy_next is None else float(buy_next["open"]))
  187. next_open_exit.append(float("nan") if sell_next is None else float(sell_next["open"]))
  188. trades["exec_same_close_entry"] = same_entry
  189. trades["exec_same_close_exit"] = same_exit
  190. trades["exec_next_open_entry"] = next_open_entry
  191. trades["exec_next_open_exit"] = next_open_exit
  192. return trades
  193. def _metric_actuals(indicators: pd.DataFrame, control_trades: pd.DataFrame, refined_trades: pd.DataFrame) -> dict[str, object]:
  194. control_eval = _apply_execution_model(_add_execution_prices(control_trades, indicators), "next_open", 0.0)
  195. refined_eval = _apply_execution_model(_add_execution_prices(refined_trades, indicators), "next_open", 0.0)
  196. control_stress = _apply_execution_model(_add_execution_prices(control_trades, indicators), "next_open", 20.0)
  197. refined_stress = _apply_execution_model(_add_execution_prices(refined_trades, indicators), "next_open", 20.0)
  198. control_sum = _summary("control", control_eval)
  199. refined_sum = _summary("refined", refined_eval)
  200. control_stress_sum = _summary("control", control_stress)
  201. refined_stress_sum = _summary("refined", refined_stress)
  202. refined_risk = _risk_cluster("refined", refined_eval)
  203. return {
  204. "next_open_avg_return_delta_vs_control": float(refined_sum["avg_return"] - control_sum["avg_return"]),
  205. "next_open_profit_factor_delta_vs_control": float(refined_sum["profit_factor"] - control_sum["profit_factor"]),
  206. "next_open_max_drawdown": float(refined_sum["max_drawdown"]),
  207. "next_open_max_loss_streak": int(refined_risk["max_loss_streak"]),
  208. "worst_5trade_sum_next_open": float(refined_risk["worst_5trade_sum"]),
  209. "short_loss_share": float(refined_risk["short_loss_share"]),
  210. "removed_trade_over_removal_count": _load_removed_trade_over_removal_count(Path(__file__).resolve().parent),
  211. "local_sensitivity_robust_case_count": _load_local_sensitivity_robust_case_count(Path(__file__).resolve().parent),
  212. "headline_avg_return_delta_vs_control": float(refined_trades["return_pct"].mean() - control_trades["return_pct"].mean()),
  213. "headline_profit_factor_delta_vs_control": float(
  214. (refined_trades[refined_trades["return_pct"] > 0]["return_pct"].sum() / -refined_trades[refined_trades["return_pct"] < 0]["return_pct"].sum())
  215. - (control_trades[control_trades["return_pct"] > 0]["return_pct"].sum() / -control_trades[control_trades["return_pct"] < 0]["return_pct"].sum())
  216. ),
  217. "next_open_20bps_cagr_refined": float(refined_stress_sum["cagr"]),
  218. "next_open_20bps_cagr_control": float(control_stress_sum["cagr"]),
  219. "next_open_20bps_pf_refined": float(refined_stress_sum["profit_factor"]),
  220. "next_open_20bps_pf_control": float(control_stress_sum["profit_factor"]),
  221. }
  222. def _compare_numeric(actual: float, warning_rule: str, hard_rule: str) -> str:
  223. if pd.isna(actual):
  224. return "missing_data"
  225. def parse(rule: str) -> tuple[str, float]:
  226. rule = str(rule).strip()
  227. if rule.startswith(("<=", ">=")):
  228. op = rule[:2]
  229. body = rule[2:]
  230. elif rule.startswith(("<", ">")):
  231. op = rule[:1]
  232. body = rule[1:]
  233. else:
  234. raise ValueError(rule)
  235. if body.endswith("%"):
  236. threshold = float(body[:-1]) / 100.0
  237. else:
  238. threshold = float(body)
  239. return op, threshold
  240. hard_op, hard_val = parse(hard_rule)
  241. warn_op, warn_val = parse(warning_rule)
  242. def hit(op: str, threshold: float) -> bool:
  243. if op == "<=":
  244. return actual <= threshold
  245. if op == ">=":
  246. return actual >= threshold
  247. if op == "<":
  248. return actual < threshold
  249. if op == ">":
  250. return actual > threshold
  251. raise ValueError(op)
  252. if hit(hard_op, hard_val):
  253. return "hard_breach"
  254. if hit(warn_op, warn_val):
  255. return "warning"
  256. return "ok"
  257. def main() -> None:
  258. base_dir = Path(__file__).resolve().parent
  259. output_dir = base_dir / "daily_reports"
  260. output_dir.mkdir(exist_ok=True)
  261. initial_capital = _infer_initial_capital(base_dir)
  262. as_of_request_date = date.today().isoformat()
  263. engine = DragonIndicatorEngine(DragonIndicatorConfig(start_date="2015-01-01", end_date=as_of_request_date))
  264. raw = engine.fetch_daily_data(include_intraday_snapshot=True)
  265. fetch_meta = dict(engine.last_fetch_meta)
  266. snapshot_appended = bool(fetch_meta.get("intraday_snapshot_appended", False))
  267. snapshot_timestamp = fetch_meta.get("intraday_snapshot_timestamp") or ""
  268. historical_latest_bar_date = str(fetch_meta.get("historical_latest_bar_date") or "")
  269. data_mode = "intraday_snapshot" if snapshot_appended else "official_daily_bar"
  270. indicators = engine.compute(raw.reset_index(drop=False).rename(columns={"index": "date"}))
  271. indicators["date"] = pd.to_datetime(indicators["date"])
  272. latest_bar_date = indicators["date"].max().date().isoformat()
  273. branch_runs = [
  274. _build_branch_state(branch_name, config_factory(), indicators)
  275. for branch_name, config_factory in BRANCH_CONFIGS
  276. ]
  277. branch_payload = {
  278. state["branch"]: {"events": events, "trades": trades, "state": state}
  279. for events, trades, state in branch_runs
  280. }
  281. refined_trades = branch_payload["alpha_first_glued_refined_hot_cap"]["trades"]
  282. control_trades = branch_payload["alpha_first_selective_veto"]["trades"]
  283. refined_trades = refined_trades[refined_trades["buy_date"] >= START_DATE].copy()
  284. control_trades = control_trades[control_trades["buy_date"] >= START_DATE].copy()
  285. refined_trades["entry_family"] = refined_trades["buy_reason"].map(_entry_family)
  286. control_trades["entry_family"] = control_trades["buy_reason"].map(_entry_family)
  287. recent_indicators = indicators.tail(15).copy()
  288. recent_indicators["date"] = recent_indicators["date"].dt.date.astype(str)
  289. recent_indicators.to_csv(base_dir / "dragon_daily_signal_snapshot.csv", index=False, encoding="utf-8-sig")
  290. recent_indicators.to_csv(output_dir / f"dragon_daily_signal_snapshot_{latest_bar_date}.csv", index=False, encoding="utf-8-sig")
  291. branch_status = pd.DataFrame([payload["state"] for payload in branch_payload.values()])
  292. branch_status["data_mode"] = data_mode
  293. branch_status["snapshot_appended"] = snapshot_appended
  294. branch_status["snapshot_timestamp"] = snapshot_timestamp
  295. branch_status["historical_latest_bar_date"] = historical_latest_bar_date or latest_bar_date
  296. branch_status.to_csv(base_dir / "dragon_daily_branch_status.csv", index=False, encoding="utf-8-sig")
  297. branch_status.to_csv(output_dir / f"dragon_daily_branch_status_{latest_bar_date}.csv", index=False, encoding="utf-8-sig")
  298. historical_detail = pd.concat(
  299. [
  300. _build_historical_trade_details(branch, payload["trades"], initial_capital)
  301. for branch, payload in branch_payload.items()
  302. ],
  303. ignore_index=True,
  304. sort=False,
  305. )
  306. historical_detail.to_csv(base_dir / "dragon_historical_trade_details.csv", index=False, encoding="utf-8-sig")
  307. historical_detail.to_csv(
  308. output_dir / f"dragon_historical_trade_details_{latest_bar_date}.csv",
  309. index=False,
  310. encoding="utf-8-sig",
  311. )
  312. actuals = _metric_actuals(indicators, control_trades, refined_trades)
  313. template = _load_monitor_template(base_dir)
  314. template["actual_value"] = template["metric"].map(actuals)
  315. template["status"] = template.apply(
  316. lambda row: _compare_numeric(row["actual_value"], str(row["warning_threshold"]), str(row["hard_threshold"])),
  317. axis=1,
  318. )
  319. template.to_csv(base_dir / "dragon_daily_monitor_snapshot.csv", index=False, encoding="utf-8-sig")
  320. template.to_csv(output_dir / f"dragon_daily_monitor_snapshot_{latest_bar_date}.csv", index=False, encoding="utf-8-sig")
  321. config_snapshot = {
  322. "release_version": "RC1",
  323. "branch": "alpha_first_glued_refined_hot_cap",
  324. "config": {**asdict(alpha_first_glued_refined_hot_cap_config()), "disabled_rules": sorted(alpha_first_glued_refined_hot_cap_config().disabled_rules)},
  325. "as_of_request_date": as_of_request_date,
  326. "latest_bar_date": latest_bar_date,
  327. "data_mode": data_mode,
  328. "snapshot_appended": snapshot_appended,
  329. "snapshot_timestamp": snapshot_timestamp,
  330. "historical_latest_bar_date": historical_latest_bar_date or latest_bar_date,
  331. }
  332. (base_dir / "dragon_daily_rc1_manifest.json").write_text(
  333. json.dumps(config_snapshot, indent=2, ensure_ascii=False) + "\n",
  334. encoding="utf-8",
  335. )
  336. (output_dir / f"dragon_daily_rc1_manifest_{latest_bar_date}.json").write_text(
  337. json.dumps(config_snapshot, indent=2, ensure_ascii=False) + "\n",
  338. encoding="utf-8",
  339. )
  340. warning_count = int((template["status"] == "warning").sum())
  341. missing_data_count = int((template["status"] == "missing_data").sum())
  342. hard_count = int(template["status"].isin(["hard_breach", "missing_data"]).sum())
  343. lines = [
  344. "# Dragon Daily Signal Report",
  345. "",
  346. f"- Request date: `{as_of_request_date}`",
  347. f"- Latest available market bar: `{latest_bar_date}`",
  348. f"- Data mode: `{data_mode}`",
  349. (
  350. f"- Historical latest official bar: `{historical_latest_bar_date}`"
  351. if snapshot_appended
  352. else f"- Historical latest official bar: `{latest_bar_date}`"
  353. ),
  354. (
  355. f"- Snapshot timestamp: `{snapshot_timestamp}`"
  356. if snapshot_appended
  357. else "- Snapshot timestamp: `none`"
  358. ),
  359. (
  360. "- Snapshot rule: `current market price is used as today's close for indicator and signal evaluation`"
  361. if snapshot_appended
  362. else "- Snapshot rule: `not used`"
  363. ),
  364. "- Instrument: `399673`",
  365. "- Forward default branch: `alpha_first_glued_refined_hot_cap`",
  366. "- Benchmark control branch: `alpha_first_selective_veto`",
  367. "",
  368. "## Latest Branch Status",
  369. ]
  370. for state in branch_status.to_dict("records"):
  371. lines.extend(
  372. [
  373. f"### {state['branch']}",
  374. f"- evaluated_at `{state['as_of_timestamp']}`",
  375. f"- latest_close `{state['latest_close']:.3f}` | a1 `{state['latest_a1']:.4f}` | b1 `{state['latest_b1']:.4f}` | c1 `{state['latest_c1']:.2f}`",
  376. 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']}`",
  377. f"- latest real event: `{state['latest_real_event_date']}` `{state['latest_real_event_side']}` `{state['latest_real_event_reason']}`",
  378. f"- events on latest bar: `{state['events_today'] if state['events_today'] else 'none'}`",
  379. f"- in_position: `{state['in_position']}`",
  380. (
  381. f"- open trade: `{state['open_entry_date']}` `{state['open_entry_reason']}` | "
  382. f"holding `{int(state['open_holding_days'])}`d | open_return `{_format_pct(float(state['open_return_pct']))}`"
  383. if bool(state["in_position"])
  384. else "- open trade: `none`"
  385. ),
  386. "",
  387. ]
  388. )
  389. lines.extend(
  390. [
  391. "## Monitor Snapshot",
  392. f"- warnings: `{warning_count}`",
  393. f"- hard breaches: `{hard_count}`",
  394. f"- missing data metrics: `{missing_data_count}`",
  395. f"- next_open avg_return delta vs control: `{_format_pct(float(actuals['next_open_avg_return_delta_vs_control']))}`",
  396. f"- next_open PF delta vs control: `{_format_num(float(actuals['next_open_profit_factor_delta_vs_control']))}`",
  397. f"- next_open max_drawdown refined: `{_format_pct(float(actuals['next_open_max_drawdown']))}`",
  398. f"- next_open max loss streak refined: `{int(actuals['next_open_max_loss_streak'])}`",
  399. 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']))}`",
  400. "",
  401. "## Outputs",
  402. "- `dragon_daily_signal_snapshot.csv`",
  403. "- `dragon_daily_branch_status.csv`",
  404. "- `dragon_daily_monitor_snapshot.csv`",
  405. "- `dragon_historical_trade_details.csv`",
  406. "- `dragon_daily_rc1_manifest.json`",
  407. ]
  408. )
  409. (base_dir / "dragon_daily_signal_report.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
  410. (output_dir / f"dragon_daily_signal_report_{latest_bar_date}.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
  411. if __name__ == "__main__":
  412. main()