api.py was 933 lines mixing four distinct concerns: indicators + news + strategic log (the JSON/HTMX API proper), the chat endpoint + its three private helpers (~200 lines), and the two HTML-only ops endpoints /markets-bar + /health (~150 lines). Extracted: - app/routers/chat.py — POST /api/chat + _latest_quotes_by_group_chat, _thesis_headlines_for_chat, _month_spend - app/routers/ops.py — GET /api/markets-bar + GET /api/health + _fmt_price helper Both new routers use the same dependencies=[Depends(require_token)] as api.py and are mounted at the /api prefix in app/main.py. URL surface is byte-identical with no externally-visible change. api.py shrinks to ~620 lines focused on indicators+news+log+settings. Helpers shared with the original api.py (_md_to_html, _resolve_tone_param) are imported from app.routers.api where needed in chat.py to avoid duplication. Also updated tests/test_chat_and_log_gates.py to mount chat_router in its local test app, since /api/chat now lives there. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
162 lines
5.6 KiB
Python
162 lines
5.6 KiB
Python
"""HTML-only ops endpoints — /api/markets-bar and /api/health.
|
|
|
|
These are HTMX partials consumed by the dashboard. They return HTML by
|
|
default (not JSON) and are not included in the OpenAPI schema.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from fastapi import APIRouter, Depends, Query, Request
|
|
from fastapi.responses import HTMLResponse, JSONResponse
|
|
from sqlalchemy import desc, func, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.auth import require_token
|
|
from app.db import get_session, utcnow
|
|
from app.models import JobRun, Quote
|
|
from app.routers.api import _age_seconds, _fmt_age
|
|
from app.schemas import HealthOut, JobStatus
|
|
from app.templates_env import templates
|
|
|
|
router = APIRouter(dependencies=[Depends(require_token)])
|
|
|
|
JOB_NAMES = ("market_job", "news_job", "ai_log_job", "rollup_job",
|
|
"indicator_summary_job", "universe_flush_job",
|
|
"email_digest_job")
|
|
JOB_STALE_HOURS = 2.0 # job is "warn" if its last success was >2h ago
|
|
|
|
# Market → headline index mapping for the sticky bottom bar. Symbols must
|
|
# be present in config/default.toml so market_job populates `quotes`.
|
|
_MARKET_INDEX = {
|
|
"NYSE": ("^GSPC", "S&P 500"),
|
|
"LSE": ("^FTSE", "FTSE 100"),
|
|
# XETRA → Euro Stoxx 50 rather than ^GDAXI: Yahoo's DAX ticker is
|
|
# patchy via the chart endpoint, and ^STOXX50E is already tracked in
|
|
# config/default.toml's equity group.
|
|
"XETRA": ("^STOXX50E", "STOXX 50"),
|
|
"JPX": ("^N225", "Nikkei 225"),
|
|
"HKEX": ("^HSI", "Hang Seng"),
|
|
"SSE": ("000300.SS", "CSI 300"),
|
|
}
|
|
|
|
|
|
def _fmt_price(p: float | None) -> str:
|
|
if p is None:
|
|
return "—"
|
|
if abs(p) >= 1000:
|
|
return f"{p:,.0f}"
|
|
if abs(p) >= 100:
|
|
return f"{p:,.1f}"
|
|
return f"{p:,.2f}"
|
|
|
|
|
|
@router.get("/markets-bar", response_class=HTMLResponse, include_in_schema=False)
|
|
async def markets_bar(
|
|
request: Request,
|
|
session: AsyncSession = Depends(get_session),
|
|
as_: str | None = Query(default=None, alias="as"),
|
|
):
|
|
"""The sticky bottom-bar payload: per-market open/close status with the
|
|
market's headline index price + 1d change. Refreshed by HTMX every 60s.
|
|
"""
|
|
from app.services.markets import all_statuses
|
|
|
|
statuses = all_statuses()
|
|
# Latest quote per headline-index symbol in one query.
|
|
wanted_syms = [sym for sym, _ in _MARKET_INDEX.values()]
|
|
sub = (
|
|
select(Quote.symbol, func.max(Quote.fetched_at).label("mx"))
|
|
.where(Quote.symbol.in_(wanted_syms))
|
|
.group_by(Quote.symbol)
|
|
.subquery()
|
|
)
|
|
rows = (await session.execute(
|
|
select(Quote).join(
|
|
sub,
|
|
(Quote.symbol == sub.c.symbol) & (Quote.fetched_at == sub.c.mx),
|
|
)
|
|
)).scalars().all()
|
|
by_sym = {q.symbol: q for q in rows}
|
|
|
|
markets: list[dict] = []
|
|
for st in statuses:
|
|
sym, label = _MARKET_INDEX.get(st["code"], (None, None))
|
|
q = by_sym.get(sym) if sym else None
|
|
idx = None
|
|
if q is not None and q.price is not None:
|
|
idx = {
|
|
"symbol": q.symbol,
|
|
"label": label,
|
|
"price_fmt": _fmt_price(q.price),
|
|
"change_1d_pct": (q.changes or {}).get("1d"),
|
|
}
|
|
markets.append({
|
|
"code": st["code"],
|
|
"label": st["label"],
|
|
"open": st["open"],
|
|
"until_iso": st["until"].isoformat(),
|
|
"until_hhmm": st["until"].strftime("%H:%M"),
|
|
"index": idx,
|
|
})
|
|
|
|
return templates.TemplateResponse(
|
|
request, "partials/markets_bar.html",
|
|
{"markets": markets},
|
|
)
|
|
|
|
|
|
@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},
|
|
)
|