Deploy forecast-service to Hugging Face Space
Browse files- .dockerignore +9 -0
- .gitignore +6 -0
- DEPLOY.md +51 -0
- Dockerfile +29 -0
- README.md +125 -6
- calibration.py +200 -0
- ensemble.py +176 -0
- main.py +213 -0
- models.py +260 -0
- pipeline.py +599 -0
- pipeline_api.py +161 -0
- requirements-lite.txt +5 -0
- 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:
|
| 3 |
-
emoji: 🌖
|
| 4 |
-
colorFrom: gray
|
| 5 |
-
colorTo: red
|
| 6 |
sdk: docker
|
| 7 |
-
|
| 8 |
---
|
|
|
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|