feat: add quant engine WBS verification harness

This commit is contained in:
2026-07-12 10:58:22 +09:00
parent a274ef448a
commit e7d1069222
39 changed files with 2888 additions and 287 deletions
+6
View File
@@ -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
+3
View File
@@ -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`
+14
View File
@@ -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 기대값 대조 + 스크린샷).
+392
View File
@@ -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
}
+167
View File
@@ -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
View File
@@ -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",
+8 -1
View File
@@ -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 */
+18
View File
@@ -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
+709
View File
@@ -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);
}
} }
} }
+5
View File
@@ -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)
{ {
View File
@@ -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"])
+18
View File
@@ -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)}")
+197
View File
@@ -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())
+584
View File
@@ -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())