feat(kevin-strategy): integrate expected_move into trading decision
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.
This commit is contained in:
Viktor Barzin 2026-05-28 22:45:24 +00:00
parent dee3f2b0a1
commit b82014995c
3 changed files with 255 additions and 13 deletions

View file

@ -18,7 +18,11 @@ from shared.schemas.kevin import (
KevinDecision,
KevinDecisionType,
)
from shared.schemas.meet_kevin import TickerAction, TimeHorizon
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)
@ -34,6 +38,14 @@ class KevinStrategyConfig:
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:
@ -57,10 +69,17 @@ class KevinStrategy:
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).
# 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:
@ -87,12 +106,29 @@ class KevinStrategy:
# 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="kevin SELL on held position",
rationale=(
f"kevin SELL+{expected_move_value} on held position"
),
)
return KevinDecision(
decision=KevinDecisionType.NO_OP,
@ -102,6 +138,17 @@ class KevinStrategy:
# 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,
@ -124,13 +171,37 @@ class KevinStrategy:
# 5. BUY path — full filter stack
assert action_value == TickerAction.BUY.value
if effective_conviction < self.config.min_conviction:
# 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 {self.config.min_conviction}"
f"min_conviction {effective_min_conviction} "
f"(expected_move={expected_move_value})"
),
)