104 lines
3.6 KiB
Python
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))
|