Merge pull request '[codex] .NET 운영 화면 및 배포 분리 정리' (#10) from feature/dotnet-migration into main
WBS-9.3 - NULL Policy CI Gate / NULL Policy Validation (push) Failing after 10s
Deploy to Production / Deploy to Production Server (push) Has been skipped
Deploy to Production / Post-Deployment Checks (push) Has been skipped
Deploy to Production / Build Release Package (push) Failing after 19s
Snapshot Admin Deployment / build-and-deploy (push) Failing after 1m1s
Quant Engine CI/CD Pipeline / validate-core (push) Failing after 2m17s
Quant Engine CI/CD Pipeline / validate-ui-and-storage (push) Has been skipped

Reviewed-on: http://178.104.200.7/kjh2064/QuantEngineByItz/pulls/10
This commit was merged in pull request #10.
This commit is contained in:
2026-06-26 18:16:33 +09:00
25 changed files with 1003 additions and 387 deletions
+10 -1
View File
@@ -187,6 +187,9 @@ jobs:
- name: Validate Report Packet Sync - 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 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 - name: Validate JSON Generator Outputs
run: python3 tools/validate_json_generator_outputs_v1.py run: python3 tools/validate_json_generator_outputs_v1.py
@@ -197,7 +200,7 @@ jobs:
run: python3 tools/validate_postgresql_history_contract_v1.py run: python3 tools/validate_postgresql_history_contract_v1.py
- name: Package Operational Report Artifacts - name: Package Operational Report Artifacts
run: tar -czf Temp/operational-report-artifacts.tar.gz Temp/operational_report.json Temp/operational_report.md 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 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 - name: Upload Operational Report Artifacts
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
@@ -205,6 +208,12 @@ jobs:
name: operational-report-artifacts name: operational-report-artifacts
path: Temp/operational-report-artifacts.tar.gz 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: validate-ui-and-storage:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: validate-core 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" 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 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" 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
+8
View File
@@ -141,14 +141,22 @@ npm run prepare-upload-zip
4. `GatherTradingData.xlsx` 의존성을 제거한 후에도 수집이 유지되는지 확인 4. `GatherTradingData.xlsx` 의존성을 제거한 후에도 수집이 유지되는지 확인
5. 이후 PostgreSQL 업그레이드 시 동일 row contract를 유지 5. 이후 PostgreSQL 업그레이드 시 동일 row contract를 유지
## CI / 배포 분리
- `.gitea/workflows/ci.yml`은 검증 전용이다.
- `.gitea/workflows/snapshot_admin_deploy.yml`은 실배포 전용이다.
- 공개 URL `http://178.104.200.7/quant/` 갱신은 deploy workflow 성공 여부로 판단한다.
## 운영 리포트 계약 ## 운영 리포트 계약
운영 리포트는 .NET canonical renderer가 사람이 읽는 `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)에서 확인합니다. 운영 상태와 legacy 분리는 [DOTNET_RENDERER_OPERATING_STATUS.md](/C:/Temp/data_feed/docs/DOTNET_RENDERER_OPERATING_STATUS.md)에서 확인합니다.
- `src/dotnet/QuantEngine.Tools/Program.cs`가 canonical 생성 경로입니다. - `src/dotnet/QuantEngine.Tools/Program.cs`가 canonical 생성 경로입니다.
- `npm run render-report-json`도 같은 .NET 경로를 호출합니다.
- `operational_report.json`이 canonical 계약입니다. - `operational_report.json`이 canonical 계약입니다.
- `operational_report.md`는 표시용 렌더입니다. - `operational_report.md`는 표시용 렌더입니다.
- `Temp/missing_data_inventory_v1.json``DATA_MISSING` 섹션 분리 인벤토리입니다.
- JSON 스키마는 `schemas/operational_report.schema.json`을 사용합니다. - JSON 스키마는 `schemas/operational_report.schema.json`을 사용합니다.
- 계약 드리프트 검사는 `npm run validate-operational-report-contract`로 수행합니다. - 계약 드리프트 검사는 `npm run validate-operational-report-contract`로 수행합니다.
- 전체 게이트에는 `render-report-json -> validate-report-json -> validate-report-quality -> validate-report-sync` 순서가 포함됩니다. - 전체 게이트에는 `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개 러너가 활성 상태. 네트워크는 `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. 러너 설정 ### 6.2. 러너 설정
```yaml ```yaml
+23 -21
View File
@@ -1378,9 +1378,9 @@ WBS-8.8 (KIS 리팩터) — 독립적 (원격 병행)
### WBS-10: C#/.NET 엔진 고도화 (Phase 10, 2026-06~12) ### 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 대시보드 기본 구현 완료. > 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개. > **발견된 결함 5건**: D1) Tests.csproj Core ProjectReference 누락, D2) Tests sln 미등록, D3) appsettings.json 비밀번호 하드코딩, D4) NU1510 불필요 패키지, D5) Class1.cs placeholder 2개.
#### WBS-10 의존성 차트 #### WBS-10 의존성 차트
@@ -1405,16 +1405,16 @@ WBS-10.1 (기반 결함 수정)
| 항목 | 내용 | | 항목 | 내용 |
|------|------| |------|------|
| **작업** | 테스트 프로젝트 참조 복원, sln 등록, 불필요 패키지 제거, placeholder 삭제, 비밀번호 환경변수화 | | **작업** | 테스트 프로젝트 참조 복원, sln 등록, 불필요 패키지 제거, placeholder 삭제, 비밀번호 환경변수화 |
| **현재 상태** | Core.Tests에 ProjectReference 없음, sln 미등록, appsettings.json 비밀번호 하드코딩, NU1510 경고 2건, Class1.cs 2개 잔존 | | **현재 상태** | 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`, `src/dotnet/QuantEngine.Core/Class1.cs`, `src/dotnet/QuantEngine.Infrastructure/Class1.cs` | | **담당 파일** | `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` |
| **상태** | TODO | | **상태** | 부분 완료 |
| 세부 WBS | 작업 | 성공 판단 데이터 | 검증 명령 | | 세부 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.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.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.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건 | | 10.1.5 | appsettings.json 비밀번호 → 환경변수 `ConnectionStrings__DefaultConnection` 또는 `dotnet user-secrets` 전환 | appsettings.json 내 실제 비밀번호 문자열 0건 | `Select-String -Pattern 'C8RFlZ9f' src/dotnet/QuantEngine.Web/appsettings.json` → 결과 0건 |
**성공 하네스 (데이터 기준)**: **성공 하네스 (데이터 기준)**:
@@ -1432,9 +1432,9 @@ WBS-10.1 (기반 결함 수정)
| 항목 | 내용 | | 항목 | 내용 |
|------|------| |------|------|
| **작업** | 기존 Domain 계산기 6개에 대한 xUnit 단위 테스트 35건+ 작성. Python golden case JSON을 xUnit `[Theory]` 데이터소스로 활용하는 인프라 구축 | | **작업** | 기존 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`(신규) | | **담당 파일** | `src/dotnet/QuantEngine.Core.Tests/ExitDecisionsTests.cs`(신규), `KrxTickNormalizerTests.cs`(신규), `ProfitLockCalculatorTests.cs`(신규), `AntiChasingCalculatorTests.cs`(신규), `PullbackTriggerCalculatorTests.cs`(신규), `SellPriceSanityCheckerTests.cs`(신규) |
| **상태** | TODO | | **상태** | 부분 완료 |
| 세부 WBS | 작업 | 성공 판단 데이터 | 검증 명령 | | 세부 WBS | 작업 | 성공 판단 데이터 | 검증 명령 |
|----------|------|------------------|----------| |----------|------|------------------|----------|
@@ -1567,9 +1567,9 @@ WBS-10.1 (기반 결함 수정)
| 항목 | 내용 | | 항목 | 내용 |
|------|------| |------|------|
| **작업** | 빈 Application 프로젝트(Class1.cs)를 실제 서비스 레이어로 전환. Workspace/Approval/Collection/Formula 4개 서비스 구현 | | **작업** | 빈 Application 프로젝트(Class1.cs)를 실제 서비스 레이어로 전환. Workspace/Approval/Collection/Formula 4개 서비스 구현 |
| **현재 상태** | Class1.cs 빈 파일만 존재 | | **현재 상태** | `HistoryIngestionService`, `WorkspaceService`, `ApprovalService`, `CollectionService`, `FormulaService`가 모두 존재하고 `ApplicationServiceTests`로 forward 동작을 검증 중 |
| **담당 파일** | `src/dotnet/QuantEngine.Application/Services/WorkspaceService.cs`(신규), `ApprovalService.cs`(신규), `CollectionService.cs`(신규), `FormulaService.cs`(신규) | | **담당 파일** | `src/dotnet/QuantEngine.Application/Services/WorkspaceService.cs`, `ApprovalService.cs`, `CollectionService.cs`, `FormulaService.cs` |
| **상태** | TODO | | **상태** | 부분 완료 |
| 세부 WBS | 작업 | 성공 판단 데이터 | | 세부 WBS | 작업 | 성공 판단 데이터 |
|----------|------|------------------| |----------|------|------------------|
@@ -1580,8 +1580,8 @@ WBS-10.1 (기반 결함 수정)
**성공 하네스 (데이터 기준)**: **성공 하네스 (데이터 기준)**:
``` ```
검증: dotnet test --filter Service 검증: dotnet test src/dotnet/QuantEngine.Core.Tests/QuantEngine.Core.Tests.csproj -c Debug --filter ApplicationServiceTests
기대: 13+ tests passed, Class1.cs 삭제됨 기대: 4+ tests passed
``` ```
--- ---
@@ -1615,7 +1615,7 @@ WBS-10.1 (기반 결함 수정)
| 항목 | 내용 | | 항목 | 내용 |
|------|------| |------|------|
| **작업** | 비밀번호 하드코딩 제거, KIS credential 환경변수 강제, read-only guard 우회 방지 테스트, PostgreSQL 스키마 분리 문서화 | | **작업** | 비밀번호 하드코딩 제거, 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`(신규) | | **담당 파일** | `src/dotnet/QuantEngine.Web/appsettings.json`, `src/dotnet/QuantEngine.Infrastructure/External/KisApiClient.cs`, `src/dotnet/QuantEngine.Core.Tests/SecurityTests.cs`(신규) |
| **상태** | TODO | | **상태** | TODO |
@@ -1639,22 +1639,24 @@ WBS-10.1 (기반 결함 수정)
| 항목 | 내용 | | 항목 | 내용 |
|------|------| |------|------|
| **작업** | Python snapshot_admin_server_v1.py의 편집/조회 기능을 Blazor SSR로 확장. 기본 템플릿 페이지 제거 | | **작업** | Python snapshot_admin_server_v1.py의 편집/조회 기능을 Blazor SSR로 확장. 기본 템플릿 페이지 제거 |
| **현재 상태** | Dashboard.razor에 Settings CRUD 구현, Counter/Weather 기본 페이지 잔존 | | **현재 상태** | `Dashboard.razor`는 데이터 비의존형 상태표시로 단순화되었고, `Operations.razor``Temp/operational_report.json` 고정 렌더 경로를 제공하며, Counter/Weather 기본 페이지는 삭제됨. 공개 배포본은 아직 이전 빌드가 남아 있을 수 있으므로 CI/CD 동기화가 필요함 |
| **담당 파일** | `src/dotnet/QuantEngine.Web/Components/Pages/Dashboard.razor`, `AccountSnapshot.razor`(신규), `CollectionDashboard.razor`(신규) | | **담당 파일** | `src/dotnet/QuantEngine.Web/Components/Pages/Dashboard.razor`, `Operations.razor`(신규), `NavMenu.razor` |
| **상태** | TODO | | **상태** | 부분 완료 |
| 세부 WBS | 작업 | 성공 판단 데이터 | | 세부 WBS | 작업 | 성공 판단 데이터 |
|----------|------|------------------| |----------|------|------------------|
| 10.10.1 | Account Snapshot 편집 페이지 — 조회/추가/수정/삭제 CRUD | 4개 CRUD 동작 테스트 PASS | | 10.10.1 | Operational Report 페이지 — `Temp/operational_report.json` 고정 렌더 | 38 sections 인식 + PASS/DATA_MISSING 표시 |
| 10.10.2 | Collection Dashboard — 수집 실행 이력 조회, 에러 로그 표시 | 테이블 조회 + 필터 동작 PASS | | 10.10.2 | Dashboard 상태 페이지 — 데이터 비의존형 요약으로 단순화 | DB 실패 시에도 200 응답 |
| 10.10.3 | Counter.razor / Weather.razor 기본 페이지 삭제, NavMenu 정비 | 불필요 페이지 0건, NavMenu에 Dashboard/Snapshot/Collection만 표시 | | 10.10.3 | Counter.razor / Weather.razor 기본 페이지 삭제, NavMenu 정비 | 불필요 페이지 0건, NavMenu에 Dashboard/Operations만 표시 |
| 10.10.4 | 다크 모드 + 반응형 레이아웃 적용 | 브라우저 렌더링 정상 확인 | | 10.10.4 | 다크 모드 + 반응형 레이아웃 적용 | 브라우저 렌더링 정상 확인 |
| 10.10.5 | 배포 동기화 | `snapshot_admin_deploy.yml``/quant/``/quant/operations` 공개 라우트를 배포 후 검증하도록 구성됨 |
**성공 하네스 (데이터 기준)**: **성공 하네스 (데이터 기준)**:
``` ```
검증: dotnet build src/dotnet/QuantEngine.Web/ → 오류 0 검증: dotnet build src/dotnet/QuantEngine.Web/ → 오류 0
검증: Counter.razor, Weather.razor 파일 미존재 검증: 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 기준을 만족
``` ```
--- ---
+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-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:sell-validate": "python tools/validate_qualitative_sell_strategy_pipeline_v1.py",
"ops:postgres-stub": "python tools/generate_postgresql_upgrade_stub_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": "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-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", "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-engine-strict": "python tools/run_release_dag_v3.py --mode release --strict",
"validate-behavioral-coverage": "python tools/validate_behavioral_coverage_v1.py --strict", "validate-behavioral-coverage": "python tools/validate_behavioral_coverage_v1.py --strict",
"validate-engine-integrity": "python tools/run_release_dag_v3.py --mode release --strict", "validate-engine-integrity": "python tools/run_release_dag_v3.py --mode release --strict",
"render-report-json": "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": { "dependencies": {
"cheerio": "1.2.0", "cheerio": "1.2.0",
@@ -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,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.NotNull(result);
Assert.Equal("BUY_BREAKOUT_PILOT_ONLY", result.Action); 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> <ItemGroup>
<ProjectReference Include="..\QuantEngine.Core\QuantEngine.Core.csproj" /> <ProjectReference Include="..\QuantEngine.Core\QuantEngine.Core.csproj" />
<ProjectReference Include="..\QuantEngine.Application\QuantEngine.Application.csproj" />
<ProjectReference Include="..\QuantEngine.Infrastructure\QuantEngine.Infrastructure.csproj" />
</ItemGroup> </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 public class UnitTest1
{ {
[Fact] [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,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("QuantEngine.Core.Tests")]
@@ -24,6 +24,14 @@ namespace QuantEngine.Infrastructure.Repositories
_connectionFactory = 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) public async Task<int> AppendAsync(string domain, IDictionary<string, object?> payload)
{ {
if (!DomainColumns.TryGetValue(domain, out var columns)) if (!DomainColumns.TryGetValue(domain, out var columns))
@@ -34,20 +42,17 @@ namespace QuantEngine.Infrastructure.Repositories
var values = new DynamicParameters(); var values = new DynamicParameters();
var insertColumns = new List<string>(columns.Length + 1); var insertColumns = new List<string>(columns.Length + 1);
var placeholders = new List<string>(columns.Length + 1);
foreach (var column in columns) foreach (var column in columns)
{ {
insertColumns.Add(column); insertColumns.Add(column);
placeholders.Add($"@{column}");
values.Add(column, payload.TryGetValue(column, out var value) ? value : null); values.Add(column, payload.TryGetValue(column, out var value) ? value : null);
} }
insertColumns.Add("provenance"); insertColumns.Add("provenance");
placeholders.Add("@provenance");
var provenance = payload.TryGetValue("provenance", out var provenanceValue) ? provenanceValue : new Dictionary<string, object?>(); var provenance = payload.TryGetValue("provenance", out var provenanceValue) ? provenanceValue : new Dictionary<string, object?>();
values.Add("provenance", provenance is string s ? s : JsonSerializer.Serialize(provenance)); values.Add("provenance", provenance is string s ? s : JsonSerializer.Serialize(provenance));
var sql = $@"INSERT INTO engine_history.{domain} ({string.Join(", ", insertColumns)}) VALUES ({string.Join(", ", placeholders)})"; var sql = BuildInsertSql(domain, insertColumns);
return await conn.ExecuteAsync(sql, values); return await conn.ExecuteAsync(sql, values);
} }
@@ -59,7 +64,7 @@ namespace QuantEngine.Infrastructure.Repositories
using var conn = _connectionFactory.CreateConnection(); using var conn = _connectionFactory.CreateConnection();
conn.Open(); conn.Open();
var sql = $@"SELECT * FROM engine_history.{domain} ORDER BY created_at DESC LIMIT @Limit"; var sql = BuildSnapshotSql(domain, limit);
var rows = await conn.QueryAsync(sql, new { Limit = limit }); var rows = await conn.QueryAsync(sql, new { Limit = limit });
return rows.Select(row => (IDictionary<string, object?>)row).ToList(); return rows.Select(row => (IDictionary<string, object?>)row).ToList();
} }
@@ -1,28 +1,8 @@
<MudNavMenu> <MudNavMenu>
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard"> <MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">
Dashboard Dashboard
</MudNavLink> </MudNavLink>
<MudNavLink Href="/operations" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Assessment">
<MudNavLink Href="/portfolio" Icon="@Icons.Material.Filled.Inventory2"> Operations
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> </MudNavLink>
</MudNavMenu> </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,310 +1,138 @@
@page "/" @page "/"
@using QuantEngine.Core.Models @using QuantEngine.Core.Infrastructure
@using QuantEngine.Core.Interfaces @inject IWebHostEnvironment Environment
@inject NavigationManager NavManager
@inject ISnackbar Snackbar
@inject IPostgresqlHistorySnapshotReader HistoryReader
<PageTitle>Quant Engine - Dashboard</PageTitle> <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"> <MudGrid Class="mb-4">
<MudItem xs="12" sm="6" md="3"> <MudItem xs="12" sm="6" md="4">
<MudCard Class="h-100"> <MudCard Class="h-100">
<MudCardContent> <MudCardContent>
<MudText Typo="Typo.body2" Color="Color.Secondary">Active Positions</MudText> <MudText Typo="Typo.body2" Color="Color.Secondary">Operational Report</MudText>
<MudText Typo="Typo.h5" Class="mt-2">@activePositions</MudText> <MudText Typo="Typo.h6" Class="mt-2">@ReportStateLabel</MudText>
<MudText Typo="Typo.caption" Class="mt-1">from history snapshot</MudText> <MudText Typo="Typo.caption">@ReportPath</MudText>
</MudCardContent> </MudCardContent>
</MudCard> </MudCard>
</MudItem> </MudItem>
<MudItem xs="12" sm="6" md="4">
<MudItem xs="12" sm="6" md="3">
<MudCard Class="h-100"> <MudCard Class="h-100">
<MudCardContent> <MudCardContent>
<MudText Typo="Typo.body2" Color="Color.Secondary">Portfolio Value</MudText> <MudText Typo="Typo.body2" Color="Color.Secondary">Sections</MudText>
<MudText Typo="Typo.h5" Class="mt-2">@portfolioValueLabel</MudText> <MudText Typo="Typo.h6" Class="mt-2">@SectionCountLabel</MudText>
<MudText Typo="Typo.caption" Class="mt-1">PostgreSQL snapshot</MudText> <MudText Typo="Typo.caption">Temp/operational_report.json</MudText>
</MudCardContent> </MudCardContent>
</MudCard> </MudCard>
</MudItem> </MudItem>
<MudItem xs="12" sm="6" md="4">
<MudItem xs="12" sm="6" md="3">
<MudCard Class="h-100"> <MudCard Class="h-100">
<MudCardContent> <MudCardContent>
<MudText Typo="Typo.body2" Color="Color.Secondary">Signal Quality</MudText> <MudText Typo="Typo.body2" Color="Color.Secondary">Primary Route</MudText>
<MudText Typo="Typo.h5" Class="mt-2">@signalQualityLabel</MudText> <MudButton Variant="Variant.Filled" Color="Color.Primary" Href="/operations" Class="mt-2">
<MudText Typo="Typo.caption" Class="mt-1">decision_result_history</MudText> Open Operations
</MudCardContent> </MudButton>
</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">@dbStatusLabel</MudChip>
</MudCardContent> </MudCardContent>
</MudCard> </MudCard>
</MudItem> </MudItem>
</MudGrid> </MudGrid>
<!-- Market Overview -->
<MudGrid Class="mb-4"> <MudGrid Class="mb-4">
<MudItem xs="12" md="6"> <MudItem xs="12" md="7">
<MudCard> <MudCard Class="h-100">
<MudCardHeader> <MudCardHeader>
<CardHeaderContent> <CardHeaderContent>
<MudText Typo="Typo.h6">Market Status</MudText> <MudText Typo="Typo.h6">Current State</MudText>
</CardHeaderContent> </CardHeaderContent>
</MudCardHeader> </MudCardHeader>
<MudCardContent> <MudCardContent>
<MudStack Spacing="2"> <MudStack Spacing="2">
<MudText Typo="Typo.body2"> <MudText Typo="Typo.body2"><strong>Status:</strong> <MudChip Size="Size.Small" Color="@ReportChipColor">@ReportChipLabel</MudChip></MudText>
<strong>Market Regime:</strong> <MudChip Size="Size.Small" Color="Color.Warning">@marketRegimeLabel</MudChip> <MudText Typo="Typo.body2"><strong>Generated:</strong> @GeneratedAtLabel</MudText>
</MudText> <MudText Typo="Typo.body2"><strong>Source:</strong> @SourceLabel</MudText>
<MudText Typo="Typo.body2"> <MudText Typo="Typo.body2"><strong>Decision feed:</strong> @DecisionFeedLabel</MudText>
<strong>Volatility:</strong> @volatilityLabel <MudText Typo="Typo.body2"><strong>Factor feed:</strong> @FactorFeedLabel</MudText>
</MudText> <MudText Typo="Typo.body2"><strong>Raw feed:</strong> @RawFeedLabel</MudText>
<MudText Typo="Typo.body2">
<strong>Cash Position:</strong> @cashPositionLabel
</MudText>
<MudText Typo="Typo.body2">
<strong>Last Updated:</strong> @lastUpdatedLabel
</MudText>
</MudStack> </MudStack>
</MudCardContent> </MudCardContent>
</MudCard> </MudCard>
</MudItem> </MudItem>
<MudItem xs="12" md="6"> <MudItem xs="12" md="5">
<MudCard> <MudCard Class="h-100">
<MudCardHeader> <MudCardHeader>
<CardHeaderContent> <CardHeaderContent>
<MudText Typo="Typo.h6">System Health</MudText> <MudText Typo="Typo.h6">Routing Notes</MudText>
</CardHeaderContent> </CardHeaderContent>
</MudCardHeader> </MudCardHeader>
<MudCardContent> <MudCardContent>
<MudStack Spacing="2"> <MudStack Spacing="2">
<MudText Typo="Typo.body2"> <MudText Typo="Typo.body2">- 운영 데이터는 snapshot 우선입니다.</MudText>
<strong>Database:</strong> <MudText Typo="Typo.body2">- Excel/GAS 의존 문구는 운영 경로에서 제거 대상입니다.</MudText>
<MudChip Size="Size.Small" Color="Color.Success">@databaseLabel</MudChip> <MudText Typo="Typo.body2">- 숫자는 provenance 없으면 표시하지 않습니다.</MudText>
</MudText>
<MudText Typo="Typo.body2">
<strong>DB History Feed:</strong>
<MudChip Size="Size.Small" Color="Color.Success">@historyFeedLabel</MudChip>
</MudText>
<MudText Typo="Typo.body2">
<strong>Signal Generator:</strong>
<MudChip Size="Size.Small" Color="Color.Info">@signalGeneratorLabel</MudChip>
</MudText>
<MudText Typo="Typo.body2">
<strong>API Uptime:</strong> 99.8%
</MudText>
</MudStack> </MudStack>
</MudCardContent> </MudCardContent>
</MudCard> </MudCard>
</MudItem> </MudItem>
</MudGrid> </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">@ytdReturnLabel</MudText>
</MudItem>
<MudItem xs="12" sm="6" md="4">
<MudText Typo="Typo.body2"><strong>Sharpe Ratio</strong></MudText>
<MudText Typo="Typo.h6">@sharpeLabel</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">@maxDrawdownLabel</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">@winRateLabel</MudText>
</MudItem>
<MudItem xs="12" sm="6" md="4">
<MudText Typo="Typo.body2"><strong>Profit Factor</strong></MudText>
<MudText Typo="Typo.h6">@profitFactorLabel</MudText>
</MudItem>
<MudItem xs="12" sm="6" md="4">
<MudText Typo="Typo.body2"><strong>Trades This Month</strong></MudText>
<MudText Typo="Typo.h6">@tradesThisMonthLabel</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.TryGetValue("Progress", out var p) ? p?.ToString() ?? string.Empty : string.Empty;
var progressValue = int.TryParse(progress.Replace("%", ""), out var val) ? val : 0;
}
<MudProgressLinear Value="@progressValue" Size="Size.Small" />
</MudTd>
</RowTemplate>
</MudTable>
</MudCardContent>
</MudCard>
<!-- Live Signal Feed -->
<MudCard> <MudCard>
<MudCardHeader> <MudCardHeader>
<CardHeaderContent> <CardHeaderContent>
<MudText Typo="Typo.h6">Recent Signals (Live Feed)</MudText> <MudText Typo="Typo.h6">Coverage Summary</MudText>
</CardHeaderContent> </CardHeaderContent>
</MudCardHeader> </MudCardHeader>
<MudCardContent> <MudCardContent>
<MudTable Items="recentSignals" Hover="true" Striped="true" Dense="true"> @if (Sections.Count == 0)
<HeaderContent> {
<MudTh>Timestamp</MudTh> <MudAlert Severity="Severity.Warning" Variant="Variant.Outlined">
<MudTh>Ticker</MudTh> DATA_MISSING: operational_report.json이 비어 있거나 아직 생성되지 않았습니다.
<MudTh>Signal</MudTh> </MudAlert>
<MudTh>Score</MudTh> }
<MudTh>Style</MudTh> else
<MudTh>Status</MudTh> {
</HeaderContent> <MudTable Items="Sections" Hover="true" Striped="true" Dense="true">
<RowTemplate> <HeaderContent>
<MudTd>@context["Timestamp"]</MudTd> <MudTh>Name</MudTh>
<MudTd><strong>@context["Ticker"]</strong></MudTd> <MudTh>Title</MudTh>
<MudTd> <MudTh>Preview</MudTh>
@{ </HeaderContent>
var signal = context["Signal"].ToString(); <RowTemplate>
var signalColor = "BUY".Equals(signal) ? Color.Success : Color.Warning; <MudTd>@context.Name</MudTd>
} <MudTd>@context.Title</MudTd>
<MudChip Size="Size.Small" Color="@signalColor">@signal</MudChip> <MudTd>@context.Preview</MudTd>
</MudTd> </RowTemplate>
<MudTd>@context["Score"]</MudTd> </MudTable>
<MudTd>@context["Style"]</MudTd> }
<MudTd>
<MudChip Size="Size.Small" Variant="Variant.Text">@context["Status"]</MudChip>
</MudTd>
</RowTemplate>
</MudTable>
</MudCardContent> </MudCardContent>
</MudCard> </MudCard>
@code { @code {
private List<Dictionary<string, object>> algorithmPhases = new(); private readonly List<OperationalReportSection> Sections = new();
private List<Dictionary<string, object>> recentSignals = new(); private string ReportStateLabel = "DATA_MISSING";
private string activePositions = "0"; private string ReportChipLabel = "DATA_MISSING";
private string portfolioValueLabel = "n/a"; private Color ReportChipColor = Color.Warning;
private string signalQualityLabel = "n/a"; private string SectionCountLabel = "0";
private string dbStatusLabel = "Pending"; private string GeneratedAtLabel = "n/a";
private string marketRegimeLabel = "PENDING"; private string SourceLabel = "n/a";
private string volatilityLabel = "n/a"; private string DecisionFeedLabel = "DISCONNECTED";
private string cashPositionLabel = "n/a"; private string FactorFeedLabel = "DISCONNECTED";
private string lastUpdatedLabel = "n/a"; private string RawFeedLabel = "DISCONNECTED";
private string databaseLabel = "Pending"; private string ReportPath = "n/a";
private string historyFeedLabel = "Pending";
private string signalGeneratorLabel = "Pending";
private string ytdReturnLabel = "n/a";
private string sharpeLabel = "n/a";
private string maxDrawdownLabel = "n/a";
private string winRateLabel = "n/a";
private string profitFactorLabel = "n/a";
private string tradesThisMonthLabel = "0";
protected override async Task OnInitializedAsync() protected override void OnInitialized()
{ {
await LoadHistoryAsync(); ReportPath = Path.GetFullPath(Path.Combine(Environment.ContentRootPath, "..", "..", "..", "Temp", "operational_report.json"));
} var report = OperationalReportLoader.Load(ReportPath);
Sections.AddRange(report.Sections);
private async Task LoadHistoryAsync() SectionCountLabel = report.SectionCount.ToString();
{ GeneratedAtLabel = report.GeneratedAt;
try SourceLabel = report.SourceJson;
{ ReportStateLabel = Sections.Count > 0 ? "READY" : "DATA_MISSING";
var decisions = await HistoryReader.ReadAsync("decision_result_history", 5); ReportChipLabel = Sections.Count > 0 ? "READY" : "DATA_MISSING";
activePositions = decisions.Count.ToString(); ReportChipColor = Sections.Count > 0 ? Color.Success : Color.Warning;
signalQualityLabel = decisions.Count > 0 ? "snapshot" : "n/a";
dbStatusLabel = decisions.Count > 0 ? "Connected" : "Empty";
databaseLabel = dbStatusLabel;
historyFeedLabel = decisions.Count > 0 ? "Active" : "Pending";
signalGeneratorLabel = decisions.Count > 0 ? "Snapshot-driven" : "Pending";
marketRegimeLabel = decisions.Count > 0 ? "SNAPSHOT" : "PENDING";
volatilityLabel = decisions.Count > 0 ? "Snapshot-derived" : "n/a";
cashPositionLabel = decisions.Count > 0 ? "Snapshot-derived" : "n/a";
lastUpdatedLabel = decisions.Count > 0
? (decisions[0].TryGetValue("decided_at", out var decidedAt) ? decidedAt?.ToString() ?? "n/a" : "n/a")
: "n/a";
ytdReturnLabel = decisions.Count > 0 ? "snapshot" : "n/a";
sharpeLabel = decisions.Count > 0 ? "snapshot" : "n/a";
maxDrawdownLabel = decisions.Count > 0 ? "snapshot" : "n/a";
winRateLabel = decisions.Count > 0 ? "snapshot" : "n/a";
profitFactorLabel = decisions.Count > 0 ? "snapshot" : "n/a";
tradesThisMonthLabel = decisions.Count.ToString();
recentSignals = decisions.Select(row => new Dictionary<string, object>
{
{ "Timestamp", row.TryGetValue("decided_at", out var decidedAt) ? decidedAt?.ToString() ?? "" : "" },
{ "Ticker", row.TryGetValue("instrument_id", out var ticker) ? ticker?.ToString() ?? "" : "" },
{ "Signal", row.TryGetValue("action", out var action) ? action?.ToString() ?? "" : "" },
{ "Score", row.TryGetValue("score", out var score) ? score?.ToString() ?? "" : "" },
{ "Style", row.TryGetValue("source_version", out var sourceVersion) ? sourceVersion?.ToString() ?? "" : "" },
{ "Status", row.TryGetValue("gate", out var gate) ? gate?.ToString() ?? "" : "" }
}).ToList();
var rawCount = (await HistoryReader.ReadAsync("market_raw_history", 1)).Count;
var factorCount = (await HistoryReader.ReadAsync("factor_output_history", 1)).Count;
var gapCount = (await HistoryReader.ReadAsync("market_vs_engine_gap_history", 1)).Count;
portfolioValueLabel = rawCount > 0 ? "snapshot" : "n/a";
algorithmPhases = new()
{
new() { { "Phase", "P0" }, { "Name", "History Contract" }, { "Status", "Calibrated" }, { "Progress", "100%" } },
new() { { "Phase", "P1" }, { "Name", "PostgreSQL Store" }, { "Status", rawCount > 0 ? "Active" : "Pending" }, { "Progress", rawCount > 0 ? "100%" : "0%" } },
new() { { "Phase", "P2" }, { "Name", "Factor Output History" }, { "Status", factorCount > 0 ? "Active" : "Pending" }, { "Progress", factorCount > 0 ? "100%" : "0%" } },
new() { { "Phase", "P3" }, { "Name", "Decision Result History" }, { "Status", recentSignals.Count > 0 ? "Active" : "Pending" }, { "Progress", recentSignals.Count > 0 ? "100%" : "0%" } },
new() { { "Phase", "P4" }, { "Name", "Gap History" }, { "Status", gapCount > 0 ? "Active" : "Pending" }, { "Progress", gapCount > 0 ? "100%" : "0%" } }
};
}
catch
{
algorithmPhases = new()
{
new() { { "Phase", "P0" }, { "Name", "History Contract" }, { "Status", "Pending" }, { "Progress", "0%" } }
};
recentSignals = new();
}
} }
} }
@@ -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);
}
}
+1
View File
@@ -23,6 +23,7 @@ def _release_commands() -> list[list[str]]:
_cmd("tools/validate_specs.py"), _cmd("tools/validate_specs.py"),
_cmd("tools/validate_active_manifest.py", "--manifest", "runtime/active_artifact_manifest.yaml", "--strict"), _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_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_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_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"), _cmd("tools/validate_low_capability_pack_v1.py", "--context", "Temp/final_context_for_llm_v4.yaml", "--contract", "spec/46_low_capability_execution_pack.yaml"),
@@ -38,6 +38,20 @@ REPORT_SECTION_ORDER = [
"rule_lifecycle_governance_report", "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: def main() -> int:
ap = argparse.ArgumentParser() ap = argparse.ArgumentParser()
@@ -62,6 +76,21 @@ def main() -> int:
missing = [n for n in REPORT_SECTION_ORDER if n not in present] 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] 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)] 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)}개 섹션 검사") print(f"REPORT_SECTION_ORDER 기준: {len(REPORT_SECTION_ORDER)}개 섹션 검사")
@@ -99,8 +128,21 @@ def main() -> int:
out = ROOT / "Temp" / "report_section_completeness.json" out = ROOT / "Temp" / "report_section_completeness.json"
out.write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding="utf-8") 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"\nREPORT_SECTION_COMPLETENESS: gate={result['gate']} missing={len(missing)} empty={len(empty)} section_errors={len(section_errors)}")
print(f"OUTPUT: {out}") print(f"OUTPUT: {out}")
print(f"MISSING_DATA_INVENTORY: sections={len(missing_data_rows)} OUTPUT: {inventory_out}")
if missing: if missing:
return 1 return 1