From a417cae77bc795f75e4550836f6bddfe4e29acfa Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sun, 24 May 2026 01:01:54 +0000 Subject: [PATCH] feat(kevin_bridge): blocklist + daily risk counters --- services/kevin_signal_bridge/blocklist.py | 37 +++++++++++++ services/kevin_signal_bridge/risk_counters.py | 54 ++++++++++++++++++ .../kevin_signal_bridge/test_blocklist.py | 49 +++++++++++++++++ .../kevin_signal_bridge/test_risk_counters.py | 55 +++++++++++++++++++ 4 files changed, 195 insertions(+) create mode 100644 services/kevin_signal_bridge/blocklist.py create mode 100644 services/kevin_signal_bridge/risk_counters.py create mode 100644 tests/services/kevin_signal_bridge/test_blocklist.py create mode 100644 tests/services/kevin_signal_bridge/test_risk_counters.py diff --git a/services/kevin_signal_bridge/blocklist.py b/services/kevin_signal_bridge/blocklist.py new file mode 100644 index 0000000..25b9346 --- /dev/null +++ b/services/kevin_signal_bridge/blocklist.py @@ -0,0 +1,37 @@ +"""Redis-backed per-symbol blocklist with TTL. + +Set on AVOID mentions. Subsequent BUY mention on the same symbol clears +the entry (handled by the strategy callsite, not here). +""" + +from __future__ import annotations + +from typing import Any + + +class KevinBlocklist: + _KEY_PREFIX = "kevin:blocked:" + + def __init__(self, redis: Any) -> None: + self.redis = redis + + async def add(self, symbol: str, ttl_days: int) -> None: + await self.redis.set( + f"{self._KEY_PREFIX}{symbol}", + "1", + ex=ttl_days * 86400, + ) + + async def remove(self, symbol: str) -> None: + await self.redis.delete(f"{self._KEY_PREFIX}{symbol}") + + async def is_blocked(self, symbol: str) -> bool: + return bool(await self.redis.exists(f"{self._KEY_PREFIX}{symbol}")) + + async def active_set(self) -> set[str]: + keys = await self.redis.keys(f"{self._KEY_PREFIX}*") + out: set[str] = set() + for k in keys: + key_str = k.decode() if isinstance(k, bytes) else k + out.add(key_str.replace(self._KEY_PREFIX, "")) + return out diff --git a/services/kevin_signal_bridge/risk_counters.py b/services/kevin_signal_bridge/risk_counters.py new file mode 100644 index 0000000..6b7f316 --- /dev/null +++ b/services/kevin_signal_bridge/risk_counters.py @@ -0,0 +1,54 @@ +"""Daily Redis counters for trade-cap + alloc-cap + pause flag.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from decimal import Decimal +from typing import Any + + +class KevinRiskCounters: + _PAUSE_KEY = "trading:paused" + _TRADES_KEY = "kevin:daily_trades:{date}" + _ALLOC_KEY = "kevin:daily_alloc:{date}" + + def __init__(self, redis: Any) -> None: + self.redis = redis + + @staticmethod + def _today_utc() -> str: + return datetime.now(timezone.utc).strftime("%Y%m%d") + + async def get_daily_trades(self) -> int: + v = await self.redis.get(self._TRADES_KEY.format(date=self._today_utc())) + if v is None: + return 0 + return int(v) + + async def increment_daily_trades(self) -> int: + key = self._TRADES_KEY.format(date=self._today_utc()) + n = await self.redis.incr(key) + await self.redis.expire(key, 172800) # 48h + return int(n) + + async def get_daily_alloc(self) -> Decimal: + v = await self.redis.get(self._ALLOC_KEY.format(date=self._today_utc())) + if v is None: + return Decimal("0") + s = v.decode() if isinstance(v, bytes) else str(v) + return Decimal(s) + + async def add_daily_alloc(self, usd: Decimal) -> Decimal: + key = self._ALLOC_KEY.format(date=self._today_utc()) + new = await self.redis.incrbyfloat(key, float(usd)) + await self.redis.expire(key, 172800) + return Decimal(str(new)) + + async def is_trading_paused(self) -> bool: + return bool(await self.redis.get(self._PAUSE_KEY)) + + async def set_trading_paused(self, ttl_seconds: int | None = None) -> None: + if ttl_seconds: + await self.redis.set(self._PAUSE_KEY, "1", ex=ttl_seconds) + else: + await self.redis.set(self._PAUSE_KEY, "PERMANENT") diff --git a/tests/services/kevin_signal_bridge/test_blocklist.py b/tests/services/kevin_signal_bridge/test_blocklist.py new file mode 100644 index 0000000..20e635a --- /dev/null +++ b/tests/services/kevin_signal_bridge/test_blocklist.py @@ -0,0 +1,49 @@ +"""Tests for KevinBlocklist using fakeredis.""" + +import fakeredis.aioredis + +from services.kevin_signal_bridge.blocklist import KevinBlocklist + + +async def _redis(): + return fakeredis.aioredis.FakeRedis() + + +async def test_blocklist_add_and_is_blocked(): + redis = await _redis() + bl = KevinBlocklist(redis) + + assert await bl.is_blocked("NVDA") is False + await bl.add("NVDA", ttl_days=7) + assert await bl.is_blocked("NVDA") is True + + +async def test_blocklist_remove(): + redis = await _redis() + bl = KevinBlocklist(redis) + + await bl.add("NVDA", ttl_days=7) + await bl.remove("NVDA") + assert await bl.is_blocked("NVDA") is False + + +async def test_blocklist_active_set_returns_all_blocked(): + redis = await _redis() + bl = KevinBlocklist(redis) + + await bl.add("NVDA", ttl_days=7) + await bl.add("AAPL", ttl_days=7) + active = await bl.active_set() + assert active == {"NVDA", "AAPL"} + + +async def test_blocklist_ttl_expires(): + redis = await _redis() + bl = KevinBlocklist(redis) + + # ttl_days=0 -> 0 seconds; set should be cleared immediately by Redis + # so use a small TTL and skip ahead. fakeredis supports `time` jump but + # the simplest test asserts the key exists with TTL set + await bl.add("NVDA", ttl_days=1) + ttl = await redis.ttl("kevin:blocked:NVDA") + assert ttl > 0 diff --git a/tests/services/kevin_signal_bridge/test_risk_counters.py b/tests/services/kevin_signal_bridge/test_risk_counters.py new file mode 100644 index 0000000..c5b69df --- /dev/null +++ b/tests/services/kevin_signal_bridge/test_risk_counters.py @@ -0,0 +1,55 @@ +"""Tests for KevinRiskCounters using fakeredis.""" + +from decimal import Decimal + +import fakeredis.aioredis + +from services.kevin_signal_bridge.risk_counters import KevinRiskCounters + + +async def _redis(): + return fakeredis.aioredis.FakeRedis() + + +async def test_get_daily_trades_starts_at_zero(): + rc = KevinRiskCounters(await _redis()) + assert await rc.get_daily_trades() == 0 + + +async def test_increment_daily_trades_increments(): + rc = KevinRiskCounters(await _redis()) + n = await rc.increment_daily_trades() + assert n == 1 + n2 = await rc.increment_daily_trades() + assert n2 == 2 + assert await rc.get_daily_trades() == 2 + + +async def test_get_daily_alloc_starts_at_zero(): + rc = KevinRiskCounters(await _redis()) + assert await rc.get_daily_alloc() == Decimal("0") + + +async def test_add_daily_alloc_accumulates(): + rc = KevinRiskCounters(await _redis()) + await rc.add_daily_alloc(Decimal("1000")) + new = await rc.add_daily_alloc(Decimal("2500")) + assert new == Decimal("3500") + assert await rc.get_daily_alloc() == Decimal("3500") + + +async def test_pause_flag_default_false(): + rc = KevinRiskCounters(await _redis()) + assert await rc.is_trading_paused() is False + + +async def test_set_trading_paused_with_ttl_sets_flag(): + rc = KevinRiskCounters(await _redis()) + await rc.set_trading_paused(ttl_seconds=60) + assert await rc.is_trading_paused() is True + + +async def test_set_trading_paused_permanent_sets_flag(): + rc = KevinRiskCounters(await _redis()) + await rc.set_trading_paused() + assert await rc.is_trading_paused() is True