Time Series Foundation Models On A CPU: Mistral's Timestral For Intraday Volume And Volatility, With A Backtest Gate In Python
On 15 October 2026 Mistral released Timestral, open-weight time series foundation models from 5M to 209M parameters that run on a CPU in 10 to 140 ms per forecast, by Mistral's own timing. That makes continuous intraday volume, volatility and client flow estimates affordable for every instrument. This guide builds it in Python: quantile volume curves from 15-minute bars, a VWAP schedule and risk bands under hard limits, and a walk-forward gate against seasonal naive and GARCH(1,1) baselines that refuses to switch models unless Timestral wins.
AlchmAI Engineering18 min read
10 to 140 ms
Mistral's CPU timing for a one-day-ahead forecast from three weeks of 15-minute history on an 8-thread CPU, from the 5M model to the 209M model
4 sizes
Of Mistral's Timestral time series foundation models: 5M, 28M, 123M and 209M parameters, with open weights under a Modified MIT licence
32,768
Time steps of context Timestral accepts, roughly 341 days of round-the-clock 15-minute bars
618 points
For the Timestral launch on Hacker News, with 122 comments when we checked
On 15 October 2026 Mistral AI released Timestral, a family of time series foundation models in four sizes, from 5 million to 209 million parameters, with open weights on Hugging Face and no GPU needed to run them. The launch post says it plainly: 'Timestral does not need a GPU at inference.' Mistral reports that on an 8-thread CPU a one-day-ahead forecast from three weeks of 15-minute history takes 10 ms with the 5M model and 140 ms with the 209M model. For a broker or a quant team that changes what is affordable to estimate continuously. An intraday volume profile, a realised volatility band or a client flow curve can be refreshed for every instrument on an ordinary server next to the tick store, instead of waiting in a queue for a GPU.
Mistral also says the 209M model ranks first zero-shot on the GIFT-Eval, TIME and fev-bench benchmarks, and that the 28M model beats Chronos-2, a model about four times its size. These are Mistral's own benchmark claims on public datasets, and none of those datasets is your order book. The launch page names the use case directly: 'Finance teams forecast flows, volumes, and volatility to inform positions, hedging, and treasury'. This guide builds the operational half of that sentence in Python: 15-minute bars and covariates from ticks, quantile volume curves through a thin adapter, a VWAP participation schedule and risk bands under hard limits with an approval gate and a kill switch, and a walk-forward backtest against a seasonal naive baseline and GARCH(1,1) that keeps the current model in place on any instrument where Timestral does not win.
1. How Do You Run Time Series Foundation Models On 15-Minute Bars?
Start where the data already is. Ticks become 15-minute bars with volume, trade count, the median quoted spread in basis points, and the absolute log return from the first to the last trade in the bar, which is the volatility target in this guide. Intraday volume is strongly seasonal: busy after the open, quieter at midday, busy into the close, with spikes at scheduled events such as a Bank of England rate decision at 12:00 London time. Those patterns are exactly what known-future covariates are for, so every bar carries its slot in the session, its weekday and an event flag drawn from your own event calendar. Spread and trade count are past-only covariates because nobody knows them in advance.
History is 15 sessions, the three weeks Mistral used in its CPU timing. A London equity session from 08:00 to 16:30 has 34 fifteen-minute bars, so that is 510 steps; a 24-hour FX book has 96 bars a day, or 1,440 steps. Both sit well inside the 32,768-step context. Volume goes in as log volume because it is heavy-tailed, and the quantiles come back through the inverse transform. Quantiles survive a monotone transform; means do not, which is one more reason to work in quantiles.
The code talks to the model through a thin adapter with one method, forecast(history, horizon, covariates), which returns quantiles. The Timestral 1.0 documentation page we checked lists the weights (mistralai/Timestral-209M on Hugging Face), the Modified MIT licence, the 32K context and the quantile output, but it does not print a Python loading call, and neither it nor the launch post states an API model ID or pricing. So the adapter takes a short predict function that you write around the official loader from Mistral's documentation and model card, and nothing else in this guide touches the model library. The keyword names in that predict function are ours, not Mistral's. When the package or weights change, the diff is one file.
# Python 3.11, pandas 2.2, numpy 2.1
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable, Protocol
import numpy as np
import pandas as pd
BAR = "15min"
LEVELS: tuple[float, ...] = (0.1, 0.25, 0.5, 0.75, 0.9)
HISTORY_SESSIONS = 15 # three weeks, as in Mistral's CPU timing
SESSION = ("08:00", "16:30") # London equity continuous trading
TZ = "Europe/London"
def build_bars(ticks: pd.DataFrame) -> pd.DataFrame:
"""ticks: UTC DatetimeIndex; columns price, size, bid, ask. One instrument."""
t = ticks.tz_convert(TZ).between_time(*SESSION, inclusive="left")
g = t.resample(BAR, label="left", closed="left")
mid = (t["bid"] + t["ask"]) / 2
bars = pd.DataFrame({
"volume": g["size"].sum(),
"trades": g["size"].count(),
"open": g["price"].first(),
"close": g["price"].last(),
"spread_bps": ((t["ask"] - t["bid"]) / mid * 1e4).resample(BAR).median(),
})
bars = bars.between_time(*SESSION, inclusive="left") # resample fills the nights
day_volume = bars.groupby(bars.index.normalize())["volume"].transform("sum")
bars = bars[day_volume > 0] # weekends, holidays, dead feeds
bars["abs_ret_bps"] = (np.log(bars["close"] / bars["open"]).abs() * 1e4).fillna(0.0)
bars["spread_bps"] = bars["spread_bps"].ffill()
return bars
def slot_of(idx: pd.DatetimeIndex) -> np.ndarray:
return ((idx - idx.normalize()) // pd.Timedelta(BAR)).to_numpy()
def session_index(day: pd.Timestamp) -> pd.DatetimeIndex:
"""Bars for one trading day. Take day from your exchange calendar, not a weekday rule."""
d = pd.Timestamp(day).date()
start = pd.Timestamp(f"{d} {SESSION[0]}", tz=TZ)
end = pd.Timestamp(f"{d} {SESSION[1]}", tz=TZ)
return pd.date_range(start, end, freq=BAR, inclusive="left")
def known_future(idx: pd.DatetimeIndex, events: pd.DatetimeIndex) -> pd.DataFrame:
"""Known in advance for every bar, past and future."""
return pd.DataFrame({
"slot": slot_of(idx),
"weekday": idx.dayofweek,
"event": idx.floor(BAR).isin(events.floor(BAR)).astype(np.int8),
}, index=idx)
@dataclass(frozen=True)
class Covariates:
past: pd.DataFrame # observed up to now only
future: pd.DataFrame # history plus horizon
@dataclass(frozen=True)
class QuantileForecast:
index: pd.DatetimeIndex
levels: tuple[float, ...]
values: np.ndarray # shape (horizon, len(levels))
model_id: str
class Forecaster(Protocol):
model_id: str
levels: tuple[float, ...]
def forecast(self, history: np.ndarray, horizon: int, covariates: Covariates) -> np.ndarray: ...
class TimestralAdapter:
"""The one class that touches the model library. predict is your short wrapper around
the official loader in Mistral's docs and the mistralai/Timestral-209M model card;
its keyword names are ours. Pin the package version and weights revision beside it."""
def __init__(self, predict: Callable[..., np.ndarray], model_id: str,
levels: tuple[float, ...] = LEVELS):
self.predict, self.model_id, self.levels = predict, model_id, levels
def forecast(self, history: np.ndarray, horizon: int, covariates: Covariates) -> np.ndarray:
return np.asarray(self.predict(
context=history.astype(np.float32),
horizon=horizon,
quantile_levels=self.levels,
past_covariates=covariates.past.to_numpy(dtype=np.float32),
future_covariates=covariates.future.to_numpy(dtype=np.float32),
), dtype=np.float64)
class ForecastRejected(RuntimeError):
pass
def forecast_curve(model: Forecaster, bars: pd.DataFrame, target: str,
fut_idx: pd.DatetimeIndex, events: pd.DatetimeIndex) -> QuantileForecast:
days = bars.index.normalize().unique()[-HISTORY_SESSIONS:]
hist = bars[bars.index.normalize().isin(days)]
y = hist[target].to_numpy(dtype=np.float64)
if target == "volume":
y = np.log1p(y) # heavy tails; quantiles survive the inverse transform
cov = Covariates(past=hist[["spread_bps", "trades"]],
future=known_future(hist.index.append(fut_idx), events))
q = model.forecast(y, len(fut_idx), cov)
if q.shape != (len(fut_idx), len(model.levels)) or not np.isfinite(q).all():
raise ForecastRejected(f"{model.model_id}: output {q.shape}, finite={np.isfinite(q).all()}")
q = np.sort(q, axis=1) # repair quantile crossing
if target == "volume":
q = np.expm1(q)
return QuantileForecast(fut_idx, model.levels, np.clip(q, 0.0, None), model.model_id)The shape and finiteness check matters more than it looks. A model that returns the wrong horizon or a NaN raises ForecastRejected, and the caller falls back to the last approved curve rather than scheduling against garbage. Pin the forecasting process to its own cores: Mistral's timing assumes 8 threads, and a forecaster competing with the feed handler for CPU is a latency problem you created yourself.
What Mistral Has Published, And What It Has Not
- Published: four sizes (5M, 28M, 123M, 209M), a 32,768-step context, CPU inference timings, quantile outputs, past-only and known-future covariates, and open weights under a Modified MIT licence. Read the licence file before production use; modified means it is not plain MIT.
- Claimed by Mistral: first place zero-shot on GIFT-Eval, TIME and fev-bench for the 209M model, and the 28M model ahead of Chronos-2. Treat both as the vendor's results until your own backtest agrees.
- Not stated on the pages we checked: a hosted API model ID or pricing. Self-hosting the open weights is the path this guide assumes.
2. How Do Quantile Volume Curves Become A VWAP Schedule And Risk Bands?
A VWAP algorithm works a parent order across the session in proportion to expected volume, so that its average fill tracks the volume-weighted average price. The volume curve is the input, and a quantile curve is better than a point estimate because it separates two questions. The shape of the schedule comes from the median. The capacity of each bar comes from the low quantile, here the 0.1 quantile: a 10% participation cap applied to median volume is breached on every quiet afternoon, while the same cap applied to q10 volume is exceeded only in the bars where volume comes in below q10, which a calibrated model expects about one bar in ten. That is why section 3 tests calibration and not only accuracy.
When the order does not fit the capacity, the plan says so. It never raises participation to make the numbers work; it reports the shortfall and a person decides whether to widen the window or reduce the size. The same file turns the 0.9 quantile of the absolute 15-minute return into per-bar risk bands, clipped to a floor and a cap set by the risk committee. Bands serve as price collars on child orders, as alert thresholds on a risk dashboard, and as one input to the next session's margin review. A model can widen a band within the cap, but it can never take it below the committee's floor, and any day-on-day move of more than 30% in either direction waits for a named person.
A plan is approved automatically only when four conditions hold: the model has passed the backtest gate for this instrument, the curve moved less than 20% from the last approved one, capacity covers the order, and no band moved beyond its limit. Anything else is held for approval with the reasons attached. The kill switch blocks every plan and the execution engine falls back to the last approved static curve. Every plan and every approval goes into a hash-chained, append-only log.
# Python 3.11, numpy 2.1
from __future__ import annotations
import hashlib
import json
import os
import time
from dataclasses import asdict, dataclass, field
from pathlib import Path
import numpy as np
from forecast.volume_curve import QuantileForecast
@dataclass(frozen=True)
class Limits:
max_participation: float = 0.10 # of the q10 volume estimate, per bar
max_child_qty: int = 50_000 # per bar, whatever the forecast says
max_curve_shift: float = 0.20 # total variation vs the last approved curve
band_floor_bps: float = 15.0 # risk committee floor: a model never goes below it
band_cap_bps: float = 400.0
max_band_shift: float = 0.30 # largest day-on-day relative move without sign-off
class AuditLog:
"""Append-only, hash-chained JSON lines. If the write fails, the plan fails."""
def __init__(self, path: str | None = None):
self.path = Path(path or os.environ.get("FORECAST_AUDIT_LOG", "audit/forecast.jsonl"))
self.path.parent.mkdir(parents=True, exist_ok=True)
last = self.path.read_text().splitlines()[-1:] if self.path.exists() else []
self.prev = json.loads(last[0])["hash"] if last else "GENESIS"
def append(self, event: dict) -> str:
body = json.dumps({"at": time.time(), "prev": self.prev, **event}, sort_keys=True, default=float)
digest = hashlib.sha256(body.encode()).hexdigest()
with self.path.open("a") as f:
f.write(json.dumps({"hash": digest, "body": body}) + "\n")
f.flush()
os.fsync(f.fileno())
self.prev = digest
return digest
def kill_switch_on() -> bool:
return os.environ.get("FORECAST_KILL_SWITCH") == "1" or Path("/run/forecast/KILL").exists()
@dataclass
class Plan:
status: str # auto_approved | needs_approval | approved | blocked
model_id: str
child_qty: list[int] = field(default_factory=list)
band_bps: list[float] = field(default_factory=list)
reasons: list[str] = field(default_factory=list)
audit_hash: str = ""
def _q(fc: QuantileForecast, level: float) -> np.ndarray:
return fc.values[:, fc.levels.index(level)]
def build_plan(order_qty: int, volume: QuantileForecast, absret: QuantileForecast, limits: Limits,
last_curve: np.ndarray | None, last_band: np.ndarray | None,
model_promoted: bool, audit: AuditLog) -> Plan:
if kill_switch_on():
plan = Plan("blocked", volume.model_id, reasons=["kill switch: execution uses the last approved static curve"])
plan.audit_hash = audit.append({"kind": "plan", **asdict(plan)})
return plan
reasons: list[str] = []
# Schedule shape from the median; capacity from q10, so a quiet day cannot breach the cap.
median = _q(volume, 0.5)
curve = median / median.sum()
cap = np.minimum(np.floor(limits.max_participation * _q(volume, 0.1)), limits.max_child_qty)
planned = np.minimum(np.floor(order_qty * curve), cap)
remaining = order_qty - planned.sum()
for i in np.argsort(planned - cap): # most spare capacity first
if remaining <= 0:
break
add = min(cap[i] - planned[i], remaining)
planned[i] += add
remaining -= add
if remaining > 0:
reasons.append(f"short {int(remaining)} at {limits.max_participation:.0%} of q10 volume: "
"widen the window or cut the size; participation is never raised silently")
if last_curve is None or len(last_curve) != len(curve):
reasons.append("no comparable approved curve")
elif (shift := 0.5 * np.abs(curve - last_curve).sum()) > limits.max_curve_shift:
reasons.append(f"curve moved {shift:.0%} from the last approved one")
# Risk bands: q90 of the absolute 15-minute return, inside committee limits.
band = np.clip(_q(absret, 0.9), limits.band_floor_bps, limits.band_cap_bps)
if last_band is None or len(last_band) != len(band):
reasons.append("no comparable approved bands")
elif (move := float(np.max(np.abs(band / last_band - 1)))) > limits.max_band_shift:
reasons.append(f"a band moved {move:.0%} day on day")
if not model_promoted:
reasons.append(f"{volume.model_id} has not passed the backtest gate for this instrument")
plan = Plan("needs_approval" if reasons else "auto_approved", volume.model_id,
planned.astype(int).tolist(), np.round(band, 1).tolist(), reasons)
plan.audit_hash = audit.append({"kind": "plan", **asdict(plan)})
return plan
def approve(plan: Plan, approver_id: str, note: str, audit: AuditLog) -> Plan:
"""A named person signs off a held plan. The execution engine accepts only
auto_approved or approved plans, and keeps its own order-level limits."""
if plan.status != "needs_approval":
raise ValueError(f"cannot approve a plan in status {plan.status}")
if kill_switch_on():
raise RuntimeError("kill switch is on")
if not note.strip():
raise ValueError("an approval needs a written reason")
plan.status = "approved"
plan.audit_hash = audit.append({"kind": "approval", "plan_hash": plan.audit_hash,
"approver": approver_id, "note": note})
return plan- The plan is an input to the execution engine's own limits, not a replacement for them. Order-level notional caps, price collars and the engine's kill switch stay where they are.
- Count how many plans need approval each morning. A model that needs sign-off on most instruments every day is not saving the desk any time, whatever its benchmark rank.
3. Does Timestral Beat GARCH And A Seasonal Naive Baseline On Your Instruments?
Here is the uncomfortable part. A benchmark win is not your market. Public benchmark datasets do not contain your closing auctions, your half-day sessions, your index rebalance days or your client mix, and a zero-shot model has never seen any of them. Before anyone relies on a Timestral curve, it has to beat the baselines a quant team would use anyway, on your instruments, out of sample. Two baselines are enough to start. The seasonal naive baseline takes the empirical quantiles of the same bar over the last 20 sessions; for intraday volume it is hard to beat because the seasonality does most of the work. GARCH(1,1), generalised autoregressive conditional heteroskedasticity with one lag of each term, is the standard volatility baseline. Here it is fitted with the arch package to 15-minute returns with the intraday volatility profile divided out, and its quantiles of the absolute return follow from the normal assumption.
Two scores decide it. Pinball loss scores a quantile at level τ by charging τ times the miss when the outcome lands above it and 1 minus τ times the miss when it lands below; averaged over all levels it scores the whole distribution, and lower is better. Coverage asks whether the quantiles mean what they say: the band from q10 to q90 should contain about 80% of outcomes, and the share of outcomes below each quantile should be close to its level. A model can win on pinball loss and still be miscalibrated in the low tail that sets your participation cap, so the gate checks both.
The promotion gate runs per instrument and per target, and its default answer is no. A candidate needs at least 60 scored days, a mean pinball loss at least 5% below the best comparator (the baselines plus whatever is live now), wins on at least 55% of days, an 80% band that covers between 74% and 86% of outcomes, every quantile's hit rate within 5 points of its level, and no failed days. Candidates are tried smallest first, so the smallest Timestral size that passes takes the CPU budget. By Mistral's timing the 209M model costs 140 ms a call, so 100 test days for one instrument and one target is about 14 seconds of model time.
# Python 3.11, arch 7.2, scipy 1.14, pandas 2.2, numpy 2.1
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol
import numpy as np
import pandas as pd
from arch import arch_model
from scipy.stats import norm
from execution.vwap_plan import AuditLog
from forecast.volume_curve import LEVELS, Forecaster, forecast_curve, slot_of
class QuantileModel(Protocol):
name: str
def predict(self, train: pd.DataFrame, fut_idx: pd.DatetimeIndex) -> np.ndarray: ...
class SeasonalNaive:
"""Empirical quantiles of the same bar over the last 20 sessions."""
def __init__(self, target: str, sessions: int = 20):
self.name, self.target, self.sessions = "seasonal_naive", target, sessions
def predict(self, train: pd.DataFrame, fut_idx: pd.DatetimeIndex) -> np.ndarray:
days = train.index.normalize().unique()[-self.sessions:]
recent = train[train.index.normalize().isin(days)]
table = recent[self.target].groupby(slot_of(recent.index)).quantile(list(LEVELS)).unstack()
return table.reindex(slot_of(fut_idx)).to_numpy()
class Garch11:
"""GARCH(1,1) on 15-minute returns with the intraday profile divided out. abs_ret_bps only."""
name = "garch11"
def predict(self, train: pd.DataFrame, fut_idx: pd.DatetimeIndex) -> np.ndarray:
r = (np.log(train["close"] / train["open"]) * 1e4).fillna(0.0)
s = slot_of(train.index)
prof = np.sqrt((r ** 2).groupby(s).mean())
prof = prof.clip(lower=prof[prof > 0].min())
z = r / prof.reindex(s).to_numpy()
res = arch_model(z.iloc[-5000:], mean="Zero", vol="GARCH", p=1, q=1,
dist="normal", rescale=False).fit(disp="off")
var = res.forecast(horizon=len(fut_idx), reindex=False).variance.to_numpy()[-1]
sigma = np.sqrt(var) * prof.reindex(slot_of(fut_idx)).to_numpy()
return np.outer(sigma, norm.ppf((1 + np.asarray(LEVELS)) / 2)) # |r| = sigma * |Z|
class Foundation:
def __init__(self, f: Forecaster, target: str, events: pd.DatetimeIndex):
self.name, self.f, self.target, self.events = f.model_id, f, target, events
def predict(self, train: pd.DataFrame, fut_idx: pd.DatetimeIndex) -> np.ndarray:
return forecast_curve(self.f, train, self.target, fut_idx, self.events).values
def pinball(y: np.ndarray, q: np.ndarray) -> float:
lv = np.asarray(LEVELS)
d = y[:, None] - q
return float(np.mean(np.maximum(lv * d, (lv - 1) * d)))
def walk_forward(bars: pd.DataFrame, target: str, models: list[QuantileModel],
test_days: list[pd.Timestamp]) -> pd.DataFrame:
"""Each day is scored using only the bars before it."""
day_of = bars.index.normalize()
rows: list[dict] = []
for d in test_days:
train, test = bars[day_of < d], bars[day_of == d]
y = test[target].to_numpy(dtype=float)
for m in models:
try:
q = np.sort(m.predict(train, test.index), axis=1)
if not np.isfinite(q).all():
raise ValueError("non-finite quantiles")
except Exception as e: # a model that cannot answer has failed the day
rows.append({"day": d, "model": m.name, "pinball": np.nan, "error": repr(e)})
continue
rows.append({"day": d, "model": m.name, "pinball": pinball(y, q),
"covered": float(np.mean((y >= q[:, 0]) & (y <= q[:, -1]))),
**{f"hit_{l}": float(np.mean(y <= q[:, i])) for i, l in enumerate(LEVELS)}})
return pd.DataFrame(rows)
@dataclass(frozen=True)
class Gate:
min_days: int = 60
min_skill: float = 0.05 # mean pinball at least 5% below the best comparator
min_win_rate: float = 0.55
coverage: tuple[float, float] = (0.74, 0.86) # nominal 80% band, q10 to q90
max_hit_error: float = 0.05
def promotion_decision(results: pd.DataFrame, instrument: str, target: str, candidate: str,
comparators: list[str], incumbent: str, gate: Gate, audit: AuditLog) -> dict:
pin = results.pivot(index="day", columns="model", values="pinball")
best = pin[comparators].mean().idxmin()
reasons: list[str] = []
if (failed := int(pin[candidate].isna().sum())):
reasons.append(f"failed on {failed} days")
both = pin[[candidate, best]].dropna()
skill = float(1 - both[candidate].mean() / both[best].mean()) if len(both) else float("nan")
win = float((both[candidate] < both[best]).mean()) if len(both) else 0.0
if len(both) < gate.min_days:
reasons.append(f"{len(both)} scored days, need {gate.min_days}")
if not skill >= gate.min_skill:
reasons.append(f"skill {skill:.1%} vs {best}, need {gate.min_skill:.0%}")
if win < gate.min_win_rate:
reasons.append(f"beats {best} on {win:.0%} of days")
c = results[(results.model == candidate) & results.pinball.notna()]
cov = float(c["covered"].mean()) if len(c) else 0.0
if not gate.coverage[0] <= cov <= gate.coverage[1]:
reasons.append(f"80% band covered {cov:.0%}")
for l in LEVELS:
hit = float(c[f"hit_{l}"].mean()) if len(c) else 0.0
if abs(hit - l) > gate.max_hit_error:
reasons.append(f"q{round(l * 100)} hit rate {hit:.0%}")
decision = {"kind": "promotion", "instrument": instrument, "target": target,
"candidate": candidate, "comparator": best, "incumbent": incumbent,
"skill": skill, "win_rate": win, "coverage": cov, "promote": not reasons,
"live_model": candidate if not reasons else incumbent, "reasons": reasons}
decision["audit_hash"] = audit.append(decision)
return decision
def choose_live_model(bars: pd.DataFrame, instrument: str, target: str, baselines: list[QuantileModel],
candidates: list[QuantileModel], incumbent: str, audit: AuditLog,
gate: Gate = Gate(), test_sessions: int = 100) -> str:
"""Candidates smallest first. Nothing switches unless a candidate clears every rule."""
days = list(bars.index.normalize().unique()[-test_sessions:])
res = walk_forward(bars, target, baselines + candidates, days)
comparators = sorted({m.name for m in baselines} | ({incumbent} & set(res["model"])))
for c in candidates:
if c.name == incumbent:
continue
if promotion_decision(res, instrument, target, c.name, comparators, incumbent, gate, audit)["promote"]:
return c.name
return incumbentRun it with SeasonalNaive and Garch11 as baselines for the volatility target, SeasonalNaive alone for volume, and Timestral sizes as candidates. Re-run it monthly and after any change to the weights revision, the package version or the covariate set. The same code that promotes a model can demote it, and every decision, including each refusal and its reasons, lands in the audit log that the morning plans already point to.
“A benchmark win is Mistral's result on public datasets. A promotion is your result on your instruments, and only the second one goes near an execution schedule.”
Where This Belongs: Brokers, Prop Firms, Quant Teams And Startups
- 01Brokers and CFD providers: estimate client flow per instrument per hour as quantiles, and plan hedging capacity and dealing desk staffing against the 0.9 quantile rather than the mean. Feed the volatility bands into the risk and surveillance dashboard as alert thresholds, with the committee floor intact.
- 02Prop trading firms: use per-instrument volatility bands as one input when reviewing intraday loss limits, to see where a limit sits inside ordinary 15-minute noise. The limit itself stays a human decision.
- 03Research and quant teams: run the walk-forward gate on your own universe before anyone wires a forecaster into execution, and keep ticks, bars, covariates, model versions and gate decisions in one place so every backtest can be reproduced.
- 04Fintech and trading startups: the 5M and 28M models are small enough to run beside an existing service on CPU. Ship the seasonal naive baseline first and let the gate decide when, and for which instruments, Timestral replaces it.
The Bottom Line
Time series foundation models that run on a CPU make continuous estimates of intraday volume, volatility and client flow cheap enough to produce for every instrument, every session, beside the tick store. Mistral's Timestral, released on 15 October 2026 with open weights, offers four sizes from 5M to 209M parameters, 32,768 steps of context, quantile outputs and, by Mistral's own timing, 10 to 140 ms per one-day forecast on an 8-thread CPU. Its benchmark rankings are Mistral's claims on public datasets. What makes it usable on a desk is the code around it: bars and covariates built from ticks, quantiles turned into a VWAP schedule and risk bands under hard limits with a kill switch and an approval gate, and a walk-forward backtest against seasonal naive and GARCH(1,1) baselines that leaves the live model in place unless the newcomer wins on pinball loss and calibration. That is the trading AI architecture AlchmAI, a London trading technology and AI studio, builds into market data infrastructure for brokers, prop firms and quant teams.
References & Further Reading
- Mistral AI - Timestral (15 October 2026). mistral.ai/news/timestral
- Mistral AI - Timestral 1.0 model documentation. docs.mistral.ai/models/timestral-1-0
- Hugging Face - mistralai/Timestral-209M model card and licence. huggingface.co/mistralai/Timestral-209M
- Hacker News - Search results for Timestral. hn.algolia.com/?q=Timestral
- arch - ARCH models in Python documentation, by Kevin Sheppard. arch.readthedocs.io/en/latest
- AlchmAI - Market data infrastructure. alchmai.com/solutions/market-data-infrastructure
- AlchmAI - Risk and surveillance dashboards. alchmai.com/solutions/risk-and-surveillance-dashboards
AlchmAI Engineering
Engineering, London
Written by the AlchmAI engineering team in Mayfair, London. We build trading platforms, real-time charts, market data pipelines and AI features for brokers, prop firms and fintech teams. The Playbook is where we explain how we approach these systems, with code you can run and sources you can check.
Code in this guide is illustrative and supplied without warranty. Review and test it before production use. Nothing here is investment advice. Important information