{
  "schema_version": "1.0.0",
  "bot_id": "1.6",
  "bot_name": "InventoryUnwinder",
  "slug": "inventoryunwinder",
  "layer": "Risk",
  "layer_key": "risk",
  "bot_class": "Guardrail",
  "authority": [
    "Reject",
    "Reshape"
  ],
  "status": "live",
  "readiness": "General live",
  "flagship": false,
  "is_reference": false,
  "public_export": false,
  "identity": {
    "layer": "Risk",
    "bot_class": "Guardrail",
    "authority": "Reject, Reshape",
    "runs_before": "ExecutionPlan emit",
    "runs_after": "Strategy OrderIntent \u2014 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 \u2014 Risk pod"
  },
  "purpose": "InventoryUnwinder detects when a position has breached its concentration or capital limit \u2014 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.",
  "why_it_matters": [
    {
      "failure": "Concentration limit breach not unwound",
      "consequence": "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."
    },
    {
      "failure": "Capital limit not enforced on position growth",
      "consequence": "Without an active unwind, capital can become trapped in a single position, reducing the portfolio's ability to respond to other opportunities or drawdowns."
    },
    {
      "failure": "NegRisk market unwind without NegRiskAdapter",
      "consequence": "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."
    },
    {
      "failure": "Strategy crash leaves open inventory",
      "consequence": "If a strategy halts mid-session with open inventory, the position will remain on the book indefinitely unless InventoryUnwinder detects and liquidates it."
    }
  ],
  "polymarket_inputs": [
    {
      "input": "Current open positions per market \u2014 size, side, cost basis",
      "source": "clob_auth",
      "required": true,
      "use": "Detect whether a position is within its inventory band or has breached concentration limits requiring unwind."
    },
    {
      "input": "Order book top-of-book (bid/ask) for target market",
      "source": "clob_public",
      "required": true,
      "use": "Determine unwind execution price and whether passive unwind is feasible at the current top-of-book."
    },
    {
      "input": "Market metadata \u2014 negRisk flag, enableNegRisk, condition ID",
      "source": "gamma",
      "required": true,
      "use": "Identify whether the market uses the NegRiskAdapter for unwind (negRisk markets must burn NO \u2192 pUSD via NegRiskAdapter)."
    },
    {
      "input": "On-chain position balance for NegRisk markets",
      "source": "onchain",
      "required": false,
      "use": "Verify the on-chain token balance before constructing NegRiskAdapter unwind transactions for negRisk multi-outcome markets."
    }
  ],
  "internal_inputs": [
    {
      "input": "Per-strategy inventory band configuration",
      "source": "internal",
      "required": true,
      "use": "Max allowed net position size per market per strategy. Unwind is triggered when position exceeds max_inventory_band."
    },
    {
      "input": "Source bot builder code (bytes32) for the breached position",
      "source": "internal",
      "required": true,
      "use": "Carry original strategy builder code on unwind OrderIntents so attribution flows back to the position's source bot."
    },
    {
      "input": "KillSwitch active flag",
      "source": "KillSwitch",
      "required": true,
      "use": "If KillSwitch is active, block all new intents and begin emergency unwind of all open inventory."
    },
    {
      "input": "PortfolioGuard per-market budget remaining",
      "source": "PortfolioGuard",
      "required": true,
      "use": "Confirm the unwind size does not itself breach portfolio limits (unwinds always reduce exposure, so they typically pass)."
    }
  ],
  "raw_params": [
    "max_inventory_band \u00b7 int",
    "unwind_aggression \u00b7 0\u2013100",
    "passive_only \u00b7 bool",
    "handback_threshold_pct \u00b7 0\u2013100"
  ],
  "parameters": [
    {
      "name": "max_inventory_band",
      "default": 1000,
      "warning": 800,
      "hard": 1000,
      "controls": "Maximum allowed net position size in pUSD for any single market. Unwind is triggered when current_position > hard.",
      "why_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": "position \u2264 800 pUSD",
          "action": "APPROVE (no action needed)"
        },
        {
          "condition": "800\u20131000 pUSD",
          "action": "WARN \u2014 attach INVENTORY_UNWINDER_BAND_WARN annotation, no block"
        },
        {
          "condition": "> 1000 pUSD",
          "action": "RESHAPE \u2014 generate unwind OrderIntents to bring position back to 800 pUSD"
        }
      ],
      "dev_check": "if (position > p.hard) generateUnwindIntents(market_id, position - p.warning); else if (position > p.warning) warn('INVENTORY_UNWINDER_BAND_WARN');",
      "user_facing": "Your position in this market was larger than the allowed inventory. We are reducing it to keep your exposure within safe limits."
    },
    {
      "name": "unwind_aggression",
      "default": 50,
      "warning": 75,
      "hard": 100,
      "controls": "How aggressively to unwind: 0 = passive limit orders only at mid; 50 = limit orders at best bid/ask; 100 = IOC at market (cross spread).",
      "why_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": "0\u201349",
          "action": "Passive limit order at or better than mid"
        },
        {
          "condition": "50\u201374",
          "action": "Limit order at best bid/ask"
        },
        {
          "condition": "75\u201399",
          "action": "Aggressive limit order crosses the spread"
        },
        {
          "condition": "100",
          "action": "IOC market order \u2014 immediate fill regardless of spread"
        }
      ],
      "dev_check": "const orderType = aggression == 100 ? 'IOC' : aggression >= 75 ? 'LIMIT_CROSS' : 'LIMIT_PASSIVE'; buildUnwindOrder(market_id, size, orderType);",
      "user_facing": "We are closing your position using market orders to reduce your risk quickly."
    },
    {
      "name": "passive_only",
      "default": true,
      "warning": null,
      "hard": false,
      "controls": "If true, all unwind orders are placed as passive limit orders only. Overrides unwind_aggression when set. Prevents crossing the spread during unwind.",
      "why_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": "passive_only = true",
          "action": "All unwind orders are POST_ONLY or passive limit; never IOC"
        },
        {
          "condition": "passive_only = false",
          "action": "unwind_aggression parameter controls order type"
        }
      ],
      "dev_check": "if (params.passive_only) intent.order_type = 'POST_ONLY';",
      "user_facing": ""
    },
    {
      "name": "handback_threshold_pct",
      "default": 80,
      "warning": 90,
      "hard": 95,
      "controls": "Once an unwind has reduced the position to this percentage of max_inventory_band, control is handed back to the originating strategy.",
      "why_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": "position \u2264 max_inventory_band \u00d7 (handback_threshold_pct / 100)",
          "action": "Emit DecisionReport UNWIND_COMPLETE; hand back to source strategy"
        },
        {
          "condition": "position > handback threshold",
          "action": "Continue generating unwind OrderIntents"
        }
      ],
      "dev_check": "if (position <= max_inventory_band * p.handback_threshold_pct / 100) emitDecisionReport('UNWIND_COMPLETE', source_bot_id);",
      "user_facing": "Your position has been reduced to a safe level."
    }
  ],
  "default_config": {
    "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
      }
    }
  },
  "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 \u2192 pUSD via NegRiskAdapter on Polygon).",
    "Compute unwind_size = current_position - (max_inventory_band \u00d7 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."
  ],
  "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 \u2014 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 \u2014 attach INVENTORY_UNWINDER_BAND_WARN to the RiskVote without blocking the intent."
  },
  "decision_output_schema": "RiskVote",
  "decision_output_example": {
    "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"
  },
  "developer_log": {
    "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"
  },
  "user_explanations": [
    {
      "situation": "Order blocked \u2014 position at inventory limit",
      "message": "Your position in this market has reached the maximum allowed size. We are reducing it before accepting new orders in the same direction."
    },
    {
      "situation": "Position being reduced automatically",
      "message": "Your position exceeded the inventory limit, so we are automatically placing orders to bring it back within the allowed range."
    },
    {
      "situation": "Order reduced \u2014 near inventory limit",
      "message": "Your order was reduced because placing the full size would push your position above the inventory limit for this market."
    },
    {
      "situation": "Unwind complete \u2014 control returned to strategy",
      "message": "Your position has been reduced to a safe level and normal strategy operation has resumed."
    }
  ],
  "failure_modes": {
    "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)"
    ]
  },
  "acceptance_tests": {
    "unit": [
      {
        "test": "Approve when position is below warning threshold",
        "setup": "current_position=700 pUSD, max_inventory_band=1000, warning=800",
        "expected": "APPROVE with no constraints"
      },
      {
        "test": "Warn when position is between warning and hard threshold",
        "setup": "current_position=900 pUSD, max_inventory_band=1000, warning=800",
        "expected": "APPROVE with INVENTORY_UNWINDER_BAND_WARN annotation"
      },
      {
        "test": "Hard-reject and emit unwind intents when position exceeds hard limit",
        "setup": "current_position=1100 pUSD, max_inventory_band=1000, incoming intent side=BUY",
        "expected": "HARD_REJECT(INVENTORY_UNWINDER_BAND_BREACH) and 1+ unwind OrderIntents emitted"
      },
      {
        "test": "Reshape to remaining room when intent would cause breach",
        "setup": "current_position=850 pUSD, max_inventory_band=1000, incoming intent size=300 pUSD",
        "expected": "RESHAPE with constraints.max_size_usd=150 pUSD"
      },
      {
        "test": "Unwind uses NegRiskAdapter for negRisk market",
        "setup": "market.negRisk=true, current_position=1200 pUSD",
        "expected": "Unwind OrderIntents include NegRiskAdapter path; on-chain balance checked before emission"
      },
      {
        "test": "Builder code preserved on unwind OrderIntents",
        "setup": "source bot builder_code=0x706f6c7974726164657273...",
        "expected": "Emitted unwind OrderIntents carry the same builder_code as the source bot"
      },
      {
        "test": "Reject when position data unavailable (fail-closed)",
        "setup": "clob_auth returns 503 for position endpoint",
        "expected": "HARD_REJECT(STALE_MARKET_DATA) \u2014 no unwind emitted"
      }
    ],
    "integration": [
      {
        "test": "Unwind flows through execution pipeline and reduces position",
        "expected": "Unwind OrderIntents generated by InventoryUnwinder are accepted by SmartRouter and result in fill that reduces position below handback threshold"
      },
      {
        "test": "DecisionReport UNWIND_COMPLETE emitted when position drops below handback threshold",
        "expected": "DecisionReport with reason UNWIND_COMPLETE emitted to reporting bus within one evaluation cycle after position drops to 80% of max_inventory_band"
      },
      {
        "test": "KillSwitch triggers emergency unwind of all open inventory",
        "expected": "InventoryUnwinder generates unwind intents for all open positions when KillSwitch is activated, regardless of max_inventory_band setting"
      }
    ],
    "property": [
      {
        "property": "Unwind intents always reduce position size, never increase it",
        "required": "Always true \u2014 unwind OrderIntents are always on the opposing side to the current position"
      },
      {
        "property": "Builder code on unwind intents always matches the source bot's builder code",
        "required": "Always true \u2014 attribution must be preserved for post-trade reporting"
      },
      {
        "property": "Missing position data never results in APPROVE for direction-adding intents",
        "required": "Always true \u2014 STALE_MARKET_DATA produces HARD_REJECT"
      }
    ]
  },
  "checklist_overrides": {},
  "legacy_goal": "Bleed off accidental directional exposure when a market-maker drifts.",
  "legacy_pm_signals": [
    "Net position vs. strategy's declared inventory band",
    "Mark-to-market PnL on a maker bot with one-sided inventory",
    "Strategy-halt or crash signal with open inventory"
  ],
  "legacy_external_feeds": [],
  "reporting_groups": [
    "risk_compliance",
    "post_trade"
  ],
  "network": [
    "polygon"
  ],
  "api_surface": [
    "clob_public",
    "clob_auth",
    "onchain",
    "internal"
  ],
  "version": {
    "spec": "2.0.0",
    "implementation": "2.1.0",
    "schema": "2",
    "released": "2026-04-28"
  },
  "migration_history": [
    {
      "date": "2026-04-28",
      "from": "v1",
      "to": "v2",
      "reason": "CLOB V2 cutover",
      "action_taken": "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 \u2192 pUSD) instead of direct CTFExchangeV1 path."
    }
  ],
  "polymarket_v2_compat": {
    "clob_version": "v2",
    "collateral": "pUSD",
    "eip712_domain_version": "2",
    "builder_code_aware": true,
    "negrisk_aware": true,
    "multichain_ready": false,
    "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)."
  },
  "reference_implementation": {
    "summary": "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.",
    "language_note": "Pseudocode is language-agnostic. FETCH = read input. EMIT = produce output. Translate to TS/Python/Go/Rust.",
    "pseudocode": "FUNCTION evaluateInventory(intent):\n  // --- 0. KillSwitch gate ---\n  ks = FETCH internal.killswitch.status\n  IF ks.active:\n    EMIT RiskVote(decision=HARD_REJECT, reason=KILL_SWITCH_ACTIVE)\n    startEmergencyUnwindAll()\n    RETURN\n\n  // --- 1. Fetch current position ---\n  position = FETCH clob_auth.GET('/positions?market=' + intent.market_id)\n  IF position IS NULL:\n    EMIT RiskVote(decision=HARD_REJECT, reason=STALE_MARKET_DATA)\n    RETURN\n  currentSize = position.net_size_pusd\n\n  // --- 2. Fetch market metadata ---\n  market = FETCH gamma.getMarketByConditionId(intent.market_id)\n  IF market IS NULL:\n    EMIT RiskVote(decision=HARD_REJECT, reason=STALE_MARKET_DATA)\n    RETURN\n\n  // --- 3. Check if existing position already breaches band ---\n  IF currentSize >= params.max_inventory_band.hard:\n    // Reject intent that would worsen the breach\n    IF intent.side == position.side:\n      EMIT RiskVote(decision=HARD_REJECT, reason=INVENTORY_UNWINDER_BAND_BREACH)\n    // Generate unwind intents\n    unwindSize = currentSize - (params.max_inventory_band.hard * params.handback_threshold_pct / 100)\n    builderCode = FETCH internal.strategy_registry.builder_code(position.source_bot_id)\n    IF market.negRisk:\n      unwindIntents = buildNegRiskUnwindIntents(market, unwindSize, builderCode)\n    ELSE:\n      unwindIntents = buildStandardUnwindIntents(market, unwindSize, builderCode)\n    FOR ui IN unwindIntents:\n      EMIT OrderIntent(ui)  // back into execution pipeline\n    RETURN\n\n  // --- 4. Check if incoming intent would cause breach ---\n  projectedSize = currentSize + (intent.size_usd IF intent.side == position.side ELSE 0)\n  IF projectedSize > params.max_inventory_band.hard:\n    allowedSize = params.max_inventory_band.hard - currentSize\n    IF allowedSize <= 0:\n      EMIT RiskVote(decision=HARD_REJECT, reason=INVENTORY_UNWINDER_BAND_BREACH)\n    ELSE:\n      EMIT RiskVote(decision=RESHAPE_REQUIRED,\n                    reason=INVENTORY_UNWINDER_BAND_BREACH,\n                    constraints={max_size_usd: allowedSize})\n    RETURN\n\n  // --- 5. Warn if approaching band ---\n  IF projectedSize > params.max_inventory_band.warning:\n    EMIT RiskVote(decision=APPROVE,\n                  annotations=[{code: INVENTORY_UNWINDER_BAND_WARN}])\n    RETURN\n\n  // --- 6. Approve ---\n  EMIT RiskVote(decision=APPROVE, checked_at=now_iso())\n\nFUNCTION buildNegRiskUnwindIntents(market, size, builderCode):\n  // Use NegRiskAdapter: burn NO tokens across outcome set \u2192 pUSD\n  onchainBalance = FETCH onchain.tokenBalance(market.condition_id, 'NO')\n  burnAmount = min(size, toPusdUnits(onchainBalance))\n  // If sum(YES prices) < $1, convert-arb: also re-mint YES\n  yesSum = SUM(FETCH clob_public.price(o) FOR o IN market.outcomes)\n  IF yesSum < 1.0:\n    RETURN [NegRiskAdapterBurnOrder(burnAmount, builderCode),\n            NegRiskAdapterMintYesOrder(burnAmount, builderCode)]\n  RETURN [NegRiskAdapterBurnOrder(burnAmount, builderCode)]\n",
    "sdk_calls": [
      "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"
  },
  "wire_examples": {
    "input": [
      {
        "label": "OrderIntent \u2014 BUY into already-breached position",
        "source": "internal",
        "payload": {
          "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
        }
      },
      {
        "label": "Current position snapshot (clob_auth)",
        "source": "clob_auth",
        "payload": {
          "market_id": "0x5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d",
          "side": "BUY",
          "net_size_pusd": 1250,
          "cost_basis_pusd": 887,
          "source_bot_id": "strat.mm_v2",
          "fetched_at_ms": 1746784195000
        }
      }
    ],
    "output": [
      {
        "label": "RiskVote \u2014 HARD_REJECT (band already breached, intent would worsen)",
        "payload": {
          "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"
        }
      },
      {
        "label": "DecisionReport \u2014 UNWIND_COMPLETE",
        "payload": {
          "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"
        }
      }
    ]
  },
  "reason_codes": [
    {
      "code": "KILL_SWITCH_ACTIVE",
      "severity": "HARD_REJECT",
      "meaning": "Global kill switch is active; all incoming intents are rejected and emergency unwind of all open inventory begins.",
      "action": "Immediately return HARD_REJECT and trigger startEmergencyUnwindAll().",
      "user_message": "Trading is currently paused. Your positions are being safely reduced."
    },
    {
      "code": "STALE_MARKET_DATA",
      "severity": "HARD_REJECT",
      "meaning": "Position data from clob_auth or market metadata from Gamma is unavailable or stale.",
      "action": "Return HARD_REJECT; retry on next fresh fetch.",
      "user_message": "Position data could not be verified. The order was blocked until current information is available."
    },
    {
      "code": "INVENTORY_UNWINDER_BAND_BREACH",
      "severity": "HARD_REJECT",
      "meaning": "Position is at or above max_inventory_band and the incoming intent would increase it further.",
      "action": "Return HARD_REJECT; emit unwind OrderIntents to reduce position back to handback threshold.",
      "user_message": "Your position in this market was larger than the allowed limit. We are reducing it before accepting new orders in the same direction."
    },
    {
      "code": "INVENTORY_UNWINDER_RESHAPE",
      "severity": "RESHAPE",
      "meaning": "Incoming intent would push the position above max_inventory_band but the position is currently below the hard ceiling.",
      "action": "Return RESHAPE_REQUIRED with constraints.max_size_usd = max_inventory_band - current_position.",
      "user_message": "Your order was reduced to keep your position within the allowed inventory limit."
    },
    {
      "code": "INVENTORY_UNWINDER_BAND_WARN",
      "severity": "WARN",
      "meaning": "Position (after this intent) would be between the warning and hard thresholds.",
      "action": "Attach annotation to APPROVE; do not block. Log for monitoring.",
      "user_message": ""
    },
    {
      "code": "INVENTORY_UNWINDER_NEGRISK_UNWIND",
      "severity": "INFO",
      "meaning": "Unwind is proceeding via the NegRiskAdapter path (burn NO tokens \u2192 pUSD).",
      "action": "Log the on-chain burn transaction reference and pUSD recovered.",
      "user_message": ""
    },
    {
      "code": "INVENTORY_UNWINDER_UNWIND_COMPLETE",
      "severity": "INFO",
      "meaning": "Position has been reduced to or below the handback threshold; control returned to the originating strategy.",
      "action": "Emit DecisionReport(UNWIND_COMPLETE) and resume accepting new intents from the source bot.",
      "user_message": "Your position has been reduced to a safe level."
    }
  ],
  "metrics": {
    "emitted": [
      {
        "name": "polytraders_risk_inventoryunwinder_decisions_total",
        "type": "counter",
        "unit": "count",
        "labels": [
          "decision",
          "reason_code",
          "market_id"
        ],
        "meaning": "Total RiskVote decisions emitted, broken down by decision type and reason code."
      },
      {
        "name": "polytraders_risk_inventoryunwinder_position_pusd",
        "type": "gauge",
        "unit": "pusd",
        "labels": [
          "market_id",
          "source_bot_id"
        ],
        "meaning": "Current net position size in pUSD per market and source bot. Alerts when approaching max_inventory_band."
      },
      {
        "name": "polytraders_risk_inventoryunwinder_unwind_intents_emitted_total",
        "type": "counter",
        "unit": "count",
        "labels": [
          "market_id",
          "unwind_type"
        ],
        "meaning": "Number of unwind OrderIntents emitted, by market and unwind type (standard vs. negrisk)."
      },
      {
        "name": "polytraders_risk_inventoryunwinder_unwind_duration_seconds",
        "type": "histogram",
        "unit": "seconds",
        "labels": [
          "market_id"
        ],
        "meaning": "Time from first unwind intent emission to UNWIND_COMPLETE DecisionReport."
      },
      {
        "name": "polytraders_risk_inventoryunwinder_eval_latency_ms",
        "type": "histogram",
        "unit": "milliseconds",
        "labels": [],
        "meaning": "Wall-clock latency from OrderIntent receipt to RiskVote emit."
      },
      {
        "name": "polytraders_risk_inventoryunwinder_negrisk_burns_total",
        "type": "counter",
        "unit": "count",
        "labels": [
          "market_id"
        ],
        "meaning": "Number of NegRiskAdapter burn-and-redeem operations initiated for negRisk market unwinds."
      }
    ],
    "alerts": [
      {
        "name": "InventoryUnwinderBandBreach",
        "condition": "polytraders_risk_inventoryunwinder_position_pusd > max_inventory_band * 1.1",
        "severity": "page",
        "runbook": "#runbook-inventoryunwinder-breach"
      },
      {
        "name": "InventoryUnwinderUnwindStuck",
        "condition": "histogram_quantile(0.99, rate(polytraders_risk_inventoryunwinder_unwind_duration_seconds_bucket[10m])) > 300",
        "severity": "page",
        "runbook": "#runbook-inventoryunwinder-stuck"
      },
      {
        "name": "InventoryUnwinderStaleLedger",
        "condition": "rate(polytraders_risk_inventoryunwinder_decisions_total{reason_code='STALE_MARKET_DATA'}[5m]) > 0",
        "severity": "warn",
        "runbook": "#runbook-inventoryunwinder-stale"
      },
      {
        "name": "InventoryUnwinderHighLatency",
        "condition": "histogram_quantile(0.99, rate(polytraders_risk_inventoryunwinder_eval_latency_ms_bucket[5m])) > 200",
        "severity": "warn",
        "runbook": "#runbook-inventoryunwinder-latency"
      }
    ],
    "dashboards": [
      "Grafana \u2014 Risk overview / InventoryUnwinder",
      "Grafana \u2014 Position management / inventory band utilisation and unwind history"
    ],
    "log_level": "info"
  },
  "state": {
    "store": "redis",
    "shape": "Per-market position snapshot keyed by market_id + source_bot_id: {net_size_pusd, side, last_updated_ms, active_unwind: bool}. Refreshed on every fill event and periodic poll.",
    "ttl": "Position cache: 120s (hard); refreshed on fill events",
    "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.",
    "size_estimate": "~500 B per active market-bot pair; typically < 50 active entries"
  },
  "concurrency": {
    "execution_model": "single-threaded event loop",
    "max_in_flight": 100,
    "idempotency_key": "intent_id",
    "timeout_ms": 200,
    "backpressure": "drop newest",
    "locking": "per-market_id mutex to prevent concurrent unwind and new-order evaluation for the same market"
  },
  "dependencies": {
    "depends_on": [
      {
        "bot_id": "risk.kill_switch",
        "why": "Global brake \u2014 checked first; KillSwitch triggers emergency unwind of all inventory.",
        "contract": "RiskVote.HARD_REJECT(KILL_SWITCH_ACTIVE) and startEmergencyUnwindAll()."
      },
      {
        "bot_id": "risk.portfolio_guard",
        "why": "PortfolioGuard per-market budget is consulted to confirm unwind intents don't inadvertently breach other limits (unwinds are closing, so they typically pass).",
        "contract": "Unwind intents always reduce exposure and therefore always pass PortfolioGuard."
      }
    ],
    "emits_to": [
      {
        "bot_id": "exec.smart_router",
        "why": "Unwind OrderIntents generated by InventoryUnwinder are routed into SmartRouter for execution.",
        "contract": "Unwind intents carry the source bot's builder code and have close_only=true constraints."
      },
      {
        "bot_id": "gov.reporting",
        "why": "DecisionReport(UNWIND_COMPLETE) emitted to reporting bus when unwind finishes.",
        "contract": "DecisionReport includes market_id, position_after_pusd, source_bot_id, and completed_at."
      }
    ],
    "sibling": [
      {
        "bot_id": "risk.portfolio_guard",
        "why": "Sibling guardrail; both must pass before SmartRouter runs."
      },
      {
        "bot_id": "risk.liquidity_guard",
        "why": "Sibling guardrail; liquidity check applies to unwind orders too."
      }
    ],
    "external": [
      {
        "service": "CLOB Auth API (positions)",
        "endpoint": "https://clob.polymarket.com",
        "sla": "99.95% / 200ms p99",
        "fallback": "HARD_REJECT(STALE_MARKET_DATA) until position data is refreshed."
      },
      {
        "service": "Gamma API (market metadata)",
        "endpoint": "https://gamma-api.polymarket.com",
        "sla": "99.9% / 300ms p99",
        "fallback": "HARD_REJECT(STALE_MARKET_DATA) if negRisk flag cannot be determined."
      },
      {
        "service": "Polygon on-chain (NegRiskAdapter)",
        "endpoint": "NegRiskAdapter contract on Polygon",
        "sla": "Polygon network uptime (~99.9%)",
        "fallback": "Log failure; retry unwind on next cycle. If on-chain is down, standard unwind path used for non-negRisk positions."
      }
    ]
  },
  "security_surfaces": {
    "signs_orders": true,
    "private_key_access": "signing-only",
    "abuse_vectors": [
      "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 \u2192 HARD_REJECT, no bypass possible"
    ],
    "contract_calls": [
      {
        "contract": "NegRiskAdapter",
        "function": "burnNO",
        "purpose": "Burn NO tokens across outcome set to recover pUSD during negRisk market unwind."
      },
      {
        "contract": "CTFExchangeV2",
        "function": "matchOrders",
        "purpose": "Settle unwind limit orders on standard (non-negRisk) markets via the CLOB."
      }
    ]
  },
  "failure_injection": [
    {
      "scenario": "POSITION_EXCEEDS_BAND",
      "how_to_inject": "Set mock clob_auth position.net_size_pusd = 1300 for a market with max_inventory_band=1000",
      "expected_behaviour": "HARD_REJECT(INVENTORY_UNWINDER_BAND_BREACH) for all same-direction intents; unwind OrderIntents emitted targeting 200 pUSD reduction",
      "recovery": "Returns to APPROVE for new intents once position drops below handback_threshold_pct of max_inventory_band."
    },
    {
      "scenario": "STALE_POSITION_DATA",
      "how_to_inject": "Block clob_auth position endpoint for 130s (exceed cache TTL of 120s)",
      "expected_behaviour": "HARD_REJECT(STALE_MARKET_DATA) on all evaluations once cache expires",
      "recovery": "Returns to normal within one evaluation cycle after clob_auth is reachable."
    },
    {
      "scenario": "NEGRISK_UNWIND",
      "how_to_inject": "Set market.negRisk=true and position.net_size_pusd=1200 in mock",
      "expected_behaviour": "Unwind intents use NegRiskAdapter burn path; on-chain balance checked before emission",
      "recovery": "UNWIND_COMPLETE DecisionReport emitted after NegRiskAdapter burn transactions settle."
    },
    {
      "scenario": "KILL_SWITCH_EMERGENCY_UNWIND",
      "how_to_inject": "Set internal.killswitch.status.active=true with two open positions in mock",
      "expected_behaviour": "HARD_REJECT on all incoming intents; unwind OrderIntents emitted for all open positions regardless of band",
      "recovery": "Returns to normal pipeline on manual KillSwitch reset after positions are fully closed."
    },
    {
      "scenario": "UNWIND_STUCK_NO_FILLS",
      "how_to_inject": "Block all CLOB match events for the target market (simulate dead book)",
      "expected_behaviour": "InventoryUnwinderUnwindStuck alert fires after p99 unwind duration exceeds 300s; bot increases aggression if passive_only=false",
      "recovery": "Unwind resumes when CLOB book has resting liquidity. If passive_only=true, on-call must manually set aggression or pause the strategy."
    }
  ],
  "runbook": {
    "summary": "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.",
    "oncall_actions": [
      {
        "alert": "InventoryUnwinderBandBreach",
        "first_action": "Confirm position size on Grafana. Check which source bot is responsible. Confirm unwind OrderIntents have been emitted and are resting on the book.",
        "escalate_to": "Risk pod lead if position exceeds 1.5x max_inventory_band or if unwind intents are not being generated."
      },
      {
        "alert": "InventoryUnwinderUnwindStuck",
        "first_action": "Check CLOB book liquidity for the affected market. If book is dead, consider increasing unwind_aggression or triggering a manual close via the override command.",
        "escalate_to": "Risk pod lead if unwind has been stuck > 10 minutes."
      },
      {
        "alert": "InventoryUnwinderStaleLedger",
        "first_action": "Check clob_auth API connectivity. Confirm position endpoint is returning 200.",
        "escalate_to": "Infra on-call if clob_auth is down > 5 minutes."
      },
      {
        "alert": "InventoryUnwinderHighLatency",
        "first_action": "Check whether position cache hit rate has dropped. Confirm Redis and clob_auth are healthy.",
        "escalate_to": "Infra on-call if latency > 500ms sustained."
      }
    ],
    "manual_overrides": [
      {
        "name": "force_unwind",
        "how": "polytraders bot force-unwind risk.inventory_unwinder --market <market_id> --size <pusd>",
        "when": "When an unwind is not firing automatically due to a bug or data issue. Requires Risk pod lead approval."
      },
      {
        "name": "increase_aggression",
        "how": "polytraders bot set-param risk.inventory_unwinder unwind_aggression 100 --market <market_id>",
        "when": "When an unwind is stuck due to no passive fills and the position must be closed urgently."
      }
    ],
    "healthcheck": "GET /internal/health/inventoryunwinder \u2192 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."
  },
  "promotion_gates": {
    "to_shadow": [
      {
        "gate": "Unit tests pass including band breach, reshape, and NegRisk unwind path",
        "how_measured": "CI test run",
        "threshold": "100% pass"
      },
      {
        "gate": "Integration test: unwind intent flows through SmartRouter and reduces mock position",
        "how_measured": "Integration test suite",
        "threshold": "Pass"
      }
    ],
    "to_limited_live": [
      {
        "gate": "Shadow mode: unwind decisions match expected baseline within 5% over 48h",
        "how_measured": "Grafana shadow vs live comparison",
        "threshold": "< 5% divergence"
      },
      {
        "gate": "p99 evaluation latency < 200ms",
        "how_measured": "polytraders_risk_inventoryunwinder_eval_latency_ms histogram",
        "threshold": "p99 < 200ms"
      }
    ],
    "to_general_live": [
      {
        "gate": "At least one successful NegRisk unwind via NegRiskAdapter in staging",
        "how_measured": "Staging integration test with negRisk mock market",
        "threshold": "Pass"
      },
      {
        "gate": "Builder code attribution verified on unwind fills in post-trade reconciliation",
        "how_measured": "Post-trade report audit",
        "threshold": "100% of unwind fills carry correct source bot builder code"
      },
      {
        "gate": "Emergency unwind (KillSwitch) clears all positions in < 5 minutes in staging",
        "how_measured": "KillSwitch failure injection test",
        "threshold": "Pass"
      }
    ]
  },
  "reporting": {
    "emits_kinds": [
      "RiskVote",
      "DecisionReport"
    ],
    "topics": [
      "polytraders.reports.risk",
      "polytraders.reports.decisions"
    ],
    "cadence": "every-event",
    "retention_class": "2y",
    "sampling_rule": "emit-every",
    "bus_failure_action": "fail-closed",
    "user_visible": "summary-only",
    "consumes_kinds": [
      "ObservationReport"
    ]
  },
  "capital_impact": "Direct",
  "mode_support": [
    "quarantine"
  ],
  "v3_status": {
    "phase": 4,
    "phase_name": "Core risk",
    "docs": {
      "done": 27,
      "total": 27,
      "state": "done"
    },
    "impl": {
      "done": 0,
      "total": 15,
      "state": "pending"
    },
    "runtime": {
      "done": 0,
      "total": 8,
      "state": "pending"
    },
    "overall": "pending"
  }
}