Volatility-adaptive spike detection + multi-instrument overlay charts
Spike detection now requires a move to clear an absolute floor AND (once there's enough price history) be a configurable multiple of that instrument's own recent typical move — MARKET_SPIKE_VOLATILITY_MULTIPLIER, _recent_volatility() in markets.py — so a routinely volatile instrument needs a bigger move to register than a normally-calm one, instead of one fixed percentage for everything. Falls back to the floor alone until there's enough history. Economic Incident History: each incident gets two lazy-loaded overlay charts (all tracked instruments, and just the incident's own), each instrument normalized to % change from the window start so they're comparable despite very different price scales/currencies — makes it easy to see whether a move was isolated or broad, and to spot counter-reactions. Backend: /api/markets/history now accepts an explicit start/end window alongside the existing hours= param. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Um48tTvZDrEgDeweFyhPYCmain
parent
b8adc70092
commit
54022ad66f
12
.env.example
12
.env.example
|
|
@ -34,8 +34,18 @@ CONFLICT_POLL_MINUTES=60
|
||||||
ARTICLE_WINDOW_HOURS=72
|
ARTICLE_WINDOW_HOURS=72
|
||||||
|
|
||||||
# Minimum |% change| between two consecutive polls of an index/oil price
|
# Minimum |% change| between two consecutive polls of an index/oil price
|
||||||
# before it's flagged as a "spike" (see /api/markets/spikes)
|
# before it's even eligible to be a "spike" (see /api/markets/spikes) —
|
||||||
|
# an absolute floor, applies regardless of the instrument's own volatility
|
||||||
MARKET_SPIKE_THRESHOLD_PCT=1.5
|
MARKET_SPIKE_THRESHOLD_PCT=1.5
|
||||||
|
# Above that floor, a move must also be at least this many times the
|
||||||
|
# instrument's own recent typical move to count — i.e. "higher than the
|
||||||
|
# historical norm for THIS instrument," not just an absolute percentage
|
||||||
|
MARKET_SPIKE_VOLATILITY_MULTIPLIER=2.5
|
||||||
|
# Lookback window (days) for computing that "recent typical move" baseline
|
||||||
|
MARKET_SPIKE_HISTORY_DAYS=7
|
||||||
|
# Minimum historical observations required before the volatility check
|
||||||
|
# applies; below this (e.g. a freshly deployed instance) only the floor above applies
|
||||||
|
MARKET_SPIKE_MIN_SAMPLES=8
|
||||||
# How many hours before a spike's previous poll to search for candidate
|
# How many hours before a spike's previous poll to search for candidate
|
||||||
# articles that might explain it
|
# articles that might explain it
|
||||||
MARKET_SPIKE_LOOKBACK_HOURS=6
|
MARKET_SPIKE_LOOKBACK_HOURS=6
|
||||||
|
|
|
||||||
35
README.md
35
README.md
|
|
@ -32,6 +32,15 @@ composition diagram when Wikipedia has one.
|
||||||
most across every headline published in that window, stopwords filtered
|
most across every headline published in that window, stopwords filtered
|
||||||
out — plain word-frequency counting, no AI/LLM involved (`textutil.py`).
|
out — plain word-frequency counting, no AI/LLM involved (`textutil.py`).
|
||||||
Read it as "worth checking these articles," not an explanation.
|
Read it as "worth checking these articles," not an explanation.
|
||||||
|
A **"Show comparison charts"** button per incident (lazy-loaded) plots two
|
||||||
|
overlaid line charts, each instrument normalized to % change from the
|
||||||
|
window's start so wildly different price scales/currencies are
|
||||||
|
comparable: one with every tracked instrument, one with just the
|
||||||
|
incident's own — useful for checking whether a move was isolated to a
|
||||||
|
couple of correlated instruments or part of a broader swing, and for
|
||||||
|
spotting counter-reactions (something moving the opposite way at the same
|
||||||
|
time). Hover for a hairline crosshair with a per-instrument reading at
|
||||||
|
that moment.
|
||||||
- **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
|
||||||
|
|
@ -158,15 +167,23 @@ into the red dots.
|
||||||
- `GET /api/clusters/{cluster_key}/articles` — articles behind one point
|
- `GET /api/clusters/{cluster_key}/articles` — articles behind one point
|
||||||
- `GET /api/articles?q=&limit=` — raw article search
|
- `GET /api/articles?q=&limit=` — raw article search
|
||||||
- `POST /api/refresh` — force an immediate RSS poll
|
- `POST /api/refresh` — force an immediate RSS poll
|
||||||
- `GET /api/markets/latest`, `GET /api/markets/history?symbol=^GSPC`
|
- `GET /api/markets/latest`, `GET /api/markets/history?symbol=^GSPC` (accepts
|
||||||
- `GET /api/markets/spikes?symbol=&hours=` — sudden index/oil moves
|
either `hours=` or an explicit `start=&end=` ISO-datetime window — the
|
||||||
(`MARKET_SPIKE_THRESHOLD_PCT`), each paired with every article published in
|
latter is what the incident overlay charts use)
|
||||||
a full-clock-hour window around it (`MARKET_SPIKE_LOOKBACK_HOURS` sets the
|
- `GET /api/markets/spikes?symbol=&hours=` — sudden index/oil moves, each
|
||||||
minimum lookback; the window then snaps outward to whole hours). Ranked
|
paired with every article published in a full-clock-hour window around it
|
||||||
by relevance (keyword/country matching) but not filtered by it — timing
|
(`MARKET_SPIKE_LOOKBACK_HOURS` sets the minimum lookback; the window then
|
||||||
alone qualifies a story as a candidate. Click a ticker item for a quick
|
snaps outward to whole hours). A move must clear an absolute floor
|
||||||
preview, or the "i" button (top-left) for the full log. Heuristic
|
(`MARKET_SPIKE_THRESHOLD_PCT`) *and*, once there's enough price history,
|
||||||
correlation, not a verified causal link.
|
be at least `MARKET_SPIKE_VOLATILITY_MULTIPLIER`× that instrument's own
|
||||||
|
recent typical move (`MARKET_SPIKE_HISTORY_DAYS` lookback,
|
||||||
|
`MARKET_SPIKE_MIN_SAMPLES` minimum observations before it applies) — so a
|
||||||
|
routinely volatile instrument needs a bigger move to register than a
|
||||||
|
normally-calm one. Candidate articles are ranked by relevance
|
||||||
|
(keyword/country matching) but not filtered by it — timing alone
|
||||||
|
qualifies a story. Click a ticker item for a quick preview, or the "i"
|
||||||
|
button (top-left) for the full log. Heuristic correlation, not a verified
|
||||||
|
causal link.
|
||||||
- `GET /api/markets/incidents?symbol=&hours=` — the same spikes merged
|
- `GET /api/markets/incidents?symbol=&hours=` — the same spikes merged
|
||||||
across instruments within `INCIDENT_MERGE_HOURS` (1h, `markets.py`) of
|
across instruments within `INCIDENT_MERGE_HOURS` (1h, `markets.py`) of
|
||||||
each other, each with a `top_keywords` word-frequency list. Backs the
|
each other, each with a `top_keywords` word-frequency list. Backs the
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,21 @@ class Settings(BaseSettings):
|
||||||
|
|
||||||
rss_poll_minutes: int = 10
|
rss_poll_minutes: int = 10
|
||||||
market_poll_minutes: int = 15
|
market_poll_minutes: int = 15
|
||||||
# Minimum |% change| between two consecutive polls of the same
|
# Absolute floor: minimum |% change| between two consecutive polls of the
|
||||||
# instrument before we flag it as a "spike" and go looking for articles
|
# same instrument before it's even eligible to be a "spike," regardless
|
||||||
# that might explain it.
|
# of that instrument's own volatility. Prevents a very calm instrument's
|
||||||
|
# tiny normal wiggle from counting as "abnormal" just because it's small.
|
||||||
market_spike_threshold_pct: float = 1.5
|
market_spike_threshold_pct: float = 1.5
|
||||||
|
# Above the floor, a move must also be at least this many times the
|
||||||
|
# instrument's own recent typical (mean absolute) poll-to-poll move to
|
||||||
|
# count as a spike — the "higher than historical norm" check. Skipped
|
||||||
|
# (falls back to the floor alone) until there's enough price history.
|
||||||
|
market_spike_volatility_multiplier: float = 2.5
|
||||||
|
# How far back to look when computing that "recent typical move" baseline.
|
||||||
|
market_spike_history_days: int = 7
|
||||||
|
# Minimum number of historical poll-to-poll observations required before
|
||||||
|
# the volatility check applies; below this, only the floor above applies.
|
||||||
|
market_spike_min_samples: int = 8
|
||||||
# How far back (and forward, capped at "now") from the previous poll to
|
# How far back (and forward, capped at "now") from the previous poll to
|
||||||
# search for candidate-cause articles around a detected spike.
|
# search for candidate-cause articles around a detected spike.
|
||||||
market_spike_lookback_hours: int = 6
|
market_spike_lookback_hours: int = 6
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ class Base(DeclarativeBase):
|
||||||
# ddl-type-and-default) tuple here whenever a model gains a field.
|
# ddl-type-and-default) tuple here whenever a model gains a field.
|
||||||
_COLUMN_MIGRATIONS = [
|
_COLUMN_MIGRATIONS = [
|
||||||
("market_spikes", "top_keywords_json", "TEXT DEFAULT '[]'"),
|
("market_spikes", "top_keywords_json", "TEXT DEFAULT '[]'"),
|
||||||
|
("market_spikes", "baseline_volatility_pct", "REAL"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -168,13 +168,26 @@ def api_markets_latest():
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/markets/history")
|
@app.get("/api/markets/history")
|
||||||
def api_markets_history(symbol: str, hours: int = Query(168, le=24 * 30)):
|
def api_markets_history(
|
||||||
|
symbol: str,
|
||||||
|
hours: int = Query(168, le=24 * 30),
|
||||||
|
start: str | None = None,
|
||||||
|
end: str | None = None,
|
||||||
|
):
|
||||||
|
"""`start`/`end` (ISO datetimes) fetch an explicit window — used to plot
|
||||||
|
an incident's own timeframe. Without them, falls back to the last
|
||||||
|
`hours` from now, as before."""
|
||||||
|
if start and end:
|
||||||
|
since = dt.datetime.fromisoformat(start)
|
||||||
|
until = dt.datetime.fromisoformat(end)
|
||||||
|
else:
|
||||||
since = dt.datetime.utcnow() - dt.timedelta(hours=hours)
|
since = dt.datetime.utcnow() - dt.timedelta(hours=hours)
|
||||||
|
until = dt.datetime.utcnow()
|
||||||
session = next(get_session())
|
session = next(get_session())
|
||||||
try:
|
try:
|
||||||
rows = session.execute(
|
rows = session.execute(
|
||||||
select(MarketPrice)
|
select(MarketPrice)
|
||||||
.where(MarketPrice.symbol == symbol, MarketPrice.recorded_at >= since)
|
.where(MarketPrice.symbol == symbol, MarketPrice.recorded_at >= since, MarketPrice.recorded_at <= until)
|
||||||
.order_by(MarketPrice.recorded_at.asc())
|
.order_by(MarketPrice.recorded_at.asc())
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
return [{"price": r.price, "recorded_at": r.recorded_at.isoformat()} for r in rows]
|
return [{"price": r.price, "recorded_at": r.recorded_at.isoformat()} for r in rows]
|
||||||
|
|
@ -208,6 +221,7 @@ def api_markets_spikes(symbol: str | None = None, hours: int = Query(168, le=24
|
||||||
"from_price": s.from_price,
|
"from_price": s.from_price,
|
||||||
"to_price": s.to_price,
|
"to_price": s.to_price,
|
||||||
"pct_change": s.pct_change,
|
"pct_change": s.pct_change,
|
||||||
|
"baseline_volatility_pct": s.baseline_volatility_pct,
|
||||||
"window_start": s.window_start.isoformat(),
|
"window_start": s.window_start.isoformat(),
|
||||||
"window_end": s.window_end.isoformat(),
|
"window_end": s.window_end.isoformat(),
|
||||||
"detected_at": s.detected_at.isoformat(),
|
"detected_at": s.detected_at.isoformat(),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import statistics
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
@ -145,13 +146,46 @@ def _ceil_hour(t: dt.datetime) -> dt.datetime:
|
||||||
return floored if floored == t else floored + dt.timedelta(hours=1)
|
return floored if floored == t else floored + dt.timedelta(hours=1)
|
||||||
|
|
||||||
|
|
||||||
|
def _recent_volatility(session: Session, symbol: str, before: dt.datetime) -> float | None:
|
||||||
|
"""This instrument's recent typical poll-to-poll move size (mean of
|
||||||
|
|% change| between consecutive polls over the lookback window), strictly
|
||||||
|
before `before` so the move being evaluated can't inflate its own
|
||||||
|
baseline. None if there isn't enough history yet to trust it."""
|
||||||
|
since = before - dt.timedelta(days=settings.market_spike_history_days)
|
||||||
|
prices = session.execute(
|
||||||
|
select(MarketPrice.price)
|
||||||
|
.where(MarketPrice.symbol == symbol, MarketPrice.recorded_at >= since, MarketPrice.recorded_at < before)
|
||||||
|
.order_by(MarketPrice.recorded_at.asc())
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
|
changes = [
|
||||||
|
abs((p2 - p1) / p1 * 100) for p1, p2 in zip(prices, prices[1:]) if p1
|
||||||
|
]
|
||||||
|
if len(changes) < settings.market_spike_min_samples:
|
||||||
|
return None
|
||||||
|
return statistics.mean(changes)
|
||||||
|
|
||||||
|
|
||||||
def _detect_and_record_spike(
|
def _detect_and_record_spike(
|
||||||
session: Session, symbol: str, label: str, prev: MarketPrice | None, price: float, recorded_at: dt.datetime
|
session: Session, symbol: str, label: str, prev: MarketPrice | None, price: float, recorded_at: dt.datetime
|
||||||
) -> None:
|
) -> None:
|
||||||
if prev is None or not prev.price:
|
if prev is None or not prev.price:
|
||||||
return
|
return
|
||||||
pct_change = (price - prev.price) / prev.price * 100
|
pct_change = (price - prev.price) / prev.price * 100
|
||||||
if abs(pct_change) < settings.market_spike_threshold_pct:
|
abs_change = abs(pct_change)
|
||||||
|
|
||||||
|
# Absolute floor: never report a move too small to matter, regardless of
|
||||||
|
# this instrument's own volatility (a near-frozen instrument's tiny
|
||||||
|
# normal wiggle shouldn't count as "abnormal" just because it's small).
|
||||||
|
if abs_change < settings.market_spike_threshold_pct:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Above the floor, also require it to be unusually large *for this
|
||||||
|
# instrument* — a historical-norm check, not just an absolute cutoff.
|
||||||
|
# Falls back to the floor alone when there's not enough price history
|
||||||
|
# yet (new deployment, or an instrument added recently).
|
||||||
|
baseline = _recent_volatility(session, symbol, prev.recorded_at)
|
||||||
|
if baseline is not None and baseline > 0 and abs_change < baseline * settings.market_spike_volatility_multiplier:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Snapped to whole clock hours so the log reads as clean ranges (e.g.
|
# Snapped to whole clock hours so the log reads as clean ranges (e.g.
|
||||||
|
|
@ -167,6 +201,7 @@ def _detect_and_record_spike(
|
||||||
from_price=prev.price,
|
from_price=prev.price,
|
||||||
to_price=price,
|
to_price=price,
|
||||||
pct_change=round(pct_change, 3),
|
pct_change=round(pct_change, 3),
|
||||||
|
baseline_volatility_pct=round(baseline, 3) if baseline is not None else None,
|
||||||
window_start=window_start,
|
window_start=window_start,
|
||||||
window_end=window_end,
|
window_end=window_end,
|
||||||
detected_at=recorded_at,
|
detected_at=recorded_at,
|
||||||
|
|
@ -174,7 +209,13 @@ def _detect_and_record_spike(
|
||||||
top_keywords_json=json.dumps(keywords),
|
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%% (baseline %s, %d candidate articles)",
|
||||||
|
symbol,
|
||||||
|
pct_change,
|
||||||
|
f"{baseline:.2f}%" if baseline is not None else "n/a",
|
||||||
|
len(article_ids),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def poll_markets(session: Session) -> int:
|
def poll_markets(session: Session) -> int:
|
||||||
|
|
@ -258,6 +299,7 @@ def merge_spikes_into_incidents(spikes: list[MarketSpike]) -> list[dict]:
|
||||||
"from_price": s.from_price,
|
"from_price": s.from_price,
|
||||||
"to_price": s.to_price,
|
"to_price": s.to_price,
|
||||||
"pct_change": s.pct_change,
|
"pct_change": s.pct_change,
|
||||||
|
"baseline_volatility_pct": s.baseline_volatility_pct,
|
||||||
"detected_at": s.detected_at,
|
"detected_at": s.detected_at,
|
||||||
}
|
}
|
||||||
for s in group
|
for s in group
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,11 @@ class MarketSpike(Base):
|
||||||
from_price: Mapped[float] = mapped_column(Float)
|
from_price: Mapped[float] = mapped_column(Float)
|
||||||
to_price: Mapped[float] = mapped_column(Float)
|
to_price: Mapped[float] = mapped_column(Float)
|
||||||
pct_change: Mapped[float] = mapped_column(Float)
|
pct_change: Mapped[float] = mapped_column(Float)
|
||||||
|
# This instrument's recent typical (mean absolute) poll-to-poll move,
|
||||||
|
# for comparison — null if there wasn't enough price history yet to
|
||||||
|
# compute one (see market_spike_min_samples). Lets the UI show "Nx this
|
||||||
|
# instrument's normal move" instead of just the raw percentage.
|
||||||
|
baseline_volatility_pct: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
|
||||||
window_start: Mapped[dt.datetime] = mapped_column(DateTime)
|
window_start: Mapped[dt.datetime] = mapped_column(DateTime)
|
||||||
window_end: Mapped[dt.datetime] = mapped_column(DateTime)
|
window_end: Mapped[dt.datetime] = mapped_column(DateTime)
|
||||||
|
|
|
||||||
|
|
@ -278,6 +278,43 @@ html, body {
|
||||||
.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; }
|
||||||
|
|
||||||
|
.overlay-toggle-btn {
|
||||||
|
background: var(--c-tag-bg);
|
||||||
|
border: 1px solid var(--c-panel-border);
|
||||||
|
color: var(--c-dark-soft);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 4px 0 6px;
|
||||||
|
}
|
||||||
|
.overlay-toggle-btn:hover { background: #3d2260; color: #fff; }
|
||||||
|
|
||||||
|
.overlay-charts { margin: 6px 0 12px; }
|
||||||
|
.overlay-charts h4 { font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--c-text-muted); margin: 12px 0 4px; font-weight: 600; }
|
||||||
|
.overlay-charts h4:first-child { margin-top: 0; }
|
||||||
|
|
||||||
|
.overlay-chart-container { position: relative; }
|
||||||
|
.overlay-chart-svg { width: 100%; height: 200px; display: block; overflow: visible; }
|
||||||
|
|
||||||
|
.overlay-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
background: #1c1030;
|
||||||
|
border: 1px solid var(--c-panel-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 2;
|
||||||
|
max-width: 220px;
|
||||||
|
}
|
||||||
|
.overlay-tooltip-row { display: flex; justify-content: space-between; gap: 10px; white-space: nowrap; }
|
||||||
|
|
||||||
|
.overlay-legend { display: flex; flex-wrap: wrap; gap: 6px 12px; margin-top: 6px; font-size: 10px; color: var(--c-text-muted); }
|
||||||
|
.overlay-legend-item { display: inline-flex; align-items: center; gap: 4px; }
|
||||||
|
.overlay-legend .swatch { width: 8px; height: 8px; border-radius: 2px; display: inline-block; }
|
||||||
|
|
||||||
.keyword-tags { margin: 6px 0 10px; font-size: 11px; display: flex; flex-wrap: wrap; align-items: center; gap: 5px; }
|
.keyword-tags { margin: 6px 0 10px; font-size: 11px; display: flex; flex-wrap: wrap; align-items: center; gap: 5px; }
|
||||||
.keyword-tag {
|
.keyword-tag {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,30 @@ const NEWS_ICON_HEX = 0xff33ff;
|
||||||
// Must match INCIDENT_MERGE_HOURS in backend/app/markets.py — display label only.
|
// Must match INCIDENT_MERGE_HOURS in backend/app/markets.py — display label only.
|
||||||
const INCIDENT_MERGE_HOURS_LABEL = "1h";
|
const INCIDENT_MERGE_HOURS_LABEL = "1h";
|
||||||
|
|
||||||
|
// Categorical palette for the multi-instrument overlay charts — the
|
||||||
|
// dataviz skill's validated 8-hue set (dark-surface steps), reused as-is
|
||||||
|
// rather than forced into the site's monochrome violet/magenta theme: a
|
||||||
|
// genuine multi-series comparison chart needs real hue separation, which a
|
||||||
|
// single-hue-family theme can't provide. Order is the CVD-safety mechanism
|
||||||
|
// (see the skill's palette.md) — never reorder or cycle it per-render.
|
||||||
|
const OVERLAY_PALETTE = ["#3987e5", "#008300", "#d55181", "#c98500", "#199e70", "#d95926", "#9085e9", "#e66767"];
|
||||||
|
const OVERLAY_SHORT_LABELS = {
|
||||||
|
"^GSPC": "S&P", "^DJI": "DJI", "^IXIC": "NASDAQ", "^FTSE": "FTSE",
|
||||||
|
"^GDAXI": "DAX", "^FCHI": "CAC", "^N225": "N225", "^HSI": "HSI",
|
||||||
|
"000001.SS": "SSE", "^BSESN": "SENSEX", "IMOEX.ME": "MOEX", "^BVSP": "BOVESPA",
|
||||||
|
"CL=F": "WTI", "BZ=F": "BRENT",
|
||||||
|
};
|
||||||
|
let overlaySymbolStyle = {}; // symbol -> {color, dashed} — assigned once, stable across renders
|
||||||
|
|
||||||
|
// Beyond 8 series, colors repeat with a dashed stroke as a secondary
|
||||||
|
// channel (still per-entity, still fixed order) rather than cycling hue.
|
||||||
|
function assignOverlaySymbolStyles(symbols) {
|
||||||
|
overlaySymbolStyle = {};
|
||||||
|
symbols.forEach((sym, i) => {
|
||||||
|
overlaySymbolStyle[sym] = { color: OVERLAY_PALETTE[i % OVERLAY_PALETTE.length], dashed: i >= OVERLAY_PALETTE.length };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
let displayedClusters = []; // zoom-adaptive regrouping of `clusters`, currently on screen
|
let displayedClusters = []; // zoom-adaptive regrouping of `clusters`, currently on screen
|
||||||
|
|
@ -856,6 +880,16 @@ function keywordTagsHtml(keywords) {
|
||||||
return `<div class="keyword-tags"><span class="subtle">Likely factors (common words in this window):</span> ${tags}</div>`;
|
return `<div class="keyword-tags"><span class="subtle">Likely factors (common words in this window):</span> ${tags}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Spikes are only reported when a move clears both an absolute floor AND
|
||||||
|
// (once there's enough price history) a multiple of this instrument's own
|
||||||
|
// recent typical move — see backend markets.py _recent_volatility. This
|
||||||
|
// renders that comparison so "why did this count as a spike" is visible.
|
||||||
|
function volatilityLabel(pctChange, baselinePct) {
|
||||||
|
if (baselinePct == null || baselinePct <= 0) return "";
|
||||||
|
const ratio = Math.abs(pctChange) / baselinePct;
|
||||||
|
return ` <span class="subtle">(${ratio.toFixed(1)}× this instrument's typical ${baselinePct.toFixed(2)}% move)</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
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");
|
||||||
|
|
@ -878,7 +912,7 @@ async function toggleMarketDetail(symbol, idx) {
|
||||||
? s.candidate_articles.slice(0, 5).map(articleItemHtml).join("")
|
? s.candidate_articles.slice(0, 5).map(articleItemHtml).join("")
|
||||||
: `<p class="subtle">No strongly-matching stories found in that window.</p>`;
|
: `<p class="subtle">No strongly-matching stories found in that window.</p>`;
|
||||||
return `
|
return `
|
||||||
<p><span class="${dir}">${arrow} ${s.pct_change.toFixed(2)}%</span>
|
<p><span class="${dir}">${arrow} ${s.pct_change.toFixed(2)}%</span>${volatilityLabel(s.pct_change, s.baseline_volatility_pct)}
|
||||||
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)}
|
${keywordTagsHtml(s.top_keywords)}
|
||||||
|
|
@ -895,6 +929,8 @@ async function toggleMarketDetail(symbol, idx) {
|
||||||
|
|
||||||
const econHistoryView = document.getElementById("econHistoryView");
|
const econHistoryView = document.getElementById("econHistoryView");
|
||||||
let econHistorySymbolsLoaded = false;
|
let econHistorySymbolsLoaded = false;
|
||||||
|
let currentIncidents = [];
|
||||||
|
let allTrackedInstruments = null; // [{symbol,label}], fetched once
|
||||||
|
|
||||||
document.getElementById("infoBtn").onclick = () => {
|
document.getElementById("infoBtn").onclick = () => {
|
||||||
econHistoryView.classList.remove("hidden");
|
econHistoryView.classList.remove("hidden");
|
||||||
|
|
@ -903,6 +939,25 @@ document.getElementById("infoBtn").onclick = () => {
|
||||||
|
|
||||||
document.getElementById("econHistorySymbolFilter").onchange = () => loadEconHistory();
|
document.getElementById("econHistorySymbolFilter").onchange = () => loadEconHistory();
|
||||||
|
|
||||||
|
// Delegated once (not per-render) so re-rendering the list doesn't stack
|
||||||
|
// duplicate listeners: expand/collapse a card's comparison charts, fetching
|
||||||
|
// them lazily on first expand only.
|
||||||
|
document.getElementById("econHistoryList").addEventListener("click", (ev) => {
|
||||||
|
const btn = ev.target.closest(".overlay-toggle-btn");
|
||||||
|
if (!btn) return;
|
||||||
|
const idx = btn.dataset.idx;
|
||||||
|
const wrap = document.getElementById(`overlay-charts-${idx}`);
|
||||||
|
if (wrap.dataset.loaded) {
|
||||||
|
wrap.classList.toggle("hidden");
|
||||||
|
btn.textContent = wrap.classList.contains("hidden") ? "📈 Show comparison charts" : "📉 Hide comparison charts";
|
||||||
|
} else {
|
||||||
|
btn.textContent = "Loading charts…";
|
||||||
|
loadIncidentOverlayCharts(currentIncidents[idx], idx).then(() => {
|
||||||
|
btn.textContent = "📉 Hide comparison charts";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
async function loadEconHistory() {
|
async function loadEconHistory() {
|
||||||
const listEl = document.getElementById("econHistoryList");
|
const listEl = document.getElementById("econHistoryList");
|
||||||
const filterEl = document.getElementById("econHistorySymbolFilter");
|
const filterEl = document.getElementById("econHistorySymbolFilter");
|
||||||
|
|
@ -925,12 +980,12 @@ async function loadEconHistory() {
|
||||||
const url = symbol
|
const url = symbol
|
||||||
? `${API}/markets/incidents?symbol=${encodeURIComponent(symbol)}&hours=8760`
|
? `${API}/markets/incidents?symbol=${encodeURIComponent(symbol)}&hours=8760`
|
||||||
: `${API}/markets/incidents?hours=8760`;
|
: `${API}/markets/incidents?hours=8760`;
|
||||||
const incidents = await (await fetch(url)).json();
|
currentIncidents = await (await fetch(url)).json();
|
||||||
if (!incidents.length) {
|
if (!currentIncidents.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 = incidents.map(econIncidentHtml).join("");
|
listEl.innerHTML = currentIncidents.map((inc, idx) => econIncidentHtml(inc, idx)).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>`;
|
||||||
}
|
}
|
||||||
|
|
@ -939,7 +994,7 @@ async function loadEconHistory() {
|
||||||
// An "incident" is one or more spikes merged because they landed within an
|
// An "incident" is one or more spikes merged because they landed within an
|
||||||
// hour of each other (backend: markets.merge_spikes_into_incidents) — e.g.
|
// hour of each other (backend: markets.merge_spikes_into_incidents) — e.g.
|
||||||
// WTI and Brent crude spiking together shows as one incident, not two.
|
// WTI and Brent crude spiking together shows as one incident, not two.
|
||||||
function econIncidentHtml(inc) {
|
function econIncidentHtml(inc, idx) {
|
||||||
const fmtHour = (iso) =>
|
const fmtHour = (iso) =>
|
||||||
new Date(iso).toLocaleString(undefined, {
|
new Date(iso).toLocaleString(undefined, {
|
||||||
month: "short",
|
month: "short",
|
||||||
|
|
@ -966,11 +1021,200 @@ function econIncidentHtml(inc) {
|
||||||
${inc.instruments.length > 1 ? ` · ${inc.instruments.length} instruments moved within ${INCIDENT_MERGE_HOURS_LABEL} of each other` : ""}
|
${inc.instruments.length > 1 ? ` · ${inc.instruments.length} instruments moved within ${INCIDENT_MERGE_HOURS_LABEL} of each other` : ""}
|
||||||
</div>
|
</div>
|
||||||
${keywordTagsHtml(inc.top_keywords)}
|
${keywordTagsHtml(inc.top_keywords)}
|
||||||
|
<button class="overlay-toggle-btn" data-idx="${idx}">📈 Show comparison charts</button>
|
||||||
|
<div class="overlay-charts hidden" id="overlay-charts-${idx}">
|
||||||
|
<h4>All tracked instruments, overlaid (% change from window start)</h4>
|
||||||
|
<div class="overlay-chart-container" id="overlay-all-${idx}"></div>
|
||||||
|
<h4>This incident's instruments only</h4>
|
||||||
|
<div class="overlay-chart-container" id="overlay-inc-${idx}"></div>
|
||||||
|
</div>
|
||||||
<div class="articles">${articlesHtml}</div>
|
<div class="articles">${articlesHtml}</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- multi-instrument overlay charts ----
|
||||||
|
//
|
||||||
|
// Normalizes every series to % change from its first point in the window
|
||||||
|
// (raw prices span wildly different scales/currencies) and plots them on
|
||||||
|
// one shared axis so counter-reactions — one instrument up while another
|
||||||
|
// is down over the same span — are visible at a glance.
|
||||||
|
|
||||||
|
async function ensureAllTrackedInstruments() {
|
||||||
|
if (allTrackedInstruments) return allTrackedInstruments;
|
||||||
|
allTrackedInstruments = await (await fetch(`${API}/markets/latest`)).json();
|
||||||
|
assignOverlaySymbolStyles(allTrackedInstruments.map((r) => r.symbol));
|
||||||
|
return allTrackedInstruments;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchHistoryFor(instruments, start, end) {
|
||||||
|
const results = await Promise.all(
|
||||||
|
instruments.map(async (inst) => {
|
||||||
|
const url = `${API}/markets/history?symbol=${encodeURIComponent(inst.symbol)}&start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}`;
|
||||||
|
const history = await (await fetch(url)).json();
|
||||||
|
return { symbol: inst.symbol, label: inst.label, history };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadIncidentOverlayCharts(inc, idx) {
|
||||||
|
const wrap = document.getElementById(`overlay-charts-${idx}`);
|
||||||
|
wrap.classList.remove("hidden");
|
||||||
|
wrap.dataset.loaded = "1";
|
||||||
|
|
||||||
|
const allEl = document.getElementById(`overlay-all-${idx}`);
|
||||||
|
const incEl = document.getElementById(`overlay-inc-${idx}`);
|
||||||
|
allEl.innerHTML = `<p class="subtle">Loading…</p>`;
|
||||||
|
incEl.innerHTML = `<p class="subtle">Loading…</p>`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const instruments = await ensureAllTrackedInstruments();
|
||||||
|
const allSeries = await fetchHistoryFor(instruments, inc.window_start, inc.window_end);
|
||||||
|
buildOverlayChart(allSeries, allEl);
|
||||||
|
|
||||||
|
const incSymbols = new Set(inc.instruments.map((i) => i.symbol));
|
||||||
|
buildOverlayChart(
|
||||||
|
allSeries.filter((s) => incSymbols.has(s.symbol)),
|
||||||
|
incEl
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
allEl.innerHTML = `<p class="subtle">Could not load comparison charts.</p>`;
|
||||||
|
incEl.innerHTML = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOverlayChart(seriesList, container) {
|
||||||
|
const width = 640;
|
||||||
|
const height = 220;
|
||||||
|
const padL = 8;
|
||||||
|
const padR = 92;
|
||||||
|
const padT = 10;
|
||||||
|
const padB = 10;
|
||||||
|
|
||||||
|
const usable = seriesList.filter((s) => s.history.length >= 2);
|
||||||
|
if (!usable.length) {
|
||||||
|
container.innerHTML = `<p class="subtle">Not enough price history in this window yet.</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = usable.map((s) => {
|
||||||
|
const base = s.history[0].price;
|
||||||
|
return {
|
||||||
|
symbol: s.symbol,
|
||||||
|
label: s.label,
|
||||||
|
points: s.history.map((h) => ({
|
||||||
|
t: new Date(h.recorded_at).getTime(),
|
||||||
|
pct: base ? ((h.price - base) / base) * 100 : 0,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const allT = normalized.flatMap((s) => s.points.map((p) => p.t));
|
||||||
|
const tMin = Math.min(...allT);
|
||||||
|
const tMax = Math.max(...allT, tMin + 1);
|
||||||
|
const allPct = normalized.flatMap((s) => s.points.map((p) => p.pct));
|
||||||
|
const pMin = Math.min(0, ...allPct);
|
||||||
|
const pMax = Math.max(0, ...allPct);
|
||||||
|
const pSpan = pMax - pMin || 1;
|
||||||
|
|
||||||
|
const xScale = (t) => padL + ((t - tMin) / (tMax - tMin)) * (width - padL - padR);
|
||||||
|
const yScale = (pct) => padT + (1 - (pct - pMin) / pSpan) * (height - padT - padB);
|
||||||
|
|
||||||
|
const svgNS = "http://www.w3.org/2000/svg";
|
||||||
|
const svg = document.createElementNS(svgNS, "svg");
|
||||||
|
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
|
||||||
|
svg.classList.add("overlay-chart-svg");
|
||||||
|
|
||||||
|
const zero = document.createElementNS(svgNS, "line");
|
||||||
|
zero.setAttribute("x1", padL);
|
||||||
|
zero.setAttribute("x2", width - padR);
|
||||||
|
zero.setAttribute("y1", yScale(0).toFixed(1));
|
||||||
|
zero.setAttribute("y2", yScale(0).toFixed(1));
|
||||||
|
zero.setAttribute("stroke", "var(--c-panel-border)");
|
||||||
|
svg.appendChild(zero);
|
||||||
|
|
||||||
|
normalized.forEach((s) => {
|
||||||
|
const style = overlaySymbolStyle[s.symbol] || { color: "#999", dashed: false };
|
||||||
|
const d = s.points.map((p, i) => `${i === 0 ? "M" : "L"}${xScale(p.t).toFixed(1)},${yScale(p.pct).toFixed(1)}`).join(" ");
|
||||||
|
const path = document.createElementNS(svgNS, "path");
|
||||||
|
path.setAttribute("d", d);
|
||||||
|
path.setAttribute("fill", "none");
|
||||||
|
path.setAttribute("stroke", style.color);
|
||||||
|
path.setAttribute("stroke-width", "2");
|
||||||
|
if (style.dashed) path.setAttribute("stroke-dasharray", "5,3");
|
||||||
|
svg.appendChild(path);
|
||||||
|
|
||||||
|
const last = s.points[s.points.length - 1];
|
||||||
|
const label = document.createElementNS(svgNS, "text");
|
||||||
|
label.setAttribute("x", (xScale(last.t) + 5).toFixed(1));
|
||||||
|
label.setAttribute("y", (yScale(last.pct) + 3).toFixed(1));
|
||||||
|
label.setAttribute("fill", style.color);
|
||||||
|
label.setAttribute("font-size", "9");
|
||||||
|
label.textContent = OVERLAY_SHORT_LABELS[s.symbol] || s.symbol;
|
||||||
|
svg.appendChild(label);
|
||||||
|
});
|
||||||
|
|
||||||
|
const crosshair = document.createElementNS(svgNS, "line");
|
||||||
|
crosshair.setAttribute("y1", String(padT));
|
||||||
|
crosshair.setAttribute("y2", String(height - padB));
|
||||||
|
crosshair.setAttribute("stroke", "var(--c-text-muted)");
|
||||||
|
crosshair.setAttribute("stroke-width", "1");
|
||||||
|
crosshair.setAttribute("stroke-dasharray", "2,2");
|
||||||
|
crosshair.style.display = "none";
|
||||||
|
svg.appendChild(crosshair);
|
||||||
|
|
||||||
|
container.innerHTML = "";
|
||||||
|
container.appendChild(svg);
|
||||||
|
|
||||||
|
const tooltip = document.createElement("div");
|
||||||
|
tooltip.className = "overlay-tooltip hidden";
|
||||||
|
container.appendChild(tooltip);
|
||||||
|
|
||||||
|
svg.addEventListener("mousemove", (ev) => {
|
||||||
|
const rect = svg.getBoundingClientRect();
|
||||||
|
const relX = ((ev.clientX - rect.left) / rect.width) * width;
|
||||||
|
const t = tMin + ((relX - padL) / (width - padL - padR)) * (tMax - tMin);
|
||||||
|
|
||||||
|
crosshair.setAttribute("x1", relX.toFixed(1));
|
||||||
|
crosshair.setAttribute("x2", relX.toFixed(1));
|
||||||
|
crosshair.style.display = "";
|
||||||
|
|
||||||
|
const rows = normalized
|
||||||
|
.map((s) => {
|
||||||
|
let nearest = s.points[0];
|
||||||
|
let best = Infinity;
|
||||||
|
s.points.forEach((p) => {
|
||||||
|
const d = Math.abs(p.t - t);
|
||||||
|
if (d < best) {
|
||||||
|
best = d;
|
||||||
|
nearest = p;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const style = overlaySymbolStyle[s.symbol] || { color: "#999" };
|
||||||
|
return `<div class="overlay-tooltip-row"><span style="color:${style.color}">${escapeHtml(s.label)}</span> <b>${nearest.pct >= 0 ? "+" : ""}${nearest.pct.toFixed(2)}%</b></div>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
tooltip.innerHTML = `<div class="subtle">${new Date(t).toLocaleString()}</div>${rows}`;
|
||||||
|
tooltip.classList.remove("hidden");
|
||||||
|
tooltip.style.left = `${Math.min(60, Math.max(0, (relX / width) * 100 - 20))}%`;
|
||||||
|
});
|
||||||
|
svg.addEventListener("mouseleave", () => {
|
||||||
|
crosshair.style.display = "none";
|
||||||
|
tooltip.classList.add("hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
const legend = document.createElement("div");
|
||||||
|
legend.className = "overlay-legend";
|
||||||
|
legend.innerHTML = normalized
|
||||||
|
.map((s) => {
|
||||||
|
const style = overlaySymbolStyle[s.symbol] || { color: "#999" };
|
||||||
|
return `<span class="overlay-legend-item"><span class="swatch" style="background:${style.color}"></span>${escapeHtml(s.label)}</span>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
container.appendChild(legend);
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------- flights --
|
// -------------------------------------------------------------- flights --
|
||||||
//
|
//
|
||||||
// Rendered as a single THREE.Points cloud (not three-globe's per-datum
|
// Rendered as a single THREE.Points cloud (not three-globe's per-datum
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue