trading/shared/strategies/kevin.py
Viktor Barzin 23ce45a4f2
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was canceled
feat(kevin): mention-driven backtest mini-engine
Walks mentions chronologically, T+1 entry, time-based exit per
KevinStrategy. Reuses backtester/metrics::compute_metrics for headline
numbers. KevinPriceLoader fronts market_data + Alpaca.
2026-05-24 00:56:57 +00:00

215 lines
7.7 KiB
Python

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