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:
Giorgio Gilestro 2026-05-15 21:56:10 +01:00
commit a10409c02b
61 changed files with 4890 additions and 0 deletions

17
.dockerignore Normal file
View file

@ -0,0 +1,17 @@
.git
.gitignore
.dockerignore
.env
.env.*
!.env.example
__pycache__
*.pyc
*.pyo
.pytest_cache
.ruff_cache
.venv
venv
backup/*.sql.gz
backup/*.sql
tests/
*.md

27
.env.example Normal file
View file

@ -0,0 +1,27 @@
# Cassandra environment — copy to .env and fill in.
# .env is mounted read-only into the containers; never commit real secrets.
# --- Database (MariaDB) ---
MARIADB_ROOT_PASSWORD=changeme-root
MARIADB_DATABASE=cassandra
MARIADB_USER=cassandra
MARIADB_PASSWORD=changeme
# --- API keys (mirror of the prototype .env) ---
API_KEY= # Trading 212 API key
SECRET_KEY= # Trading 212 secret
FRED_API_KEY= # FRED economic data
OPENROUTER_API_KEY= # OpenRouter (AI log generation)
# --- App ---
CASSANDRA_TOKEN= # Bearer token required if set; LAN-only no-auth if empty
CASSANDRA_PORT=8000
CASSANDRA_BASE_CURRENCY=GBP
CASSANDRA_ANCHOR_DATE=2026-03-04 # YYYY-MM-DD; used by market_pulse anchor column
CASSANDRA_MOCK=0 # 1 = serve canned fixtures, skip live APIs
# --- AI log ---
OPENROUTER_MODEL=deepseek/deepseek-v4-flash # cheap & fast; swap to anthropic/claude-sonnet-4.6 or anthropic/claude-opus-4.7 for higher quality
OPENROUTER_MONTHLY_CAP_USD=20
CASSANDRA_TONE=INTERMEDIATE # NOVICE | INTERMEDIATE | PRO
CASSANDRA_ANALYSIS=SPECULATIVE # DRY | SPECULATIVE

18
.gitignore vendored Normal file
View file

@ -0,0 +1,18 @@
.env
.env.*
!.env.example
__pycache__/
*.pyc
*.pyo
*.pyd
.pytest_cache/
.ruff_cache/
.venv/
venv/
backup/*.sql
backup/*.sql.gz
*.egg-info/
build/
dist/
.coverage
.mypy_cache/

34
Dockerfile Normal file
View file

@ -0,0 +1,34 @@
# syntax=docker/dockerfile:1.7
FROM python:3.13-slim AS builder
ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_NO_CACHE_DIR=1 \
PYTHONDONTWRITEBYTECODE=1
WORKDIR /build
COPY pyproject.toml ./
COPY app ./app
RUN python -m venv /opt/venv \
&& /opt/venv/bin/pip install --upgrade pip \
&& /opt/venv/bin/pip install .
FROM python:3.13-slim AS runtime
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PATH="/opt/venv/bin:$PATH" \
TZ=UTC
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /opt/venv /opt/venv
WORKDIR /app
COPY app ./app
COPY alembic ./alembic
COPY alembic.ini ./
# Default command is the web app; scheduler container overrides via `command:`.
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]

38
README.md Normal file
View file

@ -0,0 +1,38 @@
# Cassandra
Containerised macro-strategy dashboard — hourly market data, RSS news, Trading 212 portfolio, and an AI-generated strategic log. Read-only by design.
## Quick start
```bash
cp .env.example .env # fill in API keys; set CASSANDRA_TOKEN if exposing
docker compose up --build # db + app + scheduler + daily backup sidecar
open http://localhost:8000/
```
## Architecture
- **app** (FastAPI + Jinja2 + HTMX) — web dashboard on port 8000
- **scheduler** (APScheduler) — hourly ingestion jobs (market, news, portfolio, AI log)
- **db** (MariaDB 11) — quotes, headlines, portfolio snapshots, strategic logs, job runs
- **backup** (sidecar) — daily mariadb-dump to `./backup/`
See `/home/gg/.claude/plans/ok-i-think-this-tidy-lake.md` for the design plan.
## Config
| File | Purpose |
|---|---|
| `config/default.toml` | Universal data tables: indicator groups, RSS feeds, keyword presets |
| `config/portfolio.toml` | User-specific portfolios (overrides `default.toml`) |
| `.env` | Secrets and runtime knobs — mounted read-only into containers |
## Endpoints
- `GET /` — dashboard
- `GET /portfolio/{name}` — portfolio detail
- `GET /news` — news feed
- `GET /log` — strategic-log archive
- `GET /api/health` — job status (last success / failure per job)
All authenticated routes require `Authorization: Bearer $CASSANDRA_TOKEN` if the env is set; if unset, the app is open (LAN-only mode).

40
alembic.ini Normal file
View file

@ -0,0 +1,40 @@
# Alembic config — DB URL is read from app/config.py at runtime,
# not from this file. Keep sqlalchemy.url empty as a marker.
[alembic]
script_location = alembic
prepend_sys_path = .
sqlalchemy.url =
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

66
alembic/env.py Normal file
View file

@ -0,0 +1,66 @@
"""Alembic environment — DB URL is sourced from app/config.Settings at runtime
so we keep secrets out of alembic.ini. Async engine is used in 'online' mode."""
from __future__ import annotations
import asyncio
from logging.config import fileConfig
from alembic import context
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from app.config import get_settings
from app.db import Base
# Import models so that Base.metadata is populated.
from app import models # noqa: F401
config = context.config
# Inject the real DB URL from Settings.
config.set_main_option("sqlalchemy.url", get_settings().DATABASE_URL)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())

26
alembic/script.py.mako Normal file
View file

@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View file

@ -0,0 +1,152 @@
"""initial schema — quotes, headlines, feeds, strategic_logs, ai_calls,
portfolios, snapshots, positions, job_runs, quotes_daily.
Revision ID: 0001
Revises:
Create Date: 2026-05-15
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0001"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"quotes",
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
sa.Column("symbol", sa.String(64), nullable=False),
sa.Column("source", sa.String(32), nullable=False),
sa.Column("label", sa.String(128), nullable=False, server_default=""),
sa.Column("group_name", sa.String(64), nullable=False),
sa.Column("price", sa.Float),
sa.Column("currency", sa.String(8)),
sa.Column("as_of", sa.String(16)),
sa.Column("changes", sa.JSON),
sa.Column("error", sa.String(255)),
sa.Column("fetched_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_quotes_symbol_fetched", "quotes", ["symbol", "fetched_at"])
op.create_index("ix_quotes_group", "quotes", ["group_name"])
op.create_table(
"quotes_daily",
sa.Column("symbol", sa.String(64), primary_key=True),
sa.Column("date", sa.Date, primary_key=True),
sa.Column("close", sa.Float),
sa.Column("high", sa.Float),
sa.Column("low", sa.Float),
sa.Column("source", sa.String(32), nullable=False),
)
op.create_table(
"headlines",
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
sa.Column("source", sa.String(64), nullable=False),
sa.Column("category", sa.String(32), nullable=False),
sa.Column("title", sa.String(512), nullable=False),
sa.Column("url", sa.String(1024), nullable=False),
sa.Column("published_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("fetched_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("fingerprint", sa.String(40), nullable=False),
sa.UniqueConstraint("fingerprint", name="uq_headlines_fingerprint"),
)
op.create_index("ix_headlines_published", "headlines", ["published_at"])
op.create_index("ix_headlines_category_published", "headlines", ["category", "published_at"])
op.create_table(
"feeds",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("category", sa.String(32), nullable=False),
sa.Column("name", sa.String(64), nullable=False),
sa.Column("url", sa.String(1024), nullable=False),
sa.Column("enabled", sa.Boolean, nullable=False, server_default=sa.text("1")),
sa.Column("consecutive_failures", sa.Integer, nullable=False, server_default=sa.text("0")),
sa.Column("last_success_at", sa.DateTime(timezone=True)),
sa.UniqueConstraint("category", "name", name="uq_feeds_cat_name"),
)
op.create_table(
"strategic_logs",
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
sa.Column("generated_at", sa.DateTime(timezone=True), nullable=False, index=True),
sa.Column("model", sa.String(64), nullable=False),
sa.Column("anchor_date", sa.String(16)),
sa.Column("prompt_version", sa.Integer, nullable=False, server_default=sa.text("1")),
sa.Column("content", sa.Text, nullable=False),
sa.Column("prompt_tokens", sa.Integer),
sa.Column("completion_tokens", sa.Integer),
sa.Column("cost_usd", sa.Float),
)
op.create_table(
"ai_calls",
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
sa.Column("called_at", sa.DateTime(timezone=True), nullable=False, index=True),
sa.Column("model", sa.String(64), nullable=False),
sa.Column("prompt_tokens", sa.Integer),
sa.Column("completion_tokens", sa.Integer),
sa.Column("cost_usd", sa.Float),
sa.Column("status", sa.String(16), nullable=False, server_default="ok"),
sa.Column("error", sa.String(512)),
)
op.create_table(
"portfolios",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("name", sa.String(64), nullable=False),
sa.Column("source", sa.String(32), nullable=False),
sa.Column("currency", sa.String(8), nullable=False, server_default="GBP"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.UniqueConstraint("name", name="uq_portfolios_name"),
)
op.create_table(
"portfolio_snapshots",
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
sa.Column("portfolio_id", sa.Integer, sa.ForeignKey("portfolios.id", ondelete="CASCADE"), nullable=False),
sa.Column("snapshot_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("total_value", sa.Float),
sa.Column("cash", sa.Float),
sa.Column("invested", sa.Float),
sa.Column("raw_json", sa.JSON),
)
op.create_index("ix_snap_portfolio_at", "portfolio_snapshots", ["portfolio_id", "snapshot_at"])
op.create_table(
"positions",
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
sa.Column("snapshot_id", sa.BigInteger, sa.ForeignKey("portfolio_snapshots.id", ondelete="CASCADE"), nullable=False),
sa.Column("ticker", sa.String(64), nullable=False),
sa.Column("quantity", sa.Float),
sa.Column("average_price", sa.Float),
sa.Column("current_price", sa.Float),
sa.Column("ppl", sa.Float),
)
op.create_table(
"job_runs",
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
sa.Column("name", sa.String(64), nullable=False),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("finished_at", sa.DateTime(timezone=True)),
sa.Column("status", sa.String(16), nullable=False, server_default="running"),
sa.Column("error", sa.Text),
sa.Column("items_written", sa.Integer),
)
op.create_index("ix_jobruns_name_started", "job_runs", ["name", "started_at"])
def downgrade() -> None:
for t in [
"job_runs", "positions", "portfolio_snapshots", "portfolios",
"ai_calls", "strategic_logs", "feeds", "headlines",
"quotes_daily", "quotes",
]:
op.drop_table(t)

View file

@ -0,0 +1,24 @@
"""add name column to positions — populated from T212 /equity/metadata/instruments
Revision ID: 0002
Revises: 0001
Create Date: 2026-05-15
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0002"
down_revision: Union[str, None] = "0001"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("positions", sa.Column("name", sa.String(128), nullable=True))
def downgrade() -> None:
op.drop_column("positions", "name")

View file

@ -0,0 +1,26 @@
"""record tone + analysis on each strategic_log row
Revision ID: 0003
Revises: 0002
Create Date: 2026-05-15
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0003"
down_revision: Union[str, None] = "0002"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("strategic_logs", sa.Column("tone", sa.String(16), nullable=True))
op.add_column("strategic_logs", sa.Column("analysis", sa.String(16), nullable=True))
def downgrade() -> None:
op.drop_column("strategic_logs", "analysis")
op.drop_column("strategic_logs", "tone")

0
app/__init__.py Normal file
View file

31
app/auth.py Normal file
View file

@ -0,0 +1,31 @@
"""Bearer-token auth — single static token from CASSANDRA_TOKEN env.
If the env is empty, the app runs open (LAN-only / dev mode).
Constant-time comparison via secrets.compare_digest.
"""
from __future__ import annotations
import secrets
from fastapi import Header, HTTPException, status
from app.config import get_settings
async def require_token(
authorization: str | None = Header(default=None),
) -> None:
expected = get_settings().CASSANDRA_TOKEN
if not expected:
return # open mode — no auth required
if not authorization or not authorization.lower().startswith("bearer "):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Bearer token required",
headers={"WWW-Authenticate": "Bearer"},
)
provided = authorization.split(" ", 1)[1].strip()
if not secrets.compare_digest(provided.encode(), expected.encode()):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid token",
)

118
app/config.py Normal file
View file

@ -0,0 +1,118 @@
"""Runtime configuration — environment via Pydantic Settings + TOML-loaded data tables.
Settings come from .env / process env. The TOML files (default.toml, portfolio.toml)
define *what to track* they're declarative content, not config knobs, so they
stay separate from the settings model.
"""
from __future__ import annotations
import tomllib
from functools import lru_cache
from pathlib import Path
from typing import Any
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
CONFIG_DIR = Path(__file__).resolve().parent.parent / "config"
class Settings(BaseSettings):
"""All runtime knobs. Read from process env, .env not needed in-container
because compose injects vars directly; .env is supported for local dev."""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
# Database
DATABASE_URL: str = "mysql+aiomysql://cassandra:changeme@db:3306/cassandra"
# API keys (mirror prototype .env names)
API_KEY: str = "" # Trading 212 key
SECRET_KEY: str = "" # Trading 212 secret
FRED_API_KEY: str = ""
OPENROUTER_API_KEY: str = ""
# App
CASSANDRA_TOKEN: str = ""
CASSANDRA_PORT: int = 8000
CASSANDRA_BASE_CURRENCY: str = "GBP"
CASSANDRA_ANCHOR_DATE: str = ""
CASSANDRA_MOCK: bool = False
# AI log
OPENROUTER_MODEL: str = "deepseek/deepseek-v4-flash"
OPENROUTER_MONTHLY_CAP_USD: float = 20.0
CASSANDRA_TONE: str = "INTERMEDIATE" # NOVICE | INTERMEDIATE | PRO
CASSANDRA_ANALYSIS: str = "SPECULATIVE" # DRY | SPECULATIVE
# Config file locations (overridable for tests)
BASELINE_TOML: Path = Field(default_factory=lambda: CONFIG_DIR / "default.toml")
PORTFOLIO_TOML: Path = Field(default_factory=lambda: CONFIG_DIR / "portfolio.toml")
@lru_cache
def get_settings() -> Settings:
return Settings()
# --- TOML data tables --------------------------------------------------------
def _merge_toml(*paths: Path) -> dict[str, Any]:
"""Read TOML files in order; later ones override earlier at the top level
(with shallow dict-merge for nested tables)."""
out: dict[str, Any] = {}
for path in paths:
if not path.exists():
continue
with path.open("rb") as f:
data = tomllib.load(f)
for k, v in data.items():
if isinstance(v, dict) and isinstance(out.get(k), dict):
out[k].update(v)
else:
out[k] = v
return out
def load_groups(*paths: Path) -> dict[str, list[tuple[str, str, str]]]:
"""[(symbol, label, note), ...] per group name."""
data = _merge_toml(*paths)
out: dict[str, list[tuple[str, str, str]]] = {}
for name, items in (data.get("groups") or {}).items():
out[name] = [
(it["symbol"], it.get("label", it["symbol"]), it.get("note", ""))
for it in items
]
return out
def load_feeds(*paths: Path) -> dict[str, list[tuple[str, str]]]:
"""[(name, url), ...] per category name."""
data = _merge_toml(*paths)
out: dict[str, list[tuple[str, str]]] = {}
for cat, items in (data.get("feeds") or {}).items():
out[cat] = [(it["name"], it["url"]) for it in items]
return out
def load_presets(*paths: Path) -> dict[str, list[str]]:
"""Keyword presets for news filtering."""
data = _merge_toml(*paths)
presets = (data.get("news") or {}).get("presets") or {}
return {name: list(kw) for name, kw in presets.items()}
def load_all() -> tuple[dict, dict, dict]:
"""Shortcut: groups, feeds, presets using the configured TOML paths."""
s = get_settings()
return (
load_groups(s.BASELINE_TOML, s.PORTFOLIO_TOML),
load_feeds(s.BASELINE_TOML, s.PORTFOLIO_TOML),
load_presets(s.BASELINE_TOML, s.PORTFOLIO_TOML),
)

55
app/db.py Normal file
View file

@ -0,0 +1,55 @@
"""Async DB engine, session factory, declarative base, UTC helper."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import AsyncIterator
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.orm import DeclarativeBase
from app.config import get_settings
class Base(DeclarativeBase):
pass
_engine = None
_session_factory: async_sessionmaker[AsyncSession] | None = None
def utcnow() -> datetime:
"""All timestamps in Cassandra are tz-aware UTC by convention."""
return datetime.now(timezone.utc)
def get_engine():
global _engine
if _engine is None:
s = get_settings()
_engine = create_async_engine(
s.DATABASE_URL,
pool_pre_ping=True,
pool_recycle=3600,
future=True,
)
return _engine
def get_session_factory() -> async_sessionmaker[AsyncSession]:
global _session_factory
if _session_factory is None:
_session_factory = async_sessionmaker(
get_engine(), expire_on_commit=False, class_=AsyncSession
)
return _session_factory
async def get_session() -> AsyncIterator[AsyncSession]:
"""FastAPI dependency yielding an async session."""
async with get_session_factory()() as session:
yield session

0
app/jobs/__init__.py Normal file
View file

58
app/jobs/_helpers.py Normal file
View file

@ -0,0 +1,58 @@
"""Shared job machinery: job_runs lifecycle, MariaDB advisory lock,
mock-mode short-circuits."""
from __future__ import annotations
from contextlib import asynccontextmanager
from typing import AsyncIterator
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import get_session_factory, utcnow
from app.logging import get_logger
from app.models import JobRun
log = get_logger("jobs")
@asynccontextmanager
async def job_lifecycle(name: str) -> AsyncIterator[tuple[AsyncSession, JobRun]]:
"""Wraps a job invocation. Creates a JobRun row on entry; updates it on
exit. Failures are caught by the caller's try/except; this context only
handles the bookkeeping.
A MariaDB GET_LOCK(name, 0) is acquired to prevent concurrent runs of the
same job across processes. If the lock is busy, we skip the run."""
factory = get_session_factory()
async with factory() as session:
# Try lock; skip if held.
got = (await session.execute(
text("SELECT GET_LOCK(:n, 0)"), {"n": f"cassandra_{name}"}
)).scalar()
if not got:
log.warning("job.skipped_locked", name=name)
yield session, JobRun(name=name, started_at=utcnow(), status="skipped")
return
run = JobRun(name=name, started_at=utcnow(), status="running")
session.add(run)
await session.commit()
await session.refresh(run)
try:
yield session, run
run.status = run.status if run.status not in ("running",) else "success"
run.finished_at = utcnow()
await session.commit()
log.info("job.finished",
name=name, status=run.status, items=run.items_written)
except Exception as e:
run.status = "failed"
run.error = str(e)[:1000]
run.finished_at = utcnow()
await session.commit()
log.error("job.failed", name=name, error=str(e))
raise
finally:
await session.execute(text("SELECT RELEASE_LOCK(:n)"),
{"n": f"cassandra_{name}"})
await session.commit()

173
app/jobs/ai_log_job.py Normal file
View file

@ -0,0 +1,173 @@
"""Hourly AI strategic-log generator. Pulls already-persisted market data and
headlines from the DB (no live fetches), calls OpenRouter, persists the log
and a row in the cost ledger."""
from __future__ import annotations
import asyncio
from collections import defaultdict
from datetime import timedelta
import httpx
from sqlalchemy import desc, func, select
from app.config import get_settings
from app.db import utcnow
from app.jobs._helpers import job_lifecycle, log
from app.models import AICall, Headline, Quote, StrategicLog
from app.services.openrouter import (
PROMPT_VERSION,
build_system_prompt,
build_user_prompt,
call_openrouter,
month_start,
)
REFERENCE_LINE = (
"S&P 7,501 (ATH) · VIX 18.0 · US 10y 4.45% · HY OAS 279bps · "
"Brent $109/bbl · Gold $4,651/oz · CPI 3.8% YoY"
)
async def _latest_quotes_by_group(session) -> dict[str, list[dict]]:
"""Latest quote per (group, symbol). Skips error rows where price is null."""
sub = (
select(
Quote.group_name,
Quote.symbol,
func.max(Quote.fetched_at).label("mx"),
)
.group_by(Quote.group_name, Quote.symbol)
.subquery()
)
stmt = (
select(Quote)
.join(
sub,
(Quote.group_name == sub.c.group_name)
& (Quote.symbol == sub.c.symbol)
& (Quote.fetched_at == sub.c.mx),
)
.order_by(Quote.group_name, Quote.symbol)
)
rows = (await session.execute(stmt)).scalars().all()
by_group: dict[str, list[dict]] = defaultdict(list)
for q in rows:
by_group[q.group_name].append(dict(
symbol=q.symbol, source=q.source, label=q.label,
note="", price=q.price, currency=q.currency,
as_of=q.as_of, changes=q.changes,
))
return by_group
async def _recent_headlines_by_bucket(session, hours: float = 24) -> dict[str, list[dict]]:
"""Last N hours of headlines, bucketed by category. Hard cap per bucket
to keep the prompt under ~40KB."""
cutoff = utcnow() - timedelta(hours=hours)
stmt = (
select(Headline)
.where(Headline.published_at >= cutoff)
.order_by(desc(Headline.published_at))
.limit(400)
)
rows = (await session.execute(stmt)).scalars().all()
by_bucket: dict[str, list[dict]] = defaultdict(list)
for h in rows:
if len(by_bucket[h.category]) >= 40:
continue
by_bucket[h.category].append(dict(
when=h.published_at.isoformat(),
source=h.source, title=h.title,
))
return by_bucket
async def _month_spend(session) -> float:
start = month_start()
total = (await session.execute(
select(func.coalesce(func.sum(AICall.cost_usd), 0.0))
.where(AICall.called_at >= start)
)).scalar()
return float(total or 0.0)
async def run() -> None:
async with job_lifecycle("ai_log_job") as (session, jr):
if jr.status == "skipped":
return
s = get_settings()
if not s.OPENROUTER_API_KEY:
log.warning("ai_log.skipped_no_key")
jr.status = "skipped"
return
spent = await _month_spend(session)
if spent >= s.OPENROUTER_MONTHLY_CAP_USD:
log.warning("ai_log.cap_reached", spent=spent,
cap=s.OPENROUTER_MONTHLY_CAP_USD)
jr.status = "skipped"
jr.error = f"monthly cost cap reached (${spent:.2f})"
return
quotes = await _latest_quotes_by_group(session)
news = await _recent_headlines_by_bucket(session)
if not quotes and not news:
log.warning("ai_log.no_data_yet")
jr.status = "skipped"
return
anchor = s.CASSANDRA_ANCHOR_DATE or None
user_prompt = build_user_prompt(
today=utcnow(),
anchor=anchor,
quotes_by_group=quotes,
headlines_by_bucket=news,
reference_line=REFERENCE_LINE,
)
system_prompt = build_system_prompt(s.CASSANDRA_TONE, s.CASSANDRA_ANALYSIS)
try:
async with httpx.AsyncClient(follow_redirects=True) as client:
result = await call_openrouter(
client,
[{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}],
model=s.OPENROUTER_MODEL,
)
except Exception as e:
session.add(AICall(
model=s.OPENROUTER_MODEL, status="error", error=str(e)[:500],
))
await session.commit()
raise
session.add(StrategicLog(
generated_at=utcnow(),
model=result.model,
anchor_date=anchor,
prompt_version=PROMPT_VERSION,
tone=s.CASSANDRA_TONE.upper(),
analysis=s.CASSANDRA_ANALYSIS.upper(),
content=result.content,
prompt_tokens=result.prompt_tokens,
completion_tokens=result.completion_tokens,
cost_usd=result.cost_usd,
))
session.add(AICall(
model=result.model,
prompt_tokens=result.prompt_tokens,
completion_tokens=result.completion_tokens,
cost_usd=result.cost_usd,
status="ok",
))
await session.commit()
jr.items_written = 1
log.info("ai_log.done",
model=result.model,
prompt_tokens=result.prompt_tokens,
completion_tokens=result.completion_tokens)
if __name__ == "__main__":
asyncio.run(run())

63
app/jobs/market_job.py Normal file
View file

@ -0,0 +1,63 @@
"""Hourly market ingestion: fetch every (symbol, group) defined in TOML and
insert one Quote row per fetch."""
from __future__ import annotations
import asyncio
import httpx
from app.config import get_settings, load_groups
from app.db import utcnow
from app.jobs._helpers import job_lifecycle, log
from app.models import Quote
from app.services.market import fetch
async def run() -> None:
async with job_lifecycle("market_job") as (session, run):
if run.status == "skipped":
return
s = get_settings()
groups = load_groups(s.BASELINE_TOML, s.PORTFOLIO_TOML)
anchor = s.CASSANDRA_ANCHOR_DATE or None
async with httpx.AsyncClient(follow_redirects=True) as client:
tasks = [
fetch(client, sym, lab, note, anchor)
for group, items in groups.items()
for sym, lab, note in items
]
# Run in parallel but bounded — Yahoo can throttle if we hammer.
sem = asyncio.Semaphore(16)
async def bounded(t):
async with sem:
return await t
quotes = await asyncio.gather(*(bounded(t) for t in tasks))
# Re-index quotes back to their group for persistence.
items_flat = [
(group, sym)
for group, items in groups.items()
for sym, _, _ in items
]
now = utcnow()
for (group, _sym), q in zip(items_flat, quotes):
session.add(Quote(
symbol=q.symbol,
source=q.source,
label=q.label,
group_name=group,
price=q.price,
currency=q.currency,
as_of=q.as_of,
changes=q.changes or None,
error=q.error,
fetched_at=now,
))
await session.commit()
run.items_written = len(quotes)
log.info("market_job.done", count=len(quotes))
if __name__ == "__main__":
asyncio.run(run())

99
app/jobs/news_job.py Normal file
View file

@ -0,0 +1,99 @@
"""Hourly news ingestion. Reads enabled feeds from the DB (not TOML — DB has
the authoritative enabled/failure state). Per-ticker Yahoo news pulled for
each symbol in the default portfolio group ('pie')."""
from __future__ import annotations
import asyncio
import httpx
from sqlalchemy import desc, select
from sqlalchemy.dialects.mysql import insert as mysql_insert
from app.db import utcnow
from app.jobs._helpers import job_lifecycle, log
from app.models import Feed, Headline, Portfolio, PortfolioSnapshot, Position
from app.services.news import dedupe, fetch_feed, fetch_yahoo_news
AUTO_DISABLE_AT = 5
async def _process_feed(client: httpx.AsyncClient, feed: Feed) -> tuple[Feed, list]:
try:
items = await fetch_feed(client, feed.name, feed.category, feed.url)
feed.consecutive_failures = 0
feed.last_success_at = utcnow()
return feed, items
except Exception as e:
feed.consecutive_failures += 1
if feed.consecutive_failures >= AUTO_DISABLE_AT:
feed.enabled = False
log.warning("feed.fetch_failed", name=feed.name,
fails=feed.consecutive_failures, error=str(e))
return feed, []
async def run() -> None:
async with job_lifecycle("news_job") as (session, run):
if run.status == "skipped":
return
feeds = (
await session.execute(select(Feed).where(Feed.enabled == True))
).scalars().all()
# Portfolio tickers + names now come from the latest T212 snapshot,
# not from TOML. The (ticker, name) pair lets fetch_yahoo_news skip
# the chart-meta round-trip and use the proper company name directly.
latest_snap_id = (await session.execute(
select(PortfolioSnapshot.id)
.order_by(desc(PortfolioSnapshot.snapshot_at))
.limit(1)
)).scalar_one_or_none()
ticker_pairs: list[tuple[str, str]] = []
if latest_snap_id is not None:
positions = (await session.execute(
select(Position).where(Position.snapshot_id == latest_snap_id)
)).scalars().all()
ticker_pairs = [(p.ticker, p.name or p.ticker) for p in positions]
async with httpx.AsyncClient(follow_redirects=True) as client:
feed_results = await asyncio.gather(
*(_process_feed(client, f) for f in feeds)
)
ticker_results = await asyncio.gather(
*(fetch_yahoo_news(client, t, query_override=n)
for t, n in ticker_pairs)
)
all_headlines = []
for _feed, items in feed_results:
all_headlines.extend(items)
for items in ticker_results:
all_headlines.extend(items)
headlines = dedupe(all_headlines)
# Bulk INSERT IGNORE (fingerprint UNIQUE de-dupes across runs).
if headlines:
stmt = mysql_insert(Headline).values([
dict(
source=h.source,
category=h.category,
title=h.title[:512],
url=h.url[:1024],
published_at=h.when,
fetched_at=utcnow(),
fingerprint=h.fingerprint,
)
for h in headlines
]).prefix_with("IGNORE")
await session.execute(stmt)
await session.commit()
run.items_written = len(headlines)
log.info("news_job.done", fetched=len(all_headlines), kept=len(headlines))
if __name__ == "__main__":
asyncio.run(run())

90
app/jobs/portfolio_job.py Normal file
View file

@ -0,0 +1,90 @@
"""Hourly Trading 212 snapshot. One Portfolio row per portfolio name
(currently just 'pie'); one PortfolioSnapshot per run; N Position rows."""
from __future__ import annotations
import asyncio
import httpx
from sqlalchemy import select
from app.config import get_settings
from app.db import utcnow
from app.jobs._helpers import job_lifecycle, log
from app.models import Portfolio, PortfolioSnapshot, Position
from app.services.trading212 import Trading212
PORTFOLIO_NAME = "pie" # only one for now; multi-portfolio extension is schema-ready
async def run() -> None:
async with job_lifecycle("portfolio_job") as (session, jr):
if jr.status == "skipped":
return
s = get_settings()
if not (s.API_KEY and s.SECRET_KEY):
log.warning("portfolio_job.skipped_no_creds")
jr.status = "skipped"
return
t212 = Trading212()
async with httpx.AsyncClient(follow_redirects=True) as client:
summary = await t212.summary(client)
positions = await t212.positions(client)
# The instruments call is heavy (~5 MB / 17k rows) but it's our
# only path to a human-readable name per ticker. Once per hour is
# fine; later we could cache to disk.
try:
instruments = await t212.instruments(client)
name_by_ticker = {
i["ticker"]: i.get("name") or i.get("shortName") or i["ticker"]
for i in (instruments or [])
}
except Exception:
name_by_ticker = {}
portfolio = (
await session.execute(
select(Portfolio).where(Portfolio.name == PORTFOLIO_NAME)
)
).scalar_one_or_none()
if portfolio is None:
portfolio = Portfolio(
name=PORTFOLIO_NAME, source="trading212",
currency=summary.get("currency", "GBP"),
)
session.add(portfolio)
await session.flush() # need id for FK
cash = (summary.get("cash") or {})
investments = (summary.get("investments") or {})
snap = PortfolioSnapshot(
portfolio_id=portfolio.id,
snapshot_at=utcnow(),
total_value=summary.get("totalValue"),
cash=cash.get("availableToTrade"),
invested=investments.get("currentValue"),
raw_json=summary,
)
session.add(snap)
await session.flush()
for p in positions or []:
tkr = p.get("ticker", "")
session.add(Position(
snapshot_id=snap.id,
ticker=tkr,
name=name_by_ticker.get(tkr),
quantity=p.get("quantity"),
average_price=p.get("averagePrice"),
current_price=p.get("currentPrice"),
ppl=p.get("ppl"),
))
await session.commit()
jr.items_written = len(positions or []) + 1
log.info("portfolio_job.done", positions=len(positions or []))
if __name__ == "__main__":
asyncio.run(run())

70
app/jobs/rollup_job.py Normal file
View file

@ -0,0 +1,70 @@
"""Daily rollup: collapse `quotes` into `quotes_daily` (one row per
(symbol, date) using the latest fetched_at of the day) and prune `quotes`
older than 30 days. Sparklines read from `quotes_daily`."""
from __future__ import annotations
import asyncio
from datetime import date, timedelta
from sqlalchemy import delete, func, select
from sqlalchemy.dialects.mysql import insert as mysql_insert
from app.db import utcnow
from app.jobs._helpers import job_lifecycle, log
from app.models import Quote, QuoteDaily
PRUNE_DAYS = 30
async def run() -> None:
async with job_lifecycle("rollup_job") as (session, jr):
if jr.status == "skipped":
return
# 1. Rollup: latest fetched_at of each (symbol, date) gets stored as close.
sub = (
select(
Quote.symbol.label("symbol"),
func.date(Quote.fetched_at).label("date"),
func.max(Quote.fetched_at).label("mx"),
)
.where(Quote.price.is_not(None))
.group_by(Quote.symbol, func.date(Quote.fetched_at))
.subquery()
)
rows = (await session.execute(
select(
Quote.symbol,
func.date(Quote.fetched_at).label("d"),
Quote.price,
Quote.source,
)
.join(sub,
(Quote.symbol == sub.c.symbol)
& (Quote.fetched_at == sub.c.mx))
)).all()
if rows:
stmt = mysql_insert(QuoteDaily).values([
dict(symbol=r.symbol, date=r.d, close=r.price,
high=r.price, low=r.price, source=r.source)
for r in rows
])
stmt = stmt.on_duplicate_key_update(close=stmt.inserted.close)
await session.execute(stmt)
# 2. Prune raw quotes older than PRUNE_DAYS.
cutoff = utcnow() - timedelta(days=PRUNE_DAYS)
result = await session.execute(
delete(Quote).where(Quote.fetched_at < cutoff)
)
await session.commit()
pruned = result.rowcount or 0
jr.items_written = len(rows)
log.info("rollup_job.done", rolled=len(rows), pruned=pruned)
if __name__ == "__main__":
asyncio.run(run())

35
app/logging.py Normal file
View file

@ -0,0 +1,35 @@
"""structlog setup — JSON to stdout, ready for Docker json-file driver."""
from __future__ import annotations
import logging
import sys
import structlog
def configure_logging(level: str = "INFO") -> None:
logging.basicConfig(
format="%(message)s",
stream=sys.stdout,
level=getattr(logging, level.upper(), logging.INFO),
)
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso", utc=True),
structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info,
structlog.processors.JSONRenderer(),
],
wrapper_class=structlog.make_filtering_bound_logger(
getattr(logging, level.upper(), logging.INFO)
),
context_class=dict,
logger_factory=structlog.PrintLoggerFactory(),
cache_logger_on_first_use=True,
)
def get_logger(name: str | None = None) -> structlog.stdlib.BoundLogger:
return structlog.get_logger(name)

69
app/main.py Normal file
View file

@ -0,0 +1,69 @@
"""FastAPI entrypoint. Runs Alembic migrations on startup, bootstraps the
feeds table from TOML, mounts the API + HTML routers.
"""
from __future__ import annotations
import asyncio
from contextlib import asynccontextmanager
from pathlib import Path
from alembic import command
from alembic.config import Config as AlembicConfig
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from app.config import get_settings
from app.db import get_session_factory
from app.logging import configure_logging, get_logger
from app.routers import api as api_router
from app.routers import pages as pages_router
from app.services.feeds_bootstrap import bootstrap_feeds
log = get_logger("cassandra")
APP_DIR = Path(__file__).resolve().parent
PROJECT_DIR = APP_DIR.parent
def _run_migrations() -> None:
"""Synchronous Alembic upgrade. Called once at lifespan startup."""
cfg = AlembicConfig(str(PROJECT_DIR / "alembic.ini"))
cfg.set_main_option("script_location", str(PROJECT_DIR / "alembic"))
cfg.set_main_option("sqlalchemy.url", get_settings().DATABASE_URL)
command.upgrade(cfg, "head")
@asynccontextmanager
async def lifespan(app: FastAPI):
configure_logging()
log.info("cassandra.startup")
try:
# Alembic's env.py uses asyncio.run() internally; offload it to a
# worker thread so it doesn't collide with FastAPI's running loop.
await asyncio.to_thread(_run_migrations)
log.info("cassandra.migrations.applied")
except Exception as e:
log.error("cassandra.migrations.failed", error=str(e))
raise
async with get_session_factory()() as session:
inserted = await bootstrap_feeds(session)
log.info("cassandra.feeds.bootstrap", inserted=inserted)
yield
log.info("cassandra.shutdown")
app = FastAPI(
title="Cassandra",
description="Macro-strategy dashboard",
version="0.1.0",
lifespan=lifespan,
)
app.mount(
"/static",
StaticFiles(directory=str(APP_DIR / "static")),
name="static",
)
app.include_router(api_router.router, prefix="/api", tags=["api"])
app.include_router(pages_router.router, tags=["pages"])

182
app/models.py Normal file
View 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"),)

0
app/routers/__init__.py Normal file
View file

582
app/routers/api.py Normal file
View file

@ -0,0 +1,582 @@
"""JSON / HTMX-partial endpoints. Each route content-negotiates via `?as=html`:
bare requests return Pydantic JSON, `as=html` renders the matching partial.
The bearer-token dependency applies to all routes here.
"""
from __future__ import annotations
import calendar as _cal
import re
from datetime import date, datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from collections import defaultdict
import httpx
from pydantic import BaseModel, Field
from app.auth import require_token
from app.config import get_settings
from app.db import get_session, utcnow
from app.services.openrouter import (
PROMPT_VERSION,
build_chat_system_prompt,
call_openrouter,
month_start,
)
from app.templates_env import templates
from app.models import (
AICall,
Headline,
JobRun,
Portfolio,
PortfolioSnapshot,
Position,
Quote,
StrategicLog,
)
from app.schemas import (
HealthOut,
HeadlineOut,
JobStatus,
PortfolioSummary,
QuoteOut,
StrategicLogOut,
)
router = APIRouter(dependencies=[Depends(require_token)])
JOB_NAMES = ("market_job", "news_job", "portfolio_job", "ai_log_job", "rollup_job")
JOB_STALE_HOURS = 2.0 # job is "warn" if its last success was >2h ago
# --- Small helpers -----------------------------------------------------------
def _as_utc(d: datetime) -> datetime:
"""MariaDB returns naive datetimes — tag them UTC so arithmetic with
tz-aware utcnow() doesn't blow up."""
return d if d.tzinfo is not None else d.replace(tzinfo=timezone.utc)
def _age_seconds(now: datetime, when: datetime | None) -> float | None:
if when is None:
return None
return (_as_utc(now) - _as_utc(when)).total_seconds()
def _fmt_age(now: datetime, when: datetime | None) -> str:
secs = _age_seconds(now, when)
if secs is None:
return ""
if secs < 60:
return f"{int(secs)}s"
if secs < 3600:
return f"{int(secs // 60)}m"
if secs < 86400:
return f"{int(secs // 3600)}h"
return f"{int(secs // 86400)}d"
_MD_HEADER = re.compile(r"^(#{1,3})\s+(.+)$", re.MULTILINE)
_MD_BOLD = re.compile(r"\*\*([^*]+)\*\*")
def _md_to_html(text: str) -> str:
"""Tiny markdown subset for log output — headers, bold, paragraphs."""
def header_sub(m):
level = min(3, len(m.group(1))) + 1 # h2/h3/h4
return f"<h{level}>{m.group(2).strip()}</h{level}>"
out = _MD_HEADER.sub(header_sub, text)
out = _MD_BOLD.sub(r"<strong>\1</strong>", out)
# Convert blank-line-separated paragraphs to <p> blocks.
blocks = re.split(r"\n\s*\n", out.strip())
rendered: list[str] = []
for b in blocks:
if b.startswith("<h"):
rendered.append(b)
else:
rendered.append(f"<p>{b.strip().replace(chr(10), '<br>')}</p>")
return "\n".join(rendered)
# --- Indicators --------------------------------------------------------------
@router.get("/indicators/{group}")
async def indicators(
group: str,
request: Request,
as_: str | None = Query(default=None, alias="as"),
session: AsyncSession = Depends(get_session),
):
sub = (
select(Quote.symbol, func.max(Quote.fetched_at).label("mx"))
.where(Quote.group_name == group)
.group_by(Quote.symbol)
.subquery()
)
rows = (await session.execute(
select(Quote)
.join(sub,
(Quote.symbol == sub.c.symbol) & (Quote.fetched_at == sub.c.mx))
.where(Quote.group_name == group)
.order_by(Quote.symbol)
)).scalars().all()
if as_ == "html":
has_anchor = any((r.changes or {}).get("anchor") is not None for r in rows)
return templates.TemplateResponse(
request, "partials/indicators.html",
{"quotes": rows, "has_anchor": has_anchor},
)
return [QuoteOut.model_validate(r, from_attributes=True) for r in rows]
# --- News --------------------------------------------------------------------
@router.get("/news")
async def news_list(
request: Request,
session: AsyncSession = Depends(get_session),
category: str | None = Query(None),
since_hours: float = Query(24.0, ge=0.1, le=720.0),
limit: int = Query(50, ge=1, le=500),
as_: str | None = Query(default=None, alias="as"),
):
cutoff = utcnow() - timedelta(hours=since_hours)
stmt = select(Headline).where(Headline.published_at >= cutoff)
if category:
stmt = stmt.where(Headline.category == category)
stmt = stmt.order_by(desc(Headline.published_at)).limit(limit)
rows = (await session.execute(stmt)).scalars().all()
if as_ == "html":
now = utcnow()
items = []
for h in rows:
when = _as_utc(h.published_at) if h.published_at else None
items.append({
"age": _fmt_age(now, h.published_at),
"source": h.source,
"title": h.title,
"url": h.url,
"iso": when.isoformat() if when else None,
"utc_short": when.strftime("%d %b %H:%M") + "Z" if when else "",
})
return templates.TemplateResponse(
request, "partials/news.html", {"headlines": items},
)
return [HeadlineOut.model_validate(r, from_attributes=True) for r in rows]
# --- Strategic log -----------------------------------------------------------
def _log_partial_payload(row: StrategicLog | None) -> dict | None:
if row is None:
return None
return {
"content_html": _md_to_html(row.content),
"generated_at": row.generated_at,
"model": row.model,
"tone": row.tone,
"analysis": row.analysis,
"prompt_version": row.prompt_version,
"cost_usd": row.cost_usd,
"prompt_tokens": row.prompt_tokens,
"completion_tokens": row.completion_tokens,
}
@router.get("/log/latest")
async def log_latest(
request: Request,
session: AsyncSession = Depends(get_session),
as_: str | None = Query(default=None, alias="as"),
):
row = (await session.execute(
select(StrategicLog).order_by(desc(StrategicLog.generated_at)).limit(1)
)).scalar_one_or_none()
if as_ == "html":
return templates.TemplateResponse(
request, "partials/log.html", {"log": _log_partial_payload(row)},
)
if row is None:
raise HTTPException(status_code=404, detail="No strategic log generated yet")
return StrategicLogOut.model_validate(row, from_attributes=True)
@router.get("/log/by-date/{day}")
async def log_by_date(
request: Request,
day: str,
session: AsyncSession = Depends(get_session),
as_: str | None = Query(default=None, alias="as"),
):
"""Canonical log for a given day = MAX(generated_at) within that day."""
try:
target = datetime.strptime(day, "%Y-%m-%d").date()
except ValueError:
raise HTTPException(status_code=400, detail="day must be YYYY-MM-DD")
row = (await session.execute(
select(StrategicLog)
.where(func.date(StrategicLog.generated_at) == target)
.order_by(desc(StrategicLog.generated_at))
.limit(1)
)).scalar_one_or_none()
if as_ == "html":
return templates.TemplateResponse(
request, "partials/log.html", {"log": _log_partial_payload(row)},
)
if row is None:
raise HTTPException(status_code=404, detail="No log on this date")
return StrategicLogOut.model_validate(row, from_attributes=True)
# --- Calendar archive --------------------------------------------------------
def _month_grid(year: int, month: int) -> list[list[int | None]]:
"""6 weeks × 7 days, Monday-first; None for cells outside the month."""
weeks = _cal.Calendar(firstweekday=0).monthdayscalendar(year, month)
while len(weeks) < 6:
weeks.append([0] * 7)
return [[d or None for d in w] for w in weeks]
def _shift_month(year: int, month: int, delta: int) -> tuple[int, int]:
m = month + delta
y = year + (m - 1) // 12
m = ((m - 1) % 12) + 1
return y, m
@router.get("/log/days")
async def log_days(
request: Request,
month: str = Query(..., pattern=r"^\d{4}-\d{2}$"),
selected: str | None = Query(None),
session: AsyncSession = Depends(get_session),
as_: str | None = Query(default=None, alias="as"),
):
"""Return the calendar widget for a given month with the days that have
a strategic log highlighted."""
year, mnum = (int(p) for p in month.split("-"))
month_start = date(year, mnum, 1)
next_y, next_m = _shift_month(year, mnum, 1)
month_end = date(next_y, next_m, 1)
rows = (await session.execute(
select(func.distinct(func.date(StrategicLog.generated_at)))
.where(StrategicLog.generated_at >= month_start)
.where(StrategicLog.generated_at < month_end)
)).all()
# SQLAlchemy returns date or string depending on dialect; normalise to ints.
days_with_logs: set[int] = set()
for (d,) in rows:
if isinstance(d, str):
d = datetime.strptime(d, "%Y-%m-%d").date()
days_with_logs.add(d.day)
prev_y, prev_m = _shift_month(year, mnum, -1)
sel_date: date | None = None
if selected:
try:
sel_date = datetime.strptime(selected, "%Y-%m-%d").date()
except ValueError:
sel_date = None
payload = {
"year": year,
"month": mnum,
"month_name": _cal.month_name[mnum],
"grid": _month_grid(year, mnum),
"days_with_logs": days_with_logs,
"selected": sel_date,
"today": datetime.now(timezone.utc).date(),
"prev_month": f"{prev_y:04d}-{prev_m:02d}",
"next_month": f"{next_y:04d}-{next_m:02d}",
}
if as_ == "json":
return JSONResponse({
"month": month,
"days_with_logs": sorted(days_with_logs),
"prev_month": payload["prev_month"],
"next_month": payload["next_month"],
})
return templates.TemplateResponse(request, "partials/calendar.html", payload)
# --- Portfolios --------------------------------------------------------------
@router.get("/portfolios")
async def portfolios(
request: Request,
session: AsyncSession = Depends(get_session),
as_: str | None = Query(default=None, alias="as"),
):
rows: list[PortfolioSummary] = []
for p in (await session.execute(select(Portfolio))).scalars().all():
snap = (await session.execute(
select(PortfolioSnapshot)
.where(PortfolioSnapshot.portfolio_id == p.id)
.order_by(desc(PortfolioSnapshot.snapshot_at))
.limit(1)
)).scalar_one_or_none()
positions: list = []
if snap is not None:
pos = (await session.execute(
select(Position).where(Position.snapshot_id == snap.id)
.order_by(desc(
(Position.quantity * Position.current_price).label("v")
))
)).scalars().all()
positions = [
{"ticker": x.ticker, "name": x.name, "quantity": x.quantity,
"average_price": x.average_price, "current_price": x.current_price,
"ppl": x.ppl,
"ppl_pct": (
(x.current_price - x.average_price) / x.average_price * 100
if x.average_price and x.current_price else None
)}
for x in pos
]
raw = (snap.raw_json or {}) if snap else {}
inv = raw.get("investments") or {}
rows.append(PortfolioSummary(
name=p.name, currency=p.currency,
snapshot_at=snap.snapshot_at if snap else None,
total_value=snap.total_value if snap else None,
cash=snap.cash if snap else None,
invested=snap.invested if snap else None,
total_cost=inv.get("totalCost"),
unrealized_ppl=inv.get("unrealizedProfitLoss"),
realized_ppl=inv.get("realizedProfitLoss"),
positions=positions,
))
if as_ == "html":
return templates.TemplateResponse(
request, "partials/portfolio.html", {"portfolios": rows},
)
return rows
# --- Health / ops footer -----------------------------------------------------
@router.get("/health", response_class=HTMLResponse, include_in_schema=False)
async def health_html(
request: Request,
session: AsyncSession = Depends(get_session),
as_: str | None = Query(default=None, alias="as"),
):
"""Returns an HTML fragment by default (the ops footer); ?as=json returns the
structured object. The default is HTML because that's how the dashboard
consumes it; CLI/curl users will pass ?as=json."""
try:
await session.execute(select(func.now()))
db_ok = True
except Exception:
db_ok = False
now = utcnow()
jobs: list[dict] = []
structured: list[JobStatus] = []
for name in JOB_NAMES:
row = (await session.execute(
select(JobRun).where(JobRun.name == name)
.order_by(desc(JobRun.started_at)).limit(1)
)).scalar_one_or_none()
if row is None:
jobs.append({"name": name, "led": "idle", "age": "",
"last_finished": None})
structured.append(JobStatus(name=name))
continue
if row.status == "success":
secs = _age_seconds(now, row.finished_at or row.started_at) or 0
led = "ok" if secs < JOB_STALE_HOURS * 3600 else "warn"
elif row.status == "skipped":
led = "warn"
elif row.status == "running":
led = "warn"
else:
led = "err"
jobs.append({
"name": name, "led": led,
"age": _fmt_age(now, row.finished_at or row.started_at),
"last_finished": row.finished_at,
})
structured.append(JobStatus(
name=name, last_started=row.started_at,
last_finished=row.finished_at, status=row.status,
error=row.error, items_written=row.items_written,
))
if as_ == "json":
return JSONResponse(
HealthOut(db="ok" if db_ok else "down", jobs=structured).model_dump(mode="json")
)
return templates.TemplateResponse(
request, "partials/ops_footer.html",
{"db_ok": db_ok, "jobs": jobs},
)
# --- Chat -------------------------------------------------------------------
class ChatMessage(BaseModel):
role: str = Field(pattern="^(user|assistant)$")
content: str
class ChatRequest(BaseModel):
messages: list[ChatMessage]
CHAT_REFERENCE_LINE = (
"S&P 7,501 (ATH) · VIX 18.0 · US 10y 4.45% · HY OAS 279bps · "
"Brent $109/bbl · Gold $4,651/oz · CPI 3.8% YoY"
)
THESIS_KEYWORDS_FALLBACK = [
"hormuz", "iran", "opec", "brent", "wti", "crude", "oil",
"china", "taiwan", "yuan", "fed", "inflation", "cpi", "yield",
"gold", "dollar", "yen", "saudi", "russia", "ukraine", "israel",
"nato", "defence", "defense",
]
async def _latest_quotes_by_group_chat(session: AsyncSession) -> dict[str, list[dict]]:
sub = (
select(Quote.group_name, Quote.symbol,
func.max(Quote.fetched_at).label("mx"))
.group_by(Quote.group_name, Quote.symbol)
.subquery()
)
rows = (await session.execute(
select(Quote).join(
sub,
(Quote.group_name == sub.c.group_name)
& (Quote.symbol == sub.c.symbol)
& (Quote.fetched_at == sub.c.mx),
).order_by(Quote.group_name, Quote.symbol)
)).scalars().all()
by_group: dict[str, list[dict]] = defaultdict(list)
for q in rows:
by_group[q.group_name].append({
"symbol": q.symbol, "label": q.label,
"price": q.price, "currency": q.currency,
"as_of": q.as_of, "changes": q.changes,
})
return by_group
async def _thesis_headlines_for_chat(session: AsyncSession, limit: int = 50) -> list[dict]:
cutoff = utcnow() - timedelta(hours=24)
rows = (await session.execute(
select(Headline)
.where(Headline.published_at >= cutoff)
.order_by(desc(Headline.published_at))
.limit(300)
)).scalars().all()
out = []
for h in rows:
if any(kw in h.title.lower() for kw in THESIS_KEYWORDS_FALLBACK):
out.append({"source": h.source, "title": h.title})
if len(out) >= limit:
break
return out
async def _month_spend(session: AsyncSession) -> float:
total = (await session.execute(
select(func.coalesce(func.sum(AICall.cost_usd), 0.0))
.where(AICall.called_at >= month_start())
)).scalar()
return float(total or 0.0)
@router.post("/chat")
async def chat(
body: ChatRequest,
session: AsyncSession = Depends(get_session),
):
"""Answer one user turn given the conversation so far. Grounded on the
latest strategic log + market data + thesis-filtered headlines.
Ephemeral the conversation lives entirely in the client; the endpoint
just records each call's cost in `ai_calls`."""
s = get_settings()
if not s.OPENROUTER_API_KEY:
raise HTTPException(status_code=503, detail="OPENROUTER_API_KEY not set")
# Monthly cost cap — same one the log job respects.
spent = await _month_spend(session)
if spent >= s.OPENROUTER_MONTHLY_CAP_USD:
raise HTTPException(
status_code=429,
detail=f"Monthly OpenRouter cap reached (${spent:.2f})",
)
# Trim runaway conversations: keep last 20 turns.
history = body.messages[-20:]
if not history or history[-1].role != "user":
raise HTTPException(status_code=400, detail="Last message must be user")
# Gather grounding context.
log_row = (await session.execute(
select(StrategicLog).order_by(desc(StrategicLog.generated_at)).limit(1)
)).scalar_one_or_none()
quotes = await _latest_quotes_by_group_chat(session)
headlines = await _thesis_headlines_for_chat(session)
system_prompt = build_chat_system_prompt(
s.CASSANDRA_TONE, s.CASSANDRA_ANALYSIS,
log_content=log_row.content if log_row else None,
log_generated_at=log_row.generated_at if log_row else None,
quotes_by_group=quotes,
headlines=headlines,
reference_line=CHAT_REFERENCE_LINE,
)
msgs = [{"role": "system", "content": system_prompt}]
for m in history:
msgs.append({"role": m.role, "content": m.content})
try:
async with httpx.AsyncClient(follow_redirects=True) as client:
result = await call_openrouter(client, msgs, model=s.OPENROUTER_MODEL)
except Exception as e:
session.add(AICall(
model=s.OPENROUTER_MODEL, status="error", error=str(e)[:500],
))
await session.commit()
raise HTTPException(status_code=502, detail=f"OpenRouter error: {e}")
session.add(AICall(
model=result.model,
prompt_tokens=result.prompt_tokens,
completion_tokens=result.completion_tokens,
cost_usd=result.cost_usd,
status="ok",
))
await session.commit()
return {
"role": "assistant",
"content": result.content,
"content_html": _md_to_html(result.content),
"prompt_tokens": result.prompt_tokens,
"completion_tokens": result.completion_tokens,
}

80
app/routers/pages.py Normal file
View file

@ -0,0 +1,80 @@
"""HTML page routes — server-rendered Jinja2 with HTMX-driven partial refresh."""
from __future__ import annotations
from datetime import date, datetime, timezone
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_token
from app.config import get_settings, load_groups
from app.db import get_session
from app.models import StrategicLog
from app.templates_env import templates
router = APIRouter(dependencies=[Depends(require_token)])
@router.get("/", response_class=HTMLResponse)
async def dashboard(request: Request):
s = get_settings()
groups = load_groups(s.BASELINE_TOML, s.PORTFOLIO_TOML)
return templates.TemplateResponse(
request,
"dashboard.html",
{"groups": list(groups.keys()), "anchor": s.CASSANDRA_ANCHOR_DATE},
)
@router.get("/news", response_class=HTMLResponse)
async def news_page(request: Request):
return templates.TemplateResponse(request, "news.html", {})
async def _resolve_log_date(session: AsyncSession, day: str | None) -> date:
"""If `day` is YYYY-MM-DD use it; else fall back to the date of the most
recent generated log; else today."""
if day:
try:
return datetime.strptime(day, "%Y-%m-%d").date()
except ValueError:
pass
latest = (await session.execute(
select(StrategicLog.generated_at)
.order_by(desc(StrategicLog.generated_at))
.limit(1)
)).scalar_one_or_none()
if latest is not None:
return latest.date() if hasattr(latest, "date") else latest
return datetime.now(timezone.utc).date()
def _log_page_context(target: date) -> dict:
s = get_settings()
return {
"selected_iso": target.isoformat(),
"selected_month": target.strftime("%Y-%m"),
"current_tone": s.CASSANDRA_TONE.upper(),
"current_analysis": s.CASSANDRA_ANALYSIS.upper(),
}
@router.get("/log", response_class=HTMLResponse)
async def log_page(
request: Request,
session: AsyncSession = Depends(get_session),
):
target = await _resolve_log_date(session, None)
return templates.TemplateResponse(request, "log.html", _log_page_context(target))
@router.get("/log/{day}", response_class=HTMLResponse)
async def log_page_day(
request: Request,
day: str,
session: AsyncSession = Depends(get_session),
):
target = await _resolve_log_date(session, day)
return templates.TemplateResponse(request, "log.html", _log_page_context(target))

64
app/scheduler_main.py Normal file
View file

@ -0,0 +1,64 @@
"""Scheduler container entrypoint. Runs APScheduler with 5 cron jobs, each
guarded by a MariaDB advisory lock (in job_lifecycle). Waits for the DB to be
reachable, then schedules and blocks forever."""
from __future__ import annotations
import asyncio
import signal
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from app.db import get_engine
from app.logging import configure_logging, get_logger
from app.jobs import market_job, news_job, portfolio_job, ai_log_job, rollup_job
log = get_logger("scheduler")
async def _wait_for_db(retries: int = 60, delay: float = 1.0) -> None:
engine = get_engine()
for i in range(retries):
try:
async with engine.connect() as conn:
await conn.execute(__import__("sqlalchemy").text("SELECT 1"))
return
except Exception as e:
log.warning("scheduler.db_wait", attempt=i + 1, error=str(e)[:120])
await asyncio.sleep(delay)
raise RuntimeError("DB never became reachable")
async def main() -> None:
configure_logging()
log.info("scheduler.starting")
await _wait_for_db()
sched = AsyncIOScheduler(timezone="UTC")
sched.add_job(market_job.run, CronTrigger(minute=5), name="market_job", id="market_job")
sched.add_job(news_job.run, CronTrigger(minute=10), name="news_job", id="news_job")
sched.add_job(portfolio_job.run, CronTrigger(minute=15), name="portfolio_job", id="portfolio_job")
sched.add_job(ai_log_job.run, CronTrigger(minute=20), name="ai_log_job", id="ai_log_job")
sched.add_job(rollup_job.run, CronTrigger(hour=0, minute=5), name="rollup_job", id="rollup_job")
sched.start()
log.info("scheduler.started", jobs=[j.id for j in sched.get_jobs()])
# Stay alive until SIGTERM.
stop_event = asyncio.Event()
def _stop(*_):
log.info("scheduler.stopping")
stop_event.set()
loop = asyncio.get_running_loop()
for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, _stop)
await stop_event.wait()
sched.shutdown(wait=False)
log.info("scheduler.stopped")
if __name__ == "__main__":
asyncio.run(main())

73
app/schemas.py Normal file
View file

@ -0,0 +1,73 @@
"""Pydantic response shapes for the JSON API."""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel
class QuoteOut(BaseModel):
symbol: str
source: str
label: str
group_name: str
price: float | None
currency: str | None
as_of: str | None
changes: dict | None
fetched_at: datetime
error: str | None = None
class HeadlineOut(BaseModel):
source: str
category: str
title: str
url: str
published_at: datetime
class JobStatus(BaseModel):
name: str
last_started: datetime | None = None
last_finished: datetime | None = None
status: str | None = None
error: str | None = None
items_written: int | None = None
class HealthOut(BaseModel):
db: str # "ok" | "down"
jobs: list[JobStatus]
class StrategicLogOut(BaseModel):
generated_at: datetime
model: str
anchor_date: str | None
content: str
prompt_tokens: int | None
completion_tokens: int | None
class PositionOut(BaseModel):
ticker: str
name: str | None
quantity: float | None
average_price: float | None
current_price: float | None
ppl: float | None
ppl_pct: float | None = None # (current-avg)/avg * 100 — currency-neutral
class PortfolioSummary(BaseModel):
name: str
snapshot_at: datetime | None
currency: str
total_value: float | None
cash: float | None
invested: float | None
total_cost: float | None = None
unrealized_ppl: float | None = None
realized_ppl: float | None = None
positions: list[PositionOut] = []

0
app/services/__init__.py Normal file
View file

View file

@ -0,0 +1,32 @@
"""On startup, ensure every feed in default.toml/portfolio.toml has a row in
the `feeds` table. Existing rows are left untouched so admin overrides
(enabled=0 to mute a noisy source) survive restarts."""
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import load_feeds, get_settings
from app.models import Feed
async def bootstrap_feeds(session: AsyncSession) -> int:
s = get_settings()
declared = load_feeds(s.BASELINE_TOML, s.PORTFOLIO_TOML)
existing = {
(f.category, f.name): f
for f in (await session.execute(select(Feed))).scalars().all()
}
inserted = 0
for category, items in declared.items():
for name, url in items:
key = (category, name)
if key in existing:
# Refresh URL if it changed in TOML; preserve enabled/failures.
if existing[key].url != url:
existing[key].url = url
continue
session.add(Feed(category=category, name=name, url=url))
inserted += 1
await session.commit()
return inserted

285
app/services/market.py Normal file
View file

@ -0,0 +1,285 @@
"""Market data fetchers — Yahoo Finance (no auth) and FRED (key required).
Ported from /home/gg/ownCloud/Family/Finances/Wealth/market_pulse.py.
Logic preserved verbatim where possible; HTTP switched to httpx.AsyncClient.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Callable
import httpx
from app.config import get_settings
YAHOO_CHART = "https://query1.finance.yahoo.com/v8/finance/chart/{symbol}"
FRED_API = "https://api.stlouisfed.org/fred/series/observations"
UA = {"User-Agent": "Mozilla/5.0 (cassandra) Python/httpx"}
# --- In-flight data shape (services return these; jobs persist to DB) --------
@dataclass
class Quote:
symbol: str
source: str
label: str
note: str
price: float | None
currency: str | None
as_of: str | None
changes: dict[str, float | None] = field(default_factory=dict)
price_base: float | None = None
currency_base: str | None = None
anchor_date: str | None = None
error: str | None = None
def _pct(old: float | None, new: float | None) -> float | None:
if old is None or new is None or old == 0:
return None
return (new - old) / old * 100.0
def _parse_date(s: str) -> datetime:
return datetime.strptime(s, "%Y-%m-%d")
def _yahoo_range_covering(anchor: str | None) -> str:
if not anchor:
return "1y"
days = (datetime.now(timezone.utc).date() - _parse_date(anchor).date()).days
if days <= 360:
return "1y"
if days <= 1800:
return "5y"
if days <= 3600:
return "10y"
return "max"
# --- Fetchers -----------------------------------------------------------------
async def fetch_yahoo(
client: httpx.AsyncClient,
symbol: str,
label: str,
note: str,
anchor: str | None = None,
) -> Quote:
"""Latest close + %1d / %1m / %1y / (optional) %anchor from Yahoo's chart endpoint."""
try:
r = await client.get(
YAHOO_CHART.format(symbol=symbol),
params={"interval": "1d", "range": _yahoo_range_covering(anchor),
"includePrePost": "false"},
headers=UA,
timeout=15,
)
r.raise_for_status()
result = r.json()["chart"]["result"]
if not result:
raise ValueError("empty result")
res = result[0]
meta = res["meta"]
price = meta.get("regularMarketPrice")
prev_session = meta.get("previousClose")
timestamps = res.get("timestamp") or []
closes = (res.get("indicators", {}).get("quote") or [{}])[0].get("close") or []
series = [(t, c) for t, c in zip(timestamps, closes) if c is not None]
if not series:
raise ValueError("no closes in series")
last_ts = series[-1][0]
if prev_session is None and len(series) >= 2:
prev_session = series[-2][1]
chg_1m = _pct(series[-22][1], price) if len(series) >= 22 else None
chg_1y = _pct(series[0][1], price) if len(series) >= 2 else None
changes: dict[str, float | None] = {
"1d": _pct(prev_session, price),
"1m": chg_1m,
"1y": chg_1y,
}
anchor_used: str | None = None
if anchor:
anchor_ts = int(_parse_date(anchor).replace(tzinfo=timezone.utc).timestamp())
anchor_close = next((c for t, c in series if t >= anchor_ts), None)
anchor_actual_ts = next((t for t, c in series if t >= anchor_ts), None)
changes["anchor"] = _pct(anchor_close, price)
if anchor_actual_ts:
anchor_used = datetime.fromtimestamp(
anchor_actual_ts, timezone.utc
).strftime("%Y-%m-%d")
return Quote(
symbol=symbol,
source="yahoo",
label=label,
note=note,
price=price,
currency=meta.get("currency"),
as_of=datetime.fromtimestamp(last_ts, timezone.utc).strftime("%Y-%m-%d"),
changes=changes,
anchor_date=anchor_used,
)
except Exception as e:
return Quote(symbol, "yahoo", label, note, None, None, None, error=str(e))
async def fetch_fred(
client: httpx.AsyncClient,
symbol: str,
label: str,
note: str,
anchor: str | None = None,
) -> Quote:
"""Latest value + 1d/1m/1y deltas from a FRED series. Requires FRED_API_KEY."""
api_key = get_settings().FRED_API_KEY
if not api_key:
return Quote(symbol, "fred", label, note, None, None, None,
error="FRED_API_KEY not set")
try:
r = await client.get(
FRED_API,
params={
"series_id": symbol,
"api_key": api_key,
"file_type": "json",
"sort_order": "desc",
"limit": 5000 if anchor else 800,
},
headers=UA,
timeout=20,
)
r.raise_for_status()
obs = r.json().get("observations", [])
data = [
(o["date"], float(o["value"]))
for o in obs if o.get("value") not in (".", "", None)
]
if not data:
raise ValueError("no observations")
last_date, last_val = data[0]
# CPI levels reported as YoY%.
if symbol in ("CPIAUCSL", "CPILFESL") and len(data) >= 13:
yoy = _pct(data[12][1], last_val)
changes: dict[str, float | None] = {"1d": None, "1m": None, "1y": None}
anchor_used: str | None = None
if anchor:
anchor_dt = _parse_date(anchor)
anchor_idx = next(
(i for i, (d, _) in enumerate(data) if _parse_date(d) <= anchor_dt),
None,
)
if anchor_idx is not None and anchor_idx + 12 < len(data):
yoy_then = _pct(data[anchor_idx + 12][1], data[anchor_idx][1])
changes["anchor"] = (yoy or 0) - (yoy_then or 0)
anchor_used = anchor
return Quote(symbol, "fred", label, note, yoy, "%", last_date,
changes=changes, anchor_date=anchor_used)
last_dt = _parse_date(last_date)
def find_back(min_days: int) -> float | None:
for d, v in data[1:]:
if (last_dt - _parse_date(d)).days >= min_days:
return v
return None
prev_val = data[1][1] if len(data) >= 2 else None
changes = {
"1d": _pct(prev_val, last_val),
"1m": _pct(find_back(28), last_val),
"1y": _pct(find_back(360), last_val),
}
anchor_used = None
if anchor:
anchor_dt = _parse_date(anchor)
anchor_obs = next(
((d, v) for d, v in data if _parse_date(d) <= anchor_dt), None
)
if anchor_obs:
changes["anchor"] = _pct(anchor_obs[1], last_val)
anchor_used = anchor_obs[0]
return Quote(
symbol=symbol, source="fred", label=label, note=note,
price=last_val, currency=None, as_of=last_date,
changes=changes, anchor_date=anchor_used,
)
except Exception as e:
return Quote(symbol, "fred", label, note, None, None, None, error=str(e))
# --- Source registry ----------------------------------------------------------
FetcherFn = Callable[..., "Quote"]
SOURCES: dict[str, FetcherFn] = {"yahoo": fetch_yahoo, "FRED": fetch_fred}
def parse_symbol(symbol: str) -> tuple[FetcherFn, str]:
if ":" in symbol:
prefix, _, ident = symbol.partition(":")
if prefix in SOURCES:
return SOURCES[prefix], ident
return SOURCES["yahoo"], symbol
async def fetch(
client: httpx.AsyncClient,
symbol: str,
label: str,
note: str,
anchor: str | None = None,
) -> Quote:
fn, ident = parse_symbol(symbol)
return await fn(client, ident, label, note, anchor)
# --- Currency normalisation ---------------------------------------------------
async def _get_fx_rate(
client: httpx.AsyncClient,
from_ccy: str,
to_ccy: str,
cache: dict[tuple[str, str], float | None],
) -> float | None:
if from_ccy == to_ccy:
return 1.0
if from_ccy == "GBp": # LSE pence
gbp = await _get_fx_rate(client, "GBP", to_ccy, cache)
return None if gbp is None else 0.01 * gbp
key = (from_ccy, to_ccy)
if key in cache:
return cache[key]
pair = f"{from_ccy}{to_ccy}=X"
try:
r = await client.get(
YAHOO_CHART.format(symbol=pair),
params={"interval": "1d", "range": "5d"},
headers=UA, timeout=10,
)
r.raise_for_status()
rate = r.json()["chart"]["result"][0]["meta"].get("regularMarketPrice")
cache[key] = rate
return rate
except Exception:
cache[key] = None
return None
async def normalise_to_base(
client: httpx.AsyncClient, quotes: list[Quote], base: str
) -> None:
cache: dict[tuple[str, str], float | None] = {}
base = base.upper()
for q in quotes:
if q.price is None or not q.currency:
continue
rate = await _get_fx_rate(client, q.currency, base, cache)
if rate is None:
continue
q.price_base = q.price * rate
q.currency_base = base

167
app/services/news.py Normal file
View file

@ -0,0 +1,167 @@
"""RSS feed aggregator + Yahoo per-ticker news.
Ported from /home/gg/ownCloud/Family/Finances/Wealth/flash_news.py same
parsing, dedupe, and ticker-name resolution logic, async HTTP via httpx.
"""
from __future__ import annotations
import hashlib
import re
from dataclasses import dataclass
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from xml.etree import ElementTree as ET
import httpx
UA = {"User-Agent": "Mozilla/5.0 (cassandra) Python/httpx"}
ATOM_NS = "{http://www.w3.org/2005/Atom}"
DC_NS = "{http://purl.org/dc/elements/1.1/}"
YAHOO_NEWS = "https://query1.finance.yahoo.com/v1/finance/search"
YAHOO_CHART = "https://query1.finance.yahoo.com/v8/finance/chart/{symbol}"
_NAME_STOPWORDS = {"plc", "corp", "inc", "ltd", "fund", "etf", "ucits",
"class", "shares", "trust", "the", "and", "of"}
@dataclass
class Headline:
when: datetime # tz-aware UTC
source: str
category: str
title: str
url: str
@property
def fingerprint(self) -> str:
"""sha1 of normalised title — used as DB UNIQUE."""
norm = " ".join(self.title.lower().split())
return hashlib.sha1(norm.encode("utf-8")).hexdigest()
def _parse_date(s: str | None) -> datetime | None:
if not s:
return None
try:
return parsedate_to_datetime(s).astimezone(timezone.utc)
except (TypeError, ValueError):
pass
try:
return datetime.fromisoformat(s.replace("Z", "+00:00")).astimezone(timezone.utc)
except ValueError:
return None
def parse_feed(name: str, category: str, xml_bytes: bytes) -> list[Headline]:
try:
root = ET.fromstring(xml_bytes)
except ET.ParseError:
return []
out: list[Headline] = []
rss_items = root.findall(".//item")
if rss_items:
for it in rss_items:
title = (it.findtext("title") or "").strip()
link = (it.findtext("link") or "").strip()
pub = it.findtext("pubDate") or it.findtext(f"{DC_NS}date")
when = _parse_date(pub) or datetime.now(timezone.utc)
if title and link:
out.append(Headline(when, name, category, title, link))
else:
for entry in root.findall(f".//{ATOM_NS}entry"):
title = (entry.findtext(f"{ATOM_NS}title") or "").strip()
link_el = entry.find(f"{ATOM_NS}link")
link = (link_el.get("href") if link_el is not None else "") or ""
pub = entry.findtext(f"{ATOM_NS}published") or entry.findtext(f"{ATOM_NS}updated")
when = _parse_date(pub) or datetime.now(timezone.utc)
if title and link:
out.append(Headline(when, name, category, title, link.strip()))
return out
async def fetch_feed(
client: httpx.AsyncClient, name: str, category: str, url: str
) -> list[Headline]:
"""Returns headlines on success, empty list on any failure (caller logs)."""
r = await client.get(url, headers=UA, timeout=12)
r.raise_for_status()
return parse_feed(name, category, r.content)
async def _resolve_ticker_name(client: httpx.AsyncClient, ticker: str) -> str:
"""Look up the company longName so news search hits headlines that actually
mention the company rather than matching the literal ticker string."""
try:
r = await client.get(
YAHOO_CHART.format(symbol=ticker),
params={"interval": "1d", "range": "5d"},
headers=UA, timeout=8,
)
r.raise_for_status()
meta = r.json()["chart"]["result"][0]["meta"]
return meta.get("longName") or meta.get("shortName") or ticker
except Exception:
return ticker
async def fetch_yahoo_news(
client: httpx.AsyncClient,
ticker: str,
count: int = 10,
query_override: str | None = None,
) -> list[Headline]:
"""Filtered Yahoo per-ticker headlines. Niche UCITS ETFs return empty
rather than the generic firehose because of the token-overlap guard.
If `query_override` is provided (e.g. a name already fetched from
Trading 212 instruments), it skips the Yahoo chart-meta round-trip."""
query = query_override or await _resolve_ticker_name(client, ticker)
tokens = [
t.lower() for t in re.split(r"[\s.]+", query)
if len(t) >= 3 and t.lower() not in _NAME_STOPWORDS
]
try:
r = await client.get(
YAHOO_NEWS,
params={"q": query, "newsCount": count, "quotesCount": 0},
headers=UA, timeout=10,
)
r.raise_for_status()
items = r.json().get("news", [])
out: list[Headline] = []
for it in items:
title = (it.get("title") or "").strip()
link = (it.get("link") or "").strip()
if not (title and link):
continue
if tokens and not any(t in title.lower() for t in tokens):
continue
ts = it.get("providerPublishTime")
when = (
datetime.fromtimestamp(ts, timezone.utc) if ts
else datetime.now(timezone.utc)
)
out.append(Headline(when, f"Yahoo:{ticker}", "ticker", title, link))
return out
except Exception:
return []
def dedupe(headlines: list[Headline]) -> list[Headline]:
"""URL first, then normalised title — same logic as the prototype."""
seen_url: set[str] = set()
seen_fp: set[str] = set()
out: list[Headline] = []
for h in headlines:
if h.url in seen_url or h.fingerprint in seen_fp:
continue
seen_url.add(h.url)
seen_fp.add(h.fingerprint)
out.append(h)
return out
def matches_any(text: str, keywords: list[str]) -> bool:
t = text.lower()
return any(kw in t for kw in keywords)

272
app/services/openrouter.py Normal file
View file

@ -0,0 +1,272 @@
"""Strategic-log generator — DB-fed, OpenRouter-backed.
Ported from /home/gg/ownCloud/Family/Finances/Wealth/strategic_log.py. The
system prompt is preserved verbatim (the voice we converged on). The user
prompt is now built from DB rows, not from subprocess JSON dumps.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
import httpx
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
from app.config import get_settings
OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
# Bump when the composed prompt changes meaningfully. Stored on every
# StrategicLog row so historical logs can be linked to the prompt that produced
# them.
PROMPT_VERSION = 3
# --- Core: invariant across tone/analysis settings ----------------------------
_CORE = """You are Cassandra, writing a single daily strategic markets log \
for one specific investor. Synthesis, not exposition.
# Lens
- Geopolitics markets is the primary causal chain. For each sector move, \
ask: geopolitical, cyclical, or idiosyncratic. Label it.
- Divergences and contradictions are where the information is. Hunt for them.
- Absence of expected moves is signal. If the thesis predicted a reaction \
that didn't happen, that's more interesting than the reactions that did.
- Compare live readings against any reference snapshots provided.
# Multi-source news
- When state-aligned outlets (Xinhua, China Daily, RT) and Western outlets \
cover the same event, read the gap in framing that's the data.
- News matters only insofar as it changes a market read. Color without \
implications is filler.
# Structure
- One-line date header + any anchor framing (e.g. "Week 11 since Hormuz").
- Immediately after the date header with **nothing** in between write a \
TL;DR. Format it as:
## TL;DR
One concise paragraph of 2-3 sentences, **60 words total**, naming the \
single most important read or divergence of the day with concrete numbers. \
This is what a reader who only has 10 seconds sees. Don't waste it on the \
weather or generic context.
- Then 4-6 paragraphs, each anchored on a sleeve, sector, or theme. Concrete \
numbers in every paragraph. No section over ~150 words.
- One paragraph synthesising the news flow into a market read.
- End with a watch list: 3-5 specific items to track in the next week, \
each one sentence.
# Discipline
- No emojis, no marketing language, no "concerning" or "unprecedented" \
without a specific number behind it.
- Concrete > vague. "AMD +113% since the anchor" beats "AI stocks up sharply".
- Distinguish "the thesis predicted X and X happened" from "the thesis \
predicted X and X did not happen". Both are useful; conflating them is not.
- Don't repeat the same point in different words across paragraphs.
- No buy/sell recommendations. Triggers are pre-set elsewhere; your job is \
to report whether reality is confirming, modifying, or refuting the thesis."""
# --- Tone: audience-shaping block --------------------------------------------
_TONE: dict[str, str] = {
"NOVICE": """# Audience: novice
The reader is new to markets. Define jargon the first time it appears (a \
short clause in parentheses is fine). Avoid ticker shorthand without context. \
Prefer everyday phrasing: "the price of US government debt fell, pushing \
yields higher" rather than "the long end backed up". Keep paragraphs short. \
Target ~600 words instead of ~800 so density stays digestible.""",
"INTERMEDIATE": """# Audience: intermediate
Assume the reader knows market basics (yield curves, breakevens, HY OAS, \
sector ETFs). Use common terms without defining them, but stay clear of \
deep institutional shorthand ("the belly", "duration trade", "carry pickup"). \
Target ~700 words lean and clear, no padding.""",
"PRO": """# Audience: professional
Assume institutional vocabulary. Use dense market shorthand freely. Don't \
define standard terms. Target ~800 words. Density of insight > readability.""",
}
# --- Analysis: forward-vs-backward focus -------------------------------------
_ANALYSIS: dict[str, str] = {
"DRY": """# Analysis style: dry
Report what happened. Identify divergences and contradictions. Compare to \
references. Do not speculate on what comes next. Forward-looking statements \
are limited to "what would invalidate the read" never "we expect X to \
happen". The watch list contains items to monitor, not predictions.""",
"SPECULATIVE": """# Analysis style: speculative
Report what happened, then explicitly explore forward scenarios. For each \
significant sector or theme, sketch a 1-4 week scenario set: the base case \
(what the data suggests), a contrarian case (what would invalidate it), and \
what tape signal would tip you from one to the other. Be explicit about \
uncertainty say "the base case is" not "X will happen". The watch list is \
the trip-wires that decide between scenarios.""",
}
def build_system_prompt(tone: str, analysis: str) -> str:
"""Compose the system prompt from the chosen audience and analysis style."""
tone_block = _TONE.get(tone.upper(), _TONE["INTERMEDIATE"])
analysis_block = _ANALYSIS.get(analysis.upper(), _ANALYSIS["SPECULATIVE"])
return "\n\n".join([_CORE, tone_block, analysis_block])
# Backwards-compat: a default-composed SYSTEM_PROMPT for tests / callers that
# don't yet pass tone/analysis. New callers should call build_system_prompt().
SYSTEM_PROMPT = build_system_prompt("INTERMEDIATE", "SPECULATIVE")
# --- Chat-mode overrides (sidebar on /log) -----------------------------------
_CHAT_OVERRIDES = """# Chat mode (overrides the log-structure rules above)
You are NOT writing a daily log right now. The user is asking a specific
question via the chat sidebar.
- Forget the date header, TL;DR, sectional structure, and watch list. Just answer.
- Typical response: 200-400 words. Longer only if the question genuinely
warrants it.
- Cite specific numbers and named headlines from the reference materials
below whenever relevant. If a number isn't in the context, don't invent it.
- If a question is outside the provided context (e.g. asking about a stock or
event not in the data), say so plainly rather than speculating from prior
knowledge.
- No buy/sell recommendations. If asked, redirect to thesis and scenarios.
- Keep the same audience and analysis discipline established above."""
def build_chat_system_prompt(
tone: str,
analysis: str,
*,
log_content: str | None,
log_generated_at: datetime | None,
quotes_by_group: dict[str, list[dict]],
headlines: list[dict],
reference_line: str | None = None,
) -> str:
"""Composed system prompt for the /log chat sidebar. Carries the user's
chosen tone + analysis style and inlines the latest log + market data +
headlines as reference material the model can cite from."""
parts = [build_system_prompt(tone, analysis), "", _CHAT_OVERRIDES, ""]
if reference_line:
parts.append(f"# Doc reference snapshot\n{reference_line}\n")
if log_content:
ts = log_generated_at.strftime("%Y-%m-%d %H:%M UTC") if log_generated_at else "n/a"
parts.append(f"# Latest strategic log (generated {ts})\n\n{log_content}\n")
parts.append("# Live market data")
parts.append(
"```json\n" + json.dumps(quotes_by_group, indent=2, default=str)[:25000] + "\n```"
)
parts.append("# Recent headlines (last 24h, thesis-filtered top 50)")
for h in headlines[:50]:
parts.append(f"- [{h['source']}] {h['title']}")
return "\n".join(parts)
@dataclass
class LogResult:
content: str
model: str
prompt_tokens: int | None
completion_tokens: int | None
cost_usd: float | None
def build_user_prompt(
*,
today: datetime,
anchor: str | None,
quotes_by_group: dict[str, list[dict]],
headlines_by_bucket: dict[str, list[dict]],
reference_line: str | None = None,
) -> str:
"""Assemble the user message from already-fetched-and-persisted data."""
parts = [f"# Strategic log request — {today.strftime('%Y-%m-%d')}"]
if anchor:
parts.append(f"Anchor reference date: {anchor}")
if reference_line:
parts.append(
"\n## Reference snapshot (when the macro thesis was authored)"
f"\n{reference_line}\nCompare live readings against it."
)
parts.append("\n## Live market data (per group)")
parts.append("```json\n" + json.dumps(quotes_by_group, indent=2, default=str) + "\n```")
parts.append("\n## News flow (last 24h, filtered by bucket)")
for label, items in headlines_by_bucket.items():
if not items:
continue
parts.append(f"\n### {label.upper()}")
for h in items[:30]:
parts.append(f"- [{h['when'][:16].replace('T',' ')}] [{h['source']}] {h['title']}")
parts.append(
"\n## Task\nWrite the daily strategic log in ~800 words, following "
"the discipline in the system prompt. No preamble; begin directly "
"with the date header."
)
return "\n".join(parts)
@retry(
reraise=True,
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=2, min=2, max=30),
retry=retry_if_exception_type((httpx.HTTPStatusError, httpx.TransportError)),
)
async def call_openrouter(
client: httpx.AsyncClient,
messages: list[dict],
model: str,
max_tokens: int = 4000,
) -> LogResult:
s = get_settings()
if not s.OPENROUTER_API_KEY:
raise RuntimeError("OPENROUTER_API_KEY not set")
r = await client.post(
OPENROUTER_URL,
headers={
"Authorization": f"Bearer {s.OPENROUTER_API_KEY}",
"Content-Type": "application/json",
"HTTP-Referer": "https://github.com/local/cassandra",
"X-Title": "Cassandra",
},
json={"model": model, "messages": messages, "max_tokens": max_tokens},
timeout=180,
)
r.raise_for_status()
data = r.json()
msg = data["choices"][0]["message"]
# Some providers return null content + populated `reasoning` for thinking
# models, or null content when finish_reason=length cut off the response.
content = msg.get("content") or msg.get("reasoning")
if not content:
finish = data["choices"][0].get("finish_reason")
raise RuntimeError(
f"OpenRouter returned empty content (finish_reason={finish}, "
f"model={model}, max_tokens={max_tokens})"
)
usage = data.get("usage") or {}
return LogResult(
content=content,
model=model,
prompt_tokens=usage.get("prompt_tokens"),
completion_tokens=usage.get("completion_tokens"),
cost_usd=usage.get("cost") or usage.get("total_cost"),
)
def month_window() -> tuple[datetime, datetime]:
"""[start, now] in UTC for the current calendar month."""
now = datetime.now(timezone.utc)
start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
return start, now
def month_start() -> datetime:
return month_window()[0]

View file

@ -0,0 +1,69 @@
"""Trading 212 read-only client.
Ported from /home/gg/ownCloud/Family/Finances/Wealth/trading212.py same Basic
auth scheme, same endpoints, async via httpx. Live endpoint only (demo would
need a separate key pair).
"""
from __future__ import annotations
import asyncio
import base64
import time
import httpx
from app.config import get_settings
LIVE_BASE = "https://live.trading212.com/api/v0"
class Trading212:
def __init__(self, api_key: str | None = None, secret_key: str | None = None):
s = get_settings()
api_key = api_key or s.API_KEY
secret_key = secret_key or s.SECRET_KEY
if not api_key or not secret_key:
raise RuntimeError("Trading 212 API_KEY/SECRET_KEY missing in env")
token = base64.b64encode(f"{api_key}:{secret_key}".encode()).decode()
self.headers = {
"Authorization": f"Basic {token}",
"Accept": "application/json",
"User-Agent": "cassandra/0.1",
}
async def _request(
self, client: httpx.AsyncClient, method: str, path: str, **kwargs
):
url = f"{LIVE_BASE}{path}"
r = await client.request(method, url, headers=self.headers, timeout=30, **kwargs)
if r.status_code == 429:
reset = float(r.headers.get("x-ratelimit-reset", "1"))
wait = max(1.0, reset - time.time())
await asyncio.sleep(wait)
r = await client.request(method, url, headers=self.headers, timeout=30, **kwargs)
r.raise_for_status()
if not r.content:
return None
ctype = r.headers.get("content-type", "")
return r.json() if "json" in ctype else r.text
async def summary(self, client: httpx.AsyncClient):
return await self._request(client, "GET", "/equity/account/summary")
async def cash(self, client: httpx.AsyncClient):
return await self._request(client, "GET", "/equity/account/cash")
async def positions(self, client: httpx.AsyncClient):
return await self._request(client, "GET", "/equity/portfolio")
async def position(self, client: httpx.AsyncClient, ticker: str):
return await self._request(client, "GET", f"/equity/portfolio/{ticker}")
async def orders(self, client: httpx.AsyncClient):
return await self._request(client, "GET", "/equity/orders")
async def instruments(self, client: httpx.AsyncClient):
"""Full catalogue of tradable instruments. Used to enrich position
rows with human-readable names."""
return await self._request(client, "GET", "/equity/metadata/instruments")

View file

@ -0,0 +1,630 @@
/* Cassandra geopolitical-terminal aesthetic with two themes.
* Mono for data, headers, terminal feel; sans for prose surfaces (log + chat). */
:root {
/* Dark theme (default) */
--bg: #0a0e14;
--surface: #11151c;
--surface-2: #161b25;
--border: #2a3142;
--text: #d4dae8; /* lifted from #c0caf5 for readability */
--muted: #8189a1; /* lifted from #565f89 — was unreadably dim */
--dim: #565f89;
--accent: #00d9ff;
--positive: #50fa7b;
--negative: #ff5b5b;
--alert: #ff8a4a;
--warning: #f1fa8c;
--user-bubble-bg: rgba(0, 217, 255, 0.08);
}
[data-theme="light"] {
--bg: #f5f3ec; /* warm off-white, easier on the eyes than pure white */
--surface: #ffffff;
--surface-2: #efece3;
--border: #d6d3cb;
--text: #1c1f25;
--muted: #545b69;
--dim: #8a8f9a;
--accent: #0e7490; /* deep teal — still terminal-feel on light */
--positive: #166534;
--negative: #b91c1c;
--alert: #c2410c;
--warning: #a16207;
--user-bubble-bg: rgba(14, 116, 144, 0.07);
}
/* Font stacks. Mono for terminal feel; sans for reading. */
:root {
--font-mono: 'JetBrains Mono', 'IBM Plex Mono', 'Fira Code', ui-monospace, monospace;
--font-sans: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', Roboto,
'Helvetica Neue', system-ui, sans-serif;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--text);
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.5;
font-variant-numeric: tabular-nums;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
/* --- Layout ---------------------------------------------------------- */
.app {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: auto 1fr auto;
min-height: 100vh;
}
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--border);
padding: 10px 18px;
background: var(--surface);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.app-header .brand {
color: var(--accent);
font-weight: 700;
}
.app-header .brand::before { content: "▰ "; opacity: 0.6; }
.app-header nav a {
margin-left: 18px;
color: var(--muted);
}
.app-header nav a.active { color: var(--text); }
.app-header .meta { color: var(--muted); font-size: 11px; }
.app-header .header-right { display: flex; align-items: center; gap: 14px; }
.theme-toggle {
background: transparent;
border: 1px solid var(--border);
color: var(--muted);
padding: 3px 8px;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.08em;
cursor: pointer;
text-transform: lowercase;
}
.theme-toggle:hover { color: var(--accent); border-color: var(--accent); }
.theme-toggle__label::before { content: "◐ dark"; }
[data-theme="light"] .theme-toggle__label::before { content: "◐ light"; }
.app-main {
padding: 14px;
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
grid-template-rows: auto auto auto;
grid-template-areas:
"indicators log"
"portfolio log"
"news news";
gap: 14px;
}
@media (max-width: 1100px) {
.app-main {
grid-template-columns: 1fr;
grid-template-areas: "indicators" "portfolio" "log" "news";
}
}
#indicators-panel { grid-area: indicators; }
#portfolio-panel { grid-area: portfolio; }
#log-panel { grid-area: log; }
#news-panel { grid-area: news; }
.app-footer {
border-top: 1px solid var(--border);
padding: 8px 18px;
background: var(--surface);
font-size: 11px;
color: var(--muted);
display: flex;
gap: 16px;
flex-wrap: wrap;
}
/* --- Panels ----------------------------------------------------------- */
.panel {
background: var(--surface);
border: 1px solid var(--border);
position: relative;
}
.panel-header {
border-bottom: 1px solid var(--border);
padding: 8px 12px;
display: flex;
align-items: center;
justify-content: space-between;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--muted);
font-size: 11px;
background: linear-gradient(180deg, var(--surface-2), var(--surface));
}
.panel-header .title { color: var(--text); font-weight: 700; }
.panel-header .title::before { content: "■ "; color: var(--accent); }
.panel-header .meta { color: var(--dim); }
.panel-body { padding: 6px 0; }
.panel-body--scroll { max-height: 70vh; overflow-y: auto; }
/* --- Tables ----------------------------------------------------------- */
table.dense {
width: 100%;
border-collapse: collapse;
}
table.dense th, table.dense td {
padding: 4px 12px;
font-size: 12px;
border-bottom: 1px solid var(--surface-2);
white-space: nowrap;
}
table.dense th {
text-align: left;
color: var(--muted);
font-weight: 400;
text-transform: uppercase;
letter-spacing: 0.06em;
font-size: 10px;
background: var(--surface-2);
}
table.dense th.num,
table.dense td.num { text-align: right; }
table.dense td.label { color: var(--text); }
table.dense tr:hover td { background: color-mix(in srgb, var(--accent) 5%, transparent); }
.pos { color: var(--positive); }
.neg { color: var(--negative); }
.neu { color: var(--muted); }
.note { color: var(--dim); font-size: 11px; }
/* --- Status LEDs ------------------------------------------------------ */
.led { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; vertical-align: middle; }
.led.ok { background: var(--positive); box-shadow: 0 0 6px var(--positive); }
.led.warn { background: var(--warning); box-shadow: 0 0 6px var(--warning); }
.led.err { background: var(--negative); box-shadow: 0 0 6px var(--negative); }
.led.idle { background: var(--dim); }
/* --- Group tabs ------------------------------------------------------- */
.group-tabs {
display: flex;
border-bottom: 1px solid var(--border);
overflow-x: auto;
}
.group-tabs button {
background: transparent;
border: 0;
border-right: 1px solid var(--border);
color: var(--muted);
font-family: inherit;
font-size: 11px;
padding: 6px 12px;
text-transform: uppercase;
letter-spacing: 0.06em;
cursor: pointer;
}
.group-tabs button:hover { color: var(--text); }
.group-tabs button.active {
color: var(--accent);
background: var(--bg);
box-shadow: inset 0 -2px 0 var(--accent);
}
/* --- Portfolio overall ----------------------------------------------- */
.pf-overall {
border-bottom: 1px solid var(--border);
padding: 10px 14px 12px;
background: linear-gradient(180deg, var(--surface-2), var(--surface));
}
.pf-overall__head {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 8px;
}
.pf-name {
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.1em;
font-weight: 700;
font-size: 11px;
}
.pf-name::before { content: "◆ "; opacity: 0.6; }
.pf-as-of { color: var(--dim); font-size: 11px; }
.pf-overall__grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px 24px;
}
@media (max-width: 640px) {
.pf-overall__grid { grid-template-columns: repeat(2, 1fr); }
}
.pf-stat-label {
font-size: 10px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.pf-stat-value {
font-size: 16px;
color: var(--text);
font-variant-numeric: tabular-nums;
margin-top: 2px;
}
.pf-stat-value.pos { color: var(--positive); }
.pf-stat-value.neg { color: var(--negative); }
.pf-stat-value.neu { color: var(--muted); }
.pf-ccy { color: var(--dim); font-size: 11px; margin-left: 2px; }
.pf-pct { color: var(--dim); font-size: 11px; margin-left: 4px; }
/* --- Log panel -------------------------------------------------------- */
.log-content {
font-family: var(--font-sans);
padding: 28px clamp(20px, 4vw, 56px) 32px;
font-size: 15.5px;
line-height: 1.72;
color: var(--text);
max-width: 76ch;
margin: 0 auto;
max-height: calc(100vh - 240px);
overflow-y: auto;
}
.log-content p { margin: 0 0 1.1em; }
.log-content h1, .log-content h2, .log-content h3, .log-content h4 {
font-family: var(--font-mono);
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 12px;
margin-top: 1.8em;
margin-bottom: 0.5em;
font-weight: 700;
}
.log-content h1:first-child,
.log-content h2:first-child,
.log-content h3:first-child { margin-top: 0; }
/* TL;DR callout model is instructed to put it first, so style the first
* heading + paragraph block as a callout. */
.log-content h3:first-of-type {
font-size: 11px;
color: var(--accent);
border-left: 3px solid var(--accent);
padding-left: 10px;
margin-bottom: 0;
}
.log-content h3:first-of-type + p {
font-size: 16.5px;
line-height: 1.6;
color: var(--text);
border-left: 3px solid var(--accent);
padding: 4px 14px 12px;
margin: 0 0 1.8em;
background: color-mix(in srgb, var(--accent) 5%, transparent);
font-weight: 500;
}
.log-content strong { color: var(--text); font-weight: 700; }
.log-content em { color: var(--muted); font-style: italic; }
.log-content ul, .log-content ol { padding-left: 1.4em; margin: 0 0 1.1em; }
.log-content li { margin-bottom: 0.4em; }
.log-content hr {
border: 0;
border-top: 1px solid var(--border);
margin: 1.6em 0;
}
/* --- Log page (calendar + log + chat sidebar) ------------------------- */
.log-page__body {
display: grid;
grid-template-columns: 220px 1fr 320px;
gap: 1px;
background: var(--border);
}
@media (max-width: 1100px) {
.log-page__body { grid-template-columns: 1fr; }
}
.log-page__cal, .log-page__content, .log-page__chat { background: var(--surface); }
.log-page__cal { padding: 10px; }
.log-page__content { min-height: 60vh; }
.log-page__chat { padding: 8px; min-height: 60vh; display: flex; flex-direction: column; }
/* --- Calendar widget --------------------------------------------------- */
.cal__nav {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.cal__title { color: var(--accent); font-weight: 700; }
.cal__btn {
background: transparent;
color: var(--muted);
border: 1px solid var(--border);
padding: 2px 8px;
cursor: pointer;
font-family: inherit;
font-size: 13px;
}
.cal__btn:hover { color: var(--accent); border-color: var(--accent); }
.cal__grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 1px;
background: var(--border);
border: 1px solid var(--border);
}
.cal__h {
text-align: center;
font-size: 9px;
color: var(--dim);
background: var(--surface-2);
padding: 3px 0;
text-transform: uppercase;
}
.cal__d {
background: var(--surface);
border: 0;
color: var(--muted);
font-family: inherit;
font-size: 11px;
padding: 6px 0;
text-align: center;
cursor: not-allowed;
}
.cal__d--empty { background: var(--bg); cursor: default; }
.cal__d--has-log {
color: var(--text);
cursor: pointer;
position: relative;
}
.cal__d--has-log::after {
content: "";
position: absolute;
bottom: 3px;
left: 50%;
transform: translateX(-50%);
width: 3px; height: 3px;
border-radius: 50%;
background: var(--accent);
}
.cal__d--has-log:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); }
.cal__d--today { color: var(--warning); }
.cal__d--selected {
background: var(--accent);
color: var(--bg);
font-weight: 700;
}
.cal__d--selected::after { background: var(--bg); }
/* --- Badges (tone / analysis indicators) ------------------------------ */
.badge {
display: inline-block;
font-family: var(--font-mono);
font-size: 9.5px;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 1px 6px;
border: 1px solid currentColor;
margin-right: 4px;
background: transparent;
vertical-align: middle;
}
/* Tone axis — green→accent→amber as audience density rises */
.badge--tone-novice { color: var(--positive); }
.badge--tone-intermediate { color: var(--accent); }
.badge--tone-pro { color: var(--alert); }
/* Analysis axis — dry is muted, speculative is accent */
.badge--analysis-dry { color: var(--muted); }
.badge--analysis-speculative { color: var(--accent); }
.badge--ver { color: var(--dim); }
.meta__hint { color: var(--dim); font-size: 10px; margin-right: 4px; }
/* --- Log metadata footer ---------------------------------------------- */
.log-meta {
padding: 8px clamp(20px, 4vw, 56px) 16px;
max-width: 76ch;
margin: 0 auto;
border-top: 1px dashed var(--border);
}
.log-meta__row { display: flex; flex-wrap: wrap; align-items: center; gap: 0; margin-top: 6px; }
.log-meta__row--dim { color: var(--dim); font-size: 10.5px; }
/* --- Chat sidebar ----------------------------------------------------- */
.chat-header {
border-bottom: 1px solid var(--border);
padding: 6px 4px 8px;
margin-bottom: 6px;
display: flex;
flex-direction: column;
}
.chat-title {
color: var(--accent);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
font-size: 11px;
}
.chat-title::before { content: "▸ "; }
.chat-hint { color: var(--dim); font-size: 10px; margin-top: 2px; }
.chat-thread {
flex: 1 1 auto;
overflow-y: auto;
padding: 4px 2px;
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
}
.chat-msg {
font-family: var(--font-sans);
font-size: 13.5px;
padding: 9px 11px;
border: 1px solid var(--border);
line-height: 1.6;
word-wrap: break-word;
}
.chat-msg--system {
color: var(--muted);
font-size: 12px;
background: transparent;
border-style: dashed;
font-family: var(--font-mono);
}
.chat-msg--user {
background: var(--user-bubble-bg);
border-color: var(--accent);
color: var(--text);
align-self: flex-end;
max-width: 92%;
white-space: pre-wrap;
}
.chat-msg--user::before {
content: "you ";
font-family: var(--font-mono);
color: var(--accent);
opacity: 0.7;
font-size: 10px;
}
.chat-msg--assistant { background: var(--surface-2); color: var(--text); }
.chat-msg--assistant::before {
content: "cassandra ";
font-family: var(--font-mono);
color: var(--accent);
opacity: 0.7;
font-size: 10px;
}
.chat-msg--pending { color: var(--dim); font-style: italic; }
.chat-msg--error { color: var(--negative); border-color: var(--negative); }
.chat-msg p { margin: 0.4em 0; }
.chat-msg p:first-child { margin-top: 0; }
.chat-msg p:last-child { margin-bottom: 0; }
.chat-msg h2, .chat-msg h3, .chat-msg h4 {
font-family: var(--font-mono);
color: var(--accent);
font-size: 11px;
margin: 0.8em 0 0.3em;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.chat-msg strong { color: var(--text); font-weight: 700; }
.chat-msg em { color: var(--muted); font-style: italic; }
.chat-form {
border-top: 1px solid var(--border);
padding-top: 6px;
display: flex;
gap: 6px;
align-items: flex-end;
}
.chat-form textarea {
flex: 1;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
font-family: inherit;
font-size: 12px;
padding: 6px 8px;
resize: vertical;
min-height: 36px;
outline: none;
}
.chat-form textarea:focus { border-color: var(--accent); }
.chat-form button {
background: transparent;
border: 1px solid var(--accent);
color: var(--accent);
font-family: inherit;
font-size: 11px;
padding: 6px 12px;
text-transform: uppercase;
letter-spacing: 0.08em;
cursor: pointer;
}
.chat-form button:hover:not(:disabled) { background: var(--accent); color: var(--bg); }
.chat-form button:disabled { opacity: 0.4; cursor: not-allowed; }
/* --- News ------------------------------------------------------------- */
.news-row {
padding: 4px 12px;
display: grid;
grid-template-columns: 50px 130px 1fr 110px;
gap: 12px;
font-size: 12px;
border-bottom: 1px solid var(--surface-2);
align-items: baseline;
}
@media (max-width: 720px) {
.news-row { grid-template-columns: 50px 100px 1fr; }
.news-row .local { display: none; }
}
.news-row:hover { background: color-mix(in srgb, var(--accent) 5%, transparent); }
.news-row .age { color: var(--dim); text-align: right; }
.news-row .source { color: var(--muted); font-size: 11px; }
.news-row .title { color: var(--text); }
.news-row .title:hover { color: var(--accent); }
.news-row .local {
color: var(--muted);
font-size: 11px;
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* --- Empty / loading state ------------------------------------------- */
.empty {
padding: 24px;
text-align: center;
color: var(--muted);
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.htmx-indicator {
display: inline-block;
color: var(--dim);
opacity: 0;
transition: opacity 0.2s;
}
.htmx-request .htmx-indicator { opacity: 1; }
/* --- Scrollbar -------------------------------------------------------- */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--dim); border-radius: 0; }
::-webkit-scrollbar-thumb:hover { background: var(--muted); }

73
app/static/js/chat.js Normal file
View file

@ -0,0 +1,73 @@
// Cassandra chat sidebar — ephemeral, client-state conversation.
// No persistence: page refresh starts a new chat.
(() => {
const thread = document.getElementById('chat-thread');
const form = document.getElementById('chat-form');
const input = document.getElementById('chat-input');
const send = document.getElementById('chat-send');
if (!thread || !form || !input || !send) return;
/** @type {{role: string, content: string}[]} */
const messages = [];
function escapeHTML(s) {
return s.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function append(role, html_or_text, opts) {
const div = document.createElement('div');
div.className = 'chat-msg chat-msg--' + role;
if (opts && opts.html) {
div.innerHTML = html_or_text;
} else {
div.textContent = html_or_text;
}
thread.appendChild(div);
thread.scrollTop = thread.scrollHeight;
return div;
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
const text = input.value.trim();
if (!text || send.disabled) return;
messages.push({role: 'user', content: text});
append('user', text);
input.value = '';
send.disabled = true;
const thinking = append('assistant pending', '…');
try {
const r = await fetch('/api/chat', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({messages}),
});
if (!r.ok) {
const msg = await r.text();
thinking.className = 'chat-msg chat-msg--error';
thinking.textContent = 'HTTP ' + r.status + ': ' + msg.slice(0, 300);
return;
}
const data = await r.json();
thinking.className = 'chat-msg chat-msg--assistant';
thinking.innerHTML = data.content_html || escapeHTML(data.content);
messages.push({role: 'assistant', content: data.content});
} catch (err) {
thinking.className = 'chat-msg chat-msg--error';
thinking.textContent = 'error: ' + err.message;
} finally {
send.disabled = false;
input.focus();
}
});
// Enter to send; Shift+Enter for newline.
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
form.requestSubmit();
}
});
})();

