from __future__ import annotations
|
|
import hashlib
|
import json
|
import math
|
import os
|
import re
|
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-STRICT-CLOSED-234-CHART-ENHANCE-20260609-001"
|
SOURCE_RUN_ID = "RUN-ANA-WUJI-FULL-2023-2026-20260608-001"
|
GUIDE_RUN_ID = "RUN-ANA-WUJI-STRICT-CLOSED-234-REVIEW-GUIDE-20260608-001"
|
TASK_ID = "ANA-WUJI-STRICT-CLOSED-234-CHART-ENHANCE-20260609"
|
|
PACKAGE_ROOT = Path(__file__).resolve().parents[1]
|
RESULT_ROOT = Path(__file__).resolve().parents[2]
|
PROJECT_ROOT = Path(__file__).resolve().parents[4]
|
SOURCE_ROOT = RESULT_ROOT / SOURCE_RUN_ID
|
GUIDE_ROOT = RESULT_ROOT / GUIDE_RUN_ID
|
LOCAL_DB_INDEX = Path(
|
r"D:\strategy_project\s-system-doc\observer\天下模型沉淀\数据库索引数据.md"
|
)
|
|
|
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=180,
|
)
|
|
|
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(28)
|
FONT_SUB = font(20)
|
FONT_MID = font(17)
|
FONT_SMALL = font(14)
|
FONT_TINY = font(12)
|
|
|
def read_csv(name: str) -> pd.DataFrame:
|
return pd.read_csv(SOURCE_ROOT / name, encoding="utf-8-sig")
|
|
|
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 "days" in text and " " in text:
|
text = text.split()[-1]
|
if re.fullmatch(r"\d{2}:\d{2}$", text):
|
return f"{text}:00"
|
if re.fullmatch(r"\d{2}:\d{2}:\d{2}(\.\d+)?", text):
|
return text[:8]
|
return text
|
|
|
def to_bool_int(series: pd.Series) -> pd.Series:
|
return pd.to_numeric(series, errors="coerce").fillna(0).astype(int)
|
|
|
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]:
|
text = "" if pd.isna(text) else str(text)
|
lines: list[str] = []
|
current = ""
|
for ch in text:
|
current += ch
|
if len(current) >= max_chars:
|
lines.append(current)
|
current = ""
|
if current:
|
lines.append(current)
|
return lines or [""]
|
|
|
def pct_text(value) -> str:
|
try:
|
return f"{float(value):.2%}"
|
except Exception:
|
return ""
|
|
|
def money_text(value) -> str:
|
try:
|
return f"{float(value):.6f}"
|
except Exception:
|
return ""
|
|
|
def gate_cn(value: str) -> str:
|
return "市场闸门打开" if value == "MKT_GATE_OPEN_PREV_DAY_UP_3000" else "市场闸门关闭"
|
|
|
def status_cn(value: str) -> str:
|
if value == "PASS":
|
return "候选通过"
|
if value == "FAKE_BREAKOUT_RISK_REVIEW":
|
return "前高假突破风险复核"
|
return str(value)
|
|
|
def draw_text_box(
|
draw: ImageDraw.ImageDraw,
|
box: tuple[int, int, int, int],
|
title: str,
|
lines: Iterable[str],
|
) -> 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_TITLE)
|
y = y1 + 58
|
for line in lines:
|
if not line:
|
y += 10
|
continue
|
for wrapped in wrap_text(line, 25):
|
if y > y2 - 22:
|
return
|
draw.text((x1 + 18, y), wrapped, fill="#334155", font=FONT_SMALL)
|
y += 23
|
|
|
def fetch_daily(selected: pd.DataFrame) -> pd.DataFrame:
|
symbols = sorted(selected["symbol"].dropna().unique().tolist())
|
min_date = (pd.to_datetime(selected["signal_trade_date"]).min() - pd.Timedelta(days=230)).strftime("%Y-%m-%d")
|
max_date = (pd.to_datetime(selected["signal_trade_date"]).max() + pd.Timedelta(days=45)).strftime("%Y-%m-%d")
|
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()
|
if daily.empty:
|
return daily
|
daily["trade_date"] = pd.to_datetime(daily["trade_date"])
|
for col in ["open_price", "high_price", "low_price", "close_price", "volume", "amount"]:
|
daily[col] = pd.to_numeric(daily[col], errors="coerce")
|
daily = daily.sort_values(["symbol", "trade_date"]).reset_index(drop=True)
|
daily["ma5"] = daily.groupby("symbol")["close_price"].transform(lambda s: s.rolling(5, 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(orders: pd.DataFrame) -> pd.DataFrame:
|
date_symbol_map: dict[str, set[str]] = {}
|
for _, row in orders.iterrows():
|
date_symbol_map.setdefault(str(row["trade_date"]), set()).add(str(row["symbol"]))
|
parts: list[pd.DataFrame] = []
|
with get_conn() as conn:
|
for idx, trade_date in enumerate(sorted(date_symbol_map), start=1):
|
symbols = sorted(date_symbol_map[trade_date])
|
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 % 100 == 0:
|
print(f"minute query progress: {idx}/{len(date_symbol_map)} dates", flush=True)
|
minute = pd.concat(parts, ignore_index=True) if parts else pd.DataFrame()
|
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
|
|
|
def daily_window(daily: pd.DataFrame, symbol: str, signal_date: str) -> pd.DataFrame:
|
sd = pd.Timestamp(signal_date)
|
rows = daily[daily["symbol"] == symbol].sort_values("trade_date").reset_index(drop=True)
|
hits = rows.index[rows["trade_date"] == sd].tolist()
|
if not hits:
|
return pd.DataFrame()
|
pos = hits[0]
|
start = max(0, pos - 90)
|
end = min(len(rows), pos + 11)
|
return rows.iloc[start:end].copy().reset_index(drop=True)
|
|
|
def draw_daily_enhanced_chart(window: pd.DataFrame, cand: pd.Series, out_path: Path) -> None:
|
w, h = 1680, 980
|
img = Image.new("RGB", (w, h), "#fbfbf7")
|
d = ImageDraw.Draw(img)
|
d.rectangle([0, 0, w - 1, h - 1], outline="#cbd5e1")
|
|
signal_date = str(cand["signal_trade_date"])
|
entry_date = str(cand["entry_trade_date"])
|
title = f"选股日K图增强审阅:{cand['symbol']} 信号日 {signal_date}"
|
subtitle = "窗口:信号日前90个交易日 + 信号日后10个交易日;信号后部分只作 audit_view,不参与当时选股。"
|
d.text((32, 22), title, fill="#111827", font=FONT_TITLE)
|
d.text((32, 62), subtitle, fill="#7f1d1d", font=FONT_MID)
|
|
plot_left, plot_top, plot_right, plot_bottom = 80, 118, 1190, 640
|
vol_top, vol_bottom = 700, 850
|
note_left, note_top = 1220, 118
|
d.rectangle([plot_left, plot_top, plot_right, plot_bottom], outline="#94a3b8")
|
d.rectangle([plot_left, vol_top, plot_right, vol_bottom], outline="#94a3b8")
|
|
win = window.copy().reset_index(drop=True)
|
win["trade_date_str"] = win["trade_date"].dt.strftime("%Y-%m-%d")
|
price_low = float(win["low_price"].min()) * 0.98
|
price_high = float(win["high_price"].max()) * 1.02
|
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))
|
signal_positions = win.index[win["trade_date_str"] == signal_date].tolist()
|
entry_positions = win.index[win["trade_date_str"] == entry_date].tolist()
|
signal_pos = signal_positions[0] if signal_positions else None
|
|
if signal_pos is not None and signal_pos + 1 < n:
|
x1 = int(plot_left + gap * (signal_pos + 1))
|
d.rectangle([x1, plot_top, plot_right, vol_bottom], fill="#fff7ed")
|
d.text((x1 + 8, plot_top + 8), "信号后10日审阅区", fill="#9a3412", font=FONT_SMALL)
|
|
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 = {"ma5": [], "ma20": [], "ma60": []}
|
ma_colors = {"ma5": "#2563eb", "ma20": "#f59e0b", "ma60": "#7c3aed"}
|
for i, row in win.iterrows():
|
cx = int(plot_left + gap * i + gap / 2)
|
op = float(row["open_price"])
|
hi = float(row["high_price"])
|
lo = float(row["low_price"])
|
cl = float(row["close_price"])
|
color = "#dc2626" if cl >= op else "#16a34a"
|
d.line([cx, 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 row["trade_date_str"] == signal_date:
|
d.line([cx, plot_top, cx, vol_bottom], fill="#0f172a", width=3)
|
d.text((cx + 5, plot_top + 30), "信号日", fill="#0f172a", font=FONT_SMALL)
|
if row["trade_date_str"] == entry_date:
|
d.line([cx, plot_top, cx, vol_bottom], fill="#b91c1c", width=2)
|
d.text((cx + 5, plot_top + 55), "买入日", fill="#b91c1c", font=FONT_SMALL)
|
if i % max(1, n // 9) == 0:
|
d.text((cx - 24, vol_bottom + 8), 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 + 12), ma.upper(), fill=color, font=FONT_SMALL)
|
legend_x += 75
|
|
lines = [
|
f"候选排名:{cand['candidate_rank']}",
|
f"候选状态:{status_cn(cand['candidate_status'])}",
|
f"市场闸门:{gate_cn(cand['market_gate_status'])}",
|
f"上涨/下跌家数:{cand.get('up_count', '')}/{cand.get('down_count', '')}",
|
f"量比:{float(cand['volume_ratio']):.2f},长上影:{float(cand['upper_shadow_pct']):.2f}%",
|
f"近30日涨停:{cand.get('last_limitup_date', '')}",
|
f"前高引用:{cand.get('prev60_high_ref_date', '')}",
|
f"前高量能通过:{cand.get('prev_high_volume_pass_flag', '')}",
|
"",
|
"重要边界:信号日后10天仅帮助同事看后续走势,不能反推当时是否入池、买入或卖出。",
|
"原决策仍以已审核 decision_view、订单账本和 scope 表为准。",
|
]
|
draw_text_box(d, (note_left, note_top, 1640, 850), "图上说明", lines)
|
footer = f"来源:{SOURCE_RUN_ID};本图为增强 audit_view,不新增交易结论。"
|
d.text((32, 916), footer, fill="#334155", font=FONT_MID)
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
img.save(out_path)
|
|
|
def find_event_index(df: pd.DataFrame, time_str: str) -> int:
|
if df.empty:
|
return 0
|
hits = df.index[df["trade_time"] == time_str].tolist()
|
if hits:
|
return hits[0]
|
before = df.index[df["trade_time"] <= time_str].tolist()
|
if before:
|
return before[-1]
|
return 0
|
|
|
def draw_minute_line_chart(df: pd.DataFrame, order: pd.Series, action: str, out_path: Path) -> None:
|
w, h = 1540, 860
|
img = Image.new("RGB", (w, h), "#fbfbf7")
|
d = ImageDraw.Draw(img)
|
d.rectangle([0, 0, w - 1, h - 1], outline="#cbd5e1")
|
action_cn = "买入" if action == "BUY" else "卖出"
|
point_cn = "买点" if action == "BUY" else "卖点"
|
color = "#b91c1c" if action == "BUY" else "#7c3aed"
|
title = f"{action_cn}日整日分钟行情折线图:{order['symbol']} {order['trade_date']}"
|
d.text((32, 24), title, fill="#111827", font=FONT_TITLE)
|
d.text((32, 60), f"标记:{point_cn} {order['trade_time']} @ {float(order['price']):.2f};整日走势只作人工审阅,不改写原裁决。", fill=color, font=FONT_MID)
|
|
plot_left, plot_top, plot_right, plot_bottom = 80, 110, 1080, 590
|
vol_top, vol_bottom = 645, 790
|
note_left, note_top = 1115, 112
|
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 = df.copy().sort_values("trade_time").reset_index(drop=True)
|
event_price = float(order["price"])
|
if day.empty:
|
d.text((plot_left + 80, plot_top + 180), "分钟线数据缺失", fill="#b91c1c", font=FONT_TITLE)
|
img.save(out_path)
|
return
|
price_low = min(float(day["low_price"].min()), event_price) * 0.998
|
price_high = max(float(day["high_price"].max()), event_price) * 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 // 7) == 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((18, y - 8), f"{price:.2f}", fill="#64748b", font=FONT_SMALL)
|
|
event_idx = find_event_index(day, str(order["trade_time"]))
|
event_x = int(plot_left + gap * event_idx)
|
event_y = y_price(event_price, price_low, price_high, plot_top, plot_bottom)
|
d.line([event_x, plot_top, event_x, vol_bottom], fill=color, width=3)
|
d.ellipse([event_x - 8, event_y - 8, event_x + 8, event_y + 8], fill=color)
|
d.text((min(event_x + 8, plot_right - 120), max(plot_top + 8, event_y - 42)), f"{point_cn} {order['trade_time'][:5]}", fill=color, font=FONT_SUB)
|
|
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 - 95, open_y - 16), f"开盘 {open_price:.2f}", fill="#334155", font=FONT_SMALL)
|
|
lines = [
|
f"动作:{action_cn}",
|
f"订单:{order['order_id']}",
|
f"时间:{order['trade_time']}",
|
f"价格:{float(order['price']):.2f}",
|
f"仓位变化:{float(order['position_delta_pct']):.2%}",
|
f"证据来源:{order.get('evidence_image_path', '')}",
|
"",
|
"本图新增整日折线视角,目的是让同事看清买卖点处于全天什么位置。",
|
"原始买卖裁决仍以来源包的1分钟K图、订单账本和已审核scope为准。",
|
"本图不新增收益率或策略有效性结论。",
|
]
|
draw_text_box(d, (note_left, note_top, 1500, 790), f"{action_cn}说明", lines)
|
d.text((32, 820), f"来源:{SOURCE_RUN_ID};增强包:{RUN_ID}", 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")
|
targets = []
|
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()
|
targets.append(
|
{
|
"markdown_path": markdown_path.relative_to(PACKAGE_ROOT).as_posix(),
|
"link": raw,
|
"target_exists": target.exists(),
|
"target_path": str(target),
|
}
|
)
|
return targets
|
|
|
def write_manifest() -> None:
|
files = []
|
for path in sorted(PACKAGE_ROOT.rglob("*")):
|
if not path.is_file():
|
continue
|
if path.name == "manifest.json":
|
continue
|
rel = path.relative_to(PACKAGE_ROOT).as_posix()
|
files.append(
|
{
|
"path": rel,
|
"size": path.stat().st_size,
|
"sha256": sha256_file(path),
|
}
|
)
|
manifest = {
|
"run_id": RUN_ID,
|
"task_id": TASK_ID,
|
"generated_at": GENERATED_AT,
|
"source_run_id": SOURCE_RUN_ID,
|
"source_guide_run_id": GUIDE_RUN_ID,
|
"manifest_self_hash_excluded": True,
|
"file_count": len(files),
|
"files": files,
|
}
|
(PACKAGE_ROOT / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
|
def write_source_manifest() -> None:
|
source_files = [
|
SOURCE_ROOT / "full_return_stat_case_scope.csv",
|
SOURCE_ROOT / "full_selected_candidate_ledger.csv",
|
SOURCE_ROOT / "order_ledger.csv",
|
SOURCE_ROOT / "position_lot_ledger.csv",
|
SOURCE_ROOT / "case_summary.csv",
|
SOURCE_ROOT / "full_return_stat_summary.json",
|
SOURCE_ROOT / "manifest.json",
|
GUIDE_ROOT / "strict_closed_234_case_index.csv",
|
GUIDE_ROOT / "strict_closed_234_file_map.csv",
|
GUIDE_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 build_case_board(
|
case_id: str,
|
case_scope: pd.Series,
|
case_summary: pd.Series | None,
|
candidate_rows: pd.DataFrame,
|
buy_rows: pd.DataFrame,
|
sell_rows: pd.DataFrame,
|
chart_rows: list[dict],
|
) -> None:
|
case_dir = PACKAGE_ROOT / "cases" / case_id
|
source_case_dir = SOURCE_ROOT / "cases" / case_id
|
lines = [
|
f"# {case_id} 增强图片审核板",
|
"",
|
f"- 增强包:`{RUN_ID}`",
|
f"- 来源全量包:`{SOURCE_RUN_ID}`",
|
f"- 来源原图片板:[打开原 case_image_board.md](../../../{SOURCE_RUN_ID}/cases/{case_id}/case_image_board.md)",
|
f"- 当前收益口径:`PRIMARY_STRICT_CLOSED_CASE`",
|
f"- RETURN_STAT_READY:`false`",
|
"",
|
"本板是给同事看的图片增强入口,不重跑候选池、买卖裁决或账本,不新增收益结论。",
|
"选股日K图包含信号日后10个交易日的事后审阅区;该区域只能帮助复盘,不能反推当时选股或买卖决策。",
|
"",
|
"## 1. 选股日K图:信号日前90天 + 后10天",
|
"",
|
]
|
for _, cand in candidate_rows.sort_values("candidate_rank").iterrows():
|
rows = [r for r in chart_rows if r["case_id"] == case_id and r["chart_role"] == "candidate_daily_90pre_10post_audit_view" and r["symbol"] == cand["symbol"]]
|
if rows:
|
rel = Path(rows[0]["path"]).relative_to(f"cases/{case_id}").as_posix()
|
lines.extend(
|
[
|
f"### 候选排名 {int(cand['candidate_rank'])}:{cand['symbol']}",
|
"",
|
f"![{cand['symbol']}]({rel})",
|
"",
|
f"- 信号日:`{cand['signal_trade_date']}`;买入日:`{cand['entry_trade_date']}`",
|
f"- 候选状态:`{cand['candidate_status']}`;量比 `{float(cand['volume_ratio']):.2f}`;长上影 `{float(cand['upper_shadow_pct']):.2f}%`",
|
"",
|
]
|
)
|
lines.extend(["## 2. 买入日整日分钟行情折线图", ""])
|
if buy_rows.empty:
|
lines.append("- 本案例无 BUY 订单。")
|
for _, order in buy_rows.sort_values(["trade_date", "trade_time", "symbol"]).iterrows():
|
rows = [r for r in chart_rows if r["event_id"] == order["order_id"] and r["chart_role"] == "entry_full_day_minute_line_audit_view"]
|
if rows:
|
rel = Path(rows[0]["path"]).relative_to(f"cases/{case_id}").as_posix()
|
lines.extend(
|
[
|
f"### 买点:{order['symbol']} {order['trade_date']} {order['trade_time']}",
|
"",
|
f"![买点 {order['symbol']}]({rel})",
|
"",
|
f"- 买入价格:`{float(order['price']):.4f}`;仓位变化:`{float(order['position_delta_pct']):.2%}`",
|
f"- 来源订单:`{order['order_id']}`",
|
"",
|
]
|
)
|
lines.extend(["## 3. 卖出日整日分钟行情折线图", ""])
|
if sell_rows.empty:
|
lines.append("- 本案例无 SELL 订单。")
|
for _, order in sell_rows.sort_values(["trade_date", "trade_time", "symbol"]).iterrows():
|
rows = [r for r in chart_rows if r["event_id"] == order["order_id"] and r["chart_role"] == "exit_full_day_minute_line_audit_view"]
|
if rows:
|
rel = Path(rows[0]["path"]).relative_to(f"cases/{case_id}").as_posix()
|
lines.extend(
|
[
|
f"### 卖点:{order['symbol']} {order['trade_date']} {order['trade_time']}",
|
"",
|
f"![卖点 {order['symbol']}]({rel})",
|
"",
|
f"- 卖出价格:`{float(order['price']):.4f}`;仓位变化:`{float(order['position_delta_pct']):.2%}`",
|
f"- 来源订单:`{order['order_id']}`;来源 lot:`{order.get('source_lot_id', '')}`",
|
"",
|
]
|
)
|
ret = "" if case_summary is None else money_text(case_summary.get("account_return_closed_lots", ""))
|
success = "" if case_scope is None else case_scope.get("primary_case_success_flag", "")
|
lines.extend(
|
[
|
"## 4. 账本和收益口径追溯",
|
"",
|
f"- 主口径成功标记:`{success}`",
|
f"- 闭合 lot 账户贡献合计:`{ret}`",
|
f"- 来源 case scope:`{SOURCE_RUN_ID}/full_return_stat_case_scope.csv`",
|
f"- 来源订单账本:`{SOURCE_RUN_ID}/order_ledger.csv`",
|
f"- 来源 lot 账本:`{SOURCE_RUN_ID}/position_lot_ledger.csv`",
|
f"- 来源账户流水:`{SOURCE_RUN_ID}/daily_account_ledger.csv`",
|
"",
|
"禁止过读:本 case 属于 234 个严格闭合主口径子集;不得把该子集直接说成完整 743 个 entry date 的无边界表现。",
|
]
|
)
|
(case_dir / "case_image_board.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
|
def build_root_docs(case_index: pd.DataFrame, chart_rows: list[dict]) -> None:
|
readme = [
|
"# 无忌交易系统 234 个严格闭合案例增强图片包",
|
"",
|
f"- 生成时间:`{GENERATED_AT}`",
|
f"- 增强包:`{RUN_ID}`",
|
f"- 来源全量包:`{SOURCE_RUN_ID}`",
|
f"- 来源说明包:`{GUIDE_RUN_ID}`",
|
"",
|
"## 这个包解决什么问题",
|
"",
|
"同事反馈原 `case_image_board.md` 还需要三类更方便的图:",
|
"",
|
"1. 选股日K图按信号日前90个交易日 + 信号日后10个交易日展示。",
|
"2. 增加买入日整日分钟行情折线图,并清楚标记买点。",
|
"3. 增加卖出日整日分钟行情折线图,并清楚标记卖点。",
|
"",
|
"本包只增强图片阅读入口,不重跑候选池、买卖裁决或账本,不新增收益结论。",
|
"信号日后的10个交易日和整日分钟折线图均属于 `audit_view`,用于人工复核,不得反推当时决策。",
|
"",
|
"## 怎么看",
|
"",
|
"1. 先打开本目录 `case_image_board.md`,从234个 case 列表进入单个 case。",
|
"2. 单个 case 先看增强选股日K图,再看买入日整日分钟折线图,最后看卖出日整日分钟折线图。",
|
"3. 需要核对数字时回到来源全量包的 `order_ledger.csv`、`position_lot_ledger.csv`、`daily_account_ledger.csv` 和 `full_return_stat_case_scope.csv`。",
|
"",
|
"## 关键边界",
|
"",
|
"- 当前范围仍是 `PRIMARY_STRICT_CLOSED_CASE` 的 234 个严格闭合 case。",
|
"- `RETURN_STAT_READY=false` 保留。",
|
"- 不得把 234 个 case 写成完整 743 个 entry date 的无边界收益或成功率。",
|
"- 不得声称无忌 baseline 策略有效性已经被证明。",
|
]
|
(PACKAGE_ROOT / "README.md").write_text("\n".join(readme) + "\n", encoding="utf-8")
|
|
lines = [
|
"# 234 个严格闭合案例增强图片总入口",
|
"",
|
f"- 增强包:`{RUN_ID}`",
|
f"- 来源全量包:`{SOURCE_RUN_ID}`",
|
f"- 增强图数量:`{len(chart_rows)}`",
|
"",
|
"## 入口列表",
|
"",
|
"| case_id | 入场日 | 主口径收益贡献 | 买入图 | 卖出图 | 图片板 |",
|
"|---|---:|---:|---:|---:|---|",
|
]
|
for _, row in case_index.sort_values("entry_trade_date").iterrows():
|
lines.append(
|
f"| `{row['case_id']}` | {row['entry_trade_date']} | {money_text(row['account_return_closed_lots'])} | {int(row['buy_chart_count'])} | {int(row['sell_chart_count'])} | [打开](cases/{row['case_id']}/case_image_board.md) |"
|
)
|
(PACKAGE_ROOT / "case_image_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()
|
|
scope = read_csv("full_return_stat_case_scope.csv")
|
strict = scope[
|
(scope["case_scope_status"] == "PRIMARY_STRICT_CLOSED_CASE")
|
& (to_bool_int(scope["primary_strict_closed_case_flag"]) == 1)
|
].copy()
|
strict_ids = set(strict["case_id"])
|
selected = read_csv("full_selected_candidate_ledger.csv")
|
selected = selected[selected["case_id"].isin(strict_ids)].copy()
|
selected["signal_trade_date"] = pd.to_datetime(selected["signal_trade_date"]).dt.strftime("%Y-%m-%d")
|
selected["entry_trade_date"] = pd.to_datetime(selected["entry_trade_date"]).dt.strftime("%Y-%m-%d")
|
selected["candidate_rank"] = pd.to_numeric(selected["candidate_rank"], errors="coerce")
|
orders = read_csv("order_ledger.csv")
|
orders = orders[orders["case_id"].isin(strict_ids) & orders["action"].isin(["BUY", "SELL"])].copy()
|
orders["trade_date"] = pd.to_datetime(orders["trade_date"]).dt.strftime("%Y-%m-%d")
|
orders["trade_time"] = orders["trade_time"].map(norm_time)
|
orders["price"] = pd.to_numeric(orders["price"], errors="coerce")
|
orders["position_delta_pct"] = pd.to_numeric(orders["position_delta_pct"], errors="coerce")
|
case_summary = read_csv("case_summary.csv")
|
case_summary = case_summary[case_summary["case_id"].isin(strict_ids)].copy()
|
|
print(f"strict cases={len(strict)}, selected={len(selected)}, orders={len(orders)}", flush=True)
|
daily = fetch_daily(selected)
|
minute = fetch_minute(orders)
|
print(f"daily rows={len(daily)}, minute rows={len(minute)}", flush=True)
|
|
chart_rows: list[dict] = []
|
missing_rows: list[dict] = []
|
case_index_rows: list[dict] = []
|
|
for seq, (_, case_scope) in enumerate(strict.sort_values("entry_trade_date").iterrows(), start=1):
|
case_id = case_scope["case_id"]
|
case_dir = PACKAGE_ROOT / "cases" / case_id
|
img_dir = case_dir / "img"
|
img_dir.mkdir(parents=True, exist_ok=True)
|
candidate_rows = selected[selected["case_id"] == case_id].copy()
|
buy_rows = orders[(orders["case_id"] == case_id) & (orders["action"] == "BUY")].copy()
|
sell_rows = orders[(orders["case_id"] == case_id) & (orders["action"] == "SELL")].copy()
|
summary_rows = case_summary[case_summary["case_id"] == case_id]
|
summary_row = summary_rows.iloc[0] if not summary_rows.empty else None
|
|
for _, cand in candidate_rows.sort_values("candidate_rank").iterrows():
|
window = daily_window(daily, str(cand["symbol"]), str(cand["signal_trade_date"]))
|
out = img_dir / f"01_candidate_daily_90pre_10post_rank{int(cand['candidate_rank']):02d}_{str(cand['symbol']).replace('.', '_')}_{str(cand['signal_trade_date']).replace('-', '')}.png"
|
if window.empty:
|
missing_rows.append({"case_id": case_id, "symbol": cand["symbol"], "kind": "daily", "date": cand["signal_trade_date"]})
|
continue
|
draw_daily_enhanced_chart(window, cand, out)
|
chart_rows.append(
|
{
|
"case_id": case_id,
|
"symbol": cand["symbol"],
|
"event_id": cand["candidate_id"],
|
"chart_role": "candidate_daily_90pre_10post_audit_view",
|
"action": "CANDIDATE",
|
"trade_date": cand["signal_trade_date"],
|
"trade_time": "close",
|
"path": out.relative_to(PACKAGE_ROOT).as_posix(),
|
"source_order_id": "",
|
"source_candidate_id": cand["candidate_id"],
|
"status": "PASS",
|
"note": "信号日前90个交易日 + 信号日后10个交易日;信号后区间仅作audit_view。",
|
"size": out.stat().st_size,
|
"sha256": sha256_file(out),
|
}
|
)
|
|
for _, order in pd.concat([buy_rows, sell_rows], ignore_index=True).sort_values(["trade_date", "trade_time", "symbol", "order_id"]).iterrows():
|
action = str(order["action"])
|
role = "entry_full_day_minute_line_audit_view" if action == "BUY" else "exit_full_day_minute_line_audit_view"
|
prefix = "07_entry_full_day_minute_line" if action == "BUY" else "08_exit_full_day_minute_line"
|
day = minute[
|
(minute["symbol"] == order["symbol"])
|
& (minute["trade_date"] == order["trade_date"])
|
].copy()
|
safe_order = re.sub(r"[^A-Za-z0-9]+", "_", str(order["order_id"]))[-36:]
|
out = img_dir / f"{prefix}_{str(order['symbol']).replace('.', '_')}_{str(order['trade_date']).replace('-', '')}_{str(order['trade_time']).replace(':', '')}_{safe_order}.png"
|
if day.empty:
|
missing_rows.append({"case_id": case_id, "symbol": order["symbol"], "kind": action.lower(), "date": order["trade_date"]})
|
continue
|
draw_minute_line_chart(day, order, action, out)
|
chart_rows.append(
|
{
|
"case_id": case_id,
|
"symbol": order["symbol"],
|
"event_id": order["order_id"],
|
"chart_role": role,
|
"action": action,
|
"trade_date": order["trade_date"],
|
"trade_time": order["trade_time"],
|
"path": out.relative_to(PACKAGE_ROOT).as_posix(),
|
"source_order_id": order["order_id"],
|
"source_candidate_id": order.get("candidate_id", ""),
|
"status": "PASS",
|
"note": "整日分钟行情折线图;仅作audit_view,标记来源订单买卖点。",
|
"size": out.stat().st_size,
|
"sha256": sha256_file(out),
|
}
|
)
|
|
build_case_board(case_id, case_scope, summary_row, candidate_rows, buy_rows, sell_rows, chart_rows)
|
case_chart_rows = [r for r in chart_rows if r["case_id"] == case_id]
|
(case_dir / "image_manifest.csv").write_text(
|
pd.DataFrame(case_chart_rows).to_csv(index=False),
|
encoding="utf-8-sig",
|
)
|
case_index_rows.append(
|
{
|
"case_id": case_id,
|
"entry_trade_date": case_scope["entry_trade_date"],
|
"signal_trade_date": case_scope["signal_trade_date"],
|
"account_return_closed_lots": case_scope["account_return_closed_lots"],
|
"primary_case_success_flag": case_scope["primary_case_success_flag"],
|
"candidate_chart_count": len([r for r in case_chart_rows if r["chart_role"] == "candidate_daily_90pre_10post_audit_view"]),
|
"buy_chart_count": len([r for r in case_chart_rows if r["chart_role"] == "entry_full_day_minute_line_audit_view"]),
|
"sell_chart_count": len([r for r in case_chart_rows if r["chart_role"] == "exit_full_day_minute_line_audit_view"]),
|
"enhanced_case_image_board": f"cases/{case_id}/case_image_board.md",
|
"source_case_image_board": f"{SOURCE_RUN_ID}/cases/{case_id}/case_image_board.md",
|
}
|
)
|
if seq % 25 == 0:
|
print(f"case progress: {seq}/{len(strict)}", flush=True)
|
|
chart_df = pd.DataFrame(chart_rows)
|
chart_df.to_csv(PACKAGE_ROOT / "chart_evidence_audit.csv", index=False, encoding="utf-8-sig")
|
missing_df = pd.DataFrame(missing_rows)
|
missing_df.to_csv(PACKAGE_ROOT / "missing_chart_inputs.csv", index=False, encoding="utf-8-sig")
|
case_index = pd.DataFrame(case_index_rows).sort_values("entry_trade_date")
|
case_index.to_csv(PACKAGE_ROOT / "enhanced_case_index.csv", index=False, encoding="utf-8-sig")
|
build_root_docs(case_index, chart_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_daily = len(strict) * 5
|
expected_buy = len(orders[orders["action"] == "BUY"])
|
expected_sell = len(orders[orders["action"] == "SELL"])
|
self_checks = [
|
("SOURCE_FULL_RUN_EXISTS", SOURCE_ROOT.exists(), str(SOURCE_ROOT)),
|
("SOURCE_REVIEW_GUIDE_EXISTS", GUIDE_ROOT.exists(), str(GUIDE_ROOT)),
|
("STRICT_CASE_COUNT_234", len(strict) == 234, f"strict_cases={len(strict)}"),
|
("SELECTED_CANDIDATE_ROWS_1170", len(selected) == expected_daily, f"selected={len(selected)}, expected={expected_daily}"),
|
("DAILY_AUDIT_CHARTS_COMPLETE", len(chart_df[chart_df["chart_role"] == "candidate_daily_90pre_10post_audit_view"]) == expected_daily, f"actual={len(chart_df[chart_df['chart_role'] == 'candidate_daily_90pre_10post_audit_view'])}, expected={expected_daily}"),
|
("BUY_FULL_DAY_LINE_CHARTS_COMPLETE", len(chart_df[chart_df["chart_role"] == "entry_full_day_minute_line_audit_view"]) == expected_buy, f"actual={len(chart_df[chart_df['chart_role'] == 'entry_full_day_minute_line_audit_view'])}, expected={expected_buy}"),
|
("SELL_FULL_DAY_LINE_CHARTS_COMPLETE", len(chart_df[chart_df["chart_role"] == "exit_full_day_minute_line_audit_view"]) == expected_sell, f"actual={len(chart_df[chart_df['chart_role'] == 'exit_full_day_minute_line_audit_view'])}, expected={expected_sell}"),
|
("CASE_IMAGE_BOARDS_234", len(list((PACKAGE_ROOT / "cases").glob("*/case_image_board.md"))) == 234, f"boards={len(list((PACKAGE_ROOT / 'cases').glob('*/case_image_board.md')))}"),
|
("MISSING_CHART_INPUTS_ZERO", missing_df.empty, f"missing={len(missing_df)}"),
|
("MARKDOWN_LOCAL_LINKS_REACHABLE", bool(link_df.empty or link_df["target_exists"].all()), f"links={len(link_df)}, missing={0 if link_df.empty else int((~link_df['target_exists']).sum())}"),
|
("RETURN_STAT_READY_FALSE_PRESERVED", True, "enhancement package does not change source 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,
|
"status": "PASS_FOR_STRICT_CLOSED_234_CHART_ENHANCEMENT_REVIEW_READY" if fail_count == 0 else "FAIL",
|
"pass_count": int((self_df["status"] == "PASS").sum()),
|
"fail_count": fail_count,
|
"strict_case_count": len(strict),
|
"daily_audit_chart_count": int((chart_df["chart_role"] == "candidate_daily_90pre_10post_audit_view").sum()),
|
"buy_full_day_line_chart_count": int((chart_df["chart_role"] == "entry_full_day_minute_line_audit_view").sum()),
|
"sell_full_day_line_chart_count": int((chart_df["chart_role"] == "exit_full_day_minute_line_audit_view").sum()),
|
"return_stat_ready": False,
|
"full_baseline_conclusion_allowed": False,
|
}
|
(PACKAGE_ROOT / "self_check.json").write_text(json.dumps(self_json, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
(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"- 选股增强日K图:`{self_json['daily_audit_chart_count']}`",
|
f"- 买入日整日分钟折线图:`{self_json['buy_full_day_line_chart_count']}`",
|
f"- 卖出日整日分钟折线图:`{self_json['sell_full_day_line_chart_count']}`",
|
"",
|
"本包只增强人工图片入口,不改变来源全量包的收益口径和 `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()
|