read.markets/app/models.py
Giorgio Gilestro 16e9f5f0cc phase B (1/4): CSV parser + InstrumentMap (T212 shortcode → Yahoo ticker)
First two slices of the multi-user roadmap (Phase B). Validates the
core onboarding mechanic against the user's real T212 export before
paying any auth/tenancy tax.

CSV parser (app/services/csv_import.py):
  - Header-name matched (survives T212 reordering columns between
    exports), tolerant of UTF-8 BOM, dash/N/A/empty markers, thousand-
    separator commas, blank rows, zero-quantity stubs, missing Total row.
  - Returns ParsedPie(name, positions, invested, value, result) with
    derived avg_price + current_price per share in account currency.
  - 14 tests covering happy path on the real CSV + 13 edge cases.

InstrumentMap (migration 0006 + app/services/instrument_map.py):
  - Catalogue table mapping T212 ticker → Yahoo ticker, populated by
    sync_from_t212() against the dev's read-only API key. Manual rows
    (manual=True) are protected from auto-overwrite.
  - Pure t212_ticker_to_yahoo() handles both suffix forms: single
    trailing exchange letter (l/a/p/d/m/s/...) and country code (US,
    DE, FR, IT, CA, ...). All 13 of the user's holdings + 15 case-
    coverage tests pass.
  - Live sync against T212 ingests 17,050 instruments (~2.2% unmappable
    on exotic exchanges; can extend the suffix map later).
  - resolve_slice() picks the right listing per shortName using a
    UK-friendly currency preference (GBX > GBP > EUR > USD). Resolved
    correctly for all 13 of the user's positions, including TTE on
    Paris vs the NYSE dual-listing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 10:53:08 +01:00

228 lines
10 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))
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"),)