from __future__ import annotations import hashlib import json import math 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(28) FONT_MID = font(18) FONT_SMALL = font(14) def gate_label(raw: str) -> str: return "开仓闸门打开" if raw == "MKT_GATE_OPEN_PREV_DAY_UP_3000" else "开仓闸门关闭" def status_label(raw: str) -> str: return "候选通过" if raw == "PASS" else "前高量能风险待复核" def bool_label(raw) -> str: return "是" if str(raw).lower() == "true" else "否" def draw_wrapped(draw: ImageDraw.ImageDraw, xy: tuple[int, int], text: str, max_chars: int, fill: str, used_font, line_gap: int = 8) -> int: x, y = xy chunks = [] current = "" for ch in text: current += ch if len(current) >= max_chars: chunks.append(current) current = "" if current: chunks.append(current) for chunk in chunks: draw.text((x, y), chunk, fill=fill, font=used_font) y += used_font.size + line_gap if hasattr(used_font, "size") else 24 return y def price_y(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_daily_chart(window: pd.DataFrame, cand: dict, out_path: Path) -> None: w, h = 1500, 920 img = Image.new("RGB", (w, h), "#fbfbf7") d = ImageDraw.Draw(img) d.rectangle([0, 0, w - 1, h - 1], outline="#cbd5e1") title = f"选股日K图:{cand['symbol']} 入场日 {cand['entry_trade_date']}" subtitle = ( f"信号日 {cand['signal_trade_date']}|排名 {cand['candidate_rank']}|" f"量比 {float(cand['volume_ratio']):.2f}|上影 {float(cand['upper_shadow_pct']):.2f}%|" f"闸门 {gate_label(cand['market_gate_status'])}" ) d.text((32, 24), title, fill="#111827", font=FONT_TITLE) d.text((32, 62), subtitle, fill="#334155", font=FONT_MID) plot_left, plot_top, plot_right, plot_bottom = 80, 115, 1060, 610 vol_top, vol_bottom = 660, 820 note_left, note_top = 1090, 118 d.rectangle([plot_left, plot_top, plot_right, plot_bottom], outline="#94a3b8") d.rectangle([plot_left, vol_top, plot_right, vol_bottom], outline="#94a3b8") window = window.copy().reset_index(drop=True) price_low = float(window["low_price"].min()) * 0.98 price_high = float(window["high_price"].max()) * 1.02 max_vol = max(float(window["volume"].max()), 1.0) n = len(window) gap = (plot_right - plot_left) / max(n, 1) body_w = max(3, int(gap * 0.55)) # Grid and price labels. for i in range(5): p = price_low + (price_high - price_low) * i / 4 y = price_y(p, price_low, price_high, plot_top, plot_bottom) d.line([plot_left, y, plot_right, y], fill="#e2e8f0") d.text((18, y - 9), f"{p:.2f}", fill="#64748b", font=FONT_SMALL) ma_colors = {"ma5": "#2563eb", "ma20": "#f59e0b", "ma60": "#7c3aed"} ma_points: dict[str, list[tuple[int, int]]] = {k: [] for k in ma_colors} signal_date = str(cand["signal_trade_date"]) last_limit_date = str(cand.get("last_limitup_date") or "") for i, row in window.iterrows(): cx = int(plot_left + gap * i + gap / 2) op = float(row["open_price"]) hi = float(row["high_price"]) lo = float(row["low_price"]) cl = float(row["close_price"]) color = "#dc2626" if cl >= op else "#16a34a" d.line([cx, price_y(lo, price_low, price_high, plot_top, plot_bottom), cx, price_y(hi, price_low, price_high, plot_top, plot_bottom)], fill=color, width=2) y1 = price_y(op, price_low, price_high, plot_top, plot_bottom) y2 = price_y(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) trade_date = str(row["trade_date"]) if trade_date == signal_date: d.line([cx, plot_top, cx, vol_bottom], fill="#0f172a", width=2) d.text((cx - 32, plot_top - 24), "信号日", fill="#0f172a", font=FONT_SMALL) if trade_date == last_limit_date: d.ellipse([cx - 8, price_y(hi, price_low, price_high, plot_top, plot_bottom) - 22, cx + 8, price_y(hi, price_low, price_high, plot_top, plot_bottom) - 6], fill="#ef4444") d.text((cx - 28, price_y(hi, price_low, price_high, plot_top, plot_bottom) - 48), "涨停记忆", fill="#ef4444", font=FONT_SMALL) if i % max(1, n // 8) == 0: d.text((cx - 28, vol_bottom + 8), trade_date[5:], fill="#64748b", font=FONT_SMALL) for ma in ma_colors: if pd.notna(row[ma]): ma_points[ma].append((cx, price_y(float(row[ma]), price_low, price_high, plot_top, plot_bottom))) for ma, pts in ma_points.items(): if len(pts) > 1: d.line(pts, fill=ma_colors[ma], width=2) legend_x = plot_left + 8 for ma, color in ma_colors.items(): d.text((legend_x, plot_bottom + 12), ma.upper(), fill=color, font=FONT_SMALL) legend_x += 70 d.rounded_rectangle([note_left, note_top, 1460, 820], radius=8, outline="#334155", fill="#ffffff") d.text((note_left + 18, note_top + 18), "候选判读", fill="#111827", font=FONT_TITLE) notes = [ f"候选状态:{status_label(cand['candidate_status'])}", f"市场闸门:{gate_label(cand['market_gate_status'])}", f"上涨家数:{cand.get('up_count', '')},下跌家数:{cand.get('down_count', '')}", f"近30日涨停记忆:{cand.get('last_limitup_date', '')}", f"量比:{float(cand['volume_ratio']):.2f}(阈值 1.70)", f"长上影:{float(cand['upper_shadow_pct']):.2f}%", f"触及前高:{bool_label(cand['touch_prev_high_flag'])}", f"前高量能通过:{bool_label(cand['prev_high_volume_pass_flag'])}", f"60日横盘标记:{bool_label(cand['flat60_flag'])}", "", "图片口径:decision_view", "只展示信号日及以前日线。", "本图只证明进入观察,", "不代表已经买入。", ] yy = note_top + 64 for line in notes: if line: yy = draw_wrapped(d, (note_left + 18, yy), line, 24, "#334155", FONT_SMALL, line_gap=5) else: yy += 16 footer = "无忌 baseline:候选池先看最近涨停记忆、放量、长上影、前高量能;买入还需后续 1 分钟 K 图和人工确认。" d.text((32, 870), footer, fill="#334155", font=FONT_MID) img.save(out_path) def select_candidates(case_index: pd.DataFrame, candidate_ledger: pd.DataFrame) -> pd.DataFrame: selected_rows = [] for _, case in case_index.iterrows(): rows = candidate_ledger[candidate_ledger["entry_trade_date"] == case["entry_trade_date"]].copy() if rows.empty: continue if case["selection_bucket"] == "PREV_HIGH_REVIEW_RISK": review = rows[rows["candidate_status"] != "PASS"].sort_values("candidate_rank").head(2) strict = rows[rows["candidate_status"] == "PASS"].sort_values("candidate_rank").head(3) chosen = pd.concat([strict, review], ignore_index=True).sort_values("candidate_rank").head(5) else: strict = rows[rows["candidate_status"] == "PASS"].sort_values("candidate_rank").head(5) chosen = strict if len(strict) >= 5 else rows.sort_values("candidate_rank").head(5) chosen = chosen.copy() chosen["case_id"] = case["case_id"] chosen["case_status"] = case["case_status"] chosen["selection_bucket"] = case["selection_bucket"] selected_rows.append(chosen) return pd.concat(selected_rows, ignore_index=True) if selected_rows else pd.DataFrame() def main() -> None: candidate_ledger = pd.read_csv(ROOT / "candidate_ledger.csv", encoding="utf-8-sig") case_index = pd.read_csv(ROOT / "case_index.csv", encoding="utf-8-sig") selected = select_candidates(case_index, candidate_ledger) selected.to_csv(ROOT / "selected_candidate_ledger.csv", index=False, encoding="utf-8-sig") if selected.empty: raise RuntimeError("No selected candidates available for image generation.") selected["signal_trade_date"] = pd.to_datetime(selected["signal_trade_date"]) selected["entry_trade_date"] = pd.to_datetime(selected["entry_trade_date"]) symbols = sorted(selected["symbol"].unique().tolist()) min_date = (selected["signal_trade_date"].min() - pd.Timedelta(days=220)).strftime("%Y-%m-%d") max_date = selected["signal_trade_date"].max().strftime("%Y-%m-%d") placeholders = ",".join(["%s"] * len(symbols)) with get_conn() as conn: daily = pd.read_sql( f""" SELECT trade_date, symbol, open_price, high_price, low_price, close_price, volume, amount FROM a_share_daily_price WHERE trade_date BETWEEN %s AND %s AND symbol IN ({placeholders}) ORDER BY symbol, trade_date """, conn, params=[min_date, max_date, *symbols], ) daily["trade_date"] = pd.to_datetime(daily["trade_date"]) for col in ["open_price", "high_price", "low_price", "close_price", "volume", "amount"]: daily[col] = pd.to_numeric(daily[col], errors="coerce") daily = daily.sort_values(["symbol", "trade_date"]).reset_index(drop=True) grouped = daily.groupby("symbol", group_keys=False) daily["ma5"] = grouped["close_price"].transform(lambda s: s.rolling(5, min_periods=1).mean()) daily["ma20"] = grouped["close_price"].transform(lambda s: s.rolling(20, min_periods=1).mean()) daily["ma60"] = grouped["close_price"].transform(lambda s: s.rolling(60, min_periods=1).mean()) manifest_rows = [] case_board_links = [] for _, case in case_index.iterrows(): case_id = case["case_id"] case_dir = ROOT / "cases" / case_id img_dir = case_dir / "img" img_dir.mkdir(parents=True, exist_ok=True) case_candidates = selected[selected["case_id"] == case_id].copy() case_candidates.to_csv(case_dir / "candidate_ledger.csv", index=False, encoding="utf-8-sig") case_manifest = [] board_lines = [ f"# {case_id} 图片审核板", "", f"入场日:`{case['entry_trade_date']}`", f"信号日:`{case['signal_trade_date']}`", f"样本分层:`{case['selection_bucket']}`", f"当前状态:`{case['case_status']}`", "", "本板当前只完成选股日 K 图阶段;买入 1 分钟 K 图、持仓图、卖出图和结果汇总图尚未生成。", "", "## 1. 市场闸门", "", f"- 闸门状态:`{case['market_gate_status']}`", f"- 上涨家数:`{case['up_count']}`", f"- 下跌家数:`{case['down_count']}`", "", "## 2. 选股证据图", "", ] story_lines = [ f"# {case_id} 文字追溯板", "", "## 当前阶段", "", "`CANDIDATE_DAILY_IMAGE_PACKAGE_READY`", "", "当前只完成候选池和选股日 K 图;这些图只表示进入观察,不代表买入或收益结论。", "", ] for _, cand in case_candidates.iterrows(): signal_date = pd.Timestamp(cand["signal_trade_date"]) hist = daily[(daily["symbol"] == cand["symbol"]) & (daily["trade_date"] <= signal_date)].tail(100).copy() hist["trade_date"] = hist["trade_date"].dt.strftime("%Y-%m-%d") file_name = f"01_candidate_daily_100d_{cand['symbol'].replace('.', '_')}_{signal_date.strftime('%Y%m%d')}.png" out_path = img_dir / file_name cand_dict = cand.to_dict() cand_dict["signal_trade_date"] = signal_date.strftime("%Y-%m-%d") cand_dict["entry_trade_date"] = pd.Timestamp(cand["entry_trade_date"]).strftime("%Y-%m-%d") draw_daily_chart(hist, cand_dict, out_path) rel = out_path.relative_to(case_dir).as_posix() root_rel = out_path.relative_to(ROOT).as_posix() row = { "case_id": case_id, "symbol": cand["symbol"], "trade_date": cand_dict["signal_trade_date"], "event_id": cand["candidate_id"], "chart_role": "candidate_daily_100d_decision_view", "decision_time": f"{cand_dict['signal_trade_date']} close", "path": root_rel, "sha256": sha256_file(out_path), "status": "PASS", "note": "选股日K图,只展示信号日及以前数据;不代表买入。", } manifest_rows.append(row) case_manifest.append(row) board_lines.extend( [ f"### {cand['symbol']} / 候选排名 {cand['candidate_rank']}", "", f"![{cand['symbol']}]({rel})", "", f"- 候选状态:`{cand['candidate_status']}`", f"- 量比:`{float(cand['volume_ratio']):.2f}`;长上影:`{float(cand['upper_shadow_pct']):.2f}%`", f"- 前高量能通过:`{cand['prev_high_volume_pass_flag']}`", "", ] ) (case_dir / "image_manifest.csv").write_text( pd.DataFrame(case_manifest).to_csv(index=False), encoding="utf-8-sig" ) (case_dir / "case_image_board.md").write_text("\n".join(board_lines) + "\n", encoding="utf-8") (case_dir / "case_story_board.md").write_text("\n".join(story_lines) + "\n", encoding="utf-8") case_files = [ "candidate_ledger.csv", "image_manifest.csv", "case_image_board.md", "case_story_board.md", ] case_manifest_json = { "case_id": case_id, "run_id": RUN_ID, "stage": "CANDIDATE_DAILY_IMAGE_PACKAGE_READY", "files": [ { "path": name, "size": (case_dir / name).stat().st_size, "sha256": sha256_file(case_dir / name), } for name in case_files ], "image_count": len(case_manifest), } (case_dir / "manifest.json").write_text( json.dumps(case_manifest_json, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", ) case_board_links.append(f"- [{case_id}](cases/{case_id}/case_image_board.md)") image_manifest = pd.DataFrame(manifest_rows) image_manifest.to_csv(ROOT / "image_manifest.csv", index=False, encoding="utf-8-sig") root_board = [ "# RUN 图片审核入口", "", f"run_id:`{RUN_ID}`", "阶段:`CANDIDATE_DAILY_IMAGE_PACKAGE_READY`", "", "当前只完成候选池和选股日 K 图;买卖分时图、订单账本和收益复算尚未生成。", "", "## 案例入口", "", *case_board_links, "", ] (ROOT / "case_image_board.md").write_text("\n".join(root_board), encoding="utf-8") (ROOT / "case_story_board.md").write_text( "# RUN 文字追溯入口\n\n当前只完成候选池和选股日 K 图。后续进入买点审核后补充分时图、订单、持仓和收益复算。\n", encoding="utf-8", ) summary = { "schema_version": "1.0", "run_id": RUN_ID, "generated_at": "2026-06-08T00:50:00+08:00", "stage": "CANDIDATE_DAILY_IMAGE_PACKAGE_READY", "case_count": int(case_index["case_id"].nunique()), "selected_candidate_rows": int(len(selected)), "image_count": int(len(image_manifest)), "artifacts": { "selected_candidate_ledger.csv": { "size": (ROOT / "selected_candidate_ledger.csv").stat().st_size, "sha256": sha256_file(ROOT / "selected_candidate_ledger.csv"), }, "image_manifest.csv": { "size": (ROOT / "image_manifest.csv").stat().st_size, "sha256": sha256_file(ROOT / "image_manifest.csv"), }, "case_image_board.md": { "size": (ROOT / "case_image_board.md").stat().st_size, "sha256": sha256_file(ROOT / "case_image_board.md"), }, }, "next_step": "Generate entry 1-minute decision views and preliminary decision_log for selected pilot cases.", } (ROOT / "candidate_image_generation_summary.json").write_text( json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" ) (ROOT / "candidate_image_generation_summary.md").write_text( "\n".join( [ "# candidate_image_generation_summary", "", f"run_id:`{RUN_ID}`", "阶段:`CANDIDATE_DAILY_IMAGE_PACKAGE_READY`", "", f"- 案例数:{summary['case_count']}", f"- 选中候选行:{summary['selected_candidate_rows']}", f"- 生成选股日 K 图:{summary['image_count']}", "", "当前图片只用于选股审核,不产生买入或收益结论。", "", ] ), encoding="utf-8", ) if __name__ == "__main__": main()