from __future__ import annotations import hashlib import json import os import re from pathlib import Path import pandas as pd import pymysql from PIL import Image, ImageDraw, ImageFont RUN_ID = "RUN-ANA-WUJI-BASELINE-PILOT-20260607-001" ROOT = Path(__file__).resolve().parents[1] LOCAL_DB_INDEX = Path( r"D:\strategy_project\s-system-doc\observer\天下模型沉淀\数据库索引数据.md" ) def read_password() -> str: env = os.environ.get("TIANXIA_MYSQL_PASSWORD") or os.environ.get("MYSQL_PWD") if env: return env text = LOCAL_DB_INDEX.read_text(encoding="utf-8") match = re.search(r"^\s*-\s*密码:`([^`]+)`", text, re.MULTILINE) if not match: raise RuntimeError("Unable to read local MySQL credential from approved local index.") return match.group(1) def get_conn(): return pymysql.connect( host="127.0.0.1", port=3306, user="root", password=read_password(), database="tianxia", charset="utf8mb4", connect_timeout=5, read_timeout=120, ) 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 font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: 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(26) FONT_MID = font(17) FONT_SMALL = font(13) def normalize_time(value) -> str: text = str(value) if "days" in text: text = text.split()[-1] if "." in text: text = text.split(".")[0] parts = text.split(":") if len(parts) >= 3: return f"{int(parts[0]):02d}:{int(parts[1]):02d}:{int(float(parts[2])):02d}" return text 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_chart(df: pd.DataFrame, cand: dict, window_name: str, out_path: Path, ma20_ref: float | None) -> None: w, h = 1500, 860 img = Image.new("RGB", (w, h), "#fbfbf7") d = ImageDraw.Draw(img) d.rectangle([0, 0, w - 1, h - 1], outline="#cbd5e1") title = f"买点复核1分钟K图:{cand['symbol']} {cand['entry_trade_date']} {window_name}" d.text((32, 24), title, fill="#111827", font=FONT_TITLE) d.text((32, 58), "当前为人工复核材料,不自动判定 BUY;买点必须由后续人工/AI 看图确认。", fill="#334155", font=FONT_MID) plot_left, plot_top, plot_right, plot_bottom = 80, 105, 1060, 575 vol_top, vol_bottom = 625, 780 note_left, note_top = 1090, 110 d.rectangle([plot_left, plot_top, plot_right, plot_bottom], outline="#94a3b8") d.rectangle([plot_left, vol_top, plot_right, vol_bottom], outline="#94a3b8") if df.empty: d.text((plot_left + 160, plot_top + 180), "该窗口无分钟线数据", fill="#b91c1c", font=FONT_TITLE) price_low, price_high = 0.0, 1.0 else: price_low = float(df["low_price"].min()) * 0.998 price_high = float(df["high_price"].max()) * 1.002 refs = [float(df["open_price"].iloc[0])] if ma20_ref: refs.append(ma20_ref) price_low = min(price_low, min(refs) * 0.998) price_high = max(price_high, max(refs) * 1.002) max_vol = max(float(df["volume"].max()), 1.0) n = len(df) gap = (plot_right - plot_left) / max(n, 1) body_w = max(3, int(gap * 0.55)) for i, row in df.reset_index(drop=True).iterrows(): cx = int(plot_left + gap * i + gap / 2) op, hi, lo, cl = [float(row[c]) for c in ["open_price", "high_price", "low_price", "close_price"]] color = "#dc2626" if cl >= op else "#16a34a" d.line([cx, y_price(lo, price_low, price_high, plot_top, plot_bottom), cx, y_price(hi, price_low, price_high, plot_top, plot_bottom)], fill=color, width=2) y1, y2 = y_price(op, price_low, price_high, plot_top, plot_bottom), y_price(cl, price_low, price_high, plot_top, plot_bottom) d.rectangle([cx - body_w // 2, min(y1, y2), cx + body_w // 2, max(y1, y2)], fill=color, outline=color) vh = int(float(row["volume"]) / max_vol * (vol_bottom - vol_top)) d.rectangle([cx - body_w // 2, vol_bottom - vh, cx + body_w // 2, vol_bottom], fill=color, outline=color) if i % max(1, n // 6) == 0: d.text((cx - 24, vol_bottom + 8), str(row["trade_time"])[:5], fill="#64748b", font=FONT_SMALL) open_ref = float(df["open_price"].iloc[0]) y_open = y_price(open_ref, price_low, price_high, plot_top, plot_bottom) d.line([plot_left, y_open, plot_right, y_open], fill="#0f172a", width=2) d.text((plot_right + 8, y_open - 8), f"开盘价 {open_ref:.2f}", fill="#0f172a", font=FONT_SMALL) if ma20_ref: y_ma20 = y_price(ma20_ref, price_low, price_high, plot_top, plot_bottom) d.line([plot_left, y_ma20, plot_right, y_ma20], fill="#f59e0b", width=2) d.text((plot_right + 8, y_ma20 - 8), f"日MA20 {ma20_ref:.2f}", fill="#b45309", font=FONT_SMALL) for i in range(5): p = price_low + (price_high - price_low) * i / 4 y = y_price(p, price_low, price_high, plot_top, plot_bottom) d.line([plot_left, y, plot_right, y], fill="#e2e8f0") d.text((18, y - 8), f"{p:.2f}", fill="#64748b", font=FONT_SMALL) d.rounded_rectangle([note_left, note_top, 1460, 780], radius=8, outline="#334155", fill="#ffffff") d.text((note_left + 18, note_top + 18), "买点复核提示", fill="#111827", font=FONT_TITLE) notes = [ f"候选排名:{cand['candidate_rank']}", f"候选状态:{cand['candidate_status']}", f"窗口:{window_name}", "允许买点:10:40前或14:40后", "看图重点:", "1. 回踩20日均线附近", "2. 回踩开盘价附近", "3. 缩量有支撑", "4. 放量回踩均线禁买", "5. 急拉放量不追", "", "当前动作:ENTRY_REVIEW_PENDING", "不写 BUY,不计收益。", ] yy = note_top + 58 for line in notes: d.text((note_left + 18, yy), line, fill="#334155", font=FONT_SMALL) yy += 24 if line else 12 d.text((32, 820), "无忌 baseline:买点由1分钟K图和人工确认裁决;本图只准备证据,不产生交易结论。", fill="#334155", font=FONT_MID) img.save(out_path) def main() -> None: selected = pd.read_csv(ROOT / "selected_candidate_ledger.csv", encoding="utf-8-sig") selected["entry_trade_date"] = pd.to_datetime(selected["entry_trade_date"]) selected["signal_trade_date"] = pd.to_datetime(selected["signal_trade_date"]) open_selected = selected[selected["market_gate_status"] == "MKT_GATE_OPEN_PREV_DAY_UP_3000"].copy() symbols = sorted(open_selected["symbol"].unique().tolist()) dates = sorted(open_selected["entry_trade_date"].dt.strftime("%Y-%m-%d").unique().tolist()) min_signal = (open_selected["signal_trade_date"].min() - pd.Timedelta(days=80)).strftime("%Y-%m-%d") max_signal = open_selected["signal_trade_date"].max().strftime("%Y-%m-%d") minute = pd.DataFrame() daily = pd.DataFrame() if symbols and dates: sym_ph = ",".join(["%s"] * len(symbols)) date_ph = ",".join(["%s"] * len(dates)) with get_conn() as conn: minute = pd.read_sql( f""" SELECT trade_date, trade_time, symbol, open_price, high_price, low_price, close_price, volume, amount FROM a_share_minute_price WHERE symbol IN ({sym_ph}) AND trade_date IN ({date_ph}) ORDER BY symbol, trade_date, trade_time """, conn, params=[*symbols, *dates], ) daily = pd.read_sql( f""" SELECT trade_date, symbol, close_price FROM a_share_daily_price WHERE symbol IN ({sym_ph}) AND trade_date BETWEEN %s AND %s ORDER BY symbol, trade_date """, conn, params=[*symbols, min_signal, max_signal], ) if not minute.empty: minute["trade_date"] = pd.to_datetime(minute["trade_date"]) minute["trade_time"] = minute["trade_time"].map(normalize_time) for col in ["open_price", "high_price", "low_price", "close_price", "volume", "amount"]: minute[col] = pd.to_numeric(minute[col], errors="coerce") if not daily.empty: daily["trade_date"] = pd.to_datetime(daily["trade_date"]) daily["close_price"] = pd.to_numeric(daily["close_price"], errors="coerce") daily["ma20"] = daily.groupby("symbol")["close_price"].transform(lambda s: s.rolling(20, min_periods=1).mean()) manifest_rows = [] decision_rows = [] morning_count = 0 late_count = 0 data_gap_count = 0 root_manifest_path = ROOT / "image_manifest.csv" if root_manifest_path.exists(): root_manifest = pd.read_csv(root_manifest_path, encoding="utf-8-sig") else: root_manifest = pd.DataFrame() for _, cand in selected.iterrows(): case_id = cand["case_id"] case_dir = ROOT / "cases" / case_id img_dir = case_dir / "img" img_dir.mkdir(parents=True, exist_ok=True) entry_str = cand["entry_trade_date"].strftime("%Y-%m-%d") signal_str = cand["signal_trade_date"].strftime("%Y-%m-%d") if cand["market_gate_status"] != "MKT_GATE_OPEN_PREV_DAY_UP_3000": decision_rows.append( { "case_id": case_id, "candidate_id": cand["candidate_id"], "symbol": cand["symbol"], "entry_trade_date": entry_str, "decision_stage": "ENTRY_GATE", "action_status": "NO_TRADE_MARKET_GATE_CLOSED", "review_required": False, "evidence_image_path": "", "decision_reason_cn": "前一交易日全A上涨家数未达到3000,按baseline不开新仓。", "lookahead_violation_flag": False, } ) continue ma20_rows = daily[(daily["symbol"] == cand["symbol"]) & (daily["trade_date"] <= cand["signal_trade_date"])].tail(1) ma20_ref = float(ma20_rows["ma20"].iloc[0]) if not ma20_rows.empty else None m = minute[(minute["symbol"] == cand["symbol"]) & (minute["trade_date"] == cand["entry_trade_date"])].copy() windows = [ ("早盘窗口", "morning", "09:30:00", "10:40:00"), ("尾盘窗口", "late", "14:40:00", "15:00:00"), ] candidate_paths = [] missing_windows = [] for label, key, start, end in windows: part = m[(m["trade_time"] >= start) & (m["trade_time"] <= end)].copy() file_name = f"03_entry_1m_{key}_review_{cand['symbol'].replace('.', '_')}_{entry_str.replace('-', '')}.png" out_path = img_dir / file_name cand_dict = cand.to_dict() cand_dict["entry_trade_date"] = entry_str cand_dict["signal_trade_date"] = signal_str draw_chart(part, cand_dict, label, out_path, ma20_ref) if key == "morning": morning_count += 1 else: late_count += 1 if part.empty: data_gap_count += 1 missing_windows.append(label) rel = out_path.relative_to(ROOT).as_posix() candidate_paths.append(rel) manifest_rows.append( { "case_id": case_id, "symbol": cand["symbol"], "trade_date": entry_str, "event_id": f"{cand['candidate_id']}_{key}", "chart_role": f"entry_1m_{key}_review_view", "decision_time": f"{entry_str} {end}", "path": rel, "sha256": sha256_file(out_path), "status": "PASS" if not part.empty else "DATA_GAP_HELD", "note": "1分钟买点复核图;不自动判定BUY。", } ) decision_rows.append( { "case_id": case_id, "candidate_id": cand["candidate_id"], "symbol": cand["symbol"], "entry_trade_date": entry_str, "decision_stage": "ENTRY_REVIEW", "action_status": "ENTRY_REVIEW_PENDING" if not missing_windows else "ENTRY_REVIEW_DATA_GAP", "review_required": True, "evidence_image_path": ";".join(candidate_paths), "decision_reason_cn": ( "已生成1分钟买点复核图,需人工/AI按缩量支撑、回踩均线/开盘价、急拉放量禁追等规则裁决。" if not missing_windows else "部分买点窗口缺少分钟线:" + "、".join(missing_windows) ), "lookahead_violation_flag": False, } ) new_manifest = pd.DataFrame(manifest_rows) if not root_manifest.empty: combined_manifest = pd.concat([root_manifest, new_manifest], ignore_index=True) combined_manifest = combined_manifest.drop_duplicates(subset=["case_id", "symbol", "event_id", "chart_role"], keep="last") else: combined_manifest = new_manifest combined_manifest.to_csv(ROOT / "image_manifest.csv", index=False, encoding="utf-8-sig") decision_log = pd.DataFrame(decision_rows) decision_log.to_csv(ROOT / "decision_log.csv", index=False, encoding="utf-8-sig") # Append entry section to case boards. for case_id, group in new_manifest.groupby("case_id"): case_dir = ROOT / "cases" / case_id board_path = case_dir / "case_image_board.md" existing = board_path.read_text(encoding="utf-8") if board_path.exists() else f"# {case_id} 图片审核板\n" lines = [existing.rstrip(), "", "## 3. 买点 1分钟K 复核图", ""] for _, row in group.iterrows(): rel = Path(row["path"]).relative_to(f"cases/{case_id}").as_posix() lines.extend( [ f"### {row['symbol']} / {row['chart_role']}", "", f"![{row['symbol']}]({rel})", "", f"- 状态:`{row['status']}`", "- 本图只用于买点复核,不自动写 BUY。", "", ] ) board_path.write_text("\n".join(lines) + "\n", encoding="utf-8") case_manifest = combined_manifest[combined_manifest["case_id"] == case_id] case_manifest.to_csv(case_dir / "image_manifest.csv", index=False, encoding="utf-8-sig") summary = { "schema_version": "1.0", "run_id": RUN_ID, "generated_at": "2026-06-08T01:05:00+08:00", "stage": "ENTRY_1M_REVIEW_IMAGES_READY", "decision_rows": int(len(decision_log)), "entry_review_images": int(len(new_manifest)), "morning_images": morning_count, "late_images": late_count, "data_gap_windows": data_gap_count, "no_trade_market_gate_closed_rows": int((decision_log["action_status"] == "NO_TRADE_MARKET_GATE_CLOSED").sum()), "entry_review_pending_rows": int((decision_log["action_status"] == "ENTRY_REVIEW_PENDING").sum()), "entry_review_data_gap_rows": int((decision_log["action_status"] == "ENTRY_REVIEW_DATA_GAP").sum()), "artifacts": { "decision_log.csv": { "size": (ROOT / "decision_log.csv").stat().st_size, "sha256": sha256_file(ROOT / "decision_log.csv"), }, "image_manifest.csv": { "size": (ROOT / "image_manifest.csv").stat().st_size, "sha256": sha256_file(ROOT / "image_manifest.csv"), }, }, "next_step": "Perform manual/AI entry review from 1-minute K charts. Do not create order_ledger until BUY/NO_BUY decisions are confirmed.", } (ROOT / "entry_review_generation_summary.json").write_text( json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", ) (ROOT / "entry_review_generation_summary.md").write_text( "\n".join( [ "# entry_review_generation_summary", "", f"run_id:`{RUN_ID}`", "阶段:`ENTRY_1M_REVIEW_IMAGES_READY`", "", f"- decision rows:{summary['decision_rows']}", f"- 1分钟买点复核图:{summary['entry_review_images']}", f"- 数据缺口窗口:{summary['data_gap_windows']}", f"- 闸门关闭不交易行:{summary['no_trade_market_gate_closed_rows']}", f"- 待人工/AI买点复核行:{summary['entry_review_pending_rows']}", "", "当前不产生订单、不产生收益结论。", "", ] ), encoding="utf-8", ) if __name__ == "__main__": main()