2.1 SmartRouter
SmartRouter translates an approved strategy intent into a concrete executable order by selecting the appropriate order type (FOK, GTC, or GTD), price, and timing. It may split a large order into iceberg child orders to reduce market impact. SmartRouter cannot change the direction of the trade, the target market, or the strategy intent — it is not permitted to flip the side (buy/sell), alter the outcome leg, or override any constraint set by the Risk guardrail pipeline. The only transformations it may make are to price, size scheduling, order type, and submission timing.
v3 readiness
A bot is done when all four scores are. What does done mean?
exec.smartrouter
Reference execution router; thin wrapper that emits SignedOrderRequest. SEARCH_SPACE declared.
Source: @polytraders/bots · src/exec/smartrouter.js · Impl 11/15 · Backtest 3/4
1. Bot Identity
| Layer | Execution Execution |
|---|---|
| Bot class | Execution Utility |
| Authority | Reshape |
| Status | LIVE |
| Readiness | General live |
| Runs before | Order signing and submission |
| Runs after | Risk guardrail pipeline (all RiskVotes collected) |
| Applies to | Every approved OrderIntent that has passed the full Risk guardrail pipeline |
| Default mode | general_live |
| User-visible | Advanced details only |
| Developer owner | Polytraders core — Execution pod |
Operational profile
| Modes supported | quarantine |
|---|
2. Purpose
SmartRouter translates an approved strategy intent into a concrete executable order by selecting the appropriate order type (FOK, GTC, or GTD), price, and timing. It may split a large order into iceberg child orders to reduce market impact. SmartRouter cannot change the direction of the trade, the target market, or the strategy intent — it is not permitted to flip the side (buy/sell), alter the outcome leg, or override any constraint set by the Risk guardrail pipeline. The only transformations it may make are to price, size scheduling, order type, and submission timing.
3. Why This Bot Matters
Wrong order type applied
A Fill-or-Kill order on a thin book will be rejected by the exchange and the strategy misses the opportunity entirely; a GTC order on a fast-moving market may fill at an outdated price.
Large order submitted without iceberg splitting
A single large visible order signals intent to other market participants and may be front-run or cause the book to refresh at a worse price before the order fills.
Order submitted after signal TTL expires
Executing a GTC or GTD order on a signal that has aged past its validity window means acting on market intelligence that may no longer be accurate.
Tick size not respected
An order with a price that does not align to the market's tick size will be rejected by the CLOB, resulting in a failed submission.
4. Required Polymarket Inputs
| Input | Source | Required? | Use |
|---|---|---|---|
| CLOB order book — top 50 levels | CLOB | Yes | Assess current book depth to decide between FOK and GTC, and to determine whether iceberg splitting is warranted. |
| Market tick size and neg-risk flag | Gamma API | Yes | Round order price to the correct tick size; apply neg-risk routing rules when the neg-risk flag is set. |
| Recent fill rate and estimated queue position | Data API | No | Estimate time-to-fill for GTC orders and decide whether to use a more aggressive price to improve queue position. |
5. Required Internal Inputs
| Input | Source | Required? | Use |
|---|---|---|---|
| Approved OrderIntent with all Risk constraints applied | PortfolioGuard | Yes | Receive the final approved size, maximum price, and any constraint flags (passive_only, close_only) from the guardrail pipeline. |
| KillSwitch active flag | KillSwitch | Yes | Abort order construction and emit no ExecutionPlan if KillSwitch is active. |
6. Parameter Guide
| Parameter | Default | Warning | Hard | What it controls |
|---|---|---|---|---|
| default_order_type | GTC | — | — | The default order type used when the intent does not specify one. FOK = Fill-or-Kill; GTC = Good-till-Cancelled; GTD = Good-till-Date with a TTL driven by gtd_signal_ttl_s. |
| iceberg_threshold_usd | 500 | 300 | 1000 | Order size in USD above which the order is automatically split into iceberg child orders to reduce visible market impact. |
| iceberg_child_count | 3 | 5 | 8 | Number of child orders to split an iceberg order into. Each child is submitted sequentially as the previous one fills. |
| gtd_signal_ttl_s | 120 | 60 | 300 | Time-to-live in seconds for GTD orders, measured from the moment the OrderIntent was generated by the strategy. Orders not submitted before this window expires are discarded. |
7. Detailed Parameter Instructions
default_order_type
What it means
The default order type used when the intent does not specify one. FOK = Fill-or-Kill; GTC = Good-till-Cancelled; GTD = Good-till-Date with a TTL driven by gtd_signal_ttl_s.
Default
{ "default_order_type": "GTC" }
Why this default matters
GTC is the safest default because it remains in the book until it fills or is cancelled, avoiding the opportunity loss of FOK on a momentarily thin book.
Threshold logic
| Condition | Action |
|---|---|
| Order size ≤ top-of-book depth and execution is time-sensitive | Use FOK |
| Order size fits in book with no urgency | Use GTC (default) |
| Signal has a defined expiry (gtd_signal_ttl_s > 0) | Use GTD with calculated expiry timestamp |
Developer check
const type = intent.order_type || p.default_order_type; if (type === 'GTD') plan.expiry = now + p.gtd_signal_ttl_s;
User-facing English
We chose the order type that best matches current market conditions to improve your chance of getting a good fill.
iceberg_threshold_usd
What it means
Order size in USD above which the order is automatically split into iceberg child orders to reduce visible market impact.
Default
{ "iceberg_threshold_usd": 500 }
Why this default matters
Orders above $500 on typical Polymarket books are large enough to visibly move the queue and attract front-running. Splitting at this threshold keeps each child order within normal market noise.
Threshold logic
| Condition | Action |
|---|---|
| order.size_usd ≤ 500 USD | Submit as single order |
| 500–1000 USD | Split into iceberg_child_count children |
| > 1000 USD hard ceiling (locked by Risk) | Should not reach here — Risk guardrail should have capped to 1000 USD max |
Developer check
if (order.size_usd > p.iceberg_threshold_usd) return splitIceberg(order, p.iceberg_child_count);
User-facing English
Your order was split into smaller pieces to reduce its visibility in the market and get a better overall fill.
iceberg_child_count
What it means
Number of child orders to split an iceberg order into. Each child is submitted sequentially as the previous one fills.
Default
{ "iceberg_child_count": 3 }
Why this default matters
Three children provide a reasonable balance between reduced visible size and submission overhead. Too many children increase latency; too few still expose a large visible resting order.
Threshold logic
| Condition | Action |
|---|---|
| iceberg_child_count ≤ 5 | Normal iceberg split |
| 5–8 | WARN — high child count increases submission overhead |
| > 8 | Reject config change — PARAMETER_CHANGE_REQUIRES_APPROVAL |
Developer check
if (p.iceberg_child_count > p.hard) throw new ConfigError('PARAMETER_CHANGE_REQUIRES_APPROVAL');
User-facing English
Your order was broken into several smaller orders that will be submitted one after another.
gtd_signal_ttl_s
What it means
Time-to-live in seconds for GTD orders, measured from the moment the OrderIntent was generated by the strategy. Orders not submitted before this window expires are discarded.
Default
{ "gtd_signal_ttl_s": 120 }
Why this default matters
A 120-second TTL means the system will not act on a signal more than two minutes old, limiting exposure from stale strategy decisions on a fast-moving market.
Threshold logic
| Condition | Action |
|---|---|
| Signal age ≤ gtd_signal_ttl_s | Proceed with GTD order |
| Signal age > gtd_signal_ttl_s | Discard order — STALE_MARKET_DATA |
Developer check
if (Date.now() - intent.generated_at > p.gtd_signal_ttl_s * 1000) return discard('STALE_MARKET_DATA');
User-facing English
An order was not submitted because the market information it was based on had expired before the order could be sent.
8. Default Configuration
{
"bot_id": "exec.smart_router",
"version": "1.0.0",
"mode": "general_live",
"defaults": {
"default_order_type": "GTC",
"iceberg_threshold_usd": 500,
"iceberg_child_count": 3,
"gtd_signal_ttl_s": 120
},
"locked": {
"iceberg_child_count": {
"max": 8
},
"gtd_signal_ttl_s": {
"max": 300
}
}
}9. Implementation Flow
- Receive the approved OrderIntent from the Risk guardrail pipeline, including applied constraints (max_size_usd, passive_only, close_only) from PortfolioGuard.
- Check KillSwitch active flag; if active, discard the order and emit no ExecutionPlan.
- Fetch the market's tick size and neg-risk flag from Gamma API; round order.price to the nearest valid tick.
- Determine the order type: use intent.order_type if specified; otherwise apply default_order_type logic based on book depth and signal age.
- If order type is GTD, check signal age against gtd_signal_ttl_s; if expired, discard with STALE_MARKET_DATA.
- Check order.size_usd against iceberg_threshold_usd; if above threshold, compute child_size = size / iceberg_child_count and build an iceberg plan.
- Pull top 50 book levels from the CLOB to confirm there is sufficient depth for FOK orders; if depth is insufficient for FOK, downgrade to GTC automatically.
- Assemble the ExecutionPlan with: order_type, price (tick-aligned), size or iceberg children array, side (unchanged from intent), market_id (unchanged), and submission_timestamp.
- Validate that the ExecutionPlan does not alter side, market_id, or outcome leg from the original intent — these are invariants.
- Emit the ExecutionPlan to the order signing step.
10. Reference Implementation
Receives an approved OrderIntent from the Risk pipeline, selects order type, tick-aligns the price using buildOrderTypedData, optionally splits into iceberg children, and emits an ExecutionPlan. Owns the signing flow by calling the wallet adapter — never holds keys.
Pseudocode is language-agnostic. FETCH = read input. EMIT = produce output. Translate to TS/Python/Go/Rust.
FUNCTION routeOrder(intent, riskConstraints):
// --- 0. KillSwitch gate ---
ks = FETCH internal.killswitch.status
IF ks.active:
DISCARD intent
RETURN
// --- 1. Fetch market tick size and neg-risk flag ---
market = fetchClobPublic('/markets/' + intent.market_id)
IF market IS NULL OR isStale(market, 60):
DISCARD intent; reason=STALE_MARKET_DATA
RETURN
tickSize = market.minimum_tick_size // e.g. 0.01
negRisk = market.neg_risk
// --- 2. GTD TTL check ---
signalAgeMs = now_ms() - intent.generated_at_ms
IF intent.order_type == 'GTD' AND signalAgeMs > params.gtd_signal_ttl_s * 1000:
DISCARD intent; reason=STALE_MARKET_DATA
RETURN
// --- 3. Tick-align price ---
alignedPrice = floor(intent.price / tickSize) * tickSize
// --- 4. Order type selection ---
book = fetchClobPublic('/book?market=' + intent.market_id)
bookDepth = SUM(level.size FOR level IN book.asks[:50])
IF intent.order_type == 'FOK' AND bookDepth < intent.size_usd:
orderType = 'GTC' // downgrade FOK → GTC on thin book
ELSE:
orderType = intent.order_type OR params.default_order_type
// --- 5. NegRisk convert-arb routing ---
IF negRisk AND intent.negrisk_convert_requested:
EMIT NegRiskConvertRoute(market_id=intent.market_id, size=intent.size_usd)
RETURN // NegRiskAdapter path; not a CLOB order
// --- 6. Iceberg split ---
finalSize = min(intent.size_usd, riskConstraints.max_size_usd)
IF finalSize > params.iceberg_threshold_usd:
childSize = toUsdcUnits(finalSize / params.iceberg_child_count)
children = [childSize] * params.iceberg_child_count
ELSE:
children = [toUsdcUnits(finalSize)]
// --- 7. Build V2 typed order data ---
// V2 order fields: timestamp(ms) + metadata(bytes32) + builder(bytes32)
// Fees are operator-set at match time; NOT in the signed order
FOR child IN children:
typedData = buildOrderTypedData({
market_id: intent.market_id,
side: intent.side, // NEVER flipped
outcome: intent.outcome,
price: alignedPrice,
size: child,
order_type: orderType,
timestamp: now_ms(),
metadata: ZERO_BYTES32,
builder: config.builder_code_bytes32
}, domain={ version: '2', chainId: 137, verifyingContract: CTFExchangeV2 })
// wallet adapter signs typedData — never holds keys
signedOrder = wallet.sign(typedData)
VALIDATE ExecutionPlan invariants:
assert signedOrder.side == intent.side
assert signedOrder.market_id == intent.market_id
assert signedOrder.size <= riskConstraints.max_size_usd
// --- 8. Emit ExecutionPlan ---
EMIT ExecutionPlan(
router_id='exec.smart_router',
market_id=intent.market_id,
side=intent.side,
order_type=orderType,
tick_aligned_price=alignedPrice,
iceberg=(len(children) > 1),
children=children,
submission_timestamp=now_iso()
)
Helpers used
| Helper | Signature | Purpose |
|---|---|---|
| buildOrderTypedData | buildOrderTypedData(orderParams, domain) -> TypedData | Constructs the EIP-712 V2 typed data for a CLOB order. V2 fields: timestamp(ms), metadata(bytes32), builder(bytes32). No feeRateBps — fees are operator-set at match time. |
| toUsdcUnits | toUsdcUnits(rawUsd: float) -> int | Rounds a raw pUSD float to the integer unit used by CTFExchangeV2 (6 decimals). |
| fetchClobPublic | fetchClobPublic(path: str) -> JSON | Unauthenticated GET against https://clob.polymarket.com; returns parsed JSON or null on error. |
| isStale | isStale(snapshot: any, maxAgeS: int) -> bool | Returns true if snapshot was fetched more than maxAgeS seconds ago. |
| platformFee | platformFee(notional: float, prob: float, feeRate: float) -> float | Estimates the platform fee C*feeRate*p*(1-p) for a given trade; used to log expected cost alongside the ExecutionPlan. |
SDK calls used
fetchClobPublic('/markets/' + intent.market_id)fetchClobPublic('/book?market=' + intent.market_id + '&depth=50')buildOrderTypedData(orderParams, { name: 'CTFExchange', version: '2', chainId: 137 })wallet.sign(typedData)clob_auth.POST('/order', signedOrder)ws_user.subscribe('fills', intent.market_id)
Complexity: O(C) where C = iceberg_child_count
11. Wire Examples
Input — what arrives on the wire
Approved OrderIntent from Risk pipeline — internal
{
"intent_id": "int_6f7a8b9c0d1e2f3a",
"market_id": "0x6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f",
"side": "BUY",
"outcome": "YES",
"price": 0.623,
"size_usd": 500,
"order_type": "GTC",
"neg_risk": false,
"generated_at_ms": 1746768658000,
"risk_constraints": {
"max_size_usd": 450,
"passive_only": false,
"close_only": false
}
}
Output — what the bot emits
ExecutionPlan — single GTC order, tick-aligned
{
"router_id": "exec.smart_router",
"market_id": "0x6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f",
"side": "BUY",
"outcome": "YES",
"order_type": "GTC",
"price": 0.623,
"tick_aligned_price": 0.62,
"size_usd": 450,
"iceberg": false,
"children": [],
"builder_code": "0x706f6c7974726164657273000000000000000000000000000000000000000000",
"eip712_domain_version": "2",
"submission_timestamp": "2026-05-09T11:00:00Z",
"signal_age_s": 14,
"inputs_used": [
"clob_public.book.top50",
"gamma_api.market.tick_size",
"portfolio_guard.approved_intent"
]
}
ExecutionPlan — iceberg split (3 children)
{
"router_id": "exec.smart_router",
"market_id": "0x6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f",
"side": "BUY",
"outcome": "YES",
"order_type": "GTC",
"tick_aligned_price": 0.62,
"size_usd": 600,
"iceberg": true,
"children": [
200,
200,
200
],
"builder_code": "0x706f6c7974726164657273000000000000000000000000000000000000000000",
"eip712_domain_version": "2",
"submission_timestamp": "2026-05-09T11:01:00Z",
"signal_age_s": 8,
"inputs_used": [
"clob_public.book.top50",
"gamma_api.market.tick_size"
]
}
Reproduce locally
curl -H 'Authorization: Bearer <token>' -X POST 'https://clob.polymarket.com/order' -d '{...signed_order...}'12. Decision Logic
APPROVE
Not directly applicable — SmartRouter emits an ExecutionPlan, not an approval vote. The plan is emitted if the intent passes KillSwitch check and signal TTL check.
RESHAPE_REQUIRED
Iceberg splitting, GTD expiry calculation, FOK-to-GTC downgrade on thin book, and tick-size rounding are all reshape operations. The strategy intent size, side, and market are preserved — only execution mechanics change.
REJECT
SmartRouter discards the order (emits no ExecutionPlan) if KillSwitch is active or if a GTD signal has aged past gtd_signal_ttl_s.
WARNING_ONLY
Tick-size rounding that results in a price change of more than one tick is logged as a warning annotation on the ExecutionPlan.
13. Standard Decision Output
This bot returns a ExecutionPlan object. See ExecutionPlan schema.
{
"router_id": "exec.smart_router",
"market_id": "CLOB:0xabc123",
"side": "BUY",
"outcome": "YES",
"order_type": "GTC",
"price": 0.62,
"tick_aligned_price": 0.62,
"size_usd": 450,
"iceberg": false,
"children": [],
"submission_timestamp": "2026-05-09T11:00:00Z",
"signal_age_s": 14,
"inputs_used": [
"clob.book.top50",
"gamma_api.market.tick_size",
"portfolio_guard.approved_intent"
]
}14. Reason Codes
| Code | Severity | Meaning | Action | User-facing message |
|---|---|---|---|---|
STALE_MARKET_DATA | HARD_REJECT | Market metadata (tick size) is unavailable, or GTD signal has aged past gtd_signal_ttl_s. | Discard order; emit no ExecutionPlan. | The order could not be placed because the market data or signal had expired. |
SPREAD_TOO_WIDE | WARN | Tick-size rounding shifted price by more than one tick. | Attach warning annotation to ExecutionPlan; do not block. | |
KILL_SWITCH_ACTIVE | HARD_REJECT | Global kill switch is active; no orders may proceed. | Discard order; emit no ExecutionPlan. | Trading is currently paused. |
PARAMETER_CHANGE_REQUIRES_APPROVAL | HARD_REJECT | iceberg_child_count or gtd_signal_ttl_s exceeds the locked hard maximum. | Reject config change; do not apply. | |
NEGRISK_CONVERT_AVAILABLE | EXPLAIN | NegRisk convert-arb route is available for this market; notifies Strategy layer. | Emit NegRiskConvertRoute instead of a CLOB order when negrisk_convert_requested=true. | |
SMART_ROUTER_FOK_DOWNGRADE | RESHAPE | Book depth was insufficient to guarantee a complete FOK fill; order was changed to GTC. | Set order_type=GTC in the ExecutionPlan. | The market did not have enough visible liquidity for an immediate fill, so the order was changed to stay in the book until it fills. |
SMART_ROUTER_ICEBERG_SPLIT | RESHAPE | Order size exceeded iceberg_threshold_usd; split into child orders. | Set iceberg=true and populate children array. | Your order was split into smaller pieces to reduce its visibility in the market. |
FEE_RATE_CAPPED | WARN | Computed taker fee would exceed 100 bps or maker fee would exceed 50 bps. Fees are operator-set at match time; this warn is for logging only. | Log the fee estimate; do not block the order. |
15. Metrics & Logs
Metrics emitted
| Metric | Type | Unit | Labels | Meaning |
|---|---|---|---|---|
polytraders_exec_smartrouter_plans_total | counter | count | order_type, iceberg | Total ExecutionPlans emitted by order type and iceberg flag. |
polytraders_exec_smartrouter_discards_total | counter | count | reason_code | Total intents discarded (no ExecutionPlan emitted) by reason. |
polytraders_exec_smartrouter_signal_age_seconds | histogram | seconds | Age of the OrderIntent signal at routing time; tracks freshness of strategy decisions. | |
polytraders_exec_smartrouter_tick_rounding_ticks | histogram | count | market_id | Number of ticks by which the price was rounded; > 1 tick triggers a WARN. |
polytraders_exec_smartrouter_iceberg_child_count | histogram | count | Distribution of iceberg child counts per split order. | |
polytraders_exec_smartrouter_eval_latency_ms | histogram | seconds | Wall-clock latency from intent receipt to ExecutionPlan emit. |
Alerts
| Alert | Condition | Severity | Runbook |
|---|---|---|---|
SmartRouterHighDiscardRate | rate(polytraders_exec_smartrouter_discards_total[5m]) / rate(polytraders_exec_smartrouter_plans_total[5m]) > 0.2 | P2 | #runbook-smartrouter-discards |
SmartRouterStaleSignals | histogram_quantile(0.99, rate(polytraders_exec_smartrouter_signal_age_seconds_bucket[5m])) > 60 | P2 | #runbook-smartrouter-stale-signals |
SmartRouterHighLatency | histogram_quantile(0.99, rate(polytraders_exec_smartrouter_eval_latency_ms_bucket[5m])) > 200 | P2 | #runbook-smartrouter-latency |
SmartRouterTickSizeUnavailable | rate(polytraders_exec_smartrouter_discards_total{reason_code='STALE_MARKET_DATA'}[5m]) > 0.05 | P1 | #runbook-smartrouter-tick-size |
Dashboards
- Grafana — Execution / SmartRouter
- Grafana — Order quality / tick rounding and iceberg split distribution
Log levels
| Level | What gets logged |
|---|---|
| DEBUG | Tick-size computation, iceberg child sizes, and order type selection logic on every intent. |
| INFO | ExecutionPlan emitted with order_type, tick_aligned_price, iceberg flag. |
| WARN | Tick-size rounding > 1 tick; FOK downgraded to GTC; signal age approaching TTL. |
| ERROR | Gamma API tick-size unavailable; KillSwitch status unreadable; wallet adapter signing failure. |
16. Developer Reporting
{
"router_id": "exec.smart_router",
"market_id": "CLOB:0xabc123",
"order_type_selected": "GTC",
"original_price": 0.623,
"tick_aligned_price": 0.62,
"original_size_usd": 500,
"final_size_usd": 450,
"iceberg_applied": false,
"signal_age_s": 14,
"gtd_signal_ttl_s": 120,
"tick_size": 0.01,
"inputs_used": [
"clob.book.top50",
"gamma_api.market.tick_size"
],
"submission_timestamp": "2026-05-09T11:00:00Z"
}17. Plain-English Reporting
| Situation | User-facing explanation |
|---|---|
| Order split into smaller pieces | Your order was divided into smaller parts that will be submitted one at a time. This reduces the chance that other participants can see and react to a single large order. |
| Order discarded — signal expired | The market data used to generate this order was too old by the time the order reached the submission step. The order was not sent to protect you from acting on outdated information. |
| Order type changed from FOK to GTC | The market did not have enough visible liquidity to guarantee a complete immediate fill, so the order was changed to remain in the book until it fills rather than being cancelled outright. |
| Price rounded to market tick size | Your order price was adjusted slightly to align with the smallest price increment this market accepts. The change was less than one tick. |
18. Failure-Mode Block
| main_failure_mode | Submitting an iceberg child order at the wrong price due to a tick-size calculation error, or sending a GTD order on a stale signal because the TTL check used a clock that was out of sync. |
|---|---|
| false_positive_risk | Discarding a valid signal as stale because the system clock on the strategy node and the router node are not synchronised, leading to missed trades. |
| false_negative_risk | Allowing an aged signal through because gtd_signal_ttl_s is set too high, resulting in an order being submitted on outdated market intelligence. |
| safe_fallback | If Gamma API tick-size data is unavailable, discard the order with STALE_MARKET_DATA rather than submitting at an unverified price. If KillSwitch status cannot be determined, halt submission. |
| required_dependencies | CLOB top-50 book snapshot, Gamma API tick size and neg-risk flag, PortfolioGuard approved OrderIntent, KillSwitch active flag |
19. Failure-Injection Recipes
| Scenario | How to inject | Expected behaviour | Recovery |
|---|---|---|---|
GAMMA_TICK_SIZE_UNAVAILABLE | Block TCP to gamma-api.polymarket.com | Intents discarded with STALE_MARKET_DATA after cache TTL expires | Returns to normal within one evaluation cycle after Gamma API is reachable. |
GTD_SIGNAL_EXPIRED | Delay intent processing by 130s (gtd_signal_ttl_s=120) | Intent discarded with STALE_MARKET_DATA before ExecutionPlan is emitted | Automatic on next fresh intent. |
FOK_DOWNGRADE | Submit FOK intent when book depth < intent.size_usd | ExecutionPlan emitted with order_type=GTC and WARN annotation | Automatic. |
KILL_SWITCH_ON | Set killswitch.active=true | No ExecutionPlan emitted for any intent | Returns to normal on manual KillSwitch reset. |
ICEBERG_CHILD_TOO_MANY | Set iceberg_child_count=9 (hard max=8) | ConfigError PARAMETER_CHANGE_REQUIRES_APPROVAL; config change rejected | Set iceberg_child_count ≤ 8. |
NEGRISK_CONVERT | Submit intent with neg_risk=true and negrisk_convert_requested=true | NegRiskConvertRoute emitted instead of CLOB ExecutionPlan | Automatic. |
20. State & Persistence
Stateless per evaluation; holds a short-lived in-memory cache of tick sizes and market metadata.
State stores
| Name | Kind | Key | Value shape | TTL | Durability |
|---|---|---|---|---|---|
tick_size_cache | in-memory | market_id | { minimum_tick_size: float, neg_risk: bool, fetched_at_ms: int } | 60s | best-effort |
Cold-start recovery
On cold start, the cache is empty. First evaluation per market triggers a Gamma API fetch.
On restart
Tick sizes are re-fetched on first evaluation. If Gamma API is unavailable on startup, the first intent for each market is discarded with STALE_MARKET_DATA.
21. Concurrency & Idempotency
| Aspect | Specification |
|---|---|
| Execution model | per-OrderIntent goroutine |
| Max in-flight | 100 |
| Idempotency key | intent_id |
| Replay-safe | True |
| Deduplication | by intent_id within a 24h window |
| Ordering guarantees | FIFO per market_id |
| Per-call timeout (ms) | 200 |
| Backpressure strategy | shed |
| Locking / mutual exclusion | per-market_id mutex (for tick_size_cache) |
22. Dependencies
Depends on (must run first)
| Bot | Why | Contract |
|---|---|---|
| risk.kill_switch | Checked first before any order construction. | If active, no ExecutionPlan is emitted. |
| risk.portfolio_guard | Provides the approved OrderIntent with constraints.max_size_usd. | Total iceberg children size ≤ constraints.max_size_usd. |
| sec.contract_address_guard | Address validation must pass before signing. | DENY from ContractAddressGuard prevents wallet.sign() from being called. |
Emits to (downstream consumers)
| Bot | Why | Contract |
|---|---|---|
| gov.builder_attribution | Every ExecutionPlan contains a builderCode bytes32 field that BuilderAttribution logs. | builder_code field must be present on every signed order. |
Used by (auto-aggregated)
External services
| Service | Endpoint | SLA assumed | On failure |
|---|---|---|---|
| Gamma API | https://gamma-api.polymarket.com | 99.9% / 500ms p99 | Discard intent with STALE_MARKET_DATA if tick size is unavailable. |
| CLOB API (auth) | https://clob.polymarket.com | 99.95% / 200ms p99 | Retry up to 2 times; discard on persistent failure. |
| WS user feed | wss://ws-subscriptions-clob.polymarket.com/ws/user | best-effort | Falls back to REST fill polling; does not block order submission. |
23. Security Surfaces
SmartRouter triggers signed orders via the wallet adapter. It never holds private keys. The builder code is injected as a bytes32 field on every order.
Signing surface
This bot triggers signed orders via the wallet adapter — never holds keys. buildOrderTypedData produces the EIP-712 payload; the wallet adapter performs the signing.
On-chain contract calls
| Contract | Method | Network | Effect |
|---|---|---|---|
CTFExchangeV2 | matchOrders(...) | polygon | SmartRouter produces the signed order payload that is submitted to CTFExchangeV2.matchOrders() via the CLOB API. |
Abuse vectors considered
- Injecting a modified intent to flip the side (BUY→SELL) between guardrail approval and ExecutionPlan emit
- Replaying a stale signed order after the GTD TTL has expired
- Clock skew causing gtd_signal_ttl_s check to pass on an aged signal
Mitigations
- ExecutionPlan invariant check asserts side == intent.side before emitting
- per-intent_id deduplication prevents replay within 24h
- Clock synchronisation via NTP; TTL check uses server-side wall clock
- buildOrderTypedData includes current timestamp(ms) in V2 order fields, making replayed signatures invalid once the timestamp window passes
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 | @polymarket/clob-client-v2 ^2.x |
| Settlement contract | CTFExchangeV2 on Polygon |
| Notes | V2 order fields: timestamp(ms) + metadata(bytes32) + builder(bytes32). Fees are NOT on the signed order — they are operator-set at match time by CTFExchangeV2. Taker max 100 bps, maker max 50 bps, 1 bp granularity. builder_fee = notional * bps / 10000. NegRisk convert-arb routes go through NegRiskAdapter, not CTFExchangeV2. |
API surfaces declared
Networks supported
25. Versioning & Migration
| Field | Value |
|---|---|
| spec | 2.0.0 |
| implementation | 2.1.3 |
| schema | 2 |
| released | 2026-04-28 |
Migration history
| Date | From | To | Reason | Action taken |
|---|---|---|---|---|
| 2026-04-28 | v1 (USDC.e + HMAC builder) | v2 (pUSD + builderCode field) | Polymarket V2 cutover | Migrated SDK. buildOrderTypedData updated to V2 schema (timestamp/metadata/builder). Removed feeRateBps and nonce from order construction. HMAC builder replaced with on-order builderCode bytes32. EIP-712 domain version updated to '2'. NegRisk convert-arb route added. |
26. Acceptance Tests
Unit Tests
| Test | Setup | Expected result |
|---|---|---|
| Apply GTC when book depth is below FOK fill threshold | intent.order_type=FOK, visible_depth_usd=300, size_usd=350 | ExecutionPlan.order_type=GTC (FOK downgraded) |
| Split into iceberg when size exceeds threshold | size_usd=600, iceberg_threshold_usd=500, iceberg_child_count=3 | ExecutionPlan.iceberg=true, children=[200, 200, 200] |
| Discard order when GTD signal is expired | signal_age_s=150, gtd_signal_ttl_s=120, order_type=GTD | No ExecutionPlan emitted; discard logged with STALE_MARKET_DATA |
| Round price to tick size correctly | intent.price=0.623, tick_size=0.01 | ExecutionPlan.tick_aligned_price=0.62 |
| Preserve side, market_id, and outcome from original intent | intent.side=SELL, market_id=0xabc, outcome=NO | ExecutionPlan.side=SELL, market_id=0xabc, outcome=NO unchanged |
| Discard order when KillSwitch is active | killswitch.active=true | No ExecutionPlan emitted |
Integration Tests
| Test | Expected result |
|---|---|
| End-to-end: approved OrderIntent from guardrail pipeline produces valid ExecutionPlan | ExecutionPlan has tick-aligned price, correct order type, and respects all guardrail constraints |
| Iceberg children submitted sequentially as previous fills | Second child order not submitted until first fill confirmation received |
| Tick-size unavailability causes discard rather than unaligned submission | STALE_MARKET_DATA discard when Gamma API is unreachable |
Property Tests
| Property | Required behaviour |
|---|---|
| ExecutionPlan side always equals OrderIntent side — SmartRouter never flips direction | Always true |
| ExecutionPlan total size never exceeds the Risk-approved max_size_usd | Always true — sum of all iceberg children ≤ constraints.max_size_usd |
| No ExecutionPlan is emitted when KillSwitch is active | Always true |
27. Operational Runbook
SmartRouter incidents are typically stale tick-size data or elevated discard rates. Signing failures are escalated immediately.
On-call actions
| Alert | First step | Diagnosis | Mitigation | Escalate to |
|---|---|---|---|---|
SmartRouterHighDiscardRate | Check discard reason breakdown in Grafana. | If STALE_MARKET_DATA: Gamma API or CLOB may be degraded. If signal age high: check strategy latency. | If Gamma API is down, pause strategies to avoid wasted intents. Restore connectivity. | Exec pod lead after 10 minutes of sustained high discard rate. |
SmartRouterTickSizeUnavailable | Confirm Gamma API is reachable. | If unreachable, all tick-size cache TTLs have expired and orders are being discarded. | Restore Gamma API. Do not hard-code tick sizes. | Infra on-call if Gamma API is down > 5 minutes. |
SmartRouterHighLatency | Check CLOB POST /order latency. | High submission latency may indicate CLOB congestion or a saturated wallet adapter. | Reduce concurrent intents in flight. Check CLOB status page. | Exec pod lead if p99 > 500ms sustained. |
Manual overrides
polytraders bot pause exec.smart_router— Stops emitting ExecutionPlans. No orders will be signed or submitted.polytraders bot flush-cache exec.smart_router --market <market_id>— Evicts the tick-size cache for a specific market, forcing a fresh Gamma API fetch.
Healthcheck
GET /health → 200 if Gamma API tick sizes are fresh (< 60s) for all active markets and wallet adapter is responsive.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 |
|---|---|---|
| Unit tests pass including side invariant and all acceptance_tests.unit cases | CI test run | 100% pass |
| buildOrderTypedData produces valid V2 EIP-712 payload in integration test | Integration test | Pass |
Promote to Limited live
| Gate | How measured | Threshold |
|---|---|---|
| Iceberg split test: children sum ≤ max_size_usd | Integration test | Pass |
| p99 eval latency < 200ms over 24h | polytraders_exec_smartrouter_eval_latency_ms histogram | p99 < 200ms |
Promote to General live
| Gate | How measured | Threshold |
|---|---|---|
| E2E: approved intent → signed V2 order → fill confirmation on Polygon testnet | E2E test | Pass |
| NegRisk convert-arb route verified in staging | E2E 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 |