from __future__ import annotations import hashlib import json import math import os import re from collections import Counter from datetime import datetime, timedelta, timezone from pathlib import Path import pandas as pd import pymysql from PIL import Image, ImageDraw, ImageFont RUN_ID = "RUN-ANA-WUJI-V1-STRICT-NOTE-FULL-RERUN-20260614-001" ROOT = Path(__file__).resolve().parents[1] LOCAL_DB_INDEX = Path(r"D:\strategy_project\s-system-doc\observer\天下模型沉淀\数据库索引数据.md") TZ = timezone(timedelta(hours=8)) OBSERVATION_TRADING_DAYS = 10 NEAR_MA5_PCT = 0.015 ROLLING_VOLUME_RATIO = 1.5 ROLLING_START_TIME = "10:40:00" ROLLING_END_TIME = "14:40:00" 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 normalize_date(value) -> str: return pd.to_datetime(value).strftime("%Y-%m-%d") def normalize_time(value) -> str: text = str(value) if " " 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}" if len(parts) == 2: return f"{int(parts[0]):02d}:{int(parts[1]):02d}:00" return text def read_mysql_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"密码:`([^`]+)`", text) 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_mysql_password(), database="tianxia", charset="utf8mb4", connect_timeout=5, read_timeout=240, write_timeout=240, ) def font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: 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_18 = font(18) FONT_22 = font(22) FONT_28 = font(28) def fetch_trade_calendar() -> list[str]: with get_conn() as conn: dates = pd.read_sql( """ SELECT DISTINCT trade_date FROM a_share_daily_price WHERE trade_date BETWEEN '2023-01-01' AND '2026-12-31' ORDER BY trade_date """, conn, ) return [normalize_date(v) for v in dates["trade_date"].tolist()] def window_dates(trade_dates: list[str], entry_date: str, start_date: str) -> list[str]: if entry_date not in trade_dates or start_date not in trade_dates: return [] start = trade_dates.index(start_date) end = min(len(trade_dates) - 1, trade_dates.index(entry_date) + OBSERVATION_TRADING_DAYS) return trade_dates[start : end + 1] def chunked(values: list, size: int): for i in range(0, len(values), size): yield values[i : i + size] def fetch_market(signals: pd.DataFrame, trade_dates: list[str]) -> tuple[dict[tuple[str, str], pd.DataFrame], dict[tuple[str, str], dict]]: minute_pairs: set[tuple[str, str]] = set() all_daily_dates: set[str] = set() symbols = sorted(signals["symbol"].unique()) for row in signals.itertuples(index=False): start_date = row.observation_trade_date if row.observation_trade_date else row.entry_trade_date dates = window_dates(trade_dates, row.entry_trade_date, start_date) for d in dates: minute_pairs.add((row.symbol, d)) all_daily_dates.add(d) if row.entry_trade_date in trade_dates: idx = trade_dates.index(row.entry_trade_date) for d in trade_dates[max(0, idx - 5) : min(len(trade_dates), idx + OBSERVATION_TRADING_DAYS + 1)]: all_daily_dates.add(d) if not minute_pairs: return {}, {} minute_parts = [] with get_conn() as conn: pair_list = sorted(minute_pairs, key=lambda x: (x[1], x[0])) for pairs in chunked(pair_list, 600): placeholders = ",".join(["(%s,%s)"] * len(pairs)) params = [] for symbol, trade_date in pairs: params.extend([trade_date, symbol]) minute_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, symbol) IN ({placeholders}) ORDER BY symbol, trade_date, trade_time """, conn, params=params, ) ) ph = ",".join(["%s"] * len(symbols)) 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 symbol IN ({ph}) AND trade_date BETWEEN %s AND %s ORDER BY symbol, trade_date """, conn, params=[*symbols, min(all_daily_dates), max(all_daily_dates)], ) minute = pd.concat(minute_parts, ignore_index=True) if minute_parts else pd.DataFrame() minute_lookup = {} if not minute.empty: minute["trade_date"] = minute["trade_date"].map(normalize_date) minute["trade_time"] = minute["trade_time"].map(normalize_time) for col in ["open_price", "high_price", "low_price", "close_price", "volume", "amount"]: minute[col] = pd.to_numeric(minute[col], errors="coerce") for (symbol, trade_date), group in minute.groupby(["symbol", "trade_date"]): minute_lookup[(symbol, trade_date)] = group.sort_values("trade_time").reset_index(drop=True) daily_lookup = {} if not daily.empty: daily["trade_date"] = daily["trade_date"].map(normalize_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) daily["ma5_close"] = daily.groupby("symbol")["close_price"].transform(lambda s: s.rolling(5, min_periods=3).mean()) daily_lookup = {(r["symbol"], r["trade_date"]): r.to_dict() for _, r in daily.iterrows()} return minute_lookup, daily_lookup def safe_float(value, default=math.nan) -> float: try: if pd.isna(value): return default return float(value) except Exception: return default def in_rolling_time(time_text: str) -> bool: return ROLLING_START_TIME <= time_text <= ROLLING_END_TIME def rolling_candidate(row, minute_lookup: dict[tuple[str, str], pd.DataFrame], daily_lookup: dict[tuple[str, str], dict], trade_dates: list[str]) -> dict: start_date = row.observation_trade_date if row.observation_trade_date else row.entry_trade_date dates = window_dates(trade_dates, row.entry_trade_date, start_date) base = { "rolling_signal_id": f"ROLL-CAND-{row.lot_id}", "source_sell_signal_id": row.sell_signal_id, "lot_id": row.lot_id, "open_order_id": row.open_order_id, "case_id": row.case_id, "candidate_id": row.candidate_id, "symbol": row.symbol, "entry_trade_date": row.entry_trade_date, "rolling_trade_date": "", "rolling_time": "", "signal_type": "ROLLING_LOW_BUY_REVIEW_HELD", "code_suggested_action": "REVIEW_HELD", "code_suggested_reason_cn": "趋势观察后未找到五日线附近止跌放量的滚动低吸确认点,保留待人工复核。", "rolling_price": "", "ma5_close": "", "near_ma5_pct": "", "volume_ratio_vs_prev20m": "", "review_input_chart_path": "", "review_input_chart_sha256": "", } for d in dates: day = minute_lookup.get((row.symbol, d), pd.DataFrame()) daily = daily_lookup.get((row.symbol, d), {}) ma5 = safe_float(daily.get("ma5_close")) if day.empty or math.isnan(ma5): continue day = day.copy() day["prev20_volume_avg"] = day["volume"].rolling(20, min_periods=5).mean().shift(1) for r in day.itertuples(index=False): if not in_rolling_time(r.trade_time): continue price = safe_float(r.close_price) near = abs(price - ma5) / ma5 if ma5 else math.nan vol_avg = safe_float(getattr(r, "prev20_volume_avg", math.nan)) vol_ratio = safe_float(r.volume) / vol_avg if vol_avg and not math.isnan(vol_avg) else math.nan if not math.isnan(near) and not math.isnan(vol_ratio) and near <= NEAR_MA5_PCT and vol_ratio >= ROLLING_VOLUME_RATIO: base.update( { "rolling_trade_date": d, "rolling_time": r.trade_time, "signal_type": "ADD_ROLLING_LOW_BUY", "code_suggested_action": "BUY_ROLLING_LOW", "code_suggested_reason_cn": f"趋势观察后回到五日线附近,距 MA5 {near:.2%},分钟量能相对前 20 分钟均量放大 {vol_ratio:.2f} 倍,代码建议进入滚动低吸人工确认。", "rolling_price": f"{price:.4f}", "ma5_close": f"{ma5:.4f}", "near_ma5_pct": f"{near:.8f}", "volume_ratio_vs_prev20m": f"{vol_ratio:.4f}", } ) return base return base def wrap(text: str, n: int) -> list[str]: return [text[i : i + n] for i in range(0, len(text), n)] or [""] def draw_rolling_chart(signal: dict, minute_lookup: dict[tuple[str, str], pd.DataFrame], daily_lookup: dict[tuple[str, str], dict]) -> None: d = signal["rolling_trade_date"] or signal["entry_trade_date"] day = minute_lookup.get((signal["symbol"], d), pd.DataFrame()) out = ROOT / "charts" / "rolling_low_review" / signal["case_id"] / f"{signal['rolling_signal_id']}.png" out.parent.mkdir(parents=True, exist_ok=True) img = Image.new("RGB", (1500, 900), "#fbfaf6") draw = ImageDraw.Draw(img) draw.text((40, 24), f"严格版滚动低吸候选:{signal['case_id']} {signal['symbol']} {d}", fill="#111111", font=FONT_28) left, top, right, bottom = 70, 110, 1020, 620 draw.rectangle((left, top, right, bottom), outline="#cccccc") prices = [safe_float(v) for v in day["close_price"].tolist()] if not day.empty else [safe_float(signal.get("rolling_price"), 1.0)] ma5 = safe_float(signal.get("ma5_close")) levels = prices + ([] if math.isnan(ma5) else [ma5]) lo, hi = min(levels), max(levels) pad = max((hi - lo) * 0.08, 0.01) lo -= pad hi += pad def x_at(i: int) -> float: return left if len(prices) <= 1 else left + i * (right - left) / (len(prices) - 1) def y_at(price: float) -> float: return bottom - (price - lo) * (bottom - top) / (hi - lo) if prices and not day.empty: pts = [(x_at(i), y_at(p)) for i, p in enumerate(prices)] draw.line(pts, fill="#1f77b4", width=2) if not math.isnan(ma5): y = y_at(ma5) draw.line((left, y, right, y), fill="#8c564b", width=2) draw.text((right + 8, y - 10), f"日线MA5 {ma5:.2f}", fill="#8c564b", font=FONT_18) if not day.empty and signal["rolling_time"] in set(day["trade_time"].tolist()): idx = day.index[day["trade_time"].eq(signal["rolling_time"])][0] price = safe_float(day.loc[idx, "close_price"]) x, y = x_at(int(idx)), y_at(price) draw.ellipse((x - 7, y - 7, x + 7, y + 7), fill="#d62728") draw.line((x, top, x, bottom), fill="#d62728", width=2) draw.text((x + 8, y - 28), f"低吸候选 {price:.2f}", fill="#d62728", font=FONT_18) side_x = 1060 draw.text((side_x, 110), "外部人工裁决待填", fill="#111111", font=FONT_22) info = [ f"代码建议:{signal['code_suggested_action']}", f"信号:{signal['signal_type']}", f"时间:{signal['rolling_trade_date']} {signal['rolling_time']}", f"MA5:{signal['ma5_close']}", "理由:", ] y = 150 for line in info: draw.text((side_x, y), line, fill="#111111", font=FONT_18) y += 30 for part in wrap(signal["code_suggested_reason_cn"], 18): draw.text((side_x, y), part, fill="#111111", font=FONT_18) y += 28 draw.text((40, 825), "audit_view:本图只用于外部人工/AI人工确认滚动低吸,不生成最终 BUY;不买也必须写清理由。", fill="#666666", font=FONT_18) img.save(out) rel = out.relative_to(ROOT).as_posix() signal["review_input_chart_path"] = rel signal["review_input_chart_sha256"] = sha256_file(out) def build_manifest() -> pd.DataFrame: rows = [] for path in sorted(ROOT.rglob("*")): if path.is_file() and path.name not in {"manifest.csv", "manifest.json"}: rows.append({"path": path.relative_to(ROOT).as_posix(), "size": path.stat().st_size, "sha256": sha256_file(path)}) return pd.DataFrame(rows) def main() -> None: sell_df = pd.read_csv(ROOT / "strict_note_sell_rolling_review_candidate_ledger.csv", encoding="utf-8-sig") hold_df = sell_df[sell_df["code_suggested_action"].isin(["HOLD_WATCH", "HOLD_ABOVE_8"])].copy() trade_dates = fetch_trade_calendar() minute_lookup, daily_lookup = fetch_market(hold_df, trade_dates) if not hold_df.empty else ({}, {}) rolling_rows = [] for row in hold_df.itertuples(index=False): sig = rolling_candidate(row, minute_lookup, daily_lookup, trade_dates) draw_rolling_chart(sig, minute_lookup, daily_lookup) rolling_rows.append(sig) rolling_df = pd.DataFrame(rolling_rows) rolling_df.to_csv(ROOT / "strict_note_rolling_low_review_candidate_ledger.csv", index=False, encoding="utf-8-sig") sell_template = sell_df.copy() sell_template.insert(0, "artifact_type", "SELL_SIGNAL") sell_template["signal_id"] = sell_template["sell_signal_id"] sell_template["rolling_signal_id"] = "" rolling_template = rolling_df.copy() rolling_template.insert(0, "artifact_type", "ROLLING_LOW_SIGNAL") rolling_template["signal_id"] = "" if not rolling_template.empty: rolling_template["sell_signal_id"] = "" common_cols = [ "artifact_type", "signal_id", "rolling_signal_id", "sell_signal_id", "lot_id", "open_order_id", "case_id", "candidate_id", "symbol", "entry_trade_date", "signal_type", "code_suggested_action", "code_suggested_reason_cn", "review_input_chart_path", "review_input_chart_sha256", ] template = pd.concat([sell_template, rolling_template], ignore_index=True, sort=False) for col in common_cols: if col not in template.columns: template[col] = "" template = template[common_cols].copy() template["external_decision_id"] = [f"EXT-SELLROLL-STRICT-NOTE-{i:06d}" for i in range(1, len(template) + 1)] for col in [ "human_decision_action", "human_decision_reason_cn", "decision_operator", "decision_time", "decision_source", "accept_code_suggestion_flag", "reviewer_notes", ]: template[col] = "" template.to_csv(ROOT / "manual_sell_rolling_decision_external_template.csv", index=False, encoding="utf-8-sig") chart_rows = [] for df, artifact, id_col in [(sell_df, "SELL_SIGNAL", "sell_signal_id"), (rolling_df, "ROLLING_LOW_SIGNAL", "rolling_signal_id")]: for r in df.itertuples(index=False): chart_rows.append( { "artifact_type": artifact, "signal_id": getattr(r, id_col), "case_id": r.case_id, "symbol": r.symbol, "review_input_chart_path": r.review_input_chart_path, "review_input_chart_sha256": r.review_input_chart_sha256, "exists": (ROOT / r.review_input_chart_path).exists(), } ) chart_df = pd.DataFrame(chart_rows) chart_df.to_csv(ROOT / "sell_rolling_chart_evidence_audit.csv", index=False, encoding="utf-8-sig") generated_at = now_iso() sell_counts = Counter(sell_df["code_suggested_action"]) rolling_counts = Counter(rolling_df["code_suggested_action"]) if not rolling_df.empty else Counter() checks = [ ("STRICT_SELL_SIGNAL_SCOPE_IS_424", len(sell_df) == 424, f"sell_signals={len(sell_df)}"), ("ROLLING_CANDIDATES_COVER_HOLD_SIGNALS", len(rolling_df) == len(hold_df), f"rolling={len(rolling_df)}, hold_signals={len(hold_df)}"), ("UNIFIED_TEMPLATE_COVERS_SELL_AND_ROLLING", len(template) == len(sell_df) + len(rolling_df), f"template={len(template)}"), ("TEMPLATE_FINAL_HUMAN_FIELDS_BLANK", template["human_decision_action"].astype(str).str.strip().eq("").all(), "human fields blank"), ("CHARTS_EXIST", bool(chart_df["exists"].all()), f"charts={len(chart_df)}, missing={int((~chart_df['exists']).sum())}"), ("NO_STRICT_PERFORMANCE_READOUT", True, "prep package only; no return/success/win-rate generated"), ] self_items = pd.DataFrame([{"item": k, "status": "PASS" if ok else "FAIL", "detail": detail} for k, ok, detail in checks]) self_items.to_csv(ROOT / "sell_rolling_review_prep_self_check_items.csv", index=False, encoding="utf-8-sig") status = "PASS_FOR_SELL_ROLLING_MANUAL_REVIEW_PREP_READY" if self_items["status"].eq("PASS").all() else "FAIL" (ROOT / "sell_rolling_review_prep_self_check.json").write_text( json.dumps({"run_id": RUN_ID, "generated_at": generated_at, "stage": status, "pass_count": int(self_items["status"].eq("PASS").sum()), "fail_count": int(self_items["status"].eq("FAIL").sum())}, ensure_ascii=False, indent=2), encoding="utf-8", ) summary = { "run_id": RUN_ID, "generated_at": generated_at, "stage": status, "strict_buy_lots_input": 424, "sell_review_candidates": int(len(sell_df)), "rolling_low_review_candidates": int(len(rolling_df)), "manual_decision_template_rows": int(len(template)), "sell_code_suggested_action_counts": dict(sell_counts), "rolling_code_suggested_action_counts": dict(rolling_counts), "boundary": "Prep only: final sell/hold/rolling actions require external manual decision source and execution review.", } (ROOT / "sell_rolling_review_prep_summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8") (ROOT / "sell_rolling_review_prep_summary.md").write_text( "# Strict Note Sell/Rolling Manual Review Prep\n\n" f"- generated_at: {generated_at}\n" "- strict BUY lots input: 424\n" f"- sell review candidates: {len(sell_df)}\n" f"- rolling low review candidates: {len(rolling_df)}\n" f"- manual decision template rows: {len(template)}\n" f"- sell code suggested actions: {dict(sell_counts)}\n" f"- rolling code suggested actions: {dict(rolling_counts)}\n\n" "Boundary: this is a manual-review prep package only. It does not generate final SELL orders, rolling BUY orders, return, success rate, win rate, drawdown, or strategy-effectiveness conclusions.\n", encoding="utf-8", ) manifest = build_manifest() manifest.to_csv(ROOT / "manifest.csv", index=False, encoding="utf-8-sig") (ROOT / "manifest.json").write_text(json.dumps({"run_id": RUN_ID, "generated_at": generated_at, "file_count": int(len(manifest)), "files": manifest.to_dict("records")}, ensure_ascii=False, indent=2), encoding="utf-8") print(json.dumps(summary, ensure_ascii=False)) if __name__ == "__main__": main()