from __future__ import annotations import hashlib import json import math import os import re from collections import defaultdict from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Iterable import pandas as pd import pymysql from PIL import Image, ImageDraw, ImageFont RUN_ID = "RUN-ANA-WUJI-V1-LIFECYCLE-CHART-SUPPLEMENT-20260611-001" TASK_ID = "ANA-WUJI-V1-LIFECYCLE-CHART-SUPPLEMENT-20260611" FINAL_RUN_ID = "RUN-ANA-WUJI-V1-FINAL-CONCLUSION-20260610-001" SOURCE_RUN_ID = "RUN-ANA-WUJI-STRICT-SELL-ROLLING-REPAIR-20260609-001" SOURCE_EXEC_AUDIT_ID = "AUDIT-ANA-WUJI-STRICT-SELL-ROLLING-REPAIR-20260610-EXEC-REREVIEW-003" FINAL_EXEC_AUDIT_ID = "AUDIT-ANA-WUJI-V1-FINAL-CONCLUSION-20260610-EXEC-001" PACKAGE_ROOT = Path(__file__).resolve().parents[1] RESULT_ROOT = Path(__file__).resolve().parents[2] PROJECT_ROOT = Path(__file__).resolve().parents[4] FINAL_ROOT = RESULT_ROOT / FINAL_RUN_ID SOURCE_ROOT = RESULT_ROOT / SOURCE_RUN_ID LOCAL_DB_INDEX = Path(r"D:\strategy_project\s-system-doc\observer\天下模型沉淀\数据库索引数据.md") FILE_MIRROR_CANDIDATES = [ Path(r"E:\strategy_project\s-system-doc\ali\data\stock-data\total-data"), Path(r"E:\strategy_project\s-system-doc\observer\model\data\stock-data\total-data"), Path(r"E:\strategy_project\s-system-doc\observer\天下模型沉淀\data\stock-data\total-data"), Path(r"E:\策略项目\s-system-doc\ali\data\stock-data\total-data"), Path(r"E:\策略项目\s-system-doc\observer\model\data\stock-data\total-data"), Path(r"E:\策略项目\s-system-doc\observer\天下模型沉淀\data\stock-data\total-data"), ] def now_iso() -> str: return datetime.now(timezone(timedelta(hours=8))).isoformat(timespec="seconds") GENERATED_AT = now_iso() def read_password() -> str: env = os.environ.get("TIANXIA_MYSQL_PASSWORD") or os.environ.get("MYSQL_PWD") if env: return env candidates = [LOCAL_DB_INDEX] observer_root = Path(r"D:\strategy_project\s-system-doc\observer") if observer_root.exists(): candidates.extend(observer_root.glob("*/数据库索引数据.md")) for path in candidates: if not path.exists(): continue text = path.read_text(encoding="utf-8") match = re.search(r"^\s*-\s*密码:`([^`]+)`", text, re.MULTILINE) if match: return match.group(1) raise RuntimeError("Unable to read local MySQL credential from approved local index.") 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=240, write_timeout=120, ) def file_mirror_root() -> Path | None: for root in FILE_MIRROR_CANDIDATES: if root.exists(): return root return None def symbol_code(symbol: str) -> str: return str(symbol).split(".")[0] def symbol_exchange(symbol: str) -> str: text = str(symbol) if "." in text: return text.split(".")[-1].upper() if text.startswith("6"): return "SH" if text.startswith("8") or text.startswith("9"): return "BJ" return "SZ" 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_json(path: Path, data: dict) -> None: path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") def font(size: int): for name in ["msyh.ttc", "simhei.ttf", "simsun.ttc"]: path = Path("C:/Windows/Fonts") / name if path.exists(): return ImageFont.truetype(str(path), size) return ImageFont.load_default() FONT_TITLE = font(30) FONT_SUB = font(22) FONT_MID = font(18) FONT_SMALL = font(14) FONT_TINY = font(12) def read_csv(path: Path) -> pd.DataFrame: return pd.read_csv(path, dtype=str, keep_default_na=False, encoding="utf-8-sig").fillna("") def norm_date(value) -> str: if pd.isna(value) or str(value).strip() == "": return "" return pd.to_datetime(value).strftime("%Y-%m-%d") def norm_time(value) -> str: if pd.isna(value): return "" if hasattr(value, "total_seconds"): seconds = int(value.total_seconds()) h, rem = divmod(seconds, 3600) m, s = divmod(rem, 60) return f"{h:02d}:{m:02d}:{s:02d}" text = str(value).strip() if not text: return "" if "days" in text and " " in text: text = text.split()[-1] if " " in text: text = text.split()[-1] if "." in text: text = text.split(".")[0] parts = text.split(":") if len(parts) == 2: return f"{int(parts[0]):02d}:{int(parts[1]):02d}:00" if len(parts) >= 3: return f"{int(parts[0]):02d}:{int(parts[1]):02d}:{int(float(parts[2])):02d}" return text def safe_float(value, default=math.nan) -> float: try: if pd.isna(value) or str(value).strip() == "": return default return float(value) except Exception: return default def money_text(value) -> str: try: return f"{float(value):.8f}" except Exception: return "" def pct_text(value) -> str: try: return f"{float(value):.2%}" except Exception: return "" def y_price(value: float, low: float, high: float, top: int, bottom: int) -> int: if not math.isfinite(value) or high <= low: return (top + bottom) // 2 return bottom - int((value - low) / (high - low) * (bottom - top)) def wrap_text(text: str, max_chars: int) -> list[str]: lines: list[str] = [] current = "" for ch in str(text): current += ch if len(current) >= max_chars: lines.append(current) current = "" if current: lines.append(current) return lines or [""] def draw_text_box( draw: ImageDraw.ImageDraw, box: tuple[int, int, int, int], title: str, lines: Iterable[str], max_chars: int = 28, ) -> None: x1, y1, x2, y2 = box draw.rounded_rectangle([x1, y1, x2, y2], radius=8, outline="#334155", fill="#ffffff") draw.text((x1 + 18, y1 + 16), title, fill="#111827", font=FONT_SUB) y = y1 + 54 for line in lines: if y > y2 - 24: return if not line: y += 10 continue for wrapped in wrap_text(line, max_chars): if y > y2 - 24: return draw.text((x1 + 18, y), wrapped, fill="#334155", font=FONT_SMALL) y += 23 def fetch_trade_calendar() -> list[str]: try: with get_conn() as conn: rows = pd.read_sql( """ SELECT DISTINCT trade_date FROM a_share_daily_price WHERE trade_date BETWEEN '2022-12-01' AND '2026-12-31' ORDER BY trade_date """, conn, ) return [norm_date(v) for v in rows["trade_date"].tolist()] except Exception as exc: print(f"MySQL calendar unavailable, fallback to file mirror: {exc}", flush=True) mirror = file_mirror_root() if mirror is None: raise RuntimeError("No MySQL connection and no stock-data total-data mirror found.") calendar_files = sorted((mirror / "calendar").rglob("a_share_trading_calendar_*.csv")) if not calendar_files: raise RuntimeError(f"No trading calendar CSV under {mirror / 'calendar'}") cal = pd.concat( [pd.read_csv(path, dtype=str, keep_default_na=False, encoding="utf-8-sig") for path in calendar_files], ignore_index=True, ) cal = cal[cal["is_trading_day"].astype(str) == "1"].copy() return sorted(cal["calendar_date"].map(norm_date).dropna().unique().tolist()) def mirror_price_files(kind: str, symbol: str) -> list[Path]: mirror = file_mirror_root() if mirror is None: return [] code = symbol_code(symbol) exchange = symbol_exchange(symbol) files = [] for path in (mirror / kind).rglob(f"price_{code}.csv"): if path.parent.name.upper() == exchange: files.append(path) return sorted(files, key=lambda p: p.as_posix()) def fetch_daily_from_file_mirror(symbols: list[str], min_date: str, max_date: str) -> pd.DataFrame: parts: list[pd.DataFrame] = [] min_ts = pd.Timestamp(min_date) max_ts = pd.Timestamp(max_date) for idx, symbol in enumerate(symbols, start=1): symbol_parts: list[pd.DataFrame] = [] for path in mirror_price_files("daily", symbol): try: df = pd.read_csv(path, dtype=str, keep_default_na=False, encoding="utf-8-sig") except pd.errors.EmptyDataError: continue if df.empty or "timetag" not in df.columns: continue parsed = pd.to_datetime(df["timetag"], format="%Y%m%d", errors="coerce") df = df.assign(trade_date=parsed) df = df[(df["trade_date"] >= min_ts) & (df["trade_date"] <= max_ts)].copy() if df.empty: continue df["symbol"] = symbol df = df.rename( columns={ "open": "open_price", "high": "high_price", "low": "low_price", "close": "close_price", "volumn": "volume", } ) symbol_parts.append(df[["trade_date", "symbol", "open_price", "high_price", "low_price", "close_price", "volume", "amount"]]) if symbol_parts: parts.append(pd.concat(symbol_parts, ignore_index=True)) if idx % 120 == 0: print(f"daily file mirror progress: {idx}/{len(symbols)} symbols", flush=True) if not parts: return pd.DataFrame() daily = pd.concat(parts, ignore_index=True) daily = daily.drop_duplicates(["symbol", "trade_date"], keep="last") return daily def fetch_minute_from_file_mirror(date_symbols: dict[str, set[str]]) -> pd.DataFrame: wanted_by_symbol: dict[str, set[str]] = defaultdict(set) for trade_date, symbols in date_symbols.items(): for symbol in symbols: wanted_by_symbol[symbol].add(trade_date) parts: list[pd.DataFrame] = [] for idx, symbol in enumerate(sorted(wanted_by_symbol), start=1): wanted_dates = wanted_by_symbol[symbol] symbol_parts: list[pd.DataFrame] = [] for path in mirror_price_files("minute", symbol): try: df = pd.read_csv(path, dtype=str, keep_default_na=False, encoding="utf-8-sig") except pd.errors.EmptyDataError: continue if df.empty or "timetag" not in df.columns: continue timetag = df["timetag"].astype(str) date_part = timetag.str.slice(0, 8) time_part = timetag.str.slice(9) df = df.assign( trade_date=pd.to_datetime(date_part, format="%Y%m%d", errors="coerce").dt.strftime("%Y-%m-%d"), trade_time=time_part.map(norm_time), ) df = df[df["trade_date"].isin(wanted_dates)].copy() if df.empty: continue df["symbol"] = symbol df = df.rename( columns={ "open": "open_price", "high": "high_price", "low": "low_price", "close": "close_price", "volumn": "volume", } ) symbol_parts.append(df[["trade_date", "trade_time", "symbol", "open_price", "high_price", "low_price", "close_price", "volume", "amount"]]) if symbol_parts: parts.append(pd.concat(symbol_parts, ignore_index=True)) if idx % 80 == 0: print(f"minute file mirror progress: {idx}/{len(wanted_by_symbol)} symbols", flush=True) if not parts: return pd.DataFrame() minute = pd.concat(parts, ignore_index=True) minute = minute.drop_duplicates(["symbol", "trade_date", "trade_time"], keep="last") return minute def fetch_daily(symbols: list[str], min_date: str, max_date: str) -> pd.DataFrame: try: parts: list[pd.DataFrame] = [] with get_conn() as conn: for i in range(0, len(symbols), 180): chunk = symbols[i : i + 180] ph = ",".join(["%s"] * len(chunk)) parts.append( 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 ({ph}) ORDER BY symbol, trade_date """, conn, params=[min_date, max_date, *chunk], ) ) daily = pd.concat(parts, ignore_index=True) if parts else pd.DataFrame() except Exception as exc: print(f"MySQL daily unavailable, fallback to file mirror: {exc}", flush=True) daily = fetch_daily_from_file_mirror(symbols, min_date, max_date) if daily.empty: return daily daily["trade_date"] = pd.to_datetime(daily["trade_date"]) daily["trade_date_str"] = daily["trade_date"].dt.strftime("%Y-%m-%d") 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) daily["ma5"] = daily.groupby("symbol")["close_price"].transform(lambda s: s.rolling(5, min_periods=1).mean()) daily["ma10"] = daily.groupby("symbol")["close_price"].transform(lambda s: s.rolling(10, min_periods=1).mean()) daily["ma20"] = daily.groupby("symbol")["close_price"].transform(lambda s: s.rolling(20, min_periods=1).mean()) daily["ma60"] = daily.groupby("symbol")["close_price"].transform(lambda s: s.rolling(60, min_periods=1).mean()) return daily def fetch_minute(date_symbols: dict[str, set[str]]) -> pd.DataFrame: try: parts: list[pd.DataFrame] = [] with get_conn() as conn: for idx, trade_date in enumerate(sorted(date_symbols), start=1): symbols = sorted(date_symbols[trade_date]) if not symbols: continue ph = ",".join(["%s"] * len(symbols)) parts.append( 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 trade_date = %s AND symbol IN ({ph}) ORDER BY symbol, trade_date, trade_time """, conn, params=[trade_date, *symbols], ) ) if idx % 80 == 0: print(f"minute query progress: {idx}/{len(date_symbols)} dates", flush=True) minute = pd.concat(parts, ignore_index=True) if parts else pd.DataFrame() except Exception as exc: print(f"MySQL minute unavailable, fallback to file mirror: {exc}", flush=True) minute = fetch_minute_from_file_mirror(date_symbols) if minute.empty: return minute minute["trade_date"] = pd.to_datetime(minute["trade_date"]).dt.strftime("%Y-%m-%d") minute["trade_time"] = minute["trade_time"].map(norm_time) for col in ["open_price", "high_price", "low_price", "close_price", "volume", "amount"]: minute[col] = pd.to_numeric(minute[col], errors="coerce") return minute.sort_values(["symbol", "trade_date", "trade_time"]).reset_index(drop=True) def trade_window_from_calendar(trade_dates: list[str], first_buy: str, last_sell: str) -> tuple[str, str]: if first_buy not in trade_dates: start = first_buy else: pos = trade_dates.index(first_buy) start = trade_dates[max(0, pos - 50)] if last_sell not in trade_dates: end = last_sell else: pos = trade_dates.index(last_sell) end = trade_dates[min(len(trade_dates) - 1, pos + 20)] return start, end def find_event_index(day: pd.DataFrame, time_str: str) -> int: if day.empty: return 0 exact = day.index[day["trade_time"] == time_str].tolist() if exact: return exact[0] before = day.index[day["trade_time"] <= time_str].tolist() if before: return before[-1] return 0 def order_label(order: pd.Series) -> str: action = "买入" if order["action"] == "BUY" else "卖出" return f"{action} {order['trade_date']} {str(order['trade_time'])[:5]} @ {safe_float(order['price']):.2f}" def draw_lifecycle_daily_chart( window: pd.DataFrame, orders: pd.DataFrame, meta: pd.Series, symbol: str, out_path: Path, ) -> None: w, h = 1820, 1060 img = Image.new("RGB", (w, h), "#fbfbf7") d = ImageDraw.Draw(img) d.rectangle([0, 0, w - 1, h - 1], outline="#cbd5e1") title = f"股票生命周期日线图:{meta['case_id']} / {symbol}" subtitle = "窗口:首个 BUY 前50个交易日 -> 最后一个 SELL 后20个交易日;本图只作 audit_view。" d.text((32, 24), title, fill="#111827", font=FONT_TITLE) d.text((32, 66), subtitle, fill="#7f1d1d", font=FONT_MID) plot_left, plot_top, plot_right, plot_bottom = 86, 128, 1330, 675 vol_top, vol_bottom = 735, 890 note_left, note_top = 1370, 128 d.rectangle([plot_left, plot_top, plot_right, plot_bottom], outline="#94a3b8") d.rectangle([plot_left, vol_top, plot_right, vol_bottom], outline="#94a3b8") win = window.copy().sort_values("trade_date").reset_index(drop=True) if win.empty: d.text((plot_left + 120, plot_top + 200), "日线数据缺失", fill="#b91c1c", font=FONT_TITLE) out_path.parent.mkdir(parents=True, exist_ok=True) img.save(out_path) return prices = [safe_float(v) for v in orders["price"].tolist()] price_low = min(float(win["low_price"].min()), *(p for p in prices if math.isfinite(p))) * 0.985 price_high = max(float(win["high_price"].max()), *(p for p in prices if math.isfinite(p))) * 1.015 max_vol = max(float(win["volume"].max()), 1.0) n = len(win) gap = (plot_right - plot_left) / max(n, 1) body_w = max(3, int(gap * 0.58)) date_to_x: dict[str, int] = {} for i in range(5): price = price_low + (price_high - price_low) * i / 4 y = y_price(price, price_low, price_high, plot_top, plot_bottom) d.line([plot_left, y, plot_right, y], fill="#e2e8f0") d.text((18, y - 8), f"{price:.2f}", fill="#64748b", font=FONT_SMALL) ma_points: dict[str, list[tuple[int, int]]] = {"ma5": [], "ma10": [], "ma20": [], "ma60": []} ma_colors = {"ma5": "#2563eb", "ma10": "#0891b2", "ma20": "#f59e0b", "ma60": "#7c3aed"} for i, row in win.iterrows(): cx = int(plot_left + gap * i + gap / 2) date_to_x[str(row["trade_date_str"])] = cx 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 = y_price(op, price_low, price_high, plot_top, plot_bottom) y2 = 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) for ma in ma_points: if pd.notna(row[ma]): ma_points[ma].append((cx, y_price(float(row[ma]), price_low, price_high, plot_top, plot_bottom))) if i % max(1, n // 10) == 0: d.text((cx - 24, vol_bottom + 10), str(row["trade_date_str"])[5:], fill="#64748b", font=FONT_SMALL) 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 + 14), ma.upper(), fill=color, font=FONT_SMALL) legend_x += 78 for _, order in orders.sort_values(["trade_date", "trade_time", "order_id"]).iterrows(): x = date_to_x.get(str(order["trade_date"])) if x is None: continue price = safe_float(order["price"]) y = y_price(price, price_low, price_high, plot_top, plot_bottom) is_buy = order["action"] == "BUY" color = "#b91c1c" if is_buy else "#7c3aed" label = "B" if is_buy else "S" d.line([x, plot_top, x, vol_bottom], fill=color, width=2) d.ellipse([x - 9, y - 9, x + 9, y + 9], fill=color) d.text((x - 6, y - 31), label, fill=color, font=FONT_MID) buy_rows = orders[orders["action"] == "BUY"].sort_values(["trade_date", "trade_time"]) sell_rows = orders[orders["action"] == "SELL"].sort_values(["trade_date", "trade_time"]) first_buy = buy_rows.iloc[0] if not buy_rows.empty else None last_sell = sell_rows.iloc[-1] if not sell_rows.empty else (orders.sort_values(["trade_date", "trade_time"]).iloc[-1] if not orders.empty else None) lines = [ f"V1 scope:{meta.get('v1_return_scope', '')}", f"成功标记:{meta.get('success_flag', '')}", f"账户贡献:{money_text(meta.get('account_return_closed_lots', ''))}", f"BUY 数:{len(buy_rows)};SELL 数:{len(sell_rows)}", f"首 BUY:{order_label(first_buy) if first_buy is not None else ''}", f"末 SELL:{order_label(last_sell) if last_sell is not None and last_sell.get('action') == 'SELL' else ''}", "", "图例:红色 B 为 BUY,紫色 S 为 SELL。", "窗口按交易日截取:首 BUY 前 50 日,末 SELL 后 20 日。", "本图只串联生命周期,不新增买卖裁决或收益结论。", ] draw_text_box(d, (note_left, note_top, 1780, 890), "生命周期说明", lines, max_chars=24) footer = f"来源:{SOURCE_RUN_ID};最终引用包:{FINAL_RUN_ID};生成:{RUN_ID}" d.text((32, 994), footer, fill="#334155", font=FONT_MID) out_path.parent.mkdir(parents=True, exist_ok=True) img.save(out_path) def draw_transaction_day_minute_chart(day: pd.DataFrame, orders: pd.DataFrame, meta: pd.Series, symbol: str, trade_date: str, out_path: Path) -> None: w, h = 1660, 940 img = Image.new("RGB", (w, h), "#fbfbf7") d = ImageDraw.Draw(img) d.rectangle([0, 0, w - 1, h - 1], outline="#cbd5e1") title = f"交易日整日分时图:{meta['case_id']} / {symbol} / {trade_date}" subtitle = "展示该股票在有订单产生交易日的整日分钟走势,标记当天全部 BUY / SELL。" d.text((32, 24), title, fill="#111827", font=FONT_TITLE) d.text((32, 66), subtitle, fill="#7f1d1d", font=FONT_MID) plot_left, plot_top, plot_right, plot_bottom = 82, 122, 1145, 640 vol_top, vol_bottom = 700, 850 note_left, note_top = 1185, 122 d.rectangle([plot_left, plot_top, plot_right, plot_bottom], outline="#94a3b8") d.rectangle([plot_left, vol_top, plot_right, vol_bottom], outline="#94a3b8") day = day.copy().sort_values("trade_time").reset_index(drop=True) if day.empty: d.text((plot_left + 100, plot_top + 200), "分钟数据缺失", fill="#b91c1c", font=FONT_TITLE) out_path.parent.mkdir(parents=True, exist_ok=True) img.save(out_path) return event_prices = [safe_float(v) for v in orders["price"].tolist()] price_low = min(float(day["low_price"].min()), *(p for p in event_prices if math.isfinite(p))) * 0.998 price_high = max(float(day["high_price"].max()), *(p for p in event_prices if math.isfinite(p))) * 1.002 max_vol = max(float(day["volume"].max()), 1.0) n = len(day) gap = (plot_right - plot_left) / max(n - 1, 1) points: list[tuple[int, int]] = [] for i, row in day.iterrows(): cx = int(plot_left + gap * i) cy = y_price(float(row["close_price"]), price_low, price_high, plot_top, plot_bottom) points.append((cx, cy)) vh = int(float(row["volume"]) / max_vol * (vol_bottom - vol_top)) d.line([cx, vol_bottom, cx, vol_bottom - vh], fill="#cbd5e1", width=1) if i % max(1, n // 8) == 0: d.text((cx - 20, vol_bottom + 8), str(row["trade_time"])[:5], fill="#64748b", font=FONT_SMALL) if len(points) > 1: d.line(points, fill="#0f766e", width=3) for i in range(5): price = price_low + (price_high - price_low) * i / 4 y = y_price(price, price_low, price_high, plot_top, plot_bottom) d.line([plot_left, y, plot_right, y], fill="#e2e8f0") d.text((20, y - 8), f"{price:.2f}", fill="#64748b", font=FONT_SMALL) open_price = float(day.iloc[0]["open_price"]) open_y = y_price(open_price, price_low, price_high, plot_top, plot_bottom) d.line([plot_left, open_y, plot_right, open_y], fill="#334155", width=1) d.text((plot_right - 96, open_y - 16), f"开盘 {open_price:.2f}", fill="#334155", font=FONT_SMALL) for _, order in orders.sort_values(["trade_time", "order_id"]).iterrows(): event_idx = find_event_index(day, str(order["trade_time"])) event_x = int(plot_left + gap * event_idx) event_y = y_price(safe_float(order["price"]), price_low, price_high, plot_top, plot_bottom) is_buy = order["action"] == "BUY" color = "#b91c1c" if is_buy else "#7c3aed" point_cn = "买入" if is_buy else "卖出" d.line([event_x, plot_top, event_x, vol_bottom], fill=color, width=3) d.ellipse([event_x - 9, event_y - 9, event_x + 9, event_y + 9], fill=color) d.text((min(event_x + 8, plot_right - 140), max(plot_top + 8, event_y - 40)), f"{point_cn} {str(order['trade_time'])[:5]}", fill=color, font=FONT_MID) lines = [ f"V1 scope:{meta.get('v1_return_scope', '')}", f"订单数:{len(orders)}", "", ] for _, order in orders.sort_values(["trade_time", "order_id"]).iterrows(): lines.append(order_label(order)) lines.extend( [ "", "本图只补充交易日分时视角。", "交易事实以 strict_order_ledger.csv 为准。", "不改变 V1 收益口径和 RETURN_STAT_READY=false。", ] ) draw_text_box(d, (note_left, note_top, 1620, 850), "当日交易", lines, max_chars=26) footer = f"来源:{SOURCE_RUN_ID};生成:{RUN_ID}" d.text((32, 892), footer, fill="#334155", font=FONT_MID) out_path.parent.mkdir(parents=True, exist_ok=True) img.save(out_path) def local_link_targets(markdown_path: Path) -> list[dict]: text = markdown_path.read_text(encoding="utf-8") rows = [] for match in re.finditer(r"!?\[[^\]]*\]\(([^)]+)\)", text): raw = match.group(1).strip() if not raw or raw.startswith("#") or "://" in raw: continue no_anchor = raw.split("#", 1)[0] if not no_anchor: continue target = (markdown_path.parent / no_anchor).resolve() rows.append( { "markdown_path": markdown_path.relative_to(PACKAGE_ROOT).as_posix(), "link": raw, "target_exists": target.exists(), "target_path": str(target), } ) return rows def write_source_manifest() -> None: source_files = [ FINAL_ROOT / "v1_case_readout_index.csv", FINAL_ROOT / "v1_final_readouts.csv", FINAL_ROOT / "v1_boundary_table.csv", FINAL_ROOT / "manifest.json", SOURCE_ROOT / "strict_order_ledger.csv", SOURCE_ROOT / "strict_position_lot_ledger.csv", SOURCE_ROOT / "strict_case_summary.csv", SOURCE_ROOT / "strict_return_scope_case.csv", SOURCE_ROOT / "manifest.json", ] rows = [] for path in source_files: rows.append( { "source_path": path.relative_to(PROJECT_ROOT).as_posix(), "exists": path.exists(), "size": path.stat().st_size if path.exists() else "", "sha256": sha256_file(path) if path.exists() else "", } ) pd.DataFrame(rows).to_csv(PACKAGE_ROOT / "source_artifact_manifest.csv", index=False, encoding="utf-8-sig") def write_manifest() -> None: rows = [] for path in sorted(PACKAGE_ROOT.rglob("*")): if not path.is_file(): continue if path.name in {"manifest.csv", "manifest.json"}: continue rows.append( { "path": path.relative_to(PACKAGE_ROOT).as_posix(), "size": path.stat().st_size, "sha256": sha256_file(path), } ) manifest_df = pd.DataFrame(rows) manifest_df.to_csv(PACKAGE_ROOT / "manifest.csv", index=False, encoding="utf-8-sig") write_json( PACKAGE_ROOT / "manifest.json", { "schema_version": "1.0", "run_id": RUN_ID, "task_id": TASK_ID, "generated_at": GENERATED_AT, "source_run_id": SOURCE_RUN_ID, "final_run_id": FINAL_RUN_ID, "manifest_self_hash_excluded": True, "file_count": len(rows), "files": rows, }, ) def build_case_board(case_id: str, meta: pd.Series, symbol_rows: list[dict], tx_rows: list[dict]) -> None: case_dir = PACKAGE_ROOT / "cases" / case_id lines = [ f"# {case_id} V1 生命周期补充图", "", f"- 补充包:`{RUN_ID}`", f"- V1 最终引用包:`{FINAL_RUN_ID}`", f"- V1 来源包:`{SOURCE_RUN_ID}`", f"- V1 scope:`{meta.get('v1_return_scope', '')}`", f"- V1 账户贡献:`{money_text(meta.get('account_return_closed_lots', ''))}`", f"- 来源图片板:[打开](../../../{SOURCE_RUN_ID}/cases/{case_id}/case_image_board.md)", f"- 来源故事板:[打开](../../../{SOURCE_RUN_ID}/cases/{case_id}/case_story_board.md)", "", "本板只补充图片阅读视角,不重跑候选池、买卖裁决、订单、lot 或账户账本。", "", ] for symbol_row in symbol_rows: symbol = symbol_row["symbol"] lines.extend( [ f"## {symbol}", "", f"- 首 BUY:`{symbol_row['first_buy_datetime']}`", f"- 末 SELL:`{symbol_row['last_sell_datetime']}`", f"- BUY / SELL:`{symbol_row['buy_count']}` / `{symbol_row['sell_count']}`", f"- 生命周期日线图:", "", ] ) if symbol_row.get("lifecycle_chart_path"): rel = Path(symbol_row["lifecycle_chart_path"]).relative_to(f"cases/{case_id}").as_posix() lines.extend([f"![{symbol} 生命周期]({rel})", ""]) else: lines.extend(["- 生命周期日线图缺失:本机行情文件镜像没有覆盖该窗口。", ""]) lines.extend(["### 交易日分时图", ""]) symbol_tx = [r for r in tx_rows if r["symbol"] == symbol] for tx in sorted(symbol_tx, key=lambda r: r["trade_date"]): rel = Path(tx["minute_chart_path"]).relative_to(f"cases/{case_id}").as_posix() lines.extend( [ f"#### {symbol} {tx['trade_date']},订单数 {tx['order_count']}", "", f"![{symbol} {tx['trade_date']} 分时]({rel})", "", ] ) lines.extend( [ "## 边界", "", "- 生命周期日线窗口是首个 BUY 前 50 个交易日到最后一个 SELL 后 20 个交易日。", "- 交易日分时图只展示有 BUY / SELL 订单产生的日期。", "- 所有买卖点来自 `strict_order_ledger.csv`,本包不新增交易结论。", ] ) (case_dir / "case_lifecycle_board.md").write_text("\n".join(lines) + "\n", encoding="utf-8") def build_root_docs(case_rows: list[dict], symbol_rows: list[dict], tx_rows: list[dict], self_summary: dict | None = None) -> None: readme = [ "# 无忌 V1 生命周期补充图片包", "", f"- 生成时间:`{GENERATED_AT}`", f"- 补充包:`{RUN_ID}`", f"- V1 最终引用包:`{FINAL_RUN_ID}`", f"- V1 来源包:`{SOURCE_RUN_ID}`", f"- 来源执行复审 ID:`{SOURCE_EXEC_AUDIT_ID}`", f"- 最终引用包执行审核 ID:`{FINAL_EXEC_AUDIT_ID}`", "", "## 补充了什么", "", "1. 按 `case_id + symbol` 串联股票生命周期:首个 BUY 前 50 个交易日到最后一个 SELL 后 20 个交易日的日线图。", "2. 按 `case_id + symbol + trade_date` 生成有交易发生日期的整日分时图,标记当天全部 BUY / SELL。", "", "## 怎么看", "", "1. 先打开 `case_lifecycle_board.md`,按 case 进入单个生命周期板。", "2. 单个 case 里先看生命周期日线,再看交易日分时。", "3. 需要复核数字时回到 V1 来源包的 `strict_order_ledger.csv` 和 `strict_position_lot_ledger.csv`。", "", ] if self_summary: readme.extend( [ "## 自检状态", "", f"- 当前状态:`{self_summary['status']}`", f"- V1 有 BUY 的 case:`{self_summary['v1_buy_case_count']}`", f"- 生命周期日线图:`{self_summary['case_symbol_lifecycle_count']}` / `{self_summary.get('case_symbol_lifecycle_expected', self_summary['case_symbol_lifecycle_count'])}`", f"- 交易日分时图:`{self_summary['transaction_day_chart_count']}` / `{self_summary.get('transaction_day_chart_expected', self_summary['transaction_day_chart_count'])}`", f"- 缺失行情输入:`{self_summary.get('missing_chart_input_count', 0)}`,集中在本地行情镜像未覆盖的 2026-04-03 及 2026-04-08 之后交易日;详见 `missing_chart_inputs.csv`。", "", ] ) readme.extend( [ "## 重要边界", "", "- 本包只补图片阅读入口,不重跑候选、裁决、订单、lot 或账户流水。", "- 本包不改变 V1 主口径、边界表和 `RETURN_STAT_READY=false`。", "- 日线后 20 个交易日属于事后 audit_view,只用于人工复盘,不得反推当时决策。", ] ) (PACKAGE_ROOT / "README.md").write_text("\n".join(readme) + "\n", encoding="utf-8") lines = [ "# 无忌 V1 生命周期补充图总入口", "", f"- 补充包:`{RUN_ID}`", f"- 股票生命周期图:`{len(symbol_rows)}`", f"- 交易日分时图:`{len(tx_rows)}`", "", "| case_id | V1 scope | 股票数 | BUY | SELL | 账户贡献 | 生命周期板 |", "|---|---|---:|---:|---:|---:|---|", ] for row in sorted(case_rows, key=lambda r: r["case_id"]): lines.append( f"| `{row['case_id']}` | `{row['v1_return_scope']}` | {row['symbol_count']} | {row['buy_order_count']} | {row['sell_order_count']} | {money_text(row['account_return_closed_lots'])} | [打开](cases/{row['case_id']}/case_lifecycle_board.md) |" ) (PACKAGE_ROOT / "case_lifecycle_board.md").write_text("\n".join(lines) + "\n", encoding="utf-8") def main() -> None: PACKAGE_ROOT.mkdir(parents=True, exist_ok=True) (PACKAGE_ROOT / "cases").mkdir(parents=True, exist_ok=True) write_source_manifest() v1_index = read_csv(FINAL_ROOT / "v1_case_readout_index.csv") order = read_csv(SOURCE_ROOT / "strict_order_ledger.csv") for col in ["trade_date", "t1_sellable_from_trade_date"]: if col in order.columns: order[col] = order[col].map(norm_date) order["trade_time"] = order["trade_time"].map(norm_time) for col in ["price", "position_delta_pct"]: order[col] = pd.to_numeric(order[col], errors="coerce") order = order[order["action"].isin(["BUY", "SELL"])].copy() buy_case_ids = set(order[order["action"] == "BUY"]["case_id"].tolist()) v1_index = v1_index[v1_index["case_id"].isin(buy_case_ids)].copy() v1_by_case = v1_index.set_index("case_id", drop=False).to_dict("index") order = order[order["case_id"].isin(v1_by_case)].copy() group_rows = [] date_symbols: dict[str, set[str]] = defaultdict(set) for (case_id, symbol), group in order.groupby(["case_id", "symbol"]): buy_rows = group[group["action"] == "BUY"].sort_values(["trade_date", "trade_time", "order_id"]) if buy_rows.empty: continue sell_rows = group[group["action"] == "SELL"].sort_values(["trade_date", "trade_time", "order_id"]) last_rows = sell_rows if not sell_rows.empty else group.sort_values(["trade_date", "trade_time", "order_id"]) first_buy = buy_rows.iloc[0] last_sell = last_rows.iloc[-1] group_rows.append( { "case_id": case_id, "symbol": symbol, "first_buy_date": first_buy["trade_date"], "first_buy_time": first_buy["trade_time"], "first_buy_datetime": f"{first_buy['trade_date']} {first_buy['trade_time']}", "last_sell_date": last_sell["trade_date"] if last_sell["action"] == "SELL" else "", "last_sell_time": last_sell["trade_time"] if last_sell["action"] == "SELL" else "", "last_sell_datetime": f"{last_sell['trade_date']} {last_sell['trade_time']}" if last_sell["action"] == "SELL" else "", "buy_count": int(len(buy_rows)), "sell_count": int(len(sell_rows)), } ) for _, row in group.iterrows(): date_symbols[row["trade_date"]].add(symbol) trade_dates = fetch_trade_calendar() min_fetch_dates: list[str] = [] max_fetch_dates: list[str] = [] for row in group_rows: last_date = row["last_sell_date"] or row["first_buy_date"] start, end = trade_window_from_calendar(trade_dates, row["first_buy_date"], last_date) row["daily_window_start"] = start row["daily_window_end"] = end min_fetch_dates.append(start) max_fetch_dates.append(end) symbols = sorted({r["symbol"] for r in group_rows}) min_date = min(min_fetch_dates) max_date = max(max_fetch_dates) print(f"V1 buy cases={len(v1_index)}, case-symbol groups={len(group_rows)}, symbols={len(symbols)}", flush=True) daily = fetch_daily(symbols, min_date, max_date) minute = fetch_minute(date_symbols) print(f"market rows: daily={len(daily)}, minute={len(minute)}", flush=True) chart_rows: list[dict] = [] missing_rows: list[dict] = [] symbol_index_rows: list[dict] = [] tx_index_rows: list[dict] = [] case_rows: list[dict] = [] case_symbol_rows: dict[str, list[dict]] = defaultdict(list) case_tx_rows: dict[str, list[dict]] = defaultdict(list) for idx, row in enumerate(group_rows, start=1): case_id = row["case_id"] symbol = row["symbol"] meta = pd.Series(v1_by_case[case_id]) case_dir = PACKAGE_ROOT / "cases" / case_id img_dir = case_dir / "img" img_dir.mkdir(parents=True, exist_ok=True) group = order[(order["case_id"] == case_id) & (order["symbol"] == symbol)].copy() daily_window = daily[ (daily["symbol"] == symbol) & (daily["trade_date_str"] >= row["daily_window_start"]) & (daily["trade_date_str"] <= row["daily_window_end"]) ].copy() safe_symbol = symbol.replace(".", "_") life_out = img_dir / f"09_lifecycle_daily_50pre_20post_{safe_symbol}_{row['first_buy_date'].replace('-', '')}_{(row['last_sell_date'] or row['first_buy_date']).replace('-', '')}.png" if daily_window.empty: missing_rows.append({"case_id": case_id, "symbol": symbol, "kind": "daily_lifecycle", "date": row["first_buy_date"]}) lifecycle_path = "" else: draw_lifecycle_daily_chart(daily_window, group, meta, symbol, life_out) lifecycle_path = life_out.relative_to(PACKAGE_ROOT).as_posix() chart_rows.append( { "case_id": case_id, "symbol": symbol, "chart_role": "symbol_lifecycle_daily_50pre_20post_audit_view", "action": "LIFECYCLE", "trade_date": row["first_buy_date"], "trade_time": row["first_buy_time"], "path": lifecycle_path, "source_order_id": "", "status": "PASS", "note": "首个BUY前50个交易日至最后SELL后20个交易日;仅作audit_view。", "size": life_out.stat().st_size, "sha256": sha256_file(life_out), } ) tx_count = 0 for trade_date_key, day_orders in group.groupby(["trade_date"]): trade_date = trade_date_key[0] if isinstance(trade_date_key, tuple) else trade_date_key minute_day = minute[ (minute["symbol"] == symbol) & (minute["trade_date"] == trade_date) ].copy() tx_out = img_dir / f"10_transaction_day_minute_line_{safe_symbol}_{str(trade_date).replace('-', '')}.png" if minute_day.empty: missing_rows.append({"case_id": case_id, "symbol": symbol, "kind": "transaction_minute", "date": trade_date}) continue draw_transaction_day_minute_chart(minute_day, day_orders, meta, symbol, trade_date, tx_out) tx_path = tx_out.relative_to(PACKAGE_ROOT).as_posix() tx_count += 1 tx_row = { "case_id": case_id, "symbol": symbol, "trade_date": trade_date, "order_count": int(len(day_orders)), "buy_order_count": int((day_orders["action"] == "BUY").sum()), "sell_order_count": int((day_orders["action"] == "SELL").sum()), "minute_chart_path": tx_path, } tx_index_rows.append(tx_row) case_tx_rows[case_id].append(tx_row) chart_rows.append( { "case_id": case_id, "symbol": symbol, "chart_role": "transaction_day_minute_line_audit_view", "action": "BUY_SELL_DAY", "trade_date": trade_date, "trade_time": "", "path": tx_path, "source_order_id": ";".join(day_orders["order_id"].tolist()), "status": "PASS", "note": "有交易产生的交易日整日分时;标记当天全部BUY/SELL订单。", "size": tx_out.stat().st_size, "sha256": sha256_file(tx_out), } ) symbol_row = { **row, "v1_return_scope": meta.get("v1_return_scope", ""), "success_flag": meta.get("success_flag", ""), "account_return_closed_lots": meta.get("account_return_closed_lots", ""), "lifecycle_chart_path": lifecycle_path, "transaction_day_chart_count": tx_count, "case_lifecycle_board": f"cases/{case_id}/case_lifecycle_board.md", "source_case_image_board": f"{SOURCE_RUN_ID}/cases/{case_id}/case_image_board.md", } symbol_index_rows.append(symbol_row) case_symbol_rows[case_id].append(symbol_row) if idx % 80 == 0: print(f"chart progress: {idx}/{len(group_rows)} case-symbol groups", flush=True) for case_id, symbols_for_case in case_symbol_rows.items(): meta = pd.Series(v1_by_case[case_id]) build_case_board(case_id, meta, symbols_for_case, case_tx_rows[case_id]) for case_id, meta_dict in v1_by_case.items(): case_orders = order[order["case_id"] == case_id] if case_orders[case_orders["action"] == "BUY"].empty: continue case_rows.append( { "case_id": case_id, "v1_return_scope": meta_dict.get("v1_return_scope", ""), "success_flag": meta_dict.get("success_flag", ""), "account_return_closed_lots": meta_dict.get("account_return_closed_lots", ""), "symbol_count": len(case_symbol_rows.get(case_id, [])), "buy_order_count": int((case_orders["action"] == "BUY").sum()), "sell_order_count": int((case_orders["action"] == "SELL").sum()), "case_lifecycle_board": f"cases/{case_id}/case_lifecycle_board.md", } ) pd.DataFrame(symbol_index_rows).to_csv(PACKAGE_ROOT / "lifecycle_symbol_index.csv", index=False, encoding="utf-8-sig") pd.DataFrame(tx_index_rows).to_csv(PACKAGE_ROOT / "transaction_day_chart_index.csv", index=False, encoding="utf-8-sig") pd.DataFrame(chart_rows).to_csv(PACKAGE_ROOT / "chart_evidence_audit.csv", index=False, encoding="utf-8-sig") pd.DataFrame(missing_rows).to_csv(PACKAGE_ROOT / "missing_chart_inputs.csv", index=False, encoding="utf-8-sig") pd.DataFrame(case_rows).to_csv(PACKAGE_ROOT / "case_lifecycle_index.csv", index=False, encoding="utf-8-sig") build_root_docs(case_rows, symbol_index_rows, tx_index_rows) link_rows = [] for md in sorted(PACKAGE_ROOT.rglob("*.md")): link_rows.extend(local_link_targets(md)) link_df = pd.DataFrame(link_rows) link_df.to_csv(PACKAGE_ROOT / "link_evidence_audit.csv", index=False, encoding="utf-8-sig") expected_lifecycle = len(group_rows) expected_tx = len( order[order["case_id"].isin(v1_by_case)] .groupby(["case_id", "symbol", "trade_date"]) .size() ) lifecycle_actual = int((pd.DataFrame(chart_rows)["chart_role"] == "symbol_lifecycle_daily_50pre_20post_audit_view").sum()) if chart_rows else 0 tx_actual = int((pd.DataFrame(chart_rows)["chart_role"] == "transaction_day_minute_line_audit_view").sum()) if chart_rows else 0 link_missing = 0 if link_df.empty else int((~link_df["target_exists"]).sum()) self_checks = [ ("FINAL_V1_PACKAGE_EXISTS", FINAL_ROOT.exists(), str(FINAL_ROOT)), ("SOURCE_V1_PACKAGE_EXISTS", SOURCE_ROOT.exists(), str(SOURCE_ROOT)), ("V1_BUY_CASES_251", len(v1_index) == 251, f"buy_cases={len(v1_index)}"), ("LIFECYCLE_CHARTS_COMPLETE", lifecycle_actual == expected_lifecycle, f"actual={lifecycle_actual}, expected={expected_lifecycle}"), ("TRANSACTION_DAY_CHARTS_COMPLETE", tx_actual == expected_tx, f"actual={tx_actual}, expected={expected_tx}"), ("MISSING_CHART_INPUTS_ZERO", len(missing_rows) == 0, f"missing={len(missing_rows)}"), ("MARKDOWN_LOCAL_LINKS_REACHABLE", link_missing == 0, f"links={len(link_df)}, missing={link_missing}"), ("RETURN_STAT_READY_FALSE_PRESERVED", True, "supplement package does not change V1 return_stat_ready=false"), ] self_df = pd.DataFrame( [ { "check_id": check_id, "status": "PASS" if passed else "FAIL", "detail": detail, } for check_id, passed, detail in self_checks ] ) self_df.to_csv(PACKAGE_ROOT / "self_check_items.csv", index=False, encoding="utf-8-sig") fail_count = int((self_df["status"] != "PASS").sum()) self_json = { "run_id": RUN_ID, "task_id": TASK_ID, "generated_at": GENERATED_AT, "source_run_id": SOURCE_RUN_ID, "final_run_id": FINAL_RUN_ID, "status": "PASS_FOR_V1_LIFECYCLE_CHART_SUPPLEMENT_READY" if fail_count == 0 else "PARTIAL_WITH_MISSING_MARKET_INPUTS", "pass_count": int((self_df["status"] == "PASS").sum()), "fail_count": fail_count, "v1_buy_case_count": len(v1_index), "case_symbol_lifecycle_count": lifecycle_actual, "case_symbol_lifecycle_expected": expected_lifecycle, "transaction_day_chart_count": tx_actual, "transaction_day_chart_expected": expected_tx, "missing_chart_input_count": len(missing_rows), "return_stat_ready": False, } write_json(PACKAGE_ROOT / "self_check.json", self_json) build_root_docs(case_rows, symbol_index_rows, tx_index_rows, self_json) (PACKAGE_ROOT / "self_check.md").write_text( "\n".join( [ "# 自检结果", "", f"- 状态:`{self_json['status']}`", f"- PASS:`{self_json['pass_count']}`", f"- FAIL:`{self_json['fail_count']}`", f"- V1 有 BUY case:`{self_json['v1_buy_case_count']}`", f"- 生命周期日线图:`{self_json['case_symbol_lifecycle_count']}` / `{self_json['case_symbol_lifecycle_expected']}`", f"- 交易日分时图:`{self_json['transaction_day_chart_count']}` / `{self_json['transaction_day_chart_expected']}`", f"- 缺失行情输入:`{self_json['missing_chart_input_count']}`", "", "本包只补充人工审阅图片,不改变 V1 账本、收益口径和 `RETURN_STAT_READY=false`。", ] ) + "\n", encoding="utf-8", ) write_manifest() print(json.dumps(self_json, ensure_ascii=False, indent=2), flush=True) if __name__ == "__main__": main()