from __future__ import annotations import hashlib import json import sys from datetime import datetime, timedelta, timezone from pathlib import Path import pandas as pd sys.path.insert(0, str(Path(__file__).resolve().parent)) import record_p2_resolution as record # noqa: E402 RUN_ID = "RUN-ANA-WUJI-V1-BUY-POINT-SECOND-REVIEW-20260615-001" TZ = timezone(timedelta(hours=8)) ROOT = Path(__file__).resolve().parents[1] PACKET_ROOT = ROOT / "p2_step_review_packets" HARD_PULLBACK_PCTPT = 5.0 STRONG_HIGH_PCT = 5.0 WEAK_CLOSE_PCT = 2.0 WEAK_TAIL_MAX_PCT = 2.0 def now_iso() -> str: return datetime.now(TZ).isoformat(timespec="seconds") def sha256_file(path: Path) -> str: h = hashlib.sha256() with path.open("rb") as f: for chunk in iter(lambda: f.read(1024 * 1024), b""): h.update(chunk) return h.hexdigest() def write_csv(df: pd.DataFrame, path: Path) -> None: path.parent.mkdir(parents=True, exist_ok=True) df.to_csv(path, index=False, encoding="utf-8-sig") def write_text(path: Path, text: str) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(text, encoding="utf-8-sig") def num(value: object) -> float | None: try: value = float(value) except Exception: return None if pd.isna(value): return None return value def fmt(value: object, digits: int = 2) -> str: value = num(value) if value is None: return "" return f"{value:.{digits}f}" def classify(row: pd.Series) -> tuple[str, str, str]: max_ret = num(row.get("max_ret_pct")) close_ret = num(row.get("close_ret_pct")) tail_max = num(row.get("tail_max_ret_pct")) if max_ret is None or close_ret is None: return "DATA_INSUFFICIENT", "P2_PULLBACK_DATA_INSUFFICIENT", "缺少盘中高点或收盘涨幅,继续待审。" pullback = max_ret - close_ret tail_pullback = max_ret - tail_max if tail_max is not None else None hard_pullback = pullback >= HARD_PULLBACK_PCTPT weak_tail_fade = ( max_ret >= STRONG_HIGH_PCT and close_ret <= WEAK_CLOSE_PCT and tail_max is not None and tail_max <= WEAK_TAIL_MAX_PCT ) if hard_pullback or weak_tail_fade: reason = ( f"按 P2 批量口径:盘中最高 {fmt(max_ret)}%,收盘 {fmt(close_ret)}%," f"高点回落 {fmt(pullback)} 个百分点" ) if tail_pullback is not None: reason += f",尾段最高较高点回落 {fmt(tail_pullback)} 个百分点" reason += ",属于冲高后大幅回落,标记 REVIEW_HELD。" return "REVIEW_HELD", "P2_BIG_INTRADAY_PULLBACK", reason reason = ( f"按 P2 批量口径:盘中最高 {fmt(max_ret)}%,收盘 {fmt(close_ret)}%," f"高点回落 {fmt(pullback)} 个百分点" ) if tail_max is not None: reason += f",14:40 后最高 {fmt(tail_max)}%" reason += ",未触发大幅回落阈值,标记 BUY。" return "BUY", "P2_NO_BIG_INTRADAY_PULLBACK", reason def build_decisions() -> pd.DataFrame: index = pd.read_csv(PACKET_ROOT / "p2_step_review_index.csv", dtype=str, encoding="utf-8-sig").fillna("") recheck = pd.read_csv(ROOT / "buy_point_second_review_recheck_list.csv", dtype=str, encoding="utf-8-sig").fillna("") recheck = recheck[recheck["human_recheck_priority"].astype(str).str.startswith("P2_", na=False)].copy() metric_cols = [ "candidate_id", "max_ret_pct", "max_ret_time", "close_ret_pct", "tail_min_ret_pct", "tail_max_ret_pct", "tail_max_ret_time", "above_open_ratio", "human_decision_action", "human_decision_reason_cn", "second_review_issue_code", "second_review_reason_cn", ] merged = index.merge(recheck[metric_cols], on="candidate_id", how="left", suffixes=("", "_detail")) rows = [] for _, row in merged.iterrows(): final_action, rule_code, reason = classify(row) max_ret = num(row.get("max_ret_pct")) close_ret = num(row.get("close_ret_pct")) tail_max = num(row.get("tail_max_ret_pct")) rows.append( { "packet_order": row["order"], "case_id": row["case_id"], "candidate_id": row["candidate_id"], "symbol": row["symbol"], "entry_trade_date": row["entry_trade_date"], "original_action": row["original_action"], "final_action": final_action, "rule_code": rule_code, "rule_reason_cn": reason, "max_ret_pct": row.get("max_ret_pct", ""), "max_ret_time": row.get("max_ret_time", ""), "close_ret_pct": row.get("close_ret_pct", ""), "tail_max_ret_pct": row.get("tail_max_ret_pct", ""), "tail_max_ret_time": row.get("tail_max_ret_time", ""), "tail_min_ret_pct": row.get("tail_min_ret_pct", ""), "pullback_from_day_high_pctpt": "" if max_ret is None or close_ret is None else max_ret - close_ret, "tail_pullback_from_day_high_pctpt": "" if max_ret is None or tail_max is None else max_ret - tail_max, "above_open_ratio": row.get("above_open_ratio", ""), "packet_path": row["packet_path"], } ) decisions = pd.DataFrame(rows) decisions["packet_order_num"] = pd.to_numeric(decisions["packet_order"], errors="coerce") decisions = decisions.sort_values("packet_order_num").drop(columns=["packet_order_num"]) return decisions def apply_decisions(decisions: pd.DataFrame) -> None: index = pd.read_csv(PACKET_ROOT / "p2_step_review_index.csv", dtype=str, encoding="utf-8-sig").fillna("") by_order = {int(row["order"]): row for _, row in index.iterrows()} for _, decision in decisions.iterrows(): order = int(decision["packet_order"]) row = by_order[order] final_action = decision["final_action"] reason = decision["rule_reason_cn"] record.update_packet(row, final_action, reason) record.update_ledger(row, final_action, reason) record.update_rollup(row, final_action, reason) rollup = pd.read_csv(record.ROLLUP_PATH, dtype=str, encoding="utf-8-sig").fillna("") record.update_p2_summary() record.update_resolution_summary(rollup) record.update_repair_files(rollup) def write_summary(decisions: pd.DataFrame) -> None: counts = decisions["final_action"].value_counts().sort_index() rule_counts = decisions["rule_code"].value_counts().sort_index() lines = [ "# P2 冲高回落批量裁决说明", "", f"- updated_at: {now_iso()}", "- 口径:冲高大幅回落标记 REVIEW_HELD;未触发大幅回落阈值标记 BUY。", f"- 大幅回落阈值 1:盘中最高涨幅 - 收盘涨幅 >= {HARD_PULLBACK_PCTPT:.1f} 个百分点。", f"- 大幅回落阈值 2:盘中最高涨幅 >= {STRONG_HIGH_PCT:.1f}% 且收盘 <= {WEAK_CLOSE_PCT:.1f}% 且 14:40 后最高 <= {WEAK_TAIL_MAX_PCT:.1f}%。", "", "## 最终动作分布", "", "| final_action | count |", "|---|---:|", ] for action, count in counts.items(): lines.append(f"| `{action}` | {int(count)} |") lines += ["", "## 规则命中分布", "", "| rule_code | count |", "|---|---:|"] for code, count in rule_counts.items(): lines.append(f"| `{code}` | {int(count)} |") lines += [ "", "## 明细", "", "| # | case | symbol | date | final | max% | close% | pullback | reason |", "|---:|---|---|---|---|---:|---:|---:|---|", ] for _, row in decisions.iterrows(): lines.append( f"| {row['packet_order']} | {row['case_id']} | {row['symbol']} | {row['entry_trade_date']} | " f"{row['final_action']} | {fmt(row['max_ret_pct'])} | {fmt(row['close_ret_pct'])} | " f"{fmt(row['pullback_from_day_high_pctpt'])} | {row['rule_code']} |" ) write_text(PACKET_ROOT / "p2_pullback_rule_summary.md", "\n".join(lines) + "\n") def update_manifest() -> None: rows = [] for path in sorted(ROOT.rglob("*")): if path.is_file() and "__pycache__" not in path.parts: rows.append({"path": path.relative_to(ROOT).as_posix(), "size": path.stat().st_size, "sha256": sha256_file(path)}) write_csv(pd.DataFrame(rows), ROOT / "manifest.csv") (ROOT / "manifest.json").write_text( json.dumps({"run_id": RUN_ID, "updated_at": now_iso(), "file_count": len(rows), "files": rows}, ensure_ascii=False, indent=2), encoding="utf-8", ) def main() -> None: decisions = build_decisions() write_csv(decisions, PACKET_ROOT / "p2_pullback_rule_decision.csv") write_summary(decisions) apply_decisions(decisions) update_manifest() if __name__ == "__main__": main()