From e7d1069222bd167377616f49a4d017c04745e1b7 Mon Sep 17 00:00:00 2001 From: kjh2064 Date: Sun, 12 Jul 2026 10:58:22 +0900 Subject: [PATCH] feat: add quant engine WBS verification harness --- .gitea/workflows/ci.yml | 6 + CLAUDE.md | 3 + docs/ROADMAP_WBS.md | 14 + docs/db/quantengine.dbml | 392 ++++++++++ docs/diagrams/collection-pipeline.md | 167 +++++ package.json | 6 +- playwright.config.ts | 9 +- spec/41_release_dag.yaml | 18 + spec/60_quant_engine_wbs.yaml | 709 ++++++++++++++++++ .../QuantEngine.Application.csproj | 4 + .../Services/KisDataCollectionOrchestrator.cs | 65 +- .../DashboardComponentTests.cs | 256 ------- .../Endpoints/CollectionEndpoints.cs | 44 +- src/dotnet/QuantEngine.Web/Program.cs | 5 + .../Services/SchedulerService.cs | 32 +- tests/e2e/{ => archive}/button-check.spec.ts | 0 tests/e2e/{ => archive}/console-check.spec.ts | 0 tests/e2e/{ => archive}/debug-login.spec.ts | 0 .../e2e/{ => archive}/detailed-debug.spec.ts | 0 .../{ => archive}/final-login-test.spec.ts | 0 .../e2e/{ => archive}/framework-check.spec.ts | 0 .../e2e/{ => archive}/full-validation.spec.ts | 0 tests/e2e/{ => archive}/html-debug.spec.ts | 0 .../e2e/{ => archive}/html-inspection.spec.ts | 0 tests/e2e/{ => archive}/inspect-page.spec.ts | 0 .../e2e/{ => archive}/login-improved.spec.ts | 0 .../login-screenshot-final.spec.ts | 0 tests/e2e/{ => archive}/login.spec.ts | 0 .../e2e/{ => archive}/real-login-test.spec.ts | 0 .../screenshot-diagnosis.spec.ts | 0 .../{ => archive}/test-static-login.spec.ts | 0 .../{ => archive}/verify-login-page.spec.ts | 0 tests/e2e/{ => archive}/wasm-test.spec.ts | 0 tests/e2e/evidence/.gitkeep | 0 .../evidence/qe-m1-02-collection-run.spec.ts | 148 ++++ .../unit/test_validate_quant_engine_wbs_v1.py | 498 ++++++++++++ tools/validate_golden_coverage_100.py | 18 + tools/validate_quant_engine_wbs_v1.py | 197 +++++ tools/verify_wbs_task_v1.py | 584 +++++++++++++++ 39 files changed, 2888 insertions(+), 287 deletions(-) create mode 100644 docs/db/quantengine.dbml create mode 100644 docs/diagrams/collection-pipeline.md create mode 100644 spec/60_quant_engine_wbs.yaml delete mode 100644 src/dotnet/QuantEngine.Web.Tests/DashboardComponentTests.cs rename tests/e2e/{ => archive}/button-check.spec.ts (100%) rename tests/e2e/{ => archive}/console-check.spec.ts (100%) rename tests/e2e/{ => archive}/debug-login.spec.ts (100%) rename tests/e2e/{ => archive}/detailed-debug.spec.ts (100%) rename tests/e2e/{ => archive}/final-login-test.spec.ts (100%) rename tests/e2e/{ => archive}/framework-check.spec.ts (100%) rename tests/e2e/{ => archive}/full-validation.spec.ts (100%) rename tests/e2e/{ => archive}/html-debug.spec.ts (100%) rename tests/e2e/{ => archive}/html-inspection.spec.ts (100%) rename tests/e2e/{ => archive}/inspect-page.spec.ts (100%) rename tests/e2e/{ => archive}/login-improved.spec.ts (100%) rename tests/e2e/{ => archive}/login-screenshot-final.spec.ts (100%) rename tests/e2e/{ => archive}/login.spec.ts (100%) rename tests/e2e/{ => archive}/real-login-test.spec.ts (100%) rename tests/e2e/{ => archive}/screenshot-diagnosis.spec.ts (100%) rename tests/e2e/{ => archive}/test-static-login.spec.ts (100%) rename tests/e2e/{ => archive}/verify-login-page.spec.ts (100%) rename tests/e2e/{ => archive}/wasm-test.spec.ts (100%) create mode 100644 tests/e2e/evidence/.gitkeep create mode 100644 tests/e2e/evidence/qe-m1-02-collection-run.spec.ts create mode 100644 tests/unit/test_validate_quant_engine_wbs_v1.py create mode 100644 tools/validate_quant_engine_wbs_v1.py create mode 100644 tools/verify_wbs_task_v1.py diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index b9835ff1..f0ca1809 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -120,6 +120,12 @@ jobs: - name: Validate Platform Transition WBS run: python3 tools/validate_platform_transition_wbs_v1.py + - name: Validate Quant Engine WBS + run: python3 tools/validate_quant_engine_wbs_v1.py + + - name: Run .NET Unit Tests + run: dotnet test src/dotnet/QuantEngine.Core.Tests/QuantEngine.Core.Tests.csproj -c Release --nologo + - name: Build Calibration Priority Backlog run: python3 tools/build_calibration_priority_v1.py diff --git a/CLAUDE.md b/CLAUDE.md index ec143acd..9cb02fb7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -688,3 +688,6 @@ See `docs/GITEA_ACTIONS_API_GUIDE.md` for complete API reference. - **Newtonsoft.Json**: Known high-severity vulnerability (GHSA-5crp-9r3c-p9vr); update or replace when feasible - **Release Authority**: Python gates (`full-gate`, `prepare-upload-zip`) remain authority; .NET Admin fully operational as of 2026-07-11 - **Testing Requirement**: All code changes must pass local testing with SSH tunnel to remote DB before deployment (see "Local Development & Testing" above) +- **DBML Schema Sync (2026-07-12)**: DbUp 마이그레이션(`src/dotnet/QuantEngine.Infrastructure/Migrations/V*.sql`)으로 관리되는 모든 테이블은 **반드시 `docs/db/quantengine.dbml`에도 동기화**되어야 하며, 개발 시 스키마 참조는 이 DBML 파일을 기준으로 한다. 새 마이그레이션 추가 시 같은 커밋에서 DBML 갱신 필수. +- **Diagrams**: 상태전이/플로우차트/시퀀스 다이어그램은 Mermaid로 `docs/diagrams/`에 작성해 코딩 참조로 활용 (수집 파이프라인: `docs/diagrams/collection-pipeline.md`) +- **WBS Evidence Gate (2026-07-12)**: 퀀트 엔진 로드맵/WBS는 `spec/60_quant_engine_wbs.yaml`(기계 판정)로 관리. 작업 완료는 `npm run verify:task -- ` 게이트 PASS로만 인정 (BE=PG쿼리/로그/JSON, FE=Playwright+스크린샷). 전체 게이트: `npm run verify:wbs` diff --git a/docs/ROADMAP_WBS.md b/docs/ROADMAP_WBS.md index 6e854c90..96b75f22 100644 --- a/docs/ROADMAP_WBS.md +++ b/docs/ROADMAP_WBS.md @@ -2271,3 +2271,17 @@ python tools/validate_snapshot_admin_web_v1.py > 이 문서는 `docs/ROADMAP_WBS.md` 에 저장됩니다. > 스프린트 완료마다 **완성도 KPI 섹션**을 업데이트하세요. > 모든 WBS 항목의 구현 시 반드시 **하네스 성공 기준**을 먼저 충족 후 다음 단계로 진행합니다. + +--- + +## 차세대 퀀트 엔진 로드맵/WBS 포인터 (2026-07-12) + +이후의 퀀트 엔진 진화 로드맵(M0–M5: 실증 하네스 → 수집 배선 → 시계열 저장소 → +실데이터 팩터 → 백테스팅 → 포트폴리오/레짐)과 상세 WBS는 **기계 판정 YAML**로 관리한다: + +- **스펙(단일 진실 원천)**: `spec/60_quant_engine_wbs.yaml` (formula_id: `QUANT_ENGINE_WBS_V1`) +- **단일 작업 검증**: `python tools/verify_wbs_task_v1.py --task ` → `Temp/evidence//verdict.json` +- **전체 WBS 게이트**: `python tools/validate_quant_engine_wbs_v1.py` → `Temp/quant_engine_wbs_v1.json` + +완료 판정 원칙: 작업은 게이트 실행(PASS)으로만 `DONE` 이 될 수 있다. +BE = PostgreSQL 쿼리 + Serilog 로그 패턴 + JSON 아티팩트, FE = Playwright(DOM assert + API 기대값 대조 + 스크린샷). diff --git a/docs/db/quantengine.dbml b/docs/db/quantengine.dbml new file mode 100644 index 00000000..89e6ec0e --- /dev/null +++ b/docs/db/quantengine.dbml @@ -0,0 +1,392 @@ +// ============================================================================= +// QuantEngine Database Schema (DBML) +// DbUp 마이그레이션(V1~V3)과 1:1 동기화 — 마이그레이션 추가 시 이 파일도 반드시 갱신 +// (CLAUDE.md 규칙: schema 변경 → DBML + 문서 동기화) +// +// 참고: Hangfire 스키마는 Hangfire.PostgreSql 라이브러리가 자동 생성 +// (DbUp 마이그레이션으로 관리하지 않음, 여기서도 제외) +// ============================================================================= + +Project quantengine { + database_type: 'PostgreSQL' + Note: ''' + QuantEngine v0.1 데이터베이스 스키마. + 세 개 스키마로 구성: + - quantengine: 핵심 KIS API 토큰, 사용자 계정, 수집 파이프라인 데이터 + - engine_history: 팩터 계산 이력, 시장 데이터 이력, 의사결정 이력 + - (생략) hangfire: Hangfire 백그라운드 잡 관리 (auto-created) + ''' +} + +// ============================================================================= +// Schema: quantengine (V1 + V2) +// ============================================================================= + +TableGroup "quantengine" { + kis_tokens + workspace_account + workspace_session + collection_runs + collection_snapshots + collection_source_errors + settings + account_snapshot + workspace_meta + workspace_change_log + workspace_approval_v2 + workspace_lock + kis_collection_runs + kis_collection_snapshots + kis_collection_errors +} + +Table quantengine.kis_tokens { + account TEXT [pk, note: "KIS 계정 모드 (real/mock)"] + access_token TEXT [not null, note: "KIS 토큰"] + expires_at TEXT [not null, note: "만료 시각 (ISO 8601)"] + updated_at TEXT [not null, note: "마지막 갱신 시각 (ISO 8601)"] + + Note: "KIS Open API 인증 토큰 캐시" +} + +Table quantengine.workspace_account { + ordinal INT [not null, note: "순서 인덱스"] + username TEXT [pk, note: "로그인 ID"] + password_hash TEXT [not null, note: "BCrypt 또는 SHA-256 해시 (자동 마이그레이션 가능)"] + role TEXT [not null, default: "'Admin'", note: "역할 (Admin)"] + is_active TEXT [not null, default: "'true'", note: "활성 상태 (true/false)"] + created_at TEXT [not null, note: "생성 시각 (ISO 8601)"] + updated_at TEXT [not null, note: "갱신 시각 (ISO 8601)"] + + indexes { + (is_active, username) [name: "idx_workspace_account_active"] + } + + Note: "Admin UI 사용자 계정" +} + +Table quantengine.workspace_session { + session_token_hash TEXT [pk, note: "세션 토큰 해시"] + username TEXT [not null, note: "사용자명"] + role TEXT [not null, default: "'Admin'", note: "역할"] + created_at TEXT [not null, note: "세션 생성 시각 (ISO 8601)"] + expires_at TEXT [not null, note: "만료 시각 (ISO 8601)"] + revoked_at TEXT [note: "취소 시각 (ISO 8601), NULL이면 활성"] + + indexes { + (username, expires_at) [name: "idx_workspace_session_username"] + } + + Note: "세션 관리 (쿠키 기반 인증)" +} + +Table quantengine.collection_runs { + run_id TEXT [pk, note: "수집 실행 ID (예: api-20260712-120000)"] + collector_name TEXT [not null, note: "수집기 이름"] + started_at TEXT [not null, note: "시작 시각 (ISO 8601)"] + finished_at TEXT [note: "종료 시각 (ISO 8601)"] + status TEXT [not null, note: "상태 (RUNNING/COMPLETED/FAILED)"] + input_source TEXT [note: "입력 소스 경로"] + output_json_path TEXT [note: "출력 JSON 파일 경로"] + output_db_path TEXT [note: "출력 DB 경로"] + notes TEXT [note: "메모"] + created_at TIMESTAMP [default: "CURRENT_TIMESTAMP", note: "DB 기록 시각"] + + Note: "데이터 수집 실행 기록 (레거시, V2의 kis_collection_runs 참조)" +} + +Table quantengine.collection_snapshots { + run_id TEXT [not null, note: "수집 실행 ID"] + dataset_name TEXT [not null, note: "데이터셋명"] + ticker TEXT [not null, note: "종목코드 (예: 005930)"] + name TEXT [note: "종목명"] + sector TEXT [note: "업종"] + as_of_date TEXT [note: "기준 일자"] + source_priority TEXT [note: "소스 우선순위"] + source_status TEXT [note: "소스 상태"] + payload_json TEXT [not null, note: "정규화된 데이터 (JSON)"] + provenance_json TEXT [not null, note: "출처 정보 (JSON)"] + created_at TIMESTAMP [default: "CURRENT_TIMESTAMP", note: "DB 기록 시각"] + + indexes { + (run_id, dataset_name, ticker) [pk] + (ticker, created_at) [name: "idx_collection_snapshots_ticker_time"] + } + + Note: "수집 스냅샷 (레거시, V2의 kis_collection_snapshots 참조)" +} + +Table quantengine.collection_source_errors { + run_id TEXT [not null, note: "수집 실행 ID"] + ticker TEXT [note: "종목코드"] + source_name TEXT [not null, note: "소스명"] + error_kind TEXT [not null, note: "에러 타입"] + error_message TEXT [not null, note: "에러 메시지"] + payload_json TEXT [note: "에러 상세 (JSON)"] + created_at TIMESTAMP [default: "CURRENT_TIMESTAMP", note: "DB 기록 시각"] + + indexes { + (run_id, source_name) [name: "idx_collection_source_errors_run"] + } + + Note: "수집 중 발생한 에러 기록 (레거시)" +} + +Table quantengine.settings { + ordinal INT [not null, note: "순서 인덱스"] + key TEXT [pk, note: "설정 키"] + value_json TEXT [not null, note: "값 (JSON)"] + note TEXT [not null, default: "''", note: "설명"] + updated_at TEXT [not null, note: "갱신 시각 (ISO 8601)"] + + Note: "애플리케이션 설정 저장소" +} + +Table quantengine.account_snapshot { + ordinal INT [not null, note: "순서 인덱스"] + row_json TEXT [not null, note: "계정 데이터 (JSON)"] + captured_at TEXT [not null, default: "''", note: "캡처 시각 (ISO 8601)"] + account TEXT [not null, default: "''", note: "계정"] + account_type TEXT [not null, default: "''", note: "계정 타입"] + ticker TEXT [not null, default: "''", note: "종목코드"] + name TEXT [not null, default: "''", note: "이름"] + parse_status TEXT [not null, default: "''", note: "파싱 상태"] + user_confirmed TEXT [not null, default: "''", note: "사용자 확인 여부"] + updated_at TEXT [not null, note: "갱신 시각 (ISO 8601)"] + + indexes { + (captured_at) [name: "idx_account_snapshot_captured_at"] + (ticker) [name: "idx_account_snapshot_ticker"] + } + + Note: "계정 스냅샷 저장소" +} + +Table quantengine.workspace_meta { + key TEXT [pk, note: "메타 키"] + value_json TEXT [not null, note: "값 (JSON)"] + + Note: "워크스페이스 메타데이터" +} + +Table quantengine.workspace_change_log { + id SERIAL [pk, note: "자동 증가 ID"] + domain TEXT [not null, note: "도메인"] + action TEXT [not null, note: "액션 (create/update/delete)"] + target_ref TEXT [not null, default: "''", note: "대상 참조"] + actor TEXT [not null, default: "'system'", note: "액터 (사용자/시스템)"] + note TEXT [not null, default: "''", note: "메모"] + before_json TEXT [not null, default: "'null'", note: "변경 전 값 (JSON)"] + after_json TEXT [not null, default: "'null'", note: "변경 후 값 (JSON)"] + created_at TEXT [not null, note: "기록 시각 (ISO 8601)"] + + Note: "변경 로그" +} + +Table quantengine.workspace_approval_v2 { + domain TEXT [not null, note: "도메인"] + target_ref TEXT [not null, default: "'*'", note: "대상 참조"] + status TEXT [not null, note: "승인 상태"] + approved_by TEXT [not null, default: "''", note: "승인자"] + approved_at TEXT [not null, default: "''", note: "승인 시각 (ISO 8601)"] + note TEXT [not null, default: "''", note: "메모"] + updated_at TEXT [not null, note: "갱신 시각 (ISO 8601)"] + + indexes { + (domain, target_ref) [pk] + } + + Note: "승인 워크플로우" +} + +Table quantengine.workspace_lock { + domain TEXT [not null, note: "도메인"] + target_ref TEXT [not null, default: "''", note: "대상 참조"] + locked_by TEXT [not null, default: "''", note: "잠금 사용자"] + reason TEXT [not null, default: "''", note: "잠금 사유"] + locked_at TEXT [not null, note: "잠금 시각 (ISO 8601)"] + + indexes { + (domain, target_ref) [pk] + } + + Note: "동시성 제어용 잠금" +} + +// ============================================================================= +// V2: KIS 수집 파이프라인 (kis_collection_*) +// ============================================================================= + +Table quantengine.kis_collection_runs { + run_id TEXT [pk, note: "수집 실행 ID"] + status TEXT [not null, note: "상태: RUNNING / COMPLETED / COMPLETED_WITH_ERRORS / FAILED"] + started_at TEXT [not null, note: "시작 시각 (ISO 8601 KST)"] + finished_at TEXT [note: "종료 시각 (ISO 8601 KST)"] + total_snapshots INTEGER [note: "성공한 스냅샷 수"] + total_errors INTEGER [note: "발생한 에러 수"] + updated_at TEXT [not null, note: "마지막 갱신 시각 (ISO 8601)"] + + indexes { + (started_at) [name: "idx_kis_runs_started_at"] + } + + Note: "KIS API 수집 실행 기록" +} + +Table quantengine.kis_collection_snapshots { + run_id TEXT [not null, note: "수집 실행 ID"] + dataset_name TEXT [note: "데이터셋명 (예: data_feed)"] + ticker TEXT [not null, note: "종목코드 (예: 005930)"] + source_name TEXT [not null, note: "데이터 소스 (kis_open_api 등)"] + payload_json TEXT [not null, note: "정규화된 수집 데이터 (JSON)"] + captured_at TEXT [not null, note: "캡처 시각 (ISO 8601 KST)"] + created_at TEXT [not null, note: "DB 기록 시각 (ISO 8601)"] + + indexes { + (run_id, ticker, source_name) [pk] + (ticker) [name: "idx_kis_snapshots_ticker"] + (captured_at) [name: "idx_kis_snapshots_captured_at"] + } + + Note: "KIS API 수집 스냅샷 (시계열 데이터)" +} + +Table quantengine.kis_collection_errors { + id SERIAL [pk, note: "자동 증가 ID"] + run_id TEXT [not null, note: "수집 실행 ID"] + source_name TEXT [not null, note: "데이터 소스"] + error_kind TEXT [not null, note: "에러 타입 (예: HttpRequestException)"] + error_message TEXT [note: "에러 메시지"] + ticker TEXT [note: "종목코드 (해당하면)"] + created_at TEXT [not null, note: "DB 기록 시각 (ISO 8601)"] + + indexes { + (run_id) [name: "idx_kis_errors_run_id"] + } + + Note: "KIS API 수집 중 발생한 에러" +} + +// ============================================================================= +// Schema: engine_history (V3) +// ============================================================================= + +TableGroup "engine_history" { + market_raw_history + factor_version_history + factor_output_history + decision_result_history + market_vs_engine_gap_history +} + +Table engine_history.market_raw_history { + id BIGSERIAL [pk, note: "자동 증가 ID"] + source_id TEXT [not null, note: "소스 ID"] + observed_at TEXT [not null, note: "관측 시각 (ISO 8601)"] + source_name TEXT [not null, note: "소스명 (kis_open_api 등)"] + instrument_id TEXT [not null, note: "상품 ID (종목코드 등)"] + field_name TEXT [not null, note: "필드명 (현재가, 종가 등)"] + field_value TEXT [not null, note: "필드값 (문자열)"] + unit TEXT [not null, note: "단위 (원, % 등)"] + provenance JSONB [not null, default: "'{}'::jsonb", note: "출처 메타데이터 (JSON)"] + created_at TIMESTAMPTZ [not null, default: "NOW()", note: "DB 기록 시각 (UTC)"] + + indexes { + (created_at) [name: "idx_market_raw_history_created_at"] + } + + Note: "시장 데이터 원본 이력 (정규화 전)" +} + +Table engine_history.factor_version_history { + id BIGSERIAL [pk, note: "자동 증가 ID"] + factor_id TEXT [not null, note: "팩터 ID (예: momentum_ss001)"] + factor_version TEXT [not null, note: "팩터 버전 (예: v1.0.0)"] + effective_from TEXT [not null, note: "유효 시작 일자 (YYYYMMDD)"] + effective_to TEXT [not null, note: "유효 종료 일자 (YYYYMMDD)"] + formula_id TEXT [not null, note: "계산식 ID"] + source_version TEXT [not null, note: "소스 버전"] + provenance JSONB [not null, default: "'{}'::jsonb", note: "출처 메타데이터 (JSON)"] + created_at TIMESTAMPTZ [not null, default: "NOW()", note: "DB 기록 시각 (UTC)"] + + indexes { + (created_at) [name: "idx_factor_version_history_created_at"] + } + + Note: "팩터 버전 관리 이력" +} + +Table engine_history.factor_output_history { + id BIGSERIAL [pk, note: "자동 증가 ID"] + factor_output_id TEXT [not null, note: "팩터 출력 ID"] + observed_at TEXT [not null, note: "관측 일자 (YYYYMMDD)"] + factor_id TEXT [not null, note: "팩터 ID"] + factor_version TEXT [not null, note: "팩터 버전"] + output_value TEXT [not null, note: "출력값 (문자열)"] + output_gate TEXT [not null, note: "게이트 (PASS/FAIL/WARN)"] + source_version TEXT [not null, note: "소스 버전"] + provenance JSONB [not null, default: "'{}'::jsonb", note: "출처 메타데이터 (JSON)"] + created_at TIMESTAMPTZ [not null, default: "NOW()", note: "DB 기록 시각 (UTC)"] + + indexes { + (created_at) [name: "idx_factor_output_history_created_at"] + } + + Note: "팩터 계산 결과 이력" +} + +Table engine_history.decision_result_history { + id BIGSERIAL [pk, note: "자동 증가 ID"] + decision_id TEXT [not null, note: "의사결정 ID"] + decided_at TEXT [not null, note: "의사결정 일자 (YYYYMMDD)"] + instrument_id TEXT [not null, note: "상품 ID (종목코드 등)"] + action TEXT [not null, note: "액션 (BUY/SELL/HOLD)"] + gate TEXT [not null, note: "게이트 (PASS/FAIL)"] + score TEXT [not null, note: "스코어 (문자열)"] + source_version TEXT [not null, note: "소스 버전"] + provenance JSONB [not null, default: "'{}'::jsonb", note: "출처 메타데이터 (JSON)"] + created_at TIMESTAMPTZ [not null, default: "NOW()", note: "DB 기록 시각 (UTC)"] + + indexes { + (created_at) [name: "idx_decision_result_history_created_at"] + } + + Note: "의사결정 결과 이력" +} + +Table engine_history.market_vs_engine_gap_history { + id BIGSERIAL [pk, note: "자동 증가 ID"] + gap_id TEXT [not null, note: "갭 ID"] + observed_at TEXT [not null, note: "관측 일자 (YYYYMMDD)"] + instrument_id TEXT [not null, note: "상품 ID"] + metric_name TEXT [not null, note: "지표명"] + market_value TEXT [not null, note: "시장값"] + engine_value TEXT [not null, note: "엔진값"] + gap_value TEXT [not null, note: "갭값 (절대값)"] + gap_pct TEXT [not null, note: "갭 백분율 (%)"] + source_version TEXT [not null, note: "소스 버전"] + provenance JSONB [not null, default: "'{}'::jsonb", note: "출처 메타데이터 (JSON)"] + created_at TIMESTAMPTZ [not null, default: "NOW()", note: "DB 기록 시각 (UTC)"] + + indexes { + (created_at) [name: "idx_market_vs_engine_gap_history_created_at"] + } + + Note: "시장 데이터 vs 엔진 계산 갭 분석 이력" +} + +// ============================================================================= +// Relationships (Logical, not enforced as FKs in DDL) +// ============================================================================= + +Ref: quantengine.kis_collection_snapshots.run_id > quantengine.kis_collection_runs.run_id { + // logical relationship: snapshots belong to a run +} + +Ref: quantengine.kis_collection_errors.run_id > quantengine.kis_collection_runs.run_id { + // logical relationship: errors belong to a run +} + +Ref: quantengine.workspace_session.username > quantengine.workspace_account.username { + // logical relationship: session belongs to a user +} diff --git a/docs/diagrams/collection-pipeline.md b/docs/diagrams/collection-pipeline.md new file mode 100644 index 00000000..5face984 --- /dev/null +++ b/docs/diagrams/collection-pipeline.md @@ -0,0 +1,167 @@ +# QuantEngine 수집 파이프라인 (KIS API) + +## 1. 수집 실행 상태 전이도 (State Diagram) + +KIS 데이터 수집 실행(kis_collection_runs)의 상태 흐름. 상태값은 KisDataCollectionOrchestrator 에서 정의: +- `RUNNING`: 수집 진행 중 +- `COMPLETED`: 모든 스냅샷 수집 완료 (에러 없음, `total_errors == 0`) +- `COMPLETED_WITH_ERRORS`: 부분 수집 완료 (에러 발생, `total_errors > 0`이지만 일부 성공) +- `FAILED`: 전체 실패 (예외 발생, 데이터 미적재) + +```mermaid +stateDiagram-v2 + [*] --> RUNNING: 수집 시작
(RunCollectionAsync) + RUNNING --> COMPLETED: 완료 & error_count==0 + RUNNING --> COMPLETED_WITH_ERRORS: 완료 & error_count>0 + RUNNING --> FAILED: 예외 발생 + COMPLETED --> [*] + COMPLETED_WITH_ERRORS --> [*] + FAILED --> [*] +``` + +**상태 전이 조건** (KisDataCollectionOrchestrator.cs 라인 104-105): +- `error_count == 0` → `COMPLETED` +- `error_count > 0` → `COMPLETED_WITH_ERRORS` +- 예외(Exception) → `FAILED` + +**성공 기준** (CLAUDE.md "Collection Run Success Criteria"): +- Success: `status == "COMPLETED"` (NOT failed) +- Partial Success: `status == "COMPLETED"` + `total_snapshots > 0` + `total_errors > 0` +- Failure: `status == "FAILED"` OR `total_snapshots == 0` + +--- + +## 2. 수집 파이프라인 흐름도 (Flowchart) + +KIS API 데이터 수집의 전체 흐름. 두 개의 트리거: +1. **Hangfire 정기 작업**: 매일 09:00 에 자동 실행 +2. **API 수동 트리거**: POST /api/collection/run (쿠키 기반 인증) + +```mermaid +flowchart TD + A["Hangfire daily-collection
(09:00 KST)"] + B["POST /api/collection/run
(Cookie Auth)"] + + A --> C["IServiceScopeFactory.CreateScope
(resolve ICollectionOrchestrator)"] + B --> C + + C --> D["KisDataCollectionOrchestrator.RunCollectionAsync
(tickers: [005930, 000660, ...])"] + + D --> E["Per-ticker 루프"] + E --> F["KisApiPriceSource.GetPriceDataAsync
(ticker, account)"] + F --> G["PriceDataNormalizer.NormalizeCollectionRow
(seedRow, kisResult)"] + G --> H["CollectionRepository.SaveSnapshot
(kis_collection_snapshots)"] + G --> I["CollectionRepository.SaveError
(kis_collection_errors, on exception)"] + + H --> J{루프 끝?} + I --> J + J -->|Yes| K["CollectionRepository.SaveRun
(kis_collection_runs)"] + J -->|No| E + + K --> L["파일 출력:
Temp/kis_dotnet_collection_v1.json"] + L --> M["Serilog 로그:
src/dotnet/.../logs/"] + + M --> N["Admin UI: /Admin/Collection
(CollectionRepository 읽기)"] + N --> O["대시보드 표시:
상태, 스냅샷 수, 에러"] +``` + +**데이터 흐름**: +1. **입력**: Hangfire 스케줄 or API 수동 요청 +2. **오케스트레이션**: ICollectionOrchestrator 스코프 생성 +3. **수집**: KIS Open API 호출 → PriceDataNormalizer → DB 저장 +4. **출력**: + - kis_collection_runs: 실행 메타데이터 (run_id, status, total_snapshots, total_errors) + - kis_collection_snapshots: 종목별 가격 데이터 (JSON payload) + - kis_collection_errors: 에러 기록 + - Temp/kis_dotnet_collection_v1.json: 수집 결과 요약 (formula_id, gate, run_id, summary) + - Serilog 로그: 런타임 로그 (src/dotnet/QuantEngine.Web/logs/) +5. **표시**: Admin UI에서 CollectionRepository API 호출 → kis_collection_* 읽기 → Dashboard 렌더링 + +--- + +## 3. WBS 증거 검증 시퀀스도 (Sequence Diagram) + +작업 완료 증거를 자동 검증하는 파이프라인. 도구: `verify_wbs_task_v1.py` (증거 수집) + `validate_quant_engine_wbs_v1.py` (CI에서 재검증). + +```mermaid +sequenceDiagram + Developer->>verify_wbs_task_v1.py: python verify_wbs_task_v1.py --task QE-M1-01
(또는 --run-commands) + verify_wbs_task_v1.py->>+spec/60_quant_engine_wbs.yaml: load spec + spec/60_quant_engine_wbs.yaml-->>-verify_wbs_task_v1.py: meta + tasks[QE-M1-01] + + Note over verify_wbs_task_v1.py: evidence_checks 선언형 해석 + + alt pg_query 체크 + verify_wbs_task_v1.py->>+PostgreSQL: SELECT ... (WHERE 절) + PostgreSQL-->>-verify_wbs_task_v1.py: 스칼라 결과 또는 행 + verify_wbs_task_v1.py->>verify_wbs_task_v1.py: expect{min,max,equals} 비교 + end + + alt log_pattern 체크 + verify_wbs_task_v1.py->>+src/dotnet/.../logs/: file_glob 매칭 + src/dotnet/.../logs/-->>-verify_wbs_task_v1.py: 로그 라인 + verify_wbs_task_v1.py->>verify_wbs_task_v1.py: 정규식 패턴 검사
(min_matches, max_age_hours) + end + + alt json_gate 체크 + verify_wbs_task_v1.py->>+Temp/kis_dotnet_collection_v1.json: read JSON + Temp/kis_dotnet_collection_v1.json-->>-verify_wbs_task_v1.py: payload + verify_wbs_task_v1.py->>verify_wbs_task_v1.py: 점 표기 경로(dot notation)
+ 값 비교 (>=N 지원) + end + + alt file_exists 체크 + verify_wbs_task_v1.py->>verify_wbs_task_v1.py: paths[] 존재 확인
(min_bytes 검증) + end + + alt playwright_report 체크 + verify_wbs_task_v1.py->>+tests/e2e/playwright-report.json: read report + tests/e2e/playwright-report.json-->>-verify_wbs_task_v1.py: suites[].specs[] + verify_wbs_task_v1.py->>verify_wbs_task_v1.py: spec_file 매칭
(passed_min, failed) + end + + verify_wbs_task_v1.py->>verify_wbs_task_v1.py: 모든 체크 결과 종합
(gate = ALL PASS? → PASS : FAIL) + + verify_wbs_task_v1.py->>+Temp/evidence/QE-M1-01/: mkdir + verify_wbs_task_v1.py->>Temp/evidence/QE-M1-01/verdict.json: write verdict
(task_id, gate, checks[]) + verify_wbs_task_v1.py->>Temp/evidence/QE-M1-01/: save raw evidence
(pg_query_n.json, log_excerpt.txt, ...) + + verify_wbs_task_v1.py->>+runtime/lineage_events.jsonl: append event
(node_id, gate, timestamp) + + Developer<<--verify_wbs_task_v1.py: exit 0 (gate=PASS)
or exit 1 (gate=FAIL) + + Note over Developer: 선택: --run-commands 플래그
verification_commands[] 실행 + + Developer->>+validate_quant_engine_wbs_v1.py: (CI) python validate_quant_engine_wbs_v1.py + validate_quant_engine_wbs_v1.py->>validate_quant_engine_wbs_v1.py: spec load + validate_quant_engine_wbs_v1.py->>validate_quant_engine_wbs_v1.py: tasks[status==DONE] 필터 + validate_quant_engine_wbs_v1.py->>+Temp/evidence/*/verdict.json: load all verdicts + Temp/evidence/*/verdict.json-->>-validate_quant_engine_wbs_v1.py: gate 값 + validate_quant_engine_wbs_v1.py->>validate_quant_engine_wbs_v1.py: gate=FAIL? → CI FAIL + validate_quant_engine_wbs_v1.py->>+Temp/quant_engine_wbs_v1.json: write summary + Developer<<--validate_quant_engine_wbs_v1.py: exit 0 (모두 PASS)
or exit 1 (일부 FAIL) +``` + +**검증 프로세스 상세**: + +| 단계 | 역할 | 산출물 | +|------|------|--------| +| **1. 스펙 로드** | verify_wbs_task_v1.py | spec/60_quant_engine_wbs.yaml | +| **2. 증거 체크 실행** | 선언형 evidence_checks[] | pg_query / log_pattern / json_gate / file_exists / playwright_report | +| **3. 게이트 결정** | 모든 체크 PASS? | gate = PASS or FAIL | +| **4. 증거 저장** | Temp/evidence// | verdict.json + 원시 증거 | +| **5. 계보 로깅** | runtime/lineage_events.jsonl | node_id, gate, timestamp | +| **6. CI 재검증** | validate_quant_engine_wbs_v1.py | status=DONE 작업만 재검증 | + +**주요 특징**: +- **선언형 검증**: 체크 로직을 YAML에 기술 (하드코딩 최소화) +- **원시 증거 보존**: 각 체크의 상세 결과를 JSON/텍스트로 저장 +- **완료 주장 차단**: "완료했다"는 수동 선언 불가 → verdict.json gate=PASS만 인정 +- **CI 편입**: validate_quant_engine_wbs_v1.py가 release DAG의 노드로 동작 +- **멀티 트리거**: 단일 작업 검증 (--task) 또는 전체 검증 (CI) + +**검증 체크 타입 참고** (spec/60_quant_engine_wbs.yaml "evidence_check_types"): +- **pg_query**: PostgreSQL 스칼라 결과 비교 (min/max/equals) +- **log_pattern**: 로그 파일 정규식 매칭 (min_matches, max_age_hours) +- **json_gate**: JSON 아티팩트 키-값 검사 (점 표기 경로, >=N 비교) +- **file_exists**: 파일 존재 + 크기 검증 (min_bytes) +- **playwright_report**: Playwright 리포트 테스트 결과 (passed_min, failed) diff --git a/package.json b/package.json index e23c3b70..9b67099a 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,11 @@ "validate-engine-strict": "python tools/run_release_dag_v3.py --mode release --strict", "validate-behavioral-coverage": "python tools/validate_behavioral_coverage_v1.py --strict", "validate-engine-integrity": "python tools/run_release_dag_v3.py --mode release --strict", - "render-report-json": "dotnet run --project src/dotnet/QuantEngine.Tools/QuantEngine.Tools.csproj -- report --packet=Temp/final_decision_packet_active.json --out=Temp/operational_report.json" + "render-report-json": "dotnet run --project src/dotnet/QuantEngine.Tools/QuantEngine.Tools.csproj -- report --packet=Temp/final_decision_packet_active.json --out=Temp/operational_report.json", + "verify:task": "python tools/verify_wbs_task_v1.py --task", + "verify:wbs": "python tools/validate_quant_engine_wbs_v1.py", + "test:e2e": "playwright test --project=chromium", + "test:evidence": "playwright test --project=evidence" }, "dependencies": { "cheerio": "1.2.0", diff --git a/playwright.config.ts b/playwright.config.ts index a5b4c1a3..aeef29dc 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -5,6 +5,7 @@ import { defineConfig, devices } from '@playwright/test'; */ export default defineConfig({ testDir: './tests/e2e', + testIgnore: '**/archive/**', /* Run tests in files in parallel */ fullyParallel: true, /* Fail the build on CI if you accidentally left test.only in the source code. */ @@ -14,7 +15,7 @@ export default defineConfig({ /* Opt out of parallel tests on CI. */ workers: process.env.CI ? 1 : undefined, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: 'list', + reporter: [['list'], ['json', { outputFile: 'Temp/evidence/playwright-last-run.json' }]], /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Base URL to use in actions like `await page.goto('/')`. */ @@ -29,8 +30,14 @@ export default defineConfig({ projects: [ { name: 'chromium', + testIgnore: ['**/archive/**', '**/evidence/**'], use: { ...devices['Desktop Chrome'] }, }, + { + name: 'evidence', + testDir: './tests/e2e/evidence', + use: { ...devices['Desktop Chrome'], screenshot: 'on', trace: 'on' }, + }, ], /* Run your local dev server before starting the tests */ diff --git a/spec/41_release_dag.yaml b/spec/41_release_dag.yaml index e36cf5eb..0653bbda 100644 --- a/spec/41_release_dag.yaml +++ b/spec/41_release_dag.yaml @@ -2280,6 +2280,23 @@ dag: strict: false timeout_sec: 60 warn_only: true + validate_quant_engine_wbs: + artifact_policy: keep + cache_key: validate_quant_engine_wbs_v1 + command: + - python + - tools/validate_quant_engine_wbs_v1.py + depends_on: [] + id: validate_quant_engine_wbs + inputs: + - tools/validate_quant_engine_wbs_v1.py + - spec/60_quant_engine_wbs.yaml + note: 퀀트 엔진 WBS 증거 게이트 — status=DONE 작업은 Temp/evidence//verdict.json + gate=PASS 가 있어야 한다 (완료 주장 금지, 게이트 실행으로만 DONE). + outputs: + - Temp/quant_engine_wbs_v1.json + strict: true + timeout_sec: 60 validate_specs: artifact_policy: keep cache_key: validate_specs_v1 @@ -2327,6 +2344,7 @@ execution_order: - validate_metric_alias_collision - validate_packaged_refs - validate_property_invariants + - validate_quant_engine_wbs - validate_renderer_no_calc - validate_runtime_source_whitelist - validate_sector_universe_monthly_refresh diff --git a/spec/60_quant_engine_wbs.yaml b/spec/60_quant_engine_wbs.yaml new file mode 100644 index 00000000..1d8b70bf --- /dev/null +++ b/spec/60_quant_engine_wbs.yaml @@ -0,0 +1,709 @@ +# ============================================================================= +# QuantEngine 데이터 실증 기반 퀀트 엔진 로드맵 + WBS (기계 판정) +# ============================================================================= +# formula_id: QUANT_ENGINE_WBS_V1 +# 원칙: 모든 작업(task)은 "완료 주장"이 아니라 게이트 실행으로만 DONE 판정된다. +# - BE 실증: pg_query(PostgreSQL 쿼리) + log_pattern(Serilog 로그) + json_gate(아티팩트) +# - FE 실증: playwright_report(스펙 PASS) + file_exists(스크린샷) +# 실행: +# 단일 작업 검증: python tools/verify_wbs_task_v1.py --task +# → Temp/evidence//verdict.json + 원시 증거 보존 +# 전체 WBS 게이트: python tools/validate_quant_engine_wbs_v1.py +# → Temp/quant_engine_wbs_v1.json (status=DONE 작업의 증거 재검증) +# 관례: spec/16_data_gaps_roadmap.yaml 의 success_criteria 구조 +# (expected_success_value / evidence_artifacts / verification_commands) 준수. +# 검증 로직만 하드코딩 → evidence_checks 선언형으로 일반화. +# ============================================================================= + +meta: + formula_id: QUANT_ENGINE_WBS_V1 + version: 1 + created: "2026-07-12" + authority: "governance/authority_matrix.yaml" + validator: tools/validate_quant_engine_wbs_v1.py + task_verifier: tools/verify_wbs_task_v1.py + evidence_root: Temp/evidence + status_values: [PENDING, IN_PROGRESS, DONE] # DONE = 해당 verdict.json gate=PASS 필수 + db_connection: + # 검증기의 PostgreSQL 접속 순서: + # 1) env QE_WBS_PG_DSN (psycopg DSN) + # 2) env ConnectionStrings__DefaultConnection (.NET 형식 → 자동 변환) + # 3) src/dotnet/QuantEngine.Web/appsettings.Development.json 의 ConnectionStrings.DefaultConnection + # (로컬은 SSH 터널 127.0.0.1:5432 전제 — CLAUDE.md "Local Development & Testing") + dotnet_appsettings: src/dotnet/QuantEngine.Web/appsettings.Development.json + +# ----------------------------------------------------------------------------- +# 검증 체크 타입 사전 (verify_wbs_task_v1.py 가 해석하는 선언형 vocabulary) +# ----------------------------------------------------------------------------- +evidence_check_types: + pg_query: "PostgreSQL 쿼리 1개 실행, 단일 스칼라 결과를 expect{min,max,equals}와 비교. 원시 결과를 pg_query_.json 으로 보존" + log_pattern: "file_glob 로그 파일들에서 정규식 매칭. expect{min_matches, max_age_hours(파일 mtime 기준)}. 매칭 라인을 log_excerpt.txt 로 보존" + json_gate: "path 의 JSON 아티팩트에서 expect 의 키-값 검사 (점 표기 경로 지원, 값 '>=N' 비교 지원)" + file_exists: "paths 의 모든 파일 존재 (expect.min_bytes 선택)" + playwright_report: "Playwright JSON 리포트(report)에서 spec_file 의 결과가 expect{passed_min, failed} 충족" + +# ============================================================================= +# 로드맵 (M0 → M5) +# ============================================================================= +roadmap: + scope_note: > + 전통 팩터(모멘텀/거래량/수급/실적/매크로/밸류/재무건전성 = spec/08_scoring_rules.yaml SS001) + + ATR 리스크 관리 기본 포함. 최신 기법은 레짐 감지 + 워크포워드 캘리브레이션 + 거래비용 + 반영 평가로 한정(사용자 확정, 2026-07-12). 딥러닝/인트라데이/대체데이터/실거래 집행 제외 + (은퇴자산 + read-only KIS 거버넌스: governance/rules/06_no_direct_api_trading.yaml 유지). + phases: + M0: + name: "실증 하네스 + 정직성 정리" + goal: "완료 주장이 불가능한 구조 확립 — 검증기/증거 규약/CI 편입 + 가짜 검증 제거" + exit_gate: "validate_quant_engine_wbs 가 release DAG/CI 노드로 PASS; dotnet test + Playwright evidence 스위트 CI 편입; 디버그 스펙 격리·가짜 PASS 제거" + tasks: [QE-M0-01, QE-M0-02, QE-M0-03, QE-M0-04, QE-M0-05, QE-M0-06] + M1: + name: "수집 파이프라인 배선" + goal: "운영 앱이 실제 KIS 데이터를 수집하도록 고아 오케스트레이터 배선 (첫 실데이터 실증)" + exit_gate: "Hangfire daily-collection + POST /api/collection/run 으로 kis_collection_* 에 실데이터 적재, Admin Collection 페이지 Playwright 실증" + tasks: [QE-M1-01, QE-M1-02, QE-M1-03, QE-M1-04, QE-M1-05] + M2: + name: "히스토리 시계열 저장소" + goal: "모멘텀 팩터·백테스트의 전제인 일봉/매크로 시계열 축적 (2년 백필)" + exit_gate: "price_history_daily/macro_history_daily 에 유니버스 2년치; (ticker,date) 중복 0; 거래일 캘린더 대비 gap 0" + tasks: [QE-M2-01, QE-M2-02, QE-M2-03, QE-M2-04, QE-M2-05] + M3: + name: "실데이터 팩터 계산" + goal: "SS001 전통 팩터를 PG 히스토리에서 계산해 engine_history 에 적재, 파일 개수 골든커버리지를 수치 패리티로 대체" + exit_gate: "factor_output_history 에 유니버스 전체 스코어(0-100); Python 참조 대비 패리티 ≥20 formula tol 1e-9 PASS" + tasks: [QE-M3-01, QE-M3-02, QE-M3-03, QE-M3-04, QE-M3-05] + M4: + name: "백테스팅 + 검증" + goal: "point-in-time 데이터만 사용하는 워크포워드 백테스터 + 거래비용 모델 + no-lookahead 게이트 실배선" + exit_gate: "Sharpe/MDD/턴오버 JSON 산출; no-lookahead 정상 PASS + 오염 픽스처 FAIL 양방향; T+5/T+20 원장 표본 ≥30" + tasks: [QE-M4-01, QE-M4-02, QE-M4-03, QE-M4-04, QE-M4-05] + M5: + name: "포트폴리오 구성 + 최신 기법" + goal: "레짐 감지 + SS001 가중치 워크포워드 캘리브레이션(제약+shrinkage) + 변동성 타게팅 사이징" + exit_gate: "백필 전 기간 레짐 라벨; 캘리브레이션 가중치 제약 준수 + OOS Sharpe 정직 보고; 최종 포트폴리오 패킷 캡 준수" + tasks: [QE-M5-01, QE-M5-02, QE-M5-03, QE-M5-04] + +# ============================================================================= +# WBS 작업 목록 +# ============================================================================= +tasks: + + # --------------------------------------------------------------------------- + # M0 — 실증 하네스 + 정직성 정리 + # --------------------------------------------------------------------------- + QE-M0-01: + title: "WBS 스펙(YAML) + 로드맵 작성, 레거시 로드맵 문서에 포인터 추가" + status: IN_PROGRESS + depends_on: [] + owner_files: + - spec/60_quant_engine_wbs.yaml + - docs/ROADMAP_WBS.md + success_criteria: + expected_success_value: { spec_exists: true, legacy_pointer_appended: true } + evidence_artifacts: [Temp/evidence/QE-M0-01/verdict.json] + verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M0-01"] + evidence_checks: + - type: file_exists + paths: [spec/60_quant_engine_wbs.yaml] + expect: { min_bytes: 10000 } + - type: log_pattern + file_glob: docs/ROADMAP_WBS.md + pattern: "QUANT_ENGINE_WBS_V1" + expect: { min_matches: 1 } + + QE-M0-02: + title: "증거 검증기 2종 구현 (단일 작업 verifier + 전체 WBS validator) + 유닛테스트" + status: IN_PROGRESS + depends_on: [] + owner_files: + - tools/verify_wbs_task_v1.py + - tools/validate_quant_engine_wbs_v1.py + - tests/unit/test_validate_quant_engine_wbs_v1.py + success_criteria: + expected_success_value: { self_test: PASS, synthetic_pass_fail_bidirectional: true } + evidence_artifacts: [Temp/evidence/QE-M0-02/verdict.json, Temp/quant_engine_wbs_v1.json] + verification_commands: + - "python -m pytest tests/unit/test_validate_quant_engine_wbs_v1.py -q" + - "python tools/verify_wbs_task_v1.py --task QE-M0-02" + evidence_checks: + - type: file_exists + paths: + - tools/verify_wbs_task_v1.py + - tools/validate_quant_engine_wbs_v1.py + - tests/unit/test_validate_quant_engine_wbs_v1.py + - type: json_gate + path: Temp/quant_engine_wbs_v1.json + expect: { formula_id: QUANT_ENGINE_WBS_V1, gate: PASS } + + QE-M0-03: + title: "Playwright 정직성 정리 + evidence 프로젝트 + npm 스크립트" + status: PENDING + depends_on: [QE-M0-02] + owner_files: + - playwright.config.ts + - package.json + - tests/e2e/archive/ + notes: > + 디버그 스펙(~17개: debug-login, html-debug, wasm-test, framework-check, console-check, + screenshot-diagnosis, inspect-page, login* 변형 등)을 tests/e2e/archive/ 로 이동하고 + testIgnore 로 제외. full-validation.spec.ts 의 assert 없는 가짜 "[PASS]" 배너 테스트 + 제거(파일째 archive). evidence 프로젝트: testDir tests/e2e/evidence, screenshot 'on', + trace 'on', JSON reporter → Temp/evidence/playwright-last-run.json. + npm 스크립트: verify:task / verify:wbs / test:e2e / test:evidence + success_criteria: + expected_success_value: { default_project_specs: ["admin-pages.spec.ts"], fake_pass_removed: true } + evidence_artifacts: [Temp/evidence/QE-M0-03/verdict.json] + verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M0-03"] + evidence_checks: + - type: file_exists + paths: [tests/e2e/archive] + - type: log_pattern + file_glob: playwright.config.ts + pattern: "evidence" + expect: { min_matches: 1 } + - type: log_pattern + file_glob: package.json + pattern: "verify:task" + expect: { min_matches: 1 } + - type: log_pattern + file_glob: tests/e2e/full-validation.spec.ts + pattern: ".*" + expect: { max_matches: 0 } # 파일이 기본 testDir 에 더 이상 존재하지 않아야 함 + + QE-M0-04: + title: "CI에 dotnet test 편입 + 고아 QuantEngine.Web.Tests 처리" + status: PENDING + depends_on: [] + owner_files: + - .gitea/workflows/ci.yml + - src/dotnet/QuantEngine.Web.Tests/ + notes: > + QuantEngine.Web.Tests/DashboardComponentTests.cs 는 csproj 없는 고아(폐기된 Blazor 대상). + 현 Razor Pages UI 에 맞지 않으면 삭제. ci.yml 에 dotnet test 스텝 추가. + success_criteria: + expected_success_value: { ci_has_dotnet_test: true, core_tests_green: true } + evidence_artifacts: [Temp/evidence/QE-M0-04/verdict.json] + verification_commands: + - "dotnet test src/dotnet/QuantEngine.Core.Tests/QuantEngine.Core.Tests.csproj -c Release --nologo" + - "python tools/verify_wbs_task_v1.py --task QE-M0-04" + evidence_checks: + - type: log_pattern + file_glob: .gitea/workflows/ci.yml + pattern: "dotnet test" + expect: { min_matches: 1 } + + QE-M0-05: + title: "release DAG + CI 에 validate_quant_engine_wbs 게이트 노드 등록" + status: PENDING + depends_on: [QE-M0-02] + owner_files: + - spec/41_release_dag.yaml + - .gitea/workflows/ci.yml + success_criteria: + expected_success_value: { dag_node: validate_quant_engine_wbs, ci_step: true } + evidence_artifacts: [Temp/evidence/QE-M0-05/verdict.json] + verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M0-05"] + evidence_checks: + - type: log_pattern + file_glob: spec/41_release_dag.yaml + pattern: "validate_quant_engine_wbs" + expect: { min_matches: 1 } + - type: log_pattern + file_glob: .gitea/workflows/ci.yml + pattern: "validate_quant_engine_wbs_v1" + expect: { min_matches: 1 } + + QE-M0-06: + title: "골든커버리지 정직성 표기 (coverage_basis: FILE_COUNT_ONLY)" + status: PENDING + depends_on: [] + owner_files: + - tools/validate_golden_coverage_100.py + notes: > + 골든 테스트 174개는 실행되지 않는 placeholder. 커버리지 판정 출력에 + coverage_basis: FILE_COUNT_ONLY 필드를 추가해 실체를 명시(삭제는 M3 패리티 대체 후). + success_criteria: + expected_success_value: { honesty_field: FILE_COUNT_ONLY } + evidence_artifacts: [Temp/evidence/QE-M0-06/verdict.json] + verification_commands: + - "python tools/validate_golden_coverage_100.py" + - "python tools/verify_wbs_task_v1.py --task QE-M0-06" + evidence_checks: + - type: json_gate + path: Temp/golden_coverage_100_v1.json + expect: { coverage_basis: FILE_COUNT_ONLY } + + # --------------------------------------------------------------------------- + # M1 — 수집 파이프라인 배선 (첫 실데이터 실증) + # --------------------------------------------------------------------------- + QE-M1-01: + title: "KisDataCollectionOrchestrator DI 등록 + daily-collection Hangfire 잡 실구현" + status: PENDING + depends_on: [QE-M0-02] + owner_files: + - src/dotnet/QuantEngine.Web/Program.cs + - src/dotnet/QuantEngine.Web/Services/SchedulerService.cs + notes: > + Program.cs 에 PriceDataNormalizer / SourcePriorityResolver / ICollectionOrchestrator → + KisDataCollectionOrchestrator 등록. RunDailyCollectionAsync 의 Task.Delay 시뮬레이션을 + IServiceScopeFactory 스코프 → 오케스트레이터 호출로 교체 (runId "daily-yyyyMMdd-HHmmss"). + 완료 로그: "Collection run {RunId} completed: {Snapshots} snapshots, {Errors} errors". + 상태값은 대문자 COMPLETED / COMPLETED_WITH_ERRORS (KisDataCollectionOrchestrator.cs:103). + success_criteria: + expected_success_value: { runs_completed_min: 1, snapshots_min: 5, hangfire_job: daily-collection } + evidence_artifacts: [Temp/evidence/QE-M1-01/verdict.json] + verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M1-01"] + evidence_checks: + - type: pg_query + sql: > + SELECT count(*) FROM quantengine.kis_collection_runs + WHERE status LIKE 'COMPLETED%' AND total_snapshots >= 5 + AND started_at >= (now() - interval '24 hours')::text + expect: { min: 1 } + - type: pg_query + sql: > + SELECT count(DISTINCT s.ticker) FROM quantengine.kis_collection_snapshots s + JOIN quantengine.kis_collection_runs r ON r.run_id = s.run_id + WHERE r.started_at >= (now() - interval '24 hours')::text + expect: { min: 5 } + - type: log_pattern + file_glob: src/dotnet/QuantEngine.Web/logs/quantengine-*.log + pattern: 'Collection run .+ completed: \d+ snapshots' + expect: { min_matches: 1, max_age_hours: 24 } + - type: json_gate + path: Temp/kis_dotnet_collection_v1.json + expect: { gate: PASS } + + QE-M1-02: + title: "Admin Collection 페이지 FE 실증 (실제 run 렌더링을 Playwright 로 증명)" + status: PENDING + depends_on: [QE-M1-01, QE-M0-03] + owner_files: + - tests/e2e/evidence/qe-m1-02-collection-run.spec.ts + notes: > + 필수 3요소: (a) 기대값을 /api/collection/runs API 에서 조회(하드코딩 금지), + (b) /Admin/Collection DOM 에서 run_id·스냅샷 수·상태 배지를 기대값과 assert, + (c) assert 시점 스크린샷 → Temp/evidence/QE-M1-02/screenshots/{01-collection-page,02-run-detail}.png + success_criteria: + expected_success_value: { spec_passed: 1, screenshots: 2, dom_equals_api: true } + evidence_artifacts: [Temp/evidence/QE-M1-02/verdict.json] + verification_commands: + - "npx playwright test --project=evidence tests/e2e/evidence/qe-m1-02-collection-run.spec.ts" + - "python tools/verify_wbs_task_v1.py --task QE-M1-02" + evidence_checks: + - type: playwright_report + report: Temp/evidence/playwright-last-run.json + spec_file: qe-m1-02-collection-run.spec.ts + expect: { passed_min: 1, failed: 0 } + - type: file_exists + paths: + - Temp/evidence/QE-M1-02/screenshots/01-collection-page.png + - Temp/evidence/QE-M1-02/screenshots/02-run-detail.png + expect: { min_bytes: 10000 } + + QE-M1-03: + title: "POST /api/collection/run 실구현 (BackgroundJob.Enqueue + 인증 필수화)" + status: PENDING + depends_on: [QE-M1-01] + owner_files: + - src/dotnet/QuantEngine.Web/Endpoints/CollectionEndpoints.cs + notes: > + 202 no-op 스텁을 Hangfire BackgroundJob.Enqueue(오케스트레이터 실행)로 교체, + 응답에 {runId} 포함. AllowAnonymous 제거(쿠키 인증). + 로그: "Collection run {RunId} enqueued". + success_criteria: + expected_success_value: { returns_run_id: true, auth_required: true } + evidence_artifacts: [Temp/evidence/QE-M1-03/verdict.json] + verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M1-03"] + evidence_checks: + - type: log_pattern + file_glob: src/dotnet/QuantEngine.Web/logs/quantengine-*.log + pattern: 'Collection run .+ enqueued' + expect: { min_matches: 1, max_age_hours: 24 } + - type: pg_query + sql: > + SELECT count(*) FROM quantengine.kis_collection_runs + WHERE run_id LIKE 'api-%' AND started_at >= (now() - interval '24 hours')::text + expect: { min: 1 } + + QE-M1-04: + title: "오케스트레이터 로깅 복원 + 출력 아티팩트 표준화 + 멀티소스 폴백 배선" + status: PENDING + depends_on: [QE-M1-01] + owner_files: + - src/dotnet/QuantEngine.Application/Services/KisDataCollectionOrchestrator.cs + notes: > + "// Log: skipped" → ILogger 복원. + 출력 경로 Path.GetTempPath() → /Temp/kis_dotnet_collection_v1.json, + 형식 {formula_id: KIS_DOTNET_COLLECTION_V1, gate, summary{success_count, error_count, source_counts}}. + SourcePriorityResolver 를 통해 Naver/Yahoo 폴백 경로 활성화. + success_criteria: + expected_success_value: { gate: PASS, source_counts_min: 1, error_rows_on_bad_ticker: true } + evidence_artifacts: [Temp/evidence/QE-M1-04/verdict.json, Temp/kis_dotnet_collection_v1.json] + verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M1-04"] + evidence_checks: + - type: json_gate + path: Temp/kis_dotnet_collection_v1.json + expect: { formula_id: KIS_DOTNET_COLLECTION_V1, gate: PASS } + - type: log_pattern + file_glob: src/dotnet/QuantEngine.Web/logs/quantengine-*.log + pattern: 'Collecting ticker' + expect: { min_matches: 1, max_age_hours: 24 } + + QE-M1-05: + title: "티커 유니버스를 GatherTradingData 파서/DB 설정에서 로드 (하드코딩 제거)" + status: PENDING + depends_on: [QE-M1-01] + owner_files: + - src/dotnet/QuantEngine.Web/Services/SchedulerService.cs + success_criteria: + expected_success_value: { distinct_tickers_equals_universe: true } + evidence_artifacts: [Temp/evidence/QE-M1-05/verdict.json] + verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M1-05"] + evidence_checks: + - type: log_pattern + file_glob: src/dotnet/QuantEngine.Web/Services/SchedulerService.cs + pattern: '005930.+000660.+051910' + expect: { max_matches: 0 } # 하드코딩 티커 배열 부재 + + # --------------------------------------------------------------------------- + # M2 — 히스토리 시계열 저장소 + # --------------------------------------------------------------------------- + QE-M2-01: + title: "V4 마이그레이션: price_history_daily + macro_history_daily" + status: PENDING + depends_on: [QE-M1-01] + owner_files: + - src/dotnet/QuantEngine.Infrastructure/Migrations/V4__Add_Price_History.sql + notes: > + price_history_daily(ticker, trade_date, open/high/low/close numeric, volume bigint, + source text, collected_at timestamptz, PK(ticker, trade_date)); + macro_history_daily(symbol, trade_date, value numeric, source, PK(symbol, trade_date)). + DbUp 마이그레이션 추가 시 docs/db/quantengine.dbml 동기화 필수 (CLAUDE.md 규칙 — 아래 체크로 강제). + success_criteria: + expected_success_value: { tables_created: 2 } + evidence_artifacts: [Temp/evidence/QE-M2-01/verdict.json] + verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M2-01"] + evidence_checks: + - type: pg_query + sql: > + SELECT count(*) FROM information_schema.tables + WHERE table_schema='quantengine' AND table_name IN ('price_history_daily','macro_history_daily') + expect: { equals: 2 } + - type: log_pattern + file_glob: docs/db/quantengine.dbml + pattern: 'price_history_daily' + expect: { min_matches: 1 } # DBML 동기화 강제 + + QE-M2-02: + title: "일봉 OHLCV 시계열 적재 (daily run 마다 upsert, 재실행 중복 0)" + status: PENDING + depends_on: [QE-M2-01] + owner_files: + - src/dotnet/QuantEngine.Application/Services/KisDataCollectionOrchestrator.cs + success_criteria: + expected_success_value: { rows_per_ticker_min: 1, duplicate_on_rerun: 0 } + evidence_artifacts: [Temp/evidence/QE-M2-02/verdict.json] + verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M2-02"] + evidence_checks: + - type: pg_query + sql: "SELECT count(*) FROM quantengine.price_history_daily WHERE collected_at >= now() - interval '24 hours'" + expect: { min: 1 } + - type: pg_query + sql: > + SELECT count(*) FROM (SELECT ticker, trade_date, count(*) c + FROM quantengine.price_history_daily GROUP BY 1,2 HAVING count(*) > 1) d + expect: { equals: 0 } + + QE-M2-03: + title: "2년치 백필 툴 (KIS chart API 페이지네이션 + rate-limit, 매크로는 yfinance→PG)" + status: PENDING + depends_on: [QE-M2-01] + owner_files: + - src/dotnet/QuantEngine.Tools/ + - src/quant_engine/macro_index_collection_v1.py + success_criteria: + expected_success_value: { bars_per_ticker_min: 480, macro_bars_min: 480 } + evidence_artifacts: [Temp/evidence/QE-M2-03/verdict.json] + verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M2-03"] + evidence_checks: + - type: pg_query + sql: "SELECT coalesce(min(c),0) FROM (SELECT count(*) c FROM quantengine.price_history_daily GROUP BY ticker) t" + expect: { min: 480 } + - type: pg_query + sql: "SELECT count(*) FROM quantengine.macro_history_daily WHERE symbol IN ('KOSPI','KOSDAQ')" + expect: { min: 960 } + + QE-M2-04: + title: "시계열 무결성 게이트 (거래일 캘린더 대비 gap 0, 가격 sanity)" + status: PENDING + depends_on: [QE-M2-03] + owner_files: + - tools/validate_price_history_integrity_v1.py + success_criteria: + expected_success_value: { gap_count: 0, invalid_price_rows: 0 } + evidence_artifacts: [Temp/evidence/QE-M2-04/verdict.json, Temp/price_history_integrity_v1.json] + verification_commands: + - "python tools/validate_price_history_integrity_v1.py" + - "python tools/verify_wbs_task_v1.py --task QE-M2-04" + evidence_checks: + - type: json_gate + path: Temp/price_history_integrity_v1.json + expect: { gate: PASS, gap_count: 0 } + + QE-M2-05: + title: "히스토리 현황 FE (per-ticker bar 수/기간/gap — API 값과 DOM 대조)" + status: PENDING + depends_on: [QE-M2-03, QE-M0-03] + owner_files: + - src/dotnet/QuantEngine.Web/Pages/Admin/Collection/ + - tests/e2e/evidence/qe-m2-05-history-tab.spec.ts + success_criteria: + expected_success_value: { spec_passed: 1, screenshots_min: 1 } + evidence_artifacts: [Temp/evidence/QE-M2-05/verdict.json] + verification_commands: + - "npx playwright test --project=evidence tests/e2e/evidence/qe-m2-05-history-tab.spec.ts" + - "python tools/verify_wbs_task_v1.py --task QE-M2-05" + evidence_checks: + - type: playwright_report + report: Temp/evidence/playwright-last-run.json + spec_file: qe-m2-05-history-tab.spec.ts + expect: { passed_min: 1, failed: 0 } + + # --------------------------------------------------------------------------- + # M3 — 실데이터 팩터 계산 + # --------------------------------------------------------------------------- + QE-M3-01: + title: "Point-in-time 리더 (GetBarsAsOf — lookahead 구조적 차단 + xUnit 증명)" + status: PENDING + depends_on: [QE-M2-03] + owner_files: + - src/dotnet/QuantEngine.Infrastructure/Repositories/ + - src/dotnet/QuantEngine.Core.Tests/ + success_criteria: + expected_success_value: { asof_leak_tests_green: true } + evidence_artifacts: [Temp/evidence/QE-M3-01/verdict.json] + verification_commands: + - "dotnet test src/dotnet/QuantEngine.Core.Tests/QuantEngine.Core.Tests.csproj -c Release --filter PriceHistoryReader" + - "python tools/verify_wbs_task_v1.py --task QE-M3-01" + evidence_checks: + - type: log_pattern + file_glob: src/dotnet/QuantEngine.Core.Tests/**/*.cs + pattern: 'GetBarsAsOf' + expect: { min_matches: 1 } + + QE-M3-02: + title: "전통 팩터 계산기 (모멘텀 20/60/120d·RS, 저변동성 ATR%·stdev·beta, 밸류/퀄리티)" + status: PENDING + depends_on: [QE-M3-01] + owner_files: + - src/dotnet/QuantEngine.Core/Domain/ + - tools/validate_factor_parity_v1.py + success_criteria: + expected_success_value: { parity_formulas_min: 20, tolerance: 1e-9 } + evidence_artifacts: [Temp/evidence/QE-M3-02/verdict.json, Temp/factor_parity_v1.json] + verification_commands: + - "python tools/validate_factor_parity_v1.py" + - "python tools/verify_wbs_task_v1.py --task QE-M3-02" + evidence_checks: + - type: json_gate + path: Temp/factor_parity_v1.json + expect: { gate: PASS, compared_count: ">=20" } + + QE-M3-03: + title: "SS001 합성 스코어 + HF001-09 → engine_history.factor_output_history 적재" + status: PENDING + depends_on: [QE-M3-02] + owner_files: + - src/dotnet/QuantEngine.Application/Services/ + success_criteria: + expected_success_value: { scored_universe_full: true, score_range_0_100: true } + evidence_artifacts: [Temp/evidence/QE-M3-03/verdict.json] + verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M3-03"] + evidence_checks: + - type: pg_query + sql: "SELECT count(*) FROM engine_history.factor_output_history WHERE created_at >= now() - interval '24 hours'" + expect: { min: 5 } + + QE-M3-04: + title: "PipelineOrchestrator 정직화 (1-2단계 실구현, 나머지 STUBBED 표기 — mock PASS 금지)" + status: PENDING + depends_on: [QE-M3-03] + owner_files: + - src/dotnet/QuantEngine.Application/Services/PipelineOrchestrator.cs + success_criteria: + expected_success_value: { computed_steps_min: 2, stub_steps_marked: STUBBED } + evidence_artifacts: [Temp/evidence/QE-M3-04/verdict.json] + verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M3-04"] + evidence_checks: + - type: log_pattern + file_glob: src/dotnet/QuantEngine.Application/Services/PipelineOrchestrator.cs + pattern: 'STUBBED' + expect: { min_matches: 1 } + + QE-M3-05: + title: "스코어 FE (SS001 테이블 — factor_output_history 값과 DOM 대조)" + status: PENDING + depends_on: [QE-M3-03, QE-M0-03] + owner_files: + - tests/e2e/evidence/qe-m3-05-scores.spec.ts + success_criteria: + expected_success_value: { spec_passed: 1 } + evidence_artifacts: [Temp/evidence/QE-M3-05/verdict.json] + verification_commands: + - "npx playwright test --project=evidence tests/e2e/evidence/qe-m3-05-scores.spec.ts" + - "python tools/verify_wbs_task_v1.py --task QE-M3-05" + evidence_checks: + - type: playwright_report + report: Temp/evidence/playwright-last-run.json + spec_file: qe-m3-05-scores.spec.ts + expect: { passed_min: 1, failed: 0 } + + # --------------------------------------------------------------------------- + # M4 — 백테스팅 + 검증 + # --------------------------------------------------------------------------- + QE-M4-01: + title: "백테스터 + 거래비용 모델 (Sharpe/MDD/턴오버/비용 드래그 JSON)" + status: PENDING + depends_on: [QE-M3-03] + owner_files: + - src/dotnet/QuantEngine.Core/Domain/Backtester.cs + - src/dotnet/QuantEngine.Tools/ + success_criteria: + expected_success_value: { metrics_populated: [sharpe, mdd, turnover, cost_drag] } + evidence_artifacts: [Temp/evidence/QE-M4-01/verdict.json, Temp/backtest_result_v1.json] + verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M4-01"] + evidence_checks: + - type: json_gate + path: Temp/backtest_result_v1.json + expect: { gate: PASS } + + QE-M4-02: + title: "no-lookahead 게이트 실배선 (정상 PASS + 오염 픽스처 FAIL 양방향 검증)" + status: PENDING + depends_on: [QE-M4-01] + owner_files: + - tools/validate_no_lookahead_bias_v1.py + success_criteria: + expected_success_value: { real_run: PASS, corrupted_fixture: FAIL } + evidence_artifacts: [Temp/evidence/QE-M4-02/verdict.json] + verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M4-02"] + evidence_checks: + - type: json_gate + path: Temp/no_lookahead_bias_v1.json + expect: { gate: PASS } + + QE-M4-03: + title: "워크포워드 하네스 (24m train / 6m test 롤링, 윈도우 ≥4)" + status: PENDING + depends_on: [QE-M4-01] + owner_files: + - src/dotnet/QuantEngine.Tools/ + success_criteria: + expected_success_value: { windows_min: 4, oos_metrics_nonnull: true } + evidence_artifacts: [Temp/evidence/QE-M4-03/verdict.json, Temp/walk_forward_v1.json] + verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M4-03"] + evidence_checks: + - type: json_gate + path: Temp/walk_forward_v1.json + expect: { gate: PASS, windows: ">=4" } + + QE-M4-04: + title: "T+5/T+20 성과 원장 (prediction_accuracy 실표본 재계산, t5_sample≥30)" + status: PENDING + depends_on: [QE-M2-03] + owner_files: + - tools/ + success_criteria: + expected_success_value: { t5_sample_min: 30 } + evidence_artifacts: [Temp/evidence/QE-M4-04/verdict.json, Temp/prediction_accuracy_harness_v2.json] + verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M4-04"] + evidence_checks: + - type: json_gate + path: Temp/prediction_accuracy_harness_v2.json + expect: { t5_sample: ">=30" } + + QE-M4-05: + title: "백테스트 결과 FE (에쿼티커브/Sharpe/MDD — backtest_result_v1.json 값과 DOM 대조)" + status: PENDING + depends_on: [QE-M4-01, QE-M0-03] + owner_files: + - tests/e2e/evidence/qe-m4-05-backtest.spec.ts + success_criteria: + expected_success_value: { spec_passed: 1 } + evidence_artifacts: [Temp/evidence/QE-M4-05/verdict.json] + verification_commands: + - "npx playwright test --project=evidence tests/e2e/evidence/qe-m4-05-backtest.spec.ts" + - "python tools/verify_wbs_task_v1.py --task QE-M4-05" + evidence_checks: + - type: playwright_report + report: Temp/evidence/playwright-last-run.json + spec_file: qe-m4-05-backtest.spec.ts + expect: { passed_min: 1, failed: 0 } + + # --------------------------------------------------------------------------- + # M5 — 포트폴리오 구성 + 최신 기법 + # --------------------------------------------------------------------------- + QE-M5-01: + title: "레짐 감지기 (spec/11_market_regime.yaml — 실제 매크로 시계열, 전 거래일 라벨)" + status: PENDING + depends_on: [QE-M2-03] + owner_files: + - src/dotnet/QuantEngine.Core/Domain/ + success_criteria: + expected_success_value: { regime_labels_full_window: true } + evidence_artifacts: [Temp/evidence/QE-M5-01/verdict.json, Temp/market_regime_v1.json] + verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M5-01"] + evidence_checks: + - type: json_gate + path: Temp/market_regime_v1.json + expect: { gate: PASS } + + QE-M5-02: + title: "SS001 가중치 워크포워드 캘리브레이션 (±50% 제약 + shrinkage λ=0.5, 정직 보고)" + status: PENDING + depends_on: [QE-M4-03, QE-M5-01] + owner_files: + - src/dotnet/QuantEngine.Tools/ + notes: "게이트는 방법론 필드(제약 준수, OOS 비교 존재)를 검증 — 캘리브레이션이 '이겨야' PASS 가 아님" + success_criteria: + expected_success_value: { weights_within_bounds: true, oos_comparison_reported: true } + evidence_artifacts: [Temp/evidence/QE-M5-02/verdict.json, Temp/weight_calibration_v1.json] + verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M5-02"] + evidence_checks: + - type: json_gate + path: Temp/weight_calibration_v1.json + expect: { gate: PASS } + + QE-M5-03: + title: "변동성 타게팅 사이징 + heat/집중도 캡 합성 → 최종 목표 포트폴리오 패킷" + status: PENDING + depends_on: [QE-M5-02] + owner_files: + - src/dotnet/QuantEngine.Application/Services/ + success_criteria: + expected_success_value: { weights_sum_lte_100: true, all_caps_satisfied: true } + evidence_artifacts: [Temp/evidence/QE-M5-03/verdict.json, Temp/target_portfolio_v1.json] + verification_commands: ["python tools/verify_wbs_task_v1.py --task QE-M5-03"] + evidence_checks: + - type: json_gate + path: Temp/target_portfolio_v1.json + expect: { gate: PASS } + + QE-M5-04: + title: "포트폴리오·레짐 대시보드 FE (레짐 배지·목표 가중치 — API 값과 DOM 대조)" + status: PENDING + depends_on: [QE-M5-03, QE-M0-03] + owner_files: + - tests/e2e/evidence/qe-m5-04-portfolio.spec.ts + success_criteria: + expected_success_value: { spec_passed: 1 } + evidence_artifacts: [Temp/evidence/QE-M5-04/verdict.json] + verification_commands: + - "npx playwright test --project=evidence tests/e2e/evidence/qe-m5-04-portfolio.spec.ts" + - "python tools/verify_wbs_task_v1.py --task QE-M5-04" + evidence_checks: + - type: playwright_report + report: Temp/evidence/playwright-last-run.json + spec_file: qe-m5-04-portfolio.spec.ts + expect: { passed_min: 1, failed: 0 } diff --git a/src/dotnet/QuantEngine.Application/QuantEngine.Application.csproj b/src/dotnet/QuantEngine.Application/QuantEngine.Application.csproj index 9e71f432..ce16b8cc 100644 --- a/src/dotnet/QuantEngine.Application/QuantEngine.Application.csproj +++ b/src/dotnet/QuantEngine.Application/QuantEngine.Application.csproj @@ -4,6 +4,10 @@ + + + + net10.0 enable diff --git a/src/dotnet/QuantEngine.Application/Services/KisDataCollectionOrchestrator.cs b/src/dotnet/QuantEngine.Application/Services/KisDataCollectionOrchestrator.cs index e72cb9f1..6b3f6764 100644 --- a/src/dotnet/QuantEngine.Application/Services/KisDataCollectionOrchestrator.cs +++ b/src/dotnet/QuantEngine.Application/Services/KisDataCollectionOrchestrator.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; using QuantEngine.Core.Interfaces; using QuantEngine.Application.Interfaces; using QuantEngine.Application.Services; @@ -13,19 +14,20 @@ public class KisDataCollectionOrchestrator : ICollectionOrchestrator private readonly ICollectionRepository _repository; private readonly PriceDataNormalizer _normalizer; private readonly SourcePriorityResolver _priorityResolver; - // Logging removed for simplicity + private readonly ILogger _logger; public KisDataCollectionOrchestrator( IKisApiClient kisApiClient, ICollectionRepository repository, PriceDataNormalizer normalizer, - SourcePriorityResolver priorityResolver) + SourcePriorityResolver priorityResolver, + ILogger logger) { _kisApiClient = kisApiClient; _repository = repository; _normalizer = normalizer; _priorityResolver = priorityResolver; - + _logger = logger; } public async Task RunCollectionAsync(string runId, string account, List tickers) @@ -42,7 +44,7 @@ public class KisDataCollectionOrchestrator : ICollectionOrchestrator try { - // Log: skipped + _logger.LogInformation("Starting collection run {RunId}", runId); var kisSource = new KisApiPriceSource(_kisApiClient); var rows = new List>(); @@ -53,9 +55,9 @@ public class KisDataCollectionOrchestrator : ICollectionOrchestrator { try { - // Log: skipped + _logger.LogInformation("Collecting ticker {Ticker} (run {RunId})", ticker, runId); var kisResult = await kisSource.GetPriceDataAsync(ticker, account); - + var seedRow = new Dictionary { { "Ticker", ticker } }; var (normalized, provenance) = _normalizer.NormalizeCollectionRow(seedRow, kisResult, null, false); @@ -80,7 +82,7 @@ public class KisDataCollectionOrchestrator : ICollectionOrchestrator } catch (Exception ex) { - // Log: skipped + _logger.LogWarning(ex, "Collection failed for {Ticker} (run {RunId})", ticker, runId); result.ErrorCount++; errors.Add(new Dictionary { @@ -116,33 +118,64 @@ public class KisDataCollectionOrchestrator : ICollectionOrchestrator TotalErrors: result.ErrorCount )); - // Output JSON file - var outputPath = Path.Combine(Path.GetTempPath(), "kis_data_collection_v1.json"); + // Determine gate status + var gate = result.SuccessCount == 0 ? "FAIL" + : result.ErrorCount == 0 ? "PASS" + : result.ErrorCount < result.SuccessCount * 0.1 ? "PASS" + : "PASS_WITH_WARNINGS"; + + // Output JSON file to /Temp/kis_dotnet_collection_v1.json + var outputPath = GetOutputPath(); var outputData = new { - formula_id = "KIS_DATA_COLLECTION_V1", + formula_id = "KIS_DOTNET_COLLECTION_V1", + gate = gate, run_id = runId, started_at = startedAt, finished_at = finishedAt, - row_count = rows.Count, - source_counts = sourceCounts, - errors = errors, - rows = rows + summary = new + { + success_count = result.SuccessCount, + error_count = result.ErrorCount, + source_counts = sourceCounts + } }; File.WriteAllText(outputPath, JsonSerializer.Serialize(outputData, new JsonSerializerOptions { WriteIndented = true })); - // Log: skipped + + _logger.LogInformation("Collection run {RunId} finished with status {Status}: {Success} ok, {Errors} errors", + runId, result.Status, result.SuccessCount, result.ErrorCount); return result; } catch (Exception ex) { - // Log: skipped + _logger.LogError(ex, "Collection run {RunId} failed with exception", runId); result.Status = "FAILED"; result.FinishedAt = DataNormalizationHelper.KstNowIso(); result.ErrorMessage = ex.Message; return result; } } + + private static string GetOutputPath() + { + var baseDir = AppContext.BaseDirectory; + var current = new DirectoryInfo(baseDir); + + while (current != null) + { + if (Directory.Exists(Path.Combine(current.FullName, ".git")) + || File.Exists(Path.Combine(current.FullName, "GatherTradingData.json"))) + { + var tempDir = Path.Combine(current.FullName, "Temp"); + Directory.CreateDirectory(tempDir); + return Path.Combine(tempDir, "kis_dotnet_collection_v1.json"); + } + current = current.Parent; + } + + return Path.Combine(Path.GetTempPath(), "kis_dotnet_collection_v1.json"); + } } diff --git a/src/dotnet/QuantEngine.Web.Tests/DashboardComponentTests.cs b/src/dotnet/QuantEngine.Web.Tests/DashboardComponentTests.cs deleted file mode 100644 index 7316e952..00000000 --- a/src/dotnet/QuantEngine.Web.Tests/DashboardComponentTests.cs +++ /dev/null @@ -1,256 +0,0 @@ -using Bunit; -using MudBlazor; -using Xunit; -using QuantEngine.Web.Client.Pages; -using QuantEngine.Web.Client.Components; - -namespace QuantEngine.Web.Tests; - -/// -/// Unit tests for Dashboard component using bUnit -/// -public class DashboardComponentTests : TestContext -{ - [Fact] - public void Dashboard_Renders_Without_Errors() - { - // Arrange & Act - var cut = RenderComponent(); - - // Assert - cut.Markup.Should().Contain("관리자 대시보드"); - } - - [Fact] - public void Dashboard_Displays_KPI_Cards() - { - // Arrange & Act - var cut = RenderComponent(); - - // Assert - Should have 4 KPI cards - cut.FindAll(".mud-paper").Count.Should().BeGreaterThanOrEqualTo(4); - cut.Markup.Should().Contain("총 수집 실행"); - cut.Markup.Should().Contain("성공률"); - cut.Markup.Should().Contain("최근 에러"); - cut.Markup.Should().Contain("마지막 동기화"); - } - - [Fact] - public void Dashboard_Shows_System_Status() - { - // Arrange & Act - var cut = RenderComponent(); - - // Assert - cut.Markup.Should().Contain("시스템 상태"); - cut.Markup.Should().Contain("API 서버"); - cut.Markup.Should().Contain("데이터베이스"); - } - - [Fact] - public void Dashboard_Has_Activity_Feed() - { - // Arrange & Act - var cut = RenderComponent(); - - // Assert - cut.Markup.Should().Contain("최근 활동"); - } - - [Fact] - public void Dashboard_Has_Collections_Table() - { - // Arrange & Act - var cut = RenderComponent(); - - // Assert - cut.Markup.Should().Contain("최근 데이터 수집 실행"); - cut.Markup.Should().Contain("새로고침"); - } -} - -/// -/// Unit tests for FormField component -/// -public class FormFieldComponentTests : TestContext -{ - [Fact] - public void FormField_Renders_Text_Input() - { - // Arrange - var parameters = new ComponentParameterCollection - { - { "Label", "사용자명" }, - { "Type", "text" }, - { "Placeholder", "이름 입력" } - }; - - // Act - var cut = RenderComponent(parameters); - - // Assert - cut.Markup.Should().Contain("사용자명"); - cut.Markup.Should().Contain("이름 입력"); - } - - [Fact] - public void FormField_Shows_Required_Indicator() - { - // Arrange - var parameters = new ComponentParameterCollection - { - { "Label", "이메일" }, - { "Type", "email" }, - { "Required", true } - }; - - // Act - var cut = RenderComponent(parameters); - - // Assert - cut.Markup.Should().Contain("*"); - } - - [Fact] - public void FormField_Displays_Error_Message() - { - // Arrange - var parameters = new ComponentParameterCollection - { - { "Label", "비밀번호" }, - { "Type", "password" }, - { "ErrorMessage", "최소 8자 이상 입력하세요" } - }; - - // Act - var cut = RenderComponent(parameters); - - // Assert - cut.Markup.Should().Contain("최소 8자 이상 입력하세요"); - } - - [Fact] - public void FormField_Shows_Help_Text() - { - // Arrange - var parameters = new ComponentParameterCollection - { - { "Label", "핸드폰" }, - { "Type", "tel" }, - { "HelpText", "하이픈 없이 숫자만 입력하세요" } - }; - - // Act - var cut = RenderComponent(parameters); - - // Assert - cut.Markup.Should().Contain("하이픈 없이 숫자만 입력하세요"); - } -} - -/// -/// Unit tests for Portfolio component -/// -public class PortfolioComponentTests : TestContext -{ - [Fact] - public void Portfolio_Renders_Without_Errors() - { - // Arrange & Act - var cut = RenderComponent(); - - // Assert - cut.Markup.Should().Contain("포트폴리오"); - } - - [Fact] - public void Portfolio_Displays_Summary_Cards() - { - // Arrange & Act - var cut = RenderComponent(); - - // Assert - Should have summary cards - cut.Markup.Should().Contain("총 평가액"); - cut.Markup.Should().Contain("보유 종목"); - cut.Markup.Should().Contain("수익률"); - cut.Markup.Should().Contain("위험도"); - } - - [Fact] - public void Portfolio_Shows_Asset_Table() - { - // Arrange & Act - var cut = RenderComponent(); - - // Assert - cut.Markup.Should().Contain("자산 구성"); - cut.Markup.Should().Contain("종목/펀드명"); - cut.Markup.Should().Contain("평가액"); - } - - [Fact] - public void Portfolio_Shows_Asset_Classification() - { - // Arrange & Act - var cut = RenderComponent(); - - // Assert - cut.Markup.Should().Contain("자산 분류"); - cut.Markup.Should().Contain("대형주"); - cut.Markup.Should().Contain("중형주"); - } - - [Fact] - public void Portfolio_Shows_Trading_History() - { - // Arrange & Act - var cut = RenderComponent(); - - // Assert - cut.Markup.Should().Contain("거래 이력"); - cut.Markup.Should().Contain("구분"); - cut.Markup.Should().Contain("금액"); - } -} - -/// -/// Unit tests for NavMenu component -/// -public class NavMenuComponentTests : TestContext -{ - [Fact] - public void NavMenu_Renders_Navigation_Links() - { - // Arrange & Act - var cut = RenderComponent(); - - // Assert - cut.Markup.Should().Contain("대시보드"); - cut.Markup.Should().Contain("관리"); - cut.Markup.Should().Contain("운영"); - } - - [Fact] - public void NavMenu_Has_Admin_Section() - { - // Arrange & Act - var cut = RenderComponent(); - - // Assert - cut.Markup.Should().Contain("사용자 관리"); - cut.Markup.Should().Contain("데이터 수집"); - cut.Markup.Should().Contain("설정"); - } - - [Fact] - public void NavMenu_Has_Help_Section() - { - // Arrange & Act - var cut = RenderComponent(); - - // Assert - cut.Markup.Should().Contain("도움말"); - cut.Markup.Should().Contain("문서"); - cut.Markup.Should().Contain("API"); - } -} diff --git a/src/dotnet/QuantEngine.Web/Endpoints/CollectionEndpoints.cs b/src/dotnet/QuantEngine.Web/Endpoints/CollectionEndpoints.cs index 8d2b71e7..787c43d1 100644 --- a/src/dotnet/QuantEngine.Web/Endpoints/CollectionEndpoints.cs +++ b/src/dotnet/QuantEngine.Web/Endpoints/CollectionEndpoints.cs @@ -1,4 +1,6 @@ using FastEndpoints; +using Hangfire; +using QuantEngine.Application.Interfaces; using QuantEngine.Core.Interfaces; namespace QuantEngine.Web.Endpoints; @@ -214,20 +216,52 @@ public class GetLatestSnapshotsEndpoint : Endpoint +{ + private readonly IBackgroundJobClient _jobClient; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + + public StartCollectionRunEndpoint( + IBackgroundJobClient jobClient, + IConfiguration configuration, + ILogger logger) + { + _jobClient = jobClient; + _configuration = configuration; + _logger = logger; + } + public override void Configure() { Post("/api/collection/run"); - AllowAnonymous(); Description(d => d - .Produces(202) + .Produces(202) .Produces(500)); } public override async Task HandleAsync(CancellationToken ct) { - // Return 202 Accepted status code via generic status code handler - await SendResultAsync(Microsoft.AspNetCore.Http.Results.Accepted()); + try + { + var runId = $"api-{DateTime.Now:yyyyMMdd-HHmmss}"; + var accountMode = _configuration["Kis:AccountMode"] ?? "mock"; + var tickers = new[] { "005930", "000660", "051910", "005380", "010140", "005490" }.ToList(); + + _jobClient.Enqueue(o => o.RunCollectionAsync(runId, accountMode, tickers)); + + _logger.LogInformation("Collection run {RunId} enqueued", runId); + + await SendAsync(new StartCollectionRunResponse { RunId = runId }, 202, ct); + } + catch + { + await SendErrorsAsync(500, ct); + } } } diff --git a/src/dotnet/QuantEngine.Web/Program.cs b/src/dotnet/QuantEngine.Web/Program.cs index b4c61b8e..37521149 100644 --- a/src/dotnet/QuantEngine.Web/Program.cs +++ b/src/dotnet/QuantEngine.Web/Program.cs @@ -102,6 +102,11 @@ try builder.Services.AddScoped(); builder.Services.AddHttpClient(); + // Collection Pipeline Services + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + // Hangfire Background Jobs try { diff --git a/src/dotnet/QuantEngine.Web/Services/SchedulerService.cs b/src/dotnet/QuantEngine.Web/Services/SchedulerService.cs index 69d118b3..82d94396 100644 --- a/src/dotnet/QuantEngine.Web/Services/SchedulerService.cs +++ b/src/dotnet/QuantEngine.Web/Services/SchedulerService.cs @@ -5,7 +5,9 @@ using Hangfire.PostgreSql; using Hangfire.MemoryStorage; using System.Linq.Expressions; using QuantEngine.Application.Services; +using QuantEngine.Application.Interfaces; using QuantEngine.Infrastructure.Data; +using Microsoft.Extensions.Configuration; namespace QuantEngine.Web.Services; @@ -17,15 +19,21 @@ public class SchedulerService private readonly ILogger _logger; private readonly IBackgroundJobClient _jobClient; private readonly IRecurringJobManager _recurringJobManager; + private readonly IServiceScopeFactory _scopeFactory; + private readonly IConfiguration _configuration; public SchedulerService( ILogger logger, IBackgroundJobClient jobClient, - IRecurringJobManager recurringJobManager) + IRecurringJobManager recurringJobManager, + IServiceScopeFactory scopeFactory, + IConfiguration configuration) { _logger = logger; _jobClient = jobClient; _recurringJobManager = recurringJobManager; + _scopeFactory = scopeFactory; + _configuration = configuration; } /// @@ -89,14 +97,22 @@ public class SchedulerService // List of tickers to collect var tickers = new[] { "005930", "000660", "051910", "005380", "010140", "005490" }; - foreach (var ticker in tickers) - { - // Simulate data collection - await Task.Delay(100); - _logger.LogInformation("Collected data for ticker: {Ticker}", ticker); - } + // Create scope for scoped services + using var scope = _scopeFactory.CreateScope(); + var orchestrator = scope.ServiceProvider.GetRequiredService(); - _logger.LogInformation("Daily data collection completed at {Time}", DateTime.Now); + // Build runId with timestamp + var runId = $"daily-{DateTime.Now:yyyyMMdd-HHmmss}"; + + // Read account mode from configuration (default to "mock") + var accountMode = _configuration["Kis:AccountMode"] ?? "mock"; + + // Execute collection + var result = await orchestrator.RunCollectionAsync(runId, accountMode, tickers.ToList()); + + // Log completion + _logger.LogInformation("Collection run {RunId} completed: {Snapshots} snapshots, {Errors} errors", + runId, result.SuccessCount, result.ErrorCount); } catch (Exception ex) { diff --git a/tests/e2e/button-check.spec.ts b/tests/e2e/archive/button-check.spec.ts similarity index 100% rename from tests/e2e/button-check.spec.ts rename to tests/e2e/archive/button-check.spec.ts diff --git a/tests/e2e/console-check.spec.ts b/tests/e2e/archive/console-check.spec.ts similarity index 100% rename from tests/e2e/console-check.spec.ts rename to tests/e2e/archive/console-check.spec.ts diff --git a/tests/e2e/debug-login.spec.ts b/tests/e2e/archive/debug-login.spec.ts similarity index 100% rename from tests/e2e/debug-login.spec.ts rename to tests/e2e/archive/debug-login.spec.ts diff --git a/tests/e2e/detailed-debug.spec.ts b/tests/e2e/archive/detailed-debug.spec.ts similarity index 100% rename from tests/e2e/detailed-debug.spec.ts rename to tests/e2e/archive/detailed-debug.spec.ts diff --git a/tests/e2e/final-login-test.spec.ts b/tests/e2e/archive/final-login-test.spec.ts similarity index 100% rename from tests/e2e/final-login-test.spec.ts rename to tests/e2e/archive/final-login-test.spec.ts diff --git a/tests/e2e/framework-check.spec.ts b/tests/e2e/archive/framework-check.spec.ts similarity index 100% rename from tests/e2e/framework-check.spec.ts rename to tests/e2e/archive/framework-check.spec.ts diff --git a/tests/e2e/full-validation.spec.ts b/tests/e2e/archive/full-validation.spec.ts similarity index 100% rename from tests/e2e/full-validation.spec.ts rename to tests/e2e/archive/full-validation.spec.ts diff --git a/tests/e2e/html-debug.spec.ts b/tests/e2e/archive/html-debug.spec.ts similarity index 100% rename from tests/e2e/html-debug.spec.ts rename to tests/e2e/archive/html-debug.spec.ts diff --git a/tests/e2e/html-inspection.spec.ts b/tests/e2e/archive/html-inspection.spec.ts similarity index 100% rename from tests/e2e/html-inspection.spec.ts rename to tests/e2e/archive/html-inspection.spec.ts diff --git a/tests/e2e/inspect-page.spec.ts b/tests/e2e/archive/inspect-page.spec.ts similarity index 100% rename from tests/e2e/inspect-page.spec.ts rename to tests/e2e/archive/inspect-page.spec.ts diff --git a/tests/e2e/login-improved.spec.ts b/tests/e2e/archive/login-improved.spec.ts similarity index 100% rename from tests/e2e/login-improved.spec.ts rename to tests/e2e/archive/login-improved.spec.ts diff --git a/tests/e2e/login-screenshot-final.spec.ts b/tests/e2e/archive/login-screenshot-final.spec.ts similarity index 100% rename from tests/e2e/login-screenshot-final.spec.ts rename to tests/e2e/archive/login-screenshot-final.spec.ts diff --git a/tests/e2e/login.spec.ts b/tests/e2e/archive/login.spec.ts similarity index 100% rename from tests/e2e/login.spec.ts rename to tests/e2e/archive/login.spec.ts diff --git a/tests/e2e/real-login-test.spec.ts b/tests/e2e/archive/real-login-test.spec.ts similarity index 100% rename from tests/e2e/real-login-test.spec.ts rename to tests/e2e/archive/real-login-test.spec.ts diff --git a/tests/e2e/screenshot-diagnosis.spec.ts b/tests/e2e/archive/screenshot-diagnosis.spec.ts similarity index 100% rename from tests/e2e/screenshot-diagnosis.spec.ts rename to tests/e2e/archive/screenshot-diagnosis.spec.ts diff --git a/tests/e2e/test-static-login.spec.ts b/tests/e2e/archive/test-static-login.spec.ts similarity index 100% rename from tests/e2e/test-static-login.spec.ts rename to tests/e2e/archive/test-static-login.spec.ts diff --git a/tests/e2e/verify-login-page.spec.ts b/tests/e2e/archive/verify-login-page.spec.ts similarity index 100% rename from tests/e2e/verify-login-page.spec.ts rename to tests/e2e/archive/verify-login-page.spec.ts diff --git a/tests/e2e/wasm-test.spec.ts b/tests/e2e/archive/wasm-test.spec.ts similarity index 100% rename from tests/e2e/wasm-test.spec.ts rename to tests/e2e/archive/wasm-test.spec.ts diff --git a/tests/e2e/evidence/.gitkeep b/tests/e2e/evidence/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/tests/e2e/evidence/qe-m1-02-collection-run.spec.ts b/tests/e2e/evidence/qe-m1-02-collection-run.spec.ts new file mode 100644 index 00000000..aab99294 --- /dev/null +++ b/tests/e2e/evidence/qe-m1-02-collection-run.spec.ts @@ -0,0 +1,148 @@ +import { test, expect } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; + +test.describe('QE-M1-02: Collection Run List & Detail Verification', () => { + // Login before each test + test.beforeEach(async ({ page }) => { + await page.goto('/Account/Login'); + await page.waitForLoadState('domcontentloaded'); + + // Fill login form with credentials (admin/admin) + const usernameInput = page.locator('#username'); + const passwordInput = page.locator('#password'); + const loginButton = page.locator('#loginBtn'); + + await usernameInput.fill('admin'); + await passwordInput.fill('admin'); + await loginButton.click(); + + // Wait for login to complete + await page.waitForLoadState('domcontentloaded'); + }); + + test('QE-M1-02: Collection run renders in list with API-derived expected values', async ({ page }) => { + // Step 1: Fetch expected values from API (source of truth) + const apiResponse = await page.request.get('/api/collection/runs?limit=20'); + expect(apiResponse.ok()).toBeTruthy(); + + const responseJson = await apiResponse.json(); + const runs = (responseJson as any).runs || []; + + // Fail if no collection runs exist in database + if (runs.length === 0) { + throw new Error( + 'No collection runs in DB — run the daily-collection job first. ' + + 'Expected at least 1 run from kis_collection_runs table.' + ); + } + + // Extract expected values from most recent run (first in list) + const expectedRun = runs[0]; + const expectedRunId = expectedRun.runId; + const expectedTotalSnapshots = expectedRun.totalSnapshots ?? 0; + const expectedStatus = expectedRun.status; // e.g., "completed", "running", "failed" + + // Map status to Korean text (same logic as Index.cshtml — unknown statuses + // like COMPLETED_WITH_ERRORS render the raw status string in a secondary badge) + let expectedStatusText = String(expectedStatus ?? ''); + if (expectedStatus?.toLowerCase() === 'completed') { + expectedStatusText = '완료'; + } else if (expectedStatus?.toLowerCase() === 'running') { + expectedStatusText = '진행 중'; + } else if (expectedStatus?.toLowerCase() === 'failed') { + expectedStatusText = '실패'; + } + + console.log( + `\n=== QE-M1-02 Test Started ===\n` + + `Expected RunId: ${expectedRunId}\n` + + `Expected TotalSnapshots: ${expectedTotalSnapshots}\n` + + `Expected Status: ${expectedStatus} (rendered as: ${expectedStatusText})\n` + ); + + // Step 2: Navigate to Collection admin page + await page.goto('/Admin/Collection'); + await page.waitForLoadState('domcontentloaded'); + + // Step 3: Verify page title contains "데이터 수집" (collection) + const pageTitle = await page.title(); + expect(pageTitle).toContain('데이터 수집'); + + // Step 4: Assert that a row containing the expected runId is visible + const runIdCell = page.locator(`td:has-text("${expectedRunId}")`); + await expect(runIdCell).toBeVisible(); + console.log(`✓ RunId row found and visible: ${expectedRunId}`); + + // Step 5: Find the row containing this runId and verify the snapshot count + const tableRow = runIdCell.locator('xpath=ancestor::tr'); + + // Within the row, find all td elements and map to columns + // Columns: 실행 ID (0), 시작 시간 (1), 종료 시간 (2), 상태 (3), 스냅샷 수 (4), 오류 수 (5) + const cells = tableRow.locator('td'); + const cellCount = await cells.count(); + expect(cellCount).toBeGreaterThanOrEqual(5); // At least 5 columns + + // Cell 4 (index 4) is "스냅샷 수" (total snapshots) + const snapshotCell = cells.nth(4); + const snapshotText = await snapshotCell.textContent(); + expect(snapshotText?.trim()).toBe(String(expectedTotalSnapshots)); + console.log(`✓ Snapshot count matches: ${snapshotText?.trim()} == ${expectedTotalSnapshots}`); + + // Cell 3 (index 3) is "상태" (status badge) + const statusCell = cells.nth(3); + const statusBadge = statusCell.locator('span.badge'); + const statusBadgeText = await statusBadge.textContent(); + expect(statusBadgeText?.trim()).toBe(expectedStatusText); + console.log(`✓ Status badge matches: ${statusBadgeText?.trim()} == ${expectedStatusText}`); + + // Step 6: Create screenshot directory and take screenshot of collection list + const screenshotDir = path.join(process.cwd(), 'Temp', 'evidence', 'QE-M1-02', 'screenshots'); + fs.mkdirSync(screenshotDir, { recursive: true }); + + await page.screenshot({ + path: path.join(screenshotDir, '01-collection-page.png'), + fullPage: true, + }); + console.log(`✓ Screenshot saved: 01-collection-page.png`); + + // Step 7: Navigate to the run detail page + // The detail page route is /Admin/Collection/{runId} + await page.goto(`/Admin/Collection/${expectedRunId}`); + await page.waitForLoadState('domcontentloaded'); + + // Step 8: Verify detail page title contains the runId + const detailPageTitle = await page.title(); + expect(detailPageTitle).toContain('수집 실행 상세'); + + // Step 9: Verify that the RunId is displayed on the detail page + // The page title shows: "수집 실행 상세 - {runId}" + const pageHeading = page.locator('h2.page-title'); + const headingText = await pageHeading.textContent(); + expect(headingText).toContain(expectedRunId); + console.log(`✓ Detail page title contains RunId: ${headingText}`); + + // Step 10: Verify snapshots count is displayed on detail page + // The snapshot count appears in a card with "스냅샷 수" as the title + const snapshotCountCard = page.locator('h4.card-title:has-text("스냅샷 수")'); + await expect(snapshotCountCard).toBeVisible(); + + // The value is in a div with class h6 after the title + const snapshotCountValue = snapshotCountCard.locator('xpath=following-sibling::div[1]'); + const countText = await snapshotCountValue.textContent(); + expect(countText?.trim()).toBe(String(expectedTotalSnapshots)); + console.log(`✓ Detail page snapshot count matches: ${countText?.trim()} == ${expectedTotalSnapshots}`); + + // Step 11: Take screenshot of detail page + await page.screenshot({ + path: path.join(screenshotDir, '02-run-detail.png'), + fullPage: true, + }); + console.log(`✓ Screenshot saved: 02-run-detail.png`); + + console.log( + `\n=== QE-M1-02 Test Completed Successfully ===\n` + + `Evidence files saved to: ${screenshotDir}\n` + ); + }); +}); diff --git a/tests/unit/test_validate_quant_engine_wbs_v1.py b/tests/unit/test_validate_quant_engine_wbs_v1.py new file mode 100644 index 00000000..4901dc3d --- /dev/null +++ b/tests/unit/test_validate_quant_engine_wbs_v1.py @@ -0,0 +1,498 @@ +""" +Tests for verify_wbs_task_v1.py and validate_quant_engine_wbs_v1.py +""" +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest +import yaml + +# Import the modules to test +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +from tools.verify_wbs_task_v1 import parse_dotnet_connection_string + + +class TestParseDotnetConnectionString: + """Test .NET connection string parsing.""" + + def test_parse_basic_connection_string(self): + """Test parsing a basic connection string.""" + s = "Host=localhost;Port=5432;Database=testdb;Username=user;Password=pass;" + result = parse_dotnet_connection_string(s) + assert result["host"] == "localhost" + assert result["port"] == "5432" + assert result["dbname"] == "testdb" + assert result["user"] == "user" + assert result["password"] == "pass" + assert "options" not in result + + def test_parse_with_search_path(self): + """Test parsing with Search Path.""" + s = "Host=localhost;Database=testdb;Username=user;Password=pass;Search Path=myschema;" + result = parse_dotnet_connection_string(s) + assert result["host"] == "localhost" + assert result["dbname"] == "testdb" + assert result["options"] == "-c search_path=myschema" + + def test_parse_case_insensitive(self): + """Test case-insensitive key handling.""" + s = "HOST=localhost;DATABASE=testdb;USERNAME=user;PASSWORD=pass;" + result = parse_dotnet_connection_string(s) + assert result["host"] == "localhost" + assert result["dbname"] == "testdb" + assert result["user"] == "user" + assert result["password"] == "pass" + + def test_parse_unknown_keys_ignored(self): + """Test that unknown keys are ignored.""" + s = "Host=localhost;UnknownKey=value;Database=testdb;" + result = parse_dotnet_connection_string(s) + assert result["host"] == "localhost" + assert result["dbname"] == "testdb" + assert "UnknownKey" not in result and "unknownkey" not in result + + def test_parse_empty_string(self): + """Test parsing empty string.""" + result = parse_dotnet_connection_string("") + assert result == {} + + def test_parse_with_spaces(self): + """Test parsing with extra spaces.""" + s = " Host = localhost ; Database = testdb ; " + result = parse_dotnet_connection_string(s) + assert result["host"] == "localhost" + assert result["dbname"] == "testdb" + + +class TestJsonGateComparison: + """Test json_gate comparison operator parsing.""" + + def test_json_gate_exact_equality(self, tmp_path): + """Test exact equality in json_gate.""" + repo_root = tmp_path / "repo" + repo_root.mkdir() + spec_path = repo_root / "spec" / "60_quant_engine_wbs.yaml" + spec_path.parent.mkdir() + + spec = { + "meta": {"formula_id": "QUANT_ENGINE_WBS_V1"}, + "tasks": { + "TEST-01": { + "title": "Test task", + "status": "PENDING", + "depends_on": [], + "success_criteria": { + "expected_success_value": {}, + "evidence_artifacts": [], + "verification_commands": [] + }, + "evidence_checks": [ + { + "type": "json_gate", + "path": "Temp/test_output.json", + "expect": {"gate": "PASS", "count": 5} + } + ] + } + } + } + spec_path.write_text(yaml.dump(spec), encoding="utf-8") + + # Create test artifact + artifact_path = repo_root / "Temp" / "test_output.json" + artifact_path.parent.mkdir(parents=True) + artifact_path.write_text(json.dumps({"gate": "PASS", "count": 5}), encoding="utf-8") + + # Run verify_wbs_task_v1 + tools_dir = Path(__file__).resolve().parents[2] / "tools" + result = subprocess.run( + [sys.executable, str(tools_dir / "verify_wbs_task_v1.py"), "--task", "TEST-01", + "--repo-root", str(repo_root), "--spec", str(spec_path)], + cwd=repo_root, + capture_output=True, + text=True + ) + assert result.returncode == 0, result.stderr + + def test_json_gate_greater_equal_comparison(self, tmp_path): + """Test >= comparison in json_gate.""" + repo_root = tmp_path / "repo" + repo_root.mkdir() + spec_path = repo_root / "spec" / "60_quant_engine_wbs.yaml" + spec_path.parent.mkdir() + + spec = { + "meta": {"formula_id": "QUANT_ENGINE_WBS_V1"}, + "tasks": { + "TEST-02": { + "title": "Test task", + "status": "PENDING", + "depends_on": [], + "success_criteria": { + "expected_success_value": {}, + "evidence_artifacts": [], + "verification_commands": [] + }, + "evidence_checks": [ + { + "type": "json_gate", + "path": "Temp/test_output.json", + "expect": {"compared_count": ">=20"} + } + ] + } + } + } + spec_path.write_text(yaml.dump(spec), encoding="utf-8") + + # Create test artifact - PASS case + artifact_path = repo_root / "Temp" / "test_output.json" + artifact_path.parent.mkdir(parents=True) + artifact_path.write_text(json.dumps({"compared_count": 25}), encoding="utf-8") + + tools_dir = Path(__file__).resolve().parents[2] / "tools" + result = subprocess.run( + [sys.executable, str(tools_dir / "verify_wbs_task_v1.py"), "--task", "TEST-02", + "--repo-root", str(repo_root), "--spec", str(spec_path)], + cwd=repo_root, + capture_output=True, + text=True + ) + assert result.returncode == 0, result.stderr + + def test_json_gate_greater_equal_fail(self, tmp_path): + """Test >= comparison fails when below threshold.""" + repo_root = tmp_path / "repo" + repo_root.mkdir() + spec_path = repo_root / "spec" / "60_quant_engine_wbs.yaml" + spec_path.parent.mkdir() + + spec = { + "meta": {"formula_id": "QUANT_ENGINE_WBS_V1"}, + "tasks": { + "TEST-03": { + "title": "Test task", + "status": "PENDING", + "depends_on": [], + "success_criteria": { + "expected_success_value": {}, + "evidence_artifacts": [], + "verification_commands": [] + }, + "evidence_checks": [ + { + "type": "json_gate", + "path": "Temp/test_output.json", + "expect": {"compared_count": ">=20"} + } + ] + } + } + } + spec_path.write_text(yaml.dump(spec), encoding="utf-8") + + # Create test artifact - FAIL case (value too low) + artifact_path = repo_root / "Temp" / "test_output.json" + artifact_path.parent.mkdir(parents=True) + artifact_path.write_text(json.dumps({"compared_count": 15}), encoding="utf-8") + + tools_dir = Path(__file__).resolve().parents[2] / "tools" + result = subprocess.run( + [sys.executable, str(tools_dir / "verify_wbs_task_v1.py"), "--task", "TEST-03", + "--repo-root", str(repo_root), "--spec", str(spec_path)], + cwd=repo_root, + capture_output=True, + text=True + ) + assert result.returncode == 1, f"Expected failure, got: {result.stdout}" + + +class TestLogPatternCheck: + """Test log_pattern check type.""" + + def test_log_pattern_min_matches_pass(self, tmp_path): + """Test log_pattern with min_matches that passes.""" + repo_root = tmp_path / "repo" + repo_root.mkdir() + spec_path = repo_root / "spec" / "60_quant_engine_wbs.yaml" + spec_path.parent.mkdir() + + spec = { + "meta": {"formula_id": "QUANT_ENGINE_WBS_V1"}, + "tasks": { + "TEST-04": { + "title": "Test task", + "status": "PENDING", + "depends_on": [], + "success_criteria": { + "expected_success_value": {}, + "evidence_artifacts": [], + "verification_commands": [] + }, + "evidence_checks": [ + { + "type": "log_pattern", + "file_glob": "Temp/test.log", + "pattern": "SUCCESS", + "expect": {"min_matches": 1} + } + ] + } + } + } + spec_path.write_text(yaml.dump(spec), encoding="utf-8") + + # Create log file with matches + log_path = repo_root / "Temp" / "test.log" + log_path.parent.mkdir(parents=True) + log_path.write_text("Operation SUCCESS\nAnother line\nOperation SUCCESS\n", encoding="utf-8") + + tools_dir = Path(__file__).resolve().parents[2] / "tools" + result = subprocess.run( + [sys.executable, str(tools_dir / "verify_wbs_task_v1.py"), "--task", "TEST-04", + "--repo-root", str(repo_root), "--spec", str(spec_path)], + cwd=repo_root, + capture_output=True, + text=True + ) + assert result.returncode == 0, result.stderr + + def test_log_pattern_max_matches_zero(self, tmp_path): + """Test log_pattern with max_matches=0 (no file should match).""" + repo_root = tmp_path / "repo" + repo_root.mkdir() + spec_path = repo_root / "spec" / "60_quant_engine_wbs.yaml" + spec_path.parent.mkdir() + + spec = { + "meta": {"formula_id": "QUANT_ENGINE_WBS_V1"}, + "tasks": { + "TEST-05": { + "title": "Test task", + "status": "PENDING", + "depends_on": [], + "success_criteria": { + "expected_success_value": {}, + "evidence_artifacts": [], + "verification_commands": [] + }, + "evidence_checks": [ + { + "type": "log_pattern", + "file_glob": "Temp/nonexistent.log", + "pattern": "ERROR", + "expect": {"max_matches": 0} + } + ] + } + } + } + spec_path.write_text(yaml.dump(spec), encoding="utf-8") + + tools_dir = Path(__file__).resolve().parents[2] / "tools" + result = subprocess.run( + [sys.executable, str(tools_dir / "verify_wbs_task_v1.py"), "--task", "TEST-05", + "--repo-root", str(repo_root), "--spec", str(spec_path)], + cwd=repo_root, + capture_output=True, + text=True + ) + # Should pass because file doesn't exist = 0 matches + assert result.returncode == 0, result.stderr + + +class TestFileExistsCheck: + """Test file_exists check type.""" + + def test_file_exists_with_min_bytes_fail(self, tmp_path): + """Test file_exists fails when file is too small.""" + repo_root = tmp_path / "repo" + repo_root.mkdir() + spec_path = repo_root / "spec" / "60_quant_engine_wbs.yaml" + spec_path.parent.mkdir() + + spec = { + "meta": {"formula_id": "QUANT_ENGINE_WBS_V1"}, + "tasks": { + "TEST-06": { + "title": "Test task", + "status": "PENDING", + "depends_on": [], + "success_criteria": { + "expected_success_value": {}, + "evidence_artifacts": [], + "verification_commands": [] + }, + "evidence_checks": [ + { + "type": "file_exists", + "paths": ["Temp/small_file.txt"], + "expect": {"min_bytes": 100} + } + ] + } + } + } + spec_path.write_text(yaml.dump(spec), encoding="utf-8") + + # Create a file smaller than min_bytes + file_path = repo_root / "Temp" / "small_file.txt" + file_path.parent.mkdir(parents=True) + file_path.write_text("small", encoding="utf-8") + + tools_dir = Path(__file__).resolve().parents[2] / "tools" + result = subprocess.run( + [sys.executable, str(tools_dir / "verify_wbs_task_v1.py"), "--task", "TEST-06", + "--repo-root", str(repo_root), "--spec", str(spec_path)], + cwd=repo_root, + capture_output=True, + text=True + ) + assert result.returncode == 1, f"Expected failure, got: {result.stdout}" + + +class TestValidatorLogic: + """Test validate_quant_engine_wbs_v1.py logic.""" + + def test_validator_done_task_with_passing_verdict(self, tmp_path): + """Test validator passes when DONE task has PASS verdict.""" + repo_root = tmp_path / "repo" + repo_root.mkdir() + spec_path = repo_root / "spec" / "60_quant_engine_wbs.yaml" + spec_path.parent.mkdir() + + spec = { + "meta": {"formula_id": "QUANT_ENGINE_WBS_V1"}, + "tasks": { + "TEST-07": { + "title": "Test task", + "status": "DONE", + "depends_on": [], + "success_criteria": { + "expected_success_value": {}, + "evidence_artifacts": ["Temp/evidence/TEST-07/verdict.json"], + "verification_commands": [] + }, + "evidence_checks": [{"type": "file_exists", "paths": ["spec/60_quant_engine_wbs.yaml"]}] + } + } + } + spec_path.write_text(yaml.dump(spec), encoding="utf-8") + + # Create a passing verdict + verdict_path = repo_root / "Temp" / "evidence" / "TEST-07" / "verdict.json" + verdict_path.parent.mkdir(parents=True) + verdict_path.write_text( + json.dumps({ + "task_id": "TEST-07", + "formula_id": "QUANT_ENGINE_WBS_TASK_V1", + "gate": "PASS", + "checks": [] + }), + encoding="utf-8" + ) + + # Create roadmap pointer + roadmap_path = repo_root / "docs" / "ROADMAP_WBS.md" + roadmap_path.parent.mkdir(parents=True) + roadmap_path.write_text("# Roadmap\n\nQUANT_ENGINE_WBS_V1 is the standard.\n", encoding="utf-8") + + tools_dir = Path(__file__).resolve().parents[2] / "tools" + result = subprocess.run( + [sys.executable, str(tools_dir / "validate_quant_engine_wbs_v1.py"), + "--repo-root", str(repo_root), "--spec", str(spec_path)], + cwd=repo_root, + capture_output=True, + text=True + ) + assert result.returncode == 0, result.stderr + + def test_validator_done_task_without_verdict(self, tmp_path): + """Test validator fails when DONE task is missing verdict.""" + repo_root = tmp_path / "repo" + repo_root.mkdir() + spec_path = repo_root / "spec" / "60_quant_engine_wbs.yaml" + spec_path.parent.mkdir() + + spec = { + "meta": {"formula_id": "QUANT_ENGINE_WBS_V1"}, + "tasks": { + "TEST-08": { + "title": "Test task", + "status": "DONE", + "depends_on": [], + "success_criteria": { + "expected_success_value": {}, + "evidence_artifacts": [], + "verification_commands": [] + }, + "evidence_checks": [] + } + } + } + spec_path.write_text(yaml.dump(spec), encoding="utf-8") + + # Create roadmap pointer + roadmap_path = repo_root / "docs" / "ROADMAP_WBS.md" + roadmap_path.parent.mkdir(parents=True) + roadmap_path.write_text("# Roadmap\n\nQUANT_ENGINE_WBS_V1 is the standard.\n", encoding="utf-8") + + tools_dir = Path(__file__).resolve().parents[2] / "tools" + result = subprocess.run( + [sys.executable, str(tools_dir / "validate_quant_engine_wbs_v1.py"), + "--repo-root", str(repo_root), "--spec", str(spec_path)], + cwd=repo_root, + capture_output=True, + text=True + ) + assert result.returncode == 1, f"Expected failure, got: {result.stdout}" + + def test_validator_pending_tasks_no_verdict_required(self, tmp_path): + """Test validator passes for PENDING tasks without verdicts.""" + repo_root = tmp_path / "repo" + repo_root.mkdir() + spec_path = repo_root / "spec" / "60_quant_engine_wbs.yaml" + spec_path.parent.mkdir() + + spec = { + "meta": {"formula_id": "QUANT_ENGINE_WBS_V1"}, + "tasks": { + "TEST-09": { + "title": "Test task", + "status": "PENDING", + "depends_on": [], + "success_criteria": { + "expected_success_value": {}, + "evidence_artifacts": [], + "verification_commands": [] + }, + "evidence_checks": [ + {"type": "file_exists", "paths": ["spec/60_quant_engine_wbs.yaml"]} + ] + } + } + } + spec_path.write_text(yaml.dump(spec), encoding="utf-8") + + # Create roadmap pointer + roadmap_path = repo_root / "docs" / "ROADMAP_WBS.md" + roadmap_path.parent.mkdir(parents=True) + roadmap_path.write_text("# Roadmap\n\nQUANT_ENGINE_WBS_V1 is the standard.\n", encoding="utf-8") + + tools_dir = Path(__file__).resolve().parents[2] / "tools" + result = subprocess.run( + [sys.executable, str(tools_dir / "validate_quant_engine_wbs_v1.py"), + "--repo-root", str(repo_root), "--spec", str(spec_path)], + cwd=repo_root, + capture_output=True, + text=True + ) + assert result.returncode == 0, result.stderr + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tools/validate_golden_coverage_100.py b/tools/validate_golden_coverage_100.py index 57e327c7..cede3bfe 100644 --- a/tools/validate_golden_coverage_100.py +++ b/tools/validate_golden_coverage_100.py @@ -109,6 +109,24 @@ def main() -> int: ok_ratio = ratio >= COVERAGE_TARGET ok_critical = len(critical_missing) == 0 + # 정직성 표기 (QE-M0-06): 이 게이트는 golden 케이스/파일의 "존재 수"만 집계한다. + # tests/golden/generated/ 의 174개 파일은 실행되지 않는 placeholder이며, 수치 + # 실행 검증은 M3의 factor parity 게이트(validate_factor_parity_v1)로 대체된다. + payload = { + "formula_id": "GOLDEN_COVERAGE_100_V1", + "gate": "PASS" if (ok_ratio and ok_critical) else "FAIL", + "coverage_basis": "FILE_COUNT_ONLY", + "golden_coverage_ratio": ratio, + "yaml_formula_count": total, + "golden_test_count": golden, + "critical_missing": sorted(critical_missing), + "uncovered_count": len(uncovered), + "note": "coverage counts YAML golden-case entries/files only; generated golden test stubs are not executed", + } + out_path = ROOT / "Temp" / "golden_coverage_100_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(f"[GOLDEN_COVERAGE_100] total={total} golden={golden} ratio={ratio:.4f} " f"({'≥' if ok_ratio else '<'}{COVERAGE_TARGET}) " f"critical_missing={len(critical_missing)}") diff --git a/tools/validate_quant_engine_wbs_v1.py b/tools/validate_quant_engine_wbs_v1.py new file mode 100644 index 00000000..57076792 --- /dev/null +++ b/tools/validate_quant_engine_wbs_v1.py @@ -0,0 +1,197 @@ +#!/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 + evidence_checks = task.get("evidence_checks", []) + if not evidence_checks: + missing_criteria.append(f"{task_id}.evidence_checks (empty)") + else: + 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()) diff --git a/tools/verify_wbs_task_v1.py b/tools/verify_wbs_task_v1.py new file mode 100644 index 00000000..d1895255 --- /dev/null +++ b/tools/verify_wbs_task_v1.py @@ -0,0 +1,584 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import yaml + +try: + import psycopg +except ImportError: + psycopg = None + + +def parse_dotnet_connection_string(s: str) -> dict[str, str | None]: + """Parse .NET connection string format to psycopg-compatible dict. + + Input: "Host=x;Port=n;Database=d;Username=u;Password=p;Search Path=s;" + Output: {host, port, dbname, user, password, options (if Search Path present)} + Keys are case-insensitive; unknown keys ignored. + """ + result: dict[str, str | None] = {} + parts = s.split(";") + search_path_value = None + + for part in parts: + part = part.strip() + if not part: + continue + if "=" not in part: + continue + key, value = part.split("=", 1) + key_lower = key.strip().lower() + value = value.strip() + + if key_lower == "host": + result["host"] = value + elif key_lower == "port": + result["port"] = value + elif key_lower == "database": + result["dbname"] = value + elif key_lower == "username": + result["user"] = value + elif key_lower == "password": + result["password"] = value + elif key_lower == "search path": + search_path_value = value + + if search_path_value: + result["options"] = f"-c search_path={search_path_value}" + + return result + + +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 resolve_db_connection(root: Path, spec: dict[str, Any]) -> str | None: + """Resolve PostgreSQL connection string. + + Resolution order: + 1. env QE_WBS_PG_DSN (psycopg DSN format) + 2. env ConnectionStrings__DefaultConnection (.NET format, convert to psycopg) + 3. appsettings.Development.json ConnectionStrings.DefaultConnection (.NET format, convert) + """ + import os + + # Try env QE_WBS_PG_DSN + dsn = os.environ.get("QE_WBS_PG_DSN") + if dsn: + return dsn + + # Try env ConnectionStrings__DefaultConnection (.NET format) + dotnet_str = os.environ.get("ConnectionStrings__DefaultConnection") + if dotnet_str: + parsed = parse_dotnet_connection_string(dotnet_str) + return _build_psycopg_dsn(parsed) + + # Try appsettings.Development.json + appsettings_path = root / spec.get("meta", {}).get("db_connection", {}).get("dotnet_appsettings", "") + if appsettings_path and appsettings_path.is_relative_to(root): + full_path = root / appsettings_path + if full_path.exists(): + try: + appsettings = json.loads(full_path.read_text(encoding="utf-8")) + conn_str = appsettings.get("ConnectionStrings", {}).get("DefaultConnection", "") + if conn_str: + parsed = parse_dotnet_connection_string(conn_str) + return _build_psycopg_dsn(parsed) + except Exception: + pass + + return None + + +def _build_psycopg_dsn(parsed: dict[str, str | None]) -> str: + """Build psycopg DSN from parsed dict.""" + parts = [] + for key in ["host", "port", "dbname", "user", "password"]: + val = parsed.get(key) + if val: + parts.append(f"{key}={val}") + # The evidence queries use fully-qualified schema names. Do not emit the + # parsed Search Path as a libpq DSN option: unquoted values are interpreted + # as connection keywords by psycopg (for example, "search_path"), which + # makes an otherwise valid .NET connection string fail to connect. + return " ".join(parts) + + +def check_pg_query(root: Path, check: dict[str, Any], dsn: str | None) -> tuple[bool, dict[str, Any]]: + """Verify pg_query check type.""" + if psycopg is None: + return False, {"error": "psycopg not installed"} + + if not dsn: + return False, {"error": "No PostgreSQL connection available"} + + sql = check.get("sql", "") + expect = check.get("expect", {}) + + try: + conn = psycopg.connect(dsn) + try: + cursor = conn.cursor() + cursor.execute(sql) + row = cursor.fetchone() + observed = row[0] if row else None + + # Try to coerce to numeric for comparison + if observed is not None: + try: + observed = float(observed) + except (ValueError, TypeError): + pass + + # Check expectations + passed = True + if "min" in expect: + min_val = expect["min"] + if observed is None or float(observed) < float(min_val): + passed = False + if "max" in expect and passed: + max_val = expect["max"] + if observed is None or float(observed) > float(max_val): + passed = False + if "equals" in expect and passed: + eq_val = expect["equals"] + if observed != eq_val: + passed = False + + cursor.close() + conn.close() + + return passed, { + "sql": sql, + "observed": observed, + "expected": expect, + "connected_as_host": dsn.split("host=")[-1].split()[0] if "host=" in dsn else "unknown" + } + except Exception as e: + conn.close() + return False, {"error": str(e), "sql": sql} + except Exception as e: + return False, {"error": str(e), "sql": sql} + + +def check_log_pattern(root: Path, check: dict[str, Any]) -> tuple[bool, dict[str, Any]]: + """Verify log_pattern check type.""" + from pathlib import Path as PathlibPath + + file_glob = check.get("file_glob", "") + pattern_str = check.get("pattern", "") + expect = check.get("expect", {}) + min_matches = expect.get("min_matches") + max_matches = expect.get("max_matches") + max_age_hours = expect.get("max_age_hours") + + if not file_glob or not pattern_str: + return False, {"error": "Missing file_glob or pattern"} + + try: + pattern = re.compile(pattern_str) + except re.error as e: + return False, {"error": f"Invalid regex: {e}"} + + # Glob for files + glob_path = root / file_glob + matched_files = list(glob_path.parent.glob(glob_path.name)) if "*" in file_glob else ( + [glob_path] if glob_path.exists() else [] + ) + + # Handle ** in glob + if "**" in file_glob: + parts = file_glob.split("**") + base = root / parts[0] if parts[0] else root + suffix = parts[-1] if len(parts) > 1 else "*" + matched_files = list(base.glob(f"**/{suffix}")) + + # Filter by age if needed + import time as time_module + now = time_module.time() + if max_age_hours: + max_age_seconds = max_age_hours * 3600 + matched_files = [f for f in matched_files if f.is_file() and (now - f.stat().st_mtime) <= max_age_seconds] + + # Count matches + match_count = 0 + matched_lines = [] + for file_path in matched_files: + try: + content = file_path.read_text(encoding="utf-8", errors="replace") + for line in content.splitlines(): + if pattern.search(line): + match_count += 1 + if len(matched_lines) < 200: + matched_lines.append(f"{file_path.name}: {line}") + except Exception: + pass + + # Check expectations + passed = True + if min_matches is not None and match_count < min_matches: + passed = False + if max_matches is not None and match_count > max_matches: + passed = False + + # Special case: if neither bound given, default min_matches=1 + if min_matches is None and max_matches is None: + if match_count < 1: + passed = False + + return passed, { + "file_glob": file_glob, + "pattern": pattern_str, + "observed": match_count, + "expected": expect, + "matched_lines": matched_lines[:200] + } + + +def check_json_gate(root: Path, check: dict[str, Any]) -> tuple[bool, dict[str, Any]]: + """Verify json_gate check type.""" + path_str = check.get("path", "") + expect = check.get("expect", {}) + + if not path_str: + return False, {"error": "Missing path"} + + json_path = root / path_str + if not json_path.exists(): + return False, {"error": f"JSON file not found: {json_path}", "path": path_str} + + try: + payload = json.loads(json_path.read_text(encoding="utf-8")) + except Exception as e: + return False, {"error": f"Failed to parse JSON: {e}", "path": path_str} + + # Check each expectation key + passed = True + details = {} + for key, expected_val in expect.items(): + # Dot-notation path support + observed_val = payload + for part in key.split("."): + if isinstance(observed_val, dict): + observed_val = observed_val.get(part) + else: + observed_val = None + break + + # Check if expected_val is a comparison operator string + if isinstance(expected_val, str): + # Check two-character operators first + for op, op_str in [(">=", ">="), ("<=", "<="), (">", ">"), ("<", "<")]: + if expected_val.startswith(op_str): + try: + expected_num = float(expected_val[len(op_str):]) + observed_num = float(observed_val) if observed_val is not None else None + if observed_num is None: + passed = False + details[key] = f"Expected {expected_val}, got {observed_val}" + elif op == ">=" and observed_num < expected_num: + passed = False + details[key] = f"Expected {expected_val}, got {observed_num}" + elif op == "<=" and observed_num > expected_num: + passed = False + details[key] = f"Expected {expected_val}, got {observed_num}" + elif op == ">" and observed_num <= expected_num: + passed = False + details[key] = f"Expected {expected_val}, got {observed_num}" + elif op == "<" and observed_num >= expected_num: + passed = False + details[key] = f"Expected {expected_val}, got {observed_num}" + except (ValueError, TypeError): + passed = False + details[key] = f"Failed to parse comparison: {expected_val}" + break + else: + # Plain equality + if observed_val != expected_val: + passed = False + details[key] = f"Expected {expected_val}, got {observed_val}" + else: + # Plain equality + if observed_val != expected_val: + passed = False + details[key] = f"Expected {expected_val}, got {observed_val}" + + return passed, { + "path": path_str, + "expected": expect, + "details": details if details else "All checks passed" + } + + +def check_file_exists(root: Path, check: dict[str, Any]) -> tuple[bool, dict[str, Any]]: + """Verify file_exists check type.""" + paths = check.get("paths", []) + expect = check.get("expect", {}) + min_bytes = expect.get("min_bytes") + + if not paths: + return False, {"error": "Missing paths"} + + details = {} + passed = True + for path_str in paths: + file_path = root / path_str + exists = file_path.exists() + details[path_str] = {"exists": exists} + + if not exists: + passed = False + continue + + if file_path.is_file() and min_bytes is not None: + size = file_path.stat().st_size + details[path_str]["size"] = size + if size < min_bytes: + passed = False + + return passed, { + "paths": paths, + "expected": expect, + "details": details + } + + +def check_playwright_report(root: Path, check: dict[str, Any]) -> tuple[bool, dict[str, Any]]: + """Verify playwright_report check type.""" + report_path_str = check.get("report", "") + spec_file = check.get("spec_file", "") + expect = check.get("expect", {}) + passed_min = expect.get("passed_min", 1) + failed_expected = expect.get("failed", 0) + + if not report_path_str or not spec_file: + return False, {"error": "Missing report or spec_file"} + + report_path = root / report_path_str + if not report_path.exists(): + return False, {"error": f"Playwright report not found: {report_path}"} + + try: + report = json.loads(report_path.read_text(encoding="utf-8")) + except Exception as e: + return False, {"error": f"Failed to parse report: {e}"} + + # Walk suites to find specs matching spec_file + passed_count = 0 + failed_count = 0 + + def walk_suites(suites_list): + nonlocal passed_count, failed_count + if not suites_list: + return + for suite in suites_list: + # Recurse into nested suites + if "suites" in suite: + walk_suites(suite["suites"]) + # Check specs + if "specs" in suite: + for spec in suite["specs"]: + if spec_file in spec.get("file", ""): + if spec.get("ok"): + passed_count += 1 + else: + failed_count += 1 + + suites = report.get("suites", []) + walk_suites(suites) + + passed = (passed_count >= passed_min) and (failed_count == failed_expected) + + return passed, { + "report": report_path_str, + "spec_file": spec_file, + "passed_count": passed_count, + "failed_count": failed_count, + "expected": expect + } + + +def run_verification_commands(root: Path, task_id: str, commands: list[str]) -> list[dict[str, Any]]: + """Execute verification commands (excluding self-references).""" + results = [] + evidence_dir = root / "Temp" / "evidence" / task_id + evidence_dir.mkdir(parents=True, exist_ok=True) + + for idx, cmd in enumerate(commands): + # Skip commands that reference this script to avoid recursion + if "verify_wbs_task_v1.py" in cmd: + continue + + try: + # Write to command log file + log_file = evidence_dir / f"command_{idx}.log" + result = subprocess.run( + cmd, + shell=True, + cwd=root, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace" + ) + output = result.stdout + result.stderr + log_file.write_text(output, encoding="utf-8") + results.append({ + "index": idx, + "command": cmd, + "returncode": result.returncode, + "log_file": str(log_file.relative_to(root)) + }) + except Exception as e: + results.append({ + "index": idx, + "command": cmd, + "error": str(e) + }) + + return results + + +def main(argv: list[str] | None = None) -> int: + """Main entry point.""" + parser = argparse.ArgumentParser(description="Verify a single WBS task evidence") + parser.add_argument("--task", required=True, help="Task ID (e.g., QE-M0-01)") + parser.add_argument("--repo-root", default=None, help="Repository root path") + parser.add_argument("--spec", default=None, help="Spec YAML file path") + parser.add_argument("--run-commands", action="store_true", help="Execute verification commands") + + args = parser.parse_args(argv) + task_id = args.task + + # 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 2 + + # Find task + tasks = spec.get("tasks", {}) + if task_id not in tasks: + print(f"ERROR: Task {task_id} not found in spec") + return 2 + + task = tasks[task_id] + evidence_checks = task.get("evidence_checks", []) + verification_commands = task.get("success_criteria", {}).get("verification_commands", []) + + # Create evidence directory + evidence_dir = root / "Temp" / "evidence" / task_id + evidence_dir.mkdir(parents=True, exist_ok=True) + + # Resolve DB connection + dsn = resolve_db_connection(root, spec) + + # Run checks + check_results = [] + gate = "PASS" + + for check_idx, check in enumerate(evidence_checks): + check_type = check.get("type") + passed = False + detail = {} + + try: + if check_type == "pg_query": + passed, detail = check_pg_query(root, check, dsn) + elif check_type == "log_pattern": + passed, detail = check_log_pattern(root, check) + elif check_type == "json_gate": + passed, detail = check_json_gate(root, check) + elif check_type == "file_exists": + passed, detail = check_file_exists(root, check) + elif check_type == "playwright_report": + passed, detail = check_playwright_report(root, check) + else: + passed = False + detail = {"error": f"Unknown check type: {check_type}"} + except Exception as e: + passed = False + detail = {"error": str(e)} + + if not passed: + gate = "FAIL" + + check_results.append({ + "index": check_idx, + "type": check_type, + "gate": "PASS" if passed else "FAIL", + "detail": detail + }) + + # Run commands if requested + if args.run_commands: + run_verification_commands(root, task_id, verification_commands) + + # Prepare verdict + verdict = { + "task_id": task_id, + "formula_id": "QUANT_ENGINE_WBS_TASK_V1", + "gate": gate, + "generated_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + "checks": check_results, + "input_hashes": {} + } + + # Save verdict + verdict_path = evidence_dir / "verdict.json" + verdict_path.write_text(json.dumps(verdict, ensure_ascii=False, indent=2), encoding="utf-8") + + # Append lineage event + lineage_log = root / "runtime" / "lineage_events.jsonl" + lineage_log.parent.mkdir(parents=True, exist_ok=True) + event = { + "node_id": f"wbs_{task_id}", + "command": f"python tools/verify_wbs_task_v1.py --task {task_id}", + "returncode": 0 if gate == "PASS" else 1, + "elapsed_sec": 0, + "gate": gate, + "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + } + lineage_log.write_text(json.dumps(event, ensure_ascii=False) + "\n", encoding="utf-8", errors="append") + + # Print summary (ASCII only) + print(f"Task: {task_id}") + for check_result in check_results: + check_gate = check_result["gate"] + check_type = check_result["type"] + status = "PASS" if check_gate == "PASS" else "FAIL" + print(f" [{status}] {check_type}") + print(f"Gate: {gate}") + + return 0 if gate == "PASS" else 1 + + +if __name__ == "__main__": + raise SystemExit(main())