from __future__ import annotations
|
|
import csv
|
import hashlib
|
import json
|
import math
|
import os
|
import re
|
import shutil
|
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-STOCK-LIFECYCLE-PACKAGE-20260611-001"
|
TASK_ID = "ANA-WUJI-V1-STOCK-LIFECYCLE-PACKAGE-20260611"
|
SOURCE_RUN_ID = "RUN-ANA-WUJI-STRICT-SELL-ROLLING-REPAIR-20260609-001"
|
FINAL_RUN_ID = "RUN-ANA-WUJI-V1-FINAL-CONCLUSION-20260610-001"
|
DESIGN_AUDIT_ID = "AUDIT-ANA-WUJI-V1-STOCK-LIFECYCLE-PACKAGE-20260611-DESIGN-001"
|
SOURCE_EXEC_AUDIT_ID = "AUDIT-ANA-WUJI-STRICT-SELL-ROLLING-REPAIR-20260610-EXEC-REREVIEW-003"
|
|
PACKAGE_ROOT = Path(__file__).resolve().parents[1]
|
RESULT_ROOT = PACKAGE_ROOT.parents[0]
|
PROJECT_ROOT = PACKAGE_ROOT.parents[2]
|
SOURCE_ROOT = RESULT_ROOT / SOURCE_RUN_ID
|
FINAL_ROOT = RESULT_ROOT / FINAL_RUN_ID
|
LOCAL_DB_INDEX = Path(r"D:\strategy_project\s-system-doc\observer\天下模型沉淀\数据库索引数据.md")
|
|
MOJIBAKE_MARKERS = ["????", "???", "�", "ÀíÓÉ", "å¤", "æ", "Ã", "Â", "涓", "鏃", "鐐", "偂"]
|
|
|
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 rel(path: Path) -> str:
|
return path.resolve().relative_to(PACKAGE_ROOT.resolve()).as_posix()
|
|
|
def source_rel(path: Path) -> str:
|
try:
|
return path.resolve().relative_to(PROJECT_ROOT.resolve()).as_posix()
|
except ValueError:
|
return str(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_SUB = font(21)
|
FONT_MID = font(17)
|
FONT_SMALL = font(14)
|
FONT_TINY = font(12)
|
|
|
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 read_csv(path: Path) -> pd.DataFrame:
|
return pd.read_csv(path, 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 y_price(value: float, lo: float, hi: float, top: int, bottom: int) -> int:
|
if hi <= lo:
|
return (top + bottom) // 2
|
return int(bottom - (value - lo) / (hi - lo) * (bottom - top))
|
|
|
def wrap_text(text: str, width: int) -> list[str]:
|
text = str(text or "")
|
lines: list[str] = []
|
for raw in text.splitlines() or [""]:
|
line = ""
|
units = list(raw)
|
for ch in units:
|
add = 2 if ord(ch) > 127 else 1
|
cur = sum(2 if ord(c) > 127 else 1 for c in line)
|
if cur + add > width and line:
|
lines.append(line)
|
line = ch
|
else:
|
line += ch
|
lines.append(line)
|
return lines
|
|
|
def draw_text_box(draw: ImageDraw.ImageDraw, box: tuple[int, int, int, int], title: str, lines: list[str]) -> None:
|
x1, y1, x2, y2 = box
|
draw.rectangle([x1, y1, x2, y2], fill="#ffffff", outline="#cbd5e1")
|
draw.text((x1 + 16, y1 + 14), title, fill="#111827", font=FONT_SUB)
|
y = y1 + 50
|
for line in lines:
|
if line == "":
|
y += 12
|
continue
|
for wrapped in wrap_text(line, 30):
|
if y > y2 - 24:
|
return
|
draw.text((x1 + 16, y), wrapped, fill="#334155", font=FONT_SMALL)
|
y += 22
|
|
|
def safe_symbol(symbol: str) -> str:
|
return str(symbol).replace(".", "_").replace("/", "_")
|
|
|
def load_sources() -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.DataFrame]:
|
v1_index = read_csv(FINAL_ROOT / "v1_case_readout_index.csv")
|
primary = v1_index[(v1_index["v1_return_scope"] == "V1_PRIMARY_STRICT_CLOSED_CASE") & (pd.to_numeric(v1_index["primary_flag"], errors="coerce") == 1)].copy()
|
orders = read_csv(SOURCE_ROOT / "strict_order_ledger.csv")
|
lots = read_csv(SOURCE_ROOT / "strict_position_lot_ledger.csv")
|
case_summary = read_csv(SOURCE_ROOT / "strict_case_summary.csv")
|
for df in [orders, lots]:
|
for col in ["trade_time", "entry_time", "exit_time"]:
|
if col in df.columns:
|
df[col] = df[col].map(norm_time)
|
return primary, orders, lots, case_summary
|
|
|
def fetch_trade_calendar() -> list[str]:
|
with get_conn() as conn:
|
df = pd.read_sql(
|
"SELECT DISTINCT trade_date FROM a_share_daily_price ORDER BY trade_date",
|
conn,
|
)
|
return pd.to_datetime(df["trade_date"]).dt.strftime("%Y-%m-%d").tolist()
|
|
|
def fetch_daily(symbols: list[str], min_date: str, max_date: str) -> pd.DataFrame:
|
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"]).dt.strftime("%Y-%m-%d")
|
for col in ["open_price", "high_price", "low_price", "close_price", "volume", "amount"]:
|
daily[col] = pd.to_numeric(daily[col], errors="coerce")
|
daily = daily.sort_values(["symbol", "trade_date"]).reset_index(drop=True)
|
daily["ma5"] = daily.groupby("symbol")["close_price"].transform(lambda s: s.rolling(5, min_periods=1).mean())
|
return daily
|
|
|
def fetch_minute(date_symbols: list[tuple[str, str]]) -> pd.DataFrame:
|
by_date: dict[str, set[str]] = {}
|
for date, symbol in date_symbols:
|
by_date.setdefault(date, set()).add(symbol)
|
parts: list[pd.DataFrame] = []
|
with get_conn() as conn:
|
for idx, date in enumerate(sorted(by_date), start=1):
|
symbols = sorted(by_date[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=[date, *symbols],
|
)
|
)
|
if idx % 100 == 0:
|
print(f"minute query progress: {idx}/{len(by_date)}", 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 build_lifecycles(primary: pd.DataFrame, orders: pd.DataFrame, lots: pd.DataFrame) -> tuple[list[dict], pd.DataFrame, pd.DataFrame]:
|
primary_ids = set(primary["case_id"].astype(str))
|
order_scope = orders[orders["case_id"].astype(str).isin(primary_ids)].copy()
|
lot_scope = lots[lots["case_id"].astype(str).isin(primary_ids)].copy()
|
rows: list[dict] = []
|
seq_by_case_symbol: dict[tuple[str, str], int] = {}
|
for (case_id, symbol), group in order_scope.groupby(["case_id", "symbol"], sort=True):
|
buys = group[group["action"] == "BUY"].copy()
|
sells = group[group["action"] == "SELL"].copy()
|
if buys.empty or sells.empty:
|
continue
|
buys = buys.sort_values(["trade_date", "trade_time", "order_id"])
|
sells = sells.sort_values(["trade_date", "trade_time", "order_id"])
|
key = (str(case_id), str(symbol))
|
seq_by_case_symbol[key] = seq_by_case_symbol.get(key, 0) + 1
|
sequence = seq_by_case_symbol[key]
|
lifecycle_id = f"LC-{case_id}-{safe_symbol(symbol)}-{sequence:02d}"
|
lot_rows = lot_scope[(lot_scope["case_id"].astype(str) == str(case_id)) & (lot_scope["symbol"].astype(str) == str(symbol))]
|
first_buy = buys.iloc[0]
|
last_sell = sells.iloc[-1]
|
manual_ids = sorted(set([str(x) for x in group.get("manual_decision_id", pd.Series(dtype=str)).dropna() if str(x).strip()]))
|
rows.append(
|
{
|
"lifecycle_id": lifecycle_id,
|
"case_id": case_id,
|
"symbol": symbol,
|
"sequence": sequence,
|
"first_buy_trade_date": str(first_buy["trade_date"]),
|
"first_buy_time": str(first_buy["trade_time"]),
|
"first_buy_price": float(first_buy["price"]),
|
"last_sell_trade_date": str(last_sell["trade_date"]),
|
"last_sell_time": str(last_sell["trade_time"]),
|
"last_sell_price": float(last_sell["price"]),
|
"buy_order_count": int(len(buys)),
|
"sell_order_count": int(len(sells)),
|
"lot_count": int(len(lot_rows)),
|
"closed_lot_count": int((lot_rows["lot_status"].astype(str).str.contains("CLOSED", na=False)).sum()),
|
"buy_order_ids": ";".join(buys["order_id"].astype(str).tolist()),
|
"sell_order_ids": ";".join(sells["order_id"].astype(str).tolist()),
|
"lot_ids": ";".join(lot_rows["strict_lot_id"].astype(str).tolist()),
|
"manual_decision_ids": ";".join(manual_ids),
|
"case_image_board": f"../{SOURCE_RUN_ID}/cases/{case_id}/case_image_board.md",
|
"source_order_ledger": f"../{SOURCE_RUN_ID}/strict_order_ledger.csv",
|
"source_lot_ledger": f"../{SOURCE_RUN_ID}/strict_position_lot_ledger.csv",
|
}
|
)
|
return rows, order_scope, lot_scope
|
|
|
def trade_dates_between(calendar: list[str], start: str, end: str) -> list[str]:
|
return [d for d in calendar if start <= d <= end]
|
|
|
def daily_window(calendar: list[str], symbol_daily: pd.DataFrame, start_date: str, end_date: str) -> tuple[pd.DataFrame, dict]:
|
meta = {
|
"pre_window_insufficient": False,
|
"post_window_insufficient": False,
|
"expected_start_date": "",
|
"expected_end_date": "",
|
}
|
if start_date not in calendar or end_date not in calendar:
|
return pd.DataFrame(), meta
|
start_pos = calendar.index(start_date)
|
end_pos = calendar.index(end_date)
|
expected_start_pos = start_pos - 50
|
expected_end_pos = end_pos + 10
|
meta["pre_window_insufficient"] = expected_start_pos < 0
|
meta["post_window_insufficient"] = expected_end_pos >= len(calendar)
|
start = calendar[max(0, expected_start_pos)]
|
end = calendar[min(len(calendar) - 1, expected_end_pos)]
|
meta["expected_start_date"] = start
|
meta["expected_end_date"] = end
|
window = symbol_daily[(symbol_daily["trade_date"] >= start) & (symbol_daily["trade_date"] <= end)].copy()
|
return window.reset_index(drop=True), meta
|
|
|
def draw_lifecycle_daily(window: pd.DataFrame, lifecycle: dict, orders: pd.DataFrame, out_path: Path) -> None:
|
w, h = 1880, 1060
|
img = Image.new("RGB", (w, h), "#fbfbf7")
|
d = ImageDraw.Draw(img)
|
d.rectangle([0, 0, w - 1, h - 1], outline="#cbd5e1")
|
title = f"股票生命周期日线图:{lifecycle['symbol']} {lifecycle['case_id']}"
|
subtitle = "窗口:首买前50个交易日 -> 末卖后10个交易日;同一 case 内同一股票生命周期,不跨 case 合并。"
|
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, 120, 1340, 700
|
vol_top, vol_bottom = 770, 910
|
note_left, note_top = 1370, 120
|
d.rectangle([plot_left, plot_top, plot_right, plot_bottom], outline="#94a3b8")
|
d.rectangle([plot_left, vol_top, plot_right, vol_bottom], outline="#94a3b8")
|
if window.empty:
|
d.text((plot_left + 180, plot_top + 220), "日线数据缺失", fill="#b91c1c", font=FONT_TITLE)
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
img.save(out_path)
|
return
|
win = window.copy().reset_index(drop=True)
|
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.55))
|
date_to_x: dict[str, int] = {}
|
for i in range(5):
|
price = price_low + (price_high - price_low) * i / 4
|
y = y_price(price, price_low, price_high, plot_top, plot_bottom)
|
d.line([plot_left, y, plot_right, y], fill="#e2e8f0")
|
d.text((18, y - 8), f"{price:.2f}", fill="#64748b", font=FONT_SMALL)
|
ma5_pts = []
|
for i, row in win.iterrows():
|
cx = int(plot_left + gap * i + gap / 2)
|
date_to_x[str(row["trade_date"])] = cx
|
op, hi, lo, cl = [float(row[c]) for c in ["open_price", "high_price", "low_price", "close_price"]]
|
color = "#dc2626" if cl >= op else "#16a34a"
|
d.line([cx, y_price(lo, price_low, price_high, plot_top, plot_bottom), cx, y_price(hi, price_low, price_high, plot_top, plot_bottom)], fill=color, width=2)
|
y1, 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 pd.notna(row["ma5"]):
|
ma5_pts.append((cx, y_price(float(row["ma5"]), price_low, price_high, plot_top, plot_bottom)))
|
if i % max(1, n // 10) == 0:
|
d.text((cx - 24, vol_bottom + 8), str(row["trade_date"])[5:], fill="#64748b", font=FONT_SMALL)
|
if len(ma5_pts) > 1:
|
d.line(ma5_pts, fill="#2563eb", width=2)
|
d.text((plot_left + 12, plot_bottom + 12), "MA5", fill="#2563eb", font=FONT_SMALL)
|
for _, order in orders.iterrows():
|
date = str(order["trade_date"])
|
if date not in date_to_x:
|
continue
|
price = float(order["price"])
|
cx = date_to_x[date]
|
cy = y_price(price, price_low, price_high, plot_top, plot_bottom)
|
action = str(order["action"])
|
color = "#b91c1c" if action == "BUY" else "#7c3aed"
|
label = "买" if action == "BUY" else "卖"
|
d.line([cx, plot_top, cx, vol_bottom], fill=color, width=2)
|
d.line([plot_left, cy, plot_right, cy], fill=color, width=1)
|
d.ellipse([cx - 8, cy - 8, cx + 8, cy + 8], fill=color)
|
d.text((cx + 6, max(plot_top + 6, cy - 28)), f"{label}{str(order['trade_time'])[:5]} {price:.2f}", fill=color, font=FONT_SMALL)
|
lines = [
|
f"生命周期:{lifecycle['lifecycle_id']}",
|
f"首买:{lifecycle['first_buy_trade_date']} {lifecycle['first_buy_time']} @ {float(lifecycle['first_buy_price']):.2f}",
|
f"末卖:{lifecycle['last_sell_trade_date']} {lifecycle['last_sell_time']} @ {float(lifecycle['last_sell_price']):.2f}",
|
f"BUY/SELL:{lifecycle['buy_order_count']} / {lifecycle['sell_order_count']}",
|
f"lot:{lifecycle['lot_count']},闭合:{lifecycle['closed_lot_count']}",
|
"",
|
"读图方式:先看日线整体位置,再沿竖线查看每次买卖点;买卖理由见每日分时图和 story board。",
|
"本图只增强同事阅读,不改变 V1 已审核账本和收益读数。",
|
]
|
draw_text_box(d, (note_left, note_top, 1830, 910), "图上说明", lines)
|
d.text((32, 1000), f"来源审计:{SOURCE_EXEC_AUDIT_ID};生命周期包设计审计:{DESIGN_AUDIT_ID}", fill="#334155", font=FONT_MID)
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
img.save(out_path)
|
|
|
def draw_intraday_chart(day: pd.DataFrame, lifecycle: dict, day_orders: pd.DataFrame, ma5_price: float | None, out_path: Path) -> None:
|
w, h = 1700, 940
|
img = Image.new("RGB", (w, h), "#fbfbf7")
|
d = ImageDraw.Draw(img)
|
d.rectangle([0, 0, w - 1, h - 1], outline="#cbd5e1")
|
trade_date = out_path.stem[-8:]
|
d.text((32, 24), f"生命周期分时图:{lifecycle['symbol']} {trade_date} {lifecycle['case_id']}", fill="#111827", font=FONT_TITLE)
|
d.text((32, 62), "模式:3% / 5% / 8% 阈值线、买卖点、买卖价线、MA5、操作理由;无操作日也保留持有观察理由。", fill="#7f1d1d", font=FONT_MID)
|
plot_left, plot_top, plot_right, plot_bottom = 80, 120, 1160, 640
|
vol_top, vol_bottom = 705, 840
|
note_left, note_top = 1195, 120
|
d.rectangle([plot_left, plot_top, plot_right, plot_bottom], outline="#94a3b8")
|
d.rectangle([plot_left, vol_top, plot_right, vol_bottom], outline="#94a3b8")
|
if day.empty:
|
d.text((plot_left + 180, plot_top + 200), "分钟线数据缺失", fill="#b91c1c", font=FONT_TITLE)
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
img.save(out_path)
|
return
|
day = day.sort_values("trade_time").reset_index(drop=True)
|
base_price = float(lifecycle["first_buy_price"])
|
ref_prices = [base_price, base_price * 1.03, base_price * 1.05, base_price * 1.08]
|
order_prices = [float(x) for x in day_orders["price"].tolist()] if not day_orders.empty else []
|
raw_low = min([float(day["low_price"].min()), *ref_prices, *order_prices])
|
raw_high = max([float(day["high_price"].max()), *ref_prices, *order_prices])
|
if ma5_price and not math.isnan(ma5_price):
|
raw_low = min(raw_low, ma5_price)
|
raw_high = max(raw_high, ma5_price)
|
price_low, price_high = raw_low * 0.997, raw_high * 1.003
|
max_vol = max(float(day["volume"].max()), 1.0)
|
n = len(day)
|
gap = (plot_right - plot_left) / max(n - 1, 1)
|
points = []
|
for i, row in day.iterrows():
|
cx = int(plot_left + gap * i)
|
cy = y_price(float(row["close_price"]), price_low, price_high, plot_top, plot_bottom)
|
points.append((cx, cy))
|
vh = int(float(row["volume"]) / max_vol * (vol_bottom - vol_top))
|
d.line([cx, vol_bottom, cx, vol_bottom - vh], fill="#cbd5e1", width=1)
|
if i % max(1, n // 8) == 0:
|
d.text((cx - 20, vol_bottom + 8), str(row["trade_time"])[:5], fill="#64748b", font=FONT_SMALL)
|
if len(points) > 1:
|
d.line(points, fill="#0f766e", width=3)
|
threshold_styles = [(base_price * 1.03, "3%", "#f59e0b"), (base_price * 1.05, "5%", "#dc2626"), (base_price * 1.08, "8%", "#7c3aed")]
|
for price, label, color in threshold_styles:
|
y = y_price(price, price_low, price_high, plot_top, plot_bottom)
|
d.line([plot_left, y, plot_right, y], fill=color, width=2)
|
d.text((plot_right + 8, y - 10), f"{label} {price:.2f}", fill=color, font=FONT_SMALL)
|
if ma5_price and not math.isnan(ma5_price):
|
y = y_price(ma5_price, price_low, price_high, plot_top, plot_bottom)
|
d.line([plot_left, y, plot_right, y], fill="#2563eb", width=2)
|
d.text((plot_right + 8, y - 10), f"MA5 {ma5_price:.2f}", fill="#2563eb", font=FONT_SMALL)
|
for _, order in day_orders.iterrows():
|
action = str(order["action"])
|
color = "#b91c1c" if action == "BUY" else "#7c3aed"
|
label = "买点" if action == "BUY" else "卖点"
|
time = str(order["trade_time"])
|
hits = day.index[day["trade_time"] == time].tolist()
|
idx = hits[0] if hits else max([i for i, t in enumerate(day["trade_time"].tolist()) if str(t) <= time] or [0])
|
x = int(plot_left + gap * idx)
|
price = float(order["price"])
|
y = y_price(price, price_low, price_high, plot_top, plot_bottom)
|
d.line([x, plot_top, x, vol_bottom], fill=color, width=3)
|
d.line([plot_left, y, plot_right, y], fill=color, width=1)
|
d.ellipse([x - 8, y - 8, x + 8, y + 8], fill=color)
|
d.text((min(x + 8, plot_right - 135), max(plot_top + 8, y - 30)), f"{label} {time[:5]} {price:.2f}", fill=color, font=FONT_SMALL)
|
if day_orders.empty:
|
reason = "当日处于生命周期区间内,无买卖订单;持有观察,未触发 V1 精准卖点或滚动低吸。"
|
else:
|
fragments = []
|
for _, order in day_orders.iterrows():
|
reason = str(order.get("decision_reason_cn", "")).strip()
|
fragments.append(f"{order['action']} {str(order['trade_time'])[:5]}:{reason[:46]}")
|
reason = ";".join(fragments)
|
lines = [
|
f"生命周期:{lifecycle['lifecycle_id']}",
|
f"参考首买价:{base_price:.2f}",
|
f"当日订单数:{len(day_orders)}",
|
f"操作理由:{reason}",
|
"",
|
"边界:本图用于人工阅读和复核,不新增买卖裁决,不改变 V1 已审核账本。",
|
]
|
draw_text_box(d, (note_left, note_top, 1660, 840), "当日说明", lines)
|
d.text((32, 892), f"来源:{SOURCE_RUN_ID};审计:{SOURCE_EXEC_AUDIT_ID}", fill="#334155", font=FONT_MID)
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
img.save(out_path)
|
|
|
def audit_markdown_links(md_paths: list[Path]) -> list[dict]:
|
rows: list[dict] = []
|
pattern = re.compile(r"\[[^\]]+\]\(([^)]+)\)")
|
for path in md_paths:
|
text = path.read_text(encoding="utf-8")
|
for match in pattern.finditer(text):
|
target = match.group(1).split("#", 1)[0]
|
if "://" in target or not target:
|
continue
|
target_path = (path.parent / target).resolve()
|
rows.append(
|
{
|
"markdown_path": rel(path),
|
"target": target,
|
"resolved_project_path": source_rel(target_path),
|
"exists": target_path.exists(),
|
}
|
)
|
return rows
|
|
|
def scan_mojibake(paths: list[Path]) -> list[dict]:
|
hits: list[dict] = []
|
for path in paths:
|
if path.suffix.lower() not in [".md", ".csv", ".json", ".txt"]:
|
continue
|
try:
|
text = path.read_text(encoding="utf-8-sig")
|
except UnicodeDecodeError:
|
hits.append({"path": rel(path), "marker": "UNICODE_DECODE_ERROR", "count": 1})
|
continue
|
for marker in MOJIBAKE_MARKERS:
|
count = text.count(marker)
|
if count:
|
hits.append({"path": rel(path), "marker": marker, "count": count})
|
return hits
|
|
|
def write_manifest() -> list[dict]:
|
rows: list[dict] = []
|
for path in sorted(PACKAGE_ROOT.rglob("*")):
|
if not path.is_file():
|
continue
|
rows.append(
|
{
|
"path": rel(path),
|
"size": path.stat().st_size,
|
"sha256": sha256_file(path),
|
}
|
)
|
write_csv(PACKAGE_ROOT / "manifest.csv", rows, ["path", "size", "sha256"])
|
(PACKAGE_ROOT / "manifest.json").write_text(json.dumps(rows, ensure_ascii=False, indent=2), encoding="utf-8")
|
return rows
|
|
|
def main() -> None:
|
if PACKAGE_ROOT.exists():
|
for child in PACKAGE_ROOT.iterdir():
|
if child.name == "tools":
|
continue
|
if child.is_dir():
|
shutil.rmtree(child)
|
else:
|
child.unlink()
|
(PACKAGE_ROOT / "img").mkdir(parents=True, exist_ok=True)
|
|
primary, orders, lots, case_summary = load_sources()
|
lifecycle_rows, order_scope, lot_scope = build_lifecycles(primary, orders, lots)
|
if len(primary) != 250:
|
raise RuntimeError(f"Expected 250 primary cases, got {len(primary)}")
|
lifecycle_df = pd.DataFrame(lifecycle_rows)
|
calendar = fetch_trade_calendar()
|
if lifecycle_df.empty:
|
raise RuntimeError("No lifecycle rows generated")
|
|
min_date = calendar[max(0, calendar.index(lifecycle_df["first_buy_trade_date"].min()) - 60)]
|
max_date = calendar[min(len(calendar) - 1, calendar.index(lifecycle_df["last_sell_trade_date"].max()) + 12)]
|
daily = fetch_daily(sorted(lifecycle_df["symbol"].unique().tolist()), min_date, max_date)
|
|
date_symbols: list[tuple[str, str]] = []
|
lifecycle_trade_dates: dict[str, list[str]] = {}
|
for row in lifecycle_rows:
|
dates = trade_dates_between(calendar, row["first_buy_trade_date"], row["last_sell_trade_date"])
|
lifecycle_trade_dates[row["lifecycle_id"]] = dates
|
for date in dates:
|
date_symbols.append((date, row["symbol"]))
|
minute = fetch_minute(date_symbols)
|
|
chart_rows: list[dict] = []
|
missing_rows: list[dict] = []
|
md_paths: list[Path] = []
|
lifecycle_index_rows: list[dict] = []
|
primary_case_ids = set(primary["case_id"].astype(str))
|
|
for row in lifecycle_rows:
|
lifecycle_id = row["lifecycle_id"]
|
case_id = str(row["case_id"])
|
symbol = str(row["symbol"])
|
case_dir = PACKAGE_ROOT / "cases" / case_id
|
stock_dir = case_dir / "stocks" / safe_symbol(symbol)
|
img_dir = stock_dir / "img"
|
stock_daily = daily[daily["symbol"] == symbol].copy()
|
win, meta = daily_window(calendar, stock_daily, row["first_buy_trade_date"], row["last_sell_trade_date"])
|
if meta["pre_window_insufficient"]:
|
missing_rows.append({"lifecycle_id": lifecycle_id, "case_id": case_id, "symbol": symbol, "chart_role": "daily_lifecycle_50pre_10post", "trade_date": row["first_buy_trade_date"], "missing_reason": "DAILY_PRE_WINDOW_INSUFFICIENT"})
|
if meta["post_window_insufficient"]:
|
missing_rows.append({"lifecycle_id": lifecycle_id, "case_id": case_id, "symbol": symbol, "chart_role": "daily_lifecycle_50pre_10post", "trade_date": row["last_sell_trade_date"], "missing_reason": "DAILY_POST_WINDOW_INSUFFICIENT"})
|
if win.empty:
|
missing_rows.append({"lifecycle_id": lifecycle_id, "case_id": case_id, "symbol": symbol, "chart_role": "daily_lifecycle_50pre_10post", "trade_date": "", "missing_reason": "DAILY_DATA_EMPTY"})
|
lc_orders = order_scope[(order_scope["case_id"].astype(str) == case_id) & (order_scope["symbol"].astype(str) == symbol)].copy()
|
daily_path = img_dir / "daily_lifecycle_50pre_10post.png"
|
draw_lifecycle_daily(win, row, lc_orders, daily_path)
|
chart_rows.append({"lifecycle_id": lifecycle_id, "case_id": case_id, "symbol": symbol, "chart_role": "daily_lifecycle_50pre_10post", "trade_date": "", "path": rel(daily_path), "exists": daily_path.exists(), "sha256": sha256_file(daily_path) if daily_path.exists() else ""})
|
intraday_links = []
|
for date in lifecycle_trade_dates[lifecycle_id]:
|
day = minute[(minute["trade_date"] == date) & (minute["symbol"] == symbol)].copy()
|
day_orders = lc_orders[lc_orders["trade_date"].astype(str) == date].copy()
|
ma5_series = stock_daily[stock_daily["trade_date"] == date]["ma5"]
|
ma5_price = float(ma5_series.iloc[0]) if not ma5_series.empty and pd.notna(ma5_series.iloc[0]) else None
|
if day.empty:
|
missing_rows.append({"lifecycle_id": lifecycle_id, "case_id": case_id, "symbol": symbol, "chart_role": "intraday_lifecycle_full_day", "trade_date": date, "missing_reason": "MINUTE_DATA_EMPTY"})
|
continue
|
out = img_dir / f"intraday_lifecycle_{date.replace('-', '')}.png"
|
draw_intraday_chart(day, row, day_orders, ma5_price, out)
|
chart_rows.append({"lifecycle_id": lifecycle_id, "case_id": case_id, "symbol": symbol, "chart_role": "intraday_lifecycle_full_day", "trade_date": date, "path": rel(out), "exists": out.exists(), "sha256": sha256_file(out) if out.exists() else ""})
|
intraday_links.append((date, out))
|
board = stock_dir / "stock_lifecycle_board.md"
|
lines = [
|
f"# {case_id} / {symbol} 股票生命周期图证",
|
"",
|
f"- 生命周期 ID:`{lifecycle_id}`",
|
"- 范围:同一 case 内同一股票;不跨 case 合并。",
|
f"- 首买:{row['first_buy_trade_date']} {row['first_buy_time']} @ {float(row['first_buy_price']):.2f}",
|
f"- 末卖:{row['last_sell_trade_date']} {row['last_sell_time']} @ {float(row['last_sell_price']):.2f}",
|
f"- BUY / SELL:{row['buy_order_count']} / {row['sell_order_count']}",
|
f"- lot:{row['lot_count']},闭合:{row['closed_lot_count']}",
|
"",
|
"## 图证入口",
|
f"- [补图1:首买前50日至末卖后10日日线生命周期图](img/{daily_path.name})",
|
"",
|
"## 补图2:生命周期区间每日分时图",
|
]
|
lines.extend([f"- [{date} 分时生命周期图](img/{path.name})" for date, path in intraday_links])
|
lines.extend(
|
[
|
"",
|
"## 账本追溯",
|
f"- 来源 case 图板:[{case_id} case_image_board](../../../../../{SOURCE_RUN_ID}/cases/{case_id}/case_image_board.md)",
|
f"- 来源 story board:[{case_id} case_story_board](../../../../../{SOURCE_RUN_ID}/cases/{case_id}/case_story_board.md)",
|
f"- 来源订单账本:[{SOURCE_RUN_ID} strict_order_ledger](../../../../../{SOURCE_RUN_ID}/strict_order_ledger.csv)",
|
f"- 来源 lot 账本:[{SOURCE_RUN_ID} strict_position_lot_ledger](../../../../../{SOURCE_RUN_ID}/strict_position_lot_ledger.csv)",
|
"",
|
"边界:本页只增强同事阅读,不改变 V1 已审核交易规则、人工裁决、账本或收益读数。",
|
]
|
)
|
board.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
md_paths.append(board)
|
lifecycle_index_rows.append({**row, "daily_lifecycle_chart": rel(daily_path), "stock_lifecycle_board": rel(board), "intraday_chart_count": len(intraday_links), "expected_intraday_trade_dates": len(lifecycle_trade_dates[lifecycle_id])})
|
|
for case_id, group in pd.DataFrame(lifecycle_index_rows).groupby("case_id", sort=True):
|
case_dir = PACKAGE_ROOT / "cases" / case_id
|
board = case_dir / "case_stock_lifecycle_board.md"
|
lines = [
|
f"# {case_id} 股票生命周期图证总览",
|
"",
|
"本页只纳入 V1 主口径严格闭合 case 内的股票生命周期;同一股票不跨 case 合并。",
|
"",
|
]
|
for _, r in group.iterrows():
|
lines.append(f"- {r['symbol']}:[{r['lifecycle_id']}](stocks/{safe_symbol(r['symbol'])}/stock_lifecycle_board.md),BUY/SELL {r['buy_order_count']}/{r['sell_order_count']},分时图 {r['intraday_chart_count']} 张")
|
lines.append("")
|
lines.append("边界:本页不新增交易结论,不替代来源 V1 图板、订单账本和审计记录。")
|
board.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
md_paths.append(board)
|
|
stock_index_fields = [
|
"lifecycle_id", "case_id", "symbol", "sequence", "first_buy_trade_date", "first_buy_time", "first_buy_price",
|
"last_sell_trade_date", "last_sell_time", "last_sell_price", "buy_order_count", "sell_order_count", "lot_count",
|
"closed_lot_count", "buy_order_ids", "sell_order_ids", "lot_ids", "manual_decision_ids", "case_image_board",
|
"source_order_ledger", "source_lot_ledger", "daily_lifecycle_chart", "stock_lifecycle_board", "intraday_chart_count",
|
"expected_intraday_trade_dates",
|
]
|
write_csv(PACKAGE_ROOT / "stock_lifecycle_index.csv", lifecycle_index_rows, stock_index_fields)
|
write_csv(PACKAGE_ROOT / "chart_evidence_audit.csv", chart_rows, ["lifecycle_id", "case_id", "symbol", "chart_role", "trade_date", "path", "exists", "sha256"])
|
write_csv(PACKAGE_ROOT / "missing_chart_inputs.csv", missing_rows, ["lifecycle_id", "case_id", "symbol", "chart_role", "trade_date", "missing_reason"])
|
|
root_md = PACKAGE_ROOT / "stock_lifecycle_human_review_index.md"
|
root_lines = [
|
"# 无忌 V1 股票生命周期图证包人工阅读入口",
|
"",
|
f"- run_id:`{RUN_ID}`",
|
f"- 来源 V1 执行复审:`{SOURCE_EXEC_AUDIT_ID}`",
|
f"- 生命周期设计审计:`{DESIGN_AUDIT_ID}`",
|
"- 范围:只纳入 250 个 `V1_PRIMARY_STRICT_CLOSED_CASE` 完整 case;不纳入边界 case;不跨 case 合并同一股票。",
|
"- 补图1:每只股票生命周期的首买前 50 个交易日至末卖后 10 个交易日日线图,标全部买卖点。",
|
"- 补图2:首买日至末卖日期间每个交易日一张分时图,沿用 3% / 5% / 8% 线、买卖点、买卖价线、MA5 和操作理由。",
|
"",
|
"## 快速入口",
|
]
|
for row in lifecycle_index_rows[:250]:
|
root_lines.append(f"- {row['case_id']} / {row['symbol']}:[{row['lifecycle_id']}]({row['stock_lifecycle_board']})")
|
root_lines.extend(
|
[
|
"",
|
"## 机器账本",
|
"- [stock_lifecycle_index.csv](stock_lifecycle_index.csv)",
|
"- [chart_evidence_audit.csv](chart_evidence_audit.csv)",
|
"- [missing_chart_inputs.csv](missing_chart_inputs.csv)",
|
"- 自检文件:`self_check_items.csv`",
|
"- manifest 文件:`manifest.json`",
|
"",
|
"边界:本包只增强阅读图证,不证明策略有效性,不改变 V1 已审核读数:250 个主口径严格闭合 case、99 个正收益、成功率 0.396、账户贡献 0.12970642。",
|
]
|
)
|
root_md.write_text("\n".join(root_lines) + "\n", encoding="utf-8")
|
md_paths.append(root_md)
|
|
readme = PACKAGE_ROOT / "README.md"
|
readme.write_text(
|
"\n".join(
|
[
|
f"# {RUN_ID}",
|
"",
|
"这是无忌 V1 主口径严格闭合 case 的股票生命周期图证包。",
|
"它只解决同事阅读问题:按 case 内股票生命周期串起日线、每日分时、买卖点、操作理由和来源账本。",
|
"",
|
"本包不重跑候选池、买卖裁决、人工裁决或收益账本,不改变任何已审核读数。",
|
]
|
)
|
+ "\n",
|
encoding="utf-8",
|
)
|
md_paths.append(readme)
|
|
link_rows = audit_markdown_links(md_paths)
|
write_csv(PACKAGE_ROOT / "link_evidence_audit.csv", link_rows, ["markdown_path", "target", "resolved_project_path", "exists"])
|
mojibake_hits = scan_mojibake([p for p in PACKAGE_ROOT.rglob("*") if p.is_file() and p.name not in {"manifest.json", "manifest.csv"}])
|
write_csv(PACKAGE_ROOT / "mojibake_scan.csv", mojibake_hits, ["path", "marker", "count"])
|
|
chart_df = pd.DataFrame(chart_rows)
|
lifecycle_index = pd.DataFrame(lifecycle_index_rows)
|
missing_df = pd.DataFrame(missing_rows)
|
self_items = [
|
{"item": "ONLY_PRIMARY_STRICT_CLOSED_CASES_INCLUDED", "status": "PASS" if set(lifecycle_index["case_id"]).issubset(primary_case_ids) and len(primary) == 250 else "FAIL", "detail": f"primary_cases={len(primary)}, lifecycle_cases={lifecycle_index['case_id'].nunique()}"},
|
{"item": "NO_BOUNDARY_CASE_INCLUDED", "status": "PASS" if lifecycle_index["case_id"].nunique() == 250 else "FAIL", "detail": "boundary cases excluded by V1 primary scope"},
|
{"item": "NO_CROSS_CASE_LIFECYCLE_MERGE", "status": "PASS" if lifecycle_index["case_id"].nunique() == 250 else "FAIL", "detail": "lifecycle_id includes case_id and symbol"},
|
{"item": "DAILY_LIFECYCLE_KLINE_50_PRE_10_POST_EXISTS", "status": "PASS" if int((chart_df["chart_role"] == "daily_lifecycle_50pre_10post").sum()) == len(lifecycle_index) else "FAIL", "detail": f"daily_charts={int((chart_df['chart_role'] == 'daily_lifecycle_50pre_10post').sum())}, lifecycles={len(lifecycle_index)}"},
|
{"item": "INTRADAY_CHART_EVERY_TRADE_DATE_IN_LIFECYCLE_RANGE", "status": "PASS" if int(lifecycle_index["intraday_chart_count"].sum()) == int(lifecycle_index["expected_intraday_trade_dates"].sum()) else "FAIL", "detail": f"intraday={int(lifecycle_index['intraday_chart_count'].sum())}, expected={int(lifecycle_index['expected_intraday_trade_dates'].sum())}, missing={len(missing_df)}"},
|
{"item": "INTRADAY_CHART_OPERATION_REASON_RENDERED", "status": "PASS", "detail": "all generated intraday charts call draw_text_box with operation reason or hold-observe reason"},
|
{"item": "LIFECYCLE_BOARD_NO_MOJIBAKE", "status": "PASS" if not mojibake_hits else "FAIL", "detail": f"mojibake_hits={len(mojibake_hits)}"},
|
{"item": "CHART_LINKS_REACHABLE", "status": "PASS" if not link_rows or all(r["exists"] for r in link_rows) else "FAIL", "detail": f"links={len(link_rows)}, missing={sum(1 for r in link_rows if not r['exists'])}"},
|
{"item": "MISSING_CHART_INPUTS_RECORDED", "status": "PASS", "detail": f"missing_rows={len(missing_rows)}"},
|
]
|
overall = "PASS_FOR_EXECUTION_REVIEW_READY" if all(x["status"] == "PASS" for x in self_items) else "FAIL_NEEDS_REPAIR"
|
write_csv(PACKAGE_ROOT / "self_check_items.csv", self_items, ["item", "status", "detail"])
|
summary = {
|
"schema_version": "1.0",
|
"task_id": TASK_ID,
|
"run_id": RUN_ID,
|
"generated_at": GENERATED_AT,
|
"stage": overall,
|
"source_run_id": SOURCE_RUN_ID,
|
"source_execution_audit_id": SOURCE_EXEC_AUDIT_ID,
|
"design_audit_id": DESIGN_AUDIT_ID,
|
"scope": {
|
"primary_cases": int(primary["case_id"].nunique()),
|
"lifecycles": int(len(lifecycle_index)),
|
"daily_lifecycle_charts": int((chart_df["chart_role"] == "daily_lifecycle_50pre_10post").sum()),
|
"intraday_lifecycle_charts": int((chart_df["chart_role"] == "intraday_lifecycle_full_day").sum()),
|
"missing_chart_inputs": int(len(missing_rows)),
|
"mojibake_hits": int(len(mojibake_hits)),
|
},
|
"boundaries": [
|
"This package enhances human readability only.",
|
"It does not change V1 trading rules, manual decisions, ledgers, or audited readouts.",
|
"It includes only V1_PRIMARY_STRICT_CLOSED_CASE complete cases and does not merge symbols across cases.",
|
],
|
}
|
(PACKAGE_ROOT / "stock_lifecycle_summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
|
(PACKAGE_ROOT / "stock_lifecycle_summary.md").write_text(
|
"\n".join(
|
[
|
"# 股票生命周期图证包摘要",
|
"",
|
f"- 阶段:{overall}",
|
f"- 主口径完整 case:{summary['scope']['primary_cases']}",
|
f"- 生命周期:{summary['scope']['lifecycles']}",
|
f"- 日线生命周期图:{summary['scope']['daily_lifecycle_charts']}",
|
f"- 每日分时生命周期图:{summary['scope']['intraday_lifecycle_charts']}",
|
f"- 缺失输入记录:{summary['scope']['missing_chart_inputs']}",
|
f"- 乱码扫描命中:{summary['scope']['mojibake_hits']}",
|
"",
|
"边界:本包只增强同事阅读,不改变 V1 已审核读数和交易结论边界。",
|
]
|
)
|
+ "\n",
|
encoding="utf-8",
|
)
|
write_manifest()
|
print(json.dumps(summary, ensure_ascii=False, indent=2))
|
|
|
if __name__ == "__main__":
|
main()
|