Pulse-Check
Validate simulation quality against historical market data.
HTTP endpoints on this page
- POST/validation/run
- POST/validation/run/upload
- GET/validation/jobs
- GET/validation/jobs/{job_id}
- GET/validation/jobs/{job_id}/status
Parameters and response shapes for each are in the API reference.
Pulse-Check is the validation API for Pulse. It scores how closely simulated order book data matches real historical market data for the same symbol and date, using distributional distance metrics (L1 and Wasserstein), the Cont stylised facts, the MIND and FID inception distances on DeepLOB embeddings, and impact response analysis. It is a separate service from the native Pulse simulation API: validation jobs run under /validation and are accessed via client.validation in the Python SDK.
Pulse-Check is available on the Pro tier only. All validation endpoints require a Pro API key — see Plans.
A Pro result never contains the real session. The distances, the per-fact verdicts and the two inception scores are aggregate scalars, so they are returned in full, and the impact response is returned for your simulated runs — that is your own generated data. What is withheld is anything derived from the historical series itself: the distribution histograms, the full stylised-fact series, and the historical half of the impact response.
How it works
- Submit a validation job with
run()— returns ajob_idimmediately - Historical data for the symbol and date is fetched automatically; simulation data comes from your
sim_ids(or uploaded parquet files) - Poll
get_job_status(job_id)untilis_completeis true, then callget_job(job_id)once to fetch the result - Read results: per-metric distances, per-fact stylised-fact verdicts, and the MIND/FID scores
What a job computes
Six areas, one flag each. Every area defaults on, so a job submitted with no flags at all returns everything in the last column.
| Area | Flag | Returned to a Pro key |
|---|---|---|
| Distributional metrics | statistical | distances — L1 and Wasserstein per metric, per run |
| Stylised facts | stylised_facts | stylised_fact_verdicts — pass/fail per fact, per run |
| Impact response | impact | impact_response — the Bouchaud response curves for each simulated run |
| Volume correlation | volume_correlation | volume_correlation — cross-level correlation of book volumes, and the divergence from historical |
| Frechet inception distance | fid | fid_scores — one per run |
| Monge inception distance | mind | mind_scores — one per run |
Methods on this page
Validate simulation runs
The most common flow: run simulations with the native Pulse API, then pass the resulting sim_ids to Pulse-Check. Validation compares each run's sim_data.parquet against historical data for the same symbol and date.
The entry below documents the validation call itself. To poll the job and read its result, see get_job_status() and get_job().
validation.run(symbol, date, provider, exchange, *, sim_ids=None, sim_files=None, statistical=None, stylised_facts=None, impact=None, volume_correlation=None, fid=None, mind=None, lob=None, sample_period=None, match_generated_sample=None, n_levels=10, plots=None)Submit a validation job — platform runs, your own parquet, or both together.Pro
Parameters
- symbol : str
- Trading symbol, e.g.
"TSCO"or"700.HK". - date : str
"YYYY-MM-DD". Historical data for this date is the benchmark, so it must have completed historical processing — use the same calibration dates that work for simulations.- provider : str
- Data provider,
"bmll"or"omd". Required — the same symbol exists under both with different dates and tick sizes. - exchange : str
- Exchange segment, e.g.
"lse"or"hkex_securities". Required. - sim_ids : list of str or dict, optional
- Platform runs to validate. A flat list is one unnamed population; a mapping —
{"fm": [...], "abm": [...]}— compares the named populations, keying the results by name and drawing one series per name instead of averaging them together. - sim_files : list or dict, optional
- Your own runs: paths,
(filename, bytes)pairs, or polars / pandas DataFrames in pulse format. Grouped the same way assim_ids. The historical side is still fetched for you. - statistical, stylised_facts, impact, volume_correlation, fid, mind : bool or None, optional
- One flag per area of checking. Tri-state — see the note below.
- lob : bool or None, optional
- The frames are L2 snapshots only. Anything needing the message stream is skipped rather than failing, and the reason appears in
metadata["areas_skipped"]. - sample_period : str, optional
- In
lobmode, the grid the book is measured on, e.g."1s". Left unset the cadence is read off your generated frames and the historical day is resampled to match; if your frames are event-level there is no grid to match and nothing is resampled. - match_generated_sample : bool, optional
- Match the generated frames' grid even when
sample_periodis given. - n_levels : int, default 10
- Book levels to measure over. The inception distances need all 10.
- plots : bool or list of str, optional
- Unset draws nothing,
Truedraws every figure the enabled areas can draw, and a list draws just those ids — see Plot ids below. Rendered server-side;get_job()writes them to disk.
Returns
dict
Carries the validation job_id to poll with get_job_status().
Notes
At least one of sim_ids and sim_files is required, and both together is allowed — your own model against platform runs, on one set of figures. 25 runs in total across the two.
The six area flags are tri-state. Left unset, a flag is omitted from the request and Pulse-Check applies its own default, which is on for every area. Your tier does not change these defaults. It controls only whether the historical series is returned. Pass False to skip an expensive pass, or True to make one explicit. An explicit value always wins.
There is no ticksize parameter. It is read off the historical day itself — the metadata sidecar, else pulse-format's own metadata for that day. A value you passed never reached the impact response, which always used the looked-up one, so the two could disagree silently. The result now reports metadata["ticksize"] and metadata["ticksize_source"]; a source of "fallback" means none was found and 1.0 was assumed, which puts the impact response in price units rather than ticks.
The run_* keywords, l2_only, plot_data and run_inception_distances are gone: one name per thing. They are now statistical, stylised_facts, impact, volume_correlation, fid, mind and lob. run_pipeline(), run_upload() and inception_distances() are gone too — run() does all three.
Examples
# setup: the runs of a cached baseline
cached = client.simulation.list_cached(symbol="700.HK", scenario="normal")["simulations"][0]
base = cached["example_sim_id"].rsplit(":", 1)[0]
sim_ids = [f"{base}:{i:04d}" for i in range(min(cached["n_runs"], 3))]
job = client.validation.run(
symbol=cached["symbol"],
date=cached["date"],
provider="omd",
exchange="hkex_securities",
sim_ids=sim_ids,
)
print(job["job_id"])# two named populations, compared against each other and the real day
cached = client.simulation.list_cached(symbol="700.HK", scenario="normal")["simulations"][0]
base = cached["example_sim_id"].rsplit(":", 1)[0]
runs = [f"{base}:{i:04d}" for i in range(min(cached["n_runs"], 4))]
job = client.validation.run(
symbol=cached["symbol"],
date=cached["date"],
provider="omd",
exchange="hkex_securities",
sim_ids={"first_half": runs[:2], "second_half": runs[2:]},
plots=["statistical.radar", "stylised_facts.overall"],
)
print(job["job_id"])# your own parquet beside platform runs, on one set of figures
from pathlib import Path
cached = client.simulation.list_cached(symbol="700.HK", scenario="normal")["simulations"][0]
base = cached["example_sim_id"].rsplit(":", 1)[0]
Path("my_sims").mkdir(exist_ok=True)
df = client.simulation.get_sim_data(cached["example_sim_id"])
df.write_parquet("my_sims/mine_0000.parquet")
job = client.validation.run(
symbol=cached["symbol"],
date=cached["date"],
provider="omd",
exchange="hkex_securities",
sim_files={"mine": sorted(Path("my_sims").glob("mine_*.parquet"))},
sim_ids={"platform": [f"{base}:0001"]},
)
print(job["job_id"])Populations
Pass a mapping rather than a list and the job compares populations instead of averaging every run together. Results — distances, distributions, the verdicts and the FID/MIND scores — come back keyed by name, and the summary figures draw one series per population: one radar polygon each, one verdict-grid column each, one correlation heatmap each after the historical one. Runs within a population are pooled. The historical day is measured once, however many populations there are.
sim_ids and sim_files can be given together, so your own model and platform runs land on one set of figures — 25 runs in total across the two. With both present each source becomes a population of its own, so a bare list is given a name rather than merging into whatever else is there:
| Passed | Populations |
|---|---|
| sim_files mapping + sim_ids list | its names, plus platform |
| sim_files list + sim_ids mapping | uploaded, plus its names |
| both lists | uploaded and platform |
| both mappings | their own names |
| either one alone | exactly as before — a list stays one unnamed population |
The uploaded files are measured first and the platform runs follow.
Check job status
Validation jobs move through: pending → running → completed (or failed). Only completed and failed are terminal. Poll the light status call, and fetch the full result once.
validation.get_job_status(job_id)Return where a validation job has got to, without its result payload.Pro
Parameters
- job_id : str
- A validation job handle from
run().
Returns
dict
- job_id : str
- The job identifier, echoed back.
- status : {"pending", "running", "completed", "failed"}
- Where the job has got to.
- message : str
- The failure reason on a failed job, otherwise a progress message. May be empty.
- is_complete : bool
- True once the status is completed or failed, so you do not need to keep that list yourself.
- created_at, updated_at : str
- Job timestamps.
Examples
import time
# setup: your most recent validation job
job_id = client.validation.list_jobs(limit=1)["jobs"][0]["job_id"]
while not client.validation.get_job_status(job_id)["is_complete"]:
time.sleep(5)
result = client.validation.get_job(job_id)
print(f"Status: {result['status']}")
if result["status"] == "failed":
print(f"Error: {result['error']}")
elif result.get("errors"):
print(f"Areas that failed: {result['errors']}")validation.get_job(job_id, plot_dir=None)Return the status and, once finished, the full results and rendered plots of one validation job.Pro
Parameters
- job_id : str
- A validation job handle from
run(). - plot_dir : str, optional
- Where to write any figures the job rendered. Left unset they are written to the current directory; the directory is created if it does not exist. Nothing is written when the job was submitted without
plots.
Returns
dict
- job_id : str
- The job identifier, echoed back.
- status : {"pending", "running", "completed", "failed"}
- Where the job has got to.
- symbol, date : str
- The instrument and day the runs were scored against.
- errors : dict or None
{area: "ExcType: message"}for every area that was requested but failed, or None when every area ran. A completed job can carry it, because one area failing does not fail the job. Check it before you read a missing payload as "nothing computed".- plots : dict
- Rendered figures by category, each entry
{"id", "name", "content_base64"}. Match onid, not on the filename. The SDK decodes these toplot_dirfor you. - plot_paths : list of str
- Added by the SDK when the job returned figures: the paths it wrote them to under
plot_dir. - distances : dict
- Per-metric distances,
{metric: {"l1": [...], "w": [...]}}— one value per simulation run, insim_idsorder, null where a metric failed for that run. - stylised_fact_verdicts : dict
- Per-fact booleans for the historical day and each run. No series, so every tier receives it.
- impact_response : dict or None
- Bouchaud response curves.
lagsandeventsare the axes, andsimulatedholds one{ys, ci_low, ci_high}block per run, each indexed[event][lag]. None if the pass did not run. - impact_response_error : str or None
- Set when the impact pass was requested but failed, so a null
impact_responsecan be told apart from one that was never asked for. - volume_correlation : dict or None
- Cross-level volume correlation: the divergence from historical and the per-run matrices. The historical matrices are left out for a Pro key.
- mind_scores : list of float, dict, or None
- Monge Inception Distance per run, in
sim_idsorder. Null where a run could not be embedded. When you passed named populations it is a dict keyed by population name, each value a list in that population's order. - fid_scores : list of float, dict, or None
- Fréchet Inception Distance per run, with the same ordering, keying and null convention.
- metadata : dict
- Resolved run parameters, book levels used and row counts.
areas_runandareas_skippedsay which areas ran, and why any were skipped.historical_windowsays which stretch of the historical day the runs were scored against. A run that covers part of the day is compared with the same time of day, not the whole day. - error : str
- Error message, when status is failed.
- created_at, completed_at : str
- Job timestamps.
- distributions, stylised_facts : None
- Present in the response model but always null for a Pro key — both are computed from the real historical series.
Examples
# setup: your most recent validation job
job_id = client.validation.list_jobs(limit=1)["jobs"][0]["job_id"]
result = client.validation.get_job(job_id, plot_dir="figures")
print(f"Status: {result['status']}")
print(f"Window: {(result.get('metadata') or {}).get('historical_window')}")
print(f"Failed areas: {result.get('errors')}")Plot ids
plots takes True for every figure the enabled areas can draw, or a list of the ids below for just those. An area switched off draws nothing either way. A figure's filename does not carry the id it was asked for, so every returned entry carries its id — match on that.
| Id | Figure |
|---|---|
| statistical.radar | Distance spider, one polygon per population — drawn once per distance measure, so two files |
| statistical.distribution | Every metric's distribution |
| statistical.distribution.{metric} | One metric, e.g. statistical.distribution.spread |
| stylised_facts.overall | The verdict grid — historical beside one column per population |
| stylised_facts.fact | Every fact |
| stylised_facts.fact.{name} | One fact, e.g. stylised_facts.fact.heavy_tails |
| impact.response | Impact response by event type |
| impact.event | Every event type |
| impact.event.{type} | One event type, e.g. impact.event.MO_1 |
| volume_correlation.levels | Level correlation heatmap |
| volume_correlation.changes | Change correlation heatmap |
| volume_correlation.overall | Both of the above, reported under their own ids |
A Pro figure never draws the historical series — the payload it is drawn from simply has no historical key, so the correlation heatmaps come back as your simulated panels alone. The verdict grid is the exception every tier sees in full: it is booleans, not series.
Validation metrics
With statistical=True, Pulse-Check computes stylised-fact distributions on both the historical and simulated order books, then scores the gap between them with L1 and Wasserstein distances. Lower is better. This distributional approach to scoring generative order book models follows the methodology of LOB-Bench (Nagy et al.). Metrics include:
| Group | Metrics |
|---|---|
| Spread & touch | spread, touch_bid_volume, touch_ask_volume |
| Order flow | obi, ofi_rolling (plus _up / _down / _stay), log_interarrivaltime |
| Volume | total_bid_volume, total_ask_volume, volume_per_minute |
| Price | mid_returns_1s, mid_range_5min |
| Order placement | ask/bid_limit_depth, ask/bid_limit_level |
| Cancellations | time_to_cancel, ask/bid_cancel_depth, ask/bid_cancel_level |
The last two groups need the order-level columns message_type, price and order_id. They are skipped under lob, and automatically when the simulated frames carry book states without order flow — as foundation and diffusion model output does.
The scores come back on a finished job as distances — see get_job() for the shape.
Stylised facts
With stylised_facts on (the default), Pulse-Check runs the eleven Cont stylised facts on the historical day and on every simulated run, and returns the verdicts. A real session is expected to exhibit them; the question a validation answers is whether your simulation does too.
| Fact | Passes when |
|---|---|
| absence_of_autocorrelations | Ljung-Box finds no autocorrelation in 1s returns |
| heavy_tails | Return kurtosis is positive |
| gain_loss_asymmetry | Return skewness is negative at every scored timescale |
| aggregational_gaussianity | The Shapiro-Wilk statistic rises with timescale (positive slope) |
| intermittency | Extreme 1s returns arrive with a Fano factor above 1 |
| conditional_heavy_tails | Volatility-normalised returns are more kurtotic than raw returns |
| volatility_clustering | Ljung-Box finds autocorrelation in absolute 1s returns |
| slow_decay_of_autocorrelations | The absolute-return ACF fits a power law with |slope| in [0.2, 0.4] |
| leverage_effect | Returns correlate negatively with later absolute returns |
| volume_volatility_correlation | Volume and volatility move together (Ljung-Box on their product) |
| asymmetry_in_time_scales | Coarse volatility predicts fine volatility better than the reverse |
{
"heavy_tails": {
"historical": true,
"simulated": [true, true, false, true, true],
"aggregate": {"n": 5, "passes": 4, "verdict": true, "significant": true},
"policy": {
"frequencies": ["50s", "100s", "150s", "200s", "..."],
"judged": ["50s", "100s", "150s"],
"mode": "any"
}
}
}historical is the verdict on the real session and simulated holds one verdict per run, in sim_ids order; null means the fact could not be evaluated on that frame (usually too short). aggregate folds the per-run verdicts into one Monte Carlo signal with a one-sided binomial test against chance at the 5% level — significant says whether the run count was enough to call it. policy reports which sampling frequencies the methodology scores the fact over, which of them the data actually supported (judged), and how they combine: any, all, or slope across the ladder.
Impact response
With impact on (the default), Pulse-Check computes the Bouchaud response function for each simulated run: how far the mid-price moves, in ticks, in the events following a given kind of book event. It follows the same LOB-Bench methodology as the distance metrics, after Eisler, Bouchaud and Kockelkoren (2012).
Events that change a best price are classified into six types — market orders, limit orders and cancellations, each split by whether the event itself moved the mid (_1) or not (_0) — giving MO_0, MO_1, LO_0, LO_1, CA_0 and CA_1. For each, R(l) is the average signed mid-price move from just before the event to l events later, normalised by tick size, over a logarithmic ladder of lags from 1 to about 200. So R(1) is the event's own immediate move and later lags show whether that move persists or reverts. R(1) is zero by construction for the _0 types, so any growth in those curves reflects order-flow predictability rather than the event itself.
| Field | Shape | Description |
|---|---|---|
| lags | list[int] | The event lags l, the x-axis of every curve |
| events | list[str] | The six event types, the row order of every block |
| simulated | list[dict] | One block per run, in sim_ids order |
| simulated[i].ys | list[list[float]] | Mean response, indexed [event][lag] |
| simulated[i].ci_low / ci_high | list[list[float]] | Bootstrapped 99% confidence interval, same indexing |
Compare a run against the published impact laws rather than against the real session: a healthy market order curve is positive and roughly flat in l (impact that persists), while curves that decay towards a common level suggest a model whose price moves are mechanical liquidity effects rather than information.
Inception distances (MIND and FID)
Both metrics compare 96-dimensional DeepLOB embeddings of 100-row L2 windows: FID is the Frechet distance between the two embedding clouds, MIND is a sliced-Wasserstein distance over the same embeddings, which makes it sensitive to differences beyond mean and covariance. Lower is closer to the historical day. Both need the full 10 book levels.
Each score has its own flag on run(), fid and mind. They share one embedding pass, which runs when either flag is on. To get only the scores, set statistical, stylised_facts, impact and volume_correlation to False. The result then carries the two score lists, in sim_ids order.
Neither score means anything as a bare number — only relative to a noise floor. Score a real-vs-real control as well (two slices of genuine market data for the same symbol) and read a generator as a multiple of that floor. A generator at roughly 1x the control is one the metric cannot separate from ordinary intraday variation.
The embedding pass is skipped, with the rest of the validation continuing, if a job has fewer than 10 book levels or the DeepLOB checkpoint is unreachable. In a full result that shows up as null score lists plus the area named in metadata["areas_skipped"]with the reason beside it.
View past jobs
validation.list_jobs(limit=50, offset=0)Return your validation jobs, newest first, without the full results payload.Pro
Parameters
- limit : int, default 50
- Maximum jobs to return. Capped at 200.
- offset : int, default 0
- Jobs to skip before this page, for paging past limit.
Returns
dict
- jobs : list of dict
- One entry per job, with
job_id,status,paramsandcreated_at. Fetch a job's results withget_job(). - total : int
- Your full validation job count, not the page size — so a client can tell whether more pages exist.
- limit : int
- The page size that was applied.
- offset : int
- Jobs skipped before this page.
Examples
jobs = client.validation.list_jobs(limit=50) # max 200
print(f"You have {jobs['total']} validation jobs")
for job in jobs["jobs"]:
params = job["params"]
print(f"{job['job_id']} {job['status']}")
print(f" {params['symbol']} {params['date']} — {len(params.get('sim_ids') or [])} sims")
print(f" Created: {job['created_at']}")REST endpoints
For non-Python integrations, Pulse-Check is plain HTTP. Authenticate with your Pro API key in the X-API-Key header.
| Method | Path | Description |
|---|---|---|
| POST | /validation/run | Submit a job from sim_ids (JSON body: symbol, date, provider, exchange, sim_ids, config) |
| POST | /validation/run/upload | Submit a job from uploaded parquet files (multipart form: sim_files, provider, exchange, symbol, date, config as a JSON string, and optionally sim_ids as a JSON string to score platform runs alongside them) |
| GET | /validation/jobs | List your validation jobs (?limit=, default 50, max 200; ?offset= to page). Returns {total, limit, offset, jobs} |
| GET | /validation/jobs/{job_id} | Get job status and full results |
| GET | /validation/jobs/{job_id}/status | Where a job has got to, without its result — {job_id, status, message, is_complete, created_at, updated_at} |
The config object takes the area flags directly, one name per thing. There is no ticksize field on either route: it is read off the historical day.
| Config field | Type | Default |
|---|---|---|
| statistical | bool | null | null (on) |
| stylised_facts | bool | null | null (on) |
| impact | bool | null | null (on) |
| volume_correlation | bool | null | null (on) |
| fid | bool | null | null (on) |
| mind | bool | null | null (on) |
| lob | bool | null | null (false) |
| sample_period | str | null | null (read off the generated frames) |
| match_generated_sample | bool | null | null (false) |
| plots | bool | list[str] | null | null (nothing rendered) |
| n_levels | int | 10 |
| sim_groups | object | null | null — upload route only, {name: [indices]} over the files as sent |
An unset area flag runs that area. Any field not in this table is rejected with a 422 that names it — including the older spellings run_metrics, run_stylised_facts, run_fid, run_impact, plot_data and l2_only, which were removed. Whether the historical series is returned is set by your tier, not by the request.