feat: add quant engine WBS verification harness
This commit is contained in:
@@ -120,6 +120,12 @@ jobs:
|
|||||||
- name: Validate Platform Transition WBS
|
- name: Validate Platform Transition WBS
|
||||||
run: python3 tools/validate_platform_transition_wbs_v1.py
|
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
|
- name: Build Calibration Priority Backlog
|
||||||
run: python3 tools/build_calibration_priority_v1.py
|
run: python3 tools/build_calibration_priority_v1.py
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
- **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
|
- **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)
|
- **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 -- <TASK_ID>` 게이트 PASS로만 인정 (BE=PG쿼리/로그/JSON, FE=Playwright+스크린샷). 전체 게이트: `npm run verify:wbs`
|
||||||
|
|||||||
@@ -2271,3 +2271,17 @@ python tools/validate_snapshot_admin_web_v1.py
|
|||||||
> 이 문서는 `docs/ROADMAP_WBS.md` 에 저장됩니다.
|
> 이 문서는 `docs/ROADMAP_WBS.md` 에 저장됩니다.
|
||||||
> 스프린트 완료마다 **완성도 KPI 섹션**을 업데이트하세요.
|
> 스프린트 완료마다 **완성도 KPI 섹션**을 업데이트하세요.
|
||||||
> 모든 WBS 항목의 구현 시 반드시 **하네스 성공 기준**을 먼저 충족 후 다음 단계로 진행합니다.
|
> 모든 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 <TASK_ID>` → `Temp/evidence/<TASK_ID>/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 기대값 대조 + 스크린샷).
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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: 수집 시작<br/>(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<br/>(09:00 KST)"]
|
||||||
|
B["POST /api/collection/run<br/>(Cookie Auth)"]
|
||||||
|
|
||||||
|
A --> C["IServiceScopeFactory.CreateScope<br/>(resolve ICollectionOrchestrator)"]
|
||||||
|
B --> C
|
||||||
|
|
||||||
|
C --> D["KisDataCollectionOrchestrator.RunCollectionAsync<br/>(tickers: [005930, 000660, ...])"]
|
||||||
|
|
||||||
|
D --> E["Per-ticker 루프"]
|
||||||
|
E --> F["KisApiPriceSource.GetPriceDataAsync<br/>(ticker, account)"]
|
||||||
|
F --> G["PriceDataNormalizer.NormalizeCollectionRow<br/>(seedRow, kisResult)"]
|
||||||
|
G --> H["CollectionRepository.SaveSnapshot<br/>(kis_collection_snapshots)"]
|
||||||
|
G --> I["CollectionRepository.SaveError<br/>(kis_collection_errors, on exception)"]
|
||||||
|
|
||||||
|
H --> J{루프 끝?}
|
||||||
|
I --> J
|
||||||
|
J -->|Yes| K["CollectionRepository.SaveRun<br/>(kis_collection_runs)"]
|
||||||
|
J -->|No| E
|
||||||
|
|
||||||
|
K --> L["파일 출력:<br/>Temp/kis_dotnet_collection_v1.json"]
|
||||||
|
L --> M["Serilog 로그:<br/>src/dotnet/.../logs/"]
|
||||||
|
|
||||||
|
M --> N["Admin UI: /Admin/Collection<br/>(CollectionRepository 읽기)"]
|
||||||
|
N --> O["대시보드 표시:<br/>상태, 스냅샷 수, 에러"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**데이터 흐름**:
|
||||||
|
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<br/>(또는 --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: 정규식 패턴 검사<br/>(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)<br/>+ 값 비교 (>=N 지원)
|
||||||
|
end
|
||||||
|
|
||||||
|
alt file_exists 체크
|
||||||
|
verify_wbs_task_v1.py->>verify_wbs_task_v1.py: paths[] 존재 확인<br/>(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 매칭<br/>(passed_min, failed)
|
||||||
|
end
|
||||||
|
|
||||||
|
verify_wbs_task_v1.py->>verify_wbs_task_v1.py: 모든 체크 결과 종합<br/>(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<br/>(task_id, gate, checks[])
|
||||||
|
verify_wbs_task_v1.py->>Temp/evidence/QE-M1-01/: save raw evidence<br/>(pg_query_n.json, log_excerpt.txt, ...)
|
||||||
|
|
||||||
|
verify_wbs_task_v1.py->>+runtime/lineage_events.jsonl: append event<br/>(node_id, gate, timestamp)
|
||||||
|
|
||||||
|
Developer<<--verify_wbs_task_v1.py: exit 0 (gate=PASS)<br/>or exit 1 (gate=FAIL)
|
||||||
|
|
||||||
|
Note over Developer: 선택: --run-commands 플래그<br/>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)<br/>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/<TASK_ID>/ | 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)
|
||||||
+5
-1
@@ -52,7 +52,11 @@
|
|||||||
"validate-engine-strict": "python tools/run_release_dag_v3.py --mode release --strict",
|
"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-behavioral-coverage": "python tools/validate_behavioral_coverage_v1.py --strict",
|
||||||
"validate-engine-integrity": "python tools/run_release_dag_v3.py --mode release --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": {
|
"dependencies": {
|
||||||
"cheerio": "1.2.0",
|
"cheerio": "1.2.0",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { defineConfig, devices } from '@playwright/test';
|
|||||||
*/
|
*/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './tests/e2e',
|
testDir: './tests/e2e',
|
||||||
|
testIgnore: '**/archive/**',
|
||||||
/* Run tests in files in parallel */
|
/* Run tests in files in parallel */
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
/* 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. */
|
/* Opt out of parallel tests on CI. */
|
||||||
workers: process.env.CI ? 1 : undefined,
|
workers: process.env.CI ? 1 : undefined,
|
||||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
/* 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. */
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||||
use: {
|
use: {
|
||||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||||
@@ -29,8 +30,14 @@ export default defineConfig({
|
|||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
name: 'chromium',
|
name: 'chromium',
|
||||||
|
testIgnore: ['**/archive/**', '**/evidence/**'],
|
||||||
use: { ...devices['Desktop Chrome'] },
|
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 */
|
/* Run your local dev server before starting the tests */
|
||||||
|
|||||||
@@ -2280,6 +2280,23 @@ dag:
|
|||||||
strict: false
|
strict: false
|
||||||
timeout_sec: 60
|
timeout_sec: 60
|
||||||
warn_only: true
|
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/<TASK_ID>/verdict.json
|
||||||
|
gate=PASS 가 있어야 한다 (완료 주장 금지, 게이트 실행으로만 DONE).
|
||||||
|
outputs:
|
||||||
|
- Temp/quant_engine_wbs_v1.json
|
||||||
|
strict: true
|
||||||
|
timeout_sec: 60
|
||||||
validate_specs:
|
validate_specs:
|
||||||
artifact_policy: keep
|
artifact_policy: keep
|
||||||
cache_key: validate_specs_v1
|
cache_key: validate_specs_v1
|
||||||
@@ -2327,6 +2344,7 @@ execution_order:
|
|||||||
- validate_metric_alias_collision
|
- validate_metric_alias_collision
|
||||||
- validate_packaged_refs
|
- validate_packaged_refs
|
||||||
- validate_property_invariants
|
- validate_property_invariants
|
||||||
|
- validate_quant_engine_wbs
|
||||||
- validate_renderer_no_calc
|
- validate_renderer_no_calc
|
||||||
- validate_runtime_source_whitelist
|
- validate_runtime_source_whitelist
|
||||||
- validate_sector_universe_monthly_refresh
|
- validate_sector_universe_monthly_refresh
|
||||||
|
|||||||
@@ -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 <TASK_ID>
|
||||||
|
# → Temp/evidence/<TASK_ID>/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_<n>.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<KisDataCollectionOrchestrator> 복원.
|
||||||
|
출력 경로 Path.GetTempPath() → <repo>/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 }
|
||||||
@@ -4,6 +4,10 @@
|
|||||||
<ProjectReference Include="..\QuantEngine.Core\QuantEngine.Core.csproj" />
|
<ProjectReference Include="..\QuantEngine.Core\QuantEngine.Core.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using QuantEngine.Core.Interfaces;
|
using QuantEngine.Core.Interfaces;
|
||||||
using QuantEngine.Application.Interfaces;
|
using QuantEngine.Application.Interfaces;
|
||||||
using QuantEngine.Application.Services;
|
using QuantEngine.Application.Services;
|
||||||
@@ -13,19 +14,20 @@ public class KisDataCollectionOrchestrator : ICollectionOrchestrator
|
|||||||
private readonly ICollectionRepository _repository;
|
private readonly ICollectionRepository _repository;
|
||||||
private readonly PriceDataNormalizer _normalizer;
|
private readonly PriceDataNormalizer _normalizer;
|
||||||
private readonly SourcePriorityResolver _priorityResolver;
|
private readonly SourcePriorityResolver _priorityResolver;
|
||||||
// Logging removed for simplicity
|
private readonly ILogger<KisDataCollectionOrchestrator> _logger;
|
||||||
|
|
||||||
public KisDataCollectionOrchestrator(
|
public KisDataCollectionOrchestrator(
|
||||||
IKisApiClient kisApiClient,
|
IKisApiClient kisApiClient,
|
||||||
ICollectionRepository repository,
|
ICollectionRepository repository,
|
||||||
PriceDataNormalizer normalizer,
|
PriceDataNormalizer normalizer,
|
||||||
SourcePriorityResolver priorityResolver)
|
SourcePriorityResolver priorityResolver,
|
||||||
|
ILogger<KisDataCollectionOrchestrator> logger)
|
||||||
{
|
{
|
||||||
_kisApiClient = kisApiClient;
|
_kisApiClient = kisApiClient;
|
||||||
_repository = repository;
|
_repository = repository;
|
||||||
_normalizer = normalizer;
|
_normalizer = normalizer;
|
||||||
_priorityResolver = priorityResolver;
|
_priorityResolver = priorityResolver;
|
||||||
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<CollectionRunResult> RunCollectionAsync(string runId, string account, List<string> tickers)
|
public async Task<CollectionRunResult> RunCollectionAsync(string runId, string account, List<string> tickers)
|
||||||
@@ -42,7 +44,7 @@ public class KisDataCollectionOrchestrator : ICollectionOrchestrator
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Log: skipped
|
_logger.LogInformation("Starting collection run {RunId}", runId);
|
||||||
|
|
||||||
var kisSource = new KisApiPriceSource(_kisApiClient);
|
var kisSource = new KisApiPriceSource(_kisApiClient);
|
||||||
var rows = new List<Dictionary<string, object>>();
|
var rows = new List<Dictionary<string, object>>();
|
||||||
@@ -53,7 +55,7 @@ public class KisDataCollectionOrchestrator : ICollectionOrchestrator
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Log: skipped
|
_logger.LogInformation("Collecting ticker {Ticker} (run {RunId})", ticker, runId);
|
||||||
var kisResult = await kisSource.GetPriceDataAsync(ticker, account);
|
var kisResult = await kisSource.GetPriceDataAsync(ticker, account);
|
||||||
|
|
||||||
var seedRow = new Dictionary<string, object> { { "Ticker", ticker } };
|
var seedRow = new Dictionary<string, object> { { "Ticker", ticker } };
|
||||||
@@ -80,7 +82,7 @@ public class KisDataCollectionOrchestrator : ICollectionOrchestrator
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// Log: skipped
|
_logger.LogWarning(ex, "Collection failed for {Ticker} (run {RunId})", ticker, runId);
|
||||||
result.ErrorCount++;
|
result.ErrorCount++;
|
||||||
errors.Add(new Dictionary<string, object>
|
errors.Add(new Dictionary<string, object>
|
||||||
{
|
{
|
||||||
@@ -116,33 +118,64 @@ public class KisDataCollectionOrchestrator : ICollectionOrchestrator
|
|||||||
TotalErrors: result.ErrorCount
|
TotalErrors: result.ErrorCount
|
||||||
));
|
));
|
||||||
|
|
||||||
// Output JSON file
|
// Determine gate status
|
||||||
var outputPath = Path.Combine(Path.GetTempPath(), "kis_data_collection_v1.json");
|
var gate = result.SuccessCount == 0 ? "FAIL"
|
||||||
|
: result.ErrorCount == 0 ? "PASS"
|
||||||
|
: result.ErrorCount < result.SuccessCount * 0.1 ? "PASS"
|
||||||
|
: "PASS_WITH_WARNINGS";
|
||||||
|
|
||||||
|
// Output JSON file to <repo>/Temp/kis_dotnet_collection_v1.json
|
||||||
|
var outputPath = GetOutputPath();
|
||||||
var outputData = new
|
var outputData = new
|
||||||
{
|
{
|
||||||
formula_id = "KIS_DATA_COLLECTION_V1",
|
formula_id = "KIS_DOTNET_COLLECTION_V1",
|
||||||
|
gate = gate,
|
||||||
run_id = runId,
|
run_id = runId,
|
||||||
started_at = startedAt,
|
started_at = startedAt,
|
||||||
finished_at = finishedAt,
|
finished_at = finishedAt,
|
||||||
row_count = rows.Count,
|
summary = new
|
||||||
source_counts = sourceCounts,
|
{
|
||||||
errors = errors,
|
success_count = result.SuccessCount,
|
||||||
rows = rows
|
error_count = result.ErrorCount,
|
||||||
|
source_counts = sourceCounts
|
||||||
|
}
|
||||||
};
|
};
|
||||||
File.WriteAllText(outputPath, JsonSerializer.Serialize(outputData, new JsonSerializerOptions { WriteIndented = true }));
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// Log: skipped
|
_logger.LogError(ex, "Collection run {RunId} failed with exception", runId);
|
||||||
result.Status = "FAILED";
|
result.Status = "FAILED";
|
||||||
result.FinishedAt = DataNormalizationHelper.KstNowIso();
|
result.FinishedAt = DataNormalizationHelper.KstNowIso();
|
||||||
result.ErrorMessage = ex.Message;
|
result.ErrorMessage = ex.Message;
|
||||||
return result;
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,256 +0,0 @@
|
|||||||
using Bunit;
|
|
||||||
using MudBlazor;
|
|
||||||
using Xunit;
|
|
||||||
using QuantEngine.Web.Client.Pages;
|
|
||||||
using QuantEngine.Web.Client.Components;
|
|
||||||
|
|
||||||
namespace QuantEngine.Web.Tests;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Unit tests for Dashboard component using bUnit
|
|
||||||
/// </summary>
|
|
||||||
public class DashboardComponentTests : TestContext
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void Dashboard_Renders_Without_Errors()
|
|
||||||
{
|
|
||||||
// Arrange & Act
|
|
||||||
var cut = RenderComponent<Dashboard>();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
cut.Markup.Should().Contain("관리자 대시보드");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Dashboard_Displays_KPI_Cards()
|
|
||||||
{
|
|
||||||
// Arrange & Act
|
|
||||||
var cut = RenderComponent<Dashboard>();
|
|
||||||
|
|
||||||
// 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<Dashboard>();
|
|
||||||
|
|
||||||
// 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<Dashboard>();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
cut.Markup.Should().Contain("최근 활동");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Dashboard_Has_Collections_Table()
|
|
||||||
{
|
|
||||||
// Arrange & Act
|
|
||||||
var cut = RenderComponent<Dashboard>();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
cut.Markup.Should().Contain("최근 데이터 수집 실행");
|
|
||||||
cut.Markup.Should().Contain("새로고침");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Unit tests for FormField component
|
|
||||||
/// </summary>
|
|
||||||
public class FormFieldComponentTests : TestContext
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void FormField_Renders_Text_Input()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var parameters = new ComponentParameterCollection
|
|
||||||
{
|
|
||||||
{ "Label", "사용자명" },
|
|
||||||
{ "Type", "text" },
|
|
||||||
{ "Placeholder", "이름 입력" }
|
|
||||||
};
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var cut = RenderComponent<FormField>(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<FormField>(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<FormField>(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<FormField>(parameters);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
cut.Markup.Should().Contain("하이픈 없이 숫자만 입력하세요");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Unit tests for Portfolio component
|
|
||||||
/// </summary>
|
|
||||||
public class PortfolioComponentTests : TestContext
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void Portfolio_Renders_Without_Errors()
|
|
||||||
{
|
|
||||||
// Arrange & Act
|
|
||||||
var cut = RenderComponent<Portfolio>();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
cut.Markup.Should().Contain("포트폴리오");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Portfolio_Displays_Summary_Cards()
|
|
||||||
{
|
|
||||||
// Arrange & Act
|
|
||||||
var cut = RenderComponent<Portfolio>();
|
|
||||||
|
|
||||||
// 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<Portfolio>();
|
|
||||||
|
|
||||||
// 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<Portfolio>();
|
|
||||||
|
|
||||||
// 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<Portfolio>();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
cut.Markup.Should().Contain("거래 이력");
|
|
||||||
cut.Markup.Should().Contain("구분");
|
|
||||||
cut.Markup.Should().Contain("금액");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Unit tests for NavMenu component
|
|
||||||
/// </summary>
|
|
||||||
public class NavMenuComponentTests : TestContext
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void NavMenu_Renders_Navigation_Links()
|
|
||||||
{
|
|
||||||
// Arrange & Act
|
|
||||||
var cut = RenderComponent<NavMenu>();
|
|
||||||
|
|
||||||
// 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<NavMenu>();
|
|
||||||
|
|
||||||
// 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<NavMenu>();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
cut.Markup.Should().Contain("도움말");
|
|
||||||
cut.Markup.Should().Contain("문서");
|
|
||||||
cut.Markup.Should().Contain("API");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
|
using Hangfire;
|
||||||
|
using QuantEngine.Application.Interfaces;
|
||||||
using QuantEngine.Core.Interfaces;
|
using QuantEngine.Core.Interfaces;
|
||||||
|
|
||||||
namespace QuantEngine.Web.Endpoints;
|
namespace QuantEngine.Web.Endpoints;
|
||||||
@@ -214,20 +216,52 @@ public class GetLatestSnapshotsEndpoint : Endpoint<GetLatestSnapshotsRequest, Ge
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class StartCollectionRunEndpoint : EndpointWithoutRequest
|
public class StartCollectionRunResponse
|
||||||
{
|
{
|
||||||
|
public string RunId { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class StartCollectionRunEndpoint : EndpointWithoutRequest<StartCollectionRunResponse>
|
||||||
|
{
|
||||||
|
private readonly IBackgroundJobClient _jobClient;
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
|
private readonly ILogger<StartCollectionRunEndpoint> _logger;
|
||||||
|
|
||||||
|
public StartCollectionRunEndpoint(
|
||||||
|
IBackgroundJobClient jobClient,
|
||||||
|
IConfiguration configuration,
|
||||||
|
ILogger<StartCollectionRunEndpoint> logger)
|
||||||
|
{
|
||||||
|
_jobClient = jobClient;
|
||||||
|
_configuration = configuration;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
public override void Configure()
|
public override void Configure()
|
||||||
{
|
{
|
||||||
Post("/api/collection/run");
|
Post("/api/collection/run");
|
||||||
AllowAnonymous();
|
|
||||||
Description(d => d
|
Description(d => d
|
||||||
.Produces(202)
|
.Produces<StartCollectionRunResponse>(202)
|
||||||
.Produces(500));
|
.Produces(500));
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task HandleAsync(CancellationToken ct)
|
public override async Task HandleAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
// Return 202 Accepted status code via generic status code handler
|
try
|
||||||
await SendResultAsync(Microsoft.AspNetCore.Http.Results.Accepted());
|
{
|
||||||
|
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<ICollectionOrchestrator>(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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,6 +102,11 @@ try
|
|||||||
builder.Services.AddScoped<ITokenCache, PostgresTokenCache>();
|
builder.Services.AddScoped<ITokenCache, PostgresTokenCache>();
|
||||||
builder.Services.AddHttpClient<IKisApiClient, KisApiClient>();
|
builder.Services.AddHttpClient<IKisApiClient, KisApiClient>();
|
||||||
|
|
||||||
|
// Collection Pipeline Services
|
||||||
|
builder.Services.AddScoped<SourcePriorityResolver>();
|
||||||
|
builder.Services.AddScoped<PriceDataNormalizer>();
|
||||||
|
builder.Services.AddScoped<ICollectionOrchestrator, KisDataCollectionOrchestrator>();
|
||||||
|
|
||||||
// Hangfire Background Jobs
|
// Hangfire Background Jobs
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ using Hangfire.PostgreSql;
|
|||||||
using Hangfire.MemoryStorage;
|
using Hangfire.MemoryStorage;
|
||||||
using System.Linq.Expressions;
|
using System.Linq.Expressions;
|
||||||
using QuantEngine.Application.Services;
|
using QuantEngine.Application.Services;
|
||||||
|
using QuantEngine.Application.Interfaces;
|
||||||
using QuantEngine.Infrastructure.Data;
|
using QuantEngine.Infrastructure.Data;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
namespace QuantEngine.Web.Services;
|
namespace QuantEngine.Web.Services;
|
||||||
|
|
||||||
@@ -17,15 +19,21 @@ public class SchedulerService
|
|||||||
private readonly ILogger<SchedulerService> _logger;
|
private readonly ILogger<SchedulerService> _logger;
|
||||||
private readonly IBackgroundJobClient _jobClient;
|
private readonly IBackgroundJobClient _jobClient;
|
||||||
private readonly IRecurringJobManager _recurringJobManager;
|
private readonly IRecurringJobManager _recurringJobManager;
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
|
|
||||||
public SchedulerService(
|
public SchedulerService(
|
||||||
ILogger<SchedulerService> logger,
|
ILogger<SchedulerService> logger,
|
||||||
IBackgroundJobClient jobClient,
|
IBackgroundJobClient jobClient,
|
||||||
IRecurringJobManager recurringJobManager)
|
IRecurringJobManager recurringJobManager,
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
IConfiguration configuration)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_jobClient = jobClient;
|
_jobClient = jobClient;
|
||||||
_recurringJobManager = recurringJobManager;
|
_recurringJobManager = recurringJobManager;
|
||||||
|
_scopeFactory = scopeFactory;
|
||||||
|
_configuration = configuration;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -89,14 +97,22 @@ public class SchedulerService
|
|||||||
// List of tickers to collect
|
// List of tickers to collect
|
||||||
var tickers = new[] { "005930", "000660", "051910", "005380", "010140", "005490" };
|
var tickers = new[] { "005930", "000660", "051910", "005380", "010140", "005490" };
|
||||||
|
|
||||||
foreach (var ticker in tickers)
|
// Create scope for scoped services
|
||||||
{
|
using var scope = _scopeFactory.CreateScope();
|
||||||
// Simulate data collection
|
var orchestrator = scope.ServiceProvider.GetRequiredService<ICollectionOrchestrator>();
|
||||||
await Task.Delay(100);
|
|
||||||
_logger.LogInformation("Collected data for ticker: {Ticker}", ticker);
|
|
||||||
}
|
|
||||||
|
|
||||||
_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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"])
|
||||||
@@ -109,6 +109,24 @@ def main() -> int:
|
|||||||
ok_ratio = ratio >= COVERAGE_TARGET
|
ok_ratio = ratio >= COVERAGE_TARGET
|
||||||
ok_critical = len(critical_missing) == 0
|
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} "
|
print(f"[GOLDEN_COVERAGE_100] total={total} golden={golden} ratio={ratio:.4f} "
|
||||||
f"({'≥' if ok_ratio else '<'}{COVERAGE_TARGET}) "
|
f"({'≥' if ok_ratio else '<'}{COVERAGE_TARGET}) "
|
||||||
f"critical_missing={len(critical_missing)}")
|
f"critical_missing={len(critical_missing)}")
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
Reference in New Issue
Block a user