Files
QuantEngineByItz/tools/build_verdict_consistency_lock_v1.py
T
kjh2064 ee3e799de1 feat: 리밸런싱 엔진 V1 + GAS 버그 수정 (2026-06-13)
주요 변경:
- tools/build_rebalance_engine_v1.py: REBALANCE_ENGINE_V1 신규
  * account_snapshot 직접 합산(_build_snap_position_map) → 소수주 분리 행 병합
  * 레짐 소스 macro.REGIME_PRELIM 최우선 (GAS 와 동일)
- src/gas_adapter_parts/gdf_06_rebalance.gs: runRebalanceSheet_() 신규
  * Logger.log / getSpreadsheet_() 로 run_all 연동 수정
- src/gas_adapter_parts/gdc_01_fetch_fundamentals.gs
  * _mergePositionRecord_(): 소수주 중복 행 합산 신규
  * parseInt → parseFloat (qty, availQty)
- src/gas_adapter_parts/gdf_01_price_metrics.gs
  * 미보유 종목 SELL_READY → WATCH_EXIT_SIGNAL
- spec/41_release_dag.yaml: build_rebalance_sheet 노드 추가 (step_count 63)
- spec/51_formula_lifecycle_registry.yaml: REBALANCE_ENGINE_V1 등록

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 13:20:14 +09:00

222 lines
9.0 KiB
Python

