diff --git a/shared/strategies/kevin.py b/shared/strategies/kevin.py index 09915d2..fab8876 100644 --- a/shared/strategies/kevin.py +++ b/shared/strategies/kevin.py @@ -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})" ), ) diff --git a/tests/backtester/test_kevin_backtest.py b/tests/backtester/test_kevin_backtest.py index 5452782..1fb5e16 100644 --- a/tests/backtester/test_kevin_backtest.py +++ b/tests/backtester/test_kevin_backtest.py @@ -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), }, diff --git a/tests/shared/strategies/test_kevin_strategy.py b/tests/shared/strategies/test_kevin_strategy.py index ad14271..e7ff3b3 100644 --- a/tests/shared/strategies/test_kevin_strategy.py +++ b/tests/shared/strategies/test_kevin_strategy.py @@ -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