feat: postgres history-first 계약과 적재 경로 추가
- PostgreSQL history contract와 schema/validator를 추가했습니다. - .NET history store, snapshot reader, repository, migration을 연결했습니다. - history-first 운영 모델 문서와 daily signal tracking 문구를 정리했습니다.
This commit is contained in:
@@ -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()]
|
||||
|
||||
Reference in New Issue
Block a user