Polytraders Dev Guide
internal
v3 spine Phase 1 · Shared contracts 9 demo-wired · 0 shadow-ready · 0 production-live · 100 pending · 109 total 15/33 infra tasks the plan status board
HomeBy LayerRisk1.6 InventoryUnwinder

1.6 InventoryUnwinder

Risk Guardrail RejectReshape LIVE General live capital · Direct P4 · Core risk pending stub

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

Docs27/27
donehow scored
Impl0/15
pendinghow scored
Backtest0/4
pendinghow scored
Runtime0/8
pendinghow scored

A bot is done when all four scores are. What does done mean?

1. Bot Identity

LayerRisk  Risk
Bot classGuardrail
AuthorityRejectReshape
StatusLIVE
ReadinessGeneral live
Runs beforeExecutionPlan emit
Runs afterStrategy OrderIntent — triggered on concentration or capital breach
Applies toAny 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 modegeneral_live
User-visiblesummary-only
Developer ownerPolytraders core — Risk pod

Operational profile

Modes supportedquarantine

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

InputSourceRequired?Use
Current open positions per market — size, side, cost basisclob_authYesDetect whether a position is within its inventory band or has breached concentration limits requiring unwind.
Order book top-of-book (bid/ask) for target marketclob_publicYesDetermine unwind execution price and whether passive unwind is feasible at the current top-of-book.
Market metadata — negRisk flag, enableNegRisk, condition IDgammaYesIdentify whether the market uses the NegRiskAdapter for unwind (negRisk markets must burn NO → pUSD via NegRiskAdapter).
On-chain position balance for NegRisk marketsonchainNoVerify the on-chain token balance before constructing NegRiskAdapter unwind transactions for negRisk multi-outcome markets.

5. Required Internal Inputs

InputSourceRequired?Use
Per-strategy inventory band configurationinternalYesMax 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 positioninternalYesCarry original strategy builder code on unwind OrderIntents so attribution flows back to the position's source bot.
KillSwitch active flagKillSwitchYesIf KillSwitch is active, block all new intents and begin emergency unwind of all open inventory.
PortfolioGuard per-market budget remainingPortfolioGuardYesConfirm the unwind size does not itself breach portfolio limits (unwinds always reduce exposure, so they typically pass).

6. Parameter Guide

ParameterDefaultWarningHardWhat it controls
max_inventory_band10008001000Maximum allowed net position size in pUSD for any single market. Unwind is triggered when current_position > hard.
unwind_aggression5075100How aggressively to unwind: 0 = passive limit orders only at mid; 50 = limit orders at best bid/ask; 100 = IOC at market (cross spread).
passive_onlyTrueNoneFalseIf true, all unwind orders are placed as passive limit orders only. Overrides unwind_aggression when set. Prevents crossing the spread during unwind.
handback_threshold_pct809095Once 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

ConditionAction
position ≤ 800 pUSDAPPROVE (no action needed)
800–1000 pUSDWARN — attach INVENTORY_UNWINDER_BAND_WARN annotation, no block
> 1000 pUSDRESHAPE — 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

ConditionAction
0–49Passive limit order at or better than mid
50–74Limit order at best bid/ask
75–99Aggressive limit order crosses the spread
100IOC 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

ConditionAction
passive_only = trueAll unwind orders are POST_ONLY or passive limit; never IOC
passive_only = falseunwind_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

ConditionAction
position ≤ max_inventory_band × (handback_threshold_pct / 100)Emit DecisionReport UNWIND_COMPLETE; hand back to source strategy
position > handback thresholdContinue 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

  1. Receive OrderIntent from Strategy layer or trigger from periodic position-scan cycle.
  2. Check KillSwitch active flag; if active, reject the incoming intent and begin emergency unwind of all open inventory above zero.
  3. 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).
  4. 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.
  5. 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).
  6. 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.
  7. 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.
  8. 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.
  9. 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.
  10. 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 positioninternal

