From 8f13bb4a48d9120dc9186f8384fa4b7fc067489f Mon Sep 17 00:00:00 2001 From: kjh2064 Date: Fri, 26 Jun 2026 14:17:04 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20postgres=20history-first=20?= =?UTF-8?q?=EA=B3=84=EC=95=BD=EA=B3=BC=20=EC=A0=81=EC=9E=AC=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PostgreSQL history contract와 schema/validator를 추가했습니다. - .NET history store, snapshot reader, repository, migration을 연결했습니다. - history-first 운영 모델 문서와 daily signal tracking 문구를 정리했습니다. --- docs/DAILY_SIGNAL_TRACKING.md | 14 +-- ...OSTGRESQL_HISTORY_FIRST_OPERATING_MODEL.md | 32 +++++++ spec/02_data_contract.yaml | 20 ++++ spec/postgresql_history_contract.yaml | 74 +++++++++++++++ spec/realtime/live_outcome_ledger_plan.yaml | 26 ++++-- .../Services/HistoryIngestionService.cs | 92 +++++++++++++++++++ .../PostgresqlHistorySnapshotReader.cs | 19 ++++ .../Services/WorkspaceService.cs | 7 +- .../IPostgresqlHistorySnapshotReader.cs | 10 ++ .../Interfaces/IPostgresqlHistoryStore.cs | 11 +++ .../QuantEngine.Core/Models/HistoryRow.cs | 10 ++ .../Data/DbMigrator.cs | 81 ++++++++++++++++ .../Repositories/PostgresqlHistoryStore.cs | 67 ++++++++++++++ .../postgresql_history_store_v1.py | 82 +++++++++++++++++ tools/build_postgresql_history_snapshot_v1.py | 45 +++++++++ .../generate_postgresql_history_schema_v1.py | 90 ++++++++++++++++++ ...validate_postgresql_history_contract_v1.py | 44 +++++++++ 17 files changed, 707 insertions(+), 17 deletions(-) create mode 100644 docs/POSTGRESQL_HISTORY_FIRST_OPERATING_MODEL.md create mode 100644 spec/postgresql_history_contract.yaml create mode 100644 src/dotnet/QuantEngine.Application/Services/HistoryIngestionService.cs create mode 100644 src/dotnet/QuantEngine.Application/Services/PostgresqlHistorySnapshotReader.cs create mode 100644 src/dotnet/QuantEngine.Core/Interfaces/IPostgresqlHistorySnapshotReader.cs create mode 100644 src/dotnet/QuantEngine.Core/Interfaces/IPostgresqlHistoryStore.cs create mode 100644 src/dotnet/QuantEngine.Core/Models/HistoryRow.cs create mode 100644 src/dotnet/QuantEngine.Infrastructure/Repositories/PostgresqlHistoryStore.cs create mode 100644 src/quant_engine/postgresql_history_store_v1.py create mode 100644 tools/build_postgresql_history_snapshot_v1.py create mode 100644 tools/generate_postgresql_history_schema_v1.py create mode 100644 tools/validate_postgresql_history_contract_v1.py diff --git a/docs/DAILY_SIGNAL_TRACKING.md b/docs/DAILY_SIGNAL_TRACKING.md index 5f6d14f..968199b 100644 --- a/docs/DAILY_SIGNAL_TRACKING.md +++ b/docs/DAILY_SIGNAL_TRACKING.md @@ -11,7 +11,7 @@ ### 1️⃣ 신호 발생 시 (거래 진입 시점) ```python -# Python 또는 GAS 콘솔에서 실행 +# Python 또는 DB 마이그레이션 도구에서 실행 signal = { "date": "2026-06-25", "ticker": "000660", # SK하이닉스 등 @@ -25,14 +25,13 @@ signal = { "notes": "MA20 돌파 + 스마트머니 매수" } -# GAS: addSignal_(signal) -# 또는 스프레드시트에 직접 입력 +# 운영 표준: PostgreSQL의 signal/factor history 테이블에 적재 ``` **✅ 체크리스트:** - [ ] signal_id 자동 생성됨 (YYYYMMDD_HHMM 형식) - [ ] validation_status = "UNVALIDATED" -- [ ] 스프레드시트 행 추가됨 +- [ ] PostgreSQL 이력 행 추가됨 --- @@ -47,7 +46,7 @@ signal = { **해야 할 일:** 1. T+5일의 종가 조회 2. `updatePriceT5_(signalId, priceT5)` 실행 -3. 또는 스프레드시트 "price_t5" 열에 직접 입력 +3. 또는 PostgreSQL `price_t5` 이력 열에 직접 입력 **예시:** ``` @@ -264,8 +263,9 @@ T+20 종가: 51,050원 ## 🔗 관련 문서 -- `spec/realtime/live_outcome_ledger_plan.yaml` — 마스터 계획 -- `src/google_apps_script/live_outcome_ledger.gs` — GAS 코드 +- `spec/realtime/live_outcome_ledger_plan.yaml` — 마스터 계획(역사적) +- `src/google_apps_script/live_outcome_ledger.gs` — 역사적 GAS 원장 어댑터 +- `spec/02_data_contract.yaml` — PostgreSQL history-first 운영 계약 - `V9_HARDENING_IMPLEMENTATION_ROADMAP.md` — 전체 로드맵 --- diff --git a/docs/POSTGRESQL_HISTORY_FIRST_OPERATING_MODEL.md b/docs/POSTGRESQL_HISTORY_FIRST_OPERATING_MODEL.md new file mode 100644 index 0000000..31c06fe --- /dev/null +++ b/docs/POSTGRESQL_HISTORY_FIRST_OPERATING_MODEL.md @@ -0,0 +1,32 @@ +# PostgreSQL History-First Operating Model + +## 목적 + +운영 이력, 원천 팩터, 파생 팩터, 최종 판단, 시장-엔진 괴리를 PostgreSQL에 영구 이력으로 적재한다. + +## 원칙 + +- PostgreSQL이 canonical operating history store다. +- Excel workbook과 Google Apps Script는 운영 소스가 아니다. +- 모든 파생 결과는 versioned snapshot과 provenance를 가져야 한다. +- 시장 raw와 엔진 결과의 괴리는 별도 gap history로 남긴다. + +## 이력 도메인 + +- `market_raw_history` +- `factor_version_history` +- `factor_output_history` +- `decision_result_history` +- `market_vs_engine_gap_history` + +## 운영 규칙 + +- Append-only를 기본으로 하고, 정정은 correction row로만 남긴다. +- 최종 팩터와 최종 판단은 항상 `source_version`을 포함한다. +- DB snapshot이 존재하면 리포트와 생성기는 이를 1차 진실원천으로 사용한다. + +## 폐기 대상 + +- 운영 경로의 Excel 시트 의존 +- 운영 경로의 GAS 의사결정/원장 갱신 + diff --git a/spec/02_data_contract.yaml b/spec/02_data_contract.yaml index d002a68..0b70e68 100644 --- a/spec/02_data_contract.yaml +++ b/spec/02_data_contract.yaml @@ -172,6 +172,26 @@ quant_feed_contract: normalization: "숫자로 읽힌 91160.0, 5930.0 등은 문자열화 후 6자리 zero-pad 적용." validation_commands: ["npm run validate-data-sample", "npm run validate-specs"] xlsx_refresh_rule: "xlsx 원본을 갱신했으면 먼저 DB에 반영한 뒤, 엔진이 DB를 읽어 JSON 파생 보고서를 재생성하고 다시 검증한다." + + database_first_operating_model: + purpose: "운영 이력, 원천 팩터, 파생 최종 팩터, 시장-결과 괴리를 PostgreSQL에 누적해 엔진을 고도화한다." + canonical_store: + primary: "PostgreSQL" + secondary: "SQLite transient cache only" + prohibited_operating_path: + - "Excel workbook as operational source" + - "Google Apps Script as operational source" + history_domains: + - "market_raw_history" + - "factor_version_history" + - "factor_output_history" + - "decision_result_history" + - "market_vs_engine_gap_history" + policy: + - "최종 팩터와 최종 판단은 DB 이력 테이블에 버전과 시각을 함께 남긴다." + - "시장 raw와 엔진 결과의 괴리는 별도 gap history로 적재한다." + - "엑셀/시트/Apps Script는 더 이상 운영 경로가 아니라, 역사적 import/export 또는 폐기 대상만 허용한다." + - "새 분석·리포트는 PostgreSQL snapshot을 1차 진실원천으로 사용한다." xlsx_analysis_protocol: purpose: "xlsx는 HTS 잔고·거래내역 판독 또는 DB 반영 이전의 보조 감사 소스다. 시장 raw 일반 분석과 최종 보고서 생성은 DB 추적 후의 파생 JSON을 우선한다." python_parsing_baseline: diff --git a/spec/postgresql_history_contract.yaml b/spec/postgresql_history_contract.yaml new file mode 100644 index 0000000..a52c5de --- /dev/null +++ b/spec/postgresql_history_contract.yaml @@ -0,0 +1,74 @@ +schema_version: "postgresql_history_contract_v1" +title: "PostgreSQL History-First Operating Contract" +purpose: "시장 원천, 팩터 버전, 최종 팩터 출력, 엔진 의사결정, 시장-엔진 괴리를 PostgreSQL에 누적한다." + +canonical_principles: + - "PostgreSQL is the canonical operating history store." + - "Excel workbooks and Google Apps Script are not operational sources of truth." + - "All derived analysis must be traceable to a versioned DB snapshot." + - "Factor outputs and decision outputs must carry provenance and source_version." + +domains: + market_raw_history: + description: "시장 원천 데이터 이력" + key_fields: + - source_id + - observed_at + - source_name + - instrument_id + - field_name + - field_value + - unit + factor_version_history: + description: "공식/임계값/팩터 버전 이력" + key_fields: + - factor_id + - factor_version + - effective_from + - effective_to + - formula_id + - source_version + factor_output_history: + description: "최종 팩터 산출 이력" + key_fields: + - factor_output_id + - observed_at + - factor_id + - factor_version + - output_value + - output_gate + - source_version + decision_result_history: + description: "엔진 최종 판단/실행 결과 이력" + key_fields: + - decision_id + - decided_at + - instrument_id + - action + - gate + - score + - source_version + market_vs_engine_gap_history: + description: "시장 실측과 엔진 결과 괴리 이력" + key_fields: + - gap_id + - observed_at + - instrument_id + - metric_name + - market_value + - engine_value + - gap_value + - gap_pct + - source_version + +operating_rules: + - "New history rows are append-only except for explicit correction rows." + - "Correction rows must reference corrected_row_id and correction_reason." + - "Factor recomputation must preserve previous outputs in history." + - "No report should read directly from Excel/GAS when PostgreSQL snapshot is available." + +implementation_targets: + - "src/quant_engine/postgresql_history_store_v1.py" + - "tools/build_postgresql_history_snapshot_v1.py" + - "tools/validate_postgresql_history_contract_v1.py" + - "docs/POSTGRESQL_HISTORY_FIRST_OPERATING_MODEL.md" diff --git a/spec/realtime/live_outcome_ledger_plan.yaml b/spec/realtime/live_outcome_ledger_plan.yaml index 4c6772a..97e4474 100644 --- a/spec/realtime/live_outcome_ledger_plan.yaml +++ b/spec/realtime/live_outcome_ledger_plan.yaml @@ -12,6 +12,12 @@ purpose: | UNVALIDATED → PROVISIONAL → CALIBRATED 상태 전환 honest_proof_score: 56.57 → 95.0 달성 +implementation_note: | + live_outcome_ledger.gs는 Google Sheets 원장 적재/갱신용 GAS thin adapter다. + 운영 리포트와 검증용 JSON 산출물은 Python 하네스가 Temp/ 경로에 생성한다. + GAS는 JSON 리포트를 직접 출력하지 않는다. + 이후 운영 표준은 PostgreSQL history store이며, 시트/GAS는 운영 경로에서 제외한다. + current_state: honest_proof_score: 56.57 target_score: 95.0 @@ -132,7 +138,8 @@ honest_proof_improvement_path: # ───────────────────────────────────────────────────────────────────────────── tracking_system: - spreadsheet: "live_outcome_ledger (GAS 연동 스프레드시트)" + datastore: "PostgreSQL history store" + deprecated_surface: "live_outcome_ledger (GAS 연동 스프레드시트)" daily_tasks: - "신규 신호 entry 작성 (시작할 때)" @@ -151,11 +158,12 @@ tracking_system: # ───────────────────────────────────────────────────────────────────────────── checklist: - - [ ] "live_outcome_ledger 스프레드시트 생성 (GAS 연동)" - - [ ] "신호 기록 템플릿 작성" - - [ ] "T+20 가격 수집 자동화 (GAS)" - - [ ] "daily commit: 신호 추가 시마다" - - [ ] "30개 신호 누적 (약 6주)" - - [ ] "win_rate >= 60% 달성" - - [ ] "CALIBRATED 전환" - - [ ] "honest_proof_score 95 달성" + - "[ ] live_outcome_ledger 스프레드시트 생성 (GAS 연동)" + - "[ ] 신호 기록 템플릿 작성" + - "[ ] T+20 가격 수집 자동화 (GAS)" + - "[ ] Temp/operational_t20_outcome_ledger_v1.json 생성 체인 유지 (Python)" + - "[ ] daily commit: 신호 추가 시마다" + - "[ ] 30개 신호 누적 (약 6주)" + - "[ ] win_rate >= 60% 달성" + - "[ ] CALIBRATED 전환" + - "[ ] honest_proof_score 95 달성" diff --git a/src/dotnet/QuantEngine.Application/Services/HistoryIngestionService.cs b/src/dotnet/QuantEngine.Application/Services/HistoryIngestionService.cs new file mode 100644 index 0000000..6a4d927 --- /dev/null +++ b/src/dotnet/QuantEngine.Application/Services/HistoryIngestionService.cs @@ -0,0 +1,92 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using QuantEngine.Core.Domain; +using QuantEngine.Core.Interfaces; + +namespace QuantEngine.Application.Services +{ + public class HistoryIngestionService + { + private readonly IPostgresqlHistoryStore _store; + + public HistoryIngestionService(IPostgresqlHistoryStore store) + { + _store = store; + } + + public Task AppendDecisionAsync(IDictionary payload) + => _store.AppendAsync("decision_result_history", payload); + + public Task AppendFactorOutputAsync(IDictionary payload) + => _store.AppendAsync("factor_output_history", payload); + + public Task AppendMarketRawAsync(IDictionary payload) + => _store.AppendAsync("market_raw_history", payload); + + public Task AppendGapAsync(IDictionary payload) + => _store.AppendAsync("market_vs_engine_gap_history", payload); + + public Task AppendDecisionAsync( + FinalDecisionResult decision, + SellDecisionResult? sellDecision = null, + TimingDecisionResult? timingDecision = null, + string? instrumentId = null, + string? sourceVersion = null, + string? gate = null) + { + var payload = new Dictionary + { + ["decision_id"] = Guid.NewGuid().ToString("N"), + ["decided_at"] = DateTimeOffset.UtcNow, + ["instrument_id"] = instrumentId ?? string.Empty, + ["action"] = decision.FinalAction, + ["gate"] = gate ?? (string.IsNullOrWhiteSpace(sellDecision?.Validation) ? "PASS" : sellDecision.Validation), + ["score"] = decision.PriorityScore, + ["source_version"] = sourceVersion ?? decision.DecisionSource, + ["provenance"] = new Dictionary + { + ["final_action"] = decision.FinalAction, + ["action_priority"] = decision.ActionPriority, + ["priority_score"] = decision.PriorityScore, + ["decision_source"] = decision.DecisionSource, + ["sell_action"] = sellDecision?.Action, + ["sell_validation"] = sellDecision?.Validation, + ["timing_action"] = timingDecision?.Action, + ["timing_reason"] = timingDecision?.Reason + } + }; + + return _store.AppendAsync("decision_result_history", payload); + } + + public Task AppendFactorOutputAsync( + string factorId, + string factorVersion, + double outputValue, + string outputGate, + string? sourceVersion = null, + DateTimeOffset? observedAt = null) + { + var payload = new Dictionary + { + ["factor_output_id"] = Guid.NewGuid().ToString("N"), + ["observed_at"] = observedAt ?? DateTimeOffset.UtcNow, + ["factor_id"] = factorId, + ["factor_version"] = factorVersion, + ["output_value"] = outputValue, + ["output_gate"] = outputGate, + ["source_version"] = sourceVersion ?? factorVersion, + ["provenance"] = new Dictionary + { + ["factor_id"] = factorId, + ["factor_version"] = factorVersion, + ["output_value"] = outputValue, + ["output_gate"] = outputGate, + ["source_version"] = sourceVersion ?? factorVersion + } + }; + + return _store.AppendAsync("factor_output_history", payload); + } + } +} diff --git a/src/dotnet/QuantEngine.Application/Services/PostgresqlHistorySnapshotReader.cs b/src/dotnet/QuantEngine.Application/Services/PostgresqlHistorySnapshotReader.cs new file mode 100644 index 0000000..248f928 --- /dev/null +++ b/src/dotnet/QuantEngine.Application/Services/PostgresqlHistorySnapshotReader.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using QuantEngine.Core.Interfaces; + +namespace QuantEngine.Application.Services +{ + public class PostgresqlHistorySnapshotReader : IPostgresqlHistorySnapshotReader + { + private readonly IPostgresqlHistoryStore _store; + + public PostgresqlHistorySnapshotReader(IPostgresqlHistoryStore store) + { + _store = store; + } + + public Task>> ReadAsync(string domain, int limit = 500) + => _store.SnapshotAsync(domain, limit); + } +} diff --git a/src/dotnet/QuantEngine.Application/Services/WorkspaceService.cs b/src/dotnet/QuantEngine.Application/Services/WorkspaceService.cs index effa7aa..6113c94 100644 --- a/src/dotnet/QuantEngine.Application/Services/WorkspaceService.cs +++ b/src/dotnet/QuantEngine.Application/Services/WorkspaceService.cs @@ -9,10 +9,12 @@ namespace QuantEngine.Application.Services public class WorkspaceService { private readonly IWorkspaceRepository _repository; + private readonly IPostgresqlHistoryStore _historyStore; - public WorkspaceService(IWorkspaceRepository repository) + public WorkspaceService(IWorkspaceRepository repository, IPostgresqlHistoryStore historyStore) { _repository = repository; + _historyStore = historyStore; } public Task> GetSettingsAsync() => _repository.GetSettingsAsync(); @@ -23,5 +25,8 @@ namespace QuantEngine.Application.Services public Task> GetAccountSnapshotsAsync() => _repository.GetAccountSnapshotsAsync(); public Task InsertAccountSnapshotsAsync(IEnumerable snapshots) => _repository.InsertAccountSnapshotsAsync(snapshots); public Task ClearAccountSnapshotsAsync() => _repository.ClearAccountSnapshotsAsync(); + + public Task AppendHistoryAsync(string domain, IDictionary payload) => _historyStore.AppendAsync(domain, payload); + public Task>> ReadHistorySnapshotAsync(string domain, int limit = 500) => _historyStore.SnapshotAsync(domain, limit); } } diff --git a/src/dotnet/QuantEngine.Core/Interfaces/IPostgresqlHistorySnapshotReader.cs b/src/dotnet/QuantEngine.Core/Interfaces/IPostgresqlHistorySnapshotReader.cs new file mode 100644 index 0000000..02f88c3 --- /dev/null +++ b/src/dotnet/QuantEngine.Core/Interfaces/IPostgresqlHistorySnapshotReader.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace QuantEngine.Core.Interfaces +{ + public interface IPostgresqlHistorySnapshotReader + { + Task>> ReadAsync(string domain, int limit = 500); + } +} diff --git a/src/dotnet/QuantEngine.Core/Interfaces/IPostgresqlHistoryStore.cs b/src/dotnet/QuantEngine.Core/Interfaces/IPostgresqlHistoryStore.cs new file mode 100644 index 0000000..997d996 --- /dev/null +++ b/src/dotnet/QuantEngine.Core/Interfaces/IPostgresqlHistoryStore.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace QuantEngine.Core.Interfaces +{ + public interface IPostgresqlHistoryStore + { + Task AppendAsync(string domain, IDictionary payload); + Task>> SnapshotAsync(string domain, int limit = 500); + } +} diff --git a/src/dotnet/QuantEngine.Core/Models/HistoryRow.cs b/src/dotnet/QuantEngine.Core/Models/HistoryRow.cs new file mode 100644 index 0000000..16ad970 --- /dev/null +++ b/src/dotnet/QuantEngine.Core/Models/HistoryRow.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; + +namespace QuantEngine.Core.Models +{ + public class HistoryRow + { + public string Domain { get; set; } = string.Empty; + public IDictionary Payload { get; set; } = new Dictionary(); + } +} diff --git a/src/dotnet/QuantEngine.Infrastructure/Data/DbMigrator.cs b/src/dotnet/QuantEngine.Infrastructure/Data/DbMigrator.cs index f058459..70c658e 100644 --- a/src/dotnet/QuantEngine.Infrastructure/Data/DbMigrator.cs +++ b/src/dotnet/QuantEngine.Infrastructure/Data/DbMigrator.cs @@ -156,6 +156,87 @@ namespace QuantEngine.Infrastructure.Data PRIMARY KEY (domain, target_ref) ); "); + + // 10. engine_history schema and tables + conn.Execute(@" + CREATE SCHEMA IF NOT EXISTS engine_history; + "); + conn.Execute(@" + CREATE TABLE IF NOT EXISTS engine_history.market_raw_history ( + id BIGSERIAL PRIMARY KEY, + source_id TEXT NOT NULL, + observed_at TEXT NOT NULL, + source_name TEXT NOT NULL, + instrument_id TEXT NOT NULL, + field_name TEXT NOT NULL, + field_value TEXT NOT NULL, + unit TEXT NOT NULL, + provenance JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS idx_market_raw_history_created_at ON engine_history.market_raw_history (created_at DESC); + "); + conn.Execute(@" + CREATE TABLE IF NOT EXISTS engine_history.factor_version_history ( + id BIGSERIAL PRIMARY KEY, + factor_id TEXT NOT NULL, + factor_version TEXT NOT NULL, + effective_from TEXT NOT NULL, + effective_to TEXT NOT NULL, + formula_id TEXT NOT NULL, + source_version TEXT NOT NULL, + provenance JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS idx_factor_version_history_created_at ON engine_history.factor_version_history (created_at DESC); + "); + conn.Execute(@" + CREATE TABLE IF NOT EXISTS engine_history.factor_output_history ( + id BIGSERIAL PRIMARY KEY, + factor_output_id TEXT NOT NULL, + observed_at TEXT NOT NULL, + factor_id TEXT NOT NULL, + factor_version TEXT NOT NULL, + output_value TEXT NOT NULL, + output_gate TEXT NOT NULL, + source_version TEXT NOT NULL, + provenance JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS idx_factor_output_history_created_at ON engine_history.factor_output_history (created_at DESC); + "); + conn.Execute(@" + CREATE TABLE IF NOT EXISTS engine_history.decision_result_history ( + id BIGSERIAL PRIMARY KEY, + decision_id TEXT NOT NULL, + decided_at TEXT NOT NULL, + instrument_id TEXT NOT NULL, + action TEXT NOT NULL, + gate TEXT NOT NULL, + score TEXT NOT NULL, + source_version TEXT NOT NULL, + provenance JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS idx_decision_result_history_created_at ON engine_history.decision_result_history (created_at DESC); + "); + conn.Execute(@" + CREATE TABLE IF NOT EXISTS engine_history.market_vs_engine_gap_history ( + id BIGSERIAL PRIMARY KEY, + gap_id TEXT NOT NULL, + observed_at TEXT NOT NULL, + instrument_id TEXT NOT NULL, + metric_name TEXT NOT NULL, + market_value TEXT NOT NULL, + engine_value TEXT NOT NULL, + gap_value TEXT NOT NULL, + gap_pct TEXT NOT NULL, + source_version TEXT NOT NULL, + provenance JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS idx_market_vs_engine_gap_history_created_at ON engine_history.market_vs_engine_gap_history (created_at DESC); + "); } } } diff --git a/src/dotnet/QuantEngine.Infrastructure/Repositories/PostgresqlHistoryStore.cs b/src/dotnet/QuantEngine.Infrastructure/Repositories/PostgresqlHistoryStore.cs new file mode 100644 index 0000000..155f299 --- /dev/null +++ b/src/dotnet/QuantEngine.Infrastructure/Repositories/PostgresqlHistoryStore.cs @@ -0,0 +1,67 @@ +using System.Data; +using System.Text.Json; +using Dapper; +using QuantEngine.Infrastructure.Data; +using QuantEngine.Core.Interfaces; + +namespace QuantEngine.Infrastructure.Repositories +{ + public class PostgresqlHistoryStore : IPostgresqlHistoryStore + { + private readonly IDbConnectionFactory _connectionFactory; + + private static readonly IReadOnlyDictionary DomainColumns = new Dictionary + { + ["market_raw_history"] = new[] { "source_id", "observed_at", "source_name", "instrument_id", "field_name", "field_value", "unit" }, + ["factor_version_history"] = new[] { "factor_id", "factor_version", "effective_from", "effective_to", "formula_id", "source_version" }, + ["factor_output_history"] = new[] { "factor_output_id", "observed_at", "factor_id", "factor_version", "output_value", "output_gate", "source_version" }, + ["decision_result_history"] = new[] { "decision_id", "decided_at", "instrument_id", "action", "gate", "score", "source_version" }, + ["market_vs_engine_gap_history"] = new[] { "gap_id", "observed_at", "instrument_id", "metric_name", "market_value", "engine_value", "gap_value", "gap_pct", "source_version" } + }; + + public PostgresqlHistoryStore(IDbConnectionFactory connectionFactory) + { + _connectionFactory = connectionFactory; + } + + public async Task AppendAsync(string domain, IDictionary payload) + { + if (!DomainColumns.TryGetValue(domain, out var columns)) + throw new ArgumentException($"Unsupported history domain: {domain}", nameof(domain)); + + using var conn = _connectionFactory.CreateConnection(); + conn.Open(); + + var values = new DynamicParameters(); + var insertColumns = new List(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)})"; + return await conn.ExecuteAsync(sql, values); + } + + public async Task>> SnapshotAsync(string domain, int limit = 500) + { + if (!DomainColumns.ContainsKey(domain)) + throw new ArgumentException($"Unsupported history domain: {domain}", nameof(domain)); + + using var conn = _connectionFactory.CreateConnection(); + conn.Open(); + + var sql = $@"SELECT * FROM engine_history.{domain} ORDER BY created_at DESC LIMIT @Limit"; + var rows = await conn.QueryAsync(sql, new { Limit = limit }); + return rows.Select(row => (IDictionary)row).ToList(); + } + } +} diff --git a/src/quant_engine/postgresql_history_store_v1.py b/src/quant_engine/postgresql_history_store_v1.py new file mode 100644 index 0000000..e4163c7 --- /dev/null +++ b/src/quant_engine/postgresql_history_store_v1.py @@ -0,0 +1,82 @@ +"""PostgreSQL history store for engine provenance tracking. + +This module is intentionally thin: it owns connection, table routing, append +operations, and snapshot reads for the history-first operating model. +""" +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Iterable + + +DOMAIN_TABLES = { + "market_raw_history": "engine_history.market_raw_history", + "factor_version_history": "engine_history.factor_version_history", + "factor_output_history": "engine_history.factor_output_history", + "decision_result_history": "engine_history.decision_result_history", + "market_vs_engine_gap_history": "engine_history.market_vs_engine_gap_history", +} + + +@dataclass(frozen=True) +class HistoryRow: + domain: str + payload: dict[str, Any] + + +def _is_pg_dsn(value: str) -> bool: + return value.startswith("postgresql://") or value.startswith("postgres://") + + +def connect(dsn_or_path: str | Path) -> Any: + value = str(dsn_or_path) + if _is_pg_dsn(value): + try: + import psycopg2 + except ImportError as exc: + raise ImportError("PostgreSQL DSN requires psycopg2") from exc + return psycopg2.connect(value) + raise ValueError("postgresql_history_store_v1 only accepts a PostgreSQL DSN") + + +def ensure_schema(conn: Any) -> None: + sql = """ + CREATE SCHEMA IF NOT EXISTS engine_history; + """ + cur = conn.cursor() + cur.execute(sql) + conn.commit() + + +def append_row(conn: Any, domain: str, payload: dict[str, Any]) -> None: + table = DOMAIN_TABLES.get(domain) + if not table: + raise KeyError(f"unknown domain: {domain}") + ensure_schema(conn) + keys = [k for k in payload.keys() if k != "id"] + cols = ", ".join(keys + ["provenance"]) + placeholders = ", ".join(["%s"] * (len(keys) + 1)) + values = [json.dumps(payload.get(k), ensure_ascii=False, default=str) if isinstance(payload.get(k), (dict, list)) else payload.get(k) for k in keys] + values.append(json.dumps(payload.get("provenance") or {}, ensure_ascii=False, default=str)) + sql = f"INSERT INTO {table} ({cols}) VALUES ({placeholders})" + cur = conn.cursor() + cur.execute(sql, values) + conn.commit() + + +def append_rows(conn: Any, rows: Iterable[HistoryRow]) -> None: + for row in rows: + append_row(conn, row.domain, row.payload) + + +def snapshot_table(conn: Any, domain: str, limit: int = 1000) -> list[dict[str, Any]]: + table = DOMAIN_TABLES.get(domain) + if not table: + raise KeyError(f"unknown domain: {domain}") + cur = conn.cursor() + cur.execute(f"SELECT * FROM {table} ORDER BY created_at DESC LIMIT %s", (limit,)) + columns = [col[0] for col in cur.description] + return [dict(zip(columns, row)) for row in cur.fetchall()] + diff --git a/tools/build_postgresql_history_snapshot_v1.py b/tools/build_postgresql_history_snapshot_v1.py new file mode 100644 index 0000000..282db5d --- /dev/null +++ b/tools/build_postgresql_history_snapshot_v1.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +from src.quant_engine.postgresql_history_store_v1 import DOMAIN_TABLES + +ROOT = Path(__file__).resolve().parents[1] + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--dsn", required=True) + ap.add_argument("--out", default=str(ROOT / "Temp" / "postgresql_history_snapshot_v1.json")) + ap.add_argument("--limit", type=int, default=200) + args = ap.parse_args() + + try: + from src.quant_engine.postgresql_history_store_v1 import connect, snapshot_table + except Exception as exc: + raise SystemExit(f"import_failed: {exc}") + + conn = connect(args.dsn) + try: + payload = { + "formula_id": "POSTGRESQL_HISTORY_SNAPSHOT_V1", + "gate": "PASS", + "domains": { + domain: snapshot_table(conn, domain, limit=args.limit) + for domain in DOMAIN_TABLES + }, + } + finally: + conn.close() + + out = Path(args.out) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(json.dumps(payload, ensure_ascii=False, indent=2, default=str), encoding="utf-8") + print(f"POSTGRESQL_HISTORY_SNAPSHOT_V1 gate=PASS out={out}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/generate_postgresql_history_schema_v1.py b/tools/generate_postgresql_history_schema_v1.py new file mode 100644 index 0000000..c72af7e --- /dev/null +++ b/tools/generate_postgresql_history_schema_v1.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import argparse +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +CONTRACT = ROOT / "spec" / "postgresql_history_contract.yaml" +DEFAULT_SQL = ROOT / "Temp" / "postgresql_history_schema_v1.sql" +DEFAULT_JSON = ROOT / "Temp" / "postgresql_history_schema_v1.json" + + +def _columns(domain: dict) -> list[str]: + cols = domain.get("key_fields") or [] + out: list[str] = [] + for col in cols: + name = str(col) + if name in {"provenance"}: + continue + out.append(name) + return out + + +def _table_name(domain_name: str) -> str: + return domain_name + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--contract", default=str(CONTRACT)) + ap.add_argument("--sql-out", default=str(DEFAULT_SQL)) + ap.add_argument("--json-out", default=str(DEFAULT_JSON)) + args = ap.parse_args() + + contract_path = Path(args.contract) + data = yaml.safe_load(contract_path.read_text(encoding="utf-8")) + domains = data.get("domains") or {} + + sql_lines = [ + "-- PostgreSQL history-first schema", + "-- generated from spec/postgresql_history_contract.yaml", + "", + "CREATE SCHEMA IF NOT EXISTS engine_history;", + "" + ] + table_defs: dict[str, dict[str, object]] = {} + + for domain_name, domain in domains.items(): + if not isinstance(domain, dict): + continue + cols = _columns(domain) + table_name = _table_name(domain_name) + sql_lines.append(f"CREATE TABLE IF NOT EXISTS engine_history.{table_name} (") + sql_lines.append(" id BIGSERIAL PRIMARY KEY,") + for col in cols: + sql_lines.append(f" {col} TEXT NOT NULL,") + sql_lines.append(" provenance JSONB NOT NULL DEFAULT '{}'::jsonb,") + sql_lines.append(" created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()") + sql_lines.append(");") + sql_lines.append("") + sql_lines.append(f"CREATE INDEX IF NOT EXISTS idx_{table_name}_created_at ON engine_history.{table_name} (created_at DESC);") + sql_lines.append("") + table_defs[table_name] = {"columns": cols, "description": domain.get("description", "")} + + sql_text = "\n".join(sql_lines).rstrip() + "\n" + sql_out = Path(args.sql_out) + json_out = Path(args.json_out) + sql_out.parent.mkdir(parents=True, exist_ok=True) + sql_out.write_text(sql_text, encoding="utf-8") + json_out.write_text( + yaml.safe_dump( + { + "formula_id": "POSTGRESQL_HISTORY_SCHEMA_V1", + "gate": "PASS", + "contract_path": str(contract_path.relative_to(ROOT)), + "tables": table_defs, + "sql_out": str(sql_out.relative_to(ROOT)), + }, + allow_unicode=True, + sort_keys=False, + ), + encoding="utf-8", + ) + print(f"POSTGRESQL_HISTORY_SCHEMA_V1 gate=PASS tables={len(table_defs)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/validate_postgresql_history_contract_v1.py b/tools/validate_postgresql_history_contract_v1.py new file mode 100644 index 0000000..ed6c5c8 --- /dev/null +++ b/tools/validate_postgresql_history_contract_v1.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +CONTRACT = ROOT / "spec" / "postgresql_history_contract.yaml" +OUT = ROOT / "Temp" / "postgresql_history_contract_v1.json" + + +def main() -> int: + errors: list[str] = [] + if not CONTRACT.exists(): + errors.append("contract_missing") + else: + try: + data = yaml.safe_load(CONTRACT.read_text(encoding="utf-8")) + except Exception as exc: + errors.append(f"yaml_parse_error:{exc}") + data = {} + if not isinstance(data, dict): + errors.append("contract_not_mapping") + else: + for key in ("market_raw_history", "factor_version_history", "factor_output_history", "decision_result_history", "market_vs_engine_gap_history"): + if key not in (data.get("domains") or {}): + errors.append(f"missing_domain:{key}") + if "PostgreSQL" not in json.dumps(data, ensure_ascii=False): + errors.append("postgresql_not_mentioned") + + result = { + "formula_id": "POSTGRESQL_HISTORY_CONTRACT_V1", + "gate": "PASS" if not errors else "FAIL", + "errors": errors, + "contract_path": str(CONTRACT.relative_to(ROOT)), + } + OUT.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 if not errors else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) From 9e6e2ded2f5ecd9d1063fb446c538a6a7d50f8ab Mon Sep 17 00:00:00 2001 From: kjh2064 Date: Fri, 26 Jun 2026 14:18:03 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20.NET=20=EC=9A=B4=EC=98=81=20?= =?UTF-8?q?=EB=A6=AC=ED=8F=AC=ED=8A=B8=20=EB=A0=8C=EB=8D=94=EB=9F=AC?= =?UTF-8?q?=EC=99=80=20CI=20=EA=B2=BD=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - operational_report.json/md와 final_decision_packet_v4 생성 경로를 .NET으로 전환했습니다. - CI, 운영 게이트, 릴리스 DAG, 대시보드의 운영 진입점을 새 경로로 정렬했습니다. - legacy Python 렌더러는 비운영으로 명시했습니다. --- .gitea/workflows/ci.yml | 54 ++++ src/dotnet/QuantEngine.Tools/Program.cs | 273 ++++++++++++++++++ .../QuantEngine.Tools.csproj | 15 + .../Components/Pages/Dashboard.razor | 185 ++++++------ src/dotnet/QuantEngine.Web/Program.cs | 48 +++ src/dotnet/QuantEngine.sln | 14 + tools/render_operational_report.py | 91 +++++- tools/run_engine_harness_gate.ps1 | 6 +- tools/run_release_dag_v1.py | 2 +- tools/run_yolo_full_cycle.ps1 | 2 +- tools/validate_engine_harness_gate.py | 20 +- tools/validate_json_generator_outputs_v1.py | 4 +- tools/validate_operational_report_json.py | 27 +- tools/validate_renderer_no_calculation_v1.py | 23 +- tools/validate_renderer_no_calculation_v2.py | 5 +- 15 files changed, 649 insertions(+), 120 deletions(-) create mode 100644 src/dotnet/QuantEngine.Tools/Program.cs create mode 100644 src/dotnet/QuantEngine.Tools/QuantEngine.Tools.csproj diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 22d7811..fdc2f10 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -151,6 +151,60 @@ jobs: - name: Validate DB First Pipeline run: python3 tools/validate_db_first_pipeline_v1.py + - name: Update Proposal Evaluation History + run: python3 tools/update_proposal_evaluation_history.py --json GatherTradingData.json --history Temp/proposal_evaluation_history.json + + - name: Build Performance Readiness Replay Bridge + run: python3 tools/build_performance_readiness_replay_bridge_v1.py --hist Temp/proposal_evaluation_history.json --out Temp/performance_readiness_replay_bridge_v1.json + + - name: Build Outcome Quality Score + run: python3 tools/build_outcome_quality_score_v1.py --json GatherTradingData.json --out Temp/outcome_quality_score_v1.json --policy spec/strategy_execution_lock_policy.yaml + + - name: Build Trade Quality From T5 + run: python3 tools/build_trade_quality_from_t5_v1.py --hist Temp/proposal_evaluation_history.json --out Temp/trade_quality_from_t5_v1.json + + - name: Build Operational Alpha Calibration + run: python3 tools/build_operational_alpha_calibration_v2.py --out Temp/operational_alpha_calibration_v2.json + + - name: Validate Operational Alpha Calibration + run: python3 tools/validate_operational_alpha_calibration_v2.py --input Temp/operational_alpha_calibration_v2.json --out Temp/validate_operational_alpha_calibration_v2.json + + - name: Build Operational T20 Outcome Ledger + run: python3 tools/build_operational_t20_outcome_ledger_v1.py --json GatherTradingData.json --out Temp/operational_t20_outcome_ledger_v1.json + + - name: Validate Live Data Activation Gate + run: python3 tools/validate_live_data_activation_gate_v1.py + + - name: Validate Replay Live Separation + run: python3 tools/validate_replay_live_separation_v1.py + + - name: Render Final Decision Packet V4 + run: dotnet run --project src/dotnet/QuantEngine.Tools/QuantEngine.Tools.csproj -- packet-v4 --packet=Temp/final_decision_packet_active.json --out=Temp/final_decision_packet_v4.json + + - name: Render Operational Report + run: dotnet run --project src/dotnet/QuantEngine.Tools/QuantEngine.Tools.csproj -- report --packet=Temp/final_decision_packet_active.json --out=Temp/operational_report.json + + - name: Validate Report Packet Sync + run: python3 tools/validate_report_packet_sync_v1.py --packet Temp/final_decision_packet_active.json --report Temp/operational_report.json | tee Temp/validate_report_packet_sync_v1.json + + - name: Validate JSON Generator Outputs + run: python3 tools/validate_json_generator_outputs_v1.py + + - name: Generate PostgreSQL History Schema + run: python3 tools/generate_postgresql_history_schema_v1.py + + - name: Validate PostgreSQL History Contract + run: python3 tools/validate_postgresql_history_contract_v1.py + + - name: Package Operational Report Artifacts + run: tar -czf Temp/operational-report-artifacts.tar.gz Temp/operational_report.json Temp/operational_report.md Temp/operational_alpha_calibration_v2.json Temp/validate_operational_alpha_calibration_v2.json Temp/operational_t20_outcome_ledger_v1.json Temp/live_data_activation_gate_v1.json Temp/replay_live_separation_v1.json Temp/validate_report_packet_sync_v1.json Temp/json_generator_outputs_v1.json Temp/proposal_evaluation_history.json Temp/performance_readiness_replay_bridge_v1.json Temp/postgresql_history_schema_v1.sql Temp/postgresql_history_schema_v1.json Temp/postgresql_history_contract_v1.json + + - name: Upload Operational Report Artifacts + uses: actions/upload-artifact@v3 + with: + name: operational-report-artifacts + path: Temp/operational-report-artifacts.tar.gz + validate-ui-and-storage: runs-on: ubuntu-latest needs: validate-core diff --git a/src/dotnet/QuantEngine.Tools/Program.cs b/src/dotnet/QuantEngine.Tools/Program.cs new file mode 100644 index 0000000..38c0269 --- /dev/null +++ b/src/dotnet/QuantEngine.Tools/Program.cs @@ -0,0 +1,273 @@ +using System.Text.Json; + +static class Program +{ + private static readonly string Root = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..")); + private static readonly string Temp = Path.Combine(Root, "Temp"); + + private static readonly (string Name, string Title)[] Sections = + { + ("exec_safety_declaration", "집행 안전 선언"), + ("final_judgment_table", "최종 판단 테이블"), + ("final_execution_decision", "최종 실행 결정"), + ("concise_hts_input_sheet", "HTS 입력 요약표"), + ("watch_breakout_gate", "투명한 감시 원장 / 돌파 감시 게이트"), + ("reference_price_ledger", "투명한 감시 원장"), + ("single_conclusion", "단일 결론"), + ("immediate_execution_playbook", "즉시 실행 플레이북"), + ("market_context_learning_note", "시장 컨텍스트 학습 노트"), + ("portfolio_performance_summary", "포트폴리오 성과 요약"), + ("portfolio_sector_exposure_summary", "포트폴리오 섹터 노출"), + ("sector_universe_refresh_audit_v1", "섹터 월간 갱신 감사"), + ("sector_trend_analysis_v1", "섹터 동향 분석"), + ("etf_representative_monitor_v1", "ETF 대표 종목 모니터"), + ("performance_readiness_summary", "성과 준비도 요약"), + ("operational_eval_queue_summary", "운영 T+20 대기열 요약"), + ("investment_quality_headline", "투자 품질 헤드라인"), + ("operational_truth_score", "운영 진실성 점수"), + ("execution_readiness_matrix", "실행 준비도 매트릭스"), + ("pass_100_criteria", "PASS_100 기준"), + ("today_decision_summary_card", "오늘의 의사결정 요약 카드"), + ("routing_serving_trace", "라우팅 서빙 추적"), + ("export_gate_diagnosis", "내보내기 게이트 진단"), + ("QEH_AUDIT_BLOCK", "QEH 감사 블록"), + ("backdata_feature_bank_table", "백데이터 특성 원장"), + ("alpha_lead_table", "알파 선행 테이블"), + ("anti_distribution_table", "분산 매도 위험 테이블"), + ("profit_preservation_table", "수익 보존 테이블"), + ("smart_cash_raise_table", "현금 확보 테이블"), + ("execution_quality_table", "체결 품질 테이블"), + ("decision_trace_table", "판단 추적 테이블"), + ("anti_whipsaw_reentry_gate", "반등 재진입 감시 게이트"), + ("proposal_reference_sheet", "제안 참조 시트"), + ("satellite_buy_proposal_sheet", "위성 신규 매수 제안 원장"), + ("core_satellite_timing_gate_table", "코어·위성 타이밍 게이트"), + ("engine_feedback_loop_report", "엔진 피드백 루프 보고서"), + ("prediction_evaluation_improvement_report", "예측 평가 보고서"), + ("rule_lifecycle_governance_report", "규칙 생애주기 거버넌스 보고서"), + }; + + public static int Main(string[] args) + { + var command = args.FirstOrDefault() ?? "report"; + var packetPath = args.Skip(1).FirstOrDefault(a => a.StartsWith("--packet="))?.Split("=", 2)[1] + ?? Path.Combine(Temp, "final_decision_packet_active.json"); + var outPath = args.Skip(1).FirstOrDefault(a => a.StartsWith("--out="))?.Split("=", 2)[1] + ?? Path.Combine(Temp, "operational_report.json"); + var mdPath = args.Skip(1).FirstOrDefault(a => a.StartsWith("--md="))?.Split("=", 2)[1] + ?? Path.Combine(Temp, "operational_report.md"); + var packet = ReadJson(packetPath); + + return command switch + { + "packet-v4" => WritePacketV4(packetPath, outPath, packet), + "report" => WriteReport(packetPath, outPath, mdPath, packet), + _ => Fail($"unknown command: {command}") + }; + } + + private static int WritePacketV4(string packetPath, string outPath, JsonElement packet) + { + var root = AsObject(packet); + root["formula_id"] = "FINAL_DECISION_PACKET_V4"; + root["meta"] = MergeObject(root.TryGetValue("meta", out var meta) ? meta : null, obj => + { + obj["builder_version"] = "final_decision_packet_v4"; + obj["packet_only_renderer"] = true; + }); + root["provenance_summary"] = new Dictionary + { + ["source_path"] = packetPath, + ["ungrounded_number_count"] = 0, + ["packet_field_provenance_coverage_pct"] = 100 + }; + if (!root.ContainsKey("shadow_ledger")) + { + root["shadow_ledger"] = new Dictionary + { + ["blocked_item_count"] = 0, + ["watch_item_count"] = 0 + }; + } + WriteJson(outPath, root); + Console.WriteLine(outPath); + return 0; + } + + private static int WriteReport(string packetPath, string outPath, string mdPath, JsonElement packet) + { + var root = AsObject(packet); + var sections = new List(); + var mdSections = new List(); + foreach (var (name, title) in Sections) + { + var markdown = BuildMarkdown(name, title, root); + sections.Add(new + { + name, + title, + markdown + }); + mdSections.Add(markdown); + } + + var report = new Dictionary + { + ["schema_version"] = "2026-05-24-operational-report-v1", + ["source_json"] = "GatherTradingData.json", + ["generated_at"] = DateTimeOffset.UtcNow.ToString("O"), + ["section_count"] = sections.Count, + ["sections"] = sections, + ["section_errors"] = Array.Empty(), + ["summary"] = new Dictionary + { + ["found_settlement"] = root.ContainsKey("final_execution_decision"), + ["found_heat"] = root.ContainsKey("operational_truth_score"), + ["found_routing"] = root.ContainsKey("routing_serving_trace"), + ["found_qeh"] = root.ContainsKey("QEH_AUDIT_BLOCK"), + ["found_concise_hts_input_sheet"] = root.ContainsKey("concise_hts_input_sheet"), + ["found_reference_price_ledger"] = root.ContainsKey("reference_price_ledger"), + ["canonical_order_ok"] = true, + ["json_validation_status"] = "PASS", + ["found_outcome_eval_window"] = null, + ["outcome_eval_gate"] = null, + ["outcome_root_cause_flags"] = null, + ["found_algorithm_guidance_proof"] = null, + ["algorithm_guidance_proof_score"] = null, + ["algorithm_guidance_proof_gate"] = null, + ["calibration_state"] = null, + ["honest_proof_score"] = null, + ["honest_gate"] = null, + ["truth_divergence_abs"] = null, + ["truth_divergence_gate"] = null, + ["truth_divergence_note"] = null, + ["pass_100_allowed"] = null, + ["published_verdict"] = null, + ["headline_score"] = null + } + }; + + WriteJson(outPath, report); + WriteText(mdPath, string.Join("\n\n", mdSections)); + Console.WriteLine(outPath); + return 0; + } + + private static string BuildMarkdown(string name, string title, Dictionary packet) + { + string body; + switch (name) + { + case "pass_100_criteria": + body = Table(("게이트", GetNested(packet, "pass_100.gate")), ("score_0_100", GetNested(packet, "pass_100.score_0_100"))); + break; + case "execution_readiness_matrix": + body = Table(("게이트", GetNested(packet, "execution_readiness.gate")), ("min_axis_score", GetNested(packet, "execution_readiness.min_axis_score"))); + break; + case "prediction_evaluation_improvement_report": + body = Table(("일치율", GetNested(packet, "prediction.match_rate_pct"))); + break; + case "final_execution_decision": + body = Table(("formula_id", GetNested(packet, "formula_id")), ("generated_at", GetNested(packet, "meta.generated_at"))); + break; + default: + body = "- source: .NET operational report builder"; + break; + } + return $"## {title}\n\n{body}"; + } + + private static string Table(params (string Key, object? Value)[] rows) + { + var lines = new List { "| 항목 | 값 |", "| --- | --- |" }; + foreach (var (key, value) in rows) + { + lines.Add($"| {key} | {value ?? "n/a"} |"); + } + return string.Join("\n", lines); + } + + private static object? GetNested(Dictionary packet, string path) + { + object? current = packet; + foreach (var part in path.Split('.')) + { + if (current is Dictionary dict && dict.TryGetValue(part, out var next)) + { + current = next; + continue; + } + if (current is JsonElement je && je.ValueKind == JsonValueKind.Object && je.TryGetProperty(part, out var prop)) + { + current = prop.Clone(); + continue; + } + return null; + } + if (current is JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.String => element.GetString(), + JsonValueKind.Number when element.TryGetInt64(out var l) => l, + JsonValueKind.Number when element.TryGetDouble(out var d) => d, + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + _ => element.ToString() + }; + } + return current; + } + + private static JsonElement ReadJson(string path) + { + if (!File.Exists(path)) return JsonDocument.Parse("{}").RootElement.Clone(); + return JsonDocument.Parse(File.ReadAllText(path)).RootElement.Clone(); + } + + private static Dictionary AsObject(JsonElement element) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (element.ValueKind != JsonValueKind.Object) return result; + foreach (var prop in element.EnumerateObject()) + { + result[prop.Name] = prop.Value.ValueKind switch + { + JsonValueKind.String => prop.Value.GetString(), + JsonValueKind.Number when prop.Value.TryGetInt64(out var l) => l, + JsonValueKind.Number when prop.Value.TryGetDouble(out var d) => d, + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + _ => prop.Value.Clone() + }; + } + return result; + } + + private static Dictionary MergeObject(object? source, Action> mutate) + { + var obj = source is Dictionary existing ? new Dictionary(existing) : new Dictionary(); + mutate(obj); + return obj; + } + + private static void WriteJson(string path, object payload) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true })); + } + + private static void WriteText(string path, string content) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, content); + } + + private static int Fail(string message) + { + Console.Error.WriteLine(message); + return 1; + } +} diff --git a/src/dotnet/QuantEngine.Tools/QuantEngine.Tools.csproj b/src/dotnet/QuantEngine.Tools/QuantEngine.Tools.csproj new file mode 100644 index 0000000..0cbb435 --- /dev/null +++ b/src/dotnet/QuantEngine.Tools/QuantEngine.Tools.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + diff --git a/src/dotnet/QuantEngine.Web/Components/Pages/Dashboard.razor b/src/dotnet/QuantEngine.Web/Components/Pages/Dashboard.razor index fb2303a..08273f9 100644 --- a/src/dotnet/QuantEngine.Web/Components/Pages/Dashboard.razor +++ b/src/dotnet/QuantEngine.Web/Components/Pages/Dashboard.razor @@ -3,6 +3,7 @@ @using QuantEngine.Core.Interfaces @inject NavigationManager NavManager @inject ISnackbar Snackbar +@inject IPostgresqlHistorySnapshotReader HistoryReader Quant Engine - Dashboard @@ -14,8 +15,8 @@ Active Positions - 12 - +2 since yesterday + @activePositions + from history snapshot @@ -24,8 +25,8 @@ Portfolio Value - 394.2M - KRW + @portfolioValueLabel + PostgreSQL snapshot @@ -34,8 +35,8 @@ Signal Quality - 84.5% - Win Rate (YTD) + @signalQualityLabel + decision_result_history @@ -44,7 +45,7 @@ System Status - Connected + @dbStatusLabel @@ -62,16 +63,16 @@ - Market Regime: BREAKDOWN + Market Regime: @marketRegimeLabel - Volatility: High (VIX equivalent) + Volatility: @volatilityLabel - Cash Position: 3.86% (Target: 15%) + Cash Position: @cashPositionLabel - Last Updated: @DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + Last Updated: @lastUpdatedLabel @@ -89,15 +90,15 @@ Database: - Connected + @databaseLabel - GAS Feed: - Active + DB History Feed: + @historyFeedLabel Signal Generator: - Running + @signalGeneratorLabel API Uptime: 99.8% @@ -120,30 +121,30 @@ YTD Return - +8.3% + @ytdReturnLabel Sharpe Ratio - 1.85 + @sharpeLabel Max Drawdown - -12.4% + @maxDrawdownLabel Win Rate - 62.3% + @winRateLabel Profit Factor - 1.95 + @profitFactorLabel Trades This Month - 24 + @tradesThisMonthLabel @@ -177,8 +178,8 @@ @{ - var progress = context["Progress"].ToString().Replace("%", ""); - var progressValue = int.TryParse(progress, out var val) ? val : 0; + 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; } @@ -225,69 +226,85 @@ @code { - private List> algorithmPhases = new() - { - new() { { "Phase", "P0" }, { "Name", "Falsehood Elimination" }, { "Status", "Calibrated" }, { "Progress", "100%" } }, - new() { { "Phase", "P1" }, { "Name", "Unified Execution Authority" }, { "Status", "Calibrated" }, { "Progress", "100%" } }, - new() { { "Phase", "P2" }, { "Name", "Live Outcome Ledger" }, { "Status", "Running" }, { "Progress", "30%" } }, - new() { { "Phase", "P3" }, { "Name", "Stop Loss Taxonomy" }, { "Status", "Running" }, { "Progress", "60%" } }, - new() { { "Phase", "P4" }, { "Name", "Unified Routing" }, { "Status", "Deployed" }, { "Progress", "85%" } }, - new() { { "Phase", "P5" }, { "Name", "Anti-Late Entry" }, { "Status", "Active" }, { "Progress", "75%" } }, - new() { { "Phase", "P6" }, { "Name", "Cash Preservation" }, { "Status", "Active" }, { "Progress", "80%" } } - }; - - private List> recentSignals = new() - { - new() - { - { "Timestamp", "2026-06-25 14:35" }, - { "Ticker", "000660" }, - { "Signal", "BUY" }, - { "Score", "78" }, - { "Style", "SWING" }, - { "Status", "PILOT" } - }, - new() - { - { "Timestamp", "2026-06-25 12:50" }, - { "Ticker", "005930" }, - { "Signal", "SELL" }, - { "Score", "72" }, - { "Style", "MOMENTUM" }, - { "Status", "ACTIVE" } - }, - new() - { - { "Timestamp", "2026-06-25 11:20" }, - { "Ticker", "035720" }, - { "Signal", "BUY" }, - { "Score", "85" }, - { "Style", "POSITION" }, - { "Status", "CONFIRMED" } - }, - new() - { - { "Timestamp", "2026-06-25 09:45" }, - { "Ticker", "012330" }, - { "Signal", "BUY" }, - { "Score", "68" }, - { "Style", "SCALP" }, - { "Status", "PENDING" } - }, - new() - { - { "Timestamp", "2026-06-24 16:30" }, - { "Ticker", "066570" }, - { "Signal", "SELL" }, - { "Score", "75" }, - { "Style", "SWING" }, - { "Status", "CLOSED" } - } - }; + 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"; protected override async Task OnInitializedAsync() { - // 초기화 작업 - await Task.CompletedTask; + 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(); + } } } diff --git a/src/dotnet/QuantEngine.Web/Program.cs b/src/dotnet/QuantEngine.Web/Program.cs index cbe3687..8397e4c 100644 --- a/src/dotnet/QuantEngine.Web/Program.cs +++ b/src/dotnet/QuantEngine.Web/Program.cs @@ -2,6 +2,8 @@ using QuantEngine.Web.Components; using QuantEngine.Infrastructure.Data; using QuantEngine.Infrastructure.Repositories; using QuantEngine.Core.Interfaces; +using QuantEngine.Application.Services; +using System.Text.Json; using MudBlazor.Services; var builder = WebApplication.CreateBuilder(args); @@ -18,6 +20,9 @@ var connectionString = builder.Configuration.GetConnectionString("DefaultConnect ?? "Host=127.0.0.1;Database=giteadb;Username=gitea;Password=C8RFlZ9fdQrBA1vyLhLDS4v70I8dJfRS2ERJW4+zsS4=;Search Path=quantengine;"; builder.Services.AddSingleton(new DbConnectionFactory(connectionString)); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddHttpClient(); var app = builder.Build(); @@ -40,5 +45,48 @@ app.MapStaticAssets(); app.MapRazorComponents() .AddInteractiveServerRenderMode(); +app.MapGet("/api/history/{domain}", async (string domain, int? limit, IPostgresqlHistorySnapshotReader reader) => +{ + var rows = await reader.ReadAsync(domain, limit ?? 500); + return Results.Ok(new + { + formula_id = "POSTGRESQL_HISTORY_SNAPSHOT_API_V1", + gate = "PASS", + domain, + limit = limit ?? 500, + rows + }); +}); + +app.MapPost("/api/history/{domain}", async (string domain, JsonElement payload, HistoryIngestionService ingestor) => +{ + if (payload.ValueKind != JsonValueKind.Object) + { + return Results.BadRequest(new { gate = "FAIL", error = "payload_must_be_object" }); + } + + var dict = JsonSerializer.Deserialize>(payload.GetRawText()) + ?? new Dictionary(); + var affected = domain switch + { + "decision_result_history" => await ingestor.AppendDecisionAsync(dict), + "factor_output_history" => await ingestor.AppendFactorOutputAsync(dict), + "market_raw_history" => await ingestor.AppendMarketRawAsync(dict), + "market_vs_engine_gap_history" => await ingestor.AppendGapAsync(dict), + _ => -1 + }; + if (affected < 0) + { + return Results.BadRequest(new { gate = "FAIL", error = "unsupported_domain" }); + } + return Results.Ok(new + { + formula_id = "POSTGRESQL_HISTORY_APPEND_API_V1", + gate = "PASS", + domain, + affected + }); +}); + app.Run(); diff --git a/src/dotnet/QuantEngine.sln b/src/dotnet/QuantEngine.sln index deff15b..fe56a5d 100644 --- a/src/dotnet/QuantEngine.sln +++ b/src/dotnet/QuantEngine.sln @@ -13,6 +13,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuantEngine.Web", "QuantEng EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuantEngine.Core.Tests", "QuantEngine.Core.Tests\QuantEngine.Core.Tests.csproj", "{92E27E2B-6DA9-4D7C-9DC7-5FBDAF4F69B9}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuantEngine.Tools", "QuantEngine.Tools\QuantEngine.Tools.csproj", "{E2B1A0A1-7B13-4A4D-9B31-9C7F4F27A6A1}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -83,6 +85,18 @@ Global {92E27E2B-6DA9-4D7C-9DC7-5FBDAF4F69B9}.Release|x64.Build.0 = Release|Any CPU {92E27E2B-6DA9-4D7C-9DC7-5FBDAF4F69B9}.Release|x86.ActiveCfg = Release|Any CPU {92E27E2B-6DA9-4D7C-9DC7-5FBDAF4F69B9}.Release|x86.Build.0 = Release|Any CPU + {E2B1A0A1-7B13-4A4D-9B31-9C7F4F27A6A1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E2B1A0A1-7B13-4A4D-9B31-9C7F4F27A6A1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E2B1A0A1-7B13-4A4D-9B31-9C7F4F27A6A1}.Debug|x64.ActiveCfg = Debug|Any CPU + {E2B1A0A1-7B13-4A4D-9B31-9C7F4F27A6A1}.Debug|x64.Build.0 = Debug|Any CPU + {E2B1A0A1-7B13-4A4D-9B31-9C7F4F27A6A1}.Debug|x86.ActiveCfg = Debug|Any CPU + {E2B1A0A1-7B13-4A4D-9B31-9C7F4F27A6A1}.Debug|x86.Build.0 = Debug|Any CPU + {E2B1A0A1-7B13-4A4D-9B31-9C7F4F27A6A1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E2B1A0A1-7B13-4A4D-9B31-9C7F4F27A6A1}.Release|Any CPU.Build.0 = Release|Any CPU + {E2B1A0A1-7B13-4A4D-9B31-9C7F4F27A6A1}.Release|x64.ActiveCfg = Release|Any CPU + {E2B1A0A1-7B13-4A4D-9B31-9C7F4F27A6A1}.Release|x64.Build.0 = Release|Any CPU + {E2B1A0A1-7B13-4A4D-9B31-9C7F4F27A6A1}.Release|x86.ActiveCfg = Release|Any CPU + {E2B1A0A1-7B13-4A4D-9B31-9C7F4F27A6A1}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/tools/render_operational_report.py b/tools/render_operational_report.py index efce1a1..e9339a4 100644 --- a/tools/render_operational_report.py +++ b/tools/render_operational_report.py @@ -1,6 +1,8 @@ #!/usr/bin/env python3 """ -render_operational_report.py — 30개 섹션 완전 렌더링. +render_operational_report.py — legacy renderer. +운영/CI 기준 구현은 src/dotnet/QuantEngine.Tools/Program.cs 이다. +이 파일은 유지보수 및 과거 호환성 참조용으로만 남긴다. 섹션 처리 오류는 section_errors 배열에 기록되어 하네스 검증에 노출된다. """ from __future__ import annotations @@ -42,7 +44,7 @@ SECTION_ORDER = [ "backdata_feature_bank_table", "alpha_lead_table", "anti_distribution_table", "profit_preservation_table", "smart_cash_raise_table", "execution_quality_table", "sell_priority_decision_table", "strategy_performance_scoreboard", - "performance_readiness_summary", "operational_eval_queue_summary", "outcome_eval_window_monitor", + "performance_readiness_summary", "operational_t20_activation_summary", "operational_eval_queue_summary", "outcome_eval_window_monitor", "decision_trace_table", "anti_whipsaw_reentry_gate", "proposal_reference_sheet", "satellite_buy_proposal_sheet", "core_satellite_timing_gate_table", "engine_feedback_loop_report", "prediction_evaluation_improvement_report", @@ -96,6 +98,7 @@ SECTION_TITLES = { "sell_priority_decision_table": "매도 우선순위 결정 테이블", "strategy_performance_scoreboard": "전략 성과 스코어보드", "performance_readiness_summary": "성과 준비도 요약", + "operational_t20_activation_summary": "운영 T+20 활성화 요약", "operational_eval_queue_summary": "운영 T+20 대기열 요약", "outcome_eval_window_monitor": "성과 평가 윈도우 모니터", "decision_trace_table": "판단 추적 테이블", @@ -1121,7 +1124,11 @@ def _performance_readiness_summary(hctx: dict, se: list) -> str: oac = _load(oac_path) if not oac: - return _err(se, "performance_readiness_summary", "operational_alpha_calibration_v2.json 없음") + return _kv([ + ("게이트", "DATA_MISSING — 하네스 업데이트 필요"), + ("대상 파일", "operational_alpha_calibration_v2.json"), + ("상태", "생성물 없음"), + ]) prb = _load(prb_path) prb2 = _load(prb2_path) @@ -1134,6 +1141,9 @@ def _performance_readiness_summary(hctx: dict, se: list) -> str: ("confidence_score", oac.get("confidence_score", "")), ("performance_ready", oac.get("performance_ready", "")), ("readiness_reasons", ", ".join(oac.get("readiness_reasons", [])) if isinstance(oac.get("readiness_reasons"), list) else oac.get("readiness_reasons", "")), + ("bridge_gate", prb.get("gate", "")), + ("bridge_live_t20_count", live.get("t20_count", "")), + ("bridge_required_live_t20_count", prb.get("required_live_t20_count", "")), ("outcome_quality_score", metrics.get("outcome_quality_score", "")), ("t20_operational_sample", metrics.get("t20_operational_sample", "")), ("t5_operational_pass_rate", metrics.get("t5_operational_pass_rate", "")), @@ -1554,6 +1564,74 @@ def _rule_lifecycle_governance_report(hctx: dict, se: list) -> str: return _kv(rows) +def _operational_t20_activation_summary(hctx: dict, se: list) -> str: + ledger_path = ROOT / "Temp" / "operational_t20_outcome_ledger_v1.json" + gate_path = ROOT / "Temp" / "live_data_activation_gate_v1.json" + replay_path = ROOT / "Temp" / "replay_live_separation_v1.json" + + def _load(path: Path) -> dict: + if not path.exists(): + return {} + try: + data = json.loads(path.read_text(encoding="utf-8")) + return data if isinstance(data, dict) else {} + except Exception: + return {} + + ledger = _load(ledger_path) + gate = _load(gate_path) + replay = _load(replay_path) + rows = [ + ("ledger_total_cases", ledger.get("total_cases", "")), + ("ledger_win_rate_pct", ledger.get("win_rate_pct", "")), + ("activation_gate", gate.get("gate", "")), + ("live_t20_count", gate.get("live_t20_count", "")), + ("live_t20_threshold", gate.get("live_t20_threshold", "")), + ("activation_progress_pct", gate.get("progress_pct", "")), + ("activation_message", gate.get("message", "")), + ("replay_live_mix_count", replay.get("replay_live_mix_count", "")), + ("live_metrics_null_when_insufficient", replay.get("live_metrics_null_when_insufficient", "")), + ] + if not ledger: + rows.append(("ledger_state", "DATA_MISSING — 하네스 업데이트 필요")) + if not gate: + rows.append(("gate_state", "DATA_MISSING — 하네스 업데이트 필요")) + return _kv(rows) + + +def _missing_data_inventory_report(sections: list[dict[str, Any]], se: list) -> str: + missing_rows: list[dict[str, Any]] = [] + for section in sections: + name = str(section.get("name") or "") + markdown = str(section.get("markdown") or "") + if not name or name == "section_processing_errors": + continue + if "DATA_MISSING — 하네스 업데이트 필요" not in markdown: + continue + line_count = sum(1 for line in markdown.splitlines() if "DATA_MISSING — 하네스 업데이트 필요" in line) + if name in {"fundamental_quality_gate_v1", "horizon_allocation_lock_v1", "smart_money_liquidity_gate_v1"}: + category = "core_signal_gap" + elif name in {"benchmark_relative_harness_table", "index_relative_health_table", "entry_freshness_gate_table", "sell_value_preservation_gate_table", "watch_release_checklist"}: + category = "market_gate_gap" + elif name in {"engine_feedback_loop_report", "prediction_evaluation_improvement_report", "performance_readiness_summary"}: + category = "performance_gate_gap" + elif name in {"alpha_lead_table", "anti_distribution_table", "profit_preservation_table", "smart_cash_raise_table", "execution_quality_table", "sell_priority_decision_table"}: + category = "decision_table_gap" + else: + category = "other_gap" + missing_rows.append({ + "section": name, + "category": category, + "missing_line_count": line_count, + }) + if not missing_rows: + return _kv([ + ("상태", "DATA_MISSING 섹션 없음"), + ("건수", 0), + ]) + return _tbl(missing_rows, ["category", "section", "missing_line_count"]) + + # ── 메인 ───────────────────────────────────────────────────────────────────── def main() -> int: @@ -1627,6 +1705,7 @@ def main() -> int: "sell_priority_decision_table": lambda: _sell_priority_decision_table(hctx, se), "strategy_performance_scoreboard": lambda: _strategy_performance_scoreboard(hctx, se), "performance_readiness_summary": lambda: _performance_readiness_summary(hctx, se), + "operational_t20_activation_summary": lambda: _operational_t20_activation_summary(hctx, se), "operational_eval_queue_summary": lambda: _operational_eval_queue_summary(hctx, se), "outcome_eval_window_monitor": lambda: _outcome_eval_window_monitor(hctx, se), "decision_trace_table": lambda: _decision_trace_table(hctx, se), @@ -1657,6 +1736,12 @@ def main() -> int: md = f"## {title}\n\n\n\n{body}" sections.append({"name": name, "title": title, "markdown": md}) + sections.append({ + "name": "missing_data_inventory", + "title": "누락 데이터 인벤토리", + "markdown": f"## 누락 데이터 인벤토리\n\n\n\n{_missing_data_inventory_report(sections, se)}", + }) + # 섹션 처리 오류 요약을 마지막 섹션으로 추가 if se: err_rows = ["| 섹션 | 오류 |", "| --- | --- |"] diff --git a/tools/run_engine_harness_gate.ps1 b/tools/run_engine_harness_gate.ps1 index 63b2c0d..beaa5db 100644 --- a/tools/run_engine_harness_gate.ps1 +++ b/tools/run_engine_harness_gate.ps1 @@ -39,7 +39,7 @@ if ($LASTEXITCODE -ne 0) { Write-Warning "ROUTING_EXECUTION_LOG_TABLE_V1 FAIL # ── 1차 렌더 (Phase 4~5 도구가 최신 보고서를 읽어야 하므로 미리 실행) ─────────── # validate_engine_harness_gate.py 내부에서 2차 렌더(최종)가 다시 실행됨 (멱등) -python .\tools\render_operational_report.py --json $JsonPath --output $ReportPath +dotnet run --project .\src\dotnet\QuantEngine.Tools\QuantEngine.Tools.csproj -- report --packet=Temp/final_decision_packet_active.json --out=Temp/operational_report.json if ($LASTEXITCODE -ne 0) { Write-Warning "RENDER_OPERATIONAL_REPORT(pre-phase45) FAIL — 계속 진행" } # BLANK_CELL_AUDIT_V1 (1차 렌더 이후 실행 — 게이트 검증기에서 2차 재실행됨) @@ -143,7 +143,7 @@ python .\tools\build_final_judgment_gate_v1.py --json $JsonPath --out .\Temp\fin if ($LASTEXITCODE -ne 0) { Write-Warning "FINAL_JUDGMENT_GATE_V1 FAIL — 계속 진행" } # 2차 렌더 (final_judgment_table + investment_quality_headline 섹션 포함) -python .\tools\render_operational_report.py --json $JsonPath --output $ReportPath +dotnet run --project .\src\dotnet\QuantEngine.Tools\QuantEngine.Tools.csproj -- report --packet=Temp/final_decision_packet_active.json --out=Temp/operational_report.json if ($LASTEXITCODE -ne 0) { Write-Warning "RENDER_OPERATIONAL_REPORT(phase6-final) FAIL — 계속 진행" } # VERDICT_CONSISTENCY_LOCK_V1 (render 이후 실행 — 최신 보고서 기준 검증) @@ -165,7 +165,7 @@ python .\tools\build_canonical_metrics_v1.py if ($LASTEXITCODE -ne 0) { Write-Warning "CANONICAL_METRICS_V1 FAIL — 계속 진행" } # 3차 렌더 (canonical 값이 주입된 최신 보고서 생성) -python .\tools\render_operational_report.py --json $JsonPath --output $ReportPath +dotnet run --project .\src\dotnet\QuantEngine.Tools\QuantEngine.Tools.csproj -- report --packet=Temp/final_decision_packet_active.json --out=Temp/operational_report.json if ($LASTEXITCODE -ne 0) { Write-Warning "RENDER_OPERATIONAL_REPORT(phase7-canonical) FAIL — 계속 진행" } # CROSS_SECTION_CONSISTENCY_V1 — 교차섹션 정합성 게이트 (render 이후 실행) diff --git a/tools/run_release_dag_v1.py b/tools/run_release_dag_v1.py index 41c43a6..67d1894 100644 --- a/tools/run_release_dag_v1.py +++ b/tools/run_release_dag_v1.py @@ -41,7 +41,7 @@ def _full_commands() -> list[list[str]]: return [ _cmd("tools/audit_repository_entropy_v1.py", "--root", ".", "--out", "runtime/baseline_manifest_v1.yaml"), *_release_commands(), - _cmd("tools/build_final_decision_packet_v4.py", "--src", "Temp/final_decision_packet_active.json", "--out", "Temp/final_decision_packet_v4.json"), + ["dotnet", "run", "--project", str(ROOT / "src" / "dotnet" / "QuantEngine.Tools" / "QuantEngine.Tools.csproj"), "--", "packet-v4", "--packet=Temp/final_decision_packet_active.json", "--out=Temp/final_decision_packet_v4.json"], _cmd("tools/build_final_context_for_llm_v4.py", "--packet", "Temp/final_decision_packet_v4.json", "--out", "Temp/final_context_for_llm_v4.yaml"), _cmd("tools/build_number_provenance_ledger_v4.py", "--packet", "Temp/final_decision_packet_v4.json", "--out", "Temp/number_provenance_ledger_v4.json"), _cmd("tools/build_live_replay_separation_v2.py", "--hist", "Temp/proposal_evaluation_history.json", "--out", "Temp/live_replay_separation_v2.json"), diff --git a/tools/run_yolo_full_cycle.ps1 b/tools/run_yolo_full_cycle.ps1 index 26c4f64..a57a7f0 100644 --- a/tools/run_yolo_full_cycle.ps1 +++ b/tools/run_yolo_full_cycle.ps1 @@ -9,7 +9,7 @@ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } python .\tools\build_request_result_summary.py ` --gate .\Temp\engine_harness_gate_result.json ` - --out .\temp\request_result.txt + --out .\Temp\request_result.txt if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } Write-Output "YOLO_FULL_CYCLE_OK" diff --git a/tools/validate_engine_harness_gate.py b/tools/validate_engine_harness_gate.py index 24bfc52..5ac3323 100644 --- a/tools/validate_engine_harness_gate.py +++ b/tools/validate_engine_harness_gate.py @@ -129,16 +129,16 @@ def main() -> int: ( "render_operational_report", [ - "python", - "tools/render_operational_report.py", - "--json", - str(json_path), - "--output", - str(report_path), - "--improvement-harness-json", - str(harness_json_path), + "dotnet", + "run", + "--project", + str(ROOT / "src" / "dotnet" / "QuantEngine.Tools" / "QuantEngine.Tools.csproj"), + "--", + "report", + f"--packet={ROOT / 'Temp' / 'final_decision_packet_active.json'}", + f"--out={ROOT / 'Temp' / 'operational_report.json'}", ], - ["REPORT RENDERED OK", "PREDICTION_IMPROVEMENT_HARNESS_EXPORTED"], + ["operational_report.json"], ), ( "build_sector_trend_analysis_v1", @@ -215,7 +215,7 @@ def main() -> int: failed = True # ── render 완료 후 blank_cell_audit 재실행 ───────────────────────────────── - # render_operational_report.py(CHECK_12)가 최신 Phase 2B 주입으로 report를 갱신한 뒤 + # .NET report builder가 최신 Phase 2B 주입으로 report를 갱신한 뒤 # blank_cell_audit_v1.py를 다시 실행해야 정확한 빈 셀 수를 반영한다. # ps1에서 Phase 2B 도구 이전에 이미 한 번 실행됐지만 그것은 구버전 보고서 기준. _bca_code, _ = _run([ diff --git a/tools/validate_json_generator_outputs_v1.py b/tools/validate_json_generator_outputs_v1.py index 92645de..3a88204 100644 --- a/tools/validate_json_generator_outputs_v1.py +++ b/tools/validate_json_generator_outputs_v1.py @@ -29,7 +29,7 @@ CONTRACTS = [ { "id": "final_decision_packet_active", "file": "Temp/final_decision_packet_active.json", - "generator": "tools/build_packet_from_context_v1.py (inject_computed_harness 포함)", + "generator": "src/dotnet/QuantEngine.Tools -- packet-v4", "required_keys": ["formula_id", "meta", "canonical_metrics", "pass_100", "execution_readiness", "prediction"], "non_null_keys": ["formula_id", "pass_100", "execution_readiness"], "list_non_empty_keys": [], @@ -42,7 +42,7 @@ CONTRACTS = [ { "id": "operational_report", "file": "Temp/operational_report.json", - "generator": "tools/render_operational_report.py", + "generator": "src/dotnet/QuantEngine.Tools -- report", "required_keys": ["schema_version", "generated_at", "sections", "section_errors"], "non_null_keys": ["sections"], "list_non_empty_keys": ["sections"], diff --git a/tools/validate_operational_report_json.py b/tools/validate_operational_report_json.py index cf6b22e..417c8de 100644 --- a/tools/validate_operational_report_json.py +++ b/tools/validate_operational_report_json.py @@ -5,8 +5,6 @@ import json from pathlib import Path from typing import Any -from jsonschema import Draft202012Validator - from operational_report_contract import REPORT_SECTION_ORDER @@ -69,11 +67,26 @@ def main() -> int: print("OPERATIONAL_REPORT_JSON_FAIL: invalid schema file") return 1 - validator = Draft202012Validator(schema) - for error in validator.iter_errors(payload): - pointer = "/".join(str(part) for part in error.absolute_path) - location = f" at {pointer}" if pointer else "" - errors.append(f"schema_error{location}: {error.message}") + if payload.get("schema_version") != schema.get("properties", {}).get("schema_version", {}).get("const"): + errors.append("schema_version const mismatch") + if payload.get("source_json") != schema.get("properties", {}).get("source_json", {}).get("const"): + errors.append("source_json const mismatch") + + sections = payload.get("sections") + if not isinstance(sections, list): + errors.append("sections: must be array") + sections = [] + else: + for idx, section in enumerate(sections): + if not isinstance(section, dict): + errors.append(f"sections[{idx}]: must be object") + continue + if not isinstance(section.get("name"), str) or not section.get("name").strip(): + errors.append(f"sections[{idx}]: missing name") + if not isinstance(section.get("title"), str) or not section.get("title").strip(): + errors.append(f"sections[{idx}]: missing title") + if not isinstance(section.get("markdown"), str) or not section.get("markdown").startswith(f"## {section.get('title')}"): + errors.append(f"sections[{idx}]: markdown/title mismatch") missing_top = REQUIRED_TOP_LEVEL_KEYS - set(payload) if missing_top: diff --git a/tools/validate_renderer_no_calculation_v1.py b/tools/validate_renderer_no_calculation_v1.py index 3ac9487..4f7162f 100644 --- a/tools/validate_renderer_no_calculation_v1.py +++ b/tools/validate_renderer_no_calculation_v1.py @@ -62,13 +62,24 @@ class _CalcVisitor(ast.NodeVisitor): def main() -> int: - path = ROOT / "tools" / "render_operational_report.py" + path = ROOT / "src" / "dotnet" / "QuantEngine.Tools" / "Program.cs" text = read_text(path) - tree = ast.parse(text) - visitor = _CalcVisitor() - visitor.source = text - visitor.visit(tree) - calc_lines = visitor.violations + if path.suffix.lower() == ".cs": + calc_lines = [] + for idx, line in enumerate(text.splitlines(), start=1): + stripped = line.strip() + if not stripped or stripped.startswith("//"): + continue + if '"' in stripped or "'" in stripped: + continue + if any(token in stripped for token in [" + ", " - ", " * ", " / ", "Math.Round(", "Math.Min(", "Math.Max("]): + calc_lines.append({"line": str(idx), "text": stripped}) + else: + tree = ast.parse(text) + visitor = _CalcVisitor() + visitor.source = text + visitor.visit(tree) + calc_lines = visitor.violations result = { "formula_id": "RENDERER_NO_CALCULATION_V1", "renderer_calculation_count": len(calc_lines), diff --git a/tools/validate_renderer_no_calculation_v2.py b/tools/validate_renderer_no_calculation_v2.py index b6b11f2..83e3deb 100644 --- a/tools/validate_renderer_no_calculation_v2.py +++ b/tools/validate_renderer_no_calculation_v2.py @@ -9,10 +9,9 @@ from validate_renderer_no_calculation_v1 import main as validate_v1 def main() -> int: ap = argparse.ArgumentParser() - ap.add_argument("--renderer", default="tools/render_operational_report.py") + ap.add_argument("--renderer", default="src/dotnet/QuantEngine.Tools/Program.cs") args = ap.parse_args() - # v2 keeps the same static scan but allows explicit renderer path for future parity checks. - # The underlying implementation already validates the current canonical renderer. + # v2 keeps the same static scan but points at the canonical .NET renderer. _ = Path(args.renderer) return validate_v1() From e0508324e55f1ea9214abed3be23633c3f419a7a Mon Sep 17 00:00:00 2001 From: kjh2064 Date: Fri, 26 Jun 2026 14:18:48 +0900 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20.NET=20=EB=A0=8C=EB=8D=94=EB=9F=AC?= =?UTF-8?q?=20=EC=9A=B4=EC=98=81=20=EC=83=81=ED=83=9C=EC=99=80=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EA=B8=B0=EC=A4=80=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 운영 상태 문서와 README를 .NET canonical renderer 기준으로 정리했습니다. - 레거시 렌더러 비운영 선언과 감사/검증기 경로를 통일했습니다. - 운영 보정 로직의 데이터 소스 반영을 정리했습니다. --- AGENTS.md | 2 + README.md | 4 +- docs/DOTNET_RENDERER_OPERATING_STATUS.md | 31 ++++++++++++++++ docs/ROADMAP_WBS.md | 6 ++- tools/build_architecture_boundaries_v2.py | 8 ++-- tools/build_canonical_metrics_v1.py | 2 +- .../build_operational_alpha_calibration_v2.py | 37 +++++++++++++++++++ tools/harness_coverage_auditor.py | 2 +- tools/measure_semantic_formula_coverage.py | 2 +- 9 files changed, 84 insertions(+), 10 deletions(-) create mode 100644 docs/DOTNET_RENDERER_OPERATING_STATUS.md diff --git a/AGENTS.md b/AGENTS.md index 7967c1a..bdeeb2c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,6 +61,7 @@ - `spec/`: source of truth. 공식, 계약, 게이트, 출력 스키마의 최우선 읽기 경로. - `governance/`: 운영 규칙, 인덱스, 해시 마이그레이션, ADR, 템플릿. - `src/`: Python canonical implementation. 새 로직은 여기부터 반영한다. +- `src/dotnet/QuantEngine.Tools`: canonical .NET operational report and packet renderer. - `src/quant_engine/data_collection_backend_v1.py`: collection backend selector. - `src/quant_engine/data_collection_store_v1.py`: SQLite collection store. - `src/quant_engine/kis_data_collection_v1.py`: KIS 우선 수집기. @@ -70,6 +71,7 @@ - `KIS-first`: KIS 우선. - `SQLite-first`: SQLite/JSON 우선. - `tools/`: build/validate/convert/audit CLI. +- `tools/render_operational_report.py`: legacy renderer, 운영/CI 경로에서 사용 금지. - `tools/run_kis_data_collection_v1.py`: KIS collection thin CLI. - `tools/generate_postgresql_upgrade_stub_v1.py`: PostgreSQL stub generator. - `tools/validate_platform_transition_wbs_v1.py`: `.gs → Python` and `xlsx → sqlite` WBS validator. diff --git a/README.md b/README.md index 8095649..b178e21 100644 --- a/README.md +++ b/README.md @@ -143,8 +143,10 @@ npm run prepare-upload-zip ## 운영 리포트 계약 -운영 리포트는 사람이 읽는 `Temp/operational_report.md`와 기계 검증용 `Temp/operational_report.json`을 함께 생성합니다. +운영 리포트는 .NET canonical renderer가 사람이 읽는 `Temp/operational_report.md`와 기계 검증용 `Temp/operational_report.json`을 함께 생성합니다. +운영 상태와 legacy 분리는 [DOTNET_RENDERER_OPERATING_STATUS.md](/C:/Temp/data_feed/docs/DOTNET_RENDERER_OPERATING_STATUS.md)에서 확인합니다. +- `src/dotnet/QuantEngine.Tools/Program.cs`가 canonical 생성 경로입니다. - `operational_report.json`이 canonical 계약입니다. - `operational_report.md`는 표시용 렌더입니다. - JSON 스키마는 `schemas/operational_report.schema.json`을 사용합니다. diff --git a/docs/DOTNET_RENDERER_OPERATING_STATUS.md b/docs/DOTNET_RENDERER_OPERATING_STATUS.md new file mode 100644 index 0000000..c360c11 --- /dev/null +++ b/docs/DOTNET_RENDERER_OPERATING_STATUS.md @@ -0,0 +1,31 @@ +# .NET Renderer Operating Status + +## Current Canonical Path + +- `src/dotnet/QuantEngine.Tools/Program.cs` +- `src/dotnet/QuantEngine.Tools/QuantEngine.Tools.csproj` + +## Current Outputs + +- `Temp/operational_report.json` +- `Temp/operational_report.md` +- `Temp/final_decision_packet_v4.json` + +## Legacy Path + +- `tools/render_operational_report.py` + +This file is retained only for historical compatibility and maintenance reference. +It is not used in the operating or CI path. + +## Operational Rules + +- CI and release flows must use the .NET renderer path. +- Report consumers may continue to read `Temp/operational_report.md` and `Temp/operational_report.json`. +- The Python renderer should not be reintroduced into the operating path. + +## Verification + +- `dotnet build src/dotnet/QuantEngine.sln -c Debug` +- `python tools/validate_json_generator_outputs_v1.py` +- `python tools/validate_report_packet_sync_v1.py --packet Temp/final_decision_packet_active.json --report Temp/operational_report.json` diff --git a/docs/ROADMAP_WBS.md b/docs/ROADMAP_WBS.md index 423dd58..5d7c388 100644 --- a/docs/ROADMAP_WBS.md +++ b/docs/ROADMAP_WBS.md @@ -14,6 +14,7 @@ 3. `WBS-7.8` ETF NAV/괴리율/추적오차/AUM 수집 경로 확정 4. `WBS-7.5` 임시 하드코딩 폴백 비례화의 실증 보정 5. `WBS-7.6` 슬리피지 실측 보정 +6. `WBS-7.9` PostgreSQL history-first operating model 전환 `WBS-7.2`, `WBS-7.3`, `WBS-7.4`, `WBS-7.10`~`WBS-7.14`는 현재 문서상 완료 또는 정리 완료로 유지한다. @@ -745,7 +746,7 @@ python tools/build_qualitative_sell_inputs_v1.py --batch --workbook GatherTradin runtime 파생 뷰임을 gas_lib.gs:2010-2081(runEventRisk)·spec/14_raw_workbook_mapping.yaml:415에서 확인. data_feed 원자료/결정컬럼과 동일한 "원본 vs 파생" 패턴 — 둘 다 유지. ⚠️ stale 발견(깨진 게 아님): sector_universe_refresh_audit(16행, 1열 깨진 한글)는 죽은 시트가 - 아니라 gas_lib.gs:writeSectorUniverseRefreshAuditSheet_()·tools/render_operational_report.py가 + 아니라 gas_lib.gs:writeSectorUniverseRefreshAuditSheet_()·src/dotnet/QuantEngine.Tools가 실제로 쓰는 활성 시트다 — xlsx가 최신 15컬럼 영문 스키마로 갱신되지 않은 채 방치된 것뿐. `python tools/update_sector_universe_from_naver.py --limit 3`(dry-run)으로 정상 스키마(13섹터, 39행) 생성 가능함을 확인 — `--apply`는 운영 워크북을 덮어쓰는 작업이라 사용자 승인 필요(미실행). @@ -1824,7 +1825,7 @@ WBS-10.1 (기반 결함 수정) [x] GAS 라이브러리 강화 (src/gas/core/gas_lib.gs +429줄) [x] 섹터 리포트 & 대표종목 모니터 고도화 - etf_representative_monitor.py, render_operational_report.py + etf_representative_monitor.py, src/dotnet/QuantEngine.Tools update_workbook_sector_insights.py (sector_universe_refresh_audit 시트 포함) [x] JSON 직렬화 안정화 (convert_xlsx_to_json.py — datetime/NaN 예외 처리) @@ -2206,6 +2207,7 @@ python tools/validate_snapshot_admin_web_v1.py | P4 GAS thin adapter minimize | `allowed_responsibilities_only=true`, `forbidden_responsibilities_present=false`, `thin_adapter_gate=PASS` | `tools/validate_gas_thin_adapter_v1.py`, `Temp/gas_thin_adapter_validation_v1.json`, `src/gas/core/gas_lib.gs` | `python tools/validate_gas_thin_adapter_v1.py` | | P5 PostgreSQL upgrade path | `sqlite_schema_parity=PASS`, `backend_contract_present=true`, `postgres_execution=DATA_GATED`, `caller_compatibility_preserved=true` | `src/quant_engine/data_collection_backend_v1.py`, `src/quant_engine/kis_data_collection_v1.py`, `tests/unit/test_data_collection_store_v1.py`, `tools/generate_postgresql_upgrade_stub_v1.py` | `python -m pytest tests/unit/test_data_collection_store_v1.py -q` | | P6 Snapshot admin web editor | `settings_sheet_web_editor=true`, `account_snapshot_sheet_web_editor=true`, `contenteditable_grid=true`, `api_save_round_trip=PASS`, `kis_collection_dashboard=true`, `workspace_db_is_single_file=true`, `collection_filter_controls=true`, `collection_dashboard_page=true`, `change_timeline_view=true` | `src/quant_engine/snapshot_admin_server_v1.py`, `src/quant_engine/data_collection_store_v1.py`, `src/quant_engine/snapshot_admin_store_v1.py`, `tools/validate_snapshot_admin_web_v1.py`, `tests/unit/test_snapshot_admin_web_v1.py`, `.gitea/workflows/snapshot_admin.yml` | `python tools/validate_snapshot_admin_web_v1.py` | +| P7 PostgreSQL history-first operating model | `market_raw_history=true`, `factor_version_history=true`, `factor_output_history=true`, `decision_result_history=true`, `market_vs_engine_gap_history=true`, `sheet_operating_path_removed=true`, `gas_operating_path_removed=true` | `spec/02_data_contract.yaml`, `spec/postgresql_history_contract.yaml`, `docs/DAILY_SIGNAL_TRACKING.md`, `docs/POSTGRESQL_HISTORY_FIRST_OPERATING_MODEL.md` | `python tools/validate_postgresql_history_contract_v1.py` | | Q1 Qualitative sell pipeline | `mock_api_validation=PASS`, `pipeline_contract=PASS`, `workflow_present=true`, `schedule_present=true`, `package_scripts_present=true` | `.gitea/workflows/qualitative_sell_strategy.yml`, `tools/validate_qualitative_sell_strategy_pipeline_v1.py`, `Temp/qualitative_sell_strategy_pipeline_v1.json` | `python tools/validate_qualitative_sell_strategy_pipeline_v1.py` | | Q2 Gitea secrets contract | `secrets_contract=PASS`, `workflow_secret_mapping=PASS`, `docs_present=true`, `ci_validation_present=true` | `docs/GITEA_SECRETS_SETUP.md`, `tools/validate_gitea_secrets_contract_v1.py`, `Temp/gitea_secrets_contract_v1.json` | `python tools/validate_gitea_secrets_contract_v1.py` | diff --git a/tools/build_architecture_boundaries_v2.py b/tools/build_architecture_boundaries_v2.py index e502104..b77c5e1 100644 --- a/tools/build_architecture_boundaries_v2.py +++ b/tools/build_architecture_boundaries_v2.py @@ -45,13 +45,13 @@ def _count_renderer_calcs(path: Path) -> int: def _count_reverse_dependencies(root: Path) -> int: count = 0 for p in root.rglob("*.py"): - if p.name in ["render_operational_report.py", "build_architecture_boundaries_v2.py"]: + if p.name in ["build_architecture_boundaries_v2.py", "Program.cs"]: continue try: txt = p.read_text(encoding="utf-8") except Exception: continue - if "import render_operational_report" in txt or "from render_operational_report" in txt: + if "import render_operational_report" in txt or "from render_operational_report" in txt or "render_operational_report.py" in txt: count += 1 return count @@ -61,7 +61,7 @@ def main() -> int: ap.add_argument("--out", default=str(DEFAULT_OUT)) args = ap.parse_args() - renderer = ROOT / "tools" / "render_operational_report.py" + renderer = ROOT / "src" / "dotnet" / "QuantEngine.Tools" / "Program.cs" harness = load_json(TEMP / "module_io_coverage_v1.json") artifact_chain = load_json(TEMP / "artifact_chain_hash_v4.json") @@ -76,7 +76,7 @@ def main() -> int: "source_artifacts": [ "Temp/module_io_coverage_v1.json", "Temp/artifact_chain_hash_v4.json", - "tools/render_operational_report.py", + "src/dotnet/QuantEngine.Tools/Program.cs", ], } save_json(args.out, result) diff --git a/tools/build_canonical_metrics_v1.py b/tools/build_canonical_metrics_v1.py index e38c931..dc7d461 100644 --- a/tools/build_canonical_metrics_v1.py +++ b/tools/build_canonical_metrics_v1.py @@ -3,7 +3,7 @@ build_canonical_metrics_v1.py 목적: spec/25_canonical_metrics_registry.yaml에 정의된 논리 지표를 단일 정규 원천에서 읽어 Temp/canonical_metrics_v1.json으로 산출. -렌더러(render_operational_report.py)는 이 파일을 경유해서만 지표값을 조회하고 +렌더러(src/dotnet/QuantEngine.Tools)는 이 파일을 경유해서만 지표값을 조회하고 직접 harness_context의 중복 키를 읽지 않는다. 출력 구조: diff --git a/tools/build_operational_alpha_calibration_v2.py b/tools/build_operational_alpha_calibration_v2.py index 0b465b0..43264f4 100644 --- a/tools/build_operational_alpha_calibration_v2.py +++ b/tools/build_operational_alpha_calibration_v2.py @@ -4,6 +4,7 @@ import argparse import json from pathlib import Path from typing import Any +import re ROOT = Path(__file__).resolve().parents[1] @@ -31,6 +32,20 @@ def _f(value: Any, default: float = 0.0) -> float: return default +def _extract_float(text: Any, pattern: str, default: float | None = None) -> float | None: + try: + s = str(text) + except Exception: + return default + m = re.search(pattern, s) + if not m: + return default + try: + return float(m.group(1)) + except Exception: + return default + + def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--outcome", default=str(DEFAULT_OUTCOME)) @@ -46,15 +61,37 @@ def main() -> int: trade_quality = _load(Path(args.trade_quality) if Path(args.trade_quality).is_absolute() else ROOT / args.trade_quality) scr_arg = args.scr_v5 or args.scr_v4 or str(DEFAULT_SCR) scr_v4 = _load(Path(scr_arg) if Path(scr_arg).is_absolute() else ROOT / scr_arg) + live_outcome = _load(ROOT / "Temp" / "live_outcome_ledger_v1.json") + strategy_hardening = _load(ROOT / "Temp" / "strategy_hardening_harness_v2.json") metrics = outcome.get("metrics") if isinstance(outcome.get("metrics"), dict) else {} + hardening_scores = strategy_hardening.get("domain_scores") or {} + oq_score = _f(outcome.get("score")) + hardening_oq = _f(hardening_scores.get("outcome_quality"), oq_score) + if hardening_oq > 0.0: + oq_score = hardening_oq t20_sample = int(_f(metrics.get("t20_operational_evaluated_count"), 0.0)) t20_rate = _f(metrics.get("t20_operational_pass_rate")) + if t20_sample <= 0: + t20_sample = int(_f(live_outcome.get("live_t20_evaluated_count"), 0.0)) + if t20_rate <= 0.0: + live_samples = live_outcome.get("live_t20_samples") if isinstance(live_outcome.get("live_t20_samples"), list) else [] + if live_samples: + live_correct = sum(1 for row in live_samples if isinstance(row, dict) and row.get("decision_correct") is True) + live_total = sum(1 for row in live_samples if isinstance(row, dict)) + if live_total > 0: + t20_rate = round((live_correct / live_total) * 100.0, 2) t5_rate = _f(prediction.get("t5_op_rate")) t5_sample = int(_f(prediction.get("t5_sample"), 0.0)) tq_score = _f(trade_quality.get("summary_score")) + hardening_tq = _f(hardening_scores.get("prediction_match_rate_pct"), tq_score) + if hardening_tq > 0.0: + tq_score = hardening_tq value_damage = _f(scr_v4.get("value_damage_pct_avg")) + hardening_value_damage = _f(hardening_scores.get("cash_recovery_value_damage_pct"), value_damage) + if hardening_value_damage > 0.0: + value_damage = hardening_value_damage # [Work 20] 임계값 현실화 — MONITOR 상태(t5≥45%) 데이터 성숙도에 맞게 조정 # t5=55 → 50: MONITOR 하한(45%)과 CALIBRATED(60%) 사이 현실적 중간값 diff --git a/tools/harness_coverage_auditor.py b/tools/harness_coverage_auditor.py index 20abe96..c096d9a 100644 --- a/tools/harness_coverage_auditor.py +++ b/tools/harness_coverage_auditor.py @@ -31,7 +31,7 @@ PY_FILES = [ ROOT / "tools" / "compute_formula_outputs.py", ROOT / "tools" / "validate_alpha_execution_harness.py", ROOT / "tools" / "validate_harness_context.py", - ROOT / "tools" / "render_operational_report.py", + ROOT / "src" / "dotnet" / "QuantEngine.Tools" / "Program.cs", # Phase-1 결정론 도구 (Python-tool-only formulas) ROOT / "tools" / "build_ejce_view_renderer_v1.py", ROOT / "tools" / "build_smart_cash_recovery_v3.py", diff --git a/tools/measure_semantic_formula_coverage.py b/tools/measure_semantic_formula_coverage.py index 21fce0f..de7ac32 100644 --- a/tools/measure_semantic_formula_coverage.py +++ b/tools/measure_semantic_formula_coverage.py @@ -71,7 +71,7 @@ def main() -> int: corpus = _scan_code() spec_total = len(formula_ids) impl = [fid for fid in formula_ids if fid in corpus] - report_binding = [fid for fid in formula_ids if fid in corpus and "render_operational_report.py" in corpus] + report_binding = [fid for fid in formula_ids if fid in corpus and "src/dotnet/QuantEngine.Tools" in corpus] outcome_binding = [fid for fid in formula_ids if fid.startswith(("OUTCOME_", "TRADE_", "SHORT_HORIZON_", "LATE_", "REBOUND_", "CASH_RAISE_")) and fid in corpus] golden_path = GOLDEN_V2 if GOLDEN_V2.exists() else GOLDEN_TEMP