feat(kevin_bridge): blocklist + daily risk counters
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was canceled
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was canceled
This commit is contained in:
parent
3347847e38
commit
a417cae77b
4 changed files with 195 additions and 0 deletions
37
services/kevin_signal_bridge/blocklist.py
Normal file
37
services/kevin_signal_bridge/blocklist.py
Normal file
|
|
@ -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
|
||||
54
services/kevin_signal_bridge/risk_counters.py
Normal file
54
services/kevin_signal_bridge/risk_counters.py
Normal file
|
|
@ -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")
|
||||
49
tests/services/kevin_signal_bridge/test_blocklist.py
Normal file
49
tests/services/kevin_signal_bridge/test_blocklist.py
Normal file
|
|
@ -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
|
||||
55
tests/services/kevin_signal_bridge/test_risk_counters.py
Normal file
55
tests/services/kevin_signal_bridge/test_risk_counters.py
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue