Batch processing
Use batch processing for large portfolios or high-volume workloads that should run in the background instead of blocking on a synchronous response.
You submit the same payload POST /portfolios takes, get a batch_id back immediately, poll until the work finishes, then fetch the results in one call.
Overview
The flow is four steps:
POST /portfolios/batch— returns202with abatch_idGET /portfolios/batch/{batch_id}— poll untilstatusis terminalGET /portfolios/batch/{batch_id}/results— every account the batch calculatedGET /results— optional, the full drill-down for one account
Reach for this instead of POST /portfolios when the portfolio is large, when you want queueing and progress tracking, or when you would rather not hold a request open for the length of the calculation.
What happens after you post
POST /portfolios/batch does not calculate anything. It streams your payload to storage, puts a job on a queue and answers 202 straight away. That is why the response carries a batch_id and nothing else.
A worker then picks the job up and splits the portfolio into chunks by account. Chunk sizes are not fixed: the platform divides the available worker memory by the response sizes each margin engine has actually been producing, so the same portfolio may be three chunks on one run and forty on the next. Each chunk is calculated independently and stored under its own internal request_id.
Two consequences matter to you as a caller:
- Chunking is invisible and you should keep it that way.
GET /portfolios/batch/{batch_id}/resultsanswers at the level you submitted, so you never need to know how the portfolio was divided. - A
request_idyou send in the payload is not used.POST /portfolioshonours one,POST /portfolios/batchdoes not: it assigns its own to each chunk. Track your submission bybatch_id.
Batch submissions are limited to 500 MB. Larger payloads are rejected with
413.
Submit the batch
POST /portfolios/batch accepts the same JSON payload as POST /portfolios.
{
"calculation_type": "margins",
"vendor_symbology": "clearing",
"portfolio": [
{
"account_code": "account_0000",
"exchange_code": "ICE.EU",
"contract_code": "B",
"contract_type": "FUT",
"contract_expiry": "203212",
"net_position": "100",
"account_type": "H"
}
]
}
The x-processing-mode header controls queue handling, not margin calculation:
fifo— default. Jobs are processed in submission order.priority— can move the job ahead of the same user's other queued non-priority jobs.replace_all— removes the user's currently queued jobs before adding this one. Jobs already processing are not cancelled.
Poll batch status
GET /portfolios/batch/{batch_id} reports progress. Poll until status is one of completed, completed_with_errors or failed.
{
"batch_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"status": "completed",
"created_at": "2025-01-03T09:31:14Z",
"completed_at": "2025-01-03T09:33:02Z",
"runtime_ms": 108431,
"completed_pct": 100,
"request_ids": ["af59a90f-f294-4080-8a36-1d16358ca8d3"]
}
completed_with_errors means some chunks failed and others succeeded, so results are worth fetching. On failed, stop.
request_ids appears only once the batch is terminal. It is the list of internal IDs the chunks were stored under, and you need it only for the per-account drill-down further down this page.
Fetch the results
GET /portfolios/batch/{batch_id}/results returns every account the batch calculated, with its account-level figures, in one call.
results = requests.get(
f"{C9_API_ENDPOINT}/portfolios/batch/{batch_id}/results",
headers=HEADERS,
).json()
for account in results["results"]:
print(f"{account['account_code']}: {account['initial_margin']:,.2f}")
For a large book, page with limit (default 5000, capped at 20000) and offset. total is the account count for the whole batch, before paging:
accounts, offset = [], 0
while True:
page = requests.get(
f"{C9_API_ENDPOINT}/portfolios/batch/{batch_id}/results",
headers=HEADERS,
params={"limit": 5000, "offset": offset},
).json()
accounts.extend(page["results"])
offset += len(page["results"])
if not page["results"] or offset >= page["total"]:
break
Every row carries a source field, and it is worth understanding:
live— the account still carries this batch's calculation, so every figure is populated.history— a later calculation has since replaced the account's current figures. The durable record keepsinitial_margin,option_liquidation_value,additional_margin,value_at_risk,stress_loss,exceptionsandclosest_matches.requirement,gross_marginandgross_requirementcome backnullrather than as a zero you would read as a real number.
A batch fetched once it finishes is entirely live. Rows turn history when you go back and re-read an older batch whose accounts have been recalculated since.
Wait for a terminal status before fetching. Called while the batch is still
running, this endpoint returns the chunks finished so far with a total that
is still climbing, rather than an error.
Drill into one account
The endpoint above returns account totals. For the full calculation detail of a single account, the per-engine breakdowns, the priced positions and the exceptions, use GET /results with the request_id and portfolio_id from that account's row:
first = results["results"][0]
detail = requests.get(
f"{C9_API_ENDPOINT}/results",
headers=HEADERS,
params={"request_id": first["request_id"], "portfolio_id": first["portfolio_id"]},
).json()
portfolio_id is md5(account_code), or md5(account_code + sub_account_code) for a sub-account, so you can address a known account without reading the list first.
Always pass portfolio_id. Omitting it returns every account in that chunk,
which for a large book can run to tens of megabytes.
Reading a book rather than a batch
GET /results/accounts returns one row per live account you own, with the same headline figures, reflecting each account's most recent calculation whatever produced it. Use it to read current state; use the batch endpoint to read the output of one submission.