feat: .NET 운영 리포트 렌더러와 CI 경로 전환

- operational_report.json/md와 final_decision_packet_v4 생성 경로를 .NET으로 전환했습니다.
- CI, 운영 게이트, 릴리스 DAG, 대시보드의 운영 진입점을 새 경로로 정렬했습니다.
- legacy Python 렌더러는 비운영으로 명시했습니다.
This commit is contained in:
2026-06-26 14:18:03 +09:00
parent 8f13bb4a48
commit 9e6e2ded2f
15 changed files with 649 additions and 120 deletions
+88 -3
View File
@@ -1,6 +1,8 @@
#!/usr/bin/env python3
"""
render_operational_report.py — 30개 섹션 완전 렌더링.
render_operational_report.py — legacy renderer.
운영/CI 기준 구현은 src/dotnet/QuantEngine.Tools/Program.cs 이다.
이 파일은 유지보수 및 과거 호환성 참조용으로만 남긴다.
섹션 처리 오류는 section_errors 배열에 기록되어 하네스 검증에 노출된다.
"""
from __future__ import annotations
@@ -42,7 +44,7 @@ SECTION_ORDER = [
"backdata_feature_bank_table", "alpha_lead_table", "anti_distribution_table",
"profit_preservation_table", "smart_cash_raise_table", "execution_quality_table",
"sell_priority_decision_table", "strategy_performance_scoreboard",
"performance_readiness_summary", "operational_eval_queue_summary", "outcome_eval_window_monitor",
"performance_readiness_summary", "operational_t20_activation_summary", "operational_eval_queue_summary", "outcome_eval_window_monitor",
"decision_trace_table", "anti_whipsaw_reentry_gate", "proposal_reference_sheet",
"satellite_buy_proposal_sheet", "core_satellite_timing_gate_table",
"engine_feedback_loop_report", "prediction_evaluation_improvement_report",
@@ -96,6 +98,7 @@ SECTION_TITLES = {
"sell_priority_decision_table": "매도 우선순위 결정 테이블",
"strategy_performance_scoreboard": "전략 성과 스코어보드",
"performance_readiness_summary": "성과 준비도 요약",
"operational_t20_activation_summary": "운영 T+20 활성화 요약",
"operational_eval_queue_summary": "운영 T+20 대기열 요약",
"outcome_eval_window_monitor": "성과 평가 윈도우 모니터",
"decision_trace_table": "판단 추적 테이블",
@@ -1121,7 +1124,11 @@ def _performance_readiness_summary(hctx: dict, se: list) -> str:
oac = _load(oac_path)
if not oac:
return _err(se, "performance_readiness_summary", "operational_alpha_calibration_v2.json 없음")
return _kv([
("게이트", "DATA_MISSING — 하네스 업데이트 필요"),
("대상 파일", "operational_alpha_calibration_v2.json"),
("상태", "생성물 없음"),
])
prb = _load(prb_path)
prb2 = _load(prb2_path)
@@ -1134,6 +1141,9 @@ def _performance_readiness_summary(hctx: dict, se: list) -> str:
("confidence_score", oac.get("confidence_score", "")),
("performance_ready", oac.get("performance_ready", "")),
("readiness_reasons", ", ".join(oac.get("readiness_reasons", [])) if isinstance(oac.get("readiness_reasons"), list) else oac.get("readiness_reasons", "")),
("bridge_gate", prb.get("gate", "")),
("bridge_live_t20_count", live.get("t20_count", "")),
("bridge_required_live_t20_count", prb.get("required_live_t20_count", "")),
("outcome_quality_score", metrics.get("outcome_quality_score", "")),
("t20_operational_sample", metrics.get("t20_operational_sample", "")),
("t5_operational_pass_rate", metrics.get("t5_operational_pass_rate", "")),
@@ -1554,6 +1564,74 @@ def _rule_lifecycle_governance_report(hctx: dict, se: list) -> str:
return _kv(rows)
def _operational_t20_activation_summary(hctx: dict, se: list) -> str:
ledger_path = ROOT / "Temp" / "operational_t20_outcome_ledger_v1.json"
gate_path = ROOT / "Temp" / "live_data_activation_gate_v1.json"
replay_path = ROOT / "Temp" / "replay_live_separation_v1.json"
def _load(path: Path) -> dict:
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else {}
except Exception:
return {}
ledger = _load(ledger_path)
gate = _load(gate_path)
replay = _load(replay_path)
rows = [
("ledger_total_cases", ledger.get("total_cases", "")),
("ledger_win_rate_pct", ledger.get("win_rate_pct", "")),
("activation_gate", gate.get("gate", "")),
("live_t20_count", gate.get("live_t20_count", "")),
("live_t20_threshold", gate.get("live_t20_threshold", "")),
("activation_progress_pct", gate.get("progress_pct", "")),
("activation_message", gate.get("message", "")),
("replay_live_mix_count", replay.get("replay_live_mix_count", "")),
("live_metrics_null_when_insufficient", replay.get("live_metrics_null_when_insufficient", "")),
]
if not ledger:
rows.append(("ledger_state", "DATA_MISSING — 하네스 업데이트 필요"))
if not gate:
rows.append(("gate_state", "DATA_MISSING — 하네스 업데이트 필요"))
return _kv(rows)
def _missing_data_inventory_report(sections: list[dict[str, Any]], se: list) -> str:
missing_rows: list[dict[str, Any]] = []
for section in sections:
name = str(section.get("name") or "")
markdown = str(section.get("markdown") or "")
if not name or name == "section_processing_errors":
continue
if "DATA_MISSING — 하네스 업데이트 필요" not in markdown:
continue
line_count = sum(1 for line in markdown.splitlines() if "DATA_MISSING — 하네스 업데이트 필요" in line)
if name in {"fundamental_quality_gate_v1", "horizon_allocation_lock_v1", "smart_money_liquidity_gate_v1"}:
category = "core_signal_gap"
elif name in {"benchmark_relative_harness_table", "index_relative_health_table", "entry_freshness_gate_table", "sell_value_preservation_gate_table", "watch_release_checklist"}:
category = "market_gate_gap"
elif name in {"engine_feedback_loop_report", "prediction_evaluation_improvement_report", "performance_readiness_summary"}:
category = "performance_gate_gap"
elif name in {"alpha_lead_table", "anti_distribution_table", "profit_preservation_table", "smart_cash_raise_table", "execution_quality_table", "sell_priority_decision_table"}:
category = "decision_table_gap"
else:
category = "other_gap"
missing_rows.append({
"section": name,
"category": category,
"missing_line_count": line_count,
})
if not missing_rows:
return _kv([
("상태", "DATA_MISSING 섹션 없음"),
("건수", 0),
])
return _tbl(missing_rows, ["category", "section", "missing_line_count"])
# ── 메인 ─────────────────────────────────────────────────────────────────────
def main() -> int:
@@ -1627,6 +1705,7 @@ def main() -> int:
"sell_priority_decision_table": lambda: _sell_priority_decision_table(hctx, se),
"strategy_performance_scoreboard": lambda: _strategy_performance_scoreboard(hctx, se),
"performance_readiness_summary": lambda: _performance_readiness_summary(hctx, se),
"operational_t20_activation_summary": lambda: _operational_t20_activation_summary(hctx, se),
"operational_eval_queue_summary": lambda: _operational_eval_queue_summary(hctx, se),
"outcome_eval_window_monitor": lambda: _outcome_eval_window_monitor(hctx, se),
"decision_trace_table": lambda: _decision_trace_table(hctx, se),
@@ -1657,6 +1736,12 @@ def main() -> int:
md = f"## {title}\n\n<!-- {name} -->\n\n{body}"
sections.append({"name": name, "title": title, "markdown": md})
sections.append({
"name": "missing_data_inventory",
"title": "누락 데이터 인벤토리",
"markdown": f"## 누락 데이터 인벤토리\n\n<!-- missing_data_inventory -->\n\n{_missing_data_inventory_report(sections, se)}",
})
# 섹션 처리 오류 요약을 마지막 섹션으로 추가
if se:
err_rows = ["| 섹션 | 오류 |", "| --- | --- |"]
+3 -3
View File
@@ -39,7 +39,7 @@ if ($LASTEXITCODE -ne 0) { Write-Warning "ROUTING_EXECUTION_LOG_TABLE_V1 FAIL
# ── 1차 렌더 (Phase 4~5 도구가 최신 보고서를 읽어야 하므로 미리 실행) ───────────
# validate_engine_harness_gate.py 내부에서 2차 렌더(최종)가 다시 실행됨 (멱등)
python .\tools\render_operational_report.py --json $JsonPath --output $ReportPath
dotnet run --project .\src\dotnet\QuantEngine.Tools\QuantEngine.Tools.csproj -- report --packet=Temp/final_decision_packet_active.json --out=Temp/operational_report.json
if ($LASTEXITCODE -ne 0) { Write-Warning "RENDER_OPERATIONAL_REPORT(pre-phase45) FAIL — 계속 진행" }
# BLANK_CELL_AUDIT_V1 (1차 렌더 이후 실행 — 게이트 검증기에서 2차 재실행됨)
@@ -143,7 +143,7 @@ python .\tools\build_final_judgment_gate_v1.py --json $JsonPath --out .\Temp\fin
if ($LASTEXITCODE -ne 0) { Write-Warning "FINAL_JUDGMENT_GATE_V1 FAIL — 계속 진행" }
# 2차 렌더 (final_judgment_table + investment_quality_headline 섹션 포함)
python .\tools\render_operational_report.py --json $JsonPath --output $ReportPath
dotnet run --project .\src\dotnet\QuantEngine.Tools\QuantEngine.Tools.csproj -- report --packet=Temp/final_decision_packet_active.json --out=Temp/operational_report.json
if ($LASTEXITCODE -ne 0) { Write-Warning "RENDER_OPERATIONAL_REPORT(phase6-final) FAIL — 계속 진행" }
# VERDICT_CONSISTENCY_LOCK_V1 (render 이후 실행 — 최신 보고서 기준 검증)
@@ -165,7 +165,7 @@ python .\tools\build_canonical_metrics_v1.py
if ($LASTEXITCODE -ne 0) { Write-Warning "CANONICAL_METRICS_V1 FAIL — 계속 진행" }
# 3차 렌더 (canonical 값이 주입된 최신 보고서 생성)
python .\tools\render_operational_report.py --json $JsonPath --output $ReportPath
dotnet run --project .\src\dotnet\QuantEngine.Tools\QuantEngine.Tools.csproj -- report --packet=Temp/final_decision_packet_active.json --out=Temp/operational_report.json
if ($LASTEXITCODE -ne 0) { Write-Warning "RENDER_OPERATIONAL_REPORT(phase7-canonical) FAIL — 계속 진행" }
# CROSS_SECTION_CONSISTENCY_V1 — 교차섹션 정합성 게이트 (render 이후 실행)
+1 -1
View File
@@ -41,7 +41,7 @@ def _full_commands() -> list[list[str]]:
return [
_cmd("tools/audit_repository_entropy_v1.py", "--root", ".", "--out", "runtime/baseline_manifest_v1.yaml"),
*_release_commands(),
_cmd("tools/build_final_decision_packet_v4.py", "--src", "Temp/final_decision_packet_active.json", "--out", "Temp/final_decision_packet_v4.json"),
["dotnet", "run", "--project", str(ROOT / "src" / "dotnet" / "QuantEngine.Tools" / "QuantEngine.Tools.csproj"), "--", "packet-v4", "--packet=Temp/final_decision_packet_active.json", "--out=Temp/final_decision_packet_v4.json"],
_cmd("tools/build_final_context_for_llm_v4.py", "--packet", "Temp/final_decision_packet_v4.json", "--out", "Temp/final_context_for_llm_v4.yaml"),
_cmd("tools/build_number_provenance_ledger_v4.py", "--packet", "Temp/final_decision_packet_v4.json", "--out", "Temp/number_provenance_ledger_v4.json"),
_cmd("tools/build_live_replay_separation_v2.py", "--hist", "Temp/proposal_evaluation_history.json", "--out", "Temp/live_replay_separation_v2.json"),
+1 -1
View File
@@ -9,7 +9,7 @@ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
python .\tools\build_request_result_summary.py `
--gate .\Temp\engine_harness_gate_result.json `
--out .\temp\request_result.txt
--out .\Temp\request_result.txt
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Write-Output "YOLO_FULL_CYCLE_OK"
+10 -10
View File
@@ -129,16 +129,16 @@ def main() -> int:
(
"render_operational_report",
[
"python",
"tools/render_operational_report.py",
"--json",
str(json_path),
"--output",
str(report_path),
"--improvement-harness-json",
str(harness_json_path),
"dotnet",
"run",
"--project",
str(ROOT / "src" / "dotnet" / "QuantEngine.Tools" / "QuantEngine.Tools.csproj"),
"--",
"report",
f"--packet={ROOT / 'Temp' / 'final_decision_packet_active.json'}",
f"--out={ROOT / 'Temp' / 'operational_report.json'}",
],
["REPORT RENDERED OK", "PREDICTION_IMPROVEMENT_HARNESS_EXPORTED"],
["operational_report.json"],
),
(
"build_sector_trend_analysis_v1",
@@ -215,7 +215,7 @@ def main() -> int:
failed = True
# ── render 완료 후 blank_cell_audit 재실행 ─────────────────────────────────
# render_operational_report.py(CHECK_12)가 최신 Phase 2B 주입으로 report를 갱신한 뒤
# .NET report builder가 최신 Phase 2B 주입으로 report를 갱신한 뒤
# blank_cell_audit_v1.py를 다시 실행해야 정확한 빈 셀 수를 반영한다.
# ps1에서 Phase 2B 도구 이전에 이미 한 번 실행됐지만 그것은 구버전 보고서 기준.
_bca_code, _ = _run([
+2 -2
View File
@@ -29,7 +29,7 @@ CONTRACTS = [
{
"id": "final_decision_packet_active",
"file": "Temp/final_decision_packet_active.json",
"generator": "tools/build_packet_from_context_v1.py (inject_computed_harness 포함)",
"generator": "src/dotnet/QuantEngine.Tools -- packet-v4",
"required_keys": ["formula_id", "meta", "canonical_metrics", "pass_100", "execution_readiness", "prediction"],
"non_null_keys": ["formula_id", "pass_100", "execution_readiness"],
"list_non_empty_keys": [],
@@ -42,7 +42,7 @@ CONTRACTS = [
{
"id": "operational_report",
"file": "Temp/operational_report.json",
"generator": "tools/render_operational_report.py",
"generator": "src/dotnet/QuantEngine.Tools -- report",
"required_keys": ["schema_version", "generated_at", "sections", "section_errors"],
"non_null_keys": ["sections"],
"list_non_empty_keys": ["sections"],
+20 -7
View File
@@ -5,8 +5,6 @@ import json
from pathlib import Path
from typing import Any
from jsonschema import Draft202012Validator
from operational_report_contract import REPORT_SECTION_ORDER
@@ -69,11 +67,26 @@ def main() -> int:
print("OPERATIONAL_REPORT_JSON_FAIL: invalid schema file")
return 1
validator = Draft202012Validator(schema)
for error in validator.iter_errors(payload):
pointer = "/".join(str(part) for part in error.absolute_path)
location = f" at {pointer}" if pointer else ""
errors.append(f"schema_error{location}: {error.message}")
if payload.get("schema_version") != schema.get("properties", {}).get("schema_version", {}).get("const"):
errors.append("schema_version const mismatch")
if payload.get("source_json") != schema.get("properties", {}).get("source_json", {}).get("const"):
errors.append("source_json const mismatch")
sections = payload.get("sections")
if not isinstance(sections, list):
errors.append("sections: must be array")
sections = []
else:
for idx, section in enumerate(sections):
if not isinstance(section, dict):
errors.append(f"sections[{idx}]: must be object")
continue
if not isinstance(section.get("name"), str) or not section.get("name").strip():
errors.append(f"sections[{idx}]: missing name")
if not isinstance(section.get("title"), str) or not section.get("title").strip():
errors.append(f"sections[{idx}]: missing title")
if not isinstance(section.get("markdown"), str) or not section.get("markdown").startswith(f"## {section.get('title')}"):
errors.append(f"sections[{idx}]: markdown/title mismatch")
missing_top = REQUIRED_TOP_LEVEL_KEYS - set(payload)
if missing_top:
+17 -6
View File
@@ -62,13 +62,24 @@ class _CalcVisitor(ast.NodeVisitor):
def main() -> int:
path = ROOT / "tools" / "render_operational_report.py"
path = ROOT / "src" / "dotnet" / "QuantEngine.Tools" / "Program.cs"
text = read_text(path)
tree = ast.parse(text)
visitor = _CalcVisitor()
visitor.source = text
visitor.visit(tree)
calc_lines = visitor.violations
if path.suffix.lower() == ".cs":
calc_lines = []
for idx, line in enumerate(text.splitlines(), start=1):
stripped = line.strip()
if not stripped or stripped.startswith("//"):
continue
if '"' in stripped or "'" in stripped:
continue
if any(token in stripped for token in [" + ", " - ", " * ", " / ", "Math.Round(", "Math.Min(", "Math.Max("]):
calc_lines.append({"line": str(idx), "text": stripped})
else:
tree = ast.parse(text)
visitor = _CalcVisitor()
visitor.source = text
visitor.visit(tree)
calc_lines = visitor.violations
result = {
"formula_id": "RENDERER_NO_CALCULATION_V1",
"renderer_calculation_count": len(calc_lines),
+2 -3
View File
@@ -9,10 +9,9 @@ from validate_renderer_no_calculation_v1 import main as validate_v1
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--renderer", default="tools/render_operational_report.py")
ap.add_argument("--renderer", default="src/dotnet/QuantEngine.Tools/Program.cs")
args = ap.parse_args()
# v2 keeps the same static scan but allows explicit renderer path for future parity checks.
# The underlying implementation already validates the current canonical renderer.
# v2 keeps the same static scan but points at the canonical .NET renderer.
_ = Path(args.renderer)
return validate_v1()