diff --git a/alembic/versions/e5f6a7b8c9d0_kevin_expected_move.py b/alembic/versions/e5f6a7b8c9d0_kevin_expected_move.py new file mode 100644 index 0000000..7a34e71 --- /dev/null +++ b/alembic/versions/e5f6a7b8c9d0_kevin_expected_move.py @@ -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}") diff --git a/scripts/analyze_kevin_video.py b/scripts/analyze_kevin_video.py index 9c8afc7..8b3d14f 100644 --- a/scripts/analyze_kevin_video.py +++ b/scripts/analyze_kevin_video.py @@ -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() diff --git a/services/meet_kevin_watcher/config.py b/services/meet_kevin_watcher/config.py index b5e8d53..b1b997c 100644 --- a/services/meet_kevin_watcher/config.py +++ b/services/meet_kevin_watcher/config.py @@ -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 diff --git a/services/meet_kevin_watcher/llm_analyzer.py b/services/meet_kevin_watcher/llm_analyzer.py index 9ccc655..e9b8f1b 100644 --- a/services/meet_kevin_watcher/llm_analyzer.py +++ b/services/meet_kevin_watcher/llm_analyzer.py @@ -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 `[s] `), 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." + ), + }, }, }, }, diff --git a/services/meet_kevin_watcher/pipeline.py b/services/meet_kevin_watcher/pipeline.py index 93fe6e1..cb41a96 100644 --- a/services/meet_kevin_watcher/pipeline.py +++ b/services/meet_kevin_watcher/pipeline.py @@ -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) diff --git a/shared/models/meet_kevin.py b/shared/models/meet_kevin.py index 06e0cd1..61f62b2 100644 --- a/shared/models/meet_kevin.py +++ b/shared/models/meet_kevin.py @@ -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 ) diff --git a/shared/schemas/meet_kevin.py b/shared/schemas/meet_kevin.py index b6434a3..0fd4ccd 100644 --- a/shared/schemas/meet_kevin.py +++ b/shared/schemas/meet_kevin.py @@ -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