Cemez83 commited on
Commit
bfef05d
·
1 Parent(s): 3c6be8e

Deploy forecast-service to Hugging Face Space

Browse files
Files changed (13) hide show
  1. .dockerignore +9 -0
  2. .gitignore +6 -0
  3. DEPLOY.md +51 -0
  4. Dockerfile +29 -0
  5. README.md +125 -6
  6. calibration.py +200 -0
  7. ensemble.py +176 -0
  8. main.py +213 -0
  9. models.py +260 -0
  10. pipeline.py +599 -0
  11. pipeline_api.py +161 -0
  12. requirements-lite.txt +5 -0
  13. requirements.txt +15 -0
.dockerignore ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ venv/
5
+ .hf_cache/
6
+ db.sqlite
7
+ *.log
8
+ .git/
9
+ .env
.gitignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ # forecast-service local artifacts
2
+ __pycache__/
3
+ *.pyc
4
+ db.sqlite
5
+ .venv/
6
+ venv/
DEPLOY.md ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Deploying the forecast-service
2
+
3
+ The Next.js app calls this service via `FORECAST_URL`. It can't run on Vercel (it loads ~2GB of
4
+ torch models), so host the container somewhere always-on and point `FORECAST_URL` at it.
5
+
6
+ The repo has a `Dockerfile` that builds + runs it (binds `0.0.0.0`, honours the platform `$PORT`,
7
+ falls back to `8008`). First boot downloads model weights (~2GB) — slow once, then cached.
8
+
9
+ ## Option A — Hugging Face Spaces (free CPU, recommended)
10
+ 1. Create a new **Space** → SDK: **Docker** → blank.
11
+ 2. Push the contents of `forecast-service/` to the Space repo (the `Dockerfile` is at its root).
12
+ 3. In the Space **README.md** frontmatter, set the port:
13
+ ```yaml
14
+ ---
15
+ title: FutureQuery forecast-service
16
+ sdk: docker
17
+ app_port: 8008
18
+ ---
19
+ ```
20
+ 4. The Space builds + boots (first boot is slow while it downloads models). Its URL is
21
+ `https://<user>-<space>.hf.space`.
22
+ 5. (Optional) add `OPENROUTER_API_KEY` as a Space **Secret** if you want the LangGraph synthesis.
23
+
24
+ ## Option B — Render / Railway
25
+ 1. New **Web Service** → connect this repo → root directory `forecast-service` → runtime **Docker**.
26
+ 2. They inject `$PORT` automatically (the Dockerfile uses it). No port config needed.
27
+ 3. Render free tier **sleeps when idle** → the first request after idle is slow (cold model load).
28
+ Bump to a paid instance (~$7/mo) to keep it warm.
29
+
30
+ ## Option C — Fly.io / a small VM
31
+ - `fly launch` in `forecast-service/` (it detects the Dockerfile), or run the image on any VM with
32
+ ≥4GB RAM: `docker build -t forecast . && docker run -p 8008:8008 forecast`.
33
+
34
+ ## Wire it to the app (all hosts)
35
+ 1. Verify it's up: `curl https://<your-host>/health` → shows which models loaded.
36
+ 2. In **Vercel → futurequery-vercel → Settings → Environment Variables**, add
37
+ `FORECAST_URL=https://<your-host>` for **Production + Preview + Development**, then **redeploy**.
38
+ 3. The `/pipeline` page becomes usable. In `/api/predict`, the **Momentum (TimesFM 2.5 /
39
+ Chronos-2)** row can turn green only when an exact/likely Polymarket CLOB market has enough
40
+ price history and the service returns `use_forecast: true`; it remains a supporting signal and
41
+ never moves the headline probability.
42
+ 4. (Optional) for the separate `edge-finder/` PaperBrain crons, wire `FORECAST_URL` into the
43
+ workflow `env` before relying on cron momentum; the bundled workflows currently do not pass it,
44
+ so adding a secret alone does not activate that signal.
45
+
46
+ ## Notes
47
+ - CPU inference is fine for demo volume (a few seconds/call); a GPU host (Modal/Replicate) is only
48
+ needed for heavy traffic.
49
+ - The service degrades gracefully: if a model fails to load it still starts and returns a naive
50
+ forecast, so a misconfigured host won't hard-fail the web app (Momentum stays empty/warn with
51
+ clear copy).
Dockerfile ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # forecast-service — TimesFM 2.x + Chronos-2 ensemble API.
2
+ # Builds a container any host can run (Hugging Face Spaces / Render / Railway / Fly / a VM),
3
+ # so the Vercel app can reach it via FORECAST_URL.
4
+ #
5
+ # First boot downloads ~2GB of model weights from HuggingFace into HF_HOME (expected, once).
6
+ # Binds 0.0.0.0 (FORECAST_HOST) so the host can route to it; honours the host's $PORT
7
+ # (Render/Railway/Fly inject one) and otherwise serves on 8008.
8
+ FROM python:3.12-slim
9
+
10
+ # build-essential for any source-built wheels; libgomp1 for torch; git in case a dep needs it.
11
+ RUN apt-get update && apt-get install -y --no-install-recommends \
12
+ build-essential git libgomp1 \
13
+ && rm -rf /var/lib/apt/lists/*
14
+
15
+ WORKDIR /app
16
+
17
+ # Install deps first so Docker caches this layer across code changes.
18
+ COPY requirements.txt .
19
+ RUN pip install --no-cache-dir -r requirements.txt
20
+
21
+ COPY . .
22
+
23
+ ENV FORECAST_HOST=0.0.0.0 \
24
+ HF_HOME=/app/.hf_cache \
25
+ PYTHONUNBUFFERED=1
26
+
27
+ EXPOSE 8008
28
+ # ${PORT:-8008}: use the platform-injected port if present, else 8008.
29
+ CMD ["sh", "-c", "FORECAST_PORT=${PORT:-8008} python main.py"]
README.md CHANGED
@@ -1,10 +1,129 @@
1
  ---
2
- title: Futurequery Forecast Service
3
- emoji: 🌖
4
- colorFrom: gray
5
- colorTo: red
6
  sdk: docker
7
- pinned: false
8
  ---
 
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: FutureQuery forecast-service
 
 
 
3
  sdk: docker
4
+ app_port: 8008
5
  ---
6
+ # FutureQuery `forecast-service`
7
 
8
+ An ensemble time-series forecasting service for prediction-market probabilities.
9
+ It blends **Google TimesFM 2.x** and **Amazon Chronos-2**, applies
10
+ superforecasting-style calibration, tracks its own Brier score over time, and
11
+ exposes a small FastAPI surface plus a LangGraph research pipeline.
12
+
13
+ > Replaces the old single-model `timesfm-service/`.
14
+
15
+ ## Quick start
16
+
17
+ ```bash
18
+ cd forecast-service
19
+ pip install -r requirements.txt
20
+ python main.py # starts on http://localhost:8008
21
+ ```
22
+
23
+ The first run downloads model weights from HuggingFace (**~2GB** — this is
24
+ normal and only happens once). If a model fails to load (offline, no GPU, OOM)
25
+ the service **still starts** and falls back to a naive forecast, so the API
26
+ never hard-fails.
27
+
28
+ ## API
29
+
30
+ ### `POST /forecast`
31
+
32
+ ```jsonc
33
+ // request
34
+ {
35
+ "prices": [0.48, 0.50, 0.51, 0.52, ...], // historical yes_prices
36
+ "covariates": [[...], ...], // optional related series
37
+ "question_type": "event", // "event" | "numeric"
38
+ "horizon": 5,
39
+ "question": "Will X happen by 2027?" // optional, stored for calibration
40
+ }
41
+ ```
42
+
43
+ ```jsonc
44
+ // response
45
+ {
46
+ "timesfm": { "forecast": [...], "trend": "up" },
47
+ "chronos2": { "forecast": [...], "trend": "up" },
48
+ "ensemble": { "forecast": [...], "trend": "up", "brier_estimate": 0.21 },
49
+ "use_forecast": true, // false if event w/ no price history, or series < 10 pts
50
+ "warning": null, // e.g. "Series too short for reliable forecast"
51
+ "id": "…" // calibration row id
52
+ }
53
+ ```
54
+
55
+ **Ensemble logic** (`ensemble.py`)
56
+
57
+ | Step | Rule |
58
+ |------|------|
59
+ | Length guard | `< 10` points → `use_forecast=false` + warning |
60
+ | Event blend | `0.5·TimesFM + 0.5·Chronos-2` (equal weight — both models show negative R² on binary event markets, so neither dominates) |
61
+ | Numeric blend | `0.4·TimesFM + 0.6·Chronos-2` (Chronos-2 wins on fev-bench) |
62
+ | Base-rate prior | if `|last − 0.5| ≤ 0.1`, pull ensemble **15% toward 0.5** |
63
+ | Extremise | only if confidence `> 0.70`: `0.5 + 1.3·(p − 0.5)` |
64
+
65
+ ### `GET /calibration`
66
+
67
+ Returns the realised **Brier score**, a **reliability curve** (decile bins of
68
+ predicted vs. observed frequency), and **directional accuracy** over all
69
+ resolved forecasts — so you can benchmark against superforecasters (~0.15
70
+ Brier) over time. Data is stored in `db.sqlite`.
71
+
72
+ ### `POST /resolve`
73
+
74
+ ```jsonc
75
+ { "id": "<forecast id>", "outcome": 1.0 } // 1.0 = YES happened, 0.0 = NO
76
+ ```
77
+
78
+ Records a market's actual outcome so it feeds the calibration scoring.
79
+
80
+ ### `GET /health`
81
+
82
+ Liveness + which models actually loaded.
83
+
84
+ ## LangGraph pipeline (Phase 3)
85
+
86
+ `pipeline.py` runs the full **SCAN → TRIAGE → SYNTHESISE** loop across
87
+ Polymarket, Kalshi, Metaculus, and Manifold, calls `/forecast`, and pauses at a
88
+ human checkpoint before writing the result to `db.sqlite`.
89
+
90
+ ```bash
91
+ python pipeline.py "Will UK interest rates fall below 4% by Jan 2027?"
92
+ ```
93
+
94
+ At the checkpoint, press **Enter** to accept or type **`override 0.42`** to set
95
+ your own probability.
96
+
97
+ ## Files
98
+
99
+ | File | Purpose |
100
+ |------|---------|
101
+ | `main.py` | FastAPI app, startup model loading, endpoints |
102
+ | `models.py` | TimesFM / Chronos-2 wrappers with graceful fallback |
103
+ | `ensemble.py` | Blending, base-rate prior, extremisation (numpy-only, testable) |
104
+ | `calibration.py` | SQLite store + Brier / reliability / directional scoring |
105
+ | `pipeline.py` | LangGraph SCAN→TRIAGE→SYNTHESISE workflow |
106
+ | `requirements.txt` | Python dependencies |
107
+
108
+ ## Notes
109
+
110
+ * `FORECAST_PORT` overrides the default port (`8008`).
111
+ * The Next.js app calls `/forecast` only when `FORECAST_URL` is set and an exact/likely
112
+ Polymarket market with a CLOB token was selected.
113
+ * Returned ensembles are supporting Momentum signals only; they do not blend into the headline
114
+ probability, and the app degrades gracefully when the service is offline.
115
+
116
+ ## Browser pipeline (no terminal)
117
+
118
+ The same SCAN → TRIAGE → ENSEMBLE → SYNTHESISE → CHECKPOINT loop is exposed over
119
+ HTTP so it can be driven entirely from the web app:
120
+
121
+ - `POST /pipeline/start` — `{ "question": "...", "question_type": "event" }` runs
122
+ to the human checkpoint and returns the full state plus a `thread_id`.
123
+ - `POST /pipeline/resume` — `{ "thread_id": "...", "resume": "" }` to accept, or
124
+ `{ "resume": "override 0.42" }` to override.
125
+
126
+ In the Next.js app open **`/pipeline`** (e.g. http://localhost:3000/pipeline):
127
+ type a question, watch the scan / triage / arbitrage / ensemble panels populate,
128
+ then **Approve** or **Override** the synthesised probability. Requires
129
+ `FORECAST_URL` to be set so the app can reach this service.
calibration.py ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Calibration store + scoring for the FutureQuery forecaster.
2
+
3
+ Every ``/forecast`` call is logged to a local SQLite database
4
+ (``forecast-service/db.sqlite``). When a market resolves, call
5
+ ``record_resolution`` (or POST ``/resolve``) with the binary outcome. The
6
+ ``/calibration`` endpoint then reports, over all resolved forecasts:
7
+
8
+ * **Brier score** — mean squared error of the probabilities.
9
+ * **reliability curve** — predicted vs. observed frequency per decile bin
10
+ (the data for a calibration / reliability diagram).
11
+ * **directional accuracy** — how often ``p >= 0.5`` agreed with the outcome.
12
+
13
+ This is what lets us benchmark the ensemble against superforecaster Brier
14
+ scores over time. Only ``numpy``/``sqlite3`` are required; ``properscoring``
15
+ and ``scikit-learn`` are used when present but are not hard dependencies.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ import sqlite3
22
+ import time
23
+ import uuid
24
+ from typing import Dict, List, Optional
25
+
26
+ import numpy as np
27
+
28
+ DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "db.sqlite")
29
+
30
+ _SCHEMA = """
31
+ CREATE TABLE IF NOT EXISTS forecasts (
32
+ id TEXT PRIMARY KEY,
33
+ created_at REAL NOT NULL,
34
+ question TEXT,
35
+ question_type TEXT,
36
+ horizon INTEGER,
37
+ p_forecast REAL, -- headline ensemble probability
38
+ p_market REAL, -- market price at forecast time (prices[-1])
39
+ trend TEXT,
40
+ brier_estimate REAL,
41
+ use_forecast INTEGER,
42
+ resolved INTEGER DEFAULT 0,
43
+ outcome REAL, -- 1.0 = YES happened, 0.0 = NO
44
+ resolved_at REAL
45
+ );
46
+ """
47
+
48
+
49
+ def _connect(db_path: Optional[str] = None) -> sqlite3.Connection:
50
+ conn = sqlite3.connect(db_path or DB_PATH)
51
+ conn.row_factory = sqlite3.Row
52
+ return conn
53
+
54
+
55
+ def init_db(db_path: Optional[str] = None) -> None:
56
+ with _connect(db_path) as conn:
57
+ conn.executescript(_SCHEMA)
58
+ conn.commit()
59
+
60
+
61
+ def record_forecast(
62
+ *,
63
+ question: str,
64
+ question_type: str,
65
+ horizon: int,
66
+ p_forecast: Optional[float],
67
+ p_market: Optional[float],
68
+ trend: Optional[str],
69
+ brier_estimate: Optional[float],
70
+ use_forecast: bool,
71
+ db_path: Optional[str] = None,
72
+ ) -> str:
73
+ """Persist a forecast (pending resolution). Returns the new row id."""
74
+ init_db(db_path)
75
+ fid = uuid.uuid4().hex
76
+ with _connect(db_path) as conn:
77
+ conn.execute(
78
+ """
79
+ INSERT INTO forecasts (id, created_at, question, question_type,
80
+ horizon, p_forecast, p_market, trend, brier_estimate,
81
+ use_forecast, resolved)
82
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
83
+ """,
84
+ (
85
+ fid,
86
+ time.time(),
87
+ question,
88
+ question_type,
89
+ int(horizon),
90
+ _nullable_float(p_forecast),
91
+ _nullable_float(p_market),
92
+ trend,
93
+ _nullable_float(brier_estimate),
94
+ 1 if use_forecast else 0,
95
+ ),
96
+ )
97
+ conn.commit()
98
+ return fid
99
+
100
+
101
+ def record_resolution(
102
+ forecast_id: str, outcome: float, db_path: Optional[str] = None
103
+ ) -> bool:
104
+ """Record the realised binary outcome (1.0 YES / 0.0 NO). Returns found?"""
105
+ init_db(db_path)
106
+ with _connect(db_path) as conn:
107
+ cur = conn.execute(
108
+ "UPDATE forecasts SET resolved = 1, outcome = ?, resolved_at = ? "
109
+ "WHERE id = ?",
110
+ (float(outcome), time.time(), forecast_id),
111
+ )
112
+ conn.commit()
113
+ return cur.rowcount > 0
114
+
115
+
116
+ def compute_calibration(db_path: Optional[str] = None) -> Dict:
117
+ """Aggregate Brier score, reliability curve and directional accuracy."""
118
+ init_db(db_path)
119
+ with _connect(db_path) as conn:
120
+ rows = conn.execute(
121
+ "SELECT p_forecast, outcome FROM forecasts "
122
+ "WHERE resolved = 1 AND p_forecast IS NOT NULL AND outcome IS NOT NULL"
123
+ ).fetchall()
124
+ n_total = conn.execute("SELECT COUNT(*) AS c FROM forecasts").fetchone()["c"]
125
+
126
+ n_resolved = len(rows)
127
+ if n_resolved == 0:
128
+ return {
129
+ "brier_score": None,
130
+ "directional_accuracy": None,
131
+ "reliability_curve": [],
132
+ "n_resolved": 0,
133
+ "n_total": int(n_total),
134
+ "note": "No resolved forecasts yet — record outcomes via POST /resolve.",
135
+ }
136
+
137
+ preds = np.array([float(r["p_forecast"]) for r in rows], dtype=float)
138
+ outcomes = np.array([float(r["outcome"]) for r in rows], dtype=float)
139
+ preds = np.clip(preds, 0.0, 1.0)
140
+ outcomes = np.clip(outcomes, 0.0, 1.0)
141
+
142
+ brier = _brier_score(outcomes, preds)
143
+ directional = float(np.mean((preds >= 0.5) == (outcomes >= 0.5)))
144
+ reliability = _reliability_curve(preds, outcomes)
145
+
146
+ return {
147
+ "brier_score": round(float(brier), 4),
148
+ "directional_accuracy": round(directional, 4),
149
+ "reliability_curve": reliability,
150
+ "n_resolved": int(n_resolved),
151
+ "n_total": int(n_total),
152
+ # Tetlock's superforecasters land near ~0.14–0.18 on hard questions.
153
+ "benchmark": {"superforecaster_brier": 0.15, "always_0.5_brier": 0.25},
154
+ }
155
+
156
+
157
+ # --- internals -----------------------------------------------------------
158
+
159
+ def _brier_score(outcomes: np.ndarray, preds: np.ndarray) -> float:
160
+ try:
161
+ import properscoring as ps # type: ignore
162
+
163
+ return float(np.mean(ps.brier_score(outcomes, preds)))
164
+ except Exception:
165
+ return float(np.mean((preds - outcomes) ** 2))
166
+
167
+
168
+ def _reliability_curve(preds: np.ndarray, outcomes: np.ndarray) -> List[Dict]:
169
+ """Decile reliability bins (predicted vs. observed frequency)."""
170
+ edges = np.linspace(0.0, 1.0, 11)
171
+ curve: List[Dict] = []
172
+ for lo, hi in zip(edges[:-1], edges[1:]):
173
+ # include the right edge in the final bin
174
+ if hi >= 1.0:
175
+ mask = (preds >= lo) & (preds <= hi)
176
+ else:
177
+ mask = (preds >= lo) & (preds < hi)
178
+ count = int(np.sum(mask))
179
+ if count == 0:
180
+ continue
181
+ curve.append(
182
+ {
183
+ "bin_lower": round(float(lo), 2),
184
+ "bin_upper": round(float(hi), 2),
185
+ "predicted": round(float(np.mean(preds[mask])), 4),
186
+ "observed": round(float(np.mean(outcomes[mask])), 4),
187
+ "count": count,
188
+ }
189
+ )
190
+ return curve
191
+
192
+
193
+ def _nullable_float(value: Optional[float]) -> Optional[float]:
194
+ if value is None:
195
+ return None
196
+ try:
197
+ f = float(value)
198
+ except (TypeError, ValueError):
199
+ return None
200
+ return f if np.isfinite(f) else None
ensemble.py ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ensemble blending + calibration nudges for the FutureQuery forecaster.
2
+
3
+ This module is deliberately dependency-light (numpy only) so the blending
4
+ rules can be unit-tested without loading TimesFM / Chronos. ``main.py`` runs
5
+ the two models and hands their raw forecast arrays to :func:`full_response`;
6
+ the short-series / no-history guards live in :func:`short_series_response`.
7
+
8
+ Rules (per the Nov-2025 research notes baked into the task):
9
+
10
+ * < 10 points -> use_forecast = False, warning set.
11
+ * question_type == "event" -> ensemble = 0.5*TimesFM + 0.5*Chronos-2
12
+ (equal weight: the models showed negative R^2 on binary event markets, so
13
+ we do not let either dominate).
14
+ * question_type == "numeric" -> Chronos-2 0.6 / TimesFM 0.4 (Chronos-2
15
+ beats TimesFM on fev-bench for numeric trajectories).
16
+ * base-rate prior -> if the latest price is within 0.1 of 0.5,
17
+ pull the ensemble 15% back toward 0.5 (regression to the mean).
18
+ * extremise (events only) -> if confidence > 0.70, sharpen with the
19
+ standard superforecasting transform 0.5 + 1.3*(p - 0.5).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import Dict, List, Optional, Sequence
25
+
26
+ import numpy as np
27
+
28
+ # --- weights & thresholds (single source of truth) -----------------------
29
+ MIN_POINTS = 10
30
+ EVENT_WEIGHTS = (0.5, 0.5) # (timesfm, chronos2)
31
+ NUMERIC_WEIGHTS = (0.4, 0.6) # (timesfm, chronos2) -> Chronos-2 favoured
32
+ BASE_RATE_BAND = 0.1 # |last - 0.5| <= 0.1 triggers the prior
33
+ BASE_RATE_PULL = 0.15 # move 15% toward 0.5
34
+ EXTREMISE_GATE = 0.70 # only extremise confident forecasts
35
+ EXTREMISE_K = 1.3 # superforecasting extremisation factor
36
+ TREND_EPS = 0.02 # 2 percentage points = flat band for events
37
+
38
+
39
+ def _clamp01(arr: np.ndarray) -> np.ndarray:
40
+ return np.clip(arr, 0.0, 1.0)
41
+
42
+
43
+ def _trend(forecast: np.ndarray, reference: float, numeric: bool) -> str:
44
+ """up / down / flat relative to the last observed value."""
45
+ if forecast.size == 0:
46
+ return "flat"
47
+ delta = float(forecast[-1]) - float(reference)
48
+ eps = max(TREND_EPS * abs(reference), 1e-9) if numeric else TREND_EPS
49
+ if delta > eps:
50
+ return "up"
51
+ if delta < -eps:
52
+ return "down"
53
+ return "flat"
54
+
55
+
56
+ def _persistence(prices: np.ndarray, horizon: int, event: bool) -> np.ndarray:
57
+ """Flat continuation used when we cannot really forecast."""
58
+ if prices.size:
59
+ return np.full(int(horizon), float(prices[-1]), dtype=float)
60
+ return np.full(int(horizon), 0.5 if event else 0.0, dtype=float)
61
+
62
+
63
+ def _block(forecast: np.ndarray, reference: float, numeric: bool) -> Dict:
64
+ return {
65
+ "forecast": [round(float(x), 4) for x in forecast],
66
+ "trend": _trend(forecast, reference, numeric),
67
+ }
68
+
69
+
70
+ def blend(
71
+ prices: Sequence[float],
72
+ timesfm_fc: Sequence[float],
73
+ chronos_fc: Sequence[float],
74
+ question_type: str,
75
+ horizon: int,
76
+ ) -> Dict:
77
+ """Blend two model forecasts into the ensemble (the heart of Phase 1)."""
78
+ price_arr = np.asarray(prices, dtype=float)
79
+ last = float(price_arr[-1]) if price_arr.size else 0.5
80
+ event = question_type == "event"
81
+ numeric = not event
82
+
83
+ tf = np.asarray(timesfm_fc, dtype=float)
84
+ ch = np.asarray(chronos_fc, dtype=float)
85
+ n = min(tf.size, ch.size, int(horizon))
86
+ if n == 0:
87
+ n = int(horizon)
88
+ tf = tf[:n]
89
+ ch = ch[:n]
90
+
91
+ if event:
92
+ tf = _clamp01(tf)
93
+ ch = _clamp01(ch)
94
+ w_tf, w_ch = EVENT_WEIGHTS
95
+ else:
96
+ w_tf, w_ch = NUMERIC_WEIGHTS
97
+
98
+ ensemble = w_tf * tf + w_ch * ch
99
+
100
+ if event:
101
+ # 1) base-rate prior: regress near-coin-flip forecasts toward 0.5
102
+ if abs(last - 0.5) <= BASE_RATE_BAND:
103
+ ensemble = ensemble + BASE_RATE_PULL * (0.5 - ensemble)
104
+ # 2) extremise only confident forecasts
105
+ headline = float(np.mean(ensemble))
106
+ confidence = max(headline, 1.0 - headline)
107
+ if confidence > EXTREMISE_GATE:
108
+ ensemble = 0.5 + EXTREMISE_K * (ensemble - 0.5)
109
+ ensemble = _clamp01(ensemble)
110
+
111
+ brier = _brier_estimate(ensemble) if event else None
112
+
113
+ return {
114
+ "timesfm": _block(tf, last, numeric),
115
+ "chronos2": _block(ch, last, numeric),
116
+ "ensemble": {
117
+ **_block(ensemble, last, numeric),
118
+ "brier_estimate": brier,
119
+ },
120
+ }
121
+
122
+
123
+ def _brier_estimate(ensemble: np.ndarray) -> Optional[float]:
124
+ """Expected Brier score if the outcome were Bernoulli(p): p*(1-p).
125
+
126
+ This is a self-consistency proxy reported at forecast time (we don't yet
127
+ know the resolution). It peaks at 0.25 for p=0.5 and shrinks as the
128
+ forecast gets confident. The realised Brier score is tracked separately in
129
+ calibration.py once markets resolve.
130
+ """
131
+ if ensemble.size == 0:
132
+ return None
133
+ p = float(np.clip(ensemble[-1], 0.0, 1.0))
134
+ return round(p * (1.0 - p), 2)
135
+
136
+
137
+ def short_series_response(
138
+ prices: Sequence[float],
139
+ horizon: int,
140
+ question_type: str,
141
+ warning: str,
142
+ ) -> Dict:
143
+ """Schema-valid response when we refuse to forecast (too short / no data)."""
144
+ event = question_type == "event"
145
+ price_arr = np.asarray(prices, dtype=float)
146
+ flat = _persistence(price_arr, horizon, event)
147
+ last = float(price_arr[-1]) if price_arr.size else (0.5 if event else 0.0)
148
+ block = _block(flat, last, not event)
149
+ return {
150
+ "timesfm": dict(block),
151
+ "chronos2": dict(block),
152
+ "ensemble": {**block, "brier_estimate": None},
153
+ "use_forecast": False,
154
+ "warning": warning,
155
+ }
156
+
157
+
158
+ def full_response(
159
+ prices: Sequence[float],
160
+ timesfm_fc: Sequence[float],
161
+ chronos_fc: Sequence[float],
162
+ question_type: str,
163
+ horizon: int,
164
+ ) -> Dict:
165
+ payload = blend(prices, timesfm_fc, chronos_fc, question_type, horizon)
166
+ payload["use_forecast"] = True
167
+ payload["warning"] = None
168
+ return payload
169
+
170
+
171
+ def headline_probability(payload: Dict) -> Optional[float]:
172
+ """Representative probability of an ensemble payload (last horizon point)."""
173
+ forecast: List[float] = payload.get("ensemble", {}).get("forecast", [])
174
+ if not forecast:
175
+ return None
176
+ return float(np.clip(forecast[-1], 0.0, 1.0))
main.py ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FutureQuery forecast-service — FastAPI ensemble API (TimesFM + Chronos-2).
2
+
3
+ Run it::
4
+
5
+ cd forecast-service
6
+ pip install -r requirements.txt
7
+ python main.py # serves http://localhost:8008
8
+
9
+ Endpoints
10
+ ---------
11
+ POST /forecast -> ensemble forecast (TimesFM + Chronos-2) for a price series
12
+ GET /calibration -> Brier score, reliability curve, directional accuracy
13
+ POST /resolve -> record a market's realised outcome for calibration
14
+ GET /health -> model availability / liveness
15
+ POST /pipeline/start -> run the full scan->triage->ensemble->synthesise loop
16
+ POST /pipeline/resume -> approve / override the human checkpoint (browser-driven)
17
+
18
+ Models are loaded once on startup (not per request). The first run downloads
19
+ weights from HuggingFace (~2GB) — expected. If a model can't be loaded the
20
+ service still starts and falls back to a naive forecast, so the API never hard
21
+ -fails (see models.py).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ import os
28
+ from contextlib import asynccontextmanager
29
+ from typing import List, Literal, Optional
30
+
31
+ import numpy as np
32
+ from fastapi import FastAPI
33
+ from fastapi.middleware.cors import CORSMiddleware
34
+ from pydantic import BaseModel, Field
35
+
36
+ import calibration
37
+ import ensemble
38
+ from models import ChronosModel, TimesFMModel
39
+ from pipeline_api import router as pipeline_router
40
+
41
+ logging.basicConfig(
42
+ level=logging.INFO,
43
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
44
+ )
45
+ log = logging.getLogger("forecast.main")
46
+
47
+ PORT = int(os.environ.get("FORECAST_PORT", "8008"))
48
+
49
+ # Holds the loaded models for the lifetime of the process.
50
+ STATE: dict = {"timesfm": None, "chronos": None}
51
+
52
+
53
+ @asynccontextmanager
54
+ async def lifespan(app: FastAPI):
55
+ log.info("Loading forecasting models (first run downloads ~2GB)...")
56
+ STATE["timesfm"] = TimesFMModel()
57
+ STATE["chronos"] = ChronosModel()
58
+ calibration.init_db()
59
+ log.info(
60
+ "Models ready — TimesFM available=%s, Chronos available=%s",
61
+ STATE["timesfm"].available,
62
+ STATE["chronos"].available,
63
+ )
64
+ yield
65
+ STATE["timesfm"] = None
66
+ STATE["chronos"] = None
67
+
68
+
69
+ app = FastAPI(title="FutureQuery forecast-service", version="1.0.0", lifespan=lifespan)
70
+
71
+ # Allow the Next.js dev server / browser tools to call the API directly.
72
+ app.add_middleware(
73
+ CORSMiddleware,
74
+ allow_origins=["*"],
75
+ allow_credentials=False,
76
+ allow_methods=["*"],
77
+ allow_headers=["*"],
78
+ )
79
+
80
+ app.include_router(pipeline_router)
81
+
82
+
83
+ # --- request / response models -------------------------------------------
84
+
85
+ class ForecastRequest(BaseModel):
86
+ prices: List[float] = Field(default_factory=list, description="historical yes_prices")
87
+ covariates: Optional[List[List[float]]] = Field(
88
+ default=None, description="optional related series (past covariates)"
89
+ )
90
+ question_type: Literal["numeric", "event"] = "event"
91
+ horizon: int = Field(default=5, ge=1, le=64)
92
+ question: Optional[str] = None # optional label, stored for calibration
93
+
94
+
95
+ class ResolveRequest(BaseModel):
96
+ id: str
97
+ outcome: float = Field(ge=0.0, le=1.0, description="1.0 = YES happened, 0.0 = NO")
98
+
99
+
100
+ # --- endpoints ------------------------------------------------------------
101
+
102
+ @app.get("/health")
103
+ def health() -> dict:
104
+ tf = STATE.get("timesfm")
105
+ ch = STATE.get("chronos")
106
+ return {
107
+ "status": "ok",
108
+ "timesfm": {
109
+ "available": bool(tf and tf.available),
110
+ "label": tf.label if tf else None,
111
+ },
112
+ "chronos2": {
113
+ "available": bool(ch and ch.available),
114
+ "label": ch.label if ch else None,
115
+ },
116
+ }
117
+
118
+
119
+ @app.post("/forecast")
120
+ def forecast(req: ForecastRequest) -> dict:
121
+ prices = [float(p) for p in (req.prices or []) if _finite(p)]
122
+ horizon = int(req.horizon)
123
+ qtype = req.question_type
124
+
125
+ # --- guards: refuse to forecast when we shouldn't --------------------
126
+ if len(prices) == 0:
127
+ warning = (
128
+ "No price history for event market"
129
+ if qtype == "event"
130
+ else "No data provided"
131
+ )
132
+ payload = ensemble.short_series_response(prices, horizon, qtype, warning)
133
+ return _finalize(payload, req, prices)
134
+
135
+ if len(prices) < ensemble.MIN_POINTS:
136
+ warning = (
137
+ f"Series too short for reliable forecast "
138
+ f"(have {len(prices)}, need >= {ensemble.MIN_POINTS})"
139
+ )
140
+ payload = ensemble.short_series_response(prices, horizon, qtype, warning)
141
+ return _finalize(payload, req, prices)
142
+
143
+ # --- run both models on startup-loaded handles -----------------------
144
+ tf_model = STATE.get("timesfm") or TimesFMModel()
145
+ ch_model = STATE.get("chronos") or ChronosModel()
146
+
147
+ timesfm_fc = tf_model.forecast(prices, horizon, req.covariates)
148
+ chronos_fc = ch_model.forecast(prices, horizon, req.covariates)
149
+
150
+ payload = ensemble.full_response(prices, timesfm_fc, chronos_fc, qtype, horizon)
151
+ return _finalize(payload, req, prices)
152
+
153
+
154
+ @app.get("/calibration")
155
+ def get_calibration() -> dict:
156
+ return calibration.compute_calibration()
157
+
158
+
159
+ @app.post("/resolve")
160
+ def resolve(req: ResolveRequest) -> dict:
161
+ found = calibration.record_resolution(req.id, req.outcome)
162
+ return {"ok": found, "id": req.id}
163
+
164
+
165
+ # --- helpers --------------------------------------------------------------
166
+
167
+ def _finalize(payload: dict, req: ForecastRequest, prices: List[float]) -> dict:
168
+ """Attach a calibration id and persist the forecast for later scoring."""
169
+ headline = ensemble.headline_probability(payload)
170
+ p_market = prices[-1] if prices else None
171
+ trend = payload.get("ensemble", {}).get("trend")
172
+ brier = payload.get("ensemble", {}).get("brier_estimate")
173
+ try:
174
+ fid = calibration.record_forecast(
175
+ question=req.question or "",
176
+ question_type=req.question_type,
177
+ horizon=int(req.horizon),
178
+ p_forecast=headline,
179
+ p_market=p_market,
180
+ trend=trend,
181
+ brier_estimate=brier,
182
+ use_forecast=bool(payload.get("use_forecast", False)),
183
+ )
184
+ payload["id"] = fid
185
+ except Exception as exc: # never let logging break a forecast
186
+ log.warning("could not persist forecast for calibration: %s", exc)
187
+ payload["id"] = None
188
+ return payload
189
+
190
+
191
+ def _finite(value) -> bool:
192
+ try:
193
+ return np.isfinite(float(value))
194
+ except (TypeError, ValueError):
195
+ return False
196
+
197
+
198
+ if __name__ == "__main__":
199
+ import uvicorn
200
+
201
+ print("\n" + "=" * 56, flush=True)
202
+ print(" forecast-service is READY when the next line says", flush=True)
203
+ print(" 'Uvicorn running on http://127.0.0.1:8008'", flush=True)
204
+ print("=" * 56 + "\n", flush=True)
205
+ # bind loopback (127.0.0.1): avoids the Windows firewall prompt and
206
+ # localhost/IPv6 mismatches that can stop the server from coming up.
207
+ # 127.0.0.1 locally; set FORECAST_HOST=0.0.0.0 in a container so the host can reach it.
208
+ uvicorn.run(
209
+ "main:app",
210
+ host=os.environ.get("FORECAST_HOST", "127.0.0.1"),
211
+ port=PORT,
212
+ reload=False,
213
+ )
models.py ADDED
@@ -0,0 +1,260 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Model wrappers for TimesFM and Chronos-2 with graceful degradation.
2
+
3
+ Both wrappers expose a uniform interface::
4
+
5
+ model.available -> bool
6
+ model.label -> str (e.g. "TimesFM 2.5", "Chronos-2")
7
+ model.forecast(series, horizon, covariates=None) -> np.ndarray
8
+
9
+ If the heavy ML dependencies or the model weights are unavailable (not
10
+ installed, offline, download failed, OOM, ...) the wrapper keeps
11
+ ``available = False`` and ``forecast`` returns a damped naive forecast.
12
+ That guarantees ``main.py`` always starts and ``/forecast`` always responds,
13
+ which is exactly what Phase 5 asks for. When the real weights are present the
14
+ wrappers use them.
15
+
16
+ The API surfaces of these libraries changed across releases, so each loader
17
+ tries the current (2.x) API first and falls back to the legacy API. Every
18
+ inference call is wrapped so a runtime surprise degrades to the naive path
19
+ instead of 500-ing the request.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import logging
25
+ from typing import Optional, Sequence
26
+
27
+ import numpy as np
28
+
29
+ log = logging.getLogger("forecast.models")
30
+
31
+
32
+ def naive_forecast(series: Sequence[float], horizon: int) -> np.ndarray:
33
+ """Damped-drift fallback forecast.
34
+
35
+ Continues the last observed step, damping it each period so the line does
36
+ not run away over the horizon. Falls back to persistence (repeat the last
37
+ value) when there is too little history.
38
+ """
39
+ arr = np.asarray(series, dtype=float)
40
+ arr = arr[np.isfinite(arr)]
41
+ if arr.size == 0:
42
+ return np.zeros(int(horizon), dtype=float)
43
+
44
+ last = float(arr[-1])
45
+ step = float(arr[-1] - arr[-2]) if arr.size >= 2 else 0.0
46
+ damp = 0.6
47
+ out = []
48
+ cur = last
49
+ for _ in range(int(horizon)):
50
+ cur = cur + step
51
+ step *= damp
52
+ out.append(cur)
53
+ return np.asarray(out, dtype=float)
54
+
55
+
56
+ class TimesFMModel:
57
+ """Google TimesFM wrapper (tries the 2.5 API, then the legacy 2.0 API)."""
58
+
59
+ def __init__(self) -> None:
60
+ self.available = False
61
+ self.label = "TimesFM 2.0"
62
+ self._impl = None
63
+ self._mode: Optional[str] = None
64
+ self._load()
65
+
66
+ def _load(self) -> None:
67
+ try:
68
+ import timesfm # type: ignore
69
+ except Exception as exc: # pragma: no cover - import guard
70
+ log.warning("timesfm not importable (%s) — using naive fallback", exc)
71
+ return
72
+
73
+ # --- Preferred: TimesFM 2.5 (current PyPI API) ---------------------
74
+ try:
75
+ import torch # type: ignore
76
+
77
+ torch.set_float32_matmul_precision("high")
78
+ model = timesfm.TimesFM_2p5_200M_torch.from_pretrained(
79
+ "google/timesfm-2.5-200m-pytorch"
80
+ )
81
+ model.compile(
82
+ timesfm.ForecastConfig(
83
+ max_context=1024,
84
+ max_horizon=256,
85
+ normalize_inputs=True,
86
+ use_continuous_quantile_head=True,
87
+ force_flip_invariance=True,
88
+ infer_is_positive=False, # probabilities can sit near 0
89
+ fix_quantile_crossing=True,
90
+ )
91
+ )
92
+ self._impl = model
93
+ self._mode = "2.5"
94
+ self.label = "TimesFM 2.5"
95
+ self.available = True
96
+ log.info("Loaded TimesFM 2.5 (google/timesfm-2.5-200m-pytorch)")
97
+ return
98
+ except Exception as exc:
99
+ log.warning("TimesFM 2.5 load failed (%s) — trying legacy 2.0 API", exc)
100
+
101
+ # --- Fallback: TimesFM 2.0 (legacy hparams/checkpoint API) ---------
102
+ try:
103
+ tfm = timesfm.TimesFm(
104
+ hparams=timesfm.TimesFmHparams(
105
+ backend="cpu",
106
+ per_core_batch_size=32,
107
+ horizon_len=128,
108
+ context_len=2048,
109
+ ),
110
+ checkpoint=timesfm.TimesFmCheckpoint(
111
+ huggingface_repo_id="google/timesfm-2.0-500m-pytorch"
112
+ ),
113
+ )
114
+ self._impl = tfm
115
+ self._mode = "2.0"
116
+ self.label = "TimesFM 2.0"
117
+ self.available = True
118
+ log.info("Loaded TimesFM 2.0 (google/timesfm-2.0-500m-pytorch)")
119
+ except Exception as exc:
120
+ log.warning("TimesFM 2.0 load failed (%s) — using naive fallback", exc)
121
+
122
+ def forecast(
123
+ self,
124
+ series: Sequence[float],
125
+ horizon: int,
126
+ covariates: Optional[Sequence[Sequence[float]]] = None,
127
+ ) -> np.ndarray:
128
+ if not self.available or self._impl is None:
129
+ return naive_forecast(series, horizon)
130
+
131
+ arr = np.asarray(series, dtype=float)
132
+ try:
133
+ if self._mode == "2.5":
134
+ point, _quantiles = self._impl.forecast(horizon=int(horizon), inputs=[arr])
135
+ return np.asarray(point[0], dtype=float)[:horizon]
136
+ # legacy 2.0
137
+ point, _ = self._impl.forecast([arr], freq=[0])
138
+ return np.asarray(point[0], dtype=float)[:horizon]
139
+ except Exception as exc:
140
+ log.warning("TimesFM forecast failed (%s) — naive fallback", exc)
141
+ return naive_forecast(series, horizon)
142
+
143
+
144
+ class ChronosModel:
145
+ """Amazon Chronos-2 wrapper (falls back to a Chronos-Bolt pipeline)."""
146
+
147
+ def __init__(self) -> None:
148
+ self.available = False
149
+ self.label = "Chronos-2"
150
+ self._impl = None
151
+ self._mode: Optional[str] = None
152
+ self._load()
153
+
154
+ def _load(self) -> None:
155
+ # --- Preferred: Chronos-2 -----------------------------------------
156
+ try:
157
+ from chronos import Chronos2Pipeline # type: ignore
158
+
159
+ self._impl = Chronos2Pipeline.from_pretrained(
160
+ "amazon/chronos-2", device_map="cpu"
161
+ )
162
+ self._mode = "chronos2"
163
+ self.label = "Chronos-2"
164
+ self.available = True
165
+ log.info("Loaded Chronos-2 (amazon/chronos-2)")
166
+ return
167
+ except Exception as exc:
168
+ log.warning("Chronos-2 load failed (%s) — trying Chronos-Bolt", exc)
169
+
170
+ # --- Fallback: Chronos-Bolt (older base pipeline) -----------------
171
+ try:
172
+ from chronos import BaseChronosPipeline # type: ignore
173
+
174
+ self._impl = BaseChronosPipeline.from_pretrained(
175
+ "amazon/chronos-bolt-base", device_map="cpu"
176
+ )
177
+ self._mode = "bolt"
178
+ self.label = "Chronos-Bolt"
179
+ self.available = True
180
+ log.info("Loaded Chronos-Bolt (amazon/chronos-bolt-base)")
181
+ except Exception as exc:
182
+ log.warning("Chronos-Bolt load failed (%s) — using naive fallback", exc)
183
+
184
+ def forecast(
185
+ self,
186
+ series: Sequence[float],
187
+ horizon: int,
188
+ covariates: Optional[Sequence[Sequence[float]]] = None,
189
+ ) -> np.ndarray:
190
+ if not self.available or self._impl is None:
191
+ return naive_forecast(series, horizon)
192
+
193
+ arr = np.asarray(series, dtype=float)
194
+ try:
195
+ if self._mode == "chronos2":
196
+ return self._forecast_chronos2(arr, horizon, covariates)
197
+ return self._forecast_bolt(arr, horizon)
198
+ except Exception as exc:
199
+ log.warning("Chronos forecast failed (%s) — naive fallback", exc)
200
+ return naive_forecast(series, horizon)
201
+
202
+ def _forecast_chronos2(
203
+ self,
204
+ arr: np.ndarray,
205
+ horizon: int,
206
+ covariates: Optional[Sequence[Sequence[float]]],
207
+ ) -> np.ndarray:
208
+ import pandas as pd
209
+
210
+ ts = pd.date_range("2000-01-01", periods=arr.size, freq="D")
211
+ frame = {"id": ["series"] * arr.size, "timestamp": ts, "target": arr}
212
+
213
+ # Chronos-2 natively supports past covariates; attach any we were given.
214
+ if covariates:
215
+ for idx, cov in enumerate(covariates):
216
+ cov_arr = np.asarray(cov, dtype=float)
217
+ if cov_arr.size == arr.size:
218
+ frame[f"cov_{idx}"] = cov_arr
219
+
220
+ context_df = pd.DataFrame(frame)
221
+ pred_df = self._impl.predict_df(
222
+ context_df,
223
+ prediction_length=int(horizon),
224
+ quantile_levels=[0.1, 0.5, 0.9],
225
+ id_column="id",
226
+ timestamp_column="timestamp",
227
+ target="target",
228
+ )
229
+ return self._extract_median(pred_df, horizon)
230
+
231
+ @staticmethod
232
+ def _extract_median(pred_df, horizon: int) -> np.ndarray:
233
+ import pandas as pd # noqa: F401
234
+
235
+ # predict_df output column naming varies; prefer the 0.5 quantile/mean.
236
+ for candidate in ("0.5", 0.5, "median", "mean", "predictions", "target"):
237
+ if candidate in pred_df.columns:
238
+ return np.asarray(pred_df[candidate].to_numpy(), dtype=float)[:horizon]
239
+ # Last resort: first purely-numeric column that is not an id/time column.
240
+ for col in pred_df.columns:
241
+ if col in ("id", "timestamp", "item_id"):
242
+ continue
243
+ try:
244
+ return np.asarray(pred_df[col].to_numpy(), dtype=float)[:horizon]
245
+ except Exception:
246
+ continue
247
+ raise ValueError("could not locate a forecast column in predict_df output")
248
+
249
+ def _forecast_bolt(self, arr: np.ndarray, horizon: int) -> np.ndarray:
250
+ import torch # type: ignore
251
+
252
+ context = torch.tensor(arr, dtype=torch.float32)
253
+ # BaseChronosPipeline exposes predict_quantiles(...) -> (quantiles, mean)
254
+ quantiles, mean = self._impl.predict_quantiles(
255
+ context=context,
256
+ prediction_length=int(horizon),
257
+ quantile_levels=[0.1, 0.5, 0.9],
258
+ )
259
+ median = quantiles[0, :, 1] # the 0.5 quantile
260
+ return np.asarray(median, dtype=float)[:horizon]
pipeline.py ADDED
@@ -0,0 +1,599 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FutureQuery research pipeline — LangGraph SCAN -> TRIAGE -> SYNTHESISE.
2
+
3
+ Runs the full loop for a single question across Polymarket, Kalshi, Metaculus
4
+ and Manifold, calls the ensemble ``/forecast`` endpoint, fuses
5
+ everything into one probability, pauses at a human checkpoint, and writes the
6
+ result to ``db.sqlite`` for calibration tracking.
7
+
8
+ Usage::
9
+
10
+ python pipeline.py "Will UK interest rates fall below 4% by Jan 2027?"
11
+ python pipeline.py --numeric "US CPI year-over-year in Dec 2026"
12
+
13
+ At the checkpoint, press **Enter** to accept the proposed probability or type
14
+ ``override 0.42`` to set your own.
15
+
16
+ Graph::
17
+
18
+ scan_markets -> triage -> fetch_history -> run_ensemble
19
+ -> synthesise -> human_checkpoint -> output
20
+
21
+ The source fetchers and the ``/forecast`` call are module-level functions on
22
+ purpose, so they can be monkeypatched in tests and so a single failing source
23
+ never takes down the run.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import math
30
+ import os
31
+ import re
32
+ import sys
33
+ import urllib.parse
34
+ import urllib.request
35
+ import uuid
36
+ from concurrent.futures import ThreadPoolExecutor, as_completed
37
+ from typing import Dict, List, Optional, TypedDict
38
+
39
+ FORECAST_URL = os.environ.get("FORECAST_URL", "http://127.0.0.1:8008")
40
+ HORIZON = int(os.environ.get("PIPELINE_HORIZON", "5"))
41
+ ARB_SPREAD_THRESHOLD = 0.10
42
+
43
+ # Platform credibility weights used in triage scoring.
44
+ CREDIBILITY = {
45
+ "Polymarket": 1.0,
46
+ "Kalshi": 1.0,
47
+ "Metaculus": 0.9,
48
+ "Manifold": 0.6, # play-money — lower weight
49
+ }
50
+
51
+ STOP_WORDS = {
52
+ "will", "the", "a", "an", "of", "to", "in", "on", "by", "be", "is", "are",
53
+ "before", "after", "than", "this", "that", "and", "or", "for", "with",
54
+ }
55
+
56
+
57
+ # --- shared state ---------------------------------------------------------
58
+
59
+ class PipelineState(TypedDict, total=False):
60
+ question: str
61
+ question_type: str
62
+ horizon: int
63
+ hits: List[Dict]
64
+ scored: List[Dict]
65
+ top: List[Dict]
66
+ arbitrage: Optional[Dict]
67
+ history: List[float]
68
+ history_source: Optional[str]
69
+ ensemble: Dict
70
+ use_forecast: bool
71
+ ensemble_trend: Optional[str]
72
+ brier_estimate: Optional[float]
73
+ market_consensus: Optional[float]
74
+ final_probability: Optional[float]
75
+ rationale: str
76
+ decision_note: str
77
+ forecast_id: Optional[str]
78
+
79
+
80
+ # --- tiny HTTP helpers (stdlib only) -------------------------------------
81
+
82
+ def _get_json(url: str, timeout: int = 10):
83
+ req = urllib.request.Request(url, headers={"User-Agent": "futurequery-pipeline/1.0"})
84
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
85
+ return json.loads(resp.read().decode("utf-8"))
86
+
87
+
88
+ def _post_json(url: str, payload: dict, timeout: int = 20):
89
+ data = json.dumps(payload).encode("utf-8")
90
+ req = urllib.request.Request(
91
+ url, data=data, headers={"Content-Type": "application/json"}
92
+ )
93
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
94
+ return json.loads(resp.read().decode("utf-8"))
95
+
96
+
97
+ def _keywords(question: str) -> str:
98
+ words = [
99
+ w for w in re.sub(r"[^a-z0-9\s-]", " ", question.lower()).split()
100
+ if len(w) > 2 and w not in STOP_WORDS
101
+ ]
102
+ return " ".join(dict.fromkeys(words)) or question
103
+
104
+
105
+ def _as_float(value, default=None):
106
+ try:
107
+ f = float(value)
108
+ return f if math.isfinite(f) else default
109
+ except (TypeError, ValueError):
110
+ return default
111
+
112
+
113
+ # --- source fetchers (each returns a list of hit dicts) ------------------
114
+ # Hit = {source, question, probability, volume, url, clob_token_id?, end_date?}
115
+
116
+ def fetch_polymarket(question: str) -> List[Dict]:
117
+ try:
118
+ q = urllib.parse.urlencode(
119
+ {"active": "true", "closed": "false", "limit": "8", "search": _keywords(question)}
120
+ )
121
+ data = _get_json(f"https://gamma-api.polymarket.com/markets?{q}")
122
+ hits = []
123
+ for m in data if isinstance(data, list) else []:
124
+ outcomes = _json_array(m.get("outcomes"))
125
+ prices = [_as_float(p) for p in _json_array(m.get("outcomePrices"))]
126
+ tokens = _json_array(m.get("clobTokenIds"))
127
+ yes_idx = next((i for i, o in enumerate(outcomes) if str(o).lower() == "yes"), 0)
128
+ prob = prices[yes_idx] if yes_idx < len(prices) else None
129
+ if prob is None:
130
+ continue
131
+ hits.append({
132
+ "source": "Polymarket",
133
+ "question": m.get("question") or m.get("title") or "",
134
+ "probability": max(0.0, min(1.0, prob)),
135
+ "volume": _as_float(m.get("volumeNum") or m.get("volume"), 0.0) or 0.0,
136
+ "url": f"https://polymarket.com/event/{m.get('slug')}" if m.get("slug") else None,
137
+ "clob_token_id": tokens[yes_idx] if yes_idx < len(tokens) else (tokens[0] if tokens else None),
138
+ "end_date": m.get("endDate"),
139
+ })
140
+ return hits
141
+ except Exception:
142
+ return []
143
+
144
+
145
+ def fetch_kalshi(question: str) -> List[Dict]:
146
+ api_key = os.environ.get("KALSHI_API_KEY")
147
+ if not api_key:
148
+ return [] # Kalshi search needs auth — skip gracefully
149
+ try:
150
+ q = urllib.parse.urlencode({"limit": "8", "status": "open", "search": _keywords(question)})
151
+ req = urllib.request.Request(
152
+ f"https://api.elections.kalshi.com/trade-api/v2/markets?{q}",
153
+ headers={"Authorization": f"Bearer {api_key}", "User-Agent": "futurequery/1.0"},
154
+ )
155
+ with urllib.request.urlopen(req, timeout=10) as resp:
156
+ data = json.loads(resp.read().decode("utf-8"))
157
+ hits = []
158
+ for m in data.get("markets", []) or []:
159
+ bid, ask = _as_float(m.get("yes_bid")), _as_float(m.get("yes_ask"))
160
+ cents = (bid + ask) / 2 if bid is not None and ask is not None else _as_float(m.get("last_price"))
161
+ if cents is None or not m.get("title"):
162
+ continue
163
+ hits.append({
164
+ "source": "Kalshi",
165
+ "question": m["title"],
166
+ "probability": max(0.0, min(1.0, cents / 100.0)),
167
+ "volume": _as_float(m.get("volume"), 0.0) or 0.0,
168
+ "url": f"https://kalshi.com/markets/{m.get('ticker')}" if m.get("ticker") else None,
169
+ "end_date": m.get("close_time"),
170
+ })
171
+ return hits
172
+ except Exception:
173
+ return []
174
+
175
+
176
+ def fetch_metaculus(question: str) -> List[Dict]:
177
+ try:
178
+ q = urllib.parse.urlencode({"search": _keywords(question), "limit": "5", "type": "forecast"})
179
+ data = _get_json(f"https://www.metaculus.com/api2/questions/?{q}")
180
+ hits = []
181
+ for item in data.get("results", []) or []:
182
+ prob = _metaculus_probability(item)
183
+ title = item.get("title") or item.get("question", {}).get("title")
184
+ if prob is None or not title:
185
+ continue
186
+ hits.append({
187
+ "source": "Metaculus",
188
+ "question": title,
189
+ "probability": max(0.0, min(1.0, prob)),
190
+ "volume": float(item.get("number_of_forecasters", 0) or 0),
191
+ "url": f"https://www.metaculus.com{item.get('page_url', '')}" if item.get("page_url") else None,
192
+ "end_date": item.get("resolve_time") or item.get("scheduled_resolve_time"),
193
+ })
194
+ return hits
195
+ except Exception:
196
+ return []
197
+
198
+
199
+ def fetch_manifold(question: str) -> List[Dict]:
200
+ try:
201
+ q = urllib.parse.urlencode({"term": _keywords(question), "limit": "6"})
202
+ data = _get_json(f"https://api.manifold.markets/v0/search-markets?{q}")
203
+ hits = []
204
+ for m in data if isinstance(data, list) else []:
205
+ prob = _as_float(m.get("probability"))
206
+ if prob is None or m.get("outcomeType") not in (None, "BINARY"):
207
+ continue
208
+ hits.append({
209
+ "source": "Manifold",
210
+ "question": m.get("question", ""),
211
+ "probability": max(0.0, min(1.0, prob)),
212
+ "volume": _as_float(m.get("volume"), 0.0) or 0.0,
213
+ "url": m.get("url"),
214
+ "end_date": _ms_to_iso(m.get("closeTime")),
215
+ })
216
+ return hits
217
+ except Exception:
218
+ return []
219
+
220
+
221
+ def fetch_polymarket_history(token_id: str, days: int = 30) -> List[float]:
222
+ """30-day yes-price history from the Polymarket CLOB prices-history API."""
223
+ try:
224
+ import time as _time
225
+
226
+ end = int(_time.time())
227
+ start = end - days * 86400
228
+ q = urllib.parse.urlencode(
229
+ {"market": token_id, "startTs": start, "endTs": end, "fidelity": 360}
230
+ )
231
+ data = _get_json(f"https://clob.polymarket.com/prices-history?{q}")
232
+ points = data.get("history", []) if isinstance(data, dict) else []
233
+ series = [_as_float(p.get("p")) for p in points if _as_float(p.get("p")) is not None]
234
+ return series
235
+ except Exception:
236
+ return []
237
+
238
+
239
+ def call_forecast(prices: List[float], question_type: str, horizon: int) -> Dict:
240
+ """POST to the ensemble service; degrade gracefully if it is offline."""
241
+ try:
242
+ return _post_json(
243
+ f"{FORECAST_URL}/forecast",
244
+ {"prices": prices, "question_type": question_type, "horizon": horizon},
245
+ )
246
+ except Exception as exc:
247
+ return {
248
+ "timesfm": {"forecast": [], "trend": "flat"},
249
+ "chronos2": {"forecast": [], "trend": "flat"},
250
+ "ensemble": {"forecast": [], "trend": "flat", "brier_estimate": None},
251
+ "use_forecast": False,
252
+ "warning": f"forecast-service unreachable at {FORECAST_URL} ({exc})",
253
+ }
254
+
255
+
256
+ # --- graph nodes ----------------------------------------------------------
257
+
258
+ def scan_markets(state: PipelineState) -> Dict:
259
+ question = state["question"]
260
+ sources = {
261
+ "Polymarket": fetch_polymarket,
262
+ "Kalshi": fetch_kalshi,
263
+ "Metaculus": fetch_metaculus,
264
+ "Manifold": fetch_manifold,
265
+ }
266
+ hits: List[Dict] = []
267
+ with ThreadPoolExecutor(max_workers=len(sources)) as ex:
268
+ futures = {ex.submit(fn, question): name for name, fn in sources.items()}
269
+ for fut in as_completed(futures):
270
+ try:
271
+ hits.extend(fut.result() or [])
272
+ except Exception:
273
+ pass
274
+ print(f"[scan] {len(hits)} hits across {len(sources)} sources")
275
+ return {"hits": hits}
276
+
277
+
278
+ def triage(state: PipelineState) -> Dict:
279
+ hits = state.get("hits", [])
280
+ scored = []
281
+ for h in hits:
282
+ cred = CREDIBILITY.get(h["source"], 0.5)
283
+ volume = max(h.get("volume", 0.0) or 0.0, 0.0)
284
+ recency = _recency_factor(h.get("end_date"))
285
+ score = cred * math.log1p(volume + 1.0) * recency
286
+ scored.append({**h, "score": round(score, 4), "credibility": cred, "recency": round(recency, 3)})
287
+
288
+ scored.sort(key=lambda x: x["score"], reverse=True)
289
+ top = scored[:3]
290
+
291
+ arbitrage = None
292
+ probs = [h["probability"] for h in scored]
293
+ if len(probs) >= 2:
294
+ hi = max(scored, key=lambda x: x["probability"])
295
+ lo = min(scored, key=lambda x: x["probability"])
296
+ spread = hi["probability"] - lo["probability"]
297
+ if spread > ARB_SPREAD_THRESHOLD:
298
+ arbitrage = {
299
+ "spread": round(spread, 4),
300
+ "high": {"source": hi["source"], "probability": hi["probability"]},
301
+ "low": {"source": lo["source"], "probability": lo["probability"]},
302
+ }
303
+ print(f"[triage] ⚠ arbitrage: {spread:.2f} spread "
304
+ f"({lo['source']} {lo['probability']:.2f} → {hi['source']} {hi['probability']:.2f})")
305
+ print(f"[triage] top {len(top)}: " + ", ".join(f"{h['source']}({h['score']})" for h in top))
306
+ return {"scored": scored, "top": top, "arbitrage": arbitrage}
307
+
308
+
309
+ def fetch_history(state: PipelineState) -> Dict:
310
+ for h in state.get("top", []):
311
+ if h["source"] == "Polymarket" and h.get("clob_token_id"):
312
+ series = fetch_polymarket_history(h["clob_token_id"], days=30)
313
+ if len(series) >= 2:
314
+ print(f"[history] {len(series)} points from Polymarket "
315
+ f"({h['question'][:48]})")
316
+ return {"history": series, "history_source": h.get("url") or h["source"]}
317
+ print("[history] no Polymarket price history available")
318
+ return {"history": [], "history_source": None}
319
+
320
+
321
+ def run_ensemble(state: PipelineState) -> Dict:
322
+ prices = state.get("history", [])
323
+ qtype = state.get("question_type", "event")
324
+ horizon = state.get("horizon", HORIZON)
325
+ result = call_forecast(prices, qtype, horizon)
326
+ ens = result.get("ensemble", {})
327
+ print(f"[ensemble] use_forecast={result.get('use_forecast')} "
328
+ f"trend={ens.get('trend')} warning={result.get('warning')}")
329
+ return {
330
+ "ensemble": result,
331
+ "use_forecast": bool(result.get("use_forecast", False)),
332
+ "ensemble_trend": ens.get("trend"),
333
+ "brier_estimate": ens.get("brier_estimate"),
334
+ }
335
+
336
+
337
+ def synthesise(state: PipelineState) -> Dict:
338
+ top = state.get("top", []) or state.get("scored", [])
339
+ consensus = _volume_weighted_mean(top)
340
+ ensemble = state.get("ensemble", {})
341
+ ens_forecast = ensemble.get("ensemble", {}).get("forecast", [])
342
+ ens_p = float(ens_forecast[-1]) if (state.get("use_forecast") and ens_forecast) else None
343
+
344
+ if consensus is None and ens_p is None:
345
+ final = 0.5
346
+ elif ens_p is None:
347
+ final = consensus
348
+ elif consensus is None:
349
+ final = ens_p
350
+ else:
351
+ # Markets are the strong prior; the ensemble nudges it.
352
+ final = 0.6 * consensus + 0.4 * ens_p
353
+
354
+ # base-rate prior: regress genuinely-uncertain calls toward 0.5
355
+ if final is not None and abs(final - 0.5) <= 0.1:
356
+ final = final + 0.15 * (0.5 - final)
357
+ final = max(0.0, min(1.0, final)) if final is not None else None
358
+
359
+ rationale = _rationale(state, consensus, ens_p, final)
360
+ print(f"[synthesise] consensus={_fmt(consensus)} ensemble={_fmt(ens_p)} "
361
+ f"→ proposed={_fmt(final)}")
362
+ return {"market_consensus": consensus, "final_probability": final, "rationale": rationale}
363
+
364
+
365
+ def human_checkpoint(state: PipelineState) -> Dict:
366
+ from langgraph.types import interrupt
367
+
368
+ summary = {
369
+ "question": state["question"],
370
+ "market_consensus": state.get("market_consensus"),
371
+ "ensemble_trend": state.get("ensemble_trend"),
372
+ "use_forecast": state.get("use_forecast"),
373
+ "arbitrage": state.get("arbitrage"),
374
+ "proposed_probability": state.get("final_probability"),
375
+ "rationale": state.get("rationale"),
376
+ "instructions": "Press Enter to accept, or type 'override 0.XX' to set your own probability.",
377
+ }
378
+ # Pauses the graph; the value returned is whatever the driver resumes with.
379
+ user_input = interrupt(summary)
380
+
381
+ final = state.get("final_probability")
382
+ note = "accepted"
383
+ if isinstance(user_input, str):
384
+ text = user_input.strip()
385
+ if text.lower().startswith("override"):
386
+ parts = text.split()
387
+ if len(parts) >= 2:
388
+ v = _as_float(parts[1])
389
+ if v is not None:
390
+ if v > 1.0:
391
+ v = v / 100.0
392
+ final = max(0.0, min(1.0, v))
393
+ note = f"overridden to {final:.2f}"
394
+ return {"final_probability": final, "decision_note": note}
395
+
396
+
397
+ def output(state: PipelineState) -> Dict:
398
+ fid = None
399
+ try:
400
+ import calibration
401
+
402
+ fid = calibration.record_forecast(
403
+ question=state["question"],
404
+ question_type=state.get("question_type", "event"),
405
+ horizon=state.get("horizon", HORIZON),
406
+ p_forecast=state.get("final_probability"),
407
+ p_market=state.get("market_consensus"),
408
+ trend=state.get("ensemble_trend"),
409
+ brier_estimate=state.get("brier_estimate"),
410
+ use_forecast=state.get("use_forecast", False),
411
+ )
412
+ print(f"[output] stored forecast {fid} in db.sqlite")
413
+ except Exception as exc:
414
+ print(f"[output] could not store forecast: {exc}")
415
+ return {"forecast_id": fid}
416
+
417
+
418
+ # --- graph assembly -------------------------------------------------------
419
+
420
+ def build_graph():
421
+ from langgraph.graph import END, START, StateGraph
422
+
423
+ try:
424
+ from langgraph.checkpoint.memory import InMemorySaver as _Saver
425
+ except Exception: # pragma: no cover - older langgraph
426
+ from langgraph.checkpoint.memory import MemorySaver as _Saver
427
+
428
+ builder = StateGraph(PipelineState)
429
+ builder.add_node("scan_markets", scan_markets)
430
+ builder.add_node("triage", triage)
431
+ builder.add_node("fetch_history", fetch_history)
432
+ builder.add_node("run_ensemble", run_ensemble)
433
+ builder.add_node("synthesise", synthesise)
434
+ builder.add_node("human_checkpoint", human_checkpoint)
435
+ builder.add_node("output", output)
436
+
437
+ builder.add_edge(START, "scan_markets")
438
+ builder.add_edge("scan_markets", "triage")
439
+ builder.add_edge("triage", "fetch_history")
440
+ builder.add_edge("fetch_history", "run_ensemble")
441
+ builder.add_edge("run_ensemble", "synthesise")
442
+ builder.add_edge("synthesise", "human_checkpoint")
443
+ builder.add_edge("human_checkpoint", "output")
444
+ builder.add_edge("output", END)
445
+
446
+ return builder.compile(checkpointer=_Saver())
447
+
448
+
449
+ # --- helpers --------------------------------------------------------------
450
+
451
+ def _json_array(value) -> List:
452
+ if isinstance(value, list):
453
+ return value
454
+ if isinstance(value, str):
455
+ try:
456
+ parsed = json.loads(value)
457
+ return parsed if isinstance(parsed, list) else []
458
+ except Exception:
459
+ return []
460
+ return []
461
+
462
+
463
+ def _metaculus_probability(item: Dict) -> Optional[float]:
464
+ cp = item.get("community_prediction") or {}
465
+ if isinstance(cp, dict):
466
+ full = cp.get("full") or {}
467
+ for key in ("q2", "median", "mean"):
468
+ if _as_float(full.get(key)) is not None:
469
+ return _as_float(full.get(key))
470
+ q = item.get("question") or {}
471
+ agg = (q.get("aggregations") or {}).get("recency_weighted") or {}
472
+ latest = agg.get("latest") or {}
473
+ centers = latest.get("centers")
474
+ if isinstance(centers, list) and centers:
475
+ return _as_float(centers[0])
476
+ return None
477
+
478
+
479
+ def _recency_factor(end_date: Optional[str]) -> float:
480
+ """Closer-to-resolution markets are more actionable → mild score boost."""
481
+ if not end_date:
482
+ return 1.0
483
+ try:
484
+ from datetime import datetime, timezone
485
+
486
+ dt = datetime.fromisoformat(str(end_date).replace("Z", "+00:00"))
487
+ days = (dt - datetime.now(timezone.utc)).total_seconds() / 86400.0
488
+ if days <= 0:
489
+ return 0.6 # already past resolution — stale
490
+ return max(0.7, min(1.2, 1.2 - days / 365.0))
491
+ except Exception:
492
+ return 1.0
493
+
494
+
495
+ def _ms_to_iso(ms) -> Optional[str]:
496
+ if not ms:
497
+ return None
498
+ try:
499
+ from datetime import datetime, timezone
500
+
501
+ return datetime.fromtimestamp(float(ms) / 1000.0, tz=timezone.utc).isoformat()
502
+ except Exception:
503
+ return None
504
+
505
+
506
+ def _volume_weighted_mean(hits: List[Dict]) -> Optional[float]:
507
+ if not hits:
508
+ return None
509
+ total = sum(max(h.get("volume", 0.0) or 0.0, 0.0) for h in hits)
510
+ if total <= 0:
511
+ return sum(h["probability"] for h in hits) / len(hits)
512
+ return sum(h["probability"] * max(h.get("volume", 0.0) or 0.0, 0.0) for h in hits) / total
513
+
514
+
515
+ def _rationale(state, consensus, ens_p, final) -> str:
516
+ bits = []
517
+ n = len(state.get("scored", []))
518
+ if consensus is not None:
519
+ bits.append(f"{n} market hit(s), volume-weighted consensus {consensus:.0%}")
520
+ if ens_p is not None:
521
+ bits.append(f"ensemble forecast {ens_p:.0%} ({state.get('ensemble_trend')})")
522
+ elif not state.get("use_forecast"):
523
+ bits.append("ensemble withheld (insufficient price history)")
524
+ if state.get("arbitrage"):
525
+ a = state["arbitrage"]
526
+ bits.append(f"cross-platform arbitrage spread {a['spread']:.0%}")
527
+ bits.append(f"synthesised probability {final:.0%}" if final is not None else "no estimate")
528
+ return "; ".join(bits)
529
+
530
+
531
+ def _fmt(value) -> str:
532
+ return "—" if value is None else f"{value:.2%}"
533
+
534
+
535
+ # --- CLI driver -----------------------------------------------------------
536
+
537
+ def _print_checkpoint(payload: Dict) -> None:
538
+ print("\n" + "=" * 64)
539
+ print("HUMAN CHECKPOINT")
540
+ print("=" * 64)
541
+ print(f"Question : {payload.get('question')}")
542
+ print(f"Consensus: {_fmt(payload.get('market_consensus'))}")
543
+ print(f"Ensemble : trend={payload.get('ensemble_trend')} "
544
+ f"use_forecast={payload.get('use_forecast')}")
545
+ if payload.get("arbitrage"):
546
+ a = payload["arbitrage"]
547
+ print(f"Arbitrage: {a['spread']:.2%} ({a['low']['source']} → {a['high']['source']})")
548
+ print(f"Rationale: {payload.get('rationale')}")
549
+ print("-" * 64)
550
+ print(f"PROPOSED PROBABILITY: {_fmt(payload.get('proposed_probability'))}")
551
+ print(payload.get("instructions", ""))
552
+
553
+
554
+ def main(argv: List[str]) -> int:
555
+ args = [a for a in argv[1:] if a]
556
+ question_type = "event"
557
+ if "--numeric" in args:
558
+ question_type = "numeric"
559
+ args = [a for a in args if a != "--numeric"]
560
+ if not args:
561
+ print('Usage: python pipeline.py [--numeric] "Your forecast question?"')
562
+ return 2
563
+
564
+ question = " ".join(args)
565
+ from langgraph.types import Command
566
+
567
+ graph = build_graph()
568
+ config = {"configurable": {"thread_id": f"pipeline-{uuid.uuid4().hex[:8]}"}}
569
+ initial: PipelineState = {
570
+ "question": question,
571
+ "question_type": question_type,
572
+ "horizon": HORIZON,
573
+ }
574
+
575
+ print(f"\n▶ FutureQuery pipeline — {question!r} ({question_type})\n")
576
+ state = graph.invoke(initial, config=config)
577
+
578
+ # Drive through any human-checkpoint interrupts from the terminal.
579
+ while isinstance(state, dict) and state.get("__interrupt__"):
580
+ payload = state["__interrupt__"][0].value
581
+ _print_checkpoint(payload)
582
+ try:
583
+ user_input = input("> ")
584
+ except EOFError:
585
+ user_input = ""
586
+ state = graph.invoke(Command(resume=user_input), config=config)
587
+
588
+ print("\n" + "=" * 64)
589
+ print("FINAL")
590
+ print("=" * 64)
591
+ print(f"Question : {state.get('question')}")
592
+ print(f"Probability: {_fmt(state.get('final_probability'))} ({state.get('decision_note')})")
593
+ print(f"Rationale : {state.get('rationale')}")
594
+ print(f"Stored as : {state.get('forecast_id')}")
595
+ return 0
596
+
597
+
598
+ if __name__ == "__main__":
599
+ raise SystemExit(main(sys.argv))
pipeline_api.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """HTTP wrapper around the LangGraph pipeline (pipeline.py).
2
+
3
+ Drives the full SCAN -> TRIAGE -> ENSEMBLE -> SYNTHESISE -> HUMAN CHECKPOINT loop
4
+ from the browser:
5
+
6
+ POST /pipeline/start {question, question_type} -> runs to the checkpoint
7
+ POST /pipeline/resume {thread_id, resume} -> "" accepts, "override 0.XX"
8
+
9
+ Robustness:
10
+ * the ensemble is computed IN-PROCESS (no HTTP self-call), avoiding
11
+ localhost/IPv6 and nested-request issues.
12
+ * any failure is returned as a readable {"status":"error","error":...} with
13
+ HTTP 200, so the UI shows the message instead of a raw 500.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import traceback
19
+ import uuid
20
+ from typing import Literal
21
+
22
+ from fastapi import APIRouter
23
+ from pydantic import BaseModel, Field
24
+
25
+ import pipeline
26
+
27
+ router = APIRouter(prefix="/pipeline", tags=["pipeline"])
28
+
29
+ _SNAPSHOT_KEYS = [
30
+ "question", "question_type", "horizon", "hits", "scored", "top", "arbitrage",
31
+ "ensemble", "use_forecast", "ensemble_trend", "brier_estimate",
32
+ "market_consensus", "final_probability", "rationale", "history_source",
33
+ "decision_note", "forecast_id",
34
+ ]
35
+
36
+ _GRAPH = None
37
+ _PATCHED = False
38
+
39
+
40
+ def _graph():
41
+ global _GRAPH
42
+ if _GRAPH is None:
43
+ _GRAPH = pipeline.build_graph()
44
+ return _GRAPH
45
+
46
+
47
+ def _use_inprocess_forecast():
48
+ """Replace the pipeline's HTTP forecast call with a direct in-process call."""
49
+ global _PATCHED
50
+ if _PATCHED:
51
+ return
52
+
53
+ def _inprocess(prices, question_type, horizon):
54
+ import main # function-level import avoids a circular import at load
55
+ req = main.ForecastRequest(
56
+ prices=list(prices or []),
57
+ question_type=question_type,
58
+ horizon=horizon,
59
+ )
60
+ return main.forecast(req)
61
+
62
+ pipeline.call_forecast = _inprocess
63
+ _PATCHED = True
64
+
65
+
66
+ def _snapshot(graph, config) -> dict:
67
+ try:
68
+ values = graph.get_state(config).values or {}
69
+ except Exception:
70
+ values = {}
71
+ return {k: values.get(k) for k in _SNAPSHOT_KEYS if k in values}
72
+
73
+
74
+ def _interrupt_payload(result):
75
+ if isinstance(result, dict) and result.get("__interrupt__"):
76
+ try:
77
+ return result["__interrupt__"][0].value
78
+ except Exception:
79
+ return None
80
+ return None
81
+
82
+
83
+ def _error(thread_id, graph, config, exc) -> dict:
84
+ return {
85
+ "thread_id": thread_id,
86
+ "status": "error",
87
+ "state": _snapshot(graph, config) if graph is not None else {},
88
+ "checkpoint": None,
89
+ "error": f"{type(exc).__name__}: {exc}",
90
+ "trace": traceback.format_exc()[-1500:],
91
+ }
92
+
93
+
94
+ class StartRequest(BaseModel):
95
+ question: str = Field(min_length=3, max_length=500)
96
+ question_type: Literal["numeric", "event"] = "event"
97
+ horizon: int = Field(default=5, ge=1, le=64)
98
+
99
+
100
+ class ResumeRequest(BaseModel):
101
+ thread_id: str
102
+ resume: str = ""
103
+
104
+
105
+ @router.post("/start")
106
+ def start(req: StartRequest) -> dict:
107
+ _use_inprocess_forecast()
108
+ thread_id = f"web-{uuid.uuid4().hex[:10]}"
109
+ try:
110
+ graph = _graph()
111
+ except Exception as exc:
112
+ return _error(thread_id, None, None, exc)
113
+
114
+ config = {"configurable": {"thread_id": thread_id}}
115
+ initial = {
116
+ "question": req.question,
117
+ "question_type": req.question_type,
118
+ "horizon": req.horizon,
119
+ }
120
+ try:
121
+ result = graph.invoke(initial, config=config)
122
+ except Exception as exc:
123
+ return _error(thread_id, graph, config, exc)
124
+
125
+ payload = _interrupt_payload(result)
126
+ return {
127
+ "thread_id": thread_id,
128
+ "status": "checkpoint" if payload is not None else "done",
129
+ "state": _snapshot(graph, config),
130
+ "checkpoint": payload,
131
+ }
132
+
133
+
134
+ @router.post("/resume")
135
+ def resume(req: ResumeRequest) -> dict:
136
+ from langgraph.types import Command
137
+
138
+ try:
139
+ graph = _graph()
140
+ except Exception as exc:
141
+ return _error(req.thread_id, None, None, exc)
142
+
143
+ config = {"configurable": {"thread_id": req.thread_id}}
144
+ try:
145
+ existing = graph.get_state(config)
146
+ if existing is None or not existing.values:
147
+ return {
148
+ "thread_id": req.thread_id, "status": "error", "state": {},
149
+ "checkpoint": None, "error": "unknown or expired thread_id",
150
+ }
151
+ result = graph.invoke(Command(resume=req.resume), config=config)
152
+ except Exception as exc:
153
+ return _error(req.thread_id, graph, config, exc)
154
+
155
+ payload = _interrupt_payload(result)
156
+ return {
157
+ "thread_id": req.thread_id,
158
+ "status": "checkpoint" if payload is not None else "done",
159
+ "state": _snapshot(graph, config),
160
+ "checkpoint": payload,
161
+ }
requirements-lite.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn
3
+ numpy
4
+ pandas
5
+ langgraph
requirements.txt ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FutureQuery forecast-service — ensemble forecasting + LangGraph pipeline
2
+ # Python 3.10–3.12 recommended. First `python main.py` downloads model
3
+ # weights from HuggingFace (~2GB) — this is expected and only happens once.
4
+
5
+ timesfm[torch] # Google TimesFM 2.x time-series foundation model
6
+ chronos-forecasting>=2.0 # Amazon Chronos-2 (Chronos2Pipeline requires >=2.0)
7
+ langgraph # SCAN -> TRIAGE -> SYNTHESISE orchestration (Phase 3)
8
+ openai>=1.0.0 # OpenRouter (OpenAI-compatible) LLM synthesis
9
+ fastapi # /forecast + /calibration API
10
+ uvicorn # ASGI server (started from main.py on :8008)
11
+ scikit-learn # calibration_curve / reliability diagram helpers
12
+ properscoring # Brier score (graceful fallback to numpy if missing)
13
+ statsmodels # baseline statistical checks / fallbacks
14
+ numpy
15
+ pandas