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

1.1 PortfolioGuard

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

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

Docs27/27
donehow scored
Impl11/15
in progresshow scored
Backtest3/4
in progresshow scored
Runtime0/8
pendinghow scored

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

v3.5 · wired registry id 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

LayerRisk  Risk
Bot classGuardrail
AuthorityRejectReshape
StatusLIVE
ReadinessGeneral live
Runs beforeExecutionPlan emit
Runs afterStrategy OrderIntent
Applies toEvery OrderIntent — checks account-level, market-level, and cluster-level limits
Default modegeneral_live
User-visibleAdvanced details only
Developer ownerPolytraders core — Risk pod

Operational profile

Modes supportedquarantine

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

InputSourceRequired?Use
Account balance and available USDCon-chainYesEstablish the total capital base against which percentage-of-account limits are applied.
Current open positions per market with notional sizesData APIYesCompute aggregate notional across all open positions and per-market exposure.
Realised and unrealised P&L over rolling 24 hoursData APIYesCalculate rolling drawdown to enforce the max_24h_drawdown_pct circuit breaker.

5. Required Internal Inputs

InputSourceRequired?Use
Per-strategy order intent queue and current allocationsStrategyRegistryYesAggregate pending and active allocations from all strategies before approving new additions.
KillSwitch active flagKillSwitchYesImmediately reject all orders if the KillSwitch has been triggered.
Cluster mapping for correlated marketsAdmin UINoGroup neg-risk and thematically linked markets into clusters for aggregate concentration checks.

6. Parameter Guide

ParameterDefaultWarningHardWhat it controls
max_account_notional_pct807080Maximum total notional exposure across all open positions as a percentage of current account balance.
max_24h_drawdown_pct10710Maximum allowed rolling 24-hour drawdown as a percentage of starting balance before all new orders are rejected.
max_per_market_pct201520Maximum exposure in any single market as a percentage of account balance.
max_cluster_pct352835Maximum 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

ConditionAction
Aggregate notional ≤ 70% of balanceAPPROVE
70–80% of balanceRESHAPE_REQUIRED — cap this order to the remaining room
> 80% of balanceREJECT — 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

ConditionAction
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

ConditionAction
Market exposure ≤ 15% of balanceAPPROVE
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

ConditionAction
Cluster exposure ≤ 28% of balanceAPPROVE
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

  1. Receive OrderIntent from Strategy layer including market_id, side, and size_usd.
  2. Check KillSwitch active flag; if active, return REJECT with KILL_SWITCH_ACTIVE immediately.
  3. Fetch current account balance in USDC from on-chain to establish the capital base.
  4. Pull aggregate notional across all open positions and pending orders from StrategyRegistry and Data API.
  5. Check rolling 24-hour drawdown from Data API; if > max_24h_drawdown_pct hard limit, return REJECT with STRATEGY_BUDGET_EXCEEDED.
  6. Compute aggregate_budget_remaining = balance × max_account_notional_pct − current_notional. If ≤ 0, return REJECT with STRATEGY_BUDGET_EXCEEDED.
  7. Compute market_budget_remaining = balance × max_per_market_pct − current_market_exposure. If ≤ 0, return REJECT with STRATEGY_BUDGET_EXCEEDED.
  8. 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.
  9. 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.
  10. 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

HelperSignaturePurpose
toUsdcUnitstoUsdcUnits(rawUsd: float) -> intRound a raw pUSD float to the integer unit used by CTFExchangeV2 (6 decimals).
fetchClobPublicfetchClobPublic(path: str) -> JSONUnauthenticated CLOB read; used for market metadata lookups.
isStaleisStale(snapshot: any, maxAgeS: int) -> boolReturns true if snapshot was fetched more than maxAgeS seconds ago.
platformFeeplatformFee(notional: float, prob: float, feeRate: float) -> floatComputes 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 reshapeinternal

{
  "intent_id": "int_4d5e6f7a8b9c0d1e",
  "market_id": "0x2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c",
  "side": "BUY",
  "outcome": "YES",
  "size_usd": 1200,
  "generated_at": "2026-05-09T08:15:00Z"
}

Portfolio state snapshotdata_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

CodeSeverityMeaningActionUser-facing message
KILL_SWITCH_ACTIVEHARD_REJECTGlobal kill switch is active.Immediately return HARD_REJECT without fetching portfolio state.Trading is currently paused. Please try again later.
STALE_MARKET_DATAHARD_REJECTAccount 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_EXCEEDEDHARD_REJECTOne 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_BREACHEDHARD_REJECTRolling 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_RESHAPERESHAPEOrder 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_LIMITHARD_REJECTCluster 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_OBSERVEDWARNPosition 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