1
app/static/js/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

71
app/templates/base.html Normal file
View file

@ -0,0 +1,71 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{% block title %}Cassandra{% endblock %}</title>
{# Apply saved theme before stylesheet renders to avoid a flash. #}
<script>
(function() {
try {
var t = localStorage.getItem('cassandra.theme') || 'dark';
document.documentElement.dataset.theme = t;
} catch (e) { document.documentElement.dataset.theme = 'dark'; }
})();
</script>
<link rel="stylesheet" href="{{ url_for('static', path='/css/cassandra.css') }}" />
<script src="{{ url_for('static', path='/js/htmx.min.js') }}" defer></script>
<script>
// Render any <time datetime="..."> in the browser's local timezone.
// Re-runs after every HTMX swap so freshly-loaded news rows pick up too.
function formatLocalTimes() {
document.querySelectorAll('time[datetime]:not([data-local])').forEach(function (t) {
try {
var d = new Date(t.getAttribute('datetime'));
if (isNaN(d.getTime())) return;
var date = d.toLocaleDateString(undefined, { day: '2-digit', month: 'short' });
var time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false });
t.textContent = date + ' ' + time;
t.title = d.toLocaleString();
t.setAttribute('data-local', '1');
} catch (e) {}
});
}
document.addEventListener('DOMContentLoaded', function () {
formatLocalTimes();
document.body.addEventListener('htmx:afterSwap', formatLocalTimes);
});
</script>
</head>
<body>
<div class="app">
<header class="app-header">
<div class="brand">Cassandra</div>
<nav>
<a href="/" class="{% if request.url.path == '/' %}active{% endif %}">Dashboard</a>
<a href="/news" class="{% if request.url.path == '/news' %}active{% endif %}">News</a>
<a href="/log" class="{% if request.url.path.startswith('/log') %}active{% endif %}">Log</a>
</nav>
<div class="header-right">
<button class="theme-toggle" type="button" aria-label="Toggle theme"
onclick="(function(){var d=document.documentElement;var t=d.dataset.theme==='light'?'dark':'light';d.dataset.theme=t;try{localStorage.setItem('cassandra.theme',t);}catch(e){}})()">
<span class="theme-toggle__label"></span>
</button>
<span class="meta">v0.1 · UTC</span>
</div>
</header>
<main class="app-main">
{% block main %}{% endblock %}
</main>
<footer class="app-footer"
hx-get="/api/health"
hx-trigger="load, every 30s"
hx-swap="innerHTML"
id="ops-footer">
<span class="led idle"></span> awaiting status…
</footer>
</div>
</body>
</html>

