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>
582 lines
19 KiB
Python
582 lines
19 KiB
Python
"""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,
|
||
}
|