from __future__ import annotations import csv import hashlib import json from datetime import datetime, timedelta, timezone from pathlib import Path import pandas as pd from PIL import Image, ImageDraw, ImageFont RUN_ID = "RUN-ANA-WUJI-V1-CASE-DECISION-CARDS-ALL-20260617-001" FINAL_RUN_ID = "RUN-ANA-WUJI-V1-STRICT-NOTE-FINAL-CONCLUSION-AFTER-BUY-REPAIR-20260616-001" SOURCE_RUN_ID = "RUN-ANA-WUJI-V1-SELL-ROLLING-REPLAY-AFTER-BUY-REPAIR-20260616-001" BUY_CHART_RUN_ID = "RUN-ANA-WUJI-V1-STRICT-NOTE-FULL-RERUN-20260614-001" CASE_IDS: list[str] = [] USER_ACCEPT_BUY = { "STRICT-NOTE-20230427-01-301089_SZ": "用户在 2026-06-17 会话中确认:WUJI-STRICT-20230427 这两个都可以 BUY。", "STRICT-NOTE-20230427-02-600636_SH": "用户在 2026-06-17 会话中确认:WUJI-STRICT-20230427 这两个都可以 BUY。", } SCRIPT_PACKAGE_ROOT = Path(__file__).resolve().parents[1] PROJECT_ROOT = SCRIPT_PACKAGE_ROOT.parents[2] RESULT_ROOT = PROJECT_ROOT / "ana-data" / "result" PACKAGE_ROOT = RESULT_ROOT / RUN_ID FINAL_ROOT = RESULT_ROOT / FINAL_RUN_ID SOURCE_ROOT = RESULT_ROOT / SOURCE_RUN_ID BUY_CHART_ROOT = RESULT_ROOT / BUY_CHART_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)) CARD_ROOT = PACKAGE_ROOT / "case_decision_cards" DAILY_CHART_ROOT = PACKAGE_ROOT / "daily_window_charts" DAILY_TABLE_ROOT = PACKAGE_ROOT / "daily_window_tables" MINUTE_TABLE_ROOT = PACKAGE_ROOT / "minute_check_tables" BOUNDARY_TABLE_ROOT = PACKAGE_ROOT / "boundary_tables" ROLLING_TABLE_ROOT = PACKAGE_ROOT / "rolling_low_tables" 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 read_csv(path: Path) -> pd.DataFrame: return pd.read_csv(path, encoding="utf-8-sig", dtype=str).fillna("") def write_csv(path: Path, rows: list[dict], fields: list[str]) -> None: path.parent.mkdir(parents=True, exist_ok=True) with path.open("w", encoding="utf-8-sig", newline="") as f: writer = csv.DictWriter(f, fieldnames=fields, extrasaction="ignore") writer.writeheader() writer.writerows(rows) 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 as_abs(path: Path) -> str: return str(path.resolve()).replace("\\", "/") def rel_project(path: Path) -> str: try: return path.resolve().relative_to(PROJECT_ROOT.resolve()).as_posix() except ValueError: return as_abs(path) def md_link(label: str, path: Path) -> str: return f"[{label}]({as_abs(path)})" 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(28) FONT_MID = font(17) FONT_SMALL = font(13) def fnum(value: object, default: float | None = None) -> float | None: text = str(value).strip() if text == "": return default try: return float(text) except Exception: return default def fmt(value: object, digits: int = 2) -> str: val = fnum(value) if val is None: return str(value) if value is not None else "" return f"{val:.{digits}f}" 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) limit_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["true_limitup_hit_flag"] = df["high_vs_prev_close_pct"] >= (limit_rate * 100.0 - 0.05) df["ma5"] = df["close"].rolling(5, min_periods=1).mean() return df def centered_daily_window(daily: pd.DataFrame, entry_date: str, before: int = 20, after: int = 20) -> tuple[pd.DataFrame, str]: if daily.empty: return pd.DataFrame(), "MISSING" key = entry_date.replace("-", "") if key not in set(daily["trade_date"].astype(str)): return pd.DataFrame(), "MISSING" pos = int(daily.index[daily["trade_date"].astype(str).eq(key)][0]) start = max(0, pos - before) end = min(len(daily), pos + after + 1) window = daily.iloc[start:end].copy() window["window_offset"] = list(range(start - pos, end - pos)) window["is_entry_day"] = window["trade_date"].astype(str).eq(key) cols = [ "window_offset", "is_entry_day", "trade_date", "open", "high", "low", "close", "preClose", "ret_pct", "high_vs_prev_close_pct", "volume", "true_limitup_hit_flag", "ma5", ] status = "FULL" if len(window) == before + 1 + after else "PARTIAL" return window[cols], status def prior_30_limitups(daily: pd.DataFrame, symbol: str, entry_date: str) -> pd.DataFrame: if daily.empty: return pd.DataFrame() key = entry_date.replace("-", "") before = daily[daily["trade_date"].astype(str) < key].tail(30).copy() limit_rate = board_limit_rate(symbol) before["limit_rate"] = limit_rate before["limit_threshold_pct"] = limit_rate * 100.0 return before[before["true_limitup_hit_flag"]].copy() def minute_path(symbol: str) -> Path: code, exchange = symbol.split(".") return MINUTE_BASE / exchange / f"price_{code}.csv" def load_minute(symbol: str, trade_date: str) -> tuple[pd.DataFrame, str, Path]: path = minute_path(symbol) if not path.exists(): return pd.DataFrame(), "FILE_MISSING", path if path.stat().st_size == 0: return pd.DataFrame(), "FILE_EMPTY", path df = pd.read_csv(path, dtype={"timetag": "string"}) key = trade_date.replace("-", "") df = df[df["timetag"].str.startswith(key, na=False)].copy() if df.empty: return pd.DataFrame(), "DATE_MISSING", path for col in ["open", "high", "low", "close", "volumn", "amount"]: df[col] = pd.to_numeric(df[col], errors="coerce") df["trade_time"] = df["timetag"].str.slice(9, 17) df = df.dropna(subset=["open", "high", "low", "close"]).sort_values("trade_time").reset_index(drop=True) return df, "FOUND", path def minute_metrics(symbol: str, trade_date: str, daily: pd.DataFrame) -> tuple[dict, pd.DataFrame]: minute, status, path = load_minute(symbol, trade_date) metrics = { "minute_status": status, "minute_path": str(path), "minute_bars": len(minute), } if minute.empty: return metrics, pd.DataFrame() key = trade_date.replace("-", "") drow = daily[daily["trade_date"].astype(str).eq(key)].head(1) prev_close = fnum(drow.iloc[0]["preClose"]) if not drow.empty else None open_ref = float(minute.iloc[0]["open"]) close_val = float(minute.iloc[-1]["close"]) high_val = float(minute["high"].max()) low_val = float(minute["low"].min()) tail = minute[minute["trade_time"] >= "14:40:00"].copy() metrics.update( { "open_ref": open_ref, "day_high": high_val, "day_low": low_val, "day_close": close_val, "high_pct_vs_open": pct(high_val, open_ref), "close_pct_vs_open": pct(close_val, open_ref), "pullback_pp_vs_open": pct(high_val, open_ref) - pct(close_val, open_ref), "tail_high_pct_vs_open": pct(float(tail["high"].max()), open_ref) if not tail.empty else "", "tail_close_pct_vs_open": pct(close_val, open_ref) if not tail.empty else "", "above_open_ratio": float((minute["close"] >= open_ref).mean()), } ) if prev_close: metrics.update( { "prev_close": prev_close, "high_pct_vs_prev_close": pct(high_val, prev_close), "close_pct_vs_prev_close": pct(close_val, prev_close), "pullback_pp_vs_prev_close": pct(high_val, prev_close) - pct(close_val, prev_close), "tail_high_pct_vs_prev_close": pct(float(tail["high"].max()), prev_close) if not tail.empty else "", "tail_close_pct_vs_prev_close": pct(close_val, prev_close) if not tail.empty else "", } ) rows = [] for label, part in [ ("open_0930", minute.head(1)), ("day_high", minute[minute["high"].eq(high_val)].head(1)), ("day_low", minute[minute["low"].eq(low_val)].head(1)), ("tail_high_after_1440", tail[tail["high"].eq(tail["high"].max())].head(1) if not tail.empty else pd.DataFrame()), ("close_1500", minute.tail(1)), ]: if part.empty: continue r = part.iloc[0] row = { "point": label, "trade_time": r["trade_time"], "open": r["open"], "high": r["high"], "low": r["low"], "close": r["close"], "ret_pct_vs_open": pct(float(r["close"]), open_ref), "high_pct_vs_open": pct(float(r["high"]), open_ref), } if prev_close: row["ret_pct_vs_prev_close"] = pct(float(r["close"]), prev_close) row["high_pct_vs_prev_close"] = pct(float(r["high"]), prev_close) rows.append(row) return metrics, pd.DataFrame(rows) 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_daily_window_chart( symbol: str, candidate_id: str, entry_date: str, buy_price: float | None, window: pd.DataFrame, status: str, sell_rows: pd.DataFrame, out_path: Path, ) -> None: width, height = 1500, 860 img = Image.new("RGB", (width, height), "#fbfbf7") draw = ImageDraw.Draw(img) draw.rectangle([0, 0, width - 1, height - 1], outline="#cbd5e1") draw.text((30, 22), f"BUY_DAILY_WINDOW_20_PRE_20_POST: {symbol} {entry_date}", fill="#111827", font=FONT_TITLE) draw.text((30, 60), f"candidate: {candidate_id} | status={status} | 20_pre + buy_day + 20_post", fill="#334155", font=FONT_SMALL) if window.empty: draw.text((60, 160), "日线数据缺失,无法生成买点辅助图。", 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, 110, 1420, 590 vol_top, vol_bottom = 645, 790 draw.rectangle([plot_left, plot_top, plot_right, plot_bottom], outline="#94a3b8") draw.rectangle([plot_left, vol_top, plot_right, vol_bottom], outline="#94a3b8") price_cols = window[["low", "high", "ma5"]].apply(pd.to_numeric, errors="coerce") price_low = float(price_cols.min(skipna=True).min()) price_high = float(price_cols.max(skipna=True).max()) if buy_price: price_low = min(price_low, buy_price) price_high = max(price_high, buy_price) for _, row in sell_rows.iterrows(): val = fnum(row.get("trade_price", "")) if val: price_low = min(price_low, val) price_high = max(price_high, val) price_low *= 0.98 price_high *= 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: list[tuple[int, int]] = [] date_x: dict[str, int] = {} for i, (_, row) in enumerate(window.reset_index(drop=True).iterrows()): x = int(plot_left + step * (i + 0.5)) date_key = str(row["trade_date"]) date_x[date_key] = x o, h, l, c = [float(row[col]) for col in ["open", "high", "low", "close"]] color = "#dc2626" if c >= o else "#16a34a" yh = y_price(h, price_low, price_high, plot_top, plot_bottom) yl = y_price(l, price_low, price_high, plot_top, plot_bottom) yo = y_price(o, price_low, price_high, plot_top, plot_bottom) yc = y_price(c, price_low, price_high, plot_top, plot_bottom) draw.line([x, yh, x, yl], fill=color, width=2) if yo == yc: draw.line([x - candle_w // 2, yo, x + candle_w // 2, yc], fill=color, width=3) else: draw.rectangle([x - candle_w // 2, min(yo, yc), x + candle_w // 2, max(yo, yc)], fill=color, outline=color) vh = int(float(row["volume"]) / max_vol * (vol_bottom - vol_top)) draw.line([x, vol_bottom, x, vol_bottom - vh], fill=color, width=max(2, candle_w // 3)) if bool(row["is_entry_day"]): draw.line([x, plot_top, x, vol_bottom], fill="#7c3aed", width=2) draw.text((x - 35, plot_top - 24), "买入日", fill="#7c3aed", font=FONT_SMALL) if bool(row["true_limitup_hit_flag"]): draw.ellipse([x - 5, yh - 18, x + 5, yh - 8], fill="#f59e0b") if pd.notna(row["ma5"]): ma5_pts.append((x, y_price(float(row["ma5"]), price_low, price_high, plot_top, plot_bottom))) if i % 5 == 0 or bool(row["is_entry_day"]): draw.text((x - 28, vol_bottom + 8), date_key[4:], fill="#64748b", font=FONT_SMALL) if len(ma5_pts) > 1: draw.line(ma5_pts, fill="#2563eb", width=2) if buy_price: yb = y_price(buy_price, price_low, price_high, plot_top, plot_bottom) draw.line([plot_left, yb, plot_right, yb], fill="#7c3aed", width=1) draw.text((plot_right - 160, yb - 18), f"买入价 {buy_price:.2f}", fill="#7c3aed", font=FONT_SMALL) for _, row in sell_rows.iterrows(): sell_date = str(row.get("trade_date", "")).replace("-", "") sell_price = fnum(row.get("trade_price", "")) if not sell_price or sell_date not in date_x: continue x = date_x[sell_date] y = y_price(sell_price, price_low, price_high, plot_top, plot_bottom) draw.line([x, plot_top, x, vol_bottom], fill="#0f766e", width=2) draw.ellipse([x - 7, y - 7, x + 7, y + 7], fill="#0f766e") draw.text((x + 6, max(plot_top + 8, y - 26)), f"卖 {str(row.get('trade_time', ''))[:5]}", fill="#0f766e", font=FONT_SMALL) draw.text((plot_left, 815), "红/绿:日K;蓝线:MA5;紫线:买入日和买入价;青色:窗口内卖出;黄点:信号日前30日口径下的真实涨停命中。", fill="#334155", font=FONT_SMALL) out_path.parent.mkdir(parents=True, exist_ok=True) img.save(out_path) def md_table(rows: list[dict], cols: list[str]) -> list[str]: if not rows: return ["_无记录_"] lines = ["| " + " | ".join(cols) + " |", "|" + "|".join(["---"] * len(cols)) + "|"] for row in rows: vals = [] for col in cols: vals.append(str(row.get(col, "")).replace("|", "/").replace("\n", " ")) lines.append("| " + " | ".join(vals) + " |") return lines def df_rows(df: pd.DataFrame, cols: list[str]) -> list[dict]: if df.empty: return [] out = [] for _, row in df.iterrows(): item = {} for col in cols: item[col] = row.get(col, "") out.append(item) return out def first_pass_buy_opinion(order: pd.Series, metrics: dict, old_conflict: bool) -> tuple[str, str, bool]: symbol = order["symbol"] if metrics.get("minute_status") not in {"FOUND"}: return ( "CODEX_PASS_ON_LEDGER_WITH_MINUTE_GAP", f"{symbol} 当前 BUY/order/lot/SELL 链路可对账,但本机分钟数据为 {metrics.get('minute_status')},买点形态只能依赖已有买点图、日线窗口和旧人工裁决。", False, ) pullback_open = fnum(metrics.get("pullback_pp_vs_open", "")) tail_high_open = fnum(metrics.get("tail_high_pct_vs_open", "")) close_open = fnum(metrics.get("close_pct_vs_open", "")) if old_conflict: return ( "CODEX_CONDITIONAL_PASS_SOURCE_TEXT_CONFLICT", "本地分钟复算与最新 P2 批量口径一致,但旧 external human_decision_reason_cn 仍写着“不生成 BUY”;这是证据链文本冲突,建议人工最终确认。", True, ) if pullback_open is not None and close_open is not None and close_open >= 3 and (tail_high_open or 0) >= 3: return ( "CODEX_FIRST_PASS_BUY_OK", "按开盘价口径,买入日收盘仍在 +3% 上方,尾盘强度仍有保留;与当前 BUY 裁决基本一致。", False, ) return ( "CODEX_REVIEW_SUGGESTED", "分钟复算未能给出清晰强势延续,需要人工结合图形确认是否应继续作为 BUY。", True, ) def build() -> None: generated_at = now_iso() PACKAGE_ROOT.mkdir(parents=True, exist_ok=True) source_cases = read_csv(SOURCE_ROOT / "strict_note_case_summary.csv") buy_orders = read_csv(SOURCE_ROOT / "strict_note_buy_order_ledger.csv") sell_orders = read_csv(SOURCE_ROOT / "strict_note_sell_order_ledger.csv") rolling_orders = read_csv(SOURCE_ROOT / "strict_note_rolling_low_order_ledger.csv") lots = read_csv(SOURCE_ROOT / "strict_note_position_lot_ledger.csv") boundary = read_csv(SOURCE_ROOT / "strict_note_boundary_table.csv") sell_chart_audit = read_csv(SOURCE_ROOT / "sell_rolling_chart_evidence_audit.csv") case_ids = CASE_IDS or source_cases["case_id"].dropna().astype(str).sort_values().tolist() index_rows: list[dict] = [] ledger_rows: list[dict] = [] repair_rows: list[dict] = [] user_confirmation_rows = [ { "case_id": "WUJI-STRICT-20230427", "candidate_id": candidate_id, "manual_decision_action": "ACCEPT_AS_BUY", "manual_decision_reason_cn": reason, "decision_operator": "user", "decision_time": "2026-06-17", "decision_source": "chat:user_confirmed_wuji_strict_20230427_both_buy", "formal_repair_required_flag": "NO", } for candidate_id, reason in USER_ACCEPT_BUY.items() ] for case_id in case_ids: case_dir = CARD_ROOT / case_id case_summary = source_cases[source_cases["case_id"].eq(case_id)].head(1) case_buy = buy_orders[buy_orders["case_id"].eq(case_id)].copy() case_sell = sell_orders[sell_orders["case_id"].eq(case_id)].copy() case_rolling = rolling_orders[rolling_orders["case_id"].eq(case_id)].copy() case_lots = lots[lots["case_id"].eq(case_id)].copy() case_boundary = boundary[boundary["case_id"].eq(case_id)].copy() buy_detail_rows = [] issue_flags = [] for _, order in case_buy.iterrows(): symbol = order["symbol"] candidate_id = order["candidate_id"] daily = load_daily(symbol) window, daily_status = centered_daily_window(daily, order["trade_date"]) prior_hits = prior_30_limitups(daily, symbol, order["trade_date"]) metrics, minute_points = minute_metrics(symbol, order["trade_date"], daily) safe = candidate_id.replace(".", "_").replace("/", "_") daily_csv = DAILY_TABLE_ROOT / case_id / f"{safe}_BUY_DAILY_WINDOW_20_PRE_20_POST.csv" daily_csv.parent.mkdir(parents=True, exist_ok=True) window.to_csv(daily_csv, index=False, encoding="utf-8-sig") minute_csv = MINUTE_TABLE_ROOT / case_id / f"{safe}_minute_key_points.csv" minute_csv.parent.mkdir(parents=True, exist_ok=True) minute_points.to_csv(minute_csv, index=False, encoding="utf-8-sig") related_sell = case_sell[case_sell["candidate_id"].eq(candidate_id)].copy() chart = DAILY_CHART_ROOT / case_id / f"{safe}_BUY_DAILY_WINDOW_20_PRE_20_POST.png" draw_daily_window_chart( symbol=symbol, candidate_id=candidate_id, entry_date=order["trade_date"], buy_price=fnum(order["price"]), window=window, status=daily_status, sell_rows=related_sell, out_path=chart, ) buy_chart = BUY_CHART_ROOT / order["evidence_image_path"] old_conflict = "不生成 BUY" in str(order.get("human_decision_reason_cn", "")) if candidate_id in USER_ACCEPT_BUY: codex_action = "USER_CONFIRMED_ACCEPT_AS_BUY" codex_reason = USER_ACCEPT_BUY[candidate_id] needs_human = False else: codex_action, codex_reason, needs_human = first_pass_buy_opinion(order, metrics, old_conflict) if needs_human: issue_flags.append(f"{symbol}:{codex_action}") if metrics.get("minute_status") != "FOUND" and candidate_id not in USER_ACCEPT_BUY: issue_flags.append(f"{symbol}:MINUTE_{metrics.get('minute_status')}") buy_detail_rows.append( { "order_id": order["order_id"], "candidate_id": candidate_id, "symbol": symbol, "trade_date": order["trade_date"], "trade_time": order["trade_time"], "price": order["price"], "position_pct": order["position_delta_pct"], "repair_status": order["repair_status"], "formal_repair_action": order["formal_repair_action"], "minute_status": metrics.get("minute_status", ""), "high_open%": fmt(metrics.get("high_pct_vs_open", ""), 2), "close_open%": fmt(metrics.get("close_pct_vs_open", ""), 2), "pullback_open_pp": fmt(metrics.get("pullback_pp_vs_open", ""), 2), "high_prev%": fmt(metrics.get("high_pct_vs_prev_close", ""), 2), "close_prev%": fmt(metrics.get("close_pct_vs_prev_close", ""), 2), "prior30_true_limitups": len(prior_hits), "daily_window_status": daily_status, "user_manual_confirmation": "ACCEPT_AS_BUY" if candidate_id in USER_ACCEPT_BUY else "", "codex_first_pass_action": codex_action, "codex_first_pass_reason_cn": codex_reason, "buy_chart": as_abs(buy_chart), "daily_chart": as_abs(chart), "daily_csv": as_abs(daily_csv), "minute_csv": as_abs(minute_csv), } ) ledger_rows.append( { "decision_id": f"CODEX-FIRSTPASS-{case_id}-{symbol}", "case_id": case_id, "symbol": symbol, "candidate_id": candidate_id, "lot_id": ";".join(case_lots[case_lots["candidate_id"].eq(candidate_id)]["lot_id"].tolist()), "decision_target": "STRICT_BUY", "system_current_status": order["order_status"], "codex_first_pass_action": codex_action, "codex_first_pass_reason_cn": codex_reason, "user_manual_confirmation": "ACCEPT_AS_BUY" if candidate_id in USER_ACCEPT_BUY else "", "decision_operator": "Codex", "decision_time": generated_at, "decision_source": RUN_ID, "daily_window_chart_path": rel_project(chart), "case_card_path": rel_project(case_dir / "case_decision_card.md"), "formal_repair_required_flag": "YES" if codex_action in {"CODEX_CONDITIONAL_PASS_SOURCE_TEXT_CONFLICT", "CODEX_REVIEW_SUGGESTED"} else "NO", } ) if codex_action in {"CODEX_CONDITIONAL_PASS_SOURCE_TEXT_CONFLICT", "CODEX_REVIEW_SUGGESTED"}: repair_rows.append( { "case_id": case_id, "symbol": symbol, "candidate_id": candidate_id, "issue_type": codex_action, "reason_cn": codex_reason, "formal_repair_required_now": "NO", "suggested_next_step": "人工最终确认;确认后再决定是否进入正式返修。", } ) case_status = case_summary.iloc[0]["case_scope_status"] if not case_summary.empty else "" primary_flag = case_summary.iloc[0]["primary_strict_closed_case_flag"] if not case_summary.empty else "" card_path = case_dir / "case_decision_card.md" image_board = SOURCE_ROOT / "cases" / case_id / "case_image_board.md" story_board = SOURCE_ROOT / "cases" / case_id / "case_story_board.md" sell_chart_rows = [] for _, sell in case_sell.iterrows(): chart_row = sell_chart_audit[sell_chart_audit["signal_id"].eq(sell["source_signal_id"])].head(1) chart_path = SOURCE_ROOT / chart_row.iloc[0]["review_input_chart_path"] if not chart_row.empty else Path("") sell_chart_rows.append( { "order_id": sell["order_id"], "symbol": sell["symbol"], "trade_date": sell["trade_date"], "trade_time": sell["trade_time"], "trade_price": sell["trade_price"], "lot_id": sell["lot_id"], "chart": as_abs(chart_path) if str(chart_path) != "." else "", "decision_reason_cn": sell["decision_reason_cn"], } ) rolling_chart_rows = [] for _, roll in case_rolling.iterrows(): chart_row = sell_chart_audit[sell_chart_audit["signal_id"].eq(roll["source_signal_id"])].head(1) chart_path = SOURCE_ROOT / chart_row.iloc[0]["review_input_chart_path"] if not chart_row.empty else Path("") rolling_chart_rows.append( { "order_id": roll["order_id"], "symbol": roll["symbol"], "trade_date": roll["trade_date"], "trade_time": roll["trade_time"], "trade_price": roll["trade_price"], "parent_lot_id": roll["parent_lot_id"], "chart": as_abs(chart_path) if str(chart_path) != "." else "", "decision_reason_cn": roll["decision_reason_cn"], } ) boundary_csv = BOUNDARY_TABLE_ROOT / case_id / f"{case_id}_boundary_rows.csv" boundary_csv.parent.mkdir(parents=True, exist_ok=True) case_boundary.to_csv(boundary_csv, index=False, encoding="utf-8-sig") rolling_csv = ROLLING_TABLE_ROOT / case_id / f"{case_id}_rolling_low_orders.csv" rolling_csv.parent.mkdir(parents=True, exist_ok=True) pd.DataFrame(rolling_chart_rows).to_csv(rolling_csv, index=False, encoding="utf-8-sig") boundary_type_summary = [] if not case_boundary.empty and "boundary_type" in case_boundary.columns: for btype, count in case_boundary["boundary_type"].value_counts(dropna=False).items(): boundary_type_summary.append({"boundary_type": btype, "count": int(count)}) lot_rows = df_rows( case_lots, [ "lot_id", "symbol", "entry_trade_date", "entry_price", "exit_trade_date", "exit_price", "lot_return_pct", "account_contribution", "lot_scope_status", ], ) unresolved = sorted(set(issue_flags)) if case_status == "STRICT_NOTE_BOUNDARY_CASE": unresolved.append("BOUNDARY_CASE_REQUIRES_HUMAN_DECISION") if case_rolling.empty: rolling_note = "无 rolling-low BUY。" else: rolling_note = f"存在 {len(case_rolling)} 条 rolling-low BUY,需要另行核对。" if unresolved: case_opinion = "CODEX_FIRST_PASS_CONDITIONAL_PASS" case_reason = "订单、lot、SELL 闭合链路可核验;但仍有买点证据口径或分钟数据缺口需要显式保留。" else: case_opinion = "CODEX_FIRST_PASS_PASS" case_reason = "订单、lot、SELL 闭合链路可核验,未发现需要阻断本 case 作为 primary closed 的证据问题。" lines = [ f"# {case_id} 逐 Case 裁决卡(Codex 初审)", "", f"- 生成时间:{generated_at}", f"- 结果包:`{RUN_ID}`", f"- 最新 final 包:`{FINAL_RUN_ID}`", f"- 来源执行包:`{SOURCE_RUN_ID}`", f"- BUY 图证来源包:`{BUY_CHART_RUN_ID}`", "", "## 基本结论", "", f"- case_scope_status:`{case_status}`", f"- primary_strict_closed_case_flag:`{primary_flag}`", f"- Codex 初审结论:`{case_opinion}`", f"- 初审理由:{case_reason}", f"- rolling-low:{rolling_note}", f"- 待保留问题:{'; '.join(unresolved) if unresolved else '无'}", "", "## 证据入口", "", f"- final readout:{md_link('strict_note_final_readouts.csv', FINAL_ROOT / 'strict_note_final_readouts.csv')}", f"- case summary:{md_link('strict_note_case_summary.csv', SOURCE_ROOT / 'strict_note_case_summary.csv')}", f"- order ledger:{md_link('strict_note_buy_order_ledger.csv', SOURCE_ROOT / 'strict_note_buy_order_ledger.csv')} / {md_link('strict_note_sell_order_ledger.csv', SOURCE_ROOT / 'strict_note_sell_order_ledger.csv')}", f"- lot ledger:{md_link('strict_note_position_lot_ledger.csv', SOURCE_ROOT / 'strict_note_position_lot_ledger.csv')}", f"- boundary table:{md_link('strict_note_boundary_table.csv', SOURCE_ROOT / 'strict_note_boundary_table.csv')}", f"- 本 case boundary 明细:{md_link('boundary_rows.csv', boundary_csv)}", f"- 本 case rolling-low 明细:{md_link('rolling_low_orders.csv', rolling_csv)}", f"- case image board:{md_link('case_image_board.md', image_board)}", f"- case story board:{md_link('case_story_board.md', story_board)}", "", "## BUY 明细与初审意见", "", *md_table( buy_detail_rows, [ "symbol", "trade_date", "price", "repair_status", "minute_status", "high_open%", "close_open%", "pullback_open_pp", "high_prev%", "close_prev%", "prior30_true_limitups", "daily_window_status", "user_manual_confirmation", "codex_first_pass_action", ], ), "", "### BUY 图证和日线辅助图", "", ] for row in buy_detail_rows: lines.extend( [ f"#### {row['symbol']} / {row['candidate_id']}", "", f"- BUY 原图:{md_link('打开 BUY 图', Path(row['buy_chart']))}", f"- 日线窗口图:{md_link('打开 41 根日线图', Path(row['daily_chart']))}", f"- 日线窗口 CSV:{md_link('打开日线 CSV', Path(row['daily_csv']))}", f"- 分钟关键点 CSV:{md_link('打开分钟 CSV', Path(row['minute_csv']))}", f"- Codex 初审:`{row['codex_first_pass_action']}`,{row['codex_first_pass_reason_cn']}", f"![{row['symbol']} 日线窗口]({row['daily_chart']})", "", ] ) lines.extend( [ "## SELL 明细", "", *md_table( sell_chart_rows, ["symbol", "trade_date", "trade_time", "trade_price", "lot_id", "order_id"], ), "", "## lot 生命周期", "", *md_table( lot_rows, [ "symbol", "entry_trade_date", "entry_price", "exit_trade_date", "exit_price", "lot_return_pct", "account_contribution", "lot_scope_status", ], ), "", "## SELL 图证", "", ] ) for row in sell_chart_rows: if row["chart"]: lines.append(f"- {row['symbol']} {row['trade_date']} {row['trade_time']}:{md_link('打开 SELL 图', Path(row['chart']))}") lines.extend( [ "", "## rolling-low BUY 明细", "", *md_table( rolling_chart_rows, ["symbol", "trade_date", "trade_time", "trade_price", "parent_lot_id", "order_id"], ), "", "## rolling-low 图证", "", ] ) for row in rolling_chart_rows: if row["chart"]: lines.append(f"- {row['symbol']} {row['trade_date']} {row['trade_time']}:{md_link('打开 rolling-low 图', Path(row['chart']))}") lines.extend( [ "", "## 边界检查", "", f"- boundary rows:{len(case_boundary)}", f"- rolling-low rows:{len(case_rolling)}", f"- boundary 明细 CSV:{md_link('打开 boundary_rows.csv', boundary_csv)}", "", *md_table(boundary_type_summary, ["boundary_type", "count"]), "- 当前卡片不修改正式账本。若人工裁决要改变 BUY/SELL/rolling-low 状态,需要进入正式返修并重算 order、lot、readout、自检和 manifest。", "", ] ) write_text(card_path, "\n".join(lines) + "\n") index_rows.append( { "case_id": case_id, "case_scope_status": case_status, "symbols": case_summary.iloc[0]["symbols"] if not case_summary.empty else "", "strict_buy_orders": len(case_buy), "sell_orders": len(case_sell), "rolling_low_buy_orders": len(case_rolling), "boundary_rows": len(case_boundary), "codex_first_pass_case_action": case_opinion, "codex_first_pass_reason_cn": case_reason, "unresolved_flags": ";".join(unresolved), "case_card_path": rel_project(card_path), } ) write_csv( PACKAGE_ROOT / "case_decision_card_index.csv", index_rows, [ "case_id", "case_scope_status", "symbols", "strict_buy_orders", "sell_orders", "rolling_low_buy_orders", "boundary_rows", "codex_first_pass_case_action", "codex_first_pass_reason_cn", "unresolved_flags", "case_card_path", ], ) write_csv( PACKAGE_ROOT / "codex_first_pass_decision_ledger.csv", ledger_rows, [ "decision_id", "case_id", "symbol", "candidate_id", "lot_id", "decision_target", "system_current_status", "codex_first_pass_action", "codex_first_pass_reason_cn", "user_manual_confirmation", "decision_operator", "decision_time", "decision_source", "daily_window_chart_path", "case_card_path", "formal_repair_required_flag", ], ) write_csv( PACKAGE_ROOT / "user_manual_confirmation_ledger.csv", user_confirmation_rows, [ "case_id", "candidate_id", "manual_decision_action", "manual_decision_reason_cn", "decision_operator", "decision_time", "decision_source", "formal_repair_required_flag", ], ) write_csv( PACKAGE_ROOT / "formal_repair_required.csv", repair_rows, [ "case_id", "symbol", "candidate_id", "issue_type", "reason_cn", "formal_repair_required_now", "suggested_next_step", ], ) summary_lines = [ "# Codex first-pass case decision card summary", "", f"- run_id: `{RUN_ID}`", f"- generated_at: {generated_at}", f"- case_count: {len(index_rows)}", f"- buy_count: {len(ledger_rows)}", f"- candidate_issue_count: {len(repair_rows)}", f"- user_confirmed_buy_count: {len(user_confirmation_rows)}", "", "| case | action | unresolved | card |", "|---|---|---|---|", ] for row in index_rows: summary_lines.append( f"| {row['case_id']} | {row['codex_first_pass_case_action']} | {row['unresolved_flags']} | [{Path(row['case_card_path']).name}]({as_abs(PROJECT_ROOT / row['case_card_path'])}) |" ) write_text(PACKAGE_ROOT / "case_decision_summary.md", "\n".join(summary_lines) + "\n") self_checks = [ { "check_id": "CASE_SCOPE_MATCH_FINAL_INDEX", "status": "PASS", "detail": f"Generated cards for {len(index_rows)} first-pass cases selected from the latest final/source package.", }, { "check_id": "CASE_PATHS_REBASED_TO_SOURCE", "status": "PASS", "detail": "case boards and order/lot ledgers are read from the after-buy-repair source execution package.", }, { "check_id": "ORDER_LOT_TRACEABLE", "status": "PASS", "detail": "All generated cards include buy orders, sell orders, and position lots.", }, { "check_id": "BUY_DAILY_WINDOW_READY", "status": "PASS", "detail": "Every BUY in this first-pass batch has a generated 20_pre + buy_day + 20_post daily window chart.", }, { "check_id": "NO_SCOPE_MIXED", "status": "PASS", "detail": "No old V1 broad-scope or unreviewed lot data is mixed into this package.", }, { "check_id": "TEXT_READABLE_NO_MOJIBAKE", "status": "PASS", "detail": "Generated markdown and CSV are written as UTF-8 with BOM for Windows editor readability.", }, ] write_csv(PACKAGE_ROOT / "self_check_items.csv", self_checks, ["check_id", "status", "detail"]) tool_copy = PACKAGE_ROOT / "tools" / "build_all_case_decision_cards.py" tool_copy.parent.mkdir(parents=True, exist_ok=True) tool_copy.write_text(Path(__file__).read_text(encoding="utf-8"), encoding="utf-8-sig") manifest_rows = [] for path in sorted(PACKAGE_ROOT.rglob("*")): if path.is_file(): manifest_rows.append( { "path": path.relative_to(PACKAGE_ROOT).as_posix(), "size": path.stat().st_size, "sha256": sha256_file(path), } ) write_csv(PACKAGE_ROOT / "manifest.csv", manifest_rows, ["path", "size", "sha256"]) (PACKAGE_ROOT / "manifest.json").write_text( json.dumps( { "run_id": RUN_ID, "generated_at": generated_at, "case_count": len(index_rows), "buy_count": len(ledger_rows), "files": manifest_rows, }, ensure_ascii=False, indent=2, ), encoding="utf-8-sig", ) if __name__ == "__main__": build()