1.1 PortfolioGuard
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 — it only sets the maximum size an order is allowed to carry.
v3 readiness
A bot is done when all four scores are. What does done mean?
risk.drawdownguard
Impl is a focused drawdown guard; closest spec is portfolioguard. v3 spec may want a dedicated risk.drawdownguard page; this is a known gap.
Source: @polytraders/bots · src/risk/drawdownguard.js · Impl 11/15 · Backtest 3/4
1. Bot Identity
| Layer | Risk Risk |
|---|---|
| Bot class | Guardrail |
| Authority | RejectReshape |
| Status | LIVE |
| Readiness | General live |
| Runs before | ExecutionPlan emit |
| Runs after | Strategy OrderIntent |
| Applies to | Every OrderIntent — checks account-level, market-level, and cluster-level limits |
| Default mode | general_live |
| User-visible | Advanced details only |
| Developer owner | Polytraders core — Risk pod |
Operational profile
| Modes supported | quarantine |
|---|
2. 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 — it only sets the maximum size an order is allowed to carry.
3. Why This Bot Matters
Aggregate notional exceeds account limit
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.
Single-market concentration unchecked
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.
24-hour drawdown not tracked cross-strategy
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.
Correlated cluster exposure ignored
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.
4. Required Polymarket Inputs
| Input | Source | Required? | Use |
|---|---|---|---|
| Account balance and available USDC | on-chain | Yes | Establish the total capital base against which percentage-of-account limits are applied. |
| Current open positions per market with notional sizes | Data API | Yes | Compute aggregate notional across all open positions and per-market exposure. |
| Realised and unrealised P&L over rolling 24 hours | Data API | Yes | Calculate rolling drawdown to enforce the max_24h_drawdown_pct circuit breaker. |
5. Required Internal Inputs
| Input | Source | Required? | Use |
|---|---|---|---|
| Per-strategy order intent queue and current allocations | StrategyRegistry | Yes | Aggregate pending and active allocations from all strategies before approving new additions. |
| KillSwitch active flag | KillSwitch | Yes | Immediately reject all orders if the KillSwitch has been triggered. |
| Cluster mapping for correlated markets | Admin UI | No | Group neg-risk and thematically linked markets into clusters for aggregate concentration checks. |
6. Parameter Guide
| Parameter | Default | Warning | Hard | What it controls |
|---|---|---|---|---|
| max_account_notional_pct | 80 | 70 | 80 | Maximum total notional exposure across all open positions as a percentage of current account balance. |
| max_24h_drawdown_pct | 10 | 7 | 10 | Maximum allowed rolling 24-hour drawdown as a percentage of starting balance before all new orders are rejected. |
| max_per_market_pct | 20 | 15 | 20 | Maximum exposure in any single market as a percentage of account balance. |
| max_cluster_pct | 35 | 28 | 35 | Maximum exposure across all markets in a correlated cluster as a percentage of account balance. |
7. Detailed Parameter Instructions
max_account_notional_pct
What it means
Maximum total notional exposure across all open positions as a percentage of current account balance.
Default
{ "max_account_notional_pct": 80 }
Why this 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 | Action |
|---|---|
| Aggregate notional ≤ 70% of balance | APPROVE |
| 70–80% of balance | RESHAPE_REQUIRED — cap this order to the remaining room |
| > 80% of balance | REJECT — STRATEGY_BUDGET_EXCEEDED |
Developer 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 English
Your account is already near its maximum total exposure. We reduced or blocked this order to keep your overall risk within safe limits.
max_24h_drawdown_pct
What it means
Maximum allowed rolling 24-hour drawdown as a percentage of starting balance before all new orders are rejected.
Default
{ "max_24h_drawdown_pct": 10 }
Why this 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 | Action |
|---|---|
| Rolling 24h drawdown ≤ 7% | APPROVE |
| 7–10% | RESHAPE_REQUIRED — reduce allowed order size by proportional drawdown factor |
| > 10% | REJECT — STRATEGY_BUDGET_EXCEEDED (drawdown circuit breaker) |
Developer check
const drawdownPct = rollingLoss24h / startingBalance; if (drawdownPct > p.hard) return reject('STRATEGY_BUDGET_EXCEEDED');
User-facing English
Trading activity today has approached the daily loss limit. We have restricted new orders to limit further exposure.
max_per_market_pct
What it means
Maximum exposure in any single market as a percentage of account balance.
Default
{ "max_per_market_pct": 20 }
Why this 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 | Action |
|---|---|
| Market exposure ≤ 15% of balance | APPROVE |
| 15–20% | RESHAPE_REQUIRED — cap order to remaining room within 20% |
| > 20% | REJECT — STRATEGY_BUDGET_EXCEEDED |
Developer check
const marketBudget = balance * p.hard - currentMarketExposure; if (marketBudget <= 0) return reject('STRATEGY_BUDGET_EXCEEDED');
User-facing English
This order would concentrate too much of your account in a single market. We capped it at the maximum allowed for this market.
max_cluster_pct
What it means
Maximum exposure across all markets in a correlated cluster as a percentage of account balance.
Default
{ "max_cluster_pct": 35 }
Why this 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 | Action |
|---|---|
| Cluster exposure ≤ 28% of balance | APPROVE |
| 28–35% | RESHAPE_REQUIRED — reduce to cluster budget |
| > 35% | REJECT — STRATEGY_BUDGET_EXCEEDED (cluster limit) |
Developer check
const clusterBudget = balance * p.hard - currentClusterExposure; if (clusterBudget <= 0) return reject('STRATEGY_BUDGET_EXCEEDED');
User-facing English
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.
8. Default Configuration
{
"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
}
}
}9. 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 × max_account_notional_pct − current_notional. If ≤ 0, return REJECT with STRATEGY_BUDGET_EXCEEDED.
- Compute market_budget_remaining = balance × max_per_market_pct − current_market_exposure. If ≤ 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 ≤ 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.
10. Reference Implementation
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.
Pseudocode is language-agnostic. FETCH = read input. EMIT = produce output. Translate to TS/Python/Go/Rust.
FUNCTION evaluatePortfolio(intent):
// --- 0. KillSwitch gate ---
ks = FETCH internal.killswitch.status
IF ks.active:
EMIT RiskVote(decision=HARD_REJECT, reason=KILL_SWITCH_ACTIVE)
RETURN
// --- 1. Fetch portfolio state ---
balanceRaw = FETCH clob_auth.GET('/wallet/balance')
IF balanceRaw IS NULL:
EMIT RiskVote(decision=HARD_REJECT, reason=STALE_MARKET_DATA)
RETURN
balance = balanceRaw.pUSD // pUSD collateral, 6 decimals via toUsdcUnits
positions = FETCH data_api.GET('/positions?account=' + config.account_id)
IF positions IS NULL:
EMIT RiskVote(decision=HARD_REJECT, reason=STALE_MARKET_DATA)
RETURN
pending = FETCH internal.strategy_registry.pending_orders()
currentNotional = SUM(p.notional_usd FOR p IN positions)
+ SUM(o.size_usd FOR o IN pending)
// --- 2. 24h drawdown check ---
pnl24h = FETCH data_api.GET('/pnl?account=' + config.account_id + '&window=24h')
drawdownPct = (-pnl24h.realised - pnl24h.unrealised) / balance
IF drawdownPct > params.max_24h_drawdown_pct.hard / 100:
EMIT RiskVote(decision=HARD_REJECT, reason=STRATEGY_BUDGET_EXCEEDED)
RETURN
// --- 3. Aggregate notional check ---
aggregateBudget = balance * params.max_account_notional_pct.hard / 100 - currentNotional
IF aggregateBudget <= 0:
EMIT RiskVote(decision=HARD_REJECT, reason=STRATEGY_BUDGET_EXCEEDED)
RETURN
// --- 4. Per-market check ---
marketExposure = SUM(p.notional_usd FOR p IN positions IF p.market_id == intent.market_id)
marketBudget = balance * params.max_per_market_pct.hard / 100 - marketExposure
IF marketBudget <= 0:
EMIT RiskVote(decision=HARD_REJECT, reason=STRATEGY_BUDGET_EXCEEDED)
RETURN
// --- 5. Cluster check ---
cluster = FETCH internal.cluster_map.get(intent.market_id)
clusterExposure = SUM(p.notional_usd FOR p IN positions IF cluster.has(p.market_id))
clusterBudget = balance * params.max_cluster_pct.hard / 100 - clusterExposure
IF clusterBudget <= 0:
EMIT RiskVote(decision=HARD_REJECT, reason=STRATEGY_BUDGET_EXCEEDED)
RETURN
// --- 6. Compute allowed size ---
allowedSize = min(intent.size_usd,
aggregateBudget, marketBudget, clusterBudget)
allowedSize = toUsdcUnits(allowedSize)
IF allowedSize < intent.size_usd:
EMIT RiskVote(decision=RESHAPE_REQUIRED,
reason=STRATEGY_BUDGET_EXCEEDED,
constraints={ max_size_usd: allowedSize })
RETURN
// --- 7. Happy path ---
EMIT RiskVote(decision=APPROVE, checked_at=now_iso())
Helpers used
| Helper | Signature | Purpose |
|---|---|---|
| toUsdcUnits | toUsdcUnits(rawUsd: float) -> int | Round a raw pUSD float to the integer unit used by CTFExchangeV2 (6 decimals). |
| fetchClobPublic | fetchClobPublic(path: str) -> JSON | Unauthenticated CLOB read; used for market metadata lookups. |
| isStale | isStale(snapshot: any, maxAgeS: int) -> bool | Returns true if snapshot was fetched more than maxAgeS seconds ago. |
| platformFee | platformFee(notional: float, prob: float, feeRate: float) -> float | Computes platform fee; used to adjust effective notional for exposure calculations. |
SDK calls used
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
11. Wire Examples
Input — what arrives on the wire
OrderIntent requiring per-market reshape — internal
{
"intent_id": "int_4d5e6f7a8b9c0d1e",
"market_id": "0x2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c",
"side": "BUY",
"outcome": "YES",
"size_usd": 1200,
"generated_at": "2026-05-09T08:15:00Z"
}
Portfolio state snapshot — data_api
{
"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 — what the bot emits
RiskVote — RESHAPE_REQUIRED (per-market limit binding)
{
"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"
}
RiskVote — HARD_REJECT (drawdown circuit breaker)
{
"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"
}
Reproduce locally
curl -H 'Authorization: Bearer <token>' 'https://clob.polymarket.com/wallet/balance'12. 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 — 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 — 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.
13. Standard Decision Output
This bot returns a RiskVote object. See RiskVote schema.
{
"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"
}14. Reason Codes
| Code | Severity | Meaning | Action | User-facing message |
|---|---|---|---|---|
KILL_SWITCH_ACTIVE | HARD_REJECT | Global kill switch is active. | Immediately return HARD_REJECT without fetching portfolio state. | Trading is currently paused. Please try again later. |
STALE_MARKET_DATA | HARD_REJECT | Account balance or position data could not be fetched. | Return HARD_REJECT; retry on next fresh fetch. | Account data could not be verified. The order was blocked until current information is available. |
STRATEGY_BUDGET_EXCEEDED | HARD_REJECT | One or more budget limits (aggregate, drawdown, per-market, cluster) are exhausted. | Return HARD_REJECT or RESHAPE_REQUIRED depending on which limit is binding. | This order would exceed your account risk limits. It was reduced or blocked to keep your overall exposure within safe bounds. |
PORTFOLIO_GUARD_DRAWDOWN_BREACHED | HARD_REJECT | Rolling 24-hour drawdown exceeds max_24h_drawdown_pct hard limit. | Return HARD_REJECT; circuit breaker remains active until drawdown falls below warning threshold or manual reset. | Today's losses have reached the daily safety limit. No new orders will be placed until the limit resets. |
PORTFOLIO_GUARD_NOTIONAL_RESHAPE | RESHAPE | Order size exceeds remaining aggregate, per-market, or cluster budget but does not trigger a hard reject. | Return RESHAPE_REQUIRED with constraints.max_size_usd = min(remaining budgets). | Your order was reduced to stay within the account concentration limits. |
PORTFOLIO_GUARD_CLUSTER_LIMIT | HARD_REJECT | Cluster exposure would exceed max_cluster_pct. | Return HARD_REJECT or RESHAPE_REQUIRED depending on budget remaining. | This market belongs to a group of closely related markets. The combined exposure would exceed the cluster limit. |
RECONCILIATION_DRIFT_OBSERVED | WARN | Position ledger data from Data API diverges from the last cached state by more than 1%. | Log drift delta and emit WARN; continue with conservative (lower) position estimate. |
15. Metrics & Logs
Metrics emitted
| Metric | Type | Unit | Labels | Meaning |
|---|---|---|---|---|
polytraders_risk_portfolioguard_decisions_total | counter | count | decision, reason_code | Total RiskVote decisions broken down by type and reason. |
polytraders_risk_portfolioguard_drawdown_pct | gauge | ratio | Current rolling 24h drawdown as a fraction of starting balance. | |
polytraders_risk_portfolioguard_notional_utilisation | gauge | ratio | Current aggregate notional as a fraction of max_account_notional_pct. | |
polytraders_risk_portfolioguard_market_utilisation | gauge | ratio | market_id | Per-market notional utilisation as a fraction of max_per_market_pct. |
polytraders_risk_portfolioguard_cluster_utilisation | gauge | ratio | cluster_id | Cluster notional utilisation as a fraction of max_cluster_pct. |
polytraders_risk_portfolioguard_eval_latency_ms | histogram | seconds | Wall-clock latency from intent receipt to RiskVote emit. |
Alerts
| Alert | Condition | Severity | Runbook |
|---|---|---|---|
PortfolioGuardDrawdownWarning | polytraders_risk_portfolioguard_drawdown_pct > 0.07 | P1 | #runbook-portfolioguard-drawdown |
PortfolioGuardDrawdownBreached | polytraders_risk_portfolioguard_drawdown_pct > 0.10 | P0 | #runbook-portfolioguard-drawdown-breached |
PortfolioGuardNotionalHigh | polytraders_risk_portfolioguard_notional_utilisation > 0.85 | P2 | #runbook-portfolioguard-notional |
PortfolioGuardStaleLedger | rate(polytraders_risk_portfolioguard_decisions_total{reason_code='STALE_MARKET_DATA'}[5m]) > 0 | P1 | #runbook-portfolioguard-stale-ledger |
Dashboards
- Grafana — Risk overview / PortfolioGuard
- Grafana — Account exposure / drawdown and notional utilisation
Log levels
| Level | What gets logged |
|---|---|
| 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. |
16. Developer Reporting
{
"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"
}17. Plain-English Reporting
| Situation | User-facing explanation |
|---|---|
| Order reduced — per-market limit reached | 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. |
| Order blocked — daily loss limit reached | 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. |
| Order blocked — total exposure at limit | 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. |
| Order reduced — cluster concentration limit | 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. |
| Order reduced — multiple limits binding | 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. |
18. Failure-Mode Block
| 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) |
19. Failure-Injection Recipes
| Scenario | How to inject | Expected behaviour | Recovery |
|---|---|---|---|
STALE_BALANCE | Disconnect Data API for 90s | HARD_REJECT(STALE_MARKET_DATA) on every evaluation | Returns to APPROVE within one evaluation after Data API reconnects. |
DRAWDOWN_BREACH | Set rolling_24h_pnl_usd to -(balance * 0.11) | HARD_REJECT(STRATEGY_BUDGET_EXCEEDED / drawdown circuit breaker) | Returns to APPROVE only after manual reset or drawdown falls below warning threshold. |
NOTIONAL_EXHAUSTED | Set currentNotional = balance * 0.81 | HARD_REJECT(STRATEGY_BUDGET_EXCEEDED) for all new intents | Returns to APPROVE as positions close and notional falls below the hard limit. |
CLUSTER_LIMIT_HIT | Set clusterExposure = balance * 0.36 | HARD_REJECT(STRATEGY_BUDGET_EXCEEDED) for all intents in the affected cluster | Returns to APPROVE as cluster positions close. |
KILL_SWITCH_ON | Set internal.killswitch.status.active=true | HARD_REJECT(KILL_SWITCH_ACTIVE) on every intent without data fetch | Returns to normal pipeline on manual KillSwitch reset. |
20. State & Persistence
Maintains a durable Postgres ledger of positions and budgets, refreshed on every fill event and reconciliation cycle.
State stores
| Name | Kind | Key | Value shape | TTL | Durability |
|---|---|---|---|---|---|
position_ledger | postgres | account_id + market_id | { notional_usd: float, side: str, last_updated: timestamp } | none | strong |
drawdown_snapshot | redis | account_id | { rolling_24h_pnl_usd: float, snapshot_at: timestamp } | 300s | best-effort |
Cold-start 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).
21. Concurrency & Idempotency
| Aspect | Specification |
|---|---|
| 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 |
| Per-call timeout (ms) | 300 |
| Backpressure strategy | shed |
| Locking / mutual exclusion | per-market_id mutex |
22. Dependencies
Depends on (must run first)
| Bot | Why | Contract |
|---|---|---|
| risk.kill_switch | Global brake — checked first before any data fetch. | RiskVote.HARD_REJECT(KILL_SWITCH_ACTIVE) short-circuits. |
Emits to (downstream consumers)
| Bot | Why | Contract |
|---|---|---|
| exec.smart_router | Approved or reshaped RiskVote passes to SmartRouter. | constraints.max_size_usd is binding for SmartRouter's iceberg split calculation. |
| risk.liquidity_guard | Provides budget_remaining for the LiquidityGuard reshape ceiling. | LiquidityGuard uses min(liquidity_safe_size, portfolio_budget_remaining). |
| risk.oracle_risk_monitor | Provides per-market position limit for oracle proposal-window cap. | OracleRiskMonitor reads portfolio_guard.per_market_limit. |
Sibling bots (same OrderIntent)
| Bot | Why | Contract |
|---|---|---|
| risk.liquidity_guard | Sibling guardrail; both must APPROVE or RESHAPE before SmartRouter runs. | |
| risk.oracle_risk_monitor | Sibling guardrail. | |
| risk.kill_switch | Sibling guardrail. |
Used by (auto-aggregated)
External services
| Service | Endpoint | SLA assumed | On failure |
|---|---|---|---|
| Data API (positions + P&L) | https://data-api.polymarket.com | 99.9% / 500ms p99 | HARD_REJECT(STALE_MARKET_DATA) until positions are readable. |
| CLOB Auth API (balance) | https://clob.polymarket.com | 99.95% / 200ms p99 | HARD_REJECT(STALE_MARKET_DATA) if balance is unreadable. |
23. Security Surfaces
PortfolioGuard reads account balance and position data via authenticated API calls. It never signs orders or holds private keys.
Signing surface
This bot does NOT sign anything.
Abuse vectors considered
- 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
24. Polymarket V2 Compatibility
| Aspect | Value |
|---|---|
| CLOB version | v2 |
| Collateral asset | pUSD |
| EIP-712 Exchange domain version | 2 |
| Aware of builderCode field | no |
| Aware of negative-risk markets | yes |
| Multi-chain ready | no |
| 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. |
API surfaces declared
Networks supported
25. Versioning & Migration
| Field | Value |
|---|---|
| spec | 2.0.0 |
| implementation | 2.1.3 |
| schema | 2 |
| released | 2026-04-28 |
Migration history
| Date | From | To | Reason | Action taken |
|---|---|---|---|---|
| 2026-04-28 | v1 (USDC.e + HMAC builder) | v2 (pUSD + builderCode field) | Polymarket V2 cutover | 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. |
26. Acceptance Tests
Unit Tests
| Test | Setup | Expected result |
|---|---|---|
| Approve when all budgets have room | balance=10000, notional=3000, market_exp=500, cluster_exp=1000, drawdown_pct=2 | APPROVE with no constraints |
| Reshape to per-market budget when market limit is binding | balance=10000, market_exp=1800, max_per_market_pct=20, order_size=400 | RESHAPE_REQUIRED with constraints.max_size_usd=200 |
| Reject when 24h drawdown exceeds hard limit | rolling_drawdown_pct=11, max_24h_drawdown_pct=10 | REJECT with reason_code=STRATEGY_BUDGET_EXCEEDED |
| Reject when aggregate notional budget is zero | balance=10000, notional=8000, max_account_notional_pct=80 | REJECT with reason_code=STRATEGY_BUDGET_EXCEEDED |
| Reshape to cluster budget when cluster limit is binding | balance=10000, cluster_exp=3300, max_cluster_pct=35, order_size=300 | RESHAPE_REQUIRED with constraints.max_size_usd=200 |
| Allowed size is minimum of all four budget checks | aggregate_remaining=900, market_remaining=700, cluster_remaining=1200, order_size=1000 | RESHAPE_REQUIRED with constraints.max_size_usd=700 |
Integration Tests
| Test | Expected result |
|---|---|
| Cross-strategy notional aggregated correctly before approval | Two concurrent strategies each requesting $600 on a $1000 per-market limit result in the second being reshaped to $400 |
| Stale on-chain balance causes reject-safe fallback | REJECT(STALE_MARKET_DATA) when on-chain balance cannot be read |
| KillSwitch active bypasses all budget checks and rejects immediately | REJECT without any data fetches when KillSwitch flag is set |
Property Tests
| Property | Required behaviour |
|---|---|
| Approved order never causes aggregate notional to exceed max_account_notional_pct | Always true |
| Reshape size is always ≤ requested order size | Always true |
| Missing portfolio state never results in APPROVE | Always true — absent on-chain data produces REJECT(STALE_MARKET_DATA) |
27. Operational Runbook
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.
On-call actions
| Alert | First step | Diagnosis | Mitigation | Escalate to |
|---|---|---|---|---|
PortfolioGuardDrawdownBreached | Confirm drawdown percentage on Grafana. Identify which strategies are driving losses. | Review last 24h of fill logs. Determine if loss is genuine or a data artifact. | Do NOT reset the circuit breaker without confirming the root cause. Pause affected strategies first. | Risk pod lead immediately. |
PortfolioGuardDrawdownWarning | Alert monitoring team. Reduce strategy sizes if approaching hard limit. | Check which markets and strategies are contributing to drawdown. | Pause high-exposure strategies pre-emptively. | Risk pod lead if drawdown continues to rise. |
PortfolioGuardStaleLedger | Check Data API connectivity. | If Data API is down, PortfolioGuard correctly blocks all orders as a safe fallback. | Restore Data API connectivity. Do not reduce staleness thresholds. | Infra on-call if Data API is down > 5 minutes. |
PortfolioGuardNotionalHigh | Check which markets have high utilisation. | High notional utilisation is expected near end-of-session. Alert if above 85% unexpectedly early in the session. | Reduce position sizes on high-utilisation markets. | Risk pod lead if aggregate utilisation reaches 95%. |
Manual overrides
polytraders bot reset-drawdown risk.portfolio_guard— Manually clears the 24h drawdown circuit breaker. Requires Risk pod lead sign-off.polytraders bot pause risk.portfolio_guard— Stops emitting RiskVotes. All intents pass without portfolio checks. Use only with Risk pod lead explicit approval.
Healthcheck
GET /health → 200 if Postgres position_ledger is reachable and last balance fetch is < 60s old.28. Promotion Gates
A bot does not advance to the next readiness state until every gate below is green. Gates are observable from production data — no subjective sign-off.
Promote to Shadow
| Gate | How measured | Threshold |
|---|---|---|
| Unit tests pass for all four budget checks | CI test run | 100% pass |
| Postgres position_ledger connectivity verified | Integration test | Pass |
Promote to Limited live
| Gate | How measured | Threshold |
|---|---|---|
| Shadow mode decision matches live mode within 5% over 48h | Grafana shadow vs live comparison | < 5% divergence |
| p99 evaluation latency < 300ms | polytraders_risk_portfolioguard_eval_latency_ms histogram | p99 < 300ms |
Promote to General live
| Gate | How measured | Threshold |
|---|---|---|
| Drawdown circuit breaker fires correctly in staging injection | Failure injection test | Pass |
| Cross-strategy notional aggregation correct in concurrent intent test | Integration test with two concurrent strategies | Pass |
29. Developer Checklist
Ready-to-ship score: 27/27 sections complete · 100%
| Requirement | Status |
|---|---|
| Purpose defined | ✓ done |
| Required inputs listed | ✓ done |
| Parameters defined | ✓ done |
| Defaults defined | ✓ done |
| Warning thresholds defined | ✓ done |
| Hard thresholds defined | ✓ done |
| Safe fallback defined | ✓ done |
| Structured output defined | ✓ done |
| Developer log defined | ✓ done |
| Plain-English explanation | ✓ done |
| Unit tests defined | ✓ done |
| Integration tests defined | ✓ done |
| Property tests defined | ✓ done |
| Failure-mode block complete | ✓ done |
| Reference implementation pseudocode | ✓ done |
| Wire examples (input + output) | ✓ done |
| Reason codes listed | ✓ done |
| Metrics & logs defined | ✓ done |
| State & persistence defined | ✓ done |
| Concurrency & idempotency defined | ✓ done |
| Dependencies declared | ✓ done |
| Security surfaces declared | ✓ done |
| Polymarket V2 compatibility declared | ✓ done |
| Version & migration history declared | ✓ done |
| Operational runbook defined | ✓ done |
| Promotion gates defined | ✓ done |
| Failure-injection recipes defined | ✓ done |