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:

  1. POST /portfolios/batch — returns 202 with a batch_id
  2. GET /portfolios/batch/{batch_id} — poll until status is terminal
  3. GET /portfolios/batch/{batch_id}/results — every account the batch calculated
  4. GET /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}/results answers at the level you submitted, so you never need to know how the portfolio was divided.
  • A request_id you send in the payload is not used. POST /portfolios honours one, POST /portfolios/batch does not: it assigns its own to each chunk. Track your submission by batch_id.

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 keeps initial_margin, option_liquidation_value, additional_margin, value_at_risk, stress_loss, exceptions and closest_matches. requirement, gross_margin and gross_requirement come back null rather 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.

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.

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.

Was this page helpful?