5589a0432b
Critical re-review of the QuantEngine WBS evidence system found several
regressions of the "no fake gates" discipline established by M0, plus a
still-unwired M1 collection path. This closes 10 more WBS tasks
(QE-M1-01..06, QE-M2-01/02/04/05/06 — see spec/60_quant_engine_wbs.yaml)
with real, gate-verified evidence (18/34 total).
M1 — real KIS data now lands in PostgreSQL end-to-end:
- SchedulerService: load ticker universe from GatherTradingData.json instead
of a hardcoded array; fix a Hangfire scoped-service resolution bug.
- KisDataCollectionOrchestrator: restore logging on the lineage-event write
path (was a bare `catch {}` swallowing all failures silently); persist
daily OHLCV bars into quantengine.price_history_daily per run.
- Verified live: POST /api/collection/run -> Hangfire -> orchestrator ->
KIS mock API -> PostgreSQL, with Playwright DOM/API parity evidence.
M2 — historical price-history pipeline:
- CollectionRepository: SavePriceHistoryDailyAsync (idempotent upsert),
GetPriceHistorySummaryAsync (per-ticker aggregation) + a new
DateOnlyTypeHandler registered globally, since Dapper has no built-in
System.DateOnly support in either direction (write threw
NotSupportedException, read threw a constructor-mismatch
InvalidOperationException — found by exercising both paths live).
- tools/validate_price_history_integrity_v1.py: gap-freeness (vs KIS
trading calendar) + price-sanity gate over collected history.
- Admin Collection page: new "히스토리 현황" summary table +
GET /api/collection/history-summary, with Playwright evidence.
Governance/gate fixes:
- validate_market_time_series_schema_v1.py mislabeled its own output
"runtime_database_query": "DATA_GATED" despite never opening a DB
connection (pure file/regex check) — relabeled "check_scope":
"STATIC_STRUCTURAL_ONLY" and wired the node into the release DAG so it
isn't only reachable from ci.yml, matching every other validator.
Live-data authority for the same claim stays with QE-M2-01's pg_query
gate (spec/60), documented in spec/64.
- Fixed a WBS log_pattern check (QE-M1-06) that couldn't match its own
multi-line target; loosened two depends_on edges (QE-M1-05/06,
QE-M2-04/05) that encoded "needs X verified" when the real requirement
was only "needs X's code merged."
- Discovered and fixed admin-pages.spec.ts logging in with the wrong
seeded password (admin/admin instead of admin/quant123!, per CLAUDE.md)
— every test in that suite had been silently failing at the login step.
Deferred: QE-M2-03 (2-year backfill) — the KIS mock/VTS token endpoint
started returning 403 after the first successful call this session; looks
like a token-issuance rate limit or credential issue on KIS's side, not a
code defect. Backfilling at scale right now would just generate more 403s,
so left QE-M2-03 PENDING pending KIS account/console verification.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
200 lines
7.7 KiB
Python
200 lines
7.7 KiB
Python
#!/usr/bin/env python3
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import yaml
|
|
|
|
|
|
def load_spec(spec_path: Path) -> dict[str, Any]:
|
|
"""Load and parse the WBS spec YAML."""
|
|
if not spec_path.exists():
|
|
raise FileNotFoundError(f"Spec file not found: {spec_path}")
|
|
return yaml.safe_load(spec_path.read_text(encoding="utf-8"))
|
|
|
|
|
|
def read_text(path: Path) -> str:
|
|
"""Read text file safely."""
|
|
if not path.exists():
|
|
return ""
|
|
return path.read_text(encoding="utf-8", errors="replace")
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
"""Main entry point."""
|
|
parser = argparse.ArgumentParser(description="Validate whole QuantEngine WBS")
|
|
parser.add_argument("--repo-root", default=None, help="Repository root path")
|
|
parser.add_argument("--spec", default=None, help="Spec YAML file path")
|
|
|
|
args = parser.parse_args(argv)
|
|
|
|
# Resolve root
|
|
if args.repo_root:
|
|
root = Path(args.repo_root).resolve()
|
|
else:
|
|
root = Path(__file__).resolve().parents[1]
|
|
|
|
# Resolve spec path
|
|
if args.spec:
|
|
spec_path = Path(args.spec).resolve()
|
|
else:
|
|
spec_path = root / "spec" / "60_quant_engine_wbs.yaml"
|
|
|
|
# Load spec
|
|
try:
|
|
spec = load_spec(spec_path)
|
|
except FileNotFoundError as e:
|
|
print(f"ERROR: {e}")
|
|
return 1
|
|
|
|
# Validate spec structure
|
|
meta = spec.get("meta", {})
|
|
formula_id = meta.get("formula_id", "")
|
|
tasks = spec.get("tasks", {})
|
|
|
|
missing_criteria = []
|
|
failure_notes = []
|
|
|
|
# Check formula ID
|
|
if formula_id != "QUANT_ENGINE_WBS_V1":
|
|
missing_criteria.append("meta.formula_id != QUANT_ENGINE_WBS_V1")
|
|
failure_notes.append("Spec formula_id must be QUANT_ENGINE_WBS_V1")
|
|
|
|
# Validate task structure
|
|
valid_statuses = {"PENDING", "IN_PROGRESS", "DONE"}
|
|
for task_id, task in tasks.items():
|
|
# Check required fields
|
|
if "title" not in task:
|
|
missing_criteria.append(f"{task_id}.title")
|
|
if "status" not in task:
|
|
missing_criteria.append(f"{task_id}.status")
|
|
elif task["status"] not in valid_statuses:
|
|
missing_criteria.append(f"{task_id}.status={task['status']}")
|
|
|
|
# Check dependencies exist
|
|
depends_on = task.get("depends_on", [])
|
|
for dep_id in depends_on:
|
|
if dep_id not in tasks:
|
|
missing_criteria.append(f"{task_id}.depends_on={dep_id} (task not found)")
|
|
|
|
# Check success_criteria structure
|
|
success_criteria = task.get("success_criteria", {})
|
|
if not success_criteria:
|
|
missing_criteria.append(f"{task_id}.success_criteria (missing)")
|
|
else:
|
|
if "expected_success_value" not in success_criteria:
|
|
missing_criteria.append(f"{task_id}.success_criteria.expected_success_value")
|
|
if "evidence_artifacts" not in success_criteria:
|
|
missing_criteria.append(f"{task_id}.success_criteria.evidence_artifacts")
|
|
if "verification_commands" not in success_criteria:
|
|
missing_criteria.append(f"{task_id}.success_criteria.verification_commands")
|
|
|
|
# Check evidence_checks (exempt manual_user_action tasks — agents cannot
|
|
# trigger Gitea Actions workflow_dispatch, so there is nothing to gate)
|
|
execution_mode = (task.get("execution") or {}).get("mode")
|
|
evidence_checks = task.get("evidence_checks", [])
|
|
if not evidence_checks and execution_mode != "manual_user_action":
|
|
missing_criteria.append(f"{task_id}.evidence_checks (empty)")
|
|
elif evidence_checks:
|
|
valid_check_types = {"pg_query", "log_pattern", "json_gate", "file_exists", "playwright_report"}
|
|
for check in evidence_checks:
|
|
check_type = check.get("type", "")
|
|
if check_type not in valid_check_types:
|
|
missing_criteria.append(f"{task_id}.evidence_checks[] type={check_type} (unknown)")
|
|
|
|
# Check DONE tasks have verdicts
|
|
per_task = {}
|
|
for task_id, task in tasks.items():
|
|
status = task.get("status", "")
|
|
verdict_path = root / "Temp" / "evidence" / task_id / "verdict.json"
|
|
verdict_gate = None
|
|
|
|
if status == "DONE":
|
|
if not verdict_path.exists():
|
|
missing_criteria.append(f"{task_id}.verdict (missing for DONE task)")
|
|
failure_notes.append(
|
|
f"Task {task_id} has status=DONE but verdict.json is missing. "
|
|
f"Run: python tools/verify_wbs_task_v1.py --task {task_id}"
|
|
)
|
|
else:
|
|
try:
|
|
verdict = json.loads(verdict_path.read_text(encoding="utf-8"))
|
|
verdict_gate = verdict.get("gate", "")
|
|
if verdict_gate != "PASS":
|
|
missing_criteria.append(f"{task_id}.verdict gate={verdict_gate} (not PASS)")
|
|
failure_notes.append(
|
|
f"Task {task_id} has status=DONE but verdict.json gate={verdict_gate}. "
|
|
f"Fix evidence and re-run: python tools/verify_wbs_task_v1.py --task {task_id}"
|
|
)
|
|
except Exception as e:
|
|
missing_criteria.append(f"{task_id}.verdict (parse error: {e})")
|
|
failure_notes.append(f"Task {task_id} verdict.json is invalid: {e}")
|
|
|
|
# Check dependencies are DONE
|
|
depends_on = task.get("depends_on", [])
|
|
for dep_id in depends_on:
|
|
dep_task = tasks.get(dep_id, {})
|
|
dep_status = dep_task.get("status", "")
|
|
if dep_status != "DONE":
|
|
missing_criteria.append(f"{task_id}.depends_on={dep_id} (not DONE, is {dep_status})")
|
|
failure_notes.append(
|
|
f"Task {task_id} depends on {dep_id}, but {dep_id} status={dep_status} (not DONE)"
|
|
)
|
|
|
|
per_task[task_id] = {
|
|
"status": status,
|
|
"verdict_gate": verdict_gate
|
|
}
|
|
|
|
# Check roadmap doc pointer
|
|
roadmap_path = root / "docs" / "ROADMAP_WBS.md"
|
|
if not roadmap_path.exists():
|
|
missing_criteria.append("docs/ROADMAP_WBS.md (missing)")
|
|
failure_notes.append("Roadmap document is missing at docs/ROADMAP_WBS.md")
|
|
else:
|
|
roadmap_text = read_text(roadmap_path)
|
|
if "QUANT_ENGINE_WBS_V1" not in roadmap_text:
|
|
missing_criteria.append("docs/ROADMAP_WBS.md (no QUANT_ENGINE_WBS_V1 reference)")
|
|
failure_notes.append(
|
|
"Roadmap document does not contain 'QUANT_ENGINE_WBS_V1' reference. "
|
|
"Add a pointer to docs/ROADMAP_WBS.md"
|
|
)
|
|
|
|
# Build result
|
|
gate = "PASS" if not missing_criteria else "FAIL"
|
|
message = (
|
|
"QuantEngine WBS validation passed."
|
|
if gate == "PASS"
|
|
else "QuantEngine WBS validation failed. See failure_notes for details."
|
|
)
|
|
|
|
payload = {
|
|
"formula_id": "QUANT_ENGINE_WBS_V1",
|
|
"gate": gate,
|
|
"message": message,
|
|
"spec_path": str(spec_path),
|
|
"task_count": len(tasks),
|
|
"done_count": sum(1 for t in tasks.values() if t.get("status") == "DONE"),
|
|
"missing_criteria": missing_criteria,
|
|
"failure_notes": failure_notes,
|
|
"per_task": per_task
|
|
}
|
|
|
|
# Save output
|
|
out_path = root / "Temp" / "quant_engine_wbs_v1.json"
|
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
out_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
|
|
# Print result
|
|
print(message)
|
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
|
|
return 0 if gate == "PASS" else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|