Skip to content

GuidesCookbook

Scan for qualified cross-venue opportunities

The same question trades on more than one venue, and the two prices are rarely identical. Most of those gaps are not tradeable. This recipe keeps only the ones that survive every gate.

A polling worker that calls fetchArbitrage with executableOnly=true and revalidates anything it is about to act on. When you need to diagnose an empty result, make a separate call without executableOnly=true; only that unfiltered response includes non-qualifying candidates and their rejection reasons.

Prerequisites: a Builder plan or above, and an API key from the dashboard.

fetchArbitrage is the only verb that applies the word arbitrage to anything, and it does so only when every gate passes: live non-synthetic asks on both legs, both markets open, real depth at the size you asked for, verified per-venue fees, compatible resolution rules, and a net edge that survives all of it.

Returned rows that fall short of that stay labelled an indicative price discrepancy. label carries exactly two values — arbitrage and indicative price discrepancy — and executable is the boolean form of the same judgement. There is no partial credit.

Two fields describe resolution, and they are not the same claim:

  • resolution.compatible is a cheap fingerprint veto. Its reason is one of threshold_conflict, stage_conflict, source_conflict, or empty. Compatible does not mean verified equivalent.
  • resolutionEquivalence is the stronger statement — verified only when a high-confidence persisted verdict matches both legs’ current resolution-rule content hashes.

similarity is candidate similarity from matching, not matchScore or judge confidence. Two markets can read alike and settle differently, which is why the resolution gates exist.

matchScore compares resolution rules on a scale from 0 to 100. A score of 100 requires a fresh, high-confidence equivalent verdict under the current judge identity and rule hashes. It does not establish executable prices, available depth, or a profitable trade.

matchBandScoreMeaning
verified100Rules verified equivalent; every execution gate still applies.
minor85 to 99Visible as indicative, unverified and non-executable.
material60 to 84Visible with the listed differences, unverified and non-executable.
hiddenBelow 60, or a vetoExcluded from the feed; qualification can still explain it.
unscorednullNo current verdict; visible unless the close-date veto applies.

The fixed deductions below come from MATCH_SCORE_DEDUCTIONS and CONFIDENCE_PENALTY in packages/arbitrage/src/match-score.ts. The model reports differences; code computes the number.

Difference kindPoints deducted
event_identity50
deadline_handling20
resolution_authority15
cancellation_handling10
successor_handling10
other5
confidence when medium5
confidence when low10

Each judge difference kind is charged once, with its explanations joined. confidence is a scoring-only kind. An equivalent verdict at medium or low confidence scores 95 or 90 and stays unverified. matchDifferences lists every charged { kind, detail, points } entry, including confidence. For every visible numeric score, the points sum to 100 - matchScore.

A polarity inversion hides the pair. Close dates more than 21 days apart also hide it, even if there is no verdict yet. A numeric veto is accounted for with 100 points (polarity, or deadline_handling with detail close_time_mismatch); this is a hide rule, not a graded fine-print difference. Without a close-date veto, insufficient evidence or a malformed verdict has no numeric score. Hidden numeric scores use max(0, 100 - sum(points)).

The meaning of resolutionEquivalence: "verified", executable, and the arbitrage label is unchanged. A score below 100 never enables execution. The qualification endpoint exposes the same explanation as checks.resolutionEquivalence.matchDeductions; its differenceKinds contains judge kinds only, while differences remains a string list for compatibility.

The match score is a rules comparison produced by an automated judge from the two venues’ published resolution rules; it is not a probability that the markets settle identically, and trading a pair below 100 is your own risk.

Terminal window
curl -s "$PREDICTEFY_API_URL/api/router/fetchArbitrage?executableOnly=true&contracts=1000&limit=500" \
-H "Authorization: Bearer pk_live_YOUR_KEY"
  • contracts is the size the assessment is made at — depth and fees are judged for that fill, not for one contract. Change it and the answer legitimately changes.
  • limit supports up to 500 rows per page, paginated via cursor (page.nextCursor).
  • executableOnly=true returns only rows passing every executable gate. Drop it to also receive visible non-qualifying candidates with rejection reasons; hidden match bands remain excluded.
  • venues (optional) keeps a row when either leg touches the listed venues (for example, venues=polymarket,kalshi). It does not require both legs to stay inside the list.
  • minEdge (optional) filters rows by minimum net edge.
