Files
QuantEngineByItz/tools/validate_price_history_integrity_v1.py
T
kjh2064 5589a0432b
Validators (Pushes and Pull Requests) / validate-ui-and-storage (push) Successful in 16s
Validators (Pushes and Pull Requests) / validate-core (push) Failing after 56s
feat(collection): wire KIS collection end-to-end, add price-history pipeline (WBS QE-M0/M1/M2)
Critical re-review of the QuantEngine WBS evidence system found several
regressions of the "no fake gates" discipline established by M0, plus a
still-unwired M1 collection path. This closes 10 more WBS tasks
(QE-M1-01..06, QE-M2-01/02/04/05/06 — see spec/60_quant_engine_wbs.yaml)
with real, gate-verified evidence (18/34 total).

M1 — real KIS data now lands in PostgreSQL end-to-end:
- SchedulerService: load ticker universe from GatherTradingData.json instead
  of a hardcoded array; fix a Hangfire scoped-service resolution bug.
- KisDataCollectionOrchestrator: restore logging on the lineage-event write
  path (was a bare `catch {}` swallowing all failures silently); persist
  daily OHLCV bars into quantengine.price_history_daily per run.
- Verified live: POST /api/collection/run -> Hangfire -> orchestrator ->
  KIS mock API -> PostgreSQL, with Playwright DOM/API parity evidence.

M2 — historical price-history pipeline:
- CollectionRepository: SavePriceHistoryDailyAsync (idempotent upsert),
  GetPriceHistorySummaryAsync (per-ticker aggregation) + a new
  DateOnlyTypeHandler registered globally, since Dapper has no built-in
  System.DateOnly support in either direction (write threw
  NotSupportedException, read threw a constructor-mismatch
  InvalidOperationException — found by exercising both paths live).
- tools/validate_price_history_integrity_v1.py: gap-freeness (vs KIS
  trading calendar) + price-sanity gate over collected history.
- Admin Collection page: new "히스토리 현황" summary table +
  GET /api/collection/history-summary, with Playwright evidence.

Governance/gate fixes:
- validate_market_time_series_schema_v1.py mislabeled its own output
  "runtime_database_query": "DATA_GATED" despite never opening a DB
  connection (pure file/regex check) — relabeled "check_scope":
  "STATIC_STRUCTURAL_ONLY" and wired the node into the release DAG so it
  isn't only reachable from ci.yml, matching every other validator.
  Live-data authority for the same claim stays with QE-M2-01's pg_query
  gate (spec/60), documented in spec/64.
- Fixed a WBS log_pattern check (QE-M1-06) that couldn't match its own
  multi-line target; loosened two depends_on edges (QE-M1-05/06,
  QE-M2-04/05) that encoded "needs X verified" when the real requirement
  was only "needs X's code merged."
- Discovered and fixed admin-pages.spec.ts logging in with the wrong
  seeded password (admin/admin instead of admin/quant123!, per CLAUDE.md)
  — every test in that suite had been silently failing at the login step.

Deferred: QE-M2-03 (2-year backfill) — the KIS mock/VTS token endpoint
started returning 403 after the first successful call this session; looks
like a token-issuance rate limit or credential issue on KIS's side, not a
code defect. Backfilling at scale right now would just generate more 403s,
so left QE-M2-03 PENDING pending KIS account/console verification.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 21:07:53 +09:00

317 lines
11 KiB
Python

