feat(stock): 매매알람 DB — watchlist/alert_state/history 테이블+헬퍼
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqCYBhvTcdeCTUDX3RhWx9
This commit is contained in:
139
stock/app/db.py
139
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
|
||||
]
|
||||
|
||||
33
stock/tests/test_trade_alerts_db.py
Normal file
33
stock/tests/test_trade_alerts_db.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user