From 885d52d8f5de617519ee80877fcba121110528ff Mon Sep 17 00:00:00 2001 From: gahusb Date: Sun, 31 May 2026 21:36:27 +0900 Subject: [PATCH] =?UTF-8?q?feat(stock):=20holdings=5Fsignals=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=20+=20CRUD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- stock/app/db.py | 67 +++++++++++++++++++++++++++++++++++ stock/app/test_holdings_db.py | 24 +++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 stock/app/test_holdings_db.py diff --git a/stock/app/db.py b/stock/app/db.py index bfb50bf..89cd147 100644 --- a/stock/app/db.py +++ b/stock/app/db.py @@ -1,6 +1,7 @@ import sqlite3 import os import hashlib +import json from typing import List, Dict, Any, Optional from app.screener.schema import ensure_screener_schema @@ -103,6 +104,27 @@ def init_db(): if "commission" not in sh_cols: conn.execute("ALTER TABLE sell_history ADD COLUMN commission REAL NOT NULL DEFAULT 0") + conn.execute( + """ + CREATE TABLE IF NOT EXISTS holdings_signals ( + date TEXT NOT NULL, + ticker TEXT NOT NULL, + name TEXT, + action TEXT NOT NULL, + tech_score REAL, + exit_flags TEXT NOT NULL DEFAULT '{}', + issues TEXT NOT NULL DEFAULT '[]', + close INTEGER, + pnl_rate REAL, + reasons TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (date, ticker) + ); + """ + ) + conn.execute("CREATE INDEX IF NOT EXISTS idx_holdings_sig_ticker " + "ON holdings_signals(ticker, date DESC);") + # Screener 스키마 부트스트랩 (7테이블 + 디폴트 설정 시드) ensure_screener_schema(conn) @@ -297,3 +319,48 @@ def get_asset_snapshots(days: int = 30) -> List[Dict[str, Any]]: ).fetchall() rows = list(reversed(rows)) return [dict(r) for r in rows] + + +# --- Holdings Signals CRUD --- + +def upsert_holdings_signal(date, ticker, name, action, tech_score, exit_flags, + issues, close, pnl_rate, reasons) -> None: + with _conn() as conn: + conn.execute( + """ + INSERT INTO holdings_signals + (date, ticker, name, action, tech_score, exit_flags, issues, close, pnl_rate, reasons) + VALUES (?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(date, ticker) DO UPDATE SET + name=excluded.name, action=excluded.action, tech_score=excluded.tech_score, + exit_flags=excluded.exit_flags, issues=excluded.issues, close=excluded.close, + pnl_rate=excluded.pnl_rate, reasons=excluded.reasons + """, + (date, ticker, name, action, tech_score, + json.dumps(exit_flags, ensure_ascii=False), + json.dumps(issues, ensure_ascii=False), close, pnl_rate, reasons), + ) + +def _row_to_signal(r) -> dict: + d = dict(r) + d["exit_flags"] = json.loads(d.get("exit_flags") or "{}") + d["issues"] = json.loads(d.get("issues") or "[]") + return d + +def get_holdings_signals(date: str) -> list: + with _conn() as conn: + rows = conn.execute( + "SELECT * FROM holdings_signals WHERE date=? ORDER BY ticker", (date,)).fetchall() + return [_row_to_signal(r) for r in rows] + +def get_latest_holdings_date() -> str | None: + with _conn() as conn: + r = conn.execute("SELECT MAX(date) AS d FROM holdings_signals").fetchone() + return r["d"] if r and r["d"] else None + +def get_holdings_signal_history(ticker: str, days: int = 30) -> list: + with _conn() as conn: + rows = conn.execute( + "SELECT * FROM holdings_signals WHERE ticker=? ORDER BY date DESC LIMIT ?", + (ticker, days)).fetchall() + return [_row_to_signal(r) for r in rows] diff --git a/stock/app/test_holdings_db.py b/stock/app/test_holdings_db.py new file mode 100644 index 0000000..297caf0 --- /dev/null +++ b/stock/app/test_holdings_db.py @@ -0,0 +1,24 @@ +import os, tempfile, importlib + +def _fresh_db(monkeypatch): + tmp = tempfile.mkdtemp() + from app import db + monkeypatch.setattr(db, "DB_PATH", os.path.join(tmp, "stock.db")) + db.init_db() + return db + +def test_holdings_signals_table_and_upsert(monkeypatch): + db = _fresh_db(monkeypatch) + db.upsert_holdings_signal(date="2026-05-29", ticker="005930", name="삼성전자", + action="hold", tech_score=72.0, exit_flags={"stop_loss": False}, + issues=[{"type": "news", "severity": "low", "summary": "x"}], + close=80000, pnl_rate=5.2, reasons="강건") + db.upsert_holdings_signal(date="2026-05-29", ticker="005930", name="삼성전자", + action="trim", tech_score=60.0, exit_flags={"ma50_break": True}, + issues=[], close=79000, pnl_rate=3.0, reasons="MA50 이탈") + rows = db.get_holdings_signals(date="2026-05-29") + assert len(rows) == 1 # upsert 멱등 + assert rows[0]["action"] == "trim" + assert rows[0]["exit_flags"]["ma50_break"] is True # JSON 역직렬화 + hist = db.get_holdings_signal_history("005930", days=30) + assert len(hist) == 1