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 os
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import datetime as dt
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
|
|
||||||
from app.screener.schema import ensure_screener_schema
|
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 "
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_holdings_sig_ticker "
|
||||||
"ON holdings_signals(ticker, date DESC);")
|
"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테이블 + 디폴트 설정 시드)
|
# Screener 스키마 부트스트랩 (7테이블 + 디폴트 설정 시드)
|
||||||
ensure_screener_schema(conn)
|
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 ?",
|
"SELECT * FROM holdings_signals WHERE ticker=? ORDER BY date DESC LIMIT ?",
|
||||||
(ticker, limit)).fetchall()
|
(ticker, limit)).fetchall()
|
||||||
return [_row_to_signal(r) for r in rows]
|
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