I was hand-rolling ARIMA fits at 2am, again, for the fourth different side project, because every time I need “give me a forecast of this series” I end up re-solving the same three problems: which model, how do I feed it context without writing a custom adapter, and where the fuck do I put the trained artifact so it survives a container restart. wickworks already solved half of my problem — it hands me clean OHLC primitives instead of pretending to know the future. But primitives aren’t forecasts. At some point you still want a number that says “here’s where this series probably goes,” and I was tired of re-implementing that number generator from scratch, badly, per repo, with whatever half-abandoned Python package I’d bookmarked that week.
So I built predictalot. One Docker container, one HTTP API, two completely separate model families living under the same roof: five zero-shot foundation time-series models that need no training step at all, and a second family of trained tabular learners that persist to disk by model ID like actual production ML instead of “notebook that happens to be running.” Feed it context, get quantiles or sample paths back. Train on your own features, get a stored model you can hit again next week. That’s it. It does not tell you what to buy. It emits numbers. You decide what to do with the numbers, same as everywhere else in life where a machine hands you data instead of a decision.
Everything Before This Was Worse
Here’s the landscape before I built this thing, and I don’t think I’m being unfair:
- SaaS forecasting APIs that charge per call to run a model you can’t see, can’t audit, and that “predicts the market” in a way that conveniently produces a chart image and not an accuracy number.
- Classic statistical stacks — ARIMA, Prophet, exponential smoothing — that require you to hand-tune per series, retrain constantly, and fall over the second your data isn’t clean seasonal nonsense.
- Foundation time-series models that are genuinely good now — Chronos, TimesFM, Moirai, Toto, Sundial all shipped real zero-shot capability in the last two years — but each one lives in its own repo, with its own
requirements.txt, its own torch pin, its own quirky input shape. Wiring five of them into one place means five separate virtualenvs fighting each other overtransformersversions. - Tabular ML that works great for “I engineered RSI/MACD/whatever and want a model that says buy or sell” but every tutorial ends at
model.fit()in a notebook — nothing about persisting it, versioning it, or serving it as a stable API a second service can hit next Tuesday. - And then the actual worst offenders: people who wire an LLM up to a candlestick screenshot and call it “AI trading.” That’s not forecasting, that’s astrology with worse math.
I wanted one box I run once, point requests at, and get back either “here’s the zero-shot quantile forecast for this series” or “here’s what your trained model thinks about the latest bar.” No SaaS bill, no notebook, no pretending an LLM can read candles.
Two Model Families, One Port
predictalot is a FastAPI service with two registries that never touch each other: BACKENDS for the foundation models, TABULAR_BACKENDS for the trained ones. The split isn’t cosmetic — it’s the whole architecture. Foundation models are stateless one-shot predictors backed by vendor-shipped weights. You hand them a context window, they hand you a forecast, nothing is written to disk, nothing is fit to your data. Tabular backends are libraries — LightGBM, XGBoost, scikit-learn — that train an actual model on YOUR labeled series and persist the resulting blob under a caller-chosen modelId.
I went and checked this in the code instead of trusting my own memory of what I built, because apparently that’s a thing I have to remind myself to do. None of the five foundation-model modules (chronos2.py, timesfm25.py, moirai2.py, toto1.py, sundial.py) define a train function anywhere. The ModelBackend protocol they implement only declares get_model, unload, loaded, last_used_secs_ago, and per-type predict_* functions. There is no fit step, full stop. Meanwhile every one of the nine tabular backend modules defines train(X, y, feature_names, config), and the trained blob gets written to /models/tabular/{modelId}/model.blob plus a meta.json sidecar via tabular_storage.py — an actual disk-backed store with atomic write-then-rename, not a pickle you forgot in /tmp. That’s the zero-shot promise made concrete: five models where “training” literally does not exist as a code path, sitting next to nine models where training is the entire point and the artifact outlives the request that created it.
Six endpoint families, one type-routed API
The foundation-model side isn’t one dump-everything-in endpoint — it’s routed by forecasting modality, and not every model supports every modality. The registry in types.py is blunt about who’s in which club:
- univariate (
/v1/timeseries/univariate/) — all five: chronos-2, timesfm-2.5, moirai-2, toto-1, sundial-base-128m. - multivariate — chronos-2, moirai-2, toto-1.
- covariates/past — chronos-2, moirai-2.
- covariates/future — chronos-2 only.
- covariates (past+future together) — chronos-2 only.
- samples (raw sample paths instead of quantiles) — toto-1, sundial-base-128m.
Six type-routed families, each with its own forecast, forecast/ensemble, and models route. Every ensemble call also accepts memberOverrides — a per-member config shadow so you can give, say, chronos a different contextLength than moirai in the same ensemble request without four separate calls. Ask a model for a type it doesn’t support and you get a clean 400 telling you which slugs are actually valid for that type, not a stack trace.
The Sundial problem, or: why there’s a second process hiding in my container
This is the part I actually like talking about. Four of the five foundation models — chronos-2, timesfm-2.5, moirai-2, toto-1 — share one Python venv at /opt/venv with transformers==4.57.6. Sundial-base-128m can’t join that party: its published model code reaches into DynamicCache internals — seen_tokens, get_max_length, get_usable_length, plus a 4D attention-mask shape — that got removed upstream in transformers 4.42+. You can’t shim that from the outside; it breaks deeper inside transformers itself than a compat wrapper can reach.
So Sundial gets its own venv, /opt/sundial-venv, pinned to transformers==4.40.1, running as a completely separate FastAPI process — sundial_worker/server.py — talking to the main service over a Unix domain socket via httpx.AsyncHTTPTransport(uds=...). From the main process’s point of view, models/sundial.py looks exactly like every other backend: get_model() waits for /healthz, predict POSTs to /forecast or /samples. It has no idea it’s talking cross-process. The entrypoint script starts the sidecar in a while true bash loop, and if it dies — OOM, a bad snapshot download, whatever — it relaunches within about two seconds, logging [sundial] ... restarting in 2s while it does. Mid-restart requests just get a 503 instead of the whole container falling over. It’s a genuinely reusable pattern too: any future model with a dependency conflict that can’t be papered over gets its own dedicated venv, its own worker, and a thin adapter module — same shape, different socket.
Driving It
docker run -d --name predictalot
-v $HOME/predictalot-models:/models
-e PREDICTALOT_AUTH_TOKENS=changeme
-p 8080:8080
psyb0t/predictalot:latest
# Zero-shot forecast — no training, no persisted state, just quantiles back
curl -s https://ciprian.51k.eu80/v1/timeseries/univariate/forecast
-H "Authorization: Bearer changeme" -H "Content-Type: application/json"
-d '{"model":"chronos-2","context":[[10,11,12,13,14,15,16,17,18,19,20]],"config":{"horizon":5}}' | jqThat’s the entire zero-shot loop. No fit, no epochs, no “wait for training to converge.” You either need the model to already know how to forecast generic sequences, or you don’t use it. For the trained side, it’s a different contract — train once, forecast many times against the same stored model:
curl -s https://ciprian.51k.eu80/v1/tabular/train
-H "Authorization: Bearer changeme" -H "Content-Type: application/json"
-d '{"modelId":"my-model","backend":"lightgbm","target":[[100,101,99]],
"features":[{"rsi":[55,58,60],"macd":[0.3,0.4,0.35]}],
"config":{"mode":"direction","horizon":3,"nEstimators":400}}' | jq
curl -s https://ciprian.51k.eu80/v1/tabular/forecast
-H "Authorization: Bearer changeme" -H "Content-Type: application/json"
-d '{"modelId":"my-model","features":[{"rsi":[58],"macd":[0.4]}]}' | jqNine tabular backends and three meta-learners
The tabular side isn’t a single library wrapped in an endpoint — it’s nine registered backend slugs spanning seven distinct algorithm families, each with a lazy-loaded module so a dev image without the heavy ML stack can still import the package for anything that doesn’t touch tabular: boosting (lightgbm, xgboost, hist-gbt), bagging (random-forest), linear (logistic — classifier, Ridge, or QuantileRegressor depending on mode), neural (mlp), kernel (svm-rbf), distance (knn), and independence (naive-bayes, which pairs GaussianNB for direction with BayesianRidge for value/quantile). Every backend supports three forecast modes — direction (sign of the move), value (raw regression), quantile (distribution) — and every one takes the same tier-2 knobs where applicable: categoricalFeatures, monotonicConstraints, classWeight, sampleWeight, plus a per-backend extra escape hatch for the hyperparameters that don’t fit a shared schema.
On top of the nine base backends sit three composite meta-learner endpoints, each training and persisting atomically because you genuinely cannot do this correctly with separate calls:
- calibrated — a base learner plus a post-hoc Platt-sigmoid or isotonic calibrator fit on a held-out, time-ordered tail. Direction-mode only. The point: a raw tree classifier saying “prob_up = 0.7” often means something closer to 55% real hit rate. Calibration is what makes that number honest enough to size a position against.
- stacking — K base learners plus a meta-learner trained on K-fold out-of-fold predictions from the bases, which are then retrained on the full pool for the shippable model. Useful when your bases actually disagree; useless if they’re all GBTs making correlated mistakes.
- diversified — trains a candidate pool, scores each on out-of-fold performance, greedily keeps a subset whose pairwise OOF correlation stays under a threshold, equal-weights the survivors. Supports all three modes. The response ships the full correlation matrix so you can see exactly which candidates got dropped and why.
Every trained model — base or meta — lives under /models/tabular/{modelId}/ and survives a container restart if you bind-mount that path. Delete it with a DELETE /v1/tabular/models/{id} call, which is the one destructive, irreversible operation in the whole API — treat it accordingly.
MCP: twenty-six tools, and tabular deliberately isn’t one of them
/mcp is a streamable-HTTP MCP server that mirrors the foundation-model side of the API — one named tool per (type, model) cell, plus a per-type ensemble tool and a per-type listing tool. I counted them straight out of the type-membership matrix instead of trusting the docstring: 5 model tools for univariate, 3 for multivariate, 2 for covariates-past, 1 for covariates-future, 1 for covariates-both, 2 for samples — 14 per-model tools total — plus 6 ensemble tools and 6 listing tools, one pair per type. Twenty-six MCP tools, all named things like forecast_univariate_chronos_2 or forecast_samples_sundial_base_128m, deliberately not one polymorphic forecast(type=..., model=...) call, because LLM agents pick named capabilities more reliably than they pick the right combination of two enum arguments.
Tabular isn’t in that list yet, on purpose. The tabular surface is stateful — you train under a modelId, then forecast against it some unknown amount of time later — and MCP’s tool-discovery model assumes stateless calls. Bolting session state onto that or inventing a submit-features-get-inline-prediction composite endpoint are both on the table; until one ships, MCP gets the zero-shot side and tabular stays plain HTTP.
Does it actually work? (the honest numbers)
The repo ships a real benchmark — make bench against academic datasets (AirPassengers, Shampoo Sales, Daily-Min-Temperatures) and live real-world pulls (NOAA CO2, Binance gold, Binance BTC) — and the takeaways doc doesn’t sand off the ugly parts, which is the entire reason I trust it enough to build on. No single foundation model wins across the board — toto-1 takes CO2, BTC, and Shampoo; sundial takes Gold and Daily Temps; moirai takes AirPassengers — and a uniform-weight ensemble loses to the best individual model on every real-world dataset tested, because averaging in a model with the wrong inductive bias just drags the mean down. TimesFM-2.5 comes out weakest on every dataset benchmarked, slowest and worst-or-near-worst sMAPE, to the point the docs themselves suggest zeroing its ensemble weight until a newer release ships.
And here’s the line that matters most for anyone reading this as a trading tool: the best real-world result anywhere in the benchmark is toto-1 on BTC/USDT at 2.02% sMAPE against a 3.18% naive baseline. That’s a real edge over “just repeat yesterday’s value.” It is also nowhere close to “trade on this.” The docs say it themselves — foundation models don’t beat the market. predictalot gives you a number and a confidence band around that number. It does not give you a position size, a stop-loss, or permission to YOLO your account. If you want that, go read a horoscope, at least those are free.
One more thing worth knowing before you point this at a commercial project: the code itself is WTFPL — despite what the README’s license table says about “MIT,” the actual LICENSE file in the repo is Do What The Fuck You Want, which, fittingly, is what I’m telling you to do with it. The foundation model weights are a different story and don’t inherit that freedom — chronos-2, timesfm-2.5, toto-1, and sundial-base-128m ship under Apache 2.0, but moirai-2 is CC-BY-NC-4.0, non-commercial only. If your forecasting pipeline is feeding a for-profit trading operation, either don’t route through moirai or go read that license yourself instead of trusting a blog post about it.
The boring hardening bits that keep this from being a toy
- Auth is bearer-token, constant-time compared via
hmac.compare_digeston UTF-8-encoded bytes — specifically encoded rather than compared as rawstr, becausecompare_digestonstris ASCII-only and a single non-ASCII byte in a bad token would otherwise crash the auth path with a 500 instead of a clean 401. Tokens can also arrive as anapiTokenquery param for MCP clients that can’t set headers. - Body-size limiting is two layers deep on purpose: a cheap reject on a declared
Content-Lengthheader when present, plus a streaming byte-counter wrapped around the ASGIreceivecallable so a client that omitsContent-Length— legal under chunked HTTP/1.1 — can’t bypass the cap and get an unbounded body buffered before Pydantic even runs. - An idle sweeper runs every 60 seconds and unloads any foundation model that’s sat idle past its configurable timeout, freeing VRAM without you having to babysit it.
- Supply-chain pinning — the heavy ML dependencies install via
--require-hashesagainst a committed, hash-locked requirements file, paired with auvexclude-newerage-gate at lockfile-generation time. A registry swapping bytes under an existing version number fails the install, not the runtime. - Two Docker images, CPU and CUDA, both amd64-only — the CPU image because PyTorch’s CPU index has no manylinux aarch64 wheel at the pinned
torch==2.4.1, the CUDA image because that’s just where CUDA support currently lives. The CUDA image also runs fine without--gpus, useful for debugging without a GPU box handy.
Forecasts, Not Advice
predictalot is the forecasting primitive I wanted sitting next to wickworks — same stance, different layer. wickworks hands you bars in, primitives out, never signals. predictalot hands you context in, forecasts out, still never signals. Feed it five zero-shot models when you don’t have training data, feed it your own engineered features across nine trained backends when you do, and either way you’re the one who decides what a quantile band actually means for your position. If you’ve already got mt5-httpapi pulling candles out of MetaTrader, wiring those straight into predictalot’s context array is an afternoon of glue code, not a rewrite. And if you’d rather run the whole stack — this plus whatever LLM tooling you’ve got — on infrastructure you actually own instead of renting it by the token, that’s what aigate is for.
Grab it at github.com/psyb0t/docker-predictalot. It’s not going to make you rich. It’s going to give you honest numbers about a genuinely uncertain future, which is a lot more than most forecasting products on the market are willing to admit they’re selling.