# Balance Source: https://docs.polysimulator.com/account/balance Check your API wallet balance, P&L, and starting capital. # Balance ``` GET /v1/account/balance ``` Returns your current **API wallet** balance, P\&L metrics, and starting capital. API-authenticated requests always read from the API wallet — see [Wallets](/account/wallets) for how API, MAIN, and SANDBOX wallets relate. *** ## Authentication This endpoint accepts **either** an API key (`X-API-Key: `, or the PM-compat `POLY_API_KEY` / `Authorization: Bearer ps_live_...` aliases) **or** a Supabase Bearer JWT (`Authorization: Bearer `), so the website's API-keys dashboard can render the card via the cookie session. The balance returned is always the **API wallet** regardless of which scheme you use. (Most account endpoints — positions, portfolio, history, equity — are API-key only; balance and `reset-api-balance` are the exceptions that also accept the JWT.) *** ## Request ```bash theme={null} curl -H "X-API-Key: $API_KEY" \ https://api.polysimulator.com/v1/account/balance ``` *** ## Response ```json theme={null} { "balance": "9745.20", "currency": "USD", "starting_balance": "10000.00", "unrealized_pnl": "-242.50", "total_value": "9757.50" } ``` A **Pro+** key sees its \$25,000 baseline instead: ```json theme={null} { "balance": "24745.20", "currency": "USD", "starting_balance": "25000.00", "unrealized_pnl": "-242.50", "total_value": "24757.50" } ``` | Field | Type | Description | | ------------------ | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `balance` | string | Current API wallet cash balance (available for trading) | | `currency` | string | Always `USD` | | `starting_balance` | string | Initial API wallet capital — your tier baseline (Pro: `10000.00`; Pro+: `25000.00`). Falls back to `10000.00` when no API wallet baseline is set. | | `unrealized_pnl` | string | Total account P\&L: `total_value − starting_balance`. Despite the name, this is total equity vs starting capital (cash + open-position mark-to-market), not the open-position MTM delta alone — it equals true unrealized P\&L only when `balance == starting_balance`. | | `total_value` | string | Cash balance + market value of open API positions | Your **total portfolio value** = `balance` + market value of open positions. Use the [Portfolio](/account/portfolio) endpoint for a complete snapshot. Need a clean slate? `POST /v1/account/reset-api-balance` resets the API wallet to your tier baseline (Pro: $10,000; Pro+: $25,000) and closes all open API positions. Resets are **free and currently uncapped** — the cooldown is gated by `API_RESET_COOLDOWN_DAYS` (0 during the beta period, through 2026-08-31). See [Wallets](/account/wallets) for the full reset semantics. *** ## Python Example ```python theme={null} import requests, os from decimal import Decimal BASE_URL = os.environ["POLYSIM_BASE_URL"] headers = {"X-API-Key": os.environ["POLYSIM_API_KEY"]} resp = requests.get(f"{BASE_URL}/v1/account/balance", headers=headers) if resp.status_code == 200: data = resp.json() balance = Decimal(data["balance"]) pnl = Decimal(data["total_value"]) - Decimal(data["starting_balance"]) print(f"Balance: ${balance} | P&L: ${pnl}") elif resp.status_code == 401: print("Invalid API key — check POLYSIM_API_KEY") ``` *** ## Errors All errors return a JSON body of the shape `{"error": "", "message": ""}`. | Status | `error` code | When | | ------ | ------------------- | ------------------------------------------------------------------- | | 401 | `MISSING_AUTH` | No `X-API-Key` and no `Authorization` header supplied | | 401 | `INVALID_KEY` | API key (or Bearer-wrapped key) is unknown, deactivated, or expired | | 404 | `ACCOUNT_NOT_FOUND` | Authenticated user has no account record | *** ## Next Steps * [Positions](/account/positions) — View open and closed positions * [Portfolio](/account/portfolio) — Complete portfolio overview # Equity Curve Source: https://docs.polysimulator.com/account/equity-curve Track your portfolio value over time with hourly snapshots. # Equity Curve ``` GET /v1/account/equity ``` Returns hourly portfolio value snapshots for equity curve charting and performance analysis. *** ## Authentication API key only — `X-API-Key: ` (or the PM-compat `POLY_API_KEY` / `Authorization: Bearer ps_live_...` aliases). A Supabase Bearer JWT is **not** accepted here. *** ## Query Parameters | Parameter | Type | Default | Description | | ----------- | ------ | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `days` | int | 7 | Days of history (1–90) | | `wallet_id` | string | `api` | `all` (every wallet you own), `api` (your API wallet), or an integer wallet id you own (404 `WALLET_NOT_FOUND` otherwise) — see [Wallets → Scoping account reads](/account/wallets#scoping-account-reads-to-a-wallet). Omit to default to your API wallet (legacy `wallet_id IS NULL` snapshots are folded in so pre-multi-wallet history stays continuous; with no active API wallet only the legacy bucket is returned). | *** ## Request ```bash theme={null} # Last 7 days of hourly data (default) curl -H "X-API-Key: $API_KEY" \ "https://api.polysimulator.com/v1/account/equity" # Last 30 days curl -H "X-API-Key: $API_KEY" \ "https://api.polysimulator.com/v1/account/equity?days=30" ``` *** ## Response Snapshots are returned in **chronological (ascending) order** — oldest first, newest last — so you can plot the array as-is. ```json theme={null} [ { "timestamp": "2026-02-06T10:00:00Z", "cash_balance": "9993.50", "position_value": "6.30", "total_value": "9999.80", "pnl": "-0.20" }, { "timestamp": "2026-02-06T11:00:00Z", "cash_balance": "9993.50", "position_value": "7.00", "total_value": "10000.50", "pnl": "0.50" } ] ``` | Field | Type | Description | | ---------------- | ------ | ------------------------------------------------------------------------------- | | `timestamp` | string | ISO-8601 UTC snapshot time (rounded to the hour) | | `cash_balance` | string | Cash balance at this timestamp | | `position_value` | string | Total market value of positions at this timestamp | | `total_value` | string | `cash_balance + position_value` at this timestamp | | `pnl` | string | Profit & loss relative to the wallet's starting balance (API wallet by default) | *** ## Charting Example ```python theme={null} import requests, os import matplotlib.pyplot as plt from datetime import datetime BASE_URL = os.environ["POLYSIM_BASE_URL"] headers = {"X-API-Key": os.environ["POLYSIM_API_KEY"]} equity = requests.get( f"{BASE_URL}/v1/account/equity", headers=headers, params={"days": 30}, # 30 days ).json() times = [datetime.fromisoformat(e["timestamp"]) for e in equity] values = [float(e["total_value"]) for e in equity] plt.figure(figsize=(12, 6)) plt.plot(times, values) # Starting balance = your API-wallet baseline, NOT the $1,000 UI MAIN # wallet: Pro = 10_000, Pro+ = 25_000 (Free keys are read-only). Set it # to your tier's baseline so the reference line is meaningful. API_WALLET_BASELINE = 10_000 # Pro; use 25_000 on Pro+ plt.axhline(y=API_WALLET_BASELINE, color="gray", linestyle="--", label="Starting balance") plt.title("Portfolio Equity Curve") plt.ylabel("Portfolio Value ($)") plt.legend() plt.show() ``` *** ## Errors All errors return `{"error": "", "message": ""}`. | Status | `error` code | When | | ------ | ------------------ | ------------------------------------------- | | 401 | `MISSING_API_KEY` | No API key header supplied | | 401 | `INVALID_KEY` | API key is unknown, deactivated, or expired | | 404 | `WALLET_NOT_FOUND` | `wallet_id` is not owned by the caller | *** ## Next Steps * [Balance](/account/balance) — Current balance snapshot * [Portfolio](/account/portfolio) — Complete portfolio overview # Portfolio Source: https://docs.polysimulator.com/account/portfolio Complete portfolio snapshot with balance, positions, and aggregate P&L. # Portfolio ``` GET /v1/account/portfolio ``` Returns a complete portfolio snapshot combining balance, positions, and aggregate metrics. By default this endpoint reports the caller's **API wallet** (baseline Pro: $10,000 / Pro+: $25,000), so the nested `balance` object uses API-wallet figures — never the \$1,000 UI MAIN wallet. `wallet_id` accepts `all` / `api` / `` (404 `WALLET_NOT_FOUND` for ids you don't own) — see [Wallets → Scoping account reads](/account/wallets#scoping-account-reads-to-a-wallet). With `wallet_id=all`, positions and trade stats span every wallet you own while the `balance` object keeps the API-wallet cash basis (the same blended view as Profile Analysis). *** ## Authentication API key only — `X-API-Key: ` (or the PM-compat `POLY_API_KEY` / `Authorization: Bearer ps_live_...` aliases). A Supabase Bearer **JWT** is **not** accepted here (unlike `GET /v1/account/balance`). *** ## Request ```bash theme={null} curl -H "X-API-Key: $API_KEY" \ https://api.polysimulator.com/v1/account/portfolio ``` *** ## Response ```json theme={null} { "balance": { "balance": "9993.50", "currency": "USD", "unrealized_pnl": "-6.50", "total_value": "10000.50", "starting_balance": "10000.00" }, "positions": [ { "id": 1, "market_id": "0x1a2b3c...", "outcome": "Yes", "quantity": "10.0", "avg_entry_price": "0.65", "current_price": "0.70", "market_value": "7.00", "unrealized_pnl": "0.50", "status": "OPEN" } ], "total_trades": 5, "win_rate": "60.0%" } ``` | Field | Description | | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `balance` | Nested balance object (see [Balance](/account/balance)) | | `positions` | Array of open positions (see [Positions](/account/positions)). Each position's `current_price`, `market_value`, and `unrealized_pnl` are `null` when no live price is cached. | | `total_trades` | Total filled orders for the resolved wallet | | `win_rate` | Percentage of profitable closed positions (string with `%` suffix), or `null` when no closed positions have a classifiable exit price | *** ## Bot Integration ```python theme={null} import requests, os from decimal import Decimal BASE_URL = os.environ["POLYSIM_BASE_URL"] headers = {"X-API-Key": os.environ["POLYSIM_API_KEY"]} portfolio = requests.get( f"{BASE_URL}/v1/account/portfolio", headers=headers, ).json() cash = Decimal(portfolio["balance"]["balance"]) total = Decimal(portfolio["balance"]["total_value"]) positions = portfolio["positions"] print(f"Cash: ${cash} | Total: ${total} | Positions: {len(positions)}") if portfolio["win_rate"]: print(f"Win rate: {portfolio['win_rate']}") # Check if over-allocated if total > 0: cash_pct = cash / total * 100 if cash_pct < 20: print("Warning: Low cash — consider reducing positions") ``` *** ## Errors All errors return `{"error": "", "message": ""}`. | Status | `error` code | When | | ------ | ------------------- | ---------------------------------------------- | | 401 | `MISSING_API_KEY` | No API key header supplied | | 401 | `INVALID_KEY` | API key is unknown, deactivated, or expired | | 404 | `WALLET_NOT_FOUND` | `wallet_id` is not owned by the caller | | 404 | `ACCOUNT_NOT_FOUND` | Authenticated user has no account record | | 500 | `INTERNAL_ERROR` | Unexpected server error building the portfolio | *** ## Next Steps * [Trade History](/account/trade-history) — View filled orders * [Equity Curve](/account/equity-curve) — Portfolio value over time # Positions Source: https://docs.polysimulator.com/account/positions View open and closed positions with current market values and unrealized P&L. # Positions ``` GET /v1/account/positions ``` Returns your current positions with live market values and unrealized P\&L. *** ## Authentication API key only — `X-API-Key: ` (or the PM-compat `POLY_API_KEY` / `Authorization: Bearer ps_live_...` aliases). A Supabase Bearer JWT is **not** accepted here. *** ## Query Parameters | Parameter | Type | Default | Description | | ----------- | ------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `status` | string | — | Filter: `OPEN` or `CLOSED`. **Omit to return all positions.** `OPEN` returns positions with `quantity > 0`; `CLOSED` includes reset-archived (`CLOSED_BY_RESET`) positions. Any other value is ignored (treated as no filter). | | `wallet_id` | int \| `"all"` \| `"api"` | `api` | Wallet scope. An integer scopes to a single wallet you own (404 `WALLET_NOT_FOUND` otherwise). `api` scopes to your API wallet (including legacy rows recorded before per-wallet attribution). `all` returns every position across **all** your wallets, UI MAIN/SANDBOX included. Keywords are case-insensitive; any other value returns 422 `VALIDATION_FAILED`. **Omitted = `api`.** | | `envelope` | bool | `false` | When `true`, wrap the response in the Polymarket-shape `{ "data": [...], "next_cursor": "" }` envelope. `next_cursor` is always `""` because positions are unpaginated. Default `false` returns the bare array. | **Default changed on 2026-06-10.** Before 2026-06-10 the default (param omitted) returned positions across **all** wallets — UI MAIN/SANDBOX included — which could not reconcile with [Balance](/account/balance) / [Portfolio](/account/portfolio) (both API-wallet scoped). The default is now the **API wallet**, consistent with those endpoints. Pass `wallet_id=all` if you depended on the old cross-wallet behaviour. This endpoint is **unpaginated** — there is no `limit`/`offset`. It returns every matching position in one response. For paginated results use [Trade History](/account/trade-history). Polymarket's `status=ALL` has no direct equivalent — simply omit `status` to get the unfiltered set. *** ## Request ```bash theme={null} curl -H "X-API-Key: $API_KEY" \ "https://api.polysimulator.com/v1/account/positions?status=OPEN" # Polymarket-shape envelope curl -H "X-API-Key: $API_KEY" \ "https://api.polysimulator.com/v1/account/positions?status=OPEN&envelope=true" ``` *** ## Response ```json theme={null} [ { "id": 1, "market_id": "0x1a2b3c...", "outcome": "Yes", "quantity": "10.0", "avg_entry_price": "0.65", "current_price": "0.70", "market_value": "7.00", "unrealized_pnl": "0.50", "status": "OPEN", "market_question": "Will it rain tomorrow?" } ] ``` | Field | Type | Description | | ----------------- | -------------- | ------------------------------------------------------------------------------------- | | `id` | int | Position ID | | `market_id` | string | Market `condition_id` | | `outcome` | string | Outcome name (e.g. `Yes` / `No`) | | `quantity` | string | Number of shares held | | `avg_entry_price` | string | Volume-weighted average entry price | | `current_price` | string \| null | Latest market price for this outcome. `null` when no live price is cached. | | `market_value` | string \| null | `quantity × current_price`. `null` when `current_price` is `null`. | | `unrealized_pnl` | string \| null | `market_value − (quantity × avg_entry_price)`. `null` when `current_price` is `null`. | | `status` | string | `OPEN`, `CLOSED`, or `CLOSED_BY_RESET` | | `market_question` | string \| null | Market question text; `null` when the market is not in the local cache | When no live price is cached for a position, `current_price`, `market_value`, and `unrealized_pnl` are returned as `null`. (Aggregate endpoints like [Balance](/account/balance) and [Portfolio](/account/portfolio) fall back to entry-price valuation internally, but this per-position endpoint surfaces the missing price as `null`.) With `envelope=true` the same rows are wrapped Polymarket-style: ```json theme={null} { "data": [ { "id": 1, "market_id": "0x1a2b3c...", "outcome": "Yes", "quantity": "10.0", "avg_entry_price": "0.65", "current_price": "0.70", "market_value": "7.00", "unrealized_pnl": "0.50", "status": "OPEN", "market_question": "Will it rain tomorrow?" } ], "next_cursor": "" } ``` *** ## Python Example ```python theme={null} import requests, os from decimal import Decimal BASE_URL = os.environ["POLYSIM_BASE_URL"] headers = {"X-API-Key": os.environ["POLYSIM_API_KEY"]} # Fetch open positions (this endpoint is unpaginated — no limit/offset) resp = requests.get( f"{BASE_URL}/v1/account/positions", headers=headers, params={"status": "OPEN"}, ) resp.raise_for_status() positions = resp.json() total_unrealized = Decimal("0") for pos in positions: # current_price / market_value / unrealized_pnl are null when no live # price is cached — guard before summing. unrealized = Decimal(pos["unrealized_pnl"]) if pos["unrealized_pnl"] is not None else Decimal("0") total_unrealized += unrealized print(f"{pos['market_id'][:16]} {pos['outcome']}: " f"qty={pos['quantity']} entry={pos['avg_entry_price']} " f"now={pos['current_price']} pnl={pos['unrealized_pnl']}") print(f"\nTotal unrealized P&L: ${total_unrealized}") ``` *** ## Position Lifecycle ```mermaid theme={null} stateDiagram-v2 [*] --> OPEN: BUY order filled OPEN --> OPEN: Additional BUY (avg price updates) OPEN --> CLOSED: SELL all shares OPEN --> CLOSED: Market resolves OPEN --> CLOSED_BY_RESET: API wallet reset CLOSED --> [*] CLOSED_BY_RESET --> [*] ``` A position closed by `POST /v1/account/reset-api-balance` carries the `CLOSED_BY_RESET` status. The `status=CLOSED` filter surfaces both `CLOSED` and `CLOSED_BY_RESET` positions. *** ## Errors All errors return `{"error": "", "message": ""}`. | Status | `error` code | When | | ------ | ------------------- | ---------------------------------------------------------------- | | 401 | `MISSING_API_KEY` | No API key header supplied | | 401 | `INVALID_KEY` | API key is unknown, deactivated, or expired | | 404 | `WALLET_NOT_FOUND` | Integer `wallet_id` does not exist or is not owned by the caller | | 422 | `VALIDATION_FAILED` | `wallet_id` is neither an integer, `all`, nor `api` | *** ## Next Steps * [Portfolio](/account/portfolio) — Aggregate view with balance + positions * [Equity Curve](/account/equity-curve) — Track value over time # Profile Analysis Source: https://docs.polysimulator.com/account/profile-analysis Transparent formulas and definitions for profile performance, PnL, and risk analytics ## Overview The Profile Analysis endpoint aggregates user profile + portfolio data into a single payload for bots, dashboards, and LLM workflows. This page documents the **exact calculation logic** used by the API so users can verify every metric. This endpoint is designed for MCP (Model Context Protocol) tools and AI agents. Values are rounded for display in the API response, but formulas below describe the source calculations. ## Endpoint Full profile analysis with all metrics ### Query Parameters Number of recent trades to include (1-100) Days of equity history to analyze (1-365) Wallet scope for the whole analysis — positions, trading stats, risk metrics, snapshots, recent trades, cash basis and PnL baseline alike. An integer scopes to a single wallet you own (404 `WALLET_NOT_FOUND` otherwise); `api` scopes to your API wallet (including legacy rows recorded before per-wallet attribution); `all` restores the cross-wallet blended view. Keywords are case-insensitive; any other value returns 422 `VALIDATION_FAILED`. **Omitted = `api`.** **Default changed on 2026-06-10.** Before 2026-06-10 this endpoint always blended API-wallet cash with **all** wallets' position values, so `api_pnl` could report large gains on a flat API wallet. The analysis is now scoped to the **API wallet** by default and `api_pnl` reconciles with [Balance](/account/balance). Pass `wallet_id=all` if you depended on the old blended view. ### Authentication API key only — `X-API-Key: ` (or the PM-compat `POLY_API_KEY` / `Authorization: Bearer ps_live_...` aliases). A Supabase Bearer JWT is **not** accepted here. ```bash theme={null} curl -H "X-API-Key: $POLYSIM_API_KEY" \ https://api.polysimulator.com/v1/account/profile-analysis ``` ### Errors All errors return `{"error": "", "message": ""}`. | Status | `error` code | When | | ------ | ------------------- | --------------------------------------------------------------------- | | 401 | `MISSING_API_KEY` | No API key header supplied | | 401 | `INVALID_KEY` | API key is unknown, deactivated, or expired | | 401 | `USER_NOT_FOUND` | Authenticated user no longer exists in the database — re-authenticate | | 404 | `ACCOUNT_NOT_FOUND` | Authenticated user has no account record | | 404 | `WALLET_NOT_FOUND` | Integer `wallet_id` does not exist or is not owned by the caller | | 422 | `VALIDATION_FAILED` | `wallet_id` is neither an integer, `all`, nor `api` | | 500 | `ANALYSIS_ERROR` | Unexpected server error building the analysis | ## Response The top level also carries `analysis_generated_at` (ISO-8601 UTC string), `analysis_version` (string, currently `"1.0.0"`), and a `natural_language_summary` string, alongside these sections: ### `profile` — User Metadata | Field | Type | Description | | -------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------- | | `user_id` | integer | Internal user ID | | `username` | string \| null | Public username | | `display_name` | string \| null | Display name | | `bio` | string \| null | User bio | | `avatar_url` | string \| null | Avatar image URL | | `auth_provider` | string \| null | Auth provider used to sign up (e.g. `email`, `google`) | | `account_created_at` | string \| null | ISO-8601 UTC registration timestamp | | `account_age_days` | integer \| null | Days since registration | | `api_key_tier` | string | API key tier ceiling: `free`, `pro`, or `enterprise` (a Pro+ plan maps to the `enterprise` key tier). Defaults to `free`. | | `profile_visibility` | string | Profile visibility (e.g. `public`, `private`). Defaults to `public`. | ### `balance` — Balance Summary | Field | Type | Description | | ----------------------- | ------ | -------------------------------------------------------------------------------------------------------------- | | `currency` | string | Always `USD` | | `ui_balance` | string | UI (MAIN) trading balance — always the MAIN wallet, regardless of `wallet_id` | | `api_balance` | string | Cash of the **scoped wallet** (the API wallet by default; the field name is historical) | | `starting_ui_balance` | string | UI baseline = seed + top-ups + grants | | `starting_api_balance` | string | PnL baseline of the **scoped wallet** — its actual `starting_balance` (tier-aware: Pro $10,000 / Pro+ $25,000) | | `ui_pnl` | string | UI profit/loss (`ui_balance − starting_ui_balance`) | | `api_pnl` | string | Scoped-wallet profit/loss (`total_portfolio_value − starting_api_balance`) | | `ui_pnl_percentage` | string | UI P\&L as percentage | | `api_pnl_percentage` | string | Scoped-wallet P\&L as percentage | | `total_portfolio_value` | string | Scoped-wallet cash + scoped-wallet positions | | `unrealized_pnl` | string | Open-position mark-to-market P\&L (scoped) | | `realized_pnl` | string | Realized P\&L from closed positions (scoped) | ### `trading_stats` — Trading Statistics | Field | Type | Description | | ----------------------- | ------- | ---------------------------- | | `total_trades` | integer | Total filled trades | | `win_rate` | string | Win rate percentage | | `profit_factor` | string | Gross profit / gross loss | | `total_volume` | string | Total traded volume | | `avg_trade_size` | string | Average trade notional | | `best_trade_pnl` | string | Best single trade P\&L | | `worst_trade_pnl` | string | Worst single trade P\&L | | `unique_markets_traded` | integer | Distinct markets | | `active_trading_days` | integer | Days with at least one trade | ### `risk_metrics` — Risk Analysis | Field | Type | Description | | --------------------------- | ------ | -------------------------------------- | | `portfolio_diversity_score` | string | 1 - HHI (0=concentrated, 1=diverse) | | `largest_position_weight` | string | Largest position as % of portfolio | | `top_3_concentration` | string | Top 3 positions as % of portfolio | | `max_drawdown_7d` | string | Max running-peak drawdown over 7 days | | `max_drawdown_30d` | string | Max running-peak drawdown over 30 days | | `cash_percentage` | string | Cash as % of total | ## How Metrics Are Calculated ### Balance and PnL * **Total portfolio value** = `api_balance + total_position_value` (both scoped to the selected wallet — the API wallet by default) * **UI PnL** = `ui_balance - starting_ui_balance` (UI baseline = seed + topups + grants) * **API PnL** = `total_portfolio_value - starting_api_balance` * **UI PnL %** = `ui_pnl / starting_ui_balance * 100` * **API PnL %** = `api_pnl / starting_api_balance * 100` `starting_api_balance` is the scoped wallet's **actual** `starting_balance` (tier-aware: Pro $10,000 / Pro+ $25,000 for the API wallet; a specific wallet's own baseline when `wallet_id=`), so `api_pnl` here agrees with `GET /v1/account/balance` and `GET /v1/account/portfolio` for the same wallet. Before 2026-06-10 this endpoint used a fixed \$10,000 baseline regardless of tier — that caveat no longer applies. ### Open Position Valuation For each open position: * **Cost basis** = `avg_entry_price * quantity` * **Market value** = `current_price * quantity` (if live price exists) * If no live price is available, cost basis is used as fallback valuation. * **Unrealized PnL (position)** = `market_value - cost_basis` * **Unrealized PnL (account)** = `sum(open_position_market_values) - sum(open_position_cost_basis)` ### Realized PnL (Closed Positions) Closed positions in storage have `quantity = 0`, so realized PnL is reconstructed from filled orders. For each `(market_id, outcome)` closed position: * `buy_notional = sum(BUY order notionals)` * `sell_notional = sum(SELL order notionals)` * `buy_qty = sum(BUY order quantities)` * `sell_qty = sum(SELL order quantities)` * `remaining_qty = buy_qty - sell_qty` * `settlement_value = exit_price * remaining_qty` * **position\_realized\_pnl** = `sell_notional + settlement_value - buy_notional` Then: * **realized\_pnl** = `sum(position_realized_pnl over closed positions)` This handles partial closes and settlement-style closes consistently. ### Win Rate and Win/Loss Counts Win/loss is based on **realized PnL sign**, not on price comparison alone: * Win: `position_realized_pnl > 0` * Loss: `position_realized_pnl < 0` * Break-even: `position_realized_pnl == 0` (excluded from win/loss counts) `win_rate` uses closed position count as denominator: * **win\_rate** = `wins / total_closed_positions * 100` ### Profit Factor and Trade PnL Stats * **Gross profit** = `sum(all positive position_realized_pnl)` * **Gross loss** = `sum(abs(all negative position_realized_pnl))` * **profit\_factor** = `gross_profit / gross_loss` (when gross\_loss > 0) * **best\_trade\_pnl** = max positive closed-position PnL * **worst\_trade\_pnl** = min negative closed-position PnL * **avg\_win\_pnl** = average of positive closed-position PnLs * **avg\_loss\_pnl** = average of negative closed-position PnLs ### Drawdown (7d and 30d) Drawdown is computed from `portfolio_snapshots.total_value` using a **running peak**: 1. Walk snapshots in chronological order 2. Track highest value seen so far (`peak`) 3. Compute drawdown each point: `(value - peak) / peak` 4. Maximum drawdown = most negative drawdown in the period This avoids overstating drawdown by comparing early values to a later peak. ### Portfolio Diversity and Concentration For each open position: * **Effective value** = `market_value` (live price × quantity) when a live price exists, or `cost_basis` (entry price × quantity) as fallback when no live price is available. * Weight per position: `w_i = effective_value / total_invested` * **HHI** = `sum(w_i^2)` * **portfolio\_diversity\_score** = `1 - HHI` * **largest\_position\_weight** = largest `w_i` * **top\_3\_concentration** = sum of top 3 `w_i` Weights always sum to approximately 100% across all open positions. ### Equity Returns Using snapshots in the requested `equity_days` window: * `return_7d`: first-to-last return among snapshots in last 7 days * `return_30d`: first-to-last return among snapshots in last 30 days * `return_all_time`: first-to-last return across the full `equity_days` window Each is: * **period\_return** = `(end_value - start_value) / start_value * 100` If there are fewer than two snapshots in a period, return is `null`. ## Data Scope and Semantics * **Wallet scope**: every section — `open_positions`, `trading_stats`, `risk_metrics`, `equity_timeline`, `category_exposure`, `recent_trades` and the `balance` cash/baseline — covers the wallet selected by `wallet_id` (the **API wallet** when omitted; positions/orders recorded before per-wallet attribution count toward the API wallet). `wallet_id=all` spans every wallet you own. Only `ui_balance` / `ui_pnl` are always MAIN-wallet metrics regardless of scope. * `open_positions` contains only `status=OPEN` with `quantity > 0` * Trading stats and realized metrics use only filled orders * Category exposure is based on open-position values * Cash/invested percentages are relative to current total portfolio value ### `equity_timeline` — Performance Timeline | Field | Type | Description | | ------------------------------ | -------------- | ----------------------------------------------- | | `return_7d` | string \| null | 7-day return | | `return_30d` | string \| null | 30-day return | | `return_all_time` | string \| null | All-time return | | `peak_value` / `peak_date` | string \| null | Portfolio high watermark | | `trough_value` / `trough_date` | string \| null | Portfolio low point | | `current_value` | string \| null | Latest snapshot's total value | | `snapshots_available` | integer | Number of snapshots in the window (default `0`) | | `latest_snapshot_at` | string \| null | ISO-8601 UTC time of the most recent snapshot | ### `category_exposure` — Category Breakdown Array of objects showing exposure per market category: ```json theme={null} [ { "category": "crypto", "position_count": 3, "total_value": "500.00", "weight_percentage": "45.5%" }, { "category": "sports", "position_count": 2, "total_value": "300.00", "weight_percentage": "27.3%" } ] ``` ### `recent_trades` — Recent Filled Orders Array of the most recent filled orders (count controlled by the `recent_trades` query param, default 20). Each entry: | Field | Type | Description | | ----------------- | -------------- | ---------------------------- | | `order_id` | integer | Order ID | | `market_id` | string | Market `condition_id` | | `market_question` | string \| null | Market question text | | `side` | string | `BUY` or `SELL` | | `outcome` | string | Outcome name | | `price` | string | Fill price per share | | `quantity` | string | Shares filled | | `notional` | string | Total cash value of the fill | | `filled_at` | string \| null | ISO-8601 UTC fill timestamp | See [Trade History](/account/trade-history) for the full per-trade semantics. ### `natural_language_summary` A pre-computed human-readable summary of the profile: > "trader123 is a PolySimulator paper trader who joined 45 days ago. Current API balance: $9,750.00 (started at $10,000.00, P\&L: -$250.00, -2.50%). Has made 127 trades across 34 markets over 22 active trading days. Win rate: 62.5%. Currently holding 5 open positions worth ~$1,100.00. Top categories: crypto (45.5%), sports (27.3%), politics (18.2%)." ## MCP Server A standalone MCP server for profile analysis exists and will be published alongside the official SDK (coming soon). Beta-cohort members can request early access via their invite contact. ### Available MCP Tools | Tool | Description | | ---------------------- | ------------------------------ | | `get_profile_analysis` | Full analysis (primary tool) | | `get_balance` | Quick balance check | | `get_positions` | Position list with live prices | | `get_trade_history` | Trade history with pagination | | `get_equity_curve` | Equity curve snapshots | # Trade History Source: https://docs.polysimulator.com/account/trade-history View your filled orders with filtering and pagination. # Trade History ``` GET /v1/account/history ``` Returns your filled orders (trade history) with filtering and pagination. *** ## Authentication API key only — `X-API-Key: ` (or the PM-compat `POLY_API_KEY` / `Authorization: Bearer ps_live_...` aliases). A Supabase Bearer JWT is **not** accepted here. *** ## Query Parameters | Parameter | Type | Default | Description | | ----------- | ------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `market_id` | string | — | Filter by `condition_id` | | `side` | string | — | Filter: `BUY` or `SELL` | | `limit` | int | 50 | Max results (1–200) | | `offset` | int | 0 | Pagination offset | | `wallet_id` | int \| `"all"` \| `"api"` | `api` | Wallet scope. An integer scopes to a single wallet you own (404 `WALLET_NOT_FOUND` otherwise). `api` scopes to your API wallet (including legacy rows recorded before per-wallet attribution). `all` returns filled orders across **all** your wallets, UI MAIN/SANDBOX included. Keywords are case-insensitive; any other value returns 422 `VALIDATION_FAILED`. **Omitted = `api`.** | | `envelope` | bool | `false` | When `true`, wrap the response in the Polymarket-shape `{ "data": [...], "next_cursor": "" }` envelope. `next_cursor` carries the next `offset` value as a string when a full page was returned (more rows likely exist), else `""`. Default `false` returns the bare array. | **Default changed on 2026-06-10.** Before 2026-06-10 the default (param omitted) returned filled orders across **all** wallets — UI MAIN/SANDBOX included. The default is now the **API wallet**, consistent with [Balance](/account/balance) / [Portfolio](/account/portfolio) / [Equity Curve](/account/equity-curve). Pass `wallet_id=all` if you depended on the old cross-wallet behaviour. *** ## Request ```bash theme={null} # All recent trades curl -H "X-API-Key: $API_KEY" \ "https://api.polysimulator.com/v1/account/history?limit=20" # Only sells for a specific market curl -H "X-API-Key: $API_KEY" \ "https://api.polysimulator.com/v1/account/history?market_id=0x1a2b3c&side=SELL" ``` *** ## Response Orders are returned **most-recent first** (descending `filled_at`). ```json theme={null} [ { "order_id": 42, "market_id": "0x1a2b3c...", "side": "BUY", "outcome": "Yes", "price": "0.65", "quantity": "10.0", "notional": "6.50", "fee": "0.09", "status": "FILLED", "price_source": "clob_midpoint_live", "price_age_ms": 12, "filled_at": "2026-02-06T12:00:45Z", "created_at": "2026-02-06T12:00:45Z" } ] ``` | Field | Type | Description | | -------------- | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `order_id` | int | Order ID | | `market_id` | string | Market `condition_id` | | `side` | string | `BUY` or `SELL` | | `outcome` | string | Outcome name (e.g. `Yes` / `No`) | | `price` | string | Fill price per share (string-encoded decimal) | | `quantity` | string | Number of shares filled (string-encoded decimal) | | `notional` | string | Total cash value of the fill (`price × quantity`), string-encoded decimal | | `fee` | string | Taker fee charged on the fill, string-encoded decimal (USD) — the real PM-V2 per-category fee `C × feeRate × p × (1 − p)` (example: 10-share BUY at $0.65 on a 4% politics market → `10 × 0.04 × 0.65 × 0.35 = $0.09`). `0.00`for fee-free fills (maker fills, geopolitics markets, emergency exits). Note: crypto rows filled before 2026-06-10 may have been charged the prior 7.2% rate, while rate-reporting fields (e.g.`fee\_rate\_bps`on`GET /v1/data/trades`) report the current 700 bps — the stored `fee\` is always the authoritative debited amount. See [Trading Fees](/trading/fees). | | `status` | string | Simulator order status. For trade history this is always `FILLED` (the endpoint returns filled orders only). See the note below on Polymarket parity. | | `price_source` | string \| null | Free-form label for how the fill price was sourced (e.g. `clob_midpoint_live`, `clob_midpoint_cached_hft`, `matching_engine`, `ws:`, `emergency_exit_entry_price`). `null` on legacy rows. | | `price_age_ms` | int \| null | Age of the price data at execution time, in milliseconds; `null` when not recorded | | `filled_at` | string \| null | ISO-8601 UTC fill timestamp | | `created_at` | string \| null | ISO-8601 UTC order-creation timestamp | **Polymarket parity:** PolySimulator fills paper orders instantly, so the `status` here is the simulator's order status (`FILLED`), not Polymarket's on-chain trade lifecycle (`MATCHED → MINED → CONFIRMED`, with `RETRYING` / `FAILED`). There is no settlement-finality state to track for simulated trades. If you are porting a Polymarket SDK, treat `FILLED` as equivalent to a terminal `CONFIRMED` trade. With `envelope=true` the rows are wrapped Polymarket-style, with a real cursor: ```json theme={null} { "data": [ /* … same row objects … */ ], "next_cursor": "50" } ``` `next_cursor` is the next `offset` value (as a string) when a full page was returned; it is `""` when the page was short (no more rows). *** ## Errors All errors return `{"error": "", "message": ""}`. | Status | `error` code | When | | ------ | ------------------- | ---------------------------------------------------------------- | | 401 | `MISSING_API_KEY` | No API key header supplied | | 401 | `INVALID_KEY` | API key is unknown, deactivated, or expired | | 404 | `WALLET_NOT_FOUND` | Integer `wallet_id` does not exist or is not owned by the caller | | 422 | `VALIDATION_FAILED` | `wallet_id` is neither an integer, `all`, nor `api` | *** ## Next Steps * [Equity Curve](/account/equity-curve) — Visualize portfolio value over time * [Portfolio](/account/portfolio) — Aggregate portfolio snapshot # Wallets Source: https://docs.polysimulator.com/account/wallets How balances work — the API wallet, the UI main wallet, and sandbox top-ups. # Wallets PolySimulator separates trading balance into distinct **wallets** so that bot trading, manual UI trading, and paid sandbox experiments don't interfere with each other. The wallet that fills your order depends on **how the order was authenticated**. ## Wallets at a glance | Wallet | Starting balance | Reset / top-up | Used by | | --------------- | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | | **API** | Plan-dependent baseline (Free: none — read-only, Pro: $10,000, Pro+: $25,000) | Free reset; cooldown is `API_RESET_COOLDOWN_DAYS` (0 during the beta period, through 2026-08-31) | Any request authenticated with `X-API-Key` | | **MAIN** | \$1,000 | Free resets capped per 30 days (Free: 1, Pro: 4, Pro+: unlimited). Paid `topup_main_reset` SKU bypasses the cooldown. | UI trading on `polysimulator.com` | | **SANDBOX** | Plan-dependent baseline (Free: $0, Pro: $10,000, Pro+: \$25,000) | Paid top-ups (`topup_500`, `topup_2000`, `topup_10000`) credit on top of the baseline | Pro-tier UI users running paid experiments | | **COMPETITION** | Varies per event | Event-scoped, frozen during competition | Event entrants only | As an API user, you only need to think about the **API wallet**. The UI MAIN and SANDBOX wallets are for human-trader use on the website. ## The API wallet When you create your first key with `POST /v1/keys/bootstrap`, your account is seeded with your tier's API wallet baseline ($10,000 on Pro, $25,000 on Pro+; Free-tier API keys are read-only and have no API wallet seeded). Every order you place via `X-API-Key` debits this wallet — `GET /v1/account/balance` always reports the API wallet balance for API-authenticated requests. ```bash theme={null} curl -H "X-API-Key: $POLYSIM_API_KEY" \ https://api.polysimulator.com/v1/account/balance # Pro tier → {"balance": "9745.20", "starting_balance": "10000.00", ...} # Pro+ tier → {"balance": "24745.20", "starting_balance": "25000.00", ...} ``` ### Resetting the API wallet You can reset the API wallet to your tier's starting balance at any time — this also closes any open API-sourced positions. Pro keys reset to $10,000; Pro+ keys reset to $25,000. ```bash theme={null} curl -X POST -H "X-API-Key: $POLYSIM_API_KEY" \ https://api.polysimulator.com/v1/account/reset-api-balance # Pro → {"message": "API balance reset to $10,000.00", # "new_api_balance": "10000.00", # "positions_closed": 3, # "cooldown_days": 0} # Pro+ → {"message": "API balance reset to $25,000.00", # "new_api_balance": "25000.00", # "positions_closed": 0, # "cooldown_days": 0} ``` The reset response carries four fields: | Field | Type | Description | | ------------------ | ------- | ---------------------------------------------------------------------------------------------------- | | `message` | string | Human-readable confirmation | | `new_api_balance` | string | The post-reset balance (your tier baseline) | | `positions_closed` | integer | Number of open API positions force-closed by the reset | | `cooldown_days` | integer | Days until the next reset is allowed — equals `API_RESET_COOLDOWN_DAYS` (`0` during the beta period) | **API wallet resets are free and currently uncapped** — the cooldown is gated by `API_RESET_COOLDOWN_DAYS`, which is `0` during the beta period (through 2026-08-31). After that a daily cooldown may apply, but resets stay included with the API tier. Use them aggressively to test new strategies from a clean baseline. ## Scoping account reads to a wallet The account read endpoints accept a `wallet_id` query parameter with three forms (case-insensitive keywords): | Value | Meaning | | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | *(omitted)* | **Your API wallet** — the default on every account read | | `api` | Your API wallet, explicitly (includes legacy rows recorded before per-wallet attribution) | | `all` | Every wallet you own — UI MAIN/SANDBOX/COMPETITION included | | `` | One specific wallet you own (ids from `GET /v1/me/wallets`); 404 `WALLET_NOT_FOUND` otherwise. MAIN/API wallet ids fold in legacy rows recorded before per-wallet attribution | Supported on [Positions](/account/positions), [Trade History](/account/trade-history), [Profile Analysis](/account/profile-analysis), [Portfolio](/account/portfolio) and [Equity Curve](/account/equity-curve). [Balance](/account/balance) is always API-wallet scoped. Any other `wallet_id` value returns 422 `VALIDATION_FAILED`. ```bash theme={null} # Default — API wallet only curl -H "X-API-Key: $POLYSIM_API_KEY" \ https://api.polysimulator.com/v1/account/positions # Everything you own, including UI wallets curl -H "X-API-Key: $POLYSIM_API_KEY" \ "https://api.polysimulator.com/v1/account/positions?wallet_id=all" ``` **Migration note (2026-06-10):** `GET /v1/account/positions`, `GET /v1/account/history` and `GET /v1/account/profile-analysis` previously defaulted to **all wallets** when `wallet_id` was omitted. Since 2026-06-10 they default to the **API wallet**, consistent with Balance/Portfolio/Equity. Pass `wallet_id=all` to keep the old behaviour. ## Why the wallets are separate The split exists so that: * **Bots can crash without nuking your UI portfolio.** A runaway loop that blows the API wallet doesn't touch your \$1,000 MAIN balance or any SANDBOX experiment. * **The leaderboard stays clean.** Only MAIN-wallet trades count toward leaderboard ranking — bot performance is tracked separately on the API beta dashboard. * **Paid top-ups don't mix with sacred state.** Top-ups always credit SANDBOX wallets; the MAIN \$1,000 is preserved as the canonical "fresh-account" baseline for any user. ## Top-ups (paid) Top-ups are paid Stripe purchases that credit a **SANDBOX wallet** for users who want extra paper-trading capital on the UI side. They do **not** affect the API wallet. | SKU | Price | Credits | | ------------------ | ------ | ---------------------------------------------------------------------------------------------- | | `topup_500` | \$2.99 | \$500 to SANDBOX | | `topup_2000` | \$4.99 | \$2,000 to SANDBOX | | `topup_10000` | \$9.99 | \$10,000 to SANDBOX | | `topup_main_reset` | \$2.99 | Resets MAIN to its seed baseline (\$1,000); credits no new funds (bypasses the reset cooldown) | If you only use the API, you can ignore this entire section. ## See also * [Balance](/account/balance) — `GET /v1/account/balance` reference * [Portfolio](/account/portfolio) — combined balance + positions snapshot * [API keys](/concepts/api-keys) — bootstrap and key management # Get Balance Source: https://docs.polysimulator.com/api-reference/account/get-balance /openapi.json get /v1/account/balance Get the user's current API wallet balance, unrealized PnL, and total value. Accepts both ``X-API-Key`` and ``Authorization: Bearer `` so the website's API-keys dashboard can render the API wallet card via the cookie session. The returned balance is always the **API wallet** (``account.api_balance``) regardless of auth source, since this is the documented API wallet endpoint — UI MAIN/SANDBOX balances live behind ``/v1/me/wallets`` instead. Per-user 5-second cache (override via ``BALANCE_CACHE_TTL_S`` env; set to 0 to disable). Trade writes bust the cache via ``app.api_v1._user_cache.bust_balance_cache``. # Get Entitlements Source: https://docs.polysimulator.com/api-reference/account/get-entitlements /openapi.json get /v1/account/me/entitlements Return the flat entitlement map for the current user. Frontend EntitlementsProvider calls this once at app mount. The values come from Redis cache (60s TTL) keyed by user.id. Auth: ``get_web_user_lazy`` — Supabase Bearer JWT only, NO API-v1 allowlist gate, NO eager DB pool pin (fix for #1803). The endpoint was on ``get_any_user`` since PR-α (2026-05-06) which added X-API-Key dual-auth so SDKs could introspect caps. But ``get_any_user`` enforces the API-v1 allowlist via ``_enforce_api_allowlist``, so every non-whitelisted browser user (every regular Free/Pro signup) got HTTP 403 here and the frontend ``EntitlementsContext`` stayed on FREE_FALLBACK forever — silently breaking cap counters, banners, trial copy gating, and the admin override path for every paid user. Reading your own plan's feature matrix MUST be allowed for every signed-in user regardless of programmatic-API-access status. ``get_web_user_lazy`` provides the right combination of: (1) Bearer JWT auth only (no API gate), and (2) lazy DB session via ``_lazy_db_session()`` so the dispatcher doesn't pin a pool slot for the request lifetime — Pin 5 in ``test_entitlements_session_passthrough.py`` enforces this. Using the plain ``get_web_user`` here would re-introduce the W4 Alert 4 pool-drain outage of 2026-05-17. Programmatic SDK consumers that need their own entitlements should introspect via ``/v1/keys/me`` or the key-bootstrap payload — those paths already pass the API-access gate they legitimately need to. state) without needing a browser session. No ``db: Session = Depends(get_db)`` here — every request would pin a pool slot for the request lifetime and the prod pool is small (size 2 + overflow 1 on staging). Even modest concurrency (frontend EntitlementsProvider + sibling BillingClient polling on the same page) exhausts the pool with ``QueuePool limit reached``. Mirror the ``get_any_user`` P2.2 pattern (PR #1256 Codex-P2 follow-up): lazily acquire a session inside ``get_snapshot`` ONLY when a Redis cache miss requires recomputing the entitlements snapshot. With a 10s cache TTL the typical request is a cache hit and consumes zero DB connections. Initial PR #1445 fix introduced the eager pool pin and tripped pool exhaustion on staging within seconds of deploy — verified 2026-05-17 (issue #1442 follow-up). # Get Equity Curve Source: https://docs.polysimulator.com/api-reference/account/get-equity-curve /openapi.json get /v1/account/equity Get the equity curve from hourly portfolio snapshots. Wallet scoping (2026-06-10, PR #2159 review findings 3+5): ``wallet_id`` accepts an integer wallet id (ownership-checked, 404 otherwise), ``all`` (cross-wallet) or ``api`` — the same family contract as /positions, /history, /portfolio and /profile-analysis, via the shared ``wallet_scope`` module. The default (param omitted) remains the **API wallet**, legacy ``wallet_id IS NULL`` snapshots folded in so pre-Phase-2c history stays continuous (pre-PR-#943 API snapshots had NULL wallet_id). One intentional fix: a caller with no ACTIVE API wallet now sees the legacy NULL-wallet bucket only — previously the fallback silently widened to ALL wallets, contradicting the family convention. UI callers that want MAIN should pass an explicit wallet_id or use ``GET /portfolio/history`` (cookie-auth path). # Get Portfolio Source: https://docs.polysimulator.com/api-reference/account/get-portfolio /openapi.json get /v1/account/portfolio Get a combined portfolio overview: balance, positions, and win rate. Wallet scoping (2026-06-10, PR #2159 review finding 3): ``wallet_id`` accepts an integer wallet id (ownership-checked, 404 otherwise), ``all`` (cross-wallet rows with API-wallet cash basis — the same blended view as /profile-analysis?wallet_id=all) or ``api``. The default (param omitted) remains the **API wallet**, unchanged. The int form's semantics are also unchanged: MAIN/API-kind ids fold in legacy NULL rows; other kinds are strict equality (now via the shared ``wallet_scope`` module instead of inline logic). # Get Positions Source: https://docs.polysimulator.com/api-reference/account/get-positions /openapi.json get /v1/account/positions List the user's positions with live valuations from Redis cache. Wallet scoping (2026-06-10): ``wallet_id`` accepts an integer wallet id (ownership-checked, 404 otherwise), ``all`` (cross-wallet) or ``api`` (the caller's API wallet, legacy NULL rows included). The default (param omitted) is the **API wallet**, matching /balance, /portfolio and /equity; before 2026-06-10 the default was all-wallets — pass ``wallet_id=all`` for the old behaviour. Per-user 5-second cache (override via ``POSITIONS_CACHE_TTL_S`` env). Set TTL to ``0`` to disable caching for debugging. # Get Trade History Source: https://docs.polysimulator.com/api-reference/account/get-trade-history /openapi.json get /v1/account/history Get the user's trade history (filled orders), ordered by most recent. Wallet scoping (2026-06-10): ``wallet_id`` accepts an integer wallet id (ownership-checked, 404 otherwise), ``all`` (cross-wallet) or ``api`` (the caller's API wallet, legacy NULL rows included). The default (param omitted) is the **API wallet**, matching /balance, /portfolio and /equity; before 2026-06-10 the default was all-wallets — pass ``wallet_id=all`` for the old behaviour. # Reset Api Balance Source: https://docs.polysimulator.com/api-reference/account/reset-api-balance /openapi.json post /v1/account/reset-api-balance Reset the user's API wallet to their tier baseline and close all open API positions. Tier-aware reset (Codex P2 review on PR #1499): - Pro grant → $10,000 - Pro+ grant → $25,000 - Enterprise → $10,000 (admin-only tier, Pro-equivalent baseline) - No grant / Free / unknown tier → $10,000 fallback (preserves the legacy behaviour for users with a key but no active grant — e.g., lapsed beta members) Resolution uses ``app.api_access._tier_api_baseline`` which reads ``FEATURE_MATRIX["wallets.api_baseline"]`` so a ``plans.py`` edit propagates here automatically. Accepts both ``X-API-Key`` and ``Authorization: Bearer `` auth so the website's API-keys dashboard can trigger a reset via the cookie session, AND an API client can self-reset programmatically (the bot use case — "give me a clean wallet so the next strategy backtest starts fresh"). The wallet that gets reset is always the **API wallet** regardless of auth source — JWT-auth from the UI does NOT touch the MAIN wallet. Enforces a cooldown period defined by the ``API_RESET_COOLDOWN_DAYS`` environment variable (default 0 = no cooldown during beta). D1 — wrap in the per-user advisory lock so a concurrent reset + place-trade / cancel for the same user serialize at the application layer instead of contending on ``accounts FOR UPDATE``. Reset rewrites ``api_balance`` and closes every open API position; holding the lock across the body prevents the trade-vs-reset race that would otherwise let a fill commit against the pre-reset balance while the reset commit is mid-flight. See PR #1208 for the original advisory-lock landing. # Bootstrap first API key via Bearer JWT (advanced / headless) Source: https://docs.polysimulator.com/api-reference/api-keys/bootstrap-first-api-key-via-bearer-jwt-advanced-headless /openapi.json post /v1/keys/bootstrap Create your **first** API key using a Supabase Bearer access token. Most users don't call this endpoint directly — the dashboard at [polysimulator.com/api-keys](https://polysimulator.com/api-keys) handles the JWT exchange transparently. This endpoint exists for headless / CI setups where there's no browser session: `POST /v1/keys` requires an existing `X-API-Key`, but a fresh user has no key yet. **Auth:** `Authorization: Bearer ` from a programmatic Supabase sign-in. Verified HS256 against `SUPABASE_JWT_SECRET` with `audience="authenticated"`; expiry and `sub` UUID enforced. **Limits:** 1 call/minute, 5 calls/hour per IP — real users only bootstrap once per account. Returns `400 BOOTSTRAP_NOT_ALLOWED` if you already have API key(s); use `POST /v1/keys` for additional keys. # Create Key Source: https://docs.polysimulator.com/api-reference/api-keys/create-key /openapi.json post /v1/keys Generate a new API key. The raw key is returned ONCE in the response. Only the SHA-256 hash is stored in the database. Closed-beta gating (``CLOSED_BETA_MODE=true``, default): only users who already hold an ACTIVE admin-issued key — i.e. cohort members admitted via ``POST /admin/api-keys/issue`` or ``/admin/api-keys/issue-beta`` — can self-service additional keys. Admins themselves bypass the gate. A waitlisted user with no key yet receives a 403 CLOSED_BETA envelope pointing at the apply URL. # Get Ws Token Source: https://docs.polysimulator.com/api-reference/api-keys/get-ws-token /openapi.json post /v1/keys/ws-token Mint a short-lived JWT for authenticating WebSocket connections. Usage: 1. Call this endpoint with X-API-Key header. 2. Connect to ``ws://.../v1/ws/prices?token=`` 3. Token expires in 60 seconds — reconnect with fresh token. # List Keys Source: https://docs.polysimulator.com/api-reference/api-keys/list-keys /openapi.json get /v1/keys List all API keys for the authenticated user (masked). Accepts X-API-Key or Bearer JWT. # List Tiers Source: https://docs.polysimulator.com/api-reference/api-keys/list-tiers /openapi.json get /v1/keys/tiers List all available rate limit tiers. Public read — no authentication required. The tier ladder (rps / rpm / WS conns / batch size) is the documented "authoritative public source" for clients sizing their request cadence, so it must be reachable keyless (PM keeps tier/limit metadata public). The body reads only the ``ApiRateLimit`` rows; it carries no user-scoped data. # Rename Key Source: https://docs.polysimulator.com/api-reference/api-keys/rename-key /openapi.json patch /v1/keys/{key_id} Rename an API key (update its label only). Only the ``name`` is mutable here — tier/permissions are immutable for the life of a key (rotate to change them). 404 if the key isn't the caller's. No auth-cache eviction needed: the cached auth payload doesn't carry the label, so a rename never changes an access decision. # Revoke Key Source: https://docs.polysimulator.com/api-reference/api-keys/revoke-key /openapi.json delete /v1/keys/{key_id} Revoke (delete) an API key. The key is permanently deleted. Any active sessions using this key will be rejected on the next request. # Rotate an API key with a zero-downtime overlap window Source: https://docs.polysimulator.com/api-reference/api-keys/rotate-an-api-key-with-a-zero-downtime-overlap-window /openapi.json post /v1/keys/{key_id}/rotate Issue a **replacement** key with the same label, tier, and permissions as the original, and schedule the original to expire in 24h so a running bot can swap keys without any downtime. The new raw key is returned **once** in this response — store it immediately. The old key keeps authenticating until its overlap window closes, then auto-expires (401 `KEY_EXPIRED`). The replacement counts against your per-account key limit while the old key is still in its overlap window. # Create Checkout Source: https://docs.polysimulator.com/api-reference/billing/create-checkout /openapi.json post /v1/billing/checkout # Create Portal Source: https://docs.polysimulator.com/api-reference/billing/create-portal /openapi.json post /v1/billing/portal # Create Topup Checkout Source: https://docs.polysimulator.com/api-reference/billing/create-topup-checkout /openapi.json post /v1/billing/topup/checkout # Get Subscription Source: https://docs.polysimulator.com/api-reference/billing/get-subscription /openapi.json get /v1/billing/subscription # Get trade fee rate (PM-compat base_fee + polysim fee_rate_bps) Source: https://docs.polysimulator.com/api-reference/clob-compat/get-trade-fee-rate-pm-compat-base_fee-+-polysim-fee_rate_bps /openapi.json get /v1/fee-rate PM-compat ``GET /fee-rate``. Two-field contract: ``base_fee`` mirrors Polymarket's legacy base-fee parameter (observed live 2026-06-10: ``1000`` on fee-charging markets regardless of category, ``0`` on fee-free markets such as geopolitics) so ported py-clob-client bots see byte-compatible PM behavior on the PM field; it is NOT the rate you are charged. The effective per-category taker rate actually charged is ``fee_rate_bps`` — a polysim extra field: Crypto 700, Economics/Culture/Weather/Other 500, Finance/Politics/Mentions/Tech 400, Sports 300, Geopolitics 0 (formula ``C × feeRate × p × (1-p)``; makers pay 0). ``token_id`` is REQUIRED (matches PM): missing/malformed → ``400 {"error": "Invalid token id"}``; a token that resolves to no synced market → ``404 {"error": "fee rate not found for market"}`` (both PM's live-probed messages, verbatim). # Get USDC + conditional-token balance/allowance (PM keys — paper trading: huge) Source: https://docs.polysimulator.com/api-reference/clob-compat/get-usdc-+-conditional-token-balanceallowance-pm-keys-—-paper-trading:-huge /openapi.json get /v1/balance-allowance PM-compat ``GET /balance-allowance``. py-clob-client calls this before every order to pre-flight that the connected wallet has approved spending on the on-chain Exchange contract. Polysim is paper-trading — there's no on-chain allowance — so we return effectively-unlimited sentinel values under PM's keys (``balance`` / ``allowance``, base-unit strings: 1.00 USDC = ``"1000000"``), letting the SDK satisfy its pre-flight check and proceed to /v1/orders. **These sentinels are NOT your simulated cash balance** — read ``GET /v1/account/balance`` for real sizing decisions. The legacy ``collateral`` / ``conditional`` keys (decimal dollars) remain as polysim extras. ``asset_type`` (``COLLATERAL`` default | ``CONDITIONAL``) and ``token_id`` / ``signature_type`` are accepted per the PM SDK call shape; both asset types report the same unlimited sentinel. # List user orders (Polymarket-shape — recommended for SDK clients). Source: https://docs.polysimulator.com/api-reference/clob-compat/list-user-orders-polymarket-shape-—-recommended-for-sdk-clients /openapi.json get /v1/data/orders **Recommended endpoint for SDK clients ported from Polymarket.** Mirrors Polymarket's exact ``GET /data/orders`` envelope: ``{limit, count, next_cursor, data}`` with PM-shape rows (``id`` hex string, ``market``, ``asset_id``, ``original_size``, ``size_matched``, ``price``, ``ORDER_STATUS_*`` enum, unix-int ``created_at``). **Default scope matches PM: OPEN (resting) orders only.** Pass the polysim-extension ``status`` param (``ORDER_STATUS_MATCHED``, ``ORDER_STATUS_CANCELED``, ``ALL``, …) to read history. Filters: ``id``, ``market``, ``asset_id`` (token id), ``status``, ``before`` / ``after`` (unix-seconds timestamps). Paginate by passing the response's ``next_cursor`` back as the ``next_cursor`` query param (PM's request param — what py-clob-client sends); ``cursor`` is accepted as an alias. End of list is signalled by ``next_cursor == "LTE="``. The polysim-native ``GET /v1/orders`` endpoint reads from the same underlying table but emits the legacy ``{orders, has_more, ...}`` envelope with snake_case row fields. Use that one for back-compat with pre-PM-parity polysim SDKs; use this one (``/v1/data/orders``) for new integrations and anything ported from Polymarket. PR #1033 — PM parity. # List user trades (PM-compat — reads from filled orders). Source: https://docs.polysimulator.com/api-reference/clob-compat/list-user-trades-pm-compat-—-reads-from-filled-orders /openapi.json get /v1/data/trades PM-compat ``GET /data/trades``. py-clob-client uses this in bot fill-tracking loops to surface fills the client can correlate by ``client_order_id`` or ``id``. Polysim reads from the ``orders`` table where ``status='FILLED'`` and emits PM-shape rows. Filters: ``market``, ``asset_id``, ``before`` / ``after`` (unix-seconds timestamps, PM convention), ``next_cursor`` / ``cursor`` (same semantics as ``/v1/data/orders``). AF-13: end-of-list ``next_cursor`` is ``"LTE="`` (base64 of ``-1``) matching py-clob-client's documented exit condition. # Polymarket-compat bulk-cancel by id list. Source: https://docs.polysimulator.com/api-reference/clob-compat/polymarket-compat-bulk-cancel-by-id-list /openapi.json delete /v1/orders Mirrors Polymarket's ``DELETE /orders`` with a JSON array body of order ids: ``["id1", "id2", ...]``. Up to 100 ids per request (PM's documented cap is 3000; we cap lower for cohort stability — raise after the May-22 readout shows we're stable). # Polymarket-compat single-order cancel (body-payload). Source: https://docs.polysimulator.com/api-reference/clob-compat/polymarket-compat-single-order-cancel-body-payload /openapi.json delete /v1/order Mirrors Polymarket's ``DELETE /order`` with body ``{orderID: "..."}``. The legacy path-param form ``DELETE /v1/orders/{id}`` stays live — both reach the same underlying cancel logic. # Polymarket-compat single-order lookup (legacy top-level path). Source: https://docs.polysimulator.com/api-reference/clob-compat/polymarket-compat-single-order-lookup-legacy-top-level-path /openapi.json get /v1/order/{order_id} Lookup a single order by id (legacy top-level path). Polysim-extension path — current Polymarket exposes this as ``GET /data/order/{orderID}`` (nested under ``/data/``, sibling to ``/data/orders`` and ``/data/trades``). The legacy top-level ``GET /order/{id}`` shape PM used to expose was retired in mid-2025 and is no longer documented at ``docs.polymarket.com``. Both routes are kept live: this top-level path stays for back-compat with hand-coded clients that pre-date the PM move. New code should prefer ``GET /v1/data/order/{order_id}`` (the PR-#1121 follow-up alias added to match the current PM surface). Both paths funnel through ``_lookup_pm_order_by_id`` so behaviour is identical. # Polymarket-compat single-order lookup (PM-shape /data/ path). Source: https://docs.polysimulator.com/api-reference/clob-compat/polymarket-compat-single-order-lookup-pm-shape-data-path /openapi.json get /v1/data/order/{order_id} Mirrors Polymarket's current ``GET /data/order/{orderID}`` endpoint. Polymarket moved single-order lookup under ``/data/`` in mid-2025 (sibling to ``/data/orders`` and ``/data/trades``). This is the canonical path bot porters reading current PM docs should reach for. The legacy top-level ``GET /v1/order/{id}`` stays live for back-compat with hand-coded clients. # Polymarket-compat single order placement. Source: https://docs.polysimulator.com/api-reference/clob-compat/polymarket-compat-single-order-placement /openapi.json post /v1/order Mirrors Polymarket's ``POST /order`` body shape: ``{order: {tokenId, makerAmount, takerAmount, side, ...}, owner, orderType}``. The on-chain fields (``salt``, ``signature``, ``signer``, ``maker``, ``signatureType``, ``nonce``, ``feeRateBps``, ``builder``) are accepted but ignored — we don't have a chain. Maker/taker amounts use PM's 6-decimal fixed-point convention (USDC and outcome tokens both). # Get Book By Token Source: https://docs.polysimulator.com/api-reference/clob-read-public/get-book-by-token /openapi.json get /v1/book Get the order book for a single token. Public endpoint — no authentication required. Mirrors Polymarket's `GET /book?token_id=...` shape (``market``, ``asset_id``, ``timestamp``, ``hash``, ``tick_size``, ``neg_risk``, ``last_trade_price``, ``min_order_size`` from PR #1033 P1-1). **Level ordering is byte-identical to Polymarket's LIVE ``/book`` wire** (verified against ``clob.polymarket.com/book`` 2026-06-10): ``bids`` are ASCENDING by price (best/highest bid = ``bids[-1]``) and ``asks`` are DESCENDING by price (best/lowest ask = ``asks[-1]``) — the best level is at the TAIL on BOTH sides, exactly as PM's live CLOB returns it. Note this is the *wire* ordering; PM's published docs describe the opposite, but the live wire does not match the docs and wire-parity is the contract here. ``mid`` and ``spread`` are computed from the FULL book (true top-of-book), not from the truncated slice — see ``_book_levels_and_summary``. **RECOMMENDED — read order-independently**: do NOT index a fixed position. Compute best bid as ``max(float(b["price"]) for b in bids)`` and best ask as ``min(float(a["price"]) for a in asks)``. This stays correct regardless of array order and survives any future wire-format change on either side. **MIGRATION NOTE (2026-06-10)**: book level ordering changed to PM live-wire parity. History (3 changes in 24h): pre-2026-05-19 bids were ASCENDING (best = ``bids[-1]``); the 2026-06-10 AM change flipped to bids DESCENDING / asks ASCENDING (best = ``[0]``) to match PM's *docs*; this change re-sorts to PM's *live wire* — bids ASCENDING / asks DESCENDING (best = ``[-1]``). A bot that read ``bids[0]`` for the best bid during the brief docs-aligned window now gets the WORST bid; switch to the order-independent ``max``/``min`` reads above. # Get Clob Book By Path Source: https://docs.polysimulator.com/api-reference/clob-read-public/get-clob-book-by-path /openapi.json get /v1/clob/book/{token_id} Path-param alias for ``GET /v1/book?token_id=...`` — see that route for the full contract (PM-parity field set, level ordering, ``min_order_size`` derivation). This handler is a thin delegate so the two paths always return the same shape. # Get Last Trade Price Source: https://docs.polysimulator.com/api-reference/clob-read-public/get-last-trade-price /openapi.json get /v1/last-trade-price Polymarket-compat ``GET /last-trade-price?token_id=...``. Returns the cached last-traded price + side hint from our Redis price cache. Distinct from ``GET /price`` which has a side parameter for best-ask/best-bid semantics. This endpoint is pure last-trade. # Get Market By Token Source: https://docs.polysimulator.com/api-reference/clob-read-public/get-market-by-token /openapi.json get /v1/markets-by-token/{token_id} Polymarket-compat ``GET /markets-by-token/{token_id}``. Reverse-resolve a CLOB token id to its parent ``condition_id`` + the canonical outcome label. Resolves DB-first via the JSONB-indexed ``markets.clob_token_ids`` column, then falls back to the Redis poller cache. The funded-key audit on 2026-05-07 caught a divergence: ``/v1/midpoint?token_id=X`` accepted token ids that the poller cache hadn't seen yet, while ``/v1/markets-by-token/{X}`` 404'd them. The DB lookup closes that gap on every market the Gamma sync has hydrated (which is now all of them, post-PR #887 backfill). # Get Midpoint Source: https://docs.polysimulator.com/api-reference/clob-read-public/get-midpoint /openapi.json get /v1/midpoint Get the order-book midpoint for a single token. Public endpoint — no authentication required. Mirrors Polymarket's `GET /midpoint?token_id=...`. # Get Neg Risk Source: https://docs.polysimulator.com/api-reference/clob-read-public/get-neg-risk /openapi.json get /v1/neg-risk/{token_id} Polymarket-compat ``GET /neg-risk/{token_id}``. Returns whether the market routes through Polymarket's negative-risk contract — the REAL upstream flag, not a stub. PM-compat P1 batch (eval 2026-06-10, pm-compat finding): this endpoint hardcoded ``false`` while polysim's own ``/v1/book`` reported ``neg_risk: true`` for the same token (the book passes the upstream payload through). An SDK port branching on ``get_neg_risk()`` — e.g. to pick the neg-risk exchange adapter or filter mutually-exclusive event groups — got data that contradicted the book one call earlier. Single source now: the upstream CLOB book payload, with fallbacks. Resolution cascade: 1. Live CLOB ``/book`` payload's ``neg_risk`` bool (same source ``GET /v1/book`` serializes). 2. Last-known cached book (``orderbook:`` / ``clob:book:`` Redis layers) when the live fetch blips. 3. Catalog classification off the market row's tags/category (``_classify_neg_risk``) when no book is available at all. 4. ``404 {"error": "market not found"}`` — PM's live message verbatim (probed 2026-06-11) — when the token resolves to no known market. Note: sim EXECUTION semantics don't change for neg-risk markets — we report the real flag so routing/branching code behaves like it would against PM, but fills still settle per the standard binary flow (documented divergence). # Get Neg Risk Query Source: https://docs.polysimulator.com/api-reference/clob-read-public/get-neg-risk-query /openapi.json get /v1/neg-risk PM-compat query-string alias for ``GET /v1/neg-risk/{token_id}``. # Get Price Source: https://docs.polysimulator.com/api-reference/clob-read-public/get-price /openapi.json get /v1/price Get a price for a single token. Public endpoint — no authentication required. Mirrors Polymarket's ``GET /price?token_id=...&side=BUY|SELL``. ``side=BUY`` returns the best BID; ``side=SELL`` returns the best ASK — matching Polymarket's LIVE CLOB wire behaviour and its API reference ("Returns the best bid price for BUY side or best ask price for SELL side" — docs.polymarket.com/api-reference/ market-data/get-market-price). NOTE: PM's own docs are self-contradictory here — the orderbook *guide* (trading/orderbook#prices) claims "getPrice BUY → Best ask", which contradicts PM's actual wire. PolySim originally mirrored the guide text and was therefore INVERTED vs live PM until 2026-06-10 (Fable API evaluation P0, verified twice with back-to-back live probes: PM /price?side=BUY == PM /book best bid). The wire is the authority. ``side`` is treated as REQUIRED at the handler level — both missing and invalid values return 400 ``{"error": "Invalid side"}`` matching Polymarket's CLOB contract. Migration note (2026-06-10): before this fix, ``side=BUY`` returned the best ask and ``side=SELL`` the best bid. Any PolySim-native consumer that adapted to the inverted semantics will now see values flipped to the other side of the book; bots ported from py-clob-client / live PM become correct without changes. Implementation note (post-PR #1091 Codex/Copilot review): ``side`` is declared ``Optional`` in the FastAPI signature (not ``Query(...)``) precisely so the missing-side case lands in this handler — not in ``api_validation_error_handler`` as a 422 — and yields the PM-style 400 uniformly. The earlier required-Query variant routed missing side through FastAPI's validator before reaching this code, giving 422 instead of 400. PM SDK porters branching on ``{"error": "Invalid side"}`` would otherwise hit two different envelopes for the same logical error. Migration note (2026-05-11): the prior PolySim-extension behaviour where ``side`` was optional and missing-side returned the cached last-traded price has been removed. SDK consumers relying on that fallback should switch to ``GET /v1/last-trade-price`` (last fill) or ``GET /v1/midpoint`` (best-bid/best-ask average). Fresh-brain audit PR #1075 flagged the silent fallback as drift that bites Polymarket SDK ports on first use. # Get Prices Batch Source: https://docs.polysimulator.com/api-reference/clob-read-public/get-prices-batch /openapi.json post /v1/prices Get prices for multiple tokens in one request. Public endpoint — no authentication required. Two body shapes are accepted (audit P0-2, PR #1309): 1. PolySim ``{"token_ids": ["TOKEN_A", "TOKEN_B"]}`` → returns ``{"TOKEN_A": "0.52", "TOKEN_B": "0.74"}``. 2. Polymarket ``[{"token_id": "TOKEN_A", "side": "BUY"}, ...]`` (py-clob-client / clob-rust shape) → returns ``{"TOKEN_A": {"BUY": "0.52"}, "TOKEN_B": {"BUY": "0.74"}}``. The PM-shape response shape mirrors PM exactly (per ``docs.polymarket.com/trading/orderbook`` §Batch Requests). The simulator returns the cached outcome price for both ``BUY`` and ``SELL`` — separate bid/ask aren't held in the price cache, so PM porters get the correct response *shape* (no more 422) but the BUY/SELL values are the same midpoint. Use single-token ``GET /v1/price?token_id=...&side=...`` when you need a true best-bid / best-ask off the live book. # Get Server Time Source: https://docs.polysimulator.com/api-reference/clob-read-public/get-server-time /openapi.json get /v1/time Return the current server time as a bare Unix-seconds integer. PM-CLOB canonical (PM-compat round 2, audit 2026-05-19 AF-3): Polymarket emits ``GET /time`` as a bare JSON integer (``1779147906``), NOT as an object (``{"server_time":1779147906}``). py-clob-client does ``int(response.text)`` on this surface; the object form TypeErrors on every startup health check. MIGRATION NOTE (PR #1509 Copilot review, 2026-05-19): pre-AF-3 polysim wrapped this in ``{"server_time": N}``. Clients that read the old shape used ``resp.json()["server_time"]`` to extract the integer; under the new bare-integer form the equivalent is ``int(resp.text)`` (what py-clob-client does) or ``resp.json()`` (which now returns the int directly because the body parses as a JSON integer). Code that previously did ``int(resp.json()["server_time"])`` will KeyError under the new shape — clients need to switch to one of the bare forms above. # Get Spread Source: https://docs.polysimulator.com/api-reference/clob-read-public/get-spread /openapi.json get /v1/spread Get the best bid/ask spread for a single token. Public endpoint — no authentication required. Mirrors Polymarket's `GET /spread?token_id=...`, including the $0.10 cap on reported spread for thin markets. # Get Tick Size Source: https://docs.polysimulator.com/api-reference/clob-read-public/get-tick-size /openapi.json get /v1/tick-size/{token_id} Polymarket-compat ``GET /tick-size/{token_id}``. Resolution order (freshest source wins): 1. **CLOB WS overlay** (PM-MCP P1-2): if a recent ``tick_size_change`` event has been observed for this token, emit the post-change value. PM shrinks the tick from 0.01 → 0.001 when a book moves past 0.96 or below 0.04 (and grows back when the book pulls away). The two directions have asymmetric correctness consequences (Copilot review on PR #1121): - **Grow (0.001 → 0.01)**: previously-valid 0.001-step quotes are no longer multiples of the new tick. PM rejects with ``INVALID_ORDER_MIN_TICK_SIZE``. This is the case where missing the change silently breaks the bot. - **Shrink (0.01 → 0.001)**: 0.01-step quotes are still valid multiples of 0.001, so orders aren't rejected — but the bot is leaving precision on the table and quoting less competitively than the rest of the book. SDK porters reading PM docs expect tick refresh in both directions. 2. **``markets.minimum_tick_size`` column** (populated by the Gamma sync from ``orderPriceMinTickSize``). 3. **404 ``TOKEN_NOT_FOUND``** when the token doesn't resolve to any market. Audit 2026-05-15 P0-E (PR #1239): pre-fix this returned 200 with the default 0.01 fallback — a real-money risk for py-clob-client porters who quote on the wrong tick. On transient upstream failure (DB/Redis unavailable) the handler returns ``503`` with ``Retry-After: 2``, NOT a silent default tick. **Value consistency (P1-11, 2026-06-09):** the tick VALUE is resolved through :func:`resolve_canonical_tick`, the single source shared with ``GET /book`` and order validation, so all three surfaces agree. This handler resolves NETWORK-FREE (``allow_live_book=False``): overlay → DB column → default, with NO per-read ``GET /book`` fetch — that fetch would add up to ~8 s latency and contend for the shared CLOB semaphore. ``/book`` reads keep seeding the overlay so this surface stays fresh without paying the fetch on every read. This handler keeps the **JSON-number** wire shape (``minimum_tick_size: 0.01``) — PM emits ``/tick-size`` as a number, distinct from ``/book``'s string ``tick_size``; we change the value source, not the type. The token-existence (404) and upstream-failure (503) envelopes below are unchanged. # Get Tick Size Query Source: https://docs.polysimulator.com/api-reference/clob-read-public/get-tick-size-query /openapi.json get /v1/tick-size PM-compat query-string alias for ``GET /v1/tick-size/{token_id}``. Same response shape, same resolution chain, same error envelopes as the path form. Added to satisfy py-clob-client's hardcoded query URL pattern (audit AF-5). # Post Books Batch Source: https://docs.polysimulator.com/api-reference/clob-read-public/post-books-batch /openapi.json post /v1/books Polymarket-compat ``POST /books``. Body: ``[{token_id}, ...]`` (PM-compat) **or** ``{items: [{token_id}, ...]}`` (legacy wrapped form). Returns one full ``OrderBookSnapshot`` per token (PM-compat shape including ``market``, ``asset_id``, ``hash``, ``tick_size``, ``neg_risk``, ``last_trade_price``). Tokens with no book are omitted from the response (rather than returning empty snapshots — saves SDK-side filtering). # Post Midpoints Batch Source: https://docs.polysimulator.com/api-reference/clob-read-public/post-midpoints-batch /openapi.json post /v1/midpoints Polymarket-compat ``POST /midpoints``. Body: ``[{token_id}, {token_id}, ...]`` (PM-compat) **or** ``{items: [{token_id}, ...]}`` (legacy wrapped form). Returns ``{token_id: midpoint_string}``. Bounded to 50 tokens per request to match PM's documented cap. # Post Spreads Batch Source: https://docs.polysimulator.com/api-reference/clob-read-public/post-spreads-batch /openapi.json post /v1/spreads Polymarket-compat ``POST /spreads``. Body: ``[{token_id}, {token_id}, ...]`` (PM-compat) **or** ``{items: [{token_id}, ...]}`` (legacy wrapped form). Returns ``{token_id: spread_string}``. # Price history (PM wire shape — {history: [{t, p}]}). Source: https://docs.polysimulator.com/api-reference/clob-read-public/price-history-pm-wire-shape-—-] /openapi.json get /v1/prices-history Price history for a single token — PM wire parity. Mirrors Polymarket's ``GET /prices-history?market=...`` (P0 eval finding, 2026-06-10): accepts PM's REQUIRED ``market=`` param (previously 422'd as "missing token_id"), returns PM's exact ``{"history": [{"t": int, "p": float}]}`` envelope by default (previously a bare OHLCV array that KeyError'd ``resp["history"]`` on ported code), supports ``startTs``/``endTs``/``fidelity``, and 400s (not 422s) on the missing-param case with PM's live error message verbatim. Public endpoint — no authentication required. # List events with their markets (event-first shape). Source: https://docs.polysimulator.com/api-reference/events/list-events-with-their-markets-event-first-shape /openapi.json get /v1/events List events with their child markets and live prices. Thin delegate to the legacy ``GET /events`` handler in ``app.main.list_events``. The legacy path stays the implementation surface — this wrapper exists so SDK consumers can discover the events feed via the v1 OpenAPI schema instead of having to know about the root-mounted ``/events`` route. Response shape: ``{ events: [...], total: int, limit: int, offset: int, cache_source: str, has_sports: bool, message?: str }`` On a cold poller cache, the legacy handler returns a 503 with ``Retry-After: 5``; this wrapper passes that response through unchanged so SDK retry logic stays uniform. # Export Trades Csv Source: https://docs.polysimulator.com/api-reference/export/export-trades-csv /openapi.json get /v1/export/trades.csv Export the caller's FILLED trades as a streamed CSV. Auth: dashboard session (Supabase JWT) **or** a ``ps_live_``/``ps_test_`` API key. FILLED-only (``status='FILLED'`` and ``filled_at IS NOT NULL``). Keyset-paginated under the hood (``filled_at DESC, id DESC``) and streamed in batches so deep histories don't materialise in memory. Capped at ``EXPORT_MAX_ROWS`` per request. # Get Api Me Source: https://docs.polysimulator.com/api-reference/health/get-api-me /openapi.json get /v1/me Get the authenticated user's profile and account balance. Accepts both ``X-API-Key`` and ``Authorization: Bearer `` authentication. Useful as a quick connectivity test and to retrieve balance without hitting the full /v1/account/balance endpoint. The ``permissions`` field reflects the scope of the AUTHENTICATING key (e.g. ``["read", "trade"]`` or ``["read"]`` for a free-tier / read-only key), so a developer can inspect what their key can do without parsing the keys endpoint. For Bearer-JWT (dashboard) auth there is no API key, so the documented default scope is returned. Per-user 5s Redis cache (override via ``ME_CACHE_TTL_S`` env). Set TTL to 0 to disable. Cache de-amplifies the 2-DB-query body — the pool was exhausting at c=50 sustained because every authed request held a session for ~150-300ms (launch-eve perf RCA, F-2). # Health Check Source: https://docs.polysimulator.com/api-reference/health/health-check /openapi.json get /v1/health Liveness probe — always returns 200 if the process is running. Use this for Kubernetes/Docker liveness checks. # Liveness Check Source: https://docs.polysimulator.com/api-reference/health/liveness-check /openapi.json get /v1/health/live Liveness probe — process + asyncio loop responsiveness check only. Returns 200 + ``{"status": "alive", ...}`` if the FastAPI worker is reachable and the event loop is not wedged. **Does NOT touch the database. Does NOT touch Redis.** Intended as the Traefik routing probe so that a sick database (pool drain, replica lag, query storm) does NOT cause Traefik to de-register the backend. When the DB is sick: • ``/v1/health/live`` → 200 → Traefik keeps routing • ``/v1/health/ready`` → 503 → docker marks unhealthy, monitoring alerts fire, operator intervenes The cached / static / Redis-only paths can still serve while the DB recovers, instead of the whole service 404'ing at the edge — which is exactly what bit prod in the 2026-05-17 outage (memory ``prod_outage_2026_05_17_entitlements_pool_drain``). The ``await asyncio.sleep(0)`` is intentional: it yields control back to the event loop for a single tick, so if a sync handler is pegging the loop the probe will still time out (correctly surfacing the wedge). Without it a wedged loop could in theory complete this coroutine synchronously between handlers. # Readiness Check Source: https://docs.polysimulator.com/api-reference/health/readiness-check /openapi.json get /v1/health/ready Readiness probe — checks that database and Redis are reachable. Returns 200 with status "ok" if all dependencies are healthy. When ``HEALTH_READY_TOLERANCE_ENABLED`` is set (opt-in; default OFF), a single transient failure (e.g. a slow DB-ping during the bulk-poller burst) returns 200 with status "tolerating" — the endpoint only flips to **503** with status "degraded" after ``HEALTH_READY_FAIL_THRESHOLD`` consecutive failed probes (see the tolerance block above for the WHY). With the flag OFF (the default) the endpoint is strict fail-fast: any failed probe returns 503 immediately, exactly as it did pre-#1850. Use this for docker HEALTHCHECK, monitoring (Prometheus blackbox), and alerting. **Do NOT use this as the Traefik routing probe** — when the DB is sustained-unhealthy this endpoint returns 503, which would cause Traefik to de-register the backend and 404 every request at the edge (incident 2026-05-17). For routing, use ``/v1/health/live``, which proves the process is alive without touching DB/Redis. # System Status Source: https://docs.polysimulator.com/api-reference/health/system-status /openapi.json get /v1/status Detailed system status for monitoring dashboards. Includes database latency, Redis stats, WebSocket connections, and market cache information. # Heartbeat dead-man's-switch — auto-cancel orders on missed pings. Source: https://docs.polysimulator.com/api-reference/heartbeats/heartbeat-dead-mans-switch-—-auto-cancel-orders-on-missed-pings /openapi.json post /v1/heartbeats Register or refresh a dead-man's-switch heartbeat. If the server stops receiving heartbeats for longer than ``interval_ms + grace``, all resting orders for this API key's account are auto-cancelled. Polymarket-compatible: ``POST /heartbeats`` (PM-shape root path, no ``/v1/``) accepts the same body and response. Both paths route to this handler. Bounds: ``interval_ms`` must be ``[1000, 60000]``. Bots should ping at half the interval (or finer) to stay ahead of expiry. Grace window: ``max(1s, 0.25 * interval_ms)``. **Storage**: Redis-backed sorted set + per-registration hash. Survives api-worker restarts; the sweeper runs on the background leader worker with a 60s leader-election TTL so a leader crash promotes a follower automatically. Refresh/sweep TOCTOU race is closed via Redis ``WATCH``/``MULTI`` — a refresh that arrives between the sweeper's ``ZRANGEBYSCORE`` and its ``ZREM`` aborts the dead-man fire and keeps the bot alive. # List Updown Intervals Source: https://docs.polysimulator.com/api-reference/list-updown-intervals /openapi.json get /v1/markets/updown/intervals Get updown markets organized by interval with rotation timing metadata. Designed for trading bots that need to: 1. Know which markets are currently active per interval 2. See how much time remains before the current interval expires 3. Discover the next interval's markets to rotate into Returns a dict keyed by interval (1H, 4H, daily, etc.) with: - current: list of active markets for this interval (end_date in the future) - seconds_until_rotation: seconds until the soonest market in this interval expires - next_rotation_at: ISO timestamp of when the soonest market expires - asset_markets: nested {asset: [markets]} for quick lookup # Batch Prices Source: https://docs.polysimulator.com/api-reference/market-data/batch-prices /openapi.json post /v1/prices/batch Fetch cached prices for multiple markets in one request. Uses a Redis pipeline for efficient batch lookups. Limited to 50 markets per request (adjustable per API tier). # Get Candles Source: https://docs.polysimulator.com/api-reference/market-data/get-candles /openapi.json get /v1/markets/{condition_id}/candles Fetch OHLCV candles from the Polymarket CLOB /prices-history endpoint. The CLOB returns price history keyed by token_id, not condition_id. This endpoint resolves the token_id from the cached price data. If `outcome` is omitted, the first available outcome is used automatically (supports binary Yes/No and categorical Up/Down markets). Sub-hour intervals (``1m``/``5m``/``15m``) are not supported because Polymarket's CLOB ``/prices-history`` upstream is hourly-granular — we can't reconstruct 5-minute buckets from 1-hour samples. The funded-key audit on 2026-05-07 caught these silently returning ``[]``; we now 400 explicitly so SDK porters fail loudly instead of rendering empty charts. # Get Market Source: https://docs.polysimulator.com/api-reference/market-data/get-market /openapi.json get /v1/markets/{condition_id} Fetch a single market's detail with live price. If ``include_book`` is true, the response also includes a ``book`` key with the CLOB order-book snapshot (async, best-effort). (``include_book=true`` bypasses the SSR cache wrapper since each book snapshot is volatile and would dirty the cached payload.) P2.3 cache-hit DB-hold fix (sibling of P2.2 on auth.py): no ``db: Session = Depends(get_db)`` at signature level. The Redis cache-hit branch (the common case) returns without ever touching DB; only the cache-miss branch lazily acquires a session via ``_lazy_db_session()``. This frees one pool slot per cache-hit request, multiplied by the entire request lifetime — same pattern as the P2.2 keystone fix. PR (2026-05-17): wrapped in the SSR cache + single-flight helper. Under FE Next.js SSR fan-out (~30-60 parallel reads per render), every concurrent cache-cold request was doing its own DB lookup + response assembly. The wrapper coalesces them to ONE compute per (worker, condition_id, 15s window) — definitive fix for the pool-starvation failure class. See PR body for the full story. # Get Market By Slug Source: https://docs.polysimulator.com/api-reference/market-data/get-market-by-slug /openapi.json get /v1/markets/by-slug/{slug} Resolve a market by its slug for short-URL / SDK lookups. Tries Redis ``MARKET_SLUG_INDEX_PREFIX`` first, then the ``markets`` table, then the R2 archive — same chain the root ``/markets/by-slug`` handler uses, but typed and gated by ``X-API-Key`` on the v1 surface. Wrapped in a 60 s endpoint-level Redis cache (override via ``MARKET_BY_SLUG_CACHE_TTL_S``). Slug → condition_id mapping is stable; even closed markets stay reachable for the full TTL. Negative-404 caching: gated on ``MARKET_DETAIL_NEG_CACHE_TTL_S`` (default 0 = off; operator opt-in commonly 15 s) per the 2026-05-20 prod-wedge RCA — dead-slug 404 storms from FE SSR fan-out were stampeding the Gamma round-trip + archive lookup chain. Trade-off when enabled: a brand-new on-chain market surfaces up to the configured TTL late on this endpoint until the negative-cache entry expires. Default-off ships per the stability policy: operator flips to 15 in Dokploy stored env after a 4 h staging canary. PR-α (2026-05-06) — registered after the audit found the path was documented in ``api-beta-launch-plan.md`` but only existed at the root path (no ``/v1`` prefix), so SDK consumers calling ``/v1/markets/by-slug/...`` got 404. P2.3 cache-hit DB-hold fix: no ``db: Session = Depends(get_db)`` at signature level. The cache hit (the common case) returns without touching DB. The cache-miss closure ``_compute_lazy`` lazily acquires a session via ``_lazy_db_session()`` only when invoked. # Get Order Book Source: https://docs.polysimulator.com/api-reference/market-data/get-order-book /openapi.json get /v1/markets/{condition_id}/book Fetch the CLOB order book for a market outcome. Returns bids and asks with configurable depth. Requires a valid token_id in the cache (from the price enrichment data). If `outcome` is omitted, the first available outcome is used automatically (supports binary Yes/No and categorical Up/Down markets). # List markets (bare array; opt-in PM-shape envelope). Source: https://docs.polysimulator.com/api-reference/market-data/list-markets-bare-array;-opt-in-pm-shape-envelope /openapi.json get /v1/markets List markets with live prices from cache. Public read — no authentication required (P1-4). Consistent with the other public CLOB-read endpoints (``/v1/book``, ``/v1/price``, ``/v1/midpoint``, …) so the keyless quickstart works. The single- market sub-routes (``/v1/markets/{cid}`` and its ``/book`` / ``/candles`` children) keep their ``get_api_user`` gate. Per-IP / per-key rate limiting is applied by the middleware via ``_V1_PUBLIC_CLOB_READ_PATHS`` since no ``get_api_user`` runs here. Markets are sourced from the Redis market-list cache filled by the background poller. Each market is enriched with its latest cached price data. Wrapped in a 30 s endpoint-level Redis cache (override via ``MARKETS_LIST_CACHE_TTL_S``). The wrapper de-amplifies the multi-MB ``markets:list:poller`` JSON parse path, which the 2026-05-08 perf audit measured at p99 = 14–63 s under load. The in-memory ``_parsed_market_cache`` already shaves the JSON parse cost per worker but is per-pid — the Redis wrapper makes the same response shareable across all replicas. See ``docs/internal/2026-05-08-platform-upgrade-plan.md`` §3.8. ``active_only`` is a post-filter pass over the (already cached and paginated) market list — cheap and safe because the cache slice is already volume-ordered and the filter only drops a few closed rows. ``category`` and ``q`` are search-class filters: with a fixed ``offset/limit`` page the post-filter would only ever see the first N volume-ordered markets, so ``?q=trump&limit=50`` (Aidan audit 2026-05-18) would correctly find zero matches in the first 50 rows even when thousands of "trump" markets exist deeper in the catalog. For those filters we therefore fetch a wider window (``SEARCH_FILTER_SCAN_WINDOW``, default 500) from offset=0, apply the substring match, THEN paginate the FILTERED result. The response-cache wrapper still de-amplifies the underlying poller- blob parse cost; we just key it on the wider window for search- qualified requests so different ``q`` values share one warmed scan. # API Reference Source: https://docs.polysimulator.com/api-reference/overview Complete reference for the PolySimulator REST + WebSocket API — generated from a curated in-repo openapi.json snapshot. # API Reference The PolySimulator API is a **REST + WebSocket** surface. Every endpoint below is generated from a **curated in-repo snapshot** of the OpenAPI spec ([`docs-site/openapi.json`](https://github.com/Bavariance/polysimulator/blob/staging/docs-site/openapi.json)) and ships with an interactive playground — paste your `ps_live_…` key into the **Authorization** widget and you can fire requests against the live production environment from this browser tab. The published reference is generated from the in-repo curated snapshot, not from the live [`api.polysimulator.com/openapi.json`](https://api.polysimulator.com/openapi.json) served by the backend. The live spec may include additional legacy routes (v0 endpoints, internal helpers) that aren't part of the stable public surface — only the endpoints documented here are considered supported. **URL convention for endpoint pages**: each endpoint has a dedicated page at `/api-reference/{operationId}` (camelCase) — e.g., `POST /v1/orders` lives at [/api-reference/postOrder](/api-reference/postOrder), `GET /v1/markets/{condition_id}` at [/api-reference/getMarket](/api-reference/getMarket). For convenience, natural API-path guesses (e.g. `/api-reference/v1/orders`, `/api-reference/v1/markets/0xabc...`) are auto-redirected to the canonical operationId page, so you can paste live API paths straight into the URL bar and land on the right reference. How `X-API-Key`, bootstrap, and the WebSocket JWT flow work. From signup to first trade in under two minutes. *** ## Endpoint groups | Group | Coverage | | ---------------------- | --------------------------------------------------------------------------- | | **API Keys** | Bootstrap, create, list, revoke, tier metadata, WebSocket token mint | | **Account** | Balance, equity curve, portfolio, positions, trade history | | **Trading** | Place / cancel / batch / list orders, cancel-all, market cancel | | **Market Data** | Markets list + lookup, candles, prices, order book | | **CLOB Read (Public)** | Polymarket-shape book / midpoint / spread / price endpoints | | **CLOB Compat** | Polymarket-shape `POST /v1/order`, `POST /v1/orders`, `GET /v1/data/orders` | | **WebSocket** | `/v1/ws/prices` + `/v1/ws/executions` subscription endpoints | | **Health** | `/v1/health`, `/v1/health/ready`, `/v1/status`, `/v1/me` | Use the sidebar to drill into any group. Each endpoint page shows the request schema, response schema, error codes, and a copy-pasteable `curl` / TypeScript / Python snippet. *** ## Base URL | Environment | URL | | ----------- | ------------------------------- | | Production | `https://api.polysimulator.com` | Most endpoints in this reference are mounted under `/v1` — example: `GET https://api.polysimulator.com/v1/account/balance`. The one documented exception is `/api/beta/cohort-status`, a public read-only endpoint used by the pricing page to surface closed-beta slot availability. *** ## Authentication at a glance Most endpoints require an `X-API-Key` header. Public exceptions that work without a key: `/v1/health`, `/v1/health/ready`, `/api/beta/cohort-status`, and the public CLOB-read endpoints under **CLOB Read (Public)**. Note `/v1/keys/bootstrap` is **not** public — it requires a Supabase `Authorization: Bearer ` JWT (returns `401 MISSING_AUTH` without one), it just doesn't take an `X-API-Key` because it mints your first key. Each endpoint's individual page lists its own auth requirement at the top — check there if unsure. The [Authentication guide](/authentication) covers key minting, rotation, revocation, and the short-lived WebSocket JWT flow. ```bash theme={null} curl https://api.polysimulator.com/v1/account/balance \ -H "X-API-Key: ps_live_..." ``` The interactive playground on every endpoint page lets you store your key once and replay requests without retyping it. Keys you paste into the playground are stored locally in your browser and attached to playground requests as the `X-API-Key` header — they're not transmitted anywhere until you click **Send**, at which point they're sent to `api.polysimulator.com` in the request header like any other API client call. *** ## Polymarket compatibility PolySimulator is wire-compatible with `py-clob-client` and the Polymarket CLOB JSON shape for the endpoints under the **CLOB Read (Public)** and **CLOB Compat** groups. The [CLOB compatibility guide](/concepts/clob-compatibility) lists every identical-vs-deviation contract item — read it before you port a bot. For the few documented deviations (the `X-Polysim-Code` machine header, the `402 UPGRADE_REQUIRED` enrichment, paper-trading-only EIP-712 signing), the [Polymarket Raw HTTP guide](/concepts/pm-raw-http) walks through each one with side-by-side request/response examples. *** ## Rate limits + tiers | Tier | Burst (req/s) | Sustained (req/min) | WebSockets | Batch size | | ---------- | ------------- | ------------------- | ---------- | ---------- | | Free | 2 | 120 | 1 | 1 | | Pro | 10 | 600 | 3 | 5 | | Pro+ | 30 | 1,800 | 10 | 10 | | Enterprise | 100 | 6,000 | 50 | 25 | `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` are always present on rate-limited responses. `X-RateLimit-Tier` is present on authenticated responses only — unauthenticated requests metered against IP-only buckets omit it. The authoritative source for these numbers is `GET /v1/keys/tiers`. See [Rate Limits](/concepts/rate-limits) for back-off strategy and the closed-beta cohort exception (admitted cohort keys run at their granted tier's rate — admin beta-issued keys are enterprise-tier — then auto-downgrade to free + read-only at the key's `beta_until` cutoff). *** ## LLM-targeted reference If you're driving this API from an AI coding assistant, point it at [`/llms.txt`](/llms.txt) — a single \~50 KB markdown blob with every endpoint, schema, and Polymarket parity note in a model-friendly format. Cursor / Claude Code / Continue / Windsurf can also ingest the docs via [Mintlify MCP](/introduction#mintlify-mcp) or [Context7](/introduction#context7). # Preview Alert Source: https://docs.polysimulator.com/api-reference/preview-alert /openapi.json get /v1/preview/alert Render a single (event_type, channel) preview using the production formatters. Auth-scoped — only the caller's own most-recent wallet trade is used as sample data, and only when ``wallet_address`` is in their ``tracked_wallets`` set. The endpoint NEVER sends a real alert — it only renders. Use the existing per-channel ``/me//test`` POST endpoints to send a real test. # Full profile analysis for LLM/MCP consumption Source: https://docs.polysimulator.com/api-reference/profile-analysis/full-profile-analysis-for-llmmcp-consumption /openapi.json get /v1/account/profile-analysis Returns a comprehensive analysis of the authenticated user's profile, trading history, risk metrics, and portfolio composition. Designed as a single-request endpoint for MCP tools and LLM agents to consume. Scoped to the caller's API wallet by default (since 2026-06-10); pass wallet_id=all for the cross-wallet blended view or wallet_id= for a specific wallet. # Status Uptime Source: https://docs.polysimulator.com/api-reference/status/status-uptime /openapi.json get /v1/status/uptime Daily uptime ribbon for the public status page. Returns ``days`` per-day status cells per service, oldest-first. Each cell is one of ``operational | degraded | outage | no_data`` with an optional ``summary`` describing the incident span. Source is always ``redis`` (the daily-rollup cache); cells without a cached entry default to ``no_data``. The ``app.tasks.status_aggregator`` background loop keeps the cache fresh every 5 min and backfills 90 days on startup. Performance: single Redis MGET regardless of ``days`` value. Sub-millisecond at p99 even with the 90-day window — the old per-request path took ~18 s on cache miss. All services in ``SERVICES`` currently share the same daily roll- up — we don't (yet) compute per-service status separately. Per-service granularity is a documented follow-up; for now the UI shows the same row for each service which still surfaces actual outages. # Batch Orders Source: https://docs.polysimulator.com/api-reference/trading/batch-orders /openapi.json post /v1/orders/batch Place multiple orders in one request. Each order is processed independently — individual failures don't block the rest. The batch size is limited by your API tier. **HFT optimisation:** CLOB midpoint prices are pre-fetched in parallel for all unique markets in the batch before orders are processed. This collapses N sequential HTTP round-trips into one parallel burst, warming the Redis midpoint cache so each order hits the sub-ms HFT fast-path during execution. D1 — wrap the whole batch in the per-user advisory lock so a concurrent batch + cancel from the same user serialize at the application layer. The batch internally calls ``_execute_market_order`` / ``_create_limit_order`` directly (NOT ``_place_order_single``) so there is no re-entrant lock acquisition. # Cancel all pending limit orders (canonical verb). Source: https://docs.polysimulator.com/api-reference/trading/cancel-all-pending-limit-orders-canonical-verb /openapi.json post /v1/cancel-all Polymarket's CLOB and our public docs use POST for this action; DELETE is preserved as a back-compat alias for SDKs that adopted the prior shape, but POST is canonical going forward. **Requires confirmation**: pass ``?confirm=true`` query parameter OR ``X-Confirm-Cancel-All: true`` header to prevent accidental order wipeouts (P1-J footgun guard, audit 2026-05-12). # Cancel all pending limit orders (legacy DELETE alias). Source: https://docs.polysimulator.com/api-reference/trading/cancel-all-pending-limit-orders-legacy-delete-alias /openapi.json delete /v1/cancel-all **Deprecated** — use POST /v1/cancel-all going forward. The DELETE verb stays live until 2026-08-01 to avoid breaking older SDKs that shipped against it. Responses include ``X-Deprecation`` and ``Sunset`` headers. **Requires confirmation**: pass ``?confirm=true`` query parameter OR ``X-Confirm-Cancel-All: true`` header (P1-J footgun guard, audit 2026-05-12). # Cancel Market Orders Source: https://docs.polysimulator.com/api-reference/trading/cancel-market-orders /openapi.json delete /v1/cancel-market-orders Cancel all pending limit orders for a specific market. Accepts either ``market`` (condition_id) or ``asset_id`` (token_id) as a query parameter. At least one must be provided. D1 — wrap the body in the per-user advisory lock so a market- scoped cancel and a concurrent place-trade for the same user serialize at the application layer instead of contending on ``accounts FOR UPDATE``. See :func:`_cancel_order_sync` for the full design rationale. # Cancel Order Source: https://docs.polysimulator.com/api-reference/trading/cancel-order /openapi.json delete /v1/orders/{order_id} Cancel a pending limit order. Refunds reserved funds (BUY) or restores reserved shares (SELL). Only PENDING orders owned by the authenticated user can be cancelled. # Clob Order Source: https://docs.polysimulator.com/api-reference/trading/clob-order /openapi.json post /v1/clob/order CLOB-compatible order endpoint. Accepts orders using the same schema as Polymarket's real CLOB API (token_id + price + size), enabling one-URL-swap migration to live. The ``order_type`` field follows PM-CLOB semantics: * ``GTC``/``GTD`` — rests on the book (resting limit order) * ``FOK`` — fill-or-kill (immediate, all-or-nothing) * ``IOC`` — NOT YET SUPPORTED. The matching engine only has FOK worst-price enforcement; routing IOC through ``_execute_market_order`` would silently behave like FOK. Until proper partial-fill + cancel-remainder semantics land, requests with ``order_type=IOC`` get a clean ``400 UNSUPPORTED_ORDER_TYPE`` response. Resolves DB-first (markets.clob_token_ids JSONB) then falls back to the Redis poller cache. The funded-key audit on 2026-05-07 caught a divergence: ``/v1/midpoint`` accepted token ids that this endpoint 404'd, because ``_resolve_token_to_market`` only checked Redis. The DB-first lookup makes both endpoints accept the same token-id surface. # Get a single order by ID (polysim-shape row). Source: https://docs.polysimulator.com/api-reference/trading/get-a-single-order-by-id-polysim-shape-row /openapi.json get /v1/orders/{order_id} Returns one order row in the same Polysimulator shape as ``GET /v1/orders``. The ``order_id`` path param is the integer ID returned by ``POST /v1/orders`` / ``POST /v1/clob/order``. **ID space caveat:** ``PendingOrder.id`` (limit orders) and ``Order.id`` (pure-market fills) are independent auto-increment sequences — the same integer can refer to different rows in each table. The default lookup precedence is ``PendingOrder`` first (matches ``DELETE`` semantics and the common limit-order polling case), then ``Order`` on miss. Pass ``?source=filled`` to force the Order table for market-order pollers; ``?source=pending`` to force PendingOrder. The response carries the matched table in ``order_type`` (``limit``/``market``) so callers can verify. For unambiguous lookups use ``GET /v1/orders?status=PENDING&market_id=...`` and filter client-side by ``client_order_id``. Audit PR #1303 P1: this endpoint previously 405'd. # List user orders (Polysimulator-shape — see also: /v1/data/orders for PM-shape). Source: https://docs.polysimulator.com/api-reference/trading/list-user-orders-polysimulator-shape-—-see-also:-v1dataorders-for-pm-shape /openapi.json get /v1/orders Returns the user's pending, filled, and cancelled orders in the Polysimulator envelope: ``{orders, has_more, next_cursor, total_hint}`` with snake_case row fields (``order_id``, ``market_id``, ``limit_price``, ``quantity``, ISO timestamps). **SDK consumers ported from Polymarket should call ``GET /v1/data/orders`` (operationId ``listOrdersPmShape``) instead** — that endpoint emits PM's exact envelope shape (``{limit, count, next_cursor, data}``) and per-row schema (``id``, ``market``, ``asset_id``, ``original_size``, ``size_matched``, ``price``, unix-int ``created_at``, ``ORDER_STATUS_*`` enum). This endpoint stays live for back-compat but PM-SDK auto-generation should target the ``/data/orders`` shape. Both endpoints read from the same underlying ``pending_orders`` table — the only difference is response shape. # Place Order Dispatcher Source: https://docs.polysimulator.com/api-reference/trading/place-order-dispatcher /openapi.json post /v1/orders Dispatcher: PolySim native single (JSON object) OR PM batch (JSON array). See module-level comment above for the path-mapping rationale. # Archive Wallet Source: https://docs.polysimulator.com/api-reference/wallets/archive-wallet /openapi.json delete /v1/me/wallets/{wallet_id} Soft-delete a wallet by setting status=ARCHIVED + archived_at=now. Rejects: - MAIN (would orphan the leaderboard wallet) - already ARCHIVED/DELETED (idempotent? — return 409 to surface it). # Create Wallet Source: https://docs.polysimulator.com/api-reference/wallets/create-wallet /openapi.json post /v1/me/wallets Create a new SANDBOX wallet, gated by per-plan caps. Rejects MAIN (auto-seeded), COMPETITION (runner-spawned), and currently API as well — extra API wallets need per-API-key routing to receive trades, which is a separate follow-up PR. The body of this handler still contains the API code path so flipping the rejection on becomes a one-line removal once routing lands. # Get Wallet Source: https://docs.polysimulator.com/api-reference/wallets/get-wallet /openapi.json get /v1/me/wallets/{wallet_id} Single wallet detail. 404 if not owned by the caller. # List My Wallets Source: https://docs.polysimulator.com/api-reference/wallets/list-my-wallets /openapi.json get /v1/me/wallets List the user's wallets in display order (MAIN, SANDBOX, API, COMPETITION). Includes ACTIVE, FROZEN, and ARCHIVED so the UI can render generation history. DELETED wallets are hidden. Per-user 5s Redis cache (override via ``ME_CACHE_TTL_S`` env). Wallet balance/baseline/status mutations on this user (top-up webhook, MAIN reset, archive, etc.) intentionally don't bust this cache — the short TTL is the staleness contract. If you need fresh-immediate after a write, the writer hits the wallet directly via SQL. # Patch Wallet Source: https://docs.polysimulator.com/api-reference/wallets/patch-wallet /openapi.json patch /v1/me/wallets/{wallet_id} Rename a wallet's label. First-PR scope; other fields are immutable. Allowed on any kind/status (including ARCHIVED — historical labels can be tidied for trophy-case display). Empty strings reset to None. # Reset Wallet Source: https://docs.polysimulator.com/api-reference/wallets/reset-wallet /openapi.json post /v1/me/wallets/{wallet_id}/reset Archive the current wallet and spawn the next-generation copy. Behaviour (spec §8): - Old wallet: status=ARCHIVED, archived_at=NOW(); LedgerEntry(RESET) emitted on the user's accounts row as a forensic boundary marker (reference_id=`wallet:{old_id}:reset`). - New wallet: same kind, generation=N+1, parent_wallet_id=old_id, balance=baseline (from FEATURE_MATRIX), status=ACTIVE; LedgerEntry (DEPOSIT, baseline) emitted with reference_id=`wallet:{new_id}:init`. First-PR scope: SANDBOX only. MAIN reset uses the existing /reset-account flow (cooldown-gated, with full account_reset_backups snapshot) — that path is out of scope here. API reset uses /v1/account/reset-api-balance. COMPETITION is runner-only. No emergency-exit step is needed yet: SANDBOX wallets do not own positions until the per-wallet routing PR. Once that lands, this endpoint must close open SANDBOX positions before archiving. D1 — wrap in the per-user advisory lock so a concurrent SANDBOX reset + place-trade for the same user serialize at the application layer instead of contending on the wallets/accounts FOR UPDATE. Reset emits LedgerEntry rows against ``user.account.id`` and spawns a fresh wallet row; holding the lock across the body prevents a trade-vs-reset race. See PR #1208 for the original advisory-lock landing. # Ws Market Upgrade Hint Source: https://docs.polysimulator.com/api-reference/websocket-pm-compat/ws-market-upgrade-hint /openapi.json get /v1/ws/market This is a WebSocket endpoint. Use ``wss://`` to connect. # Ws User Upgrade Hint Source: https://docs.polysimulator.com/api-reference/websocket-pm-compat/ws-user-upgrade-hint /openapi.json get /v1/ws/user This is a WebSocket endpoint. Use ``wss://`` to connect. # Ws Executions Upgrade Hint Source: https://docs.polysimulator.com/api-reference/websocket/ws-executions-upgrade-hint /openapi.json get /v1/ws/executions This is a WebSocket endpoint. Use ``wss://`` to connect. # Ws Prices Upgrade Hint Source: https://docs.polysimulator.com/api-reference/websocket/ws-prices-upgrade-hint /openapi.json get /v1/ws/prices This is a WebSocket endpoint. Use ``wss://`` to connect. # Authentication Source: https://docs.polysimulator.com/authentication How API key authentication works, security model, and best practices. # Authentication PolySimulator uses **two auth methods, scoped to different jobs**: | Method | Header | Use it for | | ---------------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **API key** (primary) | `X-API-Key: ps_live_…` | Everything bots touch: trading, market data, websockets, balance/positions/history reads | | **Supabase Bearer JWT** (dashboard / one-time) | `Authorization: Bearer …` | Self-service surfaces the dashboard reads with your signed-in session: `POST /v1/keys/bootstrap`, key management (`GET/POST/DELETE /v1/keys`, `/v1/keys/tiers`, `/v1/keys/ws-token`), `GET /v1/me`, `/v1/account/me/entitlements`, and `/v1/me/wallets/*` | Most users only ever see the API key — the dashboard at [polysimulator.com/api-keys](https://polysimulator.com/api-keys) handles the Bearer-JWT bootstrap with your signed-in Supabase session, so you click a button and copy the `ps_live_…` value. The Bearer-JWT API path exists for headless setups (CI, dev tooling) where there's no browser session. The Polymarket-CLOB-compatible read endpoints (e.g. `/v1/book`, `/v1/midpoint`, `/v1/spread`, `/v1/markets-by-token`) are **public** and don't require a key. For convenience, PolySimulator also accepts the single-value header aliases `POLY_API_KEY` and `Authorization: Bearer ps_live_…`, each carrying the whole `ps_live_` key, on authenticated routes (`X-API-Key` takes precedence when several are sent). ```bash theme={null} # Standard curl -H "X-API-Key: ps_live_abc123..." \ https://api.polysimulator.com/v1/markets # Equivalent — single-value POLY_API_KEY alias (PolySimulator convenience) curl -H "POLY_API_KEY: ps_live_abc123..." \ https://api.polysimulator.com/v1/markets ``` **These aliases are a deliberate PolySimulator simplification — not a literal match of Polymarket's request shape.** Real Polymarket L2 auth attaches **five** `POLY_*` headers per request — `POLY_ADDRESS`, `POLY_SIGNATURE` (an HMAC-SHA256 of the request), `POLY_TIMESTAMP`, `POLY_API_KEY`, `POLY_PASSPHRASE` — and `py-clob-client` / `@polymarket/clob-client` never send a bare `POLY_API_KEY` or an `Authorization: Bearer ` on their own. PolySimulator collapses all of that to one value (your `ps_live_` key) and ignores HMAC signing because it's a paper-trading backend. So porting a bot still means pointing the SDK's `host` at PolySimulator and feeding it the `ps_live_` key — the aliases just mean common HTTP clients that default to `Authorization: Bearer …` or send `POLY_API_KEY` aren't rejected; they don't make a real `py-clob-client` work unchanged. **Bearer is rejected on every trading and market-data endpoint.** `POST /v1/orders`, `POST /v1/order`, `POST /v1/clob/order`, `DELETE /v1/orders/{id}`, `GET /v1/markets*`, `GET /v1/book`, `GET /v1/midpoint*`, the websocket connect URL, and `GET /v1/account/{balance,positions,portfolio,history,equity}` all require `X-API-Key` (or the `POLY_API_KEY` alias). This keeps the surface short-lived JWTs can reach narrow and auditable — short-lived browser tokens cannot reach the trade engine. *** ## Key Format Keys follow a predictable pattern for easy identification: ``` ps_live_<64 random hex chars> ``` **Example**: `ps_live_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2` Each key has a **visible prefix** (first 16 chars) used for identification without exposing the full key: | | Value | | -------- | -------------------------------------------------------------------------- | | Full key | `ps_live_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2` | | Prefix | `ps_live_a1b2c3d4` | *** ## How It Works When you send a request: 1. Your API key is **SHA-256 hashed** and looked up in the database 2. The key's `is_active` and `expires_at` fields are validated 3. **Rate limits** are enforced based on your key's tier 4. The associated **user account** is loaded for trading operations ```mermaid theme={null} sequenceDiagram participant Bot as Your Bot participant API as PolySimulator API participant DB as Database participant Redis as Redis Bot->>API: GET /v1/markets (X-API-Key: ps_live_...) API->>API: SHA-256 hash the key API->>DB: Look up key_hash DB-->>API: Key record (user_id, tier, permissions) API->>Redis: Check rate limit (tier bucket) Redis-->>API: Allowed / Denied API-->>Bot: 200 OK / 429 Rate Limited ``` *** ## Permissions Keys support granular permissions: | Permission | Grants Access To | | ---------- | ------------------------------------------------------ | | `read` | Market data, prices, balance, positions, order history | | `trade` | Place orders, cancel orders, cancel-all, batch orders | A key with only `read` permission cannot place trades. Create a key with `["read", "trade"]` permissions for bot usage. **Key management is gated by auth, not by the `trade` scope.** Creating, listing, renaming, rotating, and revoking keys (`POST`/`GET`/`PATCH`/`DELETE /v1/keys`, `/v1/keys/{id}/rotate`) only require a valid credential for your account — any active `ps_live_` key (even a read-only one) or your dashboard Supabase JWT. You don't need a `trade`-scoped key to manage keys. (Free-tier keys are still read-only for trading and can't be created *with* `trade` — that's enforced at creation, see below.) *** ## Security Best Practices Never hardcode API keys in source code. Use environment variables or a secrets manager. ```bash theme={null} export POLYSIM_API_KEY="ps_live_kJ9mNx2p..." ``` ```python theme={null} import os api_key = os.environ["POLYSIM_API_KEY"] ``` There is **no `expires_at` field on key creation** — `POST /v1/keys` and `POST /v1/keys/bootstrap` only accept `name`, `tier`, and `permissions`. (`expires_at` is set server-side: it appears on a *rotated* key's old half during the 24h overlap window, and on beta-issued keys as their `beta_until` cutoff.) For short-lived deployments, **rotate** instead: `POST /v1/keys/{id}/rotate` mints a replacement and schedules the old key to expire after a 24h overlap, so you can roll a key without downtime and let the old one lapse on its own. Create separate keys for different bots: * **Data-only bot**: `["read"]` permission * **Trading bot**: `["read", "trade"]` permission Create a new key, update your bot, then revoke the old key: ```bash theme={null} # 1. Create new key curl -X POST -H "X-API-Key: $OLD_KEY" \ https://api.polysimulator.com/v1/keys \ -d '{"name": "bot-v2", "permissions": ["read", "trade"]}' # 2. Update your bot's environment variable # 3. Revoke old key curl -X DELETE -H "X-API-Key: $NEW_KEY" \ https://api.polysimulator.com/v1/keys/OLD_KEY_ID ``` The system enforces a limit of 5 active keys per user account. Revoke unused keys to free up slots. *** ## Error Responses | Status Code | Meaning | Common Causes | | ----------------------- | ---------------------------------------------- | ------------------------------------------------- | | `401 Unauthorized` | Invalid, expired, or deactivated API key | Typo in key, key was revoked, key expired | | `403 Forbidden` | Key lacks required permission for the endpoint | Using a `read`-only key to place trades | | `429 Too Many Requests` | Rate limit exceeded | Too many requests per second/minute for your tier | All `/v1/*` errors return Polymarket-shape: a single `error` field holding a human-readable description when one is available. (For unhandled exception paths where no message was set, the body falls back to the short machine code — so always branch on the `X-Polysim-Code` response header for stable error handling, not on the body text.) The `X-Polysim-Code` response header carries a stable short code — domain-specific where the handler knows what went wrong (e.g. `INVALID_KEY`, `INSUFFICIENT_PERMISSION`, `RATE_LIMIT_EXCEEDED`, `BOOK_UNAVAILABLE`, `VALIDATION_FAILED`), or `HTTP_` as a generic fallback (`HTTP_400`, `HTTP_500`). The `X-Request-Id` response header always echoes the request id for log/support correlation. ```json theme={null} // 401 — Invalid API key (handler sets X-Polysim-Code: INVALID_KEY) {"error": "Invalid API key"} // + headers: X-Polysim-Code: INVALID_KEY, X-Request-Id: a1b2c3d4-... // 403 — Missing permission {"error": "API key missing required permission: trade"} // + headers: X-Polysim-Code: INSUFFICIENT_PERMISSION, X-Request-Id: ... // 429 — Rate limited {"error": "Rate limit exceeded. Retry after 1s."} // + headers: X-Polysim-Code: RATE_LIMIT_EXCEEDED, X-Request-Id: ..., Retry-After: 1 ``` Common auth/permission codes — branch on `X-Polysim-Code`: | Code | HTTP | When | | ------------------------- | ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `MISSING_AUTH` | 401 | No `Authorization: Bearer …` or `X-API-Key` (or `POLY_API_KEY`) on a route that requires one | | `MISSING_API_KEY` | 401 | `X-API-Key` / `POLY_API_KEY` is missing on a key-only route (e.g. trading, market data, account reads) | | `INVALID_KEY` | 401 | Key doesn't exist or is revoked | | `INVALID_TOKEN` | 401 | Bearer JWT is malformed, missing `sub`, or fails signature verification | | `KEY_EXPIRED` | 401 | Key past its `expires_at` | | `KEY_DEACTIVATED` | 401 | Key was administratively disabled | | `KEY_OWNER_NOT_FOUND` | 401 | Underlying user record missing (rare) | | `TOKEN_EXPIRED` | 401 | Bearer JWT expired | | `ACCESS_RESTRICTED` | 403 | An **already-issued** key (or a Bearer JWT) is not on the API v1 allowlist. Returned on authenticated `/v1/*` requests for accounts outside the admin-managed allowlist, and for accounts flagged or under review. | | `CLOSED_BETA` | 403 | Key **issuance** is gated: `POST /v1/keys` / `POST /v1/keys/bootstrap` refuse callers who are not in an admitted [closed-beta](#closed-beta) cohort. The default outcome while the beta is closed. Apply via the waitlist. | | `API_PRO_COMING_SOON` | 403 | Key **issuance** for a paying **Pro / Pro+** account that doesn't yet have a cohort grant — a billing-aware "rolling it out in cohorts" variant of the issuance gate. Only reached once self-serve issuance is enabled; until then a paying caller also sees `CLOSED_BETA`. | | `INSUFFICIENT_PERMISSION` | 403 | Key lacks `trade` / other scope | | `RATE_LIMIT_EXCEEDED` | 429 | Per-key or per-IP burst exceeded | On `429` responses, check the `Retry-After` header for exact wait time in seconds. **Verbose body opt-in.** Send `X-Polysim-Verbose: true` on the request to get the legacy verbose body shape — useful while writing or debugging an SDK: ```json theme={null} {"error": "INVALID_KEY", "message": "Invalid API key", "details": null, "request_id": "a1b2c3d4-..."} ``` The default single-field shape matches Polymarket's own /clob error contract, so PM-shape SDK ports work without translation. *** ## Bootstrap Flow (First-Time Setup) The recommended path is the dashboard at [polysimulator.com/api-keys](https://polysimulator.com/api-keys). Sign in, click **Create your first API key**, and copy the `ps_live_…` value shown once. The dashboard handles the Supabase JWT exchange transparently. ### Bootstrap from a script (headless / CI) If you can't open a browser and you have a Supabase access token in hand, call `POST /v1/keys/bootstrap` directly: ```python theme={null} import requests # Obtain via a programmatic Supabase sign-in — most users don't # need this path; the dashboard does it for you. supabase_jwt = "your_supabase_access_token" resp = requests.post( "https://api.polysimulator.com/v1/keys/bootstrap", headers={ "Authorization": f"Bearer {supabase_jwt}", "Content-Type": "application/json", }, json={"name": "my-first-bot"}, ) if resp.status_code == 201: raw_key = resp.json()["raw_key"] print(f"Save this key NOW (shown only once): {raw_key}") elif resp.status_code == 400: print("You already have keys — use POST /v1/keys with X-API-Key instead") elif resp.status_code == 401: print("Invalid or expired Supabase JWT — sign in again at polysimulator.com") elif resp.status_code == 403: # Branch on the stable machine code in the `X-Polysim-Code` header. # The response body is PM-shape `{"error": ""}`, so the # body's `error` field is the human message — NOT a stable code. # Header lookup is case-insensitive in `requests` / most HTTP libs. code = resp.headers.get("X-Polysim-Code") if code == "CLOSED_BETA": # API key issuance is in closed beta — your account isn't in an # admitted cohort yet. Apply via the waitlist at # https://polysimulator.com/api-trading; we'll email you when a cohort opens. print("Closed beta — apply at https://polysimulator.com/api-trading") elif code == "API_PRO_COMING_SOON": # Your Pro / Pro+ subscription includes API access — it's being # rolled out in cohorts. Check your billing page. print("Pro API access is rolling out — see /account/billing") elif code == "ACCESS_RESTRICTED": # The account is not on the API v1 allowlist (flagged / under review). print("Access restricted — contact support") else: print(f"403 [{code}]: {resp.json().get('error')}") elif resp.status_code == 429: print("Bootstrap rate limit hit — wait and retry") headers = {"X-API-Key": raw_key} health = requests.get("https://api.polysimulator.com/v1/health", headers=headers) print(health.json()) # {"status": "ok", "timestamp": "...", "version": "1.0.0"} ``` ### Security boundary * JWTs are verified using HS256 against the project's Supabase signing secret, with `audience="authenticated"`, signature, expiry, and the `sub` UUID all enforced server-side. Anon and service-role tokens are rejected. * Bearer is accepted on the **dashboard surface only**: `POST /v1/keys/bootstrap`, key management (`GET/POST/DELETE /v1/keys`, `/v1/keys/tiers`, `/v1/keys/ws-token`), `GET /v1/me`, `GET /v1/account/me/entitlements`, and `/v1/me/wallets/*` — the routes the signed-in dashboard reads. Trading (`/v1/orders`, `/v1/order`, `/v1/clob/order`), market data (`/v1/markets*`, `/v1/book`, `/v1/midpoint*`, etc.), the account-trading reads (`/v1/account/{balance,positions,portfolio, history,equity}`), and the websocket connect URL all require `X-API-Key` and reject Bearer with 401 — short-lived JWTs cannot reach the trade engine or the account ledger. * Bootstrap is idempotency-bounded: if the JWT subject already has any key, the endpoint returns 400 `BOOTSTRAP_NOT_ALLOWED` and the caller must use `POST /v1/keys` (with `X-API-Key`) for additional keys. * Bootstrap is rate-limited at 5 calls/hour and 1 call/minute per IP, on top of the global IP rate limit. Real users only bootstrap once per account; the limit caps abuse without breaking legitimate network-error retries. *** ## Closed Beta The API is in an **ongoing closed beta**: new API keys are issued to approved cohorts only. There are two distinct gates, with distinct error codes — branch on the `X-Polysim-Code` response header: ### 1. Key issuance — `CLOSED_BETA` / `API_PRO_COMING_SOON` `POST /v1/keys/bootstrap` (first key) and `POST /v1/keys` (additional keys) refuse callers who aren't in an admitted cohort. While the beta is closed (the default), **every** non-admin, non-cohort caller — free, waitlisted, **or** paying — gets **`403 CLOSED_BETA`**: apply via the waitlist and we'll email you when a cohort opens. `API_PRO_COMING_SOON` is a **conditional** variant of the same gate. Once self-serve issuance is enabled, the gate routes by subscription tier: free callers self-serve a read-only key, while a paying **Pro / Pro+** caller without a cohort grant gets the billing-aware **`403 API_PRO_COMING_SOON`** ("your subscription includes API access; it's being rolled out in cohorts") instead. Until that flag flips, paying callers also see `CLOSED_BETA`. Both are PM-shape envelopes. The default body is the single-field `{"error": ""}` form; the stable machine code lives in the `X-Polysim-Code` response header. (The `feature_key` / `upgrade_url` routing hints from the underlying error are carried in the body **only on `402` upgrade responses**, not on these `403`s — read them from the verbose body if you need them, via `X-Polysim-Verbose: true`.) ```http theme={null} HTTP/1.1 403 Forbidden X-Polysim-Code: CLOSED_BETA X-Request-Id: a1b2c3d4-... Content-Type: application/json ``` ```json theme={null} {"error": "API access is in closed beta. New keys are issued to approved cohorts only. Apply via the waitlist; we'll email you when a cohort opens."} ``` ```python theme={null} resp = requests.post( "https://api.polysimulator.com/v1/keys/bootstrap", headers={"Authorization": f"Bearer {supabase_jwt}"}, json={"name": "first-key"}, ) if resp.status_code == 403: code = resp.headers.get("X-Polysim-Code") if code == "CLOSED_BETA": # Not in an admitted cohort yet. Apply via the waitlist; we'll # email you when a cohort opens. print("Closed beta — apply at https://polysimulator.com/api-trading") elif code == "API_PRO_COMING_SOON": # Your Pro / Pro+ subscription includes API access — being # rolled out in cohorts. Check /account/billing. print("Pro API access is rolling out — see /account/billing") ``` ### 2. Runtime access — `ACCESS_RESTRICTED` Once a key is issued, authenticated `/v1/*` requests are additionally gated by an admin-managed allowlist. An account that isn't on the allowlist (or is flagged / under review) gets **`403 ACCESS_RESTRICTED`** on every authenticated request. Like every `/v1/*` error, the stable machine code is in the `X-Polysim-Code` response header: ```http theme={null} HTTP/1.1 403 Forbidden X-Polysim-Code: ACCESS_RESTRICTED X-Request-Id: a1b2c3d4-... Content-Type: application/json ``` ```json theme={null} {"error": "API access restricted. Contact support to request access."} ``` The cohort is managed by adding the user's email to the `api_allowlist` table (admin tooling: `POST /admin/api-keys/issue-beta` also pre-creates an enterprise-tier key with a fixed cutoff). Allowlist additions take effect on the next request — there's no client-visible toggle the gate is bound to. Apply via the waitlist at [polysimulator.com/api-trading](https://polysimulator.com/api-trading). ### Beta-issued keys auto-downgrade after the cutoff Beta-issued keys carry a fixed `beta_until` cutoff. After the cutoff, the key is auto-downgraded to free-tier limits and read-only permissions on every request. Every response on a downgraded key includes `X-API-Beta-Cutoff: expired` so SDKs can pivot to read-only mode without a separate round-trip: ```python theme={null} resp = client.get_orders() if resp.headers.get("x-api-beta-cutoff") == "expired": print("Beta cohort ended — convert to a paid Pro key for trade access") ``` The public cohort-status endpoint reports current capacity (no auth required, used by the pricing page): ```bash theme={null} curl https://api.polysimulator.com/api/beta/cohort-status # → {"cohort_label": "beta-2026-05", "available": true, "active": 12, # "cap": 100, "cutoff": "2026-08-31T23:59:59Z", "reopens_at": null} # `cutoff` is operator-configurable via the BETA_COHORT_CUTOFF env var. ``` *** ## Next Steps * [Create your first API key](/concepts/api-keys) * [Understand rate limits](/concepts/rate-limits) * [Place your first trade](/trading/placing-orders) # Best Practices Source: https://docs.polysimulator.com/bots/best-practices Production-grade patterns for building reliable trading bots. # Best Practices Battle-tested patterns to make your bots production-ready. *** ## 1. Always Use String Numerics Never send numeric values as JSON numbers. The API returns and expects all monetary values as **strings** to prevent IEEE 754 floating-point precision loss. ```python theme={null} # ✅ Correct payload = {"quantity": "10", "price": "0.42"} # ❌ Wrong — may lose precision payload = {"quantity": 10, "price": 0.42} ``` Use `Decimal` for all arithmetic: ```python theme={null} from decimal import Decimal price = Decimal(response["price"]) quantity = Decimal(response["quantity"]) cost = price * quantity # Exact arithmetic ``` *** ## 2. Use Idempotency Keys Every `POST /v1/orders` request should include an `Idempotency-Key` header to prevent duplicate orders on network retries: ```python theme={null} import hashlib def make_idempotency_key(market_id, side, outcome, quantity): """Generate a deterministic key for this exact order intent.""" raw = f"{market_id}:{side}:{outcome}:{quantity}" return hashlib.sha256(raw.encode()).hexdigest()[:32] ``` If you retry a request with the same idempotency key, the server returns the original response without creating a duplicate order. *** ## 3. Prefer Batch Endpoints When operating on multiple markets, use batch endpoints to reduce HTTP overhead and stay within rate limits: | Instead of... | Use... | | ------------------------------- | ------------------------ | | N × `GET /v1/markets/{id}` | `POST /v1/prices/batch` | | N × `POST /v1/orders` | `POST /v1/orders/batch` | | N × `GET /v1/markets/{id}/book` | Cache + periodic refresh | ```python theme={null} # ✅ Fetch 20 prices in one call resp = requests.post( f"{BASE_URL}/v1/prices/batch", headers=HEADERS, json={"market_ids": market_ids}, timeout=10, ) resp.raise_for_status() prices = resp.json() ``` *** ## 4. Use WebSocket Over REST Polling For price monitoring, WebSocket streaming is vastly more efficient: | | REST Polling (5s) | WebSocket | | --------------- | ----------------- | ------------ | | Latency | 0–5,000 ms | \<100 ms | | API calls/hr | 720/market | 1 connection | | Rate limit risk | High | None | Reserve REST API for: * Order placement * Account queries * Historical data *** ## 5. Use Cursor Pagination for Large Datasets For endpoints that return lists (orders, positions, trade history), prefer cursor-based pagination over offset: ```python theme={null} # ✅ Cursor pagination — consistent results cursor = None all_orders = [] while True: params = {"limit": 100} if cursor: params["cursor"] = cursor resp = requests.get(f"{BASE_URL}/v1/orders", headers=HEADERS, params=params) data = resp.json() all_orders.extend(data["orders"]) cursor = data.get("next_cursor") if not cursor: break ``` Offset pagination (`offset=100`) can skip or duplicate items when new records are inserted between pages. Use cursor pagination for trading data. *** ## 6. Cache Market Metadata Market metadata (question text, outcomes, slugs) changes rarely. Cache it locally and refresh periodically: ```python theme={null} import time class MarketCache: def __init__(self, ttl=300): self.markets = {} self.last_refresh = 0 self.ttl = ttl def get(self, market_id): if time.time() - self.last_refresh > self.ttl: self.refresh() return self.markets.get(market_id) def refresh(self): resp = requests.get(f"{BASE_URL}/v1/markets", headers=HEADERS, params={"limit": 200}) for m in resp.json(): self.markets[m["condition_id"]] = m self.last_refresh = time.time() ``` *** ## 7. Implement Exponential Backoff Never hammer the API on errors. Use exponential backoff with jitter: ```python theme={null} import random, time def backoff_wait(attempt, base=1, cap=30): """Exponential backoff with jitter.""" wait = min(base * (2 ** attempt), cap) jitter = random.uniform(0, wait * 0.1) return wait + jitter ``` *** ## 8. Monitor Your Bot Use the health and metrics endpoints to monitor the API and your bot: ```python theme={null} def check_api_health(): """Verify API is ready before trading. GET /v1/health/ready returns status "ok" or "tolerating" with HTTP 200 when the API can serve, and "degraded" with HTTP 503 when it can't. There is no "healthy" status — gate on the 200 + the ok/tolerating set. """ resp = requests.get(f"{BASE_URL}/v1/health/ready") if resp.status_code != 200: print(f"API not ready (HTTP {resp.status_code}): {resp.text}") return False status = resp.json().get("status") if status not in ("ok", "tolerating"): print(f"API not ready: {status}") return False return True ``` Track your bot's performance via the equity curve: ```python theme={null} def log_performance(): """Fetch and log P&L metrics. GET /v1/account/balance returns: balance, currency, unrealized_pnl, total_value, starting_balance. There is no `pnl` key — total PnL is total_value − starting_balance (which is exactly what `unrealized_pnl` reports here: cash + open-position value vs the wallet's starting bankroll). """ from decimal import Decimal b = requests.get(f"{BASE_URL}/v1/account/balance", headers=HEADERS).json() total_pnl = Decimal(b["total_value"]) - Decimal(b["starting_balance"]) print( f"Balance: {b['balance']} | Total value: {b['total_value']} | " f"PnL: {total_pnl} | unrealized_pnl: {b['unrealized_pnl']}" ) ``` *** ## 9. Handle Market Resolution Markets can resolve at any time. Your bot should handle positions being settled: * Winning positions: payout credited automatically * Losing positions: position marked `CLOSED` with `$0` value * Use `GET /v1/account/positions?status=CLOSED` to review settled positions *** ## 10. Test in Virtual Mode First PolySimulator is paper trading — develop and test your complete bot logic with zero financial risk against the same REST surface you'll use in production. Switching to live trading on Polymarket is **not** a pure base-URL swap, though: PM's CLOB `POST /order` requires EIP-712-signed orders (maker/taker amounts, salt, signature). The closest drop-in path is PolySimulator's PM-shape `POST /v1/order` + `py-clob-client`, which approximates PM's wire format — see [CLOB Compatibility](/concepts/clob-compatibility) and [Live Migration](/deployment/live-migration) for what changes. ```bash theme={null} # Paper trading on PolySimulator export POLYSIM_BASE_URL="https://api.polysimulator.com" export POLYSIM_API_KEY="ps_live_..." ``` *** ## Checklist Use this checklist before deploying your bot: * [ ] All numeric values sent as strings * [ ] Idempotency keys on all order requests * [ ] Exponential backoff on 429/5xx errors * [ ] `Retry-After` header respected * [ ] WebSocket reconnection with token refresh * [ ] Cursor pagination for list endpoints * [ ] Market metadata cached locally * [ ] Health check before trading loop starts * [ ] Logging for all order placements and errors * [ ] Tested in virtual mode with full strategy # Error Handling Source: https://docs.polysimulator.com/bots/error-handling Comprehensive error handling patterns for resilient trading bots. # Error Handling Every API response uses standard HTTP status codes with structured JSON error bodies. *** ## Error Response Format By default, the `/v1/*` surface returns the **Polymarket-shape** single-field envelope — one `error` key holding a human-readable description: ```json theme={null} {"error": "Account balance $12.50 insufficient for order cost $25.00"} ``` | Field | Type | Description | | ------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `error` | string | Human-readable description. For PM-shape parity, the machine-readable short code is carried out-of-band in the `X-Polysim-Code` response header (e.g. `INSUFFICIENT_BALANCE`, `RATE_LIMIT_EXCEEDED`). | For stable error handling, **branch on the `X-Polysim-Code` header**, not on the body text — the body holds the prose, the header holds the code. The `X-Request-Id` header echoes the request id for log correlation. **`402 UPGRADE_REQUIRED` is the one carve-out** to the PM-shape default: its body adds `feature_key` and `upgrade_url` alongside `error` so SDKs can render an upsell flow without an extra round-trip. Every other status code (401, 403, 404, 429, 5xx, …) sticks with the single-field shape and exposes the machine code via `X-Polysim-Code`. ```json theme={null} // 402 UPGRADE_REQUIRED — feature_key + upgrade_url at the root { "error": "UPGRADE_REQUIRED", "feature_key": "wallets.sandbox_baseline", "upgrade_url": "/pricing" } ``` For the runtime allowlist gate (`ACCESS_RESTRICTED` — an already-issued key whose account isn't on the API v1 allowlist, or is flagged / under review), the body is PM-shape and the code is in the header: ```json theme={null} // 403 ACCESS_RESTRICTED (not on the allowlist / flagged account) // + headers: X-Polysim-Code: ACCESS_RESTRICTED {"error": "API access restricted. Contact support to request access."} ``` The closed-beta **key-issuance** gate uses the same shape: `POST /v1/keys` and `POST /v1/keys/bootstrap` return `403 CLOSED_BETA` (the default for every non-admitted caller, including paying Pro / Pro+; the `API_PRO_COMING_SOON` variant appears only once self-serve issuance is enabled) with the machine code in the `X-Polysim-Code` header and the human message in the body's `error` field. The `feature_key` / `upgrade_url` hints are body fields **only on 402** responses, not these 403s. See [Closed Beta Errors](#closed-beta-errors) below. **Verbose body opt-in.** Send `X-Polysim-Verbose: true` on any request to get the legacy multi-field shape: ```json theme={null} {"error": "INVALID_KEY", "message": "Invalid API key", "details": null, "request_id": "a1b2c3d4-..."} ``` Useful when writing or debugging an SDK; PM-shape is the default so Polymarket-CLOB SDK ports work without translation. *** ## HTTP Status Codes | Code | Meaning | Retry? | Action | | ----- | ------------------------------------ | ------- | ----------------------------- | | `200` | Success | — | Process response | | `400` | Bad request | No | Fix request payload | | `401` | Invalid or missing API key | No | Check `X-API-Key` header | | `403` | Insufficient permissions | No | Check key scopes | | `404` | Resource not found | No | Verify market ID or order ID | | `409` | Conflict (duplicate idempotency key) | No | Use original response | | `422` | Validation error | No | Fix input fields | | `429` | Rate limited | **Yes** | Wait for `Retry-After` header | | `500` | Internal server error | **Yes** | Retry with backoff | | `502` | Upstream error | **Yes** | Retry with backoff | | `503` | Service unavailable | **Yes** | Retry with backoff | *** ## Common Error Codes ### Trading Errors This table is the **canonical trading error-code reference** — other trading pages link here rather than restating the codes. | Error Code | HTTP | Description | | ------------------------------ | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `INSUFFICIENT_BALANCE` | 400 | Not enough funds for order | | `INSUFFICIENT_POSITION` | 400 | Sell rejected — your position size for the chosen `token_id` is smaller than the requested `quantity`. PolySimulator does not support naked shorts: every SELL must be backed by an existing position. See **No naked shorts** below for the two-sided-quote workaround. | | `INVALID_QUANTITY` | 400 | Non-positive `quantity` rejected by the execution engine. (A schema-level out-of-range value is caught earlier by Pydantic as `422 VALIDATION_FAILED` — see HTTP Status Codes above.) | | `PRICE_REQUIRED` | 400 | Market order submitted without the required `price` (worst-price limit). | | `FOK_ORDER_NOT_FILLED_ERROR` | 400 | The order could not fill entirely at or beyond your worst-price limit (BUY: best price above your cap; SELL: best price below your floor). This is the worst-price rejection for market / FOK orders — there is **no** `409 LIMIT_PRICE_NOT_MET` code. | | `INVALID_ORDER_PAYLOAD` | 400 | Body shape invalid (PM-shape `/v1/order`): bad maker/taker amounts, missing `tokenId`, or unsupported `side` | | `INVALID_ORDER_MIN_TICK_SIZE` | 400 | Limit price doesn't conform to the market's tick size (0.1 / 0.01 / 0.001 / 0.0001). Round to a multiple of `GET /v1/tick-size/{token_id}`. Mirrors Polymarket's behaviour exactly. | | `UNSUPPORTED_ORDER_TYPE` | 400 | `order_type=IOC` on `POST /v1/clob/order` is rejected (use `GTC` to rest or `FOK` for immediate-or-fail). Code is in the `X-Polysim-Code` header. | | `MARKET_NOT_FOUND` | 404 | Unknown `market_id` (or unknown `tokenId` on PM-shape `/v1/order`). Verify with `GET /v1/markets-by-token/{token_id}`. | | `MARKET_CLOSED` | 400 | Market is resolved or inactive | | `ORDER_NOT_FOUND` | 404 | Unknown `order_id` | | `ORDER_NOT_CANCELLABLE` | 400 | The order is already `FILLED`, `CANCELLED`, or `EXPIRED` and can't be cancelled. | | `DUPLICATE_CLIENT_ORDER_ID` | 409 | A new order reused a `client_order_id` already bound to a different order. | | `IDEMPOTENCY_KEY_REUSE` | 409 | The same `Idempotency-Key` was replayed with a **different** request body. (An identical replay instead returns the original order — see Idempotency below.) | | `IDEMPOTENCY_CONFLICT_PENDING` | 409 | The same `Idempotency-Key` is still being processed; carries `Retry-After: 1`. | | `EXECUTION_ERROR` | 500 | Server-side error during fill — report with `request_id` | There is no `409 LIMIT_PRICE_NOT_MET`, `409 IDEMPOTENCY_CONFLICT`, `CANNOT_CANCEL`, or `HTTP_409` trading code — those names appeared in earlier drafts but are not emitted by the engine. Use the codes above. ### Order status values `OrderResponse.status` (and the `status` filter on `GET /v1/orders`) use the PolySimulator-native enum: | Status | Meaning | | ----------- | --------------------------------------------------------------------- | | `PENDING` | Limit order resting on the book, not yet filled. | | `FILLED` | Order matched and executed. | | `CANCELLED` | Order cancelled (by you, FOK/IOC non-fill, or the dead-man's-switch). | | `EXPIRED` | Order expired (e.g. market resolved while resting). | | `REJECTED` | Per-entry batch failure (see [Batch Orders](/trading/batch-orders)). | | `ERROR` | Per-entry batch internal error (batch only). | SDKs ported from Polymarket read the PM-shape `ORDER_STATUS_*` enum from `GET /v1/data/orders` instead — see [CLOB Compatibility](/concepts/clob-compatibility). #### No naked shorts — `INSUFFICIENT_POSITION` explained PolySimulator (and Polymarket itself) requires every SELL order to be backed by an existing position in that exact `token_id`. There is no margin, no borrow, no synthetic short. If you try to sell shares you don't hold, the order is rejected with `400 INSUFFICIENT_POSITION` (header `X-Polysim-Code: INSUFFICIENT_POSITION`). For binary markets (every `/markets/{id}` with two outcomes), the standard market-maker idiom is a **two-sided buy** rather than a buy + a short: ```python theme={null} # Wrong — naked short on the "Down" side: sell_down = post("/v1/order", json={"token_id": down_tok, "side": "SELL", "quantity": 100, "price": 0.51}) # → 400 INSUFFICIENT_POSITION (you don't hold any Down shares) # Right — buy both sides instead. Up + Down ≈ 1.00, so total notional is similar # to a two-sided quote, and you keep the spread on whichever side fills first: buy_up = post("/v1/order", json={"token_id": up_tok, "side": "BUY", "quantity": 100, "price": 0.49}) buy_down = post("/v1/order", json={"token_id": down_tok, "side": "BUY", "quantity": 100, "price": 0.49}) ``` After a buy fills you accumulate position; subsequent SELLs against that position are accepted up to the held quantity. Use `GET /v1/account/positions` to check your inventory per `token_id` before submitting a SELL. ### Authentication Errors | Error Code | HTTP | Description | | ------------------------- | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `MISSING_AUTH` | 401 | No `Authorization: Bearer …` or `X-API-Key` on a route that accepts either | | `MISSING_API_KEY` | 401 | `X-API-Key` (or legacy `POLY_API_KEY`) missing on a key-only route | | `INVALID_KEY` | 401 | Key doesn't exist or is revoked | | `INVALID_TOKEN` | 401 | Bearer JWT is malformed, missing `sub`, or fails signature verification | | `KEY_EXPIRED` | 401 | API key has expired | | `KEY_DEACTIVATED` | 401 | Key was administratively disabled | | `TOKEN_EXPIRED` | 401 | Bearer JWT past its `exp` claim | | `INSUFFICIENT_PERMISSION` | 403 | Key missing the required permission (e.g. `trade` for order endpoints) | | `ACCESS_RESTRICTED` | 403 | An already-issued key (or Bearer JWT) is not on the admin-managed API v1 allowlist (or the account is flagged / under review). Returned on authenticated `/v1/*` requests. Body is PM-shape; check the `X-Polysim-Code` header. | | `CLOSED_BETA` | 403 | Key issuance refused on `POST /v1/keys` / `POST /v1/keys/bootstrap`. The default for every non-admitted caller while the beta is closed, including paying Pro / Pro+. Machine code in the `X-Polysim-Code` header; the body's `error` is the human message. | | `API_PRO_COMING_SOON` | 403 | Conditional variant of the issuance gate for a paying Pro / Pro+ caller without a cohort grant — reached only once self-serve issuance is enabled (until then they also get `CLOSED_BETA`). Machine code in the `X-Polysim-Code` header. | | `UPGRADE_REQUIRED` | 402 | Pro-tier cap reached (sandbox count, etc.). **Only** 402 carries `feature_key` + `upgrade_url` at the root of the response body. | ### Rate Limit Errors The backend emits **two distinct 429 codes** (in the `X-Polysim-Code` header). Both are retryable and both carry `Retry-After` — branch on either, or simply treat any 429 as a back-off signal: | Error Code | HTTP | Description | | --------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `RATE_LIMIT_EXCEEDED` | 429 | The per-tier in-process concurrency cap (all `/v1/*` paths) or the IP / per-key request-rate limiter. Retry after `Retry-After`. | | `RATE_LIMITED` | 429 | The cross-worker **trade-concurrency** limiter on the three trade-write paths (`POST /v1/orders`, `/v1/orders/batch`, `/v1/clob/order`). Body also carries `retry_after_ms`. Retry after `Retry-After`. | A bot that branches **only** on `RATE_LIMIT_EXCEEDED` will miss the `RATE_LIMITED` 429s from the trade-write paths (and vice-versa). The robust pattern is to back off on `resp.status_code == 429` regardless of which code is in the header. The per-tier limits (authoritative source: `GET /v1/keys/tiers`, seeded from the `ApiRateLimit` ladder): | Tier | Req/sec | Req/min | WS conns | Max batch | | ---------- | :-----: | :-----: | :------: | :-------: | | Free | 2 | 120 | 1 | 1 | | Pro | 10 | 600 | 3 | 5 | | Pro+ | 30 | 1,800 | 10 | 10 | | Enterprise | 100 | 6,000 | 50 | 25 | Closed-beta cohort keys run at the enterprise tier until the cutoff (2026-08-31), then auto-downgrade to free + read-only. If a static value here ever disagrees with `GET /v1/keys/tiers`, the endpoint wins. ### Closed Beta Errors The API is in an ongoing closed beta. Key **issuance** is cohort-gated: | Error Code | HTTP | Description | | --------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `CLOSED_BETA` | 403 | `POST /v1/keys` / `POST /v1/keys/bootstrap` refused. The default for every non-admitted caller while the beta is closed, including paying Pro / Pro+. Machine code in the `X-Polysim-Code` header; the body's `error` is the human message. **Not retryable** — apply via the waitlist at [https://polysimulator.com/api-trading](https://polysimulator.com/api-trading). | | `API_PRO_COMING_SOON` | 403 | Same endpoints, conditional variant for a paying Pro / Pro+ caller without a cohort grant — reached only once self-serve issuance is enabled (until then they also get `CLOSED_BETA`). Machine code in the `X-Polysim-Code` header. | | `ACCESS_RESTRICTED` | 403 | Separate runtime gate: an already-issued key (or Bearer JWT) is not on the admin-managed allowlist (or is flagged / under review). Returned on authenticated `/v1/*` requests. Check the `X-Polysim-Code` header. **Not retryable**. | | `COHORT_FULL` | 409 | Beta cohort issuance refused (admin endpoint). Detail: `current_active`, `cap`, `requested`. | Beta-issued keys carry an `X-API-Beta-Cutoff` response header on every request after the cutoff date — SDKs can pivot to read-only mode without an extra round-trip. ```python theme={null} # Handle the closed-beta key-issuance gate cleanly. The issuance codes # (CLOSED_BETA / API_PRO_COMING_SOON) are in the `X-Polysim-Code` header; # the body's `error` field holds the human message. resp = requests.post(f"{BASE_URL}/v1/keys", headers={"X-API-Key": KEY}, json={"name": "bot"}) if resp.status_code == 403: code = resp.headers.get("X-Polysim-Code") if code == "CLOSED_BETA": # Not in an admitted cohort yet — apply via the waitlist. print("Closed beta — join the waitlist at https://polysimulator.com/api-trading") elif code == "API_PRO_COMING_SOON": # Paying Pro / Pro+ without a cohort grant — rolling out in cohorts. print("Pro API access is rolling out — see /account/billing") ``` *** ## Retry Strategy ```python Python — Exponential Backoff theme={null} import time import requests def api_call_with_retry(method, url, max_retries=3, **kwargs): """Make API call with exponential backoff on retryable errors.""" for attempt in range(max_retries + 1): try: resp = requests.request(method, url, **kwargs) if resp.status_code == 429: # Rate limited — use server-provided wait time wait = int(resp.headers.get("Retry-After", 2 ** attempt)) print(f"Rate limited, waiting {wait}s...") time.sleep(wait) continue if resp.status_code >= 500: # Server error — retry with backoff wait = 2 ** attempt print(f"Server error {resp.status_code}, retry in {wait}s...") time.sleep(wait) continue # Success or client error (no retry) resp.raise_for_status() return resp.json() except requests.exceptions.ConnectionError: wait = 2 ** attempt print(f"Connection error, retry in {wait}s...") time.sleep(wait) raise Exception(f"Max retries ({max_retries}) exceeded for {url}") ``` ```javascript JavaScript — Exponential Backoff theme={null} async function apiCallWithRetry(method, url, options = {}, maxRetries = 3) { for (let attempt = 0; attempt <= maxRetries; attempt++) { try { const resp = await fetch(url, { method, ...options }); if (resp.status === 429) { const wait = parseInt(resp.headers.get("Retry-After") || 2 ** attempt); console.log(`Rate limited, waiting ${wait}s...`); await new Promise(r => setTimeout(r, wait * 1000)); continue; } if (resp.status >= 500) { const wait = 2 ** attempt; console.log(`Server error ${resp.status}, retry in ${wait}s...`); await new Promise(r => setTimeout(r, wait * 1000)); continue; } if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${await resp.text()}`); return await resp.json(); } catch (err) { if (err.message.includes("fetch failed") && attempt < maxRetries) { const wait = 2 ** attempt; console.log(`Connection error, retry in ${wait}s...`); await new Promise(r => setTimeout(r, wait * 1000)); continue; } throw err; } } throw new Error(`Max retries (${maxRetries}) exceeded for ${url}`); } ``` *** ## WebSocket Error Handling WebSocket connections use custom close codes: | Close Code | Meaning | Action | | ---------- | --------------------------- | ------------------------ | | `1000` | Normal close | Reconnect if desired | | `1001` | Server going away | Reconnect after 1s | | `4001` | Authentication failed | Get new token, reconnect | | `4002` | Subscription limit exceeded | Reduce subscriptions | ```python theme={null} import asyncio import aiohttp async def resilient_ws(url, token, market_ids): """WebSocket connection with automatic reconnection.""" backoff = 1 while True: try: async with aiohttp.ClientSession() as session: async with session.ws_connect(f"{url}?token={token}") as ws: backoff = 1 # Reset on successful connect await ws.send_json({ "action": "subscribe", "markets": market_ids, }) async for msg in ws: if msg.type == aiohttp.WSMsgType.TEXT: handle_message(msg.data) elif msg.type == aiohttp.WSMsgType.CLOSED: break elif msg.type == aiohttp.WSMsgType.ERROR: break except Exception as e: print(f"WS error: {e}") wait = min(backoff, 30) print(f"Reconnecting in {wait}s...") await asyncio.sleep(wait) backoff *= 2 ``` *** ## Best Practices Never assume a 2xx response. Parse the status code and handle each category appropriately. On 429 responses, the `Retry-After` header tells you exactly how long to wait. Don't guess. Client errors (400-422) indicate a problem with your request. Fix the payload instead of retrying. Always log the full error response body for debugging — the `details` field often contains actionable info. # Example Trading Bot Source: https://docs.polysimulator.com/bots/example-trading-bot Complete Python trading bot implementing a mean-reversion strategy. # Example Trading Bot A complete, working Python bot implementing a simple **mean-reversion strategy**: 1. Fetch hot markets 2. Find markets where Yes price \< 0.40 (undervalued) 3. Buy small positions 4. Monitor and sell when price rises above 0.60 *** ## Full Source Code ```python theme={null} #!/usr/bin/env python3 """ PolySimulator Example Trading Bot Simple mean-reversion strategy: 1. Fetch hot markets 2. Find markets where Yes price is < 0.40 (undervalued) 3. Buy small positions 4. Monitor and sell when price rises above 0.60 Usage: export POLYSIM_API_KEY="ps_live_..." export POLYSIM_BASE_URL="https://api.polysimulator.com" python trading_bot.py """ import json import os import time import uuid import requests API_KEY = os.environ["POLYSIM_API_KEY"] BASE_URL = os.environ.get("POLYSIM_BASE_URL", "https://api.polysimulator.com") HEADERS = { "X-API-Key": API_KEY, "Content-Type": "application/json", } def get_balance(): """Get current account balance.""" resp = requests.get(f"{BASE_URL}/v1/account/balance", headers=HEADERS) resp.raise_for_status() return resp.json() def get_hot_markets(limit=20): """Fetch actively traded markets.""" resp = requests.get( f"{BASE_URL}/v1/markets", headers=HEADERS, params={"hot_only": True, "limit": limit}, ) resp.raise_for_status() return resp.json() # Terminal (non-resting) order states returned by GET /v1/orders/{id}. # A PENDING order is still resting on the book; anything in this set is # done. NOTE the endpoint matters: # * GET /v1/orders/{id} → PENDING | FILLED | CANCELLED | REJECTED | EXPIRED (this bot) # * GET /v1/data/order/{id} → ORDER_STATUS_PENDING | _FILLED | _CANCELLED | _REJECTED (PM-CLOB shape) # * POST /v1/order response → live | matched | unmatched (PM write-response shape) TERMINAL_STATUSES = ("FILLED", "CANCELLED", "REJECTED", "EXPIRED") def poll_order(order_id, timeout_s=30): """Poll GET /v1/orders/{id} until status is final. Used to recover from a 503 DEADLINE_OVERSHOT_BUT_PERSISTED — the order persisted but the server response timed out. Polling lets us learn the real outcome (a ``TERMINAL_STATUSES`` value, vs the still-resting ``PENDING``) without double-placing. Each request has a per-call ``timeout=5`` so a stalled TCP socket can't pin a poll iteration past the ``timeout_s`` deadline. On ``requests.exceptions.RequestException`` we sleep and retry instead of bailing — transient network failures shouldn't terminate the recovery loop. """ deadline = time.time() + timeout_s while time.time() < deadline: try: r = requests.get( f"{BASE_URL}/v1/orders/{order_id}", headers=HEADERS, timeout=5, ) if r.status_code == 200: body = r.json() status = body.get("status") if status in TERMINAL_STATUSES: return body except requests.exceptions.RequestException: # Transient (connect/read timeout, DNS, conn-reset). # Keep polling — the order is already persisted server-side. pass time.sleep(1) return None # caller decides whether to give up or keep polling def place_order( market_id, side, outcome, quantity, price, order_type="market", idempotency_key=None, ): """Place a market or limit order. Note: ``price`` is REQUIRED even for market orders — it acts as a worst-price slippage cap (Polymarket-faithful semantics). For BUY, set the maximum you'll pay; for SELL, the minimum you'll accept. Handles the 503 DEADLINE_OVERSHOT_BUT_PERSISTED envelope correctly: when the server-side deadline fires after the order has already persisted, the response carries the order_id in the ``X-Polysim-Order-Id`` header. We DO NOT re-place; we poll the order endpoint to learn the final state. Critically, we re-use the SAME idempotency_key on retry — the server's idempotency layer deduplicates, but only when the key is stable across the retry sequence. """ # The default idempotency key MUST be stable across retries of the # same logical order; otherwise the helper's retry-safety guarantee # is hollow (a fresh key on every retry defeats the server-side # dedup layer). Do NOT use ``uuid.uuid4()`` here — it's fresh-random # per call. # # The deterministic key is uuid5(NAMESPACE_OID, payload) where the # payload combines the order tuple AND a second-precision timestamp. # Second precision keeps retries within a 1-second window dedup'd # (typical 503-overshoot retry latency is <500ms) while letting # genuinely independent same-second orders be the caller's job to # disambiguate — for high-frequency strategies, pass an explicit # ``idempotency_key`` (e.g. ``f"{strategy}-{client_seq}"``). if idempotency_key is None: second_bucket = int(time.time()) seed = f"{market_id}|{side}|{outcome}|{quantity}|{price}|{second_bucket}" idempotency_key = f"bot-{uuid.uuid5(uuid.NAMESPACE_OID, seed)}" payload = { "market_id": market_id, "side": side, "outcome": outcome, "quantity": str(quantity), # Always use strings for numeric values "price": str(price), # Worst-price slippage cap "order_type": order_type, } # Mirror ``poll_order``'s per-call ``timeout=5`` here. A stalled TCP # socket on the POST would otherwise hang past the retry/idempotency # boundary — and because the request is wrapped in idempotency-by-key, # ``requests.exceptions.RequestException`` is the SAFE recovery surface: # we don't know whether the order persisted server-side or not, but the # caller's next call with the SAME default key will dedup if it did. # Surface the exception so the caller can decide (retry with the same # ``idempotency_key`` is safe; ignore-and-continue would silently lose # an order). Do NOT swallow — unlike the poll loop, place_order is the # write path, not a recovery loop. try: resp = requests.post( f"{BASE_URL}/v1/orders", headers={ **HEADERS, "Idempotency-Key": idempotency_key, }, json=payload, timeout=5, ) except requests.exceptions.RequestException as exc: # Surface a structured error so callers can retry with the SAME # ``idempotency_key`` (guaranteed-stable by the uuid5 seed above) # without double-placing. The server's idempotency layer will # deduplicate if the original request did land. raise RuntimeError( f"place_order network error (retry with same idempotency_key " f"{idempotency_key!r} is safe): {exc}" ) from exc # Handle 503 DEADLINE_OVERSHOT_BUT_PERSISTED without crashing. # The order DID land server-side; the X-Polysim-Order-Id header # carries the id so we can poll for the final status instead of # retrying (which would either double-fill or be deduplicated by # the idempotency layer — but only if the key is stable). if resp.status_code == 503: body = {} try: body = resp.json() except Exception: pass # Branch on the stable machine code in the X-Polysim-Code header — # the default /v1/* body is PM-shape {"error": ""}, # so body["error"] is prose, not the code. if resp.headers.get("X-Polysim-Code") == "DEADLINE_OVERSHOT_BUT_PERSISTED": order_id = resp.headers.get("X-Polysim-Order-Id") or body.get("order_id") if order_id is not None: print(f" [503] Order {order_id} persisted but deadline overshot. Polling for final state...") final = poll_order(order_id) if final is not None: return final # Polling timed out — surface the partial info so the # caller can decide whether to keep polling or give up. return {"order_id": order_id, "status": "PENDING", "deadline_overshoot": True} # Other 503 classes (SERVER_BUSY etc.) — let raise_for_status fire. resp.raise_for_status() return resp.json() def get_positions(): """Get all open positions.""" resp = requests.get( f"{BASE_URL}/v1/account/positions", headers=HEADERS, params={"status": "OPEN"}, ) resp.raise_for_status() return resp.json() def run_strategy(): """Main trading loop.""" print("=== PolySimulator Bot Starting ===") balance = get_balance() print(f"Balance: ${balance['balance']} | Unrealized PnL: {balance['unrealized_pnl']}") while True: try: # 1. Check balance balance = get_balance() cash = float(balance["balance"]) print(f"\n--- Cycle at {time.strftime('%H:%M:%S')} | Cash: ${cash:.2f} ---") if cash < 1.0: print("Low balance — waiting for sells or settlements") time.sleep(30) continue # 2. Scan for undervalued markets markets = get_hot_markets(limit=30) opportunities = [] for m in markets: if not m.get("live_price"): continue yes_price = float(m["live_price"]["buy"]) if 0.10 < yes_price < 0.40: opportunities.append((m, yes_price)) if opportunities: print(f"Found {len(opportunities)} undervalued markets") # Buy the cheapest one opportunities.sort(key=lambda x: x[1]) market, price = opportunities[0] qty = min(5, int(cash / price)) if qty > 0: # Worst-price BUY cap: 5% above live ask, hard-clamped at 0.99 worst_buy = min(0.99, price * 1.05) print(f" BUY {qty}x Yes @ {price:.2f} (cap {worst_buy:.2f}) — {market['question'][:60]}") result = place_order( market["condition_id"], "BUY", "Yes", qty, worst_buy ) print(f" → Order {result['order_id']}: {result['status']} @ {result['price']}") # 3. Check existing positions for exit signals positions = get_positions() for pos in positions: current = float(pos["current_price"]) if pos.get("current_price") else None if current and current > 0.60: qty = int(float(pos["quantity"])) if qty > 0: # Worst-price SELL floor: 5% below current, hard-floored at 0.01 worst_sell = max(0.01, current * 0.95) print(f" SELL {qty}x {pos['outcome']} @ {current:.2f} (floor {worst_sell:.2f})") result = place_order( pos["market_id"], "SELL", pos["outcome"], qty, worst_sell ) print(f" → Order {result['order_id']}: {result['status']} @ {result['price']}") time.sleep(10) # Wait before next cycle except requests.exceptions.HTTPError as e: if e.response.status_code == 429: retry = int(e.response.headers.get("Retry-After", 5)) print(f"Rate limited — waiting {retry}s") time.sleep(retry) else: print(f"HTTP Error: {e}") time.sleep(5) except Exception as e: print(f"Error: {e}") time.sleep(10) if __name__ == "__main__": run_strategy() ``` *** ## Running the Bot ```bash theme={null} # Set environment variables export POLYSIM_API_KEY="ps_live_..." export POLYSIM_BASE_URL="https://api.polysimulator.com" # Install dependencies pip install requests # Run python trading_bot.py ``` *** ## Key Patterns Always pass `quantity` and `price` as strings to prevent floating-point issues: ```python theme={null} payload["quantity"] = str(quantity) # "10", not 10 ``` Each order includes a STABLE idempotency key. The key MUST stay the same across retries of the same logical order — the server's idempotency layer (`/v1/orders` → `Idempotency-Key` header) only deduplicates when the key is identical to a previous attempt: ```python theme={null} # Stable across retries: uuid5 over the order tuple + a SECOND # bucket. Two calls within the same second with the same # parameters get the SAME key → server dedups → no double-fill. # Two genuinely independent orders >= 1 second apart get DIFFERENT # keys (a coarser minute-bucket would wrongly collapse rapid-fire # orders into one). second_bucket = int(time.time()) seed = f"{market_id}|{side}|{outcome}|{quantity}|{price}|{second_bucket}" idempotency_key = f"bot-{uuid.uuid5(uuid.NAMESPACE_OID, seed)}" "Idempotency-Key": idempotency_key ``` **Do NOT use `uuid.uuid4()` or a raw timestamp directly in your idempotency key.** Both change on every retry, so the server treats each retry as a fresh order and you double-fill on the first transient error. Use `uuid.uuid5()` over a deterministic seed (order tuple + second bucket) so retries within the same second collapse to the same key, but two genuinely independent orders a second later don't collide. For high-frequency strategies that place multiple orders per second, pass an explicit `idempotency_key` (e.g. `f"{strategy}-{client_seq}"`) so each logical order has its own key. The `/v1/orders` endpoint has a 5-second server-side deadline. If the order persists but the response times out, you get: ``` HTTP/1.1 503 Service Unavailable Retry-After: 1 X-Polysim-Order-Id: 42 Content-Type: application/json { "error": "DEADLINE_OVERSHOT_BUT_PERSISTED", "error_code": "PERSISTED_DO_NOT_RETRY", "message": "...", "order_id": 42 } ``` **Do NOT retry** — your order DID land. Read `X-Polysim-Order-Id` (header) or `body.order_id` (JSON), then poll `GET /v1/orders/{id}` until `status` is terminal (`FILLED` / `CANCELLED` / `REJECTED` / `EXPIRED`; `PENDING` means still resting). `response.raise_for_status()` crashes on 503 BEFORE you can read the body. If you call it before inspecting the status code, you'll lose the order\_id. Always check `response.status_code == 503` FIRST and recover from the header / body before `raise_for_status()`. The example `place_order()` above implements this recovery pattern. On HTTP 429, read the `Retry-After` header and wait exactly that long: ```python theme={null} if e.response.status_code == 429: retry = int(e.response.headers.get("Retry-After", 5)) time.sleep(retry) ``` *** ## Next Steps * [WebSocket Bot](/bots/websocket-bot) — Real-time streaming bot * [Error Handling](/bots/error-handling) — Robust error handling patterns * [Best Practices](/bots/best-practices) — Production-grade bot patterns # WebSocket Trading Bot Source: https://docs.polysimulator.com/bots/websocket-bot Real-time trading bot using WebSocket price feeds for instant reaction to market changes. # WebSocket Trading Bot Build a real-time bot that reacts to price changes **instantly** via WebSocket streaming, instead of polling the REST API. *** ## Architecture ```mermaid theme={null} graph LR A[WebSocket Connect] --> B[Authenticate] B --> C[Subscribe to Markets] C --> D{Price Update} D -->|Signal Detected| E[Place Order via REST] D -->|No Signal| C E --> F[Log Result] F --> C ``` *** ## Full Source Code ```python Python (asyncio) theme={null} #!/usr/bin/env python3 """ WebSocket Trading Bot for PolySimulator Streams real-time prices via WebSocket and places orders via REST when price signals are detected. Usage: export POLYSIM_API_KEY="ps_live_..." python ws_bot.py """ import asyncio import json import os import time import aiohttp API_KEY = os.environ["POLYSIM_API_KEY"] BASE_URL = os.environ.get("POLYSIM_BASE_URL", "https://api.polysimulator.com") WS_URL = BASE_URL.replace("http://", "ws://").replace("https://", "wss://") async def get_ws_token(session): """Obtain a short-lived WebSocket authentication token.""" async with session.post( f"{BASE_URL}/v1/keys/ws-token", headers={"X-API-Key": API_KEY}, ) as resp: data = await resp.json() return data["token"] async def place_order(session, market_id, side, outcome, quantity, price): """Place an order via REST API. Note: ``price`` is REQUIRED even for market orders — it acts as a worst-price slippage cap. Pass it as a string. Compute from the live WS frame, and **always derive the cap from the price of the outcome you are trading**: for BUY Yes use ``min(0.99, float(data["buy"]) * 1.05)``, for SELL Yes use ``max(0.01, float(data["buy"]) * 0.95)``. ``data["buy"]`` is the Yes price; ``data["sell"]`` is the No price — *not* a "sell-side ask" for Yes. """ payload = { "market_id": market_id, "side": side, "outcome": outcome, "quantity": str(quantity), "price": str(price), # Worst-price slippage cap "order_type": "market", } async with session.post( f"{BASE_URL}/v1/orders", headers={ "X-API-Key": API_KEY, "Content-Type": "application/json", "Idempotency-Key": f"ws-{market_id}-{side}-{int(time.time())}", }, json=payload, ) as resp: return await resp.json() async def run_ws_bot(market_ids: list[str]): """Main WebSocket bot loop.""" async with aiohttp.ClientSession() as session: # 1. Get WebSocket token token = await get_ws_token(session) print(f"Got WS token, connecting...") # 2. Connect to WebSocket async with session.ws_connect( f"{WS_URL}/v1/ws/prices?token={token}" ) as ws: # 3. Subscribe to markets await ws.send_json({ "action": "subscribe", "markets": market_ids, }) print(f"Subscribed to {len(market_ids)} markets") # 4. Process price updates async for msg in ws: if msg.type == aiohttp.WSMsgType.TEXT: data = json.loads(msg.data) if data.get("type") == "price": market_id = data["market_id"] yes_price = float(data["buy"]) # Strategy: buy when Yes < 0.35, sell when > 0.65 if yes_price < 0.35: # Worst-price BUY cap: 5% above live ask, clamped at 0.99 worst_buy = min(0.99, yes_price * 1.05) print(f"BUY signal: {market_id} @ {yes_price:.2f} (cap {worst_buy:.2f})") result = await place_order( session, market_id, "BUY", "Yes", 5, worst_buy ) print(f" → {result.get('status')} @ {result.get('price')}") elif yes_price > 0.65: # Worst-price SELL floor: 5% below the live Yes # quote, floored at 0.01. NOTE: derive the floor # from `data["buy"]` (the Yes outcome price) — NOT # `data["sell"]`, which is the No outcome price. # A typical market with buy=0.70, sell=0.30 would # otherwise produce a floor near 0.285, allowing # fills far below the intended Yes valuation. worst_sell = max(0.01, yes_price * 0.95) print(f"SELL signal: {market_id} @ {yes_price:.2f} (floor {worst_sell:.2f})") result = await place_order( session, market_id, "SELL", "Yes", 5, worst_sell ) print(f" → {result.get('status')} @ {result.get('price')}") elif data.get("type") == "pong": pass # Heartbeat response elif msg.type == aiohttp.WSMsgType.CLOSED: print("WebSocket closed, reconnecting...") break elif msg.type == aiohttp.WSMsgType.ERROR: print(f"WebSocket error: {ws.exception()}") break async def main(): # Define which markets to monitor market_ids = [ "0x1234abcd...", # Replace with actual condition IDs "0x5678efgh...", ] while True: try: await run_ws_bot(market_ids) except Exception as e: print(f"Connection error: {e}") print("Reconnecting in 5s...") await asyncio.sleep(5) if __name__ == "__main__": asyncio.run(main()) ``` ```javascript JavaScript (Node.js) theme={null} const WebSocket = require("ws"); const fetch = require("node-fetch"); const API_KEY = process.env.POLYSIM_API_KEY; const BASE_URL = process.env.POLYSIM_BASE_URL || "https://api.polysimulator.com"; const WS_URL = BASE_URL.replace("http://", "ws://").replace("https://", "wss://"); async function getWsToken() { const resp = await fetch(`${BASE_URL}/v1/keys/ws-token`, { method: "POST", headers: { "X-API-Key": API_KEY }, }); const data = await resp.json(); return data.token; } // `price` is REQUIRED even for market orders — it acts as a worst-price // slippage cap. For BUY Yes: `Math.min(0.99, data.buy * 1.05)`. For // SELL Yes: `Math.max(0.01, data.buy * 0.95)` (the floor is on the // SAME outcome being sold — `data.buy` is the Yes price; `data.sell` // is the No price, not a "sell-side ask" for Yes). Pass it as a string. async function placeOrder(marketId, side, outcome, quantity, price) { const resp = await fetch(`${BASE_URL}/v1/orders`, { method: "POST", headers: { "X-API-Key": API_KEY, "Content-Type": "application/json", "Idempotency-Key": `ws-${marketId}-${side}-${Date.now()}`, }, body: JSON.stringify({ market_id: marketId, side, outcome, quantity: String(quantity), price: String(price), // Worst-price slippage cap order_type: "market", }), }); return resp.json(); } async function runBot(marketIds) { const token = await getWsToken(); const ws = new WebSocket(`${WS_URL}/v1/ws/prices?token=${token}`); ws.on("open", () => { ws.send(JSON.stringify({ action: "subscribe", markets: marketIds, })); console.log(`Subscribed to ${marketIds.length} markets`); }); ws.on("message", async (raw) => { const data = JSON.parse(raw); if (data.type === "price") { const yesPrice = parseFloat(data.buy); if (yesPrice < 0.35) { const worstBuy = Math.min(0.99, yesPrice * 1.05); console.log(`BUY signal: ${data.market_id} @ ${yesPrice} (cap ${worstBuy.toFixed(2)})`); const result = await placeOrder(data.market_id, "BUY", "Yes", 5, worstBuy); console.log(` → ${result.status} @ ${result.price}`); } else if (yesPrice > 0.65) { // Worst-price SELL floor: 5% below the live Yes quote, floored at // 0.01. NOTE: derive the floor from `data.buy` (the Yes outcome // price) — NOT `data.sell`, which is the No outcome price. A // typical market with buy=0.70, sell=0.30 would otherwise produce // a floor near 0.285, allowing fills far below the intended Yes // valuation. const worstSell = Math.max(0.01, yesPrice * 0.95); console.log(`SELL signal: ${data.market_id} @ ${yesPrice} (floor ${worstSell.toFixed(2)})`); const result = await placeOrder(data.market_id, "SELL", "Yes", 5, worstSell); console.log(` → ${result.status} @ ${result.price}`); } } }); ws.on("close", () => console.log("WebSocket closed")); ws.on("error", (err) => console.error("WebSocket error:", err)); } runBot(["0x1234abcd...", "0x5678efgh..."]); ``` *** ## Why WebSocket Over Polling? | Metric | REST Polling (5s) | WebSocket | | --------------- | ----------------- | -------------- | | Latency | 0–5,000 ms | \<100 ms | | API calls/hour | 720 per market | 1 (connection) | | Rate limit risk | High | None | | Data freshness | Stale up to 5s | Real-time | Use REST API for **order placement** and **account queries**. Use WebSocket for **price monitoring** and **signal detection**. *** ## Connection Management WebSocket tokens expire after **60 seconds**. Your bot must handle reconnection: 1. Detect close/error events 2. Request a new token via `POST /v1/keys/ws-token` 3. Re-subscribe to all markets *** ## Next Steps * [Error Handling](/bots/error-handling) — Robust error and retry patterns * [Best Practices](/bots/best-practices) — Production bot patterns # Changelog Source: https://docs.polysimulator.com/changelog API and documentation changes, including breaking changes shipped during the beta. **Beta notice.** During the closed beta, breaking changes may ship with little or no advance notice — this page is the authoritative record. Pin your integration to the behaviors documented here and re-check after the dates listed. A formal versioning and deprecation policy will be published when the API leaves beta. ## 2026-06-11 — Realistic book impact + maker price fidelity (rolling out) Two further matching-engine fidelity upgrades, **flag-dependent** — they ship behind the same deployment flag as the "PM-faithful order semantics" entry below and activate together (announced here). Until activation, the legacy behaviors remain in force. * **Book-impact depletion.** Limit-order fills now consume the displayed liquidity they walk. Within one upstream order-book snapshot, size already consumed at a price level by limit executions (your fills or anyone else's) is not offered again: the depth walk subtracts prior consumption before pricing, so repeated orders walk to worse levels within your limit, partial-fill, or (FOK) kill — instead of repeatedly buying the same displayed shares while the snapshot stayed cached. A fresh upstream snapshot supersedes the synthetic depletion (real book truth wins; the safety TTL is sized to outlive the snapshot cache window). Applies to both resting-limit matching and immediate FOK/FAK/IOC execution. **Market-order fills don't yet feed or read the depletion overlay** — that path re-resolves fresher books per request; wiring it into the same overlay is a documented follow-up. Simulated fills still never move the *displayed* book or midpoint — depletion is execution-side only. *Legacy: the same displayed level was re-buyable every cycle.* * **Resting maker orders fill at their limit price.** Polymarket's maker/taker rule — price improvement always benefits the taker. A resting GTC/GTD limit the market later crosses now executes at the maker's own limit price (for BUYs the reserve equals the spend — zero refund delta; maker fee remains \$0). Marketable orders (FOK/FAK/IOC and limits that cross at placement) are takers and keep their depth-walk price improvement, exactly as before. *Legacy: resting orders filled at the later touch/VWAP price — systematically better than the maker's own limit, which inflated PnL.* ## 2026-06-11 — PM-compat P1 batch (pagination, status enums, prices-history, neg-risk, cursors, full book, WS fills) The second wire-parity wave, driven by the 2026-06-10 API evaluation's confirmed P1 findings. Every item below was verified against live `clob.polymarket.com` probes and/or Polymarket's API reference before changing. ### Breaking changes * **`GET /v1/data/orders` status enum now uses Polymarket's exact members.** `ORDER_STATUS_LIVE` (was the invented `ORDER_STATUS_PENDING`), `ORDER_STATUS_MATCHED` (was `ORDER_STATUS_FILLED`), `ORDER_STATUS_CANCELED` — single L, per PM — (was `ORDER_STATUS_CANCELLED`), `ORDER_STATUS_INVALID` (was `ORDER_STATUS_REJECTED`). Bots string-matching the old values must update; the `?status=` filter accepts both spellings. * **`GET /v1/data/orders` defaults to OPEN orders only** — matching PM ("Retrieves open orders for the authenticated user"). History is an explicit opt-in via the new polysim-extension `?status=` param (`ORDER_STATUS_MATCHED`, `ORDER_STATUS_CANCELED`, `ALL`, friendly aliases). Previously every status came back, so open-order counts and cancel-all sweeps operated on dead rows. * **`GET /v1/prices-history` returns PM's exact shape by default**: `{"history": [{"t": , "p": }]}` — `p` is a JSON number here, mirroring PM's wire. The old default (a bare array of `{t, o, h, l, c}` string points) remains available via `?format=ohlcv`. The endpoint also accepts PM's required `?market=` param (token id; `?token_id=` stays as an alias), supports `startTs`/`endTs`/`fidelity` and PM's full interval enum (`1h/6h/1d/1w/1m/max/all`), and 400s (not 422s) with PM's verbatim message when `market` is missing. * **`GET /v1/orders` `next_cursor` is now urlsafe-base64.** The raw ISO form contained `+`, which broke un-percent-encoded round-trips with a 400. Treat cursors as opaque; legacy raw-ISO (and the `+`-mangled-to-space form) are still accepted on the way in. * **`GET /v1/markets?envelope=true` cursors are now PM-format and actually round-trip.** `next_cursor` is a base64-encoded offset (`"NTA="` = 50) with PM's `"LTE="` terminal sentinel (was a raw int string + `""` terminal that NO query param accepted — PM-style paginators looped on page 1 forever). Pass it back via `?next_cursor=` or `?cursor=`; raw int offsets are still accepted on the way in. * **Order-book endpoints return the FULL book by default** (`/v1/book`, `/v1/clob/book/{token_id}`, `/v1/markets/{condition_id}/book`, `POST /v1/books`) — PM's wire contract (PM has no depth param; we measured PM 96/113 levels where polysim silently returned 10/10). `?depth=N` remains as an explicit best-N trim, cap raised from 50 to 500. * **`before`/`after` filters on `/v1/data/orders` + `/v1/data/trades` take unix-seconds timestamps** (PM convention). Unparseable values now return 400 `INVALID_TIME_FILTER` instead of being silently dropped (which returned the full unfiltered set). ISO datetimes remain accepted as a polysim extra. ### Fixes * **Pagination request param `next_cursor` honored** on `/v1/data/orders`, `/v1/data/trades`, `/v1/orders` and `/v1/markets?envelope=true` — py-clob-client sends the cursor back under this name; it was silently ignored, so `get_orders()` / `get_trades()` refetched page 1 forever. The SDK's `MA==` initial seed and `LTE=` terminal sentinel now behave exactly as on real PM. `cursor` remains an accepted alias everywhere. * **`/v1/data/orders` `asset_id` filter implemented** (was accepted and ignored): resolves the token to its market+outcome and filters both; unknown tokens short-circuit to an empty terminal envelope. * **`GET /v1/neg-risk` reports the market's real flag** (was hardcoded `false`, contradicting both real PM and polysim's own `/v1/book` for the same token). Source is the upstream book payload with cached-book and catalog fallbacks; unknown tokens get PM's verbatim `404 {"error": "market not found"}`. Sim execution semantics are unchanged for neg-risk markets — the flag is for routing/branching parity. * **`GET /v1/balance-allowance` emits PM's keys**: `balance` / `allowance` (base-unit strings, 1.00 USDC = `"1000000"`) so `resp["balance"]` no longer KeyErrors in ported SDK code. The legacy `collateral` / `conditional` keys remain as extras. `asset_type` / `token_id` / `signature_type` are now declared query params. The values stay "effectively unlimited" sentinels — read `GET /v1/account/balance` for real sizing. ### New * **`WS /v1/ws/user` now pushes PM-shape `trade` frames when your orders fill** on a subscribed market (`event_type: "trade"`, `type: "TRADE"`, `status: "MATCHED"`, unix-string timestamps — PM's documented user-channel trade message). Divergences: paper trades terminate at `MATCHED` (no `MINED`/`CONFIRMED` — no chain), `maker_orders` is empty, and `owner`/`trade_owner` are empty strings. PM's `order` placement/update/cancel events are still not emitted — poll `GET /v1/data/orders`, or use the polysim-native `/v1/ws/executions`. ## 2026-06-11 — PM-faithful order semantics (rolling out) The matching-engine fidelity fast-follows announced on 2026-06-10 are implemented and **rolling out behind a deployment flag**. Until the rollout activates (announced here), the legacy behaviors below remain in force; once active, the engine matches real Polymarket on all five: * **FAK/IOC partial fills.** Synchronous IOC/FAK limit orders walk the displayed order-book depth within your limit, fill what's available at the level-by-level VWAP, and cancel the remainder. A partial fill returns `status: "FILLED"` (PM-compat surfaces map it to `matched`) with `quantity` set to the filled slice and a `partial_fill:filled=…,requested=…` entry in `warnings`. *Legacy: atomic — full quantity at the touch price or cancel.* * **FOK depth-aware atomicity.** FOK computes the fillable quantity within your limit across book levels first: fills entirely (at the walked VWAP) iff depth covers your size, else kills cleanly. *Legacy: full-size fill at the touch price whenever the top of book crossed, regardless of depth.* * **GTD auto-expiry.** `time_in_force=GTD` on `POST /v1/orders`, `order_type=GTD` on `/v1/clob/order`, and `orderType=GTD` on the PM-shape `POST /v1/order` become true Good-Til-Date resting limits: the engine skips and auto-cancels them once their unix-seconds `expiration` passes (cancelled rows carry `cancelled_reason: "gtd_expired"`; reservations are refunded). Expired-at-placement → `400 INVALID_ORDER_EXPIRATION`. *Legacy: GTD coerced to GTC (and rejected on native `/v1/orders`).* * **post-only.** New `post_only` field (PM-shape: top-level `postOnly`): guaranteed-maker orders rejected with `400 INVALID_POST_ONLY_ORDER` (`X-Polysim-Code: POST_ONLY_WOULD_CROSS`) when marketable at placement; `400 INVALID_POST_ONLY_ORDER_TYPE` when combined with FOK/FAK/IOC/market. GTC/GTD limits only. *Legacy: field accepted but ignored.* * **Minimum order size.** The per-market `min_order_size` that `GET /v1/book` advertises (5 shares on standard binary markets, 1 on ≤0.001-tick markets) is enforced at placement: `400 INVALID_ORDER_MIN_SIZE` (`X-Polysim-Code: ORDER_BELOW_MIN_SIZE`). Note this applies to SELL too — like real Polymarket, a sub-minimum residual position can't be exited via a below-minimum order. *Legacy: any size > 0.0001 shares accepted.* New request fields (`expiration`, `post_only`) are accepted today on `POST /v1/orders`, `POST /v1/clob/order`, and the PM-shape `POST /v1/order` / batch; they are advisory no-ops until the rollout activates. The activation will be announced in this changelog. ## 2026-06-10 — Polymarket wire-parity wave A coordinated set of fixes aligning the API with Polymarket's **live wire behavior** (verified against `clob.polymarket.com`, not just Polymarket's docs — the two disagree in places), plus truthful fee reporting and wallet scoping. ### Breaking changes * **`GET /v1/price` side semantics flipped.** `side=BUY` now returns the best **bid** (your side of the book) and `side=SELL` the best **ask** — matching Polymarket's live wire and its API reference. Previously the values were inverted. Quotes return your side of the book; *executions* still cross the spread (market BUY fills at the best ask) — same convention as Polymarket. * **`GET /v1/book` level ordering re-sorted to Polymarket's live wire.** `bids` are ASCENDING and `asks` DESCENDING by price — the **best price is the LAST element on both sides** (`bids[-1]` / `asks[-1]`), byte-compatible with real `clob.polymarket.com/book` responses. Recommendation: read the inside market order-independently (max bid price / min ask price) so ordering can never bite you. Applies to `/v1/book`, `/v1/clob/book/{token_id}`, `POST /v1/books`, and `/v1/markets/{condition_id}/book`. * **Account reads default to the API wallet.** `GET /v1/account/positions`, `/history`, and `/profile-analysis` previously returned rows from ALL your wallets (including website MAIN/SANDBOX wallets) when `wallet_id` was omitted. The default is now your **API wallet**, consistent with `/balance`, `/portfolio`, and `/equity`. Pass `wallet_id=all` for the old behavior, `wallet_id=` for a specific wallet. The `all`/`api` keywords work on all five account-read endpoints. ### Fees — now reported truthfully The engine has always charged Polymarket-V2 per-category taker fees; the reporting surfaces wrongly claimed zero. Now: * `GET /v1/fee-rate` returns `{"base_fee": 0|1000, "fee_rate_bps": }`. `base_fee` mirrors Polymarket's legacy base-fee parameter (observed: flat `1000` on fee-charging markets, `0` on fee-free ones); **`fee_rate_bps` is the effective per-category taker rate actually charged** (sports 300, finance/politics/mentions/tech 400, economics/culture/weather/other 500, crypto 700, geopolitics 0). * The crypto taker rate was corrected from 7.2% to **7.0%** (Polymarket's published rate). Fills before 2026-06-10 may have been charged at 7.2%. * `GET /v1/data/trades` rows now carry the real `fee_rate_bps` (0 for maker fills). * Maker/taker classification now follows **marketability at placement**: a limit order that crosses the book when placed pays the taker fee even though it fills via the \~1s matching cycle; resting orders that fill later remain fee-free makers, and the resting remainder of a partial fill is treated as maker. See [Trading Fees](/trading/fees) for the schedule and formula. ### Fixes * Candle intervals documented correctly: `1h/6h/1d/1w/max` (sub-hour intervals are not yet available; unknown values return `400 INVALID_INTERVAL`). * FAK/IOC behavior documented honestly: currently **atomic** (full fill at the touch price or cancel) — Polymarket-style partial-fill-then-cancel is a planned fast-follow and will be announced here. * `llms.txt` corrected across both hosts (error codes, bootstrap permissions, book-ordering parity claims, fee schedule). ### Coming soon * Official Python SDK (PyPI). Until then the API is plain REST — see the [Quickstart](/quickstart). * Matching-engine fidelity fast-follows: GTD auto-expiry, post-only, per-market minimum order size, FAK partial fills. *Update 2026-06-11: implemented and rolling out — see the entry above.* ## 2026-06-09 and earlier The beta API surface was assembled and hardened through internal audit waves (error-envelope consistency, public market-data reads without a key, PM-shape compatibility endpoints, rate-limit headers). The 2026-06-10 entries above are the first changes shipped after external-facing documentation went live. # API Keys Source: https://docs.polysimulator.com/concepts/api-keys Create, list, and revoke API keys for programmatic access. # API Keys API keys are the primary authentication mechanism for the PolySimulator API. Each key is tied to a user account, has configurable permissions, and can be revoked instantly. **Closed beta (ongoing).** API key issuance is cohort-gated. New keys are issued to approved cohorts only, so `POST /v1/keys/bootstrap` (first key) and `POST /v1/keys` (additional keys) return a `403` for callers who aren't yet admitted — **`CLOSED_BETA`** for free / waitlisted accounts, or **`API_PRO_COMING_SOON`** for paying Pro / Pro+ accounts that don't yet have a cohort grant: ```http theme={null} HTTP/1.1 403 Forbidden X-Polysim-Code: CLOSED_BETA Content-Type: application/json {"error": "API access is in closed beta. New keys are issued to approved cohorts only. Apply via the waitlist; we'll email you when a cohort opens."} ``` Branch on the `X-Polysim-Code` response header (the body's `error` is the human message) and surface a "join the waitlist" prompt. While the beta is closed, every non-admitted caller — including paying Pro / Pro+ — gets `CLOSED_BETA`; `API_PRO_COMING_SOON` appears only once self-serve issuance is enabled. Apply via the waitlist at [polysimulator.com/api-trading](https://polysimulator.com/api-trading). See [Authentication → Closed Beta](/authentication#closed-beta) for the full lifecycle, including the separate runtime `ACCESS_RESTRICTED` allowlist gate on already-issued keys. *** ## Create a Key ```bash cURL theme={null} curl -X POST https://api.polysimulator.com/v1/keys \ -H "X-API-Key: $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "my-trading-bot", "permissions": ["read", "trade"], "tier": "pro" }' ``` ```python Python theme={null} import requests, os resp = requests.post( f"{os.environ['POLYSIM_BASE_URL']}/v1/keys", headers={"X-API-Key": os.environ["POLYSIM_API_KEY"]}, json={ "name": "my-trading-bot", "permissions": ["read", "trade"], "tier": "pro", }, ) resp.raise_for_status() key_data = resp.json() print(f"Save this key: {key_data['raw_key']}") ``` **Free-tier keys are read-only.** A `free` key requesting `trade` returns `403 TIER_REQUIRES_UPGRADE` — so a `["read", "trade"]` key needs a paid tier (`pro` / `pro_plus` / `enterprise`). Omit `permissions` to take the per-tier default (`free` → `["read"]`, paid → `["read", "trade"]`). **Response (201 Created):** ```json theme={null} { "id": 1, "raw_key": "ps_live_kJ9mNx2pQrStUvWxYz01Ab3CdEfGhI4j...", "key_prefix": "ps_live_kJ9mNx2p", "name": "my-trading-bot", "rate_limit_tier": "pro", "permissions": ["read", "trade"], "created_at": "2026-02-06T12:00:00Z" } ``` The `raw_key` field is shown **exactly once**. Store it securely — it cannot be retrieved again. Only the SHA-256 hash is stored in the database. | Field | Description | | ----------------- | --------------------------------------------------------------------- | | `id` | Numeric key ID (used for revocation) | | `raw_key` | Full API key — save this immediately | | `key_prefix` | First 16 chars (used for identification in listings) | | `name` | Your label for this key | | `rate_limit_tier` | `free`, `pro`, `pro_plus`, or `enterprise` (see `GET /v1/keys/tiers`) | | `permissions` | Array of granted permissions | | `created_at` | ISO 8601 timestamp | *** ## List Keys Returns all keys for your account. Only prefixes are shown — never the full key. ```bash theme={null} curl -H "X-API-Key: $API_KEY" https://api.polysimulator.com/v1/keys ``` ```json theme={null} [ { "id": 1, "key_prefix": "ps_live_kJ9mNx2p", "name": "my-trading-bot", "permissions": ["read", "trade"], "rate_limit_tier": "pro", "is_active": true, "created_at": "2026-02-06T12:00:00Z", "expires_at": null } ] ``` *** ## Revoke a Key Permanently deactivates a key. **This action cannot be undone.** ```bash theme={null} curl -X DELETE -H "X-API-Key: $API_KEY" \ https://api.polysimulator.com/v1/keys/1 ``` ```json theme={null} { "message": "Key revoked successfully" } ``` *** ## Key Limits | Constraint | Value | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Max keys per user | 5 | | Key format | `ps_live_<64 hex chars>` | | Storage | SHA-256 hash only | | Expiration | Not set at creation — `expires_at` is server-managed (populated on a rotated key's old half during its 24h overlap, and on beta keys as their cutoff). Use `POST /v1/keys/{id}/rotate` for short-lived keys. | *** ## Error Handling | Status | Endpoint | Meaning | | ------ | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `201` | `POST /v1/keys` | Key created — save `raw_key` immediately | | `400` | `POST /v1/keys` | Max 5 keys reached, or unknown tier | | `400` | `POST /v1/keys/bootstrap` | You already have key(s) — use `POST /v1/keys` with `X-API-Key` | | `401` | Any | Invalid, expired, or deactivated API key | | `401` | `POST /v1/keys/bootstrap` | Invalid or expired Supabase JWT | | `403` | `POST /v1/keys`, `POST /v1/keys/bootstrap` | `CLOSED_BETA` — key issuance is in [closed beta](/authentication#closed-beta) (the default for every non-admitted caller, including paying Pro / Pro+). The `API_PRO_COMING_SOON` variant appears only once self-serve issuance is enabled. Branch on the `X-Polysim-Code` response header. | ```python theme={null} # Robust key creation with error handling resp = requests.post( f"{BASE_URL}/v1/keys", headers={"X-API-Key": API_KEY}, json={"name": "my-bot", "permissions": ["read", "trade"]}, ) if resp.status_code == 201: key_data = resp.json() print(f"Save this key NOW: {key_data['raw_key']}") elif resp.status_code == 400: err = resp.json() print(f"Cannot create key: {err.get('message', err.get('error'))}") elif resp.status_code == 401: print("API key is invalid or expired — re-authenticate") elif resp.status_code == 403: # Key issuance is closed-beta gated; branch on the stable machine # code in the `X-Polysim-Code` header (the body's `error` is the # human message). code = resp.headers.get("X-Polysim-Code") if code == "CLOSED_BETA": # Your account isn't in an admitted cohort yet. Apply via the # waitlist at https://polysimulator.com/api-trading. print("Closed beta — apply at https://polysimulator.com/api-trading") elif code == "API_PRO_COMING_SOON": # Paying Pro / Pro+ without a cohort grant — API access is # rolling out in cohorts. Check /account/billing. print("Pro API access is rolling out — see /account/billing") elif code == "TIER_REQUIRES_UPGRADE": # Free tier is read-only: requesting ["read", "trade"] without a # paid tier is rejected. Omit "trade" or upgrade first. print("Free tier cannot create trade-scoped keys — upgrade or drop 'trade'") elif code == "INSUFFICIENT_PERMISSION": print("Key lacks the required permission for this endpoint") else: print(f"403 [{code}]: {resp.json().get('error')}") else: print(f"Unexpected error: {resp.status_code}") ``` *** ## Next Steps * [Rate Limits](/concepts/rate-limits) — Understand your tier's request budget * [Authentication](/authentication) — Security model deep dive # CLOB Compatibility Source: https://docs.polysimulator.com/concepts/clob-compatibility How the CLOB-compatible endpoint and Polymarket-aligned execution model enable seamless migration to live trading. # CLOB Compatibility PolySimulator mirrors **Polymarket's real execution model**: * **All orders are limit orders** — market orders are just limit orders with FOK time-in-force at marketable prices * **BUY fills at best ask, SELL fills at best bid** — not the midpoint. (Don't confuse fills with quotes: `GET /v1/price?side=BUY` returns the best *bid* — your side of the book — while executions cross the spread. Same convention as live Polymarket.) * **The `price` field is the worst-price limit** — slippage protection built into the order, not a separate parameter * **FOK (Fill-or-Kill)** is the immediate-execution order type on this endpoint. Polymarket's real CLOB also supports **FAK (Fill-and-Kill)**; on PolySimulator, FAK lives on the PM-raw [`POST /v1/order`](/concepts/pm-raw-http) path, and `POST /v1/clob/order` accepts `GTC`/`FOK`/`GTD` only (see below) The `POST /v1/clob/order` endpoint mirrors **Polymarket's real CLOB API schema**, enabling one-URL-swap migration from paper trading to live trading. *** ## The Migration Promise ``` ┌──────────────────────────────────────────────────────────────────┐ │ Change ONLY the base URL and credentials to go live │ │ │ │ Virtual: https://api.polysimulator.com/v1/clob/order │ │ Live: https://clob.polymarket.com/order │ └──────────────────────────────────────────────────────────────────┘ ``` ```python Virtual Mode (PolySimulator) theme={null} import requests BASE = "https://api.polysimulator.com/v1" headers = {"X-API-Key": "ps_live_kJ9mNx2p..."} order = requests.post(f"{BASE}/clob/order", headers=headers, json={ "token_id": "71321045679252...", "side": "BUY", "price": "0.65", "size": "10", "order_type": "GTC", }).json() ``` ```python Live Mode (Polymarket) theme={null} from py_clob_client.client import ClobClient client = ClobClient("https://clob.polymarket.com", key=PRIVATE_KEY, chain_id=137) client.set_api_creds(client.create_or_derive_api_key()) # Same order payload — only auth + client changes order = client.create_and_post_order(OrderArgs( token_id="71321045679252...", side="BUY", price=0.65, size=10, order_type="GTC", )) ``` **Authentication difference**: PolySimulator uses the `X-API-Key` header. Polymarket's live CLOB requires **L2 HMAC credentials** (API key + secret + passphrase) derived from your wallet's private key via `py_clob_client`. See the [Live Migration Guide](/deployment/live-migration) for full credential setup. *** ## Request Schema ```json theme={null} { "token_id": "71321045679252212594626385532706912750332728571942532289631379312455583992563", "side": "BUY", "price": "0.65", "size": "10", "order_type": "GTC", "fee_rate_bps": 0, "nonce": "optional-nonce", "client_order_id": "my-order-001" } ``` **All numeric fields (`price`, `size`) must be strings.** Both PolySimulator and Polymarket's real CLOB API expect string-encoded decimals, e.g. `"0.65"` not `0.65`. Using floats will be rejected. | Field | Type | Required | Description | | ----------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `token_id` | string | Yes | CLOB outcome token ID | | `side` | string | Yes | `BUY` or `SELL` | | `price` | string | Yes | Limit price as decimal string (`"0.01"`–`"1.00"`) | | `size` | string | Yes | Number of shares as decimal string (> 0) | | `order_type` | string | No | `GTC` (default), `FOK`, `GTD`. `GTD` is coerced to `GTC` (no native expiry). `IOC` is **rejected** on this endpoint with `400 UNSUPPORTED_ORDER_TYPE` — use the PM-raw [`POST /v1/order`](/concepts/pm-raw-http) path, which accepts `FAK`/`IOC`. | | `fee_rate_bps` | int | No | Accepted for shape parity; the engine charges the market's own category taker rate regardless (see [Trading Fees](/trading/fees)) | | `nonce` | string | No | Accepted but ignored in virtual mode | | `taker` | string | No | Accepted but ignored in virtual mode | | `client_order_id` | string | No | Idempotency key | `order_type=IOC` returns `400 {"error": "VALIDATION_FAILED", "code": "UNSUPPORTED_ORDER_TYPE", "message": "order_type=IOC not yet supported; use GTC or FOK"}` on `POST /v1/clob/order`. The two trading paths have genuinely different time-in-force support: this CLOB path accepts `GTC`/`FOK`/`GTD`, while the PM-raw `POST /v1/order` path accepts PM's full `GTC`/`FOK`/`FAK`/`GTD` set. Polymarket's own CLOB enum is `GTC`/`FOK`/`GTD`/`FAK` — there is no `IOC` on Polymarket. *** ## Response Schema This is the **PolySimulator** CLOB-compat response. It is close to Polymarket's real insert-order response but **not byte-identical** — the differences are spelled out below so a migrating bot doesn't string-match on the wrong field. ```json theme={null} { "success": true, "orderID": "42", "status": "matched", "transactID": "42", "errorMsg": null, "price": "0.65", "size": "10.0", "side": "BUY", "takingAmount": "6.50", "makingAmount": "10.0" } ``` | Status Value | Meaning | | ------------ | ----------------------------------- | | `matched` | Order fully filled | | `live` | Order pending (limit order resting) | | `unmatched` | Order cancelled or rejected | **Differences from Polymarket's real `POST /order` response** — port defensively: * **`transactID` (single string) vs PM's `transactionsHashes` / `tradeIDs` (arrays).** PolySimulator returns `transactID` set to the order ID; Polymarket has no `transactID` field — it returns `transactionsHashes: []` and `tradeIDs: []`. The PM-raw [`POST /v1/order`](/concepts/pm-raw-http) path returns the PM-shape arrays. * **`takingAmount` / `makingAmount` are human decimals here** (e.g. `"6.50"`, `"10.0"`); Polymarket returns 6-decimal fixed-point integer strings (e.g. `"500000"` for 0.50). Don't divide ours by 1e6. * **`status` enum** here is `matched` / `live` / `unmatched`. Polymarket's insert-order statuses are `live` / `matched` / `delayed` — PolySimulator never emits `delayed`, and uses `unmatched` (not a PM insert status) for cancelled/rejected orders. *** ## Field Mapping | Polymarket CLOB | PolySimulator `/v1/clob/order` | Notes | | ---------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `tokenId` / `token_id` | `token_id` | Resolved to condition\_id via Redis | | `side` (0=BUY, 1=SELL) | `side` ("BUY"/"SELL") | String enum | | `price` (string) | `price` (string) | Decimal string: `"0.65"` | | `size` (string) | `size` (string) | Decimal string: `"10"` | | `feeRateBps` | `fee_rate_bps` | Accepted for shape parity; real PM-V2 per-category taker fees ARE charged on fills (see [Trading Fees](/trading/fees)) | | `orderType` | `order_type` | Polymarket enum is `GTC`/`FOK`/`GTD`/`FAK`. On `POST /v1/clob/order`: `GTC`/`FOK`/`GTD` accepted (`GTD`→`GTC`), `IOC` rejected (400). `FAK`/`IOC` live on the PM-raw `POST /v1/order` path. | | `signature` | — | Not required (virtual mode) | | `salt` | — | Not required (virtual mode) | Fields like `signature`, `salt`, `maker`, and `signer` that are required for Polymarket's blockchain settlement are **accepted but ignored** in virtual mode. This means you can develop your bot with (or without) these fields — either way works. **String vs Float numerics**: Both PolySimulator and Polymarket require `price` and `size` as **strings** (e.g., `"0.65"` not `0.65`). Always pass strings to ensure compatibility. *** ## Public CLOB Read Endpoints These endpoints mirror Polymarket's public CLOB data API and **require no authentication**. They accept `token_id` (the CLOB outcome token) as query parameter. | Method | Endpoint | Description | | ------ | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET` | `/v1/price?token_id=...&side=...` | Single token price for one side (`side` **required**) | | `POST` | `/v1/prices` | Batch prices for multiple token IDs (returns a **dict**, not a list) | | `GET` | `/v1/midpoint?token_id=...` | Best-bid/best-ask midpoint | | `GET` | `/v1/spread?token_id=...` | Spread (best bid, best ask, spread) | | `GET` | `/v1/book?token_id=...` | Full order book snapshot. **Level ordering is byte-identical to Polymarket's live `/book` wire: bids ascending (best = `bids[-1]`), asks descending (best = `asks[-1]`)** — best at the tail on both sides. Read order-independently (max bid price / min ask price). See [Order Book](/market-data/order-book). | | `GET` | `/v1/prices-history?market=...` | PM wire shape: `{"history": [{"t": int, "p": float}]}`. Accepts PM's `market=` param (token id), `startTs`/`endTs`/`fidelity`, and PM's full interval enum (`1h/6h/1d/1w/1m/max/all`). For bucketed OHLCV use [`GET /v1/markets/{condition_id}/candles`](/market-data/price-candles) or `?format=ohlcv`. | **`/v1/prices-history` is PM wire-compatible since 2026-06-11.** It accepts PM's required `?market=` (token id; the legacy `?token_id=` alias still works), returns PM's exact `{"history": [{"t", "p"}]}` envelope by default (`p` is a JSON **number** here, mirroring PM), supports `startTs`/`endTs`/`fidelity`, and 400s with PM's verbatim error message when `market` is missing. **Migration:** the pre-2026-06-11 default was a bare array of `{t, o, h, l, c}` string points — that shape is still available via `?format=ohlcv`. ```python Single Price theme={null} import requests # `side` is REQUIRED (BUY → best bid, SELL → best ask — matches # Polymarket's live /price wire; corrected 2026-06-10). A missing or # invalid side returns 400 {"error": "Invalid side"}. price = requests.get( "https://api.polysimulator.com/v1/price", params={"token_id": "71321045679252...", "side": "BUY"} ).json() # {"price": "0.65", "quote_at": "2026-02-06T12:00:45Z", "age_ms": 42} # Note: price is a STRING; there is no token_id / bid / ask field. ``` ```python Batch Prices theme={null} # PolySim shape: {"token_ids": [...]} → returns {token_id: price_str} prices = requests.post( "https://api.polysimulator.com/v1/prices", json={"token_ids": ["71321045...", "83294756..."]} ).json() # {"71321045...": "0.65", "83294756...": "0.42"} # PM array shape: [{"token_id", "side"}, ...] → returns {token_id: {SIDE: price_str}} prices_pm = requests.post( "https://api.polysimulator.com/v1/prices", json=[{"token_id": "71321045...", "side": "BUY"}] ).json() # {"71321045...": {"BUY": "0.65"}} ``` ```python Midpoint theme={null} mid = requests.get( "https://api.polysimulator.com/v1/midpoint", params={"token_id": "71321045679252..."} ).json() # {"mid": "0.645", "mid_price": "0.645"} ``` These endpoints use the same Redis price cache as the authenticated API. Data is refreshed every 30 seconds by the price poller. *** ## Cancel Endpoints Bulk cancel endpoints match Polymarket's cancel response shape: `{canceled: [...], not_canceled: {...}}`. | Method | Endpoint | Description | | -------- | ------------------------------------- | ------------------------------------------- | | `DELETE` | `/v1/cancel-all` | Cancel all pending limit orders | | `DELETE` | `/v1/cancel-market-orders?market=...` | Cancel pending orders for a specific market | The `cancel-market-orders` endpoint accepts either `market` (condition\_id) or `asset_id` (token\_id) as query parameters. ```json theme={null} // Response shape (both endpoints) { "canceled": ["42", "43"], "not_canceled": {"44": "Cannot cancel order with status: FILLED"} } ``` The status word inside a `not_canceled` reason is the **internal uppercase** order-status enum (`FILLED`, `CANCELLED`, `EXPIRED`), which differs from the **lowercase** insert-order status the `POST /v1/clob/order` response uses (`matched` / `live` / `unmatched`). If you string-match on the status you saw at insert time, don't expect the casing to line up here — match case-insensitively, or map `FILLED → matched`, `CANCELLED → unmatched`. *** ## When to Use CLOB-Compat vs Native API | Use Case | Recommended Endpoint | | ------------------------------------------ | ---------------------------------------------------- | | New bot development | `POST /v1/orders` — richer features, string numerics | | Porting existing Polymarket bot | `POST /v1/clob/order` — minimal code changes | | Planning to go live on Polymarket | `POST /v1/clob/order` — URL-swap ready | | Advanced features (batch, limit, slippage) | `POST /v1/orders` — full feature set | *** ## Next Steps * [Placing Orders](/trading/placing-orders) — Full-featured native order API * [Live Migration](/deployment/live-migration) — Step-by-step migration guide * [Polymarket Perfect-Fit Delta](/concepts/polymarket-perfect-fit-delta) — Exact endpoint and schema gaps to close for full Polymarket parity # Polymarket Raw-HTTP Endpoints Source: https://docs.polysimulator.com/concepts/pm-raw-http Drop-in compatibility with Polymarket's CLOB HTTP API — change only the host. # Polymarket Raw-HTTP Endpoints PolySimulator exposes the **exact path + payload shapes** Polymarket's public CLOB API uses. SDK clients written against `https://clob.polymarket.com` (e.g. `py-clob-client`, `@polymarket/clob-client`) can be pointed at PolySimulator by changing **only the base URL and credentials** — no payload rewrites, no field renames. This page covers the **raw-HTTP** shape only. PolySimulator also offers two simpler shapes for new code: * `POST /v1/orders` — native (`market_id` + `outcome` + `quantity` + `price` + `time_in_force`). See [Placing Orders](/trading/placing-orders). * `POST /v1/clob/order` — legacy SDK convenience (`token_id` + `side` * `size` + `price` + `order_type`). See [CLOB Compatibility](/concepts/clob-compatibility). All three paths funnel through the same matching engine; pick by integration shape, not by behaviour. *** ## Order Placement ### `POST /v1/order` — single order Mirror of Polymarket's [`POST /order`](https://docs.polymarket.com/clob/post-order). **Request body:** ```json theme={null} { "order": { "tokenId": "54533043819946592547517511176940999955633860128497669742211153063842200957669", "makerAmount": "500000", "takerAmount": "1000000", "side": "BUY", "expiration": "0", "salt": 1234567890, "maker": "0x0000000000000000000000000000000000000000", "signer": "0x0000000000000000000000000000000000000000", "taker": "0x0000000000000000000000000000000000000000", "nonce": 0, "feeRateBps": 0, "signatureType": 0, "signature": "0x..." }, "owner": "your-api-key-uuid", "orderType": "GTC", "deferExec": false } ``` **Maker/taker amount conversion (load-bearing — get this wrong and notional sizing is silently broken):** PolySimulator follows Polymarket's 6-decimal fixed-point convention for both USDC and outcome-token amounts: | Side | `makerAmount` | `takerAmount` | Implied price | Implied quantity | | ------ | ------------------------------- | ------------------------------- | --------------------------- | ------------------- | | `BUY` | `round(price × qty × 1e6)` USDC | `round(qty × 1e6)` shares | `makerAmount / takerAmount` | `takerAmount / 1e6` | | `SELL` | `round(qty × 1e6)` shares | `round(price × qty × 1e6)` USDC | `takerAmount / makerAmount` | `makerAmount / 1e6` | Example: BUY 1 share at \$0.50 ⇒ `makerAmount=500000`, `takerAmount=1000000`. **On-chain fields are accepted and ignored:** `salt`, `maker`, `signer`, `taker`, `nonce`, `feeRateBps`, `signatureType`, `signature`, `timestamp`, `metadata`, `builder`. PolySimulator is paper trading — there is no chain to settle against. **`orderType` enum:** `GTC | FOK | FAK | GTD`. We don't support good-til-date natively; `GTD` coerces to `GTC` so SDK callers get a working order rather than a 422. **Response:** ```json theme={null} { "success": true, "orderID": "6391", "status": "live", "makingAmount": "500000", "takingAmount": "1000000", "transactionsHashes": null, "tradeIDs": null, "errorMsg": "" } ``` `status` enum on **write** (POST /v1/order, POST /v1/orders): `live` (resting on book) / `matched` (filled immediately) / `unmatched` (rejected). PolySimulator does **not** return PM's `delayed` status — we don't delay matching. **Status enum split between write and read endpoints.** The write-side returns lowercase short forms (`live` / `matched` / `unmatched`) — that's PM's `SendOrderResponse` contract. The **read-side** (`GET /v1/data/orders`, `GET /v1/order/{id}`) returns PM's `OpenOrder.status` enum — PM's **exact** members since the 2026-06-11 PM-compat batch: | Read endpoint | Write endpoint | Meaning | | ----------------------- | ------------------------------------- | ------------------------------------------------ | | `ORDER_STATUS_LIVE` | `live` | resting on book (default for new GTC orders) | | `ORDER_STATUS_MATCHED` | `matched` | fully filled | | `ORDER_STATUS_CANCELED` | (n/a — write side never returns this) | cancelled or expired (single-L spelling, per PM) | | `ORDER_STATUS_INVALID` | `unmatched` | rejected / never accepted | **Migration (2026-06-11):** before this date the read side emitted invented members (`ORDER_STATUS_PENDING` / `ORDER_STATUS_FILLED` / double-L `ORDER_STATUS_CANCELLED` / `ORDER_STATUS_REJECTED`) that real PM never sends. Update any string matches; the `?status=` filter accepts both spellings. This mirrors Polymarket's real CLOB exactly — the two enums come from different protobufs in PM's internal codebase. SDK clients should treat them as separate types and map between them at the application layer (`@polymarket/clob-client` does this internally; raw `fetch` users need to do it themselves). ### TypeScript / Node example ```typescript theme={null} const BASE = "https://api.polysimulator.com"; const KEY = process.env.POLYSIM_API_KEY!; // Token IDs are 77-digit decimal strings — DO NOT cast to Number. // They overflow Number.MAX_SAFE_INTEGER (2^53). Keep them as strings // throughout your codebase; use BigInt only if you need to do math. const tokenId = "54533043819946592547517511176940999955633860128497669742211153063842200957669"; // BUY 1 share at $0.50: // makerAmount = 0.50 * 1 * 1e6 = 500_000 (USDC, 6-decimal fixed-point) // takerAmount = 1 * 1e6 = 1_000_000 (shares, also 6-decimal) const sendOrder = await fetch(`${BASE}/v1/order`, { method: "POST", headers: { "X-API-Key": KEY, "Content-Type": "application/json", }, body: JSON.stringify({ order: { tokenId, // string, NOT number makerAmount: "500000", takerAmount: "1000000", side: "BUY", }, owner: "your-api-key-uuid", orderType: "GTC", }), }); const result = await sendOrder.json(); console.log(result.orderID, result.status); // "12345" "live" // Listing orders later: PM envelope, status uses ORDER_STATUS_* prefix const orders = await fetch( `${BASE}/v1/data/orders?limit=10`, { headers: { "X-API-Key": KEY } }, ).then(r => r.json()); for (const o of orders.data) { // o.status is "ORDER_STATUS_LIVE" / "ORDER_STATUS_MATCHED" / // "ORDER_STATUS_CANCELED" / "ORDER_STATUS_INVALID" — PM's exact // members, a different enum from the write-side response above. // See the Warning callout. console.log(o.id, o.status, o.original_size, o.size_matched); } ``` **TypeScript users**: the [PolySimulator API](https://docs.polysimulator.com) Mintlify spec is auto-generated from the live OpenAPI; if you're using `openapi-typescript` to generate types, run it against `https://docs.polysimulator.com/openapi.json` (auto-rebuilds on every deploy). *** ### `POST /v1/orders` — single object **or** PM batch array `POST /v1/orders` dispatches on the JSON body type: * A **JSON object** is the **native** single order: `{market_id, side, outcome, quantity, order_type, price, time_in_force}`. * A **JSON array** is a **Polymarket-shape batch** of up to **15** `PmSendOrderRequest` entries — each entry is validated and executed independently (per-entry failure isolation, matching PM's "failures in one entry do not abort the others" contract). A malformed entry surfaces as a failed result at its array index rather than rejecting the whole batch. More than 15 entries returns `400`. ```bash theme={null} # Native single order (object body) curl -X POST https://api.polysimulator.com/v1/orders \ -H "X-API-Key: $API_KEY" -H "Content-Type: application/json" \ -d '{"market_id": "0x...", "side": "BUY", "outcome": "Yes", "quantity": "10", "order_type": "GTC", "price": "0.65"}' # PM-shape batch (array body, ≤15 entries) curl -X POST https://api.polysimulator.com/v1/orders \ -H "X-API-Key: $API_KEY" -H "Content-Type: application/json" \ -d '[{"order": {...}, "owner": "...", "orderType": "GTC"}, {"order": {...}, "owner": "...", "orderType": "FAK"}]' ``` For a PM-shape **single** order you can also use `POST /v1/order` (singular): ```bash theme={null} curl -X POST https://api.polysimulator.com/v1/order \ -H "X-API-Key: $API_KEY" \ -H "Content-Type: application/json" \ -d '{"order": {...}, "owner": "...", "orderType": "GTC"}' ``` *** ## Order Lifecycle ### `GET /v1/data/orders` — list user's orders, PM envelope Polymarket-shaped `OrdersResponse`: ```json theme={null} { "limit": 100, "count": 5, "next_cursor": "MjAyNi0wNS0wNVQwMTozNjoxNy40MTY5NDcrMDA6MDA", "data": [ { "id": "6390", "status": "ORDER_STATUS_MATCHED", "owner": "your-api-key-uuid", "maker_address": null, "market": "0x0f49db97...", "asset_id": null, "side": "BUY", "original_size": "1.0000", "size_matched": "0.2480", "price": "0.5000", "outcome": "Yes", "expiration": "0", "order_type": "GTC", "associate_trades": [], "created_at": 1778103749 } ] } ``` Pass `next_cursor` back as `?next_cursor=...` (PM's request param — what py-clob-client sends) or the `?cursor=...` alias to paginate. Cursor is urlsafe-base64 and opaque; the SDK's initial `MA==` seed and the `LTE=` terminal sentinel both behave exactly as on real PM. **Default scope matches PM: OPEN orders only** (since 2026-06-11). Pass the polysim-extension `?status=` param (`ORDER_STATUS_MATCHED`, `ORDER_STATUS_CANCELED`, `ALL`, or friendly aliases like `matched` / `canceled`) to read history. **Filters:** `id`, `market`, `asset_id` (token id — resolves to the market+outcome and filters both), `status`, `before` / `after` (unix-seconds timestamps, PM convention; ISO accepted as an extra; garbage returns 400 instead of being silently dropped), `limit` (max 500). ### `GET /v1/order/{orderID}` — single order Returns a single `OpenOrder` with the same shape as `data[]` above. Hex order IDs (PM uses on-chain hashes; we use integer DB ids serialised as strings) cleanly 404 with `error: ORDER_NOT_FOUND` instead of crashing. ### `DELETE /v1/order` — cancel by body ```bash theme={null} curl -X DELETE https://api.polysimulator.com/v1/order \ -H "X-API-Key: $API_KEY" \ -H "Content-Type: application/json" \ -d '{"orderID": "6391"}' ``` Returns `{canceled: ["6391"], not_canceled: {}}` — PM's exact shape. ### `DELETE /v1/orders` — bulk cancel (array body) ```bash theme={null} curl -X DELETE https://api.polysimulator.com/v1/orders \ -H "X-API-Key: $API_KEY" \ -H "Content-Type: application/json" \ -d '["6391", "6392", "6393"]' ``` Up to **100** ids per request (PM's documented cap is 3,000; we cap lower for cohort stability — raise post-launch when load patterns stabilise). Invalid ids appear in `not_canceled` with an explanation. ### `POST /v1/cancel-all` — cancel all open orders No body. Returns the same `{canceled, not_canceled}` envelope. *** ## Read Endpoints (public — no auth) These mirror Polymarket's public market-data endpoints. All return the exact shape PM emits. | Endpoint | Purpose | PM equivalent | | ------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------- | | `GET /v1/markets-by-token/{token_id}` | Resolve `tokenId` → `(condition_id, outcome)` | (PolySim convenience; PM does not expose this directly) | | `GET /v1/midpoint?token_id=` | Midpoint price | `GET /midpoint` | | `POST /v1/midpoints` | Batch midpoints | `POST /midpoints` | | `GET /v1/spread?token_id=` | Bid/ask spread (capped at \$0.10 per PM parity) | `GET /spread` | | `POST /v1/spreads` | Batch spreads | `POST /spreads` | | `GET /v1/book?token_id=` | L2 order book | `GET /book` | | `POST /v1/books` | Batch books | `POST /books` | | `GET /v1/last-trade-price?token_id=` | Last trade `{price, side}` | `GET /last-trade-price` | | `GET /v1/tick-size/{token_id}` | Minimum tick size: `{minimum_tick_size: number}` ∈ `{0.1, 0.01, 0.001, 0.0001}` | `GET /tick-size/{token_id}` | | `GET /v1/neg-risk/{token_id}` | `{neg_risk: bool}` for multi-outcome markets | `GET /neg-risk/{token_id}` | | `GET /v1/time` | Bare integer Unix-epoch seconds (e.g. `1779147906`), **not** an object | `GET /time` | **Field-type details:** * `GET /v1/tick-size/{token_id}` returns `minimum_tick_size` as a JSON **number** (`0.01`), matching live Polymarket. Note that the `tick_size` field *inside* a `/v1/book` snapshot is a **string** — see [String Numerics](/concepts/string-numerics). * `GET /v1/time` returns a **bare integer** (`1779147906`), not a `{"server_time": ...}` object — this matches Polymarket's wire. **Spread cap:** PolySim mirrors Polymarket's documented behaviour of capping reported spread at \$0.10. Markets with a `bid=0.001 ask=0.999` book report `spread="0.10"`, not `0.998`. This stops arbitrage filters from treating every illiquid book as a free-money signal. *** ## Tick-Size Enforcement PolySimulator rejects limit orders whose `price` doesn't conform to the market's minimum tick size — exactly as Polymarket does. ```json theme={null} HTTP 400 { "error": "INVALID_ORDER_MIN_TICK_SIZE", "message": "Price 0.575 breaks minimum tick size rule: 0.01. Round to the nearest multiple.", "request_id": "..." } ``` Always quantise client-side using the result of `GET /v1/tick-size/{token_id}` before submitting. SDKs that already implement PM's quantisation (e.g. `py-clob-client.utilities.price_valid`) work without modification. Market orders use `price` as a **slippage bound**, not a strict price, so off-grid `price` values are **not** tick-rejected on market orders. *** ## Beta Cohort Header Beta-issued keys carry `X-API-Beta-Cutoff: expired` on every response once the cohort cutoff date passes. SDKs can treat this header as the signal to enter read-only mode: ```python theme={null} resp = client.get_orders() if resp.headers.get("x-api-beta-cutoff") == "expired": print("Beta cohort ended — switch to a paid Pro key for trade access") ``` Public cohort status (no auth required): ```bash theme={null} curl https://api.polysimulator.com/api/beta/cohort-status # → {"cohort_label": "beta-2026-05", "available": true, "active": 0, # "cap": 100, "cutoff": "2026-05-22T23:59:59Z", "reopens_at": null} ``` *** ## SDK Migration Checklist Porting a `py-clob-client` bot? Three lines of config: ```python theme={null} from py_clob_client.client import ClobClient client = ClobClient( host="https://api.polysimulator.com", # was clob.polymarket.com chain_id=137, # leave as-is (ignored) key="", # leave as-is (ignored) ) client.set_api_creds({"X-API-Key": "ps_live_..."}) ``` `@polymarket/clob-client` (TypeScript) is the same: change `host`, swap auth headers. `salt`, `signer`, `signature` continue to be generated client-side and ignored server-side — no SDK changes needed. *** ## Next Steps * [Placing Orders](/trading/placing-orders) — Native `/v1/orders` shape * [CLOB Compatibility](/concepts/clob-compatibility) — Three trading paths compared * [Error Handling](/bots/error-handling) — Full error code reference * [Authentication](/authentication) — `X-API-Key` and the `POLY_API_KEY` alias # Rate Limits Source: https://docs.polysimulator.com/concepts/rate-limits Per-key rate limiting tiers, headers, and best practices for staying within limits. # Rate Limits Rate limits are enforced **per API key** using Redis sliding-window counters with both per-second (RPS) and per-minute (RPM) buckets. *** ## Tiers | Tier | Requests/sec | Requests/min | Max WS Connections | Max Batch Size | | ------------ | :----------: | :----------: | :----------------: | :------------: | | `free` | 2 | 120 | 1 | 1 | | `pro` | 10 | 600 | 3 | 5 | | `pro_plus` | 30 | 1,800 | 10 | 10 | | `enterprise` | 100 | 6,000 | 50 | 25 | The `free` tier allows short bursts (up to 2 requests in any one second) but is also capped at 120 requests in any rolling one-minute window, so the per-minute bucket is the one you'll hit first under sustained load. Use `POST /v1/prices/batch` and the WebSocket feeds (which don't count against the REST limit) to stay well inside it. The authoritative source for tier limits is `GET /v1/keys/tiers`. If a doc page ever disagrees with that endpoint, the endpoint wins. *** ## Rate Limit Response When you exceed your limit, the API returns **HTTP 429** with the Polymarket-shape single-field `error` envelope and a stable `X-Polysim-Code` response header: ```http theme={null} HTTP/1.1 429 Too Many Requests Retry-After: 1 X-Polysim-Code: RATE_LIMIT_EXCEEDED X-Request-Id: a1b2c3d4-... Content-Type: application/json ``` ```json theme={null} {"error": "Rate limit exceeded. Retry after 1s."} ``` Branch on `X-Polysim-Code === "RATE_LIMIT_EXCEEDED"` rather than the body prose, and read the `Retry-After` header for the exact wait time in seconds. *** ## Rate Limit Headers (on every response) Every authenticated response carries `x-ratelimit-*` headers so bots can **pre-throttle** instead of waiting for an actual 429: | Header | Type | Description | | ---------------------------------- | ------ | ---------------------------------------------------------------------------- | | `x-ratelimit-tier` | string | Tier of the key making the call — `free` / `pro` / `pro_plus` / `enterprise` | | `x-ratelimit-limit` | int | Per-minute cap for the tier | | `x-ratelimit-limit-per-second` | int | Per-second cap for the tier | | `x-ratelimit-remaining` | int | Requests remaining in the current rolling-minute window | | `x-ratelimit-remaining-per-second` | int | Requests remaining in the current rolling-second window | | `x-ratelimit-reset` | int | Unix epoch seconds when the per-minute window rolls over | Every header above is **also** emitted under the PolySim-namespaced `x-polysim-ratelimit-*` prefix with identical values (e.g. `x-polysim-ratelimit-remaining`). Read whichever your SDK or proxy keys off — the unprefixed `x-ratelimit-*` form is canonical. ```python theme={null} import time import requests # Pre-throttle off the response headers — don't wait for 429. resp = requests.get(url, headers={"X-API-Key": KEY}) remaining = int(resp.headers.get("x-ratelimit-remaining", "1")) remaining_sec = int(resp.headers.get("x-ratelimit-remaining-per-second", "1")) reset_at = int(resp.headers.get("x-ratelimit-reset", "0")) # If the per-second bucket is almost drained, sleep a beat if remaining_sec <= 1: time.sleep(1.0) # If the rolling-minute bucket is almost drained, sleep until reset elif remaining <= 5: sleep_for = max(0.0, reset_at - time.time()) time.sleep(min(sleep_for, 60.0)) ``` *** ## Handling Rate Limits When the limiter does fire, exponential backoff keyed off `Retry-After`: ```python theme={null} import time import requests def api_request(url, headers, json_data=None, max_retries=3): for attempt in range(max_retries): resp = requests.post(url, headers=headers, json=json_data) # 429 → respect Retry-After (always populated in seconds) if resp.status_code == 429: retry_after = int(resp.headers.get("Retry-After", 1)) time.sleep(retry_after) continue if resp.status_code >= 500: time.sleep(2 ** attempt) # Exponential backoff continue return resp raise Exception(f"Failed after {max_retries} retries") ``` *** ## Best Practices `POST /v1/orders/batch` and `POST /v1/prices/batch` combine multiple operations into one request — and a batch call counts as **one** tick against your RPS/RPM. It's bounded by your tier's **Max Batch Size** (see the Tiers table above): `free=1` means no batching benefit on free, so this pays off most on `pro` (5) / `pro_plus` (10) / `enterprise` (25). Subscribe to `WS /v1/ws/prices` instead of polling `GET /v1/markets`. WebSocket connections don't count against your REST rate limit. Market metadata (slug, question, outcomes) changes infrequently. Cache it locally and only refresh periodically. On order placement (`POST /v1/orders`, `POST /v1/order`, `POST /v1/clob/order`), send an `Idempotency-Key` header — a Stripe-style alias for the body's `client_order_id` — so a retried request can't double-fill. Reusing a key with a **different** payload returns `409 IDEMPOTENCY_KEY_REUSE`; reuse it only for the exact same order you're retrying. *** ## Next Steps * [String Numerics](/concepts/string-numerics) — Why all numbers are strings * [Batch Orders](/trading/batch-orders) — Reduce request count with batching # String Numerics Source: https://docs.polysimulator.com/concepts/string-numerics Why all numeric values are strings and how to handle them correctly. # String Numerics All price, quantity, balance, and monetary values in the API are returned as **strings**, not floats or integers. ```json theme={null} { "price": "0.65", "quantity": "10", "balance": "993.50", "notional": "6.50" } ``` *** ## Why Strings? IEEE 754 floating-point arithmetic causes precision errors that are unacceptable in financial applications: ```python theme={null} # Float math is broken for money >>> 0.1 + 0.2 0.30000000000000004 >>> 1000.00 - 6.50 993.4999999999999 # Wrong! # String → Decimal is safe >>> from decimal import Decimal >>> Decimal("1000.00") - Decimal("6.50") Decimal('993.50') # Correct! ``` The backend uses Python's `Decimal` type internally. By returning strings, we ensure **zero precision loss** from server to client. *** ## How to Handle ```python Python theme={null} from decimal import Decimal # Parse API response price = Decimal(order["price"]) # Decimal("0.65") qty = Decimal(order["quantity"]) # Decimal("10") notional = price * qty # Decimal("6.50") — exact # Send in requests payload = { "quantity": str(qty), # "10" "price": str(price), # "0.65" } ``` ```javascript JavaScript theme={null} // Use string arithmetic libraries like bignumber.js or decimal.js import Decimal from "decimal.js"; const price = new Decimal(order.price); // "0.65" const qty = new Decimal(order.quantity); // "10" const notional = price.times(qty); // "6.50" // Send in requests const payload = { quantity: qty.toString(), price: price.toString(), }; ``` ```go Go theme={null} import "github.com/shopspring/decimal" price, _ := decimal.NewFromString(order.Price) qty, _ := decimal.NewFromString(order.Quantity) notional := price.Mul(qty) // "6.50" ``` *** ## Fields That Are Strings Every numeric field in the API uses string encoding: | Category | Fields | | -------------- | ----------------------------------------------------------------------------------------------------- | | **Prices** | `buy`, `sell`, `best_bid`, `best_ask`, `mid_price`, `spread`, `last_trade`, `GET /v1/price` → `price` | | **Order** | `quantity`, `notional`, `fee`, `limit_price`, `fill_price` | | **Account** | `balance`, `starting_balance`, `pnl`, `pnl_percent`, `unrealized_pnl` | | **Position** | `avg_entry_price`, `current_price`, `market_value`, `unrealized_pnl` | | **Order Book** | `size` (bid/ask levels) | | **Candles** | `open`, `high`, `low`, `close` | | **Volume** | `volume` | **Request bodies also expect strings** for `quantity` and `price` fields. Sending a raw float (e.g., `10.5` instead of `"10.5"`) may work but is not recommended — the API will coerce it, but you lose client-side precision. ### Known exception: `GET /markets/updown` `live_price.buy` / `live_price.sell` The legacy `/markets/updown` endpoint emits `live_price.buy` and `live_price.sell` as **floats**, not strings — every other API surface uses strings. Parse defensively: ```python theme={null} buy = float(market["live_price"]["buy"]) # float here, str everywhere else ``` This is a known drift slated for unification; treat the float values as informational quotes (UI display, signal detection), and convert to `Decimal(str(buy))` before any order math: ```python theme={null} from decimal import Decimal buy_dec = Decimal(str(market["live_price"]["buy"])) ``` The `/v1/markets`, `/v1/markets/{id}`, `/v1/prices/batch`, and `WS /v1/ws/prices` paths all emit string prices today and aren't affected. *** ## Polymarket-parity exceptions A small number of endpoints emit numeric (JSON `number`) values instead of strings, because the corresponding Polymarket endpoint has always done so and bot SDKs ported from Polymarket type-narrow on `typeof === "number"`. The list is intentionally short: | Endpoint | Field | Type | Reason | | ------------------------------ | --------------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET /v1/tick-size/{token_id}` | `minimum_tick_size` | `number` (double) | Live Polymarket emits `{"minimum_tick_size": 0.01}` as a JSON **number**, and `py-clob-client` compares it against numeric tick constants. Note: the `tick_size` field *inside* `GET /v1/book` is a **string** — only the standalone `/tick-size` endpoint emits a number. | | `GET /v1/markets/updown` | `live_price.buy/sell` | `number` | PM-parity for crypto-spike feeds. | For all OTHER price-bearing endpoints (`GET /v1/price`, `/v1/midpoint`, `/v1/spread`, `/v1/last-trade-price`, `/v1/book`, `/v1/account/positions`, etc.) the string convention continues to hold. **`GET /v1/price` returns a string.** Live Polymarket emits `GET /price` as `{"price": "0.28"}` — a JSON **string**, matching the wire most trading bots actually parse. PolySimulator briefly emitted a JSON number here (2026-05-12) but reverted to the string form on 2026-05-19 because PM's real wire is the source of truth and a number broke any client doing `isinstance(price, str)` / JSON-Schema validation against PM's published shape. Wrap the value with `Decimal(resp["price"])` or `float(resp["price"])` to be robust either way. The `quote_at` (ISO string) and `age_ms` (int) freshness fields accompany the price and are unchanged. *** ## Next Steps * [CLOB Compatibility](/concepts/clob-compatibility) — Polymarket API migration path * [Placing Orders](/trading/placing-orders) — Use string numerics in practice # Live Migration Source: https://docs.polysimulator.com/deployment/live-migration Switch from virtual paper trading to live trading on Polymarket. # Virtual → Live Migration PolySimulator is designed so your entire bot codebase works unchanged when switching from virtual (paper) trading to live (real-money) trading on Polymarket. *** ## Comparison | Aspect | Virtual Mode | Live Mode | | --------------- | -------------------------------------------------------- | ------------------------------------------- | | Money at risk | None (simulated API wallet — $10,000 Pro / $25,000 Pro+) | Real funds | | Order execution | Local ledger | Polymarket CLOB | | Market data | Real prices from Polymarket | Real prices from Polymarket | | API surface | Full HFT API v1 | CLOB trading API (different endpoint paths) | | Settlement | Automatic on resolution | Polymarket blockchain | Live mode executes real trades with real money on Polymarket. Ensure your strategy is thoroughly tested in virtual mode before migrating. *** ## Migration Steps Run your bot through multiple market cycles. Verify: * Order placement works correctly * Error handling covers all edge cases * P\&L tracking is accurate * WebSocket reconnection is stable Polymarket uses **two-layer authentication** — both are required for live trading: **L1 (wallet-level):** Your Polygon wallet's private key signs an EIP-712 message to derive L2 credentials. Each order also requires an EIP-712 signature from this key. **L2 (API-level):** HMAC credentials derived from L1 — used to authenticate all CLOB requests: * API Key (`POLY_API_KEY`) * API Secret (used to generate `POLY_SIGNATURE`) * API Passphrase (`POLY_PASSPHRASE`) **Prerequisites:** * A Polygon wallet (MetaMask, Rabby, etc.) with a funded private key * USDC.e tokens on Polygon for trading capital * Token approval on the Polymarket Exchange contract Use the Polymarket SDK to derive credentials automatically: ```python theme={null} from py_clob_client.client import ClobClient client = ClobClient("https://clob.polymarket.com", key=PRIVATE_KEY, chain_id=137) api_creds = client.create_or_derive_api_key() ``` See [Polymarket's authentication docs](https://docs.polymarket.com/api-reference/authentication) for full details. Change your credentials and client configuration for live mode. Order payload structure stays highly portable, but endpoint paths/auth handling differ: ```bash theme={null} # Virtual mode (PolySimulator) — your current config POLYSIM_BASE_URL="https://api.polysimulator.com/v1" POLYSIM_API_KEY="ps_live_kJ9mNx2p..." # Live mode (Polymarket) — swap these values POLYMARKET_BASE_URL="https://clob.polymarket.com" POLY_API_KEY="your_polymarket_api_key" POLY_API_SECRET="your_polymarket_api_secret" POLY_PASSPHRASE="your_polymarket_api_passphrase" ``` There is no single `TRADING_MODE` flag. Migration is achieved by switching to Polymarket credentials/client setup and routing order placement through Polymarket's CLOB API flow. The CLOB-compatible endpoint mirrors Polymarket's real CLOB API schema. Your bot code stays identical — only the base URL and auth header change: ```python Virtual (PolySimulator) theme={null} import requests, os BASE_URL = os.environ["POLYSIM_BASE_URL"] # https://api.polysimulator.com/v1 headers = {"X-API-Key": os.environ["POLYSIM_API_KEY"]} order = requests.post(f"{BASE_URL}/clob/order", headers=headers, json={ "token_id": "71321045679252...", "side": "BUY", "price": "0.65", "size": "10", "order_type": "GTC", }) order.raise_for_status() print(order.json()) ``` ```python Live (Polymarket) theme={null} from py_clob_client.client import ClobClient from py_clob_client.clob_types import ApiCreds # Client setup + submission method changes; order field semantics stay close: api_creds = ApiCreds( api_key=os.getenv("POLY_API_KEY"), api_secret=os.getenv("POLY_API_SECRET"), api_passphrase=os.getenv("POLY_PASSPHRASE"), ) client = ClobClient( "https://clob.polymarket.com", chain_id=137, key=os.getenv("PRIVATE_KEY"), creds=api_creds, ) # Same trading intent, submitted via Polymarket SDK order flow: from py_clob_client.clob_types import OrderArgs, OrderType from py_clob_client.order_builder.constants import BUY signed_order = client.create_order( OrderArgs( token_id="71321045679252...", side=BUY, price=0.65, size=10, ) ) result = client.post_order(signed_order, OrderType.GTC) print(result) ``` **Auth difference**: PolySimulator uses `X-API-Key` header. Polymarket uses **L2 HMAC headers** (`POLY_ADDRESS`, `POLY_SIGNATURE`, `POLY_TIMESTAMP`, `POLY_API_KEY`, `POLY_PASSPHRASE`) plus **EIP-712 signing** for each order. The Polymarket SDK handles the complexity — only the client initialisation changes, not the order fields. Start with minimal order sizes to verify live execution: ```python theme={null} import os from py_clob_client.client import ClobClient from py_clob_client.clob_types import ApiCreds, OrderArgs, OrderType from py_clob_client.order_builder.constants import BUY creds = ApiCreds( api_key=os.getenv("POLY_API_KEY"), api_secret=os.getenv("POLY_API_SECRET"), api_passphrase=os.getenv("POLY_PASSPHRASE"), ) client = ClobClient( "https://clob.polymarket.com", chain_id=137, key=os.getenv("PRIVATE_KEY"), creds=creds, ) # 1. Place a small test limit order signed = client.create_order( OrderArgs(token_id="71321045679252...", side=BUY, price=0.10, size=1) ) posted = client.post_order(signed, OrderType.GTC) print("Posted order:", posted) # 2. Verify open orders open_orders = client.get_orders() print("Open orders:", len(open_orders)) ``` *** ## CLOB Compatibility Layer The `POST /v1/clob/order` endpoint accepts the same request body as Polymarket's `POST /order`: | Field | Type | Description | | ------------ | ------ | --------------------------------------------- | | `token_id` | string | CLOB outcome token ID | | `side` | string | `BUY` or `SELL` (uppercase) | | `price` | string | Limit price as decimal string ("0.01"–"0.99") | | `size` | string | Number of shares as decimal string | | `order_type` | string | `GTC` (default), `FOK`, `IOC`, `GTD` | All numeric fields (`price`, `size`) must be **strings** (e.g., `"0.65"` not `0.65`). `side` must be **uppercase** (`"BUY"` not `"buy"`). `order_type` must be a CLOB type (`"GTC"`, `"FOK"`, `"IOC"`, `"GTD"`) — not `"market"`. See [CLOB Compatibility](/concepts/clob-compatibility) for full schema details and response format. *** ## Rollback To return to virtual mode, change your bot's environment variables back: ```bash theme={null} POLYSIM_BASE_URL="https://api.polysimulator.com/v1" POLYSIM_API_KEY="ps_live_kJ9mNx2p..." # Remove or comment out Polymarket credentials ``` Restart your bot and it resumes paper trading with the simulated balance. *** ## Checklist * [ ] Bot tested across multiple market scenarios in virtual mode * [ ] Error handling verified (network errors, rate limits, slippage) * [ ] Polymarket API credentials obtained and secured * [ ] Environment variables updated * [ ] Initial live test with minimal order size * [ ] Monitoring and alerting configured for live trading # PolySimulator API Source: https://docs.polysimulator.com/introduction Virtual prediction market trading API — build and test HFT bots with real Polymarket data, then go live with one config change. # PolySimulator HFT API v1 Build, test, and deploy prediction market trading bots in a risk-free environment powered by **real-time Polymarket data**. When you're ready, switch to live trading by changing a single environment variable. Get your API key and place your first trade in under 2 minutes. Interactive playground — test every endpoint with your API key. Copy-paste a working Python trading bot and start experimenting. *** ## Why PolySimulator? | Feature | Description | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | **Real Data** | Live mid-prices, bids, asks from Polymarket's CLOB order book | | **Book Walking** | Market orders fill against real order book depth — no infinite liquidity | | **Slippage Protection** | `price` field acts as worst-price limit — identical to Polymarket | | **Limit Orders** | GTC, IOC time-in-force with sub-second matching engine | | **Batch Orders** | Up to 25 orders per request (Enterprise tier; tier-dependent — free=1, pro=5, pro\_plus=10, enterprise=25; see `GET /v1/keys/tiers`) | | **WebSocket Feeds** | Real-time price ticks and execution notifications | | **OHLCV Candles** | Historical candlestick data for backtesting | | **CLOB-Compatible** | `/v1/clob/order` mirrors Polymarket's schema — URL-swap migration | | **String Numerics** | All price/size/balance values are strings — no float precision loss | *** ## Architecture Overview ``` ┌─────────────────────────┐ │ Your Trading Bot │ │ (REST + WebSocket) │ └────────────┬────────────┘ │ ┌────────────▼────────────┐ │ PolySimulator API v1 │ │ FastAPI + Redis + PG │ └────────────┬────────────┘ │ ┌─────────────────────┼─────────────────────┐ │ │ │ ┌─────────▼─────────┐ ┌─────────▼─────────┐ ┌─────────▼─────────┐ │ PostgreSQL │ │ Redis │ │ Polymarket │ │ (Supabase) │ │ (Price Cache) │ │ CLOB + Gamma │ └───────────────────┘ └───────────────────┘ └───────────────────┘ ``` *** ## Base URL | Environment | URL | | ----------- | ------------------------------- | | Production | `https://api.polysimulator.com` | Endpoints are mounted under `/v1` — append the version + path to the base, e.g. `GET https://api.polysimulator.com/v1/health`. (The one documented exception is `/api/beta/cohort-status`.) Keep the base URL **without** `/v1` so each path can prepend its own `/v1` — that's the convention the [Quick Start](/quickstart) and [API Reference](/api-reference) use. *** ## One Config Change to Go Live Develop and test your bot against the virtual API with a simulated API wallet ($10,000 on Pro, $25,000 on Pro+; Free-tier keys are read-only with no API wallet). Review your portfolio, trade history, and equity curve to confirm your edge. Set `TRADING_MODE=live` and add your Polymarket credentials. Same API, real money. The `/v1/clob/order` endpoint uses the same schema as Polymarket's real CLOB API. When migrating to live, you can even point your bot directly at `clob.polymarket.com` with zero code changes. *** ## AI Agent Integration (MCP) PolySimulator docs are indexed for two of the most common AI-coding-tool integrations. Either gives Cursor, Windsurf, Claude Desktop, Continue, and similar assistants live access to the current API schema. ### Mintlify MCP ```bash theme={null} # Add to your AI tool's MCP server config npx @mintlify/mcp@latest polysimulator ``` ### Context7 The docs are also published to [Context7](https://context7.com/bavariance/polysimulator-docs) (verified, trust score 7.5+). If your tool already has Context7 wired up, append `use context7` to any prompt and the latest PolySimulator docs are pulled into the conversation: ``` how do I place a CLOB-compatible BUY order on PolySimulator? use context7 ``` In Cursor / Claude Code, just install the Context7 MCP server once (see [context7.com/install](https://context7.com)) — no per-project setup. Either MCP path lets you ask things like: * *"Write a Python script to place a limit order on PolySimulator"* * *"What rate limits apply to the free tier?"* * *"Show me the WebSocket price feed message format"* * *"How do I migrate a `@polymarket/clob-client` bot to PolySimulator?"* *** ## What's Next? Learn how API key auth works and create your first key. Execute market and limit orders with string-precision numerics. Subscribe to real-time price updates and execution notifications. Handle rate limits, errors, and build retry strategies for your bot. # Batch Prices Source: https://docs.polysimulator.com/market-data/batch-prices Fetch live prices for multiple markets in a single request. # Batch Prices ``` POST /v1/prices/batch ``` Returns live prices for multiple markets in a single request. Much more efficient than calling `GET /v1/markets/{id}` individually. *** ## Request ```bash theme={null} curl -X POST https://api.polysimulator.com/v1/prices/batch \ -H "X-API-Key: $API_KEY" \ -H "Content-Type: application/json" \ -d '{"market_ids": ["0x1a2b3c...", "0x4d5e6f..."]}' ``` | Field | Type | Required | Description | | ------------ | --------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `market_ids` | string\[] | Yes | Array of condition\_ids. Max = **your tier's `max_batch_size`** (see table below); the absolute hard ceiling enforced by the schema is **50**, but no documented tier reaches it. | ### Batch Size Limits The maximum number of markets per request depends on your API key's rate limit tier: | Tier | Max `market_ids` per request | | ------------ | :--------------------------: | | `free` | 1 | | `pro` | 5 | | `pro_plus` | 10 | | `enterprise` | 25 | Exceeding your tier's limit returns a `400 BATCH_LIMIT_EXCEEDED` error. The authoritative per-tier value is on the wire via `GET /v1/keys/tiers` (the `max_batch_size` field). The schema-level hard ceiling is 50, but since the highest tier (`enterprise`) caps at 25, you will hit your tier limit long before the ceiling. *** ## Response ```json theme={null} [ { "condition_id": "0x1a2b3c...", "buy": "0.65", "sell": "0.35", "outcomes": [ {"label": "Yes", "price": "0.65", "token_id": "71321..."}, {"label": "No", "price": "0.35", "token_id": "71322..."} ], "source": "gamma_poller" }, { "condition_id": "0x4d5e6f..." } ] ``` | Field | Type | Description | | -------------- | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `condition_id` | string | The requested market ID | | `buy` | string \| null | Yes outcome price (0–1). Absent when price unavailable. | | `sell` | string \| null | No outcome price (0–1). Absent when price unavailable. | | `outcomes` | array | Per-outcome `{label, price, token_id}` breakdown | | `source` | string \| null | Opaque provenance label for where the cached price came from — observed values include `gamma_poller`, `gamma_api`, and `clob_market_fallback`. Treat it as informational; **do not branch on its exact value** (the set is not a stable contract). | Markets with unavailable prices return only the `condition_id` field — all price fields will be absent. This can happen when a market is newly listed and hasn't been cached yet. *** ## Use Cases * **Portfolio valuation**: Fetch current prices for all your positions at once * **Watchlist monitoring**: Track prices for markets you're interested in * **Batch strategies**: Evaluate multiple markets before submitting batch orders ```python theme={null} import requests API_KEY = "ps_live_..." BASE = "https://api.polysimulator.com" headers = {"X-API-Key": API_KEY} # Fetch prices for all open positions positions = requests.get( f"{BASE}/v1/account/positions", headers=headers, params={"status": "OPEN"}, ).json() market_ids = [p["market_id"] for p in positions] prices = requests.post( f"{BASE}/v1/prices/batch", headers=headers, json={"market_ids": market_ids}, ).json() for p in prices: if p.get("buy") is not None: print(f"{p['condition_id'][:16]}: Yes={p['buy']}, No={p['sell']}") else: print(f"{p['condition_id'][:16]}: price unavailable") ``` *** ## Error Handling | Status | Meaning | | ------ | ------------------------------------------------------------- | | `400` | `market_ids` missing, empty, or exceeds tier batch size limit | | `401` | Invalid or expired API key | | `429` | Rate limit exceeded — check `Retry-After` header | ```python theme={null} import time resp = requests.post( f"{BASE}/v1/prices/batch", headers=headers, json={"market_ids": market_ids}, ) if resp.status_code == 200: prices = resp.json() for p in prices: if p.get("buy") is not None: print(f"{p['condition_id'][:16]}: {p['buy']}") elif resp.status_code == 400: print(f"Bad request: {resp.json().get('message', resp.json())}") elif resp.status_code == 429: retry_after = int(resp.headers.get("Retry-After", 1)) time.sleep(retry_after) ``` Markets with unavailable prices return only the `condition_id` field — all price fields will be absent. Always check for the presence of `buy`/`sell` before using them. *** ## Next Steps * [Markets](/market-data/markets) — Full market metadata * [Order Book](/market-data/order-book) — Detailed liquidity depth # Events Source: https://docs.polysimulator.com/market-data/events List Polymarket events with their child markets in an event-first shape. # Events `/v1/events` is the event-first counterpart to `/v1/markets`. Each response row is a parent event (e.g. *"2026 U.S. midterm elections"*) with its child markets nested inside, plus live prices on each child. For most algorithmic-trading workflows the flatter `/v1/markets` shape is easier to iterate over — use `/v1/events` when you need the event-level grouping (event title, end date, category) without making a second request per market. *** ## List Events ``` GET /v1/events ``` ### Query Parameters | Parameter | Type | Default | Description | | ------------------- | ------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `limit` | int | 50 | Max events to return (1–200). | | `offset` | int | 0 | Pagination offset. | | `hot_only` | bool | false | Only return events with high-volume markets. | | `markets_per_event` | int | 20 | Cap on child markets returned per event (1–100). | | `category` | string | — | Filter to a single category (e.g. `crypto`, `sports`, `politics`). Case-insensitive substring match against the event's classified category. Categories are populated by the background poller (\~5 min cadence) from the event's tags via `classify_event`. Use **unquoted** values: `?category=crypto`, NOT `?category="crypto"`. Leading/trailing single or double quotes are stripped defensively for users pasting from Postman. | | `search` | string | — | Free-text search over event titles and market questions (case-insensitive substring). | | `include_sports` | bool | true | When `false`, suppress sports events from the response. | ### Example ```bash theme={null} # All events, first page curl -H "X-API-Key: $API_KEY" \ "https://api.polysimulator.com/v1/events?limit=10" # Crypto events only curl -H "X-API-Key: $API_KEY" \ "https://api.polysimulator.com/v1/events?category=crypto&limit=10" ``` ### Response ```json theme={null} { "events": [ { "id": "12345", "slug": "will-bitcoin-reach-100k-by-2026", "title": "Will Bitcoin reach $100K by end of 2026?", "image": "https://polymarket-upload.s3.us-east-2.amazonaws.com/...", "end_date": "2026-12-31T23:59:59Z", "is_hot": true, "category": "crypto", "markets": [ { "condition_id": "0x1a2b3c...", "slug": "btc-100k-2026", "question": "Bitcoin price > $100,000 by 2026-12-31?", "outcomes": ["Yes", "No"], "live_price": { "buy": "0.65", "sell": "0.35" } } ] } ], "total": 9740, "limit": 10, "offset": 0, "cache_source": "redisearch_all", "has_sports": false } ``` ### Envelope fields | Field | Type | Description | | -------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `events` | `Event[]` | Events on this page — one object per parent event, each with its child markets capped at `markets_per_event`. | | `total` | `int` | Total event count matching the filter across **all** pages (not just this page). | | `limit` | `int` | Echo of the requested `limit`. | | `offset` | `int` | Echo of the requested `offset`. | | `cache_source` | `string` | Internal cache tier the response was served from — e.g. `homepage_prebuilt`, `redisearch_all`, or `empty`. An opaque provenance label; do not branch on its exact value. | | `has_sports` | `bool \| null` | `true` when sports events were filtered out (i.e. you passed `include_sports=false` and some sports events matched). `false`/`null` otherwise. | | `message` | `string \| null` | Present only on `503` (cache warming) and other operational states — carries a human-readable status. Absent on a normal `200`. | On a cold poller cache the endpoint may return HTTP 503 with a `Retry-After: 5` header (and a `message` field in the body) — wait the suggested interval and retry. The poller backfills the cache within \~30 seconds of cold-start. *** ## Pairing with `/v1/markets` The two endpoints share a poller and a Redis cache layer — every market returned by `/v1/markets?category=crypto` belongs to one of the events returned by `/v1/events?category=crypto`. Pick the shape that minimises client-side work: * **`/v1/markets`** — flat, one row per `condition_id`. Best for bots that iterate over individual markets. * **`/v1/events`** — nested, one row per parent event. Best for UI rendering, category browsing, or any workflow where the event group matters. *** ## Next Steps * [Markets](/market-data/markets) — flat market-first shape * [Order Book](/market-data/order-book) — L2 depth for a specific market * [Batch Prices](/market-data/batch-prices) — Multi-market price lookup # Markets Source: https://docs.polysimulator.com/market-data/markets Browse and search prediction markets with real-time prices from Polymarket. # Markets Discover and browse Polymarket prediction markets with live pricing data. *** ## List Markets ``` GET /v1/markets ``` Returns markets with attached live prices from Polymarket's CLOB. ### Query Parameters | Parameter | Type | Default | Description | | ------------- | ------ | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `limit` | int | 50 | Max results (1–200) | | `offset` | int | 0 | Pagination offset | | `hot_only` | bool | false | Only high-volume markets | | `active_only` | bool | — | PM-shape alias: when `true`, only return markets where `active=true AND closed=false`. | | `category` | string | — | Filter to a single category (e.g. `crypto`, `sports`, `politics`). Case-insensitive substring match against each market's category, which is populated by the background poller (\~5 min cadence) from the parent event's tags. Use **unquoted** values: `?category=crypto`, NOT `?category="crypto"`. Leading/trailing single or double quotes are stripped defensively for users pasting from Postman. | | `q` | string | — | Free-text search over `question` and `slug` (case-insensitive substring). Empty string is ignored. | | `envelope` | bool | false | When `true`, wrap the response in a PM-shape `{data, next_cursor}` envelope (recommended for `py-clob-client` / other PM-ported SDKs). Default `false` returns a bare array for back-compat. | `sort` is **not** supported — the underlying list is volume-ordered server-side. For domain-specific ordering, fetch a wide page (`limit=200`) and sort client-side. ### Examples ```bash theme={null} # Bare-array (back-compat default) curl -H "X-API-Key: $API_KEY" \ "https://api.polysimulator.com/v1/markets?hot_only=true&limit=10" # PM-shape envelope (recommended for PM-ported SDKs) curl -H "X-API-Key: $API_KEY" \ "https://api.polysimulator.com/v1/markets?category=crypto&envelope=true&limit=10" # Search by keyword (matches against question OR slug, case-insensitive) curl -H "X-API-Key: $API_KEY" \ "https://api.polysimulator.com/v1/markets?q=trump&limit=5" ``` ### Response ```json theme={null} [ { "condition_id": "0x1a2b3c...", "slug": "will-bitcoin-reach-100k-by-2026", "question": "Will Bitcoin reach $100K by end of 2026?", "outcomes": ["Yes", "No"], "active": true, "closed": false, "is_hot": true, "image_url": "https://polymarket-upload.s3.us-east-2.amazonaws.com/...", "category": "crypto", "clob_token_ids": [ "71321234567890123456789012345678901234567890", "71321234567890123456789012345678901234567891" ], "volume_24h": "125000.50", "last_price": "0.65", "live_price": { "buy": "0.65", "sell": "0.35", "volume": "125000.50", "outcomes": [ {"label": "Yes", "price": "0.65", "token_id": "71321..."}, {"label": "No", "price": "0.35", "token_id": "71322..."} ], "updated_at": "2026-02-06T12:00:45Z", "source": "gamma_poller" } } ] ``` ### Field reference | Field | Type | Description | | ---------------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `condition_id` | `string` | On-chain market identifier. Pass to `GET /v1/markets/{condition_id}` for full detail. | | `slug` | `string \| null` | URL-friendly market identifier (PM canonical name). | | `question` | `string \| null` | Human-readable market question. | | `outcomes` | `string[] \| null` | Outcome labels — typically `["Yes", "No"]`; categorical markets may list more. | | `category` | `string \| null` | Canonical category slug (`crypto`, `sports`, `politics`, `general`, ...). Use `?category=X` to filter. | | `clob_token_ids` | `string[] \| null` | CLOB asset ids ordered `[yes_id, no_id]`. Pass either to `GET /v1/clob/book/{token_id}` for the L2 order book. `null` for archived markets with no tracked outcomes. | | `volume_24h` | `string \| null` | Rolling 24-hour volume in USD (stringified for precision). **`null` means "no data yet"** (poller has not observed this market); `"0"` means real zero. | | `last_price` | `string \| null` | Last trade price for the YES token (0.0–1.0 implied probability, stringified). `null` until the poller catches up. | | `live_price` | `LivePrice \| null` | Real-time best-bid / best-ask snapshot — see [String Numerics](/concepts/string-numerics). | **Cache cadence**: `volume_24h`, `last_price`, and `category` are populated by the background poller that scans Polymarket's catalog on a \~5 minute cycle. Newly-discovered markets surface immediately with these fields as `null` and gain real values on the next poll pass. Treat `null` as "data pending" rather than "missing market". `best_bid / best_ask / spread / last_trade` price fields aren't returned on the list endpoint today. Use `GET /v1/spread?token_id=...` and `GET /v1/midpoint?token_id=...` for top-of-book / mid. The detail endpoint `GET /v1/markets/{condition_id}` *does* return richer fields (`event_group_id`, `event_title`, `end_date`, `resolved_outcome`, `archived`, `archived_at`) plus the list-item shape. All numeric values (`buy`, `sell`, `price`, `volume`, `volume_24h`, `last_price`) are **strings** for floating-point precision safety. See [String Numerics](/concepts/string-numerics). *** ## Order Book Lookup Recipe Combine the list response with `GET /v1/clob/book/{token_id}` to render an L2 order book in two requests: ```bash theme={null} # Step 1: get the clob_token_ids for a market curl -H "X-API-Key: $API_KEY" \ "https://api.polysimulator.com/v1/markets?q=bitcoin&limit=1" | jq '.[0].clob_token_ids' # → ["71321...", "71322..."] # [yes_id, no_id] # Step 2: fetch the YES book (clob_token_ids[0]) curl -H "X-API-Key: $API_KEY" \ "https://api.polysimulator.com/v1/clob/book/71321..." ``` **Convention**: `clob_token_ids[0]` is the YES outcome, `clob_token_ids[1]` is NO. Pass either to `GET /v1/clob/book/{token_id}` for that side's order book. The IDs are stable for the lifetime of the market. `GET /v1/clob/book/{token_id}` (path form) and `GET /v1/book?token_id=...` (query-string form) return the same `OrderBookSnapshot` shape. Use whichever matches your SDK's routing convention — both are first-class endpoints. *** ## Get Market Detail ``` GET /v1/markets/{market_id} ``` Returns full market detail with an optional order book snapshot. ### Query Parameters | Parameter | Type | Default | Description | | -------------- | ---- | ------- | -------------------------------- | | `include_book` | bool | false | Include CLOB order book snapshot | ```bash theme={null} curl -H "X-API-Key: $API_KEY" \ "https://api.polysimulator.com/v1/markets/0x1a2b3c?include_book=true" ``` The response is the same as a list item, plus an optional `order_book` field when `include_book=true`, plus the following Polymarket-shape parity fields that `py-clob-client` SDKs read by name: | Field | Type | Description | | ------------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `tokens` | `MarketToken[] \| null` | PM-shape per-outcome token list — **the canonical Polymarket contract** that `py-clob-client` reads (`market["tokens"][i]["outcome"]`, `.find(t => t.winner)`, etc). Each entry is an object: `{token_id, outcome, price, winner}` (see below). For the flat CLOB asset-id string list, use `clob_token_ids` instead. | | `question_id` | `string` | Alias of `condition_id`. PM SDKs read this key by name. | | `market_slug` | `string \| null` | Alias of `slug`. PM SDKs use `market_slug`; polysim has historically used `slug`. Both are exposed with the same value. | | `neg_risk` | `bool` | Negative-risk routing flag. Polysim does not host negative-risk markets — always `false`. | Each `tokens[]` entry (a `MarketToken`) carries: | Sub-field | Type | Description | | ---------- | -------- | ----------------------------------------------------------------------------------------------------------------------- | | `token_id` | `string` | CLOB outcome (asset) ID for this side. | | `outcome` | `string` | Outcome label, e.g. `"Yes"`, `"No"`, `"Up"`, `"Down"`. | | `price` | `number` | YES/NO probability for this outcome at response-build time. | | `winner` | `bool` | `true` only when the market is **resolved** AND this outcome is the settled winner. Never derived from `price >= 0.99`. | `tokens` (object list) and `clob_token_ids` (flat string list) are **both** returned and both stable for the market's lifetime — pick whichever your SDK expects. `tokens` matches the Polymarket `get_market()` / `getClobMarketInfo()` shape; `clob_token_ids` is the PolySimulator convenience list ordered `[yes_id, no_id]`. *** ## Hot Markets Markets with trading volume exceeding \$5,000 are flagged as `is_hot: true`. Use `hot_only=true` to filter for actively traded markets — recommended for bots to ensure sufficient liquidity. ```bash theme={null} # Only hot markets curl -H "X-API-Key: $API_KEY" \ "https://api.polysimulator.com/v1/markets?hot_only=true" ``` *** ## Next Steps * [Order Book](/market-data/order-book) — L2 depth for a specific market * [Price Candles](/market-data/price-candles) — Historical OHLCV data * [Batch Prices](/market-data/batch-prices) — Multi-market price lookup # Order Book Source: https://docs.polysimulator.com/market-data/order-book Access L2 order book depth from Polymarket's CLOB for any market. # Order Book ``` GET /v1/markets/{condition_id}/book ``` Returns the CLOB L2 order book snapshot for a market's primary token. *** ## Request ```bash theme={null} curl -H "X-API-Key: $API_KEY" \ "https://api.polysimulator.com/v1/markets/0x1a2b3c/book?outcome=Yes&depth=5" ``` | Parameter | Type | Default | Description | | --------- | ------ | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `outcome` | string | — | Filter by outcome label (e.g. `Yes`, `No`) | | `depth` | int | full book | Optional trim: keep only the best N levels per side (max 500). Omitted = the FULL book — Polymarket's wire contract. Before 2026-06-11 the default silently truncated to 10 levels. | *** ## Response ```json theme={null} { "token_id": "71321...", "market": "0x1a2b3c...", "asset_id": "71321...", "hash": "0x9f4c...", "bids": [ {"price": "0.62", "size": "3000.0"}, {"price": "0.63", "size": "1200.0"}, {"price": "0.64", "size": "500.0"} ], "asks": [ {"price": "0.68", "size": "2500.0"}, {"price": "0.67", "size": "800.0"}, {"price": "0.66", "size": "400.0"} ], "min_order_size": "5", "tick_size": "0.01", "neg_risk": false, "last_trade_price": "0.65", "mid": "0.65", "spread": "0.02", "timestamp": "2026-02-06T12:00:45Z" } ``` In the example above the **best bid is `0.64` — the LAST `bids` entry (`bids[-1]`)** — and the **best ask is `0.66` — the LAST `asks` entry (`asks[-1]`)**. The best level is at the **tail** on both sides, byte-identical to Polymarket's live `/book` wire. For safety, read order-independently — see the warning below. *** ## Understanding the Book | Field | Description | | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `token_id` | The CLOB outcome token (asset) the book is for. | | `market` | The market's `condition_id`. PM-compat alias — the SDK reads this to associate the book with a market. | | `asset_id` | Same value as `token_id`. PM uses `asset_id` as the canonical book key, so `py-clob-client`-ported code reads this name. | | `hash` | Order-book hash for change detection (PM convention). Compare across snapshots to detect whether the book moved without diffing every level. | | `bids` | Buy orders sorted by price **ascending** — **best (highest) bid is `bids[-1]`** (the last element), worst bid first. This is byte-identical to what the real Polymarket CLOB `/book` returns on the wire, so SDKs ported from `py-clob-client` read the same index. | | `asks` | Sell orders sorted by price **descending** — **best (lowest) ask is `asks[-1]`** (the last element), worst ask first. | | `min_order_size` | Minimum orderable shares for this market (string). | | `tick_size` | Price quantum as a **string** (e.g. `"0.01"`). `py-clob-client` reads this to quantize limit prices client-side. (Note: the standalone [`GET /v1/tick-size/{token_id}`](/concepts/pm-raw-http) endpoint returns the tick as a JSON **number**; here, inside the book, it is a string — see [String Numerics](/concepts/string-numerics).) | | `neg_risk` | `true` when the market is a negative-risk (multi-outcome) market, `false` for a standard binary, or `null` when unknown. SDKs use this for order-routing. | | `last_trade_price` | The most recent trade price for this token (string), or `null` if no trades yet. | | `mid` | Average of best bid and best ask | | `spread` | Difference between best ask and best bid, **capped at \$0.10** for PM parity. Markets with extreme thin-liquidity books (e.g. `bid=0.001 ask=0.999`) report `spread="0.10"`, not the raw difference. This stops arbitrage filters from treating every illiquid book as a free-money signal. | | `stale` | Present and `true` only when the live CLOB fetch failed and the snapshot was served from a recent cached book. Absent on a fresh read. Treat a `stale` book as a best-effort fallback, not a real-time quote. | | `price` | Price level (inside a `bids`/`asks` entry) | | `size` | Total shares available at that price | **Recommended: read the inside market order-independently, not by index.** Bids are sorted **ascending** (best/highest bid last, `bids[-1]`) and asks **descending** (best/lowest ask last, `asks[-1]`) — the best of each side is the **last** element, byte-identical to what Polymarket's live CLOB `/book` returns on the wire. Rather than indexing a fixed position, compute the best bid as the max bid price and the best ask as the min ask price. **Coerce the price to a number first** — prices are JSON strings, and `max(b["price"] for b in bids)` compares them lexicographically, where `"0.9" > "0.10"` is `True` (wrong). Use `max(float(b["price"]) for b in bids)` / `min(float(a["price"]) for a in asks)` (or `Decimal` for exactness). This stays correct regardless of array order. The ordering above is verified against the live `clob.polymarket.com/book` wire (2026-06-10). **Migration (2026-06-10): book level ordering changed to Polymarket live-wire parity.** Levels are now **bids ascending (best = `bids[-1]`), asks descending (best = `asks[-1]`)** — best at the tail on both sides, byte-identical to the real `clob.polymarket.com/book` wire. A brief earlier build (2026-06-10 AM) emitted bids descending / asks ascending (best at `[0]`) to match Polymarket's *published docs*; the docs contradict the live wire, so that ordering was the exact opposite of the wire on both sides. If your integration hard-coded `bids[0]` / `asks[0]` for the inside market, switch to the order-independent `max`/`min` reads above — they survive this and any future wire-format change. ### Liquidity Assessment ```python theme={null} from decimal import Decimal book = api.get_order_book("0x1a2b3c...") # Calculate total bid liquidity bid_liquidity = sum( Decimal(level["size"]) for level in book["bids"] ) # Calculate total ask liquidity ask_liquidity = sum( Decimal(level["size"]) for level in book["asks"] ) spread = Decimal(book["spread"]) print(f"Spread: {spread}") print(f"Bid depth: {bid_liquidity} shares") print(f"Ask depth: {ask_liquidity} shares") ``` **Check the book before large orders.** If your order size exceeds the top-of-book liquidity, you'll walk through multiple price levels and experience slippage. Use [slippage protection](/trading/slippage-protection) to guard against this. *** ## Next Steps * [Markets](/market-data/markets) — Browse available markets * [Placing Orders](/trading/placing-orders) — Trade with real book depth # Price Candles Source: https://docs.polysimulator.com/market-data/price-candles Historical OHLCV candlestick data — bucketed from Polymarket's tick stream with internal-fill volume. # Price Candles ``` GET /v1/markets/{condition_id}/candles ``` Returns OHLCV candlesticks. The price ticks come from Polymarket's CLOB `/prices-history` endpoint — those arrive as `{t, p}` (single price per tick, not OHLC), so PolySimulator buckets them server-side into the requested interval and aggregates `open` (first tick), `high` (max), `low` (min), `close` (last tick) per bucket. Volume is sourced from **your internal fills** on PolySimulator, not Polymarket's chain volume. Before the May 8, 2026 launch, this endpoint returned `O = H = L = C` for every tick (no bucketing). Bots using technical indicators on the prior output saw flat candles. The current implementation produces proper OHLC variation when the underlying tick stream has it. *** ## Query Parameters | Parameter | Type | Default | Description | | ---------- | ------ | ----------------------- | ---------------------------------------------- | | `outcome` | string | first available outcome | Outcome label (e.g. `Yes`, `No`, `Up`, `Down`) | | `interval` | string | `1h` | Bucket interval — see table below | ### Available Intervals | Interval | Bucket size | | -------- | ----------------------------------------------------------------------------- | | `1h` | 1 hour (default) | | `6h` | 6 hours | | `1d` | 1 day | | `1w` | 1 week | | `max` | Treated as `1d` for bucketing — gives the longest meaningful intra-day signal | **Only `1h`, `6h`, `1d`, `1w`, and `max` are supported.** Sub-hour intervals (`1m`, `5m`, `15m`) and any other unrecognised value return **HTTP 400** with `{"error": "INVALID_INTERVAL", "message": "...", "supported_intervals": ["1h", "6h", "1d", "1w", "max"]}`. There is no silent fall-back to `1h` — the request fails loudly so you don't render an empty or mis-bucketed chart. Sub-hour granularity is unavailable because the upstream Polymarket CLOB `/prices-history` feed is hourly-granular — we can't reconstruct 5-minute buckets from 1-hour samples. (Polymarket's own `/prices-history` enum does include `1m` and `all`, which PolySimulator does not support.) *** ## Example ```bash theme={null} curl -H "X-API-Key: $API_KEY" \ "https://api.polysimulator.com/v1/markets/0x0f49db97f71c68b1e42a6d16e3de93d85dbf7d4148e3f018eb79e88554be9f75/candles?outcome=Yes&interval=1d" ``` *** ## Response ```json theme={null} [ { "t": 1777939200, "o": "0.2615", "h": "0.2680", "l": "0.2595", "c": "0.2625", "v": "98.0000" }, { "t": 1778025600, "o": "0.2605", "h": "0.2605", "l": "0.2475", "c": "0.2475", "v": "3.0000" } ] ``` | Field | Type | Description | | ----- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `t` | integer | Unix-second timestamp of the **start** of the bucket. For `1h` every value is a multiple of 3600. | | `o` | string | Open — first tick price in the bucket | | `h` | string | High — max tick price | | `l` | string | Low — min tick price | | `c` | string | Close — last tick price | | `v` | string | Volume — sum of share-quantity from your internal FILLED orders within the bucket. `"0"` when no internal fills. Polymarket's chain volume is **not** included. | The `v` field reflects your own simulated trade flow only. To measure Polymarket-wide volume on a market, query the underlying tokens via the Polymarket Gamma API directly. PolySimulator's volume is meant for "did my own backtest fill?" sanity, not for liquidity proxies. If a bucket has only a single tick, `o == h == l == c` — that's correct behaviour for a slow-moving market, not a bug. *** ## Backtesting Example ```python theme={null} import requests from decimal import Decimal BASE = "https://api.polysimulator.com" headers = {"X-API-Key": "YOUR_API_KEY"} # Fetch hourly candles for the past day. Pass outcome explicitly when # you want a specific side; default is the first outcome. candles = requests.get( f"{BASE}/v1/markets/0x0f49.../candles", headers=headers, params={"interval": "1h", "outcome": "Yes"}, ).json() # Simple moving average crossover (close prices) prices = [Decimal(c["c"]) for c in candles] if len(prices) >= 48: sma_short = sum(prices[-12:]) / 12 # last 12 hours sma_long = sum(prices[-48:]) / 48 # last 48 hours if sma_short > sma_long: print("Bullish crossover — consider BUY") else: print("Bearish crossover — consider SELL") ``` *** ## Migrating from Polymarket Polymarket's CLOB `/prices-history` returns the raw `{t, p}` tick stream without bucketing. If you're porting a bot that does its own bucketing client-side, you can either: 1. **Trust ours** — drop your bucketing code and use the `t/o/h/l/c/v` shape directly. The interval parameter behaves identically to a pandas `resample("1H").agg({"o": "first", "h": "max", ...})`. 2. **Keep yours** — fetch raw ticks via Polymarket's Gamma API, since PolySimulator does not (yet) expose the un-bucketed feed. Cross-host strategies that compare the two should bucket identically client-side. *** ## Next Steps * [Batch Prices](/market-data/batch-prices) — Multi-market price lookup * [Order Book](/market-data/order-book) — L2 depth for fill-price modelling * [Equity Curve](/account/equity-curve) — Track your portfolio over time # Up/Down Markets Source: https://docs.polysimulator.com/market-data/updown-markets Query short-term binary markets that resolve based on asset price movements within a time interval. # Up/Down Markets ``` GET /markets/updown ``` Both `/markets/updown` and `/v1/markets/updown` work — they share one handler and return the identical payload. The `/v1/markets/updown` alias is hidden from the OpenAPI schema (`include_in_schema=False`) but is a live, fully-functional route, so you can keep your `/v1/...` base path everywhere. The companion interval-discovery endpoint `GET /v1/markets/updown/intervals` is a regular versioned route. Returns active **up/down interval markets** — short-term binary markets that resolve based on whether an asset's price goes up or down within a specific time window. *** ## Query Parameters | Parameter | Type | Default | Description | | ---------- | ------ | ------- | --------------------------------------------------------- | | `asset` | string | *all* | Filter by asset: `BTC`, `ETH`, `SOL`, `XRP`, `SPX`, `NDX` | | `interval` | string | *all* | Filter by interval: `5M`, `15M`, `1H`, `4H`, `daily` | | `limit` | int | 200 | Max results (1–500) | `5M` and `15M` are subject to availability — see `GET /v1/markets/updown/intervals` to discover which intervals are currently active. *** ## Request ```bash theme={null} # All active up/down markets curl -H "X-API-Key: $API_KEY" \ https://api.polysimulator.com/v1/markets/updown # Filter by asset and interval curl -H "X-API-Key: $API_KEY" \ "https://api.polysimulator.com/v1/markets/updown?asset=BTC&interval=1H" ``` The un-versioned `https://api.polysimulator.com/markets/updown` form works identically if your existing code already uses it — same handler, same response. *** ## Response ```json theme={null} { "markets": [ { "event_id": "evt_abc123", "condition_id": "0xabc123...", "slug": "btc-updown-1h-1770706800", "title": "BTC 1H Up/Down — March 15", "description": "Will BTC go up or down in the next hour?", "asset": "BTC", "interval": "1H", "time_range": "1:00 PM-2:00 PM ET", "date_display": "March 15", "start_date": "2026-03-15T13:00:00+00:00", "end_date": "2026-03-15T14:00:00Z", "active": true, "closed": false, "resolved": false, "tags": ["up-or-down", "bitcoin", "1h"], "markets": [ { "id": "12345", "condition_id": "0xabc123...", "question": "Will Bitcoin go up or down in the next hour?", "outcomes": ["Up", "Down"], "outcome_prices": "[\"0.55\", \"0.45\"]", "token_ids": "71321045...,82432156..." } ], "live_price": { "buy": 0.55, "sell": 0.45, "updated_at": "2026-03-15T13:44:58Z" }, "group_item_threshold": "98500.50", "image": "https://polymarket.com/images/btc.png", "icon": "https://polymarket.com/icons/btc.svg" } ], "grouped_by_asset": { "BTC": ["...(same shape as markets[])"], "ETH": ["..."] }, "grouped_by_interval": { "1H": ["..."], "4H": ["..."] }, "grouped_nested": { "BTC": { "1H": ["..."], "4H": ["..."] }, "ETH": { "1H": ["..."] } }, "total": 42, "interval_counts": { "5M": 0, "15M": 0, "1H": 12, "4H": 8, "daily": 6 }, "asset_counts": { "BTC": 10, "ETH": 8, "SOL": 6 }, "available_intervals": ["1H", "4H", "daily"], "available_assets": ["BTC", "ETH", "SOL", "XRP"], "crypto_prices": { "BTC": { "price": 98500.50, "change_24h": 2.3 }, "ETH": { "price": 3850.25, "change_24h": -1.1 } }, "timestamp": "2026-03-15T13:45:00Z", "cache_hit": true } ``` | Field | Description | | --------------------- | ----------------------------------------------------------- | | `markets` | Array of market entries (see fields below) | | `grouped_by_asset` | Markets grouped by asset symbol (same shape as `markets[]`) | | `grouped_by_interval` | Markets grouped by time interval | | `grouped_nested` | Markets nested by `asset` → `interval` | | `total` | Total number of markets returned | | `interval_counts` | Count of active markets per interval | | `asset_counts` | Count of active markets per asset | | `available_intervals` | Intervals that have at least one active market | | `available_assets` | Assets that have at least one active market | | `crypto_prices` | Current reference prices for tracked assets | | `cache_hit` | Whether the result was served from cache | ### Market Entry Fields | Field | Description | | ---------------------- | ----------------------------------------------------------------------------------------------------- | | `event_id` | Polymarket event ID | | `condition_id` | Condition ID of the first nested market | | `slug` | URL-safe slug for this market | | `title` | Display title (e.g., "BTC 1H Up/Down — March 15") | | `description` | Event description | | `asset` | Asset symbol: `BTC`, `ETH`, `SOL`, `XRP`, `SPX`, `NDX` | | `interval` | Time interval: `5M`, `15M`, `1H`, `4H`, `daily` | | `start_date` | Calculated interval start (ISO 8601) | | `end_date` | Resolution time (ISO 8601) | | `markets` | Nested array with `condition_id`, `question`, `outcomes`, `outcome_prices` (JSON string), `token_ids` | | `live_price` | Object with `buy`, `sell`, `updated_at` — enriched from Redis in real time | | `group_item_threshold` | Strike price parsed from the event title | *** ## Trading Up/Down Markets Up/down markets work like any other market — use `POST /v1/orders`: ```python theme={null} import requests, os API_KEY = os.environ["POLYSIM_API_KEY"] BASE = os.environ.get("POLYSIM_BASE_URL", "https://api.polysimulator.com") HEADERS = {"X-API-Key": API_KEY, "Content-Type": "application/json"} # 1. Find a BTC 1-hour market updown = requests.get( f"{BASE}/v1/markets/updown", headers=HEADERS, params={"asset": "BTC", "interval": "1H"}, ).json() market = updown["markets"][0] live = market.get("live_price", {}) print(f"{market['title']} — Up: {live.get('buy', 'N/A')}") # 2. Buy "Up" if price is undervalued # # IMPORTANT: market orders require a ``price`` field (worst-price # slippage cap, Polymarket-faithful). Without it, the API returns # 400 ``Market orders require a 'price' field``. # # Also note: ``live_price.buy`` / ``live_price.sell`` are emitted as # floats on ``/markets/updown`` — every other API surface uses string # prices (see /concepts/string-numerics). buy_price = float(live.get("buy", 1)) if buy_price < 0.45: # Worst-price BUY cap: 5% above live ask, hard-clamped at 0.99 worst_buy = min(0.99, buy_price * 1.05) order = requests.post( f"{BASE}/v1/orders", headers=HEADERS, json={ "market_id": market["condition_id"], "side": "BUY", "outcome": "Up", "quantity": "10", "order_type": "market", "price": str(worst_buy), # Required — worst-price slippage cap }, ).json() print(f"Order {order['order_id']}: {order['status']} @ {order['price']}") ``` Up/down markets have short lifespans (5 minutes to 24 hours). After expiry, prices may show invalid values (both outcomes at 100%). The API blocks BUY orders on expired markets but allows SELL orders for emergency position exit. *** ## Next Steps * [Placing Orders](/trading/placing-orders) — Full order placement guide * [Markets](/market-data/markets) — Browse all active markets * [Slippage Protection](/trading/slippage-protection) — Protect against price movement # Quick Start Source: https://docs.polysimulator.com/quickstart Get your API key and place your first trade in under 2 minutes. # Quick Start 1. Sign up at [polysimulator.com/signin](https://polysimulator.com/signin). 2. Open [polysimulator.com/api-keys](https://polysimulator.com/api-keys). 3. Click **Create your first API key**, give it a name, and copy the `ps_live_…` value shown once. **Closed beta (ongoing).** API key issuance is cohort-gated, so `POST /v1/keys/bootstrap` and `POST /v1/keys` return a `403` for callers who aren't yet admitted — `CLOSED_BETA` for free / waitlisted accounts, or `API_PRO_COMING_SOON` for paying Pro / Pro+ accounts without a cohort grant: ```http theme={null} HTTP/1.1 403 Forbidden X-Polysim-Code: CLOSED_BETA Content-Type: application/json {"error": "API access is in closed beta. New keys are issued to approved cohorts only. Apply via the waitlist; we'll email you when a cohort opens."} ``` Branch on the `X-Polysim-Code` response header (the body's `error` is the human message). While the beta is closed, every non-admitted caller — including paying Pro / Pro+ — gets `CLOSED_BETA`; the `API_PRO_COMING_SOON` variant only appears once self-serve issuance is enabled. Apply via the waitlist at [polysimulator.com/api-trading](https://polysimulator.com/api-trading) — we'll email you when a cohort opens. The full key is shown **only once**. Save it to your password manager or a secret store immediately — only the SHA-256 hash is retained server-side, so we can't show it again later. The dashboard handles the one-time bootstrap with your signed-in Supabase session — you never see or paste a JWT. From here on every API call uses `X-API-Key: ps_live_...`. If you can't open a browser (CI runner, containerised dev env) and you have a Supabase access token in hand, the `POST /v1/keys/bootstrap` endpoint creates your first key directly: ```bash theme={null} # SUPABASE_JWT comes from a programmatic Supabase sign-in. # Most users skip this step entirely — the /api-keys dashboard # is the recommended path. curl -X POST https://api.polysimulator.com/v1/keys/bootstrap \ -H "Authorization: Bearer $SUPABASE_JWT" \ -H "Content-Type: application/json" \ -d '{"name": "my-first-bot"}' ``` **Response (201 Created):** ```json theme={null} { "id": 1, "raw_key": "ps_live_kJ9mNx2pQrStUvWxYz01Ab3CdEfGhI4j...", "key_prefix": "ps_live_kJ9mNx2p", "name": "my-first-bot", "rate_limit_tier": "free", "permissions": ["read"], "created_at": "2026-03-04T12:00:00Z" } ``` A free-tier key is **read-only** (`["read"]`); trading needs a paid tier. See [API Keys](/concepts/api-keys#create-a-key). | Status | Meaning | | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `201` | Key created — save `raw_key` | | `400` | You already have key(s) — use `POST /v1/keys` with `X-API-Key` instead | | `401` | Invalid or expired Supabase JWT | | `403` | `CLOSED_BETA` — key issuance is in closed beta (the default for every non-admitted caller, including paying Pro / Pro+). The `API_PRO_COMING_SOON` variant appears only once self-serve issuance is enabled. Branch on the `X-Polysim-Code` header; apply via the waitlist (see the callout above). | | `429` | Bootstrap rate limit hit — wait and retry | `Authorization: Bearer` is accepted on the dashboard surface (`POST /v1/keys/bootstrap`, `GET/POST/DELETE /v1/keys`, `/v1/keys/tiers`, `/v1/keys/ws-token`, `GET /v1/me`, `/v1/account/me/entitlements`, `/v1/me/wallets/*`). **All trading, market-data, websocket, and account-trading reads (`/v1/account/{balance,positions,portfolio,history,equity}`) require `X-API-Key`** — Bearer is rejected on the trade surface. See the [Authentication](/authentication) page for the full scope table. ```bash theme={null} export POLYSIM_API_KEY="ps_live_abc123..." export POLYSIM_BASE_URL="https://api.polysimulator.com" ``` ```bash theme={null} curl -H "X-API-Key: $POLYSIM_API_KEY" \ $POLYSIM_BASE_URL/v1/health ``` Expected response: ```json theme={null} {"status": "ok", "timestamp": "2026-03-02T12:00:00Z", "version": "1.0.0"} ``` ```bash theme={null} curl -H "X-API-Key: $POLYSIM_API_KEY" \ "$POLYSIM_BASE_URL/v1/markets?hot_only=true&limit=5" ``` This returns actively traded markets with live prices from Polymarket. **Market orders require `price` as a worst-price limit** — Polymarket-faithful slippage protection. BUY won't fill above this price; SELL won't fill below it. Use the current best ask + a small buffer (e.g. ask × 1.05) to allow normal fills. ```bash cURL theme={null} curl -X POST $POLYSIM_BASE_URL/v1/orders \ -H "X-API-Key: $POLYSIM_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "market_id": "0xabc123...", "side": "BUY", "outcome": "Yes", "quantity": "10", "order_type": "market", "price": "0.70" }' ``` ```python Python theme={null} import requests, os resp = requests.post( f"{os.environ['POLYSIM_BASE_URL']}/v1/orders", headers={ "X-API-Key": os.environ["POLYSIM_API_KEY"], "Content-Type": "application/json", }, json={ "market_id": "0xabc123...", "side": "BUY", "outcome": "Yes", "quantity": "10", "order_type": "market", "price": "0.70", # worst-price limit (slippage cap) }, ) print(resp.json()) ``` ```javascript JavaScript theme={null} const resp = await fetch(`${process.env.POLYSIM_BASE_URL}/v1/orders`, { method: "POST", headers: { "X-API-Key": process.env.POLYSIM_API_KEY, "Content-Type": "application/json", }, body: JSON.stringify({ market_id: "0xabc123...", side: "BUY", outcome: "Yes", quantity: "10", order_type: "market", price: "0.70", // worst-price limit (slippage cap) }), }); console.log(await resp.json()); ``` Response: ```json theme={null} { "order_id": 42, "status": "FILLED", "order_type": "market", "side": "BUY", "outcome": "Yes", "price": "0.65", "quantity": "10", "notional": "6.50", "fee": "0.09", "slippage_bps": 15, "account_balance": "9993.41" } ``` `account_balance` is your **API wallet** balance after the fill, not the dashboard MAIN wallet. API keys start at $10,000 (Pro) or $25,000 (Pro+); Free-tier keys are read-only with no API wallet. Here a $6.50 fill plus the $0.09 taker fee (PM-V2 per-category schedule — see [Trading Fees](/trading/fees)) against the $10,000 Pro wallet leaves $9,993.41. ```bash theme={null} curl -H "X-API-Key: $POLYSIM_API_KEY" \ $POLYSIM_BASE_URL/v1/account/portfolio ``` **All numeric values are strings** (`"10"`, not `10`). This prevents floating-point precision loss — critical for financial applications. See [String Numerics](/concepts/string-numerics) for details. *** ## What's Next? * [Authentication deep dive](/authentication) — Key management, security, permissions * [Rate Limits](/concepts/rate-limits) — Understand your tier's request budget * [Build a trading bot](/bots/example-trading-bot) — Complete Python example # Batch Orders Source: https://docs.polysimulator.com/trading/batch-orders Place multiple orders in a single request to reduce latency and rate limit usage. # Batch Orders ``` POST /v1/orders/batch ``` Place up to N orders in a single request. Each order is processed independently — partial failures are reported per-order. *** ## Batch Size Limits | Tier | Max Batch Size | | ------------ | :------------: | | `free` | 1 | | `pro` | 5 | | `pro_plus` | 10 | | `enterprise` | 25 | Authoritative on the wire via `GET /v1/keys/tiers` — `max_batch_size` field. See [Rate Limits](/concepts/rate-limits) for the full per-tier matrix. *** ## Request ```bash theme={null} curl -X POST https://api.polysimulator.com/v1/orders/batch \ -H "X-API-Key: $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "orders": [ { "market_id": "0xabc...", "side": "BUY", "outcome": "Yes", "quantity": "5", "order_type": "market", "price": "0.99" }, { "market_id": "0xdef...", "side": "BUY", "outcome": "No", "quantity": "3", "order_type": "market", "price": "0.99" }, { "market_id": "0xghi...", "side": "BUY", "outcome": "Yes", "quantity": "10", "order_type": "limit", "price": "0.45", "time_in_force": "GTC" } ] }' ``` Each order in the array follows the same schema as `POST /v1/orders`. **Market orders inside a batch still require `price`** — it's the worst-price slippage cap (Polymarket-faithful). Without it the entry is rejected with `Market orders require a 'price' field`. For BUY, set the maximum you'll pay; for SELL, the minimum you'll accept. See [Placing Orders](/trading/placing-orders) for the full slippage-cap rationale. *** ## Response ```json theme={null} { "results": [ { "order_id": 42, "status": "FILLED", "price": "0.65", "quantity": "5", "notional": "3.25" }, { "order_id": 43, "status": "FILLED", "price": "0.40", "quantity": "3", "notional": "1.20" }, { "order_id": 0, "status": "REJECTED", "error": "INSUFFICIENT_BALANCE", "error_message": "Insufficient balance for BUY order" } ], "succeeded": 2, "failed": 1 } ``` **Partial failures don't roll back successful orders.** Each order is independent — some may succeed while others fail. Always check the `succeeded` and `failed` counts. ### Error Response Format Every entry in `results` is a full `OrderResponse`. A failed entry has `status="REJECTED"` (a per-entry validation/business rejection) or `status="ERROR"` (an internal error processing that entry), with two clean machine-readable fields: * **`error`** — the stable machine code (e.g. `INSUFFICIENT_BALANCE`, `MARKET_CLOSED`, `INVALID_QUANTITY`), matching the single-order error envelope byte-for-byte. See the [canonical trading error-code table](/bots/error-handling#common-error-codes). * **`error_message`** — the human-readable prose for that failure. Each failed entry in `results` has this shape: ```json theme={null} { "order_id": 0, "status": "REJECTED", "order_type": "market", "side": "BUY", "outcome": "Yes", "price": "0", "quantity": "1", "notional": "0", "fee": "0", "client_order_id": "bot-123", "error": "INSUFFICIENT_BALANCE", "error_message": "Insufficient balance for BUY order" } ``` Branch on the `error` code; show `error_message` to humans. (Earlier drafts showed `error` as a stringified Python-repr of the detail dict — that behaviour was removed; `error` is now a clean code and `error_message` carries the prose.) A batch request itself returns **HTTP 200** even when individual orders fail — iterate `results` to check per-order `status`. Request-level failures still use HTTP status codes: auth, rate limit, malformed JSON, **and an oversize batch** (see below). #### Oversize batch → request-level 400 If `orders` exceeds your tier's max batch size, the **whole request** is rejected with `400 BATCH_LIMIT_EXCEEDED` — no `results` array, no per-entry processing. Check `GET /v1/keys/tiers` (`max_batch_size`) and split large batches client-side. ```json theme={null} // 400 BATCH_LIMIT_EXCEEDED {"error": "BATCH_LIMIT_EXCEEDED", "message": "Batch size 12 exceeds tier limit (10)"} ``` *** ## Use Cases Buy underweight positions and sell overweight positions in a single request. Place a ladder of limit orders at multiple price levels simultaneously. Buy small positions across multiple undervalued markets at once. 1 batch request counts as 1 API call vs N individual calls. *** ## Next Steps * [Slippage Protection](/trading/slippage-protection) — Control fill quality * [Placing Orders](/trading/placing-orders) — Single order reference # Trade Execution Internals Source: https://docs.polysimulator.com/trading/execution-internals How PolySimulator's execution engine determines fill prices — transparent, reproducible, and auditable. # Trade Execution Internals PolySimulator aims to replicate Polymarket's execution semantics as closely as possible while providing instant fills for paper trading. This document describes exactly how fill prices are determined. ## Execution Priority Cascade When you submit a trade, the execution engine evaluates price sources in this order: ``` 1. Redis Order Book Walk (VWAP) ↓ if unavailable or sanity check fails 2. Best Bid/Ask from Order Book ↓ if unavailable or sanity check fails 3. CLOB Midpoint Cache ↓ if unavailable 4. Cached Outcome Price (Gamma API / SSE) ``` Each layer has sanity guards that reject fills diverging too far from the cached display price. The threshold is **relative and market-kind-aware**: 15% for ordinary markets, but wider for fast binary Up/Down markets that legitimately swing near resolution (25% for hourly/daily Up/Down, 30% for 15-minute, 50% for 5-minute). If all layers fail, the trade is rejected with `Price unavailable for market`. *** ## Layer 1: Order Book Walk (VWAP) For size-aware execution, the engine walks the order book to compute a Volume-Weighted Average Price (VWAP). ### How It Works In every case the walk consumes the **best price first**, then steps to the next-best level until your quantity is filled: | Side | Book Side Walked | Best price | Walk order (best → worst) | | ---- | ---------------- | ----------- | ------------------------- | | BUY | Asks (offers) | Lowest ask | Ascending price | | SELL | Bids | Highest bid | Descending price | The engine accumulates fills level-by-level until the requested quantity is satisfied: ```python theme={null} remaining = quantity total_cost = 0.0 for (price, size) in sorted_levels: fill_at_level = min(remaining, size) total_cost += fill_at_level * price remaining -= fill_at_level if remaining <= 0: break vwap = total_cost / quantity ``` ### Complement-Aware Execution PolySimulator replicates Polymarket's dual-book matching. In binary markets, you can fill a BUY by: 1. **Buying the primary token** from its ask side, OR 2. **Selling the complementary token** from its bid side The effective price conversion: * `effective_ask = 1 - complementary_bid` * `effective_bid = 1 - complementary_ask` Both books are merged and sorted before walking. This prevents thin primary books from producing absurd fills when the complement has better liquidity. ### Sanity Guards The book walk is rejected if the VWAP falls outside a **symmetric relative band** around the cached display price, `cached × [1 − t, 1 + t]`, where `t` is the market-kind-aware threshold (default 0.15; wider for Up/Down — see above). Both sides use the same band: | Side | Rejection Condition | | ---- | ------------------------------------------------------ | | BUY | `vwap > cached × (1 + t)` OR `vwap < cached × (1 − t)` | | SELL | `vwap > cached × (1 + t)` OR `vwap < cached × (1 − t)` | (An earlier revision mixed a multiplicative upper bound with an absolute `cached − 0.15` lower bound; that asymmetric form was replaced because the absolute term effectively no-op'd the guard for low-priced outcomes. The guard is now purely relative so it scales correctly across `[0.01, 0.99]`.) *** ## Layer 2: Best Bid/Ask If the order book walk is unavailable, the engine uses the top-of-book prices: | Side | Price Used | | ---- | ----------------------- | | BUY | Best Ask (lowest offer) | | SELL | Best Bid (highest bid) | ### Complement Merging The best bid/ask is computed from both the primary and complementary order books: ```python theme={null} # Effective best ask = min(primary_ask, 1 - complementary_bid) # Effective best bid = max(primary_bid, 1 - complementary_ask) ``` ### Sanity Guards If the selected fill price diverges beyond the same relative threshold from the cached outcome price (15% default; wider for Up/Down): 1. First, try the primary-only best bid/ask (ignoring complement) 2. If that also diverges, **reject the fill** and fall through to the next layer This prevents the complement-book logic from accidentally inverting fills on high-probability outcomes. *** ## Layer 3: CLOB Midpoint Cache The engine caches the latest CLOB midpoint for each token. The midpoint is `(best_bid + best_ask) / 2` from Polymarket's live order book, refreshed every 30 seconds by the price poller. **Latency**: Sub-millisecond. *** ## Layer 4: Cached Outcome Price The final fallback uses the cached display price from the Gamma API or SSE price feed. This is the price visible in the UI. ### Label Matching The engine matches the requested outcome label (`"Yes"`, `"Up"`, `"Down"`, etc.) against the market's outcome array: ```python theme={null} for outcome in market.outcomes: if outcome.label.lower() == requested_outcome.lower(): return outcome.price ``` This ensures label → price mapping is correct even when the Gamma API's `buy`/`sell` fields don't correspond positionally to `outcomes[0]`/`outcomes[1]`. ### Fallback Chain If direct label matching fails: 1. Try conventional aliases (`yes`/`no` → first/second outcome) 2. Compute midpoint from `best_bid` + `best_ask` fields 3. Use `last_trade` price if spread is wide (>10%) 4. Average `yes_price` + `no_price` *** ## Limit Order Enforcement For limit orders, the fill price is validated against your specified limit: | Side | Condition for Fill | | ---- | -------------------------- | | BUY | `fill_price ≤ limit_price` | | SELL | `fill_price ≥ limit_price` | **FOK Semantics**: If the market price has moved past your limit, the order is rejected outright with `FOK_ORDER_NOT_FILLED_ERROR`. Unlike some brokers, PolySimulator does NOT cap your fill at the limit price. This matches Polymarket's IOC/FOK behavior. *** ## Price Source Tracking Every fill records which price source was used: | Source | Meaning | | ---------------------------- | -------------------------- | | `book_walk` | VWAP from order book walk | | `best_ask` | Top-of-book ask (BUY) | | `best_bid` | Top-of-book bid (SELL) | | `clob_midpoint_cached` | Redis CLOB midpoint | | `outcome_X` | Cached price for outcome X | | `midpoint` | bid/ask midpoint fallback | | `last_trade_spread_fallback` | Last trade (wide spread) | This is returned in the order response and stored in the `orders` table for audit. *** ## Spread & Impact Metrics Each fill computes: | Metric | Formula | | ------------ | ------------------------------------------- | | `spread_bps` | `(best_ask - best_bid) / midpoint × 10,000` | | `impact_bps` | How much worse your fill was vs best price | These are available in the order response and help you understand execution quality. *** ## Market Validation Before execution, the engine validates: 1. **Not closed**: `closed=false` 2. **Active**: `active=true` 3. **Not resolved**: `resolved_outcome` is null 4. **Not expired**: `end_date` is in the future (or SELL for emergency exit) 5. **Price sanity**: Outcome prices don't sum > 1.5 (post-expiry detection) **Emergency Exits**: When selling a position on an expired market with no live price, the engine allows a break-even exit at your entry price rather than blocking the trade. *** ## Idempotency Every trade requires a `client_order_id` (or `Idempotency-Key` header). Duplicate submissions return the original order without re-executing. This is critical for: * Retry-safe bot execution * Preventing accidental double-fills on network timeouts * Audit trail integrity *** ## Example Execution Flow ```mermaid theme={null} flowchart TD A[POST /v1/orders] --> B{Validate Market} B -->|Closed/Resolved| X[Reject 400] B -->|Valid| C{Order Book Walk} C -->|VWAP computed| D{Sanity Check} D -->|Within threshold| E[Fill at VWAP] D -->|Divergent| F{Best Bid/Ask} F -->|Available| G{Sanity Check} G -->|OK| H[Fill at Top-of-Book] G -->|Divergent| I{CLOB Midpoint} I -->|Cached| J[Fill at Midpoint] I -->|Miss| K{Outcome Price} K -->|Available| L[Fill at Cached Price] K -->|None| X E --> M{Limit Order?} H --> M J --> M L --> M M -->|No limit or within| N[Execute Trade] M -->|Beyond limit| Y[Reject FOK_ORDER_NOT_FILLED] N --> O[Create Order + Ledger Entry] ``` # Trading Fees Source: https://docs.polysimulator.com/trading/fees PolySimulator charges Polymarket's exact V2 per-category taker fee schedule on every taker fill — same formula, same rates, makers pay zero. # Trading Fees PolySimulator mirrors **Polymarket's V2 taker fee schedule exactly**. Fees are simulated money (this is paper trading), but the *economics* are real: every taker fill is debited the same fee a live Polymarket fill would pay, so a strategy that is fee-unprofitable here is fee-unprofitable there. Fees are **not zero**. A bot that models zero trading costs will compute wrong PnL on every non-geopolitics market. Always read the `fee` field returned on fills (`OrderResponse.fee`, `/v1/account/history`). *** ## Fee Formula ``` fee = C × feeRate × p × (1 − p) ``` | Symbol | Meaning | | --------- | ---------------------------------------------- | | `C` | Number of shares filled | | `feeRate` | The market category's taker rate (table below) | | `p` | Fill price (0–1) | The `p × (1 − p)` factor makes the fee symmetric around $0.50 — it peaks there and shrinks toward the price extremes, so a fill at $0.99 pays a tiny fraction of what the same share count pays at \$0.50. Fees are rounded to 5 decimal places; the smallest charged fee is 0.00001 USD (amounts below that round to zero). The amount actually debited from your balance is settled at cent precision. **Worked example** — 10-share BUY at \$0.65 on a crypto market: ``` fee = 10 × 0.07 × 0.65 × 0.35 = 0.15925 USD ``` *** ## Category Rates The schedule mirrors [Polymarket's published fee table](https://docs.polymarket.com/trading/fees): | Category | Taker fee rate | `fee_rate_bps` | | ------------------------------------- | :------------------------: | :------------: | | Crypto | 7% | 700 | | Economics / Culture / Weather / Other | 5% | 500 | | Finance / Politics / Mentions / Tech | 4% | 400 | | Sports | 3% | 300 | | Geopolitics | 0% | 0 | | Unknown / missing category | 5% (conservative fallback) | 500 | Discover a market's rate programmatically: ```bash theme={null} curl "https://api.polysimulator.com/v1/fee-rate?token_id=7132104567925221..." # crypto market → {"base_fee": 1000, "fee_rate_bps": 700} # geopolitics market → {"base_fee": 0, "fee_rate_bps": 0} ``` The response is a **two-field contract**: * `base_fee` mirrors **Polymarket's legacy base-fee parameter** (observed live: `1000` on fee-charging markets — sports, politics and crypto alike — and `0` on fee-free markets). It tells you *whether* fees are enabled, not the rate. Ported bots that read the PM field see byte-compatible PM behavior. * `fee_rate_bps` is the PolySimulator extra field carrying the **effective per-category taker rate actually charged**, in basis points (the table above). Use this one for fee math. `token_id` is required, and errors mirror Polymarket's live responses verbatim: a missing or malformed `token_id` returns `400 {"error": "Invalid token id"}`; a well-formed token that resolves to no synced market returns `404 {"error": "fee rate not found for market"}`. *** ## Makers Pay Zero — Classified by Marketability Matching Polymarket V2, **takers pay the fee and makers pay nothing**: * **Market orders, FOK, FAK/IOC** — always taker. * **GTC limit orders that are marketable at placement** (a BUY at/above the live ask, or a SELL at/below the live bid) — **taker**. The order crosses the book the moment you place it; the \~1-second matching-loop latency doesn't make it a maker. Swapping FOK market orders for marketable GTC limits does *not* dodge the fee. * **GTC limit orders that genuinely rest** (placed away from the touch and filled later when the market moves into them) — **maker, zero fee**. In addition to the marketability check at placement, any GTC fill that happens **within \~3 seconds of the order's creation** is classified as a taker fill (the window is operator-tunable). An order that fills that fast was effectively marketable when you placed it — the matching loop simply took a cycle or two to get to it — so the few-second latency cannot be used to dodge the fee. Orders that rest longer than the window before filling keep maker (zero-fee) treatment. Partial fills follow Polymarket's split treatment: the slice that crosses the book when your marketable order arrives pays the taker fee, while later fills of the resting remainder are maker (zero-fee). `fee_rate_bps` on `/v1/data/trades` rows reports the rate actually applied to each fill: the category rate in bps for taker fills, `0` for fee-free fills (maker fills, geopolitics markets, emergency exits). *** ## Fee-Free Fills | Case | Why | | ----------------------------------- | ----------------------------------------------------------------------------------------------------- | | Maker fills (genuinely resting GTC) | Matches Polymarket V2 — makers never pay | | Geopolitics markets | 0% category rate on Polymarket's schedule | | Emergency-exit fills | Break-even safety-valve exits on stuck/expired markets — charging would turn the recovery into a loss | *** ## Documented Divergences from Polymarket | Polymarket | PolySimulator | Why | | --------------------------------------------------------------------------- | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | | Maker **rebate** (25% of taker fees; 20% on crypto) redistributed to makers | **No rebate** — makers simply pay zero | The rebate is PM's redistribution of real collected fees; the simulator has no fee pool to fund it from | | Per-market `feesEnabled` flag — fees only charged on fee-enabled markets | Category schedule applied to **all** markets | The simulator doesn't sync PM's per-market fee flags (yet); the per-category schedule is applied uniformly | *** ## Next Steps * [Placing Orders](/trading/placing-orders) — fee field on fill responses * [Slippage Protection](/trading/slippage-protection#fees) — fees in the execution model * [Trade History](/account/trade-history) — auditing debited fees per fill # Heartbeats (Dead-Man's-Switch) Source: https://docs.polysimulator.com/trading/heartbeats Auto-cancel resting orders if your bot stops sending heartbeats — protection against unattended crashes. # Heartbeats — Dead-Man's-Switch If your trading bot crashes between placing orders and the next intended cancel, those orders sit on the book exposed to adverse selection until you notice and intervene. The **heartbeat dead-man's-switch** protects unattended bots by auto-cancelling all of your resting orders if the server stops receiving heartbeats for longer than a deadline you choose. This mirrors Polymarket's `POST /heartbeats` **dead-man's-switch behaviour** — the same path, the same `interval_ms`-style cadence, and the same "miss a heartbeat → all your resting orders are auto-cancelled" guarantee. A PM SDK's heartbeat loop keeps your orders protected when pointed at PolySimulator. **The 200 response body differs from Polymarket** — it is **not** wire-identical. PolySimulator returns `{"ok": true, "expires_at_ms": }`, whereas Polymarket's `/heartbeats` returns a `{"status": }` body (and its newer SDKs thread a `heartbeat_id` through each call). If your bot reads PM's `status` / `heartbeat_id` field off the response, it will see `None` / a `KeyError` against PolySimulator — read `ok` / `expires_at_ms` instead. The dead-man's-switch fires on the **absence** of a heartbeat, so a bot that ignores the response body entirely (just keeps pinging on a timer) is fully protected on both platforms. *** ## Endpoints PolySimulator exposes **two paths** that route through the same handler: | Path | Notes | | --------------------- | ------------------------------------------------------------------------------------- | | `POST /heartbeats` | Polymarket-shape root path (no `/v1/`) — for SDKs ported from PM without URL rewrite. | | `POST /v1/heartbeats` | PolySimulator canonical `/v1/` alias — preferred for new code. | Both accept the same body and emit the same response. *** ## Request ```json theme={null} { "interval_ms": 5000, "client_label": "alpha-bot" } ``` | Field | Type | Required | Notes | | -------------- | ------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `interval_ms` | int | Yes | Deadline between heartbeats, in milliseconds. Bounded `[1000, 60000]`. | | `client_label` | string | No | Free-form label so a single API key can register multiple independent heartbeats from different bot processes. Defaults to `""` (single-stream). Max 64 chars. | ## Response — `200 OK` ```json theme={null} { "ok": true, "expires_at_ms": 1715518800123 } ``` | Field | Type | Notes | | --------------- | ---- | -------------------------------------------------------------------------------------------------------- | | `ok` | bool | Always `true` on a successful registration / refresh. | | `expires_at_ms` | int | Wall-clock (Unix milliseconds) at which the dead-man's-switch will fire if no further heartbeat arrives. | The `expires_at_ms` value is `last_heartbeat_at_ms + interval_ms + grace`, where: * `grace = max(1000ms, 0.25 × interval_ms)` — absorbs network jitter and the 1-second sweeper tick so bots pinging at exact intervals don't trigger spurious cancels. *** ## How to use it (the heartbeat loop) The expected pattern is to ping at **half your `interval_ms`** so you stay comfortably ahead of expiry even with one missed beat. ```python Python theme={null} import asyncio import httpx API_KEY = "ps_live_..." INTERVAL_MS = 5000 # 5-second deadline BEAT_EVERY = INTERVAL_MS / 2 / 1000 # ping every 2.5s async def heartbeat_loop(): async with httpx.AsyncClient( base_url="https://api.polysimulator.com", headers={"X-API-Key": API_KEY}, ) as client: while True: try: resp = await client.post( "/v1/heartbeats", json={"interval_ms": INTERVAL_MS}, ) resp.raise_for_status() except Exception as e: # NOTE: if the heartbeat call itself fails (network blip # or a 5xx), DON'T treat that as "bot is dead" — the # server-side switch fires only on absence, not error. # Just log and try again on the next tick. print(f"heartbeat failed: {e!r}") await asyncio.sleep(BEAT_EVERY) asyncio.run(heartbeat_loop()) ``` ```bash curl theme={null} # One-shot — refreshes the heartbeat. Call this on a 2.5-second timer # (cron / systemd / your own scheduler) for a 5-second deadline. curl -X POST https://api.polysimulator.com/v1/heartbeats \ -H "X-API-Key: ${API_KEY}" \ -H "Content-Type: application/json" \ -d '{"interval_ms": 5000}' ``` ```javascript JavaScript theme={null} const API_KEY = "ps_live_..."; const INTERVAL_MS = 5000; setInterval(async () => { try { const r = await fetch("https://api.polysimulator.com/v1/heartbeats", { method: "POST", headers: { "X-API-Key": API_KEY, "Content-Type": "application/json", }, body: JSON.stringify({ interval_ms: INTERVAL_MS }), }); if (!r.ok) console.warn("heartbeat failed:", r.status); } catch (e) { console.warn("heartbeat error:", e); } }, INTERVAL_MS / 2); ``` *** ## What happens when a heartbeat is missed? A background sweeper runs every \~1 second. When it finds a registration whose `expires_at_ms` is in the past, it: 1. Removes the registration from the Redis registry. 2. Cancels **all** pending limit orders for the API key's account (same logic as `POST /v1/cancel-all` — refunds BUY notional, returns SELL shares to position). 3. Logs a structured warning: `heartbeat: dead-man's-switch triggered for user_id=... tier=... — cancelled N orders`. 4. Increments `polysim_heartbeat_dead_mans_switch_triggered_total{tier=...}` once per registration that fired (the metric counts registrations, not individual orders). To resume, the bot just calls `POST /v1/heartbeats` again — a new registration is created from scratch. The dead-man's-switch cancels every resting order for the API key's account, including orders placed by other processes sharing the same key. If you run multiple bot strategies on one key, use `client_label` to register independent heartbeats — but be aware that the cancel-all still cancels EVERY pending order, not just the labelled subset. Use distinct API keys per strategy if you need strategy-level isolation. *** ## Bounds and error responses | Field | Bound | Out-of-range response | | -------------- | --------------- | --------------------------------------------------------- | | `interval_ms` | `[1000, 60000]` | `422 Unprocessable Entity` (Pydantic validation envelope) | | `client_label` | `≤ 64` chars | `422 Unprocessable Entity` | The `[1000, 60000]` bound is deliberate: * **Below 1000ms** would burn rate-limit quota (2 RPS per registration just for heartbeats) with no safety benefit beyond what 1-second sweeps already provide. * **Above 60000ms** defeats the point — a crashed bot would stay exposed for a full minute before its orders cancel. *** ## Storage Heartbeat registrations live in **Redis**: * A sorted set `heartbeats:expiry` indexes every registration by its `expires_at_ms`. The sweeper scans this set every second with `ZRANGEBYSCORE 0 now` to find expired registrations. * A per-registration hash `heartbeats:reg:{api_key_id}:{client_label}` holds the metadata (`user_id`, `interval_ms`, `last_heartbeat_at_ms`, `tier`) needed by the sweeper to invoke cancel-all. Both writes happen atomically in a single pipeline so a sweep can't see half-written state. ### Durability across api-worker restarts State is in Redis, so restarting the api process (deploy, OOM, graceful reload) preserves every active heartbeat. A bot's next refresh after the restart simply bumps the expiry forward — no need to re-register from scratch. ### Multi-worker correctness In a multi-worker deployment (the production topology), a refresh landing on worker A and a sweep running on worker B are coordinated via Redis: * The sweeper acquires a 60-second leader lock; only one worker sweeps at any time. A crash on the leader promotes a follower within at most 60 s without manual intervention. * The sweep itself uses `WATCH` / `MULTI` around `ZREM` so a refresh that lands between the candidate scan and the claim aborts the transaction and keeps the bot alive — no false dead-man fires from race conditions across workers. *** ## Observability | Metric | Type | Labels | Notes | | ---------------------------------------------------- | ------- | ------ | --------------------------------------------------------------------------- | | `polysim_heartbeat_dead_mans_switch_triggered_total` | Counter | `tier` | Incremented once per timed-out registration (NOT once per cancelled order). | There's also a structured warning log on every fire — search for `heartbeat: dead-man's-switch triggered` in your logs. *** ## Related * [Cancel All Orders (`POST /v1/cancel-all`)](/trading/order-management#cancel-all-orders) — Manual cancel-all that the dead-man's-switch delegates to internally. * [Polymarket Raw HTTP](/concepts/pm-raw-http) — Other PM-shape compat endpoints. * [Error Handling](/bots/error-handling) — Bot resilience patterns including heartbeat-failure recovery. # Order Management Source: https://docs.polysimulator.com/trading/order-management List, filter, and cancel pending orders with pagination. # Order Management Query your order history and cancel pending limit orders. *** ## Order Endpoints Summary PolySimulator offers two order placement endpoints. Use the one that fits your workflow: | Endpoint | Best For | Time-in-force Field | Supported Values | | --------------------- | ----------------------- | ------------------- | ---------------------------------------------------------- | | `POST /v1/orders` | New bots, full features | `time_in_force` | `GTC` (default), `FOK`, `IOC`, `FAK` (normalised to `IOC`) | | `POST /v1/clob/order` | Polymarket migration | `order_type` | `GTC`, `FOK`, `GTD` | **Key difference**: The native `/v1/orders` endpoint uses `order_type` for market/limit and `time_in_force` for GTC/FOK/IOC. It also accepts `FAK` (Polymarket's term for IOC) and normalises it to `IOC` — so a listed order may show `time_in_force: "IOC"` even though you sent `FAK`. The CLOB-compatible endpoint uses `order_type` for the time-in-force policy (matching Polymarket's schema). See [CLOB Compatibility](/concepts/clob-compatibility) for the CLOB endpoint schema. **`POST /v1/clob/order` does not support every PM order type.** `order_type=IOC` is **rejected with `400 UNSUPPORTED_ORDER_TYPE`** (the engine only enforces FOK worst-price semantics; routing IOC through it would silently behave like FOK). `order_type=GTD` is accepted but **treated as GTC** until the PM-faithful semantics rollout activates — there is no per-order date expiry yet, so manage your own deadline and cancel explicitly when it lapses. Use `GTC` (rests on the book) or `FOK` (immediate-or-fail) for predictable behaviour. **Rolling out: real GTD + post-only.** A flag-gated engine upgrade is rolling out (see [Placing Orders](/trading/placing-orders) for the full list). Once active: `order_type=GTD` on `/v1/clob/order` (and `time_in_force=GTD` on `/v1/orders`) becomes a true Good-Til-Date — the order carries a unix-seconds `expiration`, the matching engine skips and auto-cancels it once the timestamp passes (the cancelled row carries `cancelled_reason: "gtd_expired"`), and an expiration in the past is rejected at placement with `INVALID_ORDER_EXPIRATION`. Both endpoints also gain Polymarket's `post_only` option (reject-if-marketable). Watch the [changelog](/changelog) for activation. *** ## List Orders ``` GET /v1/orders ``` Returns orders from two internal data sources merged into a single stream: * **Pending limit orders** — unfilled GTC orders stored in the `pending_orders` table. * **Filled / cancelled orders** — executed trades from the `orders` table. Both types share the same `OrderItem` response shape. Supports offset and cursor-based pagination. ### Query Parameters | Parameter | Type | Default | Description | | ----------- | ------ | ------- | ------------------------------------------------------------- | | `status` | string | — | Filter: `PENDING`, `FILLED`, `CANCELLED`, `EXPIRED` | | `market_id` | string | — | Filter by condition\_id | | `side` | string | — | Filter: `BUY`, `SELL` | | `limit` | int | 50 | Max results (1–200) | | `offset` | int | 0 | Pagination offset (ignored when `cursor` is set) | | `cursor` | string | — | ISO-8601 timestamp for cursor pagination (preferred for bots) | ### Response Fields | Field | Type | Description | | ----------------- | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `order_id` | int | Unique order identifier | | `market_id` | string | Market condition\_id | | `side` | string | `BUY` or `SELL` | | `outcome` | string | Outcome label (e.g. `Yes`, `No`) | | `order_type` | string | `market` or `limit` | | `limit_price` | string \| null | Limit order price (null for market orders) | | `quantity` | string | Order size in shares | | `time_in_force` | string | `GTC`, `FOK`, or `IOC`. A `FAK` order is normalised server-side, so it surfaces here as `IOC`. With the PM-semantics rollout, `GTD` also appears here for date-expiring resting limits. | | `status` | string | `PENDING`, `FILLED`, `CANCELLED`, or `EXPIRED` (PolySimulator-native enum). See the [order-status table](/bots/error-handling#order-status-values). PM-SDK ports read the `ORDER_STATUS_*` enum from `GET /v1/data/orders`. | | `client_order_id` | string \| null | Your idempotency key | | `created_at` | string | ISO-8601 creation timestamp | | `filled_at` | string \| null | ISO-8601 fill timestamp (null if unfilled) | | `fill_price` | string \| null | Execution price (null if unfilled) | | `cancelled_at` | string \| null | ISO-8601 cancellation timestamp | ### Examples ```bash Offset Pagination theme={null} curl -H "X-API-Key: $API_KEY" \ "https://api.polysimulator.com/v1/orders?status=PENDING&limit=20&offset=0" ``` ```bash Cursor Pagination theme={null} # Use next_cursor from the previous response — since 2026-06-11 it is # urlsafe-base64 (URL-safe by construction; no percent-encoding needed) curl -H "X-API-Key: $API_KEY" \ "https://api.polysimulator.com/v1/orders?limit=20&cursor=MjAyNi0wMi0wNlQxMjowMDowMCswMDowMA" ``` ```python Python (paginate all) theme={null} import requests API_KEY = "ps_live_..." BASE = "https://api.polysimulator.com/v1" headers = {"X-API-Key": API_KEY} all_orders = [] cursor = None while True: params = {"limit": 50} if cursor: params["cursor"] = cursor resp = requests.get(f"{BASE}/orders", headers=headers, params=params) data = resp.json() all_orders.extend(data["orders"]) if not data["has_more"]: break cursor = data["next_cursor"] print(f"Fetched {len(all_orders)} orders") ``` ### Response ```json theme={null} { "orders": [ { "order_id": 15, "market_id": "0x1a2b3c...", "side": "BUY", "outcome": "Yes", "order_type": "limit", "limit_price": "0.60", "quantity": "10.0", "time_in_force": "GTC", "status": "PENDING", "client_order_id": "limit-001", "created_at": "2026-02-06T12:00:00+00:00", "filled_at": null, "fill_price": null, "cancelled_at": null } ], "next_cursor": "MjAyNi0wMi0wNlQxMTo1NTowMCswMDowMA", "has_more": true, "total_hint": 137 } ``` The envelope is `{orders, has_more, next_cursor, total_hint}`. `total_hint` is an approximate count of matching orders for progress display — treat it as a hint, not an exact total, and rely on `has_more` / `next_cursor` to iterate. **Use cursor-based pagination for bots.** Treat the cursor as **opaque** (since 2026-06-11 it is urlsafe-base64 wrapping the last item's `created_at` — every character is URL-safe, so naive string interpolation round-trips correctly; the legacy raw-ISO form is still accepted on the way in). Cursoring avoids page drift when new orders arrive between requests. Pass the `next_cursor` value from the previous response back as the `cursor` (or `next_cursor`) parameter. *** ## Cancel Order ``` DELETE /v1/orders/{order_id} ``` Cancel a pending limit order. Only `PENDING` orders can be cancelled. ```bash theme={null} curl -X DELETE -H "X-API-Key: $API_KEY" \ https://api.polysimulator.com/v1/orders/42 ``` ### Response ```json theme={null} { "order_id": 42, "status": "CANCELLED", "order_type": "limit", "side": "BUY", "outcome": "Yes", "price": "0.60", "quantity": "10.0", "notional": "6.00", "fee": "0" } ``` **Cancellation returns reserved funds:** * **BUY orders**: Reserved cash is returned to your balance * **SELL orders**: Reserved shares are returned to your position *** ## Cancel All Orders ``` POST /v1/cancel-all DELETE /v1/cancel-all (deprecated alias) ``` Cancel **every** pending limit order for your account in one call. `POST` is the canonical verb; `DELETE` is a back-compat alias kept live for SDKs that adopted the earlier shape (it returns `X-Deprecation` / `Sunset` headers). **Confirmation is required** to prevent accidental wipeouts. Pass either `?confirm=true` (query parameter) **or** the `X-Confirm-Cancel-All: true` header. Without one, the call is rejected with `400 CONFIRMATION_REQUIRED` and no orders are touched. To cancel a single order use `DELETE /v1/orders/{order_id}`; to cancel by market use `DELETE /v1/cancel-market-orders`. ```bash theme={null} curl -X POST "https://api.polysimulator.com/v1/cancel-all?confirm=true" \ -H "X-API-Key: $API_KEY" ``` The response matches Polymarket's cancel shape — `canceled` is a list of order-id strings, `not_canceled` maps each skipped order-id to the reason: ```json theme={null} { "canceled": ["42", "43", "44"], "not_canceled": {} } ``` This is the same cancel-all logic the [heartbeat dead-man's-switch](/trading/heartbeats) delegates to when a bot stops sending heartbeats. *** ## Next Steps * [Batch Orders](/trading/batch-orders) — Place multiple orders at once * [Trade History](/account/trade-history) — View filled orders # Placing Orders Source: https://docs.polysimulator.com/trading/placing-orders Place market and limit orders with Polymarket-compatible execution and slippage protection. # Placing Orders ``` POST /v1/orders ``` Place a market or limit order. Requires `trade` permission. **Polymarket-compatible execution model**: On Polymarket, all orders are limit orders — "market orders" are just limit orders with FOK time-in-force at a marketable price. PolySimulator mirrors this exactly: market orders fill at the best available price (BUY at best ask, SELL at best bid), and the `price` field acts as a **required worst-price limit** for slippage protection. *** ## Market Orders Executed immediately at the best available price — **BUY at the best ask, SELL at the best bid** — matching how Polymarket fills market orders. ### Worst-Price Limit (Required) The `price` field is **required** on market orders and sets the worst price you'll accept. This is identical to Polymarket — there are no "blind" market orders. * **BUY**: the order won't fill above your `price` (you won't overpay) * **SELL**: the order won't fill below your `price` (you won't undersell) ```bash cURL theme={null} curl -X POST https://api.polysimulator.com/v1/orders \ -H "X-API-Key: $API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: my-bot-order-001" \ -d '{ "market_id": "0xabc123...", "side": "BUY", "outcome": "Yes", "quantity": "10", "order_type": "market", "price": "0.68" }' ``` ```python Python theme={null} resp = requests.post( f"{BASE_URL}/v1/orders", headers={"X-API-Key": API_KEY, "Idempotency-Key": "my-bot-order-001"}, json={ "market_id": "0xabc123...", "side": "BUY", "outcome": "Yes", "quantity": "10", "order_type": "market", "price": "0.68", # Won't fill above $0.68 }, ) order = resp.json() ``` ### FAK (Fill-and-Kill) Market Order `time_in_force: "FAK"` (alias `"IOC"`) is accepted. Engine behavior depends on the **PM-faithful order semantics** rollout (see the callout below): * **Legacy behavior (rollout off)**: FAK executes **atomically** — it fills your full quantity at the touch price or cancels entirely. * **Rolling out**: Polymarket-faithful partial fills — the engine walks the displayed order-book depth within your price limit, fills what's actually available (VWAP across levels), and cancels the remainder. A partial fill returns `status: "FILLED"` with `quantity` set to the filled slice and a `partial_fill:…` entry in `warnings` naming filled vs requested size. ```json theme={null} { "market_id": "0xabc123...", "side": "BUY", "outcome": "Yes", "quantity": "10", "order_type": "market", "price": "0.68", "time_in_force": "FAK" } ``` **Polymarket migration tip**: `FAK` and `IOC` are equivalent in PolySimulator. If your Polymarket bot uses FAK, it works here unchanged. If you omit `time_in_force`, market orders default to **FOK** (Fill-or-Kill) — the entire order fills or is cancelled. *** **Rolling out: PM-faithful order semantics.** A flag-gated engine upgrade is rolling out that brings seven behaviors in line with real Polymarket. While the rollout completes, behavior depends on the deployment flag — the legacy behavior is documented alongside each. Once fully live: 1. **FAK/IOC partial fills** — fill what's available within your limit against displayed depth (VWAP across levels), cancel the remainder. 2. **FOK depth-aware atomicity** — fill entirely iff displayed depth within your limit covers the full size, else kill cleanly (no more full-size fills at the touch price beyond displayed depth). One tolerance: a shortfall of **≤ 1 share** vs displayed depth is absorbed and the order fills in full at the walked VWAP — this smooths sub-share rounding on Polymarket's 1e6 token grid and matches the engine's general depth-walk dust rule. 3. **GTD (Good-Til-Date)** — resting limits carrying a unix-seconds `expiration`; auto-cancelled by the engine once the timestamp passes. Expired-at-placement is rejected with `INVALID_ORDER_EXPIRATION`. Auto-expiry is guaranteed for expirations up to **\~83 days** out; beyond that horizon the order rests like a GTC and you should cancel it explicitly (Polymarket itself accepts far-future GTDs, so we don't reject them). 4. **`post_only`** — guaranteed-maker orders: rejected with `INVALID_POST_ONLY_ORDER` if marketable at placement (GTC/GTD only; `INVALID_POST_ONLY_ORDER_TYPE` when combined with FOK/FAK/IOC/market). 5. **`min_order_size`** — the per-market minimum share count that `GET /v1/book` advertises is enforced at placement (`INVALID_ORDER_MIN_SIZE`, `X-Polysim-Code: ORDER_BELOW_MIN_SIZE`). 6. **Book-impact depletion** — limit-order fills consume the displayed liquidity they walk. Within one upstream order-book snapshot, size already consumed at a price level by limit executions (your fills or anyone else's) is not offered again; once the level is exhausted the walk moves to worse levels within your limit, partial-fills, or (FOK) kills. A fresh upstream snapshot restores the real displayed book. Market-order fills don't yet feed or read the overlay — wiring them in is a planned follow-up. *Legacy: the same displayed size could be bought repeatedly while the snapshot stayed cached.* Your simulated fills never alter the displayed book or midpoint — depletion is execution-side only. 7. **Resting maker orders fill at their limit price** — Polymarket's maker/taker rule: price improvement belongs to the taker. A resting GTC/GTD limit that the market later crosses executes at *your* limit price (BUY reserve = spend, zero refund delta). Marketable orders (FOK/FAK/IOC, and limits that cross immediately at placement) remain takers and keep their depth-walk price improvement. *Legacy: resting orders filled at the later touch/VWAP price — systematically better than the maker's own limit.* Watch the [changelog](/changelog) for the activation announcement. ## Limit Orders Queued and filled by the background matching engine (\~1s polling cycle). ### GTC (Good-Til-Cancel) Persists until filled, cancelled by the user, or the market closes. ```json GTC Limit Order theme={null} { "market_id": "0xabc123...", "side": "BUY", "outcome": "Yes", "quantity": "10", "order_type": "limit", "price": "0.55", "time_in_force": "GTC" } ``` ```python Python (GTC) theme={null} resp = requests.post( f"{BASE_URL}/v1/orders", headers={"X-API-Key": API_KEY}, json={ "market_id": "0xabc123...", "side": "BUY", "outcome": "Yes", "quantity": "10", "order_type": "limit", "price": "0.55", "time_in_force": "GTC", "client_order_id": "gtc-limit-001", }, ) order = resp.json() ``` ### FOK (Fill-or-Kill) All-or-nothing immediate fill — matches Polymarket's FOK order type. If the full quantity can't fill at the limit price, the entire order is cancelled. With the PM-faithful semantics rollout (see the callout above), FOK is **depth-aware**: the engine computes the fillable quantity within your limit across displayed book levels first, fills the whole order at the walked VWAP iff depth covers your size, and kills cleanly otherwise. (Legacy behavior filled the full size at the touch price whenever the top of book crossed.) ```json FOK Limit Order theme={null} { "market_id": "0xabc123...", "side": "BUY", "outcome": "Yes", "quantity": "10", "order_type": "limit", "price": "0.55", "time_in_force": "FOK" } ``` ```python Python (FOK) theme={null} resp = requests.post( f"{BASE_URL}/v1/orders", headers={"X-API-Key": API_KEY}, json={ "market_id": "0xabc123...", "side": "BUY", "outcome": "Yes", "quantity": "10", "order_type": "limit", "price": "0.55", "time_in_force": "FOK", "client_order_id": "fok-snipe-001", }, ) order = resp.json() if order["status"] == "CANCELLED": print("FOK order could not fill — cancelled") else: print(f"Filled at {order['price']}") ``` ### IOC (Immediate-or-Cancel) Evaluated **synchronously, in the request** against the cached order book — it fills immediately or is cancelled before the response returns; it never rests on the book or waits for the background matching cycle. (FOK behaves the same way. The \~1s polling cycle applies only to resting **GTC** limits.) ```json IOC Limit Order theme={null} { "market_id": "0xabc123...", "side": "BUY", "outcome": "Yes", "quantity": "10", "order_type": "limit", "price": "0.55", "time_in_force": "IOC" } ``` ```python Python (IOC) theme={null} resp = requests.post( f"{BASE_URL}/v1/orders", headers={"X-API-Key": API_KEY}, json={ "market_id": "0xabc123...", "side": "BUY", "outcome": "Yes", "quantity": "10", "order_type": "limit", "price": "0.55", "time_in_force": "IOC", "client_order_id": "ioc-snipe-001", }, ) order = resp.json() if order["status"] == "CANCELLED": print("IOC order was not matched — no fill") else: print(f"Filled at {order['price']}") ``` **When to use IOC**: Sniping a specific price level without the risk of stale orders sitting in the book. If the current market price is worse than your limit, the order cancels immediately. With the PM-faithful semantics rollout (see the callout above), IOC limit orders **partial-fill** like on real Polymarket: the available size within your limit fills at the depth-walk VWAP and the unfilled remainder is cancelled + refunded in the same request. (Legacy behavior was atomic full-fill-or-cancel.) **Fill conditions:** * **BUY limit**: Fills when market ask ≤ your limit price. Funds reserved upfront. * **SELL limit**: Fills when market bid ≥ your limit price. Shares reserved upfront. *** ## Request Fields | Field | Type | Required | Description | | ----------------- | ------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `market_id` | string | Yes | Polymarket condition\_id | | `side` | string | Yes | `BUY` or `SELL` | | `outcome` | string | Yes | Outcome label: `Yes`, `No`, or custom | | `quantity` | string | One of `quantity`/`amount` | Number of shares as decimal string | | `amount` | string | One of `quantity`/`amount` | **BUY market orders only.** USD amount to spend (Polymarket convention). Mutually exclusive with `quantity`. The handler derives `quantity = floor4(amount / price)` using your worst-price limit (the `price` field, not the eventual fill price) rounded down to 4 decimal places (the matching engine's share quantum), so the trade can't spend more than `amount` USD even at the worst acceptable price. | | `order_type` | string | No | `market` (default) or `limit` | | `price` | string | Yes | For limit orders: the limit price (0.01–0.99). For market orders: worst-price limit — required (Polymarket-style slippage protection) | | `time_in_force` | string | No | `GTC` (default for limit), `FOK` (default for market), `FAK`, or `IOC`. `GTD` is rolling out (flag-dependent — see the callout above); it requires `expiration`. | | `client_order_id` | string | No | Idempotency key | | `expiration` | string | With `GTD` | **Rolling out.** Unix-seconds timestamp at which a `GTD` resting limit auto-cancels. Must be strictly in the future at placement (`INVALID_ORDER_EXPIRATION` otherwise). Ignored for every other TIF. | | `post_only` | boolean | No | **Rolling out.** Polymarket's `postOnly`: the order must rest — rejected with `INVALID_POST_ONLY_ORDER` if marketable at placement. GTC/GTD limit orders only. Advisory (ignored) until the rollout activates. | ### `quantity` (shares) vs `amount` (USD) — Polymarket parity Polymarket's BUY market-order convention uses `amount` (USD) — the dollar amount you want to spend. SELL and limit orders use share counts. PolySimulator accepts **either** field, with these rules: * **`quantity`** is shares for both BUY and SELL (the polysim-native convention). Always usable. * **`amount`** is USD for BUY market orders only. Sending `amount=5` with `side=BUY, order_type=market` means "spend up to \$5"; the handler derives `quantity = floor4(amount / price)` — using your worst-price limit (the `price` field, not the eventual fill price) and rounding down to the matching engine's 4-decimal share quantum. Sending `amount` with `side=SELL` or `order_type=limit` returns 400. * Sending **both** `quantity` and `amount` returns 400 with `"Specify either quantity or amount, not both"`. Bots ported from Polymarket's SDK should keep using `amount` for BUY market orders unchanged. Bots written for PolySimulator can keep using `quantity` for everything. ```json theme={null} // PM-SDK style (works on PolySimulator unchanged) { "market_id": "0xabc...", "side": "BUY", "outcome": "Yes", "amount": "5", // spend up to $5 "order_type": "market", "price": "0.65" // worst-price limit } ``` ### Time in Force | Value | Description | Polymarket Equivalent | | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------- | | `GTC` | Good-till-Cancelled — persists until filled, cancelled, or expired. Overridden to FOK for market orders. | GTC | | `FOK` | Fill-or-Kill — all-or-nothing immediate fill (default for market orders). Depth-aware with the PM-semantics rollout. | FOK | | `FAK` | Fill-and-Kill — fill available quantity, cancel remainder (Polymarket term). Partial fills land with the PM-semantics rollout; legacy behavior is atomic. | FAK | | `IOC` | Immediate-or-Cancel — same behavior as FAK (PolySimulator alias) | FAK | | `GTD` | Good-Til-Date — resting limit auto-expiring at `expiration` (unix seconds). **Rolling out** (flag-dependent); until activation GTD routes through GTC on the PM-compat endpoints and is rejected here. | GTD | *** ## Response ```json theme={null} { "order_id": 42, "status": "FILLED", "order_type": "market", "side": "BUY", "outcome": "Yes", "price": "0.65", "quantity": "10", "notional": "6.50", "fee": "0.16", "filled_at": "2026-02-06T12:00:45Z", "price_source": "book_walk", "slippage_bps": 15, "quote_age_ms": 240, "spread_bps": 30, "impact_bps": 12, "book_walk_levels": 3, "account_balance": "993.50", "position": { "market_id": "0xabc123...", "outcome": "Yes", "quantity": "10", "avg_entry_price": "0.65", "status": "OPEN" } } ``` **`fee` is a real per-category taker fee — not zero.** Every taker fill is charged Polymarket's V2 fee `C × feeRate × p × (1 − p)`, where `feeRate` is the market's category rate (crypto 7%, finance / politics / tech 4%, sports 3%, economics / culture / weather / other 5%, geopolitics 0%; unknown → 5%). The `(p × (1 − p))` factor means the charge peaks near $0.50 and shrinks toward the price extremes. The example above is a 10-share BUY at $0.65 on a crypto market: `0.07 × 10 × 0.65 × 0.35 = 0.15925`, settled and returned as `0.16` — the debited amount is quantized to cent precision on every fill. Always read `OrderResponse.fee` when computing realized PnL — see the full schedule on [Trading Fees](/trading/fees). Discover a market's rate programmatically via [`GET /v1/fee-rate?token_id=…`](/concepts/clob-compatibility) and read its `fee_rate_bps` field (the effective category rate in bps; the `base_fee` field mirrors Polymarket's legacy base-fee parameter — `1000` when fees are enabled, `0` when fee-free — not the rate). ### Fill Quality Telemetry Every market order returns six fields HFT bots can use to score fill quality, detect stale-quote fills, and decide whether to flatten the position on the next tick. | Field | Type | Description | | ------------------ | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `price_source` | string | **Opaque diagnostic label** identifying which internal price path produced the fill. The label set evolves with engine changes — bots should log it for post-trade analysis but **must not switch on it as a stable enum**. Common values today are listed below; treat any unknown value as "use the fill but don't assume top-of-book quality". | | `slippage_bps` | int (unsigned) | Absolute basis points between the requested `price` and the actual fill price. `100 bps = 1%`. Magnitude only — sign is implied by side. | | `quote_age_ms` | int | Milliseconds between the underlying cached price-payload write and the fill. Populated whenever the price payload carries a timestamp (i.e. on virtually all CLOB-backed paths, including `book_walk`, `best_ask`/`best_bid`, and the cached/live midpoint paths). `0` indicates a sub-millisecond cache hit. Anything under \~500 ms is "fresh"; anything > 2 000 ms is a stale-quote fill and a bot should consider closing the position on the next tick. | | `spread_bps` | int | Book spread at fill time, computed as `(best_ask − best_bid) / mid × 10 000`. Tight liquid markets (BTC up/down) run 5–30 bps; thin micro-caps run 200+ bps. Populated only on book-walk and best-bid/ask paths. | | `impact_bps` | int (unsigned) | Market impact: the basis-point gap between the volume-weighted fill price (your VWAP across the book walk) and the best-price quote on your side. A 0 means you filled entirely at top-of-book; non-zero means your size ate through deeper levels. Populated only on the book-walk path. | | `book_walk_levels` | int | Number of distinct orderbook price levels consumed by the FOK walk. `1` means you cleanly filled at top-of-book; `2+` means the walk consumed deeper levels and `impact_bps` will be > 0. Populated only on the book-walk path. | #### `price_source` — common values Today the engine emits the labels below. Some get a transport prefix (`ws:` when the underlying price snapshot arrived via the Gamma/CLOB WebSocket stream rather than a REST poll) — strip prefixes before string comparison, or just log the raw label and grep on substring. | Label (prefix-stripped) | Path | When you see it | Quality | | ------------------------------------------------------------------------------------------------------------ | --------------------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | | `book_walk` | VWAP across live orderbook levels | Most BTC/ETH/SOL UpDown fills on liquid markets | Gold standard — all six telemetry fields populated | | `best_ask` (BUY) / `best_bid` (SELL) | Single top-of-book quote, cross the spread | Liquid market, single-tick fill (no walk needed) | Good — `quote_age_ms` + `spread_bps` populated, walk fields `null` | | `clob_midpoint_cached_hft` | Sub-ms Redis midpoint cache (HFT fast-path) | Used when bid/ask sides are stale but cached midpoint is fresh | Good — `quote_age_ms = 0` (cache hit); spread/impact/walk `null` | | `clob_midpoint_cached` | CLOB Redis cache fallback | Cascade miss on book-walk + best-bid/ask | Acceptable — `quote_age_ms = 0`; quality fields `null` | | `clob_midpoint_live` | Synchronous CLOB midpoint REST fetch (0.5s timeout) | Cold-market guard when all caches missed | Acceptable — `quote_age_ms = 0`; quality fields `null` | | `outcome_yes` / `outcome_no` / `outcome_mid` / `midpoint` / `last_trade_only` / `last_trade_spread_fallback` | Gamma cached-payload fallback | All CLOB sources unavailable — last-resort path | Lower confidence — `quote_age_ms` may be present (from payload timestamp), but spread/impact/walk all `null` | | `emergency_exit_entry_price` / `emergency_exit_entry_price_postexpiry` | Position-close at the original entry price | SELL on a market that resolved or has invalid (post-expiry) cached prices | Special case — `quote_age_ms` is `null`; this is not a market-priced fill | Bots should treat the `clob_midpoint_*` and Gamma-fallback paths as lower-confidence and the emergency-exit paths as informational-only. **Idempotent-replay caveat**: a duplicate POST with the same `Idempotency-Key`/`client_order_id` returns the original order envelope. `price_source`, `slippage_bps`, and `quote_age_ms` rehydrate from the persisted order row, but `spread_bps`, `impact_bps`, and `book_walk_levels` are **only available on the first response** — they return `null` on replay. Persist them on first receipt if your bot needs them. #### Suggested latency budget for HFT bots A round-trip-aware budget for an UpDown 5-minute bot trading 1 contract clip: | Step | Target | Source | | ----------------------- | ------------------------ | -------------------------------- | | Quote freshness at fill | `quote_age_ms < 500` | `OrderResponse.quote_age_ms` | | Spread at fill | `spread_bps < 100` (1¢) | `OrderResponse.spread_bps` | | Impact at fill | `impact_bps < 50` (0.5%) | `OrderResponse.impact_bps` | | Walk depth | `book_walk_levels <= 2` | `OrderResponse.book_walk_levels` | Breach any threshold → close the position on the next bar rather than hold it. *** ## Idempotency Use the `Idempotency-Key` header or `client_order_id` field to prevent duplicate executions on retries: ```bash theme={null} curl -X POST https://api.polysimulator.com/v1/orders \ -H "Idempotency-Key: my-bot-2026-02-06-001" \ ... ``` If the same key is submitted twice, the second request returns the result of the first execution without creating a new order. Include a timestamp or sequence number in your idempotency key to make debugging easier: `"bot-alpha-20260206-001"` *** ## Error Handling For the full, canonical trading error-code list (and the order-status enum) see [Error Handling](/bots/error-handling#common-error-codes). The codes most relevant to order placement: | Status | Error Code | Meaning | | ------ | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `400` | `PRICE_REQUIRED` | Market orders require a `price` field (worst-price limit) | | `400` | `INSUFFICIENT_BALANCE` | Not enough funds for this trade | | `400` | `MARKET_CLOSED` | Market has resolved or is no longer accepting orders | | `400` | `INVALID_QUANTITY` | Quantity must be a positive decimal string | | `400` | `FOK_ORDER_NOT_FILLED_ERROR` | The order couldn't fill entirely at or beyond your worst-price limit (best available price moved past your cap). This is the worst-price rejection — there is **no** `409 LIMIT_PRICE_NOT_MET`. | | `400` | `INVALID_ORDER_EXPIRATION` | **Rolling out.** GTD order with a missing, unparseable, or past `expiration` (PM convention: unix seconds, strictly in the future) | | `400` | `INVALID_POST_ONLY_ORDER` (`X-Polysim-Code: POST_ONLY_WOULD_CROSS`) | **Rolling out.** `post_only` order is marketable at placement — it would cross the book instead of resting | | `400` | `INVALID_POST_ONLY_ORDER_TYPE` | **Rolling out.** `post_only` combined with a market order type (FOK/FAK/IOC or `order_type=market`) — GTC/GTD limits only | | `400` | `INVALID_ORDER_MIN_SIZE` (`X-Polysim-Code: ORDER_BELOW_MIN_SIZE`) | **Rolling out.** Order size below the market's minimum (the `min_order_size` that `GET /v1/book` advertises — 5 shares on standard binary markets) | | `401` | `INVALID_KEY` | API key is invalid, expired, or revoked (`MISSING_API_KEY` if the header is absent) | | `403` | `INSUFFICIENT_PERMISSION` | Key lacks `trade` permission | | `404` | `MARKET_NOT_FOUND` | Unknown `market_id` | | `409` | `DUPLICATE_CLIENT_ORDER_ID` | A new order reused a `client_order_id` already bound to a different order | | `409` | `IDEMPOTENCY_KEY_REUSE` | Same `Idempotency-Key` replayed with a different request body (an identical replay returns the original order) | | `429` | `RATE_LIMITED` / `RATE_LIMIT_EXCEEDED` | Too many requests — both are 429; check `Retry-After`. See [Error Handling](/bots/error-handling#rate-limit-errors) for the two-code distinction. | ```python theme={null} resp = requests.post( f"{BASE_URL}/v1/orders", headers={"X-API-Key": API_KEY, "Idempotency-Key": idempotency_key}, json=order_payload, ) if resp.status_code == 200: order = resp.json() print(f"Order {order['order_id']}: {order['status']} @ {order['price']}") elif resp.status_code == 400: # Branch on the stable machine code in the X-Polysim-Code header. # The default /v1/* body is PM-shape {"error": ""}, # so the body's `error` is prose, NOT a code — use it for display only. code = resp.headers.get("X-Polysim-Code") if code == "PRICE_REQUIRED": print("Add a 'price' field — market orders require a worst-price limit") elif code == "INSUFFICIENT_BALANCE": print("Not enough funds — current balance too low") elif code == "FOK_ORDER_NOT_FILLED_ERROR": # The best available price moved past your worst-price limit. print("Price moved beyond your limit — retry with a wider worst-price cap") else: print(f"Order rejected [{code}]: {resp.json().get('error')}") elif resp.status_code == 409: # Idempotency / client_order_id conflicts. code = resp.headers.get("X-Polysim-Code") if code in ("IDEMPOTENCY_KEY_REUSE", "DUPLICATE_CLIENT_ORDER_ID"): print(f"Idempotency conflict [{code}] — reuse the original order, don't retry with a new body") else: print(f"Conflict [{code}]: {resp.json().get('error')}") elif resp.status_code == 429: # Both RATE_LIMITED and RATE_LIMIT_EXCEEDED surface here — back off on any 429. retry_after = int(resp.headers.get("Retry-After", 1)) time.sleep(retry_after) ``` *** ## Next Steps * [Order Management](/trading/order-management) — List and cancel orders * [Batch Orders](/trading/batch-orders) — Place multiple orders at once * [Slippage Protection](/trading/slippage-protection) — Fine-tune fill quality # Slippage Protection Source: https://docs.polysimulator.com/trading/slippage-protection Understand slippage protection for market orders — identical to Polymarket's worst-price limit model. # Slippage Protection PolySimulator uses the same slippage protection model as Polymarket: the `price` field on every market order acts as a **worst-price limit**. There is no separate slippage parameter — the price *is* your slippage protection. *** ## How It Works On Polymarket, all orders are limit orders. A "market order" is simply a limit order at a marketable price with FOK (Fill-or-Kill) time-in-force. PolySimulator mirrors this exactly. The `price` field is **required** on market orders and sets the worst price you'll accept: * **BUY**: fills at the best ask, but **never above** your `price` * **SELL**: fills at the best bid, but **never below** your `price` ```json theme={null} { "market_id": "0xabc...", "side": "BUY", "outcome": "Yes", "quantity": "10", "order_type": "market", "price": "0.68" } ``` ### BUY Example ``` Best ask: $0.65 Your limit: $0.68 → FILLED at $0.65 (price improvement — you pay less than your limit) Best ask: $0.72 Your limit: $0.68 → REJECTED — fill price $0.72 exceeds your worst-price limit $0.68 ``` ### SELL Example ``` Best bid: $0.60 Your limit: $0.55 → FILLED at $0.60 (price improvement — you receive more than your limit) Best bid: $0.50 Your limit: $0.55 → REJECTED — fill price $0.50 is below your worst-price limit $0.55 ``` **Polymarket migration**: This is identical to how Polymarket's `price` field works. Your existing slippage logic transfers directly to PolySimulator — and vice versa when you migrate to live trading. *** ## Price Is Required Market orders **must** include a `price` field. Submitting a market order without `price` returns a `400 PRICE_REQUIRED` error: ```json theme={null} { "error": "PRICE_REQUIRED", "message": "Market orders require a 'price' field as a worst-price limit..." } ``` This matches Polymarket's design: there are no "blind" market orders. You always control the worst price you'll accept. *** ## Time in Force Market orders default to **FOK** (Fill-or-Kill), matching Polymarket. You can also use **FAK** (Fill-and-Kill), Polymarket's term for IOC. | Value | Behavior | Polymarket Equivalent | | ----- | -------------------------------------------------------------------- | --------------------- | | `FOK` | All-or-nothing — fill entirely or cancel (default for market orders) | FOK | | `FAK` | Fill available quantity, cancel remainder | FAK | | `IOC` | Same as FAK (PolySimulator alias) | FAK | | `GTC` | Overridden to FOK for market orders | — | ```json theme={null} { "market_id": "0xabc...", "side": "BUY", "outcome": "Yes", "quantity": "10", "order_type": "market", "price": "0.68", "time_in_force": "FAK" } ``` If you omit `time_in_force` on a market order, it defaults to FOK. If you explicitly set `GTC`, it is overridden to FOK — market orders never persist in the book. *** ## Execution Model Market orders fill at the **best available price** — BUY at the best ask, SELL at the best bid — matching Polymarket's execution model. The fill-price resolution cascade (each tier falls through to the next on a miss or a failed sanity guard): 1. **Order-book walk (VWAP)** across live CLOB levels — most realistic 2. **Best bid/ask** from the CLOB order book (cross the spread) 3. **CLOB midpoint** — sub-ms Redis fast-path, then the cached read-through 4. **Live CLOB midpoint** — synchronous fetch (cold-market guard) 5. **Cached display price** — Gamma/SSE fallback (last resort) See [Trade Execution Internals](/trading/execution-internals) for the full cascade and the exact `price_source` labels each tier emits. *** ## Fill Diagnostics Every market order response includes transparency metadata: ```json theme={null} { "order_id": 42, "status": "FILLED", "price": "0.65", "price_source": "book_walk", "slippage_bps": 461 } ``` The `slippage_bps` field is **informational only** — it shows how far the fill price deviated from the cached mid-price, in basis points. It does not affect order execution. Your `price` (worst-price limit) is the only protection mechanism. ### Price Sources `price_source` is an **opaque diagnostic label** — log it for post-trade analysis, but treat it as a non-stable enum (the label set evolves with engine changes). Some labels carry a transport prefix (`ws:` / `gamma:`) when the underlying snapshot arrived over a WebSocket/poller stream — strip the prefix or substring-match. The labels the engine actually emits today, from highest to lowest confidence: | Label (prefix-stripped) | Path | Quality | | ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------- | ----------------------------------------------- | | `book_walk` | VWAP across live order-book levels | Gold standard — all telemetry fields populated | | `best_ask` (BUY) / `best_bid` (SELL) | Single top-of-book quote | Good — `spread_bps` populated, walk fields null | | `clob_midpoint_cached_hft` | Sub-ms Redis midpoint cache | Good — `quote_age_ms = 0` | | `clob_midpoint_cached` | Redis CLOB-midpoint read-through | Acceptable | | `clob_midpoint_live` | Synchronous CLOB midpoint fetch (cold-market guard) | Acceptable | | `outcome_yes` / `outcome_no` / `outcome_mid` / `midpoint` / `last_trade_only` / `last_trade_spread_fallback` | Gamma cached-payload fallback | Lower confidence | | `emergency_exit_entry_price` / `emergency_exit_entry_price_postexpiry` | Position-close at entry price (resolved / post-expiry SELL) | Informational — not a market-priced fill | **Monitor `price_source`** in your bot logs. Frequent fills on the `clob_midpoint_*` or Gamma-fallback labels (rather than `book_walk` / `best_ask` / `best_bid`) mean the live book was unavailable — consider pausing during degraded price quality. (Labels like `clob_book` or `redis_cache` are **not** emitted — don't grep for them.) *** ## Recommendations by Strategy | Strategy | Approach | | ----------------- | ------------------------------------------------------------- | | Scalping / HFT | `price` = best ask + \$0.01 (tight limit, minimal overpay) | | Swing trading | `price` = best ask + \$0.03 (moderate buffer) | | Bulk accumulation | `price` = best ask + \$0.05 (wider buffer for fill certainty) | | Illiquid markets | Use **limit orders** (GTC) for price certainty | For illiquid markets with wide spreads, consider **limit orders** instead of market orders. Limits give you price certainty at the cost of fill uncertainty. *** ## Comparison: PolySimulator vs Polymarket | Feature | Polymarket | PolySimulator | | ------------------------------- | --------------------------------- | -------------------------------------------------- | | Slippage protection | `price` field (worst-price limit) | `price` field (identical) | | Price required on market orders | Yes | Yes | | Default time-in-force (market) | FOK | FOK | | Execution model | BUY at best ask, SELL at best bid | BUY at best ask, SELL at best bid | | Price improvement | Yes — fill at best available | Yes — fill at best available | | Order types | FOK, FAK, GTC, GTD | FOK, FAK, IOC, GTC | | Fees | Per-category taker fees | Per-category taker fees (PM-V2 schedule, mirrored) | *** ## Fees PolySimulator charges the **same per-category taker fee schedule as Polymarket V2** — fees are **not** zero. Every taker fill is charged and the amount is returned in `OrderResponse.fee`. A bot computing PnL on the assumption of zero fees will be wrong on most markets. The fee formula is: ``` fee = C × feeRate × p × (1 − p) ``` where `C` is the number of shares filled, `p` is the fill price (0–1), and `feeRate` is the market's category rate. Because of the `p × (1 − p)` factor, the effective fee is largest near \$0.50 and shrinks toward the price extremes. | Category | `feeRate` | | ------------------------------------- | :------------------------: | | Crypto | 7% | | Finance / Politics / Tech | 4% | | Sports | 3% | | Economics / Culture / Weather / Other | 5% | | Geopolitics | 0% | | Unknown / missing category | 5% (conservative fallback) | Makers pay no fee (matching Polymarket V2), and break-even emergency-exit fills (selling a position on a resolved / expired market) are fee-free. Maker vs taker is classified by **marketability at placement**: a GTC limit that crosses the live book when you place it pays the taker fee even though it technically fills via the matching loop — only orders that genuinely rest earn maker (zero-fee) treatment. See [Trading Fees](/trading/fees) for the full schedule, formula, and divergences. Always read `OrderResponse.fee` when computing realized PnL — don't infer it from the rate alone, since the `p × (1 − p)` factor makes the charged amount price-dependent. To discover a market's rate up front, call `GET /v1/fee-rate?token_id=…` and read the `fee_rate_bps` field — that is the effective category rate in bps. (The `base_fee` field mirrors Polymarket's legacy base-fee parameter — `1000` when fees are enabled, `0` when fee-free — not the rate. See [Trading Fees](/trading/fees).) *** ## Next Steps * [Placing Orders](/trading/placing-orders) — Full order API reference * [Order Book](/market-data/order-book) — Inspect liquidity before trading # Execution Feed Source: https://docs.polysimulator.com/websockets/execution-feed Real-time limit order fill notifications via WebSocket. # Execution Feed ``` WS /v1/ws/executions?token= ``` Receives automatic fill notifications when your limit orders are matched. No subscription required — all your executions are streamed automatically. *** ## Connection ```bash theme={null} # Mint a WS token TOKEN=$(curl -s -X POST -H "X-API-Key: $API_KEY" \ https://api.polysimulator.com/v1/keys/ws-token | jq -r '.token') # Connect wscat -c "wss://api.polysimulator.com/v1/ws/executions?token=$TOKEN" ``` *** ## Messages ### Order Filled ```json theme={null} { "type": "fill", "order_id": 42, "market_id": "0x1a2b3c...", "side": "BUY", "outcome": "Yes", "price": "0.65", "quantity": "10.0", "filled_at": "2026-02-06T12:00:45Z" } ``` | Field | Description | | ----------- | ------------------------------ | | `type` | Always `fill` | | `order_id` | ID of the filled order | | `market_id` | Market condition\_id | | `side` | `BUY` or `SELL` | | `outcome` | Outcome label | | `price` | Fill price | | `quantity` | Filled quantity | | `filled_at` | ISO-8601 timestamp of the fill | *** ## Example: Limit Order + Fill Listener ```python theme={null} import asyncio import json import requests import websockets API_KEY = "ps_live_..." BASE = "https://api.polysimulator.com/v1" headers = {"X-API-Key": API_KEY} # 1. Place a limit order order = requests.post(f"{BASE}/orders", headers=headers, json={ "market_id": "0x1a2b3c...", "side": "BUY", "outcome": "Yes", "quantity": "10", "order_type": "limit", "price": "0.60", "time_in_force": "GTC", "client_order_id": "limit-001", }).json() print(f"Limit order placed: {order['status']}") # 2. Listen for fills ws_token = requests.post( f"{BASE}/keys/ws-token", headers=headers ).json()["token"] async def listen_fills(): async with websockets.connect( f"wss://api.polysimulator.com/v1/ws/executions?token={ws_token}" ) as ws: async for raw in ws: msg = json.loads(raw) # Fill frames are FLAT (no `data` wrapper) and use type == "fill". if msg.get("type") == "fill": print(f"Filled: {msg['side']} {msg['quantity']}x {msg['outcome']} @ {msg['price']}") asyncio.run(listen_fills()) ``` Use `client_order_id` to correlate fill events with your original orders. This is especially useful when you have multiple limit orders pending. *** ## Next Steps * [WebSocket Overview](/websockets/overview) — Authentication and protocol * [Price Feed](/websockets/price-feed) — Real-time price streaming # WebSocket Overview Source: https://docs.polysimulator.com/websockets/overview Real-time data streaming via WebSocket feeds — authentication, protocol, and best practices. # WebSocket Feeds The PolySimulator API provides two **shape families** of WebSocket feeds for real-time data: a polysim-native shape (compact, condition-id keyed) and a **Polymarket-compatible shape** that mirrors Polymarket's CLOB WS contract field-for-field so existing py-clob-client / PM-port bots can drop in without rewriting their event handlers. ### PolySim-native feeds | Feed | Endpoint | Description | | ------------------ | ---------------------- | ---------------------------------------------- | | **Price Feed** | `WS /v1/ws/prices` | Real-time price updates for subscribed markets | | **Execution Feed** | `WS /v1/ws/executions` | Limit order fill notifications | ### Polymarket-compatible feeds (AF-14) | Feed | Endpoint | Description | | ------------------ | ------------------ | -------------------------------------------------------- | | **Market Channel** | `WS /v1/ws/market` | Per-token L2 book + delta events (PM `/ws/market` shape) | | **User Channel** | `WS /v1/ws/user` | Auth-gated user channel (PM `/ws/user` shape) | The PM-compat routes: * Accept PM's subscribe message — `{"type": "market", "assets_ids": ["TOKEN_ID", ...]}` * Emit PM-shape events — `book` / `price_change` / `last_trade_price` / `best_bid_ask`, plus PM-shape `trade` fill frames on `/ws/user` (since 2026-06-11) * Field names match PM verbatim (`asset_id`, `market`, `hash`, `tick_size`, `timestamp`, stringified prices) * Accept inline auth via the subscribe message — `"auth": {"apiKey": "ps_live_..."}` (PM's L2 `secret`/`passphrase` fields are accepted but ignored — polysim is single-secret) * Also accept `?token=` for back-compat with the polysim-native auth model See [PM-compat Market Channel](/websockets/pm-compat-market) for the full PM-shape protocol. *** ## Authentication WebSocket connections require a **short-lived JWT token** (60 seconds), separate from your API key. ```bash theme={null} TOKEN=$(curl -s -X POST \ -H "X-API-Key: $API_KEY" \ https://api.polysimulator.com/v1/keys/ws-token \ | jq -r '.token') ``` Response: ```json theme={null} {"token": "eyJhbGciOiJIUzI1NiIs...", "expires_in": 60} ``` ```bash theme={null} wscat -c "wss://api.polysimulator.com/v1/ws/prices?token=$TOKEN" ``` The token expires in **60 seconds**. Connect immediately after minting. *** ## Protocol All messages are JSON. The protocol supports these client actions: | Action | Description | | ------------- | -------------------------------------- | | `subscribe` | Subscribe to price updates for markets | | `unsubscribe` | Stop receiving updates for markets | | `ping` | Heartbeat check | ### Ping/Pong ```json theme={null} // Client sends: {"action": "ping"} // Server responds: {"type": "pong", "ts": 1705312200000} ``` *** ## Connection Limits | Tier | Max WS Connections | Max Subscriptions/Connection | | ------------ | :----------------: | :--------------------------: | | `free` | 1 | 50 markets | | `pro` | 3 | 50 markets | | `pro_plus` | 10 | 50 markets | | `enterprise` | 50 | 50 markets | The **Max WS Connections** column is authoritative on the wire via `GET /v1/keys/tiers` — the `max_ws_connections` field. The **Max Subscriptions/Connection** value is fixed at **50 per connection across all tiers** (a hardcoded server cap, not tier-configurable and not returned by `/v1/keys/tiers`). See also [Rate Limits](/concepts/rate-limits) for the full tier matrix. *** ## Reconnection & Token Rotation WebSocket tokens are **short-lived** — they expire after **60 seconds**. They are *not* single-use: a token may be reused for multiple connections while it is still valid. The short TTL means most reconnect flows mint a fresh one anyway, so your bot must still implement automatic reconnection with token minting near expiry. ### Recommended Pattern ```python theme={null} import asyncio import json import websockets import httpx API_KEY = "your-api-key" BASE_URL = "https://api.polysimulator.com" async def mint_ws_token() -> str: """Mint a fresh 60-second WS token.""" async with httpx.AsyncClient() as client: resp = await client.post( f"{BASE_URL}/v1/keys/ws-token", headers={"X-API-Key": API_KEY}, ) return resp.json()["token"] async def connect_with_reconnect(subscriptions: list[str]): backoff = 1 max_backoff = 30 while True: try: token = await mint_ws_token() async with websockets.connect( f"wss://api.polysimulator.com/v1/ws/prices?token={token}" ) as ws: backoff = 1 # Reset on successful connect # Re-subscribe after reconnect await ws.send(json.dumps({ "action": "subscribe", "markets": subscriptions, })) async for message in ws: data = json.loads(message) if data.get("type") == "pong": continue # Process price update handle_price(data) except websockets.ConnectionClosedError as e: if e.code == 4001: # Token expired — mint fresh and reconnect immediately continue if e.code == 4002: # Connection limit — wait before retry await asyncio.sleep(backoff) backoff = min(backoff * 2, max_backoff) except Exception: await asyncio.sleep(backoff) backoff = min(backoff * 2, max_backoff) ``` ### Key Rules 1. **Mint a fresh token near expiry** — a token may be reused for multiple connections while still valid, but the 60-second TTL means most reconnect flows mint a new one anyway 2. **Re-subscribe after reconnect** — the server does not remember your subscriptions 3. **Exponential backoff** — start at 1s, cap at 30s, reset on successful connect 4. **Handle close code `4001` immediately** — no backoff needed, just mint and reconnect *** ## Best Practices WS tokens expire in 60 seconds. Mint and connect in the same code block. Use exponential backoff for reconnection. Mint a fresh token on each reconnect attempt. WebSocket subscriptions don't count against REST rate limits. Use them instead of polling `GET /v1/markets`. Subscribe to the execution feed for limit order fill confirmations instead of polling `GET /v1/orders`. *** ## Error Handling | WS Close Code | Meaning | | :-----------: | -------------------------------------- | | `4001` | Invalid or expired JWT token | | `4002` | Maximum WebSocket connections exceeded | When you receive close code `4001`, mint a fresh token and reconnect. When you receive close code `4002`, close idle connections before reconnecting. *** ## Next Steps * [Price Feed](/websockets/price-feed) — Subscribe to real-time prices * [Execution Feed](/websockets/execution-feed) — Get fill notifications # Polymarket-compat WebSocket Source: https://docs.polysimulator.com/websockets/pm-compat-market Drop-in WebSocket layer that mirrors the Polymarket CLOB WS contract — for py-clob-client and other PM-port bots. # Polymarket-compatible WebSocket ``` WS /v1/ws/market # public market channel WS /v1/ws/user # auth-gated user channel ``` These routes mirror Polymarket's CLOB WS contract field-for-field so an SDK port of Polymarket (py-clob-client, etc.) can be pointed at PolySim by changing only the host. No event-handler rewrites required. If you're starting fresh, the polysim-native [Price Feed](/websockets/price-feed) is more compact and slightly cheaper to parse. The PM-compat layer exists for SDK porters — pick this route when you have an existing `book` / `price_change` event consumer and don't want to re-key on `condition_id`. *** ## Subscribe message The subscribe key differs by channel, matching Polymarket: * **`/ws/market`** keys by **CLOB token id** (`assets_ids` — long decimal strings, two per binary market, one per outcome). This is PM's market channel contract. * **`/ws/user`** keys by **condition id** (`markets`). This is PM's user channel contract — *"the user channel subscribes by condition IDs (market identifiers), not asset IDs."* A faithful PM SDK port sends `markets` to its user channel. The polysim backend accepts either field on both routes (reverse-resolving token ↔ condition transparently), so a single client can use whichever it already has. ```json theme={null} // /ws/market — PM keys by token id: { "type": "market", "assets_ids": [ "72936048731589292555781174533757608024096898681344338414570549242843090464013", "84876458999851945121196253324884098156932869069716020095394562124131470571066" ] } ``` ```json theme={null} // /ws/user — PM keys by condition id: { "type": "user", "markets": ["0xabc123...condition_id"], "auth": {"apiKey": "ps_live_..."} } ``` | Field | Type | Description | | ------------ | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `type` | string | `"market"` for `/ws/market`, `"user"` for `/ws/user`. Both routes also accept `"subscribe"` for symmetry with polysim-native clients. | | `assets_ids` | array | List of CLOB token ids. The PM-canonical field for **`/ws/market`**. Capped at 50 per connection. | | `markets` | array | List of condition ids (resolves both yes/no tokens server-side). The PM-canonical field for **`/ws/user`** (PM's user channel keys by condition id). Also accepted on `/ws/market` as a convenience; the markets list is capped at 25 (= 50 tokens). | | `operation` | string | `"subscribe"` or `"unsubscribe"` — PM's dynamic-subscription verb for mutating subscriptions without reconnecting (see [Dynamic subscribe/unsubscribe](#dynamic-subscribe-unsubscribe) below). | | `auth` | object | Optional inline auth; required on `/ws/user`. Fields documented below. | ### Auth block ```json theme={null} { "auth": { "apiKey": "ps_live_..." } } ``` | Field | Required | Description | | ------------ | --------------------------------------------- | -------------------------------------------------------------------------------- | | `apiKey` | yes (on `/ws/user`); optional on `/ws/market` | Your `ps_live_...` raw API key. Same key you use for HTTP requests. | | `secret` | no | Accepted for PM-shape compatibility — ignored by PolySim (we are single-secret). | | `passphrase` | no | Accepted for PM-shape compatibility — ignored. | Alternative: pass `?token=` on connect (the polysim-native auth model). The JWT comes from `POST /v1/keys/ws-token` and expires in 60 seconds. Pick exactly one auth path — passing both is fine but the JWT takes precedence. *** ## Events emitted All four PM event types are emitted with PM's exact field names: ### `book` — full L2 snapshot ```json theme={null} { "event_type": "book", "asset_id": "72936048731589292555781174533757608024096898681344338414570549242843090464013", "market": "0xabc123...", "bids": [ {"price": "0.55", "size": "100"}, {"price": "0.54", "size": "250"} ], "asks": [ {"price": "0.56", "size": "100"} ], "hash": "0x...", "tick_size": "0.01", "timestamp": "1715518800123" } ``` Emitted once per token on subscribe (with the current CLOB orderbook snapshot). Subsequent updates flow through `price_change` / `best_bid_ask` — the `book` event is not re-emitted on every level change. ### `price_change` — orderbook delta ```json theme={null} { "event_type": "price_change", "asset_id": "...", "market": "0xabc123...", "price_changes": [ { "asset_id": "...", "price": "0.55", "best_bid": "0.55", "best_ask": "0.56", "size": "0", "side": "", "hash": "" } ], "timestamp": "1715518800123", "hash": "" } ``` **Two PM deviations:** * PolySim adds a convenience **top-level `asset_id`** that PM's `price_change` does not send (PM puts `market` / `price_changes[]` / `timestamp` / `event_type` at the top level; the asset id lives only inside each `price_changes[]` entry). * The inner `size`, `side`, and `hash` fields are **stubbed** (`"0"` / `""` / `""`). PolySim's price cache is a top-of-book-only payload with no per-level deltas or rolling book hash, so these are emitted for schema parity but carry no data. PM populates them. ### `last_trade_price` — fill broadcast ```json theme={null} { "event_type": "last_trade_price", "asset_id": "...", "market": "0xabc123...", "price": "0.55", "side": "BUY", "size": "0", "fee_rate_bps": "0", "timestamp": "1715518800123" } ``` ### `best_bid_ask` — top-of-book change ```json theme={null} { "event_type": "best_bid_ask", "asset_id": "...", "market": "0xabc123...", "best_bid": "0.55", "best_ask": "0.56", "timestamp": "1715518800123" } ``` **Two PM deviations:** * PolySim omits the `spread` field that PM's `best_bid_ask` carries (compute it yourself as `best_ask - best_bid` if you need it). * PM gates `best_bid_ask` (plus `new_market` / `market_resolved`) behind `custom_feature_enabled: true` in the subscribe message. PolySim ignores `custom_feature_enabled` and emits `best_bid_ask` **unconditionally** — you do not need to set the flag. All prices are **strings** (no JSON numbers) to match PM verbatim and avoid float-precision drift. *** ## Error frames The PM-compat layer emits structured error frames (PM does the same): ```json theme={null} { "event_type": "error", "error": "AUTH_REQUIRED", "message": "/v1/ws/user requires authentication. Pass {\"auth\": {\"apiKey\": \"ps_live_...\"}} in the subscribe message, or use ?token= on connect." } ``` | `error` code | Trigger | Connection closes? | | --------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------ | | `INVALID_JSON` | Subscribe frame isn't valid JSON | no — send a corrected frame | | `INVALID_SUBSCRIBE` | Subscribe payload missing `assets_ids` or has wrong type | no | | `EMPTY_SUBSCRIBE` | `assets_ids` parsed but was empty | no | | `UNKNOWN_ASSET` | One or more token ids didn't resolve to a known market — others (if any) ARE subscribed | no | | `MAX_ASSETS_EXCEEDED` | Cumulative subscriptions on this connection would exceed 50 — none of the new tokens were added | no — send `unsubscribe` first or open a new connection | | `AUTH_REQUIRED` | `/ws/user` subscribe without valid `auth.apiKey` or `?token=` | yes (close code 1008) | | `AUTH_INVALID` | An `auth.apiKey` was sent but didn't resolve to an active key | yes (close code 1008) | | `MAX_WS_EXCEEDED` | Per-user WebSocket connection cap reached (mixes native + PM-compat sockets) | yes (close code 4002) | | `UNKNOWN_FRAME` | Sent frame `type` not recognised | no | `UNKNOWN_ASSET` and `INVALID_SUBSCRIBE` are non-fatal — the connection stays open so you can send a corrected subscribe. The three rows marked "yes" above are terminal: the server sends the error frame, then closes the socket so the client knows to re-handshake (with corrected auth) or back off. *** ## Dynamic subscribe/unsubscribe PM SDKs mutate subscriptions **without reconnecting** by sending an `operation` frame (the initial subscribe uses `type`; subsequent updates use `operation`). PolySim accepts the same shape: ```json theme={null} // Add tokens (on /ws/market): {"assets_ids": [""], "operation": "subscribe"} // Remove tokens (on /ws/market): {"assets_ids": [""], "operation": "unsubscribe"} // On /ws/user, mutate by condition id instead: {"markets": ["0xabc123...condition_id"], "operation": "subscribe"} ``` The per-connection 50-token cap is cumulative across all subscribe operations — exceeding it returns a `MAX_ASSETS_EXCEEDED` error frame (none of the new tokens are added). Send `unsubscribe` to free room first. *** ## Ping/pong PolySim accepts **both** heartbeat shapes: ```json theme={null} // JSON shape: {"type": "ping"} ``` ``` // Plain-string shape (PM's exact form): ping ``` In **both** cases the server replies with a **JSON** pong frame: ```json theme={null} {"event_type": "pong", "ts": 1715518800123} ``` **Deviation from PM for porters:** Polymarket replies to a plain-string `PING` with a plain-string `PONG`. PolySim always replies with the JSON `{"event_type": "pong", "ts": }` frame — even when you sent a plain-string `ping`. A PM bot waiting for a literal `PONG` string never sees it; key your heartbeat handler off the JSON `pong` frame instead. The PM-compat shape uses `event_type` (not `type`) on the pong frame for consistency with the rest of the protocol. *** ## Complete example ```python Python (websockets) theme={null} import asyncio import json import websockets API_KEY = "ps_live_..." ASSET_ID = "72936048731589292555781174533757608024096898681344338414570549242843090464013" async def stream(): async with websockets.connect("wss://api.polysimulator.com/v1/ws/market") as ws: await ws.send(json.dumps({ "type": "market", "assets_ids": [ASSET_ID], "auth": {"apiKey": API_KEY}, # optional on /ws/market; required on /ws/user })) async for raw in ws: evt = json.loads(raw) kind = evt.get("event_type") if kind == "book": print(f"BOOK {evt['asset_id'][:16]}... bids={len(evt['bids'])} asks={len(evt['asks'])}") elif kind == "price_change": for change in evt.get("price_changes", []): print(f"PRICE {change['asset_id'][:16]}... -> {change['price']}") elif kind == "last_trade_price": print(f"TRADE {evt['asset_id'][:16]}... {evt['side']} @ {evt['price']}") elif kind == "best_bid_ask": print(f"BBO {evt['asset_id'][:16]}... bid={evt['best_bid']} ask={evt['best_ask']}") elif kind == "error": print(f"ERROR: {evt['error']} - {evt['message']}") asyncio.run(stream()) ``` For py-clob-client port-overs, the only change is the WS URL: ```python theme={null} # Before (Polymarket): WS_URL = "wss://ws-subscriptions-clob.polymarket.com/ws/market" # After (PolySim): WS_URL = "wss://api.polysimulator.com/v1/ws/market" ``` Event handlers — including the `event_type` field name and PM-style stringified prices — work without modification. *** ## What's NOT in the PM-compat layer (yet) * **PM's `order` PLACEMENT / UPDATE / CANCELLATION events on `/ws/user`** — **fill (`trade`) events ARE emitted since 2026-06-11**: when one of your orders fills on a subscribed market, `/ws/user` pushes PM's user-channel `trade` frame (`event_type: "trade"`, `type: "TRADE"`, `status: "MATCHED"`, unix-string timestamps). Divergences: paper trades terminate at `MATCHED` (no `MINED`/`CONFIRMED` lifecycle — no chain), `maker_orders` is empty, and `owner`/`trade_owner` are empty strings. PM's `order` placement/cancel events are still NOT emitted — poll `GET /v1/data/orders` for order state, or use the polysim-native [Execution Feed](/websockets/execution-feed) (`/v1/ws/executions`). * **L2 signature auth** — PM's full L2 contract requires `POLY_ADDRESS` / `POLY_SIGNATURE` / `POLY_TIMESTAMP` / `POLY_API_KEY` / `POLY_PASSPHRASE`. PolySim is single-secret — we accept the `apiKey` field and ignore the others. SDK code that calls a PM signer to build the L2 block doesn't need changes; the signer's output is simply ignored. *** ## Compatibility matrix | Feature | Polymarket | PolySim PM-compat | | --------------------------------------------- | :--------: | :-------------------------------------------------------------------------------------------------------------------------------------------------: | | `wss://.../ws/market` URL path | yes | yes (under `/v1/`) | | `/ws/market` keys by `assets_ids` (token ids) | yes | yes | | `/ws/user` keys by `markets` (condition ids) | yes | yes | | `operation` dynamic subscribe/unsubscribe | yes | yes | | Inline `auth.apiKey` | yes | yes | | L2 signature (`secret`/`passphrase`) | required | ignored (single-secret) | | `book` event shape | yes | yes | | `price_change` event shape | yes | yes (adds a top-level `asset_id` PM omits; inner `size`/`side`/`hash` stubbed — TOB-only cache) | | `last_trade_price` event shape | yes | yes | | `best_bid_ask` event shape | yes | yes (no `spread` field; emitted unconditionally — PM gates it behind `custom_feature_enabled`) | | Plain-string `PING` → `PONG` | yes | accepted, but reply is JSON `{event_type:"pong"}` (not plain `PONG`) | | `tick_size_change` event | yes | not on this channel — SSE `/prices/stream` only (see [Price Feed](/websockets/price-feed#tick-size-changes)); PM channel passthrough is a follow-up | | Per-user execution events on `/ws/user` | yes | use `/v1/ws/executions` for now | # Price Feed Source: https://docs.polysimulator.com/websockets/price-feed Real-time price updates via WebSocket for subscribed markets. # Price Feed ``` WS /v1/ws/prices?token= ``` Streams real-time price updates for subscribed markets. All numeric values are strings. *** ## Subscribe After connecting, send a subscribe message: ```json theme={null} {"action": "subscribe", "markets": ["0xabc123...", "0xdef456..."]} ``` Server confirms: ```json theme={null} {"type": "subscribed", "markets": ["0xabc123...", "0xdef456..."]} ``` *** ## Price Updates The server pushes price changes automatically. All fields are at the **top level** (not nested under a `data` key): ```json theme={null} { "type": "price", "market_id": "0xabc123...", "condition_id": "0xabc123...", "buy": "0.67", "sell": "0.33", "best_bid": "0.66", "best_ask": "0.68", "last_trade": "0.67", "volume": "125000.50", "outcomes": [ {"label": "Yes", "price": "0.67", "token_id": "71321..."}, {"label": "No", "price": "0.33", "token_id": "71322..."} ], "tokens": { "71321...": {"label": "Yes", "price": "0.67"}, "71322...": {"label": "No", "price": "0.33"} }, "active": true, "closed": false, "source": "websocket", "emit_ts_ms": 1715518800250, "updated_at": "2026-05-11T20:30:45.250000+00:00", "ws_updated_at": "2026-05-11T20:30:45.250000+00:00" } ``` The server broadcasts the cached `price:{condition_id}` payload with `type` and `market_id` stamped on top. Most fields are conditional — the exact set depends on whether the price came from the Gamma poller (REST), the CLOB WebSocket, the RTDS feed, or on-demand fetches during order placement. Always treat extra/missing fields defensively. | Field | Type | Always present | Description | | --------------- | ------ | :------------: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `type` | string | yes | Always `"price"` for price updates | | `market_id` | string | yes | Polymarket condition\_id | | `condition_id` | string | yes | Same value as `market_id` — emitted for backward-compat with consumers that key off `condition_id` | | `emit_ts_ms` | int | yes | Server wall-clock (milliseconds since epoch) stamped at the moment the frame is broadcast. Integer (not stringified). Purpose-built for HFT latency — compute end-to-end lag as `recv_ts_ms - emit_ts_ms`. See [Latency telemetry](#latency-telemetry-for-hft-bots) below. | | `source` | string | yes | Provenance of the cached snapshot — see [Source values](#source-values) below. | | `updated_at` | string | yes | ISO 8601 backend wall-clock when the price was written to the cache. *Not* an upstream Polymarket event timestamp. | | `outcomes` | array | yes | Per-outcome `{label, price, token_id}` breakdown | | `volume` | string | yes | 24h trading volume in USD | | `buy` | string | usually | Yes (first outcome) price (0–1). Absent if the outcome had no resolvable price. | | `sell` | string | usually | No (second outcome) price (0–1). Absent if the outcome had no resolvable price. | | `tokens` | object | usually | Per-outcome data keyed by `token_id` — convenience index for bots that already track tokens. Each value is `{label, price}`. Present whenever the cache writer had `token_id`s available (Gamma poller, CLOB WS); absent on a small number of Up/Down discovery writes. | | `best_bid` | string | sometimes | Best bid price on the order book. Set by CLOB WS writes and Gamma poller when `bestBid` is present upstream. | | `best_ask` | string | sometimes | Best ask price on the order book. Same provenance as `best_bid`. | | `last_trade` | string | sometimes | Most recent trade price observed on the CLOB WS feed. Absent until the first trade is seen for the market. | | `active` | bool | sometimes | Market `active` flag (mirrors the Gamma metadata). Present when the writer had it; drop frames where `active=false` if you care. | | `closed` | bool | sometimes | Market `closed` flag (mirrors the Gamma metadata). Frames may still arrive briefly after resolution; ignore them. | | `ws_updated_at` | string | sometimes | ISO 8601 wall-clock of the most recent WS-source cache write. Present when `source="websocket"` or `"rtds_websocket"`; absent for Gamma-poll-only snapshots. | ### Source values The `source` field marks which writer last touched the cache key: | Value | Writer | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `gamma_poller` | Default for the Gamma REST poller — used when no later WS write happened. | | `websocket` | CLOB WebSocket write (book updates, last-trade events, on-demand midpoint poll for viewed conditions). | | `rtds_websocket` | Polymarket RTDS WS write (Up/Down side-keyed price stream). | | `clob_on_demand` | One-off CLOB fetch during order placement (lazy refresh when the cached snapshot is stale). | | `updown_discovery_clob` | Initial Up/Down market discovery — written when the poller first surfaces a new Up/Down nested market. | | `updown_resolved_*` | Settled Up/Down market — the suffix records the resolution source (e.g. `updown_resolved_chainlink_onchain`). Both `buy` and `sell` will be 0 or 1; new orders should not be placed against the market. | Bots that want only CLOB-WS-fresh prices can filter on `source === "websocket"` (or `"rtds_websocket"`); bots that just want "some price" can accept any source but drop `updown_resolved_*` frames. ### Latency telemetry for HFT bots Every price frame carries `emit_ts_ms` — an always-present integer wall-clock (milliseconds since epoch) stamped at the moment the frame is broadcast. It is purpose-built for end-to-end latency measurement: subtract it from your local receive time directly, with no parsing. ```python theme={null} import json, time msg = json.loads(raw_frame) recv_ts_ms = int(time.time() * 1000) observed_lag_ms = recv_ts_ms - msg["emit_ts_ms"] ``` Typical `observed_lag_ms` is 80–500 ms; sustained values above \~2,000 ms indicate backend overload or a TCP retransmission storm on the client side. `updated_at` is a *different* timestamp — it is stamped backend-side when the price was written to the cache, which **precedes** the broadcast. Use it only for backend-internal lag: the gap between the cache write and the broadcast is `emit_ts_ms - (updated_at parsed to ms)`. For latency that matters to your strategy, prefer `emit_ts_ms`. For the upstream lag (Polymarket book event → PolySim cache write), watch the server-side gauge `polysim_clob_token_last_msg_received_timestamp_seconds` in Prometheus rather than computing it client-side. **Recommended bot logic**: drop any quote where `observed_lag_ms > 2000`. For 5-min Up/Down crypto-timer markets, tighten that to 500 ms — these markets resolve in minutes and you don't want to trade on stale signal. *** ## Tick-size changes Polymarket shrinks the minimum price tick from `0.01` to `0.001` when a market crosses certain thresholds (price `> 0.96` or `< 0.04`). UpDown 5-minute crypto markets — our headline product — spend much of their last-minute lives in those ranges, so unhandled tick changes mean bot quoters silently emit orders at the wrong precision. `tick_size_change` frames are **NOT delivered on this `/v1/ws/prices` WebSocket.** When the upstream CLOB WS publishes a `tick_size_change`, the backend broadcasts it only to subscribers of the SSE `/prices/stream` feed — a separate manager. The JWT `/v1/ws/prices` feed emits `type:"price"` frames only and has no tick-change path. For tick changes on a WS bot, **poll `GET /v1/tick-size/{token_id}`** (it consults the WS-fresh cache first, then the DB-synced value) or consume the SSE `/prices/stream` feed alongside your WS price feed. On the SSE `/prices/stream` feed, the `tick_size_change` frame looks like this (shown for reference — this is the SSE shape, not a `/v1/ws/prices` frame): ```json theme={null} { "event": "tick_size_change", "data": { "type": "tick_size_change", "market": "0xabc123...", "asset_id": "71321...", "tick_size": 0.001, "old_tick_size": 0.01, "side": "BUY", "ts_ms": 1715518800123 } } ``` | Field | Type | Description | | --------------- | ------------ | ---------------------------------------------------------------------------------------------------- | | `type` | string | Always `"tick_size_change"` | | `market` | string | Condition\_id of the market the change applies to. Nullable if the upstream payload didn't carry it. | | `asset_id` | string | CLOB token\_id — the per-outcome key. The tick change is per-token, not per-market. | | `tick_size` | number | Post-change minimum tick (e.g. `0.001`). Use this for all subsequent quote rounding. | | `old_tick_size` | number\|null | Pre-change tick if the upstream payload included it; otherwise `null`. | | `side` | string\|null | `"BUY"`, `"SELL"`, or `null`. PM optionally narrows the change to one side. | | `ts_ms` | int | Server wall-clock at broadcast (milliseconds since epoch). | The two directions have asymmetric correctness consequences: * **Grow (0.001 → 0.01)** — previously-valid 0.001-step quotes are no longer multiples of the new tick. PM rejects with `INVALID_ORDER_MIN_TICK_SIZE`. This is the case where missing the change silently breaks your bot. * **Shrink (0.01 → 0.001)** — 0.01-step quotes are still valid multiples of 0.001, so orders aren't rejected. The downside is quote-competitiveness: other bots that respect the finer grid can undercut you by 0.001 increments while you still quote at 0.01. PolySim's matching engine reads the same `markets.minimum_tick_size` column that gets refreshed on Gamma sync, so off-grid orders on grow transitions are usually rejected here too — but with WS-fresh values you avoid the gap between the price moving and the next column refresh. You can also read the current tick at any time via `GET /v1/tick-size/{token_id}` — that endpoint consults the WS-fresh cache first, then falls back to the DB-synced value. *** ## Unsubscribe ```json theme={null} {"action": "unsubscribe", "markets": ["0xabc123..."]} ``` *** ## Complete Example ```python Python (asyncio) theme={null} import asyncio import json import requests import websockets API_KEY = "ps_live_..." BASE = "https://api.polysimulator.com/v1" # Mint WS token ws_token = requests.post( f"{BASE}/keys/ws-token", headers={"X-API-Key": API_KEY}, ).json()["token"] async def stream_prices(): async with websockets.connect( f"wss://api.polysimulator.com/v1/ws/prices?token={ws_token}" ) as ws: # Subscribe to markets await ws.send(json.dumps({ "action": "subscribe", "markets": ["0xabc123...", "0xdef456..."], })) async for raw in ws: msg = json.loads(raw) if msg["type"] == "price": mid = msg["market_id"][:16] buy = msg["buy"] print(f"{mid}... → Yes: {buy}") asyncio.run(stream_prices()) ``` ```python Python (websocket-client) theme={null} import json import requests import websocket API_KEY = "ps_live_..." BASE = "https://api.polysimulator.com/v1" WS_URL = "wss://api.polysimulator.com/v1" token = requests.post( f"{BASE}/keys/ws-token", headers={"X-API-Key": API_KEY}, ).json()["token"] def on_message(ws, message): data = json.loads(message) if data["type"] == "price": print(f"{data['market_id'][:16]}... → Yes: {data['buy']}") elif data["type"] == "connected": ws.send(json.dumps({ "action": "subscribe", "markets": ["0xabc123...", "0xdef456..."], })) ws = websocket.WebSocketApp( f"{WS_URL}/ws/prices?token={token}", on_message=on_message, ) ws.run_forever() ``` ```javascript JavaScript theme={null} const API_KEY = "ps_live_..."; const BASE = "https://api.polysimulator.com/v1"; // Mint WS token const tokenResp = await fetch(`${BASE}/keys/ws-token`, { method: "POST", headers: { "X-API-Key": API_KEY }, }); const { token } = await tokenResp.json(); const ws = new WebSocket( `wss://api.polysimulator.com/v1/ws/prices?token=${token}` ); ws.onopen = () => { ws.send(JSON.stringify({ action: "subscribe", markets: ["0xabc123...", "0xdef456..."], })); }; ws.onmessage = (event) => { const msg = JSON.parse(event.data); if (msg.type === "price") { console.log(`${msg.market_id}: Yes=${msg.buy}`); } }; ``` *** ## Next Steps * [Execution Feed](/websockets/execution-feed) — Get order fill notifications * [WebSocket Bot](/bots/websocket-bot) — Build a real-time trading bot