All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
The v2 prompt produces expected_move for every ticker mention. This commit makes KevinStrategy.evaluate_mention USE it as a hard signal rather than just a display field. Three new rules, all guarded by KevinStrategyConfig knobs so the behaviour can be turned off if it over-filters: 1) SELL + non-bearish expected_move => NO_OP (require_forward_for_ bearish, default True). This is THE anti-capitulation rule — Kevin saying "I sold" without articulating where the stock goes next becomes NO_OP. Reactive sells stop translating into trades. 2) AVOID + bullish expected_move => NO_OP (don't close, don't blocklist). Same idea — if the LLM's forward call contradicts the avoid action, treat as inconsistent and skip. 3) BUY + bearish/sideways expected_move => NO_OP (schema veto). Catches LLM inconsistency. 4) BUY + unknown expected_move => bump min_conviction floor by unknown_conviction_bonus (default +0.05). Forces stronger conviction when there's no forward direction. Tests: 6 new (one per rule above), 22 regression — total 28 GREEN. Backtest stub _mention factory now defaults expected_move from action (buy/sell/avoid maps) so existing backtest scenarios stay green; the test_backtest_sell_mid_position_closes_early case was the only one that needed the fix. Side note: strategy is backward-compatible. If a mention has no expected_move attribute (e.g. v1 stub from older code), it defaults to UNKNOWN and the legacy code paths still work — just with the stricter conviction floor on buys.
286 lines
11 KiB
Python
286 lines
11 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 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"
|
|
),
|
|
)
|