Foundation models
Generate order book activity from a trained model instead of an agent-based simulation.
HTTP endpoints on this page
- GET/fm/models
- GET/data/available-data
- POST/simulation/run
- POST/fm/live
- GET/simulation/jobs/{job_id}/status
- GET/simulation/jobs/{job_id}/logs
Parameters and response shapes for each are in the API reference.
A foundation model (FM) run generates limit order book activity by continuing from real market data rather than simulating agents. You give it a market and a duration; the platform slices the model's required context out of that market's data, runs inference, and writes the output using the same conventions as an agent-based run — so the existing results and download endpoints work unchanged.
FM methods live on client.fm. The endpoints are also plain HTTP if you are not using Python — every call below shows both.
Foundation models require a Pro tier key. That includes polling a job's status and reading its logs. See Plans.
Run flow
A run is submitted, polled, and then read back through the standard simulation endpoints:
| Step | Call | Purpose |
|---|---|---|
| 1 | client.fm.models() | Discover the models you can run and the arguments each accepts |
| 2 | client.data.available_data(model_id=…) | Find a symbol and date the model will accept as a prompt |
| 3 | client.simulation.run(engine='fm', …) | Submit an inference job — returns a job_id and its sim_ids |
| 4 | client.simulation.get_job_status(job_id) | Poll until complete or failed — earlier states say whether it is waiting for a GPU, starting, or generating |
| 5 | client.simulation.get_sim_data(sim_id) | Download the generated book |
Step 5 is the ordinary simulation download, not an FM-specific one. A foundation-model run carries the same sim_id format as an agent-based one — the two differ only in the gen_method field, which reads FM-<model>_v<version> instead of ABM_v<version> — and lands in storage and the job database under the same layout, so anything already reading simulation output reads foundation-model output too. Step 3 returns the sim_ids up front, and they are queryable from the moment you submit.
Methods on this page
Discover models
fm.models()Return the models you can run, each declaring its arguments, markets and prompt size.Pro
Each model has two names. The model_id identifies a specific build, and it is the id that sim_ids are minted from. The production_name is the stable product name, built as {model}-{market}-{version}-{size}: for example flow-hkex-1-100M or flow-lse-1-100M, where 100M is the parameter count. Anywhere a model_id is accepted (run() and available_data()) you can pass either name. The match ignores case, spaces, underscores and dashes, so "Flow HKEX 1 100M" finds the same model. A name that matches more than one model is a 400, and one that matches none is a 404.
model_args differ by model family — a diffusion model exposes guidance and num_steps where an autoregressive one exposes temperature. Read the ids and the knobs off this call rather than hard-coding either, and expect min_prompt_rows to differ between models too.
Returns
dict
A models list, one entry per model.
- model_id : str
- The id to pass to
run(). - version : str
- Model version. Appears in the sim_id's
gen_methodfield. - production_name : str
- Product name,
{model}-{market}-{version}-{size}, e.g.flow-hkex-1-100M. Accepted wherever amodel_idis. Empty for a model that has not been promoted. - supported_data : list of dict
{provider, exchange}markets the model accepts a prompt from. Empty means any.- min_prompt_rows : int or None
- Context rows the model needs to start inference — the prompt size.
- model_args : dict
- The knobs it accepts, each
{type, default, min, max, description}. Only keys declared here are accepted at submission; anything else is rejected with a400rather than ignored, so a typo in an argument name fails loudly. - resources : dict
{cpu, memory, gpu}the job is scheduled with.
Examples
models = client.fm.models()["models"]
for m in models:
markets = {(d["provider"], d["exchange"]) for d in m["supported_data"]}
print(f"{m['model_id']} v{m['version']}")
print(f" markets: {markets or 'any'}")
print(f" prompt rows: {m['min_prompt_rows']}")
print(f" args: {sorted(m['model_args'])}")flob-adaln-rf-1s-lse v1.0.6
markets: {('bmll', 'lse')}
prompt rows: 2048
args: ['guidance', 'historical_conditions', 'impact_coefficient', 'impact_model', 'impact_seconds', 'method', 'num_steps']
flob-adaln-rf-1s v1.0.14
markets: {('bmll', 'hkex_securities')}
prompt rows: 2048
args: ['guidance', 'historical_conditions', 'impact_coefficient', 'impact_model', 'impact_seconds', 'method', 'num_steps']curl -H "X-API-Key: $SIMUDYNE_API_KEY" \
https://pulse-api.simudyne.com/fm/models{
"models": [
{
"model_id": "flob-adaln-rf-1s",
"version": "1.0.14",
"production_name": "flow-hkex-1-100M",
"resources": {"cpu": "2", "memory": "6Gi", "gpu": 0},
"supported_data": [{"provider": "bmll", "exchange": "hkex_securities"}],
"min_prompt_rows": 2048,
"model_args": {
"num_steps": {
"type": "int", "default": 10, "min": 1, "max": 200,
"description": "ODE solver steps; 10 is the operating point"
},
"method": {
"type": "str", "default": "euler", "choices": ["euler", "heun", "rk4"],
"description": "fixed-step ODE solver"
},
"guidance": {
"type": "float", "default": 1.0, "min": 0.0, "max": 5.0,
"description": "classifier-free guidance scale; 1.0 = as trained"
},
"historical_conditions": {
"type": "bool", "default": true, "choices": [true],
"description": "condition on the real day's regime; pinned on for this model"
},
"impact_model": {
"type": "str", "default": "resilience", "choices": ["none", "resilience"],
"description": "whether an execution leaves the book short afterwards"
},
"impact_seconds": {"type": "float", "default": 10.0, "min": 0.0, "max": 32.0},
"impact_coefficient": {"type": "float", "default": 1.0, "min": 0.0, "max": 1.0}
}
}
]
}Find data to prompt with
data.available_data(model_id=None, symbol=None, q=None, provider=None, exchange=None, date=None, include_calibration_state=False, limit=None, offset=None)Return the symbols and dates a model will accept as a prompt.Pro
A foundation model is prompted with real market data rather than calibrated parameters, so this reads the raw vendor registry rather than the calibrated catalog that calibrated_data() serves.
Parameters
- model_id : str, optional
- Restrict to the markets this model accepts. Takes the
model_idor theproduction_name, matched the same way as in models(). An ambiguous name is a400and an unknown one a404. - symbol : str, optional
- Exact symbol, e.g.
"700". - q : str, optional
- Case-insensitive substring match over the symbol.
- provider : str, optional
- Data provider, e.g.
"bmll". - exchange : str, optional
- Exchange protocol, e.g.
"lse"or"hkex_securities". - date : str, optional
- Trading date
"YYYY-MM-DD". Keeps only symbols with data on it, and narrows each entry's dates to it. - limit : int, default None
- Maximum symbols to return, 1–200. None uses the server default of 50.
- offset : int, default None
- Symbols to skip, for paging alongside limit. None uses the server default of 0.
Returns
dict
- total : int
- Match count before paging — use it to decide whether to page.
- limit, offset : int
- The window that was applied.
- symbols : list of dict
- One entry per
{symbol, provider, exchange}, each with adateslist.
Notes
Every filter is applied in the database, and the registry holds the whole vendor universe — tens of thousands of symbol-days. It is never returned whole, so always page with limit and offset and read total to know how much is behind your filter.
Examples
# Pick a model, then a market it will accept
models = client.fm.models()["models"]
model = models[0]
data = client.data.available_data(model_id=model["model_id"], limit=5)
print(f"{data['total']} symbol-days behind this filter")
for entry in data["symbols"]:
print(f" {entry['symbol']} @ {entry['provider']}/{entry['exchange']}")
print(f" {entry['dates'][:3]}"){
"total": 27306,
"limit": 2,
"offset": 0,
"symbols": [
{
"symbol": "700",
"provider": "bmll",
"exchange": "hkex_securities",
"dates": ["2025-09-01", "2025-09-02", "2025-09-03"]
}
]
}Submit a run
simulation.run(symbol="", cal_date="", provider="", exchange="", engine="fm", n_runs=None, seed=42, exec_algos=None, model_id="", duration_minutes=None, horizon=None, device="", model_args=None)Submit a foundation-model inference job. Returns as soon as it is queued.Pro
You name a market, not a prompt — the platform builds the prompt itself by slicing min_prompt_rows of real data for that symbol and date.
Parameters
- model_id : str
- A model from
models(): itsmodel_idor itsproduction_name, e.g."flow-hkex-1-100M". - symbol : str, optional
- Instrument to prompt from, e.g.
"700". - cal_date : str, optional
- Trading day
"YYYY-MM-DD"whose opening rows become the prompt. - provider : str, optional
- Data provider. Must be a market the model's
supported_dataallows. - exchange : str, optional
- Exchange protocol, e.g.
"hkex_securities". - duration_minutes : float, optional
- Market time to generate, as wall-clock minutes from the first context row. Preferred over
horizon: a duration is measured in market time, so an illiquid symbol simply produces fewer frames for the same request, where a raw frame count means something different on every instrument. The frame count that resulted is reported in the output rather than requested up front. - horizon : int, optional
- Raw frame count to generate. The older form — applies only when
duration_minutesis unset. - n_runs : int, default None
- Monte Carlo runs, 1–8. None uses the server default of 1 for a foundation-model run. Per-run seeds derive from
seedexactly as the ABM derives them. The cap is 8 because an FM job's worst case must fit on one GPU. - seed : int, default 42
- Master random seed.
- device : {"cpu", "gpu"}, optional
- None uses the model's own default.
- model_args : dict, optional
- Overrides for the model's declared knobs. Unknown or out-of-range keys are a
400, not a silent ignore. - exec_algos : list of dict, optional
- Execution algorithms, same config shape as
simulation.run(). At most one per job, and it needs a real market day.
Returns
dict
- job_id : str
- Poll this with
get_job_status(). - sim_ids : list of str
- The runs this job will produce, queryable from submission. The model and its version are in each sim_id's
gen_methodfield. - n_runs : int
- Runs in the job, after the server default is applied.
- engine : str
- Always
"fm"here.
Notes
Market scenarios are deliberately not implemented for foundation models. Passing scenario or scenario_params with engine="fm" is rejected with a 422, and so is any other field that belongs to the agent-based engine. For stress testing, use an agent-based run.
Examples
job = client.simulation.run(
engine="fm",
model_id="flob-adaln-rf-1s", # or its production name, "flow-hkex-1-100M"
symbol="700",
cal_date="2025-09-02",
provider="bmll",
exchange="hkex_securities",
duration_minutes=5,
n_runs=2,
seed=42,
model_args={"num_steps": 20},
)
print(job["job_id"], job["engine"], job["n_runs"])
print(job["sim_ids"])curl -X POST https://pulse-api.simudyne.com/simulation/run \
-H "X-API-Key: $SIMUDYNE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"engine": "fm",
"model_id": "flow-hkex-1-100M",
"symbol": "700",
"cal_date": "2025-09-02",
"provider": "bmll",
"exchange": "hkex_securities",
"duration_minutes": 30,
"n_runs": 4,
"seed": 42,
"model_args": {"num_steps": 20}
}'Poll a job
simulation.get_job_status(job_id)Report what a foundation-model job is actually doing, which for a GPU model is not always generating.Pro
Owner-only, and Pro like the rest of the jobs routes — as is simulation.get_job_logs(job_id), which returns the log as a plain str and is what to read when a run fails. This is the same get_job_status() as for an agent-based job, so the response also carries engine ("fm" here), total_simulations, status_summary, is_complete, has_errors and simulations, as documented there. For an FM job, the GPU scheduler's status, message and detail are merged in on top.
Parameters
- job_id : str
- A job handle from
run().
Returns
dict
- job_id : str
- The job identifier, echoed back.
- engine : str
"fm"for a foundation-model job.- status : {"queued", "provisioning", "starting", "running", "complete", "failed"}
queuedis accepted with no compute assigned;provisioningis waiting for capacity, which for a GPU model may mean a machine is being started;startingis loading the model image and its weights;runningis generating the order book;completemeans results are readable via the simulation endpoints.- message : str
- A ready-to-display label for the current state, e.g.
"Finding GPUs…"or"Run starting…". - detail : str
- The scheduler's reason, so a scarce GPU is distinguishable from a stuck run. Empty unless something is actually holding the run up, and carries the failure reason on failed.
Notes
Poll on is_complete and read has_errors afterwards, rather than matching status strings: the pod's own status can take new values, while those two flags always mean the same thing.
Examples
import time
# No market named: the model runs on the test day baked into its image.
job_id = client.simulation.run(
engine="fm", model_id="flob-adaln-rf-1s", duration_minutes=5,
)["job_id"]
while True:
status = client.simulation.get_job_status(job_id)
# status / message / detail are the inference pod's own state, e.g.
# waiting for a GPU; they are absent until the orchestrator reports.
print(f"{status.get('status', 'queued')}: {status.get('message', '')}")
if status.get("detail"):
print(f" {status['detail']}")
if status["is_complete"]:
break
time.sleep(30)
if status["has_errors"]:
print(client.simulation.get_job_logs(job_id))curl -H "X-API-Key: $SIMUDYNE_API_KEY" \
https://pulse-api.simudyne.com/simulation/jobs/$JOB_ID/status
curl -H "X-API-Key: $SIMUDYNE_API_KEY" \
https://pulse-api.simudyne.com/simulation/jobs/$JOB_ID/logs{
"job_id": "1f3c9a2e",
"engine": "fm",
"total_simulations": 4,
"status_summary": {"queued": 4},
"is_complete": false,
"has_errors": false,
"simulations": [...],
"status": "provisioning",
"message": "Finding GPUs…",
"detail": "0/29 nodes are available: Insufficient nvidia.com/gpu"
}Live sessions
fm.live(model_id, horizon=None, seed=42, model_args=None)Start a streaming session instead of a batch job.Pro
Takes the same model and generation fields as run(), but streams frames to a live chart rather than writing output files.
Parameters
- model_id : str
- A model from
models(). - horizon : int, optional
- Frames to generate before the session ends.
- seed : int, default 42
- Master random seed.
- model_args : dict, optional
- Overrides for the model's declared knobs.
Returns
dict
- token : str
- Connect to the live chart with this.
- job_id : str
- The session's job handle.
- model_id : str
- The model that is streaming.
Registry administration
Four more methods on client.fm manage the model registry itself. They need an admin key and are meant for operators who publish models. A Pro key gets a 403.
| Method | Endpoint | Purpose |
|---|---|---|
| fm.registry() | GET /fm/models/registry | Latest registry row for every model, including deactivated ones |
| fm.register(model_id, version, image, …) | POST /fm/models | Add a model, or update one, with its resources, model_args, supported_data, min_prompt_rows and production_name |
| fm.activate(model_id) | POST /fm/models/{model_id}/activate | Put a deactivated model back in the models() list |
| fm.deactivate(model_id) | POST /fm/models/{model_id}/deactivate | Remove a model from the models() list. Its history stays in the registry |
Prompt context in the output
The output file contains the model's prompt context as well as its generated frames. The leading rows of sim_data.parquet are the real market data the model was conditioned on, so the series runs continuously from observed history into generated activity with no gap at the handover.
Those leading rows are not model output. Treating them as generated will flatter any comparison against historical data — you would be scoring real market data against itself. Drop them before computing statistics.
Where the split is recorded
The row counts live in the parquet file-level metadata, so no extra column or row is added to the data and readers that do not care about the split are unaffected.
| Key | Value | Meaning |
|---|---|---|
| pulse_fm.n_historical | Integer, as a string | Leading rows that are real market data (the model's prompt context) |
| pulse_fm.n_generated | Integer, as a string | Rows after those that the model generated |
| pulse_fm.segments | JSON array | One entry per trading session, each with its period name and its historical/generated row ranges |
Only the row split is recorded, because it is the only thing the data cannot tell you itself. The model is warm-started on the opening rows of each trading session, so a session's start time is simply its first row's timestamp, and its handover is the timestamp at row n_historical.
One sim_id is one whole trading day. A day that spans a break — HKEX's lunch hour, say — is a single file whose sessions are stitched in time order with no rows between them, so the break reads as an absence of data rather than a flat line. Each session keeps its own warm-start block, which is why pulse_fm.segments exists: n_historical and n_generated describe the first session only, and segments is the authority for the rest. When a job returns several sim_ids, they are alternative starting points for the same day, not different parts of it.
Timestamps in Pulse Format — both the timestamp column and these keys — are timezone-naive and carry the exchange's local wall clock. An HKEX afternoon session opening at 13:00 is stored as 13:00 with no zone attached. Readers that attach the local timezone will shift every time by the reader's own UTC offset.
Both keys are absent on agent-based simulation output, where every row is generated, and on foundation-model runs produced before the keys existed. Treat a missing key as zero historical rows.
Example: keep only the generated frames
get_sim_data() hands back a DataFrame, which drops the file-level metadata — so read the raw bytes when you need the split.
import io
import time
import polars as pl
import pyarrow.parquet as pq
job = client.simulation.run(engine="fm", model_id="flob-adaln-rf-1s", duration_minutes=5)
while not client.simulation.get_job_status(job["job_id"])["is_complete"]:
time.sleep(30)
sim_id = job["sim_ids"][0]
url = f"{client.base_url}/simulation/results/{sim_id}/data/sim_data.parquet"
raw = client.session.get(url).content
meta = pq.read_metadata(io.BytesIO(raw)).metadata or {}
n_hist = int(meta.get(b"pulse_fm.n_historical", 0))
df = pl.read_parquet(io.BytesIO(raw))
context = df.head(n_hist) # real market data — the prompt
generated = df.slice(n_hist) # model output
print(f"{len(context)} context rows, {len(generated)} generated rows")The sequence column is continuous across the boundary, so sequence <= n_historical selects the context and everything above it is generated. Generated rows also carry order_id = "NA", since a generated order has no exchange identifier.
Validation
The validation API excludes the prompt context automatically — it reads pulse_fm.n_historical and drops those rows before any metric runs, so reported distances describe the generated frames only. The number excluded per simulation is echoed back in the job's metadata.historical_rows_dropped, and metadata.sim_rows is the post-drop count.
A foundation-model run usually covers only part of a trading day. A partial-day simulation is scored against the matching time-of-day window of the historical day, not the whole day, so a 30-minute run is compared with the same 30 minutes of real trading. The window that was used is reported in metadata.historical_window. See Validation API.
Foundation-model output carries book states without order flow, so the order-placement and cancellation metric groups are skipped automatically. That is expected, not a failure — see Pulse-Check for which metrics need the order-level columns.