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): 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 evaluate_candidate(cand: pd.Series, minute: pd.DataFrame, daily: pd.DataFrame) -> dict: m = minute[(minute.symbol == cand.symbol) & (minute.trade_date == cand.entry_trade_date)].copy() ma = daily[(daily.symbol == cand.symbol) & (daily.trade_date <= cand.signal_trade_date)].tail(1) ma20 = float(ma.ma20.iloc[0]) if not ma.empty else None if m.empty: return {"action_status": "DATA_GAP_HELD", "reason": "入场日无分钟线,不能判断买点。"} open_ref = float(m.open_price.iloc[0]) opening = m[(m.trade_time >= "09:30:00") & (m.trade_time <= "09:35:00")] had_opening_push = (not opening.empty) and float(opening.high_price.max()) >= open_ref * 1.01 for label, start, end, min_time in [ ("早盘窗口", "09:30:00", "10:40:00", "09:34:00"), ("尾盘窗口", "14:40:00", "15:00:00", "14:40:00"), ]: part = m[(m.trade_time >= start) & (m.trade_time <= end) & (m.trade_time >= min_time)].copy() if part.empty: continue part["vol_prev5"] = part.volume.shift(1).rolling(5, min_periods=3).mean() part["near_open"] = had_opening_push & part.low_price.le(open_ref * 1.010) & part.high_price.ge(open_ref * 0.997) part["near_ma20"] = False if ma20 is None else ( part.low_price.le(ma20 * 1.010) & part.high_price.ge(ma20 * 0.997) ) part["vol_ok"] = part.vol_prev5.notna() & part.volume.le(part.vol_prev5 * 1.10) part["not_chase"] = part.close_price.le(open_ref * 1.055) part["ok"] = (part.near_open | part.near_ma20) & part.vol_ok & part.not_chase ok = part[part.ok] if not ok.empty: row = ok.iloc[0] trigger = "回踩开盘价附近缩量支撑" if bool(row.near_open) else "回踩日MA20附近缩量支撑" return { "action_status": "AI_BUY_CONFIRMED", "decision_time": str(row.trade_time), "price": float(row.close_price), "window": label, "trigger": trigger, "ma20_ref": ma20, "open_ref": open_ref, "reason": f"{label} {row.trade_time} {trigger},未触发放量回踩禁买或急拉追高禁买。", } return { "action_status": "NO_BUY_AI_REVIEWED", "reason": "允许买入窗口内未看到符合缩量支撑的回踩开盘价或回踩日MA20买点。", "ma20_ref": ma20, "open_ref": open_ref, } def draw_buy_decision(minute: pd.DataFrame, cand: pd.Series, decision: dict, out_path: Path) -> 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.strftime('%Y-%m-%d')}" d.text((32, 24), title, fill="#111827", font=FONT_TITLE) d.text((32, 58), f"AI裁决:{decision['decision_time']} 买入第一份仓,价格 {decision['price']:.2f}", fill="#7f1d1d", 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") start, end = ("09:30:00", "10:40:00") if decision["window"] == "早盘窗口" else ("14:40:00", "15:00:00") df = minute[ (minute.symbol == cand.symbol) & (minute.trade_date == cand.entry_trade_date) & (minute.trade_time >= start) & (minute.trade_time <= end) & (minute.trade_time <= decision["decision_time"]) ].copy().reset_index(drop=True) refs = [decision["open_ref"]] if decision.get("ma20_ref"): refs.append(decision["ma20_ref"]) price_low = min(float(df.low_price.min()), min(refs)) * 0.998 price_high = max(float(df.high_price.max()), 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)) buy_x = None for i, row in df.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 str(row.trade_time) == decision["decision_time"]: buy_x = cx if i % max(1, n // 6) == 0: d.text((cx - 24, vol_bottom + 8), str(row.trade_time)[:5], fill="#64748b", font=FONT_SMALL) for ref, label, color in [ (decision["open_ref"], "开盘价", "#0f172a"), (decision.get("ma20_ref"), "日MA20", "#f59e0b"), ]: if ref: yy = y_price(float(ref), price_low, price_high, plot_top, plot_bottom) d.line([plot_left, yy, plot_right, yy], fill=color, width=2) d.text((plot_right + 8, yy - 8), f"{label} {float(ref):.2f}", fill=color, font=FONT_SMALL) if buy_x is not None: d.line([buy_x, plot_top, buy_x, vol_bottom], fill="#b91c1c", width=3) d.text((buy_x + 8, plot_top + 8), "买入", fill="#b91c1c", font=FONT_MID) d.rounded_rectangle([note_left, note_top, 1460, 780], radius=8, outline="#334155", fill="#ffffff") notes = [ "买入裁决", f"动作:BUY 第一份仓", f"时间:{decision['decision_time']}", f"价格:{decision['price']:.2f}", "仓位:账户4%", f"触发:{decision['trigger']}", "依据:无忌买点规则", "T+1:当日不可卖", "", "本图为 decision_view,", "只使用买入时间及以前", "的分钟数据裁决。", ] yy = note_top + 18 for i, line in enumerate(notes): d.text((note_left + 18, yy), line, fill="#111827" if i == 0 else "#334155", font=FONT_TITLE if i == 0 else FONT_SMALL) yy += 32 if i == 0 else (24 if line else 12) d.text((32, 820), "无忌 baseline:本买入由AI按已冻结规则看图裁决,后续仍需执行审核确认。", 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 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], ) calendar = pd.read_sql( """ SELECT calendar_date FROM a_share_trading_calendar WHERE is_trading_day=1 AND calendar_date BETWEEN '2023-01-01' AND '2026-12-31' ORDER BY calendar_date """, conn, ) 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"]: minute[col] = pd.to_numeric(minute[col], errors="coerce") 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()) calendar.calendar_date = pd.to_datetime(calendar.calendar_date) trading_dates = list(calendar.calendar_date.dt.strftime("%Y-%m-%d")) def next_trade_date(date_str: str) -> str: idx = trading_dates.index(date_str) return trading_dates[idx + 1] decision_rows = [] order_rows = [] lot_rows = [] manifest_rows = [] order_seq = 0 for _, cand in selected.iterrows(): entry_str = cand.entry_trade_date.strftime("%Y-%m-%d") if cand.market_gate_status != "MKT_GATE_OPEN_PREV_DAY_UP_3000": decision_rows.append( { "case_id": cand.case_id, "candidate_id": cand.candidate_id, "symbol": cand.symbol, "entry_trade_date": entry_str, "decision_stage": "ENTRY_AI_REVIEW", "action_status": "NO_TRADE_MARKET_GATE_CLOSED", "decision_time": "", "price": "", "position_delta_pct": 0, "review_required": False, "evidence_image_path": "", "decision_reason_cn": "前一交易日全A上涨家数未达到3000,按baseline不开新仓。", "lookahead_violation_flag": False, } ) continue decision = evaluate_candidate(cand, minute, daily) evidence = "" if decision["action_status"] == "AI_BUY_CONFIRMED": case_dir = ROOT / "cases" / cand.case_id img_dir = case_dir / "img" img_dir.mkdir(parents=True, exist_ok=True) out_path = img_dir / f"04_entry_1m_decision_{cand.symbol.replace('.', '_')}_{entry_str.replace('-', '')}_{decision['decision_time'].replace(':', '')}.png" draw_buy_decision(minute, cand, decision, out_path) evidence = out_path.relative_to(ROOT).as_posix() manifest_rows.append( { "case_id": cand.case_id, "symbol": cand.symbol, "trade_date": entry_str, "event_id": f"{cand.candidate_id}_buy_decision", "chart_role": "entry_1m_buy_decision_view", "decision_time": f"{entry_str} {decision['decision_time']}", "path": evidence, "sha256": sha256_file(out_path), "status": "PASS", "note": "AI买入裁决图,标出买入点;仍需执行审核确认。", } ) order_seq += 1 order_id = f"ORD-{RUN_ID}-{order_seq:04d}" lot_id = f"LOT-{RUN_ID}-{order_seq:04d}" sellable = next_trade_date(entry_str) order_rows.append( { "order_id": order_id, "case_id": cand.case_id, "candidate_id": cand.candidate_id, "variant_id": "V0A_STRICT_TIME_WINDOW", "symbol": cand.symbol, "trade_date": entry_str, "trade_time": decision["decision_time"], "action": "BUY", "price": f"{decision['price']:.4f}", "position_delta_pct": "0.04", "tranche_index": 1, "planned_tranche_count": 5, "decision_reason_cn": decision["reason"], "evidence_image_path": evidence, "t1_sellable_from_trade_date": sellable, "lookahead_violation_flag": False, } ) lot_rows.append( { "trade_lot_id": lot_id, "order_id": order_id, "case_id": cand.case_id, "variant_id": "V0A_STRICT_TIME_WINDOW", "symbol": cand.symbol, "entry_trade_date": entry_str, "entry_time": decision["decision_time"], "entry_price": f"{decision['price']:.4f}", "position_pct": "0.04", "tranche_index": 1, "sellable_from_trade_date": sellable, "lot_status": "OPEN_PENDING_EXIT_REVIEW", "exit_trade_date": "", "exit_time": "", "exit_price": "", "lot_return_pct": "", "account_return_contribution_pct": "", } ) decision_rows.append( { "case_id": cand.case_id, "candidate_id": cand.candidate_id, "symbol": cand.symbol, "entry_trade_date": entry_str, "decision_stage": "ENTRY_AI_REVIEW", "action_status": decision["action_status"], "decision_time": decision.get("decision_time", ""), "price": "" if "price" not in decision else f"{decision['price']:.4f}", "position_delta_pct": "0.04" if decision["action_status"] == "AI_BUY_CONFIRMED" else 0, "review_required": False, "evidence_image_path": evidence, "decision_reason_cn": decision["reason"], "lookahead_violation_flag": False, } ) decision_log = pd.DataFrame(decision_rows) decision_log.to_csv(ROOT / "decision_log.csv", index=False, encoding="utf-8-sig") pd.DataFrame(order_rows).to_csv(ROOT / "order_ledger.csv", index=False, encoding="utf-8-sig") pd.DataFrame(lot_rows).to_csv(ROOT / "position_lot_ledger.csv", index=False, encoding="utf-8-sig") root_manifest_path = ROOT / "image_manifest.csv" root_manifest = pd.read_csv(root_manifest_path, encoding="utf-8-sig") if root_manifest_path.exists() else pd.DataFrame() if manifest_rows: new_manifest = pd.DataFrame(manifest_rows) combined = pd.concat([root_manifest, new_manifest], ignore_index=True) combined = combined.drop_duplicates(subset=["case_id", "symbol", "event_id", "chart_role"], keep="last") else: combined = root_manifest combined.to_csv(ROOT / "image_manifest.csv", index=False, encoding="utf-8-sig") # Append buy-decision image references to case boards and per-case manifests. if manifest_rows: new_manifest = pd.DataFrame(manifest_rows) 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(), "", "## 4. AI 买入决策图", ""] for _, row in group.iterrows(): rel = Path(row.path).relative_to(f"cases/{case_id}").as_posix() lines.extend( [ f"### {row.symbol} / BUY", "", f"![{row.symbol}]({rel})", "", f"- 决策时间:`{row.decision_time}`", "- 本图标出买入点,后续仍需执行审核确认。", "", ] ) board_path.write_text("\n".join(lines) + "\n", encoding="utf-8") case_manifest = combined[combined.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:25:00+08:00", "stage": "ENTRY_AI_REVIEW_DONE", "decision_counts": decision_log.action_status.value_counts().to_dict(), "order_rows": len(order_rows), "open_lot_rows": len(lot_rows), "buy_decision_image_count": len(manifest_rows), "artifacts": { "decision_log.csv": {"size": (ROOT / "decision_log.csv").stat().st_size, "sha256": sha256_file(ROOT / "decision_log.csv")}, "order_ledger.csv": {"size": (ROOT / "order_ledger.csv").stat().st_size, "sha256": sha256_file(ROOT / "order_ledger.csv")}, "position_lot_ledger.csv": {"size": (ROOT / "position_lot_ledger.csv").stat().st_size, "sha256": sha256_file(ROOT / "position_lot_ledger.csv")}, "image_manifest.csv": {"size": (ROOT / "image_manifest.csv").stat().st_size, "sha256": sha256_file(ROOT / "image_manifest.csv")}, }, "boundary": "Entry AI review only; no sell decisions and no return statistics yet.", "next_step": "Run T+1-safe holding/exit review for open lots.", } (ROOT / "entry_ai_review_summary.json").write_text( json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" ) (ROOT / "entry_ai_review_summary.md").write_text( "\n".join( [ "# entry_ai_review_summary", "", f"run_id:`{RUN_ID}`", "阶段:`ENTRY_AI_REVIEW_DONE`", "", f"- BUY 订单:{len(order_rows)}", f"- open lots:{len(lot_rows)}", f"- 买入决策图:{len(manifest_rows)}", "", "当前只完成买入裁决;卖点、持仓和收益复算尚未完成。", "", ] ), encoding="utf-8", ) if __name__ == "__main__": main()