1.6 InventoryUnwinder
InventoryUnwinder detects when a position has breached its concentration or capital limit — either because an OrderIntent would push it over, or because an existing position is already over the limit (e.g. after a parameter change or strategy crash). When a breach is detected it generates unwind OrderIntents targeting the source bot, using the NegRiskAdapter on Polygon for negRisk markets, and routes them back into the execution pipeline. It can also hard-reject incoming intents that would worsen an already-breached position. Builder codes from the original strategy are preserved on unwind intents for attribution. Fail-closed: if position data is unavailable, all new intents for the affected market are rejected.
v3 readiness
A bot is done when all four scores are. What does done mean?
1. Bot Identity
| Layer | Risk Risk |
|---|---|
| Bot class | Guardrail |
| Authority | RejectReshape |
| Status | LIVE |
| Readiness | General live |
| Runs before | ExecutionPlan emit |
| Runs after | Strategy OrderIntent — triggered on concentration or capital breach |
| Applies to | Any OrderIntent that would push a position beyond concentration or capital limits; also fires on position-scan cycle to generate unwind intents for already-breached positions |
| Default mode | general_live |
| User-visible | summary-only |
| Developer owner | Polytraders core — Risk pod |
Operational profile
| Modes supported | quarantine |
|---|
2. Purpose
InventoryUnwinder detects when a position has breached its concentration or capital limit — either because an OrderIntent would push it over, or because an existing position is already over the limit (e.g. after a parameter change or strategy crash). When a breach is detected it generates unwind OrderIntents targeting the source bot, using the NegRiskAdapter on Polygon for negRisk markets, and routes them back into the execution pipeline. It can also hard-reject incoming intents that would worsen an already-breached position. Builder codes from the original strategy are preserved on unwind intents for attribution. Fail-closed: if position data is unavailable, all new intents for the affected market are rejected.
3. Why This Bot Matters
Concentration limit breach not unwound
A position that exceeds the declared inventory band creates directional exposure larger than the strategy risk envelope can justify, leading to losses that compound if the market moves against the position.
Capital limit not enforced on position growth
Without an active unwind, capital can become trapped in a single position, reducing the portfolio's ability to respond to other opportunities or drawdowns.
NegRisk market unwind without NegRiskAdapter
For multi-outcome negRisk markets, naively selling YES tokens may not fully close the position or may leave residual NO exposure. The NegRiskAdapter burn-and-redeem path must be used to correctly exit.
Strategy crash leaves open inventory
If a strategy halts mid-session with open inventory, the position will remain on the book indefinitely unless InventoryUnwinder detects and liquidates it.
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 |
|---|---|---|---|
| Current open positions per market — size, side, cost basis | clob_auth | Yes | Detect whether a position is within its inventory band or has breached concentration limits requiring unwind. |
| Order book top-of-book (bid/ask) for target market | clob_public | Yes | Determine unwind execution price and whether passive unwind is feasible at the current top-of-book. |
| Market metadata — negRisk flag, enableNegRisk, condition ID | gamma | Yes | Identify whether the market uses the NegRiskAdapter for unwind (negRisk markets must burn NO → pUSD via NegRiskAdapter). |
| On-chain position balance for NegRisk markets | onchain | No | Verify the on-chain token balance before constructing NegRiskAdapter unwind transactions for negRisk multi-outcome markets. |
5. Required Internal Inputs
| Input | Source | Required? | Use |
|---|---|---|---|
| Per-strategy inventory band configuration | internal | Yes | Max allowed net position size per market per strategy. Unwind is triggered when position exceeds max_inventory_band. |
| Source bot builder code (bytes32) for the breached position | internal | Yes | Carry original strategy builder code on unwind OrderIntents so attribution flows back to the position's source bot. |
| KillSwitch active flag | KillSwitch | Yes | If KillSwitch is active, block all new intents and begin emergency unwind of all open inventory. |
| PortfolioGuard per-market budget remaining | PortfolioGuard | Yes | Confirm the unwind size does not itself breach portfolio limits (unwinds always reduce exposure, so they typically pass). |
6. Parameter Guide
| Parameter | Default | Warning | Hard | What it controls |
|---|---|---|---|---|
| max_inventory_band | 1000 | 800 | 1000 | Maximum allowed net position size in pUSD for any single market. Unwind is triggered when current_position > hard. |
| unwind_aggression | 50 | 75 | 100 | How aggressively to unwind: 0 = passive limit orders only at mid; 50 = limit orders at best bid/ask; 100 = IOC at market (cross spread). |
| passive_only | True | None | False | If true, all unwind orders are placed as passive limit orders only. Overrides unwind_aggression when set. Prevents crossing the spread during unwind. |
| handback_threshold_pct | 80 | 90 | 95 | Once an unwind has reduced the position to this percentage of max_inventory_band, control is handed back to the originating strategy. |
7. Detailed Parameter Instructions
max_inventory_band
What it means
Maximum allowed net position size in pUSD for any single market. Unwind is triggered when current_position > hard.
Default
{ "max_inventory_band": 1000 }
Why this default matters
A $1000 inventory band is a reasonable starting cap for most market-making strategies. Breaching it means the MM has accumulated more directional risk than intended, requiring reduction.
Threshold logic
| Condition | Action |
|---|---|
| position ≤ 800 pUSD | APPROVE (no action needed) |
| 800–1000 pUSD | WARN — attach INVENTORY_UNWINDER_BAND_WARN annotation, no block |
| > 1000 pUSD | RESHAPE — generate unwind OrderIntents to bring position back to 800 pUSD |
Developer check
if (position > p.hard) generateUnwindIntents(market_id, position - p.warning); else if (position > p.warning) warn('INVENTORY_UNWINDER_BAND_WARN');
User-facing English
Your position in this market was larger than the allowed inventory. We are reducing it to keep your exposure within safe limits.
unwind_aggression
What it means
How aggressively to unwind: 0 = passive limit orders only at mid; 50 = limit orders at best bid/ask; 100 = IOC at market (cross spread).
Default
{ "unwind_aggression": 50 }
Why this default matters
At 50, the unwind uses limit orders at the inside quote, balancing speed of exposure reduction against price impact. Lower values risk slow unwind in fast markets.
Threshold logic
| Condition | Action |
|---|---|
| 0–49 | Passive limit order at or better than mid |
| 50–74 | Limit order at best bid/ask |
| 75–99 | Aggressive limit order crosses the spread |
| 100 | IOC market order — immediate fill regardless of spread |
Developer check
const orderType = aggression == 100 ? 'IOC' : aggression >= 75 ? 'LIMIT_CROSS' : 'LIMIT_PASSIVE'; buildUnwindOrder(market_id, size, orderType);
User-facing English
We are closing your position using market orders to reduce your risk quickly.
passive_only
What it means
If true, all unwind orders are placed as passive limit orders only. Overrides unwind_aggression when set. Prevents crossing the spread during unwind.
Default
{ "passive_only": true }
Why this default matters
Passive-only unwinds avoid paying the spread, which is important for positions where the inventory drift is modest. Disable for emergency unwinds.
Threshold logic
| Condition | Action |
|---|---|
| passive_only = true | All unwind orders are POST_ONLY or passive limit; never IOC |
| passive_only = false | unwind_aggression parameter controls order type |
Developer check
if (params.passive_only) intent.order_type = 'POST_ONLY';
User-facing English
— not yet authored —
handback_threshold_pct
What it means
Once an unwind has reduced the position to this percentage of max_inventory_band, control is handed back to the originating strategy.
Default
{ "handback_threshold_pct": 80 }
Why this default matters
Returning control at 80% prevents the unwind from over-shooting (selling beyond neutral) while still giving the strategy room to re-enter.
Threshold logic
| Condition | Action |
|---|---|
| position ≤ max_inventory_band × (handback_threshold_pct / 100) | Emit DecisionReport UNWIND_COMPLETE; hand back to source strategy |
| position > handback threshold | Continue generating unwind OrderIntents |
Developer check
if (position <= max_inventory_band * p.handback_threshold_pct / 100) emitDecisionReport('UNWIND_COMPLETE', source_bot_id);
User-facing English
Your position has been reduced to a safe level.
8. Default Configuration
{
"bot_id": "risk.inventory_unwinder",
"version": "2.0.0",
"mode": "hard_guard",
"defaults": {
"max_inventory_band": 1000,
"unwind_aggression": 50,
"passive_only": true,
"handback_threshold_pct": 80
},
"locked": {
"max_inventory_band": {
"min": 100
},
"handback_threshold_pct": {
"max": 95
}
}
}9. Implementation Flow
- Receive OrderIntent from Strategy layer or trigger from periodic position-scan cycle.
- Check KillSwitch active flag; if active, reject the incoming intent and begin emergency unwind of all open inventory above zero.
- Fetch current open positions for the target market from clob_auth. If position data is unavailable, reject the incoming intent with STALE_MARKET_DATA (fail-closed).
- Compare current position size against max_inventory_band. If already at or above the hard limit, hard-reject the incoming intent with INVENTORY_UNWINDER_BAND_BREACH.
- Fetch market metadata from Gamma to check negRisk and enableNegRisk flags. For negRisk markets, determine whether the NegRiskAdapter unwind path is required (burn NO tokens → pUSD via NegRiskAdapter on Polygon).
- Compute unwind_size = current_position - (max_inventory_band × handback_threshold_pct / 100). If unwind_size > 0, generate one or more unwind OrderIntents sized to bring the position back to the handback threshold.
- Attach the source bot's builder code (bytes32) to each unwind OrderIntent for attribution tracking. This ensures unwind fills are credited to the original strategy.
- Set order type on unwind intents based on passive_only flag and unwind_aggression: POST_ONLY if passive_only=true, otherwise LIMIT or IOC based on aggression level.
- Emit unwind OrderIntents back into the execution pipeline. Emit a RiskVote (HARD_REJECT or RESHAPE) for the incoming intent if it would worsen the breach.
- Emit a DecisionReport with UNWIND_COMPLETE when position falls to or below the handback threshold.
10. Reference Implementation
Evaluates incoming OrderIntent against the current position size. If a breach is detected (or is already present), generates unwind OrderIntents with the source bot's builder code, using the NegRiskAdapter path for negRisk markets. Emits RiskVote (REJECT/RESHAPE) for the incoming intent and DecisionReport when unwind completes.
Pseudocode is language-agnostic. FETCH = read input. EMIT = produce output. Translate to TS/Python/Go/Rust.
FUNCTION evaluateInventory(intent):
// --- 0. KillSwitch gate ---
ks = FETCH internal.killswitch.status
IF ks.active:
EMIT RiskVote(decision=HARD_REJECT, reason=KILL_SWITCH_ACTIVE)
startEmergencyUnwindAll()
RETURN
// --- 1. Fetch current position ---
position = FETCH clob_auth.GET('/positions?market=' + intent.market_id)
IF position IS NULL:
EMIT RiskVote(decision=HARD_REJECT, reason=STALE_MARKET_DATA)
RETURN
currentSize = position.net_size_pusd
// --- 2. Fetch market metadata ---
market = FETCH gamma.getMarketByConditionId(intent.market_id)
IF market IS NULL:
EMIT RiskVote(decision=HARD_REJECT, reason=STALE_MARKET_DATA)
RETURN
// --- 3. Check if existing position already breaches band ---
IF currentSize >= params.max_inventory_band.hard:
// Reject intent that would worsen the breach
IF intent.side == position.side:
EMIT RiskVote(decision=HARD_REJECT, reason=INVENTORY_UNWINDER_BAND_BREACH)
// Generate unwind intents
unwindSize = currentSize - (params.max_inventory_band.hard * params.handback_threshold_pct / 100)
builderCode = FETCH internal.strategy_registry.builder_code(position.source_bot_id)
IF market.negRisk:
unwindIntents = buildNegRiskUnwindIntents(market, unwindSize, builderCode)
ELSE:
unwindIntents = buildStandardUnwindIntents(market, unwindSize, builderCode)
FOR ui IN unwindIntents:
EMIT OrderIntent(ui) // back into execution pipeline
RETURN
// --- 4. Check if incoming intent would cause breach ---
projectedSize = currentSize + (intent.size_usd IF intent.side == position.side ELSE 0)
IF projectedSize > params.max_inventory_band.hard:
allowedSize = params.max_inventory_band.hard - currentSize
IF allowedSize <= 0:
EMIT RiskVote(decision=HARD_REJECT, reason=INVENTORY_UNWINDER_BAND_BREACH)
ELSE:
EMIT RiskVote(decision=RESHAPE_REQUIRED,
reason=INVENTORY_UNWINDER_BAND_BREACH,
constraints={max_size_usd: allowedSize})
RETURN
// --- 5. Warn if approaching band ---
IF projectedSize > params.max_inventory_band.warning:
EMIT RiskVote(decision=APPROVE,
annotations=[{code: INVENTORY_UNWINDER_BAND_WARN}])
RETURN
// --- 6. Approve ---
EMIT RiskVote(decision=APPROVE, checked_at=now_iso())
FUNCTION buildNegRiskUnwindIntents(market, size, builderCode):
// Use NegRiskAdapter: burn NO tokens across outcome set → pUSD
onchainBalance = FETCH onchain.tokenBalance(market.condition_id, 'NO')
burnAmount = min(size, toPusdUnits(onchainBalance))
// If sum(YES prices) < $1, convert-arb: also re-mint YES
yesSum = SUM(FETCH clob_public.price(o) FOR o IN market.outcomes)
IF yesSum < 1.0:
RETURN [NegRiskAdapterBurnOrder(burnAmount, builderCode),
NegRiskAdapterMintYesOrder(burnAmount, builderCode)]
RETURN [NegRiskAdapterBurnOrder(burnAmount, builderCode)]
SDK calls used
clob_auth.GET('/positions?market=0xabc...')gamma.getMarketByConditionId(market_id)clob_public.GET('/book?market=0xabc...&depth=1')onchain.tokenBalance(condition_id, outcome='NO')toPusdUnits(rawBalance)internal.strategy_registry.builder_code(bot_id)internal.killswitch.status()buildOrderTypedData(unwindIntent)
Complexity: O(N) where N = number of open positions in breach; typically O(1) per single-market evaluation
11. Wire Examples
Input — what arrives on the wire
OrderIntent — BUY into already-breached position — internal
{
"intent_id": "int_a1b2c3d4e5f60718",
"market_id": "0x5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d",
"side": "BUY",
"outcome": "YES",
"size_usd": 300,
"price": 0.71,
"neg_risk": true,
"builder_code": "0x706f6c7974726164657273000000000000000000000000000000000000000000",
"generated_at_ms": 1746784200000
}
Current position snapshot (clob_auth) — clob_auth
{
"market_id": "0x5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d",
"side": "BUY",
"net_size_pusd": 1250,
"cost_basis_pusd": 887,
"source_bot_id": "strat.mm_v2",
"fetched_at_ms": 1746784195000
}
Output — what the bot emits
RiskVote — HARD_REJECT (band already breached, intent would worsen)
{
"guard_id": "risk.inventory_unwinder",
"decision": "HARD_REJECT",
"severity": "HARD",
"reason_code": "INVENTORY_UNWINDER_BAND_BREACH",
"message": "Position 1250 pUSD exceeds max_inventory_band 1000 pUSD. BUY intent rejected; 1 NegRisk unwind intent emitted.",
"constraints": {},
"inputs_used": [
"clob_auth.positions",
"gamma.market.negrisk",
"internal.strategy_registry.builder_code",
"internal.killswitch.status"
],
"unwind_intents_emitted": 1,
"checked_at": "2026-05-09T11:05:00Z"
}
DecisionReport — UNWIND_COMPLETE
{
"report_id": "rpt_f1e2d3c4b5a60718",
"guard_id": "risk.inventory_unwinder",
"event": "UNWIND_COMPLETE",
"market_id": "0x5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d",
"position_after_pusd": 795,
"handback_threshold_pusd": 800,
"source_bot_id": "strat.mm_v2",
"completed_at": "2026-05-09T11:05:48Z"
}12. Decision Logic
APPROVE
Incoming OrderIntent does not push the position above max_inventory_band warning threshold; or the intent itself is a reducing (close) order.
RESHAPE_REQUIRED
Incoming intent is in the direction that would worsen inventory but does not cross the hard ceiling — downsize to the amount that keeps the position at or below max_inventory_band.
REJECT
Position is already at or above the hard ceiling (max_inventory_band); the incoming intent would worsen the breach; KillSwitch is active; or position data is unavailable.
WARNING_ONLY
Position is between the warning and hard thresholds — attach INVENTORY_UNWINDER_BAND_WARN to the RiskVote without blocking the intent.
13. Standard Decision Output
This bot returns a RiskVote object. See RiskVote schema.
{
"guard_id": "risk.inventory_unwinder",
"decision": "HARD_REJECT",
"severity": "HARD",
"reason_code": "INVENTORY_UNWINDER_BAND_BREACH",
"message": "Position 1250 pUSD in market 0x7f8a... exceeds max_inventory_band 1000 pUSD. Incoming BUY intent rejected; unwind OrderIntents emitted.",
"constraints": {},
"inputs_used": [
"clob_auth.positions",
"gamma.market.negrisk",
"internal.strategy_registry.builder_code",
"internal.killswitch.status"
],
"unwind_intents_emitted": 2,
"checked_at": "2026-05-09T11:05:00Z"
}14. Reason Codes
| Code | Severity | Meaning | Action | User-facing message |
|---|---|---|---|---|
KILL_SWITCH_ACTIVE | HARD_REJECT | Global kill switch is active; all incoming intents are rejected and emergency unwind of all open inventory begins. | Immediately return HARD_REJECT and trigger startEmergencyUnwindAll(). | Trading is currently paused. Your positions are being safely reduced. |
STALE_MARKET_DATA | HARD_REJECT | Position data from clob_auth or market metadata from Gamma is unavailable or stale. | Return HARD_REJECT; retry on next fresh fetch. | Position data could not be verified. The order was blocked until current information is available. |
INVENTORY_UNWINDER_BAND_BREACH | HARD_REJECT | Position is at or above max_inventory_band and the incoming intent would increase it further. | Return HARD_REJECT; emit unwind OrderIntents to reduce position back to handback threshold. | Your position in this market was larger than the allowed limit. We are reducing it before accepting new orders in the same direction. |
INVENTORY_UNWINDER_RESHAPE | RESHAPE | Incoming intent would push the position above max_inventory_band but the position is currently below the hard ceiling. | Return RESHAPE_REQUIRED with constraints.max_size_usd = max_inventory_band - current_position. | Your order was reduced to keep your position within the allowed inventory limit. |
INVENTORY_UNWINDER_BAND_WARN | WARN | Position (after this intent) would be between the warning and hard thresholds. | Attach annotation to APPROVE; do not block. Log for monitoring. | |
INVENTORY_UNWINDER_NEGRISK_UNWIND | INFO | Unwind is proceeding via the NegRiskAdapter path (burn NO tokens → pUSD). | Log the on-chain burn transaction reference and pUSD recovered. | |
INVENTORY_UNWINDER_UNWIND_COMPLETE | INFO | Position has been reduced to or below the handback threshold; control returned to the originating strategy. | Emit DecisionReport(UNWIND_COMPLETE) and resume accepting new intents from the source bot. | Your position has been reduced to a safe level. |
15. Metrics & Logs
Metrics emitted
| Metric | Type | Unit | Labels | Meaning |
|---|---|---|---|---|
polytraders_risk_inventoryunwinder_decisions_total | counter | count | decision, reason_code, market_id | Total RiskVote decisions emitted, broken down by decision type and reason code. |
polytraders_risk_inventoryunwinder_position_pusd | gauge | pusd | market_id, source_bot_id | Current net position size in pUSD per market and source bot. Alerts when approaching max_inventory_band. |
polytraders_risk_inventoryunwinder_unwind_intents_emitted_total | counter | count | market_id, unwind_type | Number of unwind OrderIntents emitted, by market and unwind type (standard vs. negrisk). |
polytraders_risk_inventoryunwinder_unwind_duration_seconds | histogram | seconds | market_id | Time from first unwind intent emission to UNWIND_COMPLETE DecisionReport. |
polytraders_risk_inventoryunwinder_eval_latency_ms | histogram | milliseconds | Wall-clock latency from OrderIntent receipt to RiskVote emit. | |
polytraders_risk_inventoryunwinder_negrisk_burns_total | counter | count | market_id | Number of NegRiskAdapter burn-and-redeem operations initiated for negRisk market unwinds. |
Alerts
| Alert | Condition | Severity | Runbook |
|---|---|---|---|
InventoryUnwinderBandBreach | polytraders_risk_inventoryunwinder_position_pusd > max_inventory_band * 1.1 | page | #runbook-inventoryunwinder-breach |
InventoryUnwinderUnwindStuck | histogram_quantile(0.99, rate(polytraders_risk_inventoryunwinder_unwind_duration_seconds_bucket[10m])) > 300 | page | #runbook-inventoryunwinder-stuck |
InventoryUnwinderStaleLedger | rate(polytraders_risk_inventoryunwinder_decisions_total{reason_code='STALE_MARKET_DATA'}[5m]) > 0 | warn | #runbook-inventoryunwinder-stale |
InventoryUnwinderHighLatency | histogram_quantile(0.99, rate(polytraders_risk_inventoryunwinder_eval_latency_ms_bucket[5m])) > 200 | warn | #runbook-inventoryunwinder-latency |
Dashboards
- Grafana — Risk overview / InventoryUnwinder
- Grafana — Position management / inventory band utilisation and unwind history
16. Developer Reporting
{
"bot_id": "risk.inventory_unwinder",
"decision": "HARD_REJECT",
"reason_code": "INVENTORY_UNWINDER_BAND_BREACH",
"inputs_used": [
"clob_auth.positions",
"gamma.market.negrisk"
],
"metrics": {
"current_position_pusd": 1250,
"max_inventory_band": 1000,
"unwind_size_pusd": 450,
"negrisk": true,
"unwind_adapter": "NegRiskAdapter",
"source_bot_builder_code": "0x706f6c7974726164657273000000000000000000000000000000000000000000"
},
"checked_at": "2026-05-09T11:05:00Z"
}17. Plain-English Reporting
| Situation | User-facing explanation |
|---|---|
| Order blocked — position at inventory limit | Your position in this market has reached the maximum allowed size. We are reducing it before accepting new orders in the same direction. |
| Position being reduced automatically | Your position exceeded the inventory limit, so we are automatically placing orders to bring it back within the allowed range. |
| Order reduced — near inventory limit | Your order was reduced because placing the full size would push your position above the inventory limit for this market. |
| Unwind complete — control returned to strategy | Your position has been reduced to a safe level and normal strategy operation has resumed. |
18. Failure-Mode Block
| main_failure_mode | Failing to detect an over-limit position because position data from clob_auth is stale or delayed after a fill, allowing the strategy to continue adding to an already-breached position. |
|---|---|
| false_positive_risk | Triggering an unwind on a position that was over the limit only transiently (e.g. during a fill processing lag), unwinding a position the strategy intended to hold. |
| false_negative_risk | Missing a breach because the position ledger has not yet reflected a recent fill, allowing the strategy to add further exposure before the unwind fires. |
| safe_fallback | If clob_auth position data is unavailable or stale, InventoryUnwinder hard-rejects all new intents for the affected market with STALE_MARKET_DATA. It never approves on missing position data. |
| required_dependencies | clob_auth position endpoint, clob_public order book, Gamma API market metadata (negRisk flag), Internal strategy registry (builder codes), KillSwitch active flag, On-chain position balance (negRisk markets only) |
19. Failure-Injection Recipes
| Scenario | How to inject | Expected behaviour | Recovery |
|---|---|---|---|
POSITION_EXCEEDS_BAND | Set mock clob_auth position.net_size_pusd = 1300 for a market with max_inventory_band=1000 | Returns to APPROVE for new intents once position drops below handback_threshold_pct of max_inventory_band. | |
STALE_POSITION_DATA | Block clob_auth position endpoint for 130s (exceed cache TTL of 120s) | Returns to normal within one evaluation cycle after clob_auth is reachable. | |
NEGRISK_UNWIND | Set market.negRisk=true and position.net_size_pusd=1200 in mock | UNWIND_COMPLETE DecisionReport emitted after NegRiskAdapter burn transactions settle. | |
KILL_SWITCH_EMERGENCY_UNWIND | Set internal.killswitch.status.active=true with two open positions in mock | Returns to normal pipeline on manual KillSwitch reset after positions are fully closed. | |
UNWIND_STUCK_NO_FILLS | Block all CLOB match events for the target market (simulate dead book) | Unwind resumes when CLOB book has resting liquidity. If passive_only=true, on-call must manually set aggression or pause the strategy. |
20. State & Persistence
Cold-start recovery
On cold start, position cache is empty. First evaluation per market triggers a blocking fetch from clob_auth. If fetch fails, order is rejected fail-closed until cache is populated.
21. Concurrency & Idempotency
| Aspect | Specification |
|---|---|
| Execution model | single-threaded event loop |
| Max in-flight | 100 |
| Idempotency key | intent_id |
| Per-call timeout (ms) | 200 |
| Backpressure strategy | drop newest |
| Locking / mutual exclusion | per-market_id mutex to prevent concurrent unwind and new-order evaluation for the same market |
22. Dependencies
Depends on (must run first)
| Bot | Why | Contract |
|---|---|---|
| risk.kill_switch | Global brake — checked first; KillSwitch triggers emergency unwind of all inventory. | RiskVote.HARD_REJECT(KILL_SWITCH_ACTIVE) and startEmergencyUnwindAll(). |
| risk.portfolio_guard | PortfolioGuard per-market budget is consulted to confirm unwind intents don't inadvertently breach other limits (unwinds are closing, so they typically pass). | Unwind intents always reduce exposure and therefore always pass PortfolioGuard. |
Emits to (downstream consumers)
| Bot | Why | Contract |
|---|---|---|
| exec.smart_router | Unwind OrderIntents generated by InventoryUnwinder are routed into SmartRouter for execution. | Unwind intents carry the source bot's builder code and have close_only=true constraints. |
gov.reporting | DecisionReport(UNWIND_COMPLETE) emitted to reporting bus when unwind finishes. | DecisionReport includes market_id, position_after_pusd, source_bot_id, and completed_at. |
Sibling bots (same OrderIntent)
| Bot | Why | Contract |
|---|---|---|
| risk.portfolio_guard | Sibling guardrail; both must pass before SmartRouter runs. | |
| risk.liquidity_guard | Sibling guardrail; liquidity check applies to unwind orders too. |
External services
| Service | Endpoint | SLA assumed | On failure |
|---|---|---|---|
| CLOB Auth API (positions) | https://clob.polymarket.com | 99.95% / 200ms p99 | |
| Gamma API (market metadata) | https://gamma-api.polymarket.com | 99.9% / 300ms p99 | |
| Polygon on-chain (NegRiskAdapter) | NegRiskAdapter contract on Polygon | Polygon network uptime (~99.9%) |
23. Security Surfaces
On-chain contract calls
| Contract | Method | Network | Effect |
|---|---|---|---|
NegRiskAdapter | | polygon | |
CTFExchangeV2 | | polygon |
Abuse vectors considered
- Strategy attempting to bypass unwind by splitting large orders into many small intents just below max_inventory_band
- Race condition: two strategies simultaneously adding to the same market to exceed the band before either triggers an unwind
- Manipulated position feed showing lower-than-actual position to delay unwind trigger
Mitigations
- Per-market_id mutex prevents concurrent evaluation for the same market
- Periodic position-scan cycle (every 30s) catches breaches that slip past per-intent checks
- Position cache has strict staleness TTL (120s); expired cache → HARD_REJECT, no bypass possible
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 | Unwind OrderIntents carry the source strategy's builderCode (bytes32) for maker attribution on refill orders (up to 50 bps). For negRisk markets, the unwind path burns NO tokens across the outcome set via NegRiskAdapter on Polygon to recover pUSD, then optionally re-mints YES tokens if convert-arb threshold is met (sum(YES) < $1). |
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 | v2 | CLOB V2 cutover | Switched to py-clob-client-v2; all position sizes now denominated in pUSD. Removed feeRateBps and nonce from unwind order construction; added timestamp(ms) and metadata(bytes32) fields. Builder code attribution switched from HMAC to native builderCode (bytes32) on unwind OrderIntents. NegRisk unwind path updated to use NegRiskAdapter on Polygon (burn NO → pUSD) instead of direct CTFExchangeV1 path. |
26. Acceptance Tests
Unit Tests
| Test | Setup | Expected result |
|---|---|---|
| Approve when position is below warning threshold | current_position=700 pUSD, max_inventory_band=1000, warning=800 | APPROVE with no constraints |
| Warn when position is between warning and hard threshold | current_position=900 pUSD, max_inventory_band=1000, warning=800 | APPROVE with INVENTORY_UNWINDER_BAND_WARN annotation |
| Hard-reject and emit unwind intents when position exceeds hard limit | current_position=1100 pUSD, max_inventory_band=1000, incoming intent side=BUY | HARD_REJECT(INVENTORY_UNWINDER_BAND_BREACH) and 1+ unwind OrderIntents emitted |
| Reshape to remaining room when intent would cause breach | current_position=850 pUSD, max_inventory_band=1000, incoming intent size=300 pUSD | RESHAPE with constraints.max_size_usd=150 pUSD |
| Unwind uses NegRiskAdapter for negRisk market | market.negRisk=true, current_position=1200 pUSD | Unwind OrderIntents include NegRiskAdapter path; on-chain balance checked before emission |
| Builder code preserved on unwind OrderIntents | source bot builder_code=0x706f6c7974726164657273... | Emitted unwind OrderIntents carry the same builder_code as the source bot |
| Reject when position data unavailable (fail-closed) | clob_auth returns 503 for position endpoint | HARD_REJECT(STALE_MARKET_DATA) — no unwind emitted |
Integration Tests
| Test | Expected result |
|---|---|
| Unwind flows through execution pipeline and reduces position | Unwind OrderIntents generated by InventoryUnwinder are accepted by SmartRouter and result in fill that reduces position below handback threshold |
| DecisionReport UNWIND_COMPLETE emitted when position drops below handback threshold | DecisionReport with reason UNWIND_COMPLETE emitted to reporting bus within one evaluation cycle after position drops to 80% of max_inventory_band |
| KillSwitch triggers emergency unwind of all open inventory | InventoryUnwinder generates unwind intents for all open positions when KillSwitch is activated, regardless of max_inventory_band setting |
Property Tests
| Property | Required behaviour |
|---|---|
| Unwind intents always reduce position size, never increase it | Always true — unwind OrderIntents are always on the opposing side to the current position |
| Builder code on unwind intents always matches the source bot's builder code | Always true — attribution must be preserved for post-trade reporting |
| Missing position data never results in APPROVE for direction-adding intents | Always true — STALE_MARKET_DATA produces HARD_REJECT |
27. Operational Runbook
InventoryUnwinder incidents are typically caused by a position that has grown beyond the inventory band and an unwind that is stuck (no fills), or by stale position data from clob_auth preventing detection. Stuck unwinds require manual review of book liquidity and aggression settings.
On-call actions
| Alert | First step | Diagnosis | Mitigation | Escalate to |
|---|---|---|---|---|
InventoryUnwinderBandBreach | ||||
InventoryUnwinderUnwindStuck | ||||
InventoryUnwinderStaleLedger | ||||
InventoryUnwinderHighLatency |
Manual overrides
——
Healthcheck
GET /internal/health/inventoryunwinder → 200 if position cache for all active markets is within TTL, no active unwind has been stuck for > 60s, and clob_auth and Gamma API are reachable; red if any position cache is expired and clob_auth is unreachable, or an active unwind has been in-flight for > 300s without position reduction.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 band breach, reshape, and NegRisk unwind path | CI test run | 100% pass |
| Integration test: unwind intent flows through SmartRouter and reduces mock position | Integration test suite | Pass |
Promote to Limited live
| Gate | How measured | Threshold |
|---|---|---|
| Shadow mode: unwind decisions match expected baseline within 5% over 48h | Grafana shadow vs live comparison | < 5% divergence |
| p99 evaluation latency < 200ms | polytraders_risk_inventoryunwinder_eval_latency_ms histogram | p99 < 200ms |
Promote to General live
| Gate | How measured | Threshold |
|---|---|---|
| At least one successful NegRisk unwind via NegRiskAdapter in staging | Staging integration test with negRisk mock market | Pass |
| Builder code attribution verified on unwind fills in post-trade reconciliation | Post-trade report audit | 100% of unwind fills carry correct source bot builder code |
| Emergency unwind (KillSwitch) clears all positions in < 5 minutes in staging | KillSwitch failure injection 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 |