read.markets/app/main.py
Giorgio Gilestro 6e7f57c6b2 phase G: data minimisation + passwordless auth + DeepSeek-first LLM
Server no longer holds portfolios. Holdings live in the browser
(localStorage); the server publishes an anonymous ticker_universe and a
gzipped /api/universe payload identical for every authenticated user, so
access patterns can't betray which tickers a user holds. AI commentary
is generated ephemerally from the browser-supplied pie and the cost
ledger row records no positions. Migrations 0009-0011 added the
universe table and dropped positions / portfolio_snapshots /
portfolios.

Authentication is now e-mail OTP only. Migration 0010 dropped
password_hash and email_verified (every active session is by
construction proof of email control). The /signup endpoint is gone;
signup and login share a single email-entry page. Email rendering is
HTML+plain-text multipart with a shared brand palette (app/branding.py)
asserted in sync with the CSS by a drift-detection test.

LLM provider defaults to DeepSeek-direct (cheaper, api.deepseek.com)
with OpenRouter as automatic fallback if DeepSeek fails. ai_log_job and
indicator_summary_job now iterate the two tones (NOVICE, INTERMEDIATE)
per cycle so the dashboard's tone toggle is instant; PROMPT_VERSION
bumped to 6 with an educational anti-TA / anti-gambling stance baked
into _CORE. NOVICE mode renders a curated glossary inline (CBOE VIX,
yield curve, HY OAS, etc.) with JS-positioned tooltips that survive
viewport edges and sticky bars. Model name and tokens hidden from the
user UI; still recorded in StrategicLog.model and AICall for admin.

Layout adds a sticky top nav, a sticky bottom markets bar (one chip per
exchange with status LED + headline index + 1d change), and
Phase H feedback reporting is queued in tasks/todo.md.

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

79 lines
2.6 KiB
Python

"""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.middleware.gzip import GZipMiddleware
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 auth as auth_router
from app.routers import pages as pages_router
from app.routers import universe as universe_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,
)
# Gzip responses ≥500 bytes when the client sends Accept-Encoding: gzip.
# The Phase G universe payload is repetitive JSON that gzips to ~25-30%
# of raw size; compression is mandatory for that endpoint to be cheap.
app.add_middleware(GZipMiddleware, minimum_size=500)
app.mount(
"/static",
StaticFiles(directory=str(APP_DIR / "static")),
name="static",
)
app.include_router(auth_router.router, tags=["auth"])
app.include_router(api_router.router, prefix="/api", tags=["api"])
app.include_router(universe_router.router, prefix="/api", tags=["universe"])
app.include_router(pages_router.router, tags=["pages"])