from __future__ import annotations
|
|
import csv
|
import hashlib
|
import json
|
import sys
|
from datetime import datetime, timedelta, timezone
|
from pathlib import Path
|
|
import pandas as pd
|
from PIL import Image, ImageDraw
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
import build_p1_step_review_packets as base # noqa: E402
|
|
|
RUN_ID = "RUN-ANA-WUJI-V1-BUY-POINT-SECOND-REVIEW-20260615-001"
|
SOURCE_RUN_ID = "RUN-ANA-WUJI-V1-STRICT-NOTE-FULL-RERUN-20260614-001"
|
TZ = timezone(timedelta(hours=8))
|
|
ROOT = Path(__file__).resolve().parents[1]
|
PROJECT_ROOT = ROOT.parents[2]
|
SOURCE_ROOT = PROJECT_ROOT / "ana-data" / "result" / SOURCE_RUN_ID
|
PACKET_ROOT = ROOT / "p2_step_review_packets"
|
TABLE_ROOT = PACKET_ROOT / "tables"
|
CHART_ROOT = PACKET_ROOT / "charts"
|
|
|
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 write_csv(df: pd.DataFrame, path: Path) -> None:
|
path.parent.mkdir(parents=True, exist_ok=True)
|
df.to_csv(path, index=False, encoding="utf-8-sig")
|
|
|
def write_text(text: str, path: Path) -> None:
|
path.parent.mkdir(parents=True, exist_ok=True)
|
path.write_text(text, encoding="utf-8-sig")
|
|
|
def as_posix(path: Path | str) -> str:
|
return str(path).replace("\\", "/")
|
|
|
def fmt(value: object, digits: int = 2) -> str:
|
if value is None:
|
return ""
|
text = str(value)
|
if text == "" or text.lower() == "nan":
|
return ""
|
try:
|
return f"{float(text):.{digits}f}"
|
except Exception:
|
return text
|
|
|
def md_table(df: pd.DataFrame, cols: list[str], max_rows: int | None = None) -> list[str]:
|
if df.empty:
|
return ["_无数据_"]
|
existing = [c for c in cols if c in df.columns]
|
if not existing:
|
return ["_无匹配字段_"]
|
part = df[existing].copy()
|
if max_rows is not None:
|
part = part.head(max_rows)
|
lines = ["| " + " | ".join(existing) + " |", "|" + "|".join(["---"] * len(existing)) + "|"]
|
for _, row in part.iterrows():
|
vals = []
|
for c in existing:
|
v = row[c]
|
if isinstance(v, float):
|
vals.append(fmt(v))
|
else:
|
vals.append(str(v).replace("|", "/"))
|
lines.append("| " + " | ".join(vals) + " |")
|
return lines
|
|
|
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 truthy(value: object) -> bool:
|
text = str(value).strip().lower()
|
return text in {"true", "1", "yes"}
|
|
|
def draw_daily_chart(symbol: str, entry_date: str, window: pd.DataFrame, out_path: Path) -> None:
|
width, height = 1500, 820
|
img = Image.new("RGB", (width, height), "#fbfbf7")
|
d = ImageDraw.Draw(img)
|
d.rectangle([0, 0, width - 1, height - 1], outline="#cbd5e1")
|
d.text((30, 22), f"41交易日日线窗口:{symbol} / 买入日 {entry_date}", fill="#111827", font=base.FONT_TITLE)
|
d.text((30, 56), "口径:买入日前20个交易日 + 买入日 + 买入日后20个交易日;紫色竖线为待复核买入日。", fill="#334155", font=base.FONT_SMALL)
|
|
if window.empty:
|
d.text((30, 120), "无日线窗口数据", fill="#991b1b", font=base.FONT_MID)
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
img.save(out_path)
|
return
|
|
plot_left, plot_top, plot_right, plot_bottom = 80, 105, 1420, 560
|
vol_top, vol_bottom = 610, 760
|
d.rectangle([plot_left, plot_top, plot_right, plot_bottom], outline="#94a3b8")
|
d.rectangle([plot_left, vol_top, plot_right, vol_bottom], outline="#94a3b8")
|
|
price_cols = window[["low", "high", "ma5", "ma20"]].apply(pd.to_numeric, errors="coerce")
|
price_low = float(price_cols.min(skipna=True).min()) * 0.98
|
price_high = float(price_cols.max(skipna=True).max()) * 1.02
|
max_vol = max(float(pd.to_numeric(window["volume"], errors="coerce").max()), 1.0)
|
n = len(window)
|
step = (plot_right - plot_left) / max(n, 1)
|
candle_w = max(5, int(step * 0.55))
|
|
ma5_pts: list[tuple[int, int]] = []
|
ma20_pts: list[tuple[int, int]] = []
|
for i, (_, row) in enumerate(window.reset_index(drop=True).iterrows()):
|
x = int(plot_left + step * (i + 0.5))
|
o = float(row["open"])
|
c = float(row["close"])
|
hi = float(row["high"])
|
lo = float(row["low"])
|
color = "#dc2626" if c >= o else "#16a34a"
|
yy_hi = y_price(hi, price_low, price_high, plot_top, plot_bottom)
|
yy_lo = y_price(lo, price_low, price_high, plot_top, plot_bottom)
|
yy_o = y_price(o, price_low, price_high, plot_top, plot_bottom)
|
yy_c = y_price(c, price_low, price_high, plot_top, plot_bottom)
|
d.line([x, yy_hi, x, yy_lo], fill=color, width=2)
|
body_top = min(yy_o, yy_c)
|
body_bottom = max(yy_o, yy_c)
|
if body_bottom == body_top:
|
d.line([x - candle_w // 2, body_top, x + candle_w // 2, body_top], fill=color, width=3)
|
else:
|
d.rectangle([x - candle_w // 2, body_top, x + candle_w // 2, body_bottom], fill=color, outline=color)
|
|
vol_height = int(float(row["volume"]) / max_vol * (vol_bottom - vol_top))
|
d.line([x, vol_bottom, x, vol_bottom - vol_height], fill=color, width=max(2, candle_w // 3))
|
|
if truthy(row["is_entry_day"]):
|
d.line([x, plot_top, x, vol_bottom], fill="#7c3aed", width=2)
|
d.text((x - 35, plot_top - 24), "买入日", fill="#7c3aed", font=base.FONT_SMALL)
|
if truthy(row.get("limitup_hit_flag", False)):
|
d.ellipse([x - 5, yy_hi - 18, x + 5, yy_hi - 8], fill="#f59e0b")
|
if i % 5 == 0 or truthy(row["is_entry_day"]):
|
d.text((x - 28, vol_bottom + 8), str(row["trade_date"])[4:], fill="#64748b", font=base.FONT_SMALL)
|
if pd.notna(row.get("ma5", None)):
|
ma5_pts.append((x, y_price(float(row["ma5"]), price_low, price_high, plot_top, plot_bottom)))
|
if pd.notna(row.get("ma20", None)):
|
ma20_pts.append((x, y_price(float(row["ma20"]), price_low, price_high, plot_top, plot_bottom)))
|
|
if len(ma5_pts) > 1:
|
d.line(ma5_pts, fill="#2563eb", width=2)
|
if len(ma20_pts) > 1:
|
d.line(ma20_pts, fill="#9333ea", width=2)
|
|
d.text((plot_left, 780), "红/绿:日K;蓝线:MA5;紫线:MA20;橙点:前30日真实涨停记忆命中。", fill="#334155", font=base.FONT_SMALL)
|
d.text((plot_right - 210, 80), "MA5", fill="#2563eb", font=base.FONT_SMALL)
|
d.text((plot_right - 160, 80), "MA20", fill="#9333ea", font=base.FONT_SMALL)
|
d.text((plot_right - 100, 80), "涨停记忆", fill="#f59e0b", font=base.FONT_SMALL)
|
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
img.save(out_path)
|
|
|
def initial_tendency(row: pd.Series) -> str:
|
priority = str(row.get("human_recheck_priority", ""))
|
if priority == "P2_HELD_DECISION_WORTH_REVIEW":
|
return "数据提示买点可能被人工保守过滤,建议重看;最终以人工看图裁决为准。"
|
if priority == "P2_BUY_TIME_WINDOW_AMBIGUOUS":
|
return "数据不否定买点,但买入强度可能集中在中段/午后,需确认是否符合普通新仓时间窗。"
|
return "建议重看;最终以人工裁决为准。"
|
|
|
def decision_options(row: pd.Series) -> list[str]:
|
action = str(row["human_decision_action"])
|
if action == "REVIEW_HELD":
|
return [
|
"- [ ] 维持 REVIEW_HELD",
|
"- [ ] 改为 BUY",
|
"- [ ] 数据不足,继续待审",
|
]
|
return [
|
"- [ ] 维持 BUY",
|
"- [ ] 改为 REVIEW_HELD",
|
"- [ ] 数据不足,继续待审",
|
]
|
|
|
def impact_hint(row: pd.Series, candidate_orders: pd.DataFrame, candidate_lots: pd.DataFrame) -> str:
|
action = str(row["human_decision_action"])
|
if action == "REVIEW_HELD":
|
if candidate_orders.empty and candidate_lots.empty:
|
return "源结果包当前没有生成 BUY/order/lot;如果改为 BUY,正式返修时需要新增 BUY 并重算后续卖点、lot、case 和收益。"
|
return "源结果包虽为 REVIEW_HELD 但发现交易记录,正式返修前需要先核对账本一致性。"
|
if not candidate_orders.empty or not candidate_lots.empty:
|
return "源结果包当前已生成 BUY/order/lot;如果改为 REVIEW_HELD,正式返修时需要撤销并重算。"
|
return "源结果包当前未找到交易记录;如果维持 BUY,需要核对为什么没有进入交易链路。"
|
|
|
def build_packet(
|
order_num: int,
|
row: pd.Series,
|
candidates: pd.DataFrame,
|
orders: pd.DataFrame,
|
lots: pd.DataFrame,
|
cases: pd.DataFrame,
|
generated_at: str,
|
) -> dict[str, object]:
|
candidate_id = row["candidate_id"]
|
safe_id = candidate_id.replace(".", "_").replace("/", "_")
|
packet_path = PACKET_ROOT / f"{order_num:02d}_{safe_id}.md"
|
|
cand_rows = candidates[candidates["candidate_id"].eq(candidate_id)]
|
cand = cand_rows.iloc[0] if not cand_rows.empty else pd.Series(dtype=object)
|
candidate_orders = orders[orders["candidate_id"].eq(candidate_id)].copy()
|
candidate_lots = lots[lots["candidate_id"].eq(candidate_id)].copy()
|
case_row = cases[cases["case_id"].eq(row["case_id"])].head(1)
|
|
daily = base.load_daily(row["symbol"])
|
daily_nodes = base.selected_daily_nodes(daily, cand) if not cand.empty else pd.DataFrame()
|
daily_win = base.daily_window(daily, row["entry_trade_date"])
|
centered_win = base.centered_daily_window(daily, row["entry_trade_date"], before=20, after=20)
|
minute = base.load_minute(row["symbol"], row["entry_trade_date"])
|
minute_keys = base.minute_key_points(minute)
|
ma5 = None
|
if "ma5" in row and str(row["ma5"]) not in {"", "nan"}:
|
try:
|
ma5 = float(row["ma5"])
|
except Exception:
|
ma5 = None
|
msum = base.minute_summary(minute, ma5)
|
|
write_csv(daily_nodes, TABLE_ROOT / f"{safe_id}_daily_nodes.csv")
|
write_csv(daily_win, TABLE_ROOT / f"{safe_id}_daily_window.csv")
|
write_csv(centered_win, TABLE_ROOT / f"{safe_id}_daily_center_41.csv")
|
write_csv(minute_keys, TABLE_ROOT / f"{safe_id}_minute_key_points.csv")
|
|
daily_chart_path = CHART_ROOT / f"{safe_id}_daily_center_41.png"
|
draw_daily_chart(row["symbol"], row["entry_trade_date"], centered_win, daily_chart_path)
|
|
image_path = Path(str(row.get("source_review_chart_abs_path", "")))
|
if not str(image_path) or str(image_path).lower() == "nan":
|
image_path = SOURCE_ROOT / str(row.get("review_input_chart_path", ""))
|
|
lines = [
|
f"# P2 买点逐条复核:{row['symbol']} / {row['entry_trade_date']}",
|
"",
|
f"- packet_order: {order_num}",
|
f"- generated_at: {generated_at}",
|
f"- case_id: {row['case_id']}",
|
f"- candidate_id: {candidate_id}",
|
f"- source_run_id: {SOURCE_RUN_ID}",
|
f"- P2 类型: `{row['human_recheck_priority']}`",
|
"",
|
"## 你要裁决",
|
"",
|
*decision_options(row),
|
"",
|
"建议先看:原人工 HELD/BUY 的理由是否和图证一致;再看 10:40 前或 14:40 后是否有清晰承接买点。",
|
"",
|
"## 原人工裁决和二审异议",
|
"",
|
f"- 原人工动作:`{row['human_decision_action']}`",
|
f"- 原人工理由:{row['human_decision_reason_cn']}",
|
f"- 二审异议:`{row['second_review_issue_code']}`,{row['second_review_reason_cn']}",
|
f"- 我的初步倾向:**{initial_tendency(row)}**",
|
"",
|
"## 交易账本影响",
|
"",
|
f"- 处理含义:{impact_hint(row, candidate_orders, candidate_lots)}",
|
"",
|
]
|
|
if candidate_orders.empty:
|
lines.append("_源结果包未找到该 candidate 的订单记录。_")
|
else:
|
lines += md_table(
|
candidate_orders,
|
["order_type", "order_id", "trade_date", "trade_time", "trade_price", "position_pct", "decision_reason_cn"],
|
)
|
lines += ["", "### lot / case 状态", ""]
|
if candidate_lots.empty:
|
lines.append("_源结果包未找到该 candidate 的 lot 记录。_")
|
else:
|
lines += md_table(
|
candidate_lots,
|
["lot_id", "entry_trade_date", "entry_price", "position_pct", "exit_trade_date", "exit_price", "lot_scope_status", "boundary_type"],
|
)
|
if not case_row.empty:
|
lines += ["", "### case 汇总", ""]
|
lines += md_table(
|
case_row,
|
[
|
"case_id",
|
"symbols",
|
"strict_buy_orders",
|
"sell_orders",
|
"strict_closed_lots",
|
"boundary_lots",
|
"net_account_contribution",
|
"case_scope_status",
|
],
|
)
|
|
hard_fields = [
|
"signal_trade_date",
|
"entry_trade_date",
|
"candidate_rank",
|
"market_gate_status",
|
"up_count",
|
"prior_strict_limitup_30_flag",
|
"latest_prior_strict_limitup_date",
|
"volume_ratio",
|
"pullback_from_latest_limitup_close_pct",
|
"upper_shadow_pct",
|
"upper_shadow_range_ratio",
|
"prev60_high",
|
"prev60_high_ref_date",
|
"prev_high_volume_pass_flag",
|
]
|
lines += ["", "## 入池硬条件", "", "| 字段 | 值 |", "|---|---|"]
|
for field in hard_fields:
|
if field in cand.index:
|
lines.append(f"| {field} | {fmt(cand[field])} |")
|
|
image_link = as_posix(image_path)
|
daily_chart_link = as_posix(daily_chart_path.resolve())
|
daily_nodes_link = as_posix((TABLE_ROOT / f"{safe_id}_daily_nodes.csv").resolve())
|
daily_window_link = as_posix((TABLE_ROOT / f"{safe_id}_daily_window.csv").resolve())
|
daily_center_link = as_posix((TABLE_ROOT / f"{safe_id}_daily_center_41.csv").resolve())
|
minute_keys_link = as_posix((TABLE_ROOT / f"{safe_id}_minute_key_points.csv").resolve())
|
|
lines += [
|
"",
|
"## 买点分时图",
|
"",
|
f"",
|
"",
|
"## 买入点前后 20 个交易日的日线窗口",
|
"",
|
f"",
|
"",
|
f"- 窗口 CSV:[{Path(daily_center_link).name}]({daily_center_link})",
|
f"- 实际窗口行数:{len(centered_win)}",
|
"",
|
"### 41 日窗口明细",
|
"",
|
]
|
lines += md_table(
|
centered_win,
|
[
|
"window_offset",
|
"is_entry_day",
|
"trade_date",
|
"open",
|
"high",
|
"low",
|
"close",
|
"ret_pct",
|
"high_vs_prev_close_pct",
|
"volume",
|
"limitup_hit_flag",
|
"ma5",
|
"ma20",
|
],
|
)
|
|
lines += [
|
"",
|
"## 分时复算摘要",
|
"",
|
"| 指标 | 值 | 含义 |",
|
"|---|---:|---|",
|
f"| open_ref | {fmt(msum.get('open_ref', ''))} | 当天 09:30 开盘参考价 |",
|
f"| close_ret_pct | {fmt(msum.get('close_ret_pct', ''))}% | 收盘相对开盘 |",
|
f"| day_max_ret_pct | {fmt(msum.get('day_max_ret_pct', ''))}% | 全天最高相对开盘 |",
|
f"| day_min_ret_pct | {fmt(msum.get('day_min_ret_pct', ''))}% | 全天最低相对开盘 |",
|
f"| above_open_ratio | {fmt(msum.get('above_open_ratio', ''), 4)} | 全天 close 在开盘价上方的分钟占比 |",
|
f"| pre1040_max_ret_pct | {fmt(msum.get('pre1040_max_ret_pct', ''))}% | 10:40 前最高相对开盘 |",
|
f"| pre1040_min_ret_pct | {fmt(msum.get('pre1040_min_ret_pct', ''))}% | 10:40 前最低相对开盘 |",
|
f"| pre1040_above_open_ratio | {fmt(msum.get('pre1040_above_open_ratio', ''), 4)} | 10:40 前在开盘价上方占比 |",
|
f"| tail_max_ret_pct | {fmt(msum.get('tail_max_ret_pct', ''))}% | 14:40 后最高相对开盘 |",
|
f"| tail_min_ret_pct | {fmt(msum.get('tail_min_ret_pct', ''))}% | 14:40 后最低相对开盘 |",
|
f"| tail_above_open_ratio | {fmt(msum.get('tail_above_open_ratio', ''), 4)} | 14:40 后在开盘价上方占比 |",
|
f"| ma5 | {fmt(msum.get('ma5', ''))} | 信号日前 5 日均价,仅作支撑参考 |",
|
f"| above_ma5_ratio | {fmt(msum.get('above_ma5_ratio', ''), 4)} | 全天 close 在 MA5 上方占比 |",
|
"",
|
"## 分时关键点",
|
"",
|
]
|
lines += md_table(
|
minute_keys,
|
["point", "trade_time", "open", "high", "low", "close", "ret_vs_open_pct", "high_vs_open_pct", "low_vs_open_pct", "volume"],
|
)
|
lines += [
|
"",
|
"## 日线关键节点",
|
"",
|
]
|
lines += md_table(
|
daily_nodes,
|
["node_role", "trade_date", "open", "high", "low", "close", "ret_pct", "high_vs_prev_close_pct", "volume", "limitup_hit_flag", "ma5", "ma20"],
|
)
|
lines += [
|
"",
|
"## 数据文件",
|
"",
|
f"- 原图:[{image_path.name}]({image_link})",
|
f"- 日线关键节点 CSV:[{Path(daily_nodes_link).name}]({daily_nodes_link})",
|
f"- 日线窗口 CSV:[{Path(daily_window_link).name}]({daily_window_link})",
|
f"- 买入日前后 20 交易日 CSV:[{Path(daily_center_link).name}]({daily_center_link})",
|
f"- 买入日前后 20 交易日日线图:[{Path(daily_chart_link).name}]({daily_chart_link})",
|
f"- 分时关键点 CSV:[{Path(minute_keys_link).name}]({minute_keys_link})",
|
"",
|
"## 逐条复核问题",
|
"",
|
"1. 10:40 前是否已经出现清晰的冲高后回踩承接,还是只是追高?",
|
"2. 如果主要买点在 14:40 后,尾段是否是真正重新站稳,而不是临近收盘拉高?",
|
"3. 原人工理由中的风险是否足以否定数据强势信号?",
|
"4. 如果从 REVIEW_HELD 改成 BUY,是否需要新增 BUY 并重跑卖点和收益链路?",
|
"",
|
]
|
write_text("\n".join(lines), packet_path)
|
|
return {
|
"order": order_num,
|
"case_id": row["case_id"],
|
"candidate_id": candidate_id,
|
"symbol": row["symbol"],
|
"entry_trade_date": row["entry_trade_date"],
|
"original_action": row["human_decision_action"],
|
"issue_code": row["second_review_issue_code"],
|
"priority": row["human_recheck_priority"],
|
"close_ret_pct": row.get("close_ret_pct", ""),
|
"above_open_ratio": row.get("above_open_ratio", ""),
|
"pre1040_max_ret_pct": msum.get("pre1040_max_ret_pct", ""),
|
"tail_max_ret_pct": msum.get("tail_max_ret_pct", ""),
|
"packet_path": as_posix(packet_path.resolve()),
|
"source_chart": image_link,
|
"daily_chart": daily_chart_link,
|
}
|
|
|
def build_index(index: pd.DataFrame, generated_at: str) -> None:
|
write_csv(index, PACKET_ROOT / "p2_step_review_index.csv")
|
lines = [
|
"# P2 买点逐条复核工作台",
|
"",
|
f"- generated_at: {generated_at}",
|
f"- source_run_id: {SOURCE_RUN_ID}",
|
f"- item_count: {len(index)}",
|
"",
|
"使用方式:按顺序打开每条 packet,先看原图,再看 41 日窗口、分时复算摘要和交易影响;最后在会话里告诉我“第 N 条维持 HELD / 改 BUY / 维持 BUY / 改 HELD / 数据不足”和理由,我来记录并汇总影响。",
|
"",
|
"| # | case | symbol | date | original | issue | close% | above_open | packet |",
|
"|---:|---|---|---|---|---|---:|---:|---|",
|
]
|
for _, r in index.iterrows():
|
lines.append(
|
f"| {r['order']} | {r['case_id']} | {r['symbol']} | {r['entry_trade_date']} | {r['original_action']} | "
|
f"{r['issue_code']} | {fmt(r['close_ret_pct'])} | {fmt(r['above_open_ratio'], 4)} | [打开复核包]({r['packet_path']}) |"
|
)
|
write_text("\n".join(lines) + "\n", PACKET_ROOT / "p2_step_review_index.md")
|
|
|
def ensure_resolution_files(generated_at: str) -> None:
|
ledger = PACKET_ROOT / "p2_step_review_resolution_ledger.csv"
|
if not ledger.exists():
|
columns = [
|
"resolution_time",
|
"packet_order",
|
"case_id",
|
"candidate_id",
|
"symbol",
|
"entry_trade_date",
|
"original_action",
|
"final_action",
|
"resolution_source",
|
"resolution_reason_cn",
|
"impact_note",
|
]
|
write_csv(pd.DataFrame(columns=columns), ledger)
|
|
summary = PACKET_ROOT / "p2_step_review_resolution_summary.md"
|
if not summary.exists():
|
write_text(
|
"\n".join(
|
[
|
"# P2 买点逐条复核裁决汇总",
|
"",
|
f"- updated_at: {generated_at}",
|
"- resolved_count: 0",
|
"- 状态:等待逐条人工裁决。",
|
"",
|
]
|
),
|
summary,
|
)
|
|
|
def write_manifest(generated_at: str) -> None:
|
rows = []
|
for path in sorted(PACKET_ROOT.rglob("*")):
|
if path.is_file():
|
rows.append(
|
{
|
"path": path.relative_to(ROOT).as_posix(),
|
"size": path.stat().st_size,
|
"sha256": sha256_file(path),
|
}
|
)
|
manifest = pd.DataFrame(rows)
|
write_csv(manifest, PACKET_ROOT / "p2_step_review_manifest.csv")
|
(PACKET_ROOT / "p2_step_review_summary.json").write_text(
|
json.dumps(
|
{
|
"run_id": RUN_ID,
|
"generated_at": generated_at,
|
"source_run_id": SOURCE_RUN_ID,
|
"item_count": int(len(rows)),
|
"index_path": as_posix((PACKET_ROOT / "p2_step_review_index.md").resolve()),
|
},
|
ensure_ascii=False,
|
indent=2,
|
),
|
encoding="utf-8",
|
)
|
|
|
def update_top_manifest() -> None:
|
manifest_paths = []
|
for path in ROOT.rglob("*"):
|
if path.is_file() and "__pycache__" not in path.parts:
|
manifest_paths.append(path)
|
rows = []
|
for path in sorted(manifest_paths):
|
rows.append(
|
{
|
"path": path.relative_to(ROOT).as_posix(),
|
"size": path.stat().st_size,
|
"sha256": sha256_file(path),
|
}
|
)
|
with (ROOT / "manifest.csv").open("w", encoding="utf-8-sig", newline="") as f:
|
writer = csv.DictWriter(f, fieldnames=["path", "size", "sha256"])
|
writer.writeheader()
|
writer.writerows(rows)
|
(ROOT / "manifest.json").write_text(
|
json.dumps({"run_id": RUN_ID, "updated_at": now_iso(), "file_count": len(rows), "files": rows}, ensure_ascii=False, indent=2),
|
encoding="utf-8",
|
)
|
|
|
def main() -> None:
|
generated_at = now_iso()
|
recheck = pd.read_csv(ROOT / "buy_point_second_review_recheck_list.csv", encoding="utf-8-sig").fillna("")
|
p2 = recheck[recheck["human_recheck_priority"].astype(str).str.startswith("P2_", na=False)].copy()
|
p2["entry_sort"] = pd.to_datetime(p2["entry_trade_date"], errors="coerce")
|
p2 = p2.sort_values(["human_recheck_priority_num", "entry_sort", "case_id", "candidate_id"]).reset_index(drop=True)
|
|
candidates = pd.read_csv(SOURCE_ROOT / "strict_note_buy_point_review_candidate_ledger.csv", encoding="utf-8-sig").fillna("")
|
orders = pd.read_csv(SOURCE_ROOT / "strict_note_order_ledger.csv", encoding="utf-8-sig").fillna("")
|
lots = pd.read_csv(SOURCE_ROOT / "strict_note_position_lot_ledger.csv", encoding="utf-8-sig").fillna("")
|
cases = pd.read_csv(SOURCE_ROOT / "strict_note_case_summary.csv", encoding="utf-8-sig").fillna("")
|
|
PACKET_ROOT.mkdir(parents=True, exist_ok=True)
|
TABLE_ROOT.mkdir(parents=True, exist_ok=True)
|
CHART_ROOT.mkdir(parents=True, exist_ok=True)
|
|
index_rows = []
|
for i, (_, row) in enumerate(p2.iterrows(), start=1):
|
index_rows.append(build_packet(i, row, candidates, orders, lots, cases, generated_at))
|
index = pd.DataFrame(index_rows)
|
build_index(index, generated_at)
|
ensure_resolution_files(generated_at)
|
write_manifest(generated_at)
|
update_top_manifest()
|
|
|
if __name__ == "__main__":
|
main()
|