diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index fdc2f10..0e2c217 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -187,6 +187,9 @@ jobs: - 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 @@ -197,7 +200,7 @@ jobs: 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/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 uses: actions/upload-artifact@v3 @@ -205,6 +208,12 @@ jobs: 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 diff --git a/.gitea/workflows/snapshot_admin_deploy.yml b/.gitea/workflows/snapshot_admin_deploy.yml index 42faa85..74c8634 100644 --- a/.gitea/workflows/snapshot_admin_deploy.yml +++ b/.gitea/workflows/snapshot_admin_deploy.yml @@ -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 diff --git a/README.md b/README.md index b178e21..f1a4691 100644 --- a/README.md +++ b/README.md @@ -141,14 +141,22 @@ 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 성공 여부로 판단한다. + ## 운영 리포트 계약 운영 리포트는 .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` 순서가 포함됩니다. diff --git a/docs/CLOUD_SERVER_SETUP.md b/docs/CLOUD_SERVER_SETUP.md index d14a6f6..842bbee 100644 --- a/docs/CLOUD_SERVER_SETUP.md +++ b/docs/CLOUD_SERVER_SETUP.md @@ -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 diff --git a/docs/ROADMAP_WBS.md b/docs/ROADMAP_WBS.md index 5d7c388..cae34c8 100644 --- a/docs/ROADMAP_WBS.md +++ b/docs/ROADMAP_WBS.md @@ -1378,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 의존성 차트 @@ -1405,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에 `` 추가 | 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건 | **성공 하네스 (데이터 기준)**: @@ -1432,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 | 작업 | 성공 판단 데이터 | 검증 명령 | |----------|------|------------------|----------| @@ -1567,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 | 작업 | 성공 판단 데이터 | |----------|------|------------------| @@ -1580,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 ``` --- @@ -1615,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 | @@ -1639,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 기준을 만족 ``` --- diff --git a/package.json b/package.json index 6c870a7..aad5310 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/dotnet/QuantEngine.Application/Services/CollectionService.cs b/src/dotnet/QuantEngine.Application/Services/CollectionService.cs new file mode 100644 index 0000000..b01ec61 --- /dev/null +++ b/src/dotnet/QuantEngine.Application/Services/CollectionService.cs @@ -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 AppendRunAsync(CollectionRun run) + => _historyStore.AppendAsync("collection_run_history", new Dictionary + { + ["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 AppendSnapshotAsync(CollectionSnapshot snapshot) + => _historyStore.AppendAsync("collection_snapshot_history", new Dictionary + { + ["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 AppendSourceErrorAsync(CollectionSourceError error) + => _historyStore.AppendAsync("collection_source_error_history", new Dictionary + { + ["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 + }); + } +} diff --git a/src/dotnet/QuantEngine.Application/Services/FormulaService.cs b/src/dotnet/QuantEngine.Application/Services/FormulaService.cs new file mode 100644 index 0000000..21950af --- /dev/null +++ b/src/dotnet/QuantEngine.Application/Services/FormulaService.cs @@ -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 ctx) + => FormulaEngine.ComputeTimingDecision(ctx); + + public SellDecisionResult ComputeSellDecision(Dictionary ctx) + => FormulaEngine.ComputeSellDecision(ctx); + + public FinalDecisionResult ComputeFinalDecision(Dictionary ctx) + => FormulaEngine.ComputeFinalDecision(ctx); + + public CashShortfallResult ComputeCashShortfallHarness( + Dictionary asResult, + double totalAsset, + Dictionary cashFloorInfo, + double mrsScore) + => FormulaEngine.ComputeCashShortfallHarness(asResult, totalAsset, cashFloorInfo, mrsScore); + + public CashRecoveryPlanResult ComputeCashRecoveryOptimizer( + List> sellCandidates, + double cashShortfallMinKrw) + => FormulaEngine.ComputeCashRecoveryOptimizer(sellCandidates, cashShortfallMinKrw); + + public Task AppendFormulaRunAsync(string formulaName, Dictionary payload) + => _historyStore.AppendAsync($"formula_{formulaName}_history", payload); + } +} diff --git a/src/dotnet/QuantEngine.Core.Tests/ApplicationServiceTests.cs b/src/dotnet/QuantEngine.Core.Tests/ApplicationServiceTests.cs new file mode 100644 index 0000000..7393507 --- /dev/null +++ b/src/dotnet/QuantEngine.Core.Tests/ApplicationServiceTests.cs @@ -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 { ["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 + { + ["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 + { + ["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> GetSettingsAsync() => Task.FromResult(Enumerable.Empty()); + public Task GetSettingByKeyAsync(string key) => Task.FromResult(null); + public Task UpsertSettingAsync(Setting setting) { LastSetting = setting; return Task.FromResult(true); } + public Task DeleteSettingAsync(string key) => Task.FromResult(true); + + public Task> GetAccountSnapshotsAsync() => Task.FromResult(Enumerable.Empty()); + public Task InsertAccountSnapshotsAsync(IEnumerable snapshots) => Task.FromResult(true); + public Task ClearAccountSnapshotsAsync() => Task.FromResult(true); + + public Task> GetApprovalsAsync() => Task.FromResult(Enumerable.Empty()); + public Task GetApprovalAsync(string domain, string targetRef) => Task.FromResult(null); + public Task UpsertApprovalAsync(WorkspaceApproval approval) { LastApproval = approval; return Task.FromResult(true); } + + public Task> GetLocksAsync() => Task.FromResult(Enumerable.Empty()); + public Task GetLockAsync(string domain, string targetRef) => Task.FromResult(null); + public Task AcquireLockAsync(WorkspaceLock @lock) { LastLock = @lock; return Task.FromResult(true); } + public Task 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? LastPayload { get; private set; } + + public Task AppendAsync(string domain, IDictionary payload) + { + LastDomain = domain; + LastPayload = new Dictionary(payload); + return Task.FromResult(1); + } + + public Task>> SnapshotAsync(string domain, int limit = 500) + => Task.FromResult>>(Array.Empty>()); + } +} diff --git a/src/dotnet/QuantEngine.Core.Tests/FormulaEngineTests.cs b/src/dotnet/QuantEngine.Core.Tests/FormulaEngineTests.cs index 63140c6..ed3228b 100644 --- a/src/dotnet/QuantEngine.Core.Tests/FormulaEngineTests.cs +++ b/src/dotnet/QuantEngine.Core.Tests/FormulaEngineTests.cs @@ -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 + { + { "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 + { + { "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 + { + { "settlementCashD2Krw", 10_000_000.0 } + }; + var cashFloor = new Dictionary + { + { "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); + } } diff --git a/src/dotnet/QuantEngine.Core.Tests/HistoryIngestionE2ETests.cs b/src/dotnet/QuantEngine.Core.Tests/HistoryIngestionE2ETests.cs new file mode 100644 index 0000000..6a5109c --- /dev/null +++ b/src/dotnet/QuantEngine.Core.Tests/HistoryIngestionE2ETests.cs @@ -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 + { + ["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 + { + ["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>> _rows = new(); + + public Task AppendAsync(string domain, IDictionary payload) + { + if (!_rows.TryGetValue(domain, out var list)) + { + list = new List>(); + _rows[domain] = list; + } + + list.Add(new Dictionary(payload)); + return Task.FromResult(1); + } + + public Task>> SnapshotAsync(string domain, int limit = 500) + { + if (!_rows.TryGetValue(domain, out var list)) + { + return Task.FromResult>>(Array.Empty>()); + } + + return Task.FromResult>>(list.Take(limit).ToList()); + } + } +} diff --git a/src/dotnet/QuantEngine.Core.Tests/PostgresqlHistoryStoreTests.cs b/src/dotnet/QuantEngine.Core.Tests/PostgresqlHistoryStoreTests.cs new file mode 100644 index 0000000..abdcd5b --- /dev/null +++ b/src/dotnet/QuantEngine.Core.Tests/PostgresqlHistoryStoreTests.cs @@ -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); + } +} diff --git a/src/dotnet/QuantEngine.Core.Tests/QuantEngine.Core.Tests.csproj b/src/dotnet/QuantEngine.Core.Tests/QuantEngine.Core.Tests.csproj index acbcfa6..b3c8a55 100644 --- a/src/dotnet/QuantEngine.Core.Tests/QuantEngine.Core.Tests.csproj +++ b/src/dotnet/QuantEngine.Core.Tests/QuantEngine.Core.Tests.csproj @@ -20,6 +20,8 @@ + + - \ No newline at end of file + diff --git a/src/dotnet/QuantEngine.Core.Tests/SecurityTests.cs b/src/dotnet/QuantEngine.Core.Tests/SecurityTests.cs new file mode 100644 index 0000000..d88f940 --- /dev/null +++ b/src/dotnet/QuantEngine.Core.Tests/SecurityTests.cs @@ -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(() => InvokeAssertReadOnly(client, path, trId)); + Assert.IsType(ex.InnerException); + Assert.Contains("BLOCKED", ex.InnerException!.Message); + } + + [Fact] + public void AssertReadOnly_BlocksKnownTradingTrIdPrefixes() + { + var client = CreateClient(); + + var ex = Assert.Throws(() => InvokeAssertReadOnly(client, "/uapi/domestic-stock/v1/quotations/inquire-price", "VTTC8434R00")); + Assert.IsType(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 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."); + } +} diff --git a/src/dotnet/QuantEngine.Core.Tests/UnitTest1.cs b/src/dotnet/QuantEngine.Core.Tests/UnitTest1.cs index 90e9c34..ce510e0 100644 --- a/src/dotnet/QuantEngine.Core.Tests/UnitTest1.cs +++ b/src/dotnet/QuantEngine.Core.Tests/UnitTest1.cs @@ -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); } } diff --git a/src/dotnet/QuantEngine.Core/Infrastructure/OperationalReportLoader.cs b/src/dotnet/QuantEngine.Core/Infrastructure/OperationalReportLoader.cs new file mode 100644 index 0000000..9e65fda --- /dev/null +++ b/src/dotnet/QuantEngine.Core/Infrastructure/OperationalReportLoader.cs @@ -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 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(); + 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] + "..."; + } + } +} diff --git a/src/dotnet/QuantEngine.Infrastructure/Properties/AssemblyInfo.cs b/src/dotnet/QuantEngine.Infrastructure/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..bd7f4ff --- /dev/null +++ b/src/dotnet/QuantEngine.Infrastructure/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("QuantEngine.Core.Tests")] diff --git a/src/dotnet/QuantEngine.Infrastructure/Repositories/PostgresqlHistoryStore.cs b/src/dotnet/QuantEngine.Infrastructure/Repositories/PostgresqlHistoryStore.cs index 155f299..3a2441c 100644 --- a/src/dotnet/QuantEngine.Infrastructure/Repositories/PostgresqlHistoryStore.cs +++ b/src/dotnet/QuantEngine.Infrastructure/Repositories/PostgresqlHistoryStore.cs @@ -24,6 +24,14 @@ namespace QuantEngine.Infrastructure.Repositories _connectionFactory = connectionFactory; } + internal static IReadOnlyDictionary GetDomainColumns() => DomainColumns; + + internal static string BuildInsertSql(string domain, IReadOnlyList 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 AppendAsync(string domain, IDictionary payload) { if (!DomainColumns.TryGetValue(domain, out var columns)) @@ -34,20 +42,17 @@ namespace QuantEngine.Infrastructure.Repositories var values = new DynamicParameters(); var insertColumns = new List(columns.Length + 1); - var placeholders = new List(columns.Length + 1); foreach (var column in columns) { insertColumns.Add(column); - placeholders.Add($"@{column}"); values.Add(column, payload.TryGetValue(column, out var value) ? value : null); } insertColumns.Add("provenance"); - placeholders.Add("@provenance"); var provenance = payload.TryGetValue("provenance", out var provenanceValue) ? provenanceValue : new Dictionary(); 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); } @@ -59,7 +64,7 @@ namespace QuantEngine.Infrastructure.Repositories using var conn = _connectionFactory.CreateConnection(); 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 }); return rows.Select(row => (IDictionary)row).ToList(); } diff --git a/src/dotnet/QuantEngine.Web/Components/Layout/NavMenu.razor b/src/dotnet/QuantEngine.Web/Components/Layout/NavMenu.razor index 1debd68..50a2195 100644 --- a/src/dotnet/QuantEngine.Web/Components/Layout/NavMenu.razor +++ b/src/dotnet/QuantEngine.Web/Components/Layout/NavMenu.razor @@ -1,28 +1,8 @@ - + Dashboard - - - Portfolio - - - - Analytics - - - - Reports - - - - - - Settings - - - - Help + + Operations - diff --git a/src/dotnet/QuantEngine.Web/Components/Pages/Counter.razor b/src/dotnet/QuantEngine.Web/Components/Pages/Counter.razor deleted file mode 100644 index 1a4f8e7..0000000 --- a/src/dotnet/QuantEngine.Web/Components/Pages/Counter.razor +++ /dev/null @@ -1,19 +0,0 @@ -@page "/counter" -@rendermode InteractiveServer - -Counter - -

