feat(meet-kevin): prompt v2 — forward-looking action + expected_move field
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was canceled

User reported that the old prompt could emit 'sell' on backward-looking
capitulation ('Kevin sold after a 20% drop') — exactly the false signal
to avoid. v2 reframes every per-ticker field as forward-looking and
adds an explicit expected_move enum for the trading bot to weight.

Changes:
- New ExpectedMove enum (up_strong/up_mild/sideways/down_mild/
  down_strong/unknown) in shared/schemas + shared/models, with
  matching kevin_expected_move Postgres enum + column on
  kevin_stock_mentions (migration e5f6a7b8c9d0). NOT NULL with
  server_default 'unknown' so existing rows backfill cleanly.
- SYSTEM_PROMPT rewritten: action semantics now require a FORWARD
  view; reactive sells get downgraded to 'watch' or skipped; the
  rationale_quote must contain forward reasoning. Quality
  checklist updated.
- _ANALYSIS_TOOL JSON schema gains expected_move (required).
- prompt_version v1 → v2 in config + infra + ad-hoc CLI default.
- pipeline.py persists ticker.expected_move into the new column.

Migration safety: the column is NOT NULL DEFAULT 'unknown' so 96
existing mentions auto-fill with 'unknown' (no forward call known
for backward analyses) without breaking any reads.

Cost to backfill the 27 existing analyses with v2 prompt: ~$3 LLM
spend. A follow-up reanalyze script will replay them after this
ships.
This commit is contained in:
Viktor Barzin 2026-05-28 21:40:07 +00:00
parent 658c4d3221
commit 41ab95ec4d
7 changed files with 164 additions and 18 deletions

View file

@ -0,0 +1,44 @@
"""Add expected_move column to kevin_stock_mentions (prompt v2).
Adds the KevinExpectedMove enum and a NOT NULL column with default
'unknown' so existing rows keep loading. New analyses produced by the
v2 prompt will populate it with a real directional view.
Revision ID: e5f6a7b8c9d0
Revises: d4e5f6a7b8c9
Create Date: 2026-05-28
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "e5f6a7b8c9d0"
down_revision = "d4e5f6a7b8c9"
branch_labels = None
depends_on = None
_ENUM_NAME = "kevin_expected_move"
_VALUES = ("up_strong", "up_mild", "sideways", "down_mild", "down_strong", "unknown")
def upgrade() -> None:
enum_type = postgresql.ENUM(*_VALUES, name=_ENUM_NAME, create_type=False)
enum_type.create(op.get_bind(), checkfirst=True)
op.add_column(
"kevin_stock_mentions",
sa.Column(
"expected_move",
enum_type,
nullable=False,
server_default="unknown",
),
)
def downgrade() -> None:
op.drop_column("kevin_stock_mentions", "expected_move")
op.execute(f"DROP TYPE IF EXISTS {_ENUM_NAME}")

View file

@ -10,7 +10,7 @@ no Redis publish — strictly observational.
Env vars (or pass via flags):
TRADING_ANTHROPIC_OAUTH_TOKEN required (matches what the pod uses)
TRADING_MEET_KEVIN_LLM_MODEL default: claude-haiku-4-5-20251001
TRADING_MEET_KEVIN_PROMPT_VERSION default: v1
TRADING_MEET_KEVIN_PROMPT_VERSION default: v2
"""
from __future__ import annotations
@ -94,8 +94,8 @@ def print_analysis(video_id: str, captions, result) -> None:
return
print(f"\n TICKERS ({len(a.tickers)}):")
print(f" {'SYMBOL':<8} {'ACTION':<7} {'CONV':>6} {'HORIZON':<11} {'RATIONALE'}")
print(f" {'-'*8} {'-'*7} {'-'*6} {'-'*11} {'-'*60}")
print(f" {'SYMBOL':<8} {'ACTION':<7} {'EXPECTED':<12} {'CONV':>6} {'HORIZON':<11} {'RATIONALE'}")
print(f" {'-'*8} {'-'*7} {'-'*12} {'-'*6} {'-'*11} {'-'*60}")
# sort by action priority (buy/sell first), then conviction desc
action_rank = {"buy": 0, "sell": 1, "hold": 2, "watch": 3, "avoid": 4}
sorted_t = sorted(
@ -107,9 +107,12 @@ def print_analysis(video_id: str, captions, result) -> None:
)
for t in sorted_t:
rationale = (t.rationale_quote or "").replace("\n", " ")[:60]
expected = getattr(t, "expected_move", None)
expected_str = expected.value if expected is not None else ""
print(
f" {t.symbol:<8} "
f"{t.action.value:<7} "
f"{expected_str:<12} "
f"{fmt_pct(t.conviction):>6} "
f"{t.time_horizon.value:<11} "
f"{rationale}"
@ -127,7 +130,7 @@ async def main() -> None:
)
parser.add_argument(
"--prompt-version",
default=os.environ.get("TRADING_MEET_KEVIN_PROMPT_VERSION", "v1"),
default=os.environ.get("TRADING_MEET_KEVIN_PROMPT_VERSION", "v2"),
)
args = parser.parse_args()

