from __future__ import annotations import hashlib import json from datetime import datetime, timezone, timedelta from pathlib import Path import pandas as pd from PIL import Image, ImageDraw, ImageFont RUN_ID = "RUN-ANA-WUJI-V1-BUY-POINT-SECOND-REVIEW-20260615-001" SOURCE_RUN_ID = "RUN-ANA-WUJI-V1-STRICT-NOTE-FULL-RERUN-20260614-001" ROOT = Path(__file__).resolve().parents[1] PROJECT_ROOT = ROOT.parents[2] SOURCE_ROOT = PROJECT_ROOT / "ana-data" / "result" / SOURCE_RUN_ID DAILY_DIR = Path(r"E:\quant\a_share_daily_front_20230101_20260508_complete\daily") MINUTE_BASE = Path(r"E:\quant\2023_front_m") TZ = timezone(timedelta(hours=8)) 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(text: str, path: Path) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(text, encoding="utf-8-sig") def font(size: int): for p in [ Path("C:/Windows/Fonts/msyh.ttc"), Path("C:/Windows/Fonts/simhei.ttf"), Path("C:/Windows/Fonts/simsun.ttc"), ]: if p.exists(): return ImageFont.truetype(str(p), size) return ImageFont.load_default() FONT_TITLE = font(24) FONT_MID = font(16) FONT_SMALL = font(13) def as_posix(path: Path) -> str: return str(path.resolve()).replace("\\", "/") def fmt(value: object, digits: int = 2) -> str: if value is None: return "" text = str(value) if text == "" or text.lower() == "nan": return "" try: return f"{float(text):.{digits}f}" except Exception: return text def pct(value: float, base: float) -> float: return (value / base - 1.0) * 100.0 if base else 0.0 def board_limit_rate(symbol: str) -> float: code, exchange = symbol.split(".") if exchange == "BJ": return 0.30 if exchange == "SH" and code.startswith("688"): return 0.20 if exchange == "SZ" and (code.startswith("300") or code.startswith("301")): return 0.20 return 0.10 def load_daily(symbol: str) -> pd.DataFrame: path = DAILY_DIR / f"{symbol}.csv" if not path.exists(): return pd.DataFrame() df = pd.read_csv(path, dtype={"trade_date": "string"}) for col in ["open", "high", "low", "close", "volume", "amount", "preClose"]: if col in df.columns: df[col] = pd.to_numeric(df[col], errors="coerce") df = df.dropna(subset=["open", "high", "low", "close", "preClose"]).sort_values("trade_date").reset_index(drop=True) rate = board_limit_rate(symbol) df["ret_pct"] = (df["close"] / df["preClose"] - 1.0) * 100.0 df["high_vs_prev_close_pct"] = (df["high"] / df["preClose"] - 1.0) * 100.0 df["limitup_hit_flag"] = df["high_vs_prev_close_pct"] >= (rate * 100.0 - 0.05) df["ma5"] = df["close"].rolling(5, min_periods=5).mean() df["ma20"] = df["close"].rolling(20, min_periods=20).mean() return df def minute_path(symbol: str) -> Path: code, exchange = symbol.split(".") return MINUTE_BASE / exchange / f"price_{code}.csv" def load_minute(symbol: str, entry_date: str) -> pd.DataFrame: path = minute_path(symbol) if not path.exists(): return pd.DataFrame() date_key = entry_date.replace("-", "") df = pd.read_csv(path, dtype={"timetag": "string"}) df = df[df["timetag"].str.startswith(date_key, na=False)].copy() if df.empty: return df df["trade_time"] = df["timetag"].str.slice(9, 17) for col in ["open", "high", "low", "close", "volumn", "amount"]: df[col] = pd.to_numeric(df[col], errors="coerce") df = df.dropna(subset=["open", "high", "low", "close"]).sort_values("trade_time").reset_index(drop=True) if not df.empty: open_ref = float(df.iloc[0]["open"]) df["ret_vs_open_pct"] = (df["close"] / open_ref - 1.0) * 100.0 df["high_vs_open_pct"] = (df["high"] / open_ref - 1.0) * 100.0 df["low_vs_open_pct"] = (df["low"] / open_ref - 1.0) * 100.0 return df def row_at_or_before(df: pd.DataFrame, time_value: str) -> pd.Series | None: part = df[df["trade_time"] <= time_value] if part.empty: return None return part.iloc[-1] def row_at_or_after(df: pd.DataFrame, time_value: str) -> pd.Series | None: part = df[df["trade_time"] >= time_value] if part.empty: return None return part.iloc[0] def minute_key_points(minute: pd.DataFrame) -> pd.DataFrame: if minute.empty: return pd.DataFrame() keys: list[tuple[str, pd.Series | None]] = [ ("open_0930", minute.iloc[0]), ("pre1040_max_high", minute.loc[minute[minute["trade_time"] <= "10:40:00"]["high"].idxmax()] if not minute[minute["trade_time"] <= "10:40:00"].empty else None), ("pre1040_min_low", minute.loc[minute[minute["trade_time"] <= "10:40:00"]["low"].idxmin()] if not minute[minute["trade_time"] <= "10:40:00"].empty else None), ("at_or_before_1040", row_at_or_before(minute, "10:40:00")), ("at_or_after_1440", row_at_or_after(minute, "14:40:00")), ("tail_max_high", minute.loc[minute[minute["trade_time"] >= "14:40:00"]["high"].idxmax()] if not minute[minute["trade_time"] >= "14:40:00"].empty else None), ("tail_min_low", minute.loc[minute[minute["trade_time"] >= "14:40:00"]["low"].idxmin()] if not minute[minute["trade_time"] >= "14:40:00"].empty else None), ("day_max_high", minute.loc[minute["high"].idxmax()]), ("day_min_low", minute.loc[minute["low"].idxmin()]), ("close_1500", minute.iloc[-1]), ] rows = [] seen = set() for label, row in keys: if row is None: continue key = (label, str(row["trade_time"])) if key in seen: continue seen.add(key) rows.append( { "point": label, "trade_time": row["trade_time"], "open": row["open"], "high": row["high"], "low": row["low"], "close": row["close"], "ret_vs_open_pct": row["ret_vs_open_pct"], "high_vs_open_pct": row["high_vs_open_pct"], "low_vs_open_pct": row["low_vs_open_pct"], "volume": row.get("volumn", ""), "amount": row.get("amount", ""), } ) return pd.DataFrame(rows) def minute_summary(minute: pd.DataFrame, ma5: float | None) -> dict: if minute.empty: return {} open_ref = float(minute.iloc[0]["open"]) pre1040 = minute[minute["trade_time"] <= "10:40:00"] tail = minute[minute["trade_time"] >= "14:40:00"] summary = { "open_ref": open_ref, "close_ret_pct": pct(float(minute.iloc[-1]["close"]), open_ref), "day_max_ret_pct": pct(float(minute["high"].max()), open_ref), "day_min_ret_pct": pct(float(minute["low"].min()), open_ref), "above_open_ratio": float((minute["close"] >= open_ref).mean()), "pre1040_max_ret_pct": pct(float(pre1040["high"].max()), open_ref) if not pre1040.empty else "", "pre1040_min_ret_pct": pct(float(pre1040["low"].min()), open_ref) if not pre1040.empty else "", "pre1040_above_open_ratio": float((pre1040["close"] >= open_ref).mean()) if not pre1040.empty else "", "tail_max_ret_pct": pct(float(tail["high"].max()), open_ref) if not tail.empty else "", "tail_min_ret_pct": pct(float(tail["low"].min()), open_ref) if not tail.empty else "", "tail_above_open_ratio": float((tail["close"] >= open_ref).mean()) if not tail.empty else "", } if ma5 is not None: summary["ma5"] = ma5 summary["above_ma5_ratio"] = float((minute["close"] >= ma5).mean()) return summary def daily_window(daily: pd.DataFrame, entry_date: str) -> pd.DataFrame: if daily.empty: return pd.DataFrame() key = entry_date.replace("-", "") if key not in set(daily["trade_date"]): pos = daily[daily["trade_date"] < key].index.max() else: pos = int(daily.index[daily["trade_date"].eq(key)][0]) if pd.isna(pos): return pd.DataFrame() start = max(0, int(pos) - 35) end = min(len(daily), int(pos) + 2) cols = [ "trade_date", "open", "high", "low", "close", "preClose", "ret_pct", "high_vs_prev_close_pct", "volume", "limitup_hit_flag", "ma5", "ma20", ] return daily.iloc[start:end][cols].copy() def centered_daily_window(daily: pd.DataFrame, entry_date: str, before: int = 20, after: int = 20) -> pd.DataFrame: if daily.empty: return pd.DataFrame() key = entry_date.replace("-", "") if key not in set(daily["trade_date"]): return pd.DataFrame() pos = int(daily.index[daily["trade_date"].eq(key)][0]) start = max(0, pos - before) end = min(len(daily), pos + after + 1) cols = [ "trade_date", "open", "high", "low", "close", "preClose", "ret_pct", "high_vs_prev_close_pct", "volume", "limitup_hit_flag", "ma5", "ma20", ] out = daily.iloc[start:end][cols].copy() out["window_offset"] = list(range(start - pos, end - pos)) out["is_entry_day"] = out["trade_date"].eq(key) return out[ [ "window_offset", "is_entry_day", "trade_date", "open", "high", "low", "close", "preClose", "ret_pct", "high_vs_prev_close_pct", "volume", "limitup_hit_flag", "ma5", "ma20", ] ] def y_price(value: float, low: float, high: float, top: int, bottom: int) -> int: if high <= low: return (top + bottom) // 2 return bottom - int((value - low) / (high - low) * (bottom - top)) def draw_centered_daily_chart(symbol: str, entry_date: str, window: pd.DataFrame, out_path: Path) -> None: w, h = 1500, 820 img = Image.new("RGB", (w, h), "#fbfbf7") d = ImageDraw.Draw(img) d.rectangle([0, 0, w - 1, h - 1], outline="#cbd5e1") d.text((30, 22), f"41交易日日线窗口:{symbol} / 买入日 {entry_date}", fill="#111827", font=FONT_TITLE) d.text((30, 56), "窗口口径:买入日前20个交易日 + 买入日 + 买入日后20个交易日", fill="#334155", font=FONT_SMALL) if window.empty: d.text((30, 120), "无日线窗口数据", fill="#991b1b", font=FONT_MID) out_path.parent.mkdir(parents=True, exist_ok=True) img.save(out_path) return plot_left, plot_top, plot_right, plot_bottom = 80, 105, 1420, 560 vol_top, vol_bottom = 610, 760 d.rectangle([plot_left, plot_top, plot_right, plot_bottom], outline="#94a3b8") d.rectangle([plot_left, vol_top, plot_right, vol_bottom], outline="#94a3b8") lows = window[["low", "ma5", "ma20"]].apply(pd.to_numeric, errors="coerce").min(skipna=True).min() highs = window[["high", "ma5", "ma20"]].apply(pd.to_numeric, errors="coerce").max(skipna=True).max() price_low = float(lows) * 0.98 price_high = float(highs) * 1.02 max_vol = max(float(pd.to_numeric(window["volume"], errors="coerce").max()), 1.0) n = len(window) step = (plot_right - plot_left) / max(n, 1) candle_w = max(5, int(step * 0.55)) ma5_pts = [] ma20_pts = [] for i, (_, row) in enumerate(window.reset_index(drop=True).iterrows()): x = int(plot_left + step * (i + 0.5)) o = float(row["open"]) c = float(row["close"]) hi = float(row["high"]) lo = float(row["low"]) color = "#dc2626" if c >= o else "#16a34a" yy_hi = y_price(hi, price_low, price_high, plot_top, plot_bottom) yy_lo = y_price(lo, price_low, price_high, plot_top, plot_bottom) yy_o = y_price(o, price_low, price_high, plot_top, plot_bottom) yy_c = y_price(c, price_low, price_high, plot_top, plot_bottom) d.line([x, yy_hi, x, yy_lo], fill=color, width=2) body_top = min(yy_o, yy_c) body_bottom = max(yy_o, yy_c) if body_bottom == body_top: d.line([x - candle_w // 2, body_top, x + candle_w // 2, body_top], fill=color, width=3) else: d.rectangle([x - candle_w // 2, body_top, x + candle_w // 2, body_bottom], fill=color, outline=color) vh = int(float(row["volume"]) / max_vol * (vol_bottom - vol_top)) d.line([x, vol_bottom, x, vol_bottom - vh], fill=color, width=max(2, candle_w // 3)) if str(row["is_entry_day"]).lower() == "true": d.line([x, plot_top, x, vol_bottom], fill="#7c3aed", width=2) d.text((x - 35, plot_top - 24), "买入日", fill="#7c3aed", font=FONT_SMALL) if bool(row.get("limitup_hit_flag", False)): d.ellipse([x - 5, yy_hi - 18, x + 5, yy_hi - 8], fill="#f59e0b") if i % 5 == 0 or str(row["is_entry_day"]).lower() == "true": d.text((x - 28, vol_bottom + 8), str(row["trade_date"])[4:], fill="#64748b", font=FONT_SMALL) if pd.notna(row.get("ma5", None)): ma5_pts.append((x, y_price(float(row["ma5"]), price_low, price_high, plot_top, plot_bottom))) if pd.notna(row.get("ma20", None)): ma20_pts.append((x, y_price(float(row["ma20"]), price_low, price_high, plot_top, plot_bottom))) if len(ma5_pts) > 1: d.line(ma5_pts, fill="#2563eb", width=2) if len(ma20_pts) > 1: d.line(ma20_pts, fill="#9333ea", width=2) d.text((plot_left, 780), "红/绿K:日K;蓝线 MA5;紫线 MA20;紫色竖线为买入日;橙点为 high/prevClose 触及板块涨停阈值。", fill="#334155", font=FONT_SMALL) d.text((plot_right - 210, 80), "MA5", fill="#2563eb", font=FONT_SMALL) d.text((plot_right - 160, 80), "MA20", fill="#9333ea", font=FONT_SMALL) d.text((plot_right - 100, 80), "涨停记忆", fill="#f59e0b", font=FONT_SMALL) out_path.parent.mkdir(parents=True, exist_ok=True) img.save(out_path) def selected_daily_nodes(daily: pd.DataFrame, candidate: pd.Series) -> pd.DataFrame: if daily.empty: return pd.DataFrame() node_dates = { "latest_prior_strict_limitup": str(candidate.get("latest_prior_strict_limitup_date", "")), "pullback_low_since_limitup": str(candidate.get("pullback_low_since_latest_limitup_date", "")), "prev60_high_ref": str(candidate.get("prev60_high_ref_date", "")), "signal_day": str(candidate.get("signal_trade_date", "")), "entry_day": str(candidate.get("entry_trade_date", "")), } rows = [] for role, date in node_dates.items(): if not date or date.lower() == "nan": continue key = date.replace("-", "") part = daily[daily["trade_date"].eq(key)] if part.empty: continue r = part.iloc[0].to_dict() r["node_role"] = role rows.append(r) cols = [ "node_role", "trade_date", "open", "high", "low", "close", "preClose", "ret_pct", "high_vs_prev_close_pct", "volume", "limitup_hit_flag", "ma5", "ma20", ] return pd.DataFrame(rows)[cols] if rows else pd.DataFrame(columns=cols) def md_table(df: pd.DataFrame, cols: list[str], max_rows: int | None = None) -> list[str]: if df.empty: return ["_无数据_"] part = df[cols].copy() if max_rows is not None: part = part.head(max_rows) lines = ["| " + " | ".join(cols) + " |", "|" + "|".join(["---"] * len(cols)) + "|"] for _, row in part.iterrows(): vals = [] for c in cols: v = row[c] if isinstance(v, float): vals.append(fmt(v)) else: vals.append(str(v).replace("|", "/")) lines.append("| " + " | ".join(vals) + " |") return lines def main() -> None: p1 = pd.read_csv(ROOT / "buy_point_second_review_recheck_list.csv", encoding="utf-8-sig") p1 = p1[p1["human_recheck_priority"].eq("P1_BUY_DECISION_MAY_BE_WRONG")].copy() candidates = pd.read_csv(SOURCE_ROOT / "strict_note_buy_point_review_candidate_ledger.csv", encoding="utf-8-sig") orders = pd.read_csv(SOURCE_ROOT / "strict_note_order_ledger.csv", encoding="utf-8-sig") lots = pd.read_csv(SOURCE_ROOT / "strict_note_position_lot_ledger.csv", encoding="utf-8-sig") cases = pd.read_csv(SOURCE_ROOT / "strict_note_case_summary.csv", encoding="utf-8-sig") packet_root = ROOT / "p1_step_review_packets" table_root = packet_root / "tables" packet_root.mkdir(parents=True, exist_ok=True) generated_at = now_iso() index_rows = [] for i, (_, row) in enumerate(p1.iterrows(), start=1): candidate_id = row["candidate_id"] safe_id = candidate_id.replace(".", "_").replace("/", "_") packet_path = packet_root / f"{i:02d}_{safe_id}.md" cand = candidates[candidates["candidate_id"].eq(candidate_id)].iloc[0] candidate_orders = orders[orders["candidate_id"].eq(candidate_id)].copy() candidate_lots = lots[lots["candidate_id"].eq(candidate_id)].copy() case_row = cases[cases["case_id"].eq(row["case_id"])].head(1) daily = load_daily(row["symbol"]) minute = load_minute(row["symbol"], row["entry_trade_date"]) signal_key = str(row["signal_trade_date"]).replace("-", "") ma5 = None if not daily.empty: signal_part = daily[daily["trade_date"] <= signal_key].tail(5) if len(signal_part) == 5: ma5 = float(signal_part["close"].mean()) daily_nodes = selected_daily_nodes(daily, cand) daily_win = daily_window(daily, row["entry_trade_date"]) centered_win = centered_daily_window(daily, row["entry_trade_date"]) minute_keys = minute_key_points(minute) msum = minute_summary(minute, ma5) write_csv(daily_nodes, table_root / f"{safe_id}_daily_nodes.csv") write_csv(daily_win, table_root / f"{safe_id}_daily_window.csv") write_csv(centered_win, table_root / f"{safe_id}_daily_center_41.csv") write_csv(minute_keys, table_root / f"{safe_id}_minute_key_points.csv") daily_chart_path = packet_root / "charts" / f"{safe_id}_daily_center_41.png" draw_centered_daily_chart(row["symbol"], row["entry_trade_date"], centered_win, daily_chart_path) image_path = SOURCE_ROOT / str(row["review_input_chart_path"]) image_link = as_posix(image_path) daily_chart_link = as_posix(daily_chart_path) daily_nodes_link = as_posix(table_root / f"{safe_id}_daily_nodes.csv") daily_window_link = as_posix(table_root / f"{safe_id}_daily_window.csv") daily_center_link = as_posix(table_root / f"{safe_id}_daily_center_41.csv") minute_keys_link = as_posix(table_root / f"{safe_id}_minute_key_points.csv") lines = [ f"# P1 买点逐条复核:{row['symbol']} / {row['entry_trade_date']}", "", f"- packet_order: {i}", f"- generated_at: {generated_at}", f"- case_id: {row['case_id']}", f"- candidate_id: {candidate_id}", f"- source_run_id: {SOURCE_RUN_ID}", "", "## 你要裁决", "", "- [ ] 维持 BUY", "- [ ] 改为 REVIEW_HELD", "- [ ] 数据不足,待补充", "", "建议先看:原人工理由是否和图证/数据一致;再看是否存在笔记要求的 10:40 前或 14:40 后买点。", "", "## 原人工裁决和二审异议", "", f"- 原人工动作:`{row['human_decision_action']}`", f"- 原人工理由:{row['human_decision_reason_cn']}", f"- 二审异议:`{row['second_review_issue_code']}`,{row['second_review_reason_cn']}", f"- 我的初步倾向:**需要重看,倾向至少撤出自动 BUY;最终以你人工看图和数据裁决为准。**", "", "## 交易账本影响", "", ] if candidate_orders.empty: lines.append("_未找到订单记录_") else: lines += md_table( candidate_orders, ["order_type", "order_id", "trade_date", "trade_time", "trade_price", "position_pct", "decision_reason_cn"], ) lines += ["", "### lot / case 状态", ""] if candidate_lots.empty: lines.append("_未找到 lot 记录_") else: lines += md_table( candidate_lots, ["lot_id", "entry_trade_date", "entry_price", "position_pct", "exit_trade_date", "exit_price", "lot_scope_status", "boundary_type"], ) if not case_row.empty: lines += ["", "### case 汇总", ""] lines += md_table( case_row, [ "case_id", "symbols", "strict_buy_orders", "sell_orders", "strict_closed_lots", "boundary_lots", "net_account_contribution", "case_scope_status", ], ) lines += [ "", "## 入池硬条件", "", "| 字段 | 值 |", "|---|---|", ] hard_fields = [ "signal_trade_date", "entry_trade_date", "candidate_rank", "market_gate_status", "up_count", "prior_strict_limitup_30_flag", "latest_prior_strict_limitup_date", "volume_ratio", "pullback_from_latest_limitup_close_pct", "upper_shadow_pct", "upper_shadow_range_ratio", "prev60_high", "prev60_high_ref_date", "prev_high_volume_pass_flag", ] for field in hard_fields: if field in cand.index: lines.append(f"| {field} | {fmt(cand[field])} |") lines += [ "", "## 买点分时图", "", f"![买点复核图]({image_link})", "", "## 买入点前后 20 个交易日的日线窗口", "", f"![41交易日日线窗口]({daily_chart_link})", "", f"- 窗口 CSV:[{Path(daily_center_link).name}]({daily_center_link})", f"- 实际窗口行数:{len(centered_win)}", "", "### 41 日窗口明细", "", ] lines += md_table( centered_win, [ "window_offset", "is_entry_day", "trade_date", "open", "high", "low", "close", "ret_pct", "high_vs_prev_close_pct", "volume", "limitup_hit_flag", "ma5", "ma20", ], ) lines += [ "", "## 分时复算摘要", "", "| 指标 | 值 | 含义 |", "|---|---:|---|", f"| open_ref | {fmt(msum.get('open_ref', ''))} | 当天 09:30 开盘参考价 |", f"| close_ret_pct | {fmt(msum.get('close_ret_pct', ''))}% | 收盘相对开盘 |", f"| day_max_ret_pct | {fmt(msum.get('day_max_ret_pct', ''))}% | 全天最高相对开盘 |", f"| day_min_ret_pct | {fmt(msum.get('day_min_ret_pct', ''))}% | 全天最低相对开盘 |", f"| above_open_ratio | {fmt(msum.get('above_open_ratio', ''), 4)} | 全天 close 在开盘价上方的分钟占比 |", f"| pre1040_max_ret_pct | {fmt(msum.get('pre1040_max_ret_pct', ''))}% | 10:40 前最高相对开盘 |", f"| pre1040_min_ret_pct | {fmt(msum.get('pre1040_min_ret_pct', ''))}% | 10:40 前最低相对开盘 |", f"| pre1040_above_open_ratio | {fmt(msum.get('pre1040_above_open_ratio', ''), 4)} | 10:40 前在开盘价上方占比 |", f"| tail_max_ret_pct | {fmt(msum.get('tail_max_ret_pct', ''))}% | 14:40 后最高相对开盘 |", f"| tail_min_ret_pct | {fmt(msum.get('tail_min_ret_pct', ''))}% | 14:40 后最低相对开盘 |", f"| tail_above_open_ratio | {fmt(msum.get('tail_above_open_ratio', ''), 4)} | 14:40 后在开盘价上方占比 |", f"| ma5 | {fmt(msum.get('ma5', ''))} | 信号日前 5 日均价,仅作支撑参考 |", f"| above_ma5_ratio | {fmt(msum.get('above_ma5_ratio', ''), 4)} | 全天 close 在 MA5 上方占比 |", "", "## 分时关键点", "", ] lines += md_table( minute_keys, ["point", "trade_time", "open", "high", "low", "close", "ret_vs_open_pct", "high_vs_open_pct", "low_vs_open_pct", "volume"], ) lines += [ "", "## 日线关键节点", "", ] lines += md_table( daily_nodes, ["node_role", "trade_date", "open", "high", "low", "close", "ret_pct", "high_vs_prev_close_pct", "volume", "limitup_hit_flag", "ma5", "ma20"], ) lines += [ "", "## 数据文件", "", f"- 原图:[{image_path.name}]({image_link})", f"- 日线关键节点 CSV:[{Path(daily_nodes_link).name}]({daily_nodes_link})", f"- 日线窗口 CSV:[{Path(daily_window_link).name}]({daily_window_link})", f"- 买入日前后20交易日 CSV:[{Path(daily_center_link).name}]({daily_center_link})", f"- 买入日前后20交易日日线图:[{Path(daily_chart_link).name}]({daily_chart_link})", f"- 分时关键点 CSV:[{Path(minute_keys_link).name}]({minute_keys_link})", "", "## 逐条复核问题", "", "1. 图上是否存在 10:40 前的“冲高后回踩开盘价/均线并缩量承接”?", "2. 图上是否存在 14:40 后的清晰重新站稳和承接?", "3. 原人工理由是否与图上实际走势一致?", "4. 如果改成 REVIEW_HELD,是否应从交易账本撤掉这笔 BUY,并重算后续 lot/case 读数?", "", ] write_text("\n".join(lines), packet_path) index_rows.append( { "order": i, "case_id": row["case_id"], "candidate_id": candidate_id, "symbol": row["symbol"], "entry_trade_date": row["entry_trade_date"], "original_action": row["human_decision_action"], "issue_code": row["second_review_issue_code"], "close_ret_pct": row.get("close_ret_pct", ""), "above_open_ratio": row.get("above_open_ratio", ""), "packet_path": as_posix(packet_path), "source_chart": image_link, } ) index = pd.DataFrame(index_rows) write_csv(index, packet_root / "p1_step_review_index.csv") index_lines = [ "# P1 买点逐条复核工作台", "", f"- generated_at: {generated_at}", f"- source_run_id: {SOURCE_RUN_ID}", f"- item_count: {len(index)}", "", "使用方式:按顺序打开每条 packet,先看原图,再看分时复算摘要、日线关键节点和交易影响;最后在会话里告诉我“第 N 条维持 BUY / 改 HELD / 数据不足”和理由,我来记录并汇总影响。", "", "| # | case | symbol | date | issue | close% | above_open | packet |", "|---:|---|---|---|---|---:|---:|---|", ] for _, r in index.iterrows(): index_lines.append( f"| {r['order']} | {r['case_id']} | {r['symbol']} | {r['entry_trade_date']} | {r['issue_code']} | " f"{fmt(r['close_ret_pct'])} | {fmt(r['above_open_ratio'], 4)} | [打开复核包]({r['packet_path']}) |" ) write_text("\n".join(index_lines) + "\n", packet_root / "p1_step_review_index.md") manifest_rows = [] for path in sorted(packet_root.rglob("*")): if path.is_file(): manifest_rows.append( { "path": path.relative_to(ROOT).as_posix(), "size": path.stat().st_size, "sha256": sha256_file(path), } ) manifest = pd.DataFrame(manifest_rows) write_csv(manifest, packet_root / "p1_step_review_manifest.csv") (packet_root / "p1_step_review_summary.json").write_text( json.dumps( { "run_id": RUN_ID, "generated_at": generated_at, "source_run_id": SOURCE_RUN_ID, "item_count": int(len(index)), "index_path": as_posix(packet_root / "p1_step_review_index.md"), }, ensure_ascii=False, indent=2, ), encoding="utf-8", ) if __name__ == "__main__": main()