Files
QuantumTerminal/backend/pulse_room/builder.py
T

464 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# version: v4
"""Pulse snapshot builder.
Reads cached cones/bands JSONs, extracts SD edges as forward curves,
and writes pulse_snapshot.json to AppData. Atomic write — partial
runs never corrupt the live snapshot.
"""
import json
import logging
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
from . import state
log = logging.getLogger("mk.pulse_room.builder")
# ---------- path resolvers ----------
def _default_cache_dir() -> Path:
appdata = os.environ.get("APPDATA")
if not appdata:
appdata = str(Path.home() / ".config")
dir_name = os.environ.get("MK_APP_DIR_NAME", "QuantumTerminal-v2")
return Path(appdata) / dir_name / "cache"
def cones_path(ticker: str, cache_dir: Optional[Path] = None) -> Path:
cd = cache_dir or _default_cache_dir()
return cd / f"{ticker.upper()}_cones.json"
def bands_path(ticker: str, cache_dir: Optional[Path] = None) -> Path:
cd = cache_dir or _default_cache_dir()
return cd / f"GLOBAL_{ticker.lower()}_bands.json"
# ---------- safe JSON loader ----------
def load_json(path: Path) -> Optional[dict]:
"""Load JSON; return None on missing or malformed."""
if not path.exists():
return None
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except (OSError, json.JSONDecodeError) as e:
log.warning("load_json failed for %s: %s", path, e)
return None
# ---------- cone extraction ----------
def _parse_iso(s: str) -> datetime:
"""Parse a cone-format date like '2026-04-27 00:00' as UTC."""
# Cones use 'YYYY-MM-DD HH:MM' (no seconds, no TZ)
if "T" in s:
s_normalized = s.replace("Z", "+00:00")
try:
dt = datetime.fromisoformat(s_normalized)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
except ValueError:
pass
return datetime.strptime(s, "%Y-%m-%d %H:%M").replace(tzinfo=timezone.utc)
def _date_offsets_minutes(dates: list, base: Optional[datetime] = None) -> list:
"""Convert a list of date strings to minutes-from-base.
If `base` is None, uses dates[0] (legacy behavior, kept so existing tests
that don't pass a `now` continue to work). When the production code path
in `build_snapshot` calls this, it always passes `base=now` so that t_min
is minutes-from-computed_at (per spec §3.1) — past timestamps get t_min<0.
"""
parsed = [_parse_iso(d) for d in dates]
base_dt = base if base is not None else parsed[0]
return [int((d - base_dt).total_seconds() // 60) for d in parsed]
def _index_at_offset_minutes(dates: list, target_minutes: int, base: Optional[datetime] = None) -> int:
"""Return the smallest index whose offset (relative to `base`, default dates[0])
is at or after `target_minutes`."""
offsets = _date_offsets_minutes(dates, base=base)
for i, m in enumerate(offsets):
if m >= target_minutes:
return i
return len(offsets) - 1
# ---------- cone origins ----------
# 8 origin variants — each is a separate cone scope inside the same
# <TICKER>_cones.json file. All use the gbm model.
# Format: (json_key, origin_id, display_label)
# Aligned with the producer's canonical schema:
# gbm_now — live, anchored at last D1 close / current px (uncached)
# gbm_curr — Monday open of current trading week (cached)
# gbm_prev — Monday open of last week (cached)
# gbm_week_prev_2 — 2 weeks back (conditionally cached)
# gbm_month_curr — first trading day of current month (cached)
# gbm_month_prev — first trading day of last month (cached)
# gbm_month_prev_2 — first trading day of month -2 (cached)
# gbm_extreme_high — bar of highest high in last 45 days (cached)
# gbm_extreme_low — bar of lowest low in last 45 days (cached)
CONE_ORIGINS = [
("gbm_now", "now", "now"),
("gbm_curr", "current_week", "current week"),
("gbm_prev", "prev_week", "previous week"),
("gbm_week_prev_2", "week_prev_2", "week -2"),
("gbm_month_curr", "current_month", "current month"),
("gbm_month_prev", "prev_month", "previous month"),
("gbm_month_prev_2", "prev_month_2", "month -2"),
("gbm_extreme_high", "extreme_high", "extreme high"),
("gbm_extreme_low", "extreme_low", "extreme low"),
]
# Priority order for sourcing the canonical 1-day volatility yardstick when
# multiple origins are available. gbm_curr is the historical canonical key;
# fall back to gbm_now (this-week scope), then gbm_month_curr.
_CONE_1D_SD_PRIORITY = ("gbm_curr", "gbm_now", "gbm_month_curr")
def _compute_cone_1d_sd(
cones_json: dict,
now: Optional[datetime] = None,
) -> Optional[float]:
"""Compute cone_1d_sd from the highest-priority origin variant present.
Returns None if no priority variant is available or extraction fails.
"""
for key in _CONE_1D_SD_PRIORITY:
v = cones_json.get(key)
if not v:
continue
try:
dates = v["dates"]
idx_1d = _index_at_offset_minutes(dates, 1440, base=now)
up_width = v["sd1_high"][idx_1d] - v["median"][idx_1d]
dn_width = v["median"][idx_1d] - v["sd1_low"][idx_1d]
return float((up_width + dn_width) / 2.0)
except (KeyError, IndexError, TypeError, ValueError):
continue
return None
def extract_cone_elements(
cones_json: dict,
now: Optional[datetime] = None,
) -> tuple[list, float]:
"""Return (elements_list, cone_1d_sd).
Iterates CONE_ORIGINS and emits 7 series per present origin variant:
median, sd1_upper/lower, sd2_upper/lower, sd3_upper/lower. Each
element carries an `origin` field (e.g. "now", "current_week").
Missing variant keys are silently skipped — some assets may have
fewer variants in their cones JSON.
cone_1d_sd is sourced from the highest-priority variant present
(gbm_curr → gbm_now → gbm_month_curr).
If `now` is provided, t_min values are re-based so t_min=0 corresponds
to `now`; past timestamps get t_min<0, future get t_min>0. If `now` is
None, falls back to legacy behavior (t_min=0 at dates[0]) so existing
test fixtures keep working.
"""
elements = []
for json_key, origin_id, display_label in CONE_ORIGINS:
v = cones_json.get(json_key)
if not v:
continue
try:
dates = v["dates"]
except (KeyError, TypeError):
continue
offsets = _date_offsets_minutes(dates, base=now)
# Median series → "<label> mean"
try:
median_series = v["median"]
except (KeyError, TypeError):
median_series = None
if median_series is not None:
elements.append({
"id": f"co_{origin_id}_median",
"family": "CO",
"origin": origin_id,
"label": f"{display_label} mean",
"model": "gbm",
"curve": [
{"t_min": int(t), "price": float(p)}
for t, p in zip(offsets, median_series)
],
})
# SD edges
for sd in (1, 2, 3):
for side, key_suffix in (("upper", "high"), ("lower", "low")):
series_key = f"sd{sd}_{key_suffix}"
if series_key not in v:
continue
series = v[series_key]
elements.append({
"id": f"co_{origin_id}_sd{sd}_{side}",
"family": "CO",
"origin": origin_id,
"label": f"{display_label} SD{sd} {side}",
"model": "gbm",
"curve": [
{"t_min": int(t), "price": float(p)}
for t, p in zip(offsets, series)
],
})
cone_1d_sd = _compute_cone_1d_sd(cones_json, now=now)
if cone_1d_sd is None:
# Preserve legacy behavior: raise so callers can decide whether
# to skip the asset entirely.
raise ValueError(
"cones JSON has no usable origin variant for cone_1d_sd "
"(checked gbm_curr, gbm_now, gbm_month_curr)"
)
return elements, cone_1d_sd
# ---------- band extraction ----------
def extract_band_elements(
bands_json: dict,
now: Optional[datetime] = None,
) -> list:
"""Seven band elements: gbm sd1/2/3 × upper/lower + median.
Every band element carries `origin: "bands"` so the schema is uniform
with cone elements. (The radar UI colors by `origin`, not by family.)
If `now` is provided, t_min values are re-based so t_min=0 corresponds
to `now` (per spec §3.1: t_min is minutes from computed_at). Past
timestamps get t_min<0, future get t_min>0. If `now` is None, falls
back to legacy timestamps[0]-based offsets so existing fixtures work.
"""
timestamps = bands_json["timestamps"]
offsets = _date_offsets_minutes(timestamps, base=now)
elements = []
# Median (mean) series
if "mean" in bands_json:
mean_series = bands_json["mean"]
elements.append({
"id": "qb_median",
"family": "QB",
"origin": "bands",
"label": "Bands mean",
"model": "gbm",
"curve": [
{"t_min": int(t), "price": float(p)}
for t, p in zip(offsets, mean_series)
],
})
# SD edges
for sd in (1, 2, 3):
for side, key_suffix in (("upper", "high"), ("lower", "low")):
series = bands_json[f"gbm_{sd}sd_{key_suffix}"]
elements.append({
"id": f"qb_sd{sd}_{side}",
"family": "QB",
"origin": "bands",
"label": f"Bands SD{sd} {side}",
"model": "gbm",
"curve": [
{"t_min": int(t), "price": float(p)}
for t, p in zip(offsets, series)
],
})
return elements
# ---------- pip size lookup ----------
# Phase 1 minimal pip table. Replace with a v2 metadata lookup once the
# implementer locates the existing per-symbol pip source. Defaulting to
# 0.01 covers most CFDs reasonably; FX overrides handle the major pairs.
_PIP_DEFAULTS = {
"EURUSD": 0.0001, "GBPUSD": 0.0001, "AUDUSD": 0.0001, "NZDUSD": 0.0001,
"USDCAD": 0.0001, "USDCHF": 0.0001, "USDJPY": 0.01,
"EURJPY": 0.01, "GBPJPY": 0.01,
"XAUUSD": 0.01, "XAGUSD": 0.001,
"XTIUSD": 0.01, "BRENT": 0.01,
"US500": 0.01, "USTEC": 0.01, "US30": 1.0,
"UK100": 0.1, "GER40": 0.1,
"BTCUSD": 1.0, "ETHUSD": 0.1, "SOLUSD": 0.01, "XRPUSD": 0.0001,
"DXY": 0.001,
}
def _pip_size(symbol: str) -> float:
return _PIP_DEFAULTS.get(symbol.upper(), 0.01)
# ---------- top-level snapshot build ----------
def build_snapshot(
watchlist: dict,
cache_dir: Optional[Path] = None,
write: bool = True,
) -> dict:
"""Build the snapshot dict; optionally persist to AppData.
Returns the snapshot dict regardless. If `write=True`, writes to
%APPDATA%/QuantumTerminal-v2/pulse_snapshot.json atomically.
Missing or malformed source files for an asset cause that asset
to be skipped (with a logged warning). Other assets still build.
"""
cd = cache_dir or _default_cache_dir()
# Capture the wall-clock "now" once. Used both as snapshot["computed_at"]
# and as the time-origin for re-basing curve t_min values (spec §3.1:
# t_min is minutes from computed_at).
now = datetime.now(timezone.utc)
snapshot = {
"version": "1.0",
"computed_at": now.isoformat(),
"threshold_sd": 0.5,
"assets": {},
}
earliest_source_ts = None
for asset_cfg in watchlist.get("assets", []):
ticker = asset_cfg["symbol"]
families = asset_cfg.get("families", [])
if not families:
continue
elements = []
cone_1d_sd = None
if "CO" in families:
cones_file = cones_path(ticker, cache_dir=cd)
cones = load_json(cones_file)
if cones is None:
log.warning("skipping %s: cones JSON missing/malformed", ticker)
continue
try:
cone_elements, cone_1d_sd = extract_cone_elements(cones, now=now)
elements.extend(cone_elements)
except (KeyError, ValueError) as e:
log.warning("skipping %s: cones extraction error: %s", ticker, e)
continue
# Use the cache file's mtime as the "freshness" timestamp for this
# source. The cones JSON itself has no `computed_at` field, so the
# previous fallback to `cones["ticker"]` produced a ticker name
# (e.g. "XAUUSD") which broke staleness detection downstream.
try:
ts = datetime.fromtimestamp(
cones_file.stat().st_mtime, tz=timezone.utc
).isoformat()
earliest_source_ts = _min_iso(earliest_source_ts, ts)
except OSError:
pass
if "QB" in families:
bands_file = bands_path(ticker, cache_dir=cd)
bands = load_json(bands_file)
if bands is None:
log.warning("skipping %s bands: bands JSON missing/malformed", ticker)
# If cones succeeded, we still include the asset with cones-only.
# If cones were never requested, this asset is empty — skip it.
if not elements:
continue
else:
try:
band_elements = extract_band_elements(bands, now=now)
elements.extend(band_elements)
except (KeyError, ValueError) as e:
log.warning("skipping %s bands: extraction error: %s", ticker, e)
# Track bands file mtime for staleness too.
try:
ts = datetime.fromtimestamp(
bands_file.stat().st_mtime, tz=timezone.utc
).isoformat()
earliest_source_ts = _min_iso(earliest_source_ts, ts)
except OSError:
pass
if not elements:
continue
# If only QB was selected, derive cone_1d_sd from the cones JSON anyway
# (we always need a volatility yardstick). This is a conservative
# fallback — load cones silently to compute the SD even when not in families.
if cone_1d_sd is None:
cones_for_sd = load_json(cones_path(ticker, cache_dir=cd))
if cones_for_sd is not None:
try:
_, cone_1d_sd = extract_cone_elements(cones_for_sd, now=now)
except (KeyError, ValueError):
pass
if cone_1d_sd is None or cone_1d_sd <= 0:
log.warning("skipping %s: cone_1d_sd unavailable or invalid", ticker)
continue
snapshot["assets"][ticker] = {
"pip_size": _pip_size(ticker),
"cone_1d_sd": cone_1d_sd,
"selected_families": list(families),
"elements": elements,
}
snapshot["source_sync_at"] = earliest_source_ts or snapshot["computed_at"]
snapshot["watchlist_hash"] = state.watchlist_hash(watchlist)
if write:
snap_path = state._pulse_dir() / "pulse_snapshot.json"
state._atomic_write(snap_path, snapshot)
log.info("pulse snapshot written: %d assets", len(snapshot["assets"]))
return snapshot
def _parse_iso_loose(s: str) -> datetime:
"""Parse various ISO-ish strings to a tz-aware UTC datetime.
Handles trailing 'Z', timezone-aware ISO, and the cone "YYYY-MM-DD HH:MM"
format. Naive inputs are treated as UTC.
"""
s_clean = s.replace("Z", "+00:00")
try:
dt = datetime.fromisoformat(s_clean)
except ValueError:
# Fallback for the cone "YYYY-MM-DD HH:MM" format
dt = datetime.strptime(s, "%Y-%m-%d %H:%M")
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
def _min_iso(a: Optional[str], b: Optional[str]) -> Optional[str]:
"""Return whichever ISO timestamp string is earlier in real time.
Parses to datetime before comparing (lexicographic comparison was wrong
when the two strings used different format conventions, e.g.
'YYYY-MM-DD HH:MM' vs 'YYYY-MM-DDTHH:MM:SS+00:00').
"""
if a is None:
return b
if b is None:
return a
try:
da = _parse_iso_loose(a)
db = _parse_iso_loose(b)
except (ValueError, TypeError):
# If we can't parse either, fall back to lexicographic compare so we
# at least return something deterministic rather than crashing.
return a if a < b else b
return a if da <= db else b