"""Screener schema bootstrap. Called once at module import via db.py.""" import json import sqlite3 from datetime import datetime, timezone DEFAULT_WEIGHTS = { "foreign_buy": 1.0, "volume_surge": 1.0, "momentum": 1.0, "high52w": 1.2, "rs_rating": 1.2, "ma_alignment": 1.0, "vcp_lite": 0.8, # ai_news: 검증 전 gradient 차단 (4주 IC > 0.05 확인 후 활성화). # 데이터 수집은 계속, 가중합 영향만 0. "ai_news": 0.0, } DEFAULT_NODE_PARAMS = { "foreign_buy": {"window_days": 5}, "volume_surge": {"baseline_days": 20, "eval_days": 3}, "momentum": {"window_days": 20}, "high52w": {"window_days": 252}, "rs_rating": {"weights": {"3m": 2, "6m": 1, "9m": 1, "12m": 1}}, "ma_alignment": {"ma_periods": [50, 150, 200]}, "vcp_lite": {"short_window": 40, "long_window": 252}, "ai_news": {"min_news_count": 1}, } DEFAULT_GATE_PARAMS = { "min_market_cap_won": 50_000_000_000, "min_avg_value_won": 500_000_000, "min_listed_days": 60, "skip_managed": True, "skip_preferred": True, "skip_spac": True, "skip_halted_days": 3, } DDL = """ CREATE TABLE IF NOT EXISTS krx_master ( ticker TEXT PRIMARY KEY, name TEXT NOT NULL, market TEXT NOT NULL, market_cap INTEGER, is_managed INTEGER NOT NULL DEFAULT 0, is_preferred INTEGER NOT NULL DEFAULT 0, is_spac INTEGER NOT NULL DEFAULT 0, listed_date TEXT, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS krx_daily_prices ( ticker TEXT NOT NULL, date TEXT NOT NULL, open INTEGER, high INTEGER, low INTEGER, close INTEGER, volume INTEGER, value INTEGER, PRIMARY KEY (ticker, date) ); CREATE INDEX IF NOT EXISTS idx_prices_date ON krx_daily_prices(date); CREATE TABLE IF NOT EXISTS krx_flow ( ticker TEXT NOT NULL, date TEXT NOT NULL, foreign_net INTEGER, institution_net INTEGER, PRIMARY KEY (ticker, date) ); CREATE INDEX IF NOT EXISTS idx_flow_date ON krx_flow(date); CREATE TABLE IF NOT EXISTS screener_settings ( id INTEGER PRIMARY KEY CHECK (id = 1), weights_json TEXT NOT NULL, node_params_json TEXT NOT NULL, gate_params_json TEXT NOT NULL, top_n INTEGER NOT NULL DEFAULT 20, rr_ratio REAL NOT NULL DEFAULT 2.0, atr_window INTEGER NOT NULL DEFAULT 14, atr_stop_mult REAL NOT NULL DEFAULT 2.0, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS screener_runs ( id INTEGER PRIMARY KEY AUTOINCREMENT, asof TEXT NOT NULL, mode TEXT NOT NULL, status TEXT NOT NULL, error TEXT, started_at TEXT NOT NULL, finished_at TEXT, weights_json TEXT NOT NULL, node_params_json TEXT NOT NULL, gate_params_json TEXT NOT NULL, top_n INTEGER NOT NULL, survivors_count INTEGER, telegram_sent INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_runs_asof ON screener_runs(asof DESC); CREATE TABLE IF NOT EXISTS screener_results ( run_id INTEGER NOT NULL, rank INTEGER NOT NULL, ticker TEXT NOT NULL, name TEXT NOT NULL, total_score REAL NOT NULL, scores_json TEXT NOT NULL, close INTEGER, market_cap INTEGER, entry_price INTEGER, stop_price INTEGER, target_price INTEGER, atr14 REAL, PRIMARY KEY (run_id, ticker), FOREIGN KEY (run_id) REFERENCES screener_runs(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_results_run_rank ON screener_results(run_id, rank); -- articles 테이블 (도메스틱/해외 뉴스 원본). -- 메인 app.db.init_db() 에서도 생성하지만, 테스트 환경 및 단독 screener 컨텍스트 -- (ai_news.articles_source 등)에서도 참조 가능하도록 idempotent 하게 보장한다. CREATE TABLE IF NOT EXISTS articles ( id INTEGER PRIMARY KEY AUTOINCREMENT, hash TEXT UNIQUE NOT NULL, category TEXT DEFAULT 'domestic', title TEXT NOT NULL, link TEXT, summary TEXT, press TEXT, pub_date TEXT, crawled_at TEXT ); CREATE INDEX IF NOT EXISTS idx_articles_crawled ON articles(crawled_at DESC); CREATE TABLE IF NOT EXISTS news_sentiment ( ticker TEXT NOT NULL, date TEXT NOT NULL, score_raw REAL NOT NULL, reason TEXT NOT NULL DEFAULT '', news_count INTEGER NOT NULL DEFAULT 0, tokens_input INTEGER NOT NULL DEFAULT 0, tokens_output INTEGER NOT NULL DEFAULT 0, model TEXT NOT NULL DEFAULT 'claude-haiku-4-5-20251001', source TEXT NOT NULL DEFAULT 'articles', created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')), PRIMARY KEY (ticker, date) ); CREATE INDEX IF NOT EXISTS idx_news_sentiment_date ON news_sentiment(date DESC); """ def ensure_screener_schema(conn: sqlite3.Connection) -> None: """Create tables and seed default settings (idempotent).""" conn.executescript(DDL) # ai_news 키 누락 시 1회 보충 (이미 운영 중인 환경에 대해) row = conn.execute( "SELECT weights_json, node_params_json FROM screener_settings WHERE id=1" ).fetchone() if row is not None: w = json.loads(row[0]) p = json.loads(row[1]) changed = False if "ai_news" not in w: w["ai_news"] = DEFAULT_WEIGHTS["ai_news"] changed = True # One-time reset: ai_news default 0.8 → 0.0 (검증 전 gradient 차단). # 사용자가 명시적으로 0.8 외 값을 설정했다면 영향 없음. elif w.get("ai_news") == 0.8: w["ai_news"] = 0.0 changed = True if "ai_news" not in p: p["ai_news"] = DEFAULT_NODE_PARAMS["ai_news"] changed = True if changed: conn.execute( "UPDATE screener_settings SET weights_json=?, node_params_json=? WHERE id=1", (json.dumps(w), json.dumps(p)), ) # news_sentiment.source 컬럼 1회 추가 (기존 운영 환경) cols = {r[1] for r in conn.execute( "PRAGMA table_info(news_sentiment)" ).fetchall()} if "source" not in cols: conn.execute( "ALTER TABLE news_sentiment " "ADD COLUMN source TEXT NOT NULL DEFAULT 'articles'" ) existing = conn.execute("SELECT id FROM screener_settings WHERE id=1").fetchone() if existing is None: now = datetime.now(timezone.utc).isoformat() conn.execute( """ INSERT INTO screener_settings ( id, weights_json, node_params_json, gate_params_json, top_n, rr_ratio, atr_window, atr_stop_mult, updated_at ) VALUES (1, ?, ?, ?, 20, 2.0, 14, 2.0, ?) """, ( json.dumps(DEFAULT_WEIGHTS), json.dumps(DEFAULT_NODE_PARAMS), json.dumps(DEFAULT_GATE_PARAMS), now, ), ) conn.commit()