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

  1. Submit a validation job with run() — returns a job_id immediately
  2. Historical data for the symbol and date is fetched automatically; simulation data comes from your sim_ids (or uploaded parquet files)
  3. Poll get_job_status(job_id) until is_complete is true, then call get_job(job_id) once to fetch the result
  4. 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.

AreaFlagReturned to a Pro key
Distributional metricsstatisticaldistances — L1 and Wasserstein per metric, per run
Stylised factsstylised_factsstylised_fact_verdicts — pass/fail per fact, per run
Impact responseimpactimpact_response — the Bouchaud response curves for each simulated run
Volume correlationvolume_correlationvolume_correlation — cross-level correlation of book volumes, and the divergence from historical
Frechet inception distancefidfid_scores — one per run
Monge inception distancemindmind_scores — one per run

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 as sim_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 lob mode, 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_period is 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, True draws 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

python
# 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"])
python
# 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"])
python
# 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:

PassedPopulations
sim_files mapping + sim_ids listits names, plus platform
sim_files list + sim_ids mappinguploaded, plus its names
both listsuploaded and platform
both mappingstheir own names
either one aloneexactly 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

python
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 on id, not on the filename. The SDK decodes these to plot_dir for 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, in sim_ids order, 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. lags and events are the axes, and simulated holds 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_response can 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_ids order. 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_run and areas_skipped say which areas ran, and why any were skipped. historical_window says 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

python
# 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.

IdFigure
statistical.radarDistance spider, one polygon per population — drawn once per distance measure, so two files
statistical.distributionEvery metric's distribution
statistical.distribution.{metric}One metric, e.g. statistical.distribution.spread
stylised_facts.overallThe verdict grid — historical beside one column per population
stylised_facts.factEvery fact
stylised_facts.fact.{name}One fact, e.g. stylised_facts.fact.heavy_tails
impact.responseImpact response by event type
impact.eventEvery event type
impact.event.{type}One event type, e.g. impact.event.MO_1
volume_correlation.levelsLevel correlation heatmap
volume_correlation.changesChange correlation heatmap
volume_correlation.overallBoth 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:

GroupMetrics
Spread & touchspread, touch_bid_volume, touch_ask_volume
Order flowobi, ofi_rolling (plus _up / _down / _stay), log_interarrivaltime
Volumetotal_bid_volume, total_ask_volume, volume_per_minute
Pricemid_returns_1s, mid_range_5min
Order placementask/bid_limit_depth, ask/bid_limit_level
Cancellationstime_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.

FactPasses when
absence_of_autocorrelationsLjung-Box finds no autocorrelation in 1s returns
heavy_tailsReturn kurtosis is positive
gain_loss_asymmetryReturn skewness is negative at every scored timescale
aggregational_gaussianityThe Shapiro-Wilk statistic rises with timescale (positive slope)
intermittencyExtreme 1s returns arrive with a Fano factor above 1
conditional_heavy_tailsVolatility-normalised returns are more kurtotic than raw returns
volatility_clusteringLjung-Box finds autocorrelation in absolute 1s returns
slow_decay_of_autocorrelationsThe absolute-return ACF fits a power law with |slope| in [0.2, 0.4]
leverage_effectReturns correlate negatively with later absolute returns
volume_volatility_correlationVolume and volatility move together (Ljung-Box on their product)
asymmetry_in_time_scalesCoarse volatility predicts fine volatility better than the reverse
json
{
  "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.

FieldShapeDescription
lagslist[int]The event lags l, the x-axis of every curve
eventslist[str]The six event types, the row order of every block
simulatedlist[dict]One block per run, in sim_ids order
simulated[i].yslist[list[float]]Mean response, indexed [event][lag]
simulated[i].ci_low / ci_highlist[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, params and created_at. Fetch a job's results with get_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

python
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.

MethodPathDescription
POST/validation/runSubmit a job from sim_ids (JSON body: symbol, date, provider, exchange, sim_ids, config)
POST/validation/run/uploadSubmit 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/jobsList 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}/statusWhere 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 fieldTypeDefault
statisticalbool | nullnull (on)
stylised_factsbool | nullnull (on)
impactbool | nullnull (on)
volume_correlationbool | nullnull (on)
fidbool | nullnull (on)
mindbool | nullnull (on)
lobbool | nullnull (false)
sample_periodstr | nullnull (read off the generated frames)
match_generated_samplebool | nullnull (false)
plotsbool | list[str] | nullnull (nothing rendered)
n_levelsint10
sim_groupsobject | nullnull — 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.