read.markets/app/models.py
Giorgio Gilestro a10409c02b initial commit — cassandra v0.1
Containerised macro-strategy dashboard: 4-panel web UI (indicators,
portfolio, flash news, AI strategic log), MariaDB store, hourly
ingestion jobs, OpenRouter-backed AI analysis.

Ports the four prototype scripts in the parent dir (market_pulse,
flash_news, trading212, strategic_log) into async services backed by a
persistent DB and served via FastAPI + Jinja2 + HTMX. APScheduler runs
as a separate compose service for crash-safety and easier restarts.

Portfolio composition + position names come live from Trading 212;
news per-ticker headlines reuse those names. Tone (NOVICE/INTERMEDIATE/
PRO) and analysis style (DRY/SPECULATIVE) are env-configurable and
stored on each log row so historical entries show what produced them.

Default model is deepseek/deepseek-v4-flash (overridable via env).
Light/dark theme toggle, sans-serif for prose surfaces, monospace for
data. Bearer-token auth, OpenRouter monthly cost cap, RSS feeds auto-
disabled on consecutive failures.

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

182 lines
8 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(64), 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 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 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"),)