MetricTypeUnitLabelsMeaning
polytraders_risk_portfolioguard_decisions_totalcountercountdecision, reason_codeTotal RiskVote decisions broken down by type and reason.
polytraders_risk_portfolioguard_drawdown_pctgaugeratioCurrent rolling 24h drawdown as a fraction of starting balance.
polytraders_risk_portfolioguard_notional_utilisationgaugeratioCurrent aggregate notional as a fraction of max_account_notional_pct.
polytraders_risk_portfolioguard_market_utilisationgaugeratiomarket_idPer-market notional utilisation as a fraction of max_per_market_pct.
polytraders_risk_portfolioguard_cluster_utilisationgaugeratiocluster_idCluster notional utilisation as a fraction of max_cluster_pct.
polytraders_risk_portfolioguard_eval_latency_mshistogramsecondsWall-clock latency from intent receipt to RiskVote emit.

Alerts

AlertConditionSeverityRunbook
PortfolioGuardDrawdownWarningpolytraders_risk_portfolioguard_drawdown_pct > 0.07P1#runbook-portfolioguard-drawdown
PortfolioGuardDrawdownBreachedpolytraders_risk_portfolioguard_drawdown_pct > 0.10P0#runbook-portfolioguard-drawdown-breached
PortfolioGuardNotionalHighpolytraders_risk_portfolioguard_notional_utilisation > 0.85P2#runbook-portfolioguard-notional
PortfolioGuardStaleLedgerrate(polytraders_risk_portfolioguard_decisions_total{reason_code='STALE_MARKET_DATA'}[5m]) > 0P1#runbook-portfolioguard-stale-ledger

Dashboards

  • Grafana — Risk overview / PortfolioGuard
  • Grafana — Account exposure / drawdown and notional utilisation

Log levels

LevelWhat gets logged
DEBUGIndividual budget check values (aggregateBudget, marketBudget, clusterBudget) on every evaluation.
INFORiskVote decision emitted with budget metrics attached.
WARNDrawdown approaching warning threshold; position ledger drift detected.
ERRORAccount 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

SituationUser-facing explanation
Order reduced — per-market limit reachedThis 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 reachedToday'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 limitYour 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 limitThis 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 bindingSeveral 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_modeApproving 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_riskRejecting 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_riskApproving an order using a stale balance or position snapshot that does not reflect recent fills, overstating the available budget.
safe_fallbackIf 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_dependenciesOn-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

ScenarioHow to injectExpected behaviourRecovery
STALE_BALANCEDisconnect Data API for 90sHARD_REJECT(STALE_MARKET_DATA) on every evaluationReturns to APPROVE within one evaluation after Data API reconnects.
DRAWDOWN_BREACHSet 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_EXHAUSTEDSet currentNotional = balance * 0.81HARD_REJECT(STRATEGY_BUDGET_EXCEEDED) for all new intentsReturns to APPROVE as positions close and notional falls below the hard limit.
CLUSTER_LIMIT_HITSet clusterExposure = balance * 0.36HARD_REJECT(STRATEGY_BUDGET_EXCEEDED) for all intents in the affected clusterReturns to APPROVE as cluster positions close.
KILL_SWITCH_ONSet internal.killswitch.status.active=trueHARD_REJECT(KILL_SWITCH_ACTIVE) on every intent without data fetchReturns 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

NameKindKeyValue shapeTTLDurability
position_ledgerpostgresaccount_id + market_id{ notional_usd: float, side: str, last_updated: timestamp }nonestrong
drawdown_snapshotredisaccount_id{ rolling_24h_pnl_usd: float, snapshot_at: timestamp }300sbest-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

AspectSpecification
Execution modelsingle-threaded event loop
Max in-flight50
Idempotency keyintent_id
Replay-safeTrue
Deduplicationby intent_id within a 24h window
Ordering guaranteesFIFO per market_id
Per-call timeout (ms)300
Backpressure strategyshed
Locking / mutual exclusionper-market_id mutex

22. Dependencies

Depends on (must run first)

BotWhyContract
risk.kill_switchGlobal brake — checked first before any data fetch.RiskVote.HARD_REJECT(KILL_SWITCH_ACTIVE) short-circuits.

Emits to (downstream consumers)

BotWhyContract
exec.smart_routerApproved or reshaped RiskVote passes to SmartRouter.constraints.max_size_usd is binding for SmartRouter's iceberg split calculation.
risk.liquidity_guardProvides budget_remaining for the LiquidityGuard reshape ceiling.LiquidityGuard uses min(liquidity_safe_size, portfolio_budget_remaining).
risk.oracle_risk_monitorProvides per-market position limit for oracle proposal-window cap.OracleRiskMonitor reads portfolio_guard.per_market_limit.

Sibling bots (same OrderIntent)