View file

@ -19,7 +19,7 @@ class MeetKevinWatcherConfig(BaseConfig):
# LLM analysis settings
meet_kevin_max_llm_retries: int = 3
meet_kevin_llm_model: str = "claude-sonnet-4-5"
meet_kevin_prompt_version: str = "v1"
meet_kevin_prompt_version: str = "v2"
meet_kevin_daily_cost_cap_usd: float = 5.0
meet_kevin_inter_video_sleep_seconds: int = 30

View file

@ -76,20 +76,49 @@ like "In this video Kevin discusses…" — start directly with the insight.
### Per-ticker mentions (tickers field)
Extract every stock, ETF, or crypto ticker that Kevin makes a substantive statement
about. For each one, fill in the following:
about. **All fields below must be FORWARD-LOOKING they describe where Kevin
expects the stock to go over the stated time_horizon, NOT how it has moved
recently.** Kevin often reacts to past drops by capitulating ("I sold because it
dumped 20%") — that is a backward-looking reactive trade and is NOT what we want
to capture. Only emit an action when Kevin expresses a directional view about
the FUTURE.
For each ticker, fill in the following:
- **symbol** The uppercase ticker symbol (e.g. "NVDA", "SPY", "BTC"). If Kevin
mentions the company name but not the ticker, infer the ticker from the name (e.g.
"Nvidia" "NVDA"). Max 6 characters. Only include tickers you are confident about.
- **action** The clearest action signal you can infer from what Kevin says. Use
exactly one of: `buy`, `sell`, `hold`, `watch`, `avoid`. If Kevin expresses
interest but no clear directional view, use `watch`. If he says he is exiting or
would not touch it, use `sell` or `avoid` respectively. Do not default to `hold`
just because you are unsure skip the ticker instead.
- **action** The forward-looking action that would profit from Kevin's predicted
next move. Use exactly one of: `buy`, `sell`, `hold`, `watch`, `avoid`.
- `buy` Kevin expresses a forward bullish view (he thinks it goes UP from
here). Examples: "I'm loading up", "this is going to $X", "I think this
bottoms here and rips".
- `sell` Kevin expresses a forward bearish view (he thinks it goes DOWN
from here). Examples: "I'm getting out before earnings", "this is going to
crash", "fair value is way below this".
- `hold` Kevin already owns and expects sideways/no-strong-direction.
- `watch` Kevin is interested but waiting for a setup. Used when his view is
"I want to buy IF X happens" not enough conviction yet.
- `avoid` Forward-looking "don't touch this at any price" Kevin thinks the
forward risk/reward is bad.
- **Critical filter: if Kevin says "I sold" purely because the stock already
dropped (capitulation, profit-taking, stop-loss tripped) and offers no
forward view on where it goes from here, use `watch` instead.** A
backward-looking reactive sell is NOT a forward-looking `sell` signal.
- If unsure, skip the ticker rather than defaulting to `hold`.
- **expected_move** The forward-looking directional view distilled into a single
bucket. Independent of `action`. Use one of: `up_strong` (>= +5% over the
horizon), `up_mild` (+1% to +5%), `sideways` (-1% to +1%), `down_mild` (-5% to
-1%), `down_strong` (<= -5%), `unknown` (Kevin made no forward-looking call).
This is the field the trading bot weights most heavily, so be conservative
use `unknown` when Kevin is reacting to the past instead of predicting the
future.
- **conviction** A float between 0.0 and 1.0 representing how confident Kevin
sounds. Use 0.81.0 for "I'm buying this aggressively / this is my top pick",
sounds **about the forward move** (NOT how loudly he is talking about the past).
Use 0.81.0 for "I'm buying this aggressively because I expect it to rip",
0.50.7 for a clear directional view with some hedging, 0.20.4 for a tentative
or heavily-caveated take. A ticker Kevin mentions only in passing (< 20 words of
commentary) should be **skipped entirely** rather than assigned low conviction.
@ -98,8 +127,9 @@ about. For each one, fill in the following:
`months`, `long_term`, `unspecified`. If Kevin does not say, use `unspecified`.
- **rationale_quote** A short verbatim or lightly paraphrased quote (2080 words)
from the transcript that best justifies the action you assigned. Include enough
context to be meaningful on its own.
from the transcript that best justifies the action you assigned. The quote must
contain Kevin's FORWARD-LOOKING reasoning — if you can only find a backward
statement ("it dropped 20%"), the ticker doesn't belong in this output.
- **video_timestamp_seconds** If the transcript includes segment timestamps (lines
formatted as `[<N>s] <text>`), set this to the integer second where Kevin first
@ -129,11 +159,16 @@ about. For each one, fill in the following:
- [ ] `macro_themes` has 26 items, each a concise phrase
- [ ] `key_risks` has 25 items, each a concise phrase
- [ ] `summary` is approximately 200 words
- [ ] Every ticker in `tickers` has a clear actionable signal (no "I'm not sure")
- [ ] Tickers mentioned only in passing are omitted
- [ ] `conviction` values are floats in [0.0, 1.0]
- [ ] Every ticker in `tickers` has a clear FORWARD-LOOKING signal (no "I sold
because it dropped" without a forward view)
- [ ] Each ticker's `expected_move` matches its `action` (e.g. `buy` should pair
with `up_strong` or `up_mild`; `sell` with `down_strong` or `down_mild`)
- [ ] Tickers mentioned only in passing or only reactively are omitted
- [ ] `conviction` reflects confidence in the forward move, not volume of past
commentary
- [ ] `time_horizon` is one of the six allowed values
- [ ] `rationale_quote` is grounded in something Kevin actually said
- [ ] `rationale_quote` contains Kevin's forward-looking reasoning, not just a
backward observation
- [ ] You are calling `submit_analysis` exactly once with all required fields
Now read the transcript provided in the user message and call `submit_analysis`.
@ -194,6 +229,7 @@ _ANALYSIS_TOOL: dict[str, Any] = {
"time_horizon",
"rationale_quote",
"video_timestamp_seconds",
"expected_move",
],
"properties": {
"symbol": {
@ -231,6 +267,23 @@ _ANALYSIS_TOOL: dict[str, Any] = {
"type": ["integer", "null"],
"description": "Timestamp in seconds for deep-link target",
},
"expected_move": {
"type": "string",
"enum": [
"up_strong",
"up_mild",
"sideways",
"down_mild",
"down_strong",
"unknown",
],
"description": (
"Forward-looking directional view over time_horizon. "
"up_strong >= +5%, up_mild +1-5%, sideways -1 to +1%, "
"down_mild -5 to -1%, down_strong <= -5%. "
"Use 'unknown' if Kevin makes no directional call."
),
},
},
},
},

View file

@ -247,6 +247,7 @@ async def process_one_video(
time_horizon=ticker.time_horizon.value,
rationale_quote=ticker.rationale_quote,
video_timestamp_seconds=ticker.video_timestamp_seconds,
expected_move=ticker.expected_move.value,
)
session.add(mention)

View file

@ -75,6 +75,21 @@ class KevinTimeHorizon(str, enum.Enum):
UNSPECIFIED = "unspecified"
class KevinExpectedMove(str, enum.Enum):
"""Forward-looking directional view over the mention's time_horizon.
Added in prompt_version v2 (2026-05-28) captures the LLM's prediction
of where the stock is going, separate from Kevin's reactive action.
"""
UP_STRONG = "up_strong"
UP_MILD = "up_mild"
SIDEWAYS = "sideways"
DOWN_MILD = "down_mild"
DOWN_STRONG = "down_strong"
UNKNOWN = "unknown"
# ---------------------------------------------------------------------------
# Models
# ---------------------------------------------------------------------------
@ -232,6 +247,15 @@ class KevinStockMention(Base):
video_timestamp_seconds: Mapped[int | None] = mapped_column(
Integer, nullable=True
)
expected_move: Mapped[KevinExpectedMove] = mapped_column(
SAEnum(
KevinExpectedMove,
name="kevin_expected_move",
values_callable=lambda x: [e.value for e in x],
),
nullable=False,
server_default=KevinExpectedMove.UNKNOWN.value,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)

View file

@ -47,6 +47,23 @@ class MarketOutlook(str, Enum):
MIXED = "mixed"
class ExpectedMove(str, Enum):
"""Forward-looking directional view on a ticker over its time_horizon.
Independent of `action` action records what Kevin recommends doing,
expected_move records where the LLM thinks the stock is going next.
This is the field the bridge should weight when deciding whether to
paper-trade.
"""
UP_STRONG = "up_strong" # >= +5% over the horizon
UP_MILD = "up_mild" # +1% to +5%
SIDEWAYS = "sideways" # -1% to +1%
DOWN_MILD = "down_mild" # -5% to -1%
DOWN_STRONG = "down_strong" # <= -5%
UNKNOWN = "unknown" # Kevin made no directional call
class VideoStatus(str, Enum):
"""Status of a video in the processing pipeline."""
@ -92,6 +109,10 @@ class MeetKevinTickerMention(BaseModel):
video_timestamp_seconds: int | None = Field(
default=None, description="Timestamp for deep-link target"
)
expected_move: ExpectedMove = Field(
default=ExpectedMove.UNKNOWN,
description="Forward-looking directional view over time_horizon",
)
@field_validator("symbol")
@classmethod