"""Standalone Kevin strategy decision logic. NOT a BaseStrategy subclass — the BaseStrategy signature is bar/article driven; Kevin is event-driven on YouTube mentions. Same shape called by both the live signal bridge and the backtest mini-engine, so behaviour cannot drift between them. """ from __future__ import annotations from dataclasses import dataclass from datetime import datetime, timedelta, timezone from decimal import Decimal from typing import Any from shared.schemas.kevin import ( KevinAccountState, KevinDecision, KevinDecisionType, ) from shared.schemas.meet_kevin import TickerAction, TimeHorizon @dataclass(frozen=True) class KevinStrategyConfig: """Strategy parameters (all overridable via env-vars in the bridge).""" min_conviction: Decimal max_mention_age_hours: int base_position_pct: Decimal min_trade_usd: Decimal max_trade_usd: Decimal max_per_ticker_usd: Decimal hold_days_by_horizon: dict[str, int] avoid_closes_longs: bool avoid_blocks_days: int class KevinStrategy: """Pure decision function: mention + account state -> KevinDecision. Stateless. The bridge owns side effects (blocklist writes, Redis counters). """ name: str = "kevin" def __init__(self, config: KevinStrategyConfig) -> None: self.config = config async def evaluate_mention( self, mention: Any, # KevinStockMention or stub account: KevinAccountState, *, effective_conviction: Decimal, current_price: Decimal, is_tradable: bool, ) -> KevinDecision: symbol = mention.symbol # Normalize the action/horizon to their str value so the strategy works # with both SQLAlchemy enum instances and lightweight stubs (backtest). action_value = getattr(mention.action, "value", mention.action) horizon_value = getattr(mention.time_horizon, "value", mention.time_horizon) # 1. Common no-trade gates if not is_tradable: return KevinDecision( decision=KevinDecisionType.NO_OP, symbol=symbol, rationale="not tradable on Alpaca", ) if account.paused: return KevinDecision( decision=KevinDecisionType.NO_OP, symbol=symbol, rationale="trading paused (circuit breaker / drawdown halt)", ) # 2. Action-specific gates if action_value in (TickerAction.HOLD.value, TickerAction.WATCH.value): return KevinDecision( decision=KevinDecisionType.NO_OP, symbol=symbol, rationale=f"action={action_value} is UI-only, never trades", ) # 3. SELL — close long if held, else no-op if action_value == TickerAction.SELL.value: if account.is_held(symbol): return KevinDecision( decision=KevinDecisionType.CLOSE_LONG, symbol=symbol, effective_conviction=effective_conviction, rationale="kevin SELL on held position", ) return KevinDecision( decision=KevinDecisionType.NO_OP, symbol=symbol, rationale="SELL but not held; long-only, never shorts", ) # 4. AVOID — close long if held + bridge will add blocklist (side effect) if action_value == TickerAction.AVOID.value: if account.is_held(symbol) and self.config.avoid_closes_longs: return KevinDecision( decision=KevinDecisionType.CLOSE_LONG, symbol=symbol, effective_conviction=effective_conviction, rationale=( f"kevin AVOID on held position; bridge will blocklist " f"{self.config.avoid_blocks_days}d" ), ) return KevinDecision( decision=KevinDecisionType.NO_OP, symbol=symbol, rationale=( f"kevin AVOID; bridge will add to blocklist for " f"{self.config.avoid_blocks_days}d" ), ) # 5. BUY path — full filter stack assert action_value == TickerAction.BUY.value if effective_conviction < self.config.min_conviction: return KevinDecision( decision=KevinDecisionType.NO_OP, symbol=symbol, rationale=( f"conviction {effective_conviction} below " f"min_conviction {self.config.min_conviction}" ), ) if horizon_value == TimeHorizon.INTRADAY.value: return KevinDecision( decision=KevinDecisionType.NO_OP, symbol=symbol, rationale="intraday horizon — 3h poll cadence can't catch", ) # Mention age — uses created_at if available if hasattr(mention, "created_at") and mention.created_at is not None: age = datetime.now(timezone.utc) - mention.created_at if age > timedelta(hours=self.config.max_mention_age_hours): return KevinDecision( decision=KevinDecisionType.NO_OP, symbol=symbol, rationale=( f"mention age {age} exceeds " f"{self.config.max_mention_age_hours}h" ), ) if account.is_blocklisted(symbol): return KevinDecision( decision=KevinDecisionType.NO_OP, symbol=symbol, rationale="symbol is on blocklist (prior AVOID)", ) # 6. Compute size — conviction-weighted fixed-fractional conviction_mult = (effective_conviction - self.config.min_conviction) / ( Decimal("1") - self.config.min_conviction ) target_pct = self.config.base_position_pct * ( Decimal("0.5") + Decimal("0.5") * conviction_mult ) target_dollars = account.equity_usd * target_pct target_dollars = max( self.config.min_trade_usd, min(target_dollars, self.config.max_trade_usd), ) # 7. Per-ticker cap absorbs multi-mention boost already_held_usd = account.held_positions.get(symbol, Decimal("0")) headroom = self.config.max_per_ticker_usd - already_held_usd if headroom <= 0: return KevinDecision( decision=KevinDecisionType.NO_OP, symbol=symbol, rationale=f"at per-ticker cap (${already_held_usd} already)", ) target_dollars = min(target_dollars, headroom) if target_dollars < self.config.min_trade_usd: return KevinDecision( decision=KevinDecisionType.NO_OP, symbol=symbol, rationale=( f"after cap headroom only ${target_dollars} — " f"below ${self.config.min_trade_usd} floor" ), ) # Round to 2dp dollars target_dollars = target_dollars.quantize(Decimal("0.01")) holding_days = self.config.hold_days_by_horizon.get( horizon_value, self.config.hold_days_by_horizon["unspecified"] ) return KevinDecision( decision=KevinDecisionType.OPEN_LONG, symbol=symbol, target_dollars=target_dollars, holding_days=holding_days, effective_conviction=effective_conviction, rationale=( f"BUY conv={effective_conviction} -> " f"{target_pct * 100}% target=${target_dollars} hold={holding_days}d" ), )