from __future__ import annotations
|
|
import csv
|
import hashlib
|
import json
|
from datetime import datetime, timedelta, timezone
|
from pathlib import Path
|
|
import pandas as pd
|
|
|
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]
|
DETAIL_PATH = ROOT / "buy_point_second_review_detail.csv"
|
P1_LEDGER_PATH = ROOT / "p1_step_review_packets" / "p1_step_review_resolution_ledger.csv"
|
P1_SUMMARY_PATH = ROOT / "p1_step_review_packets" / "p1_step_review_resolution_summary.md"
|
|
|
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:
|
df.to_csv(path, index=False, encoding="utf-8-sig")
|
|
|
def write_text(path: Path, text: str) -> None:
|
path.write_text(text, encoding="utf-8-sig")
|
|
|
def safe_text(value: object) -> str:
|
if value is None:
|
return ""
|
text = str(value)
|
if text.lower() == "nan":
|
return ""
|
return text
|
|
|
def p1_lookup() -> dict[str, dict[str, str]]:
|
if not P1_LEDGER_PATH.exists():
|
return {}
|
p1 = pd.read_csv(P1_LEDGER_PATH, dtype=str, encoding="utf-8-sig").fillna("")
|
return {row["candidate_id"]: row.to_dict() for _, row in p1.iterrows()}
|
|
|
def build_rollup() -> tuple[pd.DataFrame, pd.DataFrame]:
|
detail = pd.read_csv(DETAIL_PATH, dtype=str, encoding="utf-8-sig").fillna("")
|
p1_by_candidate = p1_lookup()
|
rows: list[dict[str, str]] = []
|
|
for _, row in detail.iterrows():
|
candidate_id = row["candidate_id"]
|
source_action = row["human_decision_action"]
|
second_status = row["second_review_status"]
|
priority = row.get("human_recheck_priority", "")
|
|
final_action = source_action
|
resolution_bucket = "SOURCE_MANUAL_CARRIED_NO_DATA_RECHECK"
|
resolution_source = "SOURCE_MANUAL_DECISION_NO_LOCAL_DATA_RECHECK"
|
resolution_status = "NOT_INDEPENDENTLY_CONFIRMED"
|
resolution_reason = row.get("second_review_reason_cn", "")
|
formal_repair_action = "NO_REPAIR_DECISION_RECORDED"
|
direct_resolution_flag = "0"
|
codex_direct_resolution_flag = "0"
|
|
if candidate_id in p1_by_candidate:
|
p1 = p1_by_candidate[candidate_id]
|
final_action = p1["final_action"]
|
resolution_bucket = "USER_P1_FINAL"
|
resolution_source = "USER_MANUAL_P1_CONFIRMATION"
|
resolution_status = "FINAL_CONFIRMED"
|
resolution_reason = p1["resolution_reason_cn"]
|
direct_resolution_flag = "1"
|
if source_action == "BUY" and final_action == "REVIEW_HELD":
|
formal_repair_action = "REVOKE_BUY_ORDER_LOT_AND_RECALC"
|
else:
|
formal_repair_action = "KEEP_SOURCE_DECISION_NO_REPAIR"
|
elif second_status == "AGREE":
|
resolution_bucket = "CODEX_DATA_DIRECT_AGREE"
|
resolution_source = "CODEX_DATA_SECOND_REVIEW_AGREE"
|
resolution_status = "DATA_DIRECT_CONFIRMED"
|
resolution_reason = row.get("second_review_reason_cn", "")
|
formal_repair_action = "KEEP_SOURCE_DECISION_NO_REPAIR"
|
direct_resolution_flag = "1"
|
codex_direct_resolution_flag = "1"
|
elif priority.startswith("P2_"):
|
final_action = "PENDING_HUMAN_RECHECK"
|
resolution_bucket = "P2_PENDING_HUMAN_RECHECK"
|
resolution_source = "SECOND_REVIEW_DISPUTE"
|
resolution_status = "PENDING_RECHECK"
|
formal_repair_action = "WAIT_FOR_HUMAN_RECHECK"
|
elif priority.startswith("P3_"):
|
final_action = "PENDING_LOW_PRIORITY_SAMPLE_RECHECK"
|
resolution_bucket = "P3_PENDING_SAMPLE_RECHECK"
|
resolution_source = "SECOND_REVIEW_WEAK_DISPUTE"
|
resolution_status = "PENDING_LOW_PRIORITY_RECHECK"
|
formal_repair_action = "WAIT_FOR_SAMPLE_RECHECK"
|
|
rows.append(
|
{
|
"case_id": row["case_id"],
|
"candidate_id": candidate_id,
|
"symbol": row["symbol"],
|
"entry_trade_date": row["entry_trade_date"],
|
"source_action": source_action,
|
"final_action": final_action,
|
"resolution_bucket": resolution_bucket,
|
"resolution_source": resolution_source,
|
"resolution_status": resolution_status,
|
"direct_resolution_flag": direct_resolution_flag,
|
"codex_direct_resolution_flag": codex_direct_resolution_flag,
|
"formal_repair_action": formal_repair_action,
|
"second_review_status": second_status,
|
"second_review_issue_code": row["second_review_issue_code"],
|
"human_recheck_priority": priority,
|
"local_minute_status": row["local_minute_status"],
|
"minute_rows": row["minute_rows"],
|
"close_ret_pct": row["close_ret_pct"],
|
"above_open_ratio": row["above_open_ratio"],
|
"early_max_ret_pct": row["early_max_ret_pct"],
|
"early_max_ret_time": row["early_max_ret_time"],
|
"tail_max_ret_pct": row["tail_max_ret_pct"],
|
"tail_max_ret_time": row["tail_max_ret_time"],
|
"source_human_reason_cn": row["human_decision_reason_cn"],
|
"resolution_reason_cn": resolution_reason,
|
"source_review_chart_abs_path": row["source_review_chart_abs_path"],
|
}
|
)
|
|
rollup = pd.DataFrame(rows)
|
p1 = pd.DataFrame(p1_by_candidate.values())
|
return rollup, p1
|
|
|
def table_counts(series: pd.Series) -> str:
|
counts = series.value_counts(dropna=False).sort_index()
|
lines = ["| item | count |", "|---|---:|"]
|
for name, count in counts.items():
|
lines.append(f"| `{safe_text(name)}` | {int(count)} |")
|
return "\n".join(lines)
|
|
|
def render_summary(rollup: pd.DataFrame, p1: pd.DataFrame, generated_at: str) -> str:
|
confirmed = rollup[rollup["direct_resolution_flag"].eq("1")]
|
codex = rollup[rollup["codex_direct_resolution_flag"].eq("1")]
|
repair = rollup[rollup["formal_repair_action"].eq("REVOKE_BUY_ORDER_LOT_AND_RECALC")]
|
|
source_buy_confirmed = confirmed[confirmed["final_action"].eq("BUY")]
|
source_held_confirmed = confirmed[confirmed["final_action"].eq("REVIEW_HELD")]
|
|
lines = [
|
"# 买点裁决总览(含 Codex 数据直裁)",
|
"",
|
f"- run_id: {RUN_ID}",
|
f"- source_run_id: {SOURCE_RUN_ID}",
|
f"- updated_at: {generated_at}",
|
f"- source_rows: {len(rollup)}",
|
"",
|
"## 当前结论",
|
"",
|
f"- 已形成明确裁决:{len(confirmed)} 条",
|
f"- 其中用户 P1 最终裁决:{len(p1)} 条",
|
f"- 其中 Codex 数据二审直接同意源裁决:{len(codex)} 条",
|
f"- 已确认 BUY:{len(source_buy_confirmed)} 条",
|
f"- 已确认 REVIEW_HELD:{len(source_held_confirmed)} 条",
|
f"- 需要正式返修撤销 BUY/order/lot 并重算:{len(repair)} 条",
|
f"- 仍需 P2/P3 人工复核或抽查:{len(rollup[rollup['resolution_status'].str.contains('PENDING', na=False)])} 条",
|
f"- 无本地分钟数据覆盖、仅沿用源人工裁决且未二审确认:{len(rollup[rollup['resolution_bucket'].eq('SOURCE_MANUAL_CARRIED_NO_DATA_RECHECK')])} 条",
|
"",
|
"## 裁决来源分布",
|
"",
|
table_counts(rollup["resolution_bucket"]),
|
"",
|
"## 最终动作分布",
|
"",
|
table_counts(rollup["final_action"]),
|
"",
|
"## 输出文件",
|
"",
|
"- `buy_point_resolution_rollup.csv`: 1141 条总台账,合并用户 P1 裁决、Codex 数据直裁、待复核和无数据覆盖状态。",
|
"- `buy_point_codex_direct_resolution.csv`: 388 条 Codex 数据二审直接同意源裁决的明细。",
|
"- `buy_point_formal_repair_required.csv`: 12 条需要正式返修撤销 BUY/order/lot 并重算的明细。",
|
"",
|
"## 口径边界",
|
"",
|
"- `CODEX_DATA_DIRECT_AGREE` 表示本机分钟数据复算与源人工裁决一致,因此列入“数据二审直接同意源裁决”;它不是新的买卖规则,也不替代后续正式结果包返修。",
|
"- `SOURCE_MANUAL_CARRIED_NO_DATA_RECHECK` 表示本机分钟数据没有覆盖,当前没有形成二审证据,不把它算作 Codex 直接裁决。",
|
"- 正式结果包返修时,不能只改人工裁决表;凡撤销 BUY 的条目,都需要同步重算 order、lot、case 汇总和收益口径。",
|
]
|
return "\n".join(lines) + "\n"
|
|
|
def render_p1_summary(p1: pd.DataFrame, generated_at: str) -> str:
|
if p1.empty:
|
return "# P1 买点逐条人工复核裁决汇总\n\n- 当前没有 P1 裁决记录。\n"
|
|
keep = p1[p1["final_action"].eq("BUY")]
|
revoke = p1[p1["final_action"].eq("REVIEW_HELD")]
|
|
lines = [
|
"# P1 买点逐条人工复核裁决汇总",
|
"",
|
f"- updated_at: {generated_at}",
|
f"- resolved_count: {len(p1)}",
|
f"- 维持 BUY: {len(keep)}",
|
f"- 改为 REVIEW_HELD: {len(revoke)}",
|
"",
|
"| # | case | candidate | symbol | date | original | final | reason |",
|
"|---:|---|---|---|---|---|---|---|",
|
]
|
ordered = p1.copy()
|
ordered["packet_order_num"] = pd.to_numeric(ordered["packet_order"], errors="coerce")
|
ordered = ordered.sort_values("packet_order_num")
|
for _, row in ordered.iterrows():
|
lines.append(
|
"| {packet_order} | {case_id} | {candidate_id} | {symbol} | {date} | {original} | {final} | {reason} |".format(
|
packet_order=row["packet_order"],
|
case_id=row["case_id"],
|
candidate_id=row["candidate_id"],
|
symbol=row["symbol"],
|
date=row["entry_trade_date"],
|
original=row["original_action"],
|
final=row["final_action"],
|
reason=row["resolution_reason_cn"],
|
)
|
)
|
|
lines.extend(
|
[
|
"",
|
"## 待处理影响",
|
"",
|
"- 维持 BUY 的条目保留最新结果包中的 BUY/order/lot,不进入买点撤销重算清单。",
|
"- 改为 REVIEW_HELD 的条目需要在正式返修时撤销对应 BUY/order/lot;如果已经有后续 SELL,也要一并重算 lot、case 汇总和收益口径。",
|
"- `WUJI-STRICT-20250317` 同时有 `920570.BJ` 和 `603206.SH` 两条买点被判定不适合作为买点,正式返修时应作为同一个 case 合并处理。",
|
]
|
)
|
return "\n".join(lines) + "\n"
|
|
|
def update_manifest(generated_at: str, extra_paths: list[Path]) -> None:
|
manifest_paths = [
|
ROOT / "buy_point_second_review_action_status_counts.csv",
|
ROOT / "buy_point_second_review_detail.csv",
|
ROOT / "buy_point_second_review_human_recheck_report.md",
|
ROOT / "buy_point_second_review_human_top80.csv",
|
ROOT / "buy_point_second_review_recheck_list.csv",
|
ROOT / "buy_point_second_review_strong_recheck.csv",
|
ROOT / "buy_point_second_review_summary.json",
|
ROOT / "buy_point_second_review_summary.md",
|
ROOT / "tools" / "review_buy_point_decisions.py",
|
ROOT / "tools" / "build_p1_step_review_packets.py",
|
ROOT / "tools" / "build_buy_point_resolution_rollup.py",
|
*extra_paths,
|
]
|
files = []
|
for path in manifest_paths:
|
if not path.exists():
|
continue
|
rel = path.relative_to(ROOT).as_posix()
|
files.append({"path": rel, "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(files)
|
|
manifest = {
|
"run_id": RUN_ID,
|
"updated_at": generated_at,
|
"file_count": len(files),
|
"files": files,
|
}
|
(ROOT / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
|
def main() -> None:
|
generated_at = now_iso()
|
rollup, p1 = build_rollup()
|
|
rollup_path = ROOT / "buy_point_resolution_rollup.csv"
|
codex_path = ROOT / "buy_point_codex_direct_resolution.csv"
|
repair_path = ROOT / "buy_point_formal_repair_required.csv"
|
summary_path = ROOT / "buy_point_resolution_summary.md"
|
|
write_csv(rollup, rollup_path)
|
write_csv(rollup[rollup["codex_direct_resolution_flag"].eq("1")], codex_path)
|
write_csv(rollup[rollup["formal_repair_action"].eq("REVOKE_BUY_ORDER_LOT_AND_RECALC")], repair_path)
|
write_text(summary_path, render_summary(rollup, p1, generated_at))
|
write_text(P1_SUMMARY_PATH, render_p1_summary(p1, generated_at))
|
|
update_manifest(generated_at, [rollup_path, codex_path, repair_path, summary_path])
|
|
|
if __name__ == "__main__":
|
main()
|