dragon_aux_sell_cycle_audit.py 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
  1. from __future__ import annotations
  2. from pathlib import Path
  3. import pandas as pd
  4. def _load_csv(base_dir: Path, name: str) -> pd.DataFrame:
  5. return pd.read_csv(base_dir / name, encoding="utf-8-sig")
  6. def _build_real_trade_lookup(strategy_events: pd.DataFrame, side: str) -> pd.DataFrame:
  7. real = strategy_events[(strategy_events["layer"] == "real_trade") & (strategy_events["side"] == side)].copy()
  8. real["dt"] = pd.to_datetime(real["date"])
  9. real = real.sort_values("dt")
  10. return real[["dt", "date", "reason", "c1"]]
  11. def _annotate_sell_cluster(row: pd.Series) -> str:
  12. reason = str(row["reason"])
  13. a1 = float(row["a1"])
  14. b1 = float(row["b1"])
  15. c1 = float(row["c1"])
  16. days_from_prev_real_sell = row["days_from_prev_real_sell"]
  17. ql_sell = bool(row["ql_sell"])
  18. kdj_sell = bool(row["kdj_sell"])
  19. if reason.endswith("state_crash_followthrough"):
  20. return "crash_followthrough"
  21. if pd.notna(days_from_prev_real_sell) and 0 < float(days_from_prev_real_sell) <= 10:
  22. return "post_exit_confirmation"
  23. if c1 > 80 and (ql_sell or kdj_sell):
  24. return "high_zone_warning"
  25. if b1 < -0.12 and (ql_sell or kdj_sell):
  26. return "strong_break_warning"
  27. if ql_sell and c1 < 20 and a1 < -0.02:
  28. return "deep_oversold_followthrough"
  29. return "other_bearish_followthrough"
  30. def _attach_cycle_context(aux_sell: pd.DataFrame, real_buy: pd.DataFrame, real_sell: pd.DataFrame) -> pd.DataFrame:
  31. aux_sell = aux_sell.copy()
  32. aux_sell["dt"] = pd.to_datetime(aux_sell["date"])
  33. sell_dates = real_sell["dt"].tolist()
  34. buy_dates = real_buy["dt"].tolist()
  35. prev_real_sell_dates: list[str | None] = []
  36. days_from_prev_real_sell: list[int | None] = []
  37. next_real_buy_dates: list[str | None] = []
  38. days_to_next_real_buy: list[int | None] = []
  39. for dt in aux_sell["dt"]:
  40. earlier_sells = [x for x in sell_dates if x < dt]
  41. later_buys = [x for x in buy_dates if x > dt]
  42. if earlier_sells:
  43. prev_sell = earlier_sells[-1]
  44. prev_real_sell_dates.append(prev_sell.date().isoformat())
  45. days_from_prev_real_sell.append((dt - prev_sell).days)
  46. else:
  47. prev_real_sell_dates.append(None)
  48. days_from_prev_real_sell.append(None)
  49. if later_buys:
  50. next_buy = later_buys[0]
  51. next_real_buy_dates.append(next_buy.date().isoformat())
  52. days_to_next_real_buy.append((next_buy - dt).days)
  53. else:
  54. next_real_buy_dates.append(None)
  55. days_to_next_real_buy.append(None)
  56. aux_sell["prev_real_sell_date"] = prev_real_sell_dates
  57. aux_sell["days_from_prev_real_sell"] = days_from_prev_real_sell
  58. aux_sell["next_real_buy_date"] = next_real_buy_dates
  59. aux_sell["days_to_next_real_buy"] = days_to_next_real_buy
  60. aux_sell["cycle_id"] = aux_sell.apply(
  61. lambda row: f"{row['prev_real_sell_date']} -> {row['next_real_buy_date']}"
  62. if pd.notna(row["prev_real_sell_date"]) and pd.notna(row["next_real_buy_date"])
  63. else "unbounded_cycle",
  64. axis=1,
  65. )
  66. return aux_sell
  67. def main() -> None:
  68. base_dir = Path(__file__).resolve().parent
  69. strategy_events = _load_csv(base_dir, "dragon_strategy_events.csv")
  70. workbook_layers = _load_csv(base_dir, "dragon_workbook_layers.csv")
  71. real_buy = _build_real_trade_lookup(strategy_events, "BUY")
  72. real_sell = _build_real_trade_lookup(strategy_events, "SELL")
  73. aux_sell = strategy_events[
  74. (strategy_events["layer"] == "aux_signal") & (strategy_events["side"] == "SELL")
  75. ].copy()
  76. aux_sell = aux_sell.sort_values("date").reset_index(drop=True)
  77. aux_sell = _attach_cycle_context(aux_sell, real_buy, real_sell)
  78. aux_sell["cluster"] = aux_sell.apply(_annotate_sell_cluster, axis=1)
  79. workbook_aux_sell = workbook_layers[
  80. (workbook_layers["layer"] == "aux_signal") & (workbook_layers["side"] == "SELL")
  81. ][["date", "signal_reason", "note"]].copy()
  82. workbook_aux_sell = workbook_aux_sell.rename(
  83. columns={"signal_reason": "workbook_signal_reason", "note": "workbook_note"}
  84. )
  85. aux_sell = aux_sell.merge(workbook_aux_sell, on="date", how="left")
  86. aux_sell["matched_workbook_aux"] = aux_sell["workbook_signal_reason"].notna()
  87. cycle_summary = (
  88. aux_sell.groupby(["cycle_id", "prev_real_sell_date", "next_real_buy_date"])
  89. .agg(
  90. aux_sell_count=("date", "count"),
  91. matched_count=("matched_workbook_aux", "sum"),
  92. post_exit_confirmation_count=("cluster", lambda s: int((s == "post_exit_confirmation").sum())),
  93. post_exit_confirmation_matched=(
  94. "matched_workbook_aux",
  95. lambda s: int(
  96. (
  97. aux_sell.loc[s.index, "matched_workbook_aux"]
  98. & (aux_sell.loc[s.index, "cluster"] == "post_exit_confirmation")
  99. ).sum()
  100. ),
  101. ),
  102. unique_clusters=("cluster", lambda s: " | ".join(sorted(set(s)))),
  103. cycle_dates=("date", lambda s: " | ".join(s)),
  104. )
  105. .reset_index()
  106. )
  107. matched_dates: list[str] = []
  108. unmatched_post_exit_dates: list[str] = []
  109. for _, row in cycle_summary.iterrows():
  110. cycle_rows = aux_sell[aux_sell["cycle_id"] == row["cycle_id"]]
  111. matched_cycle_dates = cycle_rows[cycle_rows["matched_workbook_aux"]]["date"].tolist()
  112. unmatched_post_exit_cycle_dates = cycle_rows[
  113. (cycle_rows["cluster"] == "post_exit_confirmation") & (~cycle_rows["matched_workbook_aux"])
  114. ]["date"].tolist()
  115. matched_dates.append(" | ".join(matched_cycle_dates))
  116. unmatched_post_exit_dates.append(" | ".join(unmatched_post_exit_cycle_dates))
  117. cycle_summary["matched_dates"] = matched_dates
  118. cycle_summary["unmatched_post_exit_dates"] = unmatched_post_exit_dates
  119. cycle_summary["compression_candidate"] = cycle_summary.apply(
  120. lambda row: bool(row["post_exit_confirmation_count"] >= 2 and row["post_exit_confirmation_matched"] == 0),
  121. axis=1,
  122. )
  123. detail_cols = [
  124. "date",
  125. "reason",
  126. "cluster",
  127. "matched_workbook_aux",
  128. "prev_real_sell_date",
  129. "days_from_prev_real_sell",
  130. "next_real_buy_date",
  131. "days_to_next_real_buy",
  132. "cycle_id",
  133. "a1",
  134. "b1",
  135. "c1",
  136. "kdj_sell",
  137. "ql_sell",
  138. "workbook_signal_reason",
  139. "workbook_note",
  140. ]
  141. aux_sell[detail_cols].to_csv(base_dir / "dragon_aux_sell_cycle_audit.csv", index=False, encoding="utf-8-sig")
  142. cycle_summary.sort_values(
  143. ["post_exit_confirmation_count", "aux_sell_count", "cycle_id"], ascending=[False, False, True]
  144. ).to_csv(base_dir / "dragon_aux_sell_cycle_summary.csv", index=False, encoding="utf-8-sig")
  145. lines = [
  146. "# Dragon Aux Sell Cycle Review",
  147. "",
  148. f"- Strategy aux SELL signals: `{len(aux_sell)}`",
  149. f"- Distinct sell cycles: `{cycle_summary['cycle_id'].nunique()}`",
  150. f"- Cycles with repeated `post_exit_confirmation` >= 2: `{int((cycle_summary['post_exit_confirmation_count'] >= 2).sum())}`",
  151. f"- Conservative compression candidates (repeated post-exit confirmations with zero workbook anchor): `{int(cycle_summary['compression_candidate'].sum())}`",
  152. "",
  153. "## Repeated Post-Exit Confirmation Cycles",
  154. ]
  155. repeated = cycle_summary[cycle_summary["post_exit_confirmation_count"] >= 2].sort_values(
  156. ["compression_candidate", "post_exit_confirmation_count", "aux_sell_count"],
  157. ascending=[False, False, False],
  158. )
  159. for _, row in repeated.iterrows():
  160. lines.append(
  161. f"- `{row['cycle_id']}` | aux SELL `{int(row['aux_sell_count'])}` | "
  162. f"`post_exit_confirmation={int(row['post_exit_confirmation_count'])}` | "
  163. f"matched `{int(row['matched_count'])}` | "
  164. f"candidate `{bool(row['compression_candidate'])}`"
  165. )
  166. if row["matched_dates"]:
  167. lines.append(f" matched dates: `{row['matched_dates']}`")
  168. if row["unmatched_post_exit_dates"]:
  169. lines.append(f" unmatched post-exit dates: `{row['unmatched_post_exit_dates']}`")
  170. lines.extend(["", "## Candidate Cycles To Review First"])
  171. candidates = repeated[repeated["compression_candidate"]].head(12)
  172. if candidates.empty:
  173. lines.append("- None. Current repeated cycles all contain workbook anchors or are singletons.")
  174. else:
  175. for _, row in candidates.iterrows():
  176. lines.append(
  177. f"- `{row['cycle_id']}` -> unmatched repeated post-exit dates `{row['unmatched_post_exit_dates']}`"
  178. )
  179. lines.extend(["", "## Protected Cycles With Workbook Anchors"])
  180. protected = repeated[~repeated["compression_candidate"]].head(12)
  181. if protected.empty:
  182. lines.append("- None.")
  183. else:
  184. for _, row in protected.iterrows():
  185. lines.append(
  186. f"- `{row['cycle_id']}` -> matched dates `{row['matched_dates']}`; unmatched post-exit dates `{row['unmatched_post_exit_dates']}`"
  187. )
  188. (base_dir / "dragon_aux_sell_cycle_review.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
  189. if __name__ == "__main__":
  190. main()