"""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 ExpectedMove, TickerAction, TimeHorizon _BULLISH_MOVES = frozenset({ExpectedMove.UP_STRONG.value, ExpectedMove.UP_MILD.value}) _BEARISH_MOVES = frozenset({ExpectedMove.DOWN_STRONG.value, ExpectedMove.DOWN_MILD.value}) @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 # v2 prompt knobs — expected_move integration. # If True, sells/avoids require a forward bearish expected_move; reactive # capitulation (action=sell with no forward view) becomes NO_OP. require_forward_for_bearish: bool = True # When expected_move is 'unknown' on a BUY, bump the min_conviction floor # by this delta. Forces higher conviction when the LLM couldn't articulate # forward direction. unknown_conviction_bonus: Decimal = Decimal("0.05") 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/expected_move 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) expected_move_raw = getattr(mention, "expected_move", None) expected_move_value = ( getattr(expected_move_raw, "value", expected_move_raw) if expected_move_raw is not None else ExpectedMove.UNKNOWN.value ) # 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: # v2: reactive capitulation guard. Require an explicit forward # bearish view; otherwise the SELL might just be Kevin reacting # to a recent drop ("I sold after the 20% dump") which is NOT # actionable for us. require_forward_for_bearish off → legacy # behaviour. if self.config.require_forward_for_bearish: if expected_move_value not in _BEARISH_MOVES: return KevinDecision( decision=KevinDecisionType.NO_OP, symbol=symbol, rationale=( f"SELL vetoed: expected_move={expected_move_value} " f"is not forward-bearish (no reactive sells)" ), ) if account.is_held(symbol): return KevinDecision( decision=KevinDecisionType.CLOSE_LONG, symbol=symbol, effective_conviction=effective_conviction, rationale=( f"kevin SELL+{expected_move_value} 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: # v2: if LLM says avoid but expected_move is bullish, the avoid # contradicts itself — skip entirely (don't close, don't blocklist). if expected_move_value in _BULLISH_MOVES: return KevinDecision( decision=KevinDecisionType.NO_OP, symbol=symbol, rationale=( f"AVOID skipped: expected_move={expected_move_value} " f"contradicts the avoid action" ), ) 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 # v2: BUY + non-bullish expected_move = LLM inconsistency. Veto. if expected_move_value in ( ExpectedMove.DOWN_STRONG.value, ExpectedMove.DOWN_MILD.value, ExpectedMove.SIDEWAYS.value, ): return KevinDecision( decision=KevinDecisionType.NO_OP, symbol=symbol, rationale=( f"BUY vetoed: expected_move={expected_move_value} is not " f"bullish — schema inconsistency" ), ) # v2: BUY + unknown expected_move → require a higher conviction floor. # Without a forward call, only act on Kevin's strongest convictions. effective_min_conviction = self.config.min_conviction if expected_move_value == ExpectedMove.UNKNOWN.value: effective_min_conviction = ( self.config.min_conviction + self.config.unknown_conviction_bonus ) if effective_conviction < effective_min_conviction: return KevinDecision( decision=KevinDecisionType.NO_OP, symbol=symbol, rationale=( f"conviction {effective_conviction} below " f"min_conviction {effective_min_conviction} " f"(expected_move={expected_move_value})" ), ) 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" ), )