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>
This commit is contained in:
commit
a10409c02b
61 changed files with 4890 additions and 0 deletions
182
app/models.py
Normal file
182
app/models.py
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
"""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"),)
|
||||
Loading…
Add table
Add a link
Reference in a new issue