#!/usr/bin/env python3
from __future__ import annotations
import json
import os
import sys
from datetime import date, timedelta
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
REPORT = ROOT / "Temp" / "price_history_integrity_v1.json"
# Import db connection resolution (inlined to avoid fragility)
try:
import psycopg
except ImportError:
psycopg = None
try:
import yaml
except ImportError:
yaml = None
# Import trading calendar
sys.path.insert(0, str(ROOT / "src"))
try:
from quant_engine.lib_trading_calendar import is_trading_day
except ImportError:
is_trading_day = None
def parse_dotnet_connection_string(s: str) -> dict[str, str | None]:
"""Parse .NET connection string format to psycopg-compatible dict."""
result: dict[str, str | None] = {}
parts = s.split(";")
search_path_value = None
for part in parts:
part = part.strip()
if not part or "=" not in part:
continue
key, value = part.split("=", 1)
key_lower = key.strip().lower()
value = value.strip()
if key_lower == "host":
result["host"] = value
elif key_lower == "port":
result["port"] = value
elif key_lower == "database":
result["dbname"] = value
elif key_lower == "username":
result["user"] = value
elif key_lower == "password":
result["password"] = value
elif key_lower == "search path":
search_path_value = value
if search_path_value:
result["options"] = f"-c search_path={search_path_value}"
return result
def _build_psycopg_dsn(parsed: dict[str, str | None]) -> str:
"""Build psycopg DSN from parsed dict."""
parts = []
for key in ["host", "port", "dbname", "user", "password"]:
val = parsed.get(key)
if val:
parts.append(f"{key}={val}")
return " ".join(parts)
def resolve_db_connection() -> str | None:
"""Resolve PostgreSQL connection string.
Resolution order:
1. env QE_WBS_PG_DSN (psycopg DSN format)
2. env ConnectionStrings__DefaultConnection (.NET format, convert to psycopg)
3. appsettings.Development.json ConnectionStrings.DefaultConnection (.NET format, convert)
"""
# Try env QE_WBS_PG_DSN
dsn = os.environ.get("QE_WBS_PG_DSN")
if dsn:
return dsn
# Try env ConnectionStrings__DefaultConnection (.NET format)
dotnet_str = os.environ.get("ConnectionStrings__DefaultConnection")
if dotnet_str:
parsed = parse_dotnet_connection_string(dotnet_str)
return _build_psycopg_dsn(parsed)
# Try appsettings.Development.json
appsettings_path = ROOT / "src/dotnet/QuantEngine.Web/appsettings.Development.json"
if appsettings_path.exists():
try:
appsettings = json.loads(appsettings_path.read_text(encoding="utf-8"))
conn_str = appsettings.get("ConnectionStrings", {}).get("DefaultConnection", "")
if conn_str:
parsed = parse_dotnet_connection_string(conn_str)
return _build_psycopg_dsn(parsed)
except Exception:
pass
return None
def query_price_data(dsn: str) -> tuple[bool, dict[str, list[tuple[str, date]]] | str]:
"""Query price_history_daily table.
Returns (success, data_or_error_msg).
On success: data = {ticker: [(ticker, trade_date), ...], ...}
On error: error message string.
"""
if psycopg is None:
return False, "psycopg not installed"
try:
conn = psycopg.connect(dsn)
try:
cursor = conn.cursor()
# Query tickers and dates, ordered for easier grouping
cursor.execute(
"SELECT ticker, trade_date FROM quantengine.price_history_daily ORDER BY ticker, trade_date"
)
rows = cursor.fetchall()
cursor.close()
# Group by ticker
data: dict[str, list[tuple[str, date]]] = {}
for ticker, trade_date in rows:
if ticker not in data:
data[ticker] = []
data[ticker].append((ticker, trade_date))
return True, data
except Exception as e:
return False, str(e)
finally:
conn.close()
except Exception as e:
return False, str(e)
def query_price_sanity(dsn: str) -> tuple[bool, int | str]:
"""Query for invalid price rows.
Returns (success, count_or_error_msg).
"""
if psycopg is None:
return False, "psycopg not installed"
try:
conn = psycopg.connect(dsn)
try:
cursor = conn.cursor()
# Count rows with invalid OHLCV
cursor.execute(
"""SELECT COUNT(*) FROM quantengine.price_history_daily
WHERE open <= 0 OR high <= 0 OR low <= 0 OR close <= 0 OR volume < 0"""
)
row = cursor.fetchone()
cursor.close()
count = row[0] if row else 0
return True, count
except Exception as e:
return False, str(e)
finally:
conn.close()
except Exception as e:
return False, str(e)
def compute_gaps(data: dict[str, list[tuple[str, date]]]) -> tuple[int, dict[str, Any]]:
"""Compute gap analysis per ticker.
Returns (total_gap_count, per_ticker_details).
"""
if is_trading_day is None:
# Can't compute gaps without trading calendar
return 0, {}
total_gaps = 0
per_ticker = []
for ticker, entries in sorted(data.items()):
if not entries:
continue
trade_dates = sorted(set(t[1] for t in entries))
min_date = trade_dates[0]
max_date = trade_dates[-1]
# Iterate through all dates in range and count missing trading days
missing_trading_days = 0
current_date = min_date
while current_date <= max_date:
if is_trading_day(current_date) and current_date not in trade_dates:
missing_trading_days += 1
current_date += timedelta(days=1)
total_gaps += missing_trading_days
per_ticker.append({
"ticker": ticker,
"min_date": min_date.isoformat(),
"max_date": max_date.isoformat(),
"row_count": len(entries),
"missing_trading_days": missing_trading_days
})
return total_gaps, per_ticker
def main() -> int:
"""Main entry point."""
dsn = resolve_db_connection()
# Attempt DB queries
if not dsn:
payload = {
"formula_id": "PRICE_HISTORY_INTEGRITY_V1",
"gate": "FAIL",
"error": "No PostgreSQL connection available (QE_WBS_PG_DSN or ConnectionStrings__DefaultConnection env var not set, and appsettings.Development.json not found or not accessible)",
"gap_count": None,
"invalid_price_rows": None,
"scope_note": "gap-freeness checked within each ticker's currently-collected min~max trade_date range; does not assert full historical coverage (see QE-M2-03 for backfill)",
"per_ticker": []
}
REPORT.parent.mkdir(parents=True, exist_ok=True)
REPORT.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps(payload, ensure_ascii=False, indent=2))
return 1
# Query price data
success, result = query_price_data(dsn)
if not success:
payload = {
"formula_id": "PRICE_HISTORY_INTEGRITY_V1",
"gate": "FAIL",
"error": f"Failed to query price_history_daily: {result}",
"gap_count": None,
"invalid_price_rows": None,
"scope_note": "gap-freeness checked within each ticker's currently-collected min~max trade_date range; does not assert full historical coverage (see QE-M2-03 for backfill)",
"per_ticker": []
}
REPORT.parent.mkdir(parents=True, exist_ok=True)
REPORT.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps(payload, ensure_ascii=False, indent=2))
return 1
price_data = result
if is_trading_day is None:
payload = {
"formula_id": "PRICE_HISTORY_INTEGRITY_V1",
"gate": "FAIL",
"error": "Failed to import is_trading_day from quant_engine.lib_trading_calendar",
"gap_count": None,
"invalid_price_rows": None,
"scope_note": "gap-freeness checked within each ticker's currently-collected min~max trade_date range; does not assert full historical coverage (see QE-M2-03 for backfill)",
"per_ticker": []
}
REPORT.parent.mkdir(parents=True, exist_ok=True)
REPORT.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps(payload, ensure_ascii=False, indent=2))
return 1
# Query price sanity
success, result = query_price_sanity(dsn)
if not success:
payload = {
"formula_id": "PRICE_HISTORY_INTEGRITY_V1",
"gate": "FAIL",
"error": f"Failed to check price sanity: {result}",
"gap_count": None,
"invalid_price_rows": None,
"scope_note": "gap-freeness checked within each ticker's currently-collected min~max trade_date range; does not assert full historical coverage (see QE-M2-03 for backfill)",
"per_ticker": []
}
REPORT.parent.mkdir(parents=True, exist_ok=True)
REPORT.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps(payload, ensure_ascii=False, indent=2))
return 1
invalid_price_rows = result
# Compute gaps
gap_count, per_ticker_details = compute_gaps(price_data)
# Determine gate result
gate = "PASS" if gap_count == 0 and invalid_price_rows == 0 else "FAIL"
# Build payload
payload = {
"formula_id": "PRICE_HISTORY_INTEGRITY_V1",
"gate": gate,
"gap_count": gap_count,
"invalid_price_rows": invalid_price_rows,
"scope_note": "gap-freeness checked within each ticker's currently-collected min~max trade_date range; does not assert full historical coverage (see QE-M2-03 for backfill)",
"per_ticker": per_ticker_details
}
# Write report
REPORT.parent.mkdir(parents=True, exist_ok=True)
REPORT.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
# Print JSON to stdout
print(json.dumps(payload, ensure_ascii=False, indent=2))
# Exit with appropriate code
return 0 if gate == "PASS" else 1
if __name__ == "__main__":
raise SystemExit(main())