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