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.
265 lines
9.4 KiB
Python
265 lines
9.4 KiB
Python
"""Meet Kevin YouTube channel models: Channel, Video, Transcript, Analysis, StockMention."""
|
|
|
|
import enum
|
|
from datetime import datetime
|
|
from decimal import Decimal
|
|
|
|
from sqlalchemy import (
|
|
BigInteger,
|
|
Boolean,
|
|
DateTime,
|
|
Enum as SAEnum,
|
|
ForeignKey,
|
|
Integer,
|
|
Numeric,
|
|
String,
|
|
Text,
|
|
func,
|
|
)
|
|
from sqlalchemy.dialects.postgresql import JSONB
|
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
|
|
from shared.models.base import Base, TimestampMixin
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Enums
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class KevinVideoStatus(str, enum.Enum):
|
|
"""Video processing status in the pipeline."""
|
|
|
|
DISCOVERED = "discovered"
|
|
CAPTIONED = "captioned"
|
|
ANALYZED = "analyzed"
|
|
FAILED = "failed"
|
|
SKIPPED = "skipped"
|
|
|
|
|
|
class KevinTranscriptSource(str, enum.Enum):
|
|
"""Source of transcript captions."""
|
|
|
|
CAPTIONS_MANUAL = "captions_manual"
|
|
CAPTIONS_AUTO = "captions_auto"
|
|
NONE = "none"
|
|
|
|
|
|
class KevinMarketOutlook(str, enum.Enum):
|
|
"""Kevin's overall market direction sentiment."""
|
|
|
|
BULLISH = "bullish"
|
|
NEUTRAL = "neutral"
|
|
BEARISH = "bearish"
|
|
MIXED = "mixed"
|
|
|
|
|
|
class KevinTickerAction(str, enum.Enum):
|
|
"""Recommended action on a ticker."""
|
|
|
|
BUY = "buy"
|
|
SELL = "sell"
|
|
HOLD = "hold"
|
|
WATCH = "watch"
|
|
AVOID = "avoid"
|
|
|
|
|
|
class KevinTimeHorizon(str, enum.Enum):
|
|
"""Time horizon for a recommendation."""
|
|
|
|
INTRADAY = "intraday"
|
|
DAYS = "days"
|
|
WEEKS = "weeks"
|
|
MONTHS = "months"
|
|
LONG_TERM = "long_term"
|
|
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
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class KevinChannel(TimestampMixin, Base):
|
|
"""YouTube channel configuration and polling metadata."""
|
|
|
|
__tablename__ = "kevin_channels"
|
|
|
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
|
youtube_channel_id: Mapped[str] = mapped_column(
|
|
String(255), unique=True, nullable=False, index=True
|
|
)
|
|
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
poll_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
|
poll_interval_seconds: Mapped[int] = mapped_column(Integer, default=10800)
|
|
daily_cost_cap_usd: Mapped[Decimal] = mapped_column(
|
|
Numeric(8, 2), default=Decimal("5.00")
|
|
)
|
|
last_polled_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), nullable=True
|
|
)
|
|
|
|
# Relationships
|
|
videos: Mapped[list["KevinVideo"]] = relationship(
|
|
back_populates="channel", cascade="all, delete-orphan"
|
|
)
|
|
|
|
|
|
class KevinVideo(TimestampMixin, Base):
|
|
"""YouTube video metadata and processing status."""
|
|
|
|
__tablename__ = "kevin_videos"
|
|
|
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
|
channel_id: Mapped[int] = mapped_column(
|
|
BigInteger, ForeignKey("kevin_channels.id"), nullable=False, index=True
|
|
)
|
|
youtube_video_id: Mapped[str] = mapped_column(
|
|
String(255), unique=True, nullable=False, index=True
|
|
)
|
|
title: Mapped[str] = mapped_column(String(500), nullable=False)
|
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
published_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), nullable=True, index=True
|
|
)
|
|
duration_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
thumbnail_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
status: Mapped[KevinVideoStatus] = mapped_column(
|
|
SAEnum(KevinVideoStatus, name="kevin_video_status", values_callable=lambda x: [e.value for e in x]),
|
|
nullable=False,
|
|
default=KevinVideoStatus.DISCOVERED,
|
|
index=True,
|
|
)
|
|
failure_reason: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
|
retry_count: Mapped[int] = mapped_column(Integer, default=0)
|
|
processed_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), nullable=True
|
|
)
|
|
|
|
# Relationships
|
|
channel: Mapped[KevinChannel] = relationship(back_populates="videos")
|
|
transcript: Mapped["KevinTranscript | None"] = relationship(
|
|
back_populates="video", uselist=False, cascade="all, delete-orphan"
|
|
)
|
|
analyses: Mapped[list["KevinAnalysis"]] = relationship(
|
|
back_populates="video", cascade="all, delete-orphan"
|
|
)
|
|
mentions: Mapped[list["KevinStockMention"]] = relationship(
|
|
back_populates="video", cascade="all, delete-orphan"
|
|
)
|
|
|
|
|
|
class KevinTranscript(Base):
|
|
"""Extracted transcript from video captions."""
|
|
|
|
__tablename__ = "kevin_transcripts"
|
|
|
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
|
video_id: Mapped[int] = mapped_column(
|
|
BigInteger, ForeignKey("kevin_videos.id"), unique=True, nullable=False
|
|
)
|
|
source: Mapped[KevinTranscriptSource] = mapped_column(
|
|
SAEnum(KevinTranscriptSource, name="kevin_transcript_source", values_callable=lambda x: [e.value for e in x]), nullable=False
|
|
)
|
|
language: Mapped[str] = mapped_column(String(8), nullable=False)
|
|
raw_text: Mapped[str] = mapped_column(Text, nullable=False)
|
|
segments_json: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
|
word_count: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
|
)
|
|
|
|
# Relationships
|
|
video: Mapped[KevinVideo] = relationship(back_populates="transcript")
|
|
|
|
|
|
class KevinAnalysis(Base):
|
|
"""LLM analysis result for a video."""
|
|
|
|
__tablename__ = "kevin_analyses"
|
|
|
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
|
video_id: Mapped[int] = mapped_column(
|
|
BigInteger, ForeignKey("kevin_videos.id"), nullable=False, index=True
|
|
)
|
|
model: Mapped[str] = mapped_column(String(100), nullable=False)
|
|
prompt_version: Mapped[str] = mapped_column(String(50), nullable=False)
|
|
market_outlook_direction: Mapped[KevinMarketOutlook] = mapped_column(
|
|
SAEnum(KevinMarketOutlook, name="kevin_market_outlook", values_callable=lambda x: [e.value for e in x]), nullable=False
|
|
)
|
|
market_outlook_reasoning: Mapped[str] = mapped_column(Text, nullable=False)
|
|
macro_themes_json: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
|
key_risks_json: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
|
summary: Mapped[str] = mapped_column(Text, nullable=False)
|
|
raw_response_json: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
|
prompt_tokens: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
completion_tokens: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
cost_usd: Mapped[Decimal] = mapped_column(Numeric(10, 4), nullable=False)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
|
)
|
|
|
|
# Relationships
|
|
video: Mapped[KevinVideo] = relationship(back_populates="analyses")
|
|
mentions: Mapped[list["KevinStockMention"]] = relationship(
|
|
back_populates="analysis", cascade="all, delete-orphan"
|
|
)
|
|
|
|
|
|
class KevinStockMention(Base):
|
|
"""Per-ticker recommendation extracted from analysis."""
|
|
|
|
__tablename__ = "kevin_stock_mentions"
|
|
|
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
|
video_id: Mapped[int] = mapped_column(
|
|
BigInteger, ForeignKey("kevin_videos.id"), nullable=False, index=True
|
|
)
|
|
analysis_id: Mapped[int] = mapped_column(
|
|
BigInteger, ForeignKey("kevin_analyses.id"), nullable=False
|
|
)
|
|
symbol: Mapped[str] = mapped_column(
|
|
String(16), nullable=False, index=True
|
|
)
|
|
action: Mapped[KevinTickerAction] = mapped_column(
|
|
SAEnum(KevinTickerAction, name="kevin_ticker_action", values_callable=lambda x: [e.value for e in x]), nullable=False
|
|
)
|
|
conviction: Mapped[Decimal] = mapped_column(Numeric(4, 3), nullable=False)
|
|
time_horizon: Mapped[KevinTimeHorizon] = mapped_column(
|
|
SAEnum(KevinTimeHorizon, name="kevin_time_horizon", values_callable=lambda x: [e.value for e in x]), nullable=False
|
|
)
|
|
rationale_quote: Mapped[str] = mapped_column(Text, nullable=False)
|
|
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
|
|
)
|
|
|
|
# Relationships
|
|
video: Mapped[KevinVideo] = relationship(back_populates="mentions")
|
|
analysis: Mapped[KevinAnalysis] = relationship(back_populates="mentions")
|