trading/services/kevin_signal_bridge/exit_scanner.py
Viktor Barzin cff2564428
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was canceled
feat(kevin_bridge): exit-scan daily job + cursor + audit writer
2026-05-24 01:03:53 +00:00

104 lines
3.6 KiB
Python

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