SignalDedup: 24h-rolling duplicate signal blocker. SQLite WAL + busy_timeout=120000 standard fix (reference_sqlite_concurrency.md pattern). PK (ticker, action) with UPSERT. Phase 4 (signal generator) will call is_recent() before sending + record() after sending. 3 unit tests pass, total 17 signal_v2. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
74 lines
2.4 KiB
Python
74 lines
2.4 KiB
Python
"""SignalDedup — SQLite-backed 24h duplicate signal blocker."""
|
|
from __future__ import annotations
|
|
import sqlite3
|
|
from contextlib import contextmanager
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
from zoneinfo import ZoneInfo
|
|
|
|
KST = ZoneInfo("Asia/Seoul")
|
|
|
|
|
|
def _now_iso() -> str:
|
|
"""Test seam — overridable via monkeypatch."""
|
|
return datetime.now(KST).isoformat()
|
|
|
|
|
|
_SCHEMA = """
|
|
CREATE TABLE IF NOT EXISTS signal_dedup (
|
|
ticker TEXT NOT NULL,
|
|
action TEXT NOT NULL,
|
|
last_sent TEXT NOT NULL,
|
|
confidence REAL NOT NULL,
|
|
PRIMARY KEY (ticker, action)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_signal_dedup_last_sent
|
|
ON signal_dedup(last_sent);
|
|
"""
|
|
|
|
|
|
class SignalDedup:
|
|
"""24h dedup interface. WAL + busy_timeout=120000."""
|
|
|
|
def __init__(self, db_path: Path):
|
|
self._db_path = Path(db_path)
|
|
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
self._init_schema()
|
|
|
|
@contextmanager
|
|
def _conn(self):
|
|
conn = sqlite3.connect(self._db_path, timeout=120.0)
|
|
try:
|
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
conn.execute("PRAGMA busy_timeout=120000")
|
|
yield conn
|
|
finally:
|
|
conn.close()
|
|
|
|
def _init_schema(self) -> None:
|
|
with self._conn() as conn:
|
|
conn.executescript(_SCHEMA)
|
|
conn.commit()
|
|
|
|
def is_recent(self, ticker: str, action: str, within_hours: int = 24) -> bool:
|
|
threshold_dt = datetime.fromisoformat(_now_iso()) - timedelta(hours=within_hours)
|
|
threshold_iso = threshold_dt.isoformat()
|
|
with self._conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT last_sent FROM signal_dedup WHERE ticker = ? AND action = ?",
|
|
(ticker, action),
|
|
).fetchone()
|
|
return row is not None and row[0] >= threshold_iso
|
|
|
|
def record(self, ticker: str, action: str, confidence: float) -> None:
|
|
with self._conn() as conn:
|
|
conn.execute(
|
|
"""INSERT INTO signal_dedup (ticker, action, last_sent, confidence)
|
|
VALUES (?, ?, ?, ?)
|
|
ON CONFLICT (ticker, action) DO UPDATE
|
|
SET last_sent = excluded.last_sent,
|
|
confidence = excluded.confidence""",
|
|
(ticker, action, _now_iso(), confidence),
|
|
)
|
|
conn.commit()
|