from __future__ import annotations
|
|
import csv
|
import hashlib
|
import json
|
from collections import Counter, defaultdict
|
from datetime import datetime
|
from pathlib import Path
|
from typing import Any
|
|
import pandas as pd
|
|
|
RUN_ID = "RUN-ANA-WUJI-NOTE-FIT-AUDIT-20260615-001"
|
|
PACKAGE_ROOT = Path(__file__).resolve().parents[1]
|
PROJECT_ROOT = PACKAGE_ROOT.parents[2]
|
RESULT_ROOT = PROJECT_ROOT / "ana-data" / "result"
|
|
STRICT_ROOT = RESULT_ROOT / "RUN-ANA-WUJI-STRICT-SELL-ROLLING-REPAIR-20260609-001"
|
FRONT_AUDIT_ROOT = RESULT_ROOT / "RUN-ANA-WUJI-FRONTDATA-RULE-AUDIT-20260613-001"
|
LIFECYCLE_ROOT = RESULT_ROOT / "RUN-ANA-WUJI-V1-STOCK-LIFECYCLE-PACKAGE-20260611-001"
|
|
LEDGER_FIELDS = [
|
"audit_row_id",
|
"run_id",
|
"case_id",
|
"symbol",
|
"subject_type",
|
"subject_id",
|
"rule_id",
|
"rule_category",
|
"audit_status",
|
"automation_level",
|
"evidence_level",
|
"trade_date",
|
"trade_time",
|
"source_file",
|
"source_key",
|
"evidence_summary",
|
"evidence_path",
|
"needs_manual_review",
|
"notes",
|
]
|
|
SUMMARY_FIELDS = [
|
"case_id",
|
"ledger_rows",
|
"pass_count",
|
"weak_pass_count",
|
"proxy_pass_count",
|
"fail_count",
|
"data_gap_count",
|
"manual_required_count",
|
"not_applicable_count",
|
"case_note_fit_status",
|
]
|
|
PILOT_FIELDS = [
|
"pilot_group",
|
"case_id",
|
"account_return_closed_lots",
|
"v1_return_scope",
|
"case_note_fit_status",
|
"fail_count",
|
"manual_required_count",
|
"suggested_review_focus",
|
"case_image_board",
|
"case_stock_lifecycle_board",
|
]
|
|
SELF_CHECK_FIELDS = ["check_id", "status", "details"]
|
|
MANIFEST_FIELDS = ["path", "size_bytes", "sha256"]
|
|
RULE_CATEGORY = {
|
"MKT_OPEN_NEW_UP_3000": "market",
|
"SEL_PRIOR30_LIMITUP_STRICT_BOARD": "selection",
|
"SEL_VOLUME_MULTIPLE": "selection",
|
"SEL_LONG_UPPER_SHADOW_TOP50": "selection",
|
"SEL_BOTTOM_SUPPORT": "selection",
|
"SEL_SIDEWAYS_60D": "selection",
|
"SEL_TOUCH_PREV_HIGH_VOLUME_GT": "selection",
|
"BUY_TIME_WINDOW_NORMAL": "buy",
|
"BUY_OPEN_RUSH_PULLBACK_OPEN_SHRINK_SUPPORT": "buy",
|
"BUY_OPEN_RUSH_PULLBACK_MA20_SHRINK_SUPPORT": "buy",
|
"BUY_NO_CHASE_INTRADAY_SPIKE": "buy",
|
"BUY_ROLLING_LOW_BUY_MA5_EXCEPTION": "buy",
|
"SELL_THREE_DAY_HIGH_NOT_RISING": "sell",
|
"POS_TRANCHE_5_PARTS": "position",
|
"CAP_T1": "lifecycle",
|
}
|
|
|
def now_iso() -> str:
|
return datetime.now().astimezone().isoformat(timespec="seconds")
|
|
|
def read_csv(path: Path) -> pd.DataFrame:
|
if not path.exists():
|
return pd.DataFrame()
|
return pd.read_csv(path, dtype=str, keep_default_na=False, encoding="utf-8-sig")
|
|
|
def write_csv(path: Path, rows: list[dict[str, Any]], fieldnames: list[str]) -> None:
|
with path.open("w", newline="", encoding="utf-8-sig") as f:
|
writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore")
|
writer.writeheader()
|
writer.writerows(rows)
|
|
|
def write_json(path: Path, data: dict[str, Any]) -> None:
|
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
|
def bval(value: Any) -> bool | None:
|
text = str(value).strip().lower()
|
if text in {"true", "1", "yes", "y"}:
|
return True
|
if text in {"false", "0", "no", "n"}:
|
return False
|
return None
|
|
|
def fnum(value: Any) -> float | None:
|
text = str(value).strip()
|
if text == "":
|
return None
|
try:
|
return float(text)
|
except ValueError:
|
return None
|
|
|
def status_from_bool(value: bool | None) -> str:
|
if value is True:
|
return "PASS"
|
if value is False:
|
return "FAIL"
|
return "DATA_GAP"
|
|
|
def status_from_proxy_bool(value: bool | None) -> str:
|
if value is True:
|
return "PROXY_PASS"
|
if value is False:
|
return "FAIL"
|
return "DATA_GAP"
|
|
|
def source_rel(path: Path) -> str:
|
try:
|
return path.relative_to(PROJECT_ROOT).as_posix()
|
except ValueError:
|
return str(path)
|
|
|
def key(*parts: Any) -> str:
|
return "|".join(str(p) for p in parts)
|
|
|
class LedgerBuilder:
|
def __init__(self) -> None:
|
self.rows: list[dict[str, Any]] = []
|
|
def add(
|
self,
|
*,
|
case_id: str,
|
symbol: str,
|
subject_type: str,
|
subject_id: str,
|
rule_id: str,
|
audit_status: str,
|
automation_level: str,
|
evidence_level: str,
|
trade_date: str = "",
|
trade_time: str = "",
|
source_file: str = "",
|
source_key: str = "",
|
evidence_summary: str = "",
|
evidence_path: str = "",
|
needs_manual_review: bool = False,
|
notes: str = "",
|
) -> None:
|
self.rows.append(
|
{
|
"audit_row_id": f"NFA-{len(self.rows) + 1:06d}",
|
"run_id": RUN_ID,
|
"case_id": case_id,
|
"symbol": symbol,
|
"subject_type": subject_type,
|
"subject_id": subject_id,
|
"rule_id": rule_id,
|
"rule_category": RULE_CATEGORY.get(rule_id, ""),
|
"audit_status": audit_status,
|
"automation_level": automation_level,
|
"evidence_level": evidence_level,
|
"trade_date": trade_date,
|
"trade_time": trade_time,
|
"source_file": source_file,
|
"source_key": source_key,
|
"evidence_summary": evidence_summary,
|
"evidence_path": evidence_path,
|
"needs_manual_review": str(bool(needs_manual_review)).lower(),
|
"notes": notes,
|
}
|
)
|
|
|
def load_inputs() -> dict[str, pd.DataFrame]:
|
return {
|
"source_buy": read_csv(FRONT_AUDIT_ROOT / "source_buy_trace_audit.csv"),
|
"daily": read_csv(FRONT_AUDIT_ROOT / "daily_candidate_rule_recalc.csv"),
|
"limitup": read_csv(FRONT_AUDIT_ROOT / "limitup_prior30_user_rule_audit.csv"),
|
"rolling": read_csv(FRONT_AUDIT_ROOT / "rolling_buy_trace_audit.csv"),
|
"sell3": read_csv(FRONT_AUDIT_ROOT / "sell_three_day_high_recalc.csv"),
|
"sell3_5m": read_csv(FRONT_AUDIT_ROOT / "three_day_high_5m_proxy_audit.csv"),
|
"orders": read_csv(STRICT_ROOT / "strict_order_ledger.csv"),
|
"lots": read_csv(STRICT_ROOT / "strict_position_lot_ledger.csv"),
|
"strict_case_summary": read_csv(STRICT_ROOT / "strict_case_summary.csv"),
|
"lifecycle": read_csv(LIFECYCLE_ROOT / "stock_lifecycle_index.csv"),
|
}
|
|
|
def index_by(df: pd.DataFrame, *cols: str) -> dict[str, dict[str, Any]]:
|
if df.empty or any(col not in df.columns for col in cols):
|
return {}
|
out: dict[str, dict[str, Any]] = {}
|
for row in df.to_dict("records"):
|
out[key(*(row.get(col, "") for col in cols))] = row
|
return out
|
|
|
def add_source_buy_rules(builder: LedgerBuilder, data: dict[str, pd.DataFrame]) -> None:
|
source_buy = data["source_buy"]
|
daily_by_order = index_by(data["daily"], "strict_order_id")
|
limitup_by_candidate = index_by(data["limitup"], "case_id", "symbol", "candidate_id")
|
source_path = source_rel(FRONT_AUDIT_ROOT / "source_buy_trace_audit.csv")
|
daily_path = source_rel(FRONT_AUDIT_ROOT / "daily_candidate_rule_recalc.csv")
|
limitup_path = source_rel(FRONT_AUDIT_ROOT / "limitup_prior30_user_rule_audit.csv")
|
|
for row in source_buy.to_dict("records"):
|
case_id = row.get("case_id", "")
|
symbol = row.get("symbol", "")
|
order_id = row.get("strict_order_id", "")
|
candidate_id = row.get("candidate_id", "")
|
trade_date = row.get("entry_trade_date", "")
|
trade_time = row.get("entry_time", "")
|
source_key = key(order_id, case_id, symbol)
|
|
builder.add(
|
case_id=case_id,
|
symbol=symbol,
|
subject_type="SOURCE_BUY",
|
subject_id=order_id,
|
rule_id="MKT_OPEN_NEW_UP_3000",
|
audit_status=status_from_bool(bval(row.get("old_up_count_ge3000", ""))),
|
automation_level="AUTO_STRICT",
|
evidence_level="LEDGER_ONLY",
|
trade_date=trade_date,
|
trade_time=trade_time,
|
source_file=source_path,
|
source_key=source_key,
|
evidence_summary=f"old_up_count={row.get('old_up_count', '')}; market_gate_open_flag={row.get('market_gate_open_flag', '')}",
|
evidence_path=row.get("source_evidence_image_path", ""),
|
)
|
|
builder.add(
|
case_id=case_id,
|
symbol=symbol,
|
subject_type="SOURCE_BUY",
|
subject_id=order_id,
|
rule_id="BUY_TIME_WINDOW_NORMAL",
|
audit_status=status_from_bool(bval(row.get("normal_buy_time_window_ok", ""))),
|
automation_level="AUTO_STRICT",
|
evidence_level="EXACT",
|
trade_date=trade_date,
|
trade_time=trade_time,
|
source_file=source_path,
|
source_key=source_key,
|
evidence_summary=f"entry_time={trade_time}; normal_buy_time_window_ok={row.get('normal_buy_time_window_ok', '')}",
|
evidence_path=row.get("source_evidence_image_path", ""),
|
)
|
|
rank = fnum(row.get("candidate_rank", ""))
|
rank_status = "DATA_GAP" if rank is None else ("PASS" if rank <= 50 else "FAIL")
|
builder.add(
|
case_id=case_id,
|
symbol=symbol,
|
subject_type="SOURCE_BUY",
|
subject_id=order_id,
|
rule_id="SEL_LONG_UPPER_SHADOW_TOP50",
|
audit_status=rank_status,
|
automation_level="AUTO_PROXY",
|
evidence_level="LEDGER_ONLY",
|
trade_date=trade_date,
|
trade_time=trade_time,
|
source_file=source_path,
|
source_key=source_key,
|
evidence_summary=f"candidate_rank={row.get('candidate_rank', '')}; note requires top50 long-upper-shadow pool",
|
evidence_path=row.get("source_evidence_image_path", ""),
|
needs_manual_review=True,
|
notes="排名字段可自动核对,但长上影形态和放量质量仍需图证复核。",
|
)
|
|
daily = daily_by_order.get(order_id, {})
|
builder.add(
|
case_id=case_id,
|
symbol=symbol,
|
subject_type="SOURCE_BUY",
|
subject_id=order_id,
|
rule_id="SEL_VOLUME_MULTIPLE",
|
audit_status=status_from_bool(bval(daily.get("recalc_volume_ratio_ge2", ""))),
|
automation_level="AUTO_STRICT",
|
evidence_level="EXACT",
|
trade_date=daily.get("signal_trade_date", trade_date),
|
trade_time="",
|
source_file=daily_path,
|
source_key=source_key,
|
evidence_summary=(
|
f"recalc_volume_ratio={daily.get('recalc_volume_ratio', '')}; "
|
f"recalc_volume_ratio_ge2={daily.get('recalc_volume_ratio_ge2', '')}; "
|
f"old_volume_ratio={daily.get('old_volume_ratio', row.get('old_volume_ratio', ''))}"
|
),
|
evidence_path=daily.get("daily_file_path", ""),
|
notes="当前严格倍量先按 >=2.0 打标;V1 历史参考口径可能低于 2.0,需分列解释。",
|
)
|
|
touch = bval(daily.get("recalc_touch_prev_high_flag", ""))
|
pass_prev = bval(daily.get("recalc_prev_high_volume_pass_flag", ""))
|
if touch is False:
|
prev_status = "NOT_APPLICABLE"
|
elif touch is True:
|
prev_status = status_from_bool(pass_prev)
|
else:
|
prev_status = "DATA_GAP"
|
builder.add(
|
case_id=case_id,
|
symbol=symbol,
|
subject_type="SOURCE_BUY",
|
subject_id=order_id,
|
rule_id="SEL_TOUCH_PREV_HIGH_VOLUME_GT",
|
audit_status=prev_status,
|
automation_level="AUTO_STRICT",
|
evidence_level="EXACT",
|
trade_date=daily.get("signal_trade_date", trade_date),
|
source_file=daily_path,
|
source_key=source_key,
|
evidence_summary=(
|
f"touch_prev_high={daily.get('recalc_touch_prev_high_flag', '')}; "
|
f"prev_high_volume_pass={daily.get('recalc_prev_high_volume_pass_flag', '')}; "
|
f"prev_high_ref_date={daily.get('recalc_prev60_high_ref_date', '')}"
|
),
|
evidence_path=daily.get("daily_file_path", ""),
|
)
|
|
limit = limitup_by_candidate.get(key(case_id, symbol, candidate_id), {})
|
builder.add(
|
case_id=case_id,
|
symbol=symbol,
|
subject_type="SOURCE_BUY",
|
subject_id=order_id,
|
rule_id="SEL_PRIOR30_LIMITUP_STRICT_BOARD",
|
audit_status=status_from_bool(bval(limit.get("user_strict_close_limitup_prior30_excl_signal", ""))),
|
automation_level="AUTO_STRICT",
|
evidence_level="EXACT",
|
trade_date=limit.get("signal_trade_date", daily.get("signal_trade_date", "")),
|
source_file=limitup_path,
|
source_key=key(case_id, symbol, candidate_id),
|
evidence_summary=(
|
f"board_rule={limit.get('board_rule', '')}; threshold_pct={limit.get('threshold_pct', '')}; "
|
f"strict_close_prior30={limit.get('user_strict_close_limitup_prior30_excl_signal', '')}; "
|
f"last_limitup={limit.get('user_strict_close_last_limitup_date', '')}"
|
),
|
evidence_path=daily.get("daily_file_path", ""),
|
notes="使用用户确认口径:信号日前30个交易日,不含信号日,按板块阈值。",
|
)
|
|
for rule_id, note in [
|
("SEL_BOTTOM_SUPPORT", "底部承接力需要人工看图确认。"),
|
("SEL_SIDEWAYS_60D", "60日横盘或横盘涨停后回调需要日线图形确认。"),
|
("BUY_OPEN_RUSH_PULLBACK_OPEN_SHRINK_SUPPORT", "买入属于回踩开盘价还是其他形态,需要旧买入图确认。"),
|
("BUY_OPEN_RUSH_PULLBACK_MA20_SHRINK_SUPPORT", "买入是否回踩20日均线附近缩量支撑,需要旧买入图确认。"),
|
("BUY_NO_CHASE_INTRADAY_SPIKE", "是否盘中急拉放量追高,需要买入前分时图确认。"),
|
]:
|
builder.add(
|
case_id=case_id,
|
symbol=symbol,
|
subject_type="SOURCE_BUY",
|
subject_id=order_id,
|
rule_id=rule_id,
|
audit_status="MANUAL_REQUIRED",
|
automation_level="MANUAL_IMAGE",
|
evidence_level="CHART_REQUIRED",
|
trade_date=trade_date,
|
trade_time=trade_time,
|
source_file=source_path,
|
source_key=source_key,
|
evidence_summary=note,
|
evidence_path=row.get("source_evidence_image_path", ""),
|
needs_manual_review=True,
|
notes=row.get("old_decision_reason_cn", ""),
|
)
|
|
|
def add_rolling_buy_rules(builder: LedgerBuilder, data: dict[str, pd.DataFrame]) -> None:
|
rolling = data["rolling"]
|
source_path = source_rel(FRONT_AUDIT_ROOT / "rolling_buy_trace_audit.csv")
|
for row in rolling.to_dict("records"):
|
case_id = row.get("case_id", "")
|
symbol = row.get("symbol", "")
|
order_id = row.get("strict_order_id", "")
|
near_ma5 = fnum(row.get("near_ma5_pct", ""))
|
time_ok = bval(row.get("rolling_window_1040_1440_ok", ""))
|
ma5_status = "DATA_GAP" if near_ma5 is None else ("PASS" if near_ma5 <= 0.012 else "FAIL")
|
combined_status = "PASS" if time_ok is True and ma5_status == "PASS" else ("FAIL" if time_ok is False or ma5_status == "FAIL" else "DATA_GAP")
|
builder.add(
|
case_id=case_id,
|
symbol=symbol,
|
subject_type="ROLLING_BUY",
|
subject_id=order_id,
|
rule_id="BUY_ROLLING_LOW_BUY_MA5_EXCEPTION",
|
audit_status=combined_status,
|
automation_level="AUTO_STRICT+MANUAL_IMAGE",
|
evidence_level="LEDGER_ONLY",
|
trade_date=row.get("rolling_trade_date", ""),
|
trade_time=row.get("rolling_time", ""),
|
source_file=source_path,
|
source_key=key(order_id, row.get("rolling_signal_id", "")),
|
evidence_summary=(
|
f"rolling_window_1040_1440_ok={row.get('rolling_window_1040_1440_ok', '')}; "
|
f"near_ma5_pct={row.get('near_ma5_pct', '')}; "
|
f"volume_ratio_vs_prev20m={row.get('volume_ratio_vs_prev20m', '')}"
|
),
|
evidence_path=row.get("evidence_image_path", ""),
|
needs_manual_review=True,
|
notes="时间和MA5附近度可自动判,趋势基础和止跌承接仍需人工看图。",
|
)
|
|
|
def add_sell_rules(builder: LedgerBuilder, data: dict[str, pd.DataFrame]) -> None:
|
sell3 = data["sell3"]
|
proxy_by_signal = index_by(data["sell3_5m"], "signal_id")
|
source_path = source_rel(FRONT_AUDIT_ROOT / "sell_three_day_high_recalc.csv")
|
proxy_path = source_rel(FRONT_AUDIT_ROOT / "three_day_high_5m_proxy_audit.csv")
|
for row in sell3.to_dict("records"):
|
signal_id = row.get("signal_id", "")
|
proxy = proxy_by_signal.get(signal_id, {})
|
if proxy:
|
status = status_from_proxy_bool(bval(proxy.get("three_highs_not_strictly_rising_5m_proxy", "")))
|
evidence_level = "PROXY_5M"
|
source_file = proxy_path
|
summary = (
|
f"verification_status_5m_proxy={proxy.get('verification_status_5m_proxy', '')}; "
|
f"three_highs_not_strictly_rising_5m_proxy={proxy.get('three_highs_not_strictly_rising_5m_proxy', '')}; "
|
f"d-2={proxy.get('d_minus_2_high_front', '')}; d-1={proxy.get('d_minus_1_high_front', '')}; "
|
f"current_5m_scaled={proxy.get('current_high_until_1040_5m_daily_scaled_front', '')}"
|
)
|
else:
|
safe = bval(row.get("three_highs_not_strictly_rising_decision_safe", ""))
|
daily_proxy = bval(row.get("three_highs_not_strictly_rising_daily_proxy", ""))
|
if safe is not None:
|
status = status_from_bool(safe)
|
evidence_level = "EXACT"
|
else:
|
status = status_from_proxy_bool(daily_proxy)
|
evidence_level = "DAILY_PROXY"
|
source_file = source_path
|
summary = (
|
f"verification_status={row.get('verification_status', '')}; "
|
f"decision_safe={row.get('three_highs_not_strictly_rising_decision_safe', '')}; "
|
f"daily_proxy={row.get('three_highs_not_strictly_rising_daily_proxy', '')}"
|
)
|
builder.add(
|
case_id=row.get("case_id", ""),
|
symbol=row.get("symbol", ""),
|
subject_type="SELL_SIGNAL",
|
subject_id=signal_id,
|
rule_id="SELL_THREE_DAY_HIGH_NOT_RISING",
|
audit_status=status,
|
automation_level="AUTO_STRICT_OR_PROXY",
|
evidence_level=evidence_level,
|
trade_date=row.get("observation_trade_date", ""),
|
trade_time=row.get("candidate_time", ""),
|
source_file=source_file,
|
source_key=signal_id,
|
evidence_summary=summary,
|
evidence_path=proxy.get("public_5m_cache_path", ""),
|
notes=row.get("code_evidence_reason_cn", ""),
|
)
|
|
|
def add_order_and_lot_rules(builder: LedgerBuilder, data: dict[str, pd.DataFrame]) -> None:
|
orders = data["orders"]
|
lots = data["lots"]
|
order_path = source_rel(STRICT_ROOT / "strict_order_ledger.csv")
|
lot_path = source_rel(STRICT_ROOT / "strict_position_lot_ledger.csv")
|
|
for row in orders.to_dict("records"):
|
if row.get("action") != "BUY":
|
continue
|
planned = fnum(row.get("planned_tranche_count", ""))
|
status = "DATA_GAP" if planned is None else ("PASS" if int(planned) == 5 else "FAIL")
|
builder.add(
|
case_id=row.get("case_id", ""),
|
symbol=row.get("symbol", ""),
|
subject_type="ORDER",
|
subject_id=row.get("order_id", ""),
|
rule_id="POS_TRANCHE_5_PARTS",
|
audit_status=status,
|
automation_level="AUTO_STRICT",
|
evidence_level="LEDGER_ONLY",
|
trade_date=row.get("trade_date", ""),
|
trade_time=row.get("trade_time", ""),
|
source_file=order_path,
|
source_key=row.get("order_id", ""),
|
evidence_summary=(
|
f"tranche_index={row.get('tranche_index', '')}; "
|
f"planned_tranche_count={row.get('planned_tranche_count', '')}; "
|
f"position_delta_pct={row.get('position_delta_pct', '')}"
|
),
|
evidence_path=row.get("evidence_image_path", ""),
|
)
|
|
for row in lots.to_dict("records"):
|
entry = row.get("entry_trade_date", "")
|
sellable = row.get("sellable_from_trade_date", "")
|
exit_date = row.get("exit_trade_date", "")
|
if not entry or not sellable:
|
status = "DATA_GAP"
|
elif exit_date and exit_date < sellable:
|
status = "FAIL"
|
else:
|
status = "PASS"
|
builder.add(
|
case_id=row.get("case_id", ""),
|
symbol=row.get("symbol", ""),
|
subject_type="LOT",
|
subject_id=row.get("strict_lot_id", ""),
|
rule_id="CAP_T1",
|
audit_status=status,
|
automation_level="AUTO_STRICT",
|
evidence_level="LEDGER_ONLY",
|
trade_date=entry,
|
trade_time=row.get("entry_time", ""),
|
source_file=lot_path,
|
source_key=row.get("strict_lot_id", ""),
|
evidence_summary=f"entry={entry}; sellable_from={sellable}; exit={exit_date}; lot_status={row.get('lot_status', '')}",
|
)
|
|
|
def build_summary(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
by_case: dict[str, Counter[str]] = defaultdict(Counter)
|
for row in rows:
|
by_case[row["case_id"]][row["audit_status"]] += 1
|
by_case[row["case_id"]]["_rows"] += 1
|
|
out: list[dict[str, Any]] = []
|
for case_id in sorted(by_case):
|
c = by_case[case_id]
|
if c["FAIL"]:
|
status = "NOTE_MISMATCH_REVIEW"
|
elif c["DATA_GAP"]:
|
status = "DATA_GAP_HELD"
|
elif c["MANUAL_REQUIRED"]:
|
status = "MANUAL_REVIEW_PENDING"
|
elif c["PROXY_PASS"]:
|
status = "NOTE_FIT_PROXY"
|
else:
|
status = "NOTE_FIT_AUTO_PASS"
|
out.append(
|
{
|
"case_id": case_id,
|
"ledger_rows": c["_rows"],
|
"pass_count": c["PASS"],
|
"weak_pass_count": c["WEAK_PASS"],
|
"proxy_pass_count": c["PROXY_PASS"],
|
"fail_count": c["FAIL"],
|
"data_gap_count": c["DATA_GAP"],
|
"manual_required_count": c["MANUAL_REQUIRED"],
|
"not_applicable_count": c["NOT_APPLICABLE"],
|
"case_note_fit_status": status,
|
}
|
)
|
return out
|
|
|
def sample_evenly(rows: list[dict[str, Any]], n: int) -> list[dict[str, Any]]:
|
if len(rows) <= n:
|
return rows
|
if n <= 1:
|
return [rows[0]]
|
indexes = [round(i * (len(rows) - 1) / (n - 1)) for i in range(n)]
|
out: list[dict[str, Any]] = []
|
seen: set[int] = set()
|
for idx in indexes:
|
if idx not in seen:
|
out.append(rows[idx])
|
seen.add(idx)
|
return out
|
|
|
def build_pilot_queue(case_summary: list[dict[str, Any]], data: dict[str, pd.DataFrame]) -> list[dict[str, Any]]:
|
summary_by_case = {row["case_id"]: row for row in case_summary}
|
strict_case = data["strict_case_summary"]
|
strict_by_case = strict_case.set_index("case_id", drop=False).to_dict("index") if not strict_case.empty else {}
|
|
lifecycle_cases = set(data["lifecycle"]["case_id"].tolist()) if not data["lifecycle"].empty else set()
|
|
merged: list[dict[str, Any]] = []
|
for case_id, note in summary_by_case.items():
|
strict = strict_by_case.get(case_id, {})
|
return_value = fnum(strict.get("account_return_closed_lots", ""))
|
case_image = STRICT_ROOT / "cases" / case_id / "case_image_board.md"
|
lifecycle_board = LIFECYCLE_ROOT / "cases" / case_id / "case_stock_lifecycle_board.md"
|
merged.append(
|
{
|
"case_id": case_id,
|
"account_return_closed_lots": "" if return_value is None else f"{return_value:.8f}",
|
"return_float": return_value,
|
"v1_return_scope": strict.get("v1_return_scope", ""),
|
"case_note_fit_status": note.get("case_note_fit_status", ""),
|
"fail_count": note.get("fail_count", "0"),
|
"manual_required_count": note.get("manual_required_count", "0"),
|
"case_image_board": source_rel(case_image) if case_image.exists() else "",
|
"case_stock_lifecycle_board": source_rel(lifecycle_board) if case_id in lifecycle_cases or lifecycle_board.exists() else "",
|
}
|
)
|
|
primary = [r for r in merged if r["v1_return_scope"] == "V1_PRIMARY_STRICT_CLOSED_CASE"]
|
winners = sorted([r for r in primary if r["return_float"] is not None and r["return_float"] > 0], key=lambda r: r["case_id"])
|
losers = sorted([r for r in primary if r["return_float"] is not None and r["return_float"] <= 0], key=lambda r: r["case_id"])
|
manual_only = sorted(
|
[r for r in merged if r["case_note_fit_status"] == "MANUAL_REVIEW_PENDING"],
|
key=lambda r: r["case_id"],
|
)
|
|
out: list[dict[str, Any]] = []
|
for group, source_rows, focus in [
|
("WINNER_SAMPLE", sample_evenly(winners, 10), "盈利样本:检查自动严格差异是否影响成功样本解释。"),
|
("LOSER_SAMPLE", sample_evenly(losers, 10), "亏损/非正收益样本:检查失败是否来自选股、买点、卖点还是市场环境。"),
|
("MANUAL_ONLY_SAMPLE", sample_evenly(manual_only, 10), "无自动失败但需要人工看图样本:校准形态类规则。"),
|
]:
|
for row in source_rows:
|
out.append(
|
{
|
"pilot_group": group,
|
"case_id": row["case_id"],
|
"account_return_closed_lots": row["account_return_closed_lots"],
|
"v1_return_scope": row["v1_return_scope"],
|
"case_note_fit_status": row["case_note_fit_status"],
|
"fail_count": row["fail_count"],
|
"manual_required_count": row["manual_required_count"],
|
"suggested_review_focus": focus,
|
"case_image_board": row["case_image_board"],
|
"case_stock_lifecycle_board": row["case_stock_lifecycle_board"],
|
}
|
)
|
return out
|
|
|
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 build_manifest() -> list[dict[str, Any]]:
|
rows: list[dict[str, Any]] = []
|
for path in sorted(PACKAGE_ROOT.rglob("*")):
|
if not path.is_file():
|
continue
|
if path.name in {"manifest.csv"}:
|
continue
|
rows.append(
|
{
|
"path": source_rel(path),
|
"size_bytes": path.stat().st_size,
|
"sha256": sha256_file(path),
|
}
|
)
|
return rows
|
|
|
def build_self_check(rows: list[dict[str, Any]], case_summary: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
package_files = [
|
"README.md",
|
"todo_status.md",
|
"note_rule_mapping.csv",
|
"data_source_inventory.csv",
|
"output_schema.md",
|
"case_note_fit_ledger.csv",
|
"case_note_fit_summary.csv",
|
"manual_review_queue.csv",
|
"rule_exception_inventory.csv",
|
"pilot_case_review_queue.csv",
|
"note_fit_audit_summary.md",
|
"summary.json",
|
]
|
checks: list[dict[str, Any]] = []
|
|
missing = [name for name in package_files if not (PACKAGE_ROOT / name).exists()]
|
checks.append(
|
{
|
"check_id": "PACKAGE_FILES_EXIST",
|
"status": "PASS" if not missing else "FAIL",
|
"details": "all required files exist" if not missing else f"missing={missing}",
|
}
|
)
|
|
checks.append(
|
{
|
"check_id": "LEDGER_NON_EMPTY",
|
"status": "PASS" if rows else "FAIL",
|
"details": f"ledger_rows={len(rows)}",
|
}
|
)
|
|
bad_pass = [row for row in rows if row["audit_status"] in {"PASS", "PROXY_PASS"} and not row["source_file"]]
|
checks.append(
|
{
|
"check_id": "PASS_ROWS_HAVE_SOURCE",
|
"status": "PASS" if not bad_pass else "FAIL",
|
"details": f"bad_rows={len(bad_pass)}",
|
}
|
)
|
|
manual_rows = [row for row in rows if row["audit_status"] == "MANUAL_REQUIRED" or row["needs_manual_review"] == "true"]
|
manual_file_rows = read_csv(PACKAGE_ROOT / "manual_review_queue.csv")
|
checks.append(
|
{
|
"check_id": "MANUAL_QUEUE_MATCHES_LEDGER",
|
"status": "PASS" if len(manual_rows) == len(manual_file_rows) else "FAIL",
|
"details": f"ledger_manual_rows={len(manual_rows)}; file_rows={len(manual_file_rows)}",
|
}
|
)
|
|
exception_rows = [row for row in rows if row["audit_status"] == "FAIL"]
|
exception_file_rows = read_csv(PACKAGE_ROOT / "rule_exception_inventory.csv")
|
checks.append(
|
{
|
"check_id": "EXCEPTION_INVENTORY_MATCHES_FAILS",
|
"status": "PASS" if len(exception_rows) == len(exception_file_rows) else "FAIL",
|
"details": f"ledger_fail_rows={len(exception_rows)}; file_rows={len(exception_file_rows)}",
|
}
|
)
|
|
checks.append(
|
{
|
"check_id": "CASE_SUMMARY_NON_EMPTY",
|
"status": "PASS" if case_summary else "FAIL",
|
"details": f"case_count={len(case_summary)}",
|
}
|
)
|
|
return checks
|
|
|
def write_summary_markdown(rows: list[dict[str, Any]], case_summary: list[dict[str, Any]]) -> None:
|
status_counts = Counter(row["audit_status"] for row in rows)
|
rule_counts = Counter(row["rule_id"] for row in rows)
|
case_status_counts = Counter(row["case_note_fit_status"] for row in case_summary)
|
lines = [
|
"# Note Fit Audit Summary",
|
"",
|
f"generated_at: {now_iso()}",
|
f"run_id: `{RUN_ID}`",
|
"",
|
"## Scope",
|
"",
|
f"- ledger_rows: {len(rows)}",
|
f"- cases: {len(case_summary)}",
|
"",
|
"## Audit Status Counts",
|
"",
|
]
|
for status, count in status_counts.most_common():
|
lines.append(f"- {status}: {count}")
|
lines.extend(["", "## Case Status Counts", ""])
|
for status, count in case_status_counts.most_common():
|
lines.append(f"- {status}: {count}")
|
lines.extend(["", "## Rule Row Counts", ""])
|
for rule_id, count in rule_counts.most_common():
|
lines.append(f"- {rule_id}: {count}")
|
lines.extend(
|
[
|
"",
|
"## Boundary",
|
"",
|
"This first-pass ledger only handles rules with existing structured evidence. "
|
"Rows marked `MANUAL_REQUIRED` must be reviewed with charts before any strict note-fit conclusion is made.",
|
"",
|
]
|
)
|
(PACKAGE_ROOT / "note_fit_audit_summary.md").write_text("\n".join(lines), encoding="utf-8")
|
|
|
def main() -> None:
|
data = load_inputs()
|
builder = LedgerBuilder()
|
add_source_buy_rules(builder, data)
|
add_rolling_buy_rules(builder, data)
|
add_sell_rules(builder, data)
|
add_order_and_lot_rules(builder, data)
|
|
rows = builder.rows
|
write_csv(PACKAGE_ROOT / "case_note_fit_ledger.csv", rows, LEDGER_FIELDS)
|
|
case_summary = build_summary(rows)
|
write_csv(PACKAGE_ROOT / "case_note_fit_summary.csv", case_summary, SUMMARY_FIELDS)
|
|
manual_rows = [
|
row for row in rows
|
if row["audit_status"] == "MANUAL_REQUIRED" or row["needs_manual_review"] == "true"
|
]
|
write_csv(PACKAGE_ROOT / "manual_review_queue.csv", manual_rows, LEDGER_FIELDS)
|
|
exception_rows = [row for row in rows if row["audit_status"] == "FAIL"]
|
write_csv(PACKAGE_ROOT / "rule_exception_inventory.csv", exception_rows, LEDGER_FIELDS)
|
|
pilot_rows = build_pilot_queue(case_summary, data)
|
write_csv(PACKAGE_ROOT / "pilot_case_review_queue.csv", pilot_rows, PILOT_FIELDS)
|
|
write_summary_markdown(rows, case_summary)
|
write_json(
|
PACKAGE_ROOT / "summary.json",
|
{
|
"generated_at": now_iso(),
|
"run_id": RUN_ID,
|
"ledger_rows": len(rows),
|
"case_count": len(case_summary),
|
"status_counts": dict(Counter(row["audit_status"] for row in rows)),
|
"case_status_counts": dict(Counter(row["case_note_fit_status"] for row in case_summary)),
|
"source_files": {
|
name: len(df)
|
for name, df in data.items()
|
},
|
},
|
)
|
|
self_check_rows = build_self_check(rows, case_summary)
|
write_csv(PACKAGE_ROOT / "self_check_items.csv", self_check_rows, SELF_CHECK_FIELDS)
|
write_json(
|
PACKAGE_ROOT / "self_check.json",
|
{
|
"generated_at": now_iso(),
|
"run_id": RUN_ID,
|
"status": "PASS" if all(row["status"] == "PASS" for row in self_check_rows) else "FAIL",
|
"items": self_check_rows,
|
},
|
)
|
self_check_lines = ["# Self Check", ""]
|
for row in self_check_rows:
|
self_check_lines.append(f"- {row['check_id']}: {row['status']} - {row['details']}")
|
self_check_lines.append("")
|
(PACKAGE_ROOT / "self_check.md").write_text("\n".join(self_check_lines), encoding="utf-8")
|
|
write_csv(PACKAGE_ROOT / "manifest.csv", build_manifest(), MANIFEST_FIELDS)
|
|
|
if __name__ == "__main__":
|
main()
|