View file

@ -0,0 +1,77 @@
{% extends "base.html" %}
{% block title %}Cassandra · Dashboard{% endblock %}
{% block main %}
<section id="indicators-panel" class="panel">
<div class="panel-header">
<span class="title">Indicators</span>
<span class="meta">{% if anchor %}anchor {{ anchor }} · {% endif %}ingest hourly @ :05 UTC</span>
</div>
<div class="group-tabs" id="group-tabs">
{% for g in groups %}
<button
class="{% if loop.first %}active{% endif %}"
hx-get="/api/indicators/{{ g }}?as=html"
hx-target="#indicators-body"
hx-trigger="click"
onclick="document.querySelectorAll('#group-tabs button').forEach(b=>b.classList.remove('active'));this.classList.add('active')"
>{{ g }}</button>
{% endfor %}
</div>
<div id="indicators-body"
class="panel-body panel-body--scroll"
hx-get="/api/indicators/{{ groups[0] }}?as=html"
hx-trigger="load"
hx-swap="innerHTML">
<div class="empty">loading…</div>
</div>
</section>
<script>
// Auto-refresh the *currently selected* group every 60s by simulating a
// click on the active tab. Replaces the hard-coded `every 60s` on
// #indicators-body which always re-fetched groups[0].
setInterval(function () {
var active = document.querySelector('#group-tabs button.active');
if (active) active.click();
}, 60000);
</script>
<section id="portfolio-panel" class="panel">
<div class="panel-header">
<span class="title">Portfolio</span>
<span class="meta">ingest hourly @ :15 UTC</span>
</div>
<div class="panel-body"
hx-get="/api/portfolios?as=html"
hx-trigger="load, every 60s"
hx-swap="innerHTML">
<div class="empty">loading…</div>
</div>
</section>
<section id="log-panel" class="panel">
<div class="panel-header">
<span class="title">Strategic Log</span>
<span class="meta">generated hourly @ :20 UTC</span>
</div>
<div class="panel-body"
hx-get="/api/log/latest?as=html"
hx-trigger="load, every 300s"
hx-swap="innerHTML">
<div class="empty">awaiting first log…</div>
</div>
</section>
<section id="news-panel" class="panel">
<div class="panel-header">
<span class="title">Flash News</span>
<span class="meta">last 24h · ingest hourly @ :10 UTC</span>
</div>
<div class="panel-body panel-body--scroll"
hx-get="/api/news?as=html&limit=40"
hx-trigger="load, every 60s"
hx-swap="innerHTML">
<div class="empty">loading…</div>
</div>
</section>
{% endblock %}

