commit a10409c02bf597512be28cdbe2274d4666814cce Author: Giorgio Gilestro Date: Fri May 15 21:56:10 2026 +0100 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) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..03caace --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..889bd4d --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0c0c5ae --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0fd7ec9 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..043161c --- /dev/null +++ b/README.md @@ -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). diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..ab9c688 --- /dev/null +++ b/alembic.ini @@ -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 diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..9918b4f --- /dev/null +++ b/alembic/env.py @@ -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()) diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/alembic/script.py.mako @@ -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"} diff --git a/alembic/versions/0001_initial_schema.py b/alembic/versions/0001_initial_schema.py new file mode 100644 index 0000000..03b0657 --- /dev/null +++ b/alembic/versions/0001_initial_schema.py @@ -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) diff --git a/alembic/versions/0002_position_name.py b/alembic/versions/0002_position_name.py new file mode 100644 index 0000000..41d8aeb --- /dev/null +++ b/alembic/versions/0002_position_name.py @@ -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") diff --git a/alembic/versions/0003_log_tone_analysis.py b/alembic/versions/0003_log_tone_analysis.py new file mode 100644 index 0000000..a01fd9d --- /dev/null +++ b/alembic/versions/0003_log_tone_analysis.py @@ -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") diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..c3b85ba --- /dev/null +++ b/app/auth.py @@ -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", + ) diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..2d8f97f --- /dev/null +++ b/app/config.py @@ -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), + ) diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..646959d --- /dev/null +++ b/app/db.py @@ -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 diff --git a/app/jobs/__init__.py b/app/jobs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/jobs/_helpers.py b/app/jobs/_helpers.py new file mode 100644 index 0000000..211110f --- /dev/null +++ b/app/jobs/_helpers.py @@ -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() diff --git a/app/jobs/ai_log_job.py b/app/jobs/ai_log_job.py new file mode 100644 index 0000000..4524bfe --- /dev/null +++ b/app/jobs/ai_log_job.py @@ -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()) diff --git a/app/jobs/market_job.py b/app/jobs/market_job.py new file mode 100644 index 0000000..a78a1e1 --- /dev/null +++ b/app/jobs/market_job.py @@ -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()) diff --git a/app/jobs/news_job.py b/app/jobs/news_job.py new file mode 100644 index 0000000..a966239 --- /dev/null +++ b/app/jobs/news_job.py @@ -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()) diff --git a/app/jobs/portfolio_job.py b/app/jobs/portfolio_job.py new file mode 100644 index 0000000..190b2dc --- /dev/null +++ b/app/jobs/portfolio_job.py @@ -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()) diff --git a/app/jobs/rollup_job.py b/app/jobs/rollup_job.py new file mode 100644 index 0000000..69f0282 --- /dev/null +++ b/app/jobs/rollup_job.py @@ -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()) diff --git a/app/logging.py b/app/logging.py new file mode 100644 index 0000000..eca3154 --- /dev/null +++ b/app/logging.py @@ -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) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..0bd9eab --- /dev/null +++ b/app/main.py @@ -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"]) diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..0a1d7ad --- /dev/null +++ b/app/models.py @@ -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"),) diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routers/api.py b/app/routers/api.py new file mode 100644 index 0000000..82a818f --- /dev/null +++ b/app/routers/api.py @@ -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"{m.group(2).strip()}" + out = _MD_HEADER.sub(header_sub, text) + out = _MD_BOLD.sub(r"\1", out) + # Convert blank-line-separated paragraphs to

