"""SQLAlchemy models for Cassandra. Schema rationale lives in /home/gg/.claude/plans/ok-i-think-this-tidy-lake.md. All datetimes are tz-aware UTC (see app.db.utcnow). """ from __future__ import annotations from datetime import datetime, date from sqlalchemy import ( JSON, BigInteger, Boolean, Date, DateTime, Float, ForeignKey, Index, Integer, String, Text, UniqueConstraint, ) from sqlalchemy.orm import Mapped, mapped_column, relationship from app.db import Base, utcnow class Quote(Base): __tablename__ = "quotes" id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) symbol: Mapped[str] = mapped_column(String(128), nullable=False) source: Mapped[str] = mapped_column(String(32), nullable=False) label: Mapped[str] = mapped_column(String(128), default="") group_name: Mapped[str] = mapped_column(String(64), nullable=False) price: Mapped[float | None] = mapped_column(Float) currency: Mapped[str | None] = mapped_column(String(8)) as_of: Mapped[str | None] = mapped_column(String(16)) # provider date string changes: Mapped[dict | None] = mapped_column(JSON) # {"1d": x, "1m": y, ...} error: Mapped[str | None] = mapped_column(String(255)) fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) __table_args__ = ( Index("ix_quotes_symbol_fetched", "symbol", "fetched_at"), Index("ix_quotes_group", "group_name"), ) class QuoteDaily(Base): """Daily rollup — sparkline source. PK on (symbol, date).""" __tablename__ = "quotes_daily" symbol: Mapped[str] = mapped_column(String(64), primary_key=True) date: Mapped[date] = mapped_column(Date, primary_key=True) close: Mapped[float | None] = mapped_column(Float) high: Mapped[float | None] = mapped_column(Float) low: Mapped[float | None] = mapped_column(Float) source: Mapped[str] = mapped_column(String(32)) class Headline(Base): __tablename__ = "headlines" id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) source: Mapped[str] = mapped_column(String(64), nullable=False) category: Mapped[str] = mapped_column(String(32), nullable=False) title: Mapped[str] = mapped_column(String(512), nullable=False) url: Mapped[str] = mapped_column(String(1024), nullable=False) published_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) fingerprint: Mapped[str] = mapped_column(String(40), nullable=False) # sha1 of normalised title __table_args__ = ( UniqueConstraint("fingerprint", name="uq_headlines_fingerprint"), Index("ix_headlines_published", "published_at"), Index("ix_headlines_category_published", "category", "published_at"), ) class Feed(Base): """Persisted feed state; bootstrapped from default.toml on first startup.""" __tablename__ = "feeds" id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) category: Mapped[str] = mapped_column(String(32), nullable=False) name: Mapped[str] = mapped_column(String(64), nullable=False) url: Mapped[str] = mapped_column(String(1024), nullable=False) enabled: Mapped[bool] = mapped_column(Boolean, default=True) consecutive_failures: Mapped[int] = mapped_column(Integer, default=0) last_success_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) __table_args__ = ( UniqueConstraint("category", "name", name="uq_feeds_cat_name"), ) class StrategicLog(Base): __tablename__ = "strategic_logs" id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) generated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True) model: Mapped[str] = mapped_column(String(64), nullable=False) anchor_date: Mapped[str | None] = mapped_column(String(16)) prompt_version: Mapped[int] = mapped_column(Integer, default=1) tone: Mapped[str | None] = mapped_column(String(16)) # NOVICE|INTERMEDIATE|PRO analysis: Mapped[str | None] = mapped_column(String(16)) # DRY|SPECULATIVE content: Mapped[str] = mapped_column(Text, nullable=False) prompt_tokens: Mapped[int | None] = mapped_column(Integer) completion_tokens: Mapped[int | None] = mapped_column(Integer) cost_usd: Mapped[float | None] = mapped_column(Float) class IndicatorSummary(Base): """Short AI-generated read for one indicator group, regenerated hourly. The latest row per group_name is what the dashboard renders.""" __tablename__ = "indicator_summaries" id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) group_name: Mapped[str] = mapped_column(String(64), nullable=False) generated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) model: Mapped[str] = mapped_column(String(64), nullable=False) tone: Mapped[str | None] = mapped_column(String(16)) analysis: Mapped[str | None] = mapped_column(String(16)) prompt_version: Mapped[int] = mapped_column(Integer, default=1) content: Mapped[str] = mapped_column(Text, nullable=False) prompt_tokens: Mapped[int | None] = mapped_column(Integer) completion_tokens: Mapped[int | None] = mapped_column(Integer) cost_usd: Mapped[float | None] = mapped_column(Float) __table_args__ = (Index("ix_indsumm_group_generated", "group_name", "generated_at"),) class AICall(Base): """Cost ledger for OpenRouter calls. Feeds the monthly cap check.""" __tablename__ = "ai_calls" id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) called_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True) model: Mapped[str] = mapped_column(String(64), nullable=False) prompt_tokens: Mapped[int | None] = mapped_column(Integer) completion_tokens: Mapped[int | None] = mapped_column(Integer) cost_usd: Mapped[float | None] = mapped_column(Float) status: Mapped[str] = mapped_column(String(16), default="ok") error: Mapped[str | None] = mapped_column(String(512)) class Portfolio(Base): __tablename__ = "portfolios" id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) name: Mapped[str] = mapped_column(String(64), nullable=False) source: Mapped[str] = mapped_column(String(32), nullable=False) # e.g. "trading212" currency: Mapped[str] = mapped_column(String(8), default="GBP") created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) snapshots: Mapped[list["PortfolioSnapshot"]] = relationship( back_populates="portfolio", cascade="all, delete-orphan" ) __table_args__ = (UniqueConstraint("name", name="uq_portfolios_name"),) class PortfolioSnapshot(Base): __tablename__ = "portfolio_snapshots" id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) portfolio_id: Mapped[int] = mapped_column(ForeignKey("portfolios.id", ondelete="CASCADE")) snapshot_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) total_value: Mapped[float | None] = mapped_column(Float) cash: Mapped[float | None] = mapped_column(Float) invested: Mapped[float | None] = mapped_column(Float) raw_json: Mapped[dict | None] = mapped_column(JSON) portfolio: Mapped[Portfolio] = relationship(back_populates="snapshots") positions: Mapped[list["Position"]] = relationship( back_populates="snapshot", cascade="all, delete-orphan" ) __table_args__ = (Index("ix_snap_portfolio_at", "portfolio_id", "snapshot_at"),) class Position(Base): __tablename__ = "positions" id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) snapshot_id: Mapped[int] = mapped_column( ForeignKey("portfolio_snapshots.id", ondelete="CASCADE") ) ticker: Mapped[str] = mapped_column(String(64), nullable=False) name: Mapped[str | None] = mapped_column(String(128)) quantity: Mapped[float | None] = mapped_column(Float) average_price: Mapped[float | None] = mapped_column(Float) current_price: Mapped[float | None] = mapped_column(Float) ppl: Mapped[float | None] = mapped_column(Float) snapshot: Mapped[PortfolioSnapshot] = relationship(back_populates="positions") class InstrumentMap(Base): """Maps T212's tickers/shortnames to Yahoo Finance tickers so we can refresh prices via Yahoo after a user uploads a T212 pie CSV. Synced periodically from T212's /equity/metadata/instruments endpoint via the admin's read-only API key. Each row is one T212 listing. Multiple rows can share a shortName (e.g. SHEL on LSE in GBX vs SHEL on NYSE in USD); the resolver picks the right one per user.""" __tablename__ = "instrument_map" id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) t212_ticker: Mapped[str] = mapped_column(String(64), nullable=False) t212_shortname: Mapped[str] = mapped_column(String(32), nullable=False) yahoo_ticker: Mapped[str | None] = mapped_column(String(32)) name: Mapped[str] = mapped_column(String(128), nullable=False) currency: Mapped[str | None] = mapped_column(String(8)) isin: Mapped[str | None] = mapped_column(String(16)) instrument_type: Mapped[str | None] = mapped_column(String(16)) manual: Mapped[bool] = mapped_column(Boolean, default=False) last_verified_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) __table_args__ = ( UniqueConstraint("t212_ticker", name="uq_imap_t212_ticker"), Index("ix_imap_shortname", "t212_shortname"), Index("ix_imap_isin", "isin"), ) class JobRun(Base): """One row per scheduled-job invocation; powers /api/health + the ops footer.""" __tablename__ = "job_runs" id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) name: Mapped[str] = mapped_column(String(64), nullable=False) started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) status: Mapped[str] = mapped_column(String(16), default="running") # running|success|failed error: Mapped[str | None] = mapped_column(Text) items_written: Mapped[int | None] = mapped_column(Integer) __table_args__ = (Index("ix_jobruns_name_started", "name", "started_at"),)