trading/shared/models/meet_kevin.py

241 lines
8.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"
# ---------------------------------------------------------------------------
# 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"),
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"), 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"), 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"), nullable=False
)
conviction: Mapped[Decimal] = mapped_column(Numeric(4, 3), nullable=False)
time_horizon: Mapped[KevinTimeHorizon] = mapped_column(
SAEnum(KevinTimeHorizon, name="kevin_time_horizon"), nullable=False
)
rationale_quote: Mapped[str] = mapped_column(Text, nullable=False)
video_timestamp_seconds: Mapped[int | None] = mapped_column(
Integer, nullable=True
)
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")