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.
466 lines
15 KiB
Python
466 lines
15 KiB
Python
"""KevinStrategy tests — one behaviour per test, full conviction x action x held grid."""
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
from decimal import Decimal
|
|
|
|
import pytest
|
|
|
|
from shared.schemas.kevin import KevinAccountState, KevinDecisionType
|
|
from shared.schemas.meet_kevin import ExpectedMove, TickerAction, TimeHorizon
|
|
from shared.strategies.kevin import KevinStrategy, KevinStrategyConfig
|
|
|
|
|
|
@pytest.fixture
|
|
def cfg() -> KevinStrategyConfig:
|
|
return KevinStrategyConfig(
|
|
min_conviction=Decimal("0.6"),
|
|
max_mention_age_hours=48,
|
|
base_position_pct=Decimal("0.04"),
|
|
min_trade_usd=Decimal("500"),
|
|
max_trade_usd=Decimal("5000"),
|
|
max_per_ticker_usd=Decimal("7500"),
|
|
hold_days_by_horizon={
|
|
"days": 3,
|
|
"weeks": 10,
|
|
"months": 45,
|
|
"long_term": 90,
|
|
"unspecified": 10,
|
|
},
|
|
avoid_closes_longs=True,
|
|
avoid_blocks_days=7,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def state() -> KevinAccountState:
|
|
return KevinAccountState(
|
|
equity_usd=Decimal("100000"),
|
|
cash_usd=Decimal("100000"),
|
|
held_positions={},
|
|
blocklisted_symbols=set(),
|
|
daily_trade_count=0,
|
|
daily_alloc_usd=Decimal("0"),
|
|
paused=False,
|
|
)
|
|
|
|
|
|
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",
|
|
(),
|
|
{
|
|
"id": 1,
|
|
"symbol": symbol,
|
|
"action": TickerAction(action) if isinstance(action, str) else action,
|
|
"conviction": Decimal(conviction),
|
|
"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
|
|
),
|
|
},
|
|
)
|
|
|
|
|
|
async def test_buy_high_conviction_emits_open_long(cfg, state):
|
|
d = await KevinStrategy(cfg).evaluate_mention(
|
|
_mention(action="buy", conviction="0.8"),
|
|
state,
|
|
effective_conviction=Decimal("0.8"),
|
|
current_price=Decimal("100"),
|
|
is_tradable=True,
|
|
)
|
|
assert d.decision == KevinDecisionType.OPEN_LONG
|
|
assert d.symbol == "NVDA"
|
|
# 4% * (0.5 + 0.5*(0.8-0.6)/0.4) = 4% * 0.75 = 3% of 100k = $3000
|
|
assert d.target_dollars == Decimal("3000.00")
|
|
assert d.holding_days == 10 # weeks
|
|
|
|
|
|
async def test_buy_conviction_at_floor_emits_minimum_size(cfg, state):
|
|
d = await KevinStrategy(cfg).evaluate_mention(
|
|
_mention(action="buy", conviction="0.6"),
|
|
state,
|
|
effective_conviction=Decimal("0.6"),
|
|
current_price=Decimal("100"),
|
|
is_tradable=True,
|
|
)
|
|
# 4% * 0.5 = 2% of 100k = $2000
|
|
assert d.target_dollars == Decimal("2000.00")
|
|
|
|
|
|
async def test_buy_below_min_conviction_is_no_op(cfg, state):
|
|
d = await KevinStrategy(cfg).evaluate_mention(
|
|
_mention(action="buy", conviction="0.5"),
|
|
state,
|
|
effective_conviction=Decimal("0.5"),
|
|
current_price=Decimal("100"),
|
|
is_tradable=True,
|
|
)
|
|
assert d.decision == KevinDecisionType.NO_OP
|
|
assert "min_conviction" in d.rationale.lower()
|
|
|
|
|
|
async def test_buy_mention_too_old_is_no_op(cfg, state):
|
|
d = await KevinStrategy(cfg).evaluate_mention(
|
|
_mention(age_hours=72),
|
|
state,
|
|
effective_conviction=Decimal("0.7"),
|
|
current_price=Decimal("100"),
|
|
is_tradable=True,
|
|
)
|
|
assert d.decision == KevinDecisionType.NO_OP
|
|
assert "age" in d.rationale.lower()
|
|
|
|
|
|
async def test_buy_non_tradable_is_no_op(cfg, state):
|
|
d = await KevinStrategy(cfg).evaluate_mention(
|
|
_mention(symbol="OTCXX"),
|
|
state,
|
|
effective_conviction=Decimal("0.7"),
|
|
current_price=Decimal("1"),
|
|
is_tradable=False,
|
|
)
|
|
assert d.decision == KevinDecisionType.NO_OP
|
|
assert "tradable" in d.rationale.lower()
|
|
|
|
|
|
async def test_buy_intraday_horizon_is_no_op(cfg, state):
|
|
d = await KevinStrategy(cfg).evaluate_mention(
|
|
_mention(horizon="intraday"),
|
|
state,
|
|
effective_conviction=Decimal("0.7"),
|
|
current_price=Decimal("100"),
|
|
is_tradable=True,
|
|
)
|
|
assert d.decision == KevinDecisionType.NO_OP
|
|
|
|
|
|
async def test_buy_when_blocklisted_is_no_op(cfg, state):
|
|
state_b = state.model_copy(update={"blocklisted_symbols": {"NVDA"}})
|
|
d = await KevinStrategy(cfg).evaluate_mention(
|
|
_mention(),
|
|
state_b,
|
|
effective_conviction=Decimal("0.7"),
|
|
current_price=Decimal("100"),
|
|
is_tradable=True,
|
|
)
|
|
assert d.decision == KevinDecisionType.NO_OP
|
|
assert "blocklist" in d.rationale.lower()
|
|
|
|
|
|
async def test_buy_at_per_ticker_cap_is_no_op(cfg, state):
|
|
state_full = state.model_copy(
|
|
update={"held_positions": {"NVDA": Decimal("7500")}}
|
|
)
|
|
d = await KevinStrategy(cfg).evaluate_mention(
|
|
_mention(),
|
|
state_full,
|
|
effective_conviction=Decimal("0.7"),
|
|
current_price=Decimal("100"),
|
|
is_tradable=True,
|
|
)
|
|
assert d.decision == KevinDecisionType.NO_OP
|
|
assert "cap" in d.rationale.lower()
|
|
|
|
|
|
async def test_buy_held_below_cap_tops_up_to_cap(cfg, state):
|
|
state_p = state.model_copy(
|
|
update={"held_positions": {"NVDA": Decimal("5000")}}
|
|
)
|
|
d = await KevinStrategy(cfg).evaluate_mention(
|
|
_mention(conviction="1.0"),
|
|
state_p,
|
|
effective_conviction=Decimal("1.0"),
|
|
current_price=Decimal("100"),
|
|
is_tradable=True,
|
|
)
|
|
assert d.decision == KevinDecisionType.OPEN_LONG
|
|
# full target = $4000 (4% of 100k clamped at 5k cap), but per-ticker cap = $7500,
|
|
# already held $5000 -> top up min(4000, 2500) = $2500
|
|
assert d.target_dollars == Decimal("2500.00")
|
|
|
|
|
|
async def test_sell_with_held_emits_close_long(cfg, state):
|
|
state_p = state.model_copy(
|
|
update={"held_positions": {"NVDA": Decimal("3000")}}
|
|
)
|
|
d = await KevinStrategy(cfg).evaluate_mention(
|
|
_mention(action="sell", expected_move="down_mild"),
|
|
state_p,
|
|
effective_conviction=Decimal("0.7"),
|
|
current_price=Decimal("100"),
|
|
is_tradable=True,
|
|
)
|
|
assert d.decision == KevinDecisionType.CLOSE_LONG
|
|
|
|
|
|
async def test_sell_without_held_is_no_op(cfg, state):
|
|
d = await KevinStrategy(cfg).evaluate_mention(
|
|
_mention(action="sell", expected_move="down_mild"),
|
|
state,
|
|
effective_conviction=Decimal("0.7"),
|
|
current_price=Decimal("100"),
|
|
is_tradable=True,
|
|
)
|
|
assert d.decision == KevinDecisionType.NO_OP
|
|
|
|
|
|
async def test_avoid_with_held_emits_close_long(cfg, state):
|
|
state_p = state.model_copy(
|
|
update={"held_positions": {"NVDA": Decimal("3000")}}
|
|
)
|
|
d = await KevinStrategy(cfg).evaluate_mention(
|
|
_mention(action="avoid", expected_move="down_mild"),
|
|
state_p,
|
|
effective_conviction=Decimal("0.7"),
|
|
current_price=Decimal("100"),
|
|
is_tradable=True,
|
|
)
|
|
assert d.decision == KevinDecisionType.CLOSE_LONG
|
|
|
|
|
|
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", expected_move="down_mild"),
|
|
state,
|
|
effective_conviction=Decimal("0.7"),
|
|
current_price=Decimal("100"),
|
|
is_tradable=True,
|
|
)
|
|
assert d.decision == KevinDecisionType.NO_OP
|
|
assert "blocklist" in d.rationale.lower() or "avoid" in d.rationale.lower()
|
|
|
|
|
|
async def test_hold_action_never_trades(cfg, state):
|
|
d = await KevinStrategy(cfg).evaluate_mention(
|
|
_mention(action="hold"),
|
|
state,
|
|
effective_conviction=Decimal("0.9"),
|
|
current_price=Decimal("100"),
|
|
is_tradable=True,
|
|
)
|
|
assert d.decision == KevinDecisionType.NO_OP
|
|
|
|
|
|
async def test_watch_action_never_trades(cfg, state):
|
|
d = await KevinStrategy(cfg).evaluate_mention(
|
|
_mention(action="watch"),
|
|
state,
|
|
effective_conviction=Decimal("0.9"),
|
|
current_price=Decimal("100"),
|
|
is_tradable=True,
|
|
)
|
|
assert d.decision == KevinDecisionType.NO_OP
|
|
|
|
|
|
async def test_size_clamps_to_min_trade_usd(cfg, state):
|
|
# Equity small enough that 2% < $500 floor
|
|
small_state = state.model_copy(update={"equity_usd": Decimal("10000")})
|
|
d = await KevinStrategy(cfg).evaluate_mention(
|
|
_mention(conviction="0.6"),
|
|
small_state,
|
|
effective_conviction=Decimal("0.6"),
|
|
current_price=Decimal("100"),
|
|
is_tradable=True,
|
|
)
|
|
# 2% of $10k = $200 -> clamped to $500
|
|
assert d.target_dollars == Decimal("500.00")
|
|
|
|
|
|
async def test_size_clamps_to_max_trade_usd(cfg, state):
|
|
big_state = state.model_copy(update={"equity_usd": Decimal("1000000")})
|
|
d = await KevinStrategy(cfg).evaluate_mention(
|
|
_mention(conviction="1.0"),
|
|
big_state,
|
|
effective_conviction=Decimal("1.0"),
|
|
current_price=Decimal("100"),
|
|
is_tradable=True,
|
|
)
|
|
# 4% of $1M = $40k -> clamped to $5k
|
|
assert d.target_dollars == Decimal("5000.00")
|
|
|
|
|
|
async def test_horizon_long_term_maps_to_90_days(cfg, state):
|
|
d = await KevinStrategy(cfg).evaluate_mention(
|
|
_mention(horizon="long_term"),
|
|
state,
|
|
effective_conviction=Decimal("0.7"),
|
|
current_price=Decimal("100"),
|
|
is_tradable=True,
|
|
)
|
|
assert d.holding_days == 90
|
|
|
|
|
|
async def test_horizon_unspecified_defaults_to_weeks(cfg, state):
|
|
d = await KevinStrategy(cfg).evaluate_mention(
|
|
_mention(horizon="unspecified"),
|
|
state,
|
|
effective_conviction=Decimal("0.7"),
|
|
current_price=Decimal("100"),
|
|
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
|