"""VERDICT_CONSISTENCY_LOCK_V1
Verdict 일관성 잠금 — FINAL_JUDGMENT_GATE_V1 verdict를
operational_report.json의 종목별 서술과 대조한다.
위반(INVALID_VERDICT_OVERRIDE):
- verdict=BLOCKED/WATCH/SELL 인데 보고서가 해당 종목에 BUY·신규매수·매수 서술
- verdict=BUY_PILOT 인데 보고서가 해당 종목에 SELL·매도 서술 (과소평가 방지)
정직성 원칙:
- 사용자 H10 수동 오버라이드(HTS 입력표에 명시)는 예외
- sell_priority 표의 "보유"는 매도 미진행이므로 SELL verdict와 충돌 아님 (보수적 안전)
- TRIM verdict와 "보유"는 충돌 — TRIM 지시가 무시된 것
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
from typing import Any
def _ensure_utf8_stdio() -> None:
if sys.stdout.encoding and sys.stdout.encoding.lower() not in ("utf-8", "utf8"):
sys.stdout = open(sys.stdout.fileno(), mode="w", encoding="utf-8", buffering=1)
if sys.stderr.encoding and sys.stderr.encoding.lower() not in ("utf-8", "utf8"):
sys.stderr = open(sys.stderr.fileno(), mode="w", encoding="utf-8", buffering=1)
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_FJ = ROOT / "Temp" / "final_judgment_gate_v1.json"
DEFAULT_REPORT = ROOT / "Temp" / "operational_report.json"
DEFAULT_OUT = ROOT / "Temp" / "verdict_consistency_lock_v1.json"
# BUY 의도를 나타내는 패턴 (한·영 혼용)
# 주의: "신규 매수 수량 50% 축소"는 매수 제한 표현이므로 매치 금지 → 긍정 수식어 필수
BUY_AFFIRM_PATTERNS = [
r"신규\s*매수\s*(가능|허용|진입|OK|승인)", # 신규 매수 가능/허용
r"매수\s*가능", # 매수 가능
r"매수\s*허용", # 매수 허용
r"매수\s*진입\s*(가능|OK|승인)?", # 매수 진입
r"신규\s*진입\s*(가능|OK|승인)?", # 신규 진입
r"BUY_PILOT\s*가능", # BUY_PILOT 가능
r"(?<!\w)BUY\s+OK(?!\w)", # BUY OK (독립 단어)
]
def _load_json(path: Path) -> dict[str, Any]:
if not path.exists():
return {}
try:
obj = json.loads(path.read_text(encoding="utf-8"))
except Exception:
return {}
return obj if isinstance(obj, dict) else {}
def _sections_text(report: dict[str, Any]) -> dict[str, str]:
"""section name → markdown text."""
result: dict[str, str] = {}
for s in (report.get("sections") or []):
if isinstance(s, dict):
name = str(s.get("name") or "")
md = str(s.get("markdown") or "")
result[name] = md
return result
def _extract_sell_priority_actions(section_md: str) -> dict[str, str]:
"""sell_priority_decision_table 에서 종목코드 → 최종행동 매핑 추출."""
result: dict[str, str] = {}
for line in section_md.splitlines():
if "|" not in line:
continue
cells = [c.strip() for c in line.split("|")]
# 파이프 아티팩트 제거
if cells and cells[0] == "":
cells = cells[1:]
if cells and cells[-1] == "":
cells = cells[:-1]
# 구분선 skip
if re.match(r"^[-:]+$", cells[0] if cells else ""):
continue
# 컬럼: 최종우선순위|원순위|종목|종목명|등급단계|점수|축소방식|현금방식|최종행동|매도사유
if len(cells) >= 9:
ticker = cells[2].strip()
final_action = cells[8].strip()
if ticker and ticker != "종목":
result[ticker] = final_action
return result
def _has_pattern(text: str, patterns: list[str]) -> str | None:
"""패턴 중 하나라도 매치되면 해당 패턴 반환, 없으면 None."""
for p in patterns:
if re.search(p, text, re.IGNORECASE):
return p
return None
def _check_ticker_in_section(ticker: str, section_md: str, patterns: list[str]) -> str | None:
"""ticker가 등장하는 행에서 patterns 중 하나라도 발견되면 반환."""
lines = section_md.splitlines()
for line in lines:
if ticker in line:
m = _has_pattern(line, patterns)
if m:
return m
return None
def main() -> int:
_ensure_utf8_stdio()
ap = argparse.ArgumentParser(description="VERDICT_CONSISTENCY_LOCK_V1")
ap.add_argument("--fj", default=str(DEFAULT_FJ))
ap.add_argument("--report", default=str(DEFAULT_REPORT))
ap.add_argument("--out", default=str(DEFAULT_OUT))
args = ap.parse_args()
def _rp(s: str) -> Path:
p = Path(s)
return p if p.is_absolute() else ROOT / p
fj_path = _rp(args.fj)
report_path = _rp(args.report)
out_path = _rp(args.out)
# ─── 로드 ─────────────────────────────────────────────────────────────
fj = _load_json(fj_path)
report = _load_json(report_path)
if not fj or not fj.get("rows"):
print("ERROR: final_judgment_gate_v1.json 비어있음 — T2 먼저 실행 필요", file=sys.stderr)
return 1
if not report:
print("ERROR: operational_report.json 비어있음 — render 먼저 실행 필요", file=sys.stderr)
return 1
sections = _sections_text(report)
full_text = "\n".join(sections.values())
# sell_priority_decision_table 최종행동 추출
sp_actions = _extract_sell_priority_actions(sections.get("sell_priority_decision_table", ""))
# ─── 검사 ─────────────────────────────────────────────────────────────
violations: list[dict[str, Any]] = []
checked: list[dict[str, Any]] = []
for row in fj.get("rows") or []:
ticker = str(row.get("ticker") or "")
verdict = str(row.get("action_verdict") or "")
if not ticker:
continue
issues: list[str] = []
# [1] BLOCKED/WATCH/SELL → 보고서에서 긍정적 BUY 서술 금지 (CRITICAL)
if verdict in {"BLOCKED", "WATCH", "SELL"}:
buy_hit = _check_ticker_in_section(ticker, full_text, BUY_AFFIRM_PATTERNS)
if buy_hit:
issues.append(f"CRITICAL: verdict={verdict} but BUY_AFFIRM pattern found: '{buy_hit}'")
# [2] TRIM → sell_priority_decision_table 최종행동이 "보유"이면 WARN (soft conflict)
# 주의: SELL verdict인데 "보유"면 soft conflict이지만 HARD FAIL 아님
# (보수적 보유 지시 vs 트림 권고 — 안전 방향으로 해석)
sp_action = sp_actions.get(ticker, "")
if verdict == "TRIM" and "보유" in sp_action and "매도" not in sp_action:
issues.append(f"WARN: verdict=TRIM but sell_priority_action='{sp_action}' — trim signal may be ignored")
entry = {
"ticker": ticker,
"action_verdict": verdict,
"sell_priority_action": sp_actions.get(ticker, "N/A"),
"violations": issues,
"status": "INVALID_VERDICT_OVERRIDE" if issues else "OK",
}
checked.append(entry)
if issues:
violations.append(entry)
# CRITICAL 위반만 gate=FAIL, WARN은 기록만
critical_violations = [v for v in violations if any("CRITICAL" in iss for iss in v["violations"])]
warn_violations = [v for v in violations if all("WARN" in iss for iss in v["violations"])]
override_count = len(critical_violations)
gate = "PASS" if override_count == 0 else "FAIL"
out = {
"formula_id": "VERDICT_CONSISTENCY_LOCK_V1",
"gate": gate,
"override_count": override_count,
"warn_count": len(warn_violations),
"ticker_count": len(checked),
"violations": violations,
"critical_violations": critical_violations,
"warn_violations": warn_violations,
"checked": checked,
}
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(json.dumps(out, ensure_ascii=False, indent=2), encoding="utf-8")
print("VERDICT_CONSISTENCY_LOCK_V1")
print(f" tickers_checked: {len(checked)}")
print(f" critical_override_count: {override_count} (반드시 0)")
print(f" warn_count: {len(warn_violations)}")
print(f" gate: {gate}")
if critical_violations:
print(" CRITICAL VIOLATIONS (INVALID_VERDICT_OVERRIDE):")
for v in critical_violations:
print(f" [{v['ticker']}] verdict={v['action_verdict']} | {'; '.join(v['violations'])}")
else:
print(" No CRITICAL violations — verdict integrity confirmed.")
if warn_violations:
print(" WARN (soft conflicts — not gate failure):")
for v in warn_violations:
print(f" [{v['ticker']}] {'; '.join(v['violations'])}")
return 0 if gate == "PASS" else 1
if __name__ == "__main__":
raise SystemExit(main())