Counter

- -

Current count: @currentCount

- - - -@code { - private int currentCount = 0; - - private void IncrementCount() - { - currentCount++; - } -} diff --git a/src/dotnet/QuantEngine.Web/Components/Pages/Dashboard.razor b/src/dotnet/QuantEngine.Web/Components/Pages/Dashboard.razor index 08273f9..23893fe 100644 --- a/src/dotnet/QuantEngine.Web/Components/Pages/Dashboard.razor +++ b/src/dotnet/QuantEngine.Web/Components/Pages/Dashboard.razor @@ -1,310 +1,138 @@ @page "/" -@using QuantEngine.Core.Models -@using QuantEngine.Core.Interfaces -@inject NavigationManager NavManager -@inject ISnackbar Snackbar -@inject IPostgresqlHistorySnapshotReader HistoryReader +@using QuantEngine.Core.Infrastructure +@inject IWebHostEnvironment Environment Quant Engine - Dashboard -Quant Engine Dashboard +Quant Engine + + 루트 화면은 운영 진입점입니다. 가짜 성과 수치 없이 현재 스냅샷 상태와 리포트 경로만 보여줍니다. + - - + - Active Positions - @activePositions - from history snapshot + Operational Report + @ReportStateLabel + @ReportPath - - + - Portfolio Value - @portfolioValueLabel - PostgreSQL snapshot + Sections + @SectionCountLabel + Temp/operational_report.json - - + - Signal Quality - @signalQualityLabel - decision_result_history - - - - - - - - System Status - @dbStatusLabel + Primary Route + + Open Operations + - - - + + - Market Status + Current State - - Market Regime: @marketRegimeLabel - - - Volatility: @volatilityLabel - - - Cash Position: @cashPositionLabel - - - Last Updated: @lastUpdatedLabel - + Status: @ReportChipLabel + Generated: @GeneratedAtLabel + Source: @SourceLabel + Decision feed: @DecisionFeedLabel + Factor feed: @FactorFeedLabel + Raw feed: @RawFeedLabel - - + + - System Health + Routing Notes - - Database: - @databaseLabel - - - DB History Feed: - @historyFeedLabel - - - Signal Generator: - @signalGeneratorLabel - - - API Uptime: 99.8% - + - 운영 데이터는 snapshot 우선입니다. + - Excel/GAS 의존 문구는 운영 경로에서 제거 대상입니다. + - 숫자는 provenance 없으면 표시하지 않습니다. - - - - - Performance Metrics - - - - - - - YTD Return - @ytdReturnLabel - - - Sharpe Ratio - @sharpeLabel - - - Max Drawdown - @maxDrawdownLabel - - - - - - Win Rate - @winRateLabel - - - Profit Factor - @profitFactorLabel - - - Trades This Month - @tradesThisMonthLabel - - - - - - - - - - - Algorithm Status (v9 Hardening) - - - - - - Phase - Name - Status - Progress - - - @context["Phase"] - @context["Name"] - - @{ - var status = context["Status"].ToString(); - var chipColor = "Calibrated".Equals(status) ? Color.Success : Color.Info; - } - @status - - - @{ - 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; - } - - - - - - - - - Recent Signals (Live Feed) + Coverage Summary - - - Timestamp - Ticker - Signal - Score - Style - Status - - - @context["Timestamp"] - @context["Ticker"] - - @{ - var signal = context["Signal"].ToString(); - var signalColor = "BUY".Equals(signal) ? Color.Success : Color.Warning; - } - @signal - - @context["Score"] - @context["Style"] - - @context["Status"] - - - + @if (Sections.Count == 0) + { + + DATA_MISSING: operational_report.json이 비어 있거나 아직 생성되지 않았습니다. + + } + else + { + + + Name + Title + Preview + + + @context.Name + @context.Title + @context.Preview + + + } @code { - private List> algorithmPhases = new(); - private List> recentSignals = new(); - private string activePositions = "0"; - private string portfolioValueLabel = "n/a"; - private string signalQualityLabel = "n/a"; - private string dbStatusLabel = "Pending"; - private string marketRegimeLabel = "PENDING"; - private string volatilityLabel = "n/a"; - private string cashPositionLabel = "n/a"; - private string lastUpdatedLabel = "n/a"; - private string databaseLabel = "Pending"; - 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"; + private readonly List 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"; - protected override async Task OnInitializedAsync() + protected override void OnInitialized() { - await LoadHistoryAsync(); - } - - private async Task LoadHistoryAsync() - { - try - { - var decisions = await HistoryReader.ReadAsync("decision_result_history", 5); - activePositions = decisions.Count.ToString(); - 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 - { - { "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(); - } + 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; } } diff --git a/src/dotnet/QuantEngine.Web/Components/Pages/Operations.razor b/src/dotnet/QuantEngine.Web/Components/Pages/Operations.razor new file mode 100644 index 0000000..ace83da --- /dev/null +++ b/src/dotnet/QuantEngine.Web/Components/Pages/Operations.razor @@ -0,0 +1,138 @@ +@page "/operations" +@using QuantEngine.Core.Infrastructure +@inject IWebHostEnvironment Environment + +Quant Engine - Operations + +Operational Report + + 이 페이지는 `Temp/operational_report.json`만 읽습니다. DB 연결과 무관하게 동일한 결과를 보여주는 운영 고정 화면입니다. + + + + + + + Schema + @SchemaVersion + + + + + + + Sections + @SectionCountLabel + + + + + + + Source + @SourceJson + + + + + + + Generated + @GeneratedAt + + + + + + + @foreach (var section in HighlightSections) + { + + + + @(section.Name) + @(section.Title) + @(section.Preview) + + + + } + + + + + + Report Health + + + + + Status: @HealthLabel + Path: @ReportPath + Sections rendered: @RenderedSectionCountLabel + + + + + + + + Sections + + + + @if (Sections.Count == 0) + { + + DATA_MISSING: operational_report.json에 표시할 섹션이 없습니다. + + } + else + { + + + Name + Title + Preview + + + @context.Name + @context.Title + @context.Preview + + + } + + + +@code { + private readonly List Sections = new(); + private readonly List 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; + } +} diff --git a/src/dotnet/QuantEngine.Web/Components/Pages/Weather.razor b/src/dotnet/QuantEngine.Web/Components/Pages/Weather.razor deleted file mode 100644 index f437e5e..0000000 --- a/src/dotnet/QuantEngine.Web/Components/Pages/Weather.razor +++ /dev/null @@ -1,64 +0,0 @@ -@page "/weather" -@attribute [StreamRendering] - -Weather - -

Weather

- -

This component demonstrates showing data.

- -@if (forecasts == null) -{ -

Loading...

-} -else -{ - - - - - - - - - - - @foreach (var forecast in forecasts) - { - - - - - - - } - -
DateTemp. (C)Temp. (F)Summary
@forecast.Date.ToShortDateString()@forecast.TemperatureC@forecast.TemperatureF@forecast.Summary
-} - -@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); - } -} diff --git a/tools/run_release_dag_v1.py b/tools/run_release_dag_v1.py index 67d1894..38bec03 100644 --- a/tools/run_release_dag_v1.py +++ b/tools/run_release_dag_v1.py @@ -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"), diff --git a/tools/validate_report_section_completeness_v1.py b/tools/validate_report_section_completeness_v1.py index dcff732..fa6dc1b 100644 --- a/tools/validate_report_section_completeness_v1.py +++ b/tools/validate_report_section_completeness_v1.py @@ -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