{
  "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

CodeSeverityMeaningActionUser-facing message
KILL_SWITCH_ACTIVEHARD_REJECTGlobal 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_DATAHARD_REJECTPosition 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_BREACHHARD_REJECTPosition 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_RESHAPERESHAPEIncoming 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_WARNWARNPosition (after this intent) would be between the warning and hard thresholds.Attach annotation to APPROVE; do not block. Log for monitoring.
INVENTORY_UNWINDER_NEGRISK_UNWINDINFOUnwind is proceeding via the NegRiskAdapter path (burn NO tokens → pUSD).Log the on-chain burn transaction reference and pUSD recovered.
INVENTORY_UNWINDER_UNWIND_COMPLETEINFOPosition 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

MetricTypeUnitLabelsMeaning
polytraders_risk_inventoryunwinder_decisions_totalcountercountdecision, reason_code, market_idTotal RiskVote decisions emitted, broken down by decision type and reason code.
polytraders_risk_inventoryunwinder_position_pusdgaugepusdmarket_id, source_bot_idCurrent net position size in pUSD per market and source bot. Alerts when approaching max_inventory_band.
polytraders_risk_inventoryunwinder_unwind_intents_emitted_totalcountercountmarket_id, unwind_typeNumber of unwind OrderIntents emitted, by market and unwind type (standard vs. negrisk).
polytraders_risk_inventoryunwinder_unwind_duration_secondshistogramsecondsmarket_idTime from first unwind intent emission to UNWIND_COMPLETE DecisionReport.
polytraders_risk_inventoryunwinder_eval_latency_mshistogrammillisecondsWall-clock latency from OrderIntent receipt to RiskVote emit.
polytraders_risk_inventoryunwinder_negrisk_burns_totalcountercountmarket_idNumber of NegRiskAdapter burn-and-redeem operations initiated for negRisk market unwinds.

Alerts

AlertConditionSeverityRunbook
InventoryUnwinderBandBreachpolytraders_risk_inventoryunwinder_position_pusd > max_inventory_band * 1.1page#runbook-inventoryunwinder-breach
InventoryUnwinderUnwindStuckhistogram_quantile(0.99, rate(polytraders_risk_inventoryunwinder_unwind_duration_seconds_bucket[10m])) > 300page#runbook-inventoryunwinder-stuck
InventoryUnwinderStaleLedgerrate(polytraders_risk_inventoryunwinder_decisions_total{reason_code='STALE_MARKET_DATA'}[5m]) > 0warn#runbook-inventoryunwinder-stale
InventoryUnwinderHighLatencyhistogram_quantile(0.99, rate(polytraders_risk_inventoryunwinder_eval_latency_ms_bucket[5m])) > 200warn#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

SituationUser-facing explanation
Order blocked — position at inventory limitYour 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 automaticallyYour position exceeded the inventory limit, so we are automatically placing orders to bring it back within the allowed range.
Order reduced — near inventory limitYour order was reduced because placing the full size would push your position above the inventory limit for this market.
Unwind complete — control returned to strategyYour position has been reduced to a safe level and normal strategy operation has resumed.

18. Failure-Mode Block

main_failure_modeFailing 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_riskTriggering 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_riskMissing 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_fallbackIf 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_dependenciesclob_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

ScenarioHow to injectExpected behaviourRecovery
POSITION_EXCEEDS_BANDSet mock clob_auth position.net_size_pusd = 1300 for a market with max_inventory_band=1000Returns to APPROVE for new intents once position drops below handback_threshold_pct of max_inventory_band.
STALE_POSITION_DATABlock clob_auth position endpoint for 130s (exceed cache TTL of 120s)Returns to normal within one evaluation cycle after clob_auth is reachable.
NEGRISK_UNWINDSet market.negRisk=true and position.net_size_pusd=1200 in mockUNWIND_COMPLETE DecisionReport emitted after NegRiskAdapter burn transactions settle.
KILL_SWITCH_EMERGENCY_UNWINDSet internal.killswitch.status.active=true with two open positions in mockReturns to normal pipeline on manual KillSwitch reset after positions are fully closed.
UNWIND_STUCK_NO_FILLSBlock 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

AspectSpecification
Execution modelsingle-threaded event loop
Max in-flight100
Idempotency keyintent_id
Per-call timeout (ms)200
Backpressure strategydrop newest
Locking / mutual exclusionper-market_id mutex to prevent concurrent unwind and new-order evaluation for the same market

22. Dependencies

Depends on (must run first)

BotWhyContract
risk.kill_switchGlobal brake — checked first; KillSwitch triggers emergency unwind of all inventory.RiskVote.HARD_REJECT(KILL_SWITCH_ACTIVE) and startEmergencyUnwindAll().
risk.portfolio_guardPortfolioGuard 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)

