{
  "schema_version": "1.0.0",
  "bot_id": "1.10",
  "bot_name": "CapitalAllocator",
  "slug": "capitalallocator",
  "layer": "Risk",
  "layer_key": "risk",
  "bot_class": "Guardrail",
  "authority": [
    "Veto",
    "Reshape"
  ],
  "status": "planned",
  "readiness": "Planned",
  "flagship": true,
  "is_reference": false,
  "public_export": false,
  "identity": {
    "layer": "Risk",
    "bot_class": "Guardrail",
    "authority": "Veto, Reshape",
    "runs_before": "ExecutionPlan emit",
    "runs_after": "Strategy OrderIntent",
    "applies_to": "Every OrderIntent \u2014 enforces per-strategy and portfolio-level capital allocation budgets",
    "default_mode": "planned",
    "user_visible": "summary-only",
    "developer_owner": "Polytraders core \u2014 Risk pod"
  },
  "purpose": "CapitalAllocator carves the user's total risk budget across active strategies and refuses new orders that would cause any strategy slice or the aggregate portfolio to exceed its configured allocation. It emits a RESHAPE constraint when a downsize is possible, and HARD_REJECT when the budget is exhausted.",
  "why_it_matters": [
    {
      "failure": "Over-allocation to a single strategy",
      "consequence": "Unconstrained capital flow into one strategy can consume the user's full budget, preventing diversification and concentrating tail risk."
    },
    {
      "failure": "Aggregate portfolio budget exceeded",
      "consequence": "Without a total-portfolio cap, concurrent strategies can collectively exceed the user's acceptable exposure, especially during correlated market events."
    }
  ],
  "polymarket_inputs": [
    {
      "input": "Open position notional per market from CLOB",
      "source": "clob_public",
      "required": true,
      "use": "Track current strategy exposure across active positions."
    },
    {
      "input": "Pending order value per strategy",
      "source": "clob_auth",
      "required": true,
      "use": "Include pending orders in the budget calculation to prevent race conditions."
    }
  ],
  "internal_inputs": [
    {
      "input": "Capital allocation config per strategy and portfolio total",
      "source": "internal",
      "required": true,
      "use": "Load per-strategy budget slice and aggregate portfolio cap."
    },
    {
      "input": "KillSwitch active flag",
      "source": "KillSwitch",
      "required": true,
      "use": "If active, reject all orders immediately."
    }
  ],
  "raw_params": [
    "per_strategy_max_usd \u00b7 int",
    "portfolio_total_max_usd \u00b7 int",
    "min_remaining_buffer_pct \u00b7 float"
  ],
  "parameters": [
    {
      "name": "per_strategy_max_usd",
      "default": 2000,
      "warning": 1600,
      "hard": 2000,
      "controls": "Maximum pUSD notional a single strategy may hold (open + pending).",
      "why_default_matters": "Caps each strategy at 2000 pUSD by default, forcing diversification across multiple strategies.",
      "threshold_logic": [
        {
          "condition": "strategy_exposure + intent.size_usd <= 2000",
          "action": "APPROVE"
        },
        {
          "condition": "80-100% of cap",
          "action": "RESHAPE to remaining budget"
        },
        {
          "condition": "> 100% of cap",
          "action": "HARD_REJECT \u2014 CAPITAL_ALLOCATOR_STRATEGY_BUDGET_EXCEEDED"
        }
      ],
      "dev_check": "if (strategyExposure + intent.size_usd > params.per_strategy_max_usd) return reshape or reject;",
      "user_facing": "Your order exceeds the budget allocated to this strategy."
    },
    {
      "name": "portfolio_total_max_usd",
      "default": 10000,
      "warning": 8000,
      "hard": 10000,
      "controls": "Maximum total pUSD notional across all active strategies combined.",
      "why_default_matters": "Ensures the sum of all strategy exposures stays within the user's declared total risk budget.",
      "threshold_logic": [
        {
          "condition": "total_exposure + intent.size_usd <= 10000",
          "action": "APPROVE"
        },
        {
          "condition": "> 10000",
          "action": "HARD_REJECT \u2014 CAPITAL_ALLOCATOR_PORTFOLIO_BUDGET_EXCEEDED"
        }
      ],
      "dev_check": "if (totalExposure + intent.size_usd > params.portfolio_total_max_usd) return reject('CAPITAL_ALLOCATOR_PORTFOLIO_BUDGET_EXCEEDED');",
      "user_facing": "Your total portfolio exposure has reached its limit."
    },
    {
      "name": "min_remaining_buffer_pct",
      "default": 0.05,
      "warning": 0.1,
      "hard": 0.05,
      "controls": "Minimum fraction of the portfolio budget that must remain unallocated as a buffer. Orders bringing the budget below this fraction are reshaped to maintain the buffer.",
      "why_default_matters": "Keeps 5% of budget free to handle mark-to-market fluctuations without triggering cascade rejects.",
      "threshold_logic": [
        {
          "condition": "remaining_pct >= 0.10",
          "action": "APPROVE"
        },
        {
          "condition": "0.05 <= remaining_pct < 0.10",
          "action": "WARN"
        },
        {
          "condition": "remaining_pct < 0.05",
          "action": "RESHAPE \u2014 cap order to preserve buffer"
        }
      ],
      "dev_check": "if (remainingAfterOrder / portfolioCap < params.min_remaining_buffer_pct) return reshape({ max_size_usd: remainingBudget - bufferAmount });",
      "user_facing": "Your order was reduced to maintain a safety buffer in your portfolio budget."
    }
  ],
  "default_config": {
    "bot_id": "risk.capital_allocator",
    "version": "0.1.0",
    "mode": "hard_guard",
    "defaults": {
      "per_strategy_max_usd": 2000,
      "portfolio_total_max_usd": 10000,
      "min_remaining_buffer_pct": 0.05
    },
    "locked": {
      "per_strategy_max_usd": {
        "min": 100
      },
      "portfolio_total_max_usd": {
        "min": 500
      }
    }
  },
  "implementation_flow": [
    "Receive OrderIntent with strategy_id, size_usd, and user context.",
    "Check KillSwitch; if active, HARD_REJECT(KILL_SWITCH_ACTIVE).",
    "Load capital allocation config (per_strategy_max_usd, portfolio_total_max_usd, buffer_pct) from internal store.",
    "Fetch current strategy exposure (open + pending) for strategy_id from CLOB and position ledger.",
    "Fetch total portfolio exposure across all strategies.",
    "If strategy_exposure + intent.size_usd > per_strategy_max_usd: compute safe_size = max(0, per_strategy_max_usd - strategy_exposure). If safe_size > 0, RESHAPE; else HARD_REJECT.",
    "If total_exposure + intent.size_usd > portfolio_total_max_usd * (1 - min_remaining_buffer_pct): RESHAPE or HARD_REJECT.",
    "All checks passed \u2014 APPROVE with budget metrics attached."
  ],
  "decision_logic": {
    "approve": "Strategy slice and portfolio total are both within budget after including the intent size.",
    "reshape_required": "Intent size would exceed the strategy or portfolio budget but a reduced size is possible within the remaining allocation.",
    "reject": "Budget is fully exhausted (no room even for a reduced order), or KillSwitch active."
  },
  "decision_output_schema": "RiskVote",
  "decision_output_example": {
    "guard_id": "risk.capital_allocator",
    "decision": "RESHAPE_REQUIRED",
    "severity": "WARN",
    "reason_code": "CAPITAL_ALLOCATOR_STRATEGY_BUDGET_EXCEEDED",
    "message": "Strategy exposure 1800 pUSD + intent 400 pUSD exceeds cap 2000 pUSD. Resized to 200 pUSD.",
    "constraints": {
      "max_size_usd": 200
    },
    "inputs_used": [
      "internal.capital_config",
      "clob_public.positions"
    ],
    "checked_at": "2026-05-10T09:00:00Z"
  },
  "developer_log": {
    "bot_id": "risk.capital_allocator",
    "decision": "RESHAPE_REQUIRED",
    "reason_code": "CAPITAL_ALLOCATOR_STRATEGY_BUDGET_EXCEEDED",
    "inputs_used": [
      "internal.capital_config",
      "clob_public.positions"
    ],
    "metrics": {
      "strategy_exposure_usd": 1800,
      "portfolio_exposure_usd": 5400,
      "intent_size_usd": 400,
      "per_strategy_cap": 2000,
      "portfolio_cap": 10000
    },
    "checked_at": "2026-05-10T09:00:00Z"
  },
  "user_explanations": [
    {
      "situation": "Order downsized \u2014 strategy budget",
      "message": "Your order was reduced because this strategy has reached its capital allocation limit. The order was resized to fit within the remaining budget."
    },
    {
      "situation": "Order blocked \u2014 portfolio budget exhausted",
      "message": "Your total portfolio exposure has reached the configured limit. Please close some positions before placing new orders."
    }
  ],
  "failure_modes": {
    "main_failure_mode": "Approving an order that exceeds the strategy or portfolio budget due to a stale position snapshot, causing over-allocation.",
    "false_positive_risk": "Rejecting an order because the position cache includes a recently cancelled pending order that has not yet been removed.",
    "false_negative_risk": "Approving concurrent orders from the same strategy if idempotency deduplication is bypassed, leading to budget double-counting.",
    "safe_fallback": "If capital allocation config or position data is unavailable, HARD_REJECT with CAPITAL_ALLOCATOR_DATA_UNAVAILABLE. Never approve on missing data.",
    "required_dependencies": [
      "Capital allocation config store",
      "CLOB position and pending order data",
      "KillSwitch active flag"
    ]
  },
  "acceptance_tests": {
    "unit": [
      {
        "test": "Approve when within strategy and portfolio budget",
        "setup": "strategy_exposure=500, portfolio_exposure=3000, intent.size_usd=300, caps=2000/10000",
        "expected": "APPROVE"
      },
      {
        "test": "Reshape when strategy budget partially exceeded",
        "setup": "strategy_exposure=1800, intent.size_usd=400, cap=2000",
        "expected": "RESHAPE_REQUIRED(max_size_usd=200)"
      },
      {
        "test": "Reject when strategy budget fully exhausted",
        "setup": "strategy_exposure=2000, intent.size_usd=100, cap=2000",
        "expected": "HARD_REJECT(CAPITAL_ALLOCATOR_STRATEGY_BUDGET_EXCEEDED)"
      },
      {
        "test": "Reject when portfolio cap exceeded",
        "setup": "total_exposure=9800, intent.size_usd=300, portfolio_cap=10000, buffer=0.05",
        "expected": "HARD_REJECT(CAPITAL_ALLOCATOR_PORTFOLIO_BUDGET_EXCEEDED)"
      }
    ],
    "integration": [
      {
        "test": "Reshape flows through to ExecutionPlan with reduced size",
        "expected": "ExecutionPlan receives constraints.max_size_usd and does not exceed it"
      },
      {
        "test": "KillSwitch bypasses all budget checks",
        "expected": "HARD_REJECT(KILL_SWITCH_ACTIVE) without reading position data"
      }
    ],
    "property": [
      {
        "property": "Approved order never causes strategy exposure to exceed per_strategy_max_usd",
        "required": "Always true"
      },
      {
        "property": "Reshape size is always strictly <= requested order size",
        "required": "Always true"
      }
    ]
  },
  "checklist_overrides": {},
  "legacy_goal": "Carve the user's risk budget across strategies; refuse new orders that exceed the slice.",
  "legacy_pm_signals": [
    "Per-strategy notional and risk-budget consumption",
    "Strategy declared volatility tier and historical drawdown",
    "Free vs. allocated capital by tag (maker / arb / event / advanced)",
    "Pending-fill reserve so partial fills don't double-count"
  ],
  "legacy_external_feeds": [],
  "reporting_groups": [
    "risk_compliance"
  ],
  "reason_codes": [
    {
      "code": "KILL_SWITCH_ACTIVE",
      "severity": "HARD_REJECT",
      "meaning": "Global kill switch active.",
      "action": "Immediate HARD_REJECT.",
      "user_message": "Trading is paused. Please try again later."
    },
    {
      "code": "CAPITAL_ALLOCATOR_STRATEGY_BUDGET_EXCEEDED",
      "severity": "RESHAPE",
      "meaning": "Strategy slice is at or above per_strategy_max_usd.",
      "action": "RESHAPE if room remains; else HARD_REJECT.",
      "user_message": "Your order exceeds the budget for this strategy."
    },
    {
      "code": "CAPITAL_ALLOCATOR_PORTFOLIO_BUDGET_EXCEEDED",
      "severity": "HARD_REJECT",
      "meaning": "Portfolio total is at or above the cap including buffer.",
      "action": "HARD_REJECT; no reshape possible at portfolio level.",
      "user_message": "Your total portfolio exposure has reached its limit."
    },
    {
      "code": "CAPITAL_ALLOCATOR_BUFFER_WARN",
      "severity": "WARN",
      "meaning": "Remaining portfolio buffer is below the warning threshold.",
      "action": "Attach WARN annotation; allow order if still above hard floor.",
      "user_message": ""
    },
    {
      "code": "CAPITAL_ALLOCATOR_DATA_UNAVAILABLE",
      "severity": "HARD_REJECT",
      "meaning": "Capital config or position data unavailable.",
      "action": "HARD_REJECT (fail-closed).",
      "user_message": "We could not verify your position data. Please try again."
    }
  ],
  "metrics": {
    "emitted": [
      {
        "name": "polytraders_risk_capitalallocator_decisions_total",
        "type": "counter",
        "unit": "count",
        "labels": [
          "decision",
          "reason_code"
        ],
        "meaning": "Total RiskVote decisions by type and reason."
      },
      {
        "name": "polytraders_risk_capitalallocator_strategy_exposure_usd",
        "type": "gauge",
        "unit": "usd",
        "labels": [
          "strategy_id"
        ],
        "meaning": "Current strategy exposure in pUSD at evaluation time."
      },
      {
        "name": "polytraders_risk_capitalallocator_portfolio_utilisation_pct",
        "type": "gauge",
        "unit": "ratio",
        "labels": [],
        "meaning": "Portfolio budget utilisation fraction (0\u20131) at last evaluation."
      },
      {
        "name": "polytraders_risk_capitalallocator_eval_latency_ms",
        "type": "histogram",
        "unit": "milliseconds",
        "labels": [],
        "meaning": "Latency from intent receipt to RiskVote emit."
      }
    ],
    "alerts": [
      {
        "name": "CapitalAllocatorPortfolioNearCap",
        "condition": "polytraders_risk_capitalallocator_portfolio_utilisation_pct > 0.9",
        "severity": "P2",
        "runbook": "#runbook-capitalallocator-near-cap"
      },
      {
        "name": "CapitalAllocatorDataUnavailable",
        "condition": "rate(polytraders_risk_capitalallocator_decisions_total{reason_code='CAPITAL_ALLOCATOR_DATA_UNAVAILABLE'}[5m]) > 0",
        "severity": "P1",
        "runbook": "#runbook-capitalallocator-data"
      }
    ]
  },
  "state": {
    "store": "redis",
    "shape": "Per-strategy exposure hash and portfolio total gauge, keyed by user_id.",
    "ttl": "30s with background refresh from CLOB position data.",
    "recovery": "On cold start, position snapshot loaded from CLOB before first evaluation. If unavailable, HARD_REJECT until restored.",
    "size_estimate": "~500 B per user/strategy pair"
  },
  "concurrency": {
    "execution_model": "single-threaded event loop",
    "max_in_flight": 200,
    "idempotency_key": "intent_id",
    "timeout_ms": 100,
    "backpressure": "drop newest",
    "locking": "per-strategy_id optimistic lock to prevent concurrent over-allocation"
  },
  "dependencies": {
    "depends_on": [
      {
        "bot_id": "risk.kill_switch",
        "why": "Global brake checked first.",
        "contract": "HARD_REJECT(KILL_SWITCH_ACTIVE) short-circuits all budget evaluation."
      }
    ],
    "emits_to": [
      {
        "bot_id": "exec.smart_router",
        "why": "APPROVE or RESHAPE passes to SmartRouter.",
        "contract": "RESHAPE constraints are respected by SmartRouter."
      }
    ],
    "sibling": [],
    "external": [
      {
        "service": "CLOB API (positions)",
        "endpoint": "https://clob.polymarket.com",
        "sla": "99.95% / 200ms p99",
        "failure_mode": "HARD_REJECT(CAPITAL_ALLOCATOR_DATA_UNAVAILABLE) if position data unavailable."
      }
    ]
  },
  "security_surfaces": {
    "signs_orders": false,
    "private_key_access": "none",
    "abuse_vectors": [
      "Submitting concurrent intents to bypass per-strategy idempotency and double-count budget",
      "Poisoning the position cache to show lower exposure than actual"
    ],
    "mitigations": [
      "Per-intent_id deduplication within 24h window prevents double-count",
      "Position cache is validated against CLOB on each refresh; stale data triggers fail-closed"
    ]
  },
  "polymarket_v2_compat": {
    "clob_version": "v2",
    "collateral": "pUSD",
    "eip712_domain_version": "2",
    "builder_code_aware": false,
    "negrisk_aware": false,
    "multichain_ready": false,
    "sdk_used": "py-clob-client-v2",
    "settlement_contract": "CTFExchangeV2",
    "notes": "All exposure values are denominated in pUSD. Uses V2 CLOB position endpoints; no order signing."
  },
  "version": {
    "spec": "2.0.0",
    "implementation": "0.1.0",
    "schema": "2",
    "released": null,
    "planned_release": "Q3-2026"
  },
  "migration_history": [
    {
      "date": "2026-04-28",
      "from": "n/a",
      "to": "v2-spec",
      "reason": "Spec drafted post-CLOB-V2 cutover; bot not yet implemented",
      "action_taken": "Designed against V2 schema (pUSD, builder codes, V2 EIP-712 domain)"
    }
  ],
  "reference_implementation": {
    "pseudocode": "FUNCTION evaluateCapital(intent):\n  ks = FETCH internal.killswitch.status\n  IF ks.active: EMIT RiskVote(HARD_REJECT, KILL_SWITCH_ACTIVE); RETURN\n\n  config = FETCH internal.capital_config(intent.user_id)\n  IF config IS NULL: EMIT RiskVote(HARD_REJECT, CAPITAL_ALLOCATOR_DATA_UNAVAILABLE); RETURN\n\n  positions = FETCH clob.get_positions(intent.user_id, intent.strategy_id)\n  IF positions IS NULL: EMIT RiskVote(HARD_REJECT, CAPITAL_ALLOCATOR_DATA_UNAVAILABLE); RETURN\n\n  strategyExposure = positions.open_usd + positions.pending_usd\n  totalExposure = positions.portfolio_total_usd\n\n  // per-strategy check\n  IF strategyExposure + intent.size_usd > config.per_strategy_max_usd:\n    safeSize = config.per_strategy_max_usd - strategyExposure\n    IF safeSize <= 0: EMIT RiskVote(HARD_REJECT, CAPITAL_ALLOCATOR_STRATEGY_BUDGET_EXCEEDED); RETURN\n    EMIT RiskVote(RESHAPE_REQUIRED, constraints={max_size_usd: safeSize}); RETURN\n\n  // portfolio buffer check\n  buffer = config.portfolio_total_max_usd * config.min_remaining_buffer_pct\n  IF totalExposure + intent.size_usd > config.portfolio_total_max_usd - buffer:\n    EMIT RiskVote(HARD_REJECT, CAPITAL_ALLOCATOR_PORTFOLIO_BUDGET_EXCEEDED); RETURN\n\n  EMIT RiskVote(APPROVE, checked_at=now_ms())",
    "sdk_calls": [
      "clob.get_positions(user_id, strategy_id)",
      "internal.capital_config(user_id)",
      "internal.killswitch.status()"
    ],
    "complexity": "O(1) per strategy lookup"
  },
  "wire_examples": {
    "input": [
      {
        "label": "OrderIntent \u2014 strategy budget overflow",
        "source": "internal",
        "payload": {
          "intent_id": "int_b2c3d4e5f6a70002",
          "strategy_id": "strat_001",
          "size_usd": 400,
          "generated_at_ms": 1746800000000
        }
      }
    ],
    "output": [
      {
        "label": "RiskVote \u2014 RESHAPE_REQUIRED",
        "payload": {
          "guard_id": "risk.capital_allocator",
          "decision": "RESHAPE_REQUIRED",
          "severity": "WARN",
          "reason_code": "CAPITAL_ALLOCATOR_STRATEGY_BUDGET_EXCEEDED",
          "message": "Strategy exposure 1800 pUSD + 400 pUSD exceeds cap 2000. Resized to 200 pUSD.",
          "constraints": {
            "max_size_usd": 200
          },
          "checked_at": "2026-05-10T09:00:00Z"
        }
      }
    ]
  },
  "failure_injection": [
    {
      "scenario": "POSITION_DATA_UNAVAILABLE",
      "how_to_inject": "Return 503 from CLOB positions endpoint",
      "expected_behaviour": "HARD_REJECT(CAPITAL_ALLOCATOR_DATA_UNAVAILABLE)",
      "recovery": "Returns to normal within one position-cache refresh cycle."
    },
    {
      "scenario": "STRATEGY_BUDGET_EXHAUSTED",
      "how_to_inject": "Set strategy exposure = per_strategy_max_usd in position mock, submit any intent",
      "expected_behaviour": "HARD_REJECT(CAPITAL_ALLOCATOR_STRATEGY_BUDGET_EXCEEDED)",
      "recovery": "Returns to APPROVE after strategy closes positions."
    },
    {
      "scenario": "PORTFOLIO_BUFFER_BREACH",
      "how_to_inject": "Set portfolio total > portfolio_max * (1 - buffer) in position mock",
      "expected_behaviour": "HARD_REJECT(CAPITAL_ALLOCATOR_PORTFOLIO_BUDGET_EXCEEDED)",
      "recovery": "Returns to APPROVE after portfolio exposure decreases."
    }
  ],
  "runbook": {
    "summary": "Incidents typically involve a position cache sync delay causing false budget exhaustion, or a genuine portfolio cap breach requiring position reduction.",
    "oncall_actions": [
      {
        "alert": "CapitalAllocatorPortfolioNearCap",
        "first_step": "Check current portfolio exposure in Grafana; confirm positions are accurate and no cache sync issue.",
        "escalation": "Risk pod lead if genuine cap breach confirmed.",
        "diagnosis": "",
        "mitigation": ""
      },
      {
        "alert": "CapitalAllocatorDataUnavailable",
        "first_step": "Check CLOB API status and Redis connectivity; manually refresh position cache if possible.",
        "escalation": "Risk pod lead if sustained > 2 minutes.",
        "diagnosis": "",
        "mitigation": ""
      }
    ],
    "manual_overrides": [
      {
        "command": "polytraders risk refresh-positions --user-id <id>",
        "effect": "After a known CLOB sync delay; forces immediate position cache refresh."
      }
    ],
    "healthcheck": "GET /internal/health/capitalallocator \u2192 green: Position cache age < 30s, CLOB reachable, no DATA_UNAVAILABLE rejections in last 5m; red: Position cache age > 60s or CLOB unreachable"
  },
  "promotion_gates": {
    "to_shadow": [
      {
        "gate": "Unit tests pass including reshape and reject budget scenarios",
        "how_measured": "CI test run",
        "threshold": "100% pass"
      }
    ],
    "to_limited_live": [
      {
        "gate": "Shadow reshape rate within 10% of expected baseline over 48h",
        "how_measured": "Grafana shadow vs live dashboard",
        "threshold": "< 10% divergence"
      }
    ],
    "to_general_live": [
      {
        "gate": "Zero DATA_UNAVAILABLE rejections during normal hours over 7 days",
        "how_measured": "CapitalAllocatorDataUnavailable alert history",
        "threshold": "0 firings"
      }
    ]
  },
  "reporting": {
    "emits_kinds": [
      "RiskVote"
    ],
    "topics": [
      "polytraders.reports.risk"
    ],
    "partition_by": "trace_id",
    "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"
  }
}