From 1edf9cad415ffabdea86002b1c0aaf865375fab0 Mon Sep 17 00:00:00 2001 From: Giorgio Gilestro Date: Fri, 15 May 2026 23:07:42 +0100 Subject: [PATCH] add Eurostat + UK ONS sources; valuation/bubble/economy/bonds groups; aggregate read; market-open header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- alembic/versions/0004_indicator_summaries.py | 43 ++++ alembic/versions/0005_widen_quote_symbol.py | 34 +++ app/jobs/indicator_summary_job.py | 237 +++++++++++++++++++ app/jobs/market_job.py | 4 +- app/models.py | 21 +- app/routers/api.py | 84 ++++++- app/scheduler_main.py | 6 +- app/services/market.py | 219 ++++++++++++++++- app/services/markets.py | 84 +++++++ app/services/openrouter.py | 147 +++++++++++- app/static/css/cassandra.css | 132 +++++++++++ app/templates/dashboard.html | 8 + app/templates/partials/dashboard_header.html | 32 +++ app/templates/partials/indicators.html | 32 ++- config/default.toml | 83 +++++++ 15 files changed, 1156 insertions(+), 10 deletions(-) create mode 100644 alembic/versions/0004_indicator_summaries.py create mode 100644 alembic/versions/0005_widen_quote_symbol.py create mode 100644 app/jobs/indicator_summary_job.py create mode 100644 app/services/markets.py create mode 100644 app/templates/partials/dashboard_header.html diff --git a/alembic/versions/0004_indicator_summaries.py b/alembic/versions/0004_indicator_summaries.py new file mode 100644 index 0000000..5157fa9 --- /dev/null +++ b/alembic/versions/0004_indicator_summaries.py @@ -0,0 +1,43 @@ +"""indicator_summaries — short AI-generated read per indicator group + +Revision ID: 0004 +Revises: 0003 +Create Date: 2026-05-15 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +revision: str = "0004" +down_revision: Union[str, None] = "0003" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "indicator_summaries", + sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True), + sa.Column("group_name", sa.String(64), nullable=False), + sa.Column("generated_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("model", sa.String(64), nullable=False), + sa.Column("tone", sa.String(16)), + sa.Column("analysis", 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_index( + "ix_indsumm_group_generated", + "indicator_summaries", + ["group_name", "generated_at"], + ) + + +def downgrade() -> None: + op.drop_index("ix_indsumm_group_generated", table_name="indicator_summaries") + op.drop_table("indicator_summaries") diff --git a/alembic/versions/0005_widen_quote_symbol.py b/alembic/versions/0005_widen_quote_symbol.py new file mode 100644 index 0000000..22b2bce --- /dev/null +++ b/alembic/versions/0005_widen_quote_symbol.py @@ -0,0 +1,34 @@ +"""widen quotes.symbol to 128 chars to fit Eurostat / ONS path identifiers + +Revision ID: 0005 +Revises: 0004 +Create Date: 2026-05-15 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +revision: str = "0005" +down_revision: Union[str, None] = "0004" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.alter_column( + "quotes", "symbol", + existing_type=sa.String(64), + type_=sa.String(128), + existing_nullable=False, + ) + + +def downgrade() -> None: + op.alter_column( + "quotes", "symbol", + existing_type=sa.String(128), + type_=sa.String(64), + existing_nullable=False, + ) diff --git a/app/jobs/indicator_summary_job.py b/app/jobs/indicator_summary_job.py new file mode 100644 index 0000000..147aa27 --- /dev/null +++ b/app/jobs/indicator_summary_job.py @@ -0,0 +1,237 @@ +"""Hourly per-group indicator summaries — a short AI read at the top of each +Indicators tab. Costs ~$0.0003 per call on DeepSeek V4 Flash, so 10+ groups +hourly stays comfortably under the monthly cap.""" +from __future__ import annotations + +import asyncio +import re +from collections import defaultdict + +import httpx +from sqlalchemy import desc, func, select + +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 AICall, IndicatorSummary, Quote +from app.services.openrouter import ( + PROMPT_VERSION, + build_aggregate_summary_system_prompt, + build_aggregate_summary_user_prompt, + build_summary_system_prompt, + build_summary_user_prompt, + call_openrouter, + month_start, +) + + +AGGREGATE_GROUP_NAME = "__all__" + + +# Strip known meta-commentary openers the model sometimes leaks despite the +# prompt's hard constraints. Each pattern matches one leading sentence. +_LEAK_PATTERNS = [ + re.compile(p, re.IGNORECASE | re.DOTALL) + for p in ( + # First-person meta — "I need to / I'll / I have to / I'm going to ..." + r"^i\s+(?:need|have|must|should|am going|'ll|will|shall|can|am)[^.]*\.\s*", + # "We need / we're / we are asked / we will ..." + r"^we\s+(?:need|are|'re|will|shall|can|should|must|have)[^.]*\.\s*", + r"^let\s+(?:me|us|'?s)[^.]*\.\s*", + r"^here['’]s[^.]*\.\s*", + r"^sure[,!]?\s[^.]*\.\s*", + r"^looking at[^.]*\.\s*", + r"^based on[^.]*\.\s*", + r"^to (?:address|answer|write|summarise|summarize)[^.]*\.\s*", + r"^first[,]?\s[^.]*\.\s*", + r"^the (?:user|data shows|reader|task|request)[^.]*\.\s*", + r"^summary[:.]\s*", + r"^okay[,]?\s+", + r"^alright[,]?\s+", + r"^thinking[^.]*\.\s*", + ) +] + + +def clean_summary(text: str) -> str: + """Strip leading meta-commentary. If cleaning removes nearly everything + (suggesting the model emitted reasoning then ran out of tokens), fall + back to the last non-empty paragraph of the raw output — that's usually + where the actual answer ended up.""" + raw = text.strip() + out = raw + for _ in range(2): + before = out + for pat in _LEAK_PATTERNS: + out = pat.sub("", out, count=1).lstrip() + if out == before: + break + if len(out) < 60 and len(raw) > 120: + # Cleaning ate too much; take the last non-empty paragraph of raw. + paragraphs = [p.strip() for p in re.split(r"\n\s*\n", raw) if p.strip()] + if paragraphs: + out = paragraphs[-1] + return out + + +async def _latest_quotes_by_group(session) -> dict[str, list[dict]]: + """Latest non-null quote per (group, symbol). Drops error rows.""" + 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), + ).where(Quote.price.is_not(None)) + .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 _month_spend(session) -> 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) + + +async def _generate_one( + session, client: httpx.AsyncClient, group: str, quotes: list[dict], + system_prompt: str, model: str, tone: str, analysis: str, +) -> bool: + """Generate + persist one group's summary. Returns True on success.""" + user_prompt = build_summary_user_prompt(group, quotes) + try: + result = await call_openrouter( + client, + [{"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}], + model=model, + max_tokens=800, # DeepSeek sometimes spends 300+ on internal reasoning + ) + except Exception as e: + session.add(AICall(model=model, status="error", error=str(e)[:500])) + log.warning("ind_summary.failed", group=group, error=str(e)[:120]) + return False + + session.add(IndicatorSummary( + group_name=group, + generated_at=utcnow(), + model=result.model, + tone=tone, + analysis=analysis, + prompt_version=PROMPT_VERSION, + content=clean_summary(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", + )) + return True + + +async def run() -> None: + async with job_lifecycle("indicator_summary_job") as (session, jr): + if jr.status == "skipped": + return + s = get_settings() + if not s.OPENROUTER_API_KEY: + jr.status = "skipped" + return + + spent = await _month_spend(session) + if spent >= s.OPENROUTER_MONTHLY_CAP_USD: + jr.status = "skipped" + jr.error = f"monthly cap reached (${spent:.2f})" + return + + groups = await _latest_quotes_by_group(session) + # Only summarise groups currently configured in TOML — drops stale + # group names (e.g. an old "pie" before T212 sourcing) that still have + # quotes in the table but no UI presence. + configured = set(load_groups(s.BASELINE_TOML, s.PORTFOLIO_TOML).keys()) + groups = {g: q for g, q in groups.items() if g in configured} + if not groups: + jr.status = "skipped" + return + + tone = s.CASSANDRA_TONE.upper() + analysis = s.CASSANDRA_ANALYSIS.upper() + system_prompt = build_summary_system_prompt(tone, analysis) + + written = 0 + async with httpx.AsyncClient(follow_redirects=True) as client: + # Sequential rather than parallel — OpenRouter free tiers can + # throttle bursts; total work is small (~12 calls × ~5s each). + for group, quotes in groups.items(): + ok = await _generate_one( + session, client, group, quotes, + system_prompt, s.OPENROUTER_MODEL, tone, analysis, + ) + if ok: + written += 1 + await session.commit() # partial progress survives mid-job error + + # One aggregate read across all groups, stored under __all__. + agg_system = build_aggregate_summary_system_prompt(tone, analysis) + agg_user = build_aggregate_summary_user_prompt(groups) + try: + result = await call_openrouter( + client, + [{"role": "system", "content": agg_system}, + {"role": "user", "content": agg_user}], + model=s.OPENROUTER_MODEL, + max_tokens=1500, # room for reasoning + 80-word output + ) + session.add(IndicatorSummary( + group_name=AGGREGATE_GROUP_NAME, + generated_at=utcnow(), + model=result.model, + tone=tone, + analysis=analysis, + prompt_version=PROMPT_VERSION, + content=clean_summary(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", + )) + written += 1 + except Exception as e: + session.add(AICall( + model=s.OPENROUTER_MODEL, status="error", error=str(e)[:500], + )) + log.warning("ind_summary.agg_failed", error=str(e)[:120]) + await session.commit() + + jr.items_written = written + log.info("ind_summary.done", groups=len(groups), written=written) + + +if __name__ == "__main__": + asyncio.run(run()) diff --git a/app/jobs/market_job.py b/app/jobs/market_job.py index a78a1e1..4e394bf 100644 --- a/app/jobs/market_job.py +++ b/app/jobs/market_job.py @@ -51,7 +51,9 @@ async def run() -> None: currency=q.currency, as_of=q.as_of, changes=q.changes or None, - error=q.error, + # Truncate to the column's 255-char limit. Some providers + # return verbose redirect chains that blow the limit. + error=(q.error[:250] if q.error else None), fetched_at=now, )) await session.commit() diff --git a/app/models.py b/app/models.py index 0a1d7ad..4add639 100644 --- a/app/models.py +++ b/app/models.py @@ -29,7 +29,7 @@ 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) + symbol: Mapped[str] = mapped_column(String(128), 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) @@ -106,6 +106,25 @@ class StrategicLog(Base): cost_usd: Mapped[float | None] = mapped_column(Float) +class IndicatorSummary(Base): + """Short AI-generated read for one indicator group, regenerated hourly. + The latest row per group_name is what the dashboard renders.""" + __tablename__ = "indicator_summaries" + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + group_name: Mapped[str] = mapped_column(String(64), nullable=False) + generated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + model: Mapped[str] = mapped_column(String(64), nullable=False) + tone: Mapped[str | None] = mapped_column(String(16)) + analysis: Mapped[str | None] = mapped_column(String(16)) + prompt_version: Mapped[int] = mapped_column(Integer, default=1) + 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) + + __table_args__ = (Index("ix_indsumm_group_generated", "group_name", "generated_at"),) + + class AICall(Base): """Cost ledger for OpenRouter calls. Feeds the monthly cap check.""" __tablename__ = "ai_calls" diff --git a/app/routers/api.py b/app/routers/api.py index 82a818f..90b80f4 100644 --- a/app/routers/api.py +++ b/app/routers/api.py @@ -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, diff --git a/app/scheduler_main.py b/app/scheduler_main.py index 03ecd70..b5f8444 100644 --- a/app/scheduler_main.py +++ b/app/scheduler_main.py @@ -11,7 +11,10 @@ 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 +from app.jobs import ( + market_job, news_job, portfolio_job, ai_log_job, rollup_job, + indicator_summary_job, +) log = get_logger("scheduler") @@ -39,6 +42,7 @@ async def main() -> None: 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(indicator_summary_job.run, CronTrigger(minute=7), name="indicator_summary_job", id="indicator_summary_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() diff --git a/app/services/market.py b/app/services/market.py index ef73f36..5f3ad28 100644 --- a/app/services/market.py +++ b/app/services/market.py @@ -16,6 +16,8 @@ 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" +EUROSTAT_API = "https://ec.europa.eu/eurostat/api/dissemination/statistics/1.0/data/{dataset}" +ONS_API = "https://www.ons.gov.uk/{topic}/timeseries/{cdid}/{dataset}/data" UA = {"User-Agent": "Mozilla/5.0 (cassandra) Python/httpx"} @@ -212,10 +214,225 @@ async def fetch_fred( return Quote(symbol, "fred", label, note, None, None, None, error=str(e)) +# --- Eurostat (no API key needed) ------------------------------------------- + + +def _eurostat_time_to_iso(t: str) -> str: + """Convert Eurostat time codes into ISO-style dates so they sort and + compare correctly. Accepts YYYY-MM, YYYY-Qn, YYYY, and YYYY-MM-DD.""" + t = t.strip() + if len(t) == 4 and t.isdigit(): # annual: "2026" + return f"{t}-01-01" + if len(t) == 6 and t[4] == "Q": # quarterly: "2026Q1" + q = int(t[5]) + return f"{t[:4]}-{(q - 1) * 3 + 1:02d}-01" + if len(t) == 7 and t[4] == "-": # monthly: "2026-03" + return f"{t}-01" + if len(t) == 10: # daily: "2026-03-15" + return t + return t # fall through; caller may flag + + +async def fetch_eurostat( + client: httpx.AsyncClient, + symbol: str, + label: str, + note: str, + anchor: str | None = None, +) -> Quote: + """Fetch a Eurostat time series. `symbol` format: + DATASET?dim1=val1&dim2=val2 + e.g. 'irt_lt_mcby_m?geo=DE&int_rt=MCBY' for German 10y bond yield. + Eurostat's API is open (no key), uses JSON-stat 2.0.""" + import urllib.parse + + try: + if "?" in symbol: + dataset, query = symbol.split("?", 1) + params = dict(urllib.parse.parse_qsl(query)) + else: + dataset, params = symbol, {} + params.setdefault("format", "JSON") + params.setdefault("lang", "EN") + + r = await client.get( + EUROSTAT_API.format(dataset=dataset), + params=params, headers=UA, timeout=20, + ) + r.raise_for_status() + data = r.json() + + time_cat = data["dimension"]["time"]["category"] + # JSON-stat 2.0: {"index": {timecode: pos}, "label": {timecode: human}} + time_index = time_cat["index"] + values = data.get("value") or {} + + # Build (iso_date, value) pairs, sorted ascending in time. + rows: list[tuple[str, float]] = [] + for tcode, pos in sorted(time_index.items(), key=lambda kv: kv[1]): + raw = values.get(str(pos)) + if raw is None: + continue + try: + rows.append((_eurostat_time_to_iso(tcode), float(raw))) + except (TypeError, ValueError): + continue + + if not rows: + raise ValueError("no observations") + + last_date, last_val = rows[-1] + + def _find_back(min_days: int) -> float | None: + ref = datetime.strptime(last_date, "%Y-%m-%d").date() + for d, v in reversed(rows[:-1]): + if (ref - datetime.strptime(d, "%Y-%m-%d").date()).days >= min_days: + return v + return None + + prev_val = rows[-2][1] if len(rows) >= 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: str | None = None + if anchor: + anchor_d = _parse_date(anchor).date() + for d, v in reversed(rows): + if datetime.strptime(d, "%Y-%m-%d").date() <= anchor_d: + changes["anchor"] = _pct(v, last_val) + anchor_used = d + break + + return Quote( + symbol=symbol, source="eurostat", 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, "eurostat", label, note, None, None, None, error=str(e)) + + +# --- UK ONS (Office for National Statistics, no API key needed) ------------- + + +_ONS_MONTH = { + "JAN": 1, "FEB": 2, "MAR": 3, "APR": 4, "MAY": 5, "JUN": 6, + "JUL": 7, "AUG": 8, "SEP": 9, "OCT": 10, "NOV": 11, "DEC": 12, +} + + +def _ons_date_to_iso(s: str) -> str | None: + """ONS date formats: monthly '2026 MAR', quarterly '2026 Q1', annual '2025'.""" + s = s.strip().upper() + parts = s.split() + try: + if len(parts) == 1 and parts[0].isdigit(): + return f"{parts[0]}-01-01" + if len(parts) == 2: + year = int(parts[0]) + tag = parts[1] + if tag in _ONS_MONTH: + return f"{year:04d}-{_ONS_MONTH[tag]:02d}-01" + if tag.startswith("Q") and tag[1:].isdigit(): + q = int(tag[1:]) + return f"{year:04d}-{(q - 1) * 3 + 1:02d}-01" + except (ValueError, IndexError): + pass + return None + + +async def fetch_ons( + client: httpx.AsyncClient, + symbol: str, + label: str, + note: str, + anchor: str | None = None, +) -> Quote: + """Fetch a UK ONS time series. `symbol` format: + // + e.g. 'economy/inflationandpriceindices/d7g7/mm23' for UK CPI YoY. + ONS publishes via www.ons.gov.uk; no auth, JSON when Accept header set.""" + try: + parts = symbol.split("/") + if len(parts) < 3: + raise ValueError("ONS symbol must be topic/cdid/dataset") + dataset = parts[-1] + cdid = parts[-2] + topic = "/".join(parts[:-2]) + + r = await client.get( + ONS_API.format(topic=topic, cdid=cdid, dataset=dataset), + headers={**UA, "Accept": "application/json"}, + timeout=20, + ) + r.raise_for_status() + data = r.json() + + # Use the most granular series available: months > quarters > years. + for key in ("months", "quarters", "years"): + raw_seq = data.get(key) or [] + if raw_seq: + break + if not raw_seq: + raise ValueError("no observations") + + rows: list[tuple[str, float]] = [] + for entry in raw_seq: + iso = _ons_date_to_iso(entry.get("date", "")) + v = entry.get("value") + if iso is None or v in (None, "", "."): + continue + try: + rows.append((iso, float(v))) + except (TypeError, ValueError): + continue + if not rows: + raise ValueError("no parseable observations") + + last_date, last_val = rows[-1] + + def _find_back(min_days: int) -> float | None: + ref = datetime.strptime(last_date, "%Y-%m-%d").date() + for d, v in reversed(rows[:-1]): + if (ref - datetime.strptime(d, "%Y-%m-%d").date()).days >= min_days: + return v + return None + + prev_val = rows[-2][1] if len(rows) >= 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: str | None = None + if anchor: + anchor_d = _parse_date(anchor).date() + for d, v in reversed(rows): + if datetime.strptime(d, "%Y-%m-%d").date() <= anchor_d: + changes["anchor"] = _pct(v, last_val) + anchor_used = d + break + + return Quote( + symbol=symbol, source="ons", 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, "ons", label, note, None, None, None, error=str(e)) + + # --- Source registry ---------------------------------------------------------- FetcherFn = Callable[..., "Quote"] -SOURCES: dict[str, FetcherFn] = {"yahoo": fetch_yahoo, "FRED": fetch_fred} +SOURCES: dict[str, FetcherFn] = { + "yahoo": fetch_yahoo, + "FRED": fetch_fred, + "EUROSTAT": fetch_eurostat, + "ONS": fetch_ons, +} def parse_symbol(symbol: str) -> tuple[FetcherFn, str]: diff --git a/app/services/markets.py b/app/services/markets.py new file mode 100644 index 0000000..7fded3a --- /dev/null +++ b/app/services/markets.py @@ -0,0 +1,84 @@ +"""Market-open/close status for the dashboard header. Pure computation — +no API needed; the schedules are known constants. Holidays are NOT modelled +(would require a region-specific calendar); a closed Monday will still show +"open" if the time-of-day fits. Good enough for the strategic dashboard. +""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, time, timedelta, timezone +from zoneinfo import ZoneInfo + + +@dataclass(frozen=True) +class Market: + code: str + name: str + tz: str # IANA zone (handles DST automatically) + open: time # local time + close: time # local time + + +# Mon=0 .. Sun=6. Markets observe Mon-Fri unless overridden. +_WORKWEEK = {0, 1, 2, 3, 4} + + +MARKETS: list[Market] = [ + Market("NYSE", "NYSE", "America/New_York", time(9, 30), time(16, 0)), + Market("LSE", "LSE", "Europe/London", time(8, 0), time(16, 30)), + Market("XETRA", "Frankfurt","Europe/Berlin", time(9, 0), time(17, 30)), + Market("JPX", "Tokyo", "Asia/Tokyo", time(9, 0), time(15, 0)), + Market("HKEX", "Hong Kong","Asia/Hong_Kong", time(9, 30), time(16, 0)), + Market("SSE", "Shanghai", "Asia/Shanghai", time(9, 30), time(15, 0)), +] + + +def _next_open_at(m: Market, now_utc: datetime) -> datetime: + """Earliest future open datetime (UTC) for this market, scanning ahead + up to 7 days for the next weekday.""" + tz = ZoneInfo(m.tz) + local = now_utc.astimezone(tz) + candidate_date = local.date() + for _ in range(8): # today + 7 days + weekday = candidate_date.weekday() + if weekday in _WORKWEEK: + local_open = datetime.combine(candidate_date, m.open, tzinfo=tz) + if local_open > local: + return local_open.astimezone(timezone.utc) + candidate_date = candidate_date + timedelta(days=1) + return now_utc + timedelta(days=7) # fallback (shouldn't happen) + + +def _close_at(m: Market, now_utc: datetime) -> datetime: + """Today's close in UTC (assumes we've already established it's open).""" + tz = ZoneInfo(m.tz) + local = now_utc.astimezone(tz) + return datetime.combine(local.date(), m.close, tzinfo=tz).astimezone(timezone.utc) + + +def status_for(m: Market, now_utc: datetime) -> dict: + tz = ZoneInfo(m.tz) + local = now_utc.astimezone(tz) + is_workday = local.weekday() in _WORKWEEK + in_session = is_workday and m.open <= local.time() < m.close + if in_session: + return { + "code": m.code, + "name": m.name, + "open": True, + "until": _close_at(m, now_utc), + "label": "open", + } + return { + "code": m.code, + "name": m.name, + "open": False, + "until": _next_open_at(m, now_utc), + "label": "closed", + } + + +def all_statuses(now_utc: datetime | None = None) -> list[dict]: + if now_utc is None: + now_utc = datetime.now(timezone.utc) + return [status_for(m, now_utc) for m in MARKETS] diff --git a/app/services/openrouter.py b/app/services/openrouter.py index 68faacd..cff414a 100644 --- a/app/services/openrouter.py +++ b/app/services/openrouter.py @@ -20,7 +20,7 @@ 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 +PROMPT_VERSION = 4 # --- Core: invariant across tone/analysis settings ---------------------------- @@ -60,6 +60,28 @@ numbers in every paragraph. No section over ~150 words. - End with a watch list: 3-5 specific items to track in the next week, \ each one sentence. +# Time-horizon discipline +- This is a STRATEGIC log, not a day-trader's read. Treat 1-day moves under \ +2% as background noise; mention them only when they break or confirm a \ +multi-week trend or are extreme outliers. +- Anchor every claim to multi-week (1m), multi-month (since-anchor), or \ +multi-year (1y) changes — not 1d. If the only thing happening is a 1d move, \ +omit the paragraph. +- The watch list is for "structural tripwires over the next 1-3 months", not \ +"things to watch tomorrow". Each watch item should name a level/threshold \ +whose breach would change the regime, not a calendar-date event. + +# Rational vs irrational framing +The reader's primary goal is to disconnect rational decisions from market \ +irrationality. In every sector or theme paragraph, separately identify: +- The RATIONAL drivers: earnings, real-economy data, monetary policy, \ +structural geopolitical shifts, valuation vs fundamentals. +- The IRRATIONAL drivers: positioning, narrative momentum, sentiment \ +extremes, concentration, flow-driven moves, options gamma, credit complacency. +When the two diverge — price moving on irrational drivers while fundamentals \ +say otherwise, or vice versa — flag the divergence explicitly. Those gaps \ +are where the next regime change starts. + # Discipline - No emojis, no marketing language, no "concerning" or "unprecedented" \ without a specific number behind it. @@ -68,7 +90,16 @@ without a specific number behind it. 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.""" +to report whether reality is confirming, modifying, or refuting the thesis. + +# System temperature (closing line, mandatory) +Close the log with a single sentence on a line of its own, formatted exactly: + + System temperature: [cool|neutral|elevated|hot|extreme] — [one clause naming the 2-3 specific divergences or readings that justify the label] + +This is the line a reader who only sees the watch list scrolls down to. Make \ +it earn its place: cite real signals (HY OAS, breadth, VIX, valuation, real \ +yields), not vibes.""" # --- Tone: audience-shaping block -------------------------------------------- @@ -141,6 +172,118 @@ question via the chat sidebar. - Keep the same audience and analysis discipline established above.""" +def build_summary_system_prompt(tone: str, analysis: str) -> str: + """A lean, focused system prompt for the per-indicator-group hourly + summary. INTERPRETATION not description — the reader has the table + next to this paragraph; they don't need numbers recited at them.""" + tone_block = _TONE.get(tone.upper(), _TONE["INTERMEDIATE"]) + analysis_block = _ANALYSIS.get(analysis.upper(), _ANALYSIS["SPECULATIVE"]) + return f"""You write a TINY interpretation (≤60 words, 2-3 sentences) \ +of ONE indicator group for a strategic markets dashboard. + +# What this is for +The reader is looking at the table of numbers right next to your text. \ +They can see the values. They CANNOT see the meaning. Your job is to \ +**explain what the data means**, not to recite it. Each sentence should be \ +a regime-level interpretation, a fundamental driver identification, or a \ +cross-indicator implication — not a description of moves. + +# Hard constraints +- Plain prose, ONE paragraph. No markdown, no headers, no lists, no labels. +- Open IMMEDIATELY with substance. NEVER start with: "I need to", "I'll", \ +"We need to", "We are asked", "Here's", "Let me", "Let's", "Sure", "Looking \ +at", "Based on", "Summary:", "The data shows", "First", "To address". No \ +meta-commentary at all. +- Cite at most 2-3 specific numbers and ONLY when they anchor an \ +interpretation. Don't list moves; explain them. +- Multi-week / multi-month horizon. 1-day moves under 2% are noise — skip. +- No buy/sell language. No predictions. No watch list. No TL;DR. No date \ +header. No "system temperature" line — that belongs to the full daily log. + +{tone_block} + +{analysis_block} + +# Bad example — describes what happened +"S&P +5.2% 1m and Nasdaq +8.8% 1m diverge from FTSE -3.4% and Euro Stoxx \ +-2.6%. The US-vs-rest gap is widening." + +# Good example — interprets what it means +"The US-vs-rest equity gap is funded by AI-capex concentration in 7 names; \ +the breadth-weighted RSP barely keeps pace with SPY, which is the classic \ +late-cycle marker — narrow leadership, not broad recovery. The 5% 1m gap \ +between Nasdaq and FTSE is a narrative trade, not a fundamental one." +""" + + +def build_summary_user_prompt(group_name: str, quotes: list[dict]) -> str: + parts = [ + f"# Group: {group_name}", + "Indicators (latest reading + 1d/1m/1y/since-anchor change):", + "```json", + json.dumps(quotes, indent=2, default=str)[:12000], + "```", + "\nWrite the 2-3 sentence read for this group now.", + ] + return "\n".join(parts) + + +def build_aggregate_summary_system_prompt(tone: str, analysis: str) -> str: + """System prompt for the cross-group aggregate read shown on the dashboard. + Wider lens than a per-group summary — synthesise across all groups.""" + tone_block = _TONE.get(tone.upper(), _TONE["INTERMEDIATE"]) + analysis_block = _ANALYSIS.get(analysis.upper(), _ANALYSIS["SPECULATIVE"]) + return f"""You write a single SHORT cross-asset INTERPRETATION (≤80 \ +words, 2-4 sentences) for the dashboard header. The reader is glancing — \ +give them the meaning of the whole tape, not a recap. + +# What this is for +The reader can see every indicator on the dashboard below this paragraph. \ +Your job is NOT to summarise the moves. It is to explain what the moves, \ +**taken together as a system**, mean: which regime is being signalled, \ +which divergences are load-bearing, what fundamental story the cross-asset \ +behaviour tells. + +# Hard constraints +- Plain prose, ONE paragraph. No markdown, headers, lists, or labels. +- Open IMMEDIATELY with substance. NEVER start with: "I need to", "I'll", \ +"We need to", "Here's", "Let me", "Looking at", "Based on", "Sure", "Summary:", \ +"The data shows", "Across the board". No meta-commentary. +- Identify the single most important **cross-asset implication**: e.g. \ +"rates and credit disagree", "equities outrun fundamentals", "geopolitical \ +risk premium is in commodities but not vol". Cite no more than 3 specific \ +numbers, and only as anchors for the interpretation. +- Multi-week / multi-month horizon. 1-day moves under 2% are noise. +- No buy/sell language. No predictions of specific levels. + +{tone_block} + +{analysis_block} + +# Bad example — describes +"Equities are up, real yields are higher, HY OAS is tight, breadth is \ +narrowing." + +# Good example — interprets +"The tape is paying a rising real discount rate (US 10y real +15bp 1m) with \ +conviction for AI growth, but credit refuses to confirm and breadth is \ +narrowing — that combination is what late-cycle looks like, not pre-crash. \ +The risk is not the level but the convergence: if any one of credit, \ +breadth, or vol turns, the others will follow fast." +""" + + +def build_aggregate_summary_user_prompt(quotes_by_group: dict[str, list[dict]]) -> str: + parts = [ + "# All indicator groups (latest readings + change windows)", + "```json", + json.dumps(quotes_by_group, indent=2, default=str)[:20000], + "```", + "\nWrite the cross-asset aggregate read now.", + ] + return "\n".join(parts) + + def build_chat_system_prompt( tone: str, analysis: str, diff --git a/app/static/css/cassandra.css b/app/static/css/cassandra.css index 2e6269f..1fb85dc 100644 --- a/app/static/css/cassandra.css +++ b/app/static/css/cassandra.css @@ -187,6 +187,12 @@ table.dense th { table.dense th.num, table.dense td.num { text-align: right; } table.dense td.label { color: var(--text); } +table.dense td.label.has-tip, +table.dense td[title] { + cursor: help; + border-bottom: 1px dotted color-mix(in srgb, var(--accent) 40%, transparent); + border-bottom-width: 1px; +} table.dense tr:hover td { background: color-mix(in srgb, var(--accent) 5%, transparent); } .pos { color: var(--positive); } @@ -194,6 +200,21 @@ table.dense tr:hover td { background: color-mix(in srgb, var(--accent) 5%, trans .neu { color: var(--muted); } .note { color: var(--dim); font-size: 11px; } +/* Stale indicator rows — last observation > 90 days old */ +table.dense tr.row-stale td { color: var(--dim); } +.stale-tag { + display: inline-block; + font-size: 8.5px; + letter-spacing: 0.08em; + color: var(--alert); + border: 1px solid var(--alert); + padding: 0 4px; + margin-left: 4px; + vertical-align: middle; + text-transform: uppercase; + cursor: help; +} + /* --- Status LEDs ------------------------------------------------------ */ .led { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; vertical-align: middle; } @@ -202,6 +223,117 @@ table.dense tr:hover td { background: color-mix(in srgb, var(--accent) 5%, trans .led.err { background: var(--negative); box-shadow: 0 0 6px var(--negative); } .led.idle { background: var(--dim); } +/* --- Dashboard top header (markets + aggregate read) ----------------- */ + +.dash-header { + display: grid; + grid-template-columns: 1fr; + gap: 12px; + margin-bottom: 0; +} +.dash-header__markets { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 1px; + background: var(--border); + border: 1px solid var(--border); +} +.mkt { + background: var(--surface); + padding: 6px 10px; + font-family: var(--font-mono); + font-size: 11px; + display: grid; + grid-template-columns: auto 1fr auto; + grid-template-rows: auto auto; + align-items: center; + gap: 2px 6px; +} +.mkt__dot { + width: 8px; height: 8px; border-radius: 50%; + grid-row: 1; grid-column: 1; +} +.mkt--open .mkt__dot { background: var(--positive); box-shadow: 0 0 6px var(--positive); } +.mkt--closed .mkt__dot { background: var(--dim); } +.mkt__name { + grid-row: 1; grid-column: 2; + color: var(--text); font-weight: 700; + text-transform: uppercase; letter-spacing: 0.08em; +} +.mkt__state { + grid-row: 1; grid-column: 3; + font-size: 9.5px; letter-spacing: 0.08em; +} +.mkt--open .mkt__state { color: var(--positive); } +.mkt--closed .mkt__state { color: var(--dim); } +.mkt__when { + grid-row: 2; grid-column: 2 / -1; + color: var(--muted); font-size: 10px; + font-variant-numeric: tabular-nums; +} +.mkt__when-label { color: var(--dim); } + +.dash-header__read { + border: 1px solid var(--border); + border-left: 3px solid var(--accent); + background: color-mix(in srgb, var(--accent) 4%, transparent); + padding: 10px 14px; +} +.dash-header__read-meta { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 4px; +} +.dash-header__read-body { + margin: 0; + font-family: var(--font-sans); + font-size: 14px; + line-height: 1.55; + color: var(--text); +} +.dash-header__read--pending { color: var(--dim); font-style: italic; } +.dash-header__read--pending .dash-header__read-body { color: var(--dim); font-size: 12px; } + +/* --- Indicator group summary (above the table) ----------------------- */ + +.ind-summary { + font-family: var(--font-sans); + padding: 10px 16px; + border-bottom: 1px solid var(--surface-2); + border-left: 3px solid var(--accent); + background: color-mix(in srgb, var(--accent) 4%, transparent); +} +.ind-summary__head { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: 4px; +} +.ind-summary__label { + font-family: var(--font-mono); + font-size: 10px; + color: var(--accent); + text-transform: uppercase; + letter-spacing: 0.1em; + font-weight: 700; +} +.ind-summary__label::before { content: "▸ "; } +.ind-summary__when { + font-family: var(--font-mono); + font-size: 10px; + color: var(--dim); + font-variant-numeric: tabular-nums; +} +.ind-summary__body { + margin: 0; + font-size: 13.5px; + line-height: 1.55; + color: var(--text); +} +.ind-summary--pending { color: var(--dim); font-style: italic; } +.ind-summary--pending .ind-summary__body { color: var(--dim); font-size: 12px; } + /* --- Group tabs ------------------------------------------------------- */ .group-tabs { diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 0d9f451..eb2e6cb 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -2,6 +2,14 @@ {% block title %}Cassandra · Dashboard{% endblock %} {% block main %} +
+
loading aggregate read…
+
+
Indicators diff --git a/app/templates/partials/dashboard_header.html b/app/templates/partials/dashboard_header.html new file mode 100644 index 0000000..71d593d --- /dev/null +++ b/app/templates/partials/dashboard_header.html @@ -0,0 +1,32 @@ +
+
+ {% for m in markets %} +
+ + {{ m.name }} + {{ m.label }} + + {% if m.open %}closes{% else %}opens{% endif %} + + +
+ {% endfor %} +
+ + {% if summary %} +
+
+ aggregate read + + {{ summary.generated_at.strftime("%H:%M UTC") }} + +
+

{{ summary.content }}

+
+ {% else %} +
+ aggregate read + pending — generated hourly @ :07 UTC +
+ {% endif %} +
diff --git a/app/templates/partials/indicators.html b/app/templates/partials/indicators.html index cf0bb55..848c915 100644 --- a/app/templates/partials/indicators.html +++ b/app/templates/partials/indicators.html @@ -1,3 +1,19 @@ +{% if summary %} +
+
+ read + + {{ summary.generated_at.strftime("%H:%M UTC") }} + +
+

{{ summary.content }}

+
+{% else %} +
+ read + summary pending — generated hourly @ :07 UTC +
+{% endif %} {% if not quotes %}
no data yet — scheduler may not have run
{% else %} @@ -13,9 +29,19 @@ {% for q in quotes %} - - {{ q.symbol }} - {{ q.label or "" }} + {% set is_stale = stale_symbols and q.symbol in stale_symbols %} + + {% set tip = notes.get(q.symbol, '') if notes else '' %} + {# Long Eurostat ('dataset?...') and ONS ('topic/.../cdid/dataset') symbols + get truncated for display; hover shows the full identifier via title. + Other symbols pass through. #} + {% set short_sym = q.symbol %} + {% if '?' in short_sym %}{% set short_sym = short_sym.split('?')[0] %}{% endif %} + {% if '/' in short_sym %}{% set short_sym = short_sym.split('/')[-2] | upper %}{% endif %} + + {{ short_sym }}{% if is_stale %} stale{% endif %} + + {{ q.label or "" }} {{ q.price | price }} {{ q.currency or "" }} {% for k in ["1d","1m","1y"] %} diff --git a/config/default.toml b/config/default.toml index 5683727..4f1468c 100644 --- a/config/default.toml +++ b/config/default.toml @@ -99,6 +99,89 @@ financials = [ {symbol="^FTAS", label="FTSE All-Share", note="UK breadth"}, ] +# --- Strategic / fundamentals groups ----------------------------------------- +# These feed the Temperature page (phase 2) where each indicator is shown as a +# percentile of its own history vs past cycles incl. recessions. Until phase 2 +# they render as standard tabs in the Indicators panel. + +# Valuation — slow-moving "is the market expensive vs history" anchors. +valuation = [ + {symbol="FRED:WILL5000PRFC", label="Wilshire 5000 (US market cap proxy)", note="numerator for Buffett indicator"}, + {symbol="FRED:GDP", label="US Nominal GDP (quarterly)", note="denominator for Buffett indicator"}, + {symbol="FRED:SP500", label="S&P 500 (FRED long-history)", note="FRED-anchored percentile vs deep history"}, + {symbol="FRED:DJIA", label="Dow Jones Industrial Average", note="long-history equity reference"}, + {symbol="FRED:DGS10", label="US 10y nominal yield (FRED)", note="ERP denominator"}, + {symbol="FRED:DFII10", label="US 10y real yield (TIPS)", note="real-rate signal — gold + multiples"}, + {symbol="FRED:T10YIE", label="US 10y breakeven inflation", note="market-implied inflation"}, +] + +# Bubble watch — irrationality / positioning / behavioural extremes. +bubble_watch = [ + {symbol="RSP", label="RSP — equal-weight S&P 500", note="breadth — falling vs SPY = concentration"}, + {symbol="SPY", label="SPY — cap-weighted S&P 500", note="pair with RSP for concentration read"}, + {symbol="IWM", label="IWM — Russell 2000", note="small-cap cycle health"}, + {symbol="^VIX", label="VIX — equity vol", note="LOW percentile = complacency"}, + {symbol="^VVIX", label="VVIX — vol of vol", note="LOW = no demand for vol insurance"}, + {symbol="^SKEW", label="SKEW — tail-risk pricing", note="HIGH = options market pricing crash"}, + {symbol="HYG", label="HYG — high-yield credit", note="pair with TLT for risk-on/off"}, + {symbol="TLT", label="TLT — long Treasuries", note="duration / safe haven flows"}, + {symbol="IPO", label="IPO — Renaissance IPO ETF", note="late-cycle frothiness gauge"}, + {symbol="BTC-USD", label="Bitcoin", note="retail risk appetite + USD-debasement"}, + {symbol="ETH-USD", label="Ethereum", note=""}, + {symbol="FRED:BAMLH0A0HYM2", label="US HY OAS", note="LOW percentile = credit complacency"}, +] + +# Real economy — fundamental anchors that move on quarters, not days. +# +# Geographic coverage note: FRED mirrors OECD "international comparable" +# series for non-US economies. Several (Japan CPI, Eurozone unemployment, +# Eurozone industrial production, UK CPI) were de-facto abandoned by +# OECD-Stat in 2022-2023 and are 1-4 YEARS stale on FRED. Listing them +# would be worse than not having them. We only include series that are +# currently maintained. Up-to-date non-US economic data would require +# direct integration with Eurostat / ONS / Statistics Bureau of Japan / +# NBS-China — out of scope for v0. The dashboard flags any indicator +# older than 90 days as "stale" on screen. +economy = [ + # United States + {symbol="FRED:ICSA", label="US initial jobless claims (weekly)", note="leading labour signal — weekly"}, + {symbol="FRED:INDPRO", label="US industrial production", note="real activity, monthly"}, + {symbol="FRED:HOUST", label="US housing starts", note="cyclical real-economy gauge"}, + {symbol="FRED:UMCSENT", label="US consumer sentiment (UMich)", note="demand-side mood"}, + {symbol="FRED:T10Y3M", label="US 10y-3m yield curve", note="Fed-favoured recession signal"}, + {symbol="FRED:USREC", label="NBER recession indicator (0/1)", note="binary — context overlay only"}, + # Eurozone — direct Eurostat (open API, no key, current) + {symbol="EUROSTAT:ei_cphi_m?geo=EA&indic=TOTAL&unit=RT12", label="Eurozone HICP YoY %", note="EZ inflation, YoY % — ECB-style series, current"}, + {symbol="EUROSTAT:une_rt_m?geo=EA21&age=TOTAL&sex=T&unit=PC_ACT&s_adj=SA", label="Eurozone unemployment rate", note="EZ labour, seasonally adjusted"}, + {symbol="EUROSTAT:sts_inpr_m?geo=EA21&nace_r2=B-D&s_adj=SCA&unit=I21", label="Eurozone industrial production", note="EZ real activity"}, + # United Kingdom — direct ONS (open API, current within 1-2 months) + {symbol="ONS:economy/inflationandpriceindices/d7g7/mm23", label="UK CPI YoY %", note="UK headline inflation, monthly"}, + {symbol="ONS:employmentandlabourmarket/peoplenotinwork/unemployment/mgsx/lms", label="UK unemployment rate", note="UK labour, 16+, seasonally adjusted"}, + {symbol="ONS:economy/grossdomesticproductgdp/ihyo/qna", label="UK GDP growth q/q-4 %", note="UK GDP, year-on-year quarterly"}, + {symbol="ONS:economy/economicoutputandproductivity/output/k222/diop", label="UK industrial production", note="UK IoP, monthly"}, + # Japan + {symbol="FRED:LRHUTTTTJPM156S", label="Japan unemployment rate", note="JP labour — ~3mo lag"}, +] + +# Sovereign bond yields — UK / EU / US / JP. China yields have no free open-API +# source; would need scraping or paid feed. +bonds = [ + # United States — daily, deep curve + {symbol="^IRX", label="US 3m T-bill yield", note="short-end, daily"}, + {symbol="^FVX", label="US 5y Treasury yield", note="belly of the curve"}, + {symbol="^TNX", label="US 10y Treasury yield", note="benchmark long rate"}, + {symbol="^TYX", label="US 30y Treasury yield", note="long-end / term premium proxy"}, + # Eurozone — Maastricht convergence yields via Eurostat (monthly) + {symbol="EUROSTAT:irt_lt_mcby_m?geo=EA&int_rt=MCBY", label="Eurozone 10y aggregate", note="EZ avg 10y govt bond yield"}, + {symbol="EUROSTAT:irt_lt_mcby_m?geo=DE&int_rt=MCBY", label="German 10y Bund", note="EU risk-free benchmark"}, + {symbol="EUROSTAT:irt_lt_mcby_m?geo=FR&int_rt=MCBY", label="French 10y OAT", note="core-EU spread to Bund"}, + {symbol="EUROSTAT:irt_lt_mcby_m?geo=IT&int_rt=MCBY", label="Italian 10y BTP", note="periphery — BTP/Bund spread = EU stress"}, + # United Kingdom — FRED OECD mirror (Eurostat dropped UK post-Brexit) + {symbol="FRED:IRLTLT01GBM156N", label="UK 10y Gilt", note="UK long-term yield"}, + # Japan — BoJ regime tracker + {symbol="FRED:IRLTLT01JPM156N", label="Japan 10y JGB", note="BoJ; YCC legacy"}, +] + # ============================================================================= # flash_news.py — RSS feed registry, by category