Files
ai-trade/signal_v2/rate_limit.py
gahusb 1a6d9fcb39 feat(signal_v2): rate_limit + 3 unit tests
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>
2026-05-16 03:46:59 +09:00

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()