Compare commits

...

7 Commits

Author SHA1 Message Date
kjh2064 0b4caa95f1 document gitea token handling 2026-06-26 18:16:28 +09:00
kjh2064 99c4885692 deploy workflow and docs
WBS-9.3 - NULL Policy CI Gate / NULL Policy Validation (push) Failing after 4s
WBS-9.3 - NULL Policy CI Gate / NULL Policy Validation (pull_request) Failing after 6s
Quant Engine CI/CD Pipeline / validate-core (pull_request) Failing after 2m16s
Quant Engine CI/CD Pipeline / validate-ui-and-storage (pull_request) Has been skipped
2026-06-26 18:07:13 +09:00
kjh2064 74a83f94fb ui dashboard cleanup 2026-06-26 18:07:02 +09:00
kjh2064 1e6bf702bc core services and tests 2026-06-26 18:06:36 +09:00
kjh2064 e0508324e5 docs: .NET 렌더러 운영 상태와 검증 기준 정리
WBS-9.3 - NULL Policy CI Gate / NULL Policy Validation (push) Failing after 3s
WBS-9.3 - NULL Policy CI Gate / NULL Policy Validation (pull_request) Failing after 4s
Quant Engine CI/CD Pipeline / validate-core (pull_request) Failing after 2m17s
Quant Engine CI/CD Pipeline / validate-ui-and-storage (pull_request) Has been skipped
- 운영 상태 문서와 README를 .NET canonical renderer 기준으로 정리했습니다.
- 레거시 렌더러 비운영 선언과 감사/검증기 경로를 통일했습니다.
- 운영 보정 로직의 데이터 소스 반영을 정리했습니다.
2026-06-26 14:18:48 +09:00
kjh2064 9e6e2ded2f feat: .NET 운영 리포트 렌더러와 CI 경로 전환
- operational_report.json/md와 final_decision_packet_v4 생성 경로를 .NET으로 전환했습니다.
- CI, 운영 게이트, 릴리스 DAG, 대시보드의 운영 진입점을 새 경로로 정렬했습니다.
- legacy Python 렌더러는 비운영으로 명시했습니다.
2026-06-26 14:18:03 +09:00
kjh2064 8f13bb4a48 feat: postgres history-first 계약과 적재 경로 추가
- PostgreSQL history contract와 schema/validator를 추가했습니다.
- .NET history store, snapshot reader, repository, migration을 연결했습니다.
- history-first 운영 모델 문서와 daily signal tracking 문구를 정리했습니다.
2026-06-26 14:17:04 +09:00
60 changed files with 2337 additions and 427 deletions
+63
View File
@@ -151,6 +151,69 @@ jobs:
- name: Validate DB First Pipeline
run: python3 tools/validate_db_first_pipeline_v1.py
- name: Update Proposal Evaluation History
run: python3 tools/update_proposal_evaluation_history.py --json GatherTradingData.json --history Temp/proposal_evaluation_history.json
- name: Build Performance Readiness Replay Bridge
run: python3 tools/build_performance_readiness_replay_bridge_v1.py --hist Temp/proposal_evaluation_history.json --out Temp/performance_readiness_replay_bridge_v1.json
- name: Build Outcome Quality Score
run: python3 tools/build_outcome_quality_score_v1.py --json GatherTradingData.json --out Temp/outcome_quality_score_v1.json --policy spec/strategy_execution_lock_policy.yaml
- name: Build Trade Quality From T5
run: python3 tools/build_trade_quality_from_t5_v1.py --hist Temp/proposal_evaluation_history.json --out Temp/trade_quality_from_t5_v1.json
- name: Build Operational Alpha Calibration
run: python3 tools/build_operational_alpha_calibration_v2.py --out Temp/operational_alpha_calibration_v2.json
- name: Validate Operational Alpha Calibration
run: python3 tools/validate_operational_alpha_calibration_v2.py --input Temp/operational_alpha_calibration_v2.json --out Temp/validate_operational_alpha_calibration_v2.json
- name: Build Operational T20 Outcome Ledger
run: python3 tools/build_operational_t20_outcome_ledger_v1.py --json GatherTradingData.json --out Temp/operational_t20_outcome_ledger_v1.json
- name: Validate Live Data Activation Gate
run: python3 tools/validate_live_data_activation_gate_v1.py
- name: Validate Replay Live Separation
run: python3 tools/validate_replay_live_separation_v1.py
- name: Render Final Decision Packet V4
run: dotnet run --project src/dotnet/QuantEngine.Tools/QuantEngine.Tools.csproj -- packet-v4 --packet=Temp/final_decision_packet_active.json --out=Temp/final_decision_packet_v4.json
- name: Render Operational Report
run: dotnet run --project src/dotnet/QuantEngine.Tools/QuantEngine.Tools.csproj -- report --packet=Temp/final_decision_packet_active.json --out=Temp/operational_report.json
- name: Validate Report Packet Sync
run: python3 tools/validate_report_packet_sync_v1.py --packet Temp/final_decision_packet_active.json --report Temp/operational_report.json | tee Temp/validate_report_packet_sync_v1.json
- name: Validate Report Section Completeness
run: python3 tools/validate_report_section_completeness_v1.py
- name: Validate JSON Generator Outputs
run: python3 tools/validate_json_generator_outputs_v1.py
- name: Generate PostgreSQL History Schema
run: python3 tools/generate_postgresql_history_schema_v1.py
- name: Validate PostgreSQL History Contract
run: python3 tools/validate_postgresql_history_contract_v1.py
- name: Package Operational Report Artifacts
run: tar -czf Temp/operational-report-artifacts.tar.gz Temp/operational_report.json Temp/operational_report.md Temp/missing_data_inventory_v1.json Temp/report_section_completeness.json Temp/operational_alpha_calibration_v2.json Temp/validate_operational_alpha_calibration_v2.json Temp/operational_t20_outcome_ledger_v1.json Temp/live_data_activation_gate_v1.json Temp/replay_live_separation_v1.json Temp/validate_report_packet_sync_v1.json Temp/json_generator_outputs_v1.json Temp/proposal_evaluation_history.json Temp/performance_readiness_replay_bridge_v1.json Temp/postgresql_history_schema_v1.sql Temp/postgresql_history_schema_v1.json Temp/postgresql_history_contract_v1.json
- name: Upload Operational Report Artifacts
uses: actions/upload-artifact@v3
with:
name: operational-report-artifacts
path: Temp/operational-report-artifacts.tar.gz
- name: Upload Operational Report JSON
uses: actions/upload-artifact@v3
with:
name: operational-report-json
path: Temp/operational_report.json
validate-ui-and-storage:
runs-on: ubuntu-latest
needs: validate-core
@@ -53,3 +53,21 @@ jobs:
ssh -i ~/.ssh/id_ed25519 kjh2064@178.104.200.7 "chmod +x /home/kjh2064/tmp/deploy.sh 2>/dev/null || true"
scp -i ~/.ssh/id_ed25519 tools/deploy_quantengine.sh kjh2064@178.104.200.7:/home/kjh2064/tmp/deploy.sh
ssh -i ~/.ssh/id_ed25519 kjh2064@178.104.200.7 "chmod +x /home/kjh2064/tmp/deploy.sh && /home/kjh2064/tmp/deploy.sh"
- name: Verify Public Routes
run: |
set -e
root_html=$(curl -s "http://178.104.200.7/quant/")
ops_html=$(curl -s "http://178.104.200.7/quant/operations")
root_code=$(printf '%s' "$root_html" | grep -q "Quant Engine" && echo 200 || echo 500)
ops_code=$(printf '%s' "$ops_html" | grep -q "Operational Report" && echo 200 || echo 500)
echo "/quant/ -> ${root_code}"
echo "/quant/operations -> ${ops_code}"
if [ "$root_code" != "200" ]; then
echo "Deployment content check failed for /quant/"
exit 1
fi
if [ "$ops_code" != "200" ]; then
echo "Deployment content check failed for /quant/operations"
exit 1
fi
+2
View File
@@ -61,6 +61,7 @@
- `spec/`: source of truth. 공식, 계약, 게이트, 출력 스키마의 최우선 읽기 경로.
- `governance/`: 운영 규칙, 인덱스, 해시 마이그레이션, ADR, 템플릿.
- `src/`: Python canonical implementation. 새 로직은 여기부터 반영한다.
- `src/dotnet/QuantEngine.Tools`: canonical .NET operational report and packet renderer.
- `src/quant_engine/data_collection_backend_v1.py`: collection backend selector.
- `src/quant_engine/data_collection_store_v1.py`: SQLite collection store.
- `src/quant_engine/kis_data_collection_v1.py`: KIS 우선 수집기.
@@ -70,6 +71,7 @@
- `KIS-first`: KIS 우선.
- `SQLite-first`: SQLite/JSON 우선.
- `tools/`: build/validate/convert/audit CLI.
- `tools/render_operational_report.py`: legacy renderer, 운영/CI 경로에서 사용 금지.
- `tools/run_kis_data_collection_v1.py`: KIS collection thin CLI.
- `tools/generate_postgresql_upgrade_stub_v1.py`: PostgreSQL stub generator.
- `tools/validate_platform_transition_wbs_v1.py`: `.gs → Python` and `xlsx → sqlite` WBS validator.
+12 -1
View File
@@ -141,12 +141,23 @@ npm run prepare-upload-zip
4. `GatherTradingData.xlsx` 의존성을 제거한 후에도 수집이 유지되는지 확인
5. 이후 PostgreSQL 업그레이드 시 동일 row contract를 유지
## CI / 배포 분리
- `.gitea/workflows/ci.yml`은 검증 전용이다.
- `.gitea/workflows/snapshot_admin_deploy.yml`은 실배포 전용이다.
- 공개 URL `http://178.104.200.7/quant/` 갱신은 deploy workflow 성공 여부로 판단한다.
- Gitea 토큰은 문서에 값으로 적지 않고 `GITEA_TOKEN_TAXBAIK` 같은 환경변수/secret 이름으로만 관리한다.
## 운영 리포트 계약
운영 리포트는 사람이 읽는 `Temp/operational_report.md`와 기계 검증용 `Temp/operational_report.json`을 함께 생성합니다.
운영 리포트는 .NET canonical renderer가 사람이 읽는 `Temp/operational_report.md`와 기계 검증용 `Temp/operational_report.json`을 함께 생성합니다.
운영 상태와 legacy 분리는 [DOTNET_RENDERER_OPERATING_STATUS.md](/C:/Temp/data_feed/docs/DOTNET_RENDERER_OPERATING_STATUS.md)에서 확인합니다.
- `src/dotnet/QuantEngine.Tools/Program.cs`가 canonical 생성 경로입니다.
- `npm run render-report-json`도 같은 .NET 경로를 호출합니다.
- `operational_report.json`이 canonical 계약입니다.
- `operational_report.md`는 표시용 렌더입니다.
- `Temp/missing_data_inventory_v1.json``DATA_MISSING` 섹션 분리 인벤토리입니다.
- JSON 스키마는 `schemas/operational_report.schema.json`을 사용합니다.
- 계약 드리프트 검사는 `npm run validate-operational-report-contract`로 수행합니다.
- 전체 게이트에는 `render-report-json -> validate-report-json -> validate-report-quality -> validate-report-sync` 순서가 포함됩니다.
+6
View File
@@ -228,6 +228,12 @@ services:
> 총 6개 러너가 활성 상태. 네트워크는 `gitea_default` Docker 네트워크 사용.
### 6.4. CI / 배포 분리
- `.gitea/workflows/ci.yml`: 검증 전용. 스펙/공식/리포트/아티팩트 생성까지만 수행한다.
- `.gitea/workflows/snapshot_admin_deploy.yml`: 실배포 전용. `dotnet publish``tools/deploy_quantengine.sh`를 이용해 `/home/kjh2064/quantengine_active`로 반영한다.
- 공개 URL `/quant/` 갱신은 `snapshot_admin_deploy.yml`의 성공 여부를 기준으로 판단한다.
### 6.2. 러너 설정
```yaml
+7 -7
View File
@@ -11,7 +11,7 @@
### 1️⃣ 신호 발생 시 (거래 진입 시점)
```python
# Python 또는 GAS 콘솔에서 실행
# Python 또는 DB 마이그레이션 도구에서 실행
signal = {
"date": "2026-06-25",
"ticker": "000660", # SK하이닉스 등
@@ -25,14 +25,13 @@ signal = {
"notes": "MA20 돌파 + 스마트머니 매수"
}
# GAS: addSignal_(signal)
# 또는 스프레드시트에 직접 입력
# 운영 표준: PostgreSQL의 signal/factor history 테이블에 적재
```
**✅ 체크리스트:**
- [ ] signal_id 자동 생성됨 (YYYYMMDD_HHMM 형식)
- [ ] validation_status = "UNVALIDATED"
- [ ] 스프레드시트 행 추가됨
- [ ] PostgreSQL 이력 행 추가됨
---
@@ -47,7 +46,7 @@ signal = {
**해야 할 일:**
1. T+5일의 종가 조회
2. `updatePriceT5_(signalId, priceT5)` 실행
3. 또는 스프레드시트 "price_t5" 열에 직접 입력
3. 또는 PostgreSQL `price_t5` 이력 열에 직접 입력
**예시:**
```
@@ -264,8 +263,9 @@ T+20 종가: 51,050원
## 🔗 관련 문서
- `spec/realtime/live_outcome_ledger_plan.yaml` — 마스터 계획
- `src/google_apps_script/live_outcome_ledger.gs`GAS 코드
- `spec/realtime/live_outcome_ledger_plan.yaml` — 마스터 계획(역사적)
- `src/google_apps_script/live_outcome_ledger.gs`역사적 GAS 원장 어댑터
- `spec/02_data_contract.yaml` — PostgreSQL history-first 운영 계약
- `V9_HARDENING_IMPLEMENTATION_ROADMAP.md` — 전체 로드맵
---
+31
View File
@@ -0,0 +1,31 @@
# .NET Renderer Operating Status
## Current Canonical Path
- `src/dotnet/QuantEngine.Tools/Program.cs`
- `src/dotnet/QuantEngine.Tools/QuantEngine.Tools.csproj`
## Current Outputs
- `Temp/operational_report.json`
- `Temp/operational_report.md`
- `Temp/final_decision_packet_v4.json`
## Legacy Path
- `tools/render_operational_report.py`
This file is retained only for historical compatibility and maintenance reference.
It is not used in the operating or CI path.
## Operational Rules
- CI and release flows must use the .NET renderer path.
- Report consumers may continue to read `Temp/operational_report.md` and `Temp/operational_report.json`.
- The Python renderer should not be reintroduced into the operating path.
## Verification
- `dotnet build src/dotnet/QuantEngine.sln -c Debug`
- `python tools/validate_json_generator_outputs_v1.py`
- `python tools/validate_report_packet_sync_v1.py --packet Temp/final_decision_packet_active.json --report Temp/operational_report.json`
@@ -0,0 +1,32 @@
# PostgreSQL History-First Operating Model
## 목적
운영 이력, 원천 팩터, 파생 팩터, 최종 판단, 시장-엔진 괴리를 PostgreSQL에 영구 이력으로 적재한다.
## 원칙
- PostgreSQL이 canonical operating history store다.
- Excel workbook과 Google Apps Script는 운영 소스가 아니다.
- 모든 파생 결과는 versioned snapshot과 provenance를 가져야 한다.
- 시장 raw와 엔진 결과의 괴리는 별도 gap history로 남긴다.
## 이력 도메인
- `market_raw_history`
- `factor_version_history`
- `factor_output_history`
- `decision_result_history`
- `market_vs_engine_gap_history`
## 운영 규칙
- Append-only를 기본으로 하고, 정정은 correction row로만 남긴다.
- 최종 팩터와 최종 판단은 항상 `source_version`을 포함한다.
- DB snapshot이 존재하면 리포트와 생성기는 이를 1차 진실원천으로 사용한다.
## 폐기 대상
- 운영 경로의 Excel 시트 의존
- 운영 경로의 GAS 의사결정/원장 갱신
+27 -23
View File
@@ -14,6 +14,7 @@
3. `WBS-7.8` ETF NAV/괴리율/추적오차/AUM 수집 경로 확정
4. `WBS-7.5` 임시 하드코딩 폴백 비례화의 실증 보정
5. `WBS-7.6` 슬리피지 실측 보정
6. `WBS-7.9` PostgreSQL history-first operating model 전환
`WBS-7.2`, `WBS-7.3`, `WBS-7.4`, `WBS-7.10`~`WBS-7.14`는 현재 문서상 완료 또는 정리 완료로 유지한다.
@@ -745,7 +746,7 @@ python tools/build_qualitative_sell_inputs_v1.py --batch --workbook GatherTradin
runtime 파생 뷰임을 gas_lib.gs:2010-2081(runEventRisk)·spec/14_raw_workbook_mapping.yaml:415에서
확인. data_feed 원자료/결정컬럼과 동일한 "원본 vs 파생" 패턴 — 둘 다 유지.
⚠️ stale 발견(깨진 게 아님): sector_universe_refresh_audit(16행, 1열 깨진 한글)는 죽은 시트가
아니라 gas_lib.gs:writeSectorUniverseRefreshAuditSheet_()·tools/render_operational_report.py
아니라 gas_lib.gs:writeSectorUniverseRefreshAuditSheet_()·src/dotnet/QuantEngine.Tools
실제로 쓰는 활성 시트다 — xlsx가 최신 15컬럼 영문 스키마로 갱신되지 않은 채 방치된 것뿐.
`python tools/update_sector_universe_from_naver.py --limit 3`(dry-run)으로 정상 스키마(13섹터,
39행) 생성 가능함을 확인 — `--apply`는 운영 워크북을 덮어쓰는 작업이라 사용자 승인 필요(미실행).
@@ -1377,9 +1378,9 @@ WBS-8.8 (KIS 리팩터) — 독립적 (원격 병행)
### WBS-10: C#/.NET 엔진 고도화 (Phase 10, 2026-06~12)
> 현황 진단(2026-06-25): .NET 프로젝트는 Python 엔진(41 모듈, 14,500 LOC) 대비 5~10%(~1,400 LOC) 수준.
> 현황 진단(2026-06-26): .NET 프로젝트는 Python 엔진(41 모듈, 14,500 LOC) 대비 5~10%(~1,400 LOC) 수준.
> Domain 계산기 6개·데이터 모델 8개·KIS/Naver/Yahoo 클라이언트·PostgreSQL 마이그레이션·Blazor 대시보드 기본 구현 완료.
> **미구현**: Application 레이어(빈 Class1.cs), 테스트(빈 UnitTest1.cs + Core 참조 누락), 공식 엔진, 하네스 주입, 파이프라인 오케스트레이터.
> **미구현**: Application 서비스 일부, 공식 엔진, 하네스 주입, 파이프라인 오케스트레이터.
> **발견된 결함 5건**: D1) Tests.csproj Core ProjectReference 누락, D2) Tests sln 미등록, D3) appsettings.json 비밀번호 하드코딩, D4) NU1510 불필요 패키지, D5) Class1.cs placeholder 2개.
#### WBS-10 의존성 차트
@@ -1404,16 +1405,16 @@ WBS-10.1 (기반 결함 수정)
| 항목 | 내용 |
|------|------|
| **작업** | 테스트 프로젝트 참조 복원, sln 등록, 불필요 패키지 제거, placeholder 삭제, 비밀번호 환경변수화 |
| **현재 상태** | Core.Tests에 ProjectReference 없음, sln 미등록, appsettings.json 비밀번호 하드코딩, NU1510 경고 2건, Class1.cs 2개 잔존 |
| **담당 파일** | `src/dotnet/QuantEngine.Core.Tests/QuantEngine.Core.Tests.csproj`, `src/dotnet/QuantEngine.sln`, `src/dotnet/QuantEngine.Infrastructure/QuantEngine.Infrastructure.csproj`, `src/dotnet/QuantEngine.Web/appsettings.json`, `src/dotnet/QuantEngine.Core/Class1.cs`, `src/dotnet/QuantEngine.Infrastructure/Class1.cs` |
| **상태** | TODO |
| **현재 상태** | Core.Tests에 Core/Infrastructure ProjectReference 추가 완료, sln에 Tests 등록 완료, appsettings.json 비밀번호는 유지(운영 후속 조치), Class1.cs placeholder 0개, build 경고 0 |
| **담당 파일** | `src/dotnet/QuantEngine.Core.Tests/QuantEngine.Core.Tests.csproj`, `src/dotnet/QuantEngine.sln`, `src/dotnet/QuantEngine.Infrastructure/QuantEngine.Infrastructure.csproj`, `src/dotnet/QuantEngine.Web/appsettings.json` |
| **상태** | 부분 완료 |
| 세부 WBS | 작업 | 성공 판단 데이터 | 검증 명령 |
|----------|------|------------------|----------|
| 10.1.1 | Core.Tests.csproj에 `<ProjectReference Include="../QuantEngine.Core/QuantEngine.Core.csproj" />` 추가 | csproj 내 ProjectReference 존재 | `dotnet build src/dotnet/QuantEngine.Core.Tests/` → 오류 0 |
| 10.1.2 | QuantEngine.sln에 Core.Tests 프로젝트 등록 | sln 내 Tests 프로젝트 GUID 존재 | `dotnet sln src/dotnet/QuantEngine.sln list` → 5개 프로젝트 출력 |
| 10.1.3 | Infrastructure.csproj에서 `System.Text.Encoding.CodePages` PackageReference 제거 | NU1510 경고 소멸 | `dotnet build src/dotnet/QuantEngine.sln --verbosity quiet` → 경고 0 |
| 10.1.4 | Class1.cs placeholder 파일 2개 삭제 (Core/, Infrastructure/) | 파일 미존재 | `Test-Path src/dotnet/QuantEngine.Core/Class1.cs` → False |
| 10.1.4 | Class1.cs placeholder 파일 2개 삭제 (Core/, Infrastructure/) | 파일 미존재 | `Test-Path src/dotnet/QuantEngine.Core/Class1.cs``Test-Path src/dotnet/QuantEngine.Infrastructure/Class1.cs` → False |
| 10.1.5 | appsettings.json 비밀번호 → 환경변수 `ConnectionStrings__DefaultConnection` 또는 `dotnet user-secrets` 전환 | appsettings.json 내 실제 비밀번호 문자열 0건 | `Select-String -Pattern 'C8RFlZ9f' src/dotnet/QuantEngine.Web/appsettings.json` → 결과 0건 |
**성공 하네스 (데이터 기준)**:
@@ -1431,9 +1432,9 @@ WBS-10.1 (기반 결함 수정)
| 항목 | 내용 |
|------|------|
| **작업** | 기존 Domain 계산기 6개에 대한 xUnit 단위 테스트 35건+ 작성. Python golden case JSON을 xUnit `[Theory]` 데이터소스로 활용하는 인프라 구축 |
| **현재 상태** | UnitTest1.cs 빈 파일 1개, 실제 테스트 0건 |
| **현재 상태** | FormulaEngine/HistoryIngestion/Kis security 테스트가 존재, 10.2 세부 테스트 확장 중 |
| **담당 파일** | `src/dotnet/QuantEngine.Core.Tests/ExitDecisionsTests.cs`(신규), `KrxTickNormalizerTests.cs`(신규), `ProfitLockCalculatorTests.cs`(신규), `AntiChasingCalculatorTests.cs`(신규), `PullbackTriggerCalculatorTests.cs`(신규), `SellPriceSanityCheckerTests.cs`(신규) |
| **상태** | TODO |
| **상태** | 부분 완료 |
| 세부 WBS | 작업 | 성공 판단 데이터 | 검증 명령 |
|----------|------|------------------|----------|
@@ -1566,9 +1567,9 @@ WBS-10.1 (기반 결함 수정)
| 항목 | 내용 |
|------|------|
| **작업** | 빈 Application 프로젝트(Class1.cs)를 실제 서비스 레이어로 전환. Workspace/Approval/Collection/Formula 4개 서비스 구현 |
| **현재 상태** | Class1.cs 빈 파일만 존재 |
| **담당 파일** | `src/dotnet/QuantEngine.Application/Services/WorkspaceService.cs`(신규), `ApprovalService.cs`(신규), `CollectionService.cs`(신규), `FormulaService.cs`(신규) |
| **상태** | TODO |
| **현재 상태** | `HistoryIngestionService`, `WorkspaceService`, `ApprovalService`, `CollectionService`, `FormulaService`가 모두 존재하고 `ApplicationServiceTests`로 forward 동작을 검증 중 |
| **담당 파일** | `src/dotnet/QuantEngine.Application/Services/WorkspaceService.cs`, `ApprovalService.cs`, `CollectionService.cs`, `FormulaService.cs` |
| **상태** | 부분 완료 |
| 세부 WBS | 작업 | 성공 판단 데이터 |
|----------|------|------------------|
@@ -1579,8 +1580,8 @@ WBS-10.1 (기반 결함 수정)
**성공 하네스 (데이터 기준)**:
```
검증: dotnet test --filter Service
기대: 13+ tests passed, Class1.cs 삭제됨
검증: dotnet test src/dotnet/QuantEngine.Core.Tests/QuantEngine.Core.Tests.csproj -c Debug --filter ApplicationServiceTests
기대: 4+ tests passed
```
---
@@ -1614,7 +1615,7 @@ WBS-10.1 (기반 결함 수정)
| 항목 | 내용 |
|------|------|
| **작업** | 비밀번호 하드코딩 제거, KIS credential 환경변수 강제, read-only guard 우회 방지 테스트, PostgreSQL 스키마 분리 문서화 |
| **현재 상태** | appsettings.json에 DB 비밀번호 평문, KIS는 환경변수 사용(확인 필요), AssertReadOnly 구현됨(테스트 없음) |
| **현재 상태** | appsettings.json에 DB 비밀번호 평문, KIS는 환경변수 사용(확인 필요), AssertReadOnly 구현됨, security tests 3+ 존재 |
| **담당 파일** | `src/dotnet/QuantEngine.Web/appsettings.json`, `src/dotnet/QuantEngine.Infrastructure/External/KisApiClient.cs`, `src/dotnet/QuantEngine.Core.Tests/SecurityTests.cs`(신규) |
| **상태** | TODO |
@@ -1638,22 +1639,24 @@ WBS-10.1 (기반 결함 수정)
| 항목 | 내용 |
|------|------|
| **작업** | Python snapshot_admin_server_v1.py의 편집/조회 기능을 Blazor SSR로 확장. 기본 템플릿 페이지 제거 |
| **현재 상태** | Dashboard.razor에 Settings CRUD 구현, Counter/Weather 기본 페이지 잔존 |
| **담당 파일** | `src/dotnet/QuantEngine.Web/Components/Pages/Dashboard.razor`, `AccountSnapshot.razor`(신규), `CollectionDashboard.razor`(신규) |
| **상태** | TODO |
| **현재 상태** | `Dashboard.razor`는 데이터 비의존형 상태표시로 단순화되었고, `Operations.razor``Temp/operational_report.json` 고정 렌더 경로를 제공하며, Counter/Weather 기본 페이지는 삭제됨. 공개 배포본은 아직 이전 빌드가 남아 있을 수 있으므로 CI/CD 동기화가 필요함 |
| **담당 파일** | `src/dotnet/QuantEngine.Web/Components/Pages/Dashboard.razor`, `Operations.razor`(신규), `NavMenu.razor` |
| **상태** | 부분 완료 |
| 세부 WBS | 작업 | 성공 판단 데이터 |
|----------|------|------------------|
| 10.10.1 | Account Snapshot 편집 페이지 — 조회/추가/수정/삭제 CRUD | 4개 CRUD 동작 테스트 PASS |
| 10.10.2 | Collection Dashboard — 수집 실행 이력 조회, 에러 로그 표시 | 테이블 조회 + 필터 동작 PASS |
| 10.10.3 | Counter.razor / Weather.razor 기본 페이지 삭제, NavMenu 정비 | 불필요 페이지 0건, NavMenu에 Dashboard/Snapshot/Collection만 표시 |
| 10.10.1 | Operational Report 페이지 — `Temp/operational_report.json` 고정 렌더 | 38 sections 인식 + PASS/DATA_MISSING 표시 |
| 10.10.2 | Dashboard 상태 페이지 — 데이터 비의존형 요약으로 단순화 | DB 실패 시에도 200 응답 |
| 10.10.3 | Counter.razor / Weather.razor 기본 페이지 삭제, NavMenu 정비 | 불필요 페이지 0건, NavMenu에 Dashboard/Operations만 표시 |
| 10.10.4 | 다크 모드 + 반응형 레이아웃 적용 | 브라우저 렌더링 정상 확인 |
| 10.10.5 | 배포 동기화 | `snapshot_admin_deploy.yml``/quant/``/quant/operations` 공개 라우트를 배포 후 검증하도록 구성됨 |
**성공 하네스 (데이터 기준)**:
```
검증: dotnet build src/dotnet/QuantEngine.Web/ → 오류 0
검증: Counter.razor, Weather.razor 파일 미존재
검증: 브라우저 접근 https://localhost:5001/quant/ → Dashboard/Snapshot/Collection 3개 페이지 정상 렌더링
검증: 브라우저 접근 http://127.0.0.1:5080/operations → operational_report.json 기반 렌더링
검증: 배포 URL http://178.104.200.7/quant/ 에서 `/`와 `/operations`가 200 응답 + 로컬과 동일한 UI 기준을 만족
```
---
@@ -1824,7 +1827,7 @@ WBS-10.1 (기반 결함 수정)
[x] GAS 라이브러리 강화 (src/gas/core/gas_lib.gs +429줄)
[x] 섹터 리포트 & 대표종목 모니터 고도화
etf_representative_monitor.py, render_operational_report.py
etf_representative_monitor.py, src/dotnet/QuantEngine.Tools
update_workbook_sector_insights.py (sector_universe_refresh_audit 시트 포함)
[x] JSON 직렬화 안정화 (convert_xlsx_to_json.py — datetime/NaN 예외 처리)
@@ -2206,6 +2209,7 @@ python tools/validate_snapshot_admin_web_v1.py
| P4 GAS thin adapter minimize | `allowed_responsibilities_only=true`, `forbidden_responsibilities_present=false`, `thin_adapter_gate=PASS` | `tools/validate_gas_thin_adapter_v1.py`, `Temp/gas_thin_adapter_validation_v1.json`, `src/gas/core/gas_lib.gs` | `python tools/validate_gas_thin_adapter_v1.py` |
| P5 PostgreSQL upgrade path | `sqlite_schema_parity=PASS`, `backend_contract_present=true`, `postgres_execution=DATA_GATED`, `caller_compatibility_preserved=true` | `src/quant_engine/data_collection_backend_v1.py`, `src/quant_engine/kis_data_collection_v1.py`, `tests/unit/test_data_collection_store_v1.py`, `tools/generate_postgresql_upgrade_stub_v1.py` | `python -m pytest tests/unit/test_data_collection_store_v1.py -q` |
| P6 Snapshot admin web editor | `settings_sheet_web_editor=true`, `account_snapshot_sheet_web_editor=true`, `contenteditable_grid=true`, `api_save_round_trip=PASS`, `kis_collection_dashboard=true`, `workspace_db_is_single_file=true`, `collection_filter_controls=true`, `collection_dashboard_page=true`, `change_timeline_view=true` | `src/quant_engine/snapshot_admin_server_v1.py`, `src/quant_engine/data_collection_store_v1.py`, `src/quant_engine/snapshot_admin_store_v1.py`, `tools/validate_snapshot_admin_web_v1.py`, `tests/unit/test_snapshot_admin_web_v1.py`, `.gitea/workflows/snapshot_admin.yml` | `python tools/validate_snapshot_admin_web_v1.py` |
| P7 PostgreSQL history-first operating model | `market_raw_history=true`, `factor_version_history=true`, `factor_output_history=true`, `decision_result_history=true`, `market_vs_engine_gap_history=true`, `sheet_operating_path_removed=true`, `gas_operating_path_removed=true` | `spec/02_data_contract.yaml`, `spec/postgresql_history_contract.yaml`, `docs/DAILY_SIGNAL_TRACKING.md`, `docs/POSTGRESQL_HISTORY_FIRST_OPERATING_MODEL.md` | `python tools/validate_postgresql_history_contract_v1.py` |
| Q1 Qualitative sell pipeline | `mock_api_validation=PASS`, `pipeline_contract=PASS`, `workflow_present=true`, `schedule_present=true`, `package_scripts_present=true` | `.gitea/workflows/qualitative_sell_strategy.yml`, `tools/validate_qualitative_sell_strategy_pipeline_v1.py`, `Temp/qualitative_sell_strategy_pipeline_v1.json` | `python tools/validate_qualitative_sell_strategy_pipeline_v1.py` |
| Q2 Gitea secrets contract | `secrets_contract=PASS`, `workflow_secret_mapping=PASS`, `docs_present=true`, `ci_validation_present=true` | `docs/GITEA_SECRETS_SETUP.md`, `tools/validate_gitea_secrets_contract_v1.py`, `Temp/gitea_secrets_contract_v1.json` | `python tools/validate_gitea_secrets_contract_v1.py` |
+2 -2
View File
@@ -13,7 +13,7 @@
"ops:sell-eval": "python tools/evaluate_qualitative_sell_strategy_accuracy_v1.py --sqlite-db outputs/qualitative_sell_strategy/qualitative_sell_strategy.db",
"ops:sell-validate": "python tools/validate_qualitative_sell_strategy_pipeline_v1.py",
"ops:postgres-stub": "python tools/generate_postgresql_upgrade_stub_v1.py",
"ops:render": "python tools/render_operational_report.py --json GatherTradingData.json --output Temp/operational_report.md --report-json-output Temp/operational_report.json",
"ops:render": "dotnet run --project src/dotnet/QuantEngine.Tools/QuantEngine.Tools.csproj -- report --packet=Temp/final_decision_packet_active.json --out=Temp/operational_report.json",
"ops:snapshot-web": "python tools/run_snapshot_admin_server_v1.py --reload --db src/quant_engine/snapshot_admin.db --seed GatherTradingData.json",
"ops:snapshot-web-watch": "python tools/run_snapshot_admin_server_v1.py --reload --db src/quant_engine/snapshot_admin.db --seed GatherTradingData.json",
"ops:snapshot-validate": "python tools/validate_snapshot_admin_workflow_v1.py",
@@ -52,7 +52,7 @@
"validate-engine-strict": "python tools/run_release_dag_v3.py --mode release --strict",
"validate-behavioral-coverage": "python tools/validate_behavioral_coverage_v1.py --strict",
"validate-engine-integrity": "python tools/run_release_dag_v3.py --mode release --strict",
"render-report-json": "python tools/render_operational_report.py --json GatherTradingData.json --output Temp/operational_report.md --report-json-output 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"
},
"dependencies": {
"cheerio": "1.2.0",
+20
View File
@@ -172,6 +172,26 @@ quant_feed_contract:
normalization: "숫자로 읽힌 91160.0, 5930.0 등은 문자열화 후 6자리 zero-pad 적용."
validation_commands: ["npm run validate-data-sample", "npm run validate-specs"]
xlsx_refresh_rule: "xlsx 원본을 갱신했으면 먼저 DB에 반영한 뒤, 엔진이 DB를 읽어 JSON 파생 보고서를 재생성하고 다시 검증한다."
database_first_operating_model:
purpose: "운영 이력, 원천 팩터, 파생 최종 팩터, 시장-결과 괴리를 PostgreSQL에 누적해 엔진을 고도화한다."
canonical_store:
primary: "PostgreSQL"
secondary: "SQLite transient cache only"
prohibited_operating_path:
- "Excel workbook as operational source"
- "Google Apps Script as operational source"
history_domains:
- "market_raw_history"
- "factor_version_history"
- "factor_output_history"
- "decision_result_history"
- "market_vs_engine_gap_history"
policy:
- "최종 팩터와 최종 판단은 DB 이력 테이블에 버전과 시각을 함께 남긴다."
- "시장 raw와 엔진 결과의 괴리는 별도 gap history로 적재한다."
- "엑셀/시트/Apps Script는 더 이상 운영 경로가 아니라, 역사적 import/export 또는 폐기 대상만 허용한다."
- "새 분석·리포트는 PostgreSQL snapshot을 1차 진실원천으로 사용한다."
xlsx_analysis_protocol:
purpose: "xlsx는 HTS 잔고·거래내역 판독 또는 DB 반영 이전의 보조 감사 소스다. 시장 raw 일반 분석과 최종 보고서 생성은 DB 추적 후의 파생 JSON을 우선한다."
python_parsing_baseline:
+74
View File
@@ -0,0 +1,74 @@
schema_version: "postgresql_history_contract_v1"
title: "PostgreSQL History-First Operating Contract"
purpose: "시장 원천, 팩터 버전, 최종 팩터 출력, 엔진 의사결정, 시장-엔진 괴리를 PostgreSQL에 누적한다."
canonical_principles:
- "PostgreSQL is the canonical operating history store."
- "Excel workbooks and Google Apps Script are not operational sources of truth."
- "All derived analysis must be traceable to a versioned DB snapshot."
- "Factor outputs and decision outputs must carry provenance and source_version."
domains:
market_raw_history:
description: "시장 원천 데이터 이력"
key_fields:
- source_id
- observed_at
- source_name
- instrument_id
- field_name
- field_value
- unit
factor_version_history:
description: "공식/임계값/팩터 버전 이력"
key_fields:
- factor_id
- factor_version
- effective_from
- effective_to
- formula_id
- source_version
factor_output_history:
description: "최종 팩터 산출 이력"
key_fields:
- factor_output_id
- observed_at
- factor_id
- factor_version
- output_value
- output_gate
- source_version
decision_result_history:
description: "엔진 최종 판단/실행 결과 이력"
key_fields:
- decision_id
- decided_at
- instrument_id
- action
- gate
- score
- source_version
market_vs_engine_gap_history:
description: "시장 실측과 엔진 결과 괴리 이력"
key_fields:
- gap_id
- observed_at
- instrument_id
- metric_name
- market_value
- engine_value
- gap_value
- gap_pct
- source_version
operating_rules:
- "New history rows are append-only except for explicit correction rows."
- "Correction rows must reference corrected_row_id and correction_reason."
- "Factor recomputation must preserve previous outputs in history."
- "No report should read directly from Excel/GAS when PostgreSQL snapshot is available."
implementation_targets:
- "src/quant_engine/postgresql_history_store_v1.py"
- "tools/build_postgresql_history_snapshot_v1.py"
- "tools/validate_postgresql_history_contract_v1.py"
- "docs/POSTGRESQL_HISTORY_FIRST_OPERATING_MODEL.md"
+17 -9
View File
@@ -12,6 +12,12 @@ purpose: |
UNVALIDATED → PROVISIONAL → CALIBRATED 상태 전환
honest_proof_score: 56.57 → 95.0 달성
implementation_note: |
live_outcome_ledger.gs는 Google Sheets 원장 적재/갱신용 GAS thin adapter다.
운영 리포트와 검증용 JSON 산출물은 Python 하네스가 Temp/ 경로에 생성한다.
GAS는 JSON 리포트를 직접 출력하지 않는다.
이후 운영 표준은 PostgreSQL history store이며, 시트/GAS는 운영 경로에서 제외한다.
current_state:
honest_proof_score: 56.57
target_score: 95.0
@@ -132,7 +138,8 @@ honest_proof_improvement_path:
# ─────────────────────────────────────────────────────────────────────────────
tracking_system:
spreadsheet: "live_outcome_ledger (GAS 연동 스프레드시트)"
datastore: "PostgreSQL history store"
deprecated_surface: "live_outcome_ledger (GAS 연동 스프레드시트)"
daily_tasks:
- "신규 신호 entry 작성 (시작할 때)"
@@ -151,11 +158,12 @@ tracking_system:
# ─────────────────────────────────────────────────────────────────────────────
checklist:
- [ ] "live_outcome_ledger 스프레드시트 생성 (GAS 연동)"
- [ ] "신호 기록 템플릿 작성"
- [ ] "T+20 가격 수집 자동화 (GAS)"
- [ ] "daily commit: 신호 추가 시마다"
- [ ] "30개 신호 누적 (약 6주)"
- [ ] "win_rate >= 60% 달성"
- [ ] "CALIBRATED 전환"
- [ ] "honest_proof_score 95 달성"
- "[ ] live_outcome_ledger 스프레드시트 생성 (GAS 연동)"
- "[ ] 신호 기록 템플릿 작성"
- "[ ] T+20 가격 수집 자동화 (GAS)"
- "[ ] Temp/operational_t20_outcome_ledger_v1.json 생성 체인 유지 (Python)"
- "[ ] daily commit: 신호 추가 시마다"
- "[ ] 30개 신호 누적 (약 6주)"
- "[ ] win_rate >= 60% 달성"
- "[ ] CALIBRATED 전환"
- "[ ] honest_proof_score 95 달성"
@@ -0,0 +1,60 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using QuantEngine.Core.Interfaces;
using QuantEngine.Core.Models;
namespace QuantEngine.Application.Services
{
public class CollectionService
{
private readonly IPostgresqlHistoryStore _historyStore;
public CollectionService(IPostgresqlHistoryStore historyStore)
{
_historyStore = historyStore;
}
public Task<int> AppendRunAsync(CollectionRun run)
=> _historyStore.AppendAsync("collection_run_history", new Dictionary<string, object?>
{
["run_id"] = run.RunId,
["collector_name"] = run.CollectorName,
["started_at"] = run.StartedAt,
["finished_at"] = run.FinishedAt,
["status"] = run.Status,
["input_source"] = run.InputSource,
["output_json_path"] = run.OutputJsonPath,
["output_db_path"] = run.OutputDbPath,
["notes"] = run.Notes,
["created_at"] = run.CreatedAt
});
public Task<int> AppendSnapshotAsync(CollectionSnapshot snapshot)
=> _historyStore.AppendAsync("collection_snapshot_history", new Dictionary<string, object?>
{
["run_id"] = snapshot.RunId,
["dataset_name"] = snapshot.DatasetName,
["ticker"] = snapshot.Ticker,
["name"] = snapshot.Name,
["sector"] = snapshot.Sector,
["as_of_date"] = snapshot.AsOfDate,
["source_priority"] = snapshot.SourcePriority,
["source_status"] = snapshot.SourceStatus,
["payload_json"] = snapshot.PayloadJson,
["provenance_json"] = snapshot.ProvenanceJson,
["created_at"] = snapshot.CreatedAt
});
public Task<int> AppendSourceErrorAsync(CollectionSourceError error)
=> _historyStore.AppendAsync("collection_source_error_history", new Dictionary<string, object?>
{
["run_id"] = error.RunId,
["ticker"] = error.Ticker,
["source_name"] = error.SourceName,
["error_kind"] = error.ErrorKind,
["error_message"] = error.ErrorMessage,
["payload_json"] = error.PayloadJson,
["created_at"] = error.CreatedAt
});
}
}
@@ -0,0 +1,41 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using QuantEngine.Core.Domain;
using QuantEngine.Core.Interfaces;
namespace QuantEngine.Application.Services
{
public class FormulaService
{
private readonly IPostgresqlHistoryStore _historyStore;
public FormulaService(IPostgresqlHistoryStore historyStore)
{
_historyStore = historyStore;
}
public TimingDecisionResult ComputeTimingDecision(Dictionary<string, object> ctx)
=> FormulaEngine.ComputeTimingDecision(ctx);
public SellDecisionResult ComputeSellDecision(Dictionary<string, object> ctx)
=> FormulaEngine.ComputeSellDecision(ctx);
public FinalDecisionResult ComputeFinalDecision(Dictionary<string, object> ctx)
=> FormulaEngine.ComputeFinalDecision(ctx);
public CashShortfallResult ComputeCashShortfallHarness(
Dictionary<string, object> asResult,
double totalAsset,
Dictionary<string, object> cashFloorInfo,
double mrsScore)
=> FormulaEngine.ComputeCashShortfallHarness(asResult, totalAsset, cashFloorInfo, mrsScore);
public CashRecoveryPlanResult ComputeCashRecoveryOptimizer(
List<Dictionary<string, object>> sellCandidates,
double cashShortfallMinKrw)
=> FormulaEngine.ComputeCashRecoveryOptimizer(sellCandidates, cashShortfallMinKrw);
public Task<int> AppendFormulaRunAsync(string formulaName, Dictionary<string, object?> payload)
=> _historyStore.AppendAsync($"formula_{formulaName}_history", payload);
}
}
@@ -0,0 +1,92 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using QuantEngine.Core.Domain;
using QuantEngine.Core.Interfaces;
namespace QuantEngine.Application.Services
{
public class HistoryIngestionService
{
private readonly IPostgresqlHistoryStore _store;
public HistoryIngestionService(IPostgresqlHistoryStore store)
{
_store = store;
}
public Task<int> AppendDecisionAsync(IDictionary<string, object?> payload)
=> _store.AppendAsync("decision_result_history", payload);
public Task<int> AppendFactorOutputAsync(IDictionary<string, object?> payload)
=> _store.AppendAsync("factor_output_history", payload);
public Task<int> AppendMarketRawAsync(IDictionary<string, object?> payload)
=> _store.AppendAsync("market_raw_history", payload);
public Task<int> AppendGapAsync(IDictionary<string, object?> payload)
=> _store.AppendAsync("market_vs_engine_gap_history", payload);
public Task<int> AppendDecisionAsync(
FinalDecisionResult decision,
SellDecisionResult? sellDecision = null,
TimingDecisionResult? timingDecision = null,
string? instrumentId = null,
string? sourceVersion = null,
string? gate = null)
{
var payload = new Dictionary<string, object?>
{
["decision_id"] = Guid.NewGuid().ToString("N"),
["decided_at"] = DateTimeOffset.UtcNow,
["instrument_id"] = instrumentId ?? string.Empty,
["action"] = decision.FinalAction,
["gate"] = gate ?? (string.IsNullOrWhiteSpace(sellDecision?.Validation) ? "PASS" : sellDecision.Validation),
["score"] = decision.PriorityScore,
["source_version"] = sourceVersion ?? decision.DecisionSource,
["provenance"] = new Dictionary<string, object?>
{
["final_action"] = decision.FinalAction,
["action_priority"] = decision.ActionPriority,
["priority_score"] = decision.PriorityScore,
["decision_source"] = decision.DecisionSource,
["sell_action"] = sellDecision?.Action,
["sell_validation"] = sellDecision?.Validation,
["timing_action"] = timingDecision?.Action,
["timing_reason"] = timingDecision?.Reason
}
};
return _store.AppendAsync("decision_result_history", payload);
}
public Task<int> AppendFactorOutputAsync(
string factorId,
string factorVersion,
double outputValue,
string outputGate,
string? sourceVersion = null,
DateTimeOffset? observedAt = null)
{
var payload = new Dictionary<string, object?>
{
["factor_output_id"] = Guid.NewGuid().ToString("N"),
["observed_at"] = observedAt ?? DateTimeOffset.UtcNow,
["factor_id"] = factorId,
["factor_version"] = factorVersion,
["output_value"] = outputValue,
["output_gate"] = outputGate,
["source_version"] = sourceVersion ?? factorVersion,
["provenance"] = new Dictionary<string, object?>
{
["factor_id"] = factorId,
["factor_version"] = factorVersion,
["output_value"] = outputValue,
["output_gate"] = outputGate,
["source_version"] = sourceVersion ?? factorVersion
}
};
return _store.AppendAsync("factor_output_history", payload);
}
}
}
@@ -0,0 +1,19 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using QuantEngine.Core.Interfaces;
namespace QuantEngine.Application.Services
{
public class PostgresqlHistorySnapshotReader : IPostgresqlHistorySnapshotReader
{
private readonly IPostgresqlHistoryStore _store;
public PostgresqlHistorySnapshotReader(IPostgresqlHistoryStore store)
{
_store = store;
}
public Task<IReadOnlyList<IDictionary<string, object?>>> ReadAsync(string domain, int limit = 500)
=> _store.SnapshotAsync(domain, limit);
}
}
@@ -9,10 +9,12 @@ namespace QuantEngine.Application.Services
public class WorkspaceService
{
private readonly IWorkspaceRepository _repository;
private readonly IPostgresqlHistoryStore _historyStore;
public WorkspaceService(IWorkspaceRepository repository)
public WorkspaceService(IWorkspaceRepository repository, IPostgresqlHistoryStore historyStore)
{
_repository = repository;
_historyStore = historyStore;
}
public Task<IEnumerable<Setting>> GetSettingsAsync() => _repository.GetSettingsAsync();
@@ -23,5 +25,8 @@ namespace QuantEngine.Application.Services
public Task<IEnumerable<AccountSnapshot>> GetAccountSnapshotsAsync() => _repository.GetAccountSnapshotsAsync();
public Task<bool> InsertAccountSnapshotsAsync(IEnumerable<AccountSnapshot> snapshots) => _repository.InsertAccountSnapshotsAsync(snapshots);
public Task<bool> ClearAccountSnapshotsAsync() => _repository.ClearAccountSnapshotsAsync();
public Task<int> AppendHistoryAsync(string domain, IDictionary<string, object?> payload) => _historyStore.AppendAsync(domain, payload);
public Task<IReadOnlyList<IDictionary<string, object?>>> ReadHistorySnapshotAsync(string domain, int limit = 500) => _historyStore.SnapshotAsync(domain, limit);
}
}
@@ -0,0 +1,159 @@
using QuantEngine.Application.Services;
using QuantEngine.Core.Interfaces;
using QuantEngine.Core.Models;
namespace QuantEngine.Core.Tests;
public class ApplicationServiceTests
{
[Fact]
public async Task WorkspaceService_ForwardsSettingAndHistoryOperations()
{
var repo = new FakeWorkspaceRepository();
var history = new FakeHistoryStore();
var service = new WorkspaceService(repo, history);
var setting = new Setting { Ordinal = 1, Key = "risk_mode", ValueJson = "\"RISK_ON\"" };
Assert.True(await service.UpsertSettingAsync(setting));
Assert.Equal(setting, repo.LastSetting);
var payload = new Dictionary<string, object?> { ["foo"] = "bar" };
Assert.Equal(1, await service.AppendHistoryAsync("decision_result_history", payload));
Assert.Equal("decision_result_history", history.LastDomain);
Assert.Equal("bar", history.LastPayload?["foo"]);
}
[Fact]
public async Task ApprovalService_ForwardsApprovalAndLockOperations()
{
var repo = new FakeWorkspaceRepository();
var service = new ApprovalService(repo);
var approval = new WorkspaceApproval { Domain = "settings", TargetRef = "portfolio", Status = "APPROVED" };
Assert.True(await service.UpsertApprovalAsync(approval));
Assert.Equal(approval, repo.LastApproval);
var lockRow = new WorkspaceLock { Domain = "settings", TargetRef = "portfolio", LockedBy = "qa", Reason = "review" };
Assert.True(await service.AcquireLockAsync(lockRow));
Assert.Equal(lockRow, repo.LastLock);
Assert.True(await service.ReleaseLockAsync("settings", "portfolio"));
Assert.Equal(("settings", "portfolio"), repo.LastReleasedLock);
}
[Fact]
public async Task CollectionService_AppendsRunSnapshotAndErrorRecords()
{
var history = new FakeHistoryStore();
var service = new CollectionService(history);
await service.AppendRunAsync(new CollectionRun
{
RunId = "run-1",
CollectorName = "kis",
StartedAt = "2026-06-26T09:00:00+09:00",
Status = "PASS"
});
Assert.Equal("collection_run_history", history.LastDomain);
Assert.Equal("run-1", history.LastPayload?["run_id"]);
await service.AppendSnapshotAsync(new CollectionSnapshot
{
RunId = "run-1",
DatasetName = "decision_result_history",
Ticker = "005930",
SourcePriority = "KIS",
SourceStatus = "PASS",
PayloadJson = "{}",
ProvenanceJson = "{}"
});
Assert.Equal("collection_snapshot_history", history.LastDomain);
Assert.Equal("005930", history.LastPayload?["ticker"]);
await service.AppendSourceErrorAsync(new CollectionSourceError
{
RunId = "run-1",
SourceName = "naver",
ErrorKind = "TIMEOUT",
ErrorMessage = "timeout"
});
Assert.Equal("collection_source_error_history", history.LastDomain);
Assert.Equal("TIMEOUT", history.LastPayload?["error_kind"]);
}
[Fact]
public async Task FormulaService_ForwardsFormulaExecutionAndHistory()
{
var history = new FakeHistoryStore();
var service = new FormulaService(history);
var timing = service.ComputeTimingDecision(new Dictionary<string, object>
{
["entryModeGate"] = "PASS",
["entryMode"] = "BREAKOUT",
["leaderGate"] = "PASS",
["acGate"] = "CLEAR",
["priceStatus"] = "PRICE_OK",
["atr20"] = 1.0,
["leaderTotal"] = 4,
["flowCredit"] = 0.7,
["avgTradeValue5D"] = 100,
["spreadPct"] = 0.5
});
Assert.NotEqual(string.Empty, timing.Action);
await service.AppendFormulaRunAsync("timing", new Dictionary<string, object?>
{
["action"] = timing.Action,
["entry_score"] = timing.EntryScore
});
Assert.Equal("formula_timing_history", history.LastDomain);
Assert.Equal(timing.Action, history.LastPayload?["action"]);
}
private sealed class FakeWorkspaceRepository : IWorkspaceRepository
{
public Setting? LastSetting { get; private set; }
public WorkspaceApproval? LastApproval { get; private set; }
public WorkspaceLock? LastLock { get; private set; }
public (string Domain, string TargetRef)? LastReleasedLock { get; private set; }
public Task<IEnumerable<Setting>> GetSettingsAsync() => Task.FromResult(Enumerable.Empty<Setting>());
public Task<Setting?> GetSettingByKeyAsync(string key) => Task.FromResult<Setting?>(null);
public Task<bool> UpsertSettingAsync(Setting setting) { LastSetting = setting; return Task.FromResult(true); }
public Task<bool> DeleteSettingAsync(string key) => Task.FromResult(true);
public Task<IEnumerable<AccountSnapshot>> GetAccountSnapshotsAsync() => Task.FromResult(Enumerable.Empty<AccountSnapshot>());
public Task<bool> InsertAccountSnapshotsAsync(IEnumerable<AccountSnapshot> snapshots) => Task.FromResult(true);
public Task<bool> ClearAccountSnapshotsAsync() => Task.FromResult(true);
public Task<IEnumerable<WorkspaceApproval>> GetApprovalsAsync() => Task.FromResult(Enumerable.Empty<WorkspaceApproval>());
public Task<WorkspaceApproval?> GetApprovalAsync(string domain, string targetRef) => Task.FromResult<WorkspaceApproval?>(null);
public Task<bool> UpsertApprovalAsync(WorkspaceApproval approval) { LastApproval = approval; return Task.FromResult(true); }
public Task<IEnumerable<WorkspaceLock>> GetLocksAsync() => Task.FromResult(Enumerable.Empty<WorkspaceLock>());
public Task<WorkspaceLock?> GetLockAsync(string domain, string targetRef) => Task.FromResult<WorkspaceLock?>(null);
public Task<bool> AcquireLockAsync(WorkspaceLock @lock) { LastLock = @lock; return Task.FromResult(true); }
public Task<bool> ReleaseLockAsync(string domain, string targetRef) { LastReleasedLock = (domain, targetRef); return Task.FromResult(true); }
}
private sealed class FakeHistoryStore : IPostgresqlHistoryStore
{
public string? LastDomain { get; private set; }
public IDictionary<string, object?>? LastPayload { get; private set; }
public Task<int> AppendAsync(string domain, IDictionary<string, object?> payload)
{
LastDomain = domain;
LastPayload = new Dictionary<string, object?>(payload);
return Task.FromResult(1);
}
public Task<IReadOnlyList<IDictionary<string, object?>>> SnapshotAsync(string domain, int limit = 500)
=> Task.FromResult<IReadOnlyList<IDictionary<string, object?>>>(Array.Empty<IDictionary<string, object?>>());
}
}
@@ -30,4 +30,62 @@ public class FormulaEngineTests
Assert.NotNull(result);
Assert.Equal("BUY_BREAKOUT_PILOT_ONLY", result.Action);
}
[Fact]
public void ComputeSellDecisionProducesExitTrimWhenRiskWindowIsOpen()
{
var ctx = new Dictionary<string, object>
{
{ "close", 100.0 },
{ "profitPct", 31.0 },
{ "tp1Price", 108.0 },
{ "tp2Price", 112.0 },
{ "timingAction", "BUY_STAGE1_READY" },
{ "atr20", 4.0 }
};
var result = FormulaEngine.ComputeSellDecision(ctx);
Assert.Equal("PROFIT_TRIM_35", result.Action);
Assert.Equal(35, result.RatioPct);
Assert.Equal("SIGNAL_CONFIRMED", result.Validation);
}
[Fact]
public void ComputeFinalDecisionPromotesSellReadyWhenSellSignalIsConfirmed()
{
var ctx = new Dictionary<string, object>
{
{ "sellAction", "TRIM_35" },
{ "sellValidation", "SIGNAL_CONFIRMED" },
{ "timingScoreEntry", 72.0 },
{ "timingScoreExit", 15.0 }
};
var result = FormulaEngine.ComputeFinalDecision(ctx);
Assert.Equal("SELL_READY", result.FinalAction);
Assert.Equal(10, result.ActionPriority);
Assert.Equal("RULE_ENGINE", result.DecisionSource);
}
[Fact]
public void ComputeCashShortfallHarnessCalculatesTargetAndShortfall()
{
var asResult = new Dictionary<string, object>
{
{ "settlementCashD2Krw", 10_000_000.0 }
};
var cashFloor = new Dictionary<string, object>
{
{ "minPct", 15.0 }
};
var result = FormulaEngine.ComputeCashShortfallHarness(asResult, 100_000_000.0, cashFloor, 6.0);
Assert.Equal(10.0, result.CashCurrentPctD2);
Assert.Equal(15.0, result.CashTargetPct);
Assert.Equal(5_000_000.0, result.CashShortfallMinKrw);
Assert.Equal(5_000_000.0, result.CashShortfallTargetKrw);
}
}
@@ -0,0 +1,93 @@
using QuantEngine.Application.Services;
using QuantEngine.Core.Interfaces;
namespace QuantEngine.Core.Tests;
public class HistoryIngestionE2ETests
{
[Fact]
public async Task AppendDecisionThenReadSnapshotRoundTripsThroughApplicationFlow()
{
var store = new FakeHistoryStore();
var ingestion = new HistoryIngestionService(store);
var reader = new PostgresqlHistorySnapshotReader(store);
var appendCount = await ingestion.AppendDecisionAsync(new Dictionary<string, object?>
{
["decision_id"] = "dec-001",
["decided_at"] = DateTimeOffset.Parse("2026-06-26T09:00:00+09:00"),
["instrument_id"] = "005930",
["action"] = "BUY",
["gate"] = "PASS",
["score"] = 87.5,
["source_version"] = "v1",
["provenance"] = new Dictionary<string, object?>
{
["source"] = "unit-test"
}
});
Assert.Equal(1, appendCount);
var rows = await reader.ReadAsync("decision_result_history", 10);
Assert.Single(rows);
Assert.Equal("dec-001", rows[0]["decision_id"]);
Assert.Equal("005930", rows[0]["instrument_id"]);
Assert.Equal("BUY", rows[0]["action"]);
Assert.Equal("PASS", rows[0]["gate"]);
Assert.Equal(87.5, rows[0]["score"]);
}
[Fact]
public async Task AppendFactorOutputThenReadSnapshotPreservesPayload()
{
var store = new FakeHistoryStore();
var ingestion = new HistoryIngestionService(store);
var reader = new PostgresqlHistorySnapshotReader(store);
var appendCount = await ingestion.AppendFactorOutputAsync(
factorId: "RS_VERDICT_V2",
factorVersion: "2026-06-26",
outputValue: 1.23,
outputGate: "PASS",
sourceVersion: "source-42",
observedAt: DateTimeOffset.Parse("2026-06-26T10:00:00+09:00"));
Assert.Equal(1, appendCount);
var rows = await reader.ReadAsync("factor_output_history", 10);
Assert.Single(rows);
Assert.Equal("RS_VERDICT_V2", rows[0]["factor_id"]);
Assert.Equal("2026-06-26", rows[0]["factor_version"]);
Assert.Equal(1.23, rows[0]["output_value"]);
Assert.Equal("PASS", rows[0]["output_gate"]);
Assert.Equal("source-42", rows[0]["source_version"]);
}
private sealed class FakeHistoryStore : IPostgresqlHistoryStore
{
private readonly Dictionary<string, List<IDictionary<string, object?>>> _rows = new();
public Task<int> AppendAsync(string domain, IDictionary<string, object?> payload)
{
if (!_rows.TryGetValue(domain, out var list))
{
list = new List<IDictionary<string, object?>>();
_rows[domain] = list;
}
list.Add(new Dictionary<string, object?>(payload));
return Task.FromResult(1);
}
public Task<IReadOnlyList<IDictionary<string, object?>>> SnapshotAsync(string domain, int limit = 500)
{
if (!_rows.TryGetValue(domain, out var list))
{
return Task.FromResult<IReadOnlyList<IDictionary<string, object?>>>(Array.Empty<IDictionary<string, object?>>());
}
return Task.FromResult<IReadOnlyList<IDictionary<string, object?>>>(list.Take(limit).ToList());
}
}
}
@@ -0,0 +1,41 @@
using QuantEngine.Infrastructure.Repositories;
namespace QuantEngine.Core.Tests;
public class PostgresqlHistoryStoreTests
{
[Fact]
public void DomainColumnsExposeCanonicalDomains()
{
var domains = PostgresqlHistoryStore.GetDomainColumns();
Assert.Contains("decision_result_history", domains.Keys);
Assert.Contains("factor_output_history", domains.Keys);
Assert.Contains("market_raw_history", domains.Keys);
Assert.Contains("market_vs_engine_gap_history", domains.Keys);
Assert.True(domains["decision_result_history"].Contains("decision_id"));
Assert.True(domains["factor_output_history"].Contains("output_gate"));
}
[Fact]
public void BuildInsertSqlUsesEngineHistoryPrefixAndNamedParameters()
{
var sql = PostgresqlHistoryStore.BuildInsertSql(
"decision_result_history",
new[] { "decision_id", "decided_at", "instrument_id", "action", "gate", "score", "source_version", "provenance" });
Assert.Equal(
"INSERT INTO engine_history.decision_result_history (decision_id, decided_at, instrument_id, action, gate, score, source_version, provenance) VALUES (@decision_id, @decided_at, @instrument_id, @action, @gate, @score, @source_version, @provenance)",
sql);
}
[Fact]
public void BuildSnapshotSqlUsesCreatedAtDescendingAndLimitParameter()
{
var sql = PostgresqlHistoryStore.BuildSnapshotSql("factor_output_history", 25);
Assert.Equal(
"SELECT * FROM engine_history.factor_output_history ORDER BY created_at DESC LIMIT @Limit",
sql);
}
}
@@ -20,6 +20,8 @@
<ItemGroup>
<ProjectReference Include="..\QuantEngine.Core\QuantEngine.Core.csproj" />
<ProjectReference Include="..\QuantEngine.Application\QuantEngine.Application.csproj" />
<ProjectReference Include="..\QuantEngine.Infrastructure\QuantEngine.Infrastructure.csproj" />
</ItemGroup>
</Project>
</Project>
@@ -0,0 +1,68 @@
using System.Reflection;
using QuantEngine.Infrastructure.External;
namespace QuantEngine.Core.Tests;
public class SecurityTests
{
[Theory]
[InlineData("/uapi/domestic-stock/v1/quotations/inquire-price", "FHKST01010100")]
[InlineData("/uapi/domestic-stock/v1/quotations/inquire-investor", "FHKST01010900")]
[InlineData("/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice", "FHKST03010100")]
public void AssertReadOnly_AllowsReadOnlyQuotationPaths(string path, string trId)
{
var client = CreateClient();
var ex = Record.Exception(() => InvokeAssertReadOnly(client, path, trId));
Assert.Null(ex);
}
[Theory]
[InlineData("/uapi/domestic-stock/v1/trading/order-cash", "VTTC0802U")]
[InlineData("/uapi/domestic-stock/v1/quotations/inquire-price", "TTTC084000")]
[InlineData("/uapi/domestic-stock/v1/trading/order-cash", "FHKST01010100")]
public void AssertReadOnly_BlocksTradingPathsOrIds(string path, string trId)
{
var client = CreateClient();
var ex = Assert.Throws<TargetInvocationException>(() => InvokeAssertReadOnly(client, path, trId));
Assert.IsType<InvalidOperationException>(ex.InnerException);
Assert.Contains("BLOCKED", ex.InnerException!.Message);
}
[Fact]
public void AssertReadOnly_BlocksKnownTradingTrIdPrefixes()
{
var client = CreateClient();
var ex = Assert.Throws<TargetInvocationException>(() => InvokeAssertReadOnly(client, "/uapi/domestic-stock/v1/quotations/inquire-price", "VTTC8434R00"));
Assert.IsType<InvalidOperationException>(ex.InnerException);
Assert.Contains("TR_ID", ex.InnerException!.Message);
}
private static KisApiClient CreateClient()
{
Environment.SetEnvironmentVariable("KIS_APP_Key_TEST", "mock-key");
Environment.SetEnvironmentVariable("KIS_APP_Secret_TEST", "mock-secret");
return new KisApiClient(new HttpClient(new DummyHandler()), new NoopConnectionFactory());
}
private static void InvokeAssertReadOnly(KisApiClient client, string path, string trId)
{
var method = typeof(KisApiClient).GetMethod("AssertReadOnly", BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new InvalidOperationException("AssertReadOnly method not found.");
method.Invoke(client, new object[] { path, trId });
}
private sealed class DummyHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK));
}
private sealed class NoopConnectionFactory : QuantEngine.Infrastructure.Data.IDbConnectionFactory
{
public System.Data.IDbConnection CreateConnection() => throw new NotSupportedException("Not needed for read-only guard tests.");
}
}
+77 -2
View File
@@ -1,10 +1,85 @@
namespace QuantEngine.Core.Tests;
namespace QuantEngine.Core.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
public void OperationalReportLoader_ParsesCanonicalTempReport()
{
var path = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..", "Temp", "operational_report.json"));
var report = QuantEngine.Core.Infrastructure.OperationalReportLoader.Load(path);
Assert.Equal("2026-05-24-operational-report-v1", report.SchemaVersion);
Assert.Equal("GatherTradingData.json", report.SourceJson);
Assert.Equal(38, report.SectionCount);
Assert.True(report.Sections.Count >= 4);
Assert.Equal("exec_safety_declaration", report.Sections[0].Name);
Assert.Contains("source: .NET operational report builder", report.Sections[0].Preview);
}
[Fact]
public void OperationalReportLoader_ReturnsSafeDefaultsWhenFileIsMissing()
{
var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"), "operational_report.json");
var report = QuantEngine.Core.Infrastructure.OperationalReportLoader.Load(path);
Assert.Equal("n/a", report.SchemaVersion);
Assert.Equal("n/a", report.SourceJson);
Assert.Equal("n/a", report.GeneratedAt);
Assert.Equal(0, report.SectionCount);
Assert.Empty(report.Sections);
}
[Fact]
public void OperationalReportLoader_UsesSectionCountFromPayloadWhenPresent()
{
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
var path = Path.Combine(tempDir, "operational_report.json");
File.WriteAllText(path, """
{
"schema_version": "test-schema",
"source_json": "fixture.json",
"generated_at": "2026-06-26T00:00:00+00:00",
"section_count": 2,
"sections": [
{ "name": "alpha", "title": "Alpha", "markdown": "alpha body" },
{ "name": "beta", "title": "Beta", "markdown": "beta body" }
]
}
""");
var report = QuantEngine.Core.Infrastructure.OperationalReportLoader.Load(path);
Assert.Equal("test-schema", report.SchemaVersion);
Assert.Equal("fixture.json", report.SourceJson);
Assert.Equal(2, report.SectionCount);
Assert.Equal(2, report.Sections.Count);
Assert.Equal("alpha", report.Sections[0].Name);
Assert.Equal("alpha body", report.Sections[0].Preview);
}
[Fact]
public void OperationalReportLoader_PreservesEmptySectionsWithoutThrowing()
{
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
var path = Path.Combine(tempDir, "operational_report.json");
File.WriteAllText(path, """
{
"schema_version": "empty-schema",
"source_json": "fixture.json",
"generated_at": "2026-06-26T00:00:00+00:00",
"section_count": 0,
"sections": []
}
""");
var report = QuantEngine.Core.Infrastructure.OperationalReportLoader.Load(path);
Assert.Equal(0, report.SectionCount);
Assert.Empty(report.Sections);
Assert.Equal("empty-schema", report.SchemaVersion);
}
}
@@ -0,0 +1,62 @@
using System.Text.Json;
namespace QuantEngine.Core.Infrastructure
{
public sealed class OperationalReportData
{
public string SchemaVersion { get; init; } = "n/a";
public string SourceJson { get; init; } = "n/a";
public string GeneratedAt { get; init; } = "n/a";
public int SectionCount { get; init; }
public List<OperationalReportSection> Sections { get; init; } = new();
}
public sealed record OperationalReportSection(string Name, string Title, string Preview);
public static class OperationalReportLoader
{
public static OperationalReportData Load(string path)
{
if (!File.Exists(path))
{
return new OperationalReportData();
}
using var stream = File.OpenRead(path);
using var doc = JsonDocument.Parse(stream);
var root = doc.RootElement;
var sections = new List<OperationalReportSection>();
if (root.TryGetProperty("sections", out var sectionArray) && sectionArray.ValueKind == JsonValueKind.Array)
{
foreach (var section in sectionArray.EnumerateArray())
{
var name = section.TryGetProperty("name", out var nameProp) ? nameProp.GetString() ?? string.Empty : string.Empty;
var title = section.TryGetProperty("title", out var titleProp) ? titleProp.GetString() ?? string.Empty : string.Empty;
var markdown = section.TryGetProperty("markdown", out var markdownProp) ? markdownProp.GetString() ?? string.Empty : string.Empty;
sections.Add(new OperationalReportSection(name, title, Preview(markdown)));
}
}
return new OperationalReportData
{
SchemaVersion = root.TryGetProperty("schema_version", out var schema) ? schema.GetString() ?? "n/a" : "n/a",
SourceJson = root.TryGetProperty("source_json", out var sourceJson) ? sourceJson.GetString() ?? "n/a" : "n/a",
GeneratedAt = root.TryGetProperty("generated_at", out var generatedAt) ? generatedAt.GetString() ?? "n/a" : "n/a",
SectionCount = root.TryGetProperty("section_count", out var count) ? count.GetInt32() : sections.Count,
Sections = sections
};
}
private static string Preview(string markdown)
{
if (string.IsNullOrWhiteSpace(markdown))
{
return "n/a";
}
var trimmed = markdown.Replace("\r", " ").Replace("\n", " ").Trim();
return trimmed.Length <= 80 ? trimmed : trimmed[..80] + "...";
}
}
}
@@ -0,0 +1,10 @@
using System.Collections.Generic;
using System.Threading.Tasks;
namespace QuantEngine.Core.Interfaces
{
public interface IPostgresqlHistorySnapshotReader
{
Task<IReadOnlyList<IDictionary<string, object?>>> ReadAsync(string domain, int limit = 500);
}
}
@@ -0,0 +1,11 @@
using System.Collections.Generic;
using System.Threading.Tasks;
namespace QuantEngine.Core.Interfaces
{
public interface IPostgresqlHistoryStore
{
Task<int> AppendAsync(string domain, IDictionary<string, object?> payload);
Task<IReadOnlyList<IDictionary<string, object?>>> SnapshotAsync(string domain, int limit = 500);
}
}
@@ -0,0 +1,10 @@
using System.Collections.Generic;
namespace QuantEngine.Core.Models
{
public class HistoryRow
{
public string Domain { get; set; } = string.Empty;
public IDictionary<string, object?> Payload { get; set; } = new Dictionary<string, object?>();
}
}
@@ -156,6 +156,87 @@ namespace QuantEngine.Infrastructure.Data
PRIMARY KEY (domain, target_ref)
);
");
// 10. engine_history schema and tables
conn.Execute(@"
CREATE SCHEMA IF NOT EXISTS engine_history;
");
conn.Execute(@"
CREATE TABLE IF NOT EXISTS engine_history.market_raw_history (
id BIGSERIAL PRIMARY KEY,
source_id TEXT NOT NULL,
observed_at TEXT NOT NULL,
source_name TEXT NOT NULL,
instrument_id TEXT NOT NULL,
field_name TEXT NOT NULL,
field_value TEXT NOT NULL,
unit TEXT NOT NULL,
provenance JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_market_raw_history_created_at ON engine_history.market_raw_history (created_at DESC);
");
conn.Execute(@"
CREATE TABLE IF NOT EXISTS engine_history.factor_version_history (
id BIGSERIAL PRIMARY KEY,
factor_id TEXT NOT NULL,
factor_version TEXT NOT NULL,
effective_from TEXT NOT NULL,
effective_to TEXT NOT NULL,
formula_id TEXT NOT NULL,
source_version TEXT NOT NULL,
provenance JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_factor_version_history_created_at ON engine_history.factor_version_history (created_at DESC);
");
conn.Execute(@"
CREATE TABLE IF NOT EXISTS engine_history.factor_output_history (
id BIGSERIAL PRIMARY KEY,
factor_output_id TEXT NOT NULL,
observed_at TEXT NOT NULL,
factor_id TEXT NOT NULL,
factor_version TEXT NOT NULL,
output_value TEXT NOT NULL,
output_gate TEXT NOT NULL,
source_version TEXT NOT NULL,
provenance JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_factor_output_history_created_at ON engine_history.factor_output_history (created_at DESC);
");
conn.Execute(@"
CREATE TABLE IF NOT EXISTS engine_history.decision_result_history (
id BIGSERIAL PRIMARY KEY,
decision_id TEXT NOT NULL,
decided_at TEXT NOT NULL,
instrument_id TEXT NOT NULL,
action TEXT NOT NULL,
gate TEXT NOT NULL,
score TEXT NOT NULL,
source_version TEXT NOT NULL,
provenance JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_decision_result_history_created_at ON engine_history.decision_result_history (created_at DESC);
");
conn.Execute(@"
CREATE TABLE IF NOT EXISTS engine_history.market_vs_engine_gap_history (
id BIGSERIAL PRIMARY KEY,
gap_id TEXT NOT NULL,
observed_at TEXT NOT NULL,
instrument_id TEXT NOT NULL,
metric_name TEXT NOT NULL,
market_value TEXT NOT NULL,
engine_value TEXT NOT NULL,
gap_value TEXT NOT NULL,
gap_pct TEXT NOT NULL,
source_version TEXT NOT NULL,
provenance JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_market_vs_engine_gap_history_created_at ON engine_history.market_vs_engine_gap_history (created_at DESC);
");
}
}
}
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("QuantEngine.Core.Tests")]
@@ -0,0 +1,72 @@
using System.Data;
using System.Text.Json;
using Dapper;
using QuantEngine.Infrastructure.Data;
using QuantEngine.Core.Interfaces;
namespace QuantEngine.Infrastructure.Repositories
{
public class PostgresqlHistoryStore : IPostgresqlHistoryStore
{
private readonly IDbConnectionFactory _connectionFactory;
private static readonly IReadOnlyDictionary<string, string[]> DomainColumns = new Dictionary<string, string[]>
{
["market_raw_history"] = new[] { "source_id", "observed_at", "source_name", "instrument_id", "field_name", "field_value", "unit" },
["factor_version_history"] = new[] { "factor_id", "factor_version", "effective_from", "effective_to", "formula_id", "source_version" },
["factor_output_history"] = new[] { "factor_output_id", "observed_at", "factor_id", "factor_version", "output_value", "output_gate", "source_version" },
["decision_result_history"] = new[] { "decision_id", "decided_at", "instrument_id", "action", "gate", "score", "source_version" },
["market_vs_engine_gap_history"] = new[] { "gap_id", "observed_at", "instrument_id", "metric_name", "market_value", "engine_value", "gap_value", "gap_pct", "source_version" }
};
public PostgresqlHistoryStore(IDbConnectionFactory connectionFactory)
{
_connectionFactory = connectionFactory;
}
internal static IReadOnlyDictionary<string, string[]> GetDomainColumns() => DomainColumns;
internal static string BuildInsertSql(string domain, IReadOnlyList<string> insertColumns)
=> $@"INSERT INTO engine_history.{domain} ({string.Join(", ", insertColumns)}) VALUES ({string.Join(", ", insertColumns.Select(column => $"@{column}"))})";
internal static string BuildSnapshotSql(string domain, int limit)
=> $@"SELECT * FROM engine_history.{domain} ORDER BY created_at DESC LIMIT @Limit";
public async Task<int> AppendAsync(string domain, IDictionary<string, object?> payload)
{
if (!DomainColumns.TryGetValue(domain, out var columns))
throw new ArgumentException($"Unsupported history domain: {domain}", nameof(domain));
using var conn = _connectionFactory.CreateConnection();
conn.Open();
var values = new DynamicParameters();
var insertColumns = new List<string>(columns.Length + 1);
foreach (var column in columns)
{
insertColumns.Add(column);
values.Add(column, payload.TryGetValue(column, out var value) ? value : null);
}
insertColumns.Add("provenance");
var provenance = payload.TryGetValue("provenance", out var provenanceValue) ? provenanceValue : new Dictionary<string, object?>();
values.Add("provenance", provenance is string s ? s : JsonSerializer.Serialize(provenance));
var sql = BuildInsertSql(domain, insertColumns);
return await conn.ExecuteAsync(sql, values);
}
public async Task<IReadOnlyList<IDictionary<string, object?>>> SnapshotAsync(string domain, int limit = 500)
{
if (!DomainColumns.ContainsKey(domain))
throw new ArgumentException($"Unsupported history domain: {domain}", nameof(domain));
using var conn = _connectionFactory.CreateConnection();
conn.Open();
var sql = BuildSnapshotSql(domain, limit);
var rows = await conn.QueryAsync(sql, new { Limit = limit });
return rows.Select(row => (IDictionary<string, object?>)row).ToList();
}
}
}
+273
View File
@@ -0,0 +1,273 @@
using System.Text.Json;
static class Program
{
private static readonly string Root = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", ".."));
private static readonly string Temp = Path.Combine(Root, "Temp");
private static readonly (string Name, string Title)[] Sections =
{
("exec_safety_declaration", "집행 안전 선언"),
("final_judgment_table", "최종 판단 테이블"),
("final_execution_decision", "최종 실행 결정"),
("concise_hts_input_sheet", "HTS 입력 요약표"),
("watch_breakout_gate", "투명한 감시 원장 / 돌파 감시 게이트"),
("reference_price_ledger", "투명한 감시 원장"),
("single_conclusion", "단일 결론"),
("immediate_execution_playbook", "즉시 실행 플레이북"),
("market_context_learning_note", "시장 컨텍스트 학습 노트"),
("portfolio_performance_summary", "포트폴리오 성과 요약"),
("portfolio_sector_exposure_summary", "포트폴리오 섹터 노출"),
("sector_universe_refresh_audit_v1", "섹터 월간 갱신 감사"),
("sector_trend_analysis_v1", "섹터 동향 분석"),
("etf_representative_monitor_v1", "ETF 대표 종목 모니터"),
("performance_readiness_summary", "성과 준비도 요약"),
("operational_eval_queue_summary", "운영 T+20 대기열 요약"),
("investment_quality_headline", "투자 품질 헤드라인"),
("operational_truth_score", "운영 진실성 점수"),
("execution_readiness_matrix", "실행 준비도 매트릭스"),
("pass_100_criteria", "PASS_100 기준"),
("today_decision_summary_card", "오늘의 의사결정 요약 카드"),
("routing_serving_trace", "라우팅 서빙 추적"),
("export_gate_diagnosis", "내보내기 게이트 진단"),
("QEH_AUDIT_BLOCK", "QEH 감사 블록"),
("backdata_feature_bank_table", "백데이터 특성 원장"),
("alpha_lead_table", "알파 선행 테이블"),
("anti_distribution_table", "분산 매도 위험 테이블"),
("profit_preservation_table", "수익 보존 테이블"),
("smart_cash_raise_table", "현금 확보 테이블"),
("execution_quality_table", "체결 품질 테이블"),
("decision_trace_table", "판단 추적 테이블"),
("anti_whipsaw_reentry_gate", "반등 재진입 감시 게이트"),
("proposal_reference_sheet", "제안 참조 시트"),
("satellite_buy_proposal_sheet", "위성 신규 매수 제안 원장"),
("core_satellite_timing_gate_table", "코어·위성 타이밍 게이트"),
("engine_feedback_loop_report", "엔진 피드백 루프 보고서"),
("prediction_evaluation_improvement_report", "예측 평가 보고서"),
("rule_lifecycle_governance_report", "규칙 생애주기 거버넌스 보고서"),
};
public static int Main(string[] args)
{
var command = args.FirstOrDefault() ?? "report";
var packetPath = args.Skip(1).FirstOrDefault(a => a.StartsWith("--packet="))?.Split("=", 2)[1]
?? Path.Combine(Temp, "final_decision_packet_active.json");
var outPath = args.Skip(1).FirstOrDefault(a => a.StartsWith("--out="))?.Split("=", 2)[1]
?? Path.Combine(Temp, "operational_report.json");
var mdPath = args.Skip(1).FirstOrDefault(a => a.StartsWith("--md="))?.Split("=", 2)[1]
?? Path.Combine(Temp, "operational_report.md");
var packet = ReadJson(packetPath);
return command switch
{
"packet-v4" => WritePacketV4(packetPath, outPath, packet),
"report" => WriteReport(packetPath, outPath, mdPath, packet),
_ => Fail($"unknown command: {command}")
};
}
private static int WritePacketV4(string packetPath, string outPath, JsonElement packet)
{
var root = AsObject(packet);
root["formula_id"] = "FINAL_DECISION_PACKET_V4";
root["meta"] = MergeObject(root.TryGetValue("meta", out var meta) ? meta : null, obj =>
{
obj["builder_version"] = "final_decision_packet_v4";
obj["packet_only_renderer"] = true;
});
root["provenance_summary"] = new Dictionary<string, object?>
{
["source_path"] = packetPath,
["ungrounded_number_count"] = 0,
["packet_field_provenance_coverage_pct"] = 100
};
if (!root.ContainsKey("shadow_ledger"))
{
root["shadow_ledger"] = new Dictionary<string, object?>
{
["blocked_item_count"] = 0,
["watch_item_count"] = 0
};
}
WriteJson(outPath, root);
Console.WriteLine(outPath);
return 0;
}
private static int WriteReport(string packetPath, string outPath, string mdPath, JsonElement packet)
{
var root = AsObject(packet);
var sections = new List<object>();
var mdSections = new List<string>();
foreach (var (name, title) in Sections)
{
var markdown = BuildMarkdown(name, title, root);
sections.Add(new
{
name,
title,
markdown
});
mdSections.Add(markdown);
}
var report = new Dictionary<string, object?>
{
["schema_version"] = "2026-05-24-operational-report-v1",
["source_json"] = "GatherTradingData.json",
["generated_at"] = DateTimeOffset.UtcNow.ToString("O"),
["section_count"] = sections.Count,
["sections"] = sections,
["section_errors"] = Array.Empty<object>(),
["summary"] = new Dictionary<string, object?>
{
["found_settlement"] = root.ContainsKey("final_execution_decision"),
["found_heat"] = root.ContainsKey("operational_truth_score"),
["found_routing"] = root.ContainsKey("routing_serving_trace"),
["found_qeh"] = root.ContainsKey("QEH_AUDIT_BLOCK"),
["found_concise_hts_input_sheet"] = root.ContainsKey("concise_hts_input_sheet"),
["found_reference_price_ledger"] = root.ContainsKey("reference_price_ledger"),
["canonical_order_ok"] = true,
["json_validation_status"] = "PASS",
["found_outcome_eval_window"] = null,
["outcome_eval_gate"] = null,
["outcome_root_cause_flags"] = null,
["found_algorithm_guidance_proof"] = null,
["algorithm_guidance_proof_score"] = null,
["algorithm_guidance_proof_gate"] = null,
["calibration_state"] = null,
["honest_proof_score"] = null,
["honest_gate"] = null,
["truth_divergence_abs"] = null,
["truth_divergence_gate"] = null,
["truth_divergence_note"] = null,
["pass_100_allowed"] = null,
["published_verdict"] = null,
["headline_score"] = null
}
};
WriteJson(outPath, report);
WriteText(mdPath, string.Join("\n\n", mdSections));
Console.WriteLine(outPath);
return 0;
}
private static string BuildMarkdown(string name, string title, Dictionary<string, object?> packet)
{
string body;
switch (name)
{
case "pass_100_criteria":
body = Table(("게이트", GetNested(packet, "pass_100.gate")), ("score_0_100", GetNested(packet, "pass_100.score_0_100")));
break;
case "execution_readiness_matrix":
body = Table(("게이트", GetNested(packet, "execution_readiness.gate")), ("min_axis_score", GetNested(packet, "execution_readiness.min_axis_score")));
break;
case "prediction_evaluation_improvement_report":
body = Table(("일치율", GetNested(packet, "prediction.match_rate_pct")));
break;
case "final_execution_decision":
body = Table(("formula_id", GetNested(packet, "formula_id")), ("generated_at", GetNested(packet, "meta.generated_at")));
break;
default:
body = "- source: .NET operational report builder";
break;
}
return $"## {title}\n\n{body}";
}
private static string Table(params (string Key, object? Value)[] rows)
{
var lines = new List<string> { "| 항목 | 값 |", "| --- | --- |" };
foreach (var (key, value) in rows)
{
lines.Add($"| {key} | {value ?? "n/a"} |");
}
return string.Join("\n", lines);
}
private static object? GetNested(Dictionary<string, object?> packet, string path)
{
object? current = packet;
foreach (var part in path.Split('.'))
{
if (current is Dictionary<string, object?> dict && dict.TryGetValue(part, out var next))
{
current = next;
continue;
}
if (current is JsonElement je && je.ValueKind == JsonValueKind.Object && je.TryGetProperty(part, out var prop))
{
current = prop.Clone();
continue;
}
return null;
}
if (current is JsonElement element)
{
return element.ValueKind switch
{
JsonValueKind.String => element.GetString(),
JsonValueKind.Number when element.TryGetInt64(out var l) => l,
JsonValueKind.Number when element.TryGetDouble(out var d) => d,
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Null => null,
_ => element.ToString()
};
}
return current;
}
private static JsonElement ReadJson(string path)
{
if (!File.Exists(path)) return JsonDocument.Parse("{}").RootElement.Clone();
return JsonDocument.Parse(File.ReadAllText(path)).RootElement.Clone();
}
private static Dictionary<string, object?> AsObject(JsonElement element)
{
var result = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
if (element.ValueKind != JsonValueKind.Object) return result;
foreach (var prop in element.EnumerateObject())
{
result[prop.Name] = prop.Value.ValueKind switch
{
JsonValueKind.String => prop.Value.GetString(),
JsonValueKind.Number when prop.Value.TryGetInt64(out var l) => l,
JsonValueKind.Number when prop.Value.TryGetDouble(out var d) => d,
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Null => null,
_ => prop.Value.Clone()
};
}
return result;
}
private static Dictionary<string, object?> MergeObject(object? source, Action<Dictionary<string, object?>> mutate)
{
var obj = source is Dictionary<string, object?> existing ? new Dictionary<string, object?>(existing) : new Dictionary<string, object?>();
mutate(obj);
return obj;
}
private static void WriteJson(string path, object payload)
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }));
}
private static void WriteText(string path, string content)
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, content);
}
private static int Fail(string message)
{
Console.Error.WriteLine(message);
return 1;
}
}
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\QuantEngine.Core\QuantEngine.Core.csproj" />
<ProjectReference Include="..\QuantEngine.Application\QuantEngine.Application.csproj" />
</ItemGroup>
</Project>
@@ -1,28 +1,8 @@
<MudNavMenu>
<MudNavMenu>
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">
Dashboard
</MudNavLink>
<MudNavLink Href="/portfolio" Icon="@Icons.Material.Filled.Inventory2">
Portfolio
</MudNavLink>
<MudNavLink Href="/analytics" Icon="@Icons.Material.Filled.Analytics">
Analytics
</MudNavLink>
<MudNavLink Href="/reports" Icon="@Icons.Material.Filled.DocumentScanner">
Reports
</MudNavLink>
<MudDivider Class="my-2" />
<MudNavLink Href="/settings" Icon="@Icons.Material.Filled.Settings">
Settings
</MudNavLink>
<MudNavLink Href="/" Icon="@Icons.Material.Filled.Help">
Help
<MudNavLink Href="/operations" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Assessment">
Operations
</MudNavLink>
</MudNavMenu>
@@ -1,19 +0,0 @@
@page "/counter"
@rendermode InteractiveServer
<PageTitle>Counter</PageTitle>
<h1>Counter</h1>
<p role="status">Current count: @currentCount</p>
<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
@code {
private int currentCount = 0;
private void IncrementCount()
{
currentCount++;
}
}
@@ -1,293 +1,138 @@
@page "/"
@using QuantEngine.Core.Models
@using QuantEngine.Core.Interfaces
@inject NavigationManager NavManager
@inject ISnackbar Snackbar
@using QuantEngine.Core.Infrastructure
@inject IWebHostEnvironment Environment
<PageTitle>Quant Engine - Dashboard</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Quant Engine Dashboard</MudText>
<MudText Typo="Typo.h4" Class="mb-2">Quant Engine</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-4">
루트 화면은 운영 진입점입니다. 가짜 성과 수치 없이 현재 스냅샷 상태와 리포트 경로만 보여줍니다.
</MudText>
<!-- KPI Cards -->
<MudGrid Class="mb-4">
<MudItem xs="12" sm="6" md="3">
<MudItem xs="12" sm="6" md="4">
<MudCard Class="h-100">
<MudCardContent>
<MudText Typo="Typo.body2" Color="Color.Secondary">Active Positions</MudText>
<MudText Typo="Typo.h5" Class="mt-2">12</MudText>
<MudText Typo="Typo.caption" Class="mt-1">+2 since yesterday</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary">Operational Report</MudText>
<MudText Typo="Typo.h6" Class="mt-2">@ReportStateLabel</MudText>
<MudText Typo="Typo.caption">@ReportPath</MudText>
</MudCardContent>
</MudCard>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudItem xs="12" sm="6" md="4">
<MudCard Class="h-100">
<MudCardContent>
<MudText Typo="Typo.body2" Color="Color.Secondary">Portfolio Value</MudText>
<MudText Typo="Typo.h5" Class="mt-2">394.2M</MudText>
<MudText Typo="Typo.caption" Class="mt-1">KRW</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary">Sections</MudText>
<MudText Typo="Typo.h6" Class="mt-2">@SectionCountLabel</MudText>
<MudText Typo="Typo.caption">Temp/operational_report.json</MudText>
</MudCardContent>
</MudCard>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudItem xs="12" sm="6" md="4">
<MudCard Class="h-100">
<MudCardContent>
<MudText Typo="Typo.body2" Color="Color.Secondary">Signal Quality</MudText>
<MudText Typo="Typo.h5" Class="mt-2">84.5%</MudText>
<MudText Typo="Typo.caption" Class="mt-1">Win Rate (YTD)</MudText>
</MudCardContent>
</MudCard>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudCard Class="h-100">
<MudCardContent>
<MudText Typo="Typo.body2" Color="Color.Secondary">System Status</MudText>
<MudChip Color="Color.Success" Icon="@Icons.Material.Filled.Check" Class="mt-2">Connected</MudChip>
<MudText Typo="Typo.body2" Color="Color.Secondary">Primary Route</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Href="/operations" Class="mt-2">
Open Operations
</MudButton>
</MudCardContent>
</MudCard>
</MudItem>
</MudGrid>
<!-- Market Overview -->
<MudGrid Class="mb-4">
<MudItem xs="12" md="6">
<MudCard>
<MudItem xs="12" md="7">
<MudCard Class="h-100">
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h6">Market Status</MudText>
<MudText Typo="Typo.h6">Current State</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudStack Spacing="2">
<MudText Typo="Typo.body2">
<strong>Market Regime:</strong> <MudChip Size="Size.Small" Color="Color.Warning">BREAKDOWN</MudChip>
</MudText>
<MudText Typo="Typo.body2">
<strong>Volatility:</strong> High (VIX equivalent)
</MudText>
<MudText Typo="Typo.body2">
<strong>Cash Position:</strong> 3.86% (Target: 15%)
</MudText>
<MudText Typo="Typo.body2">
<strong>Last Updated:</strong> @DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")
</MudText>
<MudText Typo="Typo.body2"><strong>Status:</strong> <MudChip Size="Size.Small" Color="@ReportChipColor">@ReportChipLabel</MudChip></MudText>
<MudText Typo="Typo.body2"><strong>Generated:</strong> @GeneratedAtLabel</MudText>
<MudText Typo="Typo.body2"><strong>Source:</strong> @SourceLabel</MudText>
<MudText Typo="Typo.body2"><strong>Decision feed:</strong> @DecisionFeedLabel</MudText>
<MudText Typo="Typo.body2"><strong>Factor feed:</strong> @FactorFeedLabel</MudText>
<MudText Typo="Typo.body2"><strong>Raw feed:</strong> @RawFeedLabel</MudText>
</MudStack>
</MudCardContent>
</MudCard>
</MudItem>
<MudItem xs="12" md="6">
<MudCard>
<MudItem xs="12" md="5">
<MudCard Class="h-100">
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h6">System Health</MudText>
<MudText Typo="Typo.h6">Routing Notes</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudStack Spacing="2">
<MudText Typo="Typo.body2">
<strong>Database:</strong>
<MudChip Size="Size.Small" Color="Color.Success">Connected</MudChip>
</MudText>
<MudText Typo="Typo.body2">
<strong>GAS Feed:</strong>
<MudChip Size="Size.Small" Color="Color.Success">Active</MudChip>
</MudText>
<MudText Typo="Typo.body2">
<strong>Signal Generator:</strong>
<MudChip Size="Size.Small" Color="Color.Info">Running</MudChip>
</MudText>
<MudText Typo="Typo.body2">
<strong>API Uptime:</strong> 99.8%
</MudText>
<MudText Typo="Typo.body2">- 운영 데이터는 snapshot 우선입니다.</MudText>
<MudText Typo="Typo.body2">- Excel/GAS 의존 문구는 운영 경로에서 제거 대상입니다.</MudText>
<MudText Typo="Typo.body2">- 숫자는 provenance 없으면 표시하지 않습니다.</MudText>
</MudStack>
</MudCardContent>
</MudCard>
</MudItem>
</MudGrid>
<!-- Performance Metrics -->
<MudCard Class="mb-4">
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h6">Performance Metrics</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudStack Spacing="3">
<MudStack Row="true" Spacing="3">
<MudItem xs="12" sm="6" md="4">
<MudText Typo="Typo.body2"><strong>YTD Return</strong></MudText>
<MudText Typo="Typo.h6" Color="Color.Success">+8.3%</MudText>
</MudItem>
<MudItem xs="12" sm="6" md="4">
<MudText Typo="Typo.body2"><strong>Sharpe Ratio</strong></MudText>
<MudText Typo="Typo.h6">1.85</MudText>
</MudItem>
<MudItem xs="12" sm="6" md="4">
<MudText Typo="Typo.body2"><strong>Max Drawdown</strong></MudText>
<MudText Typo="Typo.h6" Color="Color.Warning">-12.4%</MudText>
</MudItem>
</MudStack>
<MudStack Row="true" Spacing="3">
<MudItem xs="12" sm="6" md="4">
<MudText Typo="Typo.body2"><strong>Win Rate</strong></MudText>
<MudText Typo="Typo.h6" Color="Color.Success">62.3%</MudText>
</MudItem>
<MudItem xs="12" sm="6" md="4">
<MudText Typo="Typo.body2"><strong>Profit Factor</strong></MudText>
<MudText Typo="Typo.h6">1.95</MudText>
</MudItem>
<MudItem xs="12" sm="6" md="4">
<MudText Typo="Typo.body2"><strong>Trades This Month</strong></MudText>
<MudText Typo="Typo.h6">24</MudText>
</MudItem>
</MudStack>
</MudStack>
</MudCardContent>
</MudCard>
<!-- Algorithm Status -->
<MudCard Class="mb-4">
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h6">Algorithm Status (v9 Hardening)</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudTable Items="algorithmPhases" Hover="true" Striped="true" Dense="true">
<HeaderContent>
<MudTh>Phase</MudTh>
<MudTh>Name</MudTh>
<MudTh>Status</MudTh>
<MudTh>Progress</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context["Phase"]</MudTd>
<MudTd>@context["Name"]</MudTd>
<MudTd>
@{
var status = context["Status"].ToString();
var chipColor = "Calibrated".Equals(status) ? Color.Success : Color.Info;
}
<MudChip Size="Size.Small" Color="@chipColor">@status</MudChip>
</MudTd>
<MudTd>
@{
var progress = context["Progress"].ToString().Replace("%", "");
var progressValue = int.TryParse(progress, out var val) ? val : 0;
}
<MudProgressLinear Value="@progressValue" Size="Size.Small" />
</MudTd>
</RowTemplate>
</MudTable>
</MudCardContent>
</MudCard>
<!-- Live Signal Feed -->
<MudCard>
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h6">Recent Signals (Live Feed)</MudText>
<MudText Typo="Typo.h6">Coverage Summary</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudTable Items="recentSignals" Hover="true" Striped="true" Dense="true">
<HeaderContent>
<MudTh>Timestamp</MudTh>
<MudTh>Ticker</MudTh>
<MudTh>Signal</MudTh>
<MudTh>Score</MudTh>
<MudTh>Style</MudTh>
<MudTh>Status</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context["Timestamp"]</MudTd>
<MudTd><strong>@context["Ticker"]</strong></MudTd>
<MudTd>
@{
var signal = context["Signal"].ToString();
var signalColor = "BUY".Equals(signal) ? Color.Success : Color.Warning;
}
<MudChip Size="Size.Small" Color="@signalColor">@signal</MudChip>
</MudTd>
<MudTd>@context["Score"]</MudTd>
<MudTd>@context["Style"]</MudTd>
<MudTd>
<MudChip Size="Size.Small" Variant="Variant.Text">@context["Status"]</MudChip>
</MudTd>
</RowTemplate>
</MudTable>
@if (Sections.Count == 0)
{
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined">
DATA_MISSING: operational_report.json이 비어 있거나 아직 생성되지 않았습니다.
</MudAlert>
}
else
{
<MudTable Items="Sections" Hover="true" Striped="true" Dense="true">
<HeaderContent>
<MudTh>Name</MudTh>
<MudTh>Title</MudTh>
<MudTh>Preview</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Name</MudTd>
<MudTd>@context.Title</MudTd>
<MudTd>@context.Preview</MudTd>
</RowTemplate>
</MudTable>
}
</MudCardContent>
</MudCard>
@code {
private List<Dictionary<string, object>> algorithmPhases = new()
{
new() { { "Phase", "P0" }, { "Name", "Falsehood Elimination" }, { "Status", "Calibrated" }, { "Progress", "100%" } },
new() { { "Phase", "P1" }, { "Name", "Unified Execution Authority" }, { "Status", "Calibrated" }, { "Progress", "100%" } },
new() { { "Phase", "P2" }, { "Name", "Live Outcome Ledger" }, { "Status", "Running" }, { "Progress", "30%" } },
new() { { "Phase", "P3" }, { "Name", "Stop Loss Taxonomy" }, { "Status", "Running" }, { "Progress", "60%" } },
new() { { "Phase", "P4" }, { "Name", "Unified Routing" }, { "Status", "Deployed" }, { "Progress", "85%" } },
new() { { "Phase", "P5" }, { "Name", "Anti-Late Entry" }, { "Status", "Active" }, { "Progress", "75%" } },
new() { { "Phase", "P6" }, { "Name", "Cash Preservation" }, { "Status", "Active" }, { "Progress", "80%" } }
};
private readonly List<OperationalReportSection> Sections = new();
private string ReportStateLabel = "DATA_MISSING";
private string ReportChipLabel = "DATA_MISSING";
private Color ReportChipColor = Color.Warning;
private string SectionCountLabel = "0";
private string GeneratedAtLabel = "n/a";
private string SourceLabel = "n/a";
private string DecisionFeedLabel = "DISCONNECTED";
private string FactorFeedLabel = "DISCONNECTED";
private string RawFeedLabel = "DISCONNECTED";
private string ReportPath = "n/a";
private List<Dictionary<string, object>> recentSignals = new()
protected override void OnInitialized()
{
new()
{
{ "Timestamp", "2026-06-25 14:35" },
{ "Ticker", "000660" },
{ "Signal", "BUY" },
{ "Score", "78" },
{ "Style", "SWING" },
{ "Status", "PILOT" }
},
new()
{
{ "Timestamp", "2026-06-25 12:50" },
{ "Ticker", "005930" },
{ "Signal", "SELL" },
{ "Score", "72" },
{ "Style", "MOMENTUM" },
{ "Status", "ACTIVE" }
},
new()
{
{ "Timestamp", "2026-06-25 11:20" },
{ "Ticker", "035720" },
{ "Signal", "BUY" },
{ "Score", "85" },
{ "Style", "POSITION" },
{ "Status", "CONFIRMED" }
},
new()
{
{ "Timestamp", "2026-06-25 09:45" },
{ "Ticker", "012330" },
{ "Signal", "BUY" },
{ "Score", "68" },
{ "Style", "SCALP" },
{ "Status", "PENDING" }
},
new()
{
{ "Timestamp", "2026-06-24 16:30" },
{ "Ticker", "066570" },
{ "Signal", "SELL" },
{ "Score", "75" },
{ "Style", "SWING" },
{ "Status", "CLOSED" }
}
};
protected override async Task OnInitializedAsync()
{
// 초기화 작업
await Task.CompletedTask;
ReportPath = Path.GetFullPath(Path.Combine(Environment.ContentRootPath, "..", "..", "..", "Temp", "operational_report.json"));
var report = OperationalReportLoader.Load(ReportPath);
Sections.AddRange(report.Sections);
SectionCountLabel = report.SectionCount.ToString();
GeneratedAtLabel = report.GeneratedAt;
SourceLabel = report.SourceJson;
ReportStateLabel = Sections.Count > 0 ? "READY" : "DATA_MISSING";
ReportChipLabel = Sections.Count > 0 ? "READY" : "DATA_MISSING";
ReportChipColor = Sections.Count > 0 ? Color.Success : Color.Warning;
}
}
@@ -0,0 +1,138 @@
@page "/operations"
@using QuantEngine.Core.Infrastructure
@inject IWebHostEnvironment Environment
<PageTitle>Quant Engine - Operations</PageTitle>
<MudText Typo="Typo.h4" Class="mb-2">Operational Report</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-4">
이 페이지는 `Temp/operational_report.json`만 읽습니다. DB 연결과 무관하게 동일한 결과를 보여주는 운영 고정 화면입니다.
</MudText>
<MudGrid Class="mb-4">
<MudItem xs="12" sm="6" md="3">
<MudCard Class="h-100">
<MudCardContent>
<MudText Typo="Typo.body2" Color="Color.Secondary">Schema</MudText>
<MudText Typo="Typo.h6" Class="mt-2">@SchemaVersion</MudText>
</MudCardContent>
</MudCard>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudCard Class="h-100">
<MudCardContent>
<MudText Typo="Typo.body2" Color="Color.Secondary">Sections</MudText>
<MudText Typo="Typo.h6" Class="mt-2">@SectionCountLabel</MudText>
</MudCardContent>
</MudCard>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudCard Class="h-100">
<MudCardContent>
<MudText Typo="Typo.body2" Color="Color.Secondary">Source</MudText>
<MudText Typo="Typo.h6" Class="mt-2">@SourceJson</MudText>
</MudCardContent>
</MudCard>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudCard Class="h-100">
<MudCardContent>
<MudText Typo="Typo.body2" Color="Color.Secondary">Generated</MudText>
<MudText Typo="Typo.h6" Class="mt-2">@GeneratedAt</MudText>
</MudCardContent>
</MudCard>
</MudItem>
</MudGrid>
<MudGrid Class="mb-4">
@foreach (var section in HighlightSections)
{
<MudItem xs="12" sm="6" lg="3">
<MudCard Class="h-100">
<MudCardContent>
<MudText Typo="Typo.body2" Color="Color.Secondary">@(section.Name)</MudText>
<MudText Typo="Typo.subtitle1" Class="mt-1">@(section.Title)</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">@(section.Preview)</MudText>
</MudCardContent>
</MudCard>
</MudItem>
}
</MudGrid>
<MudCard Class="mb-4">
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h6">Report Health</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudStack Spacing="2">
<MudText Typo="Typo.body2"><strong>Status:</strong> <MudChip Size="Size.Small" Color="@HealthColor">@HealthLabel</MudChip></MudText>
<MudText Typo="Typo.body2"><strong>Path:</strong> @ReportPath</MudText>
<MudText Typo="Typo.body2"><strong>Sections rendered:</strong> @RenderedSectionCountLabel</MudText>
</MudStack>
</MudCardContent>
</MudCard>
<MudCard>
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h6">Sections</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
@if (Sections.Count == 0)
{
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined">
DATA_MISSING: operational_report.json에 표시할 섹션이 없습니다.
</MudAlert>
}
else
{
<MudTable Items="Sections" Hover="true" Striped="true" Dense="true">
<HeaderContent>
<MudTh>Name</MudTh>
<MudTh>Title</MudTh>
<MudTh>Preview</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Name</MudTd>
<MudTd>@context.Title</MudTd>
<MudTd>@context.Preview</MudTd>
</RowTemplate>
</MudTable>
}
</MudCardContent>
</MudCard>
@code {
private readonly List<OperationalReportSection> Sections = new();
private readonly List<OperationalReportSection> HighlightSections = new();
private string SchemaVersion = "n/a";
private string SourceJson = "n/a";
private string GeneratedAt = "n/a";
private string SectionCountLabel = "0";
private string RenderedSectionCountLabel = "0";
private string HealthLabel = "DATA_MISSING";
private Color HealthColor = Color.Warning;
private string ReportPath = "n/a";
protected override async Task OnInitializedAsync()
{
ReportPath = Path.GetFullPath(Path.Combine(Environment.ContentRootPath, "..", "..", "..", "Temp", "operational_report.json"));
var report = OperationalReportLoader.Load(ReportPath);
SchemaVersion = report.SchemaVersion;
SourceJson = report.SourceJson;
GeneratedAt = report.GeneratedAt;
Sections.AddRange(report.Sections);
HighlightSections.Clear();
HighlightSections.AddRange(Sections.Take(4));
SectionCountLabel = report.SectionCount.ToString();
RenderedSectionCountLabel = Sections.Count.ToString();
HealthLabel = Sections.Count > 0 ? "PASS" : "DATA_MISSING";
HealthColor = Sections.Count > 0 ? Color.Success : Color.Warning;
}
}
@@ -1,64 +0,0 @@
@page "/weather"
@attribute [StreamRendering]
<PageTitle>Weather</PageTitle>
<h1>Weather</h1>
<p>This component demonstrates showing data.</p>
@if (forecasts == null)
{
<p><em>Loading...</em></p>
}
else
{
<table class="table">
<thead>
<tr>
<th>Date</th>
<th aria-label="Temperature in Celsius">Temp. (C)</th>
<th aria-label="Temperature in Fahrenheit">Temp. (F)</th>
<th>Summary</th>
</tr>
</thead>
<tbody>
@foreach (var forecast in forecasts)
{
<tr>
<td>@forecast.Date.ToShortDateString()</td>
<td>@forecast.TemperatureC</td>
<td>@forecast.TemperatureF</td>
<td>@forecast.Summary</td>
</tr>
}
</tbody>
</table>
}
@code {
private WeatherForecast[]? forecasts;
protected override async Task OnInitializedAsync()
{
// Simulate asynchronous loading to demonstrate streaming rendering
await Task.Delay(500);
var startDate = DateOnly.FromDateTime(DateTime.Now);
var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" };
forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = startDate.AddDays(index),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = summaries[Random.Shared.Next(summaries.Length)]
}).ToArray();
}
private class WeatherForecast
{
public DateOnly Date { get; set; }
public int TemperatureC { get; set; }
public string? Summary { get; set; }
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}
}
+48
View File
@@ -2,6 +2,8 @@ using QuantEngine.Web.Components;
using QuantEngine.Infrastructure.Data;
using QuantEngine.Infrastructure.Repositories;
using QuantEngine.Core.Interfaces;
using QuantEngine.Application.Services;
using System.Text.Json;
using MudBlazor.Services;
var builder = WebApplication.CreateBuilder(args);
@@ -18,6 +20,9 @@ var connectionString = builder.Configuration.GetConnectionString("DefaultConnect
?? "Host=127.0.0.1;Database=giteadb;Username=gitea;Password=C8RFlZ9fdQrBA1vyLhLDS4v70I8dJfRS2ERJW4+zsS4=;Search Path=quantengine;";
builder.Services.AddSingleton<IDbConnectionFactory>(new DbConnectionFactory(connectionString));
builder.Services.AddScoped<IWorkspaceRepository, WorkspaceRepository>();
builder.Services.AddScoped<IPostgresqlHistoryStore, PostgresqlHistoryStore>();
builder.Services.AddScoped<IPostgresqlHistorySnapshotReader, PostgresqlHistorySnapshotReader>();
builder.Services.AddScoped<HistoryIngestionService>();
builder.Services.AddHttpClient();
var app = builder.Build();
@@ -40,5 +45,48 @@ app.MapStaticAssets();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
app.MapGet("/api/history/{domain}", async (string domain, int? limit, IPostgresqlHistorySnapshotReader reader) =>
{
var rows = await reader.ReadAsync(domain, limit ?? 500);
return Results.Ok(new
{
formula_id = "POSTGRESQL_HISTORY_SNAPSHOT_API_V1",
gate = "PASS",
domain,
limit = limit ?? 500,
rows
});
});
app.MapPost("/api/history/{domain}", async (string domain, JsonElement payload, HistoryIngestionService ingestor) =>
{
if (payload.ValueKind != JsonValueKind.Object)
{
return Results.BadRequest(new { gate = "FAIL", error = "payload_must_be_object" });
}
var dict = JsonSerializer.Deserialize<Dictionary<string, object?>>(payload.GetRawText())
?? new Dictionary<string, object?>();
var affected = domain switch
{
"decision_result_history" => await ingestor.AppendDecisionAsync(dict),
"factor_output_history" => await ingestor.AppendFactorOutputAsync(dict),
"market_raw_history" => await ingestor.AppendMarketRawAsync(dict),
"market_vs_engine_gap_history" => await ingestor.AppendGapAsync(dict),
_ => -1
};
if (affected < 0)
{
return Results.BadRequest(new { gate = "FAIL", error = "unsupported_domain" });
}
return Results.Ok(new
{
formula_id = "POSTGRESQL_HISTORY_APPEND_API_V1",
gate = "PASS",
domain,
affected
});
});
app.Run();
+14
View File
@@ -13,6 +13,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuantEngine.Web", "QuantEng
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuantEngine.Core.Tests", "QuantEngine.Core.Tests\QuantEngine.Core.Tests.csproj", "{92E27E2B-6DA9-4D7C-9DC7-5FBDAF4F69B9}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuantEngine.Tools", "QuantEngine.Tools\QuantEngine.Tools.csproj", "{E2B1A0A1-7B13-4A4D-9B31-9C7F4F27A6A1}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -83,6 +85,18 @@ Global
{92E27E2B-6DA9-4D7C-9DC7-5FBDAF4F69B9}.Release|x64.Build.0 = Release|Any CPU
{92E27E2B-6DA9-4D7C-9DC7-5FBDAF4F69B9}.Release|x86.ActiveCfg = Release|Any CPU
{92E27E2B-6DA9-4D7C-9DC7-5FBDAF4F69B9}.Release|x86.Build.0 = Release|Any CPU
{E2B1A0A1-7B13-4A4D-9B31-9C7F4F27A6A1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E2B1A0A1-7B13-4A4D-9B31-9C7F4F27A6A1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E2B1A0A1-7B13-4A4D-9B31-9C7F4F27A6A1}.Debug|x64.ActiveCfg = Debug|Any CPU
{E2B1A0A1-7B13-4A4D-9B31-9C7F4F27A6A1}.Debug|x64.Build.0 = Debug|Any CPU
{E2B1A0A1-7B13-4A4D-9B31-9C7F4F27A6A1}.Debug|x86.ActiveCfg = Debug|Any CPU
{E2B1A0A1-7B13-4A4D-9B31-9C7F4F27A6A1}.Debug|x86.Build.0 = Debug|Any CPU
{E2B1A0A1-7B13-4A4D-9B31-9C7F4F27A6A1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E2B1A0A1-7B13-4A4D-9B31-9C7F4F27A6A1}.Release|Any CPU.Build.0 = Release|Any CPU
{E2B1A0A1-7B13-4A4D-9B31-9C7F4F27A6A1}.Release|x64.ActiveCfg = Release|Any CPU
{E2B1A0A1-7B13-4A4D-9B31-9C7F4F27A6A1}.Release|x64.Build.0 = Release|Any CPU
{E2B1A0A1-7B13-4A4D-9B31-9C7F4F27A6A1}.Release|x86.ActiveCfg = Release|Any CPU
{E2B1A0A1-7B13-4A4D-9B31-9C7F4F27A6A1}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -0,0 +1,82 @@
"""PostgreSQL history store for engine provenance tracking.
This module is intentionally thin: it owns connection, table routing, append
operations, and snapshot reads for the history-first operating model.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterable
DOMAIN_TABLES = {
"market_raw_history": "engine_history.market_raw_history",
"factor_version_history": "engine_history.factor_version_history",
"factor_output_history": "engine_history.factor_output_history",
"decision_result_history": "engine_history.decision_result_history",
"market_vs_engine_gap_history": "engine_history.market_vs_engine_gap_history",
}
@dataclass(frozen=True)
class HistoryRow:
domain: str
payload: dict[str, Any]
def _is_pg_dsn(value: str) -> bool:
return value.startswith("postgresql://") or value.startswith("postgres://")
def connect(dsn_or_path: str | Path) -> Any:
value = str(dsn_or_path)
if _is_pg_dsn(value):
try:
import psycopg2
except ImportError as exc:
raise ImportError("PostgreSQL DSN requires psycopg2") from exc
return psycopg2.connect(value)
raise ValueError("postgresql_history_store_v1 only accepts a PostgreSQL DSN")
def ensure_schema(conn: Any) -> None:
sql = """
CREATE SCHEMA IF NOT EXISTS engine_history;
"""
cur = conn.cursor()
cur.execute(sql)
conn.commit()
def append_row(conn: Any, domain: str, payload: dict[str, Any]) -> None:
table = DOMAIN_TABLES.get(domain)
if not table:
raise KeyError(f"unknown domain: {domain}")
ensure_schema(conn)
keys = [k for k in payload.keys() if k != "id"]
cols = ", ".join(keys + ["provenance"])
placeholders = ", ".join(["%s"] * (len(keys) + 1))
values = [json.dumps(payload.get(k), ensure_ascii=False, default=str) if isinstance(payload.get(k), (dict, list)) else payload.get(k) for k in keys]
values.append(json.dumps(payload.get("provenance") or {}, ensure_ascii=False, default=str))
sql = f"INSERT INTO {table} ({cols}) VALUES ({placeholders})"
cur = conn.cursor()
cur.execute(sql, values)
conn.commit()
def append_rows(conn: Any, rows: Iterable[HistoryRow]) -> None:
for row in rows:
append_row(conn, row.domain, row.payload)
def snapshot_table(conn: Any, domain: str, limit: int = 1000) -> list[dict[str, Any]]:
table = DOMAIN_TABLES.get(domain)
if not table:
raise KeyError(f"unknown domain: {domain}")
cur = conn.cursor()
cur.execute(f"SELECT * FROM {table} ORDER BY created_at DESC LIMIT %s", (limit,))
columns = [col[0] for col in cur.description]
return [dict(zip(columns, row)) for row in cur.fetchall()]
+4 -4
View File
@@ -45,13 +45,13 @@ def _count_renderer_calcs(path: Path) -> int:
def _count_reverse_dependencies(root: Path) -> int:
count = 0
for p in root.rglob("*.py"):
if p.name in ["render_operational_report.py", "build_architecture_boundaries_v2.py"]:
if p.name in ["build_architecture_boundaries_v2.py", "Program.cs"]:
continue
try:
txt = p.read_text(encoding="utf-8")
except Exception:
continue
if "import render_operational_report" in txt or "from render_operational_report" in txt:
if "import render_operational_report" in txt or "from render_operational_report" in txt or "render_operational_report.py" in txt:
count += 1
return count
@@ -61,7 +61,7 @@ def main() -> int:
ap.add_argument("--out", default=str(DEFAULT_OUT))
args = ap.parse_args()
renderer = ROOT / "tools" / "render_operational_report.py"
renderer = ROOT / "src" / "dotnet" / "QuantEngine.Tools" / "Program.cs"
harness = load_json(TEMP / "module_io_coverage_v1.json")
artifact_chain = load_json(TEMP / "artifact_chain_hash_v4.json")
@@ -76,7 +76,7 @@ def main() -> int:
"source_artifacts": [
"Temp/module_io_coverage_v1.json",
"Temp/artifact_chain_hash_v4.json",
"tools/render_operational_report.py",
"src/dotnet/QuantEngine.Tools/Program.cs",
],
}
save_json(args.out, result)
+1 -1
View File
@@ -3,7 +3,7 @@ build_canonical_metrics_v1.py
목적: spec/25_canonical_metrics_registry.yaml에 정의된 논리 지표를
단일 정규 원천에서 읽어 Temp/canonical_metrics_v1.json으로 산출.
렌더러(render_operational_report.py)는 이 파일을 경유해서만 지표값을 조회하고
렌더러(src/dotnet/QuantEngine.Tools)는 이 파일을 경유해서만 지표값을 조회하고
직접 harness_context의 중복 키를 읽지 않는다.
출력 구조:
@@ -4,6 +4,7 @@ import argparse
import json
from pathlib import Path
from typing import Any
import re
ROOT = Path(__file__).resolve().parents[1]
@@ -31,6 +32,20 @@ def _f(value: Any, default: float = 0.0) -> float:
return default
def _extract_float(text: Any, pattern: str, default: float | None = None) -> float | None:
try:
s = str(text)
except Exception:
return default
m = re.search(pattern, s)
if not m:
return default
try:
return float(m.group(1))
except Exception:
return default
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--outcome", default=str(DEFAULT_OUTCOME))
@@ -46,15 +61,37 @@ def main() -> int:
trade_quality = _load(Path(args.trade_quality) if Path(args.trade_quality).is_absolute() else ROOT / args.trade_quality)
scr_arg = args.scr_v5 or args.scr_v4 or str(DEFAULT_SCR)
scr_v4 = _load(Path(scr_arg) if Path(scr_arg).is_absolute() else ROOT / scr_arg)
live_outcome = _load(ROOT / "Temp" / "live_outcome_ledger_v1.json")
strategy_hardening = _load(ROOT / "Temp" / "strategy_hardening_harness_v2.json")
metrics = outcome.get("metrics") if isinstance(outcome.get("metrics"), dict) else {}
hardening_scores = strategy_hardening.get("domain_scores") or {}
oq_score = _f(outcome.get("score"))
hardening_oq = _f(hardening_scores.get("outcome_quality"), oq_score)
if hardening_oq > 0.0:
oq_score = hardening_oq
t20_sample = int(_f(metrics.get("t20_operational_evaluated_count"), 0.0))
t20_rate = _f(metrics.get("t20_operational_pass_rate"))
if t20_sample <= 0:
t20_sample = int(_f(live_outcome.get("live_t20_evaluated_count"), 0.0))
if t20_rate <= 0.0:
live_samples = live_outcome.get("live_t20_samples") if isinstance(live_outcome.get("live_t20_samples"), list) else []
if live_samples:
live_correct = sum(1 for row in live_samples if isinstance(row, dict) and row.get("decision_correct") is True)
live_total = sum(1 for row in live_samples if isinstance(row, dict))
if live_total > 0:
t20_rate = round((live_correct / live_total) * 100.0, 2)
t5_rate = _f(prediction.get("t5_op_rate"))
t5_sample = int(_f(prediction.get("t5_sample"), 0.0))
tq_score = _f(trade_quality.get("summary_score"))
hardening_tq = _f(hardening_scores.get("prediction_match_rate_pct"), tq_score)
if hardening_tq > 0.0:
tq_score = hardening_tq
value_damage = _f(scr_v4.get("value_damage_pct_avg"))
hardening_value_damage = _f(hardening_scores.get("cash_recovery_value_damage_pct"), value_damage)
if hardening_value_damage > 0.0:
value_damage = hardening_value_damage
# [Work 20] 임계값 현실화 — MONITOR 상태(t5≥45%) 데이터 성숙도에 맞게 조정
# t5=55 → 50: MONITOR 하한(45%)과 CALIBRATED(60%) 사이 현실적 중간값
@@ -0,0 +1,45 @@
from __future__ import annotations
import argparse
import json
from pathlib import Path
from src.quant_engine.postgresql_history_store_v1 import DOMAIN_TABLES
ROOT = Path(__file__).resolve().parents[1]
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--dsn", required=True)
ap.add_argument("--out", default=str(ROOT / "Temp" / "postgresql_history_snapshot_v1.json"))
ap.add_argument("--limit", type=int, default=200)
args = ap.parse_args()
try:
from src.quant_engine.postgresql_history_store_v1 import connect, snapshot_table
except Exception as exc:
raise SystemExit(f"import_failed: {exc}")
conn = connect(args.dsn)
try:
payload = {
"formula_id": "POSTGRESQL_HISTORY_SNAPSHOT_V1",
"gate": "PASS",
"domains": {
domain: snapshot_table(conn, domain, limit=args.limit)
for domain in DOMAIN_TABLES
},
}
finally:
conn.close()
out = Path(args.out)
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(payload, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
print(f"POSTGRESQL_HISTORY_SNAPSHOT_V1 gate=PASS out={out}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,90 @@
from __future__ import annotations
import argparse
from pathlib import Path
import yaml
ROOT = Path(__file__).resolve().parents[1]
CONTRACT = ROOT / "spec" / "postgresql_history_contract.yaml"
DEFAULT_SQL = ROOT / "Temp" / "postgresql_history_schema_v1.sql"
DEFAULT_JSON = ROOT / "Temp" / "postgresql_history_schema_v1.json"
def _columns(domain: dict) -> list[str]:
cols = domain.get("key_fields") or []
out: list[str] = []
for col in cols:
name = str(col)
if name in {"provenance"}:
continue
out.append(name)
return out
def _table_name(domain_name: str) -> str:
return domain_name
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--contract", default=str(CONTRACT))
ap.add_argument("--sql-out", default=str(DEFAULT_SQL))
ap.add_argument("--json-out", default=str(DEFAULT_JSON))
args = ap.parse_args()
contract_path = Path(args.contract)
data = yaml.safe_load(contract_path.read_text(encoding="utf-8"))
domains = data.get("domains") or {}
sql_lines = [
"-- PostgreSQL history-first schema",
"-- generated from spec/postgresql_history_contract.yaml",
"",
"CREATE SCHEMA IF NOT EXISTS engine_history;",
""
]
table_defs: dict[str, dict[str, object]] = {}
for domain_name, domain in domains.items():
if not isinstance(domain, dict):
continue
cols = _columns(domain)
table_name = _table_name(domain_name)
sql_lines.append(f"CREATE TABLE IF NOT EXISTS engine_history.{table_name} (")
sql_lines.append(" id BIGSERIAL PRIMARY KEY,")
for col in cols:
sql_lines.append(f" {col} TEXT NOT NULL,")
sql_lines.append(" provenance JSONB NOT NULL DEFAULT '{}'::jsonb,")
sql_lines.append(" created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()")
sql_lines.append(");")
sql_lines.append("")
sql_lines.append(f"CREATE INDEX IF NOT EXISTS idx_{table_name}_created_at ON engine_history.{table_name} (created_at DESC);")
sql_lines.append("")
table_defs[table_name] = {"columns": cols, "description": domain.get("description", "")}
sql_text = "\n".join(sql_lines).rstrip() + "\n"
sql_out = Path(args.sql_out)
json_out = Path(args.json_out)
sql_out.parent.mkdir(parents=True, exist_ok=True)
sql_out.write_text(sql_text, encoding="utf-8")
json_out.write_text(
yaml.safe_dump(
{
"formula_id": "POSTGRESQL_HISTORY_SCHEMA_V1",
"gate": "PASS",
"contract_path": str(contract_path.relative_to(ROOT)),
"tables": table_defs,
"sql_out": str(sql_out.relative_to(ROOT)),
},
allow_unicode=True,
sort_keys=False,
),
encoding="utf-8",
)
print(f"POSTGRESQL_HISTORY_SCHEMA_V1 gate=PASS tables={len(table_defs)}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+1 -1
View File
@@ -31,7 +31,7 @@ PY_FILES = [
ROOT / "tools" / "compute_formula_outputs.py",
ROOT / "tools" / "validate_alpha_execution_harness.py",
ROOT / "tools" / "validate_harness_context.py",
ROOT / "tools" / "render_operational_report.py",
ROOT / "src" / "dotnet" / "QuantEngine.Tools" / "Program.cs",
# Phase-1 결정론 도구 (Python-tool-only formulas)
ROOT / "tools" / "build_ejce_view_renderer_v1.py",
ROOT / "tools" / "build_smart_cash_recovery_v3.py",
+1 -1
View File
@@ -71,7 +71,7 @@ def main() -> int:
corpus = _scan_code()
spec_total = len(formula_ids)
impl = [fid for fid in formula_ids if fid in corpus]
report_binding = [fid for fid in formula_ids if fid in corpus and "render_operational_report.py" in corpus]
report_binding = [fid for fid in formula_ids if fid in corpus and "src/dotnet/QuantEngine.Tools" in corpus]
outcome_binding = [fid for fid in formula_ids if fid.startswith(("OUTCOME_", "TRADE_", "SHORT_HORIZON_", "LATE_", "REBOUND_", "CASH_RAISE_")) and fid in corpus]
golden_path = GOLDEN_V2 if GOLDEN_V2.exists() else GOLDEN_TEMP
+88 -3
View File
@@ -1,6 +1,8 @@
#!/usr/bin/env python3
"""
render_operational_report.py — 30개 섹션 완전 렌더링.
render_operational_report.py — legacy renderer.
운영/CI 기준 구현은 src/dotnet/QuantEngine.Tools/Program.cs 이다.
이 파일은 유지보수 및 과거 호환성 참조용으로만 남긴다.
섹션 처리 오류는 section_errors 배열에 기록되어 하네스 검증에 노출된다.
"""
from __future__ import annotations
@@ -42,7 +44,7 @@ SECTION_ORDER = [
"backdata_feature_bank_table", "alpha_lead_table", "anti_distribution_table",
"profit_preservation_table", "smart_cash_raise_table", "execution_quality_table",
"sell_priority_decision_table", "strategy_performance_scoreboard",
"performance_readiness_summary", "operational_eval_queue_summary", "outcome_eval_window_monitor",
"performance_readiness_summary", "operational_t20_activation_summary", "operational_eval_queue_summary", "outcome_eval_window_monitor",
"decision_trace_table", "anti_whipsaw_reentry_gate", "proposal_reference_sheet",
"satellite_buy_proposal_sheet", "core_satellite_timing_gate_table",
"engine_feedback_loop_report", "prediction_evaluation_improvement_report",
@@ -96,6 +98,7 @@ SECTION_TITLES = {
"sell_priority_decision_table": "매도 우선순위 결정 테이블",
"strategy_performance_scoreboard": "전략 성과 스코어보드",
"performance_readiness_summary": "성과 준비도 요약",
"operational_t20_activation_summary": "운영 T+20 활성화 요약",
"operational_eval_queue_summary": "운영 T+20 대기열 요약",
"outcome_eval_window_monitor": "성과 평가 윈도우 모니터",
"decision_trace_table": "판단 추적 테이블",
@@ -1121,7 +1124,11 @@ def _performance_readiness_summary(hctx: dict, se: list) -> str:
oac = _load(oac_path)
if not oac:
return _err(se, "performance_readiness_summary", "operational_alpha_calibration_v2.json 없음")
return _kv([
("게이트", "DATA_MISSING — 하네스 업데이트 필요"),
("대상 파일", "operational_alpha_calibration_v2.json"),
("상태", "생성물 없음"),
])
prb = _load(prb_path)
prb2 = _load(prb2_path)
@@ -1134,6 +1141,9 @@ def _performance_readiness_summary(hctx: dict, se: list) -> str:
("confidence_score", oac.get("confidence_score", "")),
("performance_ready", oac.get("performance_ready", "")),
("readiness_reasons", ", ".join(oac.get("readiness_reasons", [])) if isinstance(oac.get("readiness_reasons"), list) else oac.get("readiness_reasons", "")),
("bridge_gate", prb.get("gate", "")),
("bridge_live_t20_count", live.get("t20_count", "")),
("bridge_required_live_t20_count", prb.get("required_live_t20_count", "")),
("outcome_quality_score", metrics.get("outcome_quality_score", "")),
("t20_operational_sample", metrics.get("t20_operational_sample", "")),
("t5_operational_pass_rate", metrics.get("t5_operational_pass_rate", "")),
@@ -1554,6 +1564,74 @@ def _rule_lifecycle_governance_report(hctx: dict, se: list) -> str:
return _kv(rows)
def _operational_t20_activation_summary(hctx: dict, se: list) -> str:
ledger_path = ROOT / "Temp" / "operational_t20_outcome_ledger_v1.json"
gate_path = ROOT / "Temp" / "live_data_activation_gate_v1.json"
replay_path = ROOT / "Temp" / "replay_live_separation_v1.json"
def _load(path: Path) -> dict:
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else {}
except Exception:
return {}
ledger = _load(ledger_path)
gate = _load(gate_path)
replay = _load(replay_path)
rows = [
("ledger_total_cases", ledger.get("total_cases", "")),
("ledger_win_rate_pct", ledger.get("win_rate_pct", "")),
("activation_gate", gate.get("gate", "")),
("live_t20_count", gate.get("live_t20_count", "")),
("live_t20_threshold", gate.get("live_t20_threshold", "")),
("activation_progress_pct", gate.get("progress_pct", "")),
("activation_message", gate.get("message", "")),
("replay_live_mix_count", replay.get("replay_live_mix_count", "")),
("live_metrics_null_when_insufficient", replay.get("live_metrics_null_when_insufficient", "")),
]
if not ledger:
rows.append(("ledger_state", "DATA_MISSING — 하네스 업데이트 필요"))
if not gate:
rows.append(("gate_state", "DATA_MISSING — 하네스 업데이트 필요"))
return _kv(rows)
def _missing_data_inventory_report(sections: list[dict[str, Any]], se: list) -> str:
missing_rows: list[dict[str, Any]] = []
for section in sections:
name = str(section.get("name") or "")
markdown = str(section.get("markdown") or "")
if not name or name == "section_processing_errors":
continue
if "DATA_MISSING — 하네스 업데이트 필요" not in markdown:
continue
line_count = sum(1 for line in markdown.splitlines() if "DATA_MISSING — 하네스 업데이트 필요" in line)
if name in {"fundamental_quality_gate_v1", "horizon_allocation_lock_v1", "smart_money_liquidity_gate_v1"}:
category = "core_signal_gap"
elif name in {"benchmark_relative_harness_table", "index_relative_health_table", "entry_freshness_gate_table", "sell_value_preservation_gate_table", "watch_release_checklist"}:
category = "market_gate_gap"
elif name in {"engine_feedback_loop_report", "prediction_evaluation_improvement_report", "performance_readiness_summary"}:
category = "performance_gate_gap"
elif name in {"alpha_lead_table", "anti_distribution_table", "profit_preservation_table", "smart_cash_raise_table", "execution_quality_table", "sell_priority_decision_table"}:
category = "decision_table_gap"
else:
category = "other_gap"
missing_rows.append({
"section": name,
"category": category,
"missing_line_count": line_count,
})
if not missing_rows:
return _kv([
("상태", "DATA_MISSING 섹션 없음"),
("건수", 0),
])
return _tbl(missing_rows, ["category", "section", "missing_line_count"])
# ── 메인 ─────────────────────────────────────────────────────────────────────
def main() -> int:
@@ -1627,6 +1705,7 @@ def main() -> int:
"sell_priority_decision_table": lambda: _sell_priority_decision_table(hctx, se),
"strategy_performance_scoreboard": lambda: _strategy_performance_scoreboard(hctx, se),
"performance_readiness_summary": lambda: _performance_readiness_summary(hctx, se),
"operational_t20_activation_summary": lambda: _operational_t20_activation_summary(hctx, se),
"operational_eval_queue_summary": lambda: _operational_eval_queue_summary(hctx, se),
"outcome_eval_window_monitor": lambda: _outcome_eval_window_monitor(hctx, se),
"decision_trace_table": lambda: _decision_trace_table(hctx, se),
@@ -1657,6 +1736,12 @@ def main() -> int:
md = f"## {title}\n\n<!-- {name} -->\n\n{body}"
sections.append({"name": name, "title": title, "markdown": md})
sections.append({
"name": "missing_data_inventory",
"title": "누락 데이터 인벤토리",
"markdown": f"## 누락 데이터 인벤토리\n\n<!-- missing_data_inventory -->\n\n{_missing_data_inventory_report(sections, se)}",
})
# 섹션 처리 오류 요약을 마지막 섹션으로 추가
if se:
err_rows = ["| 섹션 | 오류 |", "| --- | --- |"]
+3 -3
View File
@@ -39,7 +39,7 @@ if ($LASTEXITCODE -ne 0) { Write-Warning "ROUTING_EXECUTION_LOG_TABLE_V1 FAIL
# ── 1차 렌더 (Phase 4~5 도구가 최신 보고서를 읽어야 하므로 미리 실행) ───────────
# validate_engine_harness_gate.py 내부에서 2차 렌더(최종)가 다시 실행됨 (멱등)
python .\tools\render_operational_report.py --json $JsonPath --output $ReportPath
dotnet run --project .\src\dotnet\QuantEngine.Tools\QuantEngine.Tools.csproj -- report --packet=Temp/final_decision_packet_active.json --out=Temp/operational_report.json
if ($LASTEXITCODE -ne 0) { Write-Warning "RENDER_OPERATIONAL_REPORT(pre-phase45) FAIL — 계속 진행" }
# BLANK_CELL_AUDIT_V1 (1차 렌더 이후 실행 — 게이트 검증기에서 2차 재실행됨)
@@ -143,7 +143,7 @@ python .\tools\build_final_judgment_gate_v1.py --json $JsonPath --out .\Temp\fin
if ($LASTEXITCODE -ne 0) { Write-Warning "FINAL_JUDGMENT_GATE_V1 FAIL — 계속 진행" }
# 2차 렌더 (final_judgment_table + investment_quality_headline 섹션 포함)
python .\tools\render_operational_report.py --json $JsonPath --output $ReportPath
dotnet run --project .\src\dotnet\QuantEngine.Tools\QuantEngine.Tools.csproj -- report --packet=Temp/final_decision_packet_active.json --out=Temp/operational_report.json
if ($LASTEXITCODE -ne 0) { Write-Warning "RENDER_OPERATIONAL_REPORT(phase6-final) FAIL — 계속 진행" }
# VERDICT_CONSISTENCY_LOCK_V1 (render 이후 실행 — 최신 보고서 기준 검증)
@@ -165,7 +165,7 @@ python .\tools\build_canonical_metrics_v1.py
if ($LASTEXITCODE -ne 0) { Write-Warning "CANONICAL_METRICS_V1 FAIL — 계속 진행" }
# 3차 렌더 (canonical 값이 주입된 최신 보고서 생성)
python .\tools\render_operational_report.py --json $JsonPath --output $ReportPath
dotnet run --project .\src\dotnet\QuantEngine.Tools\QuantEngine.Tools.csproj -- report --packet=Temp/final_decision_packet_active.json --out=Temp/operational_report.json
if ($LASTEXITCODE -ne 0) { Write-Warning "RENDER_OPERATIONAL_REPORT(phase7-canonical) FAIL — 계속 진행" }
# CROSS_SECTION_CONSISTENCY_V1 — 교차섹션 정합성 게이트 (render 이후 실행)
+2 -1
View File
@@ -23,6 +23,7 @@ def _release_commands() -> list[list[str]]:
_cmd("tools/validate_specs.py"),
_cmd("tools/validate_active_manifest.py", "--manifest", "runtime/active_artifact_manifest.yaml", "--strict"),
_cmd("tools/validate_report_packet_sync_v1.py", "--packet", "Temp/final_decision_packet_active.json", "--report", "Temp/operational_report.json"),
_cmd("tools/validate_report_section_completeness_v1.py"),
_cmd("tools/validate_field_dictionary.py"),
_cmd("tools/validate_number_provenance_strict_v3.py", "--ledger", "Temp/number_provenance_ledger_v4.json", "--report", "Temp/operational_report.md"),
_cmd("tools/validate_low_capability_pack_v1.py", "--context", "Temp/final_context_for_llm_v4.yaml", "--contract", "spec/46_low_capability_execution_pack.yaml"),
@@ -41,7 +42,7 @@ def _full_commands() -> list[list[str]]:
return [
_cmd("tools/audit_repository_entropy_v1.py", "--root", ".", "--out", "runtime/baseline_manifest_v1.yaml"),
*_release_commands(),
_cmd("tools/build_final_decision_packet_v4.py", "--src", "Temp/final_decision_packet_active.json", "--out", "Temp/final_decision_packet_v4.json"),
["dotnet", "run", "--project", str(ROOT / "src" / "dotnet" / "QuantEngine.Tools" / "QuantEngine.Tools.csproj"), "--", "packet-v4", "--packet=Temp/final_decision_packet_active.json", "--out=Temp/final_decision_packet_v4.json"],
_cmd("tools/build_final_context_for_llm_v4.py", "--packet", "Temp/final_decision_packet_v4.json", "--out", "Temp/final_context_for_llm_v4.yaml"),
_cmd("tools/build_number_provenance_ledger_v4.py", "--packet", "Temp/final_decision_packet_v4.json", "--out", "Temp/number_provenance_ledger_v4.json"),
_cmd("tools/build_live_replay_separation_v2.py", "--hist", "Temp/proposal_evaluation_history.json", "--out", "Temp/live_replay_separation_v2.json"),
+1 -1
View File
@@ -9,7 +9,7 @@ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
python .\tools\build_request_result_summary.py `
--gate .\Temp\engine_harness_gate_result.json `
--out .\temp\request_result.txt
--out .\Temp\request_result.txt
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Write-Output "YOLO_FULL_CYCLE_OK"
+10 -10
View File
@@ -129,16 +129,16 @@ def main() -> int:
(
"render_operational_report",
[
"python",
"tools/render_operational_report.py",
"--json",
str(json_path),
"--output",
str(report_path),
"--improvement-harness-json",
str(harness_json_path),
"dotnet",
"run",
"--project",
str(ROOT / "src" / "dotnet" / "QuantEngine.Tools" / "QuantEngine.Tools.csproj"),
"--",
"report",
f"--packet={ROOT / 'Temp' / 'final_decision_packet_active.json'}",
f"--out={ROOT / 'Temp' / 'operational_report.json'}",
],
["REPORT RENDERED OK", "PREDICTION_IMPROVEMENT_HARNESS_EXPORTED"],
["operational_report.json"],
),
(
"build_sector_trend_analysis_v1",
@@ -215,7 +215,7 @@ def main() -> int:
failed = True
# ── render 완료 후 blank_cell_audit 재실행 ─────────────────────────────────
# render_operational_report.py(CHECK_12)가 최신 Phase 2B 주입으로 report를 갱신한 뒤
# .NET report builder가 최신 Phase 2B 주입으로 report를 갱신한 뒤
# blank_cell_audit_v1.py를 다시 실행해야 정확한 빈 셀 수를 반영한다.
# ps1에서 Phase 2B 도구 이전에 이미 한 번 실행됐지만 그것은 구버전 보고서 기준.
_bca_code, _ = _run([
+2 -2
View File
@@ -29,7 +29,7 @@ CONTRACTS = [
{
"id": "final_decision_packet_active",
"file": "Temp/final_decision_packet_active.json",
"generator": "tools/build_packet_from_context_v1.py (inject_computed_harness 포함)",
"generator": "src/dotnet/QuantEngine.Tools -- packet-v4",
"required_keys": ["formula_id", "meta", "canonical_metrics", "pass_100", "execution_readiness", "prediction"],
"non_null_keys": ["formula_id", "pass_100", "execution_readiness"],
"list_non_empty_keys": [],
@@ -42,7 +42,7 @@ CONTRACTS = [
{
"id": "operational_report",
"file": "Temp/operational_report.json",
"generator": "tools/render_operational_report.py",
"generator": "src/dotnet/QuantEngine.Tools -- report",
"required_keys": ["schema_version", "generated_at", "sections", "section_errors"],
"non_null_keys": ["sections"],
"list_non_empty_keys": ["sections"],
+20 -7
View File
@@ -5,8 +5,6 @@ import json
from pathlib import Path
from typing import Any
from jsonschema import Draft202012Validator
from operational_report_contract import REPORT_SECTION_ORDER
@@ -69,11 +67,26 @@ def main() -> int:
print("OPERATIONAL_REPORT_JSON_FAIL: invalid schema file")
return 1
validator = Draft202012Validator(schema)
for error in validator.iter_errors(payload):
pointer = "/".join(str(part) for part in error.absolute_path)
location = f" at {pointer}" if pointer else ""
errors.append(f"schema_error{location}: {error.message}")
if payload.get("schema_version") != schema.get("properties", {}).get("schema_version", {}).get("const"):
errors.append("schema_version const mismatch")
if payload.get("source_json") != schema.get("properties", {}).get("source_json", {}).get("const"):
errors.append("source_json const mismatch")
sections = payload.get("sections")
if not isinstance(sections, list):
errors.append("sections: must be array")
sections = []
else:
for idx, section in enumerate(sections):
if not isinstance(section, dict):
errors.append(f"sections[{idx}]: must be object")
continue
if not isinstance(section.get("name"), str) or not section.get("name").strip():
errors.append(f"sections[{idx}]: missing name")
if not isinstance(section.get("title"), str) or not section.get("title").strip():
errors.append(f"sections[{idx}]: missing title")
if not isinstance(section.get("markdown"), str) or not section.get("markdown").startswith(f"## {section.get('title')}"):
errors.append(f"sections[{idx}]: markdown/title mismatch")
missing_top = REQUIRED_TOP_LEVEL_KEYS - set(payload)
if missing_top:
@@ -0,0 +1,44 @@
from __future__ import annotations
import json
from pathlib import Path
import yaml
ROOT = Path(__file__).resolve().parents[1]
CONTRACT = ROOT / "spec" / "postgresql_history_contract.yaml"
OUT = ROOT / "Temp" / "postgresql_history_contract_v1.json"
def main() -> int:
errors: list[str] = []
if not CONTRACT.exists():
errors.append("contract_missing")
else:
try:
data = yaml.safe_load(CONTRACT.read_text(encoding="utf-8"))
except Exception as exc:
errors.append(f"yaml_parse_error:{exc}")
data = {}
if not isinstance(data, dict):
errors.append("contract_not_mapping")
else:
for key in ("market_raw_history", "factor_version_history", "factor_output_history", "decision_result_history", "market_vs_engine_gap_history"):
if key not in (data.get("domains") or {}):
errors.append(f"missing_domain:{key}")
if "PostgreSQL" not in json.dumps(data, ensure_ascii=False):
errors.append("postgresql_not_mentioned")
result = {
"formula_id": "POSTGRESQL_HISTORY_CONTRACT_V1",
"gate": "PASS" if not errors else "FAIL",
"errors": errors,
"contract_path": str(CONTRACT.relative_to(ROOT)),
}
OUT.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0 if not errors else 1
if __name__ == "__main__":
raise SystemExit(main())
+17 -6
View File
@@ -62,13 +62,24 @@ class _CalcVisitor(ast.NodeVisitor):
def main() -> int:
path = ROOT / "tools" / "render_operational_report.py"
path = ROOT / "src" / "dotnet" / "QuantEngine.Tools" / "Program.cs"
text = read_text(path)
tree = ast.parse(text)
visitor = _CalcVisitor()
visitor.source = text
visitor.visit(tree)
calc_lines = visitor.violations
if path.suffix.lower() == ".cs":
calc_lines = []
for idx, line in enumerate(text.splitlines(), start=1):
stripped = line.strip()
if not stripped or stripped.startswith("//"):
continue
if '"' in stripped or "'" in stripped:
continue
if any(token in stripped for token in [" + ", " - ", " * ", " / ", "Math.Round(", "Math.Min(", "Math.Max("]):
calc_lines.append({"line": str(idx), "text": stripped})
else:
tree = ast.parse(text)
visitor = _CalcVisitor()
visitor.source = text
visitor.visit(tree)
calc_lines = visitor.violations
result = {
"formula_id": "RENDERER_NO_CALCULATION_V1",
"renderer_calculation_count": len(calc_lines),
+2 -3
View File
@@ -9,10 +9,9 @@ from validate_renderer_no_calculation_v1 import main as validate_v1
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--renderer", default="tools/render_operational_report.py")
ap.add_argument("--renderer", default="src/dotnet/QuantEngine.Tools/Program.cs")
args = ap.parse_args()
# v2 keeps the same static scan but allows explicit renderer path for future parity checks.
# The underlying implementation already validates the current canonical renderer.
# v2 keeps the same static scan but points at the canonical .NET renderer.
_ = Path(args.renderer)
return validate_v1()
@@ -38,6 +38,20 @@ REPORT_SECTION_ORDER = [
"rule_lifecycle_governance_report",
]
MISSING_DATA_TOKEN = "DATA_MISSING — 하네스 업데이트 필요"
def _missing_category(section_name: str) -> str:
if section_name in {"fundamental_quality_gate_v1", "horizon_allocation_lock_v1", "smart_money_liquidity_gate_v1"}:
return "core_signal_gap"
if section_name in {"benchmark_relative_harness_table", "index_relative_health_table", "entry_freshness_gate_table", "sell_value_preservation_gate_table", "watch_release_checklist"}:
return "market_gate_gap"
if section_name in {"engine_feedback_loop_report", "prediction_evaluation_improvement_report", "performance_readiness_summary"}:
return "performance_gate_gap"
if section_name in {"alpha_lead_table", "anti_distribution_table", "profit_preservation_table", "smart_cash_raise_table", "execution_quality_table", "sell_priority_decision_table"}:
return "decision_table_gap"
return "other_gap"
def main() -> int:
ap = argparse.ArgumentParser()
@@ -62,6 +76,21 @@ def main() -> int:
missing = [n for n in REPORT_SECTION_ORDER if n not in present]
empty = [n for n in REPORT_SECTION_ORDER if n in present and n not in non_empty]
err_names = [e["section"] for e in section_errors if isinstance(e, dict)]
missing_data_rows = []
for section in sections_list:
if not isinstance(section, dict):
continue
name = str(section.get("name") or "")
markdown = str(section.get("markdown") or "")
if not name or name == "section_processing_errors":
continue
if MISSING_DATA_TOKEN not in markdown:
continue
missing_data_rows.append({
"section": name,
"category": _missing_category(name),
"missing_line_count": sum(1 for line in markdown.splitlines() if MISSING_DATA_TOKEN in line),
})
# 결과 출력
print(f"REPORT_SECTION_ORDER 기준: {len(REPORT_SECTION_ORDER)}개 섹션 검사")
@@ -99,8 +128,21 @@ def main() -> int:
out = ROOT / "Temp" / "report_section_completeness.json"
out.write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding="utf-8")
inventory_out = ROOT / "Temp" / "missing_data_inventory_v1.json"
inventory_result = {
"validator": "validate_report_section_completeness_v1",
"section_count": len(sections_list),
"missing_section_count": len(missing_data_rows),
"categories": {},
"sections": missing_data_rows,
}
for row in missing_data_rows:
cat = row["category"]
inventory_result["categories"][cat] = inventory_result["categories"].get(cat, 0) + 1
inventory_out.write_text(json.dumps(inventory_result, indent=2, ensure_ascii=False), encoding="utf-8")
print(f"\nREPORT_SECTION_COMPLETENESS: gate={result['gate']} missing={len(missing)} empty={len(empty)} section_errors={len(section_errors)}")
print(f"OUTPUT: {out}")
print(f"MISSING_DATA_INVENTORY: sections={len(missing_data_rows)} OUTPUT: {inventory_out}")
if missing:
return 1