"""qualitative_sell_strategy_v1 산출물의 SQLite 시계열 저장소. GAS/xlsx 구조와 완전히 분리된 추가(additive) 저장소다 — 이 모듈이 다루는 데이터는 순수 Python 산출물(KIS API 수집 + confluence 판단 결과)이며, GAS가 쓰지도 읽지도 않고 사람이 시트에서 직접 편집하지도 않는다. 기존 outputs/qualitative_sell_strategy/ *.json 파일 출력을 대체하지 않고 병행 저장한다(JSON은 1회성 점검용, SQLite는 시계열 추이 조회용). 표준 라이브러리 sqlite3만 사용 — 추가 의존성 없음. """ from __future__ import annotations import json import sqlite3 from pathlib import Path from dataclasses import dataclass from typing import Any from src.quant_engine.storage_backend_v1 import StoreSpec, default_sqlite_store_path, normalize_store_spec SCHEMA = """ CREATE TABLE IF NOT EXISTS sell_strategy_results ( id INTEGER PRIMARY KEY AUTOINCREMENT, code TEXT NOT NULL, generated_at TEXT NOT NULL, action TEXT, conviction TEXT, market_regime TEXT, composite_score REAL, rationale TEXT, raw_json TEXT NOT NULL, inserted_at TEXT DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_sell_strategy_code_time ON sell_strategy_results(code, generated_at); CREATE TABLE IF NOT EXISTS satellite_recommendations ( id INTEGER PRIMARY KEY AUTOINCREMENT, ticker TEXT NOT NULL, generated_at TEXT NOT NULL, satellite_action TEXT, attractiveness_score REAL, market_regime TEXT, raw_json TEXT NOT NULL, inserted_at TEXT DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_satellite_ticker_time ON satellite_recommendations(ticker, generated_at); """ @dataclass(frozen=True) class QualitativeSellStoreSpec(StoreSpec): pass def default_qualitative_sell_store_path(root: Path) -> Path: return default_sqlite_store_path(root, "qualitative_sell_strategy/qualitative_sell_strategy.db") def resolve_store_path(spec: QualitativeSellStoreSpec, root: Path) -> Path: backend, location = normalize_store_spec( spec, root, default_sqlite_name="qualitative_sell_strategy/qualitative_sell_strategy.db", ) if backend != "sqlite": raise ValueError( "qualitative_sell_strategy_store_v1 currently executes on sqlite only; " "the caller contract already allows future PostgreSQL swap-in." ) return Path(location) def init_db(db_path: Path) -> None: db_path.parent.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(db_path) try: conn.executescript(SCHEMA) conn.commit() finally: conn.close() def insert_sell_strategy_result(db_path: Path, result: dict[str, Any]) -> None: """build_qualitative_sell_inputs_v1.process_one()의 반환값(dict)을 그대로 받는다.""" init_db(db_path) decision = result.get("decision") or {} conn = sqlite3.connect(db_path) try: conn.execute( "INSERT INTO sell_strategy_results " "(code, generated_at, action, conviction, market_regime, composite_score, rationale, raw_json) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", ( result.get("code"), result.get("generated_at"), decision.get("action"), decision.get("conviction"), decision.get("market_regime"), decision.get("composite_score"), decision.get("rationale"), json.dumps(result, ensure_ascii=False, default=str), ), ) conn.commit() finally: conn.close() def insert_satellite_recommendation(db_path: Path, generated_at: str, candidate: dict[str, Any]) -> None: """build_satellite_candidate_recommendations_v1.py results[i] 항목 하나를 받는다.""" init_db(db_path) score = candidate.get("score") or {} conn = sqlite3.connect(db_path) try: conn.execute( "INSERT INTO satellite_recommendations " "(ticker, generated_at, satellite_action, attractiveness_score, market_regime, raw_json) " "VALUES (?, ?, ?, ?, ?, ?)", ( candidate.get("ticker"), generated_at, score.get("satellite_action"), score.get("attractiveness_score"), score.get("market_regime"), json.dumps(candidate, ensure_ascii=False, default=str), ), ) conn.commit() finally: conn.close() def fetch_recent_sell_strategy_results(db_path: Path, code: str, limit: int = 20) -> list[dict[str, Any]]: if not db_path.exists(): return [] conn = sqlite3.connect(db_path) conn.row_factory = sqlite3.Row try: rows = conn.execute( "SELECT code, generated_at, action, conviction, market_regime, composite_score, rationale " "FROM sell_strategy_results WHERE code = ? ORDER BY generated_at DESC LIMIT ?", (code, limit), ).fetchall() return [dict(row) for row in rows] finally: conn.close()