55
app/templates/log.html Normal file
View file

@ -0,0 +1,55 @@
{% extends "base.html" %}
{% block title %}Cassandra · Strategic Log{% endblock %}
{% block main %}
<section class="panel log-page" style="grid-column: 1 / -1;">
<div class="panel-header">
<span class="title">Strategic Log Archive</span>
<span class="meta">
selected {{ selected_iso }}
&nbsp;·&nbsp;
<span class="meta__hint">new logs use:</span>
<span class="badge badge--tone-{{ current_tone | lower }}">tone {{ current_tone | lower }}</span>
<span class="badge badge--analysis-{{ current_analysis | lower }}">analysis {{ current_analysis | lower }}</span>
</span>
</div>
<div class="log-page__body">
<aside class="log-page__cal"
hx-get="/api/log/days?month={{ selected_month }}&selected={{ selected_iso }}"
hx-trigger="load"
hx-swap="innerHTML">
<div class="empty">loading calendar…</div>
</aside>
<article id="log-content"
class="log-page__content"
hx-get="/api/log/by-date/{{ selected_iso }}?as=html"
hx-trigger="load"
hx-swap="innerHTML">
<div class="empty">loading log…</div>
</article>
<aside id="chat-sidebar" class="log-page__chat">
<div class="chat-header">
<span class="chat-title">Ask Cassandra</span>
<span class="chat-hint">grounded on the latest log + live data</span>
</div>
<div id="chat-thread" class="chat-thread">
<div class="chat-msg chat-msg--system">
Ask about today's analysis. The model sees the latest strategic log,
live market readings across all groups, and the last 24h of
thesis-filtered headlines. Refresh wipes this conversation.
</div>
</div>
<form id="chat-form" class="chat-form" autocomplete="off">
<textarea id="chat-input" rows="2"
placeholder="e.g. why is the defence sleeve flat through Hormuz?"
required></textarea>
<button id="chat-send" type="submit">Send</button>
</form>
</aside>
</div>
</section>
<script src="{{ url_for('static', path='/js/chat.js') }}" defer></script>
{% endblock %}

