feat(stock-lab): screener 스키마 7테이블 + 디폴트 설정 시드
This commit is contained in:
@@ -3,6 +3,8 @@ import os
|
|||||||
import hashlib
|
import hashlib
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
|
|
||||||
|
from app.screener.schema import ensure_screener_schema
|
||||||
|
|
||||||
DB_PATH = "/app/data/stock.db"
|
DB_PATH = "/app/data/stock.db"
|
||||||
|
|
||||||
def _conn() -> sqlite3.Connection:
|
def _conn() -> sqlite3.Connection:
|
||||||
@@ -96,6 +98,9 @@ def init_db():
|
|||||||
if "commission" not in sh_cols:
|
if "commission" not in sh_cols:
|
||||||
conn.execute("ALTER TABLE sell_history ADD COLUMN commission REAL NOT NULL DEFAULT 0")
|
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:
|
def save_articles(articles: List[Dict[str, str]]) -> int:
|
||||||
count = 0
|
count = 0
|
||||||
with _conn() as conn:
|
with _conn() as conn:
|
||||||
|
|||||||
136
stock-lab/app/screener/schema.py
Normal file
136
stock-lab/app/screener/schema.py
Normal file
@@ -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()
|
||||||
37
stock-lab/app/test_screener_schema.py
Normal file
37
stock-lab/app/test_screener_schema.py
Normal file
@@ -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,)]
|
||||||
Reference in New Issue
Block a user