Skip to content

Backtesting

A backtest replays your historical transactions through a policy version and reports what it would have decided. Backtests never read or write live data. Rows are replayed in occurred_at order through an isolated history, using the same rules as live scoring.

The version is pinned at submission, so a backtest always names the exact version it evaluated.

POST /v1/backtest:

{
"policy_version_id": "plv_…",
"transactions": [
{ "transaction": { "id": "t1", "type": "loan_disbursement", "amount": 5000000,
"currency": "NGN", "occurred_at": "2026-08-01T10:00:00Z" },
"customer": { "id_hash": "<hmac>" },
"label": "fraud" }
]
}

Each item takes the same fields as POST /v1/score, plus an optional label.

Send Content-Type: text/csv with ?policy_version_id= and these columns (the first six are required):

transaction_id,type,amount,currency,occurred_at,channel,customer_id_hash,device_id_hash,device_platform,beneficiary_account_hash,beneficiary_bank_code,country,region,label

Identifier columns must already be hashed. In the dashboard, hashing happens in your browser before the file is uploaded, using the identifier key you enter.

  • Target: policy_version_id, or policy_id (with version, default latest), or nothing for the active policy for product.
  • Limits: 50,000 rows and 25 MB. Larger files get 413 backtest_too_large, so split them by date range. Files are not split for you, because every row’s features depend on the rows before it.
  • Validation: rows are checked like /v1/score, including the currency, which must match the market (NGN). If more than 1% of rows are invalid the batch is rejected (422 backtest_invalid_rows, with the first 100 row errors in details). Otherwise invalid rows are skipped and reported.
  • Labels (optional): fraud, legitimate or suspected. They enable precision and recall.
  • Idempotency: send an Idempotency-Key to make retries safe. A repeat returns the original batch with 200 and Idempotent-Replayed: true.

POST /v1/backtest returns 202 with the batch.

GET /v1/backtest/{id}:

{
"backtest_id": "bt_…",
"status": "completed",
"dataset": { "format": "csv", "row_count": 5000, "invalid_row_count": 3 },
"summary": {
"scored": 4997,
"by_risk_level": {},
"by_action": {},
"mean_score": 0,
"rule_hits": {},
"labels": { "thresholds": [{ "level": "high", "min_score": 60, "flagged": 0, "precision": 0, "recall": 0 }] }
}
}

GET /v1/backtest/{id}/rows?after=&limit=&errors_only=true pages through each row’s input and result. GET /v1/backtest lists recent batches.

Large backtests take a while. Instead of polling:

  • Email: pass notify_emails (up to 10 members of your organisation). The email carries the status, a one-line summary and a dashboard link, and never transaction data. Dashboard submissions notify the person who submitted by default; pass an empty list to opt out.
  • Webhook: subscribe to backtest.completed and backtest.failed. See Webhooks.
const backtest = await asqary.backtests.create({
policyVersionId: policy.versions![0]!.versionId,
transactions: history.map((t) => ({
transactionId: t.id,
type: "loan_disbursement",
amount: t.amountKobo,
currency: "NGN",
occurredAt: t.createdAt,
customerId: t.customerId, // hashed locally
label: t.wasFraud ? "fraud" : "legitimate",
})),
});
const result = await asqary.backtests.waitForCompletion(backtest.backtestId);
for await (const row of asqary.backtests.rows(backtest.backtestId)) {
// row.score, row.riskLevel, row.reasons, row.error
}

To upload a CSV with already-hashed columns: asqary.backtests.createFromCsv(csv, { policyVersionId }).