Initial commit: Quantum Terminal — Free & Open Source Trading Platform
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
# version: v1
|
||||
"""Pulse Room — Phase 1 foundation.
|
||||
|
||||
Reads cached cones/bands JSONs, builds a pulse snapshot,
|
||||
serves REST endpoints for the React Pulse Room UI.
|
||||
|
||||
See docs/specs/2026-05-04-pulse-room-design.md for full design.
|
||||
"""
|
||||
|
||||
# Re-exports added as submodules land. Until then this package is empty.
|
||||
@@ -0,0 +1,463 @@
|
||||
# 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
|
||||
@@ -0,0 +1,123 @@
|
||||
# version: v2
|
||||
"""Pulse Room REST endpoints.
|
||||
|
||||
Wire into data_server.py:
|
||||
from pulse_room.routes import create_pulse_router
|
||||
app.include_router(create_pulse_router())
|
||||
|
||||
Endpoints:
|
||||
GET /api/pulse/state — full state (scanner, watchlist, snapshot, meta)
|
||||
POST /api/pulse/calculate — rebuild snapshot (sync; 409 if in-flight)
|
||||
PUT /api/pulse/watchlist — replace watchlist
|
||||
PUT /api/pulse/scanner — set scanner enabled flag
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from . import builder, state
|
||||
# Concurrency: only one Calculate at a time. Lock lives in state.py so
|
||||
# sync_hook.py shares the same instance and the two paths can't race.
|
||||
from .state import calc_lock as _calc_lock
|
||||
|
||||
|
||||
log = logging.getLogger("mk.pulse_room.routes")
|
||||
|
||||
|
||||
# ---------- request models ----------
|
||||
|
||||
class WatchlistAssetIn(BaseModel):
|
||||
symbol: str
|
||||
families: list[str]
|
||||
|
||||
|
||||
class WatchlistIn(BaseModel):
|
||||
assets: list[WatchlistAssetIn]
|
||||
|
||||
|
||||
class ScannerIn(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
# ---------- helpers ----------
|
||||
|
||||
def _read_snapshot() -> tuple[dict | None, dict]:
|
||||
"""Return (snapshot, meta). Snapshot may be None if not yet built."""
|
||||
path = state._pulse_dir() / "pulse_snapshot.json"
|
||||
if not path.exists():
|
||||
return None, {"exists": False}
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
snap = json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None, {"exists": False}
|
||||
meta = {
|
||||
"exists": True,
|
||||
"computed_at": snap.get("computed_at"),
|
||||
"source_sync_at": snap.get("source_sync_at"),
|
||||
"watchlist_hash": snap.get("watchlist_hash"),
|
||||
"asset_count": len(snap.get("assets", {})),
|
||||
"size_bytes": path.stat().st_size,
|
||||
}
|
||||
return snap, meta
|
||||
|
||||
|
||||
def _build_state_response() -> dict:
|
||||
snap, meta = _read_snapshot()
|
||||
return {
|
||||
"scanner_enabled": state.load_scanner().get("enabled", False),
|
||||
"watchlist": state.load_watchlist(),
|
||||
"snapshot_meta": meta,
|
||||
"snapshot": snap,
|
||||
}
|
||||
|
||||
|
||||
# ---------- router ----------
|
||||
|
||||
def create_pulse_router() -> APIRouter:
|
||||
router = APIRouter(prefix="/api/pulse", tags=["pulse"])
|
||||
|
||||
@router.get("/state")
|
||||
async def get_state():
|
||||
return _build_state_response()
|
||||
|
||||
@router.put("/scanner")
|
||||
async def put_scanner(body: ScannerIn):
|
||||
state.save_scanner({"enabled": body.enabled})
|
||||
return _build_state_response()
|
||||
|
||||
@router.put("/watchlist")
|
||||
async def put_watchlist(body: WatchlistIn):
|
||||
wl = {"assets": [a.model_dump() for a in body.assets]}
|
||||
state.save_watchlist(wl)
|
||||
return _build_state_response()
|
||||
|
||||
@router.post("/calculate")
|
||||
async def post_calculate():
|
||||
wl = state.load_watchlist()
|
||||
if not wl.get("assets"):
|
||||
raise HTTPException(400, detail="Watchlist is empty")
|
||||
|
||||
if not _calc_lock.acquire(blocking=False):
|
||||
raise HTTPException(409, detail="A calculate is already in progress")
|
||||
try:
|
||||
try:
|
||||
# build_snapshot does sync file I/O; offload to a worker
|
||||
# thread so we don't block the FastAPI event loop (which
|
||||
# would freeze /api/ticks, /ws/prices, etc. for 0.5–3 s).
|
||||
await asyncio.to_thread(builder.build_snapshot, wl, write=True)
|
||||
except Exception as e:
|
||||
log.exception("Calculate failed")
|
||||
raise HTTPException(500, detail=f"Calculate failed: {e}") from e
|
||||
finally:
|
||||
_calc_lock.release()
|
||||
|
||||
return _build_state_response()
|
||||
|
||||
return router
|
||||
@@ -0,0 +1,118 @@
|
||||
# version: v2
|
||||
"""Pulse Room state persistence.
|
||||
|
||||
Owns the two AppData JSON files:
|
||||
- pulse_watchlist.json : per-asset family selection
|
||||
- pulse_state.json : scanner enabled flag
|
||||
|
||||
All writes go through atomic_write() so partial writes can never
|
||||
corrupt the live file.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
# Process-wide lock guarding pulse snapshot rebuilds. Both the user-initiated
|
||||
# Calculate (routes.py) and the auto-rebuild after sync (sync_hook.py) acquire
|
||||
# this so they cannot race. Defined here (rather than in routes.py) so both
|
||||
# importers see the same lock instance.
|
||||
calc_lock = threading.Lock()
|
||||
|
||||
|
||||
def _appdata_root() -> Path:
|
||||
"""Return %APPDATA%/QuantumTerminal-v2 (or whatever MK_APP_DIR_NAME points to).
|
||||
|
||||
This mirrors v2's existing AppData isolation pattern. We import
|
||||
config_manager lazily to avoid a circular import at module load time.
|
||||
"""
|
||||
appdata = os.environ.get("APPDATA")
|
||||
if not appdata:
|
||||
# Fallback for non-Windows dev setups.
|
||||
appdata = str(Path.home() / ".config")
|
||||
dir_name = os.environ.get("MK_APP_DIR_NAME", "QuantumTerminal-v2")
|
||||
return Path(appdata) / dir_name
|
||||
|
||||
|
||||
def _pulse_dir() -> Path:
|
||||
p = _appdata_root()
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
return p
|
||||
|
||||
|
||||
def _atomic_write(path: Path, payload: Any) -> None:
|
||||
"""Write JSON atomically: tempfile next to target, then rename."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp_path = tempfile.mkstemp(
|
||||
prefix=path.stem + ".",
|
||||
suffix=".tmp",
|
||||
dir=str(path.parent),
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, indent=2, sort_keys=True)
|
||||
os.replace(tmp_path, path)
|
||||
except Exception:
|
||||
if os.path.exists(tmp_path):
|
||||
os.unlink(tmp_path)
|
||||
raise
|
||||
|
||||
|
||||
# ---------- watchlist ----------
|
||||
|
||||
_WATCHLIST_DEFAULT = {"assets": []}
|
||||
|
||||
|
||||
def load_watchlist() -> dict:
|
||||
path = _pulse_dir() / "pulse_watchlist.json"
|
||||
if not path.exists():
|
||||
return dict(_WATCHLIST_DEFAULT)
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return dict(_WATCHLIST_DEFAULT)
|
||||
|
||||
|
||||
def save_watchlist(wl: dict) -> None:
|
||||
_atomic_write(_pulse_dir() / "pulse_watchlist.json", wl)
|
||||
|
||||
|
||||
def watchlist_hash(wl: dict) -> str:
|
||||
"""Stable hash regardless of asset order or family-list order."""
|
||||
canonical = {
|
||||
"assets": sorted(
|
||||
(
|
||||
{"symbol": a["symbol"], "families": sorted(a.get("families", []))}
|
||||
for a in wl.get("assets", [])
|
||||
),
|
||||
key=lambda a: a["symbol"],
|
||||
)
|
||||
}
|
||||
encoded = json.dumps(canonical, sort_keys=True).encode("utf-8")
|
||||
return "sha256:" + hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
# ---------- scanner ----------
|
||||
|
||||
_SCANNER_DEFAULT = {"enabled": False}
|
||||
|
||||
|
||||
def load_scanner() -> dict:
|
||||
path = _pulse_dir() / "pulse_state.json"
|
||||
if not path.exists():
|
||||
return dict(_SCANNER_DEFAULT)
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return dict(_SCANNER_DEFAULT)
|
||||
|
||||
|
||||
def save_scanner(s: dict) -> None:
|
||||
_atomic_write(_pulse_dir() / "pulse_state.json", s)
|
||||
@@ -0,0 +1,43 @@
|
||||
# version: v2
|
||||
"""Sync-completion hook for Pulse Room.
|
||||
|
||||
Called by periodic_sync.py after a successful background sync.
|
||||
If scanner is ON and watchlist non-empty, rebuild the snapshot
|
||||
in a daemon thread (non-blocking to the sync caller).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
|
||||
from . import builder, state
|
||||
|
||||
|
||||
log = logging.getLogger("mk.pulse_room.sync_hook")
|
||||
|
||||
|
||||
def on_sync_complete() -> None:
|
||||
"""Fire-and-forget: spawn a thread that rebuilds if eligible."""
|
||||
threading.Thread(target=_rebuild_if_eligible, daemon=True).start()
|
||||
|
||||
|
||||
def _rebuild_if_eligible() -> None:
|
||||
try:
|
||||
scanner = state.load_scanner()
|
||||
if not scanner.get("enabled"):
|
||||
return
|
||||
wl = state.load_watchlist()
|
||||
if not wl.get("assets"):
|
||||
return
|
||||
# Don't fight a user-initiated Calculate. If the lock is held, skip
|
||||
# this rebuild silently — the user's Calculate will produce a fresh
|
||||
# snapshot momentarily.
|
||||
if not state.calc_lock.acquire(blocking=False):
|
||||
log.info("calc lock held — skipping auto-rebuild")
|
||||
return
|
||||
try:
|
||||
log.info("auto-rebuilding pulse snapshot after sync")
|
||||
builder.build_snapshot(wl, write=True)
|
||||
finally:
|
||||
state.calc_lock.release()
|
||||
except Exception:
|
||||
log.exception("auto-rebuild failed; existing snapshot retained")
|
||||
Reference in New Issue
Block a user