feat(meet-kevin): prompt v2 — forward-looking action + expected_move field
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was canceled
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:
parent
658c4d3221
commit
41ab95ec4d
7 changed files with 164 additions and 18 deletions
44
alembic/versions/e5f6a7b8c9d0_kevin_expected_move.py
Normal file
44
alembic/versions/e5f6a7b8c9d0_kevin_expected_move.py
Normal 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}")
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.8–1.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.8–1.0 for "I'm buying this aggressively because I expect it to rip",
|
||||
0.5–0.7 for a clear directional view with some hedging, 0.2–0.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 (20–80 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 2–6 items, each a concise phrase
|
||||
- [ ] `key_risks` has 2–5 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."
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue