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:
parent
a10409c02b
commit
1edf9cad41
15 changed files with 1156 additions and 10 deletions
43
alembic/versions/0004_indicator_summaries.py
Normal file
43
alembic/versions/0004_indicator_summaries.py
Normal file
|
|
@ -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")
|
||||
34
alembic/versions/0005_widen_quote_symbol.py
Normal file
34
alembic/versions/0005_widen_quote_symbol.py
Normal file
|
|
@ -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,
|
||||
)
|
||||
237
app/jobs/indicator_summary_job.py
Normal file
237
app/jobs/indicator_summary_job.py
Normal file
|
|
@ -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())
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
<topic_path>/<cdid>/<dataset>
|
||||
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]:
|
||||
|
|
|
|||
84
app/services/markets.py
Normal file
84
app/services/markets.py
Normal file
|
|
@ -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]
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,14 @@
|
|||
{% block title %}Cassandra · Dashboard{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div id="dash-header-container"
|
||||
style="grid-column: 1 / -1;"
|
||||
hx-get="/api/summary/aggregate?as=html"
|
||||
hx-trigger="load, every 300s"
|
||||
hx-swap="innerHTML">
|
||||
<div class="empty">loading aggregate read…</div>
|
||||
</div>
|
||||
|
||||
<section id="indicators-panel" class="panel">
|
||||
<div class="panel-header">
|
||||
<span class="title">Indicators</span>
|
||||
|
|
|
|||
32
app/templates/partials/dashboard_header.html
Normal file
32
app/templates/partials/dashboard_header.html
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<div class="dash-header">
|
||||
<div class="dash-header__markets">
|
||||
{% for m in markets %}
|
||||
<div class="mkt {% if m.open %}mkt--open{% else %}mkt--closed{% endif %}">
|
||||
<span class="mkt__dot"></span>
|
||||
<span class="mkt__name">{{ m.name }}</span>
|
||||
<span class="mkt__state">{{ m.label }}</span>
|
||||
<span class="mkt__when">
|
||||
<span class="mkt__when-label">{% if m.open %}closes{% else %}opens{% endif %}</span>
|
||||
<time datetime="{{ m.until.isoformat() }}" title="{{ m.until.isoformat() }}">{{ m.until.strftime("%H:%MZ") }}</time>
|
||||
</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if summary %}
|
||||
<div class="dash-header__read">
|
||||
<div class="dash-header__read-meta">
|
||||
<span class="ind-summary__label">aggregate read</span>
|
||||
<span class="ind-summary__when" title="generated {{ summary.generated_at }}">
|
||||
{{ summary.generated_at.strftime("%H:%M UTC") }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="dash-header__read-body">{{ summary.content }}</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="dash-header__read dash-header__read--pending">
|
||||
<span class="ind-summary__label">aggregate read</span>
|
||||
<span class="dash-header__read-body">pending — generated hourly @ :07 UTC</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
|
@ -1,3 +1,19 @@
|
|||
{% if summary %}
|
||||
<div class="ind-summary">
|
||||
<div class="ind-summary__head">
|
||||
<span class="ind-summary__label">read</span>
|
||||
<span class="ind-summary__when" title="generated {{ summary.generated_at }}">
|
||||
{{ summary.generated_at.strftime("%H:%M UTC") }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="ind-summary__body">{{ summary.content }}</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="ind-summary ind-summary--pending">
|
||||
<span class="ind-summary__label">read</span>
|
||||
<span class="ind-summary__body">summary pending — generated hourly @ :07 UTC</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if not quotes %}
|
||||
<div class="empty">no data yet — scheduler may not have run</div>
|
||||
{% else %}
|
||||
|
|
@ -13,9 +29,19 @@
|
|||
</thead>
|
||||
<tbody>
|
||||
{% for q in quotes %}
|
||||
<tr>
|
||||
<td class="label">{{ q.symbol }}</td>
|
||||
<td>{{ q.label or "" }}</td>
|
||||
{% set is_stale = stale_symbols and q.symbol in stale_symbols %}
|
||||
<tr class="{% if is_stale %}row-stale{% endif %}">
|
||||
{% 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 %}
|
||||
<td class="label has-tip" title="{{ q.symbol }}{% if tip %} — {{ tip }}{% endif %}">
|
||||
{{ short_sym }}{% if is_stale %} <span class="stale-tag" title="last observation older than 90 days">stale</span>{% endif %}
|
||||
</td>
|
||||
<td {% if tip %}title="{{ tip }}"{% endif %}>{{ q.label or "" }}</td>
|
||||
<td class="num">{{ q.price | price }}</td>
|
||||
<td class="neu">{{ q.currency or "" }}</td>
|
||||
{% for k in ["1d","1m","1y"] %}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue