From b2c4ca0e0b734934d3231bcd62bccbf79e3b5842 Mon Sep 17 00:00:00 2001 From: gahusb Date: Thu, 2 Jul 2026 15:34:53 +0900 Subject: [PATCH] =?UTF-8?q?feat(stock):=20=EB=A7=A4=EB=A7=A4=EC=95=8C?= =?UTF-8?q?=EB=9E=8C=20DB=20=E2=80=94=20watchlist/alert=5Fstate/history=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94+=ED=97=AC=ED=8D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01EqCYBhvTcdeCTUDX3RhWx9 --- stock/app/db.py | 139 ++++++++++++++++++++++++++++ stock/tests/test_trade_alerts_db.py | 33 +++++++ 2 files changed, 172 insertions(+) create mode 100644 stock/tests/test_trade_alerts_db.py diff --git a/stock/app/db.py b/stock/app/db.py index 5d4c9cc..95a9986 100644 --- a/stock/app/db.py +++ b/stock/app/db.py @@ -2,6 +2,7 @@ import sqlite3 import os import hashlib import json +import datetime as dt from typing import List, Dict, Any, Optional from app.screener.schema import ensure_screener_schema @@ -125,6 +126,42 @@ def init_db(): conn.execute("CREATE INDEX IF NOT EXISTS idx_holdings_sig_ticker " "ON holdings_signals(ticker, date DESC);") + # 실시간 매매 알람: watchlist / alert_state / alert_history + conn.execute(""" + CREATE TABLE IF NOT EXISTS watchlist ( + ticker TEXT PRIMARY KEY, + name TEXT, + note TEXT, + params_json TEXT NOT NULL DEFAULT '{}', + added_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS trade_alert_state ( + ticker TEXT NOT NULL, + kind TEXT NOT NULL, + condition TEXT NOT NULL, + currently_firing INTEGER NOT NULL DEFAULT 0, + first_fired_at TEXT, + last_fired_at TEXT, + last_seen_at TEXT, + PRIMARY KEY (ticker, kind, condition) + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS trade_alert_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ticker TEXT NOT NULL, + name TEXT, + kind TEXT NOT NULL, + condition TEXT NOT NULL, + price REAL, + detail_json TEXT, + fired_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) + ) + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_tah_fired ON trade_alert_history(fired_at DESC)") + # Screener 스키마 부트스트랩 (7테이블 + 디폴트 설정 시드) ensure_screener_schema(conn) @@ -379,3 +416,105 @@ def get_holdings_signal_history(ticker: str, limit: int = 30) -> list: "SELECT * FROM holdings_signals WHERE ticker=? ORDER BY date DESC LIMIT ?", (ticker, limit)).fetchall() return [_row_to_signal(r) for r in rows] + + +# --- 실시간 매매 알람: 공통 유틸 --- + +def _now_iso() -> str: + return dt.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%fZ") + + +# --- Watchlist CRUD --- + +def add_watchlist(ticker: str, name: str = None, note: str = None) -> None: + with _conn() as conn: + conn.execute( + "INSERT OR IGNORE INTO watchlist(ticker,name,note) VALUES(?,?,?)", + (ticker, name, note), + ) + # 이름/노트 갱신(이미 있으면) + conn.execute( + "UPDATE watchlist SET name=COALESCE(?,name), note=COALESCE(?,note) WHERE ticker=?", + (name, note, ticker), + ) + + +def remove_watchlist(ticker: str) -> bool: + with _conn() as conn: + cur = conn.execute("DELETE FROM watchlist WHERE ticker=?", (ticker,)) + return cur.rowcount > 0 + + +def get_watchlist() -> list: + with _conn() as conn: + rows = conn.execute("SELECT * FROM watchlist ORDER BY added_at").fetchall() + return [ + {"ticker": r["ticker"], "name": r["name"], "note": r["note"], + "params": json.loads(r["params_json"] or "{}"), "added_at": r["added_at"]} + for r in rows + ] + + +# --- Trade Alert State --- + +def get_alert_state_firing() -> set: + with _conn() as conn: + rows = conn.execute( + "SELECT ticker,kind,condition FROM trade_alert_state WHERE currently_firing=1" + ).fetchall() + return {(r["ticker"], r["kind"], r["condition"]) for r in rows} + + +def set_alert_firing(ticker: str, kind: str, condition: str, firing: bool, at_iso: str = None) -> None: + now = at_iso or _now_iso() + with _conn() as conn: + if firing: + conn.execute( + """INSERT INTO trade_alert_state(ticker,kind,condition,currently_firing,first_fired_at,last_fired_at,last_seen_at) + VALUES(?,?,?,1,?,?,?) + ON CONFLICT(ticker,kind,condition) DO UPDATE SET + currently_firing=1, + first_fired_at=COALESCE(first_fired_at,excluded.first_fired_at), + last_fired_at=excluded.last_fired_at, + last_seen_at=excluded.last_seen_at""", + (ticker, kind, condition, now, now, now), + ) + else: + conn.execute( + "UPDATE trade_alert_state SET currently_firing=0, last_seen_at=? WHERE ticker=? AND kind=? AND condition=?", + (now, ticker, kind, condition), + ) + + +def touch_alert_seen(keys: list, at_iso: str) -> None: + with _conn() as conn: + for (ticker, kind, condition) in keys: + conn.execute( + "UPDATE trade_alert_state SET last_seen_at=? WHERE ticker=? AND kind=? AND condition=?", + (at_iso, ticker, kind, condition), + ) + + +# --- Trade Alert History --- + +def add_alert_history(ticker: str, name: str, kind: str, condition: str, price, detail: dict) -> int: + with _conn() as conn: + cur = conn.execute( + "INSERT INTO trade_alert_history(ticker,name,kind,condition,price,detail_json) VALUES(?,?,?,?,?,?)", + (ticker, name, kind, condition, price, json.dumps(detail or {}, ensure_ascii=False)), + ) + return cur.lastrowid + + +def get_alert_history(days: int = 7) -> list: + with _conn() as conn: + rows = conn.execute( + "SELECT * FROM trade_alert_history WHERE fired_at >= datetime('now', ?) ORDER BY fired_at DESC", + (f"-{int(days)} days",), + ).fetchall() + return [ + {"id": r["id"], "ticker": r["ticker"], "name": r["name"], "kind": r["kind"], + "condition": r["condition"], "price": r["price"], + "detail": json.loads(r["detail_json"] or "{}"), "fired_at": r["fired_at"]} + for r in rows + ] diff --git a/stock/tests/test_trade_alerts_db.py b/stock/tests/test_trade_alerts_db.py new file mode 100644 index 0000000..5e68820 --- /dev/null +++ b/stock/tests/test_trade_alerts_db.py @@ -0,0 +1,33 @@ +import os, sqlite3, tempfile, datetime as dt +import pytest + +@pytest.fixture +def db(monkeypatch, tmp_path): + from app import db as _db + monkeypatch.setattr(_db, "DB_PATH", str(tmp_path / "stock.db")) + _db.init_db() + return _db + +def test_watchlist_add_get_remove(db): + db.add_watchlist("005930", "삼성전자", note="관심") + db.add_watchlist("005930", "삼성전자") # 멱등 + wl = db.get_watchlist() + assert [w["ticker"] for w in wl] == ["005930"] + assert wl[0]["name"] == "삼성전자" + assert db.remove_watchlist("005930") is True + assert db.get_watchlist() == [] + +def test_alert_state_edge_firing_and_clear(db): + key = ("005930", "buy", "buy_breakout") + assert db.get_alert_state_firing() == set() + db.set_alert_firing(*key, firing=True, at_iso="2026-07-02T00:01:00Z") + assert key in db.get_alert_state_firing() + db.set_alert_firing(*key, firing=False) + assert key not in db.get_alert_state_firing() + +def test_alert_history_records_and_reads(db): + db.add_alert_history("005930", "삼성전자", "buy", "buy_breakout", 71500, {"vol": 2.1}) + rows = db.get_alert_history(days=7) + assert len(rows) == 1 + assert rows[0]["ticker"] == "005930" and rows[0]["kind"] == "buy" + assert rows[0]["detail"]["vol"] == 2.1