add Eurostat + UK ONS sources; valuation/bubble/economy/bonds groups; aggregate read; market-open header

Three new data sources hooked into the existing SOURCES registry. All
open APIs, no keys:

  - EUROSTAT: prefix EUROSTAT:dataset?dim=val&... — current EU bond
    yields (Bund/OAT/BTP/EZ) and Eurozone economic indicators that
    FRED's OECD-mirror series stopped updating in 2022-2023.
  - ONS: prefix ONS:topic/cdid/dataset — current UK CPI, unemployment,
    GDP, industrial production. Replaces the 5+ month-stale FRED
    LRHUTTTTGBM156S mirror.

New indicator groups in default.toml feed the strategic/fundamental
lens we converged on: valuation (CAPE/Buffett anchors), bubble_watch
(SKEW/VVIX/RSP vs SPY/HYG vs TLT/IPO/crypto), economy (multi-region,
ALL current-or-stale-flagged), bonds (UK/EU/US/JPN sovereign yields).

Indicator panel now opens with an AI "read" interpretation per group
(generated hourly at :07 UTC alongside an aggregate cross-group read
shown in the dashboard header). The aggregate is grounded by a markets
strip — NYSE/LSE/Frankfurt/Tokyo/HK/Shanghai with open/closed LEDs and
next-open countdown, computed locally from each exchange's tz.

Other UX bits: indicator-row tooltips populated from TOML notes;
rows whose last observation is >90 days old get a 'stale' chip;
ghost symbols (in DB but no longer in TOML) filtered out of the
panel; Eurostat/ONS symbols display as short codes rather than the
full API path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Giorgio Gilestro 2026-05-15 23:07:42 +01:00
parent a10409c02b
commit 1edf9cad41
15 changed files with 1156 additions and 10 deletions

View file

@ -32,6 +32,7 @@ from app.templates_env import templates
from app.models import (
AICall,
Headline,
IndicatorSummary,
JobRun,
Portfolio,
PortfolioSnapshot,
@ -130,10 +131,51 @@ async def indicators(
)).scalars().all()
if as_ == "html":
from app.config import get_settings, load_groups
from app.services.market import parse_symbol
s_ = get_settings()
# Build the set of symbols currently configured for this group AND a
# notes lookup keyed by the post-parse identifier (the form stored in
# the DB).
notes: dict[str, str] = {}
configured: set[str] = set()
for sym, _lab, note in load_groups(s_.BASELINE_TOML, s_.PORTFOLIO_TOML).get(group, []):
_fn, ident = parse_symbol(sym)
notes[ident] = note
configured.add(ident)
# Drop ghost rows: symbols that used to be in this group but were
# removed from the TOML — their last quote still sits in the DB until
# rollup prunes it, but we shouldn't show them.
rows = [r for r in rows if r.symbol in configured]
has_anchor = any((r.changes or {}).get("anchor") is not None for r in rows)
summary = (await session.execute(
select(IndicatorSummary)
.where(IndicatorSummary.group_name == group)
.order_by(desc(IndicatorSummary.generated_at))
.limit(1)
)).scalar_one_or_none()
# Mark rows whose `as_of` is older than 90 days as stale so the UI
# can dim them — some FRED international series are months/years
# behind their primary source.
today = utcnow().date()
stale_symbols: set[str] = set()
for r in rows:
try:
as_of_d = datetime.strptime(r.as_of, "%Y-%m-%d").date() if r.as_of else None
except ValueError:
as_of_d = None
if as_of_d and (today - as_of_d).days > 90:
stale_symbols.add(r.symbol)
return templates.TemplateResponse(
request, "partials/indicators.html",
{"quotes": rows, "has_anchor": has_anchor},
{"quotes": rows, "has_anchor": has_anchor,
"summary": summary, "notes": notes,
"stale_symbols": stale_symbols},
)
return [QuoteOut.model_validate(r, from_attributes=True) for r in rows]
@ -376,6 +418,46 @@ async def portfolios(
# --- Health / ops footer -----------------------------------------------------
# --- Aggregate summary + market status (dashboard header) -------------------
AGGREGATE_GROUP_NAME = "__all__"
@router.get("/summary/aggregate")
async def aggregate_summary(
request: Request,
session: AsyncSession = Depends(get_session),
as_: str | None = Query(default=None, alias="as"),
):
row = (await session.execute(
select(IndicatorSummary)
.where(IndicatorSummary.group_name == AGGREGATE_GROUP_NAME)
.order_by(desc(IndicatorSummary.generated_at))
.limit(1)
)).scalar_one_or_none()
from app.services.markets import all_statuses
statuses = all_statuses()
if as_ == "html":
return templates.TemplateResponse(
request, "partials/dashboard_header.html",
{"summary": row, "markets": statuses},
)
return {
"summary": (
{"content": row.content,
"generated_at": row.generated_at.isoformat(),
"model": row.model}
if row else None
),
"markets": [
{**m, "until": m["until"].isoformat()} for m in statuses
],
}
@router.get("/health", response_class=HTMLResponse, include_in_schema=False)
async def health_html(
request: Request,