initial commit — cassandra v0.1
Containerised macro-strategy dashboard: 4-panel web UI (indicators, portfolio, flash news, AI strategic log), MariaDB store, hourly ingestion jobs, OpenRouter-backed AI analysis. Ports the four prototype scripts in the parent dir (market_pulse, flash_news, trading212, strategic_log) into async services backed by a persistent DB and served via FastAPI + Jinja2 + HTMX. APScheduler runs as a separate compose service for crash-safety and easier restarts. Portfolio composition + position names come live from Trading 212; news per-ticker headlines reuse those names. Tone (NOVICE/INTERMEDIATE/ PRO) and analysis style (DRY/SPECULATIVE) are env-configurable and stored on each log row so historical entries show what produced them. Default model is deepseek/deepseek-v4-flash (overridable via env). Light/dark theme toggle, sans-serif for prose surfaces, monospace for data. Bearer-token auth, OpenRouter monthly cost cap, RSS feeds auto- disabled on consecutive failures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
a10409c02b
61 changed files with 4890 additions and 0 deletions
17
.dockerignore
Normal file
17
.dockerignore
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.dockerignore
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.pytest_cache
|
||||||
|
.ruff_cache
|
||||||
|
.venv
|
||||||
|
venv
|
||||||
|
backup/*.sql.gz
|
||||||
|
backup/*.sql
|
||||||
|
tests/
|
||||||
|
*.md
|
||||||
27
.env.example
Normal file
27
.env.example
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
# Cassandra environment — copy to .env and fill in.
|
||||||
|
# .env is mounted read-only into the containers; never commit real secrets.
|
||||||
|
|
||||||
|
# --- Database (MariaDB) ---
|
||||||
|
MARIADB_ROOT_PASSWORD=changeme-root
|
||||||
|
MARIADB_DATABASE=cassandra
|
||||||
|
MARIADB_USER=cassandra
|
||||||
|
MARIADB_PASSWORD=changeme
|
||||||
|
|
||||||
|
# --- API keys (mirror of the prototype .env) ---
|
||||||
|
API_KEY= # Trading 212 API key
|
||||||
|
SECRET_KEY= # Trading 212 secret
|
||||||
|
FRED_API_KEY= # FRED economic data
|
||||||
|
OPENROUTER_API_KEY= # OpenRouter (AI log generation)
|
||||||
|
|
||||||
|
# --- App ---
|
||||||
|
CASSANDRA_TOKEN= # Bearer token required if set; LAN-only no-auth if empty
|
||||||
|
CASSANDRA_PORT=8000
|
||||||
|
CASSANDRA_BASE_CURRENCY=GBP
|
||||||
|
CASSANDRA_ANCHOR_DATE=2026-03-04 # YYYY-MM-DD; used by market_pulse anchor column
|
||||||
|
CASSANDRA_MOCK=0 # 1 = serve canned fixtures, skip live APIs
|
||||||
|
|
||||||
|
# --- AI log ---
|
||||||
|
OPENROUTER_MODEL=deepseek/deepseek-v4-flash # cheap & fast; swap to anthropic/claude-sonnet-4.6 or anthropic/claude-opus-4.7 for higher quality
|
||||||
|
OPENROUTER_MONTHLY_CAP_USD=20
|
||||||
|
CASSANDRA_TONE=INTERMEDIATE # NOVICE | INTERMEDIATE | PRO
|
||||||
|
CASSANDRA_ANALYSIS=SPECULATIVE # DRY | SPECULATIVE
|
||||||
18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
backup/*.sql
|
||||||
|
backup/*.sql.gz
|
||||||
|
*.egg-info/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
.coverage
|
||||||
|
.mypy_cache/
|
||||||
34
Dockerfile
Normal file
34
Dockerfile
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
FROM python:3.13-slim AS builder
|
||||||
|
|
||||||
|
ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1 \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
COPY pyproject.toml ./
|
||||||
|
COPY app ./app
|
||||||
|
RUN python -m venv /opt/venv \
|
||||||
|
&& /opt/venv/bin/pip install --upgrade pip \
|
||||||
|
&& /opt/venv/bin/pip install .
|
||||||
|
|
||||||
|
FROM python:3.13-slim AS runtime
|
||||||
|
|
||||||
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PATH="/opt/venv/bin:$PATH" \
|
||||||
|
TZ=UTC
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=builder /opt/venv /opt/venv
|
||||||
|
WORKDIR /app
|
||||||
|
COPY app ./app
|
||||||
|
COPY alembic ./alembic
|
||||||
|
COPY alembic.ini ./
|
||||||
|
|
||||||
|
# Default command is the web app; scheduler container overrides via `command:`.
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||||
38
README.md
Normal file
38
README.md
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
# Cassandra
|
||||||
|
|
||||||
|
Containerised macro-strategy dashboard — hourly market data, RSS news, Trading 212 portfolio, and an AI-generated strategic log. Read-only by design.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env # fill in API keys; set CASSANDRA_TOKEN if exposing
|
||||||
|
docker compose up --build # db + app + scheduler + daily backup sidecar
|
||||||
|
open http://localhost:8000/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **app** (FastAPI + Jinja2 + HTMX) — web dashboard on port 8000
|
||||||
|
- **scheduler** (APScheduler) — hourly ingestion jobs (market, news, portfolio, AI log)
|
||||||
|
- **db** (MariaDB 11) — quotes, headlines, portfolio snapshots, strategic logs, job runs
|
||||||
|
- **backup** (sidecar) — daily mariadb-dump to `./backup/`
|
||||||
|
|
||||||
|
See `/home/gg/.claude/plans/ok-i-think-this-tidy-lake.md` for the design plan.
|
||||||
|
|
||||||
|
## Config
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `config/default.toml` | Universal data tables: indicator groups, RSS feeds, keyword presets |
|
||||||
|
| `config/portfolio.toml` | User-specific portfolios (overrides `default.toml`) |
|
||||||
|
| `.env` | Secrets and runtime knobs — mounted read-only into containers |
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
- `GET /` — dashboard
|
||||||
|
- `GET /portfolio/{name}` — portfolio detail
|
||||||
|
- `GET /news` — news feed
|
||||||
|
- `GET /log` — strategic-log archive
|
||||||
|
- `GET /api/health` — job status (last success / failure per job)
|
||||||
|
|
||||||
|
All authenticated routes require `Authorization: Bearer $CASSANDRA_TOKEN` if the env is set; if unset, the app is open (LAN-only mode).
|
||||||
40
alembic.ini
Normal file
40
alembic.ini
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
# Alembic config — DB URL is read from app/config.py at runtime,
|
||||||
|
# not from this file. Keep sqlalchemy.url empty as a marker.
|
||||||
|
[alembic]
|
||||||
|
script_location = alembic
|
||||||
|
prepend_sys_path = .
|
||||||
|
sqlalchemy.url =
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARNING
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARNING
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
66
alembic/env.py
Normal file
66
alembic/env.py
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
"""Alembic environment — DB URL is sourced from app/config.Settings at runtime
|
||||||
|
so we keep secrets out of alembic.ini. Async engine is used in 'online' mode."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy import pool
|
||||||
|
from sqlalchemy.engine import Connection
|
||||||
|
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.db import Base
|
||||||
|
# Import models so that Base.metadata is populated.
|
||||||
|
from app import models # noqa: F401
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# Inject the real DB URL from Settings.
|
||||||
|
config.set_main_option("sqlalchemy.url", get_settings().DATABASE_URL)
|
||||||
|
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
compare_type=True,
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def do_run_migrations(connection: Connection) -> None:
|
||||||
|
context.configure(
|
||||||
|
connection=connection,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
compare_type=True,
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_migrations_online() -> None:
|
||||||
|
connectable = async_engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
async with connectable.connect() as connection:
|
||||||
|
await connection.run_sync(do_run_migrations)
|
||||||
|
await connectable.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
asyncio.run(run_migrations_online())
|
||||||
26
alembic/script.py.mako
Normal file
26
alembic/script.py.mako
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
152
alembic/versions/0001_initial_schema.py
Normal file
152
alembic/versions/0001_initial_schema.py
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
"""initial schema — quotes, headlines, feeds, strategic_logs, ai_calls,
|
||||||
|
portfolios, snapshots, positions, job_runs, quotes_daily.
|
||||||
|
|
||||||
|
Revision ID: 0001
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-05-15
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "0001"
|
||||||
|
down_revision: Union[str, None] = None
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"quotes",
|
||||||
|
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("symbol", sa.String(64), nullable=False),
|
||||||
|
sa.Column("source", sa.String(32), nullable=False),
|
||||||
|
sa.Column("label", sa.String(128), nullable=False, server_default=""),
|
||||||
|
sa.Column("group_name", sa.String(64), nullable=False),
|
||||||
|
sa.Column("price", sa.Float),
|
||||||
|
sa.Column("currency", sa.String(8)),
|
||||||
|
sa.Column("as_of", sa.String(16)),
|
||||||
|
sa.Column("changes", sa.JSON),
|
||||||
|
sa.Column("error", sa.String(255)),
|
||||||
|
sa.Column("fetched_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("ix_quotes_symbol_fetched", "quotes", ["symbol", "fetched_at"])
|
||||||
|
op.create_index("ix_quotes_group", "quotes", ["group_name"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"quotes_daily",
|
||||||
|
sa.Column("symbol", sa.String(64), primary_key=True),
|
||||||
|
sa.Column("date", sa.Date, primary_key=True),
|
||||||
|
sa.Column("close", sa.Float),
|
||||||
|
sa.Column("high", sa.Float),
|
||||||
|
sa.Column("low", sa.Float),
|
||||||
|
sa.Column("source", sa.String(32), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"headlines",
|
||||||
|
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("source", sa.String(64), nullable=False),
|
||||||
|
sa.Column("category", sa.String(32), nullable=False),
|
||||||
|
sa.Column("title", sa.String(512), nullable=False),
|
||||||
|
sa.Column("url", sa.String(1024), nullable=False),
|
||||||
|
sa.Column("published_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("fetched_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("fingerprint", sa.String(40), nullable=False),
|
||||||
|
sa.UniqueConstraint("fingerprint", name="uq_headlines_fingerprint"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_headlines_published", "headlines", ["published_at"])
|
||||||
|
op.create_index("ix_headlines_category_published", "headlines", ["category", "published_at"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"feeds",
|
||||||
|
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("category", sa.String(32), nullable=False),
|
||||||
|
sa.Column("name", sa.String(64), nullable=False),
|
||||||
|
sa.Column("url", sa.String(1024), nullable=False),
|
||||||
|
sa.Column("enabled", sa.Boolean, nullable=False, server_default=sa.text("1")),
|
||||||
|
sa.Column("consecutive_failures", sa.Integer, nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("last_success_at", sa.DateTime(timezone=True)),
|
||||||
|
sa.UniqueConstraint("category", "name", name="uq_feeds_cat_name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"strategic_logs",
|
||||||
|
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("generated_at", sa.DateTime(timezone=True), nullable=False, index=True),
|
||||||
|
sa.Column("model", sa.String(64), nullable=False),
|
||||||
|
sa.Column("anchor_date", sa.String(16)),
|
||||||
|
sa.Column("prompt_version", sa.Integer, nullable=False, server_default=sa.text("1")),
|
||||||
|
sa.Column("content", sa.Text, nullable=False),
|
||||||
|
sa.Column("prompt_tokens", sa.Integer),
|
||||||
|
sa.Column("completion_tokens", sa.Integer),
|
||||||
|
sa.Column("cost_usd", sa.Float),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"ai_calls",
|
||||||
|
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("called_at", sa.DateTime(timezone=True), nullable=False, index=True),
|
||||||
|
sa.Column("model", sa.String(64), nullable=False),
|
||||||
|
sa.Column("prompt_tokens", sa.Integer),
|
||||||
|
sa.Column("completion_tokens", sa.Integer),
|
||||||
|
sa.Column("cost_usd", sa.Float),
|
||||||
|
sa.Column("status", sa.String(16), nullable=False, server_default="ok"),
|
||||||
|
sa.Column("error", sa.String(512)),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"portfolios",
|
||||||
|
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("name", sa.String(64), nullable=False),
|
||||||
|
sa.Column("source", sa.String(32), nullable=False),
|
||||||
|
sa.Column("currency", sa.String(8), nullable=False, server_default="GBP"),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.UniqueConstraint("name", name="uq_portfolios_name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"portfolio_snapshots",
|
||||||
|
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("portfolio_id", sa.Integer, sa.ForeignKey("portfolios.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("snapshot_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("total_value", sa.Float),
|
||||||
|
sa.Column("cash", sa.Float),
|
||||||
|
sa.Column("invested", sa.Float),
|
||||||
|
sa.Column("raw_json", sa.JSON),
|
||||||
|
)
|
||||||
|
op.create_index("ix_snap_portfolio_at", "portfolio_snapshots", ["portfolio_id", "snapshot_at"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"positions",
|
||||||
|
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("snapshot_id", sa.BigInteger, sa.ForeignKey("portfolio_snapshots.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("ticker", sa.String(64), nullable=False),
|
||||||
|
sa.Column("quantity", sa.Float),
|
||||||
|
sa.Column("average_price", sa.Float),
|
||||||
|
sa.Column("current_price", sa.Float),
|
||||||
|
sa.Column("ppl", sa.Float),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"job_runs",
|
||||||
|
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("name", sa.String(64), nullable=False),
|
||||||
|
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("finished_at", sa.DateTime(timezone=True)),
|
||||||
|
sa.Column("status", sa.String(16), nullable=False, server_default="running"),
|
||||||
|
sa.Column("error", sa.Text),
|
||||||
|
sa.Column("items_written", sa.Integer),
|
||||||
|
)
|
||||||
|
op.create_index("ix_jobruns_name_started", "job_runs", ["name", "started_at"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
for t in [
|
||||||
|
"job_runs", "positions", "portfolio_snapshots", "portfolios",
|
||||||
|
"ai_calls", "strategic_logs", "feeds", "headlines",
|
||||||
|
"quotes_daily", "quotes",
|
||||||
|
]:
|
||||||
|
op.drop_table(t)
|
||||||
24
alembic/versions/0002_position_name.py
Normal file
24
alembic/versions/0002_position_name.py
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
"""add name column to positions — populated from T212 /equity/metadata/instruments
|
||||||
|
|
||||||
|
Revision ID: 0002
|
||||||
|
Revises: 0001
|
||||||
|
Create Date: 2026-05-15
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "0002"
|
||||||
|
down_revision: Union[str, None] = "0001"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("positions", sa.Column("name", sa.String(128), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("positions", "name")
|
||||||
26
alembic/versions/0003_log_tone_analysis.py
Normal file
26
alembic/versions/0003_log_tone_analysis.py
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
"""record tone + analysis on each strategic_log row
|
||||||
|
|
||||||
|
Revision ID: 0003
|
||||||
|
Revises: 0002
|
||||||
|
Create Date: 2026-05-15
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "0003"
|
||||||
|
down_revision: Union[str, None] = "0002"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("strategic_logs", sa.Column("tone", sa.String(16), nullable=True))
|
||||||
|
op.add_column("strategic_logs", sa.Column("analysis", sa.String(16), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("strategic_logs", "analysis")
|
||||||
|
op.drop_column("strategic_logs", "tone")
|
||||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
31
app/auth.py
Normal file
31
app/auth.py
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
"""Bearer-token auth — single static token from CASSANDRA_TOKEN env.
|
||||||
|
If the env is empty, the app runs open (LAN-only / dev mode).
|
||||||
|
Constant-time comparison via secrets.compare_digest.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from fastapi import Header, HTTPException, status
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
async def require_token(
|
||||||
|
authorization: str | None = Header(default=None),
|
||||||
|
) -> None:
|
||||||
|
expected = get_settings().CASSANDRA_TOKEN
|
||||||
|
if not expected:
|
||||||
|
return # open mode — no auth required
|
||||||
|
if not authorization or not authorization.lower().startswith("bearer "):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Bearer token required",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
provided = authorization.split(" ", 1)[1].strip()
|
||||||
|
if not secrets.compare_digest(provided.encode(), expected.encode()):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Invalid token",
|
||||||
|
)
|
||||||
118
app/config.py
Normal file
118
app/config.py
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
"""Runtime configuration — environment via Pydantic Settings + TOML-loaded data tables.
|
||||||
|
|
||||||
|
Settings come from .env / process env. The TOML files (default.toml, portfolio.toml)
|
||||||
|
define *what to track* — they're declarative content, not config knobs, so they
|
||||||
|
stay separate from the settings model.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tomllib
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
CONFIG_DIR = Path(__file__).resolve().parent.parent / "config"
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""All runtime knobs. Read from process env, .env not needed in-container
|
||||||
|
because compose injects vars directly; .env is supported for local dev."""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
extra="ignore",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DATABASE_URL: str = "mysql+aiomysql://cassandra:changeme@db:3306/cassandra"
|
||||||
|
|
||||||
|
# API keys (mirror prototype .env names)
|
||||||
|
API_KEY: str = "" # Trading 212 key
|
||||||
|
SECRET_KEY: str = "" # Trading 212 secret
|
||||||
|
FRED_API_KEY: str = ""
|
||||||
|
OPENROUTER_API_KEY: str = ""
|
||||||
|
|
||||||
|
# App
|
||||||
|
CASSANDRA_TOKEN: str = ""
|
||||||
|
CASSANDRA_PORT: int = 8000
|
||||||
|
CASSANDRA_BASE_CURRENCY: str = "GBP"
|
||||||
|
CASSANDRA_ANCHOR_DATE: str = ""
|
||||||
|
CASSANDRA_MOCK: bool = False
|
||||||
|
|
||||||
|
# AI log
|
||||||
|
OPENROUTER_MODEL: str = "deepseek/deepseek-v4-flash"
|
||||||
|
OPENROUTER_MONTHLY_CAP_USD: float = 20.0
|
||||||
|
CASSANDRA_TONE: str = "INTERMEDIATE" # NOVICE | INTERMEDIATE | PRO
|
||||||
|
CASSANDRA_ANALYSIS: str = "SPECULATIVE" # DRY | SPECULATIVE
|
||||||
|
|
||||||
|
# Config file locations (overridable for tests)
|
||||||
|
BASELINE_TOML: Path = Field(default_factory=lambda: CONFIG_DIR / "default.toml")
|
||||||
|
PORTFOLIO_TOML: Path = Field(default_factory=lambda: CONFIG_DIR / "portfolio.toml")
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
|
|
||||||
|
|
||||||
|
# --- TOML data tables --------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_toml(*paths: Path) -> dict[str, Any]:
|
||||||
|
"""Read TOML files in order; later ones override earlier at the top level
|
||||||
|
(with shallow dict-merge for nested tables)."""
|
||||||
|
out: dict[str, Any] = {}
|
||||||
|
for path in paths:
|
||||||
|
if not path.exists():
|
||||||
|
continue
|
||||||
|
with path.open("rb") as f:
|
||||||
|
data = tomllib.load(f)
|
||||||
|
for k, v in data.items():
|
||||||
|
if isinstance(v, dict) and isinstance(out.get(k), dict):
|
||||||
|
out[k].update(v)
|
||||||
|
else:
|
||||||
|
out[k] = v
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def load_groups(*paths: Path) -> dict[str, list[tuple[str, str, str]]]:
|
||||||
|
"""[(symbol, label, note), ...] per group name."""
|
||||||
|
data = _merge_toml(*paths)
|
||||||
|
out: dict[str, list[tuple[str, str, str]]] = {}
|
||||||
|
for name, items in (data.get("groups") or {}).items():
|
||||||
|
out[name] = [
|
||||||
|
(it["symbol"], it.get("label", it["symbol"]), it.get("note", ""))
|
||||||
|
for it in items
|
||||||
|
]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def load_feeds(*paths: Path) -> dict[str, list[tuple[str, str]]]:
|
||||||
|
"""[(name, url), ...] per category name."""
|
||||||
|
data = _merge_toml(*paths)
|
||||||
|
out: dict[str, list[tuple[str, str]]] = {}
|
||||||
|
for cat, items in (data.get("feeds") or {}).items():
|
||||||
|
out[cat] = [(it["name"], it["url"]) for it in items]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def load_presets(*paths: Path) -> dict[str, list[str]]:
|
||||||
|
"""Keyword presets for news filtering."""
|
||||||
|
data = _merge_toml(*paths)
|
||||||
|
presets = (data.get("news") or {}).get("presets") or {}
|
||||||
|
return {name: list(kw) for name, kw in presets.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def load_all() -> tuple[dict, dict, dict]:
|
||||||
|
"""Shortcut: groups, feeds, presets using the configured TOML paths."""
|
||||||
|
s = get_settings()
|
||||||
|
return (
|
||||||
|
load_groups(s.BASELINE_TOML, s.PORTFOLIO_TOML),
|
||||||
|
load_feeds(s.BASELINE_TOML, s.PORTFOLIO_TOML),
|
||||||
|
load_presets(s.BASELINE_TOML, s.PORTFOLIO_TOML),
|
||||||
|
)
|
||||||
55
app/db.py
Normal file
55
app/db.py
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
"""Async DB engine, session factory, declarative base, UTC helper."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import AsyncIterator
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import (
|
||||||
|
AsyncSession,
|
||||||
|
async_sessionmaker,
|
||||||
|
create_async_engine,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
_engine = None
|
||||||
|
_session_factory: async_sessionmaker[AsyncSession] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def utcnow() -> datetime:
|
||||||
|
"""All timestamps in Cassandra are tz-aware UTC by convention."""
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine():
|
||||||
|
global _engine
|
||||||
|
if _engine is None:
|
||||||
|
s = get_settings()
|
||||||
|
_engine = create_async_engine(
|
||||||
|
s.DATABASE_URL,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
pool_recycle=3600,
|
||||||
|
future=True,
|
||||||
|
)
|
||||||
|
return _engine
|
||||||
|
|
||||||
|
|
||||||
|
def get_session_factory() -> async_sessionmaker[AsyncSession]:
|
||||||
|
global _session_factory
|
||||||
|
if _session_factory is None:
|
||||||
|
_session_factory = async_sessionmaker(
|
||||||
|
get_engine(), expire_on_commit=False, class_=AsyncSession
|
||||||
|
)
|
||||||
|
return _session_factory
|
||||||
|
|
||||||
|
|
||||||
|
async def get_session() -> AsyncIterator[AsyncSession]:
|
||||||
|
"""FastAPI dependency yielding an async session."""
|
||||||
|
async with get_session_factory()() as session:
|
||||||
|
yield session
|
||||||
0
app/jobs/__init__.py
Normal file
0
app/jobs/__init__.py
Normal file
58
app/jobs/_helpers.py
Normal file
58
app/jobs/_helpers.py
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
"""Shared job machinery: job_runs lifecycle, MariaDB advisory lock,
|
||||||
|
mock-mode short-circuits."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import AsyncIterator
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.db import get_session_factory, utcnow
|
||||||
|
from app.logging import get_logger
|
||||||
|
from app.models import JobRun
|
||||||
|
|
||||||
|
|
||||||
|
log = get_logger("jobs")
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def job_lifecycle(name: str) -> AsyncIterator[tuple[AsyncSession, JobRun]]:
|
||||||
|
"""Wraps a job invocation. Creates a JobRun row on entry; updates it on
|
||||||
|
exit. Failures are caught by the caller's try/except; this context only
|
||||||
|
handles the bookkeeping.
|
||||||
|
|
||||||
|
A MariaDB GET_LOCK(name, 0) is acquired to prevent concurrent runs of the
|
||||||
|
same job across processes. If the lock is busy, we skip the run."""
|
||||||
|
factory = get_session_factory()
|
||||||
|
async with factory() as session:
|
||||||
|
# Try lock; skip if held.
|
||||||
|
got = (await session.execute(
|
||||||
|
text("SELECT GET_LOCK(:n, 0)"), {"n": f"cassandra_{name}"}
|
||||||
|
)).scalar()
|
||||||
|
if not got:
|
||||||
|
log.warning("job.skipped_locked", name=name)
|
||||||
|
yield session, JobRun(name=name, started_at=utcnow(), status="skipped")
|
||||||
|
return
|
||||||
|
run = JobRun(name=name, started_at=utcnow(), status="running")
|
||||||
|
session.add(run)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(run)
|
||||||
|
try:
|
||||||
|
yield session, run
|
||||||
|
run.status = run.status if run.status not in ("running",) else "success"
|
||||||
|
run.finished_at = utcnow()
|
||||||
|
await session.commit()
|
||||||
|
log.info("job.finished",
|
||||||
|
name=name, status=run.status, items=run.items_written)
|
||||||
|
except Exception as e:
|
||||||
|
run.status = "failed"
|
||||||
|
run.error = str(e)[:1000]
|
||||||
|
run.finished_at = utcnow()
|
||||||
|
await session.commit()
|
||||||
|
log.error("job.failed", name=name, error=str(e))
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await session.execute(text("SELECT RELEASE_LOCK(:n)"),
|
||||||
|
{"n": f"cassandra_{name}"})
|
||||||
|
await session.commit()
|
||||||
173
app/jobs/ai_log_job.py
Normal file
173
app/jobs/ai_log_job.py
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
"""Hourly AI strategic-log generator. Pulls already-persisted market data and
|
||||||
|
headlines from the DB (no live fetches), calls OpenRouter, persists the log
|
||||||
|
and a row in the cost ledger."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy import desc, func, select
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.db import utcnow
|
||||||
|
from app.jobs._helpers import job_lifecycle, log
|
||||||
|
from app.models import AICall, Headline, Quote, StrategicLog
|
||||||
|
from app.services.openrouter import (
|
||||||
|
PROMPT_VERSION,
|
||||||
|
build_system_prompt,
|
||||||
|
build_user_prompt,
|
||||||
|
call_openrouter,
|
||||||
|
month_start,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
REFERENCE_LINE = (
|
||||||
|
"S&P 7,501 (ATH) · VIX 18.0 · US 10y 4.45% · HY OAS 279bps · "
|
||||||
|
"Brent $109/bbl · Gold $4,651/oz · CPI 3.8% YoY"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _latest_quotes_by_group(session) -> dict[str, list[dict]]:
|
||||||
|
"""Latest quote per (group, symbol). Skips error rows where price is null."""
|
||||||
|
sub = (
|
||||||
|
select(
|
||||||
|
Quote.group_name,
|
||||||
|
Quote.symbol,
|
||||||
|
func.max(Quote.fetched_at).label("mx"),
|
||||||
|
)
|
||||||
|
.group_by(Quote.group_name, Quote.symbol)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
stmt = (
|
||||||
|
select(Quote)
|
||||||
|
.join(
|
||||||
|
sub,
|
||||||
|
(Quote.group_name == sub.c.group_name)
|
||||||
|
& (Quote.symbol == sub.c.symbol)
|
||||||
|
& (Quote.fetched_at == sub.c.mx),
|
||||||
|
)
|
||||||
|
.order_by(Quote.group_name, Quote.symbol)
|
||||||
|
)
|
||||||
|
rows = (await session.execute(stmt)).scalars().all()
|
||||||
|
by_group: dict[str, list[dict]] = defaultdict(list)
|
||||||
|
for q in rows:
|
||||||
|
by_group[q.group_name].append(dict(
|
||||||
|
symbol=q.symbol, source=q.source, label=q.label,
|
||||||
|
note="", price=q.price, currency=q.currency,
|
||||||
|
as_of=q.as_of, changes=q.changes,
|
||||||
|
))
|
||||||
|
return by_group
|
||||||
|
|
||||||
|
|
||||||
|
async def _recent_headlines_by_bucket(session, hours: float = 24) -> dict[str, list[dict]]:
|
||||||
|
"""Last N hours of headlines, bucketed by category. Hard cap per bucket
|
||||||
|
to keep the prompt under ~40KB."""
|
||||||
|
cutoff = utcnow() - timedelta(hours=hours)
|
||||||
|
stmt = (
|
||||||
|
select(Headline)
|
||||||
|
.where(Headline.published_at >= cutoff)
|
||||||
|
.order_by(desc(Headline.published_at))
|
||||||
|
.limit(400)
|
||||||
|
)
|
||||||
|
rows = (await session.execute(stmt)).scalars().all()
|
||||||
|
by_bucket: dict[str, list[dict]] = defaultdict(list)
|
||||||
|
for h in rows:
|
||||||
|
if len(by_bucket[h.category]) >= 40:
|
||||||
|
continue
|
||||||
|
by_bucket[h.category].append(dict(
|
||||||
|
when=h.published_at.isoformat(),
|
||||||
|
source=h.source, title=h.title,
|
||||||
|
))
|
||||||
|
return by_bucket
|
||||||
|
|
||||||
|
|
||||||
|
async def _month_spend(session) -> float:
|
||||||
|
start = month_start()
|
||||||
|
total = (await session.execute(
|
||||||
|
select(func.coalesce(func.sum(AICall.cost_usd), 0.0))
|
||||||
|
.where(AICall.called_at >= start)
|
||||||
|
)).scalar()
|
||||||
|
return float(total or 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
async def run() -> None:
|
||||||
|
async with job_lifecycle("ai_log_job") as (session, jr):
|
||||||
|
if jr.status == "skipped":
|
||||||
|
return
|
||||||
|
s = get_settings()
|
||||||
|
if not s.OPENROUTER_API_KEY:
|
||||||
|
log.warning("ai_log.skipped_no_key")
|
||||||
|
jr.status = "skipped"
|
||||||
|
return
|
||||||
|
|
||||||
|
spent = await _month_spend(session)
|
||||||
|
if spent >= s.OPENROUTER_MONTHLY_CAP_USD:
|
||||||
|
log.warning("ai_log.cap_reached", spent=spent,
|
||||||
|
cap=s.OPENROUTER_MONTHLY_CAP_USD)
|
||||||
|
jr.status = "skipped"
|
||||||
|
jr.error = f"monthly cost cap reached (${spent:.2f})"
|
||||||
|
return
|
||||||
|
|
||||||
|
quotes = await _latest_quotes_by_group(session)
|
||||||
|
news = await _recent_headlines_by_bucket(session)
|
||||||
|
if not quotes and not news:
|
||||||
|
log.warning("ai_log.no_data_yet")
|
||||||
|
jr.status = "skipped"
|
||||||
|
return
|
||||||
|
|
||||||
|
anchor = s.CASSANDRA_ANCHOR_DATE or None
|
||||||
|
user_prompt = build_user_prompt(
|
||||||
|
today=utcnow(),
|
||||||
|
anchor=anchor,
|
||||||
|
quotes_by_group=quotes,
|
||||||
|
headlines_by_bucket=news,
|
||||||
|
reference_line=REFERENCE_LINE,
|
||||||
|
)
|
||||||
|
|
||||||
|
system_prompt = build_system_prompt(s.CASSANDRA_TONE, s.CASSANDRA_ANALYSIS)
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||||
|
result = await call_openrouter(
|
||||||
|
client,
|
||||||
|
[{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user", "content": user_prompt}],
|
||||||
|
model=s.OPENROUTER_MODEL,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
session.add(AICall(
|
||||||
|
model=s.OPENROUTER_MODEL, status="error", error=str(e)[:500],
|
||||||
|
))
|
||||||
|
await session.commit()
|
||||||
|
raise
|
||||||
|
|
||||||
|
session.add(StrategicLog(
|
||||||
|
generated_at=utcnow(),
|
||||||
|
model=result.model,
|
||||||
|
anchor_date=anchor,
|
||||||
|
prompt_version=PROMPT_VERSION,
|
||||||
|
tone=s.CASSANDRA_TONE.upper(),
|
||||||
|
analysis=s.CASSANDRA_ANALYSIS.upper(),
|
||||||
|
content=result.content,
|
||||||
|
prompt_tokens=result.prompt_tokens,
|
||||||
|
completion_tokens=result.completion_tokens,
|
||||||
|
cost_usd=result.cost_usd,
|
||||||
|
))
|
||||||
|
session.add(AICall(
|
||||||
|
model=result.model,
|
||||||
|
prompt_tokens=result.prompt_tokens,
|
||||||
|
completion_tokens=result.completion_tokens,
|
||||||
|
cost_usd=result.cost_usd,
|
||||||
|
status="ok",
|
||||||
|
))
|
||||||
|
await session.commit()
|
||||||
|
jr.items_written = 1
|
||||||
|
log.info("ai_log.done",
|
||||||
|
model=result.model,
|
||||||
|
prompt_tokens=result.prompt_tokens,
|
||||||
|
completion_tokens=result.completion_tokens)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(run())
|
||||||
63
app/jobs/market_job.py
Normal file
63
app/jobs/market_job.py
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
"""Hourly market ingestion: fetch every (symbol, group) defined in TOML and
|
||||||
|
insert one Quote row per fetch."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.config import get_settings, load_groups
|
||||||
|
from app.db import utcnow
|
||||||
|
from app.jobs._helpers import job_lifecycle, log
|
||||||
|
from app.models import Quote
|
||||||
|
from app.services.market import fetch
|
||||||
|
|
||||||
|
|
||||||
|
async def run() -> None:
|
||||||
|
async with job_lifecycle("market_job") as (session, run):
|
||||||
|
if run.status == "skipped":
|
||||||
|
return
|
||||||
|
s = get_settings()
|
||||||
|
groups = load_groups(s.BASELINE_TOML, s.PORTFOLIO_TOML)
|
||||||
|
anchor = s.CASSANDRA_ANCHOR_DATE or None
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||||
|
tasks = [
|
||||||
|
fetch(client, sym, lab, note, anchor)
|
||||||
|
for group, items in groups.items()
|
||||||
|
for sym, lab, note in items
|
||||||
|
]
|
||||||
|
# Run in parallel but bounded — Yahoo can throttle if we hammer.
|
||||||
|
sem = asyncio.Semaphore(16)
|
||||||
|
async def bounded(t):
|
||||||
|
async with sem:
|
||||||
|
return await t
|
||||||
|
quotes = await asyncio.gather(*(bounded(t) for t in tasks))
|
||||||
|
|
||||||
|
# Re-index quotes back to their group for persistence.
|
||||||
|
items_flat = [
|
||||||
|
(group, sym)
|
||||||
|
for group, items in groups.items()
|
||||||
|
for sym, _, _ in items
|
||||||
|
]
|
||||||
|
now = utcnow()
|
||||||
|
for (group, _sym), q in zip(items_flat, quotes):
|
||||||
|
session.add(Quote(
|
||||||
|
symbol=q.symbol,
|
||||||
|
source=q.source,
|
||||||
|
label=q.label,
|
||||||
|
group_name=group,
|
||||||
|
price=q.price,
|
||||||
|
currency=q.currency,
|
||||||
|
as_of=q.as_of,
|
||||||
|
changes=q.changes or None,
|
||||||
|
error=q.error,
|
||||||
|
fetched_at=now,
|
||||||
|
))
|
||||||
|
await session.commit()
|
||||||
|
run.items_written = len(quotes)
|
||||||
|
log.info("market_job.done", count=len(quotes))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(run())
|
||||||
99
app/jobs/news_job.py
Normal file
99
app/jobs/news_job.py
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
"""Hourly news ingestion. Reads enabled feeds from the DB (not TOML — DB has
|
||||||
|
the authoritative enabled/failure state). Per-ticker Yahoo news pulled for
|
||||||
|
each symbol in the default portfolio group ('pie')."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy import desc, select
|
||||||
|
from sqlalchemy.dialects.mysql import insert as mysql_insert
|
||||||
|
|
||||||
|
from app.db import utcnow
|
||||||
|
from app.jobs._helpers import job_lifecycle, log
|
||||||
|
from app.models import Feed, Headline, Portfolio, PortfolioSnapshot, Position
|
||||||
|
from app.services.news import dedupe, fetch_feed, fetch_yahoo_news
|
||||||
|
|
||||||
|
|
||||||
|
AUTO_DISABLE_AT = 5
|
||||||
|
|
||||||
|
|
||||||
|
async def _process_feed(client: httpx.AsyncClient, feed: Feed) -> tuple[Feed, list]:
|
||||||
|
try:
|
||||||
|
items = await fetch_feed(client, feed.name, feed.category, feed.url)
|
||||||
|
feed.consecutive_failures = 0
|
||||||
|
feed.last_success_at = utcnow()
|
||||||
|
return feed, items
|
||||||
|
except Exception as e:
|
||||||
|
feed.consecutive_failures += 1
|
||||||
|
if feed.consecutive_failures >= AUTO_DISABLE_AT:
|
||||||
|
feed.enabled = False
|
||||||
|
log.warning("feed.fetch_failed", name=feed.name,
|
||||||
|
fails=feed.consecutive_failures, error=str(e))
|
||||||
|
return feed, []
|
||||||
|
|
||||||
|
|
||||||
|
async def run() -> None:
|
||||||
|
async with job_lifecycle("news_job") as (session, run):
|
||||||
|
if run.status == "skipped":
|
||||||
|
return
|
||||||
|
|
||||||
|
feeds = (
|
||||||
|
await session.execute(select(Feed).where(Feed.enabled == True))
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
|
# Portfolio tickers + names now come from the latest T212 snapshot,
|
||||||
|
# not from TOML. The (ticker, name) pair lets fetch_yahoo_news skip
|
||||||
|
# the chart-meta round-trip and use the proper company name directly.
|
||||||
|
latest_snap_id = (await session.execute(
|
||||||
|
select(PortfolioSnapshot.id)
|
||||||
|
.order_by(desc(PortfolioSnapshot.snapshot_at))
|
||||||
|
.limit(1)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
ticker_pairs: list[tuple[str, str]] = []
|
||||||
|
if latest_snap_id is not None:
|
||||||
|
positions = (await session.execute(
|
||||||
|
select(Position).where(Position.snapshot_id == latest_snap_id)
|
||||||
|
)).scalars().all()
|
||||||
|
ticker_pairs = [(p.ticker, p.name or p.ticker) for p in positions]
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||||
|
feed_results = await asyncio.gather(
|
||||||
|
*(_process_feed(client, f) for f in feeds)
|
||||||
|
)
|
||||||
|
ticker_results = await asyncio.gather(
|
||||||
|
*(fetch_yahoo_news(client, t, query_override=n)
|
||||||
|
for t, n in ticker_pairs)
|
||||||
|
)
|
||||||
|
|
||||||
|
all_headlines = []
|
||||||
|
for _feed, items in feed_results:
|
||||||
|
all_headlines.extend(items)
|
||||||
|
for items in ticker_results:
|
||||||
|
all_headlines.extend(items)
|
||||||
|
|
||||||
|
headlines = dedupe(all_headlines)
|
||||||
|
|
||||||
|
# Bulk INSERT IGNORE (fingerprint UNIQUE de-dupes across runs).
|
||||||
|
if headlines:
|
||||||
|
stmt = mysql_insert(Headline).values([
|
||||||
|
dict(
|
||||||
|
source=h.source,
|
||||||
|
category=h.category,
|
||||||
|
title=h.title[:512],
|
||||||
|
url=h.url[:1024],
|
||||||
|
published_at=h.when,
|
||||||
|
fetched_at=utcnow(),
|
||||||
|
fingerprint=h.fingerprint,
|
||||||
|
)
|
||||||
|
for h in headlines
|
||||||
|
]).prefix_with("IGNORE")
|
||||||
|
await session.execute(stmt)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
run.items_written = len(headlines)
|
||||||
|
log.info("news_job.done", fetched=len(all_headlines), kept=len(headlines))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(run())
|
||||||
90
app/jobs/portfolio_job.py
Normal file
90
app/jobs/portfolio_job.py
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
"""Hourly Trading 212 snapshot. One Portfolio row per portfolio name
|
||||||
|
(currently just 'pie'); one PortfolioSnapshot per run; N Position rows."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.db import utcnow
|
||||||
|
from app.jobs._helpers import job_lifecycle, log
|
||||||
|
from app.models import Portfolio, PortfolioSnapshot, Position
|
||||||
|
from app.services.trading212 import Trading212
|
||||||
|
|
||||||
|
|
||||||
|
PORTFOLIO_NAME = "pie" # only one for now; multi-portfolio extension is schema-ready
|
||||||
|
|
||||||
|
|
||||||
|
async def run() -> None:
|
||||||
|
async with job_lifecycle("portfolio_job") as (session, jr):
|
||||||
|
if jr.status == "skipped":
|
||||||
|
return
|
||||||
|
s = get_settings()
|
||||||
|
if not (s.API_KEY and s.SECRET_KEY):
|
||||||
|
log.warning("portfolio_job.skipped_no_creds")
|
||||||
|
jr.status = "skipped"
|
||||||
|
return
|
||||||
|
|
||||||
|
t212 = Trading212()
|
||||||
|
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||||
|
summary = await t212.summary(client)
|
||||||
|
positions = await t212.positions(client)
|
||||||
|
# The instruments call is heavy (~5 MB / 17k rows) but it's our
|
||||||
|
# only path to a human-readable name per ticker. Once per hour is
|
||||||
|
# fine; later we could cache to disk.
|
||||||
|
try:
|
||||||
|
instruments = await t212.instruments(client)
|
||||||
|
name_by_ticker = {
|
||||||
|
i["ticker"]: i.get("name") or i.get("shortName") or i["ticker"]
|
||||||
|
for i in (instruments or [])
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
name_by_ticker = {}
|
||||||
|
|
||||||
|
portfolio = (
|
||||||
|
await session.execute(
|
||||||
|
select(Portfolio).where(Portfolio.name == PORTFOLIO_NAME)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if portfolio is None:
|
||||||
|
portfolio = Portfolio(
|
||||||
|
name=PORTFOLIO_NAME, source="trading212",
|
||||||
|
currency=summary.get("currency", "GBP"),
|
||||||
|
)
|
||||||
|
session.add(portfolio)
|
||||||
|
await session.flush() # need id for FK
|
||||||
|
|
||||||
|
cash = (summary.get("cash") or {})
|
||||||
|
investments = (summary.get("investments") or {})
|
||||||
|
snap = PortfolioSnapshot(
|
||||||
|
portfolio_id=portfolio.id,
|
||||||
|
snapshot_at=utcnow(),
|
||||||
|
total_value=summary.get("totalValue"),
|
||||||
|
cash=cash.get("availableToTrade"),
|
||||||
|
invested=investments.get("currentValue"),
|
||||||
|
raw_json=summary,
|
||||||
|
)
|
||||||
|
session.add(snap)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
for p in positions or []:
|
||||||
|
tkr = p.get("ticker", "")
|
||||||
|
session.add(Position(
|
||||||
|
snapshot_id=snap.id,
|
||||||
|
ticker=tkr,
|
||||||
|
name=name_by_ticker.get(tkr),
|
||||||
|
quantity=p.get("quantity"),
|
||||||
|
average_price=p.get("averagePrice"),
|
||||||
|
current_price=p.get("currentPrice"),
|
||||||
|
ppl=p.get("ppl"),
|
||||||
|
))
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
jr.items_written = len(positions or []) + 1
|
||||||
|
log.info("portfolio_job.done", positions=len(positions or []))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(run())
|
||||||
70
app/jobs/rollup_job.py
Normal file
70
app/jobs/rollup_job.py
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
"""Daily rollup: collapse `quotes` into `quotes_daily` (one row per
|
||||||
|
(symbol, date) using the latest fetched_at of the day) and prune `quotes`
|
||||||
|
older than 30 days. Sparklines read from `quotes_daily`."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import delete, func, select
|
||||||
|
from sqlalchemy.dialects.mysql import insert as mysql_insert
|
||||||
|
|
||||||
|
from app.db import utcnow
|
||||||
|
from app.jobs._helpers import job_lifecycle, log
|
||||||
|
from app.models import Quote, QuoteDaily
|
||||||
|
|
||||||
|
|
||||||
|
PRUNE_DAYS = 30
|
||||||
|
|
||||||
|
|
||||||
|
async def run() -> None:
|
||||||
|
async with job_lifecycle("rollup_job") as (session, jr):
|
||||||
|
if jr.status == "skipped":
|
||||||
|
return
|
||||||
|
|
||||||
|
# 1. Rollup: latest fetched_at of each (symbol, date) gets stored as close.
|
||||||
|
sub = (
|
||||||
|
select(
|
||||||
|
Quote.symbol.label("symbol"),
|
||||||
|
func.date(Quote.fetched_at).label("date"),
|
||||||
|
func.max(Quote.fetched_at).label("mx"),
|
||||||
|
)
|
||||||
|
.where(Quote.price.is_not(None))
|
||||||
|
.group_by(Quote.symbol, func.date(Quote.fetched_at))
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
rows = (await session.execute(
|
||||||
|
select(
|
||||||
|
Quote.symbol,
|
||||||
|
func.date(Quote.fetched_at).label("d"),
|
||||||
|
Quote.price,
|
||||||
|
Quote.source,
|
||||||
|
)
|
||||||
|
.join(sub,
|
||||||
|
(Quote.symbol == sub.c.symbol)
|
||||||
|
& (Quote.fetched_at == sub.c.mx))
|
||||||
|
)).all()
|
||||||
|
|
||||||
|
if rows:
|
||||||
|
stmt = mysql_insert(QuoteDaily).values([
|
||||||
|
dict(symbol=r.symbol, date=r.d, close=r.price,
|
||||||
|
high=r.price, low=r.price, source=r.source)
|
||||||
|
for r in rows
|
||||||
|
])
|
||||||
|
stmt = stmt.on_duplicate_key_update(close=stmt.inserted.close)
|
||||||
|
await session.execute(stmt)
|
||||||
|
|
||||||
|
# 2. Prune raw quotes older than PRUNE_DAYS.
|
||||||
|
cutoff = utcnow() - timedelta(days=PRUNE_DAYS)
|
||||||
|
result = await session.execute(
|
||||||
|
delete(Quote).where(Quote.fetched_at < cutoff)
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
pruned = result.rowcount or 0
|
||||||
|
jr.items_written = len(rows)
|
||||||
|
log.info("rollup_job.done", rolled=len(rows), pruned=pruned)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(run())
|
||||||
35
app/logging.py
Normal file
35
app/logging.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
"""structlog setup — JSON to stdout, ready for Docker json-file driver."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
|
||||||
|
def configure_logging(level: str = "INFO") -> None:
|
||||||
|
logging.basicConfig(
|
||||||
|
format="%(message)s",
|
||||||
|
stream=sys.stdout,
|
||||||
|
level=getattr(logging, level.upper(), logging.INFO),
|
||||||
|
)
|
||||||
|
structlog.configure(
|
||||||
|
processors=[
|
||||||
|
structlog.contextvars.merge_contextvars,
|
||||||
|
structlog.processors.add_log_level,
|
||||||
|
structlog.processors.TimeStamper(fmt="iso", utc=True),
|
||||||
|
structlog.processors.StackInfoRenderer(),
|
||||||
|
structlog.processors.format_exc_info,
|
||||||
|
structlog.processors.JSONRenderer(),
|
||||||
|
],
|
||||||
|
wrapper_class=structlog.make_filtering_bound_logger(
|
||||||
|
getattr(logging, level.upper(), logging.INFO)
|
||||||
|
),
|
||||||
|
context_class=dict,
|
||||||
|
logger_factory=structlog.PrintLoggerFactory(),
|
||||||
|
cache_logger_on_first_use=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_logger(name: str | None = None) -> structlog.stdlib.BoundLogger:
|
||||||
|
return structlog.get_logger(name)
|
||||||
69
app/main.py
Normal file
69
app/main.py
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
"""FastAPI entrypoint. Runs Alembic migrations on startup, bootstraps the
|
||||||
|
feeds table from TOML, mounts the API + HTML routers.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from alembic import command
|
||||||
|
from alembic.config import Config as AlembicConfig
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.db import get_session_factory
|
||||||
|
from app.logging import configure_logging, get_logger
|
||||||
|
from app.routers import api as api_router
|
||||||
|
from app.routers import pages as pages_router
|
||||||
|
from app.services.feeds_bootstrap import bootstrap_feeds
|
||||||
|
|
||||||
|
|
||||||
|
log = get_logger("cassandra")
|
||||||
|
APP_DIR = Path(__file__).resolve().parent
|
||||||
|
PROJECT_DIR = APP_DIR.parent
|
||||||
|
|
||||||
|
|
||||||
|
def _run_migrations() -> None:
|
||||||
|
"""Synchronous Alembic upgrade. Called once at lifespan startup."""
|
||||||
|
cfg = AlembicConfig(str(PROJECT_DIR / "alembic.ini"))
|
||||||
|
cfg.set_main_option("script_location", str(PROJECT_DIR / "alembic"))
|
||||||
|
cfg.set_main_option("sqlalchemy.url", get_settings().DATABASE_URL)
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
configure_logging()
|
||||||
|
log.info("cassandra.startup")
|
||||||
|
try:
|
||||||
|
# Alembic's env.py uses asyncio.run() internally; offload it to a
|
||||||
|
# worker thread so it doesn't collide with FastAPI's running loop.
|
||||||
|
await asyncio.to_thread(_run_migrations)
|
||||||
|
log.info("cassandra.migrations.applied")
|
||||||
|
except Exception as e:
|
||||||
|
log.error("cassandra.migrations.failed", error=str(e))
|
||||||
|
raise
|
||||||
|
async with get_session_factory()() as session:
|
||||||
|
inserted = await bootstrap_feeds(session)
|
||||||
|
log.info("cassandra.feeds.bootstrap", inserted=inserted)
|
||||||
|
yield
|
||||||
|
log.info("cassandra.shutdown")
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="Cassandra",
|
||||||
|
description="Macro-strategy dashboard",
|
||||||
|
version="0.1.0",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.mount(
|
||||||
|
"/static",
|
||||||
|
StaticFiles(directory=str(APP_DIR / "static")),
|
||||||
|
name="static",
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(api_router.router, prefix="/api", tags=["api"])
|
||||||
|
app.include_router(pages_router.router, tags=["pages"])
|
||||||
182
app/models.py
Normal file
182
app/models.py
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
"""SQLAlchemy models for Cassandra.
|
||||||
|
|
||||||
|
Schema rationale lives in /home/gg/.claude/plans/ok-i-think-this-tidy-lake.md.
|
||||||
|
All datetimes are tz-aware UTC (see app.db.utcnow).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, date
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
JSON,
|
||||||
|
BigInteger,
|
||||||
|
Boolean,
|
||||||
|
Date,
|
||||||
|
DateTime,
|
||||||
|
Float,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db import Base, utcnow
|
||||||
|
|
||||||
|
|
||||||
|
class Quote(Base):
|
||||||
|
__tablename__ = "quotes"
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
symbol: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
source: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||||
|
label: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
group_name: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
price: Mapped[float | None] = mapped_column(Float)
|
||||||
|
currency: Mapped[str | None] = mapped_column(String(8))
|
||||||
|
as_of: Mapped[str | None] = mapped_column(String(16)) # provider date string
|
||||||
|
changes: Mapped[dict | None] = mapped_column(JSON) # {"1d": x, "1m": y, ...}
|
||||||
|
error: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_quotes_symbol_fetched", "symbol", "fetched_at"),
|
||||||
|
Index("ix_quotes_group", "group_name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class QuoteDaily(Base):
|
||||||
|
"""Daily rollup — sparkline source. PK on (symbol, date)."""
|
||||||
|
__tablename__ = "quotes_daily"
|
||||||
|
symbol: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
|
date: Mapped[date] = mapped_column(Date, primary_key=True)
|
||||||
|
close: Mapped[float | None] = mapped_column(Float)
|
||||||
|
high: Mapped[float | None] = mapped_column(Float)
|
||||||
|
low: Mapped[float | None] = mapped_column(Float)
|
||||||
|
source: Mapped[str] = mapped_column(String(32))
|
||||||
|
|
||||||
|
|
||||||
|
class Headline(Base):
|
||||||
|
__tablename__ = "headlines"
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
source: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
category: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||||
|
title: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||||
|
url: Mapped[str] = mapped_column(String(1024), nullable=False)
|
||||||
|
published_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
|
fingerprint: Mapped[str] = mapped_column(String(40), nullable=False) # sha1 of normalised title
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("fingerprint", name="uq_headlines_fingerprint"),
|
||||||
|
Index("ix_headlines_published", "published_at"),
|
||||||
|
Index("ix_headlines_category_published", "category", "published_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Feed(Base):
|
||||||
|
"""Persisted feed state; bootstrapped from default.toml on first startup."""
|
||||||
|
__tablename__ = "feeds"
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
category: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
url: Mapped[str] = mapped_column(String(1024), nullable=False)
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
consecutive_failures: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
last_success_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("category", "name", name="uq_feeds_cat_name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class StrategicLog(Base):
|
||||||
|
__tablename__ = "strategic_logs"
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
generated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
|
||||||
|
model: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
anchor_date: Mapped[str | None] = mapped_column(String(16))
|
||||||
|
prompt_version: Mapped[int] = mapped_column(Integer, default=1)
|
||||||
|
tone: Mapped[str | None] = mapped_column(String(16)) # NOVICE|INTERMEDIATE|PRO
|
||||||
|
analysis: Mapped[str | None] = mapped_column(String(16)) # DRY|SPECULATIVE
|
||||||
|
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
prompt_tokens: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
completion_tokens: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
cost_usd: Mapped[float | None] = mapped_column(Float)
|
||||||
|
|
||||||
|
|
||||||
|
class AICall(Base):
|
||||||
|
"""Cost ledger for OpenRouter calls. Feeds the monthly cap check."""
|
||||||
|
__tablename__ = "ai_calls"
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
called_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
|
||||||
|
model: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
prompt_tokens: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
completion_tokens: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
cost_usd: Mapped[float | None] = mapped_column(Float)
|
||||||
|
status: Mapped[str] = mapped_column(String(16), default="ok")
|
||||||
|
error: Mapped[str | None] = mapped_column(String(512))
|
||||||
|
|
||||||
|
|
||||||
|
class Portfolio(Base):
|
||||||
|
__tablename__ = "portfolios"
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
source: Mapped[str] = mapped_column(String(32), nullable=False) # e.g. "trading212"
|
||||||
|
currency: Mapped[str] = mapped_column(String(8), default="GBP")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
|
|
||||||
|
snapshots: Mapped[list["PortfolioSnapshot"]] = relationship(
|
||||||
|
back_populates="portfolio", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("name", name="uq_portfolios_name"),)
|
||||||
|
|
||||||
|
|
||||||
|
class PortfolioSnapshot(Base):
|
||||||
|
__tablename__ = "portfolio_snapshots"
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
portfolio_id: Mapped[int] = mapped_column(ForeignKey("portfolios.id", ondelete="CASCADE"))
|
||||||
|
snapshot_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
|
total_value: Mapped[float | None] = mapped_column(Float)
|
||||||
|
cash: Mapped[float | None] = mapped_column(Float)
|
||||||
|
invested: Mapped[float | None] = mapped_column(Float)
|
||||||
|
raw_json: Mapped[dict | None] = mapped_column(JSON)
|
||||||
|
|
||||||
|
portfolio: Mapped[Portfolio] = relationship(back_populates="snapshots")
|
||||||
|
positions: Mapped[list["Position"]] = relationship(
|
||||||
|
back_populates="snapshot", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (Index("ix_snap_portfolio_at", "portfolio_id", "snapshot_at"),)
|
||||||
|
|
||||||
|
|
||||||
|
class Position(Base):
|
||||||
|
__tablename__ = "positions"
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
snapshot_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("portfolio_snapshots.id", ondelete="CASCADE")
|
||||||
|
)
|
||||||
|
ticker: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
name: Mapped[str | None] = mapped_column(String(128))
|
||||||
|
quantity: Mapped[float | None] = mapped_column(Float)
|
||||||
|
average_price: Mapped[float | None] = mapped_column(Float)
|
||||||
|
current_price: Mapped[float | None] = mapped_column(Float)
|
||||||
|
ppl: Mapped[float | None] = mapped_column(Float)
|
||||||
|
|
||||||
|
snapshot: Mapped[PortfolioSnapshot] = relationship(back_populates="positions")
|
||||||
|
|
||||||
|
|
||||||
|
class JobRun(Base):
|
||||||
|
"""One row per scheduled-job invocation; powers /api/health + the ops footer."""
|
||||||
|
__tablename__ = "job_runs"
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
|
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
status: Mapped[str] = mapped_column(String(16), default="running") # running|success|failed
|
||||||
|
error: Mapped[str | None] = mapped_column(Text)
|
||||||
|
items_written: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
|
||||||
|
__table_args__ = (Index("ix_jobruns_name_started", "name", "started_at"),)
|
||||||
0
app/routers/__init__.py
Normal file
0
app/routers/__init__.py
Normal file
582
app/routers/api.py
Normal file
582
app/routers/api.py
Normal file
|
|
@ -0,0 +1,582 @@
|
||||||
|
"""JSON / HTMX-partial endpoints. Each route content-negotiates via `?as=html`:
|
||||||
|
bare requests return Pydantic JSON, `as=html` renders the matching partial.
|
||||||
|
|
||||||
|
The bearer-token dependency applies to all routes here.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import calendar as _cal
|
||||||
|
import re
|
||||||
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
|
from sqlalchemy import desc, func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.auth import require_token
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.db import get_session, utcnow
|
||||||
|
from app.services.openrouter import (
|
||||||
|
PROMPT_VERSION,
|
||||||
|
build_chat_system_prompt,
|
||||||
|
call_openrouter,
|
||||||
|
month_start,
|
||||||
|
)
|
||||||
|
from app.templates_env import templates
|
||||||
|
from app.models import (
|
||||||
|
AICall,
|
||||||
|
Headline,
|
||||||
|
JobRun,
|
||||||
|
Portfolio,
|
||||||
|
PortfolioSnapshot,
|
||||||
|
Position,
|
||||||
|
Quote,
|
||||||
|
StrategicLog,
|
||||||
|
)
|
||||||
|
from app.schemas import (
|
||||||
|
HealthOut,
|
||||||
|
HeadlineOut,
|
||||||
|
JobStatus,
|
||||||
|
PortfolioSummary,
|
||||||
|
QuoteOut,
|
||||||
|
StrategicLogOut,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(dependencies=[Depends(require_token)])
|
||||||
|
|
||||||
|
JOB_NAMES = ("market_job", "news_job", "portfolio_job", "ai_log_job", "rollup_job")
|
||||||
|
JOB_STALE_HOURS = 2.0 # job is "warn" if its last success was >2h ago
|
||||||
|
|
||||||
|
|
||||||
|
# --- Small helpers -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _as_utc(d: datetime) -> datetime:
|
||||||
|
"""MariaDB returns naive datetimes — tag them UTC so arithmetic with
|
||||||
|
tz-aware utcnow() doesn't blow up."""
|
||||||
|
return d if d.tzinfo is not None else d.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _age_seconds(now: datetime, when: datetime | None) -> float | None:
|
||||||
|
if when is None:
|
||||||
|
return None
|
||||||
|
return (_as_utc(now) - _as_utc(when)).total_seconds()
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_age(now: datetime, when: datetime | None) -> str:
|
||||||
|
secs = _age_seconds(now, when)
|
||||||
|
if secs is None:
|
||||||
|
return "—"
|
||||||
|
if secs < 60:
|
||||||
|
return f"{int(secs)}s"
|
||||||
|
if secs < 3600:
|
||||||
|
return f"{int(secs // 60)}m"
|
||||||
|
if secs < 86400:
|
||||||
|
return f"{int(secs // 3600)}h"
|
||||||
|
return f"{int(secs // 86400)}d"
|
||||||
|
|
||||||
|
|
||||||
|
_MD_HEADER = re.compile(r"^(#{1,3})\s+(.+)$", re.MULTILINE)
|
||||||
|
_MD_BOLD = re.compile(r"\*\*([^*]+)\*\*")
|
||||||
|
|
||||||
|
|
||||||
|
def _md_to_html(text: str) -> str:
|
||||||
|
"""Tiny markdown subset for log output — headers, bold, paragraphs."""
|
||||||
|
def header_sub(m):
|
||||||
|
level = min(3, len(m.group(1))) + 1 # h2/h3/h4
|
||||||
|
return f"<h{level}>{m.group(2).strip()}</h{level}>"
|
||||||
|
out = _MD_HEADER.sub(header_sub, text)
|
||||||
|
out = _MD_BOLD.sub(r"<strong>\1</strong>", out)
|
||||||
|
# Convert blank-line-separated paragraphs to <p> blocks.
|
||||||
|
blocks = re.split(r"\n\s*\n", out.strip())
|
||||||
|
rendered: list[str] = []
|
||||||
|
for b in blocks:
|
||||||
|
if b.startswith("<h"):
|
||||||
|
rendered.append(b)
|
||||||
|
else:
|
||||||
|
rendered.append(f"<p>{b.strip().replace(chr(10), '<br>')}</p>")
|
||||||
|
return "\n".join(rendered)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Indicators --------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/indicators/{group}")
|
||||||
|
async def indicators(
|
||||||
|
group: str,
|
||||||
|
request: Request,
|
||||||
|
as_: str | None = Query(default=None, alias="as"),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
sub = (
|
||||||
|
select(Quote.symbol, func.max(Quote.fetched_at).label("mx"))
|
||||||
|
.where(Quote.group_name == group)
|
||||||
|
.group_by(Quote.symbol)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
rows = (await session.execute(
|
||||||
|
select(Quote)
|
||||||
|
.join(sub,
|
||||||
|
(Quote.symbol == sub.c.symbol) & (Quote.fetched_at == sub.c.mx))
|
||||||
|
.where(Quote.group_name == group)
|
||||||
|
.order_by(Quote.symbol)
|
||||||
|
)).scalars().all()
|
||||||
|
|
||||||
|
if as_ == "html":
|
||||||
|
has_anchor = any((r.changes or {}).get("anchor") is not None for r in rows)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "partials/indicators.html",
|
||||||
|
{"quotes": rows, "has_anchor": has_anchor},
|
||||||
|
)
|
||||||
|
return [QuoteOut.model_validate(r, from_attributes=True) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
# --- News --------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/news")
|
||||||
|
async def news_list(
|
||||||
|
request: Request,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
category: str | None = Query(None),
|
||||||
|
since_hours: float = Query(24.0, ge=0.1, le=720.0),
|
||||||
|
limit: int = Query(50, ge=1, le=500),
|
||||||
|
as_: str | None = Query(default=None, alias="as"),
|
||||||
|
):
|
||||||
|
cutoff = utcnow() - timedelta(hours=since_hours)
|
||||||
|
stmt = select(Headline).where(Headline.published_at >= cutoff)
|
||||||
|
if category:
|
||||||
|
stmt = stmt.where(Headline.category == category)
|
||||||
|
stmt = stmt.order_by(desc(Headline.published_at)).limit(limit)
|
||||||
|
rows = (await session.execute(stmt)).scalars().all()
|
||||||
|
|
||||||
|
if as_ == "html":
|
||||||
|
now = utcnow()
|
||||||
|
items = []
|
||||||
|
for h in rows:
|
||||||
|
when = _as_utc(h.published_at) if h.published_at else None
|
||||||
|
items.append({
|
||||||
|
"age": _fmt_age(now, h.published_at),
|
||||||
|
"source": h.source,
|
||||||
|
"title": h.title,
|
||||||
|
"url": h.url,
|
||||||
|
"iso": when.isoformat() if when else None,
|
||||||
|
"utc_short": when.strftime("%d %b %H:%M") + "Z" if when else "",
|
||||||
|
})
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "partials/news.html", {"headlines": items},
|
||||||
|
)
|
||||||
|
return [HeadlineOut.model_validate(r, from_attributes=True) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Strategic log -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _log_partial_payload(row: StrategicLog | None) -> dict | None:
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"content_html": _md_to_html(row.content),
|
||||||
|
"generated_at": row.generated_at,
|
||||||
|
"model": row.model,
|
||||||
|
"tone": row.tone,
|
||||||
|
"analysis": row.analysis,
|
||||||
|
"prompt_version": row.prompt_version,
|
||||||
|
"cost_usd": row.cost_usd,
|
||||||
|
"prompt_tokens": row.prompt_tokens,
|
||||||
|
"completion_tokens": row.completion_tokens,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/log/latest")
|
||||||
|
async def log_latest(
|
||||||
|
request: Request,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
as_: str | None = Query(default=None, alias="as"),
|
||||||
|
):
|
||||||
|
row = (await session.execute(
|
||||||
|
select(StrategicLog).order_by(desc(StrategicLog.generated_at)).limit(1)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
|
||||||
|
if as_ == "html":
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "partials/log.html", {"log": _log_partial_payload(row)},
|
||||||
|
)
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="No strategic log generated yet")
|
||||||
|
return StrategicLogOut.model_validate(row, from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/log/by-date/{day}")
|
||||||
|
async def log_by_date(
|
||||||
|
request: Request,
|
||||||
|
day: str,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
as_: str | None = Query(default=None, alias="as"),
|
||||||
|
):
|
||||||
|
"""Canonical log for a given day = MAX(generated_at) within that day."""
|
||||||
|
try:
|
||||||
|
target = datetime.strptime(day, "%Y-%m-%d").date()
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="day must be YYYY-MM-DD")
|
||||||
|
row = (await session.execute(
|
||||||
|
select(StrategicLog)
|
||||||
|
.where(func.date(StrategicLog.generated_at) == target)
|
||||||
|
.order_by(desc(StrategicLog.generated_at))
|
||||||
|
.limit(1)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
|
||||||
|
if as_ == "html":
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "partials/log.html", {"log": _log_partial_payload(row)},
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="No log on this date")
|
||||||
|
return StrategicLogOut.model_validate(row, from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Calendar archive --------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _month_grid(year: int, month: int) -> list[list[int | None]]:
|
||||||
|
"""6 weeks × 7 days, Monday-first; None for cells outside the month."""
|
||||||
|
weeks = _cal.Calendar(firstweekday=0).monthdayscalendar(year, month)
|
||||||
|
while len(weeks) < 6:
|
||||||
|
weeks.append([0] * 7)
|
||||||
|
return [[d or None for d in w] for w in weeks]
|
||||||
|
|
||||||
|
|
||||||
|
def _shift_month(year: int, month: int, delta: int) -> tuple[int, int]:
|
||||||
|
m = month + delta
|
||||||
|
y = year + (m - 1) // 12
|
||||||
|
m = ((m - 1) % 12) + 1
|
||||||
|
return y, m
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/log/days")
|
||||||
|
async def log_days(
|
||||||
|
request: Request,
|
||||||
|
month: str = Query(..., pattern=r"^\d{4}-\d{2}$"),
|
||||||
|
selected: str | None = Query(None),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
as_: str | None = Query(default=None, alias="as"),
|
||||||
|
):
|
||||||
|
"""Return the calendar widget for a given month with the days that have
|
||||||
|
a strategic log highlighted."""
|
||||||
|
year, mnum = (int(p) for p in month.split("-"))
|
||||||
|
month_start = date(year, mnum, 1)
|
||||||
|
next_y, next_m = _shift_month(year, mnum, 1)
|
||||||
|
month_end = date(next_y, next_m, 1)
|
||||||
|
|
||||||
|
rows = (await session.execute(
|
||||||
|
select(func.distinct(func.date(StrategicLog.generated_at)))
|
||||||
|
.where(StrategicLog.generated_at >= month_start)
|
||||||
|
.where(StrategicLog.generated_at < month_end)
|
||||||
|
)).all()
|
||||||
|
# SQLAlchemy returns date or string depending on dialect; normalise to ints.
|
||||||
|
days_with_logs: set[int] = set()
|
||||||
|
for (d,) in rows:
|
||||||
|
if isinstance(d, str):
|
||||||
|
d = datetime.strptime(d, "%Y-%m-%d").date()
|
||||||
|
days_with_logs.add(d.day)
|
||||||
|
|
||||||
|
prev_y, prev_m = _shift_month(year, mnum, -1)
|
||||||
|
sel_date: date | None = None
|
||||||
|
if selected:
|
||||||
|
try:
|
||||||
|
sel_date = datetime.strptime(selected, "%Y-%m-%d").date()
|
||||||
|
except ValueError:
|
||||||
|
sel_date = None
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"year": year,
|
||||||
|
"month": mnum,
|
||||||
|
"month_name": _cal.month_name[mnum],
|
||||||
|
"grid": _month_grid(year, mnum),
|
||||||
|
"days_with_logs": days_with_logs,
|
||||||
|
"selected": sel_date,
|
||||||
|
"today": datetime.now(timezone.utc).date(),
|
||||||
|
"prev_month": f"{prev_y:04d}-{prev_m:02d}",
|
||||||
|
"next_month": f"{next_y:04d}-{next_m:02d}",
|
||||||
|
}
|
||||||
|
|
||||||
|
if as_ == "json":
|
||||||
|
return JSONResponse({
|
||||||
|
"month": month,
|
||||||
|
"days_with_logs": sorted(days_with_logs),
|
||||||
|
"prev_month": payload["prev_month"],
|
||||||
|
"next_month": payload["next_month"],
|
||||||
|
})
|
||||||
|
return templates.TemplateResponse(request, "partials/calendar.html", payload)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Portfolios --------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/portfolios")
|
||||||
|
async def portfolios(
|
||||||
|
request: Request,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
as_: str | None = Query(default=None, alias="as"),
|
||||||
|
):
|
||||||
|
rows: list[PortfolioSummary] = []
|
||||||
|
for p in (await session.execute(select(Portfolio))).scalars().all():
|
||||||
|
snap = (await session.execute(
|
||||||
|
select(PortfolioSnapshot)
|
||||||
|
.where(PortfolioSnapshot.portfolio_id == p.id)
|
||||||
|
.order_by(desc(PortfolioSnapshot.snapshot_at))
|
||||||
|
.limit(1)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
positions: list = []
|
||||||
|
if snap is not None:
|
||||||
|
pos = (await session.execute(
|
||||||
|
select(Position).where(Position.snapshot_id == snap.id)
|
||||||
|
.order_by(desc(
|
||||||
|
(Position.quantity * Position.current_price).label("v")
|
||||||
|
))
|
||||||
|
)).scalars().all()
|
||||||
|
positions = [
|
||||||
|
{"ticker": x.ticker, "name": x.name, "quantity": x.quantity,
|
||||||
|
"average_price": x.average_price, "current_price": x.current_price,
|
||||||
|
"ppl": x.ppl,
|
||||||
|
"ppl_pct": (
|
||||||
|
(x.current_price - x.average_price) / x.average_price * 100
|
||||||
|
if x.average_price and x.current_price else None
|
||||||
|
)}
|
||||||
|
for x in pos
|
||||||
|
]
|
||||||
|
raw = (snap.raw_json or {}) if snap else {}
|
||||||
|
inv = raw.get("investments") or {}
|
||||||
|
rows.append(PortfolioSummary(
|
||||||
|
name=p.name, currency=p.currency,
|
||||||
|
snapshot_at=snap.snapshot_at if snap else None,
|
||||||
|
total_value=snap.total_value if snap else None,
|
||||||
|
cash=snap.cash if snap else None,
|
||||||
|
invested=snap.invested if snap else None,
|
||||||
|
total_cost=inv.get("totalCost"),
|
||||||
|
unrealized_ppl=inv.get("unrealizedProfitLoss"),
|
||||||
|
realized_ppl=inv.get("realizedProfitLoss"),
|
||||||
|
positions=positions,
|
||||||
|
))
|
||||||
|
if as_ == "html":
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "partials/portfolio.html", {"portfolios": rows},
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
# --- Health / ops footer -----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health", response_class=HTMLResponse, include_in_schema=False)
|
||||||
|
async def health_html(
|
||||||
|
request: Request,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
as_: str | None = Query(default=None, alias="as"),
|
||||||
|
):
|
||||||
|
"""Returns an HTML fragment by default (the ops footer); ?as=json returns the
|
||||||
|
structured object. The default is HTML because that's how the dashboard
|
||||||
|
consumes it; CLI/curl users will pass ?as=json."""
|
||||||
|
try:
|
||||||
|
await session.execute(select(func.now()))
|
||||||
|
db_ok = True
|
||||||
|
except Exception:
|
||||||
|
db_ok = False
|
||||||
|
|
||||||
|
now = utcnow()
|
||||||
|
jobs: list[dict] = []
|
||||||
|
structured: list[JobStatus] = []
|
||||||
|
for name in JOB_NAMES:
|
||||||
|
row = (await session.execute(
|
||||||
|
select(JobRun).where(JobRun.name == name)
|
||||||
|
.order_by(desc(JobRun.started_at)).limit(1)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if row is None:
|
||||||
|
jobs.append({"name": name, "led": "idle", "age": "—",
|
||||||
|
"last_finished": None})
|
||||||
|
structured.append(JobStatus(name=name))
|
||||||
|
continue
|
||||||
|
if row.status == "success":
|
||||||
|
secs = _age_seconds(now, row.finished_at or row.started_at) or 0
|
||||||
|
led = "ok" if secs < JOB_STALE_HOURS * 3600 else "warn"
|
||||||
|
elif row.status == "skipped":
|
||||||
|
led = "warn"
|
||||||
|
elif row.status == "running":
|
||||||
|
led = "warn"
|
||||||
|
else:
|
||||||
|
led = "err"
|
||||||
|
jobs.append({
|
||||||
|
"name": name, "led": led,
|
||||||
|
"age": _fmt_age(now, row.finished_at or row.started_at),
|
||||||
|
"last_finished": row.finished_at,
|
||||||
|
})
|
||||||
|
structured.append(JobStatus(
|
||||||
|
name=name, last_started=row.started_at,
|
||||||
|
last_finished=row.finished_at, status=row.status,
|
||||||
|
error=row.error, items_written=row.items_written,
|
||||||
|
))
|
||||||
|
|
||||||
|
if as_ == "json":
|
||||||
|
return JSONResponse(
|
||||||
|
HealthOut(db="ok" if db_ok else "down", jobs=structured).model_dump(mode="json")
|
||||||
|
)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "partials/ops_footer.html",
|
||||||
|
{"db_ok": db_ok, "jobs": jobs},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Chat -------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ChatMessage(BaseModel):
|
||||||
|
role: str = Field(pattern="^(user|assistant)$")
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
class ChatRequest(BaseModel):
|
||||||
|
messages: list[ChatMessage]
|
||||||
|
|
||||||
|
|
||||||
|
CHAT_REFERENCE_LINE = (
|
||||||
|
"S&P 7,501 (ATH) · VIX 18.0 · US 10y 4.45% · HY OAS 279bps · "
|
||||||
|
"Brent $109/bbl · Gold $4,651/oz · CPI 3.8% YoY"
|
||||||
|
)
|
||||||
|
THESIS_KEYWORDS_FALLBACK = [
|
||||||
|
"hormuz", "iran", "opec", "brent", "wti", "crude", "oil",
|
||||||
|
"china", "taiwan", "yuan", "fed", "inflation", "cpi", "yield",
|
||||||
|
"gold", "dollar", "yen", "saudi", "russia", "ukraine", "israel",
|
||||||
|
"nato", "defence", "defense",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def _latest_quotes_by_group_chat(session: AsyncSession) -> dict[str, list[dict]]:
|
||||||
|
sub = (
|
||||||
|
select(Quote.group_name, Quote.symbol,
|
||||||
|
func.max(Quote.fetched_at).label("mx"))
|
||||||
|
.group_by(Quote.group_name, Quote.symbol)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
rows = (await session.execute(
|
||||||
|
select(Quote).join(
|
||||||
|
sub,
|
||||||
|
(Quote.group_name == sub.c.group_name)
|
||||||
|
& (Quote.symbol == sub.c.symbol)
|
||||||
|
& (Quote.fetched_at == sub.c.mx),
|
||||||
|
).order_by(Quote.group_name, Quote.symbol)
|
||||||
|
)).scalars().all()
|
||||||
|
by_group: dict[str, list[dict]] = defaultdict(list)
|
||||||
|
for q in rows:
|
||||||
|
by_group[q.group_name].append({
|
||||||
|
"symbol": q.symbol, "label": q.label,
|
||||||
|
"price": q.price, "currency": q.currency,
|
||||||
|
"as_of": q.as_of, "changes": q.changes,
|
||||||
|
})
|
||||||
|
return by_group
|
||||||
|
|
||||||
|
|
||||||
|
async def _thesis_headlines_for_chat(session: AsyncSession, limit: int = 50) -> list[dict]:
|
||||||
|
cutoff = utcnow() - timedelta(hours=24)
|
||||||
|
rows = (await session.execute(
|
||||||
|
select(Headline)
|
||||||
|
.where(Headline.published_at >= cutoff)
|
||||||
|
.order_by(desc(Headline.published_at))
|
||||||
|
.limit(300)
|
||||||
|
)).scalars().all()
|
||||||
|
out = []
|
||||||
|
for h in rows:
|
||||||
|
if any(kw in h.title.lower() for kw in THESIS_KEYWORDS_FALLBACK):
|
||||||
|
out.append({"source": h.source, "title": h.title})
|
||||||
|
if len(out) >= limit:
|
||||||
|
break
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def _month_spend(session: AsyncSession) -> float:
|
||||||
|
total = (await session.execute(
|
||||||
|
select(func.coalesce(func.sum(AICall.cost_usd), 0.0))
|
||||||
|
.where(AICall.called_at >= month_start())
|
||||||
|
)).scalar()
|
||||||
|
return float(total or 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/chat")
|
||||||
|
async def chat(
|
||||||
|
body: ChatRequest,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""Answer one user turn given the conversation so far. Grounded on the
|
||||||
|
latest strategic log + market data + thesis-filtered headlines.
|
||||||
|
Ephemeral — the conversation lives entirely in the client; the endpoint
|
||||||
|
just records each call's cost in `ai_calls`."""
|
||||||
|
s = get_settings()
|
||||||
|
if not s.OPENROUTER_API_KEY:
|
||||||
|
raise HTTPException(status_code=503, detail="OPENROUTER_API_KEY not set")
|
||||||
|
|
||||||
|
# Monthly cost cap — same one the log job respects.
|
||||||
|
spent = await _month_spend(session)
|
||||||
|
if spent >= s.OPENROUTER_MONTHLY_CAP_USD:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail=f"Monthly OpenRouter cap reached (${spent:.2f})",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Trim runaway conversations: keep last 20 turns.
|
||||||
|
history = body.messages[-20:]
|
||||||
|
if not history or history[-1].role != "user":
|
||||||
|
raise HTTPException(status_code=400, detail="Last message must be user")
|
||||||
|
|
||||||
|
# Gather grounding context.
|
||||||
|
log_row = (await session.execute(
|
||||||
|
select(StrategicLog).order_by(desc(StrategicLog.generated_at)).limit(1)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
quotes = await _latest_quotes_by_group_chat(session)
|
||||||
|
headlines = await _thesis_headlines_for_chat(session)
|
||||||
|
|
||||||
|
system_prompt = build_chat_system_prompt(
|
||||||
|
s.CASSANDRA_TONE, s.CASSANDRA_ANALYSIS,
|
||||||
|
log_content=log_row.content if log_row else None,
|
||||||
|
log_generated_at=log_row.generated_at if log_row else None,
|
||||||
|
quotes_by_group=quotes,
|
||||||
|
headlines=headlines,
|
||||||
|
reference_line=CHAT_REFERENCE_LINE,
|
||||||
|
)
|
||||||
|
|
||||||
|
msgs = [{"role": "system", "content": system_prompt}]
|
||||||
|
for m in history:
|
||||||
|
msgs.append({"role": m.role, "content": m.content})
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||||
|
result = await call_openrouter(client, msgs, model=s.OPENROUTER_MODEL)
|
||||||
|
except Exception as e:
|
||||||
|
session.add(AICall(
|
||||||
|
model=s.OPENROUTER_MODEL, status="error", error=str(e)[:500],
|
||||||
|
))
|
||||||
|
await session.commit()
|
||||||
|
raise HTTPException(status_code=502, detail=f"OpenRouter error: {e}")
|
||||||
|
|
||||||
|
session.add(AICall(
|
||||||
|
model=result.model,
|
||||||
|
prompt_tokens=result.prompt_tokens,
|
||||||
|
completion_tokens=result.completion_tokens,
|
||||||
|
cost_usd=result.cost_usd,
|
||||||
|
status="ok",
|
||||||
|
))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": result.content,
|
||||||
|
"content_html": _md_to_html(result.content),
|
||||||
|
"prompt_tokens": result.prompt_tokens,
|
||||||
|
"completion_tokens": result.completion_tokens,
|
||||||
|
}
|
||||||
80
app/routers/pages.py
Normal file
80
app/routers/pages.py
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
"""HTML page routes — server-rendered Jinja2 with HTMX-driven partial refresh."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from sqlalchemy import desc, func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_token
|
||||||
|
from app.config import get_settings, load_groups
|
||||||
|
from app.db import get_session
|
||||||
|
from app.models import StrategicLog
|
||||||
|
from app.templates_env import templates
|
||||||
|
|
||||||
|
router = APIRouter(dependencies=[Depends(require_token)])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_class=HTMLResponse)
|
||||||
|
async def dashboard(request: Request):
|
||||||
|
s = get_settings()
|
||||||
|
groups = load_groups(s.BASELINE_TOML, s.PORTFOLIO_TOML)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"dashboard.html",
|
||||||
|
{"groups": list(groups.keys()), "anchor": s.CASSANDRA_ANCHOR_DATE},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/news", response_class=HTMLResponse)
|
||||||
|
async def news_page(request: Request):
|
||||||
|
return templates.TemplateResponse(request, "news.html", {})
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_log_date(session: AsyncSession, day: str | None) -> date:
|
||||||
|
"""If `day` is YYYY-MM-DD use it; else fall back to the date of the most
|
||||||
|
recent generated log; else today."""
|
||||||
|
if day:
|
||||||
|
try:
|
||||||
|
return datetime.strptime(day, "%Y-%m-%d").date()
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
latest = (await session.execute(
|
||||||
|
select(StrategicLog.generated_at)
|
||||||
|
.order_by(desc(StrategicLog.generated_at))
|
||||||
|
.limit(1)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if latest is not None:
|
||||||
|
return latest.date() if hasattr(latest, "date") else latest
|
||||||
|
return datetime.now(timezone.utc).date()
|
||||||
|
|
||||||
|
|
||||||
|
def _log_page_context(target: date) -> dict:
|
||||||
|
s = get_settings()
|
||||||
|
return {
|
||||||
|
"selected_iso": target.isoformat(),
|
||||||
|
"selected_month": target.strftime("%Y-%m"),
|
||||||
|
"current_tone": s.CASSANDRA_TONE.upper(),
|
||||||
|
"current_analysis": s.CASSANDRA_ANALYSIS.upper(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/log", response_class=HTMLResponse)
|
||||||
|
async def log_page(
|
||||||
|
request: Request,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
target = await _resolve_log_date(session, None)
|
||||||
|
return templates.TemplateResponse(request, "log.html", _log_page_context(target))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/log/{day}", response_class=HTMLResponse)
|
||||||
|
async def log_page_day(
|
||||||
|
request: Request,
|
||||||
|
day: str,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
target = await _resolve_log_date(session, day)
|
||||||
|
return templates.TemplateResponse(request, "log.html", _log_page_context(target))
|
||||||
64
app/scheduler_main.py
Normal file
64
app/scheduler_main.py
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
"""Scheduler container entrypoint. Runs APScheduler with 5 cron jobs, each
|
||||||
|
guarded by a MariaDB advisory lock (in job_lifecycle). Waits for the DB to be
|
||||||
|
reachable, then schedules and blocks forever."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import signal
|
||||||
|
|
||||||
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
|
|
||||||
|
from app.db import get_engine
|
||||||
|
from app.logging import configure_logging, get_logger
|
||||||
|
from app.jobs import market_job, news_job, portfolio_job, ai_log_job, rollup_job
|
||||||
|
|
||||||
|
|
||||||
|
log = get_logger("scheduler")
|
||||||
|
|
||||||
|
|
||||||
|
async def _wait_for_db(retries: int = 60, delay: float = 1.0) -> None:
|
||||||
|
engine = get_engine()
|
||||||
|
for i in range(retries):
|
||||||
|
try:
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
await conn.execute(__import__("sqlalchemy").text("SELECT 1"))
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("scheduler.db_wait", attempt=i + 1, error=str(e)[:120])
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
raise RuntimeError("DB never became reachable")
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
configure_logging()
|
||||||
|
log.info("scheduler.starting")
|
||||||
|
await _wait_for_db()
|
||||||
|
|
||||||
|
sched = AsyncIOScheduler(timezone="UTC")
|
||||||
|
sched.add_job(market_job.run, CronTrigger(minute=5), name="market_job", id="market_job")
|
||||||
|
sched.add_job(news_job.run, CronTrigger(minute=10), name="news_job", id="news_job")
|
||||||
|
sched.add_job(portfolio_job.run, CronTrigger(minute=15), name="portfolio_job", id="portfolio_job")
|
||||||
|
sched.add_job(ai_log_job.run, CronTrigger(minute=20), name="ai_log_job", id="ai_log_job")
|
||||||
|
sched.add_job(rollup_job.run, CronTrigger(hour=0, minute=5), name="rollup_job", id="rollup_job")
|
||||||
|
sched.start()
|
||||||
|
log.info("scheduler.started", jobs=[j.id for j in sched.get_jobs()])
|
||||||
|
|
||||||
|
# Stay alive until SIGTERM.
|
||||||
|
stop_event = asyncio.Event()
|
||||||
|
|
||||||
|
def _stop(*_):
|
||||||
|
log.info("scheduler.stopping")
|
||||||
|
stop_event.set()
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||||
|
loop.add_signal_handler(sig, _stop)
|
||||||
|
|
||||||
|
await stop_event.wait()
|
||||||
|
sched.shutdown(wait=False)
|
||||||
|
log.info("scheduler.stopped")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
73
app/schemas.py
Normal file
73
app/schemas.py
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
"""Pydantic response shapes for the JSON API."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class QuoteOut(BaseModel):
|
||||||
|
symbol: str
|
||||||
|
source: str
|
||||||
|
label: str
|
||||||
|
group_name: str
|
||||||
|
price: float | None
|
||||||
|
currency: str | None
|
||||||
|
as_of: str | None
|
||||||
|
changes: dict | None
|
||||||
|
fetched_at: datetime
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class HeadlineOut(BaseModel):
|
||||||
|
source: str
|
||||||
|
category: str
|
||||||
|
title: str
|
||||||
|
url: str
|
||||||
|
published_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class JobStatus(BaseModel):
|
||||||
|
name: str
|
||||||
|
last_started: datetime | None = None
|
||||||
|
last_finished: datetime | None = None
|
||||||
|
status: str | None = None
|
||||||
|
error: str | None = None
|
||||||
|
items_written: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class HealthOut(BaseModel):
|
||||||
|
db: str # "ok" | "down"
|
||||||
|
jobs: list[JobStatus]
|
||||||
|
|
||||||
|
|
||||||
|
class StrategicLogOut(BaseModel):
|
||||||
|
generated_at: datetime
|
||||||
|
model: str
|
||||||
|
anchor_date: str | None
|
||||||
|
content: str
|
||||||
|
prompt_tokens: int | None
|
||||||
|
completion_tokens: int | None
|
||||||
|
|
||||||
|
|
||||||
|
class PositionOut(BaseModel):
|
||||||
|
ticker: str
|
||||||
|
name: str | None
|
||||||
|
quantity: float | None
|
||||||
|
average_price: float | None
|
||||||
|
current_price: float | None
|
||||||
|
ppl: float | None
|
||||||
|
ppl_pct: float | None = None # (current-avg)/avg * 100 — currency-neutral
|
||||||
|
|
||||||
|
|
||||||
|
class PortfolioSummary(BaseModel):
|
||||||
|
name: str
|
||||||
|
snapshot_at: datetime | None
|
||||||
|
currency: str
|
||||||
|
total_value: float | None
|
||||||
|
cash: float | None
|
||||||
|
invested: float | None
|
||||||
|
total_cost: float | None = None
|
||||||
|
unrealized_ppl: float | None = None
|
||||||
|
realized_ppl: float | None = None
|
||||||
|
positions: list[PositionOut] = []
|
||||||
0
app/services/__init__.py
Normal file
0
app/services/__init__.py
Normal file
32
app/services/feeds_bootstrap.py
Normal file
32
app/services/feeds_bootstrap.py
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
"""On startup, ensure every feed in default.toml/portfolio.toml has a row in
|
||||||
|
the `feeds` table. Existing rows are left untouched so admin overrides
|
||||||
|
(enabled=0 to mute a noisy source) survive restarts."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.config import load_feeds, get_settings
|
||||||
|
from app.models import Feed
|
||||||
|
|
||||||
|
|
||||||
|
async def bootstrap_feeds(session: AsyncSession) -> int:
|
||||||
|
s = get_settings()
|
||||||
|
declared = load_feeds(s.BASELINE_TOML, s.PORTFOLIO_TOML)
|
||||||
|
existing = {
|
||||||
|
(f.category, f.name): f
|
||||||
|
for f in (await session.execute(select(Feed))).scalars().all()
|
||||||
|
}
|
||||||
|
inserted = 0
|
||||||
|
for category, items in declared.items():
|
||||||
|
for name, url in items:
|
||||||
|
key = (category, name)
|
||||||
|
if key in existing:
|
||||||
|
# Refresh URL if it changed in TOML; preserve enabled/failures.
|
||||||
|
if existing[key].url != url:
|
||||||
|
existing[key].url = url
|
||||||
|
continue
|
||||||
|
session.add(Feed(category=category, name=name, url=url))
|
||||||
|
inserted += 1
|
||||||
|
await session.commit()
|
||||||
|
return inserted
|
||||||
285
app/services/market.py
Normal file
285
app/services/market.py
Normal file
|
|
@ -0,0 +1,285 @@
|
||||||
|
"""Market data fetchers — Yahoo Finance (no auth) and FRED (key required).
|
||||||
|
|
||||||
|
Ported from /home/gg/ownCloud/Family/Finances/Wealth/market_pulse.py.
|
||||||
|
Logic preserved verbatim where possible; HTTP switched to httpx.AsyncClient.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
YAHOO_CHART = "https://query1.finance.yahoo.com/v8/finance/chart/{symbol}"
|
||||||
|
FRED_API = "https://api.stlouisfed.org/fred/series/observations"
|
||||||
|
UA = {"User-Agent": "Mozilla/5.0 (cassandra) Python/httpx"}
|
||||||
|
|
||||||
|
|
||||||
|
# --- In-flight data shape (services return these; jobs persist to DB) --------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Quote:
|
||||||
|
symbol: str
|
||||||
|
source: str
|
||||||
|
label: str
|
||||||
|
note: str
|
||||||
|
price: float | None
|
||||||
|
currency: str | None
|
||||||
|
as_of: str | None
|
||||||
|
changes: dict[str, float | None] = field(default_factory=dict)
|
||||||
|
price_base: float | None = None
|
||||||
|
currency_base: str | None = None
|
||||||
|
anchor_date: str | None = None
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _pct(old: float | None, new: float | None) -> float | None:
|
||||||
|
if old is None or new is None or old == 0:
|
||||||
|
return None
|
||||||
|
return (new - old) / old * 100.0
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(s: str) -> datetime:
|
||||||
|
return datetime.strptime(s, "%Y-%m-%d")
|
||||||
|
|
||||||
|
|
||||||
|
def _yahoo_range_covering(anchor: str | None) -> str:
|
||||||
|
if not anchor:
|
||||||
|
return "1y"
|
||||||
|
days = (datetime.now(timezone.utc).date() - _parse_date(anchor).date()).days
|
||||||
|
if days <= 360:
|
||||||
|
return "1y"
|
||||||
|
if days <= 1800:
|
||||||
|
return "5y"
|
||||||
|
if days <= 3600:
|
||||||
|
return "10y"
|
||||||
|
return "max"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Fetchers -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_yahoo(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
symbol: str,
|
||||||
|
label: str,
|
||||||
|
note: str,
|
||||||
|
anchor: str | None = None,
|
||||||
|
) -> Quote:
|
||||||
|
"""Latest close + %1d / %1m / %1y / (optional) %anchor from Yahoo's chart endpoint."""
|
||||||
|
try:
|
||||||
|
r = await client.get(
|
||||||
|
YAHOO_CHART.format(symbol=symbol),
|
||||||
|
params={"interval": "1d", "range": _yahoo_range_covering(anchor),
|
||||||
|
"includePrePost": "false"},
|
||||||
|
headers=UA,
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
result = r.json()["chart"]["result"]
|
||||||
|
if not result:
|
||||||
|
raise ValueError("empty result")
|
||||||
|
res = result[0]
|
||||||
|
meta = res["meta"]
|
||||||
|
price = meta.get("regularMarketPrice")
|
||||||
|
prev_session = meta.get("previousClose")
|
||||||
|
timestamps = res.get("timestamp") or []
|
||||||
|
closes = (res.get("indicators", {}).get("quote") or [{}])[0].get("close") or []
|
||||||
|
series = [(t, c) for t, c in zip(timestamps, closes) if c is not None]
|
||||||
|
if not series:
|
||||||
|
raise ValueError("no closes in series")
|
||||||
|
last_ts = series[-1][0]
|
||||||
|
if prev_session is None and len(series) >= 2:
|
||||||
|
prev_session = series[-2][1]
|
||||||
|
chg_1m = _pct(series[-22][1], price) if len(series) >= 22 else None
|
||||||
|
chg_1y = _pct(series[0][1], price) if len(series) >= 2 else None
|
||||||
|
changes: dict[str, float | None] = {
|
||||||
|
"1d": _pct(prev_session, price),
|
||||||
|
"1m": chg_1m,
|
||||||
|
"1y": chg_1y,
|
||||||
|
}
|
||||||
|
anchor_used: str | None = None
|
||||||
|
if anchor:
|
||||||
|
anchor_ts = int(_parse_date(anchor).replace(tzinfo=timezone.utc).timestamp())
|
||||||
|
anchor_close = next((c for t, c in series if t >= anchor_ts), None)
|
||||||
|
anchor_actual_ts = next((t for t, c in series if t >= anchor_ts), None)
|
||||||
|
changes["anchor"] = _pct(anchor_close, price)
|
||||||
|
if anchor_actual_ts:
|
||||||
|
anchor_used = datetime.fromtimestamp(
|
||||||
|
anchor_actual_ts, timezone.utc
|
||||||
|
).strftime("%Y-%m-%d")
|
||||||
|
return Quote(
|
||||||
|
symbol=symbol,
|
||||||
|
source="yahoo",
|
||||||
|
label=label,
|
||||||
|
note=note,
|
||||||
|
price=price,
|
||||||
|
currency=meta.get("currency"),
|
||||||
|
as_of=datetime.fromtimestamp(last_ts, timezone.utc).strftime("%Y-%m-%d"),
|
||||||
|
changes=changes,
|
||||||
|
anchor_date=anchor_used,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return Quote(symbol, "yahoo", label, note, None, None, None, error=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_fred(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
symbol: str,
|
||||||
|
label: str,
|
||||||
|
note: str,
|
||||||
|
anchor: str | None = None,
|
||||||
|
) -> Quote:
|
||||||
|
"""Latest value + 1d/1m/1y deltas from a FRED series. Requires FRED_API_KEY."""
|
||||||
|
api_key = get_settings().FRED_API_KEY
|
||||||
|
if not api_key:
|
||||||
|
return Quote(symbol, "fred", label, note, None, None, None,
|
||||||
|
error="FRED_API_KEY not set")
|
||||||
|
try:
|
||||||
|
r = await client.get(
|
||||||
|
FRED_API,
|
||||||
|
params={
|
||||||
|
"series_id": symbol,
|
||||||
|
"api_key": api_key,
|
||||||
|
"file_type": "json",
|
||||||
|
"sort_order": "desc",
|
||||||
|
"limit": 5000 if anchor else 800,
|
||||||
|
},
|
||||||
|
headers=UA,
|
||||||
|
timeout=20,
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
obs = r.json().get("observations", [])
|
||||||
|
data = [
|
||||||
|
(o["date"], float(o["value"]))
|
||||||
|
for o in obs if o.get("value") not in (".", "", None)
|
||||||
|
]
|
||||||
|
if not data:
|
||||||
|
raise ValueError("no observations")
|
||||||
|
last_date, last_val = data[0]
|
||||||
|
# CPI levels reported as YoY%.
|
||||||
|
if symbol in ("CPIAUCSL", "CPILFESL") and len(data) >= 13:
|
||||||
|
yoy = _pct(data[12][1], last_val)
|
||||||
|
changes: dict[str, float | None] = {"1d": None, "1m": None, "1y": None}
|
||||||
|
anchor_used: str | None = None
|
||||||
|
if anchor:
|
||||||
|
anchor_dt = _parse_date(anchor)
|
||||||
|
anchor_idx = next(
|
||||||
|
(i for i, (d, _) in enumerate(data) if _parse_date(d) <= anchor_dt),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if anchor_idx is not None and anchor_idx + 12 < len(data):
|
||||||
|
yoy_then = _pct(data[anchor_idx + 12][1], data[anchor_idx][1])
|
||||||
|
changes["anchor"] = (yoy or 0) - (yoy_then or 0)
|
||||||
|
anchor_used = anchor
|
||||||
|
return Quote(symbol, "fred", label, note, yoy, "%", last_date,
|
||||||
|
changes=changes, anchor_date=anchor_used)
|
||||||
|
|
||||||
|
last_dt = _parse_date(last_date)
|
||||||
|
|
||||||
|
def find_back(min_days: int) -> float | None:
|
||||||
|
for d, v in data[1:]:
|
||||||
|
if (last_dt - _parse_date(d)).days >= min_days:
|
||||||
|
return v
|
||||||
|
return None
|
||||||
|
|
||||||
|
prev_val = data[1][1] if len(data) >= 2 else None
|
||||||
|
changes = {
|
||||||
|
"1d": _pct(prev_val, last_val),
|
||||||
|
"1m": _pct(find_back(28), last_val),
|
||||||
|
"1y": _pct(find_back(360), last_val),
|
||||||
|
}
|
||||||
|
anchor_used = None
|
||||||
|
if anchor:
|
||||||
|
anchor_dt = _parse_date(anchor)
|
||||||
|
anchor_obs = next(
|
||||||
|
((d, v) for d, v in data if _parse_date(d) <= anchor_dt), None
|
||||||
|
)
|
||||||
|
if anchor_obs:
|
||||||
|
changes["anchor"] = _pct(anchor_obs[1], last_val)
|
||||||
|
anchor_used = anchor_obs[0]
|
||||||
|
return Quote(
|
||||||
|
symbol=symbol, source="fred", label=label, note=note,
|
||||||
|
price=last_val, currency=None, as_of=last_date,
|
||||||
|
changes=changes, anchor_date=anchor_used,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return Quote(symbol, "fred", label, note, None, None, None, error=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# --- Source registry ----------------------------------------------------------
|
||||||
|
|
||||||
|
FetcherFn = Callable[..., "Quote"]
|
||||||
|
SOURCES: dict[str, FetcherFn] = {"yahoo": fetch_yahoo, "FRED": fetch_fred}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_symbol(symbol: str) -> tuple[FetcherFn, str]:
|
||||||
|
if ":" in symbol:
|
||||||
|
prefix, _, ident = symbol.partition(":")
|
||||||
|
if prefix in SOURCES:
|
||||||
|
return SOURCES[prefix], ident
|
||||||
|
return SOURCES["yahoo"], symbol
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
symbol: str,
|
||||||
|
label: str,
|
||||||
|
note: str,
|
||||||
|
anchor: str | None = None,
|
||||||
|
) -> Quote:
|
||||||
|
fn, ident = parse_symbol(symbol)
|
||||||
|
return await fn(client, ident, label, note, anchor)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Currency normalisation ---------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_fx_rate(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
from_ccy: str,
|
||||||
|
to_ccy: str,
|
||||||
|
cache: dict[tuple[str, str], float | None],
|
||||||
|
) -> float | None:
|
||||||
|
if from_ccy == to_ccy:
|
||||||
|
return 1.0
|
||||||
|
if from_ccy == "GBp": # LSE pence
|
||||||
|
gbp = await _get_fx_rate(client, "GBP", to_ccy, cache)
|
||||||
|
return None if gbp is None else 0.01 * gbp
|
||||||
|
key = (from_ccy, to_ccy)
|
||||||
|
if key in cache:
|
||||||
|
return cache[key]
|
||||||
|
pair = f"{from_ccy}{to_ccy}=X"
|
||||||
|
try:
|
||||||
|
r = await client.get(
|
||||||
|
YAHOO_CHART.format(symbol=pair),
|
||||||
|
params={"interval": "1d", "range": "5d"},
|
||||||
|
headers=UA, timeout=10,
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
rate = r.json()["chart"]["result"][0]["meta"].get("regularMarketPrice")
|
||||||
|
cache[key] = rate
|
||||||
|
return rate
|
||||||
|
except Exception:
|
||||||
|
cache[key] = None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def normalise_to_base(
|
||||||
|
client: httpx.AsyncClient, quotes: list[Quote], base: str
|
||||||
|
) -> None:
|
||||||
|
cache: dict[tuple[str, str], float | None] = {}
|
||||||
|
base = base.upper()
|
||||||
|
for q in quotes:
|
||||||
|
if q.price is None or not q.currency:
|
||||||
|
continue
|
||||||
|
rate = await _get_fx_rate(client, q.currency, base, cache)
|
||||||
|
if rate is None:
|
||||||
|
continue
|
||||||
|
q.price_base = q.price * rate
|
||||||
|
q.currency_base = base
|
||||||
167
app/services/news.py
Normal file
167
app/services/news.py
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
"""RSS feed aggregator + Yahoo per-ticker news.
|
||||||
|
|
||||||
|
Ported from /home/gg/ownCloud/Family/Finances/Wealth/flash_news.py — same
|
||||||
|
parsing, dedupe, and ticker-name resolution logic, async HTTP via httpx.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from email.utils import parsedate_to_datetime
|
||||||
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
UA = {"User-Agent": "Mozilla/5.0 (cassandra) Python/httpx"}
|
||||||
|
ATOM_NS = "{http://www.w3.org/2005/Atom}"
|
||||||
|
DC_NS = "{http://purl.org/dc/elements/1.1/}"
|
||||||
|
YAHOO_NEWS = "https://query1.finance.yahoo.com/v1/finance/search"
|
||||||
|
YAHOO_CHART = "https://query1.finance.yahoo.com/v8/finance/chart/{symbol}"
|
||||||
|
|
||||||
|
_NAME_STOPWORDS = {"plc", "corp", "inc", "ltd", "fund", "etf", "ucits",
|
||||||
|
"class", "shares", "trust", "the", "and", "of"}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Headline:
|
||||||
|
when: datetime # tz-aware UTC
|
||||||
|
source: str
|
||||||
|
category: str
|
||||||
|
title: str
|
||||||
|
url: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def fingerprint(self) -> str:
|
||||||
|
"""sha1 of normalised title — used as DB UNIQUE."""
|
||||||
|
norm = " ".join(self.title.lower().split())
|
||||||
|
return hashlib.sha1(norm.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(s: str | None) -> datetime | None:
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return parsedate_to_datetime(s).astimezone(timezone.utc)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(s.replace("Z", "+00:00")).astimezone(timezone.utc)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_feed(name: str, category: str, xml_bytes: bytes) -> list[Headline]:
|
||||||
|
try:
|
||||||
|
root = ET.fromstring(xml_bytes)
|
||||||
|
except ET.ParseError:
|
||||||
|
return []
|
||||||
|
out: list[Headline] = []
|
||||||
|
rss_items = root.findall(".//item")
|
||||||
|
if rss_items:
|
||||||
|
for it in rss_items:
|
||||||
|
title = (it.findtext("title") or "").strip()
|
||||||
|
link = (it.findtext("link") or "").strip()
|
||||||
|
pub = it.findtext("pubDate") or it.findtext(f"{DC_NS}date")
|
||||||
|
when = _parse_date(pub) or datetime.now(timezone.utc)
|
||||||
|
if title and link:
|
||||||
|
out.append(Headline(when, name, category, title, link))
|
||||||
|
else:
|
||||||
|
for entry in root.findall(f".//{ATOM_NS}entry"):
|
||||||
|
title = (entry.findtext(f"{ATOM_NS}title") or "").strip()
|
||||||
|
link_el = entry.find(f"{ATOM_NS}link")
|
||||||
|
link = (link_el.get("href") if link_el is not None else "") or ""
|
||||||
|
pub = entry.findtext(f"{ATOM_NS}published") or entry.findtext(f"{ATOM_NS}updated")
|
||||||
|
when = _parse_date(pub) or datetime.now(timezone.utc)
|
||||||
|
if title and link:
|
||||||
|
out.append(Headline(when, name, category, title, link.strip()))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_feed(
|
||||||
|
client: httpx.AsyncClient, name: str, category: str, url: str
|
||||||
|
) -> list[Headline]:
|
||||||
|
"""Returns headlines on success, empty list on any failure (caller logs)."""
|
||||||
|
r = await client.get(url, headers=UA, timeout=12)
|
||||||
|
r.raise_for_status()
|
||||||
|
return parse_feed(name, category, r.content)
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_ticker_name(client: httpx.AsyncClient, ticker: str) -> str:
|
||||||
|
"""Look up the company longName so news search hits headlines that actually
|
||||||
|
mention the company rather than matching the literal ticker string."""
|
||||||
|
try:
|
||||||
|
r = await client.get(
|
||||||
|
YAHOO_CHART.format(symbol=ticker),
|
||||||
|
params={"interval": "1d", "range": "5d"},
|
||||||
|
headers=UA, timeout=8,
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
meta = r.json()["chart"]["result"][0]["meta"]
|
||||||
|
return meta.get("longName") or meta.get("shortName") or ticker
|
||||||
|
except Exception:
|
||||||
|
return ticker
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_yahoo_news(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
ticker: str,
|
||||||
|
count: int = 10,
|
||||||
|
query_override: str | None = None,
|
||||||
|
) -> list[Headline]:
|
||||||
|
"""Filtered Yahoo per-ticker headlines. Niche UCITS ETFs return empty
|
||||||
|
rather than the generic firehose because of the token-overlap guard.
|
||||||
|
|
||||||
|
If `query_override` is provided (e.g. a name already fetched from
|
||||||
|
Trading 212 instruments), it skips the Yahoo chart-meta round-trip."""
|
||||||
|
query = query_override or await _resolve_ticker_name(client, ticker)
|
||||||
|
tokens = [
|
||||||
|
t.lower() for t in re.split(r"[\s.]+", query)
|
||||||
|
if len(t) >= 3 and t.lower() not in _NAME_STOPWORDS
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
r = await client.get(
|
||||||
|
YAHOO_NEWS,
|
||||||
|
params={"q": query, "newsCount": count, "quotesCount": 0},
|
||||||
|
headers=UA, timeout=10,
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
items = r.json().get("news", [])
|
||||||
|
out: list[Headline] = []
|
||||||
|
for it in items:
|
||||||
|
title = (it.get("title") or "").strip()
|
||||||
|
link = (it.get("link") or "").strip()
|
||||||
|
if not (title and link):
|
||||||
|
continue
|
||||||
|
if tokens and not any(t in title.lower() for t in tokens):
|
||||||
|
continue
|
||||||
|
ts = it.get("providerPublishTime")
|
||||||
|
when = (
|
||||||
|
datetime.fromtimestamp(ts, timezone.utc) if ts
|
||||||
|
else datetime.now(timezone.utc)
|
||||||
|
)
|
||||||
|
out.append(Headline(when, f"Yahoo:{ticker}", "ticker", title, link))
|
||||||
|
return out
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def dedupe(headlines: list[Headline]) -> list[Headline]:
|
||||||
|
"""URL first, then normalised title — same logic as the prototype."""
|
||||||
|
seen_url: set[str] = set()
|
||||||
|
seen_fp: set[str] = set()
|
||||||
|
out: list[Headline] = []
|
||||||
|
for h in headlines:
|
||||||
|
if h.url in seen_url or h.fingerprint in seen_fp:
|
||||||
|
continue
|
||||||
|
seen_url.add(h.url)
|
||||||
|
seen_fp.add(h.fingerprint)
|
||||||
|
out.append(h)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def matches_any(text: str, keywords: list[str]) -> bool:
|
||||||
|
t = text.lower()
|
||||||
|
return any(kw in t for kw in keywords)
|
||||||
272
app/services/openrouter.py
Normal file
272
app/services/openrouter.py
Normal file
|
|
@ -0,0 +1,272 @@
|
||||||
|
"""Strategic-log generator — DB-fed, OpenRouter-backed.
|
||||||
|
|
||||||
|
Ported from /home/gg/ownCloud/Family/Finances/Wealth/strategic_log.py. The
|
||||||
|
system prompt is preserved verbatim (the voice we converged on). The user
|
||||||
|
prompt is now built from DB rows, not from subprocess JSON dumps.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
|
||||||
|
# Bump when the composed prompt changes meaningfully. Stored on every
|
||||||
|
# StrategicLog row so historical logs can be linked to the prompt that produced
|
||||||
|
# them.
|
||||||
|
PROMPT_VERSION = 3
|
||||||
|
|
||||||
|
|
||||||
|
# --- Core: invariant across tone/analysis settings ----------------------------
|
||||||
|
|
||||||
|
_CORE = """You are Cassandra, writing a single daily strategic markets log \
|
||||||
|
for one specific investor. Synthesis, not exposition.
|
||||||
|
|
||||||
|
# Lens
|
||||||
|
- Geopolitics → markets is the primary causal chain. For each sector move, \
|
||||||
|
ask: geopolitical, cyclical, or idiosyncratic. Label it.
|
||||||
|
- Divergences and contradictions are where the information is. Hunt for them.
|
||||||
|
- Absence of expected moves is signal. If the thesis predicted a reaction \
|
||||||
|
that didn't happen, that's more interesting than the reactions that did.
|
||||||
|
- Compare live readings against any reference snapshots provided.
|
||||||
|
|
||||||
|
# Multi-source news
|
||||||
|
- When state-aligned outlets (Xinhua, China Daily, RT) and Western outlets \
|
||||||
|
cover the same event, read the gap in framing — that's the data.
|
||||||
|
- News matters only insofar as it changes a market read. Color without \
|
||||||
|
implications is filler.
|
||||||
|
|
||||||
|
# Structure
|
||||||
|
- One-line date header + any anchor framing (e.g. "Week 11 since Hormuz").
|
||||||
|
- Immediately after the date header — with **nothing** in between — write a \
|
||||||
|
TL;DR. Format it as:
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
|
||||||
|
One concise paragraph of 2-3 sentences, **≤60 words total**, naming the \
|
||||||
|
single most important read or divergence of the day with concrete numbers. \
|
||||||
|
This is what a reader who only has 10 seconds sees. Don't waste it on the \
|
||||||
|
weather or generic context.
|
||||||
|
|
||||||
|
- Then 4-6 paragraphs, each anchored on a sleeve, sector, or theme. Concrete \
|
||||||
|
numbers in every paragraph. No section over ~150 words.
|
||||||
|
- One paragraph synthesising the news flow into a market read.
|
||||||
|
- End with a watch list: 3-5 specific items to track in the next week, \
|
||||||
|
each one sentence.
|
||||||
|
|
||||||
|
# Discipline
|
||||||
|
- No emojis, no marketing language, no "concerning" or "unprecedented" \
|
||||||
|
without a specific number behind it.
|
||||||
|
- Concrete > vague. "AMD +113% since the anchor" beats "AI stocks up sharply".
|
||||||
|
- Distinguish "the thesis predicted X and X happened" from "the thesis \
|
||||||
|
predicted X and X did not happen". Both are useful; conflating them is not.
|
||||||
|
- Don't repeat the same point in different words across paragraphs.
|
||||||
|
- No buy/sell recommendations. Triggers are pre-set elsewhere; your job is \
|
||||||
|
to report whether reality is confirming, modifying, or refuting the thesis."""
|
||||||
|
|
||||||
|
|
||||||
|
# --- Tone: audience-shaping block --------------------------------------------
|
||||||
|
|
||||||
|
_TONE: dict[str, str] = {
|
||||||
|
"NOVICE": """# Audience: novice
|
||||||
|
The reader is new to markets. Define jargon the first time it appears (a \
|
||||||
|
short clause in parentheses is fine). Avoid ticker shorthand without context. \
|
||||||
|
Prefer everyday phrasing: "the price of US government debt fell, pushing \
|
||||||
|
yields higher" rather than "the long end backed up". Keep paragraphs short. \
|
||||||
|
Target ~600 words instead of ~800 so density stays digestible.""",
|
||||||
|
|
||||||
|
"INTERMEDIATE": """# Audience: intermediate
|
||||||
|
Assume the reader knows market basics (yield curves, breakevens, HY OAS, \
|
||||||
|
sector ETFs). Use common terms without defining them, but stay clear of \
|
||||||
|
deep institutional shorthand ("the belly", "duration trade", "carry pickup"). \
|
||||||
|
Target ~700 words — lean and clear, no padding.""",
|
||||||
|
|
||||||
|
"PRO": """# Audience: professional
|
||||||
|
Assume institutional vocabulary. Use dense market shorthand freely. Don't \
|
||||||
|
define standard terms. Target ~800 words. Density of insight > readability.""",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Analysis: forward-vs-backward focus -------------------------------------
|
||||||
|
|
||||||
|
_ANALYSIS: dict[str, str] = {
|
||||||
|
"DRY": """# Analysis style: dry
|
||||||
|
Report what happened. Identify divergences and contradictions. Compare to \
|
||||||
|
references. Do not speculate on what comes next. Forward-looking statements \
|
||||||
|
are limited to "what would invalidate the read" — never "we expect X to \
|
||||||
|
happen". The watch list contains items to monitor, not predictions.""",
|
||||||
|
|
||||||
|
"SPECULATIVE": """# Analysis style: speculative
|
||||||
|
Report what happened, then explicitly explore forward scenarios. For each \
|
||||||
|
significant sector or theme, sketch a 1-4 week scenario set: the base case \
|
||||||
|
(what the data suggests), a contrarian case (what would invalidate it), and \
|
||||||
|
what tape signal would tip you from one to the other. Be explicit about \
|
||||||
|
uncertainty — say "the base case is" not "X will happen". The watch list is \
|
||||||
|
the trip-wires that decide between scenarios.""",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_system_prompt(tone: str, analysis: str) -> str:
|
||||||
|
"""Compose the system prompt from the chosen audience and analysis style."""
|
||||||
|
tone_block = _TONE.get(tone.upper(), _TONE["INTERMEDIATE"])
|
||||||
|
analysis_block = _ANALYSIS.get(analysis.upper(), _ANALYSIS["SPECULATIVE"])
|
||||||
|
return "\n\n".join([_CORE, tone_block, analysis_block])
|
||||||
|
|
||||||
|
|
||||||
|
# Backwards-compat: a default-composed SYSTEM_PROMPT for tests / callers that
|
||||||
|
# don't yet pass tone/analysis. New callers should call build_system_prompt().
|
||||||
|
SYSTEM_PROMPT = build_system_prompt("INTERMEDIATE", "SPECULATIVE")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Chat-mode overrides (sidebar on /log) -----------------------------------
|
||||||
|
|
||||||
|
_CHAT_OVERRIDES = """# Chat mode (overrides the log-structure rules above)
|
||||||
|
You are NOT writing a daily log right now. The user is asking a specific
|
||||||
|
question via the chat sidebar.
|
||||||
|
- Forget the date header, TL;DR, sectional structure, and watch list. Just answer.
|
||||||
|
- Typical response: 200-400 words. Longer only if the question genuinely
|
||||||
|
warrants it.
|
||||||
|
- Cite specific numbers and named headlines from the reference materials
|
||||||
|
below whenever relevant. If a number isn't in the context, don't invent it.
|
||||||
|
- If a question is outside the provided context (e.g. asking about a stock or
|
||||||
|
event not in the data), say so plainly rather than speculating from prior
|
||||||
|
knowledge.
|
||||||
|
- No buy/sell recommendations. If asked, redirect to thesis and scenarios.
|
||||||
|
- Keep the same audience and analysis discipline established above."""
|
||||||
|
|
||||||
|
|
||||||
|
def build_chat_system_prompt(
|
||||||
|
tone: str,
|
||||||
|
analysis: str,
|
||||||
|
*,
|
||||||
|
log_content: str | None,
|
||||||
|
log_generated_at: datetime | None,
|
||||||
|
quotes_by_group: dict[str, list[dict]],
|
||||||
|
headlines: list[dict],
|
||||||
|
reference_line: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Composed system prompt for the /log chat sidebar. Carries the user's
|
||||||
|
chosen tone + analysis style and inlines the latest log + market data +
|
||||||
|
headlines as reference material the model can cite from."""
|
||||||
|
parts = [build_system_prompt(tone, analysis), "", _CHAT_OVERRIDES, ""]
|
||||||
|
if reference_line:
|
||||||
|
parts.append(f"# Doc reference snapshot\n{reference_line}\n")
|
||||||
|
if log_content:
|
||||||
|
ts = log_generated_at.strftime("%Y-%m-%d %H:%M UTC") if log_generated_at else "n/a"
|
||||||
|
parts.append(f"# Latest strategic log (generated {ts})\n\n{log_content}\n")
|
||||||
|
parts.append("# Live market data")
|
||||||
|
parts.append(
|
||||||
|
"```json\n" + json.dumps(quotes_by_group, indent=2, default=str)[:25000] + "\n```"
|
||||||
|
)
|
||||||
|
parts.append("# Recent headlines (last 24h, thesis-filtered top 50)")
|
||||||
|
for h in headlines[:50]:
|
||||||
|
parts.append(f"- [{h['source']}] {h['title']}")
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LogResult:
|
||||||
|
content: str
|
||||||
|
model: str
|
||||||
|
prompt_tokens: int | None
|
||||||
|
completion_tokens: int | None
|
||||||
|
cost_usd: float | None
|
||||||
|
|
||||||
|
|
||||||
|
def build_user_prompt(
|
||||||
|
*,
|
||||||
|
today: datetime,
|
||||||
|
anchor: str | None,
|
||||||
|
quotes_by_group: dict[str, list[dict]],
|
||||||
|
headlines_by_bucket: dict[str, list[dict]],
|
||||||
|
reference_line: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Assemble the user message from already-fetched-and-persisted data."""
|
||||||
|
parts = [f"# Strategic log request — {today.strftime('%Y-%m-%d')}"]
|
||||||
|
if anchor:
|
||||||
|
parts.append(f"Anchor reference date: {anchor}")
|
||||||
|
if reference_line:
|
||||||
|
parts.append(
|
||||||
|
"\n## Reference snapshot (when the macro thesis was authored)"
|
||||||
|
f"\n{reference_line}\nCompare live readings against it."
|
||||||
|
)
|
||||||
|
parts.append("\n## Live market data (per group)")
|
||||||
|
parts.append("```json\n" + json.dumps(quotes_by_group, indent=2, default=str) + "\n```")
|
||||||
|
parts.append("\n## News flow (last 24h, filtered by bucket)")
|
||||||
|
for label, items in headlines_by_bucket.items():
|
||||||
|
if not items:
|
||||||
|
continue
|
||||||
|
parts.append(f"\n### {label.upper()}")
|
||||||
|
for h in items[:30]:
|
||||||
|
parts.append(f"- [{h['when'][:16].replace('T',' ')}] [{h['source']}] {h['title']}")
|
||||||
|
parts.append(
|
||||||
|
"\n## Task\nWrite the daily strategic log in ~800 words, following "
|
||||||
|
"the discipline in the system prompt. No preamble; begin directly "
|
||||||
|
"with the date header."
|
||||||
|
)
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
@retry(
|
||||||
|
reraise=True,
|
||||||
|
stop=stop_after_attempt(3),
|
||||||
|
wait=wait_exponential(multiplier=2, min=2, max=30),
|
||||||
|
retry=retry_if_exception_type((httpx.HTTPStatusError, httpx.TransportError)),
|
||||||
|
)
|
||||||
|
async def call_openrouter(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
messages: list[dict],
|
||||||
|
model: str,
|
||||||
|
max_tokens: int = 4000,
|
||||||
|
) -> LogResult:
|
||||||
|
s = get_settings()
|
||||||
|
if not s.OPENROUTER_API_KEY:
|
||||||
|
raise RuntimeError("OPENROUTER_API_KEY not set")
|
||||||
|
r = await client.post(
|
||||||
|
OPENROUTER_URL,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {s.OPENROUTER_API_KEY}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"HTTP-Referer": "https://github.com/local/cassandra",
|
||||||
|
"X-Title": "Cassandra",
|
||||||
|
},
|
||||||
|
json={"model": model, "messages": messages, "max_tokens": max_tokens},
|
||||||
|
timeout=180,
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
msg = data["choices"][0]["message"]
|
||||||
|
# Some providers return null content + populated `reasoning` for thinking
|
||||||
|
# models, or null content when finish_reason=length cut off the response.
|
||||||
|
content = msg.get("content") or msg.get("reasoning")
|
||||||
|
if not content:
|
||||||
|
finish = data["choices"][0].get("finish_reason")
|
||||||
|
raise RuntimeError(
|
||||||
|
f"OpenRouter returned empty content (finish_reason={finish}, "
|
||||||
|
f"model={model}, max_tokens={max_tokens})"
|
||||||
|
)
|
||||||
|
usage = data.get("usage") or {}
|
||||||
|
return LogResult(
|
||||||
|
content=content,
|
||||||
|
model=model,
|
||||||
|
prompt_tokens=usage.get("prompt_tokens"),
|
||||||
|
completion_tokens=usage.get("completion_tokens"),
|
||||||
|
cost_usd=usage.get("cost") or usage.get("total_cost"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def month_window() -> tuple[datetime, datetime]:
|
||||||
|
"""[start, now] in UTC for the current calendar month."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
return start, now
|
||||||
|
|
||||||
|
|
||||||
|
def month_start() -> datetime:
|
||||||
|
return month_window()[0]
|
||||||
69
app/services/trading212.py
Normal file
69
app/services/trading212.py
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
"""Trading 212 read-only client.
|
||||||
|
|
||||||
|
Ported from /home/gg/ownCloud/Family/Finances/Wealth/trading212.py — same Basic
|
||||||
|
auth scheme, same endpoints, async via httpx. Live endpoint only (demo would
|
||||||
|
need a separate key pair).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import time
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
LIVE_BASE = "https://live.trading212.com/api/v0"
|
||||||
|
|
||||||
|
|
||||||
|
class Trading212:
|
||||||
|
def __init__(self, api_key: str | None = None, secret_key: str | None = None):
|
||||||
|
s = get_settings()
|
||||||
|
api_key = api_key or s.API_KEY
|
||||||
|
secret_key = secret_key or s.SECRET_KEY
|
||||||
|
if not api_key or not secret_key:
|
||||||
|
raise RuntimeError("Trading 212 API_KEY/SECRET_KEY missing in env")
|
||||||
|
token = base64.b64encode(f"{api_key}:{secret_key}".encode()).decode()
|
||||||
|
self.headers = {
|
||||||
|
"Authorization": f"Basic {token}",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"User-Agent": "cassandra/0.1",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _request(
|
||||||
|
self, client: httpx.AsyncClient, method: str, path: str, **kwargs
|
||||||
|
):
|
||||||
|
url = f"{LIVE_BASE}{path}"
|
||||||
|
r = await client.request(method, url, headers=self.headers, timeout=30, **kwargs)
|
||||||
|
if r.status_code == 429:
|
||||||
|
reset = float(r.headers.get("x-ratelimit-reset", "1"))
|
||||||
|
wait = max(1.0, reset - time.time())
|
||||||
|
await asyncio.sleep(wait)
|
||||||
|
r = await client.request(method, url, headers=self.headers, timeout=30, **kwargs)
|
||||||
|
r.raise_for_status()
|
||||||
|
if not r.content:
|
||||||
|
return None
|
||||||
|
ctype = r.headers.get("content-type", "")
|
||||||
|
return r.json() if "json" in ctype else r.text
|
||||||
|
|
||||||
|
async def summary(self, client: httpx.AsyncClient):
|
||||||
|
return await self._request(client, "GET", "/equity/account/summary")
|
||||||
|
|
||||||
|
async def cash(self, client: httpx.AsyncClient):
|
||||||
|
return await self._request(client, "GET", "/equity/account/cash")
|
||||||
|
|
||||||
|
async def positions(self, client: httpx.AsyncClient):
|
||||||
|
return await self._request(client, "GET", "/equity/portfolio")
|
||||||
|
|
||||||
|
async def position(self, client: httpx.AsyncClient, ticker: str):
|
||||||
|
return await self._request(client, "GET", f"/equity/portfolio/{ticker}")
|
||||||
|
|
||||||
|
async def orders(self, client: httpx.AsyncClient):
|
||||||
|
return await self._request(client, "GET", "/equity/orders")
|
||||||
|
|
||||||
|
async def instruments(self, client: httpx.AsyncClient):
|
||||||
|
"""Full catalogue of tradable instruments. Used to enrich position
|
||||||
|
rows with human-readable names."""
|
||||||
|
return await self._request(client, "GET", "/equity/metadata/instruments")
|
||||||
630
app/static/css/cassandra.css
Normal file
630
app/static/css/cassandra.css
Normal file
|
|
@ -0,0 +1,630 @@
|
||||||
|
/* Cassandra — geopolitical-terminal aesthetic with two themes.
|
||||||
|
* Mono for data, headers, terminal feel; sans for prose surfaces (log + chat). */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Dark theme (default) */
|
||||||
|
--bg: #0a0e14;
|
||||||
|
--surface: #11151c;
|
||||||
|
--surface-2: #161b25;
|
||||||
|
--border: #2a3142;
|
||||||
|
--text: #d4dae8; /* lifted from #c0caf5 for readability */
|
||||||
|
--muted: #8189a1; /* lifted from #565f89 — was unreadably dim */
|
||||||
|
--dim: #565f89;
|
||||||
|
--accent: #00d9ff;
|
||||||
|
--positive: #50fa7b;
|
||||||
|
--negative: #ff5b5b;
|
||||||
|
--alert: #ff8a4a;
|
||||||
|
--warning: #f1fa8c;
|
||||||
|
--user-bubble-bg: rgba(0, 217, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] {
|
||||||
|
--bg: #f5f3ec; /* warm off-white, easier on the eyes than pure white */
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-2: #efece3;
|
||||||
|
--border: #d6d3cb;
|
||||||
|
--text: #1c1f25;
|
||||||
|
--muted: #545b69;
|
||||||
|
--dim: #8a8f9a;
|
||||||
|
--accent: #0e7490; /* deep teal — still terminal-feel on light */
|
||||||
|
--positive: #166534;
|
||||||
|
--negative: #b91c1c;
|
||||||
|
--alert: #c2410c;
|
||||||
|
--warning: #a16207;
|
||||||
|
--user-bubble-bg: rgba(14, 116, 144, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Font stacks. Mono for terminal feel; sans for reading. */
|
||||||
|
:root {
|
||||||
|
--font-mono: 'JetBrains Mono', 'IBM Plex Mono', 'Fira Code', ui-monospace, monospace;
|
||||||
|
--font-sans: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', Roboto,
|
||||||
|
'Helvetica Neue', system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: var(--accent); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* --- Layout ---------------------------------------------------------- */
|
||||||
|
|
||||||
|
.app {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: auto 1fr auto;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 10px 18px;
|
||||||
|
background: var(--surface);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.app-header .brand {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.app-header .brand::before { content: "▰ "; opacity: 0.6; }
|
||||||
|
.app-header nav a {
|
||||||
|
margin-left: 18px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.app-header nav a.active { color: var(--text); }
|
||||||
|
.app-header .meta { color: var(--muted); font-size: 11px; }
|
||||||
|
|
||||||
|
.app-header .header-right { display: flex; align-items: center; gap: 14px; }
|
||||||
|
.theme-toggle {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 3px 8px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
cursor: pointer;
|
||||||
|
text-transform: lowercase;
|
||||||
|
}
|
||||||
|
.theme-toggle:hover { color: var(--accent); border-color: var(--accent); }
|
||||||
|
.theme-toggle__label::before { content: "◐ dark"; }
|
||||||
|
[data-theme="light"] .theme-toggle__label::before { content: "◐ light"; }
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
padding: 14px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
|
||||||
|
grid-template-rows: auto auto auto;
|
||||||
|
grid-template-areas:
|
||||||
|
"indicators log"
|
||||||
|
"portfolio log"
|
||||||
|
"news news";
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.app-main {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-areas: "indicators" "portfolio" "log" "news";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#indicators-panel { grid-area: indicators; }
|
||||||
|
#portfolio-panel { grid-area: portfolio; }
|
||||||
|
#log-panel { grid-area: log; }
|
||||||
|
#news-panel { grid-area: news; }
|
||||||
|
|
||||||
|
.app-footer {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding: 8px 18px;
|
||||||
|
background: var(--surface);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Panels ----------------------------------------------------------- */
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.panel-header {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 8px 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
background: linear-gradient(180deg, var(--surface-2), var(--surface));
|
||||||
|
}
|
||||||
|
.panel-header .title { color: var(--text); font-weight: 700; }
|
||||||
|
.panel-header .title::before { content: "■ "; color: var(--accent); }
|
||||||
|
.panel-header .meta { color: var(--dim); }
|
||||||
|
.panel-body { padding: 6px 0; }
|
||||||
|
.panel-body--scroll { max-height: 70vh; overflow-y: auto; }
|
||||||
|
|
||||||
|
/* --- Tables ----------------------------------------------------------- */
|
||||||
|
|
||||||
|
table.dense {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
table.dense th, table.dense td {
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-bottom: 1px solid var(--surface-2);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
table.dense th {
|
||||||
|
text-align: left;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 400;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
font-size: 10px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
table.dense th.num,
|
||||||
|
table.dense td.num { text-align: right; }
|
||||||
|
table.dense td.label { color: var(--text); }
|
||||||
|
table.dense tr:hover td { background: color-mix(in srgb, var(--accent) 5%, transparent); }
|
||||||
|
|
||||||
|
.pos { color: var(--positive); }
|
||||||
|
.neg { color: var(--negative); }
|
||||||
|
.neu { color: var(--muted); }
|
||||||
|
.note { color: var(--dim); font-size: 11px; }
|
||||||
|
|
||||||
|
/* --- Status LEDs ------------------------------------------------------ */
|
||||||
|
|
||||||
|
.led { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; vertical-align: middle; }
|
||||||
|
.led.ok { background: var(--positive); box-shadow: 0 0 6px var(--positive); }
|
||||||
|
.led.warn { background: var(--warning); box-shadow: 0 0 6px var(--warning); }
|
||||||
|
.led.err { background: var(--negative); box-shadow: 0 0 6px var(--negative); }
|
||||||
|
.led.idle { background: var(--dim); }
|
||||||
|
|
||||||
|
/* --- Group tabs ------------------------------------------------------- */
|
||||||
|
|
||||||
|
.group-tabs {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.group-tabs button {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.group-tabs button:hover { color: var(--text); }
|
||||||
|
.group-tabs button.active {
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--bg);
|
||||||
|
box-shadow: inset 0 -2px 0 var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Portfolio overall ----------------------------------------------- */
|
||||||
|
|
||||||
|
.pf-overall {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 10px 14px 12px;
|
||||||
|
background: linear-gradient(180deg, var(--surface-2), var(--surface));
|
||||||
|
}
|
||||||
|
.pf-overall__head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.pf-name {
|
||||||
|
color: var(--accent);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.pf-name::before { content: "◆ "; opacity: 0.6; }
|
||||||
|
.pf-as-of { color: var(--dim); font-size: 11px; }
|
||||||
|
.pf-overall__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 6px 24px;
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.pf-overall__grid { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
}
|
||||||
|
.pf-stat-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
.pf-stat-value {
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--text);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.pf-stat-value.pos { color: var(--positive); }
|
||||||
|
.pf-stat-value.neg { color: var(--negative); }
|
||||||
|
.pf-stat-value.neu { color: var(--muted); }
|
||||||
|
.pf-ccy { color: var(--dim); font-size: 11px; margin-left: 2px; }
|
||||||
|
.pf-pct { color: var(--dim); font-size: 11px; margin-left: 4px; }
|
||||||
|
|
||||||
|
/* --- Log panel -------------------------------------------------------- */
|
||||||
|
|
||||||
|
.log-content {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
padding: 28px clamp(20px, 4vw, 56px) 32px;
|
||||||
|
font-size: 15.5px;
|
||||||
|
line-height: 1.72;
|
||||||
|
color: var(--text);
|
||||||
|
max-width: 76ch;
|
||||||
|
margin: 0 auto;
|
||||||
|
max-height: calc(100vh - 240px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.log-content p { margin: 0 0 1.1em; }
|
||||||
|
.log-content h1, .log-content h2, .log-content h3, .log-content h4 {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--accent);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 1.8em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.log-content h1:first-child,
|
||||||
|
.log-content h2:first-child,
|
||||||
|
.log-content h3:first-child { margin-top: 0; }
|
||||||
|
|
||||||
|
/* TL;DR callout — model is instructed to put it first, so style the first
|
||||||
|
* heading + paragraph block as a callout. */
|
||||||
|
.log-content h3:first-of-type {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--accent);
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
padding-left: 10px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.log-content h3:first-of-type + p {
|
||||||
|
font-size: 16.5px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text);
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
padding: 4px 14px 12px;
|
||||||
|
margin: 0 0 1.8em;
|
||||||
|
background: color-mix(in srgb, var(--accent) 5%, transparent);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.log-content strong { color: var(--text); font-weight: 700; }
|
||||||
|
.log-content em { color: var(--muted); font-style: italic; }
|
||||||
|
.log-content ul, .log-content ol { padding-left: 1.4em; margin: 0 0 1.1em; }
|
||||||
|
.log-content li { margin-bottom: 0.4em; }
|
||||||
|
.log-content hr {
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
margin: 1.6em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Log page (calendar + log + chat sidebar) ------------------------- */
|
||||||
|
|
||||||
|
.log-page__body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 220px 1fr 320px;
|
||||||
|
gap: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.log-page__body { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
.log-page__cal, .log-page__content, .log-page__chat { background: var(--surface); }
|
||||||
|
.log-page__cal { padding: 10px; }
|
||||||
|
.log-page__content { min-height: 60vh; }
|
||||||
|
.log-page__chat { padding: 8px; min-height: 60vh; display: flex; flex-direction: column; }
|
||||||
|
|
||||||
|
/* --- Calendar widget --------------------------------------------------- */
|
||||||
|
|
||||||
|
.cal__nav {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.cal__title { color: var(--accent); font-weight: 700; }
|
||||||
|
.cal__btn {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 2px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.cal__btn:hover { color: var(--accent); border-color: var(--accent); }
|
||||||
|
.cal__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.cal__h {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--dim);
|
||||||
|
background: var(--surface-2);
|
||||||
|
padding: 3px 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.cal__d {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 6px 0;
|
||||||
|
text-align: center;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.cal__d--empty { background: var(--bg); cursor: default; }
|
||||||
|
.cal__d--has-log {
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.cal__d--has-log::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
bottom: 3px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 3px; height: 3px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
.cal__d--has-log:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); }
|
||||||
|
.cal__d--today { color: var(--warning); }
|
||||||
|
.cal__d--selected {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.cal__d--selected::after { background: var(--bg); }
|
||||||
|
|
||||||
|
/* --- Badges (tone / analysis indicators) ------------------------------ */
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9.5px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
margin-right: 4px;
|
||||||
|
background: transparent;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
/* Tone axis — green→accent→amber as audience density rises */
|
||||||
|
.badge--tone-novice { color: var(--positive); }
|
||||||
|
.badge--tone-intermediate { color: var(--accent); }
|
||||||
|
.badge--tone-pro { color: var(--alert); }
|
||||||
|
|
||||||
|
/* Analysis axis — dry is muted, speculative is accent */
|
||||||
|
.badge--analysis-dry { color: var(--muted); }
|
||||||
|
.badge--analysis-speculative { color: var(--accent); }
|
||||||
|
|
||||||
|
.badge--ver { color: var(--dim); }
|
||||||
|
|
||||||
|
.meta__hint { color: var(--dim); font-size: 10px; margin-right: 4px; }
|
||||||
|
|
||||||
|
/* --- Log metadata footer ---------------------------------------------- */
|
||||||
|
|
||||||
|
.log-meta {
|
||||||
|
padding: 8px clamp(20px, 4vw, 56px) 16px;
|
||||||
|
max-width: 76ch;
|
||||||
|
margin: 0 auto;
|
||||||
|
border-top: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
.log-meta__row { display: flex; flex-wrap: wrap; align-items: center; gap: 0; margin-top: 6px; }
|
||||||
|
.log-meta__row--dim { color: var(--dim); font-size: 10.5px; }
|
||||||
|
|
||||||
|
/* --- Chat sidebar ----------------------------------------------------- */
|
||||||
|
|
||||||
|
.chat-header {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 6px 4px 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.chat-title {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.chat-title::before { content: "▸ "; }
|
||||||
|
.chat-hint { color: var(--dim); font-size: 10px; margin-top: 2px; }
|
||||||
|
|
||||||
|
.chat-thread {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 4px 2px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.chat-msg {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 13.5px;
|
||||||
|
padding: 9px 11px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
line-height: 1.6;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
.chat-msg--system {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
background: transparent;
|
||||||
|
border-style: dashed;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.chat-msg--user {
|
||||||
|
background: var(--user-bubble-bg);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--text);
|
||||||
|
align-self: flex-end;
|
||||||
|
max-width: 92%;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.chat-msg--user::before {
|
||||||
|
content: "you › ";
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--accent);
|
||||||
|
opacity: 0.7;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.chat-msg--assistant { background: var(--surface-2); color: var(--text); }
|
||||||
|
.chat-msg--assistant::before {
|
||||||
|
content: "cassandra › ";
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--accent);
|
||||||
|
opacity: 0.7;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.chat-msg--pending { color: var(--dim); font-style: italic; }
|
||||||
|
.chat-msg--error { color: var(--negative); border-color: var(--negative); }
|
||||||
|
|
||||||
|
.chat-msg p { margin: 0.4em 0; }
|
||||||
|
.chat-msg p:first-child { margin-top: 0; }
|
||||||
|
.chat-msg p:last-child { margin-bottom: 0; }
|
||||||
|
.chat-msg h2, .chat-msg h3, .chat-msg h4 {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 11px;
|
||||||
|
margin: 0.8em 0 0.3em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
.chat-msg strong { color: var(--text); font-weight: 700; }
|
||||||
|
.chat-msg em { color: var(--muted); font-style: italic; }
|
||||||
|
|
||||||
|
.chat-form {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding-top: 6px;
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
.chat-form textarea {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 36px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.chat-form textarea:focus { border-color: var(--accent); }
|
||||||
|
.chat-form button {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.chat-form button:hover:not(:disabled) { background: var(--accent); color: var(--bg); }
|
||||||
|
.chat-form button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* --- News ------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.news-row {
|
||||||
|
padding: 4px 12px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 50px 130px 1fr 110px;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-bottom: 1px solid var(--surface-2);
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.news-row { grid-template-columns: 50px 100px 1fr; }
|
||||||
|
.news-row .local { display: none; }
|
||||||
|
}
|
||||||
|
.news-row:hover { background: color-mix(in srgb, var(--accent) 5%, transparent); }
|
||||||
|
.news-row .age { color: var(--dim); text-align: right; }
|
||||||
|
.news-row .source { color: var(--muted); font-size: 11px; }
|
||||||
|
.news-row .title { color: var(--text); }
|
||||||
|
.news-row .title:hover { color: var(--accent); }
|
||||||
|
.news-row .local {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Empty / loading state ------------------------------------------- */
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.htmx-indicator {
|
||||||
|
display: inline-block;
|
||||||
|
color: var(--dim);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
.htmx-request .htmx-indicator { opacity: 1; }
|
||||||
|
|
||||||
|
/* --- Scrollbar -------------------------------------------------------- */
|
||||||
|
|
||||||
|
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||||
|
::-webkit-scrollbar-track { background: var(--bg); }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--dim); border-radius: 0; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: var(--muted); }
|
||||||
73
app/static/js/chat.js
Normal file
73
app/static/js/chat.js
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
// Cassandra chat sidebar — ephemeral, client-state conversation.
|
||||||
|
// No persistence: page refresh starts a new chat.
|
||||||
|
(() => {
|
||||||
|
const thread = document.getElementById('chat-thread');
|
||||||
|
const form = document.getElementById('chat-form');
|
||||||
|
const input = document.getElementById('chat-input');
|
||||||
|
const send = document.getElementById('chat-send');
|
||||||
|
if (!thread || !form || !input || !send) return;
|
||||||
|
|
||||||
|
/** @type {{role: string, content: string}[]} */
|
||||||
|
const messages = [];
|
||||||
|
|
||||||
|
function escapeHTML(s) {
|
||||||
|
return s.replace(/&/g, '&')
|
||||||
|
.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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
1
app/static/js/htmx.min.js
vendored
Normal file
1
app/static/js/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
71
app/templates/base.html
Normal file
71
app/templates/base.html
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>{% block title %}Cassandra{% endblock %}</title>
|
||||||
|
{# Apply saved theme before stylesheet renders to avoid a flash. #}
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
try {
|
||||||
|
var t = localStorage.getItem('cassandra.theme') || 'dark';
|
||||||
|
document.documentElement.dataset.theme = t;
|
||||||
|
} catch (e) { document.documentElement.dataset.theme = 'dark'; }
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', path='/css/cassandra.css') }}" />
|
||||||
|
<script src="{{ url_for('static', path='/js/htmx.min.js') }}" defer></script>
|
||||||
|
<script>
|
||||||
|
// Render any <time datetime="..."> in the browser's local timezone.
|
||||||
|
// Re-runs after every HTMX swap so freshly-loaded news rows pick up too.
|
||||||
|
function formatLocalTimes() {
|
||||||
|
document.querySelectorAll('time[datetime]:not([data-local])').forEach(function (t) {
|
||||||
|
try {
|
||||||
|
var d = new Date(t.getAttribute('datetime'));
|
||||||
|
if (isNaN(d.getTime())) return;
|
||||||
|
var date = d.toLocaleDateString(undefined, { day: '2-digit', month: 'short' });
|
||||||
|
var time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||||
|
t.textContent = date + ' ' + time;
|
||||||
|
t.title = d.toLocaleString();
|
||||||
|
t.setAttribute('data-local', '1');
|
||||||
|
} catch (e) {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
formatLocalTimes();
|
||||||
|
document.body.addEventListener('htmx:afterSwap', formatLocalTimes);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app">
|
||||||
|
<header class="app-header">
|
||||||
|
<div class="brand">Cassandra</div>
|
||||||
|
<nav>
|
||||||
|
<a href="/" class="{% if request.url.path == '/' %}active{% endif %}">Dashboard</a>
|
||||||
|
<a href="/news" class="{% if request.url.path == '/news' %}active{% endif %}">News</a>
|
||||||
|
<a href="/log" class="{% if request.url.path.startswith('/log') %}active{% endif %}">Log</a>
|
||||||
|
</nav>
|
||||||
|
<div class="header-right">
|
||||||
|
<button class="theme-toggle" type="button" aria-label="Toggle theme"
|
||||||
|
onclick="(function(){var d=document.documentElement;var t=d.dataset.theme==='light'?'dark':'light';d.dataset.theme=t;try{localStorage.setItem('cassandra.theme',t);}catch(e){}})()">
|
||||||
|
<span class="theme-toggle__label"></span>
|
||||||
|
</button>
|
||||||
|
<span class="meta">v0.1 · UTC</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="app-main">
|
||||||
|
{% block main %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="app-footer"
|
||||||
|
hx-get="/api/health"
|
||||||
|
hx-trigger="load, every 30s"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
id="ops-footer">
|
||||||
|
<span class="led idle"></span> awaiting status…
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
77
app/templates/dashboard.html
Normal file
77
app/templates/dashboard.html
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Cassandra · Dashboard{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<section id="indicators-panel" class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span class="title">Indicators</span>
|
||||||
|
<span class="meta">{% if anchor %}anchor {{ anchor }} · {% endif %}ingest hourly @ :05 UTC</span>
|
||||||
|
</div>
|
||||||
|
<div class="group-tabs" id="group-tabs">
|
||||||
|
{% for g in groups %}
|
||||||
|
<button
|
||||||
|
class="{% if loop.first %}active{% endif %}"
|
||||||
|
hx-get="/api/indicators/{{ g }}?as=html"
|
||||||
|
hx-target="#indicators-body"
|
||||||
|
hx-trigger="click"
|
||||||
|
onclick="document.querySelectorAll('#group-tabs button').forEach(b=>b.classList.remove('active'));this.classList.add('active')"
|
||||||
|
>{{ g }}</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div id="indicators-body"
|
||||||
|
class="panel-body panel-body--scroll"
|
||||||
|
hx-get="/api/indicators/{{ groups[0] }}?as=html"
|
||||||
|
hx-trigger="load"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<div class="empty">loading…</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<script>
|
||||||
|
// Auto-refresh the *currently selected* group every 60s by simulating a
|
||||||
|
// click on the active tab. Replaces the hard-coded `every 60s` on
|
||||||
|
// #indicators-body which always re-fetched groups[0].
|
||||||
|
setInterval(function () {
|
||||||
|
var active = document.querySelector('#group-tabs button.active');
|
||||||
|
if (active) active.click();
|
||||||
|
}, 60000);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section id="portfolio-panel" class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span class="title">Portfolio</span>
|
||||||
|
<span class="meta">ingest hourly @ :15 UTC</span>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body"
|
||||||
|
hx-get="/api/portfolios?as=html"
|
||||||
|
hx-trigger="load, every 60s"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<div class="empty">loading…</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="log-panel" class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span class="title">Strategic Log</span>
|
||||||
|
<span class="meta">generated hourly @ :20 UTC</span>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body"
|
||||||
|
hx-get="/api/log/latest?as=html"
|
||||||
|
hx-trigger="load, every 300s"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<div class="empty">awaiting first log…</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="news-panel" class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span class="title">Flash News</span>
|
||||||
|
<span class="meta">last 24h · ingest hourly @ :10 UTC</span>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body panel-body--scroll"
|
||||||
|
hx-get="/api/news?as=html&limit=40"
|
||||||
|
hx-trigger="load, every 60s"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<div class="empty">loading…</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
55
app/templates/log.html
Normal file
55
app/templates/log.html
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Cassandra · Strategic Log{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<section class="panel log-page" style="grid-column: 1 / -1;">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span class="title">Strategic Log Archive</span>
|
||||||
|
<span class="meta">
|
||||||
|
selected {{ selected_iso }}
|
||||||
|
·
|
||||||
|
<span class="meta__hint">new logs use:</span>
|
||||||
|
<span class="badge badge--tone-{{ current_tone | lower }}">tone {{ current_tone | lower }}</span>
|
||||||
|
<span class="badge badge--analysis-{{ current_analysis | lower }}">analysis {{ current_analysis | lower }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="log-page__body">
|
||||||
|
<aside class="log-page__cal"
|
||||||
|
hx-get="/api/log/days?month={{ selected_month }}&selected={{ selected_iso }}"
|
||||||
|
hx-trigger="load"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<div class="empty">loading calendar…</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<article id="log-content"
|
||||||
|
class="log-page__content"
|
||||||
|
hx-get="/api/log/by-date/{{ selected_iso }}?as=html"
|
||||||
|
hx-trigger="load"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<div class="empty">loading log…</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<aside id="chat-sidebar" class="log-page__chat">
|
||||||
|
<div class="chat-header">
|
||||||
|
<span class="chat-title">Ask Cassandra</span>
|
||||||
|
<span class="chat-hint">grounded on the latest log + live data</span>
|
||||||
|
</div>
|
||||||
|
<div id="chat-thread" class="chat-thread">
|
||||||
|
<div class="chat-msg chat-msg--system">
|
||||||
|
Ask about today's analysis. The model sees the latest strategic log,
|
||||||
|
live market readings across all groups, and the last 24h of
|
||||||
|
thesis-filtered headlines. Refresh wipes this conversation.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form id="chat-form" class="chat-form" autocomplete="off">
|
||||||
|
<textarea id="chat-input" rows="2"
|
||||||
|
placeholder="e.g. why is the defence sleeve flat through Hormuz?"
|
||||||
|
required></textarea>
|
||||||
|
<button id="chat-send" type="submit">Send</button>
|
||||||
|
</form>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<script src="{{ url_for('static', path='/js/chat.js') }}" defer></script>
|
||||||
|
{% endblock %}
|
||||||
17
app/templates/news.html
Normal file
17
app/templates/news.html
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Cassandra · News{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<section class="panel" style="grid-column: 1 / -1;">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span class="title">News Feed</span>
|
||||||
|
<span class="meta">last 24h · ingest hourly @ :10 UTC</span>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body panel-body--scroll"
|
||||||
|
hx-get="/api/news?as=html&limit=200"
|
||||||
|
hx-trigger="load, every 60s"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<div class="empty">loading…</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
48
app/templates/partials/calendar.html
Normal file
48
app/templates/partials/calendar.html
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
<div class="cal" id="cal-widget">
|
||||||
|
<div class="cal__nav">
|
||||||
|
<button class="cal__btn"
|
||||||
|
hx-get="/api/log/days?month={{ prev_month }}{% if selected %}&selected={{ selected.isoformat() }}{% endif %}"
|
||||||
|
hx-target="#cal-widget"
|
||||||
|
hx-swap="outerHTML">‹</button>
|
||||||
|
<div class="cal__title">{{ month_name }} {{ year }}</div>
|
||||||
|
<button class="cal__btn"
|
||||||
|
hx-get="/api/log/days?month={{ next_month }}{% if selected %}&selected={{ selected.isoformat() }}{% endif %}"
|
||||||
|
hx-target="#cal-widget"
|
||||||
|
hx-swap="outerHTML">›</button>
|
||||||
|
</div>
|
||||||
|
<div class="cal__grid">
|
||||||
|
<div class="cal__h">Mo</div>
|
||||||
|
<div class="cal__h">Tu</div>
|
||||||
|
<div class="cal__h">We</div>
|
||||||
|
<div class="cal__h">Th</div>
|
||||||
|
<div class="cal__h">Fr</div>
|
||||||
|
<div class="cal__h">Sa</div>
|
||||||
|
<div class="cal__h">Su</div>
|
||||||
|
{% for week in grid %}
|
||||||
|
{% for d in week %}
|
||||||
|
{% if d is none %}
|
||||||
|
<div class="cal__d cal__d--empty"></div>
|
||||||
|
{% else %}
|
||||||
|
{% set has_log = d in days_with_logs %}
|
||||||
|
{% set is_selected = (selected and selected.day == d and selected.month == month and selected.year == year) %}
|
||||||
|
{% set is_today = (today.day == d and today.month == month and today.year == year) %}
|
||||||
|
{% set iso = "%04d-%02d-%02d" | format(year, month, d) %}
|
||||||
|
<button class="cal__d
|
||||||
|
{% if has_log %}cal__d--has-log{% else %}cal__d--no-log{% endif %}
|
||||||
|
{% if is_selected %}cal__d--selected{% endif %}
|
||||||
|
{% if is_today %}cal__d--today{% endif %}"
|
||||||
|
{% if has_log %}
|
||||||
|
hx-get="/api/log/by-date/{{ iso }}?as=html"
|
||||||
|
hx-target="#log-content"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-push-url="/log/{{ iso }}"
|
||||||
|
onclick="document.querySelectorAll('.cal__d--selected').forEach(b=>b.classList.remove('cal__d--selected'));this.classList.add('cal__d--selected')"
|
||||||
|
{% else %}
|
||||||
|
disabled
|
||||||
|
{% endif %}
|
||||||
|
>{{ d }}</button>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
38
app/templates/partials/indicators.html
Normal file
38
app/templates/partials/indicators.html
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
{% if not quotes %}
|
||||||
|
<div class="empty">no data yet — scheduler may not have run</div>
|
||||||
|
{% else %}
|
||||||
|
<table class="dense">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Symbol</th><th>Label</th>
|
||||||
|
<th class="num">Price</th><th>Ccy</th>
|
||||||
|
<th class="num">1d</th><th class="num">1m</th><th class="num">1y</th>
|
||||||
|
{% if has_anchor %}<th class="num">anchor</th>{% endif %}
|
||||||
|
<th>as-of</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for q in quotes %}
|
||||||
|
<tr>
|
||||||
|
<td class="label">{{ q.symbol }}</td>
|
||||||
|
<td>{{ q.label or "" }}</td>
|
||||||
|
<td class="num">{{ q.price | price }}</td>
|
||||||
|
<td class="neu">{{ q.currency or "" }}</td>
|
||||||
|
{% for k in ["1d","1m","1y"] %}
|
||||||
|
{% set v = q.changes.get(k) if q.changes else None %}
|
||||||
|
<td class="num {% if v is none %}neu{% elif v >= 0 %}pos{% else %}neg{% endif %}">
|
||||||
|
{% if v is none %}—{% else %}{{ "%+.2f"|format(v) }}%{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
{% if has_anchor %}
|
||||||
|
{% set va = q.changes.get('anchor') if q.changes else None %}
|
||||||
|
<td class="num {% if va is none %}neu{% elif va >= 0 %}pos{% else %}neg{% endif %}">
|
||||||
|
{% if va is none %}—{% else %}{{ "%+.2f"|format(va) }}%{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
<td class="neu">{{ q.as_of or "" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
18
app/templates/partials/log.html
Normal file
18
app/templates/partials/log.html
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
{% if not log %}
|
||||||
|
<div class="empty">awaiting first generated log</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="log-content">{{ log.content_html | safe }}</div>
|
||||||
|
<div class="log-meta">
|
||||||
|
<div class="log-meta__row">
|
||||||
|
{% if log.tone %}<span class="badge badge--tone-{{ log.tone | lower }}">tone {{ log.tone | lower }}</span>{% endif %}
|
||||||
|
{% if log.analysis %}<span class="badge badge--analysis-{{ log.analysis | lower }}">analysis {{ log.analysis | lower }}</span>{% endif %}
|
||||||
|
{% if log.prompt_version %}<span class="badge badge--ver">prompt v{{ log.prompt_version }}</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="log-meta__row log-meta__row--dim">
|
||||||
|
generated {{ log.generated_at.strftime("%Y-%m-%d %H:%M UTC") }}
|
||||||
|
· model <span class="neu">{{ log.model }}</span>
|
||||||
|
{% 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 %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
16
app/templates/partials/news.html
Normal file
16
app/templates/partials/news.html
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{% if not headlines %}
|
||||||
|
<div class="empty">no headlines in window</div>
|
||||||
|
{% else %}
|
||||||
|
{% for h in headlines %}
|
||||||
|
<div class="news-row">
|
||||||
|
<span class="age">{{ h.age }}</span>
|
||||||
|
<span class="source">{{ h.source }}</span>
|
||||||
|
<a class="title" href="{{ h.url }}" target="_blank" rel="noopener">{{ h.title }}</a>
|
||||||
|
{% if h.iso %}
|
||||||
|
<time class="local" datetime="{{ h.iso }}" title="{{ h.iso }}">{{ h.utc_short }}</time>
|
||||||
|
{% else %}
|
||||||
|
<span class="local">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
7
app/templates/partials/ops_footer.html
Normal file
7
app/templates/partials/ops_footer.html
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<span><span class="led {% if db_ok %}ok{% else %}err{% endif %}"></span>DB</span>
|
||||||
|
{% for j in jobs %}
|
||||||
|
<span title="{{ j.name }}">
|
||||||
|
<span class="led {{ j.led }}"></span>{{ j.name }}
|
||||||
|
{% if j.last_finished %}· {{ j.age }}{% endif %}
|
||||||
|
</span>
|
||||||
|
{% endfor %}
|
||||||
80
app/templates/partials/portfolio.html
Normal file
80
app/templates/partials/portfolio.html
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
{% if not portfolios %}
|
||||||
|
<div class="empty">no portfolio snapshots yet</div>
|
||||||
|
{% else %}
|
||||||
|
{% for p in portfolios %}
|
||||||
|
{# --- overall block --- #}
|
||||||
|
<div class="pf-overall">
|
||||||
|
<div class="pf-overall__head">
|
||||||
|
<span class="pf-name">{{ p.name }}</span>
|
||||||
|
<span class="pf-as-of">
|
||||||
|
{% if p.snapshot_at %}{{ p.snapshot_at.strftime("%Y-%m-%d %H:%M UTC") }}{% else %}—{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="pf-overall__grid">
|
||||||
|
<div class="pf-stat">
|
||||||
|
<div class="pf-stat-label">Total</div>
|
||||||
|
<div class="pf-stat-value">{{ p.total_value | money }} <span class="pf-ccy">{{ p.currency }}</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="pf-stat">
|
||||||
|
<div class="pf-stat-label">Invested</div>
|
||||||
|
<div class="pf-stat-value">{{ p.invested | money }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="pf-stat">
|
||||||
|
<div class="pf-stat-label">Cash</div>
|
||||||
|
<div class="pf-stat-value">{{ p.cash | money }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="pf-stat">
|
||||||
|
<div class="pf-stat-label">Unrealised P/L</div>
|
||||||
|
<div class="pf-stat-value {% if p.unrealized_ppl is none %}neu{% elif p.unrealized_ppl >= 0 %}pos{% else %}neg{% endif %}">
|
||||||
|
{{ p.unrealized_ppl | signed }}
|
||||||
|
{% if p.total_cost and p.unrealized_ppl is not none %}
|
||||||
|
<span class="pf-pct">({{ "%+.2f"|format(p.unrealized_ppl / p.total_cost * 100) }}%)</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pf-stat">
|
||||||
|
<div class="pf-stat-label">Realised P/L</div>
|
||||||
|
<div class="pf-stat-value {% if p.realized_ppl is none %}neu{% elif p.realized_ppl >= 0 %}pos{% else %}neg{% endif %}">
|
||||||
|
{{ p.realized_ppl | signed }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pf-stat">
|
||||||
|
<div class="pf-stat-label">Positions</div>
|
||||||
|
<div class="pf-stat-value">{{ p.positions | length }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# --- per-position table --- #}
|
||||||
|
<table class="dense">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Ticker</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th class="num">Qty</th>
|
||||||
|
<th class="num">Avg</th>
|
||||||
|
<th class="num">Last</th>
|
||||||
|
<th class="num">P/L</th>
|
||||||
|
<th class="num">%</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for pos in p.positions %}
|
||||||
|
<tr>
|
||||||
|
<td class="label">{{ pos.ticker }}</td>
|
||||||
|
<td>{{ pos.name or "" }}</td>
|
||||||
|
<td class="num">{{ pos.quantity | price }}</td>
|
||||||
|
<td class="num neu">{{ pos.average_price | price }}</td>
|
||||||
|
<td class="num">{{ pos.current_price | price }}</td>
|
||||||
|
<td class="num {% if pos.ppl is none %}neu{% elif pos.ppl >= 0 %}pos{% else %}neg{% endif %}">
|
||||||
|
{{ pos.ppl | signed }}
|
||||||
|
</td>
|
||||||
|
<td class="num {% if pos.ppl_pct is none %}neu{% elif pos.ppl_pct >= 0 %}pos{% else %}neg{% endif %}">
|
||||||
|
{% if pos.ppl_pct is not none %}{{ "%+.2f"|format(pos.ppl_pct) }}%{% else %}—{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
45
app/templates_env.py
Normal file
45
app/templates_env.py
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
"""Shared Jinja2 environment with custom filters for the dashboard.
|
||||||
|
Imported by both routers/pages.py and routers/api.py so the filters are
|
||||||
|
registered exactly once."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
|
||||||
|
TEMPLATE_DIR = Path(__file__).resolve().parent / "templates"
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_price(v: float | None) -> str:
|
||||||
|
"""Format a price in a way that's readable in dense terminal tables.
|
||||||
|
Avoids scientific notation for large round numbers (FTSE 25,962, not 2.596e+04)
|
||||||
|
and keeps enough precision for FX rates like 0.8725 EUR/GBP."""
|
||||||
|
if v is None:
|
||||||
|
return "—"
|
||||||
|
av = abs(v)
|
||||||
|
if av >= 1000:
|
||||||
|
return f"{v:,.2f}"
|
||||||
|
if av >= 10:
|
||||||
|
return f"{v:.2f}"
|
||||||
|
if av >= 1:
|
||||||
|
return f"{v:.4f}"
|
||||||
|
return f"{v:.4f}"
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_signed(v: float | None, decimals: int = 2) -> str:
|
||||||
|
if v is None:
|
||||||
|
return "—"
|
||||||
|
return f"{v:+,.{decimals}f}"
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_money(v: float | None) -> str:
|
||||||
|
if v is None:
|
||||||
|
return "—"
|
||||||
|
return f"{v:,.2f}"
|
||||||
|
|
||||||
|
|
||||||
|
templates = Jinja2Templates(directory=str(TEMPLATE_DIR))
|
||||||
|
templates.env.filters["price"] = _fmt_price
|
||||||
|
templates.env.filters["signed"] = _fmt_signed
|
||||||
|
templates.env.filters["money"] = _fmt_money
|
||||||
181
config/default.toml
Normal file
181
config/default.toml
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
# Baseline config — universal data tables consumed by market_pulse.py and
|
||||||
|
# flash_news.py. Edit this file to change what every user/portfolio tracks by
|
||||||
|
# default. User-specific or portfolio-specific overrides go in portfolio.toml,
|
||||||
|
# which is loaded on top of this and wins on key conflicts.
|
||||||
|
#
|
||||||
|
# Symbol prefixes in [groups]:
|
||||||
|
# bare → Yahoo Finance (default)
|
||||||
|
# FRED:SERIES_ID → FRED economic series (needs FRED_API_KEY in .env)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# market_pulse.py — indicator groups
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
[groups]
|
||||||
|
|
||||||
|
equity = [
|
||||||
|
{symbol="^GSPC", label="S&P 500", note="doc ref 7,501 (ATH)"},
|
||||||
|
{symbol="^IXIC", label="Nasdaq Composite", note="Mag 7 concentration"},
|
||||||
|
{symbol="^NDX", label="Nasdaq 100", note="Mag 7 concentration"},
|
||||||
|
{symbol="^FTSE", label="FTSE 100", note="GBP base"},
|
||||||
|
{symbol="^STOXX50E", label="Euro Stoxx 50", note=""},
|
||||||
|
{symbol="^N225", label="Nikkei 225", note="JPY at multi-decade low → VJPN thesis"},
|
||||||
|
{symbol="^HSI", label="Hang Seng", note="China expression"},
|
||||||
|
{symbol="000300.SS", label="CSI 300", note="broad China A-share"},
|
||||||
|
{symbol="^VIX", label="VIX", note="doc ref 18.0 — 'anomalously calm'"},
|
||||||
|
]
|
||||||
|
|
||||||
|
mag7 = [
|
||||||
|
{symbol="AAPL", label="Apple", note="doc thesis: 'Mag 7 pops' scenario"},
|
||||||
|
{symbol="MSFT", label="Microsoft", note=""},
|
||||||
|
{symbol="GOOGL", label="Alphabet (Class A)", note=""},
|
||||||
|
{symbol="AMZN", label="Amazon", note=""},
|
||||||
|
{symbol="META", label="Meta Platforms", note=""},
|
||||||
|
{symbol="NVDA", label="Nvidia", note="AI capex bellwether"},
|
||||||
|
{symbol="TSLA", label="Tesla", note=""},
|
||||||
|
]
|
||||||
|
|
||||||
|
rates = [
|
||||||
|
{symbol="^IRX", label="US 3-month T-bill", note="%"},
|
||||||
|
{symbol="^FVX", label="US 5y yield", note="%"},
|
||||||
|
{symbol="^TNX", label="US 10y yield", note="% — doc ref 4.45"},
|
||||||
|
{symbol="^TYX", label="US 30y yield", note="%"},
|
||||||
|
{symbol="HYG", label="HYG — HY corp bond ETF", note="HY OAS price proxy"},
|
||||||
|
{symbol="TIP", label="TIP — TIPS ETF", note="inflation expectations proxy"},
|
||||||
|
]
|
||||||
|
|
||||||
|
macro = [
|
||||||
|
{symbol="FRED:DFF", label="Fed Funds (effective)", note="%"},
|
||||||
|
{symbol="FRED:CPIAUCSL", label="US CPI (YoY)", note="% — doc ref 3.8"},
|
||||||
|
{symbol="FRED:CPILFESL", label="US Core CPI (YoY)", note="% — ex food & energy"},
|
||||||
|
{symbol="FRED:BAMLH0A0HYM2", label="US HY OAS", note="% — doc ref 2.79 (279bps)"},
|
||||||
|
{symbol="FRED:T10YIE", label="US 10y breakeven inflation", note="%"},
|
||||||
|
{symbol="FRED:T10Y2Y", label="10y-2y term spread", note="% — recession watch"},
|
||||||
|
{symbol="FRED:UNRATE", label="US unemployment rate", note="%"},
|
||||||
|
{symbol="FRED:WALCL", label="Fed balance sheet", note="$M — QT/QE state"},
|
||||||
|
]
|
||||||
|
|
||||||
|
commodities = [
|
||||||
|
{symbol="BZ=F", label="Brent crude", note="$/bbl — doc ref 109; closure-priced 180-250"},
|
||||||
|
{symbol="CL=F", label="WTI crude", note="$/bbl"},
|
||||||
|
{symbol="NG=F", label="Henry Hub nat-gas", note="$/MMBtu"},
|
||||||
|
{symbol="GC=F", label="Gold futures", note="$/oz — doc ref 4,651"},
|
||||||
|
{symbol="SI=F", label="Silver futures", note="$/oz"},
|
||||||
|
{symbol="HG=F", label="Copper futures", note="$/lb — transition demand"},
|
||||||
|
{symbol="PL=F", label="Platinum futures", note="$/oz"},
|
||||||
|
]
|
||||||
|
|
||||||
|
fx = [
|
||||||
|
{symbol="DX-Y.NYB", label="DXY (USD index)", note="USD-debasement channel"},
|
||||||
|
{symbol="GBPUSD=X", label="GBP/USD", note="household base FX"},
|
||||||
|
{symbol="EURGBP=X", label="EUR/GBP", note="~£400-530k EUR exposure"},
|
||||||
|
{symbol="EURUSD=X", label="EUR/USD", note=""},
|
||||||
|
{symbol="USDJPY=X", label="USD/JPY", note="JPY at multi-decade low"},
|
||||||
|
{symbol="USDCNY=X", label="USD/CNY", note="managed peg, watch deviation"},
|
||||||
|
{symbol="GC=F", label="Gold (USD)", note="anchor for FX read"},
|
||||||
|
]
|
||||||
|
|
||||||
|
tech_ai = [
|
||||||
|
{symbol="XLK", label="XLK — US tech sector", note="broad US tech read"},
|
||||||
|
{symbol="SOXX", label="SOXX — iShares semis", note="AI capex bellwether"},
|
||||||
|
{symbol="TSM", label="TSMC (ADR)", note="foundry monopoly + Taiwan risk"},
|
||||||
|
{symbol="ASML", label="ASML", note="EUV monopoly — China-export friction"},
|
||||||
|
{symbol="AVGO", label="Broadcom", note="AI infra; cyclical risk"},
|
||||||
|
{symbol="AMD", label="AMD", note="Nvidia competitor"},
|
||||||
|
{symbol="ARM", label="ARM Holdings", note="mobile/edge AI"},
|
||||||
|
]
|
||||||
|
|
||||||
|
financials = [
|
||||||
|
{symbol="XLF", label="XLF — US financials sector", note="broad US financials"},
|
||||||
|
{symbol="KBE", label="KBE — US banks", note="bank-only, regional+money centre"},
|
||||||
|
{symbol="KRE", label="KRE — US regional banks", note="post-2023 stress watch"},
|
||||||
|
{symbol="JPM", label="JPMorgan", note="systemically important"},
|
||||||
|
{symbol="GS", label="Goldman Sachs", note="capital markets read"},
|
||||||
|
{symbol="HSBA.L", label="HSBC (LSE)", note="UK + Asia exposure"},
|
||||||
|
{symbol="BARC.L", label="Barclays (LSE)", note="UK clearing bank"},
|
||||||
|
{symbol="BNP.PA", label="BNP Paribas (Paris)", note="EU systemic"},
|
||||||
|
{symbol="DBK.DE", label="Deutsche Bank (Frankfurt)", note="EU stress canary"},
|
||||||
|
{symbol="^FTAS", label="FTSE All-Share", note="UK breadth"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# flash_news.py — RSS feed registry, by category
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
[feeds]
|
||||||
|
|
||||||
|
markets = [
|
||||||
|
{name="BBC Business", url="https://feeds.bbci.co.uk/news/business/rss.xml"},
|
||||||
|
{name="Guardian Business", url="https://www.theguardian.com/uk/business/rss"},
|
||||||
|
{name="CNBC Top", url="https://www.cnbc.com/id/100003114/device/rss/rss.html"},
|
||||||
|
{name="MarketWatch Top", url="https://feeds.content.dowjones.io/public/rss/mw_topstories"},
|
||||||
|
{name="FT Markets", url="https://www.ft.com/markets?format=rss"},
|
||||||
|
{name="Yahoo Finance", url="https://finance.yahoo.com/news/rssindex"},
|
||||||
|
]
|
||||||
|
|
||||||
|
world = [
|
||||||
|
{name="BBC World", url="https://feeds.bbci.co.uk/news/world/rss.xml"},
|
||||||
|
{name="Al Jazeera", url="https://www.aljazeera.com/xml/rss/all.xml"},
|
||||||
|
]
|
||||||
|
|
||||||
|
energy = [
|
||||||
|
{name="OilPrice", url="https://oilprice.com/rss/main"},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Pan-Asia coverage: HK/China, Japan, Korea, SE Asia, regional wire.
|
||||||
|
asia = [
|
||||||
|
{name="SCMP", url="https://www.scmp.com/rss/91/feed/"},
|
||||||
|
{name="China Daily", url="http://www.chinadaily.com.cn/rss/world_rss.xml"},
|
||||||
|
{name="Xinhua", url="http://www.xinhuanet.com/english/rss/worldrss.xml"},
|
||||||
|
{name="Japan Times", url="https://www.japantimes.co.jp/feed/"},
|
||||||
|
{name="Nikkei Asia", url="https://asia.nikkei.com/rss/feed/nar"},
|
||||||
|
{name="Yonhap", url="https://en.yna.co.kr/RSS/news.xml"},
|
||||||
|
{name="Straits Times", url="https://www.straitstimes.com/news/world/rss.xml"},
|
||||||
|
{name="Asia Times", url="https://asiatimes.com/feed/"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# flash_news.py — built-in keyword presets for headline filtering
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
[news.presets]
|
||||||
|
|
||||||
|
# Mirrors Family_Wealth_Summary_v2.md §8 macro thesis. Swap freely if the
|
||||||
|
# portfolio's thesis changes (e.g. crypto, biotech, deep value).
|
||||||
|
thesis = [
|
||||||
|
"hormuz", "iran", "opec", "brent", "wti", "crude", "oil", "gas", "lng",
|
||||||
|
"china", "taiwan", "yuan", "beijing", "xi",
|
||||||
|
"fed ", "powell", "ecb", "lagarde", "boe ", "boj", "bailey",
|
||||||
|
"inflation", "cpi", "rate cut", "rate hike", "yield",
|
||||||
|
"gold", "silver", "dollar", "yen",
|
||||||
|
"sanction", "tariff", "trade war",
|
||||||
|
"saudi", "russia", "ukraine", "israel", "nato",
|
||||||
|
"defence", "defense", "rearmament",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Tech / AI sector — semis, hyperscaler capex, model labs, chip-export controls.
|
||||||
|
tech = [
|
||||||
|
" ai ", "ai ", " ai,", "artificial intelligence",
|
||||||
|
"nvidia", "openai", "anthropic", "deepmind", "gemini", "chatgpt",
|
||||||
|
"semiconductor", "chip", "foundry", "tsmc", "asml", "samsung",
|
||||||
|
"lithography", "euv", "wafer",
|
||||||
|
"data centre", "data center", "hyperscaler", "capex",
|
||||||
|
"broadcom", "amd ", "arm holdings", "intel ",
|
||||||
|
"apple", "microsoft", "alphabet", "google", "meta ", "tesla",
|
||||||
|
"cloud", "saas", "compute",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Financials — banks, credit, central banks, market plumbing.
|
||||||
|
finance = [
|
||||||
|
"bank", "banking", "lender", "lending",
|
||||||
|
"jpmorgan", "goldman", "morgan stanley", "wells fargo", "citigroup",
|
||||||
|
"hsbc", "barclays", "lloyds", "natwest", "santander",
|
||||||
|
"bnp paribas", "deutsche bank", "credit suisse", "ubs",
|
||||||
|
"federal reserve", "rate cut", "rate hike", "basel",
|
||||||
|
"credit spread", "default", "junk bond", "high yield",
|
||||||
|
"private credit", "shadow banking",
|
||||||
|
"earnings", "buyback", "dividend",
|
||||||
|
]
|
||||||
18
config/portfolio.toml
Normal file
18
config/portfolio.toml
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Portfolio config — optional user-specific overrides on top of default.toml.
|
||||||
|
#
|
||||||
|
# As of v0.2 the portfolio composition (the "pie") is sourced live from the
|
||||||
|
# Trading 212 /equity/portfolio endpoint, not from this file. The portfolio_job
|
||||||
|
# also calls /equity/metadata/instruments to attach human-readable names. Once
|
||||||
|
# portfolio_job has run, the dashboard's Portfolio panel shows the live
|
||||||
|
# composition and the news_job uses those tickers for per-ticker headlines.
|
||||||
|
#
|
||||||
|
# This file is still loaded — anything you add here overlays default.toml.
|
||||||
|
# Use it for:
|
||||||
|
# - extra indicator groups (e.g. a "crypto" or "watchlist" group):
|
||||||
|
# [groups]
|
||||||
|
# crypto = [
|
||||||
|
# {symbol="BTC-USD", label="Bitcoin", note=""},
|
||||||
|
# {symbol="ETH-USD", label="Ethereum", note=""},
|
||||||
|
# ]
|
||||||
|
# - replacing universal groups from default.toml (same key wins)
|
||||||
|
# - adding portfolio-specific RSS feeds or keyword presets
|
||||||
75
docker-compose.yml
Normal file
75
docker-compose.yml
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
# Cassandra — app + scheduler + MariaDB + daily backup sidecar.
|
||||||
|
# .env is mounted read-only; never bake secrets into the image.
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: mariadb:11
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD:-changeme-root}
|
||||||
|
MARIADB_DATABASE: ${MARIADB_DATABASE:-cassandra}
|
||||||
|
MARIADB_USER: ${MARIADB_USER:-cassandra}
|
||||||
|
MARIADB_PASSWORD: ${MARIADB_PASSWORD:-changeme}
|
||||||
|
volumes:
|
||||||
|
- db-data:/var/lib/mysql
|
||||||
|
- ./backup:/backup
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
restart: unless-stopped
|
||||||
|
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: mysql+aiomysql://${MARIADB_USER:-cassandra}:${MARIADB_PASSWORD:-changeme}@db:3306/${MARIADB_DATABASE:-cassandra}
|
||||||
|
volumes:
|
||||||
|
- ./config:/app/config:ro
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- "${CASSANDRA_PORT:-8000}:8000"
|
||||||
|
|
||||||
|
scheduler:
|
||||||
|
build: .
|
||||||
|
restart: unless-stopped
|
||||||
|
command: ["python", "-m", "app.scheduler_main"]
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: mysql+aiomysql://${MARIADB_USER:-cassandra}:${MARIADB_PASSWORD:-changeme}@db:3306/${MARIADB_DATABASE:-cassandra}
|
||||||
|
volumes:
|
||||||
|
- ./config:/app/config:ro
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
backup:
|
||||||
|
image: mariadb:11
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MARIADB_HOST: db
|
||||||
|
MARIADB_USER: ${MARIADB_USER:-cassandra}
|
||||||
|
MARIADB_PASSWORD: ${MARIADB_PASSWORD:-changeme}
|
||||||
|
MARIADB_DATABASE: ${MARIADB_DATABASE:-cassandra}
|
||||||
|
entrypoint: ["/bin/sh", "-c"]
|
||||||
|
# Daily dump at 03:00 UTC; keeps last 14 days.
|
||||||
|
command: |
|
||||||
|
"while true; do
|
||||||
|
sleep $$((86400 - $$(date +%s) % 86400 + 10800));
|
||||||
|
f=/backup/cassandra-$$(date -u +%Y-%m-%d).sql.gz;
|
||||||
|
echo \"[backup] $$f\";
|
||||||
|
mariadb-dump -h $$MARIADB_HOST -u $$MARIADB_USER -p$$MARIADB_PASSWORD $$MARIADB_DATABASE | gzip > $$f || echo '[backup] FAILED';
|
||||||
|
find /backup -name 'cassandra-*.sql.gz' -mtime +14 -delete;
|
||||||
|
done"
|
||||||
|
volumes:
|
||||||
|
- ./backup:/backup
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db-data:
|
||||||
43
pyproject.toml
Normal file
43
pyproject.toml
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
[project]
|
||||||
|
name = "cassandra"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Containerised macro-strategy dashboard — market data, news, portfolios, AI daily log."
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.115",
|
||||||
|
"uvicorn[standard]>=0.32",
|
||||||
|
"jinja2>=3.1",
|
||||||
|
"python-multipart>=0.0.12",
|
||||||
|
"sqlalchemy[asyncio]>=2.0.36",
|
||||||
|
"aiomysql>=0.2.0",
|
||||||
|
"alembic>=1.14",
|
||||||
|
"pydantic>=2.9",
|
||||||
|
"pydantic-settings>=2.6",
|
||||||
|
"httpx>=0.28",
|
||||||
|
"apscheduler>=3.10",
|
||||||
|
"tenacity>=9.0",
|
||||||
|
"structlog>=24.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.3",
|
||||||
|
"pytest-asyncio>=0.24",
|
||||||
|
"pytest-httpx>=0.34",
|
||||||
|
"ruff>=0.7",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
testpaths = ["tests"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py313"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
packages = ["app", "app.services", "app.jobs", "app.routers"]
|
||||||
19
tests/conftest.py
Normal file
19
tests/conftest.py
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
"""Pytest config — no DB / no network. Tests target pure functions only.
|
||||||
|
|
||||||
|
Heavy runtime deps (fastapi, httpx, sqlalchemy, pydantic-settings, tenacity)
|
||||||
|
are installed inside the container but not necessarily on the host. Tests
|
||||||
|
that need them use pytest.importorskip; the full suite runs via
|
||||||
|
`docker compose run --rm app pytest tests/`."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
# Sentinel env so importing app.config doesn't try to read a missing .env.
|
||||||
|
os.environ.setdefault("DATABASE_URL", "sqlite+aiosqlite:///:memory:")
|
||||||
|
os.environ.setdefault("CASSANDRA_MOCK", "1")
|
||||||
21
tests/fixtures/rss_sample.xml
vendored
Normal file
21
tests/fixtures/rss_sample.xml
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0">
|
||||||
|
<channel>
|
||||||
|
<title>Sample</title>
|
||||||
|
<item>
|
||||||
|
<title>Brent crude jumps on Hormuz uncertainty</title>
|
||||||
|
<link>https://example.com/a</link>
|
||||||
|
<pubDate>Fri, 15 May 2026 12:00:00 GMT</pubDate>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>Fed signals caution as inflation re-accelerates</title>
|
||||||
|
<link>https://example.com/b</link>
|
||||||
|
<pubDate>Fri, 15 May 2026 13:30:00 GMT</pubDate>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title></title>
|
||||||
|
<link>https://example.com/empty</link>
|
||||||
|
<pubDate>Fri, 15 May 2026 14:00:00 GMT</pubDate>
|
||||||
|
</item>
|
||||||
|
</channel>
|
||||||
|
</rss>
|
||||||
37
tests/test_api_helpers.py
Normal file
37
tests/test_api_helpers.py
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
"""Tests for tiny helpers in app.routers.api."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytest.importorskip("fastapi")
|
||||||
|
pytest.importorskip("sqlalchemy")
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from app.routers.api import _fmt_age, _md_to_html
|
||||||
|
|
||||||
|
|
||||||
|
def test_fmt_age_none():
|
||||||
|
assert _fmt_age(datetime.now(timezone.utc), None) == "—"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fmt_age_units():
|
||||||
|
now = datetime(2026, 5, 15, 12, 0, tzinfo=timezone.utc)
|
||||||
|
assert _fmt_age(now, now - timedelta(seconds=30)) == "30s"
|
||||||
|
assert _fmt_age(now, now - timedelta(minutes=5)) == "5m"
|
||||||
|
assert _fmt_age(now, now - timedelta(hours=3)) == "3h"
|
||||||
|
assert _fmt_age(now, now - timedelta(days=2)) == "2d"
|
||||||
|
|
||||||
|
|
||||||
|
def test_md_to_html_headers_and_bold():
|
||||||
|
src = "## Section one\n\nBody text with **bold** word.\n\n## Section two\n\nMore."
|
||||||
|
out = _md_to_html(src)
|
||||||
|
assert "<h3>Section one</h3>" in out
|
||||||
|
assert "<strong>bold</strong>" in out
|
||||||
|
assert "<p>" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_md_to_html_preserves_line_breaks_inside_block():
|
||||||
|
src = "Line one\nLine two"
|
||||||
|
out = _md_to_html(src)
|
||||||
|
assert "Line one<br>Line two" in out
|
||||||
40
tests/test_config_loading.py
Normal file
40
tests/test_config_loading.py
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
"""default.toml and portfolio.toml must load cleanly into the expected shapes."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytest.importorskip("pydantic_settings")
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.config import load_feeds, load_groups, load_presets
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
DEFAULT = ROOT / "config" / "default.toml"
|
||||||
|
PORTFOLIO = ROOT / "config" / "portfolio.toml"
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_groups_present():
|
||||||
|
g = load_groups(DEFAULT, PORTFOLIO)
|
||||||
|
for expected in ("equity", "mag7", "rates", "macro", "commodities", "fx", "pie"):
|
||||||
|
assert expected in g, f"missing group: {expected}"
|
||||||
|
# Every item is a 3-tuple of strings.
|
||||||
|
for items in g.values():
|
||||||
|
for sym, lab, note in items:
|
||||||
|
assert isinstance(sym, str) and sym
|
||||||
|
assert isinstance(lab, str)
|
||||||
|
assert isinstance(note, str)
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_feeds_present():
|
||||||
|
f = load_feeds(DEFAULT, PORTFOLIO)
|
||||||
|
for cat in ("markets", "world", "energy", "asia"):
|
||||||
|
assert cat in f, f"missing feed category: {cat}"
|
||||||
|
assert all(name and url for name, url in f[cat])
|
||||||
|
|
||||||
|
|
||||||
|
def test_presets_thesis_present():
|
||||||
|
p = load_presets(DEFAULT, PORTFOLIO)
|
||||||
|
assert "thesis" in p
|
||||||
|
assert "hormuz" in p["thesis"]
|
||||||
47
tests/test_market_parsing.py
Normal file
47
tests/test_market_parsing.py
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
"""Pure-function tests for app.services.market."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytest.importorskip("httpx")
|
||||||
|
pytest.importorskip("pydantic_settings")
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from app.services.market import _pct, _parse_date, _yahoo_range_covering, parse_symbol
|
||||||
|
|
||||||
|
|
||||||
|
def test_pct_basic():
|
||||||
|
assert _pct(100, 110) == 10.0
|
||||||
|
assert _pct(100, 90) == -10.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_pct_handles_none_and_zero():
|
||||||
|
assert _pct(None, 10) is None
|
||||||
|
assert _pct(10, None) is None
|
||||||
|
assert _pct(0, 5) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_date():
|
||||||
|
assert _parse_date("2026-03-04") == datetime(2026, 3, 4)
|
||||||
|
|
||||||
|
|
||||||
|
def test_yahoo_range_covering_picks_smallest():
|
||||||
|
today = datetime.now(timezone.utc).date()
|
||||||
|
# anchor 100 days ago → 1y range is enough
|
||||||
|
short = (today.replace(year=today.year)).isoformat()
|
||||||
|
assert _yahoo_range_covering(None) == "1y"
|
||||||
|
assert _yahoo_range_covering("2026-01-01") == "1y"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_symbol_routes_by_prefix():
|
||||||
|
fn, ident = parse_symbol("FRED:DFF")
|
||||||
|
assert ident == "DFF"
|
||||||
|
assert fn.__name__ == "fetch_fred"
|
||||||
|
fn2, ident2 = parse_symbol("AAPL")
|
||||||
|
assert ident2 == "AAPL"
|
||||||
|
assert fn2.__name__ == "fetch_yahoo"
|
||||||
|
# Unknown prefix falls through to yahoo.
|
||||||
|
fn3, ident3 = parse_symbol("UNKNOWN:XYZ")
|
||||||
|
assert ident3 == "UNKNOWN:XYZ"
|
||||||
|
assert fn3.__name__ == "fetch_yahoo"
|
||||||
53
tests/test_news_parsing.py
Normal file
53
tests/test_news_parsing.py
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
"""Pure-function tests for app.services.news."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytest.importorskip("httpx")
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.services.news import Headline, _parse_date, dedupe, parse_feed
|
||||||
|
|
||||||
|
|
||||||
|
FIXTURE = Path(__file__).parent / "fixtures" / "rss_sample.xml"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_feed_returns_real_items_only():
|
||||||
|
items = parse_feed("Sample", "world", FIXTURE.read_bytes())
|
||||||
|
titles = [h.title for h in items]
|
||||||
|
assert "Brent crude jumps on Hormuz uncertainty" in titles
|
||||||
|
assert "Fed signals caution as inflation re-accelerates" in titles
|
||||||
|
# Empty-title row is dropped.
|
||||||
|
assert all(t for t in titles)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_feed_uses_rfc822_dates():
|
||||||
|
items = parse_feed("Sample", "world", FIXTURE.read_bytes())
|
||||||
|
when = items[0].when
|
||||||
|
assert when.tzinfo is not None
|
||||||
|
assert when.year == 2026
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_date_atom_iso():
|
||||||
|
d = _parse_date("2026-05-15T12:34:56Z")
|
||||||
|
assert d == datetime(2026, 5, 15, 12, 34, 56, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def test_headline_fingerprint_is_normalised():
|
||||||
|
h1 = Headline(datetime.now(timezone.utc), "S1", "c", " Hello WORLD ", "u1")
|
||||||
|
h2 = Headline(datetime.now(timezone.utc), "S2", "c", "hello world", "u2")
|
||||||
|
assert h1.fingerprint == h2.fingerprint
|
||||||
|
|
||||||
|
|
||||||
|
def test_dedupe_keeps_first_by_url_or_title():
|
||||||
|
t = datetime.now(timezone.utc)
|
||||||
|
hs = [
|
||||||
|
Headline(t, "A", "c", "Same headline", "https://a.example/1"),
|
||||||
|
Headline(t, "B", "c", "Same headline", "https://b.example/2"), # title dupe
|
||||||
|
Headline(t, "C", "c", "Other", "https://a.example/1"), # url dupe
|
||||||
|
Headline(t, "D", "c", "Fresh", "https://d.example"),
|
||||||
|
]
|
||||||
|
out = dedupe(hs)
|
||||||
|
assert [h.source for h in out] == ["A", "D"]
|
||||||
45
tests/test_openrouter_prompt.py
Normal file
45
tests/test_openrouter_prompt.py
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
"""build_user_prompt is the heart of the AI log — verify shape."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytest.importorskip("httpx")
|
||||||
|
pytest.importorskip("tenacity")
|
||||||
|
pytest.importorskip("pydantic_settings")
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from app.services.openrouter import SYSTEM_PROMPT, build_user_prompt
|
||||||
|
|
||||||
|
|
||||||
|
def test_system_prompt_has_voice_anchors():
|
||||||
|
# Tripwires for prompt regressions.
|
||||||
|
for marker in ["Objective", "Lens", "Discipline", "watch list"]:
|
||||||
|
assert marker in SYSTEM_PROMPT
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_user_prompt_includes_anchor_and_reference():
|
||||||
|
out = build_user_prompt(
|
||||||
|
today=datetime(2026, 5, 15, tzinfo=timezone.utc),
|
||||||
|
anchor="2026-03-04",
|
||||||
|
quotes_by_group={"equity": [{"symbol": "^GSPC", "label": "S&P 500"}]},
|
||||||
|
headlines_by_bucket={"world": [{"when": "2026-05-15T10:00", "source": "BBC", "title": "x"}]},
|
||||||
|
reference_line="S&P 7501 · VIX 18",
|
||||||
|
)
|
||||||
|
assert "2026-05-15" in out
|
||||||
|
assert "Anchor reference date: 2026-03-04" in out
|
||||||
|
assert "S&P 7501" in out
|
||||||
|
assert "WORLD" in out
|
||||||
|
assert "^GSPC" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_user_prompt_omits_empty_buckets():
|
||||||
|
out = build_user_prompt(
|
||||||
|
today=datetime(2026, 5, 15, tzinfo=timezone.utc),
|
||||||
|
anchor=None,
|
||||||
|
quotes_by_group={},
|
||||||
|
headlines_by_bucket={"world": [], "tech": [{"when": "2026-05-15T10:00",
|
||||||
|
"source": "X", "title": "AI thing"}]},
|
||||||
|
)
|
||||||
|
assert "TECH" in out
|
||||||
|
assert "WORLD" not in out
|
||||||
Loading…
Add table
Add a link
Reference in a new issue