Server no longer holds portfolios. Holdings live in the browser (localStorage); the server publishes an anonymous ticker_universe and a gzipped /api/universe payload identical for every authenticated user, so access patterns can't betray which tickers a user holds. AI commentary is generated ephemerally from the browser-supplied pie and the cost ledger row records no positions. Migrations 0009-0011 added the universe table and dropped positions / portfolio_snapshots / portfolios. Authentication is now e-mail OTP only. Migration 0010 dropped password_hash and email_verified (every active session is by construction proof of email control). The /signup endpoint is gone; signup and login share a single email-entry page. Email rendering is HTML+plain-text multipart with a shared brand palette (app/branding.py) asserted in sync with the CSS by a drift-detection test. LLM provider defaults to DeepSeek-direct (cheaper, api.deepseek.com) with OpenRouter as automatic fallback if DeepSeek fails. ai_log_job and indicator_summary_job now iterate the two tones (NOVICE, INTERMEDIATE) per cycle so the dashboard's tone toggle is instant; PROMPT_VERSION bumped to 6 with an educational anti-TA / anti-gambling stance baked into _CORE. NOVICE mode renders a curated glossary inline (CBOE VIX, yield curve, HY OAS, etc.) with JS-positioned tooltips that survive viewport edges and sticky bars. Model name and tokens hidden from the user UI; still recorded in StrategicLog.model and AICall for admin. Layout adds a sticky top nav, a sticky bottom markets bar (one chip per exchange with status LED + headline index + 1d change), and Phase H feedback reporting is queued in tasks/todo.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
238 lines
11 KiB
Python
238 lines
11 KiB
Python
"""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))
|
|
|
|
|
|
# Portfolio / PortfolioSnapshot / Position removed in Phase G —
|
|
# holdings live in the browser, the server stores only the anonymous
|
|
# ticker universe + public market data.
|
|
|
|
|
|
class User(Base):
|
|
"""A user account. Authentication is e-mail-only via one-time codes
|
|
(see EmailOTP) — no passwords. Possessing an active session cookie
|
|
means the user proved control of `email` at session creation time, so
|
|
a separate `email_verified` flag would be redundant."""
|
|
__tablename__ = "users"
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
email: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
tier: Mapped[str] = mapped_column(String(16), default="free") # free | paid | enterprise
|
|
settings_json: Mapped[dict | None] = mapped_column(JSON)
|
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
|
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
|
|
__table_args__ = (UniqueConstraint("email", name="uq_users_email"),)
|
|
|
|
|
|
class EmailOTP(Base):
|
|
"""One-time codes for email verification. The plaintext 6-digit code is
|
|
sent in the email; we store an argon2 hash, expiry, attempt count, and
|
|
a used_at timestamp so a single code can't be reused or brute-forced."""
|
|
__tablename__ = "email_otps"
|
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
|
email: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
code_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
attempts: Mapped[int] = mapped_column(Integer, default=0)
|
|
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
purpose: Mapped[str] = mapped_column(String(16), default="signup")
|
|
|
|
__table_args__ = (Index("ix_otps_email_created", "email", "created_at"),)
|
|
|
|
|
|
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 TickerUniverse(Base):
|
|
"""The set of public tickers Cassandra is currently tracking. Populated
|
|
as the union of all users' holdings, *without user attribution* — once
|
|
a ticker is in the universe, the row carries no signal as to who put
|
|
it there. The /api/universe endpoint returns the entire set (gzipped)
|
|
to every authenticated client, so the request body itself doesn't leak
|
|
which tickers belong to which user.
|
|
|
|
Eviction policy: passive aging. last_referenced_at is bumped whenever
|
|
the ticker appears in /api/portfolio/parse or /api/analyze. A nightly
|
|
cron prunes rows older than UNIVERSE_EVICTION_TTL (60 days).
|
|
"""
|
|
__tablename__ = "ticker_universe"
|
|
yahoo_ticker: Mapped[str] = mapped_column(String(32), primary_key=True)
|
|
currency: Mapped[str | None] = mapped_column(String(8))
|
|
first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
|
last_referenced_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
|
|
|
__table_args__ = (Index("ix_universe_last_ref", "last_referenced_at"),)
|
|
|
|
|
|
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"),)
|