From d7e235c00837820d8b2e8162cd5f8217e7b90914 Mon Sep 17 00:00:00 2001 From: gahusb Date: Tue, 12 May 2026 04:10:36 +0900 Subject: [PATCH] =?UTF-8?q?feat(stock-lab):=20screener=20=EC=8A=A4?= =?UTF-8?q?=ED=82=A4=EB=A7=88=207=ED=85=8C=EC=9D=B4=EB=B8=94=20+=20?= =?UTF-8?q?=EB=94=94=ED=8F=B4=ED=8A=B8=20=EC=84=A4=EC=A0=95=20=EC=8B=9C?= =?UTF-8?q?=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- stock-lab/app/db.py | 5 + stock-lab/app/screener/schema.py | 136 ++++++++++++++++++++++++++ stock-lab/app/test_screener_schema.py | 37 +++++++ 3 files changed, 178 insertions(+) create mode 100644 stock-lab/app/screener/schema.py create mode 100644 stock-lab/app/test_screener_schema.py diff --git a/stock-lab/app/db.py b/stock-lab/app/db.py index a6d85c9..189e54c 100644 --- a/stock-lab/app/db.py +++ b/stock-lab/app/db.py @@ -3,6 +3,8 @@ import os import hashlib from typing import List, Dict, Any, Optional +from app.screener.schema import ensure_screener_schema + DB_PATH = "/app/data/stock.db" def _conn() -> sqlite3.Connection: @@ -96,6 +98,9 @@ def init_db(): if "commission" not in sh_cols: conn.execute("ALTER TABLE sell_history ADD COLUMN commission REAL NOT NULL DEFAULT 0") + # Screener 스키마 부트스트랩 (7테이블 + 디폴트 설정 시드) + ensure_screener_schema(conn) + def save_articles(articles: List[Dict[str, str]]) -> int: count = 0 with _conn() as conn: diff --git a/stock-lab/app/screener/schema.py b/stock-lab/app/screener/schema.py new file mode 100644 index 0000000..cde379d --- /dev/null +++ b/stock-lab/app/screener/schema.py @@ -0,0 +1,136 @@ +"""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, +} +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}, +} +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); +""" + + +def ensure_screener_schema(conn: sqlite3.Connection) -> None: + """Create tables and seed default settings (idempotent).""" + conn.executescript(DDL) + 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() diff --git a/stock-lab/app/test_screener_schema.py b/stock-lab/app/test_screener_schema.py new file mode 100644 index 0000000..46cf9d0 --- /dev/null +++ b/stock-lab/app/test_screener_schema.py @@ -0,0 +1,37 @@ +import sqlite3 +from app.screener.schema import ensure_screener_schema + + +def test_creates_all_tables(tmp_path): + db_path = tmp_path / "test.db" + conn = sqlite3.connect(db_path) + ensure_screener_schema(conn) + + tables = {r[0] for r in conn.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ).fetchall()} + + expected = { + "krx_master", "krx_daily_prices", "krx_flow", + "screener_settings", "screener_runs", "screener_results", + } + assert expected.issubset(tables) + + +def test_settings_seeded_with_singleton_row(tmp_path): + db_path = tmp_path / "test.db" + conn = sqlite3.connect(db_path) + ensure_screener_schema(conn) + + rows = conn.execute("SELECT id FROM screener_settings").fetchall() + assert rows == [(1,)] + + +def test_idempotent(tmp_path): + db_path = tmp_path / "test.db" + conn = sqlite3.connect(db_path) + ensure_screener_schema(conn) + ensure_screener_schema(conn) # 두 번 호출해도 에러 없어야 함 + + rows = conn.execute("SELECT count(*) FROM screener_settings").fetchall() + assert rows == [(1,)]