dragon_followthrough_profit_loop_review.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694
  1. from __future__ import annotations
  2. from dataclasses import dataclass
  3. from pathlib import Path
  4. import pandas as pd
  5. from dragon_branch_configs import (
  6. alpha_first_glued_followthrough_probe_config,
  7. alpha_first_glued_refined_hot_cap_config,
  8. )
  9. from dragon_indicators import DragonIndicatorConfig, DragonIndicatorEngine
  10. from dragon_shared import END_DATE, START_DATE, format_pct as _format_pct
  11. from dragon_strategy import DragonRuleEngine
  12. from dragon_strategy_config import StrategyConfig
  13. REENTRY_REASON_PREFIX = "glued_followthrough_reentry_buy:confirmed_"
  14. OUTPUT_CANDIDATES = "dragon_followthrough_profit_loop_candidates.csv"
  15. OUTPUT_CANDIDATE_SUMMARY = "dragon_followthrough_profit_loop_candidate_summary.csv"
  16. OUTPUT_REENTRIES = "dragon_followthrough_profit_loop_reentries.csv"
  17. OUTPUT_REENTRY_SUMMARY = "dragon_followthrough_profit_loop_reentry_summary.csv"
  18. OUTPUT_REVIEW = "dragon_followthrough_profit_loop_review.md"
  19. @dataclass(frozen=True)
  20. class ProbeDefinition:
  21. name: str
  22. label: str
  23. allowed_subtype: str
  24. config: StrategyConfig
  25. def build_followthrough_probe_configs() -> dict[str, ProbeDefinition]:
  26. base = alpha_first_glued_refined_hot_cap_config()
  27. return {
  28. "probe_mid_zone": ProbeDefinition(
  29. name="probe_mid_zone",
  30. label="pending only for mid_zone_very_weak_b1",
  31. allowed_subtype="mid_zone_very_weak_b1",
  32. config=alpha_first_glued_followthrough_probe_config(),
  33. ),
  34. "probe_high_zone": ProbeDefinition(
  35. name="probe_high_zone",
  36. label="pending only for high_zone_weak_b1",
  37. allowed_subtype="high_zone_weak_b1",
  38. config=base.with_updates(
  39. glued_followthrough_pending_enabled=True,
  40. glued_followthrough_allow_mid_zone_very_weak_b1=False,
  41. glued_followthrough_allow_high_zone_weak_b1=True,
  42. glued_followthrough_allow_ql_rebound_weak_followthrough=False,
  43. ),
  44. ),
  45. "probe_ql_rebound": ProbeDefinition(
  46. name="probe_ql_rebound",
  47. label="pending only for ql_rebound_weak_followthrough",
  48. allowed_subtype="ql_rebound_weak_followthrough",
  49. config=base.with_updates(
  50. glued_followthrough_pending_enabled=True,
  51. glued_followthrough_allow_mid_zone_very_weak_b1=False,
  52. glued_followthrough_allow_high_zone_weak_b1=False,
  53. glued_followthrough_allow_ql_rebound_weak_followthrough=True,
  54. ),
  55. ),
  56. }
  57. def prepare_indicator_history(df: pd.DataFrame) -> pd.DataFrame:
  58. history = df.copy()
  59. if "date" in history.columns and "date" in list(history.index.names):
  60. history = history.reset_index(drop=True)
  61. if "date" not in history.columns:
  62. history = history.reset_index()
  63. if "date" not in history.columns:
  64. history = history.rename(columns={history.columns[0]: "date"})
  65. history["date"] = pd.to_datetime(history["date"])
  66. history = history.sort_values("date").reset_index(drop=True)
  67. for column in ["open", "high", "low", "close", "a1", "b1", "c1"]:
  68. if column in history.columns:
  69. history[column] = history[column].astype(float)
  70. for column in ["kdj_buy", "kdj_sell", "ql_buy", "ql_sell"]:
  71. if column in history.columns:
  72. history[column] = history[column].astype(bool)
  73. history["ma20"] = history["close"].rolling(20).mean()
  74. history["ma60"] = history["close"].rolling(60).mean()
  75. history["regime"] = history.apply(_classify_regime, axis=1)
  76. return history.set_index("date", drop=False)
  77. def collect_glued_block_candidates(
  78. df: pd.DataFrame,
  79. config: StrategyConfig,
  80. *,
  81. branch_name: str = "base_rc1",
  82. ) -> pd.DataFrame:
  83. indexed = prepare_indicator_history(df)
  84. blocked, _ = _collect_branch_diagnostics(indexed, config, branch_name)
  85. return blocked
  86. def collect_followthrough_reentries(
  87. df: pd.DataFrame,
  88. config: StrategyConfig,
  89. *,
  90. branch_name: str = "probe",
  91. ) -> pd.DataFrame:
  92. indexed = prepare_indicator_history(df)
  93. _, reentries = _collect_branch_diagnostics(indexed, config, branch_name)
  94. return reentries
  95. def _classify_regime(row: pd.Series) -> str:
  96. close = float(row["close"])
  97. ma20 = float(row["ma20"]) if pd.notna(row["ma20"]) else float("nan")
  98. ma60 = float(row["ma60"]) if pd.notna(row["ma60"]) else float("nan")
  99. if pd.isna(ma20) or pd.isna(ma60):
  100. return "unknown"
  101. if close >= ma20 >= ma60:
  102. return "uptrend"
  103. if close <= ma20 <= ma60:
  104. return "downtrend"
  105. return "range"
  106. def _in_release_window(value: str) -> bool:
  107. return START_DATE <= value <= END_DATE
  108. def _collect_branch_diagnostics(
  109. indexed: pd.DataFrame,
  110. config: StrategyConfig,
  111. branch_name: str,
  112. ) -> tuple[pd.DataFrame, pd.DataFrame]:
  113. engine = DragonRuleEngine(config=config)
  114. blocked_rows: list[dict[str, object]] = []
  115. reentry_rows: list[dict[str, object]] = []
  116. prev_row = None
  117. for _, row in indexed.iterrows():
  118. engine._record_cross_counters(row)
  119. engine._update_position_counters(row)
  120. engine._update_pending_states(row)
  121. just_bought = False
  122. pending_snapshot = {
  123. "origin_date": (
  124. engine.context.pending_glued_followthrough_origin_date.isoformat()
  125. if engine.context.pending_glued_followthrough_origin_date
  126. else ""
  127. ),
  128. "subtype": engine.context.pending_glued_followthrough_subtype,
  129. "bars_waited": int(engine.context.pending_glued_followthrough_bars_waited),
  130. "signal_close": float(engine.context.pending_glued_followthrough_signal_close or 0.0),
  131. "signal_a1": float(engine.context.pending_glued_followthrough_a1 or 0.0),
  132. "signal_b1": float(engine.context.pending_glued_followthrough_b1 or 0.0),
  133. "signal_c1": float(engine.context.pending_glued_followthrough_c1 or 0.0),
  134. }
  135. if (not engine.context.in_position) or bool(row["kdj_buy"] or row["ql_buy"]):
  136. action, reason = engine._buy_decision(row, prev_row)
  137. if action == "BLOCK" and reason == "buy_block_glued_high_weak_rebound":
  138. subtype = engine._glued_high_weak_rebound_subtype(row)
  139. blocked_rows.append(
  140. {
  141. "branch": branch_name,
  142. "signal_date": row.name.date().isoformat(),
  143. "subtype": subtype,
  144. "signal_close": float(row["close"]),
  145. "signal_a1": float(row["a1"]),
  146. "signal_b1": float(row["b1"]),
  147. "signal_c1": float(row["c1"]),
  148. "signal_kdj_buy": bool(row["kdj_buy"]),
  149. "signal_ql_buy": bool(row["ql_buy"]),
  150. "in_release_window": _in_release_window(row.name.date().isoformat()),
  151. }
  152. )
  153. elif action == "BUY":
  154. if reason.startswith(REENTRY_REASON_PREFIX):
  155. reentry_rows.append(
  156. {
  157. "branch": branch_name,
  158. "buy_date": row.name.date().isoformat(),
  159. "buy_reason": reason,
  160. "origin_date": pending_snapshot["origin_date"],
  161. "subtype": pending_snapshot["subtype"],
  162. "bars_waited": pending_snapshot["bars_waited"],
  163. "signal_close": pending_snapshot["signal_close"],
  164. "signal_a1": pending_snapshot["signal_a1"],
  165. "signal_b1": pending_snapshot["signal_b1"],
  166. "signal_c1": pending_snapshot["signal_c1"],
  167. "buy_close": float(row["close"]),
  168. "buy_a1": float(row["a1"]),
  169. "buy_b1": float(row["b1"]),
  170. "buy_c1": float(row["c1"]),
  171. "buy_kdj_buy": bool(row["kdj_buy"]),
  172. "buy_ql_buy": bool(row["ql_buy"]),
  173. "b1_repair": float(row["b1"]) - pending_snapshot["signal_b1"],
  174. "in_release_window": _in_release_window(row.name.date().isoformat()),
  175. }
  176. )
  177. engine._post_real_buy(row, reason)
  178. just_bought = True
  179. elif action == "AUX_BUY":
  180. engine._post_aux_buy(row)
  181. state_aux_sell_candidate = (not engine.context.in_position) and engine._should_emit_state_aux_sell(row)
  182. if not just_bought and (engine.context.in_position or bool(row["kdj_sell"] or row["ql_sell"]) or state_aux_sell_candidate):
  183. if engine.context.in_position and bool(row["kdj_sell"] or row["ql_sell"]):
  184. engine.context.sell_signal_count += 1
  185. if bool(row["kdj_sell"]):
  186. engine.context.kdj_sell_signal_count += 1
  187. if bool(row["ql_sell"]):
  188. engine.context.ql_sell_signal_count += 1
  189. if float(row["b1"]) < 0:
  190. engine.context.b1_negative_sell_count += 1
  191. action, reason = engine._sell_decision(row, prev_row)
  192. if action == "SELL":
  193. engine.context.first_exit_checked = True
  194. engine._post_real_sell(row, reason)
  195. elif action == "AUX_SELL":
  196. if engine.context.in_position:
  197. engine.context.first_exit_checked = True
  198. engine._post_aux_sell(row, reason)
  199. prev_row = row
  200. blocked_df = pd.DataFrame(blocked_rows)
  201. reentry_df = pd.DataFrame(reentry_rows)
  202. return blocked_df, reentry_df
  203. def _load_full_history() -> tuple[pd.DataFrame, dict[str, object]]:
  204. indicator_engine = DragonIndicatorEngine(DragonIndicatorConfig(start_date="2015-01-01", end_date=None))
  205. raw = indicator_engine.fetch_daily_data(include_intraday_snapshot=False)
  206. prepared = indicator_engine.compute(raw.reset_index(drop=False).rename(columns={"index": "date"}))
  207. history = prepare_indicator_history(prepared)
  208. latest_bar = history["date"].max().date().isoformat()
  209. meta = {
  210. "data_source": "fetch_daily_data+compute",
  211. "latest_bar": latest_bar,
  212. "row_count": int(len(history)),
  213. }
  214. return history, meta
  215. def _enrich_block_candidates(
  216. blocked: pd.DataFrame,
  217. history: pd.DataFrame,
  218. config: StrategyConfig,
  219. ) -> pd.DataFrame:
  220. if blocked.empty:
  221. return blocked.copy()
  222. date_to_pos = {row.date().isoformat(): idx for idx, row in enumerate(history["date"])}
  223. enriched_rows: list[dict[str, object]] = []
  224. for row in blocked.itertuples(index=False):
  225. pos = date_to_pos[str(row.signal_date)]
  226. signal_row = history.iloc[pos]
  227. next3 = history.iloc[pos + 1 : pos + 4].copy()
  228. next10 = history.iloc[pos + 1 : pos + 11].copy()
  229. next20 = history.iloc[pos + 1 : pos + 21].copy()
  230. next3_sell_cross = (next3["kdj_sell"] | next3["ql_sell"]) if not next3.empty else pd.Series(dtype=bool)
  231. confirm_like_bar = _first_confirm_like_bar(
  232. next3=next3,
  233. signal_close=float(row.signal_close),
  234. signal_b1=float(row.signal_b1),
  235. config=config,
  236. )
  237. record = {
  238. "branch": row.branch,
  239. "signal_date": row.signal_date,
  240. "subtype": row.subtype,
  241. "signal_close": float(row.signal_close),
  242. "signal_a1": float(row.signal_a1),
  243. "signal_b1": float(row.signal_b1),
  244. "signal_c1": float(row.signal_c1),
  245. "signal_kdj_buy": bool(row.signal_kdj_buy),
  246. "signal_ql_buy": bool(row.signal_ql_buy),
  247. "regime": str(signal_row["regime"]),
  248. "ma20": float(signal_row["ma20"]) if pd.notna(signal_row["ma20"]) else float("nan"),
  249. "ma60": float(signal_row["ma60"]) if pd.notna(signal_row["ma60"]) else float("nan"),
  250. "in_release_window": bool(row.in_release_window),
  251. "bars_available_3": int(len(next3)),
  252. "bars_available_10": int(len(next10)),
  253. "bars_available_20": int(len(next20)),
  254. "next3_any_ql_buy": bool(next3["ql_buy"].any()) if not next3.empty else False,
  255. "next3_ql_buy_count": int(next3["ql_buy"].sum()) if not next3.empty else 0,
  256. "next3_any_sell_cross": bool(next3_sell_cross.any()) if not next3.empty else False,
  257. "next3_sell_cross_count": int(next3_sell_cross.sum()) if not next3.empty else 0,
  258. "confirm_like_within_3": confirm_like_bar > 0,
  259. "confirm_like_bar": int(confirm_like_bar) if confirm_like_bar > 0 else 0,
  260. "next3_max_close_return": _window_max_return(next3, float(row.signal_close)),
  261. "next3_min_close_return": _window_min_return(next3, float(row.signal_close)),
  262. "next10_close_return": _window_close_return(next10, float(row.signal_close), 10),
  263. "next20_close_return": _window_close_return(next20, float(row.signal_close), 20),
  264. "next20_max_close_return": _window_max_return(next20, float(row.signal_close)),
  265. "next20_min_close_return": _window_min_return(next20, float(row.signal_close)),
  266. "next20_max_close_date": _window_extreme_date(next20, "close", "max"),
  267. }
  268. enriched_rows.append(record)
  269. return pd.DataFrame(enriched_rows)
  270. def _first_confirm_like_bar(next3: pd.DataFrame, signal_close: float, signal_b1: float, config: StrategyConfig) -> int:
  271. for offset, (_, row) in enumerate(next3.iterrows(), start=1):
  272. if bool(row["kdj_sell"]) or bool(row["ql_sell"]):
  273. continue
  274. if config.glued_followthrough_require_ql_buy_reconfirm and not bool(row["ql_buy"]):
  275. continue
  276. if config.glued_followthrough_require_close_break_signal_close and float(row["close"]) <= signal_close:
  277. continue
  278. if config.glued_followthrough_require_b1_repair and (float(row["b1"]) - signal_b1) < config.glued_followthrough_b1_repair_min:
  279. continue
  280. return offset
  281. return 0
  282. def _window_close_return(window: pd.DataFrame, base_price: float, required_bars: int) -> float:
  283. if len(window) < required_bars:
  284. return float("nan")
  285. return float(window.iloc[required_bars - 1]["close"]) / base_price - 1.0
  286. def _window_max_return(window: pd.DataFrame, base_price: float) -> float:
  287. if window.empty:
  288. return float("nan")
  289. return float(window["close"].max()) / base_price - 1.0
  290. def _window_min_return(window: pd.DataFrame, base_price: float) -> float:
  291. if window.empty:
  292. return float("nan")
  293. return float(window["close"].min()) / base_price - 1.0
  294. def _window_extreme_date(window: pd.DataFrame, column: str, mode: str) -> str:
  295. if window.empty:
  296. return ""
  297. idx = window[column].idxmax() if mode == "max" else window[column].idxmin()
  298. return pd.Timestamp(idx).date().isoformat()
  299. def _collect_reentry_trade_details(
  300. history: pd.DataFrame,
  301. probes: dict[str, ProbeDefinition],
  302. ) -> pd.DataFrame:
  303. indexed = history.set_index("date", drop=False)
  304. rows: list[pd.DataFrame] = []
  305. for probe in probes.values():
  306. mapped = collect_followthrough_reentries(indexed, probe.config, branch_name=probe.name)
  307. _, trades = DragonRuleEngine(config=probe.config).run(indexed)
  308. trades = trades[trades["buy_reason"].astype(str).str.startswith(REENTRY_REASON_PREFIX)].copy()
  309. if trades.empty:
  310. rows.append(
  311. pd.DataFrame(
  312. columns=[
  313. "branch",
  314. "buy_date",
  315. "buy_reason",
  316. "origin_date",
  317. "subtype",
  318. "bars_waited",
  319. "signal_close",
  320. "signal_a1",
  321. "signal_b1",
  322. "signal_c1",
  323. "buy_close",
  324. "buy_a1",
  325. "buy_b1",
  326. "buy_c1",
  327. "buy_kdj_buy",
  328. "buy_ql_buy",
  329. "b1_repair",
  330. "in_release_window",
  331. "buy_price",
  332. "sell_date",
  333. "sell_price",
  334. "sell_reason",
  335. "holding_days",
  336. "return_pct",
  337. ]
  338. )
  339. )
  340. continue
  341. trades.insert(0, "branch", probe.name)
  342. merged = mapped.merge(
  343. trades[
  344. [
  345. "branch",
  346. "buy_date",
  347. "buy_price",
  348. "buy_reason",
  349. "sell_date",
  350. "sell_price",
  351. "sell_reason",
  352. "holding_days",
  353. "return_pct",
  354. ]
  355. ],
  356. on=["branch", "buy_date", "buy_reason"],
  357. how="left",
  358. validate="one_to_one",
  359. )
  360. rows.append(merged)
  361. details = pd.concat(rows, ignore_index=True) if rows else pd.DataFrame()
  362. if details.empty:
  363. return details
  364. return _enrich_reentry_trade_details(details, history)
  365. def _enrich_reentry_trade_details(details: pd.DataFrame, history: pd.DataFrame) -> pd.DataFrame:
  366. if details.empty:
  367. return details.copy()
  368. if "date" in history.columns and "date" in list(history.index.names):
  369. history = history.reset_index(drop=True)
  370. history = history.sort_values("date").reset_index(drop=True)
  371. date_to_pos = {row.date().isoformat(): idx for idx, row in enumerate(history["date"])}
  372. open_available = "open" in history.columns and history["open"].notna().any()
  373. enriched_rows: list[dict[str, object]] = []
  374. for row in details.itertuples(index=False):
  375. buy_pos = date_to_pos[str(row.buy_date)]
  376. sell_pos = date_to_pos[str(row.sell_date)]
  377. buy_row = history.iloc[buy_pos]
  378. buy_next = history.iloc[buy_pos + 1] if (buy_pos + 1) < len(history) else None
  379. sell_next = history.iloc[sell_pos + 1] if (sell_pos + 1) < len(history) else None
  380. post_exit_5 = history.iloc[sell_pos + 1 : sell_pos + 6]
  381. post_exit_10 = history.iloc[sell_pos + 1 : sell_pos + 11]
  382. entry_10 = history.iloc[buy_pos + 1 : buy_pos + 11]
  383. entry_20 = history.iloc[buy_pos + 1 : buy_pos + 21]
  384. next_open_return = float("nan")
  385. if open_available and buy_next is not None and sell_next is not None:
  386. next_open_return = float(sell_next["open"]) / float(buy_next["open"]) - 1.0
  387. enriched_rows.append(
  388. {
  389. "branch": row.branch,
  390. "subtype": row.subtype,
  391. "origin_date": row.origin_date,
  392. "origin_regime": str(history.iloc[date_to_pos[str(row.origin_date)]]["regime"]) if str(row.origin_date) in date_to_pos else "",
  393. "buy_date": row.buy_date,
  394. "buy_regime": str(buy_row["regime"]),
  395. "buy_reason": row.buy_reason,
  396. "bars_waited": int(row.bars_waited),
  397. "signal_close": float(row.signal_close),
  398. "signal_a1": float(row.signal_a1),
  399. "signal_b1": float(row.signal_b1),
  400. "signal_c1": float(row.signal_c1),
  401. "buy_price": float(row.buy_price),
  402. "buy_close": float(row.buy_close),
  403. "buy_a1": float(row.buy_a1),
  404. "buy_b1": float(row.buy_b1),
  405. "buy_c1": float(row.buy_c1),
  406. "b1_repair": float(row.b1_repair),
  407. "sell_date": row.sell_date,
  408. "sell_price": float(row.sell_price),
  409. "sell_reason": row.sell_reason,
  410. "holding_days": int(row.holding_days),
  411. "return_pct": float(row.return_pct),
  412. "next_open_return_pct": next_open_return,
  413. "next_open_minus_same_close_pct": next_open_return - float(row.return_pct) if pd.notna(next_open_return) else float("nan"),
  414. "entry_max_close_return_10b": _window_max_return(entry_10, float(row.buy_price)),
  415. "entry_max_close_return_20b": _window_max_return(entry_20, float(row.buy_price)),
  416. "post_exit_max_close_return_5b": _window_max_return(post_exit_5, float(row.sell_price)),
  417. "post_exit_max_close_return_10b": _window_max_return(post_exit_10, float(row.sell_price)),
  418. "post_exit_min_close_return_5b": _window_min_return(post_exit_5, float(row.sell_price)),
  419. "in_release_window": bool(row.in_release_window and _in_release_window(str(row.sell_date))),
  420. }
  421. )
  422. return pd.DataFrame(enriched_rows)
  423. def _summarize_candidates(candidates: pd.DataFrame, reentries: pd.DataFrame) -> pd.DataFrame:
  424. rows: list[dict[str, object]] = []
  425. subtype_order = {
  426. "mid_zone_very_weak_b1": 0,
  427. "high_zone_weak_b1": 1,
  428. "ql_rebound_weak_followthrough": 2,
  429. }
  430. for subtype, group in candidates.groupby("subtype"):
  431. full_group = group.copy()
  432. complete_3 = group[group["bars_available_3"] >= 3].copy()
  433. complete_10 = group[group["bars_available_10"] >= 10].copy()
  434. complete_20 = group[group["bars_available_20"] >= 20].copy()
  435. subtype_reentries = reentries[reentries["subtype"] == subtype].copy()
  436. rows.append(
  437. {
  438. "subtype": subtype,
  439. "sort_key": subtype_order.get(subtype, 99),
  440. "blocked_count_full": int(len(full_group)),
  441. "blocked_count_release": int(full_group["in_release_window"].sum()),
  442. "shadow_confirmed_trade_count": int(len(subtype_reentries)),
  443. "complete_3bar_count": int(len(complete_3)),
  444. "complete_10bar_count": int(len(complete_10)),
  445. "complete_20bar_count": int(len(complete_20)),
  446. "uptrend_count": int((full_group["regime"] == "uptrend").sum()),
  447. "range_count": int((full_group["regime"] == "range").sum()),
  448. "downtrend_count": int((full_group["regime"] == "downtrend").sum()),
  449. "confirm_like_3bar_rate": float(complete_3["confirm_like_within_3"].mean()) if not complete_3.empty else float("nan"),
  450. "next3_ql_reconfirm_rate": float(complete_3["next3_any_ql_buy"].mean()) if not complete_3.empty else float("nan"),
  451. "next3_sell_cross_rate": float(complete_3["next3_any_sell_cross"].mean()) if not complete_3.empty else float("nan"),
  452. "avg_next3_max_close_return": float(complete_3["next3_max_close_return"].mean()) if not complete_3.empty else float("nan"),
  453. "avg_next10_close_return": float(complete_10["next10_close_return"].mean()) if not complete_10.empty else float("nan"),
  454. "avg_next20_close_return": float(complete_20["next20_close_return"].mean()) if not complete_20.empty else float("nan"),
  455. "avg_next20_max_close_return": float(complete_20["next20_max_close_return"].mean()) if not complete_20.empty else float("nan"),
  456. }
  457. )
  458. summary = pd.DataFrame(rows)
  459. if summary.empty:
  460. return summary
  461. return summary.sort_values(["sort_key", "subtype"]).drop(columns=["sort_key"]).reset_index(drop=True)
  462. def _summarize_reentries(reentries: pd.DataFrame, probes: dict[str, ProbeDefinition]) -> pd.DataFrame:
  463. rows: list[dict[str, object]] = []
  464. for probe in probes.values():
  465. group = reentries[reentries["branch"] == probe.name].copy()
  466. rows.append(
  467. {
  468. "branch": probe.name,
  469. "allowed_subtype": probe.allowed_subtype,
  470. "trades": int(len(group)),
  471. "release_window_trades": int(group["in_release_window"].sum()) if not group.empty else 0,
  472. "avg_bars_waited": float(group["bars_waited"].mean()) if not group.empty else float("nan"),
  473. "same_close_win_rate": float((group["return_pct"] > 0).mean()) if not group.empty else float("nan"),
  474. "same_close_avg_return": float(group["return_pct"].mean()) if not group.empty else float("nan"),
  475. "next_open_win_rate": float((group["next_open_return_pct"] > 0).mean()) if not group.empty else float("nan"),
  476. "next_open_avg_return": float(group["next_open_return_pct"].mean()) if not group.empty else float("nan"),
  477. "avg_next_open_minus_same_close": float(group["next_open_minus_same_close_pct"].mean()) if not group.empty else float("nan"),
  478. "avg_holding_days": float(group["holding_days"].mean()) if not group.empty else float("nan"),
  479. "avg_entry_max_close_return_10b": float(group["entry_max_close_return_10b"].mean()) if not group.empty else float("nan"),
  480. "avg_entry_max_close_return_20b": float(group["entry_max_close_return_20b"].mean()) if not group.empty else float("nan"),
  481. "avg_post_exit_max_close_return_5b": float(group["post_exit_max_close_return_5b"].mean()) if not group.empty else float("nan"),
  482. "avg_post_exit_max_close_return_10b": float(group["post_exit_max_close_return_10b"].mean()) if not group.empty else float("nan"),
  483. "exit_reason_distribution": _value_counts_string(group["sell_reason"]) if not group.empty else "",
  484. }
  485. )
  486. return pd.DataFrame(rows)
  487. def _value_counts_string(series: pd.Series) -> str:
  488. if series.empty:
  489. return ""
  490. counts = series.astype(str).value_counts()
  491. return " | ".join(f"{idx}:{int(val)}" for idx, val in counts.items())
  492. def _latest_case_review(candidates: pd.DataFrame, latest_signal_date: str) -> list[str]:
  493. case = candidates[candidates["signal_date"] == latest_signal_date].copy()
  494. if case.empty:
  495. return []
  496. row = case.iloc[0]
  497. return [
  498. "## Latest Block Case",
  499. f"- signal_date `{row['signal_date']}` | subtype `{row['subtype']}` | regime `{row['regime']}`",
  500. f"- observed bars after block `{int(row['bars_available_3'])}/3` | ql_reconfirm_count `{int(row['next3_ql_buy_count'])}` | sell_cross_count `{int(row['next3_sell_cross_count'])}`",
  501. f"- max_close_return so far `{_format_pct(float(row['next3_max_close_return']))}` | confirm_like_within_3 `{bool(row['confirm_like_within_3'])}`",
  502. "",
  503. ]
  504. def _build_review_markdown(
  505. *,
  506. meta: dict[str, object],
  507. candidates: pd.DataFrame,
  508. candidate_summary: pd.DataFrame,
  509. reentries: pd.DataFrame,
  510. reentry_summary: pd.DataFrame,
  511. ) -> str:
  512. lines = [
  513. "# Dragon Followthrough Profit Loop Review",
  514. "",
  515. "## Scope",
  516. "- objective: inspect whether false-veto glued weak rebounds deserve a fast trend reentry path",
  517. f"- data_source: `{meta['data_source']}`",
  518. f"- latest_bar: `{meta['latest_bar']}`",
  519. f"- row_count: `{int(meta['row_count'])}`",
  520. "",
  521. "## Subtype Readout",
  522. ]
  523. for row in candidate_summary.itertuples(index=False):
  524. lines.append(
  525. f"- `{row.subtype}` | blocked_full `{int(row.blocked_count_full)}` | shadow_confirmed `{int(row.shadow_confirmed_trade_count)}` | "
  526. f"confirm_like_3bar `{_format_pct(float(row.confirm_like_3bar_rate))}` | "
  527. f"next3_sell_cross_rate `{_format_pct(float(row.next3_sell_cross_rate))}` | "
  528. f"avg_next20_max `{_format_pct(float(row.avg_next20_max_close_return))}`"
  529. )
  530. lines.extend(["", "## Probe Readout"])
  531. for row in reentry_summary.itertuples(index=False):
  532. lines.append(
  533. f"- `{row.branch}` -> `{row.allowed_subtype}` | trades `{int(row.trades)}` | "
  534. f"same_close_avg `{_format_pct(float(row.same_close_avg_return))}` | "
  535. f"next_open_avg `{_format_pct(float(row.next_open_avg_return))}` | "
  536. f"post_exit_max_10b `{_format_pct(float(row.avg_post_exit_max_close_return_10b))}` | "
  537. f"exit_reasons `{row.exit_reason_distribution or 'none'}`"
  538. )
  539. latest_signal_date = ""
  540. if not candidates.empty:
  541. latest_signal_date = str(candidates["signal_date"].max())
  542. lines.extend([""] + _latest_case_review(candidates, latest_signal_date))
  543. mid_probe = reentry_summary[reentry_summary["branch"] == "probe_mid_zone"]
  544. high_probe = reentry_summary[reentry_summary["branch"] == "probe_high_zone"]
  545. ql_probe = reentry_summary[reentry_summary["branch"] == "probe_ql_rebound"]
  546. lines.extend(
  547. [
  548. "## Judgment",
  549. "- `mid_zone_very_weak_b1` is the only subtype that still shows a non-zero delayed-reentry path; it stays the only practical followthrough research lane.",
  550. "- `high_zone_weak_b1` should not be promoted. Enabling pending there only created losing followthrough trades in this replay.",
  551. "- `ql_rebound_weak_followthrough` remains a hard-block family. The probe branch still produced zero confirmed reentries there.",
  552. ]
  553. )
  554. if not mid_probe.empty:
  555. row = mid_probe.iloc[0]
  556. lines.append(
  557. f"- execution timing remains a real question only for the mid-zone path: same_close `{_format_pct(float(row['same_close_avg_return']))}` vs "
  558. f"next_open `{_format_pct(float(row['next_open_avg_return']))}`."
  559. )
  560. lines.append(
  561. "- entry-specific exit treatment is worth a narrow check only if the mid-zone path keeps showing higher post-entry upside than realized return."
  562. )
  563. if not high_probe.empty:
  564. row = high_probe.iloc[0]
  565. lines.append(
  566. f"- high-zone probe result is already weak enough to stop here: trades `{int(row['trades'])}`, same_close_avg `{_format_pct(float(row['same_close_avg_return']))}`."
  567. )
  568. if not ql_probe.empty:
  569. row = ql_probe.iloc[0]
  570. lines.append(
  571. f"- ql rebound probe stays off the table for promotion: confirmed trades `{int(row['trades'])}`."
  572. )
  573. if not reentries.empty:
  574. lines.extend(["", "## Trade Detail Highlights"])
  575. for row in reentries.itertuples(index=False):
  576. lines.append(
  577. f"- `{row.branch}` | origin `{row.origin_date}` -> buy `{row.buy_date}` -> sell `{row.sell_date}` | "
  578. f"same_close `{_format_pct(float(row.return_pct))}` | next_open `{_format_pct(float(row.next_open_return_pct))}` | "
  579. f"sell `{row.sell_reason}` | post_exit_max_10b `{_format_pct(float(row.post_exit_max_close_return_10b))}`"
  580. )
  581. return "\n".join(lines) + "\n"
  582. def main() -> None:
  583. base_dir = Path(__file__).resolve().parent
  584. history, meta = _load_full_history()
  585. base_config = alpha_first_glued_refined_hot_cap_config()
  586. probes = build_followthrough_probe_configs()
  587. normalized_history = history.reset_index(drop=True) if "date" in history.columns and "date" in list(history.index.names) else history.copy()
  588. candidates = collect_glued_block_candidates(history, base_config, branch_name="base_rc1")
  589. candidates = _enrich_block_candidates(candidates, normalized_history, base_config)
  590. reentries = _collect_reentry_trade_details(history, probes)
  591. candidate_summary = _summarize_candidates(candidates, reentries)
  592. reentry_summary = _summarize_reentries(reentries, probes)
  593. review = _build_review_markdown(
  594. meta=meta,
  595. candidates=candidates,
  596. candidate_summary=candidate_summary,
  597. reentries=reentries,
  598. reentry_summary=reentry_summary,
  599. )
  600. candidates.to_csv(base_dir / OUTPUT_CANDIDATES, index=False, encoding="utf-8-sig")
  601. candidate_summary.to_csv(base_dir / OUTPUT_CANDIDATE_SUMMARY, index=False, encoding="utf-8-sig")
  602. reentries.to_csv(base_dir / OUTPUT_REENTRIES, index=False, encoding="utf-8-sig")
  603. reentry_summary.to_csv(base_dir / OUTPUT_REENTRY_SUMMARY, index=False, encoding="utf-8-sig")
  604. (base_dir / OUTPUT_REVIEW).write_text(review, encoding="utf-8")
  605. if __name__ == "__main__":
  606. main()