3.9 Sports Model
Sports Model computes a quantitative fair-value probability for Polymarket sports markets using an internal power-rating model fed by league APIs (NBA, NFL, EPL, ATP/WTA, MLB), lineup and injury data via SportsFeed-Adapter, and weather data for outdoor events. When the CLOB mid-price diverges from the model price by more than min_edge_bps_vs_model, the bot sizes an IOC OrderIntent using a fractional-Kelly position sizing formula (kelly_fraction) bounded by max_per_bet_usd. The bot targets the Apr 2026 $5M pUSD maker rebate pool for sports+esports markets when the edge direction permits maker posting. This is a user-controlled execution tool that automates quantitative sports market trading decisions. No performance claims are made.
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 | BETA |
| Readiness | Limited live |
| Runs before | Risk guardrail pipeline |
| Runs after | SportsFeed-Adapter model price feed + Market scanner |
| Applies to | Polymarket sports markets (NBA, NFL, EPL, ATP/WTA, MLB, esports) where internal power-rating model price diverges from CLOB mid by >= min_edge_bps_vs_model, and lineup/injury/weather data is current |
| Default mode | limited_live |
| User-visible | Advanced details only |
| Developer owner | Polytraders core — Strategy pod |
2. Purpose
Sports Model computes a quantitative fair-value probability for Polymarket sports markets using an internal power-rating model fed by league APIs (NBA, NFL, EPL, ATP/WTA, MLB), lineup and injury data via SportsFeed-Adapter, and weather data for outdoor events. When the CLOB mid-price diverges from the model price by more than min_edge_bps_vs_model, the bot sizes an IOC OrderIntent using a fractional-Kelly position sizing formula (kelly_fraction) bounded by max_per_bet_usd. The bot targets the Apr 2026 $5M pUSD maker rebate pool for sports+esports markets when the edge direction permits maker posting. This is a user-controlled execution tool that automates quantitative sports market trading decisions. No performance claims are made.
3. Why This Bot Matters
Model price computed with stale lineup or injury data
A key player's injury changes the model price substantially. Trading on the stale model produces a position in the wrong direction relative to the true updated probability.
Kelly fraction set too high
Fractional Kelly with a high fraction creates large positions that overwhelm the top-of-book depth, causing significant slippage and potentially exceeding the Risk guardrail's position limits.
In-play market state not accounted for
For in-play markets, the CLOB price updates continuously with live game state. Trading on a model price calibrated for pre-game conditions during in-play produces systematically mispriced entries.
feeRateBps present on signed order (V1 pattern)
CTFExchangeV2 rejects orders with feeRateBps. Fees are operator-set at match time. The signed order must not contain this field.
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 |
|---|---|---|---|
| Polymarket sports market mid and depth | ws_market (CLOB WebSocket) | Yes | Compute CLOB mid-price; measure divergence from model price in bps; size order against available depth. |
| In-play market state (where applicable) | ws_sports (Polymarket sports WebSocket) | No | Detect live game state changes (score, time remaining, possession) that affect model price validity. |
| Market open/closed/resolved status | clob_public | Yes | Skip markets that are closed, resolved, or halted (in-play pause). |
5. Required Internal Inputs
| Input | Source | Required? | Use |
|---|---|---|---|
| KillSwitch active flag | KillSwitch | Yes | Abort all intent emission immediately if KillSwitch is active. |
| Power-rating model price (fair-value probability per market) | internal (model engine) | Yes | Compare model_price against CLOB mid; compute edge_bps = |model_price - clob_mid| * 10000. |
| League APIs (NBA, NFL, EPL, ATP/WTA, MLB) | internal (SportsFeed-Adapter) | Yes | Current standings, team stats, and match schedules for model calibration. |
| Lineup / injury / weather data via SportsFeed-Adapter | internal (SportsFeed-Adapter) | Yes | Adjust power-rating model for confirmed lineup changes, injuries, or weather conditions for outdoor events. |
| Builder code bytes32 | internal config | Yes | Injected into builder field on every signed V2 OrderIntent. Sports maker rebate pool (Apr 2026: $5M pUSD) eligible. |
6. Parameter Guide
| Parameter | Default | Warning | Hard | What it controls |
|---|---|---|---|---|
| min_edge_bps_vs_model | 200 | 100 | 50 | Minimum basis-point divergence between CLOB mid-price and internal model price required before emitting an OrderIntent. |
| kelly_fraction | 0.1 | 0.2 | 0.3 | Fraction of the full Kelly criterion bet size applied to each trade. Kelly criterion determines theoretically optimal bet fraction; this parameter scales it down to control risk. |
| max_per_bet_usd | 500 | 750 | 1000 | Hard cap in pUSD on any single sports market bet, regardless of Kelly-derived size. |
| drawdown_guard_bps | 500 | 800 | 1200 | Maximum basis-point drawdown on the running sports book P&L (mark-to-model) before the bot pauses new entries on all sports markets for the session. |
7. Detailed Parameter Instructions
min_edge_bps_vs_model
What it means
Minimum basis-point divergence between CLOB mid-price and internal model price required before emitting an OrderIntent.
Default
{ "min_edge_bps_vs_model": 200 }
Why this default matters
200 bps provides meaningful edge after fee drag (sports markets: 0.75% fee, ~37.5 bps at p=0.5) and expected slippage. Below 100 bps the trade is marginal; below 50 bps the bot will not fire.
Threshold logic
| Condition | Action |
|---|---|
| >= 200 bps | EMIT IOC OrderIntent |
| 100–200 bps | WARN SPORTS_MODEL_EDGE_MARGINAL; emit at 50% kelly size |
| < 50 bps (hard floor) | SKIP — SPORTS_MODEL_NO_EDGE |
Developer check
if edge_bps < params.hard: return skip('SPORTS_MODEL_NO_EDGE')
User-facing English
The difference between the model's estimate and the market price was too small to justify a trade after fees.
kelly_fraction
What it means
Fraction of the full Kelly criterion bet size applied to each trade. Kelly criterion determines theoretically optimal bet fraction; this parameter scales it down to control risk.
Default
{ "kelly_fraction": 0.1 }
Why this default matters
0.10 (10% Kelly) provides conservative position sizing that limits variance. Full Kelly (1.0) maximises long-run growth but produces extreme drawdowns; 0.30 hard cap prevents over-aggressive sizing.
Threshold logic
| Condition | Action |
|---|---|
| <= 0.10 | Conservative Kelly sizing |
| 0.10–0.30 | WARN SPORTS_MODEL_HIGH_KELLY; elevated per-trade size |
| > 0.30 (hard cap) | Reject config — PARAMETER_CHANGE_REQUIRES_APPROVAL |
Developer check
if params.kelly_fraction > params.hard: raise ConfigError('PARAMETER_CHANGE_REQUIRES_APPROVAL')
User-facing English
The trade was sized using a conservative fraction of the model's suggested bet size.
max_per_bet_usd
What it means
Hard cap in pUSD on any single sports market bet, regardless of Kelly-derived size.
Default
{ "max_per_bet_usd": 500 }
Why this default matters
500 pUSD per bet limits single-event exposure and fits within typical Polymarket sports market top-of-book depth without significant slippage.
Threshold logic
| Condition | Action |
|---|---|
| <= 500 pUSD | Normal bet sizing |
| 500–1000 pUSD | WARN; confirm depth supports order size |
| > 1000 pUSD | Reject config — PARAMETER_CHANGE_REQUIRES_APPROVAL |
Developer check
if params.max_per_bet_usd > params.hard: raise ConfigError('PARAMETER_CHANGE_REQUIRES_APPROVAL')
User-facing English
The trade size was capped at the configured maximum to limit single-event exposure.
drawdown_guard_bps
What it means
Maximum basis-point drawdown on the running sports book P&L (mark-to-model) before the bot pauses new entries on all sports markets for the session.
Default
{ "drawdown_guard_bps": 500 }
Why this default matters
500 bps (5%) drawdown guard prevents runaway losses from systematic model miscalibration. Above 800 bps the guard may be too late; above 1200 bps the bot will not fire.
Threshold logic
| Condition | Action |
|---|---|
| < 500 bps drawdown | Normal trading |
| 500–1200 bps | WARN SPORTS_MODEL_DRAWDOWN_WARNING; reduce kelly_fraction to 50% |
| > 1200 bps (hard cap) | SKIP all new entries — SPORTS_MODEL_DRAWDOWN_GUARD_TRIGGERED |
Developer check
if session_drawdown_bps >= params.hard: return skip('SPORTS_MODEL_DRAWDOWN_GUARD_TRIGGERED')
User-facing English
Trading was paused because the session's mark-to-model losses reached the configured limit.
8. Default Configuration
{
"bot_id": "strat.sports_model",
"version": "2.1.0",
"mode": "limited_live",
"defaults": {
"min_edge_bps_vs_model": 200,
"kelly_fraction": 0.1,
"max_per_bet_usd": 500,
"drawdown_guard_bps": 500
},
"locked": {
"min_edge_bps_vs_model": {
"min": 50
},
"kelly_fraction": {
"max": 0.3
},
"max_per_bet_usd": {
"max": 1000
},
"drawdown_guard_bps": {
"max": 1200
}
}
}9. Implementation Flow
- Check KillSwitch active flag; if active, emit no OrderIntents.
- Subscribe to ws_market book updates and ws_sports live state for all active sports markets.
- On each model update or book tick: compute CLOB mid; measure edge_bps = |model_price - clob_mid| * 10000.
- Check session drawdown guard: if session_drawdown_bps >= drawdown_guard_bps hard (1200 bps), skip all new entries.
- Gate 1 — Edge check: if edge_bps < 50 bps hard, emit sampled DecisionReport SPORTS_MODEL_NO_EDGE; skip.
- Gate 2 — Data freshness: confirm SportsFeed-Adapter data is current (lineup_last_updated < 30 min); if stale, emit SPORTS_MODEL_STALE_DATA; skip.
- Gate 3 — In-play check: if market is in-play, confirm ws_sports game state is current (< 5s); if stale, skip.
- Gate 4 — Market status: confirm market is open and not approaching resolution (< 15 min to close for pre-game; skip in-play if halted).
- If edge_bps < warning (200 bps): WARN SPORTS_MODEL_EDGE_MARGINAL; reduce kelly_fraction to 50%.
- Compute Kelly size: kelly_size_usd = kelly_fraction * bankroll * edge_bps / (model_price * (1 - model_price) * 10000).
- Set orderSize = toPusdUnits(min(kelly_size_usd, max_per_bet_usd, available_depth)) using sizeMultiplier.
- Determine direction: buy YES if model_price > clob_mid (market underpricing YES); buy NO otherwise.
- Emit OrderIntent: outcome=YES/NO, side=buy, price=best_ask, size_pUSD=orderSize, tif=IOC, builder={code, fee_bps:25}.
- Note: fees are operator-set at match time in V2 — feeRateBps is NOT on the signed order. Sports maker rebate pool eligible.
- Update session_drawdown tracking in state.
- Emit DecisionReport with intent_emitted=true, edge_bps, model_price, clob_mid, kelly_size_usd, reason SPORTS_MODEL_EDGE_TRADE.
10. Reference Implementation
Consumes model price updates from the internal power-rating engine, computes edge against CLOB mid-price, and emits IOC OrderIntents when edge exceeds min_edge_bps_vs_model using fractional-Kelly sizing bounded by max_per_bet_usd.
Pseudocode is language-agnostic. FETCH = read input. EMIT = produce output. Translate to TS/Python/Go/Rust.
FUNCTION onModelUpdate(market_id, modelUpdate):
// --- 0. KillSwitch gate ---
ks = FETCH internal.killswitch.status
IF ks.active: RETURN
// --- 1. Drawdown guard ---
sessionDrawdown = FETCH state.sessionDrawdownBps()
IF sessionDrawdown >= params.drawdown_guard_bps_hard: // 1200 bps
EMIT DecisionReport(intent_emitted=false, reason='SPORTS_MODEL_DRAWDOWN_GUARD_TRIGGERED')
RETURN
// --- 2. Data freshness check ---
sportsFeed = FETCH internal.sportsfeed.marketData(market_id)
lineupAge = (now_ms() - sportsFeed.lineup_last_updated_ms) / 60000
IF lineupAge > 30:
EMIT DecisionReport(intent_emitted=false, reason='SPORTS_MODEL_STALE_DATA')
RETURN
// --- 3. In-play state check ---
IF market_id.is_inplay:
gameState = FETCH ws_sports.liveState(market_id)
IF isStale(gameState, maxAgeS=5): RETURN
IF gameState.halted: RETURN
// --- 4. Market status check ---
mkt = FETCH clob_public.GET('/markets/' + market_id)
IF mkt.closed OR mkt.resolved OR mkt.minutes_to_close < 15: RETURN
// --- 5. Edge computation ---
book = FETCH ws_market.book(market_id)
clobMid = (book.best_bid + book.best_ask) / 2
modelPrice = modelUpdate.model_price
edgeBps = abs(modelPrice - clobMid) * 10000
// --- 6. Hard floor check ---
IF edgeBps < params.min_edge_bps_vs_model_hard: // 50 bps
IF random() < 0.01:
EMIT DecisionReport(intent_emitted=false, reason='SPORTS_MODEL_NO_EDGE', edge_bps=edgeBps)
RETURN
// --- 7. Kelly sizing ---
kellyMultiplier = 0.5 IF edgeBps < params.min_edge_bps_vs_model ELSE 1.0
IF kellyMultiplier < 1.0: WARN('SPORTS_MODEL_EDGE_MARGINAL')
IF sessionDrawdown > params.drawdown_guard_bps: kellyMultiplier *= 0.5 // progressive reduction
bankroll = FETCH internal.accountState.bankroll_usd
kellySizeUSD = params.kelly_fraction * bankroll * edgeBps / (modelPrice * (1 - modelPrice) * 10000)
depth = FETCH clob_public.depth(market_id)
orderSize = toPusdUnits(min(kellySizeUSD, params.max_per_bet_usd, depth) * kellyMultiplier)
// --- 8. Direction ---
outcome = 'YES' IF modelPrice > clobMid ELSE 'NO'
price = book.best_ask IF outcome == 'YES' ELSE book.best_ask_NO
// --- 9. Emit IOC OrderIntent (V2: no feeRateBps) ---
EMIT OrderIntent(
market_id = market_id, outcome = outcome, side = 'buy',
price = price, size_pUSD = orderSize, tif = 'IOC', post_only = false,
builder = {code: internal.builder_code, fee_bps: 25}
// sports maker rebate pool eligible when fee_bps applied to resting side
)
// --- 10. Update session drawdown tracking ---
UPDATE state.sessionDrawdownBps(market_id, orderSize, modelPrice, price)
EMIT DecisionReport(intent_emitted=true, edge_bps=edgeBps,
model_price=modelPrice, clob_mid=clobMid,
kelly_size_usd=kellySizeUSD, reason='SPORTS_MODEL_EDGE_TRADE')
SDK calls used
ws_market.subscribe('book', [market_id])ws_sports.subscribe('live_state', [market_id])fetchClobPublic('/markets/' + market_id)internal.sportsfeed.marketData(market_id)internal.killswitch.status()internal.accountState.bankroll_usd()toPusdUnits(rawFloat)buildOrderTypedData(orderParams, { name: 'CTFExchange', version: '2', chainId: 137 })internal.builder_code
Complexity: O(1) per model update or book tick per market
11. Wire Examples
Input — what arrives on the wire
Model update — NBA market, edge=245 bps — internal (model engine)
{
"market_id": "0xsportsmd00000000000000000000000000000000000000000000000000000001",
"model_price": "0.537",
"clob_mid": "0.512",
"edge_bps": "245.0",
"sport": "NBA",
"lineup_last_updated_min": "8.2",
"is_inplay": false,
"received_at_ms": 1746790800000
}
Output — what the bot emits
OrderIntent — sports model IOC buy YES (builder-attributed)
{
"intent_id": "oi_01HXSPM0000001A",
"trace_id": "tr_01HXSPM000TR001",
"market_id": "0xsportsmd00000000000000000000000000000000000000000000000000000001",
"outcome": "YES",
"side": "buy",
"price": "0.512",
"size_pUSD": "220.00",
"tif": "IOC",
"post_only": false,
"builder": {
"code": "0x706f6c7974726164657273000000000000000000000000000000000000000000",
"fee_bps": 25
},
"negrisk_aware": false,
"decision": {
"edge_bps": 245.0,
"model_price": 0.537,
"clob_mid": 0.512,
"kelly_size_usd": 220.0,
"sport": "NBA",
"reasons": [
"SPORTS_MODEL_EDGE_TRADE"
]
},
"comment": "fees are operator-set at match time in V2 — feeRateBps is NOT on the signed order"
}
DecisionReport — skipped (no edge), sampled 1/100
{
"report_id": "dr_01HXSPM999ZZZZ",
"bot_id": "strat.sports_model",
"market_id": "0xsportsmd00000000000000000000000000000000000000000000000000000001",
"intent_emitted": false,
"edge_bps": 22.0,
"reasons": [
"SPORTS_MODEL_NO_EDGE"
],
"sampled": true,
"evaluated_at_ms": 1746790801000
}12. Decision Logic
APPROVE
edge_bps >= min_edge_bps_vs_model, data fresh, market open, session drawdown within guard, KillSwitch inactive. Emit IOC OrderIntent.
RESHAPE_REQUIRED
Not applicable — strat bots emit OrderIntents; reshaping is handled by the downstream Risk guardrail pipeline.
REJECT
edge_bps < 50 bps hard floor; stale SportsFeed data; market closed; drawdown guard triggered; KillSwitch active. Emit DecisionReport intent_emitted=false.
WARNING_ONLY
edge_bps between 50 and 200 bps, or session drawdown between 500 and 1200 bps, triggers warning and 50% Kelly size reduction.
13. Standard Decision Output
This bot returns a OrderIntent object. See OrderIntent schema.
{
"intent_id": "oi_01HXSPM0000001A",
"trace_id": "tr_01HXSPM000TR001",
"market_id": "0xsportsmd00000000000000000000000000000000000000000000000000000001",
"outcome": "YES",
"side": "buy",
"price": "0.512",
"size_pUSD": "220.00",
"tif": "IOC",
"post_only": false,
"builder": {
"code": "0x706f6c7974726164657273000000000000000000000000000000000000000000",
"fee_bps": 25
},
"negrisk_aware": false,
"decision": {
"edge_bps": 245.0,
"model_price": 0.537,
"clob_mid": 0.512,
"kelly_size_usd": 220.0,
"sport": "NBA",
"reasons": [
"SPORTS_MODEL_EDGE_TRADE"
]
},
"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 |
|---|---|---|---|---|
SPORTS_MODEL_EDGE_TRADE | INFO | edge_bps >= min_edge_bps_vs_model, data fresh, drawdown guard clear. IOC OrderIntent emitted. | Emit IOC OrderIntent; update session drawdown tracking. | The model found a pricing difference and placed a trade to capture it. |
SPORTS_MODEL_NO_EDGE | INFO | edge_bps is below the 50 bps hard floor. Model and CLOB prices are sufficiently aligned. | Skip; emit sampled DecisionReport. | The model's estimate was too close to the market price to justify a trade. |
SPORTS_MODEL_EDGE_MARGINAL | WARN | edge_bps between 50 and 200 bps. Trade is marginal; Kelly size reduced by 50%. | Emit IOC at 50% Kelly size; log warning. | A small pricing difference was detected. A reduced-size trade was placed. |
SPORTS_MODEL_STALE_DATA | HARD_REJECT | SportsFeed-Adapter lineup/injury data is more than 30 minutes old. Model may be miscalibrated. | Skip; no OrderIntent emitted. | The sports data powering the model was not current. No trade was placed. |
SPORTS_MODEL_DRAWDOWN_GUARD_TRIGGERED | HARD_REJECT | Session mark-to-model drawdown has exceeded drawdown_guard_bps hard limit (1200 bps). All new entries paused. | Skip all markets; emit DecisionReport. | Trading was paused because session losses reached the configured limit. |
SPORTS_MODEL_DRAWDOWN_WARNING | WARN | Session drawdown between 500 and 1200 bps. Kelly fraction reduced to 50%. | Continue trading at 50% Kelly size; log warning. | Session losses are elevated. Trade sizes were reduced. |
SPORTS_MODEL_HIGH_KELLY | WARN | kelly_fraction config is above the 0.20 warning threshold. Per-trade position size is elevated. | Allow but log warning. | |
STALE_MARKET_DATA | HARD_REJECT | ws_market or ws_sports feed is stale (> 5s). | Skip; no OrderIntent emitted. | Market data was too old to act on safely. |
KILL_SWITCH_ACTIVE | HARD_REJECT | Global kill switch is active. | Skip all markets; no OrderIntents emitted. | Trading is currently paused. |
PARAMETER_CHANGE_REQUIRES_APPROVAL | HARD_REJECT | A config change would push a parameter past its locked hard limit. | Reject config change; do not apply. |
15. Metrics & Logs
Metrics emitted
| Metric | Type | Unit | Labels | Meaning |
|---|---|---|---|---|
polytraders_strat_sportsmodel_decisions_total | counter | count | verdict, reason_code, sport | Total evaluation cycles by intent_emitted, reason code, and sport. |
polytraders_strat_sportsmodel_edge_bps | histogram | basis_points | sport | Distribution of model-vs-CLOB edge in bps per sport. |
polytraders_strat_sportsmodel_kelly_size_usd | histogram | pusd | sport | Distribution of Kelly-derived order sizes per sport. |
polytraders_strat_sportsmodel_session_drawdown_bps | gauge | basis_points | Current session mark-to-model drawdown in bps. | |
polytraders_strat_sportsmodel_intents_emitted_total | counter | count | sport, outcome | Total OrderIntents emitted by sport and outcome (YES/NO). |
polytraders_strat_sportsmodel_eval_latency_ms | histogram | milliseconds | Wall-clock time from model update to OrderIntent emit. |
Alerts
| Alert | Condition | Severity | Runbook |
|---|---|---|---|
SportsModelDrawdownWarning | polytraders_strat_sportsmodel_session_drawdown_bps > 500 | warn | #runbook-sportsmodel-drawdown |
SportsModelDrawdownGuardTriggered | polytraders_strat_sportsmodel_session_drawdown_bps > 1200 | page | #runbook-sportsmodel-drawdown-guard |
SportsModelStaleFeed | rate(polytraders_strat_sportsmodel_decisions_total{reason_code='STALE_MARKET_DATA'}[5m]) > 0.1 | warn | #runbook-sportsmodel-stale-feed |
SportsModelKillSwitchBlocking | rate(polytraders_strat_sportsmodel_decisions_total{reason_code='KILL_SWITCH_ACTIVE'}[1m]) > 0 | page | #runbook-killswitch |
Dashboards
- Grafana — Strategy / SportsModel edge distribution per sport
- Grafana — Strategy / SportsModel session drawdown and Kelly sizing
16. Developer Reporting
{
"bot_id": "strat.sports_model",
"market_id": "0xsportsmd00000000000000000000000000000000000000000000000000000001",
"model_price": 0.537,
"clob_mid": 0.512,
"edge_bps": 245.0,
"kelly_fraction": 0.1,
"kelly_size_usd": 220.0,
"max_per_bet_usd": 500,
"sport": "NBA",
"lineup_last_updated_min": 8.2,
"session_drawdown_bps": 120.0,
"intent_emitted": true,
"reason": "SPORTS_MODEL_EDGE_TRADE",
"emitted_at_ms": 1746790800000
}17. Plain-English Reporting
| Situation | User-facing explanation |
|---|---|
| Sports model trade placed | The quantitative model estimated a different probability than the current market price. An order was placed to trade the difference using conservative Kelly sizing. |
| No edge — no trade | The model's estimate and the market price were too close after fees to justify a trade. No order was placed. |
| Stale model data — no trade | The lineup, injury, or league data powering the model was not current enough. No order was placed until the data is refreshed. |
| Drawdown guard active — paused | The session's mark-to-model losses reached the configured limit. New trades are paused for the remainder of the session. |
18. Failure-Mode Block
| main_failure_mode | Systematic model miscalibration: the power-rating model consistently misprices a sport or event type, producing repeated losses across multiple markets until the drawdown guard triggers. |
|---|---|
| false_positive_risk | Stale lineup or injury data produces incorrect model prices, triggering trades in the wrong direction shortly before the correct data arrives and the CLOB reprices. |
| false_negative_risk | min_edge_bps_vs_model set too high misses genuine model edges on liquid sports markets, particularly when the fee rate is low (sports: 0.75%) and the edge is real. |
| safe_fallback | If ws_market feed is stale (> 5s) or SportsFeed-Adapter data is unavailable, emit STALE_MARKET_DATA and skip without emitting any OrderIntent. Drawdown guard provides session-level risk control. |
| required_dependencies | ws_market book stream, ws_sports live game state (for in-play markets), clob_public market endpoint (status + depth), Internal power-rating model engine, SportsFeed-Adapter (league APIs, lineup/injury/weather), KillSwitch active flag, internal builder code |
19. Failure-Injection Recipes
| Scenario | How to inject | Expected behaviour | Recovery |
|---|---|---|---|
STALE_SPORTSFEED_DATA | Freeze SportsFeed-Adapter updates; let lineup_last_updated age beyond 30 min | Automatic when SportsFeed-Adapter data refreshes. | |
DRAWDOWN_GUARD_TRIGGER | Inject sequence of losing trades until session_drawdown_bps reaches 1200 | Manual session reset required after ops review. | |
INPLAY_FEED_STALE | Pause ws_sports for in-play market; let game state age beyond 5s | Automatic when ws_sports reconnects. | |
NO_EDGE_MARKET | Set mock model_price=0.505, clob_mid=0.503 (edge=20 bps < 50 hard) | Automatic when model or CLOB price diverges sufficiently. | |
KILL_SWITCH_ON | Set killswitch.active=true | Automatic on manual KillSwitch reset. |
20. State & Persistence
Cold-start recovery
On cold start, session drawdown resets to 0. Market state rebuilt from first ws_market tick and model update.
21. Concurrency & Idempotency
| Aspect | Specification |
|---|---|
| Execution model | actor-per-market |
| Max in-flight | 40 |
| Idempotency key | intent_id |
| Per-call timeout (ms) | 250 |
| Backpressure strategy | drop oldest pending model update per market_id when queue depth > 3 |
| Locking / mutual exclusion | per-market_id mutex for position state; global mutex for session drawdown update |
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 |
|---|---|---|---|
| Polymarket CLOB WebSocket (ws_market) | best-effort | ||
| Polymarket Sports WebSocket (ws_sports) | best-effort | ||
| SportsFeed-Adapter (league APIs + lineup/injury/weather) | internal SLA | ||
| Polymarket CLOB public API (depth) | 99.9% |
23. Security Surfaces
On-chain contract calls
| Contract | Method | Network | Effect |
|---|---|---|---|
CTFExchangeV2 | | polygon |
Abuse vectors considered
- Adversary manipulates SportsFeed-Adapter data to produce false model prices, triggering trades in the wrong direction
- In-play market state spoofing via ws_sports injection to trigger model recalculation
- Front-running: adversary infers model price by monitoring bot's IOC orders on specific markets
Mitigations
- SportsFeed-Adapter data is authenticated from official league API sources; injection requires compromising the internal feed
- ws_sports data validated against clob_public market metadata before use in model
- IOC orders do not rest on the book; adversary cannot exploit known bid/ask placement
- Drawdown guard limits total session exposure from systematic model attacks
- V2 order timestamp(ms) invalidates replayed signed orders
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 | no |
| Multi-chain ready | no |
| SDK used | py-clob-client-v2 |
| Settlement contract | CTFExchangeV2 |
| Notes | Executes IOC taker orders on Polymarket sports markets where model price diverges from CLOB mid by more than min_edge_bps_vs_model. Maker rebate pool (Apr 2026: $5M pUSD sports+esports) incentivises maker orders when feasible. feeRateBps is not present on any signed order. |
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, auto_disable_neg_sharpe_weeks) | v2 (pUSD, fees operator-set at match time, no performance-claim parameters) | CLOB V2 cutover + policy update | Switched to py-clob-client-v2. Removed feeRateBps from all signed order construction. Updated collateral denomination to pUSD. Injected builder field (bytes32). EIP-712 Exchange domain version updated from '1' to '2'. Removed auto_disable_neg_sharpe_weeks parameter (performance claim; replaced with drawdown_guard_bps). SportsFeed-Adapter updated to V2 internal bus schema. ws_sports WebSocket added for live in-play market state. |
26. Acceptance Tests
Unit Tests
| Test | Setup | Expected result |
|---|---|---|
| Emit IOC when edge_bps=245, model_price=0.537, clob_mid=0.512, data fresh | min_edge_bps_vs_model=200, kelly_fraction=0.10, max_per_bet_usd=500 | IOC OrderIntent emitted; DecisionReport intent_emitted=true, reason=SPORTS_MODEL_EDGE_TRADE |
| Skip when edge_bps=30 (< hard floor 50) | model_price=0.503, clob_mid=0.500 | No OrderIntent; sampled DecisionReport reason=SPORTS_MODEL_NO_EDGE |
| Skip when drawdown guard triggered | session_drawdown_bps=1300 (> hard 1200) | No OrderIntent; reason=SPORTS_MODEL_DRAWDOWN_GUARD_TRIGGERED |
| Reduce size 50% when edge marginal (120 bps) | edge_bps=120, min_edge_bps_vs_model=200 | OrderIntent emitted at 50% kelly size; WARN SPORTS_MODEL_EDGE_MARGINAL |
| Skip when SportsFeed data stale (lineup_last_updated > 30 min) | lineup_last_updated_min=45 | No OrderIntent; reason=SPORTS_MODEL_STALE_DATA |
| Skip when KillSwitch active | killswitch.active=true | No OrderIntents emitted |
Integration Tests
| Test | Expected result |
|---|---|
| Full cycle: model update → edge computed → IOC OrderIntent submitted on Polygon testnet | Order has builder.code (bytes32), no feeRateBps, tif=IOC, EIP-712 domain version '2' |
| Drawdown guard accumulates across multiple trades and triggers at hard limit | SPORTS_MODEL_DRAWDOWN_GUARD_TRIGGERED after session_drawdown_bps reaches 1200 |
Property Tests
| Property | Required behaviour |
|---|---|
| Kelly-derived size is always bounded by max_per_bet_usd | Always true |
| feeRateBps never present on any signed OrderIntent | Always true — V2 fees are operator-set at match time |
| Bot never trades when SportsFeed-Adapter data is unavailable or stale > 30 min | Always true |
27. Operational Runbook
Sports Model incidents are typically drawdown guard triggers (requiring manual session review), stale SportsFeed data (blocking all trades until feed refreshes), or stale in-play feeds. Drawdown guard triggers require ops review before session reset.
On-call actions
| Alert | First step | Diagnosis | Mitigation | Escalate to |
|---|---|---|---|---|
SportsModelDrawdownWarning | ||||
SportsModelDrawdownGuardTriggered | ||||
SportsModelStaleFeed | ||||
SportsModelKillSwitchBlocking |
Manual overrides
——
Healthcheck
GET /internal/health/sports-model -> 200 if ws_market feed last_seen < 5s, SportsFeed-Adapter data age < 30 min, session drawdown < 500 bps, 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 drawdown guard trigger and stale-data block | CI test run | 100% pass |
| feeRateBps absence verified; tif=IOC and kelly sizing verified in integration test | Integration test asserting V2 order schema | Pass |
Promote to Limited live
| Gate | How measured | Threshold |
|---|---|---|
| p99 eval latency < 250ms over 24h | polytraders_strat_sportsmodel_eval_latency_ms histogram | p99 < 250ms |
| Session drawdown stays below 500 bps over 48h shadow run | polytraders_strat_sportsmodel_session_drawdown_bps gauge | < 500 bps |
Promote to General live
| Gate | How measured | Threshold |
|---|---|---|
| E2E: model update → edge computed → IOC OrderIntent submitted on Polygon testnet | E2E test | Pass |
| Drawdown guard verified: session halted at 1200 bps in integration test | Integration test | 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 |