Polytraders Dev Guide
internal
Phase 1 · 9/109 wired

User bots

Users author the bots that trade. Their code and their config are untrusted input, in the same sense that an HTTP request body is untrusted input. This page defines the contract they write against, the sandbox it runs in, and the budgets it cannot exceed.

Two contracts, not one. Bot<I,O> is the contract for bots we write. It is broad, and its rules ("must not submit orders directly") are conventions enforced by code review, which is appropriate for code we own. UserStrategy is the contract for bots users write. Its rules are enforced by the runtime, because there is no code review.

Two authoring tiers

TierAuthor writesExpressivenessReviewStatus
Tier 1 — declarative rules.yaml Bounded. Conditions, entries, take-profit rungs, caps, kill-switch bindings. Automatic. Schema validation plus a static bounds check. v1 target
Tier 2 — sandboxed code A UserStrategy module Arbitrary decision logic within the sandbox. Automatic static checks, then a shadow soak before it can go live. later tier

Build Tier 1 first. It covers the majority of what users ask for, and it is the only tier where the safety argument is straightforward: a declarative rule set cannot execute arbitrary code, so the sandbox is a bounds check rather than an isolate.

Tier 1 — rules.yaml

The user's bot definition. Version-controlled per user, diffable, and the artefact an audit refers to. Every field maps to a guardrail that already exists in the library, which is the point: the authoring format cannot express something the chain cannot check.

// rules.yaml — one user bot
version: 1
name: nfl-favourite-fade
market_scope:
  venue: polymarket
  cluster: nfl-2026-regular          # or an explicit market id list, cap 50
  exclude_resolving_within: 2h

entry:
  when:                              # ALL must hold; each maps to a signal service
    - signal: implied_prob
      of: favourite
      above: 0.72
    - signal: book_depth_usd
      at_touch: { min: 2500 }
    - signal: minutes_to_kickoff
      between: [30, 240]
  side: sell
  size:
    type: pct_of_bankroll            # never an absolute — bankroll is the user's
    value: 2.0
    max_usd: 500                     # hard ceiling, must be ≤ platform per-bot cap

scale_out:                           # take-profit rungs, evaluated in order
  - at_pnl_pct: 8   sell_pct: 40
  - at_pnl_pct: 15  sell_pct: 40
  - at_pnl_pct: 25  sell_pct: 20

risk:                                # user tunes DOWN from platform floor, never up
  max_open_positions: 6
  max_exposure_pct: 12
  max_drawdown_pct: 10               # breach ⇒ bot pauses, user is notified
  max_correlated_exposure_pct: 18    # see intel.crossmarketgraph

kill_switch:
  bind:                              # conditions that stop THIS bot immediately
    - venue_degraded
    - oracle_disputed
    - user_manual
  on_trigger: flatten                # flatten | hold | cancel_open_only

notify:
  on: [entry, scale_out, guardrail_reject, kill_switch, drawdown_warning]
  channel: [in_app, email]

Validation rules

Tier 2 — the UserStrategy contract

Deliberately smaller than Bot<I,O>. Note what is absent: no authority field (a user bot has exactly one authority level, propose), no setMode (the platform owns mode), no emit (the platform emits on the user's behalf so envelopes cannot be forged).

interface UserStrategy {
  // Identity — assigned by the platform, not the author
  readonly userBotId: string;
  readonly version: string;

  // The only hook. Pure. Deterministic. Budgeted.
  decide(
    input: UserStrategyInput,      // pre-filtered, pre-validated snapshot
    ctx: UserStrategyContext
  ): Promise<Proposal | null>;    // null = do nothing, the common case
}

interface UserStrategyInput {
  markets: ReadonlyArray<MarketSnapshot>;   // only markets in declared scope
  positions: ReadonlyArray<Position>;       // only THIS bot's positions
  signals: Readonly<Record<string, Signal>>; // only declared signals
}

interface UserStrategyContext {
  now(): number;                 // platform clock. Date.now() is unavailable
  bankroll(): number;            // the slice allocated to this bot, not the wallet
  log(msg: string): void;        // rate-limited, goes to the user's own log only
  // Deliberately absent: fetch, require, process, venue clients, signers,
  // other bots' positions, other users' anything, secrets, the filesystem.
}

// A Proposal is NOT an order. It is a request to be considered.
interface Proposal {
  marketId: string;              // must be in declared scope or hard reject
  side: "buy" | "sell";
  sizePctOfBankroll: number;     // platform converts to absolute
  limitPrice?: number;
  rationale: string;             // shown to the user, required, max 280 chars
}

Sandbox budgets

Enforced by the runtime, not by review. Every breach emits a reason code and is visible to the user on their own bot page.

BudgetLimitOn breachReason code
CPU per decide50 msAbort the call, treat as null, count a strikeUSER_DECIDE_TIMEOUT
Memory per isolate64 MBKill the isolate, pause the botUSER_MEMORY_EXCEEDED
Decisions per minute60Throttle, then pause on sustained breachUSER_RATE_LIMITED
Proposals per hour30Reject further proposals, notify the userUSER_PROPOSAL_FLOOD
Consecutive strikes5Quarantine the bot, require the user to re-enableUSER_BOT_QUARANTINED
Log volume200 lines / minDrop and mark the log truncatedUSER_LOG_TRUNCATED

Budgets are per user bot, not per user. One user running twenty bots gets twenty budgets — which is why the per-account aggregate cap in RiskGate matters independently.

Determinism, because backtests have to mean something

A user bot must produce the same proposals given the same input. If it does not, its backtest is not evidence and its shadow soak proves nothing. Enforced by running every decide twice on a sample of ticks and comparing:

Lifecycle of a user bot

  1. Authored. User writes rules.yaml in the builder, or uploads a Tier-2 module. Schema validation and bounds checks run on save.
  2. Backtested. Runs against retained order-book depth. Mid-price-only results are not accepted as evidence. The user sees fills, fees, and slippage.
  3. Shadow. Runs live against real data, proposals evaluated by the full guardrail chain, nothing signed. The user sees exactly what would have happened, including every reject.
  4. Live, capped. Signing enabled, but the per-bot cap starts low regardless of what the user asked for, and ratchets up on clean history.
  5. Live. The user's configured caps apply, still floored by the platform.
  6. Paused or quarantined. By the user, by a drawdown breach, by a strike count, or by a kill-switch binding. Re-enabling is always an explicit user action.

The state names deliberately mirror the promotion ladder we use for our own bots. Same vocabulary, same gates, different owner.

Forbidden, and enforced rather than requested

the four invariants · these are not negotiable

  1. 1

    The platform never holds user funds.

    Keys stay in the user's wallet. There is no platform-controlled omnibus account, no internal ledger of custodied balances, and no code path that can move a user's assets without a signature the user authorised. If a feature needs custody to work, it does not ship.

  2. 2

    A user bot never reaches the venue directly.

    User-authored logic produces a proposal. It never holds a venue client, an API key, or a signer. Exactly one component signs and submits, and it is ours.

  3. 3

    Guardrails cannot be configured away.

    The user tunes thresholds inside bounds we set. The user cannot remove a guardrail from the chain, reorder the chain, or lower a hard cap below the platform floor.

  4. 4

    AI never holds Trade authority.

    Model output is advisory. An AI suggestion enters the same RiskGate as any bot intent and is subject to the same rejects. There is no path from a model response to a signed order that skips the chain.

If a design requires breaking one of these, the design is wrong. Escalate before writing code — do not work around it. See RiskGate and the signing model.