17
app/templates/news.html Normal file
View file

@ -0,0 +1,17 @@
{% extends "base.html" %}
{% block title %}Cassandra · News{% endblock %}
{% block main %}
<section class="panel" style="grid-column: 1 / -1;">
<div class="panel-header">
<span class="title">News Feed</span>
<span class="meta">last 24h · ingest hourly @ :10 UTC</span>
</div>
<div class="panel-body panel-body--scroll"
hx-get="/api/news?as=html&limit=200"
hx-trigger="load, every 60s"
hx-swap="innerHTML">
<div class="empty">loading…</div>
</div>
</section>
{% endblock %}

View file

@ -0,0 +1,48 @@
<div class="cal" id="cal-widget">
<div class="cal__nav">
<button class="cal__btn"
hx-get="/api/log/days?month={{ prev_month }}{% if selected %}&selected={{ selected.isoformat() }}{% endif %}"
hx-target="#cal-widget"
hx-swap="outerHTML">&lsaquo;</button>
<div class="cal__title">{{ month_name }} {{ year }}</div>
<button class="cal__btn"
hx-get="/api/log/days?month={{ next_month }}{% if selected %}&selected={{ selected.isoformat() }}{% endif %}"
hx-target="#cal-widget"
hx-swap="outerHTML">&rsaquo;</button>
</div>
<div class="cal__grid">
<div class="cal__h">Mo</div>
<div class="cal__h">Tu</div>
<div class="cal__h">We</div>
<div class="cal__h">Th</div>
<div class="cal__h">Fr</div>
<div class="cal__h">Sa</div>
<div class="cal__h">Su</div>
{% for week in grid %}
{% for d in week %}
{% if d is none %}
<div class="cal__d cal__d--empty"></div>
{% else %}
{% set has_log = d in days_with_logs %}
{% set is_selected = (selected and selected.day == d and selected.month == month and selected.year == year) %}
{% set is_today = (today.day == d and today.month == month and today.year == year) %}
{% set iso = "%04d-%02d-%02d" | format(year, month, d) %}
<button class="cal__d
{% if has_log %}cal__d--has-log{% else %}cal__d--no-log{% endif %}
{% if is_selected %}cal__d--selected{% endif %}
{% if is_today %}cal__d--today{% endif %}"
{% if has_log %}
hx-get="/api/log/by-date/{{ iso }}?as=html"
hx-target="#log-content"
hx-swap="innerHTML"
hx-push-url="/log/{{ iso }}"
onclick="document.querySelectorAll('.cal__d--selected').forEach(b=>b.classList.remove('cal__d--selected'));this.classList.add('cal__d--selected')"
{% else %}
disabled
{% endif %}
>{{ d }}</button>
{% endif %}
{% endfor %}
{% endfor %}
</div>
</div>

