ee3e799de1
주요 변경: - 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>
222 lines
9.0 KiB
Python
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())
|