Signals
Named, explained observations over the live market: a cross-venue arbitrage, a price move, a whale trade, one-sided flow. Each carries a headline, a plain-English explanation, a strength, a confidence and the evidence it was computed from — so an agent or a screen can act on it without recomputing it.
What a signal is
A signal is a detector's claim about one position, with its working shown. It has a signal_type from a small catalog, a strength whose meaning the catalog states per type, a confidence of low / medium / high, an expires_at, a venues list, and an evidence block whose shape depends on the type.
venues is always [{ partner_id, partner_name }]: the venues the signal is about. A trade print names one venue, an arbitrage names both legs, and a price move names every venue that moved. Read the venue from here and never from evidence. evidence is the type's own data packet: everything specific to that kind of signal, and only that. Its fields are listed per type in the catalog's evidence_fields.| Type | Status | Needs | Typical life | Evidence |
|---|---|---|---|---|
| arbitrage | active | signals:read | 120 s | arbitrage_id, best_combined_price, best_guaranteed_profit_pct, after_fee_roi, profitable_after_fees, best_max_investment, sides |
| price_move | beta | signals:read | 300 s | from_price, to_price, move, direction, window_ms, venues_now, venues_before, total_available_usd |
| whale_trade | beta | signals:read | 600 s | notional_usd, size_multiple, threshold_usd, baseline_median_notional_usd, depth_ratio, contest_phase, trader_id, is_block |
| block_trade | beta | signals:read | 900 s | notional_usd, price, trader_id |
| trade_burst | beta · not published | signals:read | 300 s | trades, total_notional_usd, largest_print_usd, window_ms |
| flow_imbalance | beta | signals:read | 180 s | buy_notional_usd, sell_notional_usd, imbalance, venues |
| trade_through | beta · evaluating | signals:read | 300 s | price, cached_price, through, quote_age_ms |
| volume_spike | beta · evaluating | signals:read | 600 s | trades_last_5m, baseline_per_5m, multiple |
| off_market_price | retired | signals:read | — | still described by the catalog; no longer emitted |
The trade types are built from venue prints — trades that actually happened — rather than from quotes, and only on venues that publish their trades. Each carries the venue in evidence.partner_id. trader_id is filled where the venue exposes an identity (a wallet) and is null where it does not, so a whale on one venue is a large print and on another can name the wallet behind it.
What the trade types publish, as calibrated against historical prints before release:
whale_trade— a print of at least twice the whale threshold (the greater of $500 or eight times the position's median print), so roughly $1,000 and up on most markets.contest_phasesays whether it printed before the scheduled start; pre-game whales did not move prices in testing, so theirconfidenceis capped atmediumhowever large they are.flow_imbalance— only unanimous flow: every aggressive print in the last minute one way, at least five prints and $1,000, with no single print more than half of it (a single large print is a whale, not flow).trade_burst— observed for calibration but not published: bursts did not beat ordinary trading in testing. Its catalog entry stays so a stored type string still resolves.trade_throughandvolume_spike— published at their initial thresholds while they are evaluated; expect their calibration to change.
Read the catalog rather than this table: GET /signals/types is open to any key on any plan and states each type's strength_semantics and evidence_fields. Beta types are served only when you ask for them.
required_entitlements; a signal whose type needs a code your key lacks is simply absent from the feed, not an error. Today every type needs only signals:read. To see what your own key will receive, read the catalog: each type carries entitled (true or false for the key that asked) and missing_entitlements (the codes you would need).evidence is its data packet: the summary numbers (best_combined_price, after_fee_roi, best_max_investment, …) and sides, which gives both legs with every venue quoting each one (partner_id, price, after_fee_price, available, position_hash). The legs' venues are in the envelope's venues. The older evidence.venues (two bare ids) is deprecated: it is still sent, but read venues instead. The standalone GET /flow/v1/arbitrages endpoint and the arbitrage stream channel are deprecated in favour of ?type=arbitrage and the signal channel. They keep working under arbitrage:read./flow/v1/signals/typesThe catalog — every type OpenMarkets has ever emitted, retired ones included, with its status, family, strength semantics, evidence fields, required entitlements and typical TTL, and whether the key that asked will receive it. Ungated, so you can read it before deciding whether to buy signals:read.
/flow/v1/signals/types[
{
"signal_type": "arbitrage",
"name": "Arbitrage",
"family": "core", // core | premium
"status": "active", // beta | active | deprecated | retired
"schema_version": 1,
"description": "…",
"strength_semantics": "…",
"evidence_fields": { "after_fee_roi": "…", "…": "…" },
"required_entitlements": ["signals:read"],
"typical_ttl_seconds": 120,
"entitled": true, // for the key that asked
"missing_entitlements": [] // codes this key lacks for the type
}
]
/flow/v1/signalsThe live feed. Filters compose as AND; contest_id and league_id together return the intersection. Beta types are excluded unless include_beta is set. Not read-cached, because the body depends on your entitlements. Requires signals:read.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| contest_id | query | string | no | Comma-separated contest ids |
| league_id | query | string | no | Comma-separated league ids |
| market_id | query | string | no | Comma-separated market ids |
| position_hash | query | string | no | Comma-separated; also matches related_positions |
| type | query | string | no | Comma-separated signal types |
| include_beta | query | enum | no | Serve beta types too |
| min_strength | query | string | no | Strength floor, 0–100 (the same scale for every type) |
| min_confidence | query | enum | no | |
| sort | query | enum | no | strength and detected_at descending; expires_at ascending |
| limit | query | integer | no | Max 500. meta carries total, returned and contest_count |
/flow/v1/signals?sort=strength&limit=100Comma-separated contest ids
Comma-separated league ids
Comma-separated market ids
Comma-separated; also matches related_positions
Comma-separated signal types
Serve beta types too
Strength floor, 0–100 (the same scale for every type)
strength and detected_at descending; expires_at ascending
Max 500. meta carries total, returned and contest_count
[
{
"signal_id": "sig_…",
"dedupe_key": "arbitrage:ct_abc123:mk_ml:…",
"signal_type": "arbitrage",
"schema_version": 1,
"status": "active",
"position": {
"position_hash": "ct_abc123:mk_ml:side_home:var_0:p_lal:tf_full",
"router_contest_id": "ct_abc123",
"router_league_id": "lg_nba",
"router_market_id": "mk_ml",
"market_key": "moneyline",
"market_side_id": "side_home",
"market_variable_id": "var_0",
"participant_id": "p_lal",
"timeframe_id": "tf_full",
"title": "Lakers",
"contest_label": "Lakers @ Rockets"
},
"related_positions": ["ct_abc123:mk_ml:side_away:var_0:p_hou:tf_full"],
"headline": "1.2% after fees across Kalshi and ProphetX",
"explanation": "…",
"strength": 82,
"confidence": "high",
"calibration_version": 1,
"detected_at": "2026-09-22T03:01:46.120Z",
"expires_at": "2026-09-22T03:03:46.120Z",
"evidence": {
"arbitrage_id": "3f2a91c4-…",
"best_combined_price": 0.968,
"best_guaranteed_profit_pct": 3.31,
"after_fee_roi": 1.18,
"profitable_after_fees": true,
"best_max_investment": 412.5,
"venues": ["p_kalshi", "p_prophetx"]
}
}
]
Over the stream
channel: signal · signals:read + stream:realtimeSignals expire in seconds, so the WebSocket is the intended surface; the REST feed is for backfill and one-off queries. Subscribe on the signal channel with the same filters:
{ "action": "subscribe", "channel": "signal",
"league_ids": ["lg_nba"],
"types": ["arbitrage"],
"min_confidence": "medium",
"min_strength": 50,
"include_beta": false }
Every filter is optional: a subscription with none receives signals for every contest, which is what a monitor wants. strength is 0–100 on every type. Filters are applied server-side per subscriber, so you only pay bandwidth for signals you would act on.
{ "type": "signal.update",
"contest_id": "ct_abc123",
"league_id": "lg_nba",
"signals": [ /* the same objects GET /flow/v1/signals returns */ ],
"timestamp": "2026-09-22T03:01:46.180Z" }
Each event is a snapshot of that contest's current signals, not a delta: replace whatever you hold for contest_id. An empty signals array means the contest's signals cleared. Events are sent only when the set changes. When every signal in a change fails your filters you get nothing — except that a clear (an empty array) reaches every subscriber of the contest, so treat it as a no-op for a contest you hold nothing for. Still drop a signal yourself once its expires_at passes.
Everything else about the connection — the key in the query string, heartbeats, limits, the entitlement_required error — is on the Streaming page.
Acting on a signal
- Detection is not advice. A signal reports a priced relationship and shows its evidence; venue rules, settlement-source differences and your own limits are yours to check.
- Use the evidence, not just the strength. For arbitrage, trade off
after_fee_roiand size tobest_max_investment; the full legs are onGET /arbitragesbyarbitrage_id. - Respect
expires_at. A signal past it is history, whatever the feed still holds.