feat(kevin_bridge): exit-scan daily job + cursor + audit writer
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
a417cae77b
commit
cff2564428
6 changed files with 480 additions and 0 deletions
50
services/kevin_signal_bridge/audit.py
Normal file
50
services/kevin_signal_bridge/audit.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
"""Audit writer: upserts a row in kevin_signal_bridge_state per processed mention."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
from typing import Any, Callable
|
||||
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
from shared.models.meet_kevin_trading import KevinSignalBridgeState
|
||||
|
||||
|
||||
class AuditWriter:
|
||||
def __init__(self, session_factory: Callable[..., Any]) -> None:
|
||||
self.session_factory = session_factory
|
||||
|
||||
async def write(
|
||||
self,
|
||||
*,
|
||||
mention_id: int,
|
||||
bridge_status: str,
|
||||
effective_conviction: Decimal | None = None,
|
||||
signal_id: uuid.UUID | None = None,
|
||||
trade_id: uuid.UUID | None = None,
|
||||
notes: str | None = None,
|
||||
) -> None:
|
||||
"""Upsert one audit row (mention_id is unique)."""
|
||||
async with self.session_factory() as session:
|
||||
stmt = pg_insert(KevinSignalBridgeState).values(
|
||||
id=uuid.uuid4(),
|
||||
mention_id=mention_id,
|
||||
bridge_status=bridge_status,
|
||||
effective_conviction=effective_conviction,
|
||||
signal_id=signal_id,
|
||||
trade_id=trade_id,
|
||||
notes=notes,
|
||||
)
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
index_elements=["mention_id"],
|
||||
set_={
|
||||
"bridge_status": stmt.excluded.bridge_status,
|
||||
"effective_conviction": stmt.excluded.effective_conviction,
|
||||
"signal_id": stmt.excluded.signal_id,
|
||||
"trade_id": stmt.excluded.trade_id,
|
||||
"notes": stmt.excluded.notes,
|
||||
},
|
||||
)
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
28
services/kevin_signal_bridge/cursor.py
Normal file
28
services/kevin_signal_bridge/cursor.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
"""Redis-backed cursor for the bridge.
|
||||
|
||||
Tracks the highest `kevin_stock_mentions.id` we've already attempted to
|
||||
process. Stored at key `kevin:bridge:last_mention_id`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
class RedisCursor:
|
||||
_KEY = "kevin:bridge:last_mention_id"
|
||||
|
||||
def __init__(self, redis: Any) -> None:
|
||||
self.redis = redis
|
||||
|
||||
async def last_seen_id(self) -> int:
|
||||
v = await self.redis.get(self._KEY)
|
||||
if v is None:
|
||||
return 0
|
||||
return int(v)
|
||||
|
||||
async def advance(self, mention_id: int) -> None:
|
||||
# Conservative: only set if mention_id > current
|
||||
current = await self.last_seen_id()
|
||||
if mention_id > current:
|
||||
await self.redis.set(self._KEY, str(mention_id))
|
||||
104
services/kevin_signal_bridge/exit_scanner.py
Normal file
104
services/kevin_signal_bridge/exit_scanner.py
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
"""Daily exit-scan job for the Kevin bridge.
|
||||
|
||||
Walks open Kevin trades (Trade.strategy_id == KEVIN_STRATEGY_UUID, status
|
||||
FILLED, no offsetting SELL) and publishes EXIT TradeSignals for any whose
|
||||
holding period has elapsed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Callable
|
||||
|
||||
from sqlalchemy import and_, select
|
||||
|
||||
from shared.constants.kevin import KEVIN_STRATEGY_UUID
|
||||
from shared.models.meet_kevin_trading import KevinSignalBridgeState
|
||||
from shared.models.trading import Trade, TradeStatus
|
||||
from shared.schemas.trading import SignalDirection, TradeSignal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ExitScanner:
|
||||
def __init__(
|
||||
self,
|
||||
session_factory: Callable[..., Any],
|
||||
publisher: Any,
|
||||
config: Any,
|
||||
) -> None:
|
||||
self.session_factory = session_factory
|
||||
self.publisher = publisher
|
||||
self.config = config
|
||||
|
||||
async def scan_and_emit_exits(self) -> int:
|
||||
"""Returns the number of EXIT signals emitted."""
|
||||
now = datetime.now(timezone.utc)
|
||||
emitted = 0
|
||||
async with self.session_factory() as session:
|
||||
# Find open Kevin trades (FILLED, no closing trade yet on same ticker)
|
||||
open_trades = (
|
||||
(
|
||||
await session.execute(
|
||||
select(Trade).where(
|
||||
and_(
|
||||
Trade.strategy_id == KEVIN_STRATEGY_UUID,
|
||||
Trade.status == TradeStatus.FILLED,
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
|
||||
for trade in open_trades:
|
||||
# Find the source audit row to learn the original holding_days target
|
||||
async with self.session_factory() as session:
|
||||
audit = (
|
||||
(
|
||||
await session.execute(
|
||||
select(KevinSignalBridgeState).where(
|
||||
KevinSignalBridgeState.trade_id == trade.id
|
||||
)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.one_or_none()
|
||||
)
|
||||
if audit is None:
|
||||
continue
|
||||
|
||||
hold_days = self._holding_days(audit)
|
||||
target_exit_at = audit.decided_at + timedelta(days=hold_days)
|
||||
if now < target_exit_at:
|
||||
continue
|
||||
|
||||
signal = TradeSignal(
|
||||
ticker=trade.ticker,
|
||||
direction=SignalDirection.EXIT,
|
||||
strength=1.0,
|
||||
strategy_id=KEVIN_STRATEGY_UUID,
|
||||
strategy_sources=[f"kevin:exit_scan:hold_expired:{hold_days}d"],
|
||||
)
|
||||
try:
|
||||
await self.publisher.publish(signal)
|
||||
emitted += 1
|
||||
except Exception:
|
||||
logger.exception("exit-scan publish failed for trade %s", trade.id)
|
||||
|
||||
return emitted
|
||||
|
||||
def _holding_days(self, audit: KevinSignalBridgeState) -> int:
|
||||
"""Best-effort holding days from notes; fallback to config default."""
|
||||
notes = audit.notes or ""
|
||||
# Try to find ' hold=Nd' in the audit notes
|
||||
for token in notes.split():
|
||||
if token.startswith("hold=") and token.endswith("d"):
|
||||
try:
|
||||
return int(token.removeprefix("hold=").removesuffix("d"))
|
||||
except ValueError:
|
||||
pass
|
||||
default_map = getattr(self.config, "kevin_hold_days", {})
|
||||
return int(default_map.get("unspecified", 10))
|
||||
Loading…
Add table
Add a link
Reference in a new issue