—
+ +Forecast fan Layer 1
+ band width = uncertainty +News sentiment Layer 2
+ headline tone +Daily distribution
+ not "predicted price" +Skew / downside tail
—
+diff --git a/forecast_dashboard/.dockerignore b/forecast_dashboard/.dockerignore new file mode 100644 index 000000000..06819b07d --- /dev/null +++ b/forecast_dashboard/.dockerignore @@ -0,0 +1,21 @@ +# Keep the Docker build context small + avoid baking local junk into the image. +.git +.venv +__pycache__/ +*.py[cod] +*.egg-info/ +.ipynb_checkpoints/ +.pytest_cache/ + +# Local data / secrets / logs (the image uses /tmp + build-time HF cache) +.env +*.db +*.sqlite +*.log + +# The image clones Kronos itself at build time; don't ship a local clone. +Kronos/ + +# Deploy helpers + docs not needed inside the container +deploy/_* +*.md diff --git a/forecast_dashboard/.env.example b/forecast_dashboard/.env.example new file mode 100644 index 000000000..78e978cc7 --- /dev/null +++ b/forecast_dashboard/.env.example @@ -0,0 +1,44 @@ +# ---------------------------------------------------------------------------- +# Kronos Forecasting Dashboard — environment template +# Copy to .env and fill in. NEVER commit the real .env. +# ---------------------------------------------------------------------------- + +# --- Kronos repo location ----------------------------------------------------- +# Path to the cloned Kronos repo root (the dir containing `model/`). +# Defaults to ./Kronos next to this project if unset. +KRONOS_DIR=./Kronos + +# --- Model config ------------------------------------------------------------- +KRONOS_MODEL=NeoQuasar/Kronos-small +KRONOS_TOKENIZER=NeoQuasar/Kronos-Tokenizer-base +# device: cpu | cuda:0 | mps +KRONOS_DEVICE=cpu + +# --- Forecast defaults -------------------------------------------------------- +DEFAULT_PRED_LEN=30 +DEFAULT_N_SAMPLES=30 +DEFAULT_LOOKBACK=400 + +# --- Storage ------------------------------------------------------------------ +# SQLite file for forecast logging + accuracy tracking. +FORECAST_DB=./forecasts.db + +# --- Web server (server.py) --------------------------------------------------- +# Bind address/port for the bespoke FastAPI dashboard. +# For GCP, bind 0.0.0.0 and reach it via SSH port-forward (see README). +HOST=127.0.0.1 +PORT=8000 +# Prewarm all four forecasts in the background on startup (1=on, 0=off). +# Hides slow first-run CPU inference from the first visitor. +KRONOS_PREWARM=1 + +# --- News / sentiment (OPTIONAL) --------------------------------------------- +# Used ONLY to summarize recent headlines as "things the model did NOT see". +# This NEVER feeds the forecast and NEVER produces a confidence score. +# Leave blank to disable the news panel (the dashboard still works fully). +ANTHROPIC_API_KEY= +ANTHROPIC_MODEL=claude-3-5-haiku-latest + +# Optional web-search provider for headlines (else news panel shows a notice). +# Supported: tavily (set TAVILY_API_KEY). Leave blank to skip live search. +TAVILY_API_KEY= diff --git a/forecast_dashboard/.gitattributes b/forecast_dashboard/.gitattributes new file mode 100644 index 000000000..3897fd7b1 --- /dev/null +++ b/forecast_dashboard/.gitattributes @@ -0,0 +1,16 @@ +# Shell scripts and deploy config MUST stay LF — they run on the Linux VM and +# bash/systemd break on CRLF (\r). Force LF regardless of core.autocrlf. +*.sh text eol=lf +deploy/config.env text eol=lf +deploy/vm_setup.sh text eol=lf + +# Keep these LF too for cross-platform consistency. +*.py text eol=lf +*.js text eol=lf +*.css text eol=lf +*.html text eol=lf +*.md text eol=lf + +# PowerShell deploy scripts are Windows-only; CRLF is the native convention and +# PowerShell reads either, so pin CRLF for clarity. +*.ps1 text eol=crlf diff --git a/forecast_dashboard/.gitignore b/forecast_dashboard/.gitignore new file mode 100644 index 000000000..5a31835ce --- /dev/null +++ b/forecast_dashboard/.gitignore @@ -0,0 +1,30 @@ +# Secrets / config +.env + +# Data / artifacts +forecasts.db +*.db +*.sqlite +*.log + +# Optional standalone sibling clone of Kronos (when this folder is used outside +# the Kronos repo). Inside the repo the model code is the parent dir, not here. +Kronos/ + +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.ipynb_checkpoints/ +.pytest_cache/ + +# OS / editor +.DS_Store +Thumbs.db +.vscode/ +.idea/ + +# Throwaway deploy helper scripts + their captured output (see CLAUDE notes). +# deploy/_common.ps1 is a real shared helper, so keep it tracked. +deploy/_* +!deploy/_common.ps1 diff --git a/forecast_dashboard/Dockerfile b/forecast_dashboard/Dockerfile new file mode 100644 index 000000000..fcbdf09ac --- /dev/null +++ b/forecast_dashboard/Dockerfile @@ -0,0 +1,60 @@ +# syntax=docker/dockerfile:1 +# ============================================================================ +# Cloud Run image for the Kronos Forecasting Dashboard. +# +# Notes specific to Cloud Run: +# - Cloud Run injects $PORT; uvicorn must bind 0.0.0.0:$PORT. +# - The container filesystem is read-only except /tmp, so the SQLite DB and +# the HuggingFace cache are pointed at /tmp. +# - Kronos is cloned at BUILD time (not runtime) and model weights are +# pre-downloaded into the image so cold starts don't pay that cost. +# - CPU-only torch (Cloud Run general compute has no GPU). Forecasts are slow +# (~minutes); raise --timeout and concurrency accordingly when deploying. +# ============================================================================ +FROM python:3.11-slim + +ENV PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + HF_HOME=/opt/hf_cache \ + KRONOS_DIR=/app/Kronos \ + KRONOS_DEVICE=cpu \ + KRONOS_MODEL=NeoQuasar/Kronos-small \ + KRONOS_TOKENIZER=NeoQuasar/Kronos-Tokenizer-base \ + FORECAST_DB=/tmp/forecasts.db \ + KRONOS_PREWARM=0 + +WORKDIR /app + +# System deps: git to clone Kronos. Build tools only if a wheel needs compiling. +RUN apt-get update && apt-get install -y --no-install-recommends \ + git curl ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# --- Python deps ----------------------------------------------------------- +# CPU-only torch first (smaller, no CUDA), then the rest of the app deps. +COPY requirements.txt . +RUN pip install --index-url https://download.pytorch.org/whl/cpu torch \ + && pip install -r requirements.txt + +# --- Kronos model code (cloned at build time, baked into the image) -------- +RUN git clone --depth 1 https://github.com/shiyu-coder/Kronos.git /app/Kronos + +# --- App code -------------------------------------------------------------- +COPY . /app + +# --- Pre-download model + tokenizer weights into the image ----------------- +# So the first request after a cold start doesn't wait on a ~100MB HF download. +RUN python -c "import os,sys; sys.path.insert(0,'/app/Kronos'); \ +from model import Kronos, KronosTokenizer; \ +KronosTokenizer.from_pretrained(os.environ['KRONOS_TOKENIZER']); \ +Kronos.from_pretrained(os.environ['KRONOS_MODEL']); \ +print('weights cached into image OK')" + +# Cloud Run sets $PORT (default 8080). Bind it. +ENV PORT=8080 +EXPOSE 8080 + +# Single worker: the in-process forecast cache + per-key locks assume one +# process. Scale via Cloud Run instances, not workers. +CMD exec uvicorn server:app --host 0.0.0.0 --port ${PORT} --workers 1 diff --git a/forecast_dashboard/README.md b/forecast_dashboard/README.md new file mode 100644 index 000000000..d0ec01827 --- /dev/null +++ b/forecast_dashboard/README.md @@ -0,0 +1,285 @@ +# Kronos Forecasting Dashboard + +A small, honest toolkit for **forecasting** and **measuring forecast quality** on a +demo (paper) trading account. It wraps the [Kronos](https://github.com/shiyu-coder/Kronos) +pretrained price model and forecasts four instruments: **Silver, Gold, WTI crude, GBP/JPY**. + +> **The single most important thing:** this tool is honest about uncertainty. +> - The forecast is a **distribution** of sample paths (a p10–p90 band), never a lone "prediction". +> - The model sees **past daily price candles only**. It cannot see Fed/BoE/BoJ +> decisions, CPI prints, OPEC headlines, or risk events. News is shown as +> *"things the model did NOT see"*, never as a confidence input. +> - There is **no fabricated confidence score** anywhere. +> - Every forecast is **logged** and later **reconciled** against actuals so you can +> see, as a number, whether the band is calibrated (the point of the demo account). + +--- + +## What's in here + +``` +Kronos/forecast_dashboard/ +├── forecast_engine.py # CORE — all model + data logic (single source of truth) +├── server.py # PRIMARY UI — FastAPI JSON API + bespoke web frontend +├── web/ # hand-built dashboard (HTML/CSS/JS, ECharts vendored offline) +│ ├── index.html +│ ├── styles.css +│ ├── app.js +│ └── vendor/echarts.min.js +├── app.py # legacy Streamlit dashboard (thin client, fallback) +├── mcp_server.py # MCP server exposing the engine as tools (thin client) +├── notebook_sync.ipynb # experimentation notebook (thin client, zero dup logic) +├── instruments.py # the four instruments + their caveats +├── storage.py # forecast logging + accuracy tracking (SQLite) +├── ui_theme.py # minimalist black & white themes (Streamlit legacy) +├── news_context.py # "model did NOT see" headlines + text analysis bot +├── deploy/ # optional GCP VM + Cloud Run deploy kit (see deploy/README.md) +├── Dockerfile # Cloud Run container image +├── requirements.txt +├── .env.example +└── README.md +``` + +`forecast_engine.py` holds **100%** of the model/data logic. `server.py`, `app.py`, +`mcp_server.py`, and the notebook all import it. Change forecasting behaviour in one place. + +--- + +## Setup + +This dashboard ships **inside the Kronos repo** (at `Kronos/forecast_dashboard/`), +so the model package (`from model import ...`) is already importable from the +parent directory. No separate clone is needed. + +```bash +git clone https://github.com/shiyu-coder/Kronos.git +cd Kronos/forecast_dashboard +``` + +> The engine auto-discovers the model code: it uses `$KRONOS_DIR` if set, +> otherwise the parent Kronos repo root, otherwise a sibling `./Kronos` clone +> (so this folder also works if copied out and used standalone). + +### Install dependencies + +```bash +pip install -r requirements.txt +``` + +This installs the dashboard's stack (streamlit, yfinance, pandas, numpy, plotly, +matplotlib, mcp, python-dotenv) **and** Kronos's own deps (torch, einops, +huggingface_hub, safetensors, tqdm). + +> CPU-only torch is fine for the small/base models. For CPU wheels: +> `pip install torch --index-url https://download.pytorch.org/whl/cpu` + +### Configure + +```bash +cp .env.example .env # then edit +``` + +Key settings: `KRONOS_DIR`, `KRONOS_DEVICE` (`cpu` | `cuda:0` | `mps`), forecast +defaults, `FORECAST_DB`. The `ANTHROPIC_API_KEY` / `TAVILY_API_KEY` are **optional** +and only used for the *"context the model did not see"* news panel and an optional +LLM-phrased narrative — they never feed the forecast and never produce a score. + +### 4. First run downloads model weights + +The first forecast pulls the model + tokenizer from HuggingFace (~100 MB) into your +local HF cache (`~/.cache/huggingface`). Subsequent runs reuse the cache. + +--- + +## Run each piece + +### Smoke tests (do these first) + +```bash +python forecast_engine.py # loads model, forecasts all 4 instruments, prints bands +python storage.py # logs a fake forecast, reconciles, checks band hit-rate +``` + +`forecast_engine.py` should print sane bands for all four instruments, and GBP/JPY +should report it used the **no-volume path** (it has no reliable volume). + +### Dashboard (bespoke web UI — primary) + +```bash +python server.py +# open http://localhost:8000 +``` + +A hand-built, minimalist dashboard (FastAPI backend + vanilla HTML/CSS/JS with +Apache ECharts vendored locally, so it works fully offline on a GCP box). Pick an +instrument from the top tabs, tune horizon / lookback / sample count, click +**Run forecast**. **All four** shows a glance grid of every instrument. The theme +button (top-right) cycles Paper / Ink / Slate / E-ink live. **Refresh actuals** +reconciles past forecasts against realized closes. + +The fan chart makes the **p10–p90 band the most prominent element**, draws the +median **dashed** (so it never reads as "the answer"), and overlays faint +individual sample paths. The server **prewarms** all four forecasts in the +background on startup, so the first visitor doesn't wait on CPU inference +(disable with `KRONOS_PREWARM=0`). + +### Dashboard (legacy Streamlit — fallback) + +```bash +streamlit run app.py +# open http://localhost:8501 +``` + +### MCP server (ask Claude to forecast) + +```bash +python mcp_server.py # stdio transport +``` + +Register it with your Claude client. Example `claude_desktop_config.json` snippet +(adjust paths; on Windows use double backslashes): + +```json +{ + "mcpServers": { + "kronos-forecast": { + "command": "python", + "args": ["mcp_server.py"], + "cwd": "/path/to/Kronos/forecast_dashboard" + } + } +} +``` + +Tools exposed: `list_instruments`, `get_forecast`, `get_accuracy`, `refresh_actuals`. +Every response carries the caveats (band-not-point, model-did-not-see-news, the +per-instrument confidence note). + +### Notebook + +Open `notebook_sync.ipynb`. It imports `forecast_engine` and calls the same +functions the dashboard does — no duplicated model logic. + +--- + +## The four instruments + +| Display | Ticker (primary) | Fallback | Volume | Note | +|---|---|---|---|---| +| Silver (XAG/USD) | `SI=F` | `XAGUSD=X` | real (futures) | | +| Gold (XAU/USD) | `GC=F` | `XAUUSD=X` | real (futures) | | +| WTI Crude Oil | `CL=F` | — | real (futures) | **EVENT-SENSITIVE** (OPEC/inventory) | +| GBP/JPY | `GBPJPY=X` | — | **unreliable** | **LOW CONFIDENCE**, routes to no-volume path | + +The UI flags GBP/JPY as low-confidence and WTI as event-sensitive. This is by design. + +--- + +## Accuracy tracking (the point of the demo account) + +- Every forecast is logged to SQLite (`forecasts.db`) at generation time. +- `update_actuals()` fetches realized closes for past target dates and stores them + next to the predictions. +- `accuracy_summary(instrument)` reports the **headline metric — band hit-rate** + (a calibrated p10–p90 band should contain the actual ~80% of the time), plus + median absolute error and directional accuracy. + +After enough days, the dashboard's "Accuracy so far" panel tells you, as a number, +whether the model is calibrated on each instrument — not a vibe. + +--- + +## Running on GCP (the user's environment) + +Run the bespoke web dashboard bound to all interfaces: + +```bash +python server.py # honours HOST/PORT env vars +# or explicitly: +HOST=0.0.0.0 PORT=8000 python server.py +# or via uvicorn: +uvicorn server:app --host 0.0.0.0 --port 8000 +``` + +ECharts is vendored under `web/vendor/`, so the dashboard renders with **no +external CDN** — it works on a locked-down GCP box. On startup the server +prewarms all four forecasts in the background (CPU inference is slow on the first +hit; this hides that latency). + +**Reaching it (recommended: SSH port-forward — no firewall changes):** + +```bash +gcloud compute ssh YOUR_VM --zone YOUR_ZONE -- -L 8000:localhost:8000 +# then open http://localhost:8000 on your laptop +``` + +Alternatively you can open the firewall port `8000`, but that exposes the app to +the internet (there is no auth by default) — prefer the port-forward. + +> Legacy Streamlit fallback: `streamlit run app.py --server.port 8501 --server.address 0.0.0.0` + +### ⚠️ Cost guardrail (required reading) + +- **Stop the VM when you're not using it.** A *stopped* VM bills only for its disk + (~**$1–2/month**). An `e2-standard-2` left running 24/7 is roughly **~$50/month**. +- **Set a billing budget alert** in GCP (Billing → Budgets & alerts) so you get + emailed before a surprise bill. + +--- + +## Deploy to Cloud Run (stable public HTTPS, no VM/tunnel) + +`deploy/cloudrun.ps1` builds the container with Cloud Build and deploys it to +**Cloud Run** — a managed, autoscaling service with a stable HTTPS URL and no +firewall/tunnel to manage. The `Dockerfile` clones Kronos and bakes the model +weights into the image at build time, so cold starts don't pay the download cost. + +```powershell +powershell -ExecutionPolicy Bypass -File deploy\cloudrun.ps1 -Project YOUR_PROJECT +``` + +It enables the needed APIs, pre-creates the Artifact Registry repo, builds + +deploys, then **warms** each instrument once so the day's forecasts are cached. + +**Speed reality (important):** Cloud Run general compute is **CPU-only** (no GPU), +so a *fresh* forecast still takes **~4–5 min**. The mitigation is the in-memory +per-day cache: it's computed once (at warm time) and every later visit that same +day is **instant** — *as long as it lands on the warm instance*. Because the cache +is in-memory (and the SQLite DB lives in ephemeral `/tmp`), it is **per-instance**. +So the script pins the service to a **single always-warm instance** — +`-MinInstances 1 -MaxInstances 1` — otherwise Cloud Run can scale out and requests +round-robin to a fresh empty instance, half hitting the cache and half paying the +full ~4-min recompute. One instance at `--concurrency 4` still serves several +simultaneous viewers, and capping at 1 also hard-caps the bill. + +| flag | default | effect | +|------|---------|--------| +| `-MinInstances 1` | warm | keeps the day cache alive → instant repeat visits | +| `-MaxInstances 1` | pinned | never scale out → cache stays consistent + cost is capped | +| `-MinInstances 0` | — | $0 idle cost, but **every** visit after idle cold-starts + recomputes (~5 min) | +| `-Warm:$false` | warm on | skip the post-deploy cache warming | +| `-Cpu` / `-Memory` | `2` / `2Gi` | per-instance resources (Kronos barely uses >2 threads, so 2 vCPU ≈ 4 vCPU speed) | + +> Cost note (this billing account is **INR**): the always-warm instance is the +> dominant cost — **~$26/mo (~₹2200)** at 2 vCPU/2Gi, ~$50/mo at 4 vCPU/4Gi. +> Request compute on top is cents. With `-MinInstances 0` Cloud Run scales to +> zero ($0 idle) but every visit after idle is a slow cold recompute. Set a GCP +> budget alert (Billing → Budgets) so an always-on instance can't surprise you. + +--- + +## Design notes + +- **Minimalist black & white.** Monochrome by design; color is never used to imply + confidence. The p10–p90 band is the most visually prominent element on the chart. +- **Live theme switcher**: Paper (white), Ink (black), Slate, E-ink, High-contrast. +- **Text-based analysis ("bot")**: a deterministic plain-English read of the + distribution (bands, skew, realized vol, calibration). With `ANTHROPIC_API_KEY` + set, an optional LLM rephrases it — explicitly labelled unvalidated, never adding + a score or a buy/sell call. + +## Non-goals (deliberately not built) + +Intraday/session forecasting (we're on daily data), order execution / broker +integration, a single buy/sell signal or confidence-score headline, reimplementing +Kronos internals, and fine-tuning. diff --git a/forecast_dashboard/app.py b/forecast_dashboard/app.py new file mode 100644 index 000000000..54213d8ec --- /dev/null +++ b/forecast_dashboard/app.py @@ -0,0 +1,474 @@ +"""app.py — Kronos Forecasting Dashboard (Streamlit). + +A THIN client over forecast_engine + storage. No model/data logic lives here. + +Design (SCOPE.md §5): minimalist black & white, monochrome, data-dense but not +cluttered. Uncertainty (the p10-p90 band) is the most visually prominent element. +A live theme switcher offers several grayscale themes. Honesty guardrails are +visible everywhere: no fabricated confidence score, distribution not point, the +model's blind spot is always shown, and accuracy is tracked over time. + +Run: + streamlit run app.py + # GCP: streamlit run app.py --server.port 8501 --server.address 0.0.0.0 +""" + +from __future__ import annotations + +import os +from datetime import datetime, timezone + +import numpy as np +import pandas as pd +import plotly.graph_objects as go +import streamlit as st + +try: + from dotenv import load_dotenv + load_dotenv() +except Exception: # noqa: BLE001 + pass + +import forecast_engine as fe +import storage +import ui_theme +import news_context as nc +from instruments import all_instruments, get_instrument, INSTRUMENT_ORDER + +st.set_page_config( + page_title="Kronos Forecasting Dashboard", + page_icon="◼", + layout="wide", + initial_sidebar_state="expanded", +) + +# --------------------------------------------------------------------------- # +# Defaults from env +# --------------------------------------------------------------------------- # +DEF_PRED_LEN = int(os.environ.get("DEFAULT_PRED_LEN", "30")) +DEF_N_SAMPLES = int(os.environ.get("DEFAULT_N_SAMPLES", "30")) +DEF_LOOKBACK = int(os.environ.get("DEFAULT_LOOKBACK", "400")) +DEVICE = os.environ.get("KRONOS_DEVICE", "cpu") + + +# --------------------------------------------------------------------------- # +# Cached resources +# --------------------------------------------------------------------------- # +@st.cache_resource(show_spinner="Loading Kronos model (first run downloads weights)...") +def _get_predictor(): + return fe.load_predictor(device=DEVICE) + + +@st.cache_data(ttl=60 * 30, show_spinner=False) +def _cached_history(instrument_key: str, lookback_days: int) -> pd.DataFrame: + df = fe.fetch_ohlcv(instrument_key, lookback_days=lookback_days) + # carry attrs through the cache boundary + df = df.copy() + df["_ticker_used"] = df.attrs.get("ticker_used", "") + df["_volume_reliable"] = df.attrs.get("volume_is_reliable", False) + return df + + +def _run_and_log(instrument_key, lookback, pred_len, n_samples, T, top_p): + """Run a forecast (expensive) and persist it. Cached per param-set + day.""" + res = fe.run_forecast( + instrument_key, + lookback=lookback, + pred_len=pred_len, + n_samples=n_samples, + T=T, + top_p=top_p, + device=DEVICE, + predictor=_get_predictor(), + ) + try: + storage.log_forecast(res) + except Exception as exc: # noqa: BLE001 + st.warning(f"Could not log forecast: {exc}") + return res + + +@st.cache_data(ttl=60 * 60 * 6, show_spinner=False) +def _cached_forecast_key(instrument_key, lookback, pred_len, n_samples, T, top_p, day_token): + """Cache wrapper: a forecast is recomputed at most once per param-set per day. + + We cannot cache the ForecastResult (numpy/dataframe) trivially across reruns + with full fidelity, so we store it in session_state keyed by these args and + use this function only to gate recomputation via day_token. + """ + return _run_and_log(instrument_key, lookback, pred_len, n_samples, T, top_p) + + +def get_forecast(instrument_key, lookback, pred_len, n_samples, T, top_p, force=False): + day_token = datetime.now(timezone.utc).strftime("%Y-%m-%d") + if force: + _cached_forecast_key.clear() + return _cached_forecast_key(instrument_key, lookback, pred_len, n_samples, T, top_p, day_token) + + +# --------------------------------------------------------------------------- # +# Sidebar +# --------------------------------------------------------------------------- # +def sidebar(): + st.sidebar.markdown("## ◼ Kronos") + st.sidebar.caption("Forecast distribution & calibration tool. Demo account.") + + theme_key = st.sidebar.selectbox( + "Theme", + options=ui_theme.THEME_ORDER, + format_func=lambda k: ui_theme.get_theme(k).name, + index=ui_theme.THEME_ORDER.index(st.session_state.get("theme", ui_theme.DEFAULT_THEME)), + ) + st.session_state["theme"] = theme_key + + st.sidebar.divider() + view = st.sidebar.radio("View", ["Single instrument", "All four at a glance"], index=0) + + inst_keys = INSTRUMENT_ORDER + instrument_key = st.sidebar.selectbox( + "Instrument", + options=inst_keys, + format_func=lambda k: get_instrument(k).display_name, + index=0, + disabled=(view != "Single instrument"), + ) + + st.sidebar.divider() + st.sidebar.markdown("**Forecast settings**") + lookback = st.sidebar.slider("Lookback (days, <=512)", 100, 512, DEF_LOOKBACK, step=20) + pred_len = st.sidebar.slider("Forecast horizon (business days)", 5, 60, DEF_PRED_LEN, step=1) + n_samples = st.sidebar.slider("Sample paths (distribution width)", 10, 80, DEF_N_SAMPLES, step=5) + with st.sidebar.expander("Advanced sampling"): + T = st.slider("Temperature (T)", 0.5, 1.5, 1.0, step=0.05) + top_p = st.slider("top_p", 0.5, 1.0, 0.9, step=0.05) + + st.sidebar.divider() + run_clicked = st.sidebar.button("Run forecast", use_container_width=True, type="primary") + refresh_clicked = st.sidebar.button("Refresh actuals / accuracy", use_container_width=True) + + st.sidebar.divider() + st.sidebar.caption("Honesty mode: bands not points, no confidence score, " + "the model sees price only.") + + return dict(view=view, instrument_key=instrument_key, lookback=lookback, + pred_len=pred_len, n_samples=n_samples, T=T, top_p=top_p, + run_clicked=run_clicked, refresh_clicked=refresh_clicked) + + +# --------------------------------------------------------------------------- # +# Components +# --------------------------------------------------------------------------- # +def header_strip(res, theme): + inst = get_instrument(res.instrument_key) + badges = [] + if not res.has_reliable_volume: + badges.append(ui_theme.badge("LOW CONFIDENCE: no reliable volume", "warn")) + if res.event_sensitive: + badges.append(ui_theme.badge("EVENT-SENSITIVE: headlines not seen by model", "warn")) + if res.used_volume_path: + badges.append(ui_theme.badge("volume path", "line")) + else: + badges.append(ui_theme.badge("no-volume path", "soft")) + + st.markdown(f"## {res.display_name}") + c1, c2, c3, c4 = st.columns(4) + c1.metric("Last close", f"{res.last_close:,.4f}") + c2.metric("Ticker used", res.ticker_used) + c3.metric("History as of", str(res.last_history_date.date())) + c4.metric("Realized vol (ann.)", f"{res.realized_vol_annualized*100:,.1f}%") + st.markdown(" ".join(badges), unsafe_allow_html=True) + st.markdown( + f'
$1");
+ for (let raw of lines) {
+ const line = raw.trimEnd();
+ if (!line.trim()) { if (inList) { html += ""; inList = false; } continue; }
+ if (line.startsWith("### ")) { if (inList) { html += ""; inList = false; } html += `${inline(line.slice(2))}`; } + else if (line.startsWith("- ")) { if (!inList) { html += "
${inline(line)}
`; } + } + if (inList) html += ""; + return html; +} + +/* ---------------- Glance ---------------- */ +async function loadGlance() { + showOverlay("Loading all four…"); + try { + const data = await api(`/api/glance`); + const grid = $("#glanceGrid"); + grid.innerHTML = ""; + data.cards.forEach((c) => { + const div = document.createElement("div"); + div.className = "gcard"; + if (c.error) { + div.innerHTML = `—
+=a)}}for(var h=this.__startIndex;h n?a:o,h=Math.abs(l.label.y-n);if(h>=u.maxY){var c=l.label.x-e-l.len2*r,p=i+l.len,f=Math.abs(c) t.unconstrainedWidth?null:d:null;i.setStyle("width",f)}var g=i.getBoundingRect();o.width=g.width;var y=(i.style.margin||0)+2.1;o.height=g.height+y,o.y-=(o.height-c)/2}}}function kM(t){return"center"===t.position}function LM(t){var e,n,i=t.getData(),r=[],o=!1,a=(t.get("minShowLabelAngle")||0)*CM,s=i.getLayout("viewRect"),l=i.getLayout("r"),u=s.width,h=s.x,c=s.y,p=s.height;function d(t){t.ignore=!0}i.each((function(t){var s=i.getItemGraphicEl(t),c=s.shape,p=s.getTextContent(),f=s.getTextGuideLine(),g=i.getItemModel(t),y=g.getModel("label"),v=y.get("position")||g.get(["emphasis","label","position"]),m=y.get("distanceToLabelLine"),x=y.get("alignTo"),_=$r(y.get("edgeDistance"),u),b=y.get("bleedMargin"),w=g.getModel("labelLine"),S=w.get("length");S=$r(S,u);var M=w.get("length2");if(M=$r(M,u),Math.abs(c.endAngle-c.startAngle)0?"right":"left":k>0?"left":"right"}var B=Math.PI,F=0,G=y.get("rotate");if(j(G))F=G*(B/180);else if("center"===v)F=0;else if("radial"===G||!0===G){F=k<0?-A+B:-A}else if("tangential"===G&&"outside"!==v&&"outer"!==v){var W=Math.atan2(k,L);W<0&&(W=2*B+W),L>0&&(W=B+W),F=W-B}if(o=!!F,p.x=I,p.y=T,p.rotation=F,p.setStyle({verticalAlign:"middle"}),P){p.setStyle({align:D});var H=p.states.select;H&&(H.x+=p.x,H.y+=p.y)}else{var Y=p.getBoundingRect().clone();Y.applyTransform(p.getComputedTransform());var X=(p.style.margin||0)+2.1;Y.y-=X/2,Y.height+=X,r.push({label:p,labelLine:f,position:v,len:S,len2:M,minTurnAngle:w.get("minTurnAngle"),maxSurfaceAngle:w.get("maxSurfaceAngle"),surfaceNormal:new De(k,L),linePoints:C,textAlign:D,labelDistance:m,labelAlignTo:x,edgeDistance:_,bleedMargin:b,rect:Y,unconstrainedWidth:Y.width,labelStyleWidth:p.style.width})}s.setTextConfig({inside:P})}})),!o&&t.get("avoidLabelOverlap")&&function(t,e,n,i,r,o,a,s){for(var l=[],u=[],h=Number.MAX_VALUE,c=-Number.MAX_VALUE,p=0;p i&&(i=e);var o=i%2?i+2:i+3;r=[];for(var a=0;a5)return;var i=this._model.coordinateSystem.getSlidedAxisExpandWindow([t.offsetX,t.offsetY]);"none"!==i.behavior&&this._dispatchExpand({axisExpandWindow:i.axisExpandWindow})}this._mouseDownPoint=null},mousemove:function(t){if(!this._mouseDownPoint&&Rk(this,"mousemove")){var e=this._model,n=e.coordinateSystem.getSlidedAxisExpandWindow([t.offsetX,t.offsetY]),i=n.behavior;"jump"===i&&this._throttledDispatchExpand.debounceNextCall(e.get("axisExpandDebounce")),this._throttledDispatchExpand("none"===i?null:{axisExpandWindow:n.axisExpandWindow,animation:"jump"===i?null:{duration:0}})}}};function Rk(t,e){var n=t._model;return n.get("axisExpandable")&&n.get("axisExpandTriggerOn")===e}var Nk=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.init=function(){t.prototype.init.apply(this,arguments),this.mergeOption({})},e.prototype.mergeOption=function(t){var e=this.option;t&&C(e,t,!0),this._initDimensions()},e.prototype.contains=function(t,e){var n=t.get("parallelIndex");return null!=n&&e.getComponent("parallel",n)===this},e.prototype.setAxisExpand=function(t){E(["axisExpandable","axisExpandCenter","axisExpandCount","axisExpandWidth","axisExpandWindow"],(function(e){t.hasOwnProperty(e)&&(this.option[e]=t[e])}),this)},e.prototype._initDimensions=function(){var t=this.dimensions=[],e=this.parallelAxisIndex=[];E(B(this.ecModel.queryComponents({mainType:"parallelAxis"}),(function(t){return(t.get("parallelIndex")||0)===this.componentIndex}),this),(function(n){t.push("dim"+n.get("dim")),e.push(n.componentIndex)}))},e.type="parallel",e.dependencies=["parallelAxis"],e.layoutMode="box",e.defaultOption={z:0,left:80,top:60,right:80,bottom:60,layout:"horizontal",axisExpandable:!1,axisExpandCenter:null,axisExpandCount:0,axisExpandWidth:50,axisExpandRate:17,axisExpandDebounce:50,axisExpandSlideTriggerArea:[-.15,.05,.4],axisExpandTriggerOn:"click",parallelAxisDefault:null},e}(zp),Ek=function(t){function e(e,n,i,r,o){var a=t.call(this,e,n,i)||this;return a.type=r||"value",a.axisIndex=o,a}return n(e,t),e.prototype.isHorizontal=function(){return"horizontal"!==this.coordinateSystem.getModel().get("layout")},e}(ab);function zk(t,e,n,i,r,o){t=t||0;var a=n[1]-n[0];if(null!=r&&(r=Bk(r,[0,a])),null!=o&&(o=Math.max(o,null!=r?r:0)),"all"===i){var s=Math.abs(e[1]-e[0]);s=Bk(s,[0,a]),r=o=Bk(s,[r,o]),i=0}e[0]=Bk(e[0],n),e[1]=Bk(e[1],n);var l=Vk(e,i);e[i]+=t;var u,h=r||0,c=n.slice();return l.sign<0?c[0]+=h:c[1]-=h,e[i]=Bk(e[i],c),u=Vk(e,i),null!=r&&(u.sign!==l.sign||u.span