View file

@ -0,0 +1,38 @@
{% if not quotes %}
<div class="empty">no data yet — scheduler may not have run</div>
{% else %}
<table class="dense">
<thead>
<tr>
<th>Symbol</th><th>Label</th>
<th class="num">Price</th><th>Ccy</th>
<th class="num">1d</th><th class="num">1m</th><th class="num">1y</th>
{% if has_anchor %}<th class="num">anchor</th>{% endif %}
<th>as-of</th>
</tr>
</thead>
<tbody>
{% for q in quotes %}
<tr>
<td class="label">{{ q.symbol }}</td>
<td>{{ q.label or "" }}</td>
<td class="num">{{ q.price | price }}</td>
<td class="neu">{{ q.currency or "" }}</td>
{% for k in ["1d","1m","1y"] %}
{% set v = q.changes.get(k) if q.changes else None %}
<td class="num {% if v is none %}neu{% elif v >= 0 %}pos{% else %}neg{% endif %}">
{% if v is none %}—{% else %}{{ "%+.2f"|format(v) }}%{% endif %}
</td>
{% endfor %}
{% if has_anchor %}
{% set va = q.changes.get('anchor') if q.changes else None %}
<td class="num {% if va is none %}neu{% elif va >= 0 %}pos{% else %}neg{% endif %}">
{% if va is none %}—{% else %}{{ "%+.2f"|format(va) }}%{% endif %}
</td>
{% endif %}
<td class="neu">{{ q.as_of or "" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}

View file

@ -0,0 +1,18 @@
{% if not log %}
<div class="empty">awaiting first generated log</div>
{% else %}
<div class="log-content">{{ log.content_html | safe }}</div>
<div class="log-meta">
<div class="log-meta__row">
{% if log.tone %}<span class="badge badge--tone-{{ log.tone | lower }}">tone {{ log.tone | lower }}</span>{% endif %}
{% if log.analysis %}<span class="badge badge--analysis-{{ log.analysis | lower }}">analysis {{ log.analysis | lower }}</span>{% endif %}
{% if log.prompt_version %}<span class="badge badge--ver">prompt v{{ log.prompt_version }}</span>{% endif %}
</div>
<div class="log-meta__row log-meta__row--dim">
generated {{ log.generated_at.strftime("%Y-%m-%d %H:%M UTC") }}
&nbsp;·&nbsp; model <span class="neu">{{ log.model }}</span>
{% if log.prompt_tokens %} &nbsp;·&nbsp; {{ log.prompt_tokens }}↑/{{ log.completion_tokens }}↓ tokens{% endif %}
{% if log.cost_usd is not none %} &nbsp;·&nbsp; ${{ "%.4f"|format(log.cost_usd) }}{% endif %}
</div>
</div>
{% endif %}

View file

@ -0,0 +1,16 @@
{% if not headlines %}
<div class="empty">no headlines in window</div>
{% else %}
{% for h in headlines %}
<div class="news-row">
<span class="age">{{ h.age }}</span>
<span class="source">{{ h.source }}</span>
<a class="title" href="{{ h.url }}" target="_blank" rel="noopener">{{ h.title }}</a>
{% if h.iso %}
<time class="local" datetime="{{ h.iso }}" title="{{ h.iso }}">{{ h.utc_short }}</time>
{% else %}
<span class="local"></span>
{% endif %}
</div>
{% endfor %}
{% endif %}

View file

@ -0,0 +1,7 @@
<span><span class="led {% if db_ok %}ok{% else %}err{% endif %}"></span>DB</span>
{% for j in jobs %}
<span title="{{ j.name }}">
<span class="led {{ j.led }}"></span>{{ j.name }}
{% if j.last_finished %}· {{ j.age }}{% endif %}
</span>
{% endfor %}

View file

@ -0,0 +1,80 @@
{% if not portfolios %}
<div class="empty">no portfolio snapshots yet</div>
{% else %}
{% for p in portfolios %}
{# --- overall block --- #}
<div class="pf-overall">
<div class="pf-overall__head">
<span class="pf-name">{{ p.name }}</span>
<span class="pf-as-of">
{% if p.snapshot_at %}{{ p.snapshot_at.strftime("%Y-%m-%d %H:%M UTC") }}{% else %}—{% endif %}
</span>
</div>
<div class="pf-overall__grid">
<div class="pf-stat">
<div class="pf-stat-label">Total</div>
<div class="pf-stat-value">{{ p.total_value | money }} <span class="pf-ccy">{{ p.currency }}</span></div>
</div>
<div class="pf-stat">
<div class="pf-stat-label">Invested</div>
<div class="pf-stat-value">{{ p.invested | money }}</div>
</div>
<div class="pf-stat">
<div class="pf-stat-label">Cash</div>
<div class="pf-stat-value">{{ p.cash | money }}</div>
</div>
<div class="pf-stat">
<div class="pf-stat-label">Unrealised P/L</div>
<div class="pf-stat-value {% if p.unrealized_ppl is none %}neu{% elif p.unrealized_ppl >= 0 %}pos{% else %}neg{% endif %}">
{{ p.unrealized_ppl | signed }}
{% if p.total_cost and p.unrealized_ppl is not none %}
<span class="pf-pct">({{ "%+.2f"|format(p.unrealized_ppl / p.total_cost * 100) }}%)</span>
{% endif %}
</div>
</div>
<div class="pf-stat">
<div class="pf-stat-label">Realised P/L</div>
<div class="pf-stat-value {% if p.realized_ppl is none %}neu{% elif p.realized_ppl >= 0 %}pos{% else %}neg{% endif %}">
{{ p.realized_ppl | signed }}
</div>
</div>
<div class="pf-stat">
<div class="pf-stat-label">Positions</div>
<div class="pf-stat-value">{{ p.positions | length }}</div>
</div>
</div>
</div>
{# --- per-position table --- #}
<table class="dense">
<thead>
<tr>
<th>Ticker</th>
<th>Name</th>
<th class="num">Qty</th>
<th class="num">Avg</th>
<th class="num">Last</th>
<th class="num">P/L</th>
<th class="num">%</th>
</tr>
</thead>
<tbody>
{% for pos in p.positions %}
<tr>
<td class="label">{{ pos.ticker }}</td>
<td>{{ pos.name or "" }}</td>
<td class="num">{{ pos.quantity | price }}</td>
<td class="num neu">{{ pos.average_price | price }}</td>
<td class="num">{{ pos.current_price | price }}</td>
<td class="num {% if pos.ppl is none %}neu{% elif pos.ppl >= 0 %}pos{% else %}neg{% endif %}">
{{ pos.ppl | signed }}
</td>
<td class="num {% if pos.ppl_pct is none %}neu{% elif pos.ppl_pct >= 0 %}pos{% else %}neg{% endif %}">
{% if pos.ppl_pct is not none %}{{ "%+.2f"|format(pos.ppl_pct) }}%{% else %}—{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endfor %}
{% endif %}

45
app/templates_env.py Normal file
View file

@ -0,0 +1,45 @@
"""Shared Jinja2 environment with custom filters for the dashboard.
Imported by both routers/pages.py and routers/api.py so the filters are
registered exactly once."""
from __future__ import annotations
from pathlib import Path
from fastapi.templating import Jinja2Templates
TEMPLATE_DIR = Path(__file__).resolve().parent / "templates"
def _fmt_price(v: float | None) -> str:
"""Format a price in a way that's readable in dense terminal tables.
Avoids scientific notation for large round numbers (FTSE 25,962, not 2.596e+04)
and keeps enough precision for FX rates like 0.8725 EUR/GBP."""
if v is None:
return ""
av = abs(v)
if av >= 1000:
return f"{v:,.2f}"
if av >= 10:
return f"{v:.2f}"
if av >= 1:
return f"{v:.4f}"
return f"{v:.4f}"
def _fmt_signed(v: float | None, decimals: int = 2) -> str:
if v is None:
return ""
return f"{v:+,.{decimals}f}"
def _fmt_money(v: float | None) -> str:
if v is None:
return ""
return f"{v:,.2f}"
templates = Jinja2Templates(directory=str(TEMPLATE_DIR))
templates.env.filters["price"] = _fmt_price
templates.env.filters["signed"] = _fmt_signed
templates.env.filters["money"] = _fmt_money

181
config/default.toml Normal file
View file

@ -0,0 +1,181 @@
# Baseline config — universal data tables consumed by market_pulse.py and
# flash_news.py. Edit this file to change what every user/portfolio tracks by
# default. User-specific or portfolio-specific overrides go in portfolio.toml,
# which is loaded on top of this and wins on key conflicts.
#
# Symbol prefixes in [groups]:
# bare → Yahoo Finance (default)
# FRED:SERIES_ID → FRED economic series (needs FRED_API_KEY in .env)
# =============================================================================
# market_pulse.py — indicator groups
# =============================================================================
[groups]
equity = [
{symbol="^GSPC", label="S&P 500", note="doc ref 7,501 (ATH)"},
{symbol="^IXIC", label="Nasdaq Composite", note="Mag 7 concentration"},
{symbol="^NDX", label="Nasdaq 100", note="Mag 7 concentration"},
{symbol="^FTSE", label="FTSE 100", note="GBP base"},
{symbol="^STOXX50E", label="Euro Stoxx 50", note=""},
{symbol="^N225", label="Nikkei 225", note="JPY at multi-decade low → VJPN thesis"},
{symbol="^HSI", label="Hang Seng", note="China expression"},
{symbol="000300.SS", label="CSI 300", note="broad China A-share"},
{symbol="^VIX", label="VIX", note="doc ref 18.0 — 'anomalously calm'"},
]
mag7 = [
{symbol="AAPL", label="Apple", note="doc thesis: 'Mag 7 pops' scenario"},
{symbol="MSFT", label="Microsoft", note=""},
{symbol="GOOGL", label="Alphabet (Class A)", note=""},
{symbol="AMZN", label="Amazon", note=""},
{symbol="META", label="Meta Platforms", note=""},
{symbol="NVDA", label="Nvidia", note="AI capex bellwether"},
{symbol="TSLA", label="Tesla", note=""},
]
rates = [
{symbol="^IRX", label="US 3-month T-bill", note="%"},
{symbol="^FVX", label="US 5y yield", note="%"},
{symbol="^TNX", label="US 10y yield", note="% — doc ref 4.45"},
{symbol="^TYX", label="US 30y yield", note="%"},
{symbol="HYG", label="HYG — HY corp bond ETF", note="HY OAS price proxy"},
{symbol="TIP", label="TIP — TIPS ETF", note="inflation expectations proxy"},
]
macro = [
{symbol="FRED:DFF", label="Fed Funds (effective)", note="%"},
{symbol="FRED:CPIAUCSL", label="US CPI (YoY)", note="% — doc ref 3.8"},
{symbol="FRED:CPILFESL", label="US Core CPI (YoY)", note="% — ex food & energy"},
{symbol="FRED:BAMLH0A0HYM2", label="US HY OAS", note="% — doc ref 2.79 (279bps)"},
{symbol="FRED:T10YIE", label="US 10y breakeven inflation", note="%"},
{symbol="FRED:T10Y2Y", label="10y-2y term spread", note="% — recession watch"},
{symbol="FRED:UNRATE", label="US unemployment rate", note="%"},
{symbol="FRED:WALCL", label="Fed balance sheet", note="$M — QT/QE state"},
]
commodities = [
{symbol="BZ=F", label="Brent crude", note="$/bbl — doc ref 109; closure-priced 180-250"},
{symbol="CL=F", label="WTI crude", note="$/bbl"},
{symbol="NG=F", label="Henry Hub nat-gas", note="$/MMBtu"},
{symbol="GC=F", label="Gold futures", note="$/oz — doc ref 4,651"},
{symbol="SI=F", label="Silver futures", note="$/oz"},
{symbol="HG=F", label="Copper futures", note="$/lb — transition demand"},
{symbol="PL=F", label="Platinum futures", note="$/oz"},
]
fx = [
{symbol="DX-Y.NYB", label="DXY (USD index)", note="USD-debasement channel"},
{symbol="GBPUSD=X", label="GBP/USD", note="household base FX"},
{symbol="EURGBP=X", label="EUR/GBP", note="~£400-530k EUR exposure"},
{symbol="EURUSD=X", label="EUR/USD", note=""},
{symbol="USDJPY=X", label="USD/JPY", note="JPY at multi-decade low"},
{symbol="USDCNY=X", label="USD/CNY", note="managed peg, watch deviation"},
{symbol="GC=F", label="Gold (USD)", note="anchor for FX read"},
]
tech_ai = [
{symbol="XLK", label="XLK — US tech sector", note="broad US tech read"},
{symbol="SOXX", label="SOXX — iShares semis", note="AI capex bellwether"},
{symbol="TSM", label="TSMC (ADR)", note="foundry monopoly + Taiwan risk"},
{symbol="ASML", label="ASML", note="EUV monopoly — China-export friction"},
{symbol="AVGO", label="Broadcom", note="AI infra; cyclical risk"},
{symbol="AMD", label="AMD", note="Nvidia competitor"},
{symbol="ARM", label="ARM Holdings", note="mobile/edge AI"},
]
financials = [
{symbol="XLF", label="XLF — US financials sector", note="broad US financials"},
{symbol="KBE", label="KBE — US banks", note="bank-only, regional+money centre"},
{symbol="KRE", label="KRE — US regional banks", note="post-2023 stress watch"},
{symbol="JPM", label="JPMorgan", note="systemically important"},
{symbol="GS", label="Goldman Sachs", note="capital markets read"},
{symbol="HSBA.L", label="HSBC (LSE)", note="UK + Asia exposure"},
{symbol="BARC.L", label="Barclays (LSE)", note="UK clearing bank"},
{symbol="BNP.PA", label="BNP Paribas (Paris)", note="EU systemic"},
{symbol="DBK.DE", label="Deutsche Bank (Frankfurt)", note="EU stress canary"},
{symbol="^FTAS", label="FTSE All-Share", note="UK breadth"},
]
# =============================================================================
# flash_news.py — RSS feed registry, by category
# =============================================================================
[feeds]
markets = [
{name="BBC Business", url="https://feeds.bbci.co.uk/news/business/rss.xml"},
{name="Guardian Business", url="https://www.theguardian.com/uk/business/rss"},
{name="CNBC Top", url="https://www.cnbc.com/id/100003114/device/rss/rss.html"},
{name="MarketWatch Top", url="https://feeds.content.dowjones.io/public/rss/mw_topstories"},
{name="FT Markets", url="https://www.ft.com/markets?format=rss"},
{name="Yahoo Finance", url="https://finance.yahoo.com/news/rssindex"},
]
world = [
{name="BBC World", url="https://feeds.bbci.co.uk/news/world/rss.xml"},
{name="Al Jazeera", url="https://www.aljazeera.com/xml/rss/all.xml"},
]
energy = [
{name="OilPrice", url="https://oilprice.com/rss/main"},
]
# Pan-Asia coverage: HK/China, Japan, Korea, SE Asia, regional wire.
asia = [
{name="SCMP", url="https://www.scmp.com/rss/91/feed/"},
{name="China Daily", url="http://www.chinadaily.com.cn/rss/world_rss.xml"},
{name="Xinhua", url="http://www.xinhuanet.com/english/rss/worldrss.xml"},
{name="Japan Times", url="https://www.japantimes.co.jp/feed/"},
{name="Nikkei Asia", url="https://asia.nikkei.com/rss/feed/nar"},
{name="Yonhap", url="https://en.yna.co.kr/RSS/news.xml"},
{name="Straits Times", url="https://www.straitstimes.com/news/world/rss.xml"},
{name="Asia Times", url="https://asiatimes.com/feed/"},
]
# =============================================================================
# flash_news.py — built-in keyword presets for headline filtering
# =============================================================================
[news.presets]
# Mirrors Family_Wealth_Summary_v2.md §8 macro thesis. Swap freely if the
# portfolio's thesis changes (e.g. crypto, biotech, deep value).
thesis = [
"hormuz", "iran", "opec", "brent", "wti", "crude", "oil", "gas", "lng",
"china", "taiwan", "yuan", "beijing", "xi",
"fed ", "powell", "ecb", "lagarde", "boe ", "boj", "bailey",
"inflation", "cpi", "rate cut", "rate hike", "yield",
"gold", "silver", "dollar", "yen",
"sanction", "tariff", "trade war",
"saudi", "russia", "ukraine", "israel", "nato",
"defence", "defense", "rearmament",
]
# Tech / AI sector — semis, hyperscaler capex, model labs, chip-export controls.
tech = [
" ai ", "ai ", " ai,", "artificial intelligence",
"nvidia", "openai", "anthropic", "deepmind", "gemini", "chatgpt",
"semiconductor", "chip", "foundry", "tsmc", "asml", "samsung",
"lithography", "euv", "wafer",
"data centre", "data center", "hyperscaler", "capex",
"broadcom", "amd ", "arm holdings", "intel ",
"apple", "microsoft", "alphabet", "google", "meta ", "tesla",
"cloud", "saas", "compute",
]
# Financials — banks, credit, central banks, market plumbing.
finance = [
"bank", "banking", "lender", "lending",
"jpmorgan", "goldman", "morgan stanley", "wells fargo", "citigroup",
"hsbc", "barclays", "lloyds", "natwest", "santander",
"bnp paribas", "deutsche bank", "credit suisse", "ubs",
"federal reserve", "rate cut", "rate hike", "basel",
"credit spread", "default", "junk bond", "high yield",
"private credit", "shadow banking",
"earnings", "buyback", "dividend",
]

18
config/portfolio.toml Normal file
View file

@ -0,0 +1,18 @@
# Portfolio config — optional user-specific overrides on top of default.toml.
#
# As of v0.2 the portfolio composition (the "pie") is sourced live from the
# Trading 212 /equity/portfolio endpoint, not from this file. The portfolio_job
# also calls /equity/metadata/instruments to attach human-readable names. Once
# portfolio_job has run, the dashboard's Portfolio panel shows the live
# composition and the news_job uses those tickers for per-ticker headlines.
#
# This file is still loaded — anything you add here overlays default.toml.
# Use it for:
# - extra indicator groups (e.g. a "crypto" or "watchlist" group):
# [groups]
# crypto = [
# {symbol="BTC-USD", label="Bitcoin", note=""},
# {symbol="ETH-USD", label="Ethereum", note=""},
# ]
# - replacing universal groups from default.toml (same key wins)
# - adding portfolio-specific RSS feeds or keyword presets

75
docker-compose.yml Normal file
View file

@ -0,0 +1,75 @@
# Cassandra — app + scheduler + MariaDB + daily backup sidecar.
# .env is mounted read-only; never bake secrets into the image.
services:
db:
image: mariadb:11
restart: unless-stopped
environment:
MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD:-changeme-root}
MARIADB_DATABASE: ${MARIADB_DATABASE:-cassandra}
MARIADB_USER: ${MARIADB_USER:-cassandra}
MARIADB_PASSWORD: ${MARIADB_PASSWORD:-changeme}
volumes:
- db-data:/var/lib/mysql
- ./backup:/backup
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 10
app:
build: .
restart: unless-stopped
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
env_file: .env
environment:
DATABASE_URL: mysql+aiomysql://${MARIADB_USER:-cassandra}:${MARIADB_PASSWORD:-changeme}@db:3306/${MARIADB_DATABASE:-cassandra}
volumes:
- ./config:/app/config:ro
depends_on:
db:
condition: service_healthy
ports:
- "${CASSANDRA_PORT:-8000}:8000"
scheduler:
build: .
restart: unless-stopped
command: ["python", "-m", "app.scheduler_main"]
env_file: .env
environment:
DATABASE_URL: mysql+aiomysql://${MARIADB_USER:-cassandra}:${MARIADB_PASSWORD:-changeme}@db:3306/${MARIADB_DATABASE:-cassandra}
volumes:
- ./config:/app/config:ro
depends_on:
db:
condition: service_healthy
backup:
image: mariadb:11
restart: unless-stopped
environment:
MARIADB_HOST: db
MARIADB_USER: ${MARIADB_USER:-cassandra}
MARIADB_PASSWORD: ${MARIADB_PASSWORD:-changeme}
MARIADB_DATABASE: ${MARIADB_DATABASE:-cassandra}
entrypoint: ["/bin/sh", "-c"]
# Daily dump at 03:00 UTC; keeps last 14 days.
command: |
"while true; do
sleep $$((86400 - $$(date +%s) % 86400 + 10800));
f=/backup/cassandra-$$(date -u +%Y-%m-%d).sql.gz;
echo \"[backup] $$f\";
mariadb-dump -h $$MARIADB_HOST -u $$MARIADB_USER -p$$MARIADB_PASSWORD $$MARIADB_DATABASE | gzip > $$f || echo '[backup] FAILED';
find /backup -name 'cassandra-*.sql.gz' -mtime +14 -delete;
done"
volumes:
- ./backup:/backup
depends_on:
db:
condition: service_healthy
volumes:
db-data:

43
pyproject.toml Normal file
View file

@ -0,0 +1,43 @@
[project]
name = "cassandra"
version = "0.1.0"
description = "Containerised macro-strategy dashboard — market data, news, portfolios, AI daily log."
requires-python = ">=3.13"
dependencies = [
"fastapi>=0.115",
"uvicorn[standard]>=0.32",
"jinja2>=3.1",
"python-multipart>=0.0.12",
"sqlalchemy[asyncio]>=2.0.36",
"aiomysql>=0.2.0",
"alembic>=1.14",
"pydantic>=2.9",
"pydantic-settings>=2.6",
"httpx>=0.28",
"apscheduler>=3.10",
"tenacity>=9.0",
"structlog>=24.4",
]
[project.optional-dependencies]
dev = [
"pytest>=8.3",
"pytest-asyncio>=0.24",
"pytest-httpx>=0.34",
"ruff>=0.7",
]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
[tool.ruff]
line-length = 100
target-version = "py313"
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[tool.setuptools]
packages = ["app", "app.services", "app.jobs", "app.routers"]

19
tests/conftest.py Normal file
View file

@ -0,0 +1,19 @@
"""Pytest config — no DB / no network. Tests target pure functions only.
Heavy runtime deps (fastapi, httpx, sqlalchemy, pydantic-settings, tenacity)
are installed inside the container but not necessarily on the host. Tests
that need them use pytest.importorskip; the full suite runs via
`docker compose run --rm app pytest tests/`."""
from __future__ import annotations
import os
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
# Sentinel env so importing app.config doesn't try to read a missing .env.
os.environ.setdefault("DATABASE_URL", "sqlite+aiosqlite:///:memory:")
os.environ.setdefault("CASSANDRA_MOCK", "1")

21
tests/fixtures/rss_sample.xml vendored Normal file
View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>Sample</title>
<item>
<title>Brent crude jumps on Hormuz uncertainty</title>
<link>https://example.com/a</link>
<pubDate>Fri, 15 May 2026 12:00:00 GMT</pubDate>
</item>
<item>
<title>Fed signals caution as inflation re-accelerates</title>
<link>https://example.com/b</link>
<pubDate>Fri, 15 May 2026 13:30:00 GMT</pubDate>
</item>
<item>
<title></title>
<link>https://example.com/empty</link>
<pubDate>Fri, 15 May 2026 14:00:00 GMT</pubDate>
</item>
</channel>
</rss>

37
tests/test_api_helpers.py Normal file
View file

@ -0,0 +1,37 @@
"""Tests for tiny helpers in app.routers.api."""
from __future__ import annotations
import pytest
pytest.importorskip("fastapi")
pytest.importorskip("sqlalchemy")
from datetime import datetime, timedelta, timezone
from app.routers.api import _fmt_age, _md_to_html
def test_fmt_age_none():
assert _fmt_age(datetime.now(timezone.utc), None) == ""
def test_fmt_age_units():
now = datetime(2026, 5, 15, 12, 0, tzinfo=timezone.utc)
assert _fmt_age(now, now - timedelta(seconds=30)) == "30s"
assert _fmt_age(now, now - timedelta(minutes=5)) == "5m"
assert _fmt_age(now, now - timedelta(hours=3)) == "3h"
assert _fmt_age(now, now - timedelta(days=2)) == "2d"
def test_md_to_html_headers_and_bold():
src = "## Section one\n\nBody text with **bold** word.\n\n## Section two\n\nMore."
out = _md_to_html(src)
assert "<h3>Section one</h3>" in out
assert "<strong>bold</strong>" in out
assert "<p>" in out
def test_md_to_html_preserves_line_breaks_inside_block():
src = "Line one\nLine two"
out = _md_to_html(src)
assert "Line one<br>Line two" in out

View file

@ -0,0 +1,40 @@
"""default.toml and portfolio.toml must load cleanly into the expected shapes."""
from __future__ import annotations
import pytest
pytest.importorskip("pydantic_settings")
from pathlib import Path
from app.config import load_feeds, load_groups, load_presets
ROOT = Path(__file__).resolve().parent.parent
DEFAULT = ROOT / "config" / "default.toml"
PORTFOLIO = ROOT / "config" / "portfolio.toml"
def test_default_groups_present():
g = load_groups(DEFAULT, PORTFOLIO)
for expected in ("equity", "mag7", "rates", "macro", "commodities", "fx", "pie"):
assert expected in g, f"missing group: {expected}"
# Every item is a 3-tuple of strings.
for items in g.values():
for sym, lab, note in items:
assert isinstance(sym, str) and sym
assert isinstance(lab, str)
assert isinstance(note, str)
def test_default_feeds_present():
f = load_feeds(DEFAULT, PORTFOLIO)
for cat in ("markets", "world", "energy", "asia"):
assert cat in f, f"missing feed category: {cat}"
assert all(name and url for name, url in f[cat])
def test_presets_thesis_present():
p = load_presets(DEFAULT, PORTFOLIO)
assert "thesis" in p
assert "hormuz" in p["thesis"]

View file

@ -0,0 +1,47 @@
"""Pure-function tests for app.services.market."""
from __future__ import annotations
import pytest
pytest.importorskip("httpx")
pytest.importorskip("pydantic_settings")
from datetime import datetime, timezone
from app.services.market import _pct, _parse_date, _yahoo_range_covering, parse_symbol
def test_pct_basic():
assert _pct(100, 110) == 10.0
assert _pct(100, 90) == -10.0
def test_pct_handles_none_and_zero():
assert _pct(None, 10) is None
assert _pct(10, None) is None
assert _pct(0, 5) is None
def test_parse_date():
assert _parse_date("2026-03-04") == datetime(2026, 3, 4)
def test_yahoo_range_covering_picks_smallest():
today = datetime.now(timezone.utc).date()
# anchor 100 days ago → 1y range is enough
short = (today.replace(year=today.year)).isoformat()
assert _yahoo_range_covering(None) == "1y"
assert _yahoo_range_covering("2026-01-01") == "1y"
def test_parse_symbol_routes_by_prefix():
fn, ident = parse_symbol("FRED:DFF")
assert ident == "DFF"
assert fn.__name__ == "fetch_fred"
fn2, ident2 = parse_symbol("AAPL")
assert ident2 == "AAPL"
assert fn2.__name__ == "fetch_yahoo"
# Unknown prefix falls through to yahoo.
fn3, ident3 = parse_symbol("UNKNOWN:XYZ")
assert ident3 == "UNKNOWN:XYZ"
assert fn3.__name__ == "fetch_yahoo"

View file

@ -0,0 +1,53 @@
"""Pure-function tests for app.services.news."""
from __future__ import annotations
import pytest
pytest.importorskip("httpx")
from datetime import datetime, timezone
from pathlib import Path
from app.services.news import Headline, _parse_date, dedupe, parse_feed
FIXTURE = Path(__file__).parent / "fixtures" / "rss_sample.xml"
def test_parse_feed_returns_real_items_only():
items = parse_feed("Sample", "world", FIXTURE.read_bytes())
titles = [h.title for h in items]
assert "Brent crude jumps on Hormuz uncertainty" in titles
assert "Fed signals caution as inflation re-accelerates" in titles
# Empty-title row is dropped.
assert all(t for t in titles)
def test_parse_feed_uses_rfc822_dates():
items = parse_feed("Sample", "world", FIXTURE.read_bytes())
when = items[0].when
assert when.tzinfo is not None
assert when.year == 2026
def test_parse_date_atom_iso():
d = _parse_date("2026-05-15T12:34:56Z")
assert d == datetime(2026, 5, 15, 12, 34, 56, tzinfo=timezone.utc)
def test_headline_fingerprint_is_normalised():
h1 = Headline(datetime.now(timezone.utc), "S1", "c", " Hello WORLD ", "u1")
h2 = Headline(datetime.now(timezone.utc), "S2", "c", "hello world", "u2")
assert h1.fingerprint == h2.fingerprint
def test_dedupe_keeps_first_by_url_or_title():
t = datetime.now(timezone.utc)
hs = [
Headline(t, "A", "c", "Same headline", "https://a.example/1"),
Headline(t, "B", "c", "Same headline", "https://b.example/2"), # title dupe
Headline(t, "C", "c", "Other", "https://a.example/1"), # url dupe
Headline(t, "D", "c", "Fresh", "https://d.example"),
]
out = dedupe(hs)
assert [h.source for h in out] == ["A", "D"]

View file

@ -0,0 +1,45 @@
"""build_user_prompt is the heart of the AI log — verify shape."""
from __future__ import annotations
import pytest
pytest.importorskip("httpx")
pytest.importorskip("tenacity")
pytest.importorskip("pydantic_settings")
from datetime import datetime, timezone
from app.services.openrouter import SYSTEM_PROMPT, build_user_prompt
def test_system_prompt_has_voice_anchors():
# Tripwires for prompt regressions.
for marker in ["Objective", "Lens", "Discipline", "watch list"]:
assert marker in SYSTEM_PROMPT
def test_build_user_prompt_includes_anchor_and_reference():
out = build_user_prompt(
today=datetime(2026, 5, 15, tzinfo=timezone.utc),
anchor="2026-03-04",
quotes_by_group={"equity": [{"symbol": "^GSPC", "label": "S&P 500"}]},
headlines_by_bucket={"world": [{"when": "2026-05-15T10:00", "source": "BBC", "title": "x"}]},
reference_line="S&P 7501 · VIX 18",
)
assert "2026-05-15" in out
assert "Anchor reference date: 2026-03-04" in out
assert "S&P 7501" in out
assert "WORLD" in out
assert "^GSPC" in out
def test_build_user_prompt_omits_empty_buckets():
out = build_user_prompt(
today=datetime(2026, 5, 15, tzinfo=timezone.utc),
anchor=None,
quotes_by_group={},
headlines_by_bucket={"world": [], "tech": [{"when": "2026-05-15T10:00",
"source": "X", "title": "AI thing"}]},
)
assert "TECH" in out
assert "WORLD" not in out