{
  "schema_version": "1.0.0",
  "bot_id": "1.1",
  "bot_name": "PortfolioGuard",
  "slug": "portfolioguard",
  "layer": "Risk",
  "layer_key": "risk",
  "bot_class": "Guardrail",
  "authority": [
    "Reject",
    "Reshape"
  ],
  "status": "live",
  "readiness": "General live",
  "flagship": false,
  "is_reference": true,
  "public_export": false,
  "identity": {
    "layer": "Risk",
    "bot_class": "Guardrail",
    "authority": "Reject, Reshape",
    "runs_before": "ExecutionPlan emit",
    "runs_after": "Strategy OrderIntent",
    "applies_to": "Every OrderIntent \u2014 checks account-level, market-level, and cluster-level limits",
    "default_mode": "general_live",
    "user_visible": "Advanced details only",
    "developer_owner": "Polytraders core \u2014 Risk pod"
  },
  "purpose": "PortfolioGuard enforces account-wide exposure limits across every running strategy simultaneously. It tracks aggregate notional, rolling 24-hour drawdown, per-market concentration, and correlated-cluster concentration. When an incoming order would breach any of these limits, it is either downsized to the safe remaining budget or rejected outright. PortfolioGuard does not change the strategy intent, the market, or the direction \u2014 it only sets the maximum size an order is allowed to carry.",
  "why_it_matters": [
    {
      "failure": "Aggregate notional exceeds account limit",
      "consequence": "Multiple strategies running simultaneously could push total exposure beyond what the account balance can safely support, creating risk of insolvency if several positions move adversely at the same time.",
      "worked_example": {
        "setup": "Account aggregate notional is 38,000 pUSD across 6 markets. Cap is 50,000 pUSD. Strategy proposes a new 14,000 pUSD entry on market 0x05a.",
        "without_bot": "Each strategy enforces only its own per-strategy budget. The 14,000 entry takes total notional to 52,000, breaching the account cap by 4%. The breach is only caught at the daily reconciliation run.",
        "with_bot": "PortfolioGuard sums across all strategies, computes 38,000 + 14,000 = 52,000, and votes RESHAPE_REQUIRED with `cap_remaining_pUSD=12,000`. The strategy resubmits at 12,000 and the order is approved."
      }
    },
    {
      "failure": "Single-market concentration unchecked",
      "consequence": "Concentrating too much capital in one market means a single bad resolution or liquidity event can cause a disproportionate drawdown relative to the account size."
    },
    {
      "failure": "24-hour drawdown not tracked cross-strategy",
      "consequence": "Each strategy sees only its own losses. Without a cross-strategy drawdown check, the total intraday loss can exceed a level the account is designed to tolerate."
    },
    {
      "failure": "Correlated cluster exposure ignored",
      "consequence": "On neg-risk or thematically linked markets, multiple strategies may each hold positions that all resolve together. Cluster concentration can make seemingly diversified positions highly correlated."
    }
  ],
  "polymarket_inputs": [
    {
      "input": "Account balance and available USDC",
      "source": "on-chain",
      "required": true,
      "use": "Establish the total capital base against which percentage-of-account limits are applied."
    },
    {
      "input": "Current open positions per market with notional sizes",
      "source": "Data API",
      "required": true,
      "use": "Compute aggregate notional across all open positions and per-market exposure."
    },
    {
      "input": "Realised and unrealised P&L over rolling 24 hours",
      "source": "Data API",
      "required": true,
      "use": "Calculate rolling drawdown to enforce the max_24h_drawdown_pct circuit breaker."
    }
  ],
  "internal_inputs": [
    {
      "input": "Per-strategy order intent queue and current allocations",
      "source": "StrategyRegistry",
      "required": true,
      "use": "Aggregate pending and active allocations from all strategies before approving new additions."
    },
    {
      "input": "KillSwitch active flag",
      "source": "KillSwitch",
      "required": true,
      "use": "Immediately reject all orders if the KillSwitch has been triggered."
    },
    {
      "input": "Cluster mapping for correlated markets",
      "source": "Admin UI",
      "required": false,
      "use": "Group neg-risk and thematically linked markets into clusters for aggregate concentration checks."
    }
  ],
  "raw_params": [
    "max_account_notional_pct \u00b7 0\u2013100",
    "max_24h_drawdown_pct \u00b7 0\u2013100",
    "max_per_market_pct \u00b7 0\u2013100",
    "max_cluster_pct \u00b7 0\u2013100"
  ],
  "parameters": [
    {
      "name": "max_account_notional_pct",
      "default": 80,
      "warning": 70,
      "hard": 80,
      "controls": "Maximum total notional exposure across all open positions as a percentage of current account balance.",
      "why_default_matters": "Keeping total notional at or below 80% of account balance preserves a 20% reserve to absorb unrealised losses without requiring forced position reductions.",
      "threshold_logic": [
        {
          "condition": "Aggregate notional \u2264 70% of balance",
          "action": "APPROVE"
        },
        {
          "condition": "70\u201380% of balance",
          "action": "RESHAPE_REQUIRED \u2014 cap this order to the remaining room"
        },
        {
          "condition": "> 80% of balance",
          "action": "REJECT \u2014 STRATEGY_BUDGET_EXCEEDED"
        }
      ],
      "dev_check": "const budget = balance * p.hard - currentNotional; if (budget <= 0) return reject('STRATEGY_BUDGET_EXCEEDED'); if (order.size_usd > budget) return reshape({ max_size_usd: budget });",
      "user_facing": "Your account is already near its maximum total exposure. We reduced or blocked this order to keep your overall risk within safe limits."
    },
    {
      "name": "max_24h_drawdown_pct",
      "default": 10,
      "warning": 7,
      "hard": 10,
      "controls": "Maximum allowed rolling 24-hour drawdown as a percentage of starting balance before all new orders are rejected.",
      "why_default_matters": "A 10% intraday drawdown limit acts as a circuit breaker that stops further losses from compounding during an adverse period.",
      "threshold_logic": [
        {
          "condition": "Rolling 24h drawdown \u2264 7%",
          "action": "APPROVE"
        },
        {
          "condition": "7\u201310%",
          "action": "RESHAPE_REQUIRED \u2014 reduce allowed order size by proportional drawdown factor"
        },
        {
          "condition": "> 10%",
          "action": "REJECT \u2014 STRATEGY_BUDGET_EXCEEDED (drawdown circuit breaker)"
        }
      ],
      "dev_check": "const drawdownPct = rollingLoss24h / startingBalance; if (drawdownPct > p.hard) return reject('STRATEGY_BUDGET_EXCEEDED');",
      "user_facing": "Trading activity today has approached the daily loss limit. We have restricted new orders to limit further exposure."
    },
    {
      "name": "max_per_market_pct",
      "default": 20,
      "warning": 15,
      "hard": 20,
      "controls": "Maximum exposure in any single market as a percentage of account balance.",
      "why_default_matters": "Capping single-market exposure at 20% means no single binary outcome can cause more than a 20% account loss, preserving diversification.",
      "threshold_logic": [
        {
          "condition": "Market exposure \u2264 15% of balance",
          "action": "APPROVE"
        },
        {
          "condition": "15\u201320%",
          "action": "RESHAPE_REQUIRED \u2014 cap order to remaining room within 20%"
        },
        {
          "condition": "> 20%",
          "action": "REJECT \u2014 STRATEGY_BUDGET_EXCEEDED"
        }
      ],
      "dev_check": "const marketBudget = balance * p.hard - currentMarketExposure; if (marketBudget <= 0) return reject('STRATEGY_BUDGET_EXCEEDED');",
      "user_facing": "This order would concentrate too much of your account in a single market. We capped it at the maximum allowed for this market."
    },
    {
      "name": "max_cluster_pct",
      "default": 35,
      "warning": 28,
      "hard": 35,
      "controls": "Maximum exposure across all markets in a correlated cluster as a percentage of account balance.",
      "why_default_matters": "Correlated markets often move together. A 35% cluster cap prevents a group of related positions from creating a concentrated bet that resembles a single-market exposure.",
      "threshold_logic": [
        {
          "condition": "Cluster exposure \u2264 28% of balance",
          "action": "APPROVE"
        },
        {
          "condition": "28\u201335%",
          "action": "RESHAPE_REQUIRED \u2014 reduce to cluster budget"
        },
        {
          "condition": "> 35%",
          "action": "REJECT \u2014 STRATEGY_BUDGET_EXCEEDED (cluster limit)"
        }
      ],
      "dev_check": "const clusterBudget = balance * p.hard - currentClusterExposure; if (clusterBudget <= 0) return reject('STRATEGY_BUDGET_EXCEEDED');",
      "user_facing": "This order would put too much of your account into a group of closely related markets. We reduced the order to stay within the cluster concentration limit."
    }
  ],
  "default_config": {
    "bot_id": "risk.portfolio_guard",
    "version": "1.0.0",
    "mode": "hard_guard",
    "defaults": {
      "max_account_notional_pct": 80,
      "max_24h_drawdown_pct": 10,
      "max_per_market_pct": 20,
      "max_cluster_pct": 35
    },
    "locked": {
      "max_account_notional_pct": {
        "max": 80
      },
      "max_24h_drawdown_pct": {
        "max": 10
      }
    }
  },
  "implementation_flow": [
    "Receive OrderIntent from Strategy layer including market_id, side, and size_usd.",
    "Check KillSwitch active flag; if active, return REJECT with KILL_SWITCH_ACTIVE immediately.",
    "Fetch current account balance in USDC from on-chain to establish the capital base.",
    "Pull aggregate notional across all open positions and pending orders from StrategyRegistry and Data API.",
    "Check rolling 24-hour drawdown from Data API; if > max_24h_drawdown_pct hard limit, return REJECT with STRATEGY_BUDGET_EXCEEDED.",
    "Compute aggregate_budget_remaining = balance \u00d7 max_account_notional_pct \u2212 current_notional. If \u2264 0, return REJECT with STRATEGY_BUDGET_EXCEEDED.",
    "Compute market_budget_remaining = balance \u00d7 max_per_market_pct \u2212 current_market_exposure. If \u2264 0, return REJECT with STRATEGY_BUDGET_EXCEEDED.",
    "Identify the cluster for this market_id (from Admin UI cluster map) and compute cluster_budget_remaining. If \u2264 0, return REJECT with STRATEGY_BUDGET_EXCEEDED.",
    "Compute allowed_size = min(order.size_usd, aggregate_budget_remaining, market_budget_remaining, cluster_budget_remaining). If allowed_size < order.size_usd, return RESHAPE_REQUIRED with constraints.max_size_usd = allowed_size.",
    "Return APPROVE with budget metrics attached to inputs_used and checked_at timestamp."
  ],
  "decision_logic": {
    "approve": "All four budget checks (aggregate notional, 24h drawdown, per-market, cluster) show positive remaining room, and the order size fits within the tightest remaining budget.",
    "reshape_required": "Order size exceeds one or more warning thresholds but the hard limits have not yet been hit \u2014 emit constraints.max_size_usd equal to the minimum remaining budget across all checks.",
    "reject": "24h drawdown circuit breaker is tripped, aggregate notional would exceed hard ceiling, per-market or cluster budget is exhausted, or KillSwitch is active.",
    "warning_only": "Not used \u2014 PortfolioGuard has reject authority. Drawdown approaching the warning threshold is logged as a metric annotation but does not block the order until the hard limit is reached."
  },
  "decision_output_schema": "RiskVote",
  "decision_output_example": {
    "guard_id": "risk.portfolio_guard",
    "decision": "RESHAPE_REQUIRED",
    "severity": "WARN",
    "reason_code": "STRATEGY_BUDGET_EXCEEDED",
    "message": "Order size 1200 USD exceeds per-market budget remaining of 850 USD. Resized to 850 USD.",
    "constraints": {
      "max_size_usd": 850,
      "passive_only": false,
      "close_only": false
    },
    "inputs_used": [
      "on-chain.balance",
      "data_api.positions",
      "strategy_registry.pending_orders"
    ],
    "checked_at": "2026-05-09T08:15:00Z"
  },
  "developer_log": {
    "bot_id": "risk.portfolio_guard",
    "decision": "RESHAPE_REQUIRED",
    "reason_code": "STRATEGY_BUDGET_EXCEEDED",
    "inputs_used": [
      "on-chain.balance",
      "data_api.positions",
      "strategy_registry.pending_orders"
    ],
    "metrics": {
      "account_balance_usd": 10000,
      "current_notional_usd": 7500,
      "aggregate_budget_remaining_usd": 500,
      "current_market_exposure_usd": 1150,
      "market_budget_remaining_usd": 850,
      "cluster_budget_remaining_usd": 2100,
      "rolling_24h_drawdown_pct": 4.2
    },
    "allowed_size_usd": 850,
    "checked_at": "2026-05-09T08:15:00Z"
  },
  "user_explanations": [
    {
      "situation": "Order reduced \u2014 per-market limit reached",
      "message": "This order would have put too much of your account into a single market. We reduced it to stay within the maximum allowed for any one market."
    },
    {
      "situation": "Order blocked \u2014 daily loss limit reached",
      "message": "Today's losses have reached the maximum allowed for a single day. No new orders can be placed until the next trading session begins or the limit is manually reset."
    },
    {
      "situation": "Order blocked \u2014 total exposure at limit",
      "message": "Your account is already at its maximum total exposure. This order would have pushed it over the limit. Please close some positions before adding new ones."
    },
    {
      "situation": "Order reduced \u2014 cluster concentration limit",
      "message": "This market belongs to a group of closely related markets. Together, your positions in this group are near the cluster limit, so we reduced this order to stay within that boundary."
    },
    {
      "situation": "Order reduced \u2014 multiple limits binding",
      "message": "Several limits applied to this order at once. We sized it down to the smallest amount that satisfies all of them at the same time."
    }
  ],
  "failure_modes": {
    "main_failure_mode": "Approving an order that pushes aggregate notional or drawdown over the configured limits because position data is stale or a pending order from another strategy is not yet reflected in the ledger.",
    "false_positive_risk": "Rejecting or downsizing a legitimate order when another strategy has a large pending order that is subsequently cancelled, leaving unused budget that was never available.",
    "false_negative_risk": "Approving an order using a stale balance or position snapshot that does not reflect recent fills, overstating the available budget.",
    "safe_fallback": "If on-chain balance or position data cannot be fetched, reject all new orders with STALE_MARKET_DATA. PortfolioGuard never approves on missing portfolio state.",
    "required_dependencies": [
      "On-chain USDC balance read",
      "Data API open-position feed",
      "StrategyRegistry pending-order ledger",
      "KillSwitch active flag",
      "Admin UI cluster map (optional but recommended)"
    ]
  },
  "acceptance_tests": {
    "unit": [
      {
        "test": "Approve when all budgets have room",
        "setup": "balance=10000, notional=3000, market_exp=500, cluster_exp=1000, drawdown_pct=2",
        "expected": "APPROVE with no constraints"
      },
      {
        "test": "Reshape to per-market budget when market limit is binding",
        "setup": "balance=10000, market_exp=1800, max_per_market_pct=20, order_size=400",
        "expected": "RESHAPE_REQUIRED with constraints.max_size_usd=200"
      },
      {
        "test": "Reject when 24h drawdown exceeds hard limit",
        "setup": "rolling_drawdown_pct=11, max_24h_drawdown_pct=10",
        "expected": "REJECT with reason_code=STRATEGY_BUDGET_EXCEEDED"
      },
      {
        "test": "Reject when aggregate notional budget is zero",
        "setup": "balance=10000, notional=8000, max_account_notional_pct=80",
        "expected": "REJECT with reason_code=STRATEGY_BUDGET_EXCEEDED"
      },
      {
        "test": "Reshape to cluster budget when cluster limit is binding",
        "setup": "balance=10000, cluster_exp=3300, max_cluster_pct=35, order_size=300",
        "expected": "RESHAPE_REQUIRED with constraints.max_size_usd=200"
      },
      {
        "test": "Allowed size is minimum of all four budget checks",
        "setup": "aggregate_remaining=900, market_remaining=700, cluster_remaining=1200, order_size=1000",
        "expected": "RESHAPE_REQUIRED with constraints.max_size_usd=700"
      }
    ],
    "integration": [
      {
        "test": "Cross-strategy notional aggregated correctly before approval",
        "expected": "Two concurrent strategies each requesting $600 on a $1000 per-market limit result in the second being reshaped to $400"
      },
      {
        "test": "Stale on-chain balance causes reject-safe fallback",
        "expected": "REJECT(STALE_MARKET_DATA) when on-chain balance cannot be read"
      },
      {
        "test": "KillSwitch active bypasses all budget checks and rejects immediately",
        "expected": "REJECT without any data fetches when KillSwitch flag is set"
      }
    ],
    "property": [
      {
        "property": "Approved order never causes aggregate notional to exceed max_account_notional_pct",
        "required": "Always true"
      },
      {
        "property": "Reshape size is always \u2264 requested order size",
        "required": "Always true"
      },
      {
        "property": "Missing portfolio state never results in APPROVE",
        "required": "Always true \u2014 absent on-chain data produces REJECT(STALE_MARKET_DATA)"
      }
    ]
  },
  "checklist_overrides": {},
  "legacy_goal": "Enforce account-level limits across every running strategy.",
  "legacy_pm_signals": [
    "Aggregate notional across all open positions",
    "Realised + unrealised drawdown over rolling 24h",
    "Per-market and per-event concentration",
    "Correlated-cluster exposure across neg-risk events"
  ],
  "legacy_external_feeds": [],
  "network": [
    "polygon"
  ],
  "api_surface": [
    "data_api",
    "clob_auth"
  ],
  "reference_implementation": {
    "summary": "Reads the account balance, all open positions, and the rolling 24-hour P&L from the Data API, then enforces four independent budget checks (aggregate notional, 24h drawdown, per-market, cluster) before returning a RiskVote.",
    "language_note": "Pseudocode is language-agnostic. FETCH = read input. EMIT = produce output. Translate to TS/Python/Go/Rust.",
    "pseudocode": "FUNCTION evaluatePortfolio(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    RETURN\n\n  // --- 1. Fetch portfolio state ---\n  balanceRaw = FETCH clob_auth.GET('/wallet/balance')\n  IF balanceRaw IS NULL:\n    EMIT RiskVote(decision=HARD_REJECT, reason=STALE_MARKET_DATA)\n    RETURN\n  balance = balanceRaw.pUSD  // pUSD collateral, 6 decimals via toUsdcUnits\n\n  positions = FETCH data_api.GET('/positions?account=' + config.account_id)\n  IF positions IS NULL:\n    EMIT RiskVote(decision=HARD_REJECT, reason=STALE_MARKET_DATA)\n    RETURN\n\n  pending = FETCH internal.strategy_registry.pending_orders()\n  currentNotional = SUM(p.notional_usd FOR p IN positions)\n                  + SUM(o.size_usd FOR o IN pending)\n\n  // --- 2. 24h drawdown check ---\n  pnl24h = FETCH data_api.GET('/pnl?account=' + config.account_id + '&window=24h')\n  drawdownPct = (-pnl24h.realised - pnl24h.unrealised) / balance\n  IF drawdownPct > params.max_24h_drawdown_pct.hard / 100:\n    EMIT RiskVote(decision=HARD_REJECT, reason=STRATEGY_BUDGET_EXCEEDED)\n    RETURN\n\n  // --- 3. Aggregate notional check ---\n  aggregateBudget = balance * params.max_account_notional_pct.hard / 100 - currentNotional\n  IF aggregateBudget <= 0:\n    EMIT RiskVote(decision=HARD_REJECT, reason=STRATEGY_BUDGET_EXCEEDED)\n    RETURN\n\n  // --- 4. Per-market check ---\n  marketExposure = SUM(p.notional_usd FOR p IN positions IF p.market_id == intent.market_id)\n  marketBudget = balance * params.max_per_market_pct.hard / 100 - marketExposure\n  IF marketBudget <= 0:\n    EMIT RiskVote(decision=HARD_REJECT, reason=STRATEGY_BUDGET_EXCEEDED)\n    RETURN\n\n  // --- 5. Cluster check ---\n  cluster = FETCH internal.cluster_map.get(intent.market_id)\n  clusterExposure = SUM(p.notional_usd FOR p IN positions IF cluster.has(p.market_id))\n  clusterBudget = balance * params.max_cluster_pct.hard / 100 - clusterExposure\n  IF clusterBudget <= 0:\n    EMIT RiskVote(decision=HARD_REJECT, reason=STRATEGY_BUDGET_EXCEEDED)\n    RETURN\n\n  // --- 6. Compute allowed size ---\n  allowedSize = min(intent.size_usd,\n                    aggregateBudget, marketBudget, clusterBudget)\n  allowedSize = toUsdcUnits(allowedSize)\n  IF allowedSize < intent.size_usd:\n    EMIT RiskVote(decision=RESHAPE_REQUIRED,\n                  reason=STRATEGY_BUDGET_EXCEEDED,\n                  constraints={ max_size_usd: allowedSize })\n    RETURN\n\n  // --- 7. Happy path ---\n  EMIT RiskVote(decision=APPROVE, checked_at=now_iso())\n",
    "helpers": [
      {
        "name": "toUsdcUnits",
        "signature": "toUsdcUnits(rawUsd: float) -> int",
        "purpose": "Round a raw pUSD float to the integer unit used by CTFExchangeV2 (6 decimals)."
      },
      {
        "name": "fetchClobPublic",
        "signature": "fetchClobPublic(path: str) -> JSON",
        "purpose": "Unauthenticated CLOB read; used for market metadata lookups."
      },
      {
        "name": "isStale",
        "signature": "isStale(snapshot: any, maxAgeS: int) -> bool",
        "purpose": "Returns true if snapshot was fetched more than maxAgeS seconds ago."
      },
      {
        "name": "platformFee",
        "signature": "platformFee(notional: float, prob: float, feeRate: float) -> float",
        "purpose": "Computes platform fee; used to adjust effective notional for exposure calculations."
      }
    ],
    "sdk_calls": [
      "clob_auth.GET('/wallet/balance')",
      "data_api.GET('/positions?account=...')",
      "data_api.GET('/pnl?account=...&window=24h')",
      "internal.strategy_registry.pending_orders()",
      "internal.cluster_map.get(market_id)",
      "internal.killswitch.status()"
    ],
    "complexity": "O(P) where P = number of open positions"
  },
  "wire_examples": {
    "input": [
      {
        "label": "OrderIntent requiring per-market reshape",
        "source": "internal",
        "payload": {
          "intent_id": "int_4d5e6f7a8b9c0d1e",
          "market_id": "0x2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c",
          "side": "BUY",
          "outcome": "YES",
          "size_usd": 1200,
          "generated_at": "2026-05-09T08:15:00Z"
        }
      },
      {
        "label": "Portfolio state snapshot",
        "source": "data_api",
        "payload": {
          "account_balance_pusd": 10000,
          "current_notional_usd": 7500,
          "market_exposure_usd": 1150,
          "cluster_exposure_usd": 2100,
          "rolling_24h_pnl_usd": -420,
          "rolling_24h_drawdown_pct": 4.2
        }
      }
    ],
    "output": [
      {
        "label": "RiskVote \u2014 RESHAPE_REQUIRED (per-market limit binding)",
        "payload": {
          "guard_id": "risk.portfolio_guard",
          "decision": "RESHAPE_REQUIRED",
          "severity": "WARN",
          "reason_code": "STRATEGY_BUDGET_EXCEEDED",
          "message": "Order size 1200 pUSD exceeds per-market budget remaining of 850 pUSD. Resized to 850 pUSD.",
          "constraints": {
            "max_size_usd": 850,
            "passive_only": false,
            "close_only": false
          },
          "inputs_used": [
            "clob_auth.balance",
            "data_api.positions",
            "strategy_registry.pending_orders"
          ],
          "checked_at": "2026-05-09T08:15:00Z"
        }
      },
      {
        "label": "RiskVote \u2014 HARD_REJECT (drawdown circuit breaker)",
        "payload": {
          "guard_id": "risk.portfolio_guard",
          "decision": "HARD_REJECT",
          "severity": "HARD",
          "reason_code": "STRATEGY_BUDGET_EXCEEDED",
          "message": "Rolling 24h drawdown 10.3% exceeded hard limit 10%. All new orders blocked.",
          "constraints": {},
          "inputs_used": [
            "data_api.pnl",
            "clob_auth.balance"
          ],
          "checked_at": "2026-05-09T09:00:00Z"
        }
      }
    ],
    "curl": "curl -H 'Authorization: Bearer <token>' 'https://clob.polymarket.com/wallet/balance'"
  },
  "reason_codes": [
    {
      "code": "KILL_SWITCH_ACTIVE",
      "severity": "HARD_REJECT",
      "meaning": "Global kill switch is active.",
      "action": "Immediately return HARD_REJECT without fetching portfolio state.",
      "user_message": "Trading is currently paused. Please try again later."
    },
    {
      "code": "STALE_MARKET_DATA",
      "severity": "HARD_REJECT",
      "meaning": "Account balance or position data could not be fetched.",
      "action": "Return HARD_REJECT; retry on next fresh fetch.",
      "user_message": "Account data could not be verified. The order was blocked until current information is available."
    },
    {
      "code": "STRATEGY_BUDGET_EXCEEDED",
      "severity": "HARD_REJECT",
      "meaning": "One or more budget limits (aggregate, drawdown, per-market, cluster) are exhausted.",
      "action": "Return HARD_REJECT or RESHAPE_REQUIRED depending on which limit is binding.",
      "user_message": "This order would exceed your account risk limits. It was reduced or blocked to keep your overall exposure within safe bounds."
    },
    {
      "code": "PORTFOLIO_GUARD_DRAWDOWN_BREACHED",
      "severity": "HARD_REJECT",
      "meaning": "Rolling 24-hour drawdown exceeds max_24h_drawdown_pct hard limit.",
      "action": "Return HARD_REJECT; circuit breaker remains active until drawdown falls below warning threshold or manual reset.",
      "user_message": "Today's losses have reached the daily safety limit. No new orders will be placed until the limit resets."
    },
    {
      "code": "PORTFOLIO_GUARD_NOTIONAL_RESHAPE",
      "severity": "RESHAPE",
      "meaning": "Order size exceeds remaining aggregate, per-market, or cluster budget but does not trigger a hard reject.",
      "action": "Return RESHAPE_REQUIRED with constraints.max_size_usd = min(remaining budgets).",
      "user_message": "Your order was reduced to stay within the account concentration limits."
    },
    {
      "code": "PORTFOLIO_GUARD_CLUSTER_LIMIT",
      "severity": "HARD_REJECT",
      "meaning": "Cluster exposure would exceed max_cluster_pct.",
      "action": "Return HARD_REJECT or RESHAPE_REQUIRED depending on budget remaining.",
      "user_message": "This market belongs to a group of closely related markets. The combined exposure would exceed the cluster limit."
    },
    {
      "code": "RECONCILIATION_DRIFT_OBSERVED",
      "severity": "WARN",
      "meaning": "Position ledger data from Data API diverges from the last cached state by more than 1%.",
      "action": "Log drift delta and emit WARN; continue with conservative (lower) position estimate.",
      "user_message": ""
    }
  ],
  "metrics": {
    "emitted": [
      {
        "name": "polytraders_risk_portfolioguard_decisions_total",
        "type": "counter",
        "unit": "count",
        "labels": [
          "decision",
          "reason_code"
        ],
        "meaning": "Total RiskVote decisions broken down by type and reason."
      },
      {
        "name": "polytraders_risk_portfolioguard_drawdown_pct",
        "type": "gauge",
        "unit": "ratio",
        "labels": [],
        "meaning": "Current rolling 24h drawdown as a fraction of starting balance."
      },
      {
        "name": "polytraders_risk_portfolioguard_notional_utilisation",
        "type": "gauge",
        "unit": "ratio",
        "labels": [],
        "meaning": "Current aggregate notional as a fraction of max_account_notional_pct."
      },
      {
        "name": "polytraders_risk_portfolioguard_market_utilisation",
        "type": "gauge",
        "unit": "ratio",
        "labels": [
          "market_id"
        ],
        "meaning": "Per-market notional utilisation as a fraction of max_per_market_pct."
      },
      {
        "name": "polytraders_risk_portfolioguard_cluster_utilisation",
        "type": "gauge",
        "unit": "ratio",
        "labels": [
          "cluster_id"
        ],
        "meaning": "Cluster notional utilisation as a fraction of max_cluster_pct."
      },
      {
        "name": "polytraders_risk_portfolioguard_eval_latency_ms",
        "type": "histogram",
        "unit": "seconds",
        "labels": [],
        "meaning": "Wall-clock latency from intent receipt to RiskVote emit."
      }
    ],
    "alerts": [
      {
        "name": "PortfolioGuardDrawdownWarning",
        "condition": "polytraders_risk_portfolioguard_drawdown_pct > 0.07",
        "severity": "P1",
        "runbook": "#runbook-portfolioguard-drawdown"
      },
      {
        "name": "PortfolioGuardDrawdownBreached",
        "condition": "polytraders_risk_portfolioguard_drawdown_pct > 0.10",
        "severity": "P0",
        "runbook": "#runbook-portfolioguard-drawdown-breached"
      },
      {
        "name": "PortfolioGuardNotionalHigh",
        "condition": "polytraders_risk_portfolioguard_notional_utilisation > 0.85",
        "severity": "P2",
        "runbook": "#runbook-portfolioguard-notional"
      },
      {
        "name": "PortfolioGuardStaleLedger",
        "condition": "rate(polytraders_risk_portfolioguard_decisions_total{reason_code='STALE_MARKET_DATA'}[5m]) > 0",
        "severity": "P1",
        "runbook": "#runbook-portfolioguard-stale-ledger"
      }
    ],
    "dashboards": [
      "Grafana \u2014 Risk overview / PortfolioGuard",
      "Grafana \u2014 Account exposure / drawdown and notional utilisation"
    ],
    "log_levels": {
      "DEBUG": "Individual budget check values (aggregateBudget, marketBudget, clusterBudget) on every evaluation.",
      "INFO": "RiskVote decision emitted with budget metrics attached.",
      "WARN": "Drawdown approaching warning threshold; position ledger drift detected.",
      "ERROR": "Account balance or position fetch returned null; cluster map unavailable."
    }
  },
  "state": {
    "summary": "Maintains a durable Postgres ledger of positions and budgets, refreshed on every fill event and reconciliation cycle.",
    "stores": [
      {
        "name": "position_ledger",
        "kind": "postgres",
        "key": "account_id + market_id",
        "value": "{ notional_usd: float, side: str, last_updated: timestamp }",
        "ttl": "none",
        "durability": "strong"
      },
      {
        "name": "drawdown_snapshot",
        "kind": "redis",
        "key": "account_id",
        "value": "{ rolling_24h_pnl_usd: float, snapshot_at: timestamp }",
        "ttl": "300s",
        "durability": "best-effort"
      }
    ],
    "recovery": "On cold start, position_ledger is read from Postgres. drawdown_snapshot is re-fetched from Data API.",
    "on_restart": "Postgres state is immediately available. Redis drawdown cache is rebuilt within one polling cycle (60s)."
  },
  "concurrency": {
    "execution_model": "single-threaded event loop",
    "max_in_flight": 50,
    "idempotency_key": "intent_id",
    "replay_safe": true,
    "deduplication": "by intent_id within a 24h window",
    "ordering_guarantees": "FIFO per market_id",
    "timeout_ms": 300,
    "backpressure": "shed",
    "locking": "per-market_id mutex"
  },
  "dependencies": {
    "depends_on": [
      {
        "bot_id": "risk.kill_switch",
        "why": "Global brake \u2014 checked first before any data fetch.",
        "contract": "RiskVote.HARD_REJECT(KILL_SWITCH_ACTIVE) short-circuits."
      }
    ],
    "emits_to": [
      {
        "bot_id": "exec.smart_router",
        "why": "Approved or reshaped RiskVote passes to SmartRouter.",
        "contract": "constraints.max_size_usd is binding for SmartRouter's iceberg split calculation."
      },
      {
        "bot_id": "risk.liquidity_guard",
        "why": "Provides budget_remaining for the LiquidityGuard reshape ceiling.",
        "contract": "LiquidityGuard uses min(liquidity_safe_size, portfolio_budget_remaining)."
      },
      {
        "bot_id": "risk.oracle_risk_monitor",
        "why": "Provides per-market position limit for oracle proposal-window cap.",
        "contract": "OracleRiskMonitor reads portfolio_guard.per_market_limit."
      }
    ],
    "sibling": [
      {
        "bot_id": "risk.liquidity_guard",
        "why": "Sibling guardrail; both must APPROVE or RESHAPE before SmartRouter runs."
      },
      {
        "bot_id": "risk.oracle_risk_monitor",
        "why": "Sibling guardrail."
      },
      {
        "bot_id": "risk.kill_switch",
        "why": "Sibling guardrail."
      }
    ],
    "external": [
      {
        "service": "Data API (positions + P&L)",
        "endpoint": "https://data-api.polymarket.com",
        "sla": "99.9% / 500ms p99",
        "failure_mode": "HARD_REJECT(STALE_MARKET_DATA) until positions are readable."
      },
      {
        "service": "CLOB Auth API (balance)",
        "endpoint": "https://clob.polymarket.com",
        "sla": "99.95% / 200ms p99",
        "failure_mode": "HARD_REJECT(STALE_MARKET_DATA) if balance is unreadable."
      }
    ]
  },
  "security_surfaces": {
    "summary": "PortfolioGuard reads account balance and position data via authenticated API calls. It never signs orders or holds private keys.",
    "signing": "This bot does NOT sign anything.",
    "secrets": [],
    "contract_calls": [],
    "abuse_vectors": [
      "Feeding a stale or low balance snapshot to inflate available budget",
      "Racing two intents simultaneously to double-count the same budget"
    ],
    "mitigations": [
      "per-market_id mutex prevents concurrent budget double-counting",
      "Balance and position snapshots older than 60s are rejected as STALE_MARKET_DATA",
      "Postgres position_ledger provides strong-consistency ground truth"
    ]
  },
  "polymarket_v2_compat": {
    "clob_version": "v2",
    "collateral": "pUSD",
    "eip712_domain_version": "2",
    "builder_code_aware": false,
    "negrisk_aware": true,
    "multichain_ready": false,
    "sdk_used": "@polymarket/clob-client-v2 ^2.x",
    "settlement_contract": "CTFExchangeV2 on Polygon",
    "notes": "Account balance is denominated in pUSD (USDC-backed ERC-20). Notional calculations use toUsdcUnits for precision. NegRisk cluster grouping uses the Gamma API enableNegRisk flag."
  },
  "version": {
    "spec": "2.0.0",
    "implementation": "2.1.3",
    "schema": "2",
    "released": "2026-04-28"
  },
  "migration_history": [
    {
      "date": "2026-04-28",
      "from": "v1 (USDC.e + HMAC builder)",
      "to": "v2 (pUSD + builderCode field)",
      "reason": "Polymarket V2 cutover",
      "action_taken": "Migrated SDK. Balance and position data now denominated in pUSD. Removed feeRateBps from order construction. Cluster map updated to use Gamma API V2 negRisk field."
    }
  ],
  "failure_injection": [
    {
      "scenario": "STALE_BALANCE",
      "how_to_inject": "Disconnect Data API for 90s",
      "expected_behavior": "HARD_REJECT(STALE_MARKET_DATA) on every evaluation",
      "recovery": "Returns to APPROVE within one evaluation after Data API reconnects."
    },
    {
      "scenario": "DRAWDOWN_BREACH",
      "how_to_inject": "Set rolling_24h_pnl_usd to -(balance * 0.11)",
      "expected_behavior": "HARD_REJECT(STRATEGY_BUDGET_EXCEEDED / drawdown circuit breaker)",
      "recovery": "Returns to APPROVE only after manual reset or drawdown falls below warning threshold."
    },
    {
      "scenario": "NOTIONAL_EXHAUSTED",
      "how_to_inject": "Set currentNotional = balance * 0.81",
      "expected_behavior": "HARD_REJECT(STRATEGY_BUDGET_EXCEEDED) for all new intents",
      "recovery": "Returns to APPROVE as positions close and notional falls below the hard limit."
    },
    {
      "scenario": "CLUSTER_LIMIT_HIT",
      "how_to_inject": "Set clusterExposure = balance * 0.36",
      "expected_behavior": "HARD_REJECT(STRATEGY_BUDGET_EXCEEDED) for all intents in the affected cluster",
      "recovery": "Returns to APPROVE as cluster positions close."
    },
    {
      "scenario": "KILL_SWITCH_ON",
      "how_to_inject": "Set internal.killswitch.status.active=true",
      "expected_behavior": "HARD_REJECT(KILL_SWITCH_ACTIVE) on every intent without data fetch",
      "recovery": "Returns to normal pipeline on manual KillSwitch reset."
    }
  ],
  "runbook": {
    "summary": "PortfolioGuard incidents typically involve drawdown breaches or stale ledger data. Drawdown breaches require manual review before reset; ledger staleness should be resolved via Data API connectivity.",
    "oncall_actions": [
      {
        "alert": "PortfolioGuardDrawdownBreached",
        "first_step": "Confirm drawdown percentage on Grafana. Identify which strategies are driving losses.",
        "diagnosis": "Review last 24h of fill logs. Determine if loss is genuine or a data artifact.",
        "mitigation": "Do NOT reset the circuit breaker without confirming the root cause. Pause affected strategies first.",
        "escalation": "Risk pod lead immediately."
      },
      {
        "alert": "PortfolioGuardDrawdownWarning",
        "first_step": "Alert monitoring team. Reduce strategy sizes if approaching hard limit.",
        "diagnosis": "Check which markets and strategies are contributing to drawdown.",
        "mitigation": "Pause high-exposure strategies pre-emptively.",
        "escalation": "Risk pod lead if drawdown continues to rise."
      },
      {
        "alert": "PortfolioGuardStaleLedger",
        "first_step": "Check Data API connectivity.",
        "diagnosis": "If Data API is down, PortfolioGuard correctly blocks all orders as a safe fallback.",
        "mitigation": "Restore Data API connectivity. Do not reduce staleness thresholds.",
        "escalation": "Infra on-call if Data API is down > 5 minutes."
      },
      {
        "alert": "PortfolioGuardNotionalHigh",
        "first_step": "Check which markets have high utilisation.",
        "diagnosis": "High notional utilisation is expected near end-of-session. Alert if above 85% unexpectedly early in the session.",
        "mitigation": "Reduce position sizes on high-utilisation markets.",
        "escalation": "Risk pod lead if aggregate utilisation reaches 95%."
      }
    ],
    "manual_overrides": [
      {
        "command": "polytraders bot reset-drawdown risk.portfolio_guard",
        "effect": "Manually clears the 24h drawdown circuit breaker. Requires Risk pod lead sign-off."
      },
      {
        "command": "polytraders bot pause risk.portfolio_guard",
        "effect": "Stops emitting RiskVotes. All intents pass without portfolio checks. Use only with Risk pod lead explicit approval."
      }
    ],
    "healthcheck": "GET /health \u2192 200 if Postgres position_ledger is reachable and last balance fetch is < 60s old."
  },
  "promotion_gates": {
    "to_shadow": [
      {
        "gate": "Unit tests pass for all four budget checks",
        "how_measured": "CI test run",
        "threshold": "100% pass"
      },
      {
        "gate": "Postgres position_ledger connectivity verified",
        "how_measured": "Integration test",
        "threshold": "Pass"
      }
    ],
    "to_limited_live": [
      {
        "gate": "Shadow mode decision matches live mode within 5% over 48h",
        "how_measured": "Grafana shadow vs live comparison",
        "threshold": "< 5% divergence"
      },
      {
        "gate": "p99 evaluation latency < 300ms",
        "how_measured": "polytraders_risk_portfolioguard_eval_latency_ms histogram",
        "threshold": "p99 < 300ms"
      }
    ],
    "to_general_live": [
      {
        "gate": "Drawdown circuit breaker fires correctly in staging injection",
        "how_measured": "Failure injection test",
        "threshold": "Pass"
      },
      {
        "gate": "Cross-strategy notional aggregation correct in concurrent intent test",
        "how_measured": "Integration test with two concurrent strategies",
        "threshold": "Pass"
      }
    ]
  },
  "reporting_groups": [
    "risk_compliance"
  ],
  "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"
  }
}