diff --git a/CLAUDE.md b/CLAUDE.md index 50569cbe..ee5fbf24 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -286,9 +286,16 @@ Pipeline) and Collection Run status/success-criteria definitions: ## OpenDART Fallback API (2026-07-30) -**Credential**: `DART_API_KEY` 환경변수 (KIS 키와 동일한 패턴 — 코드/문서/워크플로우 파일에 -절대 하드코딩하지 않는다. 실제 키 값은 로컬 환경변수 또는 Gitea Secrets에만 존재해야 한다). -로딩 위치: `tools/ingest_fundamental_raw.py:55` (`os.environ.get("DART_API_KEY")`). +**Credential**: Gitea Secrets에는 `OPENDART_OPENAPI_KEY`라는 이름으로 이미 등록되어 있음 +(사용자 확인, 2026-07-30). **⚠️ 이름 불일치 주의**: 실제 코드(`tools/ingest_fundamental_raw.py:55`, +`os.environ.get("DART_API_KEY")`)는 `DART_API_KEY`라는 *다른* 환경변수명을 읽는다. `.gitea/workflows/` +전체를 확인했지만 `OPENDART_OPENAPI_KEY` → `DART_API_KEY` 매핑이 어떤 워크플로우에도 없다 — +즉 지금 이 스크립트를 CI에서 돌리면 Gitea Secrets에 값이 있어도 코드가 못 읽는다. 다음 중 +하나로 해소해야 함(사람 판단 필요, 여기서는 실행하지 않음): + (a) 워크플로우 step에 `env: DART_API_KEY: ${{ secrets.OPENDART_OPENAPI_KEY }}` 매핑 추가, 또는 + (b) 코드가 읽는 환경변수명을 `OPENDART_OPENAPI_KEY`로 통일. +로컬 개발 시에는 이름 불일치와 무관하게 `DART_API_KEY`로 설정해야 현재 코드가 그대로 동작한다. +실제 키 값은 코드/문서/워크플로우 파일에 절대 하드코딩하지 않는다(KIS와 동일 원칙). **⚠️ CRITICAL: 일일 호출 한도 40,000건** — OpenDART는 계정당 하루 40,000건으로 제한된다. 이 한도를 넘으면 그날 나머지 호출이 전부 실패한다. DART를 호출하는 모든 코드(현재는 @@ -302,6 +309,23 @@ Pipeline) and Collection Run status/success-criteria definitions: - 일일 호출 수를 실제로 추적하는 카운터가 아직 없다 — 실 데이터 호출을 구현할 때 호출 카운트를 파일 또는 DB에 기록하고 한도 근접 시 중단하는 가드를 함께 추가한다. +## KRX Open API (2026-07-30, 미구현) + +**Credential**: Gitea Secrets에 `KRX_OPENAPI_KEY`라는 이름으로 이미 등록되어 있음 +(사용자 확인, 2026-07-30). 코드/문서/워크플로우 파일에 실제 값을 절대 하드코딩하지 않는다 +(KIS와 동일 원칙) — 로컬 개발 시 환경변수로도 `KRX_OPENAPI_KEY`를 그대로 쓸 것(아직 이를 +읽는 코드가 없으므로 새 이름 지어낼 필요 없음). + +**⚠️ 2026-07-30 확인**: 저장소 전체에 KRX Open API를 실제로 호출하는 클라이언트 코드가 +**아직 존재하지 않는다** (`KRX`라는 단어 자체는 시장/거래소 명칭으로 여러 spec 파일에 +등장하지만, API 키를 쓰는 실제 호출부는 없음 — grep으로 확인). `.gitea/workflows/`에도 +`KRX_OPENAPI_KEY`를 참조하는 곳이 없다. 실제 구현 시: +- KIS/OpenDART와 마찬가지로 일일/분당 호출 한도를 KRX 공식 문서에서 확인하고 호출 예산을 + 먼저 설계할 것 (한도를 여기 문서에 기록하지 않은 이유: 사용자로부터 아직 전달받지 않음 — + 추측해서 적지 않는다). +- 신규 클라이언트 추가 시 `AssertReadOnly`류 거버넌스 가드가 필요한지(주문 API 존재 여부) + KRX 공식 문서로 먼저 확인. + ## Local Development & Testing ### Mandatory Pre-Deployment Checklist @@ -344,7 +368,7 @@ troubleshooting table: [docs/DEV_WORKFLOWS.md](docs/DEV_WORKFLOWS.md) - **Legacy Code**: No `QuantEngine.Web.Client` remains in the repo — fully removed (verified 2026-07-30), not just excluded from `.sln`. - **DBML Sync (2026-07-12)**: All DbUp migrations (`V*.sql`) must sync with `docs/db/quantengine.dbml` in same commit. Future schema reads use DBML. -- **Migration naming (2026-07-30)**: Existing migrations mix two conventions — `V1__Name.sql` (double underscore, unpadded) and `V003_name.sql` (single underscore, zero-padded). DbUp sorts scripts alphabetically, so `V003_...` actually runs *before* `V1__...`. **Do not rename existing migration files** — DbUp journals applied scripts by filename/checksum, so a rename makes an already-applied migration look new and re-runs it. New migrations must use `V{next_number}__Name.sql` (double underscore, matching V1–V8, the majority convention) so filename order matches intended execution order. **Note**: verified against production (`quantengine.schemaversions`, 2026-07-30) that `V003`/`V004` have never actually run — only V1–V8 are journaled. Renumbering V003/V004 to `V9__`/`V10__` is therefore safe from a checksum standpoint, but changes their execution order relative to V1–V8 (they currently run *first*); that's a real design decision (open, not made here), not a blind rename. +- **Migration naming/ordering (2026-07-30, fixed)**: `V003_add_audit_trail_tables.sql` and `V004_normalize_snapshots_schema.sql` used to sort *before* `V1__Initial_Schema.sql` under DbUp's default ordinal filename sort (zero-padded `V003` < unpadded `V1`). Verified against production that neither had ever actually applied (absent from `quantengine.schemaversions`) — and investigation showed *why*: `V004` has an unguarded FK to a table `V2` creates, so on a fresh database it would hard-fail and abort every migration after it (V1–V8 would never run at all); `V003`'s trigger-creation guards would have silently no-op'd forever for the same reason. Fixed by renaming them to `V9__Add_Audit_Trail_Tables.sql` / `V10__Normalize_Snapshots_Schema.sql` (safe — never journaled anywhere) **and** adding `MigrationScriptNameComparer` (`QuantEngine.Infrastructure/Data/MigrationScriptNameComparer.cs`, wired into `DbMigrator.cs` via `.WithScriptNameComparer(...)`), which sorts scripts by numeric `V{n}` value instead of raw string order — so `V10` correctly sorts after `V9`/`V2`/etc. regardless of digit count. This makes the whole scheme collision-proof going forward: new migrations can just use the next integer, `V{n}__Name.sql`, with no padding needed. **Still do not rename V1–V8** — those are already journaled in production, and DbUp identifies applied scripts by filename. - **Diagrams**: Mermaid diagrams in `docs/diagrams/` for state machines, flows, sequences (e.g., collection-pipeline.md). - **WBS Evidence (2026-07-12)**: Task completion = `npm run verify:task -- ` PASS (BE=DB queries/logs/JSON, FE=Playwright+screenshot). Full check: `npm run verify:wbs`. diff --git a/docs/db/quantengine.dbml b/docs/db/quantengine.dbml index 9c132261..0fe03dc6 100644 --- a/docs/db/quantengine.dbml +++ b/docs/db/quantengine.dbml @@ -1,13 +1,16 @@ // ============================================================================= // QuantEngine Database Schema (DBML) -// DbUp 마이그레이션(V1~V8, V003, V004)과 1:1 동기화 — 마이그레이션 추가 시 이 파일도 반드시 갱신 +// DbUp 마이그레이션(V1~V10)과 1:1 동기화 — 마이그레이션 추가 시 이 파일도 반드시 갱신 // (CLAUDE.md 규칙: schema 변경 → DBML + 문서 동기화) // -// 마이그레이션 파일명 규칙 2종 혼재 (2026-07-30 발견, 미해결): -// V1__Name.sql .. V8__Name.sql (더블언더스코어, zero-pad 없음) -// V003_name.sql, V004_name.sql (싱글언더스코어, zero-pad) -// DbUp는 파일명 알파벳순으로 실행하므로 "V003" < "V1" 순서로 적용됨 — 신규 마이그레이션은 -// 반드시 하나의 규칙(권장: V{n}__Name.sql)만 사용할 것. +// 2026-07-30 해결됨: 예전에 V003_name.sql/V004_name.sql(싱글언더스코어, zero-pad)이 +// V1__Name.sql(더블언더스코어, zero-pad 없음) 방식과 섞여 있어, DbUp의 기본 알파벳순 +// 정렬에서 "V003" < "V1"로 먼저 실행되는 문제가 있었다 (V004는 V2가 만드는 테이블에 대한 +// 하드 FK 제약이 있어 빈 DB에 처음부터 배포하면 V004에서 하드 실패 → V1~V8 전체가 실행 +// 안 되는 재현성 버그였음). 조치: V003→V9, V004→V10으로 리네임 + DbMigrator.cs에 +// MigrationScriptNameComparer(숫자 기반 비교자)를 추가해 "V{n}"이 몇 자리 숫자든 항상 +// 숫자 크기순으로 정렬되도록 함. 신규 마이그레이션은 V{n}__Name.sql 규칙만 사용할 것 +// (이 비교자 덕분에 V11, V12... 로 계속 늘어나도 더 이상 이 문제가 재발하지 않는다). // // 참고: Hangfire 스키마는 Hangfire.PostgreSql 라이브러리가 자동 생성 // (DbUp 마이그레이션으로 관리하지 않음, 여기서도 제외) @@ -504,14 +507,15 @@ Table engine_history.outcome_evaluation { } // ============================================================================= -// V003: Audit Trail Tables (quantengine schema, 2026-07-24) +// V9: Audit Trail Tables (quantengine schema, 파일: V9__Add_Audit_Trail_Tables.sql, +// 원래 이름 V003_add_audit_trail_tables.sql, 2026-07-30에 V9로 리네임 — 위 헤더 참고) // -// 2026-07-30 확정 (실제 프로덕션 DB 조회로 검증): 이 마이그레이션은 CREATE TABLE 안에 -// MySQL 전용 인라인 "INDEX name (cols)" 구문을 사용해 PostgreSQL에서 문법 오류로 실패했다. -// quantengine.schemaversions(DbUp 저널)에 V003이 아예 기록되어 있지 않고, 아래 3개 테이블도 -// 프로덕션에 실제로 존재하지 않음을 직접 확인했다. V003_add_audit_trail_tables.sql의 인라인 -// INDEX 구문은 이미 별도 CREATE INDEX 문으로 수정됐으므로, 다음 배포 시 DbUp가 이 마이그레이션을 -// 최초로 실행해 아래 3개 테이블을 생성할 것이다. +// 2026-07-30 확정 (실제 프로덕션 DB 조회로 검증): 리네임 전 이 마이그레이션은 CREATE TABLE +// 안에 MySQL 전용 인라인 "INDEX name (cols)" 구문을 사용해 PostgreSQL에서 문법 오류로 +// 실패했고, quantengine.schemaversions(DbUp 저널)에도 전혀 기록되어 있지 않았다 — 아래 3개 +// 테이블은 프로덕션에 실제로 존재하지 않음을 확인했다. 인라인 INDEX 구문은 별도 CREATE INDEX +// 문으로 수정했고, V003→V9 리네임으로 실행 순서 문제도 해결했으므로 다음 배포 시 DbUp가 이 +// 마이그레이션을 최초로 실행해 아래 3개 테이블을 생성할 것이다. // ============================================================================= Table quantengine.kis_collection_runs_audit { @@ -557,8 +561,10 @@ Table quantengine.kis_collection_errors_audit { } // ============================================================================= -// V004: 3NF Normalization / Star Schema (quantengine schema, Adapter 패턴으로 -// 기존 kis_collection_snapshots와 병행 운영 — 마이그레이션 자체 주석에 명시됨) +// V10: 3NF Normalization / Star Schema (quantengine schema, 파일: +// V10__Normalize_Snapshots_Schema.sql, 원래 이름 V004_normalize_snapshots_schema.sql, +// 2026-07-30에 V10로 리네임 — 위 헤더 참고. Adapter 패턴으로 기존 +// kis_collection_snapshots와 병행 운영 — 마이그레이션 자체 주석에 명시됨) // ============================================================================= Table quantengine.stocks { @@ -611,14 +617,21 @@ Table quantengine.kis_collection_snapshots_v2 { // ============================================================================= // V8: PostgreSQL History-First Operating Model (quantengine schema) // -// ⚠️ 스키마 충돌 주의: 아래 4개 테이블(market_raw_history, factor_version_history, -// factor_output_history, decision_result_history)은 engine_history 스키마(V3, 위 참고)에 -// 이미 동일한 이름으로 존재한다. 컬럼 구조를 대조한 결과 같은 테이블의 재적용이 아니라 -// 서로 다른 두 가지 설계다: -// - engine_history.*: EAV형 원본 관측 이력 (field_name/field_value 페어) -// - quantengine.*(이 섹션): OHLCV 와이드 테이블 / 팩터ID-스코어 구조 -// 둘 다 실제 마이그레이션 파일에 존재하므로 DBML에는 두 스키마 버전을 모두 남긴다. -// 어느 쪽이 정본인지, 혹은 통합이 필요한지는 별도 아키텍처 결정 필요 (이번 작업 범위 밖). +// ⚠️ 동명이의 테이블 (통합 대상 아님, 2026-07-30 코드 조사로 확정): 아래 4개 테이블 +// (market_raw_history, factor_version_history, factor_output_history, +// decision_result_history)은 engine_history 스키마(V3, 위 참고)에도 같은 이름으로 존재하지만, +// 마이그레이션 버그도 아니고 죽은 코드도 아니다 — 둘 다 실제로 읽고 쓰는 라이브 코드가 있는 +// 서로 다른 두 모델이다: +// - engine_history.*: PostgresqlHistoryStore.AppendAsync() + HistoryIngestionService가 +// 쓰는 범용 append-only 이력 저장소 (EAV형 원본 관측 이력) +// - quantengine.*(이 섹션): PostgresqlHistoryStore의 RecordWaterfallExecutionAsync 등 +// + Admin 엔드포인트(BulkInsertMarketExcelEndpoint, UpdateFactorThresholdEndpoint, +// ExportStreamingFactorOlapEndpoint)가 쓰는 신형 구조화 운영 모델 (OHLCV 와이드 테이블 / +// 팩터ID-스코어 구조) +// 결론: 둘 다 유지해야 한다. 다만 같은 개념에 같은 테이블명을 두 스키마에서 쓰는 것 자체가 +// 향후 개발자가 착각하기 쉬우므로(예: quantengine.market_raw_history에 쓸 걸 실수로 +// engine_history.market_raw_history에 씀), 신규 코드 작성 시 스키마를 명시적으로 지정하고 +// 반드시 어느 모델을 쓰는지 주석으로 남길 것. // ============================================================================= Table quantengine.market_raw_history { diff --git a/src/dotnet/QuantEngine.Core.Tests/MigrationScriptNameComparerTests.cs b/src/dotnet/QuantEngine.Core.Tests/MigrationScriptNameComparerTests.cs new file mode 100644 index 00000000..e0b26c84 --- /dev/null +++ b/src/dotnet/QuantEngine.Core.Tests/MigrationScriptNameComparerTests.cs @@ -0,0 +1,51 @@ +using System.Linq; +using QuantEngine.Infrastructure.Data; + +namespace QuantEngine.Core.Tests; + +public class MigrationScriptNameComparerTests +{ + [Fact] + public void Sorts_DoubleDigit_Version_After_SingleDigit_Versions() + { + var scripts = new[] + { + "QuantEngine.Infrastructure.Migrations.V10__Normalize_Snapshots_Schema.sql", + "QuantEngine.Infrastructure.Migrations.V2__Add_Kis_Collections.sql", + "QuantEngine.Infrastructure.Migrations.V9__Add_Audit_Trail_Tables.sql", + "QuantEngine.Infrastructure.Migrations.V1__Initial_Schema.sql", + "QuantEngine.Infrastructure.Migrations.V8__PostgreSQL_History_First_Schema.sql", + }; + + var sorted = scripts.OrderBy(s => s, new MigrationScriptNameComparer()).ToArray(); + + Assert.Equal(new[] + { + "QuantEngine.Infrastructure.Migrations.V1__Initial_Schema.sql", + "QuantEngine.Infrastructure.Migrations.V2__Add_Kis_Collections.sql", + "QuantEngine.Infrastructure.Migrations.V8__PostgreSQL_History_First_Schema.sql", + "QuantEngine.Infrastructure.Migrations.V9__Add_Audit_Trail_Tables.sql", + "QuantEngine.Infrastructure.Migrations.V10__Normalize_Snapshots_Schema.sql", + }, sorted); + } + + [Fact] + public void Regression_ZeroPadded_Script_No_Longer_Sorts_Before_V1() + { + // This is the exact bug that shipped 2026-07-24: "V003_..." sorted before "V1__..." + // under plain ordinal comparison, so a migration with unmet table dependencies ran + // first. The comparer must treat "V003" as version 3, landing it between V2 and V4. + var scripts = new[] + { + "QuantEngine.Infrastructure.Migrations.V003_add_audit_trail_tables.sql", + "QuantEngine.Infrastructure.Migrations.V1__Initial_Schema.sql", + "QuantEngine.Infrastructure.Migrations.V4__Add_Initial_Admin.sql", + }; + + var sorted = scripts.OrderBy(s => s, new MigrationScriptNameComparer()).ToArray(); + + Assert.Equal("QuantEngine.Infrastructure.Migrations.V1__Initial_Schema.sql", sorted[0]); + Assert.Equal("QuantEngine.Infrastructure.Migrations.V003_add_audit_trail_tables.sql", sorted[1]); + Assert.Equal("QuantEngine.Infrastructure.Migrations.V4__Add_Initial_Admin.sql", sorted[2]); + } +} diff --git a/src/dotnet/QuantEngine.Infrastructure/Data/DbMigrator.cs b/src/dotnet/QuantEngine.Infrastructure/Data/DbMigrator.cs index 16335dbf..51f0a41b 100644 --- a/src/dotnet/QuantEngine.Infrastructure/Data/DbMigrator.cs +++ b/src/dotnet/QuantEngine.Infrastructure/Data/DbMigrator.cs @@ -28,6 +28,7 @@ namespace QuantEngine.Infrastructure.Data var upgrader = DeployChanges.To .PostgresqlDatabase(_connectionString) .WithScriptsEmbeddedInAssembly(typeof(DbMigrator).Assembly, s => s.StartsWith("QuantEngine.Infrastructure.Migrations")) + .WithScriptNameComparer(new MigrationScriptNameComparer()) .LogToConsole() .Build(); diff --git a/src/dotnet/QuantEngine.Infrastructure/Data/MigrationScriptNameComparer.cs b/src/dotnet/QuantEngine.Infrastructure/Data/MigrationScriptNameComparer.cs new file mode 100644 index 00000000..cdf331cf --- /dev/null +++ b/src/dotnet/QuantEngine.Infrastructure/Data/MigrationScriptNameComparer.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace QuantEngine.Infrastructure.Data +{ + /// + /// Orders DbUp migration scripts by their numeric V{n} version instead of plain ordinal + /// string order. Without this, "V10__Name.sql" sorts before "V2__Name.sql" (and, as + /// happened on 2026-07-24, zero-padded "V003_Name.sql" sorts before unpadded + /// "V1__Name.sql") because ordinal comparison looks at characters, not numeric value. + /// That mismatch let a migration with an unmet table dependency run first and silently + /// no-op, and another one run first and hard-fail, blocking every migration after it on a + /// fresh database. This comparer makes the "V{n}" scheme collision-proof regardless of + /// digit count or padding, so it can never happen again. + /// + public class MigrationScriptNameComparer : IComparer + { + private static readonly Regex VersionPattern = new(@"V(\d+)", RegexOptions.Compiled); + + public int Compare(string? x, string? y) + { + if (x == null || y == null) + { + return string.CompareOrdinal(x, y); + } + + var matchX = VersionPattern.Match(x); + var matchY = VersionPattern.Match(y); + + if (matchX.Success && matchY.Success) + { + var versionX = long.Parse(matchX.Groups[1].Value); + var versionY = long.Parse(matchY.Groups[1].Value); + if (versionX != versionY) + { + return versionX.CompareTo(versionY); + } + } + + return string.CompareOrdinal(x, y); + } + } +} diff --git a/src/dotnet/QuantEngine.Infrastructure/Migrations/V004_normalize_snapshots_schema.sql b/src/dotnet/QuantEngine.Infrastructure/Migrations/V10__Normalize_Snapshots_Schema.sql similarity index 100% rename from src/dotnet/QuantEngine.Infrastructure/Migrations/V004_normalize_snapshots_schema.sql rename to src/dotnet/QuantEngine.Infrastructure/Migrations/V10__Normalize_Snapshots_Schema.sql diff --git a/src/dotnet/QuantEngine.Infrastructure/Migrations/V003_add_audit_trail_tables.sql b/src/dotnet/QuantEngine.Infrastructure/Migrations/V9__Add_Audit_Trail_Tables.sql similarity index 100% rename from src/dotnet/QuantEngine.Infrastructure/Migrations/V003_add_audit_trail_tables.sql rename to src/dotnet/QuantEngine.Infrastructure/Migrations/V9__Add_Audit_Trail_Tables.sql