const BASE = 'https://data.predictefy.com';
async function scan({ contracts = 1000, minNetEdge = 0.01, venues } = {}) {
let cursor;
const results = [];
do {
const url = new URL('/api/router/fetchArbitrage', BASE);
url.searchParams.set('executableOnly', 'true');
url.searchParams.set('contracts', String(contracts));
url.searchParams.set('limit', '500');
if (venues) url.searchParams.set('venues', venues.join(','));
if (cursor) url.searchParams.set('cursor', cursor);
const res = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.PREDICTEFY_API_KEY}` },
});
const body = await res.json();
if (!body.success) {
// PLAN_REQUIRED on Free keys; RATE_LIMITED and INSUFFICIENT_CREDITS are also expected.
if (body.error.retryable) return results;
throw new Error(`${body.error.code}: ${body.error.message}`);
}
// netEdge is nullable — an unpriced pair is not a zero-edge pair.
results.push(...body.data.filter((row) => row.netEdge !== null && row.netEdge >= minNetEdge));
cursor = body.page?.hasMore ? body.page.nextCursor : undefined;
} while (cursor);
return results;
}
{
"success": true,
"data": [
{
"clusterId": "clr_9f2a7c41:kalshi:polymarket",
"question": "Will the Fed cut rates at the January 2026 meeting?",
"similarity": 0.94,
"contracts": 1000,
"legs": {
"buyYes": {
"venue": "kalshi",
"canonicalMarketId": "kalshi:FED-26JAN-CUT",
"side": "yes",
"executable": true,
"reasons": [],
"vwap": 0.412,
"cost": 412.0,
"fee": 3.7,
"feeBasis": "general",
"filled": 1000,
"fullyFilled": true
},
"buyNo": {
"venue": "polymarket",
"canonicalMarketId": "polymarket:0x7d3f...c19a",
"side": "no",
"executable": true,
"reasons": [],
"vwap": 0.559,
"cost": 559.0,
"fee": 0.0,
"feeBasis": "general",
"filled": 1000,
"fullyFilled": true
}
},
"resolutionEquivalence": "verified",
"matchScore": 100,
"matchBand": "verified",
"matchDifferences": [],
"resolution": { "compatible": true, "reason": "", "auditReasons": [] },
"settlementFee": 0.0,
"totalCost": 974.7,
"payout": 1000.0,
"netEdge": 25.3,
"roi": 0.026,
"executable": true,
"reasons": [],
"label": "arbitrage",
"asOf": "2026-08-31T14:22:08.412Z"
}
],
"meta": {
"live": true,
"contracts": 1000,
"provenance": { "source": "predictefy-live" },
"asOf": "2026-08-31T14:22:08.412Z",
"seq": 1042,
"source": "published"
},
"page": {
"limit": 500,
"offset": 0,
"total": 1,
"hasMore": false,
"nextCursor": null
}
}

Every leg carries its own executable and reasons, so a pair can fail on one side only. Read the leg-level reasons when the pair-level reasons array does not explain enough.

fullyFilled is the field to branch on for sizing: filled is what the walked asks could actually absorb, and it is less than or equal to what you requested.

  • meta.source discloses the data origin. 'published' means the response was served from the live published surface (fresh within the publisher’s self-declared cadence (about 30s; 3s floor)); 'computed-fallback' indicates bounded on-demand computation over at most 10 candidate clusters when the published surface is unavailable. One candidate cluster can emit multiple ordered pair rows. meta.asOf and meta.seq record the timestamp and sequence number.
  • netEdge, roi, totalCost, vwap, cost, fee and settlementFee are all nullable. A null is “not priced”, not “zero”. Filtering with row.netEdge >= x silently drops nulls in some languages and admits them in others — test for null explicitly.
  • fee is a verified taker fee or nothing. Where a venue’s schedule is not verified, the leg carries a note explaining why no model was applied, and the edge is unknown rather than optimistic.
  • page.total may be null, which means not counted, not zero. Paginate on hasMore and nextCursor.

Good market data on a venue does not mean you can trade there. GET /v1/exec/venues is the authoritative list of execution lanes — see Trading & execution. Several venues serve real books with no hosted trading lane at all.

Venues with reconstructed books cannot qualify by design: a synthetic book is a faithful representation of price and is not executable depth. Venue coverage records which venues have a real book.

An arbitrage query is the most expensive read on the platform at 15 credits; a cross-venue price comparison is 10 and an order-book snapshot is 5. Continuous polling adds up:

IntervalQueries/dayCredits/dayCredits/30 days
5 min2884,320129,600
60 s1,44021,600648,000
10 s8,640129,6003,888,000

Budget before you poll, and add revalidation on top — 5 credits per leg per check. Current weights and plan allowances are in Credits & billing.