3.4 Neg-Risk Sum Arb
Neg-Risk Sum Arb exploits pricing dislocations on Polymarket’s negative-risk (multi-outcome) markets, where the sum of YES token prices across N outcomes must equal $1.00 at resolution. When sum(YES asks across outcomes) < $1.00 net of fees, the bot buys the underpriced YES tokens and — where profitable — routes the position through the NegRiskAdapter on Polygon to convert NO tokens across the set into pUSD. The 'Other' outcome is always excluded from the conversion path. This is a user-controlled execution tool that targets a structural pricing constraint specific to negative-risk event design on Polymarket. It is the multi-outcome counterpart to sum-to-one-arb.
v3 readiness
A bot is done when all four scores are. What does done mean?
1. Bot Identity
| Layer | Strategy Strategy |
|---|---|
| Bot class | Alpha Strategy |
| Authority | Trade |
| Status | LIVE |
| Readiness | General live |
| Runs before | Risk guardrail pipeline |
| Runs after | Market scanner / opportunity feed |
| Applies to | Negative-risk (multi-outcome) markets where the sum of YES token best asks across all outcomes (excluding 'Other') falls below 1.00 pUSD |
| Default mode | general_live |
| User-visible | Advanced details only |
| Developer owner | Polytraders core — Strategy pod |
2. Purpose
Neg-Risk Sum Arb exploits pricing dislocations on Polymarket’s negative-risk (multi-outcome) markets, where the sum of YES token prices across N outcomes must equal $1.00 at resolution. When sum(YES asks across outcomes) < $1.00 net of fees, the bot buys the underpriced YES tokens and — where profitable — routes the position through the NegRiskAdapter on Polygon to convert NO tokens across the set into pUSD. The 'Other' outcome is always excluded from the conversion path. This is a user-controlled execution tool that targets a structural pricing constraint specific to negative-risk event design on Polymarket. It is the multi-outcome counterpart to sum-to-one-arb.
3. Why This Bot Matters
Sum computed without excluding 'Other' outcome
The 'Other' token is illiquid and reprices discontinuously. Including it in the sum produces false edge signals that cannot be closed via the NegRiskAdapter path.
NegRiskAdapter path not available (market not on negRisk contract)
Buying all YES tokens across N outcomes without a conversion path leaves open positions on all legs; settlement risk is uncapped until individual markets resolve.
feeRateBps hardcoded on signed order (V1 pattern)
CLOB V2 rejects orders with feeRateBps. Fees are operator-set at match time. All signed orders must omit this field.
Partial fill on one outcome leg leaves unbalanced multi-leg exposure
In a 4-outcome event, buying 3 of 4 YES tokens does not produce a guaranteed $1 settlement and creates directional risk on the unfilled outcome.
No worked examples on this bot yet. Worked examples are optional but strongly recommended — they turn an abstract failure mode into something a developer can verify in a fixture.
4. Required Polymarket Inputs
| Input | Source | Required? | Use |
|---|---|---|---|
| Outcome token list and YES token IDs for the neg-risk event | gamma (Gamma API — negRisk/enableNegRisk flag + condition ID) | Yes | Enumerate all N outcome tokens; identify and exclude the 'Other' outcome token. |
| Best ask for each YES outcome token | ws_market (CLOB WebSocket book stream) | Yes | Compute sum(YES asks) across N-1 outcomes (excluding Other) and measure edge against 1.00 pUSD. |
| Top-of-book depth per outcome | clob_public | Yes | Size each leg to the minimum available depth across all outcomes up to max_leg_size_usd. |
| NegRiskAdapter contract availability and condition ID | onchain (NegRiskAdapter on Polygon) | Yes | Verify the NegRiskAdapter conversion path is live for this event before committing to the arb. |
| Platform fee rate per market category | onchain (CTFExchangeV2 fee config) | Yes | Estimate per-leg fee drag; crypto ≤1.80%, sports 0.75%, geopolitical free. |
5. Required Internal Inputs
| Input | Source | Required? | Use |
|---|---|---|---|
| KillSwitch active flag | KillSwitch | Yes | Abort all intent emission if KillSwitch is active. |
| Builder code bytes32 | internal config | Yes | Injected into builder field on every signed V2 order for attribution. |
6. Parameter Guide
| Parameter | Default | Warning | Hard | What it controls |
|---|---|---|---|---|
| min_edge_bps | 20 | 12 | 8 | Minimum net edge in basis points required across the full outcome set before emitting any OrderIntents. Higher default than sum-to-one-arb to account for N-leg fee drag. |
| prefer_conversion_path | True | None | None | When true, bot prefers the NegRiskAdapter burn-NO-tokens→pUSD conversion path over holding all YES tokens to resolution. This crystallises the arb profit without waiting for the event to resolve. |
| max_outcomes_per_trade | 8 | 6 | 12 | Maximum number of outcome legs to include in a single arb trade. Events with more outcomes than this ceiling are skipped. |
| exclude_other_outcome | True | None | None | Always exclude the 'Other' outcome token from the sum computation and from order emission. Locked to true — cannot be disabled. |
7. Detailed Parameter Instructions
min_edge_bps
What it means
Minimum net edge in basis points required across the full outcome set before emitting any OrderIntents. Higher default than sum-to-one-arb to account for N-leg fee drag.
Default
{ "min_edge_bps": 20 }
Why this default matters
With N outcomes each incurring taker fees, total fee drag grows proportionally. 20 bps default provides comfortable margin above multi-leg fees on crypto markets.
Threshold logic
| Condition | Action |
|---|---|
| net_edge_bps ≥ 20 | EMIT N OrderIntents across all non-Other outcomes |
| 12 ≤ net_edge_bps < 20 | WARN NEG_RISK_SUM_ARB_EDGE_MARGINAL — emit with reduced size (50%) |
| net_edge_bps < 8 (hard floor) | SKIP — NEG_RISK_SUM_ARB_NO_EDGE |
Developer check
if net_edge_bps < params.hard: return skip('NEG_RISK_SUM_ARB_NO_EDGE')
User-facing English
The combined price of all outcome tokens in this multi-outcome market was not low enough to trade profitably after accounting for multi-leg fees.
prefer_conversion_path
What it means
When true, bot prefers the NegRiskAdapter burn-NO-tokens→pUSD conversion path over holding all YES tokens to resolution. This crystallises the arb profit without waiting for the event to resolve.
Default
{ "prefer_conversion_path": true }
Why this default matters
The conversion path (burn NO tokens → pUSD via NegRiskAdapter) is lower-risk than holding N YES positions open to resolution. Prefer it when available.
Threshold logic
| Condition | Action |
|---|---|
| prefer_conversion_path=true and NegRiskAdapter available | Route through NegRiskAdapter after fills |
| prefer_conversion_path=true and NegRiskAdapter unavailable | Hold YES positions; warn NEG_RISK_SUM_ARB_NO_CONVERSION_PATH |
Developer check
if params.prefer_conversion_path and adapter.available: EMIT NegRiskConvertRoute
User-facing English
Positions will be converted to pUSD immediately after filling where possible.
max_outcomes_per_trade
What it means
Maximum number of outcome legs to include in a single arb trade. Events with more outcomes than this ceiling are skipped.
Default
{ "max_outcomes_per_trade": 8 }
Why this default matters
More legs increase total fee drag and the risk of a partial fill leaving an unbalanced position. 8 outcomes cover the vast majority of Polymarket neg-risk events.
Threshold logic
| Condition | Action |
|---|---|
| N outcomes ≤ 8 | Normal multi-leg emission |
| 8 < N ≤ 12 | WARN NEG_RISK_SUM_ARB_HIGH_OUTCOME_COUNT; emit with 50% size per leg |
| N > 12 | SKIP — too many legs for safe atomic execution |
Developer check
if len(outcomes) > params.hard: return skip('NEG_RISK_SUM_ARB_NO_EDGE')
User-facing English
— not yet authored —
exclude_other_outcome
What it means
Always exclude the 'Other' outcome token from the sum computation and from order emission. Locked to true — cannot be disabled.
Default
{ "exclude_other_outcome": true }
Why this default matters
The 'Other' token covers residual probability and is illiquid and non-standard. Including it in an arb creates unquantifiable tail risk.
Threshold logic
| Condition | Action |
|---|---|
| Always true (locked) | Other outcome excluded from all calculations |
Developer check
outcomes = [o for o in event.outcomes if o.name.lower() != 'other']
User-facing English
— not yet authored —
8. Default Configuration
{
"bot_id": "strat.neg_risk_sum_arb",
"version": "2.1.0",
"mode": "general_live",
"defaults": {
"min_edge_bps": 20,
"prefer_conversion_path": true,
"max_outcomes_per_trade": 8,
"exclude_other_outcome": true
},
"locked": {
"exclude_other_outcome": true,
"max_outcomes_per_trade": {
"max": 12
},
"min_edge_bps": {
"min": 8
}
}
}9. Implementation Flow
- Check KillSwitch active flag; if active, skip and emit no OrderIntents.
- FETCH Gamma API for all active neg-risk events (enableNegRisk=true); extract outcome token lists.
- Exclude 'Other' outcome token from each event's outcome list.
- If len(outcomes) > max_outcomes_per_trade, skip this event.
- Verify NegRiskAdapter availability for event condition ID via onchain call.
- Subscribe to ws_market book updates for all YES token IDs of each eligible event.
- On each book tick: compute sum_YES_asks = sum(best_ask[outcome] for outcome in outcomes).
- Compute raw_edge_bps = (1.00 - sum_YES_asks) * 10000.
- Deduct total fee drag across N legs; compute net_edge_bps = raw_edge_bps - N * per_leg_fee_bps - fee_buffer_bps.
- If net_edge_bps < min_edge_bps hard floor (8), emit DecisionReport intent_emitted=false NEG_RISK_SUM_ARB_NO_EDGE; skip.
- If 8 ≤ net_edge_bps < 20, emit WARN NEG_RISK_SUM_ARB_EDGE_MARGINAL; reduce leg size 50%.
- Compute legSize = toPusdUnits(min(min_depth_across_outcomes, max_leg_size_usd) * sizeMultiplier).
- Emit one OrderIntent per outcome leg: side=buy, outcome=YES, price=best_ask, tif=FOK, builder={code, fee_bps: 25}. No feeRateBps on any signed order — fees are operator-set at match time in V2.
- If prefer_conversion_path and NegRiskAdapter available: EMIT NegRiskConvertRoute after fill confirmations (burn NO tokens → pUSD via NegRiskAdapter).
- Emit DecisionReport with intent_emitted=true, edge_bps, outcome_count, conversion_path_used.
10. Reference Implementation
Scans Gamma API for neg-risk events, subscribes to book streams for all YES token IDs, computes net edge across N outcomes, and emits N FOK OrderIntents when edge exceeds threshold. Optionally routes through NegRiskAdapter for immediate pUSD conversion.
Pseudocode is language-agnostic. FETCH = read input. EMIT = produce output. Translate to TS/Python/Go/Rust.
FUNCTION scanNegRiskEvents():
// --- 0. KillSwitch gate ---
ks = FETCH internal.killswitch.status
IF ks.active:
RETURN
// --- 1. Fetch active neg-risk events from Gamma ---
events = FETCH gamma.GET('/markets?negRisk=true&active=true')
FOR event IN events:
outcomes = [o for o in event.outcomes if o.name.lower() != 'other'] // exclude Other (locked)
IF len(outcomes) > params.max_outcomes_per_trade:
SKIP event // too many legs
// --- 2. Verify NegRiskAdapter available ---
adapterAvailable = FETCH onchain.NegRiskAdapter.isConditionRegistered(event.conditionId)
// --- 3. Subscribe to book streams for all YES token IDs ---
tokenIds = [o.yes_token_id for o in outcomes]
ws_market.subscribe('book', tokenIds)
FUNCTION onBookTick(event, outcomes, tick):
// --- 4. Check freshness ---
IF isStale(tick, maxAgeS=5):
EMIT DecisionReport(intent_emitted=false, reason='STALE_MARKET_DATA')
RETURN
// --- 5. Compute edge ---
asks = [tick[o.yes_token_id].best_ask for o in outcomes]
sum_YES_asks = SUM(asks)
raw_edge_bps = (1.00 - sum_YES_asks) * 10000
// --- 6. Fee drag across N legs ---
feeRate = FETCH onchain.feeConfig(event.conditionId)
N = len(outcomes)
total_fee = SUM(feeRate * asks[i] * (1 - asks[i]) * 10000 for i in range(N))
net_edge_bps = raw_edge_bps - total_fee - params.fee_buffer_bps
// --- 7. Hard floor ---
IF net_edge_bps < params.min_edge_bps_hard: // 8 bps
EMIT DecisionReport(intent_emitted=false, reason='NEG_RISK_SUM_ARB_NO_EDGE')
RETURN
// --- 8. Warning threshold ---
legSizeMultiplier = 1.0
IF net_edge_bps < params.min_edge_bps: // 20 bps default
WARN('NEG_RISK_SUM_ARB_EDGE_MARGINAL')
legSizeMultiplier = 0.5
// --- 9. Leg sizing ---
minDepth = MIN(tick[o.yes_token_id].depth_pusd for o in outcomes)
legSize = toPusdUnits(min(minDepth, params.max_leg_size_usd) * legSizeMultiplier)
// --- 10. Emit N OrderIntents (V2: no feeRateBps; builder field carries code) ---
FOR i, outcome IN enumerate(outcomes):
EMIT OrderIntent(
market_id = outcome.market_id,
outcome = 'YES',
side = 'buy',
price = asks[i],
size_pUSD = legSize,
tif = 'FOK',
post_only = false,
builder = { code: config.builder_code, fee_bps: 25 },
negrisk_aware = true
)
// --- 11. NegRiskAdapter conversion path ---
IF params.prefer_conversion_path AND adapterAvailable:
EMIT NegRiskConvertRoute(
conditionId = event.conditionId,
action = 'burn_NO_to_pUSD', // NegRiskAdapter: burn NO across set -> pUSD
trigger = 'on_all_fills'
)
EMIT DecisionReport(
intent_emitted = true,
edge_bps = net_edge_bps,
outcome_count = N,
conversion_path = 'NegRiskAdapter' IF adapterAvailable ELSE 'hold',
reasons = ['NEG_RISK_SUM_ARB_EDGE_PRESENT']
)
SDK calls used
gamma.GET('/markets?negRisk=true&active=true')onchain.NegRiskAdapter.isConditionRegistered(conditionId)ws_market.subscribe('book', tokenIds)onchain.feeConfig(conditionId)toPusdUnits(rawFloat)buildOrderTypedData(orderParams, { name: 'CTFExchange', version: '2', chainId: 137 })internal.killswitch.status()internal.builder_code
Complexity: O(N) per book tick where N = number of outcomes per event
11. Wire Examples
Input — what arrives on the wire
Gamma API neg-risk event with 4 outcomes — gamma
{
"conditionId": "0xbcd1ef2345678901bcdef01234567890bcdef01234567890bcdef01234567890bc",
"negRisk": true,
"outcomes": [
{
"name": "Outcome A",
"yes_token_id": "0xtoken_a"
},
{
"name": "Outcome B",
"yes_token_id": "0xtoken_b"
},
{
"name": "Outcome C",
"yes_token_id": "0xtoken_c"
},
{
"name": "Outcome D",
"yes_token_id": "0xtoken_d"
},
{
"name": "Other",
"yes_token_id": "0xtoken_other"
}
]
}
ws_market book tick — sum(YES) = 0.978 — ws_market
{
"token_a_ask": "0.242",
"token_b_ask": "0.245",
"token_c_ask": "0.248",
"token_d_ask": "0.243",
"sum_YES": "0.978",
"received_at_ms": 1746789700000
}
Output — what the bot emits
OrderIntent — Outcome A leg (one of 4; all similar)
{
"intent_id": "oi_01HX9NGRSUM4A1B",
"trace_id": "tr_01HX9NGRSUM4VR5",
"market_id": "0xbcd1ef2345678901bcdef01234567890bcdef01234567890bcdef01234567890bc",
"outcome": "YES",
"side": "buy",
"price": "0.242",
"size_pUSD": "400.00",
"tif": "FOK",
"post_only": false,
"builder": {
"code": "0x706f6c7974726164657273000000000000000000000000000000000000000000",
"fee_bps": 25
},
"negrisk_aware": true,
"decision": {
"edge_bps": 22.1,
"outcome_count": 4,
"conversion_path": "NegRiskAdapter",
"reasons": [
"NEG_RISK_SUM_ARB_EDGE_PRESENT"
]
},
"comment": "fees are operator-set at match time in V2 — feeRateBps is NOT on the signed order"
}
NegRiskConvertRoute — burn NO tokens → pUSD after fills
{
"route_id": "nrc_01HX9NGRSUM4X9Z",
"conditionId": "0xbcd1ef2345678901bcdef01234567890bcdef01234567890bcdef01234567890bc",
"action": "burn_NO_to_pUSD",
"trigger": "on_all_fills",
"adapter": "NegRiskAdapter",
"network": "polygon"
}12. Decision Logic
APPROVE
net_edge_bps ≥ min_edge_bps, NegRiskAdapter confirmed available, all outcome legs have depth, outcome count ≤ max_outcomes_per_trade, KillSwitch inactive. Emit N OrderIntents.
RESHAPE_REQUIRED
Not applicable — strat bots emit OrderIntents; reshaping is handled downstream by the Risk guardrail pipeline.
REJECT
net_edge_bps < 8 bps hard floor; NegRiskAdapter unavailable and prefer_conversion_path=true; outcome count > 12; KillSwitch active; stale feed. Emit DecisionReport intent_emitted=false.
WARNING_ONLY
net_edge_bps between 8 and 20 triggers NEG_RISK_SUM_ARB_EDGE_MARGINAL and 50% size reduction before emitting.
13. Standard Decision Output
This bot returns a OrderIntent object. See OrderIntent schema.
{
"intent_id": "oi_01HX9NGRSUM4A1B",
"trace_id": "tr_01HX9NGRSUM4VR5",
"market_id": "0xbcd1ef2345678901bcdef01234567890bcdef01234567890bcdef01234567890bc",
"outcome": "YES",
"side": "buy",
"price": "0.242",
"size_pUSD": "400.00",
"tif": "FOK",
"post_only": false,
"builder": {
"code": "0x706f6c7974726164657273000000000000000000000000000000000000000000",
"fee_bps": 25
},
"negrisk_aware": true,
"decision": {
"edge_bps": 22.1,
"outcome_count": 4,
"conversion_path": "NegRiskAdapter",
"reasons": [
"NEG_RISK_SUM_ARB_EDGE_PRESENT"
]
},
"comment": "fees are operator-set at match time in V2 \u2014 feeRateBps is NOT on the signed order"
}14. Reason Codes
| Code | Severity | Meaning | Action | User-facing message |
|---|---|---|---|---|
NEG_RISK_SUM_ARB_EDGE_PRESENT | INFO | Net edge across all N non-Other outcome YES legs meets or exceeds min_edge_bps. N OrderIntents emitted. | Emit N OrderIntents and optional NegRiskConvertRoute. | A pricing gap was detected across all outcomes of this market and orders were placed to capture it. |
NEG_RISK_SUM_ARB_NO_EDGE | INFO | Net edge across outcome legs is below the 8 bps hard floor after multi-leg fees. No trade. | Skip; emit DecisionReport intent_emitted=false. | The combined outcome pricing was not low enough to trade profitably after multi-leg fees. |
NEG_RISK_SUM_ARB_EDGE_MARGINAL | WARN | Edge is between 8 and 20 bps. Trade is entered at 50% leg size. | Emit N OrderIntents at 50% leg size; log warning. | A small pricing gap was detected across outcomes. Reduced-size orders were placed. |
NEG_RISK_SUM_ARB_NO_CONVERSION_PATH | WARN | NegRiskAdapter is unavailable or not registered for this condition ID. Positions will be held to resolution. | Emit OrderIntents if edge is present; log warning; do not emit NegRiskConvertRoute. | Immediate conversion is not available for this market. Positions are held until resolution. |
NEG_RISK_SUM_ARB_HIGH_OUTCOME_COUNT | WARN | Outcome count is between max_outcomes_per_trade warning (8) and hard limit (12). | Emit at 50% leg size with warning. | |
STALE_MARKET_DATA | HARD_REJECT | Book snapshot older than 5 seconds or Gamma API data is stale. | Skip; emit DecisionReport intent_emitted=false. | Market data was too old to act on safely. |
MARKET_CLOSED | HARD_REJECT | Event is closed or resolved. | Skip immediately; no OrderIntents. | This market is no longer open for trading. |
KILL_SWITCH_ACTIVE | HARD_REJECT | Global kill switch is active. | Skip all events; no OrderIntents. | Trading is currently paused. |
15. Metrics & Logs
Metrics emitted
| Metric | Type | Unit | Labels | Meaning |
|---|---|---|---|---|
polytraders_strat_negrisksumarb_decisions_total | counter | count | verdict, reason_code | Total evaluation cycles by intent_emitted (true/false) and reason code. |
polytraders_strat_negrisksumarb_edge_bps | histogram | basis_points | Distribution of net edge in bps across all evaluated neg-risk events. | |
polytraders_strat_negrisksumarb_outcome_count | histogram | count | Distribution of outcome counts per traded event (excluding Other). | |
polytraders_strat_negrisksumarb_intents_emitted_total | counter | count | conversion_path | Total OrderIntent sets emitted, labelled by conversion path used (NegRiskAdapter or hold). |
polytraders_strat_negrisksumarb_eval_latency_ms | histogram | milliseconds | Wall-clock latency from book tick to last OrderIntent emit per event. | |
polytraders_strat_negrisksumarb_conversion_route_total | counter | count | result | NegRiskConvertRoute emissions by result (success, adapter_unavailable, skipped). |
Alerts
| Alert | Condition | Severity | Runbook |
|---|---|---|---|
NegRiskSumArbStaleFeed | rate(polytraders_strat_negrisksumarb_decisions_total{reason_code='STALE_MARKET_DATA'}[5m]) > 0.1 | warn | #runbook-negrisksumarb-stale-feed |
NegRiskSumArbAdapterUnavailable | rate(polytraders_strat_negrisksumarb_decisions_total{reason_code='NEG_RISK_SUM_ARB_NO_CONVERSION_PATH'}[5m]) > 0.5 | warn | #runbook-negrisksumarb-adapter |
NegRiskSumArbHighLatency | histogram_quantile(0.99, rate(polytraders_strat_negrisksumarb_eval_latency_ms_bucket[5m])) > 200 | warn | #runbook-negrisksumarb-latency |
NegRiskSumArbKillSwitchBlocking | rate(polytraders_strat_negrisksumarb_decisions_total{reason_code='KILL_SWITCH_ACTIVE'}[1m]) > 0 | page | #runbook-killswitch |
Dashboards
- Grafana — Strategy / NegRiskSumArb edge and outcome count distribution
- Grafana — Strategy / NegRiskSumArb NegRiskAdapter conversion throughput
16. Developer Reporting
{
"bot_id": "strat.neg_risk_sum_arb",
"event_condition_id": "0xbcd1ef2345678901bcdef01234567890bcdef01234567890bcdef01234567890bc",
"outcome_count": 4,
"sum_YES_asks": 0.978,
"raw_edge_bps": 22.0,
"total_fee_drag_bps": 14.2,
"net_edge_bps": 22.1,
"leg_size_pusd": 400.0,
"conversion_path": "NegRiskAdapter",
"intent_emitted": true,
"reason": "NEG_RISK_SUM_ARB_EDGE_PRESENT",
"emitted_at_ms": 1746789700000
}17. Plain-English Reporting
| Situation | User-facing explanation |
|---|---|
| Multi-outcome arb trade initiated | The combined price of all outcome tokens in this multi-outcome market was below $1. Orders were placed across all outcomes to capture this pricing gap. Positions may be immediately converted to settled value via the NegRisk contract. |
| No edge in multi-outcome market | The combined outcome prices did not create a profitable opportunity after accounting for fees on each leg. No orders were placed. |
| Conversion path unavailable | The NegRisk contract path was not available for this event. Positions are being held to settlement. |
| Too many outcomes to trade safely | This event has more outcome tokens than the configured maximum for safe multi-leg execution. The trade was skipped. |
18. Failure-Mode Block
| main_failure_mode | Partial fill: N-1 of N outcome legs fill FOK but one leg's price moves, leaving an unbalanced multi-outcome position that does not sum cleanly to $1. |
|---|---|
| false_positive_risk | Stale book data shows sum(YES asks) < 1.00 when the live market has already rebalanced, causing the bot to attempt an arb on prices that no longer exist. |
| false_negative_risk | NegRiskAdapter availability check has latency; a valid arb is skipped because the adapter check returns stale-unavailable during a brief RPC delay. |
| safe_fallback | If book data is stale (> 5s) or the NegRiskAdapter cannot be confirmed available, skip and emit DecisionReport intent_emitted=false. Never enter a partial multi-leg position deliberately. |
| required_dependencies | Gamma API neg-risk event list (enableNegRisk=true), ws_market book stream (all YES token IDs per event), clob_public depth per outcome, onchain NegRiskAdapter contract (Polygon), KillSwitch active flag, internal builder code |
19. Failure-Injection Recipes
| Scenario | How to inject | Expected behaviour | Recovery |
|---|---|---|---|
STALE_GAMMA_FEED | Block TCP to gamma-api.polymarket.com for 70s (cache TTL = 60s) | Automatic on Gamma API reconnect. | |
NEGRISK_ADAPTER_UNAVAILABLE | Mock onchain.NegRiskAdapter.isConditionRegistered to return false | Automatic when adapter check returns true again. | |
PARTIAL_LEG_FILL | Mock CLOB to reject one of N FOK legs after N-1 legs fill | Risk pod manually evaluates and closes partial position. | |
KILL_SWITCH_ON | Set killswitch.active=true | Automatic on manual KillSwitch reset. | |
HIGH_OUTCOME_COUNT | Create mock event with 14 outcomes | Automatic; event is re-evaluated each cycle. |
20. State & Persistence
Cold-start recovery
On cold start, state is empty; first book tick per event triggers fresh evaluation. NegRiskAdapter availability is re-checked from onchain on first evaluation.
21. Concurrency & Idempotency
| Aspect | Specification |
|---|---|
| Execution model | actor-per-market |
| Max in-flight | 30 |
| Idempotency key | intent_id |
| Per-call timeout (ms) | 200 |
| Backpressure strategy | drop oldest pending tick per conditionId when queue depth > 5 |
| Locking / mutual exclusion | per-conditionId mutex for Redis state write and NegRiskAdapter check |
22. Dependencies
Depends on (must run first)
| Bot | Why | Contract |
|---|---|---|
| risk.kill_switch | Checked first; blocks all intent emission when active. |
Emits to (downstream consumers)
| Bot | Why | Contract |
|---|---|---|
| risk.portfolio_guard | ||
| gov.builder_attribution |
External services
| Service | Endpoint | SLA assumed | On failure |
|---|---|---|---|
| Gamma API (neg-risk event list) | 99.9% (Polymarket-published) | ||
| Polymarket CLOB WebSocket (ws_market) | best-effort | ||
| NegRiskAdapter (onchain, Polygon) | Polygon RPC SLA |
23. Security Surfaces
On-chain contract calls
| Contract | Method | Network | Effect |
|---|---|---|---|
CTFExchangeV2 | | polygon | |
NegRiskAdapter | | polygon |
Abuse vectors considered
- Injecting a fake Gamma API response to fabricate neg-risk events with artificially low outcome prices
- NegRiskAdapter reentrancy on Polygon if burn-NO flow is invoked with a manipulated condition ID
- Partial-fill attack: adversary moves one outcome book between leg submissions to leave an unbalanced position
Mitigations
- Gamma API response validated against onchain conditionId before any order emission
- NegRiskAdapter conditionId verified onchain before emitting NegRiskConvertRoute
- All legs use FOK; a failed leg prevents NegRiskConvertRoute from being triggered
- builder.code read from immutable internal config; not user-supplied
- V2 order timestamp(ms) invalidates replays outside exchange acceptance window
24. Polymarket V2 Compatibility
| Aspect | Value |
|---|---|
| CLOB version | v2 |
| Collateral asset | pUSD |
| EIP-712 Exchange domain version | 2 |
| Aware of builderCode field | yes |
| Aware of negative-risk markets | yes |
| Multi-chain ready | no |
| SDK used | py-clob-client-v2 |
| Settlement contract | CTFExchangeV2 |
| Notes | Core strategy is NegRiskAdapter convert-arb: buy YES tokens across all N non-Other outcomes when sum(YES asks) < $1, then burn NO tokens via NegRiskAdapter on Polygon to receive pUSD without waiting for resolution. feeRateBps is not present on any signed order — fees are operator-set at match time. |
API surfaces declared
Networks supported
25. Versioning & Migration
| Field | Value |
|---|---|
| spec | 2.0.0 |
| implementation | 2.1.0 |
| schema | 2 |
| released | 2026-04-28 |
Migration history
| Date | From | To | Reason | Action taken |
|---|---|---|---|---|
| 2026-04-28 | v1 (USDC.e, feeRateBps on signed order, HMAC builder) | v2 (pUSD, fees operator-set at match time, builderCode bytes32) | CLOB V2 cutover | Switched to py-clob-client-v2. Removed feeRateBps from all signed order construction — fees are operator-set at match time by CTFExchangeV2. Updated collateral from USDC.e to pUSD. Injected builder field (bytes32) on every leg. EIP-712 Exchange domain version updated from '1' to '2'. NegRiskAdapter path updated to V2 contract address on Polygon. |
26. Acceptance Tests
Unit Tests
| Test | Setup | Expected result |
|---|---|---|
| Emit N intents when sum(YES asks) = 0.978 across 4 outcomes (net_edge > 20 bps) | outcomes=[0.242,0.245,0.248,0.243], fee_buffer=40, min_edge=20 | 4 OrderIntents emitted; intent_emitted=true; negrisk_aware=true on each |
| Skip when sum(YES asks) = 0.992 (raw edge < 8 bps after fees) | sum_YES=0.992, N=4, per_leg_fee_drag=5bps | No OrderIntents; DecisionReport intent_emitted=false, reason=NEG_RISK_SUM_ARB_NO_EDGE |
| Always exclude 'Other' outcome from sum and from intent emission | outcomes=[A, B, C, D, Other], sum without Other = 0.976 | 4 intents (A,B,C,D); Other never included; sum check uses 4 outcomes only |
| Skip event with > 12 outcomes (max_outcomes_per_trade hard limit) | event has 14 outcomes | No OrderIntents; reason=NEG_RISK_SUM_ARB_NO_EDGE |
| Route NegRiskConvertRoute when prefer_conversion_path=true and adapter available | prefer_conversion_path=true, adapter.available=true | NegRiskConvertRoute emitted after fill confirmations |
| Skip when KillSwitch active | killswitch.active=true | No OrderIntents emitted |
Integration Tests
| Test | Expected result |
|---|---|
| Full cycle: Gamma API → outcome list → book ticks → N signed V2 OrderIntents → NegRiskAdapter conversion | All N orders contain builder.code (bytes32), no feeRateBps, EIP-712 domain version '2'; NegRiskConvertRoute emitted post-fill |
| NegRiskAdapter unavailable triggers warn and hold-to-resolution path | NEG_RISK_SUM_ARB_NO_CONVERSION_PATH warn emitted; intents still emitted if edge > min; positions held |
Property Tests
| Property | Required behaviour |
|---|---|
| Bot never emits a subset of outcome legs; always emits full N-outcome set or none | Always true — partial fills are handled by FOK on individual orders, not by design |
| 'Other' outcome is never included in any OrderIntent or sum computation | Always true — exclude_other_outcome is locked |
| feeRateBps field is never present on any emitted OrderIntent | Always true — V2 fees are operator-set at match time |
27. Operational Runbook
NegRiskSumArb incidents typically involve NegRiskAdapter unavailability, stale Gamma API data, or partial-leg fills. Partial fills require manual risk review; adapter issues are usually transient RPC problems.
On-call actions
| Alert | First step | Diagnosis | Mitigation | Escalate to |
|---|---|---|---|---|
NegRiskSumArbAdapterUnavailable | ||||
NegRiskSumArbStaleFeed | ||||
NegRiskSumArbHighLatency | ||||
NegRiskSumArbKillSwitchBlocking |
Manual overrides
——
Healthcheck
GET /internal/health/neg-risk-sum-arb -> 200 if Gamma API last_seen < 60s, ws_market feed last_seen < 5s per active event, NegRiskAdapter reachable, and KillSwitch inactive.28. Promotion Gates
A bot does not advance to the next readiness state until every gate below is green. Gates are observable from production data — no subjective sign-off.
Promote to Shadow
| Gate | How measured | Threshold |
|---|---|---|
| All unit tests pass including 'Other' exclusion invariant | CI test run | 100% pass |
| NegRiskAdapter conditionId validation test passes | Integration test against Polygon testnet | Pass |
Promote to Limited live
| Gate | How measured | Threshold |
|---|---|---|
| p99 eval latency < 200ms over 24h for events with N ≤ 8 outcomes | polytraders_strat_negrisksumarb_eval_latency_ms histogram | p99 < 200ms |
| Zero partial-fill incidents in shadow mode over 48h | Fill reconciliation report | 0 incidents |
Promote to General live
| Gate | How measured | Threshold |
|---|---|---|
| E2E: Gamma event → N signed V2 OrderIntents → NegRiskConvertRoute on Polygon testnet | E2E test | Pass |
| feeRateBps absence verified in all N OrderIntent signed payloads | Integration test asserting V2 order schema | Pass |
29. Developer Checklist
Ready-to-ship score: 27/27 sections complete · 100%
| Requirement | Status |
|---|---|
| Purpose defined | ✓ done |
| Required inputs listed | ✓ done |
| Parameters defined | ✓ done |
| Defaults defined | ✓ done |
| Warning thresholds defined | ✓ done |
| Hard thresholds defined | ✓ done |
| Safe fallback defined | ✓ done |
| Structured output defined | ✓ done |
| Developer log defined | ✓ done |
| Plain-English explanation | ✓ done |
| Unit tests defined | ✓ done |
| Integration tests defined | ✓ done |
| Property tests defined | ✓ done |
| Failure-mode block complete | ✓ done |
| Reference implementation pseudocode | ✓ done |
| Wire examples (input + output) | ✓ done |
| Reason codes listed | ✓ done |
| Metrics & logs defined | ✓ done |
| State & persistence defined | ✓ done |
| Concurrency & idempotency defined | ✓ done |
| Dependencies declared | ✓ done |
| Security surfaces declared | ✓ done |
| Polymarket V2 compatibility declared | ✓ done |
| Version & migration history declared | ✓ done |
| Operational runbook defined | ✓ done |
| Promotion gates defined | ✓ done |
| Failure-injection recipes defined | ✓ done |