BotWhyContract
exec.smart_routerUnwind 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.reportingDecisionReport(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)

BotWhyContract
risk.portfolio_guardSibling guardrail; both must pass before SmartRouter runs.
risk.liquidity_guardSibling guardrail; liquidity check applies to unwind orders too.

External services

ServiceEndpointSLA assumedOn failure
CLOB Auth API (positions)https://clob.polymarket.com99.95% / 200ms p99
Gamma API (market metadata)https://gamma-api.polymarket.com99.9% / 300ms p99
Polygon on-chain (NegRiskAdapter)NegRiskAdapter contract on PolygonPolygon network uptime (~99.9%)

23. Security Surfaces

On-chain contract calls

ContractMethodNetworkEffect
NegRiskAdapterpolygon
CTFExchangeV2polygon

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

AspectValue
CLOB versionv2
Collateral assetpUSD
EIP-712 Exchange domain version2
Aware of builderCode fieldyes
Aware of negative-risk marketsyes
Multi-chain readyno
SDK usedpy-clob-client-v2
Settlement contractCTFExchangeV2
NotesUnwind 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

clob_publicclob_authonchaininternal

Networks supported

polygon

25. Versioning & Migration

FieldValue
spec2.0.0
implementation2.1.0
schema2
released2026-04-28

Migration history

DateFromToReasonAction taken
2026-04-28v1v2CLOB V2 cutoverSwitched 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

TestSetupExpected result
Approve when position is below warning thresholdcurrent_position=700 pUSD, max_inventory_band=1000, warning=800APPROVE with no constraints
Warn when position is between warning and hard thresholdcurrent_position=900 pUSD, max_inventory_band=1000, warning=800APPROVE with INVENTORY_UNWINDER_BAND_WARN annotation
Hard-reject and emit unwind intents when position exceeds hard limitcurrent_position=1100 pUSD, max_inventory_band=1000, incoming intent side=BUYHARD_REJECT(INVENTORY_UNWINDER_BAND_BREACH) and 1+ unwind OrderIntents emitted
Reshape to remaining room when intent would cause breachcurrent_position=850 pUSD, max_inventory_band=1000, incoming intent size=300 pUSDRESHAPE with constraints.max_size_usd=150 pUSD
Unwind uses NegRiskAdapter for negRisk marketmarket.negRisk=true, current_position=1200 pUSDUnwind OrderIntents include NegRiskAdapter path; on-chain balance checked before emission
Builder code preserved on unwind OrderIntentssource 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 endpointHARD_REJECT(STALE_MARKET_DATA) — no unwind emitted

Integration Tests

TestExpected result
Unwind flows through execution pipeline and reduces positionUnwind 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 thresholdDecisionReport 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 inventoryInventoryUnwinder generates unwind intents for all open positions when KillSwitch is activated, regardless of max_inventory_band setting

Property Tests

PropertyRequired behaviour
Unwind intents always reduce position size, never increase itAlways 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 codeAlways true — attribution must be preserved for post-trade reporting
Missing position data never results in APPROVE for direction-adding intentsAlways 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

AlertFirst stepDiagnosisMitigationEscalate 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

GateHow measuredThreshold
Unit tests pass including band breach, reshape, and NegRisk unwind pathCI test run100% pass
Integration test: unwind intent flows through SmartRouter and reduces mock positionIntegration test suitePass

Promote to Limited live

GateHow measuredThreshold
Shadow mode: unwind decisions match expected baseline within 5% over 48hGrafana shadow vs live comparison< 5% divergence
p99 evaluation latency < 200mspolytraders_risk_inventoryunwinder_eval_latency_ms histogramp99 < 200ms

Promote to General live

GateHow measuredThreshold
At least one successful NegRisk unwind via NegRiskAdapter in stagingStaging integration test with negRisk mock marketPass
Builder code attribution verified on unwind fills in post-trade reconciliationPost-trade report audit100% of unwind fills carry correct source bot builder code
Emergency unwind (KillSwitch) clears all positions in < 5 minutes in stagingKillSwitch failure injection testPass

29. Developer Checklist

Ready-to-ship score: 27/27 sections complete · 100%

RequirementStatus
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