blocks. + blocks = re.split(r"\n\s*\n", out.strip()) + rendered: list[str] = [] + for b in blocks: + if b.startswith("{b.strip().replace(chr(10), '
')}

") + 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, + } diff --git a/app/routers/pages.py b/app/routers/pages.py new file mode 100644 index 0000000..2a7ec11 --- /dev/null +++ b/app/routers/pages.py @@ -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)) diff --git a/app/scheduler_main.py b/app/scheduler_main.py new file mode 100644 index 0000000..03ecd70 --- /dev/null +++ b/app/scheduler_main.py @@ -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()) diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..15cf535 --- /dev/null +++ b/app/schemas.py @@ -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] = [] diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/feeds_bootstrap.py b/app/services/feeds_bootstrap.py new file mode 100644 index 0000000..89e7fe2 --- /dev/null +++ b/app/services/feeds_bootstrap.py @@ -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 diff --git a/app/services/market.py b/app/services/market.py new file mode 100644 index 0000000..ef73f36 --- /dev/null +++ b/app/services/market.py @@ -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 diff --git a/app/services/news.py b/app/services/news.py new file mode 100644 index 0000000..1a240c4 --- /dev/null +++ b/app/services/news.py @@ -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) diff --git a/app/services/openrouter.py b/app/services/openrouter.py new file mode 100644 index 0000000..68faacd --- /dev/null +++ b/app/services/openrouter.py @@ -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] diff --git a/app/services/trading212.py b/app/services/trading212.py new file mode 100644 index 0000000..2f142c4 --- /dev/null +++ b/app/services/trading212.py @@ -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") diff --git a/app/static/css/cassandra.css b/app/static/css/cassandra.css new file mode 100644 index 0000000..2e6269f --- /dev/null +++ b/app/static/css/cassandra.css @@ -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); } diff --git a/app/static/js/chat.js b/app/static/js/chat.js new file mode 100644 index 0000000..0fba827 --- /dev/null +++ b/app/static/js/chat.js @@ -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, '&') + .replace(//g, '>'); + } + + 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(); + } + }); +})(); diff --git a/app/static/js/htmx.min.js b/app/static/js/htmx.min.js new file mode 100644 index 0000000..59937d7 --- /dev/null +++ b/app/static/js/htmx.min.js @@ -0,0 +1 @@ +var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true},parseInterval:null,_:null,version:"2.0.4"};Q.onLoad=j;Q.process=kt;Q.on=ye;Q.off=be;Q.trigger=he;Q.ajax=Rn;Q.find=u;Q.findAll=x;Q.closest=g;Q.remove=z;Q.addClass=K;Q.removeClass=G;Q.toggleClass=W;Q.takeClass=Z;Q.swap=$e;Q.defineExtension=Fn;Q.removeExtension=Bn;Q.logAll=V;Q.logNone=_;Q.parseInterval=d;Q._=e;const n={addTriggerHandler:St,bodyContains:le,canAccessLocalStorage:B,findThisElement:Se,filterValues:hn,swap:$e,hasAttribute:s,getAttributeValue:te,getClosestAttributeValue:re,getClosestMatch:o,getExpressionVars:En,getHeaders:fn,getInputValues:cn,getInternalData:ie,getSwapSpecification:gn,getTriggerSpecs:st,getTarget:Ee,makeFragment:P,mergeObjects:ce,makeSettleInfo:xn,oobSwap:He,querySelectorExt:ae,settleImmediately:Kt,shouldCancel:ht,triggerEvent:he,triggerErrorEvent:fe,withExtensions:Ft};const r=["get","post","put","delete","patch"];const H=r.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function c(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ne(){return document}function m(e,t){return e.getRootNode?e.getRootNode({composed:t}):ne()}function o(e,t){while(e&&!t(e)){e=c(e)}return e||null}function i(e,t,n){const r=te(t,n);const o=te(t,"hx-disinherit");var i=te(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function re(t,n){let r=null;o(t,function(e){return!!(r=i(t,ue(e),n))});if(r!=="unset"){return r}}function h(e,t){const n=e instanceof Element&&(e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector);return!!n&&n.call(e,t)}function T(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function q(e){const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function A(e){const t=ne().createElement("script");se(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function N(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function I(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(N(e)){const t=A(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){O(e)}finally{e.remove()}}})}function P(e){const t=e.replace(/]*)?>[\s\S]*?<\/head>/i,"");const n=T(t);let r;if(n==="html"){r=new DocumentFragment;const i=q(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=q(t);L(r,i.body);r.title=i.title}else{const i=q('");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){I(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function oe(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return typeof e==="function"}function D(e){return t(e,"Object")}function ie(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function M(t){const n=[];if(t){for(let e=0;e=0}function le(e){return e.getRootNode({composed:true})===document}function F(e){return e.trim().split(/\s+/)}function ce(e,t){for(const n in t){if(t.hasOwnProperty(n)){e[n]=t[n]}}return e}function S(e){try{return JSON.parse(e)}catch(e){O(e);return null}}function B(){const e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function U(t){try{const e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function e(e){return vn(ne().body,function(){return eval(e)})}function j(t){const e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function V(){Q.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function _(){Q.logger=null}function u(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return u(ne(),e)}}function x(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return x(ne(),e)}}function E(){return window}function z(e,t){e=y(e);if(t){E().setTimeout(function(){z(e);e=null},t)}else{c(e).removeChild(e)}}function ue(e){return e instanceof Element?e:null}function $(e){return e instanceof HTMLElement?e:null}function J(e){return typeof e==="string"?e:null}function f(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function K(e,t,n){e=ue(y(e));if(!e){return}if(n){E().setTimeout(function(){K(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function G(e,t,n){let r=ue(y(e));if(!r){return}if(n){E().setTimeout(function(){G(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function W(e,t){e=y(e);e.classList.toggle(t)}function Z(e,t){e=y(e);se(e.parentElement.children,function(e){G(e,t)});K(ue(e),t)}function g(e,t){e=ue(y(e));if(e&&e.closest){return e.closest(t)}else{do{if(e==null||h(e,t)){return e}}while(e=e&&ue(c(e)));return null}}function l(e,t){return e.substring(0,t.length)===t}function Y(e,t){return e.substring(e.length-t.length)===t}function ge(e){const t=e.trim();if(l(t,"<")&&Y(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function p(t,r,n){if(r.indexOf("global ")===0){return p(t,r.slice(7),true)}t=y(t);const o=[];{let t=0;let n=0;for(let e=0;e"){t--}}if(n0){const r=ge(o.shift());let e;if(r.indexOf("closest ")===0){e=g(ue(t),ge(r.substr(8)))}else if(r.indexOf("find ")===0){e=u(f(t),ge(r.substr(5)))}else if(r==="next"||r==="nextElementSibling"){e=ue(t).nextElementSibling}else if(r.indexOf("next ")===0){e=pe(t,ge(r.substr(5)),!!n)}else if(r==="previous"||r==="previousElementSibling"){e=ue(t).previousElementSibling}else if(r.indexOf("previous ")===0){e=me(t,ge(r.substr(9)),!!n)}else if(r==="document"){e=document}else if(r==="window"){e=window}else if(r==="body"){e=document.body}else if(r==="root"){e=m(t,!!n)}else if(r==="host"){e=t.getRootNode().host}else{s.push(r)}if(e){i.push(e)}}if(s.length>0){const e=s.join(",");const c=f(m(t,!!n));i.push(...M(c.querySelectorAll(e)))}return i}var pe=function(t,e,n){const r=f(m(t,n)).querySelectorAll(e);for(let e=0;e=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function ae(e,t){if(typeof e!=="string"){return p(e,t)[0]}else{return p(ne().body,e)[0]}}function y(e,t){if(typeof e==="string"){return u(f(t)||document,e)}else{return e}}function xe(e,t,n,r){if(k(t)){return{target:ne().body,event:J(e),listener:t,options:n}}else{return{target:y(e),event:J(t),listener:n,options:r}}}function ye(t,n,r,o){Vn(function(){const e=xe(t,n,r,o);e.target.addEventListener(e.event,e.listener,e.options)});const e=k(n);return e?n:r}function be(t,n,r){Vn(function(){const e=xe(t,n,r);e.target.removeEventListener(e.event,e.listener)});return k(n)?n:r}const ve=ne().createElement("output");function we(e,t){const n=re(e,t);if(n){if(n==="this"){return[Se(e,t)]}else{const r=p(e,n);if(r.length===0){O('The selector "'+n+'" on '+t+" returned no matches!");return[ve]}else{return r}}}}function Se(e,t){return ue(o(e,function(e){return te(ue(e),t)!=null}))}function Ee(e){const t=re(e,"hx-target");if(t){if(t==="this"){return Se(e,"hx-target")}else{return ae(e,t)}}else{const n=ie(e);if(n.boosted){return ne().body}else{return e}}}function Ce(t){const n=Q.config.attributesToSettle;for(let e=0;e0){s=e.substring(0,e.indexOf(":"));n=e.substring(e.indexOf(":")+1)}else{s=e}o.removeAttribute("hx-swap-oob");o.removeAttribute("data-hx-swap-oob");const r=p(t,n,false);if(r){se(r,function(e){let t;const n=o.cloneNode(true);t=ne().createDocumentFragment();t.appendChild(n);if(!Re(s,e)){t=f(n)}const r={shouldSwap:true,target:e,fragment:t};if(!he(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){qe(t);_e(s,e,e,t,i);Te()}se(i.elts,function(e){he(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);fe(ne().body,"htmx:oobErrorNoTarget",{content:o})}return e}function Te(){const e=u("#--htmx-preserve-pantry--");if(e){for(const t of[...e.children]){const n=u("#"+t.id);n.parentNode.moveBefore(t,n);n.remove()}e.remove()}}function qe(e){se(x(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=te(e,"id");const n=ne().getElementById(t);if(n!=null){if(e.moveBefore){let e=u("#--htmx-preserve-pantry--");if(e==null){ne().body.insertAdjacentHTML("afterend","
");e=u("#--htmx-preserve-pantry--")}e.moveBefore(n,null)}else{e.parentNode.replaceChild(n,e)}}})}function Le(l,e,c){se(e.querySelectorAll("[id]"),function(t){const n=ee(t,"id");if(n&&n.length>0){const r=n.replace("'","\\'");const o=t.tagName.replace(":","\\:");const e=f(l);const i=e&&e.querySelector(o+"[id='"+r+"']");if(i&&i!==e){const s=t.cloneNode();Oe(t,i);c.tasks.push(function(){Oe(t,s)})}}})}function Ae(e){return function(){G(e,Q.config.addedClass);kt(ue(e));Ne(f(e));he(e,"htmx:load")}}function Ne(e){const t="[autofocus]";const n=$(h(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function a(e,t,n,r){Le(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;K(ue(o),Q.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Ae(o))}}}function Ie(e,t){let n=0;while(n0}function $e(e,t,r,o){if(!o){o={}}e=y(e);const i=o.contextElement?m(o.contextElement,false):ne();const n=document.activeElement;let s={};try{s={elt:n,start:n?n.selectionStart:null,end:n?n.selectionEnd:null}}catch(e){}const l=xn(e);if(r.swapStyle==="textContent"){e.textContent=t}else{let n=P(t);l.title=n.title;if(o.selectOOB){const u=o.selectOOB.split(",");for(let t=0;t0){E().setTimeout(c,r.settleDelay)}else{c()}}function Je(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=S(r);for(const i in o){if(o.hasOwnProperty(i)){let e=o[i];if(D(e)){n=e.target!==undefined?e.target:n}else{e={value:e}}he(n,i,e)}}}else{const s=r.split(",");for(let e=0;e0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=vn(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){fe(ne().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(tt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function C(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function rt(e){let t;if(e.length>0&&Ye.test(e[0])){e.shift();t=C(e,Qe).trim();e.shift()}else{t=C(e,v)}return t}const ot="input, textarea, select";function it(e,t,n){const r=[];const o=et(t);do{C(o,w);const l=o.length;const c=C(o,/[,\[\s]/);if(c!==""){if(c==="every"){const u={trigger:"every"};C(o,w);u.pollInterval=d(C(o,/[,\[\s]/));C(o,w);var i=nt(e,o,"event");if(i){u.eventFilter=i}r.push(u)}else{const a={trigger:c};var i=nt(e,o,"event");if(i){a.eventFilter=i}C(o,w);while(o.length>0&&o[0]!==","){const f=o.shift();if(f==="changed"){a.changed=true}else if(f==="once"){a.once=true}else if(f==="consume"){a.consume=true}else if(f==="delay"&&o[0]===":"){o.shift();a.delay=d(C(o,v))}else if(f==="from"&&o[0]===":"){o.shift();if(Ye.test(o[0])){var s=rt(o)}else{var s=C(o,v);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const h=rt(o);if(h.length>0){s+=" "+h}}}a.from=s}else if(f==="target"&&o[0]===":"){o.shift();a.target=rt(o)}else if(f==="throttle"&&o[0]===":"){o.shift();a.throttle=d(C(o,v))}else if(f==="queue"&&o[0]===":"){o.shift();a.queue=C(o,v)}else if(f==="root"&&o[0]===":"){o.shift();a[f]=rt(o)}else if(f==="threshold"&&o[0]===":"){o.shift();a[f]=C(o,v)}else{fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}r.push(a)}}if(o.length===l){fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function st(e){const t=te(e,"hx-trigger");let n=[];if(t){const r=Q.config.triggerSpecsCache;n=r&&r[t]||it(e,t,r)}if(n.length>0){return n}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,ot)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function lt(e){ie(e).cancelled=true}function ct(e,t,n){const r=ie(e);r.timeout=E().setTimeout(function(){if(le(e)&&r.cancelled!==true){if(!gt(n,e,Mt("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ct(e,t,n)}},n.pollInterval)}function ut(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function at(e){return g(e,Q.config.disableSelector)}function ft(t,n,e){if(t instanceof HTMLAnchorElement&&ut(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"&&String(ee(t,"method")).toLowerCase()!=="dialog"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=ee(t,"href")}else{const i=ee(t,"method");r=i?i.toLowerCase():"get";o=ee(t,"action");if(o==null||o===""){o=ne().location.href}if(r==="get"&&o.includes("?")){o=o.replace(/\?[^#]+/,"")}}e.forEach(function(e){pt(t,function(e,t){const n=ue(e);if(at(n)){b(n);return}de(r,o,n,t)},n,e,true)})}}function ht(e,t){const n=ue(t);if(!n){return false}if(e.type==="submit"||e.type==="click"){if(n.tagName==="FORM"){return true}if(h(n,'input[type="submit"], button')&&(h(n,"[form]")||g(n,"form")!==null)){return true}if(n instanceof HTMLAnchorElement&&n.href&&(n.getAttribute("href")==="#"||n.getAttribute("href").indexOf("#")!==0)){return true}}return false}function dt(e,t){return ie(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function gt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;fe(ne().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function pt(l,c,e,u,a){const f=ie(l);let t;if(u.from){t=p(l,u.from)}else{t=[l]}if(u.changed){if(!("lastValue"in f)){f.lastValue=new WeakMap}t.forEach(function(e){if(!f.lastValue.has(u)){f.lastValue.set(u,new WeakMap)}f.lastValue.get(u).set(e,e.value)})}se(t,function(i){const s=function(e){if(!le(l)){i.removeEventListener(u.trigger,s);return}if(dt(l,e)){return}if(a||ht(e,l)){e.preventDefault()}if(gt(u,l,e)){return}const t=ie(e);t.triggerSpec=u;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(l)<0){t.handledFor.push(l);if(u.consume){e.stopPropagation()}if(u.target&&e.target){if(!h(ue(e.target),u.target)){return}}if(u.once){if(f.triggeredOnce){return}else{f.triggeredOnce=true}}if(u.changed){const n=event.target;const r=n.value;const o=f.lastValue.get(u);if(o.has(n)&&o.get(n)===r){return}o.set(n,r)}if(f.delayed){clearTimeout(f.delayed)}if(f.throttle){return}if(u.throttle>0){if(!f.throttle){he(l,"htmx:trigger");c(l,e);f.throttle=E().setTimeout(function(){f.throttle=null},u.throttle)}}else if(u.delay>0){f.delayed=E().setTimeout(function(){he(l,"htmx:trigger");c(l,e)},u.delay)}else{he(l,"htmx:trigger");c(l,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:u.trigger,listener:s,on:i});i.addEventListener(u.trigger,s)})}let mt=false;let xt=null;function yt(){if(!xt){xt=function(){mt=true};window.addEventListener("scroll",xt);window.addEventListener("resize",xt);setInterval(function(){if(mt){mt=false;se(ne().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){bt(e)})}},200)}}function bt(e){if(!s(e,"data-hx-revealed")&&X(e)){e.setAttribute("data-hx-revealed","true");const t=ie(e);if(t.initHash){he(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){he(e,"revealed")},{once:true})}}}function vt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;he(e,"htmx:trigger");t(e)}};if(r>0){E().setTimeout(o,r)}else{o()}}function wt(t,n,e){let i=false;se(r,function(r){if(s(t,"hx-"+r)){const o=te(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){St(t,e,n,function(e,t){const n=ue(e);if(g(n,Q.config.disableSelector)){b(n);return}de(r,o,n,t)})})}});return i}function St(r,e,t,n){if(e.trigger==="revealed"){yt();pt(r,n,t,e);bt(ue(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=ae(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e0){t.polling=true;ct(ue(r),n,e)}else{pt(r,n,t,e)}}function Et(e){const t=ue(e);if(!t){return false}const n=t.attributes;for(let e=0;e", "+e).join(""));return o}else{return[]}}function Tt(e){const t=g(ue(e.target),"button, input[type='submit']");const n=Lt(e);if(n){n.lastButtonClicked=t}}function qt(e){const t=Lt(e);if(t){t.lastButtonClicked=null}}function Lt(e){const t=g(ue(e.target),"button, input[type='submit']");if(!t){return}const n=y("#"+ee(t,"form"),t.getRootNode())||g(t,"form");if(!n){return}return ie(n)}function At(e){e.addEventListener("click",Tt);e.addEventListener("focusin",Tt);e.addEventListener("focusout",qt)}function Nt(t,e,n){const r=ie(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){vn(t,function(){if(at(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function It(t){ke(t);for(let e=0;eQ.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(ne().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Vt(t){if(!B()){return null}t=U(t);const n=S(localStorage.getItem("htmx-history-cache"))||[];for(let e=0;e=200&&this.status<400){he(ne().body,"htmx:historyCacheMissLoad",i);const e=P(this.response);const t=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;const n=Ut();const r=xn(n);kn(e.title);qe(e);Ve(n,t,r);Te();Kt(r.tasks);Bt=o;he(ne().body,"htmx:historyRestore",{path:o,cacheMiss:true,serverResponse:this.response})}else{fe(ne().body,"htmx:historyCacheMissLoadError",i)}};e.send()}function Wt(e){zt();e=e||location.pathname+location.search;const t=Vt(e);if(t){const n=P(t.content);const r=Ut();const o=xn(r);kn(t.title);qe(n);Ve(r,n,o);Te();Kt(o.tasks);E().setTimeout(function(){window.scrollTo(0,t.scroll)},0);Bt=e;he(ne().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{Gt(e)}}}function Zt(e){let t=we(e,"hx-indicator");if(t==null){t=[e]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.classList.add.call(e.classList,Q.config.requestClass)});return t}function Yt(e){let t=we(e,"hx-disabled-elt");if(t==null){t=[]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","");e.setAttribute("data-disabled-by-htmx","")});return t}function Qt(e,t){se(e.concat(t),function(e){const t=ie(e);t.requestCount=(t.requestCount||1)-1});se(e,function(e){const t=ie(e);if(t.requestCount===0){e.classList.remove.call(e.classList,Q.config.requestClass)}});se(t,function(e){const t=ie(e);if(t.requestCount===0){e.removeAttribute("disabled");e.removeAttribute("data-disabled-by-htmx")}})}function en(t,n){for(let e=0;en.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);se(e,e=>r.append(t,e))}}function on(t,n,r,o,i){if(o==null||en(t,o)){return}else{t.push(o)}if(tn(o)){const s=ee(o,"name");let e=o.value;if(o instanceof HTMLSelectElement&&o.multiple){e=M(o.querySelectorAll("option:checked")).map(function(e){return e.value})}if(o instanceof HTMLInputElement&&o.files){e=M(o.files)}nn(s,e,n);if(i){sn(o,r)}}if(o instanceof HTMLFormElement){se(o.elements,function(e){if(t.indexOf(e)>=0){rn(e.name,e.value,n)}else{t.push(e)}if(i){sn(e,r)}});new FormData(o).forEach(function(e,t){if(e instanceof File&&e.name===""){return}nn(t,e,n)})}}function sn(e,t){const n=e;if(n.willValidate){he(n,"htmx:validation:validate");if(!n.checkValidity()){t.push({elt:n,message:n.validationMessage,validity:n.validity});he(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})}}}function ln(n,e){for(const t of e.keys()){n.delete(t)}e.forEach(function(e,t){n.append(t,e)});return n}function cn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=ie(e);if(s.lastButtonClicked&&!le(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||te(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){on(n,o,i,g(e,"form"),l)}on(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){const u=s.lastButtonClicked||e;const a=ee(u,"name");nn(a,u.value,o)}const c=we(e,"hx-include");se(c,function(e){on(n,r,i,ue(e),l);if(!h(e,"form")){se(f(e).querySelectorAll(ot),function(e){on(n,r,i,e,l)})}});ln(r,o);return{errors:i,formData:r,values:An(r)}}function un(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function an(e){e=qn(e);let n="";e.forEach(function(e,t){n=un(n,t,e)});return n}function fn(e,t,n){const r={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":te(t,"id"),"HX-Current-URL":ne().location.href};bn(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(ie(e).boosted){r["HX-Boosted"]="true"}return r}function hn(n,e){const t=re(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){se(t.slice(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;se(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function dn(e){return!!ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function gn(e,t){const n=t||re(e,"hx-swap");const r={swapStyle:ie(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ie(e).boosted&&!dn(e)){r.show="top"}if(n){const s=F(n);if(s.length>0){for(let e=0;e0?o.join(":"):null;r.scroll=u;r.scrollTarget=i}else if(l.indexOf("show:")===0){const a=l.slice(5);var o=a.split(":");const f=o.pop();var i=o.length>0?o.join(":"):null;r.show=f;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const h=l.slice("focus-scroll:".length);r.focusScroll=h=="true"}else if(e==0){r.swapStyle=l}else{O("Unknown modifier in hx-swap: "+l)}}}}return r}function pn(e){return re(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function mn(t,n,r){let o=null;Ft(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(pn(n)){return ln(new FormData,qn(r))}else{return an(r)}}}function xn(e){return{tasks:[],elts:[e]}}function yn(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=ue(ae(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=ue(ae(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function bn(r,e,o,i){if(i==null){i={}}if(r==null){return i}const s=te(r,e);if(s){let e=s.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.slice(11);t=true}else if(e.indexOf("js:")===0){e=e.slice(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=vn(r,function(){return Function("return ("+e+")")()},{})}else{n=S(e)}for(const l in n){if(n.hasOwnProperty(l)){if(i[l]==null){i[l]=n[l]}}}}return bn(ue(c(r)),e,o,i)}function vn(e,t,n){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return n}}function wn(e,t){return bn(e,"hx-vars",true,t)}function Sn(e,t){return bn(e,"hx-vals",false,t)}function En(e){return ce(wn(e),Sn(e))}function Cn(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function On(t){if(t.responseURL&&typeof URL!=="undefined"){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(ne().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function R(e,t){return t.test(e.getAllResponseHeaders())}function Rn(t,n,r){t=t.toLowerCase();if(r){if(r instanceof Element||typeof r==="string"){return de(t,n,null,null,{targetOverride:y(r)||ve,returnPromise:true})}else{let e=y(r.target);if(r.target&&!e||r.source&&!e&&!y(r.source)){e=ve}return de(t,n,y(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:e,swapOverride:r.swap,select:r.select,returnPromise:true})}}else{return de(t,n,null,null,{returnPromise:true})}}function Hn(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function Tn(e,t,n){let r;let o;if(typeof URL==="function"){o=new URL(t,document.location.href);const i=document.location.origin;r=i===o.origin}else{o=t;r=l(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!r){return false}}return he(e,"htmx:validateUrl",ce({url:o,sameHost:r},n))}function qn(e){if(e instanceof FormData)return e;const t=new FormData;for(const n in e){if(e.hasOwnProperty(n)){if(e[n]&&typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"&&!(e[n]instanceof Blob)){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}}return t}function Ln(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function An(o){return new Proxy(o,{get:function(e,t){if(typeof t==="symbol"){const r=Reflect.get(e,t);if(typeof r==="function"){return function(){return r.apply(o,arguments)}}else{return r}}if(t==="toJSON"){return()=>Object.fromEntries(o)}if(t in e){if(typeof e[t]==="function"){return function(){return o[t].apply(o,arguments)}}else{return e[t]}}const n=o.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Ln(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(e&&typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else if(typeof e==="object"&&!(e instanceof Blob)){t.append(n,JSON.stringify(e))}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function de(t,n,r,o,i,D){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=ne().body}const M=i.handler||Dn;const X=i.select||null;if(!le(r)){oe(s);return e}const c=i.targetOverride||ue(Ee(r));if(c==null||c==ve){fe(r,"htmx:targetError",{target:te(r,"hx-target")});oe(l);return e}let u=ie(r);const a=u.lastButtonClicked;if(a){const L=ee(a,"formaction");if(L!=null){n=L}const A=ee(a,"formmethod");if(A!=null){if(A.toLowerCase()!=="dialog"){t=A}}}const f=re(r,"hx-confirm");if(D===undefined){const K=function(e){return de(t,n,r,o,i,!!e)};const G={target:c,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:f};if(he(r,"htmx:confirm",G)===false){oe(s);return e}}let h=r;let d=re(r,"hx-sync");let g=null;let F=false;if(d){const N=d.split(":");const I=N[0].trim();if(I==="this"){h=Se(r,"hx-sync")}else{h=ue(ae(r,I))}d=(N[1]||"drop").trim();u=ie(h);if(d==="drop"&&u.xhr&&u.abortable!==true){oe(s);return e}else if(d==="abort"){if(u.xhr){oe(s);return e}else{F=true}}else if(d==="replace"){he(h,"htmx:abort")}else if(d.indexOf("queue")===0){const W=d.split(" ");g=(W[1]||"last").trim()}}if(u.xhr){if(u.abortable){he(h,"htmx:abort")}else{if(g==null){if(o){const P=ie(o);if(P&&P.triggerSpec&&P.triggerSpec.queue){g=P.triggerSpec.queue}}if(g==null){g="last"}}if(u.queuedRequests==null){u.queuedRequests=[]}if(g==="first"&&u.queuedRequests.length===0){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="all"){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="last"){u.queuedRequests=[];u.queuedRequests.push(function(){de(t,n,r,o,i)})}oe(s);return e}}const p=new XMLHttpRequest;u.xhr=p;u.abortable=F;const m=function(){u.xhr=null;u.abortable=false;if(u.queuedRequests!=null&&u.queuedRequests.length>0){const e=u.queuedRequests.shift();e()}};const B=re(r,"hx-prompt");if(B){var x=prompt(B);if(x===null||!he(r,"htmx:prompt",{prompt:x,target:c})){oe(s);m();return e}}if(f&&!D){if(!confirm(f)){oe(s);m();return e}}let y=fn(r,c,x);if(t!=="get"&&!pn(r)){y["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){y=ce(y,i.headers)}const U=cn(r,t);let b=U.errors;const j=U.formData;if(i.values){ln(j,qn(i.values))}const V=qn(En(r));const v=ln(j,V);let w=hn(v,r);if(Q.config.getCacheBusterParam&&t==="get"){w.set("org.htmx.cache-buster",ee(c,"id")||"true")}if(n==null||n===""){n=ne().location.href}const S=bn(r,"hx-request");const _=ie(r).boosted;let E=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:_,useUrlParams:E,formData:w,parameters:An(w),unfilteredFormData:v,unfilteredParameters:An(v),headers:y,target:c,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Q.config.withCredentials,timeout:i.timeout||S.timeout||Q.config.timeout,path:n,triggeringEvent:o};if(!he(r,"htmx:configRequest",C)){oe(s);m();return e}n=C.path;t=C.verb;y=C.headers;w=qn(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){he(r,"htmx:validation:halted",C);oe(s);m();return e}const z=n.split("#");const $=z[0];const O=z[1];let R=n;if(E){R=$;const Z=!w.keys().next().done;if(Z){if(R.indexOf("?")<0){R+="?"}else{R+="&"}R+=an(w);if(O){R+="#"+O}}}if(!Tn(r,R,C)){fe(r,"htmx:invalidPath",C);oe(l);return e}p.open(t.toUpperCase(),R,true);p.overrideMimeType("text/html");p.withCredentials=C.withCredentials;p.timeout=C.timeout;if(S.noHeaders){}else{for(const k in y){if(y.hasOwnProperty(k)){const Y=y[k];Cn(p,k,Y)}}}const H={xhr:p,target:c,requestConfig:C,etc:i,boosted:_,select:X,pathInfo:{requestPath:n,finalRequestPath:R,responsePath:null,anchor:O}};p.onload=function(){try{const t=Hn(r);H.pathInfo.responsePath=On(p);M(r,H);if(H.keepIndicators!==true){Qt(T,q)}he(r,"htmx:afterRequest",H);he(r,"htmx:afterOnLoad",H);if(!le(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(le(n)){e=n}}if(e){he(e,"htmx:afterRequest",H);he(e,"htmx:afterOnLoad",H)}}oe(s);m()}catch(e){fe(r,"htmx:onLoadError",ce({error:e},H));throw e}};p.onerror=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendError",H);oe(l);m()};p.onabort=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendAbort",H);oe(l);m()};p.ontimeout=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:timeout",H);oe(l);m()};if(!he(r,"htmx:beforeRequest",H)){oe(s);m();return e}var T=Zt(r);var q=Yt(r);se(["loadstart","loadend","progress","abort"],function(t){se([p,p.upload],function(e){e.addEventListener(t,function(e){he(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});he(r,"htmx:beforeSend",H);const J=E?null:mn(p,r,w);p.send(J);return e}function Nn(e,t){const n=t.xhr;let r=null;let o=null;if(R(n,/HX-Push:/i)){r=n.getResponseHeader("HX-Push");o="push"}else if(R(n,/HX-Push-Url:/i)){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(R(n,/HX-Replace-Url:/i)){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;const l=re(e,"hx-push-url");const c=re(e,"hx-replace-url");const u=ie(e).boosted;let a=null;let f=null;if(l){a="push";f=l}else if(c){a="replace";f=c}else if(u){a="push";f=s||i}if(f){if(f==="false"){return{}}if(f==="true"){f=s||i}if(t.pathInfo.anchor&&f.indexOf("#")===-1){f=f+"#"+t.pathInfo.anchor}return{type:a,path:f}}else{return{}}}function In(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function Pn(e){for(var t=0;t0){E().setTimeout(e,x.swapDelay)}else{e()}}if(f){fe(o,"htmx:responseError",ce({error:"Response Status Error Code "+s.status+" from "+i.pathInfo.requestPath},i))}}const Mn={};function Xn(){return{init:function(e){return null},getSelectors:function(){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,n){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,n,r){return false},encodeParameters:function(e,t,n){return null}}}function Fn(e,t){if(t.init){t.init(n)}Mn[e]=ce(Xn(),t)}function Bn(e){delete Mn[e]}function Un(e,n,r){if(n==undefined){n=[]}if(e==undefined){return n}if(r==undefined){r=[]}const t=te(e,"hx-ext");if(t){se(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){r.push(e.slice(7));return}if(r.indexOf(e)<0){const t=Mn[e];if(t&&n.indexOf(t)<0){n.push(t)}}})}return Un(ue(c(e)),n,r)}var jn=false;ne().addEventListener("DOMContentLoaded",function(){jn=true});function Vn(e){if(jn||ne().readyState==="complete"){e()}else{ne().addEventListener("DOMContentLoaded",e)}}function _n(){if(Q.config.includeIndicatorStyles!==false){const e=Q.config.inlineStyleNonce?` nonce="${Q.config.inlineStyleNonce}"`:"";ne().head.insertAdjacentHTML("beforeend"," ."+Q.config.indicatorClass+"{opacity:0} ."+Q.config.requestClass+" ."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ."+Q.config.requestClass+"."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ")}}function zn(){const e=ne().querySelector('meta[name="htmx-config"]');if(e){return S(e.content)}else{return null}}function $n(){const e=zn();if(e){Q.config=ce(Q.config,e)}}Vn(function(){$n();_n();let e=ne().body;kt(e);const t=ne().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.target;const n=ie(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){Wt();se(t,function(e){he(e,"htmx:restored",{document:ne(),triggerEvent:he})})}else{if(n){n(e)}}};E().setTimeout(function(){he(e,"htmx:load",{});e=null},0)});return Q}(); \ No newline at end of file diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..bef4f18 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,71 @@ + + + + + + {% block title %}Cassandra{% endblock %} + {# Apply saved theme before stylesheet renders to avoid a flash. #} + + + + + + +
+
+
Cassandra
+ +
+ + v0.1 · UTC +
+
+ +
+ {% block main %}{% endblock %} +
+ +
+ awaiting status… +
+
+ + diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html new file mode 100644 index 0000000..0d9f451 --- /dev/null +++ b/app/templates/dashboard.html @@ -0,0 +1,77 @@ +{% extends "base.html" %} +{% block title %}Cassandra · Dashboard{% endblock %} + +{% block main %} +
+
+ Indicators + {% if anchor %}anchor {{ anchor }} · {% endif %}ingest hourly @ :05 UTC +
+
+ {% for g in groups %} + + {% endfor %} +
+
+
loading…
+
+
+ + +
+
+ Portfolio + ingest hourly @ :15 UTC +
+
+
loading…
+
+
+ +
+
+ Strategic Log + generated hourly @ :20 UTC +
+
+
awaiting first log…
+
+
+ +
+
+ Flash News + last 24h · ingest hourly @ :10 UTC +
+
+
loading…
+
+
+{% endblock %} diff --git a/app/templates/log.html b/app/templates/log.html new file mode 100644 index 0000000..61ddae7 --- /dev/null +++ b/app/templates/log.html @@ -0,0 +1,55 @@ +{% extends "base.html" %} +{% block title %}Cassandra · Strategic Log{% endblock %} + +{% block main %} +
+
+ Strategic Log Archive + + selected {{ selected_iso }} +  ·  + new logs use: + tone {{ current_tone | lower }} + analysis {{ current_analysis | lower }} + +
+ +
+ + +
+
loading log…
+
+ + +
+
+ +{% endblock %} diff --git a/app/templates/news.html b/app/templates/news.html new file mode 100644 index 0000000..b7d435f --- /dev/null +++ b/app/templates/news.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} +{% block title %}Cassandra · News{% endblock %} + +{% block main %} +
+
+ News Feed + last 24h · ingest hourly @ :10 UTC +
+
+
loading…
+
+
+{% endblock %} diff --git a/app/templates/partials/calendar.html b/app/templates/partials/calendar.html new file mode 100644 index 0000000..5d65654 --- /dev/null +++ b/app/templates/partials/calendar.html @@ -0,0 +1,48 @@ +
+
+ +
{{ month_name }} {{ year }}
+ +
+
+
Mo
+
Tu
+
We
+
Th
+
Fr
+
Sa
+
Su
+ {% for week in grid %} + {% for d in week %} + {% if d is none %} +
+ {% 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) %} + + {% endif %} + {% endfor %} + {% endfor %} +
+
diff --git a/app/templates/partials/indicators.html b/app/templates/partials/indicators.html new file mode 100644 index 0000000..cf0bb55 --- /dev/null +++ b/app/templates/partials/indicators.html @@ -0,0 +1,38 @@ +{% if not quotes %} +
no data yet — scheduler may not have run
+{% else %} + + + + + + + {% if has_anchor %}{% endif %} + + + + + {% for q in quotes %} + + + + + + {% for k in ["1d","1m","1y"] %} + {% set v = q.changes.get(k) if q.changes else None %} + + {% endfor %} + {% if has_anchor %} + {% set va = q.changes.get('anchor') if q.changes else None %} + + {% endif %} + + + {% endfor %} + +
SymbolLabelPriceCcy1d1m1yanchoras-of
{{ q.symbol }}{{ q.label or "" }}{{ q.price | price }}{{ q.currency or "" }} + {% if v is none %}—{% else %}{{ "%+.2f"|format(v) }}%{% endif %} + + {% if va is none %}—{% else %}{{ "%+.2f"|format(va) }}%{% endif %} + {{ q.as_of or "" }}
+{% endif %} diff --git a/app/templates/partials/log.html b/app/templates/partials/log.html new file mode 100644 index 0000000..2257a58 --- /dev/null +++ b/app/templates/partials/log.html @@ -0,0 +1,18 @@ +{% if not log %} +
awaiting first generated log
+{% else %} +
{{ log.content_html | safe }}
+
+
+ {% if log.tone %}tone {{ log.tone | lower }}{% endif %} + {% if log.analysis %}analysis {{ log.analysis | lower }}{% endif %} + {% if log.prompt_version %}prompt v{{ log.prompt_version }}{% endif %} +
+
+ generated {{ log.generated_at.strftime("%Y-%m-%d %H:%M UTC") }} +  ·  model {{ log.model }} + {% if log.prompt_tokens %}  ·  {{ log.prompt_tokens }}↑/{{ log.completion_tokens }}↓ tokens{% endif %} + {% if log.cost_usd is not none %}  ·  ${{ "%.4f"|format(log.cost_usd) }}{% endif %} +
+
+{% endif %} diff --git a/app/templates/partials/news.html b/app/templates/partials/news.html new file mode 100644 index 0000000..2566c85 --- /dev/null +++ b/app/templates/partials/news.html @@ -0,0 +1,16 @@ +{% if not headlines %} +
no headlines in window
+{% else %} +{% for h in headlines %} +
+ {{ h.age }} + {{ h.source }} + {{ h.title }} + {% if h.iso %} + + {% else %} + + {% endif %} +
+{% endfor %} +{% endif %} diff --git a/app/templates/partials/ops_footer.html b/app/templates/partials/ops_footer.html new file mode 100644 index 0000000..1e9f3e5 --- /dev/null +++ b/app/templates/partials/ops_footer.html @@ -0,0 +1,7 @@ +DB +{% for j in jobs %} + + {{ j.name }} + {% if j.last_finished %}· {{ j.age }}{% endif %} + +{% endfor %} diff --git a/app/templates/partials/portfolio.html b/app/templates/partials/portfolio.html new file mode 100644 index 0000000..18061f9 --- /dev/null +++ b/app/templates/partials/portfolio.html @@ -0,0 +1,80 @@ +{% if not portfolios %} +
no portfolio snapshots yet
+{% else %} +{% for p in portfolios %} + {# --- overall block --- #} +
+
+ {{ p.name }} + + {% if p.snapshot_at %}{{ p.snapshot_at.strftime("%Y-%m-%d %H:%M UTC") }}{% else %}—{% endif %} + +
+
+
+
Total
+
{{ p.total_value | money }} {{ p.currency }}
+
+
+
Invested
+
{{ p.invested | money }}
+
+
+
Cash
+
{{ p.cash | money }}
+
+
+
Unrealised P/L
+
+ {{ p.unrealized_ppl | signed }} + {% if p.total_cost and p.unrealized_ppl is not none %} + ({{ "%+.2f"|format(p.unrealized_ppl / p.total_cost * 100) }}%) + {% endif %} +
+
+
+
Realised P/L
+
+ {{ p.realized_ppl | signed }} +
+
+
+
Positions
+
{{ p.positions | length }}
+
+
+
+ + {# --- per-position table --- #} + + + + + + + + + + + + + + {% for pos in p.positions %} + + + + + + + + + + {% endfor %} + +
TickerNameQtyAvgLastP/L%
{{ pos.ticker }}{{ pos.name or "" }}{{ pos.quantity | price }}{{ pos.average_price | price }}{{ pos.current_price | price }} + {{ pos.ppl | signed }} + + {% if pos.ppl_pct is not none %}{{ "%+.2f"|format(pos.ppl_pct) }}%{% else %}—{% endif %} +
+{% endfor %} +{% endif %} diff --git a/app/templates_env.py b/app/templates_env.py new file mode 100644 index 0000000..2951c34 --- /dev/null +++ b/app/templates_env.py @@ -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 diff --git a/config/default.toml b/config/default.toml new file mode 100644 index 0000000..5683727 --- /dev/null +++ b/config/default.toml @@ -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", +] diff --git a/config/portfolio.toml b/config/portfolio.toml new file mode 100644 index 0000000..2e30444 --- /dev/null +++ b/config/portfolio.toml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5a5bbab --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..bf873b2 --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..6d0d19b --- /dev/null +++ b/tests/conftest.py @@ -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") diff --git a/tests/fixtures/rss_sample.xml b/tests/fixtures/rss_sample.xml new file mode 100644 index 0000000..8f69cb2 --- /dev/null +++ b/tests/fixtures/rss_sample.xml @@ -0,0 +1,21 @@ + + + + Sample + + Brent crude jumps on Hormuz uncertainty + https://example.com/a + Fri, 15 May 2026 12:00:00 GMT + + + Fed signals caution as inflation re-accelerates + https://example.com/b + Fri, 15 May 2026 13:30:00 GMT + + + + https://example.com/empty + Fri, 15 May 2026 14:00:00 GMT + + + diff --git a/tests/test_api_helpers.py b/tests/test_api_helpers.py new file mode 100644 index 0000000..a318f38 --- /dev/null +++ b/tests/test_api_helpers.py @@ -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 "

Section one

" in out + assert "bold" in out + assert "

" 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
Line two" in out diff --git a/tests/test_config_loading.py b/tests/test_config_loading.py new file mode 100644 index 0000000..ce2d57d --- /dev/null +++ b/tests/test_config_loading.py @@ -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"] diff --git a/tests/test_market_parsing.py b/tests/test_market_parsing.py new file mode 100644 index 0000000..c5215dc --- /dev/null +++ b/tests/test_market_parsing.py @@ -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" diff --git a/tests/test_news_parsing.py b/tests/test_news_parsing.py new file mode 100644 index 0000000..8eef8ae --- /dev/null +++ b/tests/test_news_parsing.py @@ -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"] diff --git a/tests/test_openrouter_prompt.py b/tests/test_openrouter_prompt.py new file mode 100644 index 0000000..6e80760 --- /dev/null +++ b/tests/test_openrouter_prompt.py @@ -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