# 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 # _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 → "