feat(kis-collection): finalize sqlite migration, add fallback resilience, and update WBS documentation

This commit is contained in:
2026-06-22 18:34:56 +09:00
parent c576138829
commit 6c549b7bdc
48 changed files with 34610 additions and 24883 deletions
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env python3
from __future__ import annotations
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "Temp" / "document_search_index_v1.json"
EXCLUDED_PREFIXES = ("docs/archive/", "suggest/", "artifacts/archive/")
INCLUDED_ROOTS = ("docs", "spec", "governance", "src", "tools", "AGENTS.md", "README.md")
def _is_excluded(rel: str) -> bool:
return rel.startswith(EXCLUDED_PREFIXES)
def main() -> int:
indexed: list[str] = []
excluded: list[str] = []
for path in ROOT.rglob("*"):
if not path.is_file():
continue
rel = path.relative_to(ROOT).as_posix()
if _is_excluded(rel):
excluded.append(rel)
continue
if rel.startswith("docs/") or rel.startswith("spec/") or rel.startswith("governance/") or rel.startswith("src/") or rel.startswith("tools/") or rel in {"AGENTS.md", "README.md"}:
indexed.append(rel)
result = {
"formula_id": "DOCUMENT_SEARCH_INDEX_V1",
"gate": "PASS",
"indexed_count": len(indexed),
"excluded_count": len(excluded),
"excluded_prefixes": list(EXCLUDED_PREFIXES),
"indexed_sample": sorted(indexed)[:50],
"excluded_sample": sorted(excluded)[:50],
}
OUT.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+58 -14
View File
@@ -28,6 +28,7 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
# 입력 파일
PREDICTION_ACCURACY = ROOT / "Temp" / "prediction_accuracy_harness_v2.json"
REBOUND_EFF = ROOT / "Temp" / "rebound_sell_efficiency_v1.json"
LATE_CHASE = ROOT / "Temp" / "late_chase_attribution_v1.json"
PROPOSAL_HIS = ROOT / "Temp" / "proposal_evaluation_history.json"
@@ -46,6 +47,30 @@ def load_json(p: Path) -> dict | list:
return json.loads(p.read_text(encoding="utf-8"))
def load_prediction_accuracy() -> dict:
data = load_json(PREDICTION_ACCURACY)
return data if isinstance(data, dict) else {}
def current_t5_status() -> tuple[float | None, str]:
"""WBS-7.2 source-of-truth shim.
Prefer the latest prediction accuracy harness when present. Do not fall back to
stale hardcoded percentages when the harness explicitly says sample=0.
"""
data = load_prediction_accuracy()
if not data:
return None, "ARTIFACT_MISSING"
t5_sample = int(data.get("t5_sample") or 0)
t5_rate = data.get("t5_op_rate")
if t5_sample == 0:
return None, "DATA_GATED"
if isinstance(t5_rate, (int, float)):
return float(t5_rate), "OK"
return None, "DATA_MISSING"
def main() -> int:
rebound = load_json(REBOUND_EFF)
chase = load_json(LATE_CHASE)
@@ -90,7 +115,8 @@ def main() -> int:
})
# ── (3) T+1 / T+5 KPI 추적 ─────────────────────────────────────────
# operational_report 에서 일치율 추출
# operational_report는 보고서 텍스트용 보조 원장이고,
# T+5 현재값은 prediction_accuracy_harness_v2.json을 우선한다.
t1_rate = None
t5_rate = None
sections = op.get("sections", []) if isinstance(op, dict) else []
@@ -109,7 +135,11 @@ def main() -> int:
# 직접 알려진 값 사용 (operational_report 에서 확인된 수치)
if t1_rate is None: t1_rate = 47.28
if t5_rate is None: t5_rate = 35.86
live_t5_rate, live_t5_status = current_t5_status()
if live_t5_rate is not None:
t5_rate = live_t5_rate
elif t5_rate is None:
t5_rate = None
kpi_tracker.append({
"metric": "T+1_match_rate_pct",
@@ -119,14 +149,24 @@ def main() -> int:
"status": "BELOW_TARGET" if t1_rate < 55.0 else "ON_TARGET",
"note": "동전던지기(50%) 이하 — 신호 품질 개선 필요",
})
kpi_tracker.append({
"metric": "T+5_match_rate_pct",
"current": t5_rate,
"target_min": 55.0,
"gap": round(55.0 - t5_rate, 2),
"status": "BELOW_TARGET" if t5_rate < 55.0 else "ON_TARGET",
"note": "T+5 35.86% — ANTI_LATE_ENTRY_GATE_V2 임계값 보정 시 개선 목표",
})
if t5_rate is None:
kpi_tracker.append({
"metric": "T+5_match_rate_pct",
"current": None,
"target_min": 55.0,
"gap": None,
"status": "DATA_GATED",
"note": f"T+5 current source={live_t5_status} — sample=0 or artifact missing; do not cite stale 35.86%",
})
else:
kpi_tracker.append({
"metric": "T+5_match_rate_pct",
"current": t5_rate,
"target_min": 55.0,
"gap": round(55.0 - t5_rate, 2),
"status": "BELOW_TARGET" if t5_rate < 55.0 else "ON_TARGET",
"note": "T+5 current source-of-truth read from prediction_accuracy_harness_v2.json",
})
# ── (4) OUTCOME_TRUST_GAP ───────────────────────────────────────────
# design_score 97.12 vs 실측 T+5 35.86% 간 신뢰도 괴리
@@ -134,7 +174,8 @@ def main() -> int:
"design_score": rb_score,
"actual_t5_pct": t5_rate,
"gap_note": (
f"설계점수 rebound_efficiency={rb_score:.2f} vs 실측 T+5 일치율 {t5_rate}% — "
f"설계점수 rebound_efficiency={rb_score:.2f} vs 실측 T+5 일치율 "
f"{('DATA_GATED' if t5_rate is None else f'{t5_rate}%')}"
f"설계점수가 높아도 실제 수익성 지표(T+5)는 낮을 수 있음. "
f"두 지표를 항상 물리적으로 분리해 표시해야 한다."
),
@@ -153,11 +194,14 @@ def main() -> int:
print(f"\n [T+1/T+5 KPI 현황]")
for k in kpi_tracker:
status_icon = "" if k["status"] == "BELOW_TARGET" else ""
print(f" {k['metric']}: {k['current']}% (목표 ≥{k['target_min']}%) {status_icon}")
if k["current"] is None:
print(f" {k['metric']}: DATA_GATED (목표 ≥{k['target_min']}%) {status_icon}")
else:
print(f" {k['metric']}: {k['current']}% (목표 ≥{k['target_min']}%) {status_icon}")
print(f"{k['note']}")
print(f"\n [보정루프 개선 경로]")
print(f" T+5 35.86% → 50%+ 목표:")
print(f" T+5 {'DATA_GATED' if t5_rate is None else f'{t5_rate}%'} → 50%+ 목표:")
print(f" Step 1. ALEG_V2_GATE1_BLOCK_PCT(3%) → 표본 누적 후 최적값 보정")
print(f" Step 2. DSD_V1 가중치 → logistic regression 최적화")
print(f" Step 3. K2 분할비율 0.5 → 30/70/40/60/50/50 backtest 비교")
@@ -191,7 +235,7 @@ def main() -> int:
"correction_steps": [
f"rebound_efficiency_score={rb_score:.2f} → 보고서 표시 시 [UNVALIDATED_DESIGN_SCORE: n={rb_combo}] 주석 필수",
f"late_chase_attribution: samples=0 → 최소 {SAMPLE_MIN}건 표본 누적 후 chase_entry_rate 검증",
f"T+5 {t5_rate}% → 보정루프(calibration_registry.yaml) 기반 임계값 최적화로 50%+ 목표",
f"T+5 {'DATA_GATED' if t5_rate is None else f'{t5_rate}%'} → 보정루프(calibration_registry.yaml) 기반 임계값 최적화로 50%+ 목표",
],
}
@@ -0,0 +1,46 @@
#!/usr/bin/env python3
from __future__ import annotations
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
TEMP = ROOT / "Temp"
PLACEHOLDERS = {
"breakout_failure_stop_v1.json": "DATA_MISSING",
"consecutive_streak_v1.json": "DATA_MISSING",
"execution_capacity_ladder_v1.json": "DATA_MISSING",
"execution_plan_compiler_v1.json": "DATA_MISSING",
"fifty_two_week_high_trigger_v1.json": "DATA_MISSING",
"golden_cross_signal_v1.json": "DATA_MISSING",
"immutable_decision_ledger_v1.json": "DATA_MISSING",
"model_governance_kill_switch_v1.json": "DATA_MISSING",
"portfolio_transition_optimizer_v1.json": "DATA_MISSING",
"scenario_shock_matrix_v1.json": "DATA_MISSING",
"sector_exposure_graph_v1.json": "DATA_MISSING",
"strong_close_signal_v1.json": "DATA_MISSING",
"trend_filter_gate_v1.json": "DATA_MISSING",
"volatility_expansion_breakout_v1.json": "DATA_MISSING",
}
def main() -> int:
TEMP.mkdir(parents=True, exist_ok=True)
for name, value in PLACEHOLDERS.items():
path = TEMP / name
payload = {
"formula_id": path.stem.upper(),
"gate": "DATA_MISSING",
"status": "DATA_MISSING",
"value": value,
"note": "harness update required",
}
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"wrote {path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+133 -7
View File
@@ -1,7 +1,7 @@
"""qualitative_sell_strategy_v1 입력 ctx 조립 오케스트레이터.
데이터 출처 (2026-06-21 세션 실측 기준, KIS Open API 연동 이후):
- relative_return_20d, volume_ratio_5d ← tools/fetch_naver_market_data_v1.py (무인증, 동작 확인)
데이터 출처 (2026-06-22 기준, KIS Open API 우선):
- relative_return_20d, volume_ratio_5d ← KIS Open API 우선, Naver는 fallback
- sector_export_trend ← tools/fetch_trade_statistics_motie_v1.py (--csv 경로 권장)
- short_turnover_share ← [신규] KIS Open API daily-short-sale(FHPST04830000)
output2.ssts_vol_rlim — 실측 동작 확인(실전계좌 도메인,
@@ -81,6 +81,125 @@ def _parse_date(value: str | None) -> dt.date | None:
return None
def _coerce_price_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
normalized: list[dict[str, Any]] = []
for row in rows:
if not isinstance(row, dict):
continue
normalized.append(
{
"date": str(row.get("date") or "").strip(),
"close": row.get("close"),
"open": row.get("open"),
"high": row.get("high"),
"low": row.get("low"),
"volume": row.get("volume"),
}
)
return [row for row in normalized if row["date"]]
def _parse_kis_price_rows(payload: dict[str, Any]) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for key in ("output2", "output1", "output"):
items = payload.get(key)
if not isinstance(items, list):
continue
for item in items:
if not isinstance(item, dict):
continue
date = str(
item.get("stck_bsop_date")
or item.get("data_date")
or item.get("trd_dd")
or item.get("date")
or ""
).strip()
close = item.get("stck_clpr") or item.get("close") or item.get("price")
volume = item.get("acml_vol") or item.get("volume") or item.get("trd_vol") or 0
if not date:
continue
try:
close_val = float(str(close).replace(",", ""))
except Exception:
close_val = 0.0
try:
volume_val = float(str(volume).replace(",", ""))
except Exception:
volume_val = 0.0
rows.append(
{
"date": date.replace(".", "-"),
"close": close_val,
"open": float(str(item.get("stck_oprc") or item.get("open") or close or 0).replace(",", "")) if str(item.get("stck_oprc") or item.get("open") or close or 0).replace(",", "").strip() else close_val,
"high": float(str(item.get("stck_hgpr") or item.get("high") or close or 0).replace(",", "")) if str(item.get("stck_hgpr") or item.get("high") or close or 0).replace(",", "").strip() else close_val,
"low": float(str(item.get("stck_lwpr") or item.get("low") or close or 0).replace(",", "")) if str(item.get("stck_lwpr") or item.get("low") or close or 0).replace(",", "").strip() else close_val,
"volume": volume_val,
}
)
return rows
def fetch_price_history_kis(code: str, kis_account: str | None, benchmark_code: str | None = None) -> dict[str, Any]:
if not kis_account:
return {"status": "DATA_MISSING", "rows": []}
from src.quant_engine.kis_api_client_v1 import KisCredentials, get_daily_item_chart_price
try:
creds = KisCredentials.load(kis_account)
except RuntimeError as exc:
return {"status": "DATA_MISSING", "rows": [], "error": str(exc)}
try:
today = dt.date.today()
end = today.strftime("%Y%m%d")
start = (today - dt.timedelta(days=40)).strftime("%Y%m%d")
payload = get_daily_item_chart_price(creds, code, start, end, period="D")
rows = _parse_kis_price_rows(payload)
if benchmark_code is not None and not rows:
return {"status": "DATA_MISSING", "rows": []}
if rows:
return {
"status": "OK",
"rows": rows,
"source_url": "KIS Open API /uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice",
"source_as_of": _kst_now_iso(),
}
except Exception as exc: # noqa: BLE001
return {"status": "DATA_MISSING", "rows": [], "error": str(exc)}
return {"status": "DATA_MISSING", "rows": []}
def _fetch_price_bundle(
code: str,
*,
kis_account: str | None,
prefer_kis: bool = True,
) -> dict[str, Any]:
"""가격 히스토리와 벤치마크 히스토리를 동일 규칙으로 조립한다.
SRP:
- 소스 선택은 이 함수가 담당
- 상대수익률/거래량 비율 계산은 계산 함수가 담당
- 호출자(process_one)는 결과만 소비한다
"""
kis_price = fetch_price_history_kis(code, kis_account)
if prefer_kis and kis_price.get("status") == "OK":
return {
"source": "kis_open_api",
"price": kis_price,
}
session = _session()
naver_price = fetch_price_history(session, code)
source = "naver_finance" if naver_price.get("status") == "OK" else "data_missing"
return {
"source": source,
"price": naver_price,
"kis_price": kis_price,
}
def load_short_interest_csv(path: Path, code: str) -> dict[str, Any]:
"""KRX 공매도종합포털 수동 다운로드 CSV. 컬럼: 종목코드, 잔고율, 잔고율변화20일, 거래비중."""
import csv
@@ -145,12 +264,15 @@ def build_ctx_for_ticker(
external_context: dict[str, Any],
kis_account: str | None = None,
) -> dict[str, Any]:
session = _session()
price = fetch_price_history(session, code)
benchmark = fetch_price_history(session, benchmark_code)
price_bundle = _fetch_price_bundle(code, kis_account=kis_account, prefer_kis=True)
benchmark_bundle = _fetch_price_bundle(benchmark_code, kis_account=kis_account, prefer_kis=True)
price = price_bundle["price"]
benchmark = benchmark_bundle["price"]
relative_return_20d = compute_relative_return_20d(price.get("rows", []), benchmark.get("rows", []))
volume_ratio_5d = compute_volume_ratio_5d(price.get("rows", []))
price_rows = _coerce_price_rows(price.get("rows") or [])
benchmark_rows = _coerce_price_rows(benchmark.get("rows") or [])
relative_return_20d = compute_relative_return_20d(price_rows, benchmark_rows)
volume_ratio_5d = compute_volume_ratio_5d(price_rows)
kis_supplement = fetch_kis_supplement(code, kis_account)
short_inputs: dict[str, Any] = {}
@@ -195,6 +317,10 @@ def build_ctx_for_ticker(
"relative_return_20d": relative_return_20d,
"volume_ratio_5d": volume_ratio_5d,
"kis_supplement": kis_supplement,
"price_source": price_bundle["source"],
"price_source_url": price.get("source_url"),
"benchmark_source": benchmark_bundle["source"],
"benchmark_source_url": benchmark.get("source_url"),
"generated_at": _kst_now_iso(),
}
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env python3
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
TEMP = ROOT / "Temp"
OUTPUT = TEMP / "wbs_4_1_7_1_status_v1.json"
def _load_json(path: Path) -> dict[str, Any]:
if not path.exists():
return {}
try:
payload = json.loads(path.read_text(encoding="utf-8"))
return payload if isinstance(payload, dict) else {}
except Exception:
return {}
def _pick_top_candidates(priority: dict[str, Any], limit: int = 5) -> list[dict[str, Any]]:
rows = priority.get("priority_list") if isinstance(priority.get("priority_list"), list) else []
out: list[dict[str, Any]] = []
for row in rows[:limit]:
if isinstance(row, dict):
out.append(
{
"calibration_id": row.get("calibration_id"),
"source": row.get("source"),
"sample_n": row.get("sample_n"),
"urgency_score": row.get("urgency_score"),
"current_value": row.get("current_value"),
"owner_formula": row.get("owner_formula"),
}
)
return out
def main() -> int:
live_gate = _load_json(TEMP / "live_data_activation_gate_v1.json")
op_queue = _load_json(TEMP / "operational_eval_queue_v1.json")
calib_reg = _load_json(TEMP / "calibration_registry_v1.json")
calib_priority = _load_json(TEMP / "calibration_priority_v1.json")
pred_acc = _load_json(TEMP / "prediction_accuracy_harness_v2.json")
live_t20_count = int(live_gate.get("live_t20_count") or 0)
live_t20_threshold = int(live_gate.get("live_t20_threshold") or 30)
live_progress_pct = float(live_gate.get("progress_pct") or 0.0)
live_gate_state = str(live_gate.get("gate") or "PENDING")
eval_metrics = op_queue.get("metrics") if isinstance(op_queue.get("metrics"), dict) else {}
records_total = int(eval_metrics.get("records_total") or 0)
t20_evaluated_count = int(eval_metrics.get("t20_evaluated_count") or 0)
t20_due_capture_count = int(eval_metrics.get("t20_due_capture_count") or 0)
reg_counts = {
"total_thresholds": int(calib_reg.get("total_thresholds") or 0),
"calibrated_count": int(calib_reg.get("calibrated_count") or 0),
"provisional_count": int(calib_reg.get("provisional_count") or 0),
"expert_prior_count": int(calib_reg.get("expert_prior_count") or 0),
"spec_derived_count": int(calib_reg.get("spec_derived_count") or 0),
"unregistered_threshold_count": int(calib_reg.get("unregistered_threshold_count") or 0),
"overclaimed_count": int(calib_reg.get("overclaimed_count") or 0),
}
pred_t5_sample = int(pred_acc.get("t5_sample") or 0)
pred_t5_rate = pred_acc.get("t5_op_rate")
pred_t5_state = "DATA_GATED" if pred_t5_sample == 0 else "OK"
status = {
"formula_id": "WBS_4_1_7_1_STATUS_V1",
"wbs_4_1": {
"live_t20_count": live_t20_count,
"live_t20_threshold": live_t20_threshold,
"live_progress_pct": live_progress_pct,
"live_gate": live_gate_state,
"records_total": records_total,
"t20_evaluated_count": t20_evaluated_count,
"t20_due_capture_count": t20_due_capture_count,
"operational_queue_state": "EMPTY" if records_total == 0 else "POPULATED",
},
"wbs_7_1": {
"registry_counts": reg_counts,
"prediction_accuracy_state": pred_t5_state,
"prediction_t5_sample": pred_t5_sample,
"prediction_t5_rate_pct": pred_t5_rate,
"top_priority_candidates": _pick_top_candidates(calib_priority, 5),
},
"summary": {
"wbs_4_1_remaining_to_threshold": max(0, live_t20_threshold - live_t20_count),
"wbs_7_1_calibrated_pct": round(100.0 * reg_counts["calibrated_count"] / reg_counts["total_thresholds"], 2)
if reg_counts["total_thresholds"] else 0.0,
"wbs_7_1_unvalidated_pct": round(100.0 * (reg_counts["spec_derived_count"] + reg_counts["expert_prior_count"]) / reg_counts["total_thresholds"], 2)
if reg_counts["total_thresholds"] else 0.0,
},
}
OUTPUT.write_text(json.dumps(status, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps(status, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,43 @@
#!/usr/bin/env python3
from __future__ import annotations
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
AGENTS = ROOT / "AGENTS.md"
REPORT = ROOT / "Temp" / "document_search_exclusion_v1.json"
REQUIRED_EXCLUDED_PATHS = [
"docs/archive/",
"suggest/",
"artifacts/archive/",
]
def main() -> int:
text = AGENTS.read_text(encoding="utf-8", errors="replace") if AGENTS.exists() else ""
missing = [path for path in REQUIRED_EXCLUDED_PATHS if path not in text]
archive_candidates = []
for rel in REQUIRED_EXCLUDED_PATHS:
root = ROOT / rel.rstrip("/")
if root.exists():
archive_candidates.extend(sorted(str(p.relative_to(ROOT)).replace("\\", "/") for p in root.rglob("*") if p.is_file()))
result = {
"formula_id": "DOCUMENT_SEARCH_EXCLUSION_V1",
"gate": "PASS" if not missing else "FAIL",
"required_excluded_paths": REQUIRED_EXCLUDED_PATHS,
"missing_policy_markers": missing,
"archive_candidate_count": len(archive_candidates),
"archive_candidates_sample": archive_candidates[:20],
}
REPORT.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0 if not missing else 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -48,7 +48,7 @@ def main() -> int:
else:
# Check if it is included in upload package mode
is_included = should_include(ref_path, mode="upload", include_xlsx=False, include_backups=False)
if not is_included:
if not is_included and not ref.startswith("Temp/"):
missing_refs.append((ref, "excluded_from_package"))
if missing_refs:
+196
View File
@@ -0,0 +1,196 @@
#!/usr/bin/env python3
from __future__ import annotations
import json
import sys
from pathlib import Path
from typing import Any
import yaml
ROOT = Path(__file__).resolve().parents[1]
PLAN_PATH = ROOT / "governance" / "todo" / "v8_9_p3_adoption_plan.yaml"
DECISION_FLOW_PATH = ROOT / "spec" / "09_decision_flow.yaml"
MANIFEST_PATH = ROOT / "runtime" / "active_artifact_manifest.yaml"
TASKS = {
"P3-A": {
"formula_id": "STATE_VECTOR_CONSTRUCTOR_V1",
"builder": ROOT / "tools" / "build_state_vector_constructor_v1.py",
"spec_path": ROOT / "spec" / "formulas" / "domains" / "portfolio.yaml",
"schema_path": ROOT / "schemas" / "generated" / "state_vector_constructor_v1.schema.json",
"model_path": ROOT / "src" / "quant_engine" / "models" / "generated" / "state_vector_constructor_v1_schema.py",
"temp_path": ROOT / "Temp" / "state_vector_constructor_v1.json",
},
"P3-B": {
"formula_id": "WALK_FORWARD_BOOTSTRAP_V1",
"builder": ROOT / "tools" / "build_walk_forward_bootstrap_v1.py",
"spec_path": ROOT / "spec" / "formulas" / "domains" / "simulation.yaml",
"schema_path": ROOT / "schemas" / "generated" / "walk_forward_bootstrap_v1.schema.json",
"model_path": ROOT / "src" / "quant_engine" / "models" / "generated" / "walk_forward_bootstrap_v1_schema.py",
"temp_path": ROOT / "Temp" / "walk_forward_bootstrap_v1.json",
},
"P3-C": {
"formula_id": "TRANSITION_SET_ENUMERATOR_V1",
"builder": ROOT / "tools" / "build_transition_set_enumerator_v1.py",
"spec_path": ROOT / "spec" / "formulas" / "domains" / "portfolio.yaml",
"schema_path": ROOT / "schemas" / "generated" / "transition_set_enumerator_v1.schema.json",
"model_path": ROOT / "src" / "quant_engine" / "models" / "generated" / "transition_set_enumerator_v1_schema.py",
"temp_path": ROOT / "Temp" / "transition_set_enumerator_v1.json",
},
"P3-D": {
"formula_id": "REBALANCE_CADENCE_GATE_V1",
"builder": ROOT / "tools" / "build_rebalance_cadence_gate_v1.py",
"spec_path": ROOT / "spec" / "formulas" / "domains" / "portfolio.yaml",
"schema_path": ROOT / "schemas" / "generated" / "rebalance_cadence_gate_v1.schema.json",
"model_path": ROOT / "src" / "quant_engine" / "models" / "generated" / "rebalance_cadence_gate_v1_schema.py",
"temp_path": ROOT / "Temp" / "rebalance_cadence_gate_v1.json",
},
"P3-E": {
"formula_id": "WEEKLY_LEGACY_TRANSFER_PLAN_V1",
"builder": ROOT / "tools" / "build_weekly_legacy_transfer_plan_v1.py",
"spec_path": ROOT / "spec" / "formulas" / "domains" / "cash.yaml",
"schema_path": ROOT / "schemas" / "generated" / "weekly_legacy_transfer_plan_v1.schema.json",
"model_path": ROOT / "src" / "quant_engine" / "models" / "generated" / "weekly_legacy_transfer_plan_v1_schema.py",
"temp_path": ROOT / "Temp" / "weekly_legacy_transfer_plan_v1.json",
},
}
def _read_text(path: Path) -> str:
if not path.exists():
return ""
return path.read_text(encoding="utf-8", errors="replace")
def _read_json(path: Path) -> dict[str, Any]:
if not path.exists():
return {}
try:
payload = json.loads(path.read_text(encoding="utf-8"))
return payload if isinstance(payload, dict) else {}
except Exception:
return {}
def _contains_all(text: str, needles: list[str]) -> bool:
lower = text.lower()
return all(needle.lower() in lower for needle in needles)
def _validate_task(task_id: str, config: dict[str, Path | str]) -> dict[str, Any]:
formula_id = str(config["formula_id"])
builder = config["builder"]
spec_path = config["spec_path"]
schema_path = config["schema_path"]
model_path = config["model_path"]
temp_path = config["temp_path"]
errors: list[str] = []
for path, label in (
(builder, "builder"),
(spec_path, "spec"),
(schema_path, "schema"),
(model_path, "model"),
(temp_path, "temp"),
):
if not Path(path).exists():
errors.append(f"missing_{label}:{Path(path).relative_to(ROOT)}")
temp_doc = _read_json(Path(temp_path))
if temp_doc.get("formula_id") != formula_id:
errors.append(f"temp_formula_id:{temp_doc.get('formula_id')!r}")
if task_id == "P3-A" and float(temp_doc.get("state_vector_completeness_pct") or 0.0) < 0.0:
errors.append("state_vector_completeness_pct_invalid")
if task_id == "P3-B" and "gate" not in temp_doc:
errors.append("bootstrap_gate_missing")
if task_id == "P3-C" and "selected_transition_set" not in temp_doc:
errors.append("selected_transition_set_missing")
if task_id == "P3-D" and "rebalance_execution_allowed" not in temp_doc:
errors.append("rebalance_execution_allowed_missing")
if task_id == "P3-E" and "deployable_cash_contribution_krw" not in temp_doc:
errors.append("deployable_cash_contribution_missing")
return {
"formula_id": formula_id,
"builder_path": str(builder),
"spec_path": str(spec_path),
"schema_path": str(schema_path),
"model_path": str(model_path),
"temp_path": str(temp_path),
"gate": "PASS" if not errors else "FAIL",
"errors": errors,
"temp_doc": temp_doc,
}
def main() -> int:
plan_text = _read_text(PLAN_PATH)
decision_flow_text = _read_text(DECISION_FLOW_PATH)
manifest = _read_text(MANIFEST_PATH)
plan_doc = yaml.safe_load(plan_text) if plan_text else {}
task_results: dict[str, dict[str, Any]] = {}
errors: list[str] = []
for task_id, config in TASKS.items():
result = _validate_task(task_id, config)
task_results[task_id] = result
if result["gate"] != "PASS":
errors.append(task_id)
required_plan_text = [
"P3-A",
"P3-B",
"P3-C",
"P3-D",
"P3-E",
"P3-F",
"STATE_VECTOR_CONSTRUCTOR_V1",
"WALK_FORWARD_BOOTSTRAP_V1",
"TRANSITION_SET_ENUMERATOR_V1",
"REBALANCE_CADENCE_GATE_V1",
"WEEKLY_LEGACY_TRANSFER_PLAN_V1",
]
if not _contains_all(plan_text, required_plan_text):
errors.append("plan_missing_required_tokens")
required_flow_text = [
"STATE_VECTOR_CONSTRUCTION",
"WEEKLY_LEGACY_TRANSFER_PLAN_V1",
"TRANSITION_SET_ENUMERATOR_V1",
"REBALANCE_CADENCE_GATE_V1",
"WALK_FORWARD_BOOTSTRAP_V1",
]
if not _contains_all(decision_flow_text, required_flow_text):
errors.append("decision_flow_missing_required_tokens")
required_manifest_tokens = [
"state_vector_constructor_v1",
"walk_forward_bootstrap_v1",
"transition_set_enumerator_v1",
"rebalance_cadence_gate_v1",
"weekly_legacy_transfer_plan_v1",
]
if not _contains_all(manifest, required_manifest_tokens):
errors.append("manifest_missing_required_tokens")
result = {
"formula_id": "V8_9_P3_ADOPTION_PLAN_V1",
"gate": "PASS" if not errors and all(r["gate"] == "PASS" for r in task_results.values()) else "FAIL",
"plan_path": str(PLAN_PATH),
"decision_flow_path": str(DECISION_FLOW_PATH),
"manifest_path": str(MANIFEST_PATH),
"task_results": task_results,
"errors": errors,
}
out = ROOT / "Temp" / "v8_9_p3_adoption_plan_v1.json"
out.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0 if result["gate"] == "PASS" else 1
if __name__ == "__main__":
raise SystemExit(main())