BotWhyContract
risk.liquidity_guardSibling guardrail; both must APPROVE or RESHAPE before SmartRouter runs.
risk.oracle_risk_monitorSibling guardrail.
risk.kill_switchSibling guardrail.

Used by (auto-aggregated)

2.1 2.2 1.2 1.3 1.4 1.6

External services

ServiceEndpointSLA assumedOn failure
Data API (positions + P&L)https://data-api.polymarket.com99.9% / 500ms p99HARD_REJECT(STALE_MARKET_DATA) until positions are readable.
CLOB Auth API (balance)https://clob.polymarket.com99.95% / 200ms p99HARD_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

AspectValue
CLOB versionv2
Collateral assetpUSD
EIP-712 Exchange domain version2
Aware of builderCode fieldno
Aware of negative-risk marketsyes
Multi-chain readyno
SDK used@polymarket/clob-client-v2 ^2.x
Settlement contractCTFExchangeV2 on Polygon
NotesAccount 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

data_apiclob_auth

Networks supported

polygon

25. Versioning & Migration

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

Migration history

DateFromToReasonAction taken
2026-04-28v1 (USDC.e + HMAC builder)v2 (pUSD + builderCode field)Polymarket V2 cutoverMigrated 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

TestSetupExpected result
Approve when all budgets have roombalance=10000, notional=3000, market_exp=500, cluster_exp=1000, drawdown_pct=2APPROVE with no constraints
Reshape to per-market budget when market limit is bindingbalance=10000, market_exp=1800, max_per_market_pct=20, order_size=400RESHAPE_REQUIRED with constraints.max_size_usd=200
Reject when 24h drawdown exceeds hard limitrolling_drawdown_pct=11, max_24h_drawdown_pct=10REJECT with reason_code=STRATEGY_BUDGET_EXCEEDED
Reject when aggregate notional budget is zerobalance=10000, notional=8000, max_account_notional_pct=80REJECT with reason_code=STRATEGY_BUDGET_EXCEEDED
Reshape to cluster budget when cluster limit is bindingbalance=10000, cluster_exp=3300, max_cluster_pct=35, order_size=300RESHAPE_REQUIRED with constraints.max_size_usd=200
Allowed size is minimum of all four budget checksaggregate_remaining=900, market_remaining=700, cluster_remaining=1200, order_size=1000RESHAPE_REQUIRED with constraints.max_size_usd=700

Integration Tests

TestExpected result
Cross-strategy notional aggregated correctly before approvalTwo 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 fallbackREJECT(STALE_MARKET_DATA) when on-chain balance cannot be read
KillSwitch active bypasses all budget checks and rejects immediatelyREJECT without any data fetches when KillSwitch flag is set

Property Tests

PropertyRequired behaviour
Approved order never causes aggregate notional to exceed max_account_notional_pctAlways true
Reshape size is always ≤ requested order sizeAlways true
Missing portfolio state never results in APPROVEAlways 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

AlertFirst stepDiagnosisMitigationEscalate to
PortfolioGuardDrawdownBreachedConfirm 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.
PortfolioGuardDrawdownWarningAlert 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.
PortfolioGuardStaleLedgerCheck 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.
PortfolioGuardNotionalHighCheck 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

GateHow measuredThreshold
Unit tests pass for all four budget checksCI test run100% pass
Postgres position_ledger connectivity verifiedIntegration testPass

Promote to Limited live

GateHow measuredThreshold
Shadow mode decision matches live mode within 5% over 48hGrafana shadow vs live comparison< 5% divergence
p99 evaluation latency < 300mspolytraders_risk_portfolioguard_eval_latency_ms histogramp99 < 300ms

Promote to General live

GateHow measuredThreshold
Drawdown circuit breaker fires correctly in staging injectionFailure injection testPass
Cross-strategy notional aggregation correct in concurrent intent testIntegration test with two concurrent strategiesPass

29. Developer Checklist

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

RequirementStatus
Purpose defined✓ done
Required inputs listed✓ done
Parameters defined✓ done
Defaults defined✓ done
Warning thresholds defined✓ done
Hard thresholds defined✓ done
Safe fallback defined✓ done
Structured output defined✓ done
Developer log defined✓ done
Plain-English explanation✓ done
Unit tests defined✓ done
Integration tests defined✓ done
Property tests defined✓ done
Failure-mode block complete✓ done
Reference implementation pseudocode✓ done
Wire examples (input + output)✓ done
Reason codes listed✓ done
Metrics & logs defined✓ done
State & persistence defined✓ done
Concurrency & idempotency defined✓ done
Dependencies declared✓ done
Security surfaces declared✓ done
Polymarket V2 compatibility declared✓ done
Version & migration history declared✓ done
Operational runbook defined✓ done
Promotion gates defined✓ done
Failure-injection recipes defined✓ done