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

@ -6,7 +6,7 @@ from decimal import Decimal
import pytest
from shared.schemas.kevin import KevinAccountState, KevinDecisionType
from shared.schemas.meet_kevin import TickerAction, TimeHorizon
from shared.schemas.meet_kevin import ExpectedMove, TickerAction, TimeHorizon
from shared.strategies.kevin import KevinStrategy, KevinStrategyConfig
@ -44,7 +44,14 @@ def state() -> KevinAccountState:
)
def _mention(symbol="NVDA", action="buy", conviction="0.7", horizon="weeks", age_hours=1):
def _mention(
symbol="NVDA",
action="buy",
conviction="0.7",
horizon="weeks",
age_hours=1,
expected_move="up_mild",
):
"""Lightweight stub matching KevinStockMention attribute access."""
return type(
"M",
@ -57,6 +64,9 @@ def _mention(symbol="NVDA", action="buy", conviction="0.7", horizon="weeks", age
"time_horizon": TimeHorizon(horizon),
"rationale_quote": "test",
"created_at": datetime.now(timezone.utc) - timedelta(hours=age_hours),
"expected_move": (
ExpectedMove(expected_move) if isinstance(expected_move, str) else expected_move
),
},
)
@ -185,7 +195,7 @@ async def test_sell_with_held_emits_close_long(cfg, state):
update={"held_positions": {"NVDA": Decimal("3000")}}
)
d = await KevinStrategy(cfg).evaluate_mention(
_mention(action="sell"),
_mention(action="sell", expected_move="down_mild"),
state_p,
effective_conviction=Decimal("0.7"),
current_price=Decimal("100"),
@ -196,7 +206,7 @@ async def test_sell_with_held_emits_close_long(cfg, state):
async def test_sell_without_held_is_no_op(cfg, state):
d = await KevinStrategy(cfg).evaluate_mention(
_mention(action="sell"),
_mention(action="sell", expected_move="down_mild"),
state,
effective_conviction=Decimal("0.7"),
current_price=Decimal("100"),
@ -210,7 +220,7 @@ async def test_avoid_with_held_emits_close_long(cfg, state):
update={"held_positions": {"NVDA": Decimal("3000")}}
)
d = await KevinStrategy(cfg).evaluate_mention(
_mention(action="avoid"),
_mention(action="avoid", expected_move="down_mild"),
state_p,
effective_conviction=Decimal("0.7"),
current_price=Decimal("100"),
@ -223,7 +233,7 @@ async def test_avoid_without_held_is_no_op_but_blocklists(cfg, state):
# The strategy itself returns NO_OP; the bridge applies the blocklist
# side-effect. We assert rationale reflects intent.
d = await KevinStrategy(cfg).evaluate_mention(
_mention(action="avoid"),
_mention(action="avoid", expected_move="down_mild"),
state,
effective_conviction=Decimal("0.7"),
current_price=Decimal("100"),
@ -302,3 +312,155 @@ async def test_horizon_unspecified_defaults_to_weeks(cfg, state):
is_tradable=True,
)
assert d.holding_days == 10
# ============================================================================
# v2: expected_move integration — forward-looking veto + alignment
# ============================================================================
async def test_buy_with_down_expected_move_is_veto(cfg, state):
"""LLM said 'buy' but expected_move is bearish — schema inconsistency. Veto."""
d = await KevinStrategy(cfg).evaluate_mention(
_mention(action="buy", conviction="0.8", expected_move="down_mild"),
state,
effective_conviction=Decimal("0.8"),
current_price=Decimal("100"),
is_tradable=True,
)
assert d.decision == KevinDecisionType.NO_OP
assert "expected_move" in d.rationale.lower()
async def test_buy_with_sideways_expected_move_is_veto(cfg, state):
d = await KevinStrategy(cfg).evaluate_mention(
_mention(action="buy", conviction="0.8", expected_move="sideways"),
state,
effective_conviction=Decimal("0.8"),
current_price=Decimal("100"),
is_tradable=True,
)
assert d.decision == KevinDecisionType.NO_OP
async def test_buy_with_up_strong_passes(cfg, state):
d = await KevinStrategy(cfg).evaluate_mention(
_mention(action="buy", conviction="0.7", expected_move="up_strong"),
state,
effective_conviction=Decimal("0.7"),
current_price=Decimal("100"),
is_tradable=True,
)
assert d.decision == KevinDecisionType.OPEN_LONG
async def test_buy_with_unknown_expected_requires_higher_conviction(cfg, state):
"""Without a forward direction, require a conviction floor bump."""
# Just above old floor (0.6) but below new floor (0.6 + 0.05 = 0.65) → veto
d = await KevinStrategy(cfg).evaluate_mention(
_mention(action="buy", conviction="0.62", expected_move="unknown"),
state,
effective_conviction=Decimal("0.62"),
current_price=Decimal("100"),
is_tradable=True,
)
assert d.decision == KevinDecisionType.NO_OP
assert "unknown" in d.rationale.lower() or "0.65" in d.rationale
async def test_buy_with_unknown_expected_high_enough_passes(cfg, state):
# 0.70 > 0.65 → passes the bumped floor
d = await KevinStrategy(cfg).evaluate_mention(
_mention(action="buy", conviction="0.70", expected_move="unknown"),
state,
effective_conviction=Decimal("0.70"),
current_price=Decimal("100"),
is_tradable=True,
)
assert d.decision == KevinDecisionType.OPEN_LONG
async def test_sell_without_forward_direction_is_veto(cfg, state):
"""KEY: Kevin saying 'sold' without explaining where it goes next = reactive
capitulation. We do NOT close the long on that signal."""
state_held = KevinAccountState(
equity_usd=Decimal("100000"),
cash_usd=Decimal("95000"),
held_positions={"NVDA": Decimal("5000")},
blocklisted_symbols=set(),
daily_trade_count=0,
daily_alloc_usd=Decimal("0"),
paused=False,
)
d = await KevinStrategy(cfg).evaluate_mention(
_mention(action="sell", expected_move="unknown"),
state_held,
effective_conviction=Decimal("0.7"),
current_price=Decimal("100"),
is_tradable=True,
)
assert d.decision == KevinDecisionType.NO_OP
assert "forward" in d.rationale.lower() or "expected_move" in d.rationale.lower()
async def test_sell_with_up_expected_is_veto(cfg, state):
"""sell + up_* = LLM inconsistency. Veto."""
state_held = KevinAccountState(
equity_usd=Decimal("100000"),
cash_usd=Decimal("95000"),
held_positions={"NVDA": Decimal("5000")},
blocklisted_symbols=set(),
daily_trade_count=0,
daily_alloc_usd=Decimal("0"),
paused=False,
)
d = await KevinStrategy(cfg).evaluate_mention(
_mention(action="sell", expected_move="up_mild"),
state_held,
effective_conviction=Decimal("0.7"),
current_price=Decimal("100"),
is_tradable=True,
)
assert d.decision == KevinDecisionType.NO_OP
async def test_sell_with_forward_down_direction_closes_long(cfg, state):
"""sell + down_* with a held position → close long (what we WANT)."""
state_held = KevinAccountState(
equity_usd=Decimal("100000"),
cash_usd=Decimal("95000"),
held_positions={"NVDA": Decimal("5000")},
blocklisted_symbols=set(),
daily_trade_count=0,
daily_alloc_usd=Decimal("0"),
paused=False,
)
d = await KevinStrategy(cfg).evaluate_mention(
_mention(action="sell", expected_move="down_strong"),
state_held,
effective_conviction=Decimal("0.7"),
current_price=Decimal("100"),
is_tradable=True,
)
assert d.decision == KevinDecisionType.CLOSE_LONG
async def test_avoid_with_up_expected_skipped(cfg, state):
"""avoid + up_* = LLM thinks it'll rise. Don't blocklist / don't close."""
state_held = KevinAccountState(
equity_usd=Decimal("100000"),
cash_usd=Decimal("95000"),
held_positions={"NVDA": Decimal("5000")},
blocklisted_symbols=set(),
daily_trade_count=0,
daily_alloc_usd=Decimal("0"),
paused=False,
)
d = await KevinStrategy(cfg).evaluate_mention(
_mention(action="avoid", expected_move="up_mild"),
state_held,
effective_conviction=Decimal("0.7"),
current_price=Decimal("100"),
is_tradable=True,
)
assert d.decision == KevinDecisionType.NO_OP