Add heuristic incident correlation, IRNA source, flatten news icon color

Economic Incident History: each spike now gets "likely factors" — plain
word-frequency across every headline in its window, stopwords dropped
(English + German), no AI/LLM involved (new backend/app/textutil.py, shared
with the existing cluster-topic labeling). Spikes across instruments within
an hour of each other now merge into one incident (new
GET /api/markets/incidents) rather than showing WTI/Brent-style correlated
moves as separate entries.

Also: IRNA (Iranian state news agency) added as a source; news cluster
icons flattened from a violet->pink gradient to a single bright magenta for
legibility against the globe's own violet tint (size still scales with
article count); lightweight column-migration mechanism in db.py so the new
MarketSpike field doesn't break existing deployments' SQLite files.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Um48tTvZDrEgDeweFyhPYC
main
Amir Alexander Abdelbaki 2026-07-20 14:49:09 +02:00
parent 8e2c5efdbe
commit b8adc70092
10 changed files with 284 additions and 49 deletions

View File

@ -24,7 +24,14 @@ composition diagram when Wikipedia has one.
- **Top-left "i" button** — opens the **Economic Incident History** view: every - **Top-left "i" button** — opens the **Economic Incident History** view: every
detected market spike, full clock-hour window, with the complete list of detected market spike, full clock-hour window, with the complete list of
articles published in it (not just the top few) — a comprehensive log, articles published in it (not just the top few) — a comprehensive log,
distinct from the quick preview in the right rail. distinct from the quick preview in the right rail. Spikes across
instruments within an hour of each other are merged into one incident
(e.g. WTI and Brent crude spiking together shows as one entry, not two —
a timestamp heuristic, not evidence the moves actually share a cause).
Each incident also shows its **likely factors**: the words that recur
most across every headline published in that window, stopwords filtered
out — plain word-frequency counting, no AI/LLM involved (`textutil.py`).
Read it as "worth checking these articles," not an explanation.
- **Bottom drawer** — collapsible conflict/military-event log (ACLED-backed; - **Bottom drawer** — collapsible conflict/military-event log (ACLED-backed;
see below). see below).
- **Flights toggle** — live global air traffic (OpenSky Network) as airplane - **Flights toggle** — live global air traffic (OpenSky Network) as airplane
@ -160,6 +167,10 @@ into the red dots.
alone qualifies a story as a candidate. Click a ticker item for a quick alone qualifies a story as a candidate. Click a ticker item for a quick
preview, or the "i" button (top-left) for the full log. Heuristic preview, or the "i" button (top-left) for the full log. Heuristic
correlation, not a verified causal link. correlation, not a verified causal link.
- `GET /api/markets/incidents?symbol=&hours=` — the same spikes merged
across instruments within `INCIDENT_MERGE_HOURS` (1h, `markets.py`) of
each other, each with a `top_keywords` word-frequency list. Backs the
Economic Incident History view.
- `GET /api/conflict-events?hours=168` - `GET /api/conflict-events?hours=168`
- `GET /api/flights` — latest global OpenSky snapshot, with `is_military` - `GET /api/flights` — latest global OpenSky snapshot, with `is_military`
per aircraft (see the heuristic caveat above) per aircraft (see the heuristic caveat above)
@ -173,8 +184,14 @@ into the red dots.
- Add gazetteer locations: `backend/app/data/gazetteer.csv`. - Add gazetteer locations: `backend/app/data/gazetteer.csv`.
- Add/fix parliament page mappings: `backend/app/data/parliaments.yaml`. - Add/fix parliament page mappings: `backend/app/data/parliaments.yaml`.
- Add/fix military ICAO24/callsign ranges: `backend/app/data/military_ranges.yaml`. - Add/fix military ICAO24/callsign ranges: `backend/app/data/military_ranges.yaml`.
- Extend stopwords for keyword extraction (cluster topics, incident "likely
factors"): `backend/app/textutil.py`.
- Change poll intervals / clustering window: `.env`. - Change poll intervals / clustering window: `.env`.
New model fields need a matching entry in `_COLUMN_MIGRATIONS` in
`backend/app/db.py``create_all()` only creates missing tables, not
columns on tables that already exist in a deployed `data/newsatlas.db`.
## Deploying as a Proxmox LXC ## Deploying as a Proxmox LXC
See `deploy/lxc/README.md` for a `pct`-based build script that provisions a See `deploy/lxc/README.md` for a `pct`-based build script that provisions a

View File

@ -1,31 +1,12 @@
import datetime as dt import datetime as dt
import re from collections import defaultdict
from collections import Counter, defaultdict
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .config import settings from .config import settings
from .models import Article from .models import Article
from .textutil import topic_label as _topic_label
_STOPWORDS = {
"the", "a", "an", "in", "on", "of", "to", "for", "and", "or", "is", "as",
"at", "by", "with", "from", "after", "over", "amid", "amid", "into",
"says", "say", "will", "has", "have", "had", "its", "it", "his", "her",
"new", "up", "out", "how", "why", "what", "who", "be", "are", "was",
"were", "this", "that", "than", "not", "no", "us", "u.s.",
}
_WORD_RE = re.compile(r"[A-Za-z][A-Za-z'-]{2,}")
def _topic_label(titles: list[str]) -> str:
words = Counter()
for title in titles:
for word in _WORD_RE.findall(title.lower()):
if word not in _STOPWORDS:
words[word] += 1
top = [w for w, _ in words.most_common(3)]
return ", ".join(top) if top else ""
def build_clusters(session: Session) -> list[dict]: def build_clusters(session: Session) -> list[dict]:

View File

@ -1,6 +1,6 @@
from pathlib import Path from pathlib import Path
from sqlalchemy import create_engine from sqlalchemy import create_engine, text
from sqlalchemy.orm import DeclarativeBase, sessionmaker from sqlalchemy.orm import DeclarativeBase, sessionmaker
from .config import settings from .config import settings
@ -18,10 +18,30 @@ class Base(DeclarativeBase):
pass pass
# Columns added to existing tables after their first release. create_all()
# only creates missing *tables*, so an upgrade on an existing SQLite file
# needs these added by hand — a lightweight alternative to a full migration
# framework, appropriate for this project's scale. Add a new (table, column,
# ddl-type-and-default) tuple here whenever a model gains a field.
_COLUMN_MIGRATIONS = [
("market_spikes", "top_keywords_json", "TEXT DEFAULT '[]'"),
]
def _run_column_migrations() -> None:
with engine.connect() as conn:
for table, column, ddl in _COLUMN_MIGRATIONS:
existing = {row[1] for row in conn.execute(text(f"PRAGMA table_info({table})"))}
if column not in existing:
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {column} {ddl}"))
conn.commit()
def init_db() -> None: def init_db() -> None:
from . import models # noqa: F401 (registers tables on Base.metadata) from . import models # noqa: F401 (registers tables on Base.metadata)
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
_run_column_migrations()
def get_session(): def get_session():

View File

@ -15,7 +15,7 @@ from .config import settings
from .db import get_session, init_db, SessionLocal from .db import get_session, init_db, SessionLocal
from .flights import get_latest_flights from .flights import get_latest_flights
from .ingest import fetch_all from .ingest import fetch_all
from .markets import poll_markets, INSTRUMENTS from .markets import poll_markets, merge_spikes_into_incidents, INSTRUMENTS
from .conflict import enabled as conflict_enabled, poll_conflict_events from .conflict import enabled as conflict_enabled, poll_conflict_events
from .models import Article, ConflictEvent, MarketPrice, MarketSpike from .models import Article, ConflictEvent, MarketPrice, MarketSpike
from .scheduler import start_scheduler from .scheduler import start_scheduler
@ -212,6 +212,48 @@ def api_markets_spikes(symbol: str | None = None, hours: int = Query(168, le=24
"window_end": s.window_end.isoformat(), "window_end": s.window_end.isoformat(),
"detected_at": s.detected_at.isoformat(), "detected_at": s.detected_at.isoformat(),
"candidate_articles": articles, "candidate_articles": articles,
"top_keywords": json.loads(s.top_keywords_json or "[]"),
}
)
return out
finally:
session.close()
@app.get("/api/markets/incidents")
def api_markets_incidents(symbol: str | None = None, hours: int = Query(720, le=24 * 365)):
"""Spikes merged across instruments when they land within
MERGE_INCIDENT_HOURS of each other (see markets.merge_spikes_into_incidents)
the comprehensive view behind the Economic Incident History UI.
`symbol`, if given, filters to incidents that include that instrument,
but still returns every instrument in the merged incident, not just it."""
since = dt.datetime.utcnow() - dt.timedelta(hours=hours)
session = next(get_session())
try:
spikes = session.execute(select(MarketSpike).where(MarketSpike.detected_at >= since)).scalars().all()
incidents = merge_spikes_into_incidents(spikes)
if symbol:
incidents = [i for i in incidents if any(instr["symbol"] == symbol for instr in i["instruments"])]
out = []
for inc in incidents:
article_ids = inc["article_ids"]
articles = []
if article_ids:
rows = session.execute(select(Article).where(Article.id.in_(article_ids))).scalars().all()
by_id = {a.id: a for a in rows}
articles = [_article_dict(by_id[i]) for i in article_ids if i in by_id]
out.append(
{
"instruments": [
{**instr, "detected_at": instr["detected_at"].isoformat()} for instr in inc["instruments"]
],
"window_start": inc["window_start"].isoformat(),
"window_end": inc["window_end"].isoformat(),
"detected_at": inc["detected_at"].isoformat(),
"latest_detected_at": inc["latest_detected_at"].isoformat(),
"candidate_articles": articles,
"top_keywords": inc["top_keywords"],
} }
) )
return out return out

View File

@ -1,6 +1,7 @@
import datetime as dt import datetime as dt
import json import json
import logging import logging
from collections import Counter
import httpx import httpx
from sqlalchemy import select from sqlalchemy import select
@ -8,6 +9,14 @@ from sqlalchemy.orm import Session
from .config import settings from .config import settings
from .models import Article, MarketPrice, MarketSpike from .models import Article, MarketPrice, MarketSpike
from .textutil import extract_keywords
# Two spikes within this many hours of each other are treated as one
# "incident" — e.g. WTI and Brent crude almost always move together, and a
# real shock often shows up across several indices within the same hour.
# Chain-merged: A+B within range and B+C within range merges all three even
# if A and C individually aren't, same declutter pattern used for the globe.
INCIDENT_MERGE_HOURS = 1.0
log = logging.getLogger("newsatlas.markets") log = logging.getLogger("newsatlas.markets")
@ -100,7 +109,9 @@ def _score_article(article: Article, keywords: list[str], country: str | None) -
return score return score
def _find_candidate_articles(session: Session, symbol: str, window_start: dt.datetime, window_end: dt.datetime, limit: int = 60) -> list[int]: def _find_candidate_articles(
session: Session, symbol: str, window_start: dt.datetime, window_end: dt.datetime, limit: int = 60
) -> tuple[list[int], list[tuple[str, int]]]:
hints = _SPIKE_HINTS.get(symbol, {"keywords": [], "country": None}) hints = _SPIKE_HINTS.get(symbol, {"keywords": [], "country": None})
rows = session.execute( rows = session.execute(
select(Article) select(Article)
@ -115,7 +126,14 @@ def _find_candidate_articles(session: Session, symbol: str, window_start: dt.dat
# first), it just no longer excludes zero-score articles. # first), it just no longer excludes zero-score articles.
scored = [(_score_article(a, hints["keywords"], hints["country"]), a) for a in rows] scored = [(_score_article(a, hints["keywords"], hints["country"]), a) for a in rows]
scored.sort(key=lambda pair: (pair[0], pair[1].published_at), reverse=True) scored.sort(key=lambda pair: (pair[0], pair[1].published_at), reverse=True)
return [a.id for _, a in scored[:limit]]
# "Most likely reasons," heuristically: just the words that recur across
# every headline in the window (stopwords dropped) — no AI/LLM involved,
# see textutil.py. Computed over the whole window, not just the
# `limit`-truncated list below, for a more robust signal.
keywords = extract_keywords([a.title for _, a in scored], top_n=8)
return [a.id for _, a in scored[:limit]], keywords
def _floor_hour(t: dt.datetime) -> dt.datetime: def _floor_hour(t: dt.datetime) -> dt.datetime:
@ -140,7 +158,7 @@ def _detect_and_record_spike(
# "14:00-16:00"), covering at least the full hour the spike landed in. # "14:00-16:00"), covering at least the full hour the spike landed in.
window_start = _floor_hour(prev.recorded_at - dt.timedelta(hours=settings.market_spike_lookback_hours)) window_start = _floor_hour(prev.recorded_at - dt.timedelta(hours=settings.market_spike_lookback_hours))
window_end = _ceil_hour(recorded_at) window_end = _ceil_hour(recorded_at)
article_ids = _find_candidate_articles(session, symbol, window_start, window_end) article_ids, keywords = _find_candidate_articles(session, symbol, window_start, window_end)
session.add( session.add(
MarketSpike( MarketSpike(
@ -153,6 +171,7 @@ def _detect_and_record_spike(
window_end=window_end, window_end=window_end,
detected_at=recorded_at, detected_at=recorded_at,
article_ids_json=json.dumps(article_ids), article_ids_json=json.dumps(article_ids),
top_keywords_json=json.dumps(keywords),
) )
) )
log.info("Spike detected: %s %.2f%% (%d candidate articles)", symbol, pct_change, len(article_ids)) log.info("Spike detected: %s %.2f%% (%d candidate articles)", symbol, pct_change, len(article_ids))
@ -194,3 +213,63 @@ def poll_markets(session: Session) -> int:
session.commit() session.commit()
return added return added
def merge_spikes_into_incidents(spikes: list[MarketSpike]) -> list[dict]:
"""Chain-merge spikes within INCIDENT_MERGE_HOURS of each other into
"incidents" spanning possibly multiple instruments, on the assumption
that near-simultaneous moves are more likely to share a cause than
coincidence. Purely a timestamp heuristic no correlation of the
actual price movements is attempted."""
ordered = sorted(spikes, key=lambda s: s.detected_at)
groups: list[list[MarketSpike]] = []
threshold = dt.timedelta(hours=INCIDENT_MERGE_HOURS)
for spike in ordered:
target = next(
(g for g in groups if any(abs(spike.detected_at - member.detected_at) <= threshold for member in g)),
None,
)
if target is not None:
target.append(spike)
else:
groups.append([spike])
incidents = []
for group in groups:
group.sort(key=lambda s: s.detected_at)
article_ids: list[int] = []
seen_ids: set[int] = set()
keyword_counts: Counter[str] = Counter()
for s in group:
for aid in json.loads(s.article_ids_json or "[]"):
if aid not in seen_ids:
seen_ids.add(aid)
article_ids.append(aid)
for word, count in json.loads(s.top_keywords_json or "[]"):
keyword_counts[word] += count
incidents.append(
{
"instruments": [
{
"symbol": s.symbol,
"label": s.label,
"from_price": s.from_price,
"to_price": s.to_price,
"pct_change": s.pct_change,
"detected_at": s.detected_at,
}
for s in group
],
"window_start": min(s.window_start for s in group),
"window_end": max(s.window_end for s in group),
"detected_at": group[0].detected_at,
"latest_detected_at": group[-1].detected_at,
"article_ids": article_ids[:60],
"top_keywords": keyword_counts.most_common(8),
}
)
incidents.sort(key=lambda i: i["latest_detected_at"], reverse=True)
return incidents

View File

@ -58,6 +58,10 @@ class MarketSpike(Base):
# JSON-encoded list of Article.id, ranked most-to-least likely relevant. # JSON-encoded list of Article.id, ranked most-to-least likely relevant.
article_ids_json: Mapped[str] = mapped_column(Text, default="[]") article_ids_json: Mapped[str] = mapped_column(Text, default="[]")
# JSON-encoded [[word, count], ...] — plain word-frequency across every
# article in the window's titles, stopwords dropped. A heuristic hint at
# "likely factors," not an explanation; see textutil.py.
top_keywords_json: Mapped[str] = mapped_column(Text, default="[]")
class ConflictEvent(Base): class ConflictEvent(Base):

View File

@ -61,3 +61,7 @@ sources:
- name: Der Standard (International) - name: Der Standard (International)
url: https://www.derstandard.at/rss/international url: https://www.derstandard.at/rss/international
bias: austrian-mainstream bias: austrian-mainstream
- name: IRNA (Islamic Republic News Agency)
url: https://en.irna.ir/rss
bias: iranian-state-run

56
backend/app/textutil.py Normal file
View File

@ -0,0 +1,56 @@
"""Plain word-frequency text analysis — no ML/LLM involved.
Shared by clustering.py (per-cluster topic labels) and markets.py (heuristic
"likely factors" behind a detected market spike): tokenize, drop stopwords,
count. That's the whole technique — it surfaces words that recur across a
set of headlines, nothing more. Treat the result as a hint worth reading the
linked articles over, not an explanation.
"""
import re
from collections import Counter
_WORD_RE = re.compile(r"[A-Za-zÀ-ÖØ-öø-ÿ][A-Za-zÀ-ÖØ-öø-ÿ'-]{2,}")
# English + German (taz, Der Standard contribute German-language headlines)
# function words. Deliberately does NOT include short, meaningful tokens
# common in market/political news (us, uk, eu, fed, opec, ...) — those are
# exactly the signal this is trying to surface, not noise to remove.
_STOPWORDS = {
# English
"the", "a", "an", "in", "on", "of", "to", "for", "and", "or", "is", "as",
"at", "by", "with", "from", "after", "over", "amid", "into", "says",
"say", "said", "will", "has", "have", "had", "its", "it", "his", "her",
"new", "up", "out", "how", "why", "what", "who", "be", "are", "was",
"were", "this", "that", "than", "not", "no", "been", "but", "also",
"more", "their", "they", "you", "your", "we", "our", "can", "could",
"would", "should", "about", "against", "all", "any", "some", "such",
"one", "two", "first", "if", "so", "just", "most", "other", "which",
"when", "where", "while", "during", "before", "between", "under",
"again", "then", "there", "here", "still", "now", "only", "own", "same",
"too", "very", "did", "does", "do", "being", "them", "he", "she", "i",
"my", "me", "him", "off", "down", "because", "each", "few", "further",
"once", "both", "don",
# German
"der", "die", "das", "und", "zu", "den", "von", "mit", "auf", "für",
"ist", "im", "dem", "des", "ein", "eine", "einer", "eines", "auch",
"nach", "bei", "aus", "wie", "was", "wird", "werden", "sich", "sie",
"er", "es", "noch", "nur", "schon", "über", "um", "als", "aber", "oder",
"wenn", "wir", "ihr", "man", "sind", "hat", "haben", "kann", "können",
"wurde", "wurden", "einem", "einen", "diese", "dieser", "dieses", "vor",
"durch", "zum", "zur", "doch",
}
def extract_keywords(texts: list[str], top_n: int = 8) -> list[tuple[str, int]]:
"""Word-frequency count across `texts`, stopwords dropped, most common first."""
counts: Counter[str] = Counter()
for text in texts:
for word in _WORD_RE.findall((text or "").lower()):
if word not in _STOPWORDS:
counts[word] += 1
return counts.most_common(top_n)
def topic_label(titles: list[str], top_n: int = 3) -> str:
return ", ".join(w for w, _ in extract_keywords(titles, top_n))

View File

@ -273,10 +273,22 @@ html, body {
} }
.econ-incident { padding: 14px 0; border-bottom: 1px solid #241533; } .econ-incident { padding: 14px 0; border-bottom: 1px solid #241533; }
.econ-incident h3 { margin: 0 0 4px; font-size: 14px; color: #f2dede; } .econ-incident h3 { margin: 0 0 4px; font-size: 14px; color: #f2dede; display: flex; flex-wrap: wrap; gap: 8px 14px; font-weight: 600; }
.instrument-chip { display: inline-flex; gap: 6px; align-items: baseline; }
.econ-incident .window { font-size: 11px; color: var(--c-text-muted); margin-bottom: 8px; } .econ-incident .window { font-size: 11px; color: var(--c-text-muted); margin-bottom: 8px; }
.econ-incident .articles { margin-top: 8px; } .econ-incident .articles { margin-top: 8px; }
.keyword-tags { margin: 6px 0 10px; font-size: 11px; display: flex; flex-wrap: wrap; align-items: center; gap: 5px; }
.keyword-tag {
display: inline-block;
background: var(--c-tag-bg);
border: 1px solid var(--c-panel-border);
color: var(--c-dark-soft);
border-radius: 10px;
padding: 2px 8px;
font-size: 11px;
}
.panel-section { margin-bottom: 20px; } .panel-section { margin-bottom: 20px; }
.panel-section h2 { .panel-section h2 {
font-size: 13px; font-size: 13px;

View File

@ -5,6 +5,12 @@ const API = "/api";
// than a separate alert layer, per the flights-overlay color spec. // than a separate alert layer, per the flights-overlay color spec.
const GLOBE_TINT_HEX = 0x6a3fd9; const GLOBE_TINT_HEX = 0x6a3fd9;
const MILITARY_FLIGHT_HEX = 0xff33ff; const MILITARY_FLIGHT_HEX = 0xff33ff;
// News icons used to gradient violet->pink by corroborating-source count;
// flattened to a single bright magenta so they stay legible against the
// (also violet) globe tint — size still scales with source/article count.
const NEWS_ICON_HEX = 0xff33ff;
// Must match INCIDENT_MERGE_HOURS in backend/app/markets.py — display label only.
const INCIDENT_MERGE_HOURS_LABEL = "1h";
let config = { weather_enabled: false, conflict_enabled: false }; let config = { weather_enabled: false, conflict_enabled: false };
let clusters = []; // raw, server-side ~11km-grid clusters let clusters = []; // raw, server-side ~11km-grid clusters
@ -85,12 +91,6 @@ function resizeGlobe() {
window.addEventListener("resize", resizeGlobe); window.addEventListener("resize", resizeGlobe);
setTimeout(resizeGlobe, 0); setTimeout(resizeGlobe, 0);
function colorForCluster(d) {
// violet (few corroborating sources) -> hot pink (many) — theme accent gradient
const n = Math.min(d.source_count, 6);
const palette = ["#5018DD", "#7018C4", "#9018AB", "#B01092", "#D00879", "#E40046"];
return palette[n - 1] || palette[0];
}
// ---- globe icon sprites (news clusters + conflict events) -------------- // ---- globe icon sprites (news clusters + conflict events) --------------
// //
@ -164,7 +164,7 @@ function buildGlobeObject(d) {
if (d.kind === "conflict") { if (d.kind === "conflict") {
return makeIconSprite(helmetIconTexture(), MILITARY_FLIGHT_HEX, HELMET_ICON_DEG * sceneUnitsPerDeg); return makeIconSprite(helmetIconTexture(), MILITARY_FLIGHT_HEX, HELMET_ICON_DEG * sceneUnitsPerDeg);
} }
return makeIconSprite(newspaperIconTexture(), colorForCluster(d), pointRadiusFor(d) * sceneUnitsPerDeg * 2.4); return makeIconSprite(newspaperIconTexture(), NEWS_ICON_HEX, pointRadiusFor(d) * sceneUnitsPerDeg * 2.4);
} }
function objectLabelFor(d) { function objectLabelFor(d) {
@ -846,6 +846,16 @@ function mountSparkline(container, history) {
}); });
} }
// "Likely factors": the words that recur most across every headline
// published in a spike's window (backend textutil.py — stopword-filtered
// word frequency, no AI/LLM). A hint worth reading the linked articles
// over, not an explanation, so it's labeled as such wherever it's shown.
function keywordTagsHtml(keywords) {
if (!keywords || !keywords.length) return "";
const tags = keywords.map(([word, count]) => `<span class="keyword-tag">${escapeHtml(word)} · ${count}</span>`).join("");
return `<div class="keyword-tags"><span class="subtle">Likely factors (common words in this window):</span> ${tags}</div>`;
}
async function toggleMarketDetail(symbol, idx) { async function toggleMarketDetail(symbol, idx) {
const el = document.getElementById(`market-detail-${idx}`); const el = document.getElementById(`market-detail-${idx}`);
const wasHidden = el.classList.contains("hidden"); const wasHidden = el.classList.contains("hidden");
@ -871,6 +881,7 @@ async function toggleMarketDetail(symbol, idx) {
<p><span class="${dir}">${arrow} ${s.pct_change.toFixed(2)}%</span> <p><span class="${dir}">${arrow} ${s.pct_change.toFixed(2)}%</span>
at ${new Date(s.detected_at).toLocaleString()} at ${new Date(s.detected_at).toLocaleString()}
(${s.from_price.toLocaleString()} ${s.to_price.toLocaleString()})</p> (${s.from_price.toLocaleString()} ${s.to_price.toLocaleString()})</p>
${keywordTagsHtml(s.top_keywords)}
${articlesHtml} ${articlesHtml}
`; `;
}) })
@ -912,22 +923,23 @@ async function loadEconHistory() {
listEl.innerHTML = `<p class="subtle">Loading…</p>`; listEl.innerHTML = `<p class="subtle">Loading…</p>`;
try { try {
const url = symbol const url = symbol
? `${API}/markets/spikes?symbol=${encodeURIComponent(symbol)}&hours=8760` ? `${API}/markets/incidents?symbol=${encodeURIComponent(symbol)}&hours=8760`
: `${API}/markets/spikes?hours=8760`; : `${API}/markets/incidents?hours=8760`;
const spikes = await (await fetch(url)).json(); const incidents = await (await fetch(url)).json();
if (!spikes.length) { if (!incidents.length) {
listEl.innerHTML = `<p class="subtle">No moves ≥ the spike threshold recorded yet.</p>`; listEl.innerHTML = `<p class="subtle">No moves ≥ the spike threshold recorded yet.</p>`;
return; return;
} }
listEl.innerHTML = spikes.map(econIncidentHtml).join(""); listEl.innerHTML = incidents.map(econIncidentHtml).join("");
} catch (e) { } catch (e) {
listEl.innerHTML = `<p class="subtle">Could not load incident history.</p>`; listEl.innerHTML = `<p class="subtle">Could not load incident history.</p>`;
} }
} }
function econIncidentHtml(s) { // An "incident" is one or more spikes merged because they landed within an
const dir = s.pct_change > 0 ? "up" : "down"; // hour of each other (backend: markets.merge_spikes_into_incidents) — e.g.
const arrow = s.pct_change > 0 ? "▲" : "▼"; // WTI and Brent crude spiking together shows as one incident, not two.
function econIncidentHtml(inc) {
const fmtHour = (iso) => const fmtHour = (iso) =>
new Date(iso).toLocaleString(undefined, { new Date(iso).toLocaleString(undefined, {
month: "short", month: "short",
@ -935,17 +947,25 @@ function econIncidentHtml(s) {
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
}); });
const articlesHtml = s.candidate_articles.length const instrumentsHtml = inc.instruments
? s.candidate_articles.map(articleItemHtml).join("") .map((i) => {
const dir = i.pct_change > 0 ? "up" : "down";
const arrow = i.pct_change > 0 ? "▲" : "▼";
return `<span class="instrument-chip">${escapeHtml(i.label)} <span class="${dir}">${arrow} ${i.pct_change.toFixed(2)}%</span></span>`;
})
.join("");
const articlesHtml = inc.candidate_articles.length
? inc.candidate_articles.map(articleItemHtml).join("")
: `<p class="subtle">No stories found published in that window.</p>`; : `<p class="subtle">No stories found published in that window.</p>`;
return ` return `
<div class="econ-incident"> <div class="econ-incident">
<h3>${escapeHtml(s.label)} <span class="${dir}">${arrow} ${s.pct_change.toFixed(2)}%</span></h3> <h3>${instrumentsHtml}</h3>
<div class="window"> <div class="window">
${fmtHour(s.window_start)} ${fmtHour(s.window_end)} ${fmtHour(inc.window_start)} ${fmtHour(inc.window_end)}
· ${s.from_price.toLocaleString()} ${s.to_price.toLocaleString()} · ${inc.candidate_articles.length} stor${inc.candidate_articles.length === 1 ? "y" : "ies"} in window
· ${s.candidate_articles.length} stor${s.candidate_articles.length === 1 ? "y" : "ies"} in window ${inc.instruments.length > 1 ? ` · ${inc.instruments.length} instruments moved within ${INCIDENT_MERGE_HOURS_LABEL} of each other` : ""}
</div> </div>
${keywordTagsHtml(inc.top_keywords)}
<div class="articles">${articlesHtml}</div> <div class="articles">${articlesHtml}</div>
</div> </div>
`; `;