feat(kevin-strategy): integrate expected_move into trading decision
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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:
parent
dee3f2b0a1
commit
b82014995c
3 changed files with 255 additions and 13 deletions
|
|
@ -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})"
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -31,7 +31,15 @@ class _StubPriceLoader:
|
|||
return self.spy if self.spy is not None else pd.DataFrame()
|
||||
|
||||
|
||||
def _mention(symbol, action, conviction, horizon, days_ago):
|
||||
def _mention(symbol, action, conviction, horizon, days_ago, expected_move=None):
|
||||
# Default expected_move based on action so backtests don't trip the
|
||||
# v2 forward-direction veto on sells.
|
||||
if expected_move is None:
|
||||
expected_move = {
|
||||
"buy": "up_mild",
|
||||
"sell": "down_mild",
|
||||
"avoid": "down_mild",
|
||||
}.get(action, "unknown")
|
||||
return type(
|
||||
"M",
|
||||
(),
|
||||
|
|
@ -41,6 +49,7 @@ def _mention(symbol, action, conviction, horizon, days_ago):
|
|||
"action": type("A", (), {"value": action})(),
|
||||
"conviction": Decimal(conviction),
|
||||
"time_horizon": type("H", (), {"value": horizon})(),
|
||||
"expected_move": type("E", (), {"value": expected_move})(),
|
||||
"created_at": datetime(2026, 5, 15, 14, 0, tzinfo=timezone.utc)
|
||||
+ timedelta(days=days_ago),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue