# PaperTiger API

PaperTiger is a paper-trading API for Polymarket and Kalshi. It discovers
markets, reads public order books, simulates execution, and maintains virtual
wallets, orders, fills, positions, activity, equity, and profit and loss.

No endpoint submits a real-money order.

- Production API: `https://api.papertigerapp.com`
- Interactive documentation: `https://papertigerapp.com/docs`
- Canonical Markdown: `https://papertigerapp.com/api.md`
- LLM discovery: `https://papertigerapp.com/llms.txt`

## API capabilities

PaperTiger provides the following API capabilities today.

### Brokerage and account foundation

- Canonical instrument input. Clients submit venue, market ID, and outcome ID;
  PaperTiger fetches authoritative market metadata instead of trusting
  client-supplied titles, URLs, categories, or close times.
- Stable order lifecycle with client order IDs, server order IDs, states,
  timestamps, requested, filled, and remaining quantities.
- Safe order retries with `Idempotency-Key`.
- Immutable fills and wallet activity for reconciliation.
- Wallet buying power and reserved cash.
- Position reserved and available quantities.
- Structured errors, request IDs, credential-scoped rate limiting, bounded
  queries, and keyset pagination.
- Conservative live marks with last-known-good fallback and stale-mark flags.
  Expired holdings are reported as closed while awaiting authoritative
  settlement; an unmarked pending holding is carried at cost instead of being
  presented as a total unrealized loss.
- Minute-bucketed equity snapshots written only when values change for
  portfolio risk metrics. Dashboard history replays immutable fills and
  settlements at their business timestamps instead of plotting polling marks.
- Background reconciliation for open orders and market settlement.
- API keys with an editable default wallet and either default-wallet-only or
  all-wallet access.
- Explicit wallet- and account-level API-key authority, with account lifecycle
  management under `/v1/accounts`.

### Execution and account reads

- Market and limit orders.
- Buy orders sized by contract quantity or a maximum all-in dollar spend.
- Ordered bulk placement, replacement, and cancellation with item-level
  results.
- Fill-or-kill (`fok`), immediate-or-cancel (`ioc`), and good-til-canceled
  (`gtc`) instructions.
- Partial fills for limit orders using a conservative share of available public
  book depth.
- Durable cancellation explanations: an IOC order distinguishes no executable
  price from a partially filled remainder, and immediate FOK orders report
  unavailable or insufficient displayed depth.
- Get, list, filter, replace, and cancel orders.
- Get and list positions; close an available position with a simulated market
  sell.
- Per-order fills, paginated account activity, and bounded portfolio history.
- Dashboard controls for limit price and time in force, open-order
  cancellation, order status, buying power, and equity history.

### Strategy backtesting

- Versioned declarative strategies for recurring 5-minute and 15-minute crypto
  market families on Polymarket and Kalshi.
- Backward-compatible V1 top-of-book strategies plus V2 weighted L2 signals:
  depth and order-flow imbalance, aggressive trades, microprice deviation,
  persistence, resilience, ephemeral-liquidity penalty, reference momentum,
  and normalized target distance.
- V2 ask/bid depth walking after configurable latency, per-level displayed
  participation, slippage caps, exact venue fee curves, and a minimum expected
  net-edge gate after spread, slippage, and round-trip fees.
- Dynamic executable-bid take-profit/stop-loss, signal reversal, pressure
  decay, maximum holding time, market-close deadline, and portfolio limits.
- Synchronous authenticated runs of up to 31 days and 500,000 market-data
  events, with immutable strategy versions and frozen market-data watermarks.
- Equity curves, complete trade statistics, decision rejection reasons,
  component/book/flow diagnostics, and 250 ms/1 s/3 s/10 s markouts.
- A responsive `/backtests` dashboard form plus a `gpt-5.6-luna` natural
  language form builder. LLM token and cost usage is returned with each
  generation.

### Experiments and LLM tooling

- Falsifiable experiments with success criteria, guardrails, lineage, immutable
  strategy versions, historical backtests, and external-paper runs.
- Retry-safe decision-event ingestion covering candidates, rejections,
  qualifications, submissions, fills, invalidations, and exits.
- Deterministic run analysis with rejection funnels, fill rate, gross P&L,
  fees, net P&L, and sample-size warnings.
- Two-to-ten-run comparisons and bounded reproducibility exports containing the
  experiment, exact strategy version, run, decisions, analysis, and historical
  result when applicable.
- A hosted Streamable HTTP MCP endpoint at
  `https://api.papertigerapp.com/mcp`, packaged with the PaperTiger
  ChatGPT/Codex plugin and available to Claude custom connectors. See the
  [user integration guide](https://papertigerapp.com/integrations) and
  [MCP technical guide](https://papertigerapp.com/mcp.md).
- OAuth 2.1 authorization-code linking with PKCE S256, resource-bound one-hour
  access tokens, and rotating 30-day refresh tokens. The
  `papertiger:account` grant carries full virtual-account authority while
  keeping raw account API keys outside model context.

Streaming is not currently available. Open GTC orders are
checked in small bounded batches every 10 seconds. Market lifecycle maintenance
wakes at the persisted expiry and rechecks closed-but-unresolved positions every
10 seconds; positions without an expiry use a 60-second fallback. Reads use short-lived in-process market-data
caches and in-flight request deduplication. This avoids a persistent quote
table, a job table, and unnecessary venue or database traffic.

## Quick start

Create an account at
[papertigerapp.com](https://papertigerapp.com), confirm the
account email, sign in, and create an API key. The secret is displayed once.
The key initially accesses only the wallet selected at creation. You can
change its default wallet or grant it all-wallet access later.

Install the official type-safe TypeScript package:

```bash
pnpm add @discomedia/papertiger
```

```bash
npm install @discomedia/papertiger
```

```ts
import { PaperTigerClient } from "@discomedia/papertiger";

const apiKey = process.env.PAPERTIGER_API_KEY;
if (apiKey === undefined) {
  throw new Error("PAPERTIGER_API_KEY is required.");
}

const paperTiger = new PaperTigerClient({
  apiKey,
});

const wallets = await paperTiger.listWallets();
const { markets, warnings } = await paperTiger.searchMarkets({
  q: "bitcoin",
  venue: "all",
});
```

The package contains ESM, CommonJS, TypeScript declarations, this complete API
reference at the `@discomedia/papertiger/api.md` export, and LLM discovery
guidance at `@discomedia/papertiger/llms.txt`.

For raw HTTP access, export the same API key:

```bash
export PAPERTIGER_API_KEY="pt_key_replace_me"
```

List wallets:

```bash
curl --fail-with-body \
  --header "X-API-Key: ${PAPERTIGER_API_KEY}" \
  https://api.papertigerapp.com/v1/wallets
```

Search markets:

```bash
curl --fail-with-body \
  --get \
  --header "X-API-Key: ${PAPERTIGER_API_KEY}" \
  --data-urlencode "q=bitcoin" \
  --data-urlencode "venue=all" \
  https://api.papertigerapp.com/v1/markets/search
```

Place a GTC limit order with canonical identifiers from search:

```bash
curl --fail-with-body \
  --request POST \
  --header "X-API-Key: ${PAPERTIGER_API_KEY}" \
  --header "Idempotency-Key: replace-with-a-uuid" \
  --header "Content-Type: application/json" \
  --data '{
    "venue": "polymarket",
    "marketId": "canonical-market-id",
    "outcomeId": "canonical-outcome-id",
    "side": "buy",
    "quantity": 10,
    "type": "limit",
    "timeInForce": "gtc",
    "limitPricePercent1": 0.54,
    "clientOrderId": "strategy-a-2026-07-26-001"
  }' \
  https://api.papertigerapp.com/v1/orders
```

## Authentication

Every `/v1` route except contact, registration, email confirmation, and login
requires one of:

```http
X-API-Key: pt_key_...
```

```http
Authorization: Bearer pt_session_...
```

The hosted MCP also accepts resource-bound OAuth bearer tokens:

```http
Authorization: Bearer pt_oauth_...
```

OAuth discovery is available at
`/.well-known/oauth-authorization-server` and
`/.well-known/oauth-protected-resource`. The public plugin client uses
dynamic public-client registration at `/oauth/register`, followed by
authorization code with PKCE S256. OAuth account grants have full PaperTiger
account authority, including virtual-wallet lifecycle and paper execution, but
cannot reach real venue order endpoints because PaperTiger has none.

## Experiment endpoints

- `GET /v1/experiments?limit=50`
- `POST /v1/experiments`
- `GET /v1/experiments/{experimentId}`
- `POST /v1/experiments/{experimentId}/strategy-versions`
- `POST /v1/experiments/{experimentId}/backtests`
- `POST /v1/experiments/{experimentId}/runs`
- `GET /v1/runs?experimentId={experimentId}&limit=50`
- `GET /v1/runs/{runId}`
- `POST /v1/runs/{runId}/decisions:batch`
- `GET /v1/runs/{runId}/analysis`
- `POST /v1/runs/compare`
- `GET /v1/runs/{runId}/export`

External decision batches accept 1–500 events and use
`eventKey` as a run-local idempotency key. External-paper runs are registered
by externally hosted strategies and can submit their own decision evidence.

API keys are intended for server integrations. Browser sessions use a rolling
30-day lifetime from recent authenticated activity. Logout invalidates the
supplied session. API keys remain valid until revoked.

Every API key has an editable default wallet and an access scope:

- `wallet`: only the default wallet can be accessed.
- `all`: every wallet owned by the account can be accessed.

Keys also have an authority `level`:

- `wallet`: trading and read access only. The key cannot create, modify, reset,
  or destroy paper accounts and cannot manage API keys.
- `account`: account-management authority. The key is forced to `access:
  "all"` and may use the account-management and API-key endpoints.

Existing and newly created keys default to `level: "wallet"`. A browser
session or an existing account-level key must explicitly create or promote an
account-level key.

For wallet-scoped API-key requests, omit both wallet selectors to use the
default wallet. To select explicitly, supply either `walletId` or the exact,
case-sensitive `walletName`, never both. An explicit wallet must be within the
key's access scope or PaperTiger returns `403 wallet_access_denied`. Browser
sessions must supply a wallet selector on the selector-based routes because
browser selection state stays in the browser.

## Request conventions

- JSON bodies use `Content-Type: application/json`.
- Timestamps are ISO 8601 UTC strings.
- Dollar fields are decimal US dollars, not cents.
- `Percent1` means scale 0–1; `0.64` is 64%.
- `Percent100` means scale 0–100; `5` is 5%.
- Quantity may be fractional and is persisted as integer micros.
- Server resource IDs are UUIDs. Venue IDs remain venue-native strings.
- Every response includes `X-Request-ID`. A valid caller-provided
  `X-Request-ID` is preserved; otherwise PaperTiger generates one.
- Authenticated rate limits are scoped to the API key or session rather than a
  shared client IP.
- Wallet selectors are `walletId` or exact `walletName`. API keys may omit
  both to use their default wallet.

### Idempotency

Send a unique `Idempotency-Key` header when placing an order. Repeating the
same request body, wallet, and key returns the original order without another
reservation or fill. Reusing the key with a different request returns `409`.

`clientOrderId` is separately unique within a wallet and is returned on every
order. Use it to reconcile your own strategy state; use `Idempotency-Key` to
make a specific HTTP attempt safe to retry.

### Pagination

Order and activity lists use opaque keyset cursors. Supply the returned
`nextCursor` unchanged in the next request and keep the same filters, including
time bounds. Page sizes are bounded to 200.

Order, position, and activity lists accept optional inclusive `start` and `end`
ISO 8601 timestamps. `start` must not be later than `end`. Orders are filtered
by `createdAt`, positions by `createdAt`, and activities by `occurredAt`.

### Error format

```json
{
  "code": "conflict",
  "message": "The wallet does not have enough paper cash.",
  "requestId": "7b65c19d-920f-4c99-85d0-34c4bb85253a"
}
```

`details` is present only when useful, such as validation issues.

| Status | Meaning |
| --- | --- |
| `200` | Request succeeded |
| `201` | Resource created |
| `202` | Public message accepted for delivery |
| `204` | Request succeeded with no response body |
| `400` | Request or query validation failed |
| `401` | Credential is absent, invalid, expired, or revoked |
| `403` | Account email must be confirmed, or the API key lacks wallet or account-management authority |
| `404` | Resource does not exist or is not owned by the caller |
| `409` | State conflict, protected account deletion, duplicate key, insufficient cash/contracts, or liquidity failure |
| `429` | Credential rate limit exceeded |
| `500` | PaperTiger could not complete the request |

## Resource models

### Wallet

```json
{
  "id": "uuid",
  "name": "Main",
  "startingBalanceDollars": 10000,
  "cashBalanceDollars": 9459.2,
  "reservedCashDollars": 100,
  "buyingPowerDollars": 9359.2,
  "positionsValueDollars": 552,
  "equityDollars": 10011.2,
  "totalProfitDollars": 11.2,
  "performance": {
    "totalProfitPercent100": 0.112,
    "dailyGrowthRatePercent100": 0.0003,
    "annualizedGrowthRatePercent100": 0.11,
    "maxDrawdownDollars": 48.5,
    "maxDrawdownPercent100": 0.48,
    "sharpeRatio": 1.24
  },
  "config": {
    "currency": "USD",
    "maxSlippagePercent100": 5,
    "feeOverrides": {}
  },
  "createdAt": "2026-07-26T00:00:00.000Z"
}
```

Wallet list/create/update responses omit marked portfolio fields. Fetch the
portfolio for current equity and P&L.

`performance` is returned with a marked portfolio. Growth rates are compounded
from the wallet's creation date; annualized growth uses a 365-day calendar year
and is withheld until 90 days of history.
Maximum drawdown is the largest observed peak-to-trough equity loss. Sharpe uses
daily equity returns, a zero risk-free rate, and 365-day annualization; it is
`null` until there are at least 30 observed daily returns.

### Order

```json
{
  "id": "uuid",
  "walletId": "uuid",
  "clientOrderId": "strategy-a-001",
  "status": "partially_filled",
  "type": "limit",
  "timeInForce": "gtc",
  "side": "buy",
  "venue": "polymarket",
  "marketId": "venue-market-id",
  "outcomeId": "venue-outcome-id",
  "marketTitle": "Will the event happen?",
  "outcomeName": "Yes",
  "marketUrl": "https://polymarket.com/event/example",
  "quantity": 10,
  "filledQuantity": 4,
  "remainingQuantity": 6,
  "limitPricePercent1": 0.54,
  "executionFloorPricePercent1": 0.5,
  "averageFillPricePercent1": 0.53,
  "feeDollars": 0,
  "grossDollars": 2.12,
  "createdAt": "2026-07-26T00:00:00.000Z",
  "updatedAt": "2026-07-26T00:00:10.000Z",
  "canceledAt": null,
  "rejectionCode": null,
  "rejectionMessage": null
}
```

Order status is one of `accepted`, `partially_filled`, `filled`, `canceled`,
`expired`, or `rejected`.
For canceled orders, `rejectionCode` and `rejectionMessage` contain the stable
cancellation cause and readable explanation. Older orders created before this
field was populated can return `null` for both values.

### Fill

```json
{
  "id": "uuid",
  "orderId": "uuid",
  "quantity": 4,
  "pricePercent1": 0.53,
  "grossDollars": 2.12,
  "feeDollars": 0,
  "bookObservedAt": "2026-07-26T00:00:09.800Z",
  "createdAt": "2026-07-26T00:00:10.000Z"
}
```

### Position

```json
{
  "id": "uuid",
  "walletId": "uuid",
  "venue": "polymarket",
  "marketId": "venue-market-id",
  "outcomeId": "venue-outcome-id",
  "marketTitle": "Will the event happen?",
  "outcomeName": "Yes",
  "marketUrl": "https://polymarket.com/event/example",
  "closesAt": "2026-08-01T00:00:00.000Z",
  "marketStatus": "open",
  "quantity": 10,
  "reservedQuantity": 2,
  "availableQuantity": 8,
  "averageEntryPricePercent1": 0.54,
  "acquiredQuantity": 10,
  "averageAcquisitionPricePercent1": 0.53,
  "orderPlacedAt": "2026-07-26T00:00:09.000Z",
  "orderType": "market",
  "filledAt": "2026-07-26T00:00:10.000Z",
  "costBasisDollars": 5.4,
  "markPricePercent1": 0.56,
  "markObservedAt": "2026-07-26T00:01:00.000Z",
  "markIsStale": false,
  "marketValueDollars": 5.6,
  "unrealizedProfitDollars": 0.2,
  "realizedProfitDollars": 0,
  "feesPaidDollars": 0,
  "settlementPricePercent1": null,
  "settledAt": null,
  "createdAt": "2026-07-26T00:00:09.000Z",
  "updatedAt": "2026-07-26T00:01:00.000Z"
}
```

Marks use the best public bid, which is a conservative liquidation value.
When a venue read fails, PaperTiger returns the last known mark and a warning.
`markIsStale` tells clients not to treat that mark as current.
Once `closesAt` passes, the position reports `marketStatus: "closed"` even
before its final payout is available. A pending position with no executable
mark is carried at cost for wallet equity and unrealized P&L until explicit
venue winner evidence arrives. Automatically resolved Polymarket Chainlink
Up/Down contracts can finalize from the venue page's completed opening and
final-price response. Other Polymarket finalization accepts Gamma's resolved
state or the public CLOB market's single explicit winner. Close time alone never
selects a winner. `settledAt` is the time PaperTiger actually credited the
wallet, while the upstream resolution timestamp is retained in settlement
evidence.

Positions aggregate all fills for one wallet, venue, market, and outcome.
`acquiredQuantity` is the lifetime quantity from filled buy orders, including
units later sold or settled. `averageAcquisitionPricePercent1` is their
volume-weighted execution price before fees. `orderPlacedAt` is the first filled
buy order's creation time, and `filledAt` is the most recent buy fill time.
`orderType` is `market`, `limit`, or `mixed` when both order types contributed
to the aggregate position.

## Health

### `GET /health/live`

Unauthenticated and database-free so liveness probes do not wake Neon.

```json
{
  "status": "ok",
  "service": "papertiger-api"
}
```

## Authentication endpoints

### `POST /v1/contact`

Unauthenticated, rate-limited support and beta-feedback form:

```json
{
  "name": "Beta Tester",
  "email": "trader@example.com",
  "topic": "feedback",
  "message": "The paper-order workflow is useful, but I found...",
  "website": ""
}
```

`topic` is `support`, `feedback`, `account`, `privacy`, or `security`.
`website` is an anti-spam honeypot and should be omitted or empty. Returns
`202` after the message is accepted.

### `POST /v1/auth/register`

Body:

```json
{
  "email": "trader@example.com",
  "password": "at-least-12-characters",
  "eligibilityAcknowledged": true
}
```

`eligibilityAcknowledged` must be exactly `true`. It confirms that the user is
at least 13 and, when under the age of majority where they live, has permission
from a parent or legal guardian. Creates an unconfirmed account, records the
acknowledgement time, and sends a confirmation email.

### `POST /v1/auth/confirm-email`

```json
{
  "token": "confirmation-token"
}
```

Sets a secure, HTTP-only session cookie and returns `{ "user": ... }`.

### `POST /v1/auth/login`

Accepts only `email` and `password`, sets a secure HTTP-only session cookie, and returns a user. Sessions roll for up to 30 days.

### `POST /v1/auth/logout`

Invalidates the browser session cookie and returns `204`.

### `GET /v1/me`

Returns the authenticated user.

### `PATCH /v1/me/password`

Requires a browser session. Email addresses are immutable for now. Confirm the
existing password and provide a replacement password, each 12–200 characters:

```json
{ "currentPassword": "current-password", "newPassword": "new-password" }
```

Returns `204`.

### `DELETE /v1/me`

Requires a browser session and the current password:

```json
{ "password": "current-password" }
```

Permanently deletes the user and all of their PaperTiger data, including
sessions, API keys, wallets, orders, positions, activities, strategies, and
experiments, clears the session cookie, and returns `204`. Administrator
accounts must be managed by another administrator and cannot self-delete.

## Paper-account and wallet endpoints

Paper accounts are the same resources historically called wallets. Account and
wallet identifiers are interchangeable; the `/v1/accounts` surface provides
explicit lifecycle management while the existing `/v1/wallets` routes remain
backward-compatible.

Every mutation in this section requires a browser session or an account-level
API key.

### `GET /v1/accounts`

Returns `{ "accounts": [...] }` for every paper account owned by the user.

### `POST /v1/accounts`

```json
{
  "name": "Research",
  "startingBalanceDollars": 10000
}
```

Returns the created resource as `{ "account": {...} }`.

### `PATCH /v1/accounts/:accountId`

Accepts the same `name` and `config` fields as the wallet update route and
returns `{ "account": {...} }`.

### `POST /v1/accounts/:accountId/reset`

Restores starting cash and permanently clears positions, orders, fills,
activity, and portfolio history. Returns the reset account identity and cash
balance.

### `DELETE /v1/accounts/:accountId`

Permanently destroys the paper account and all of its trading history. An
account must retain at least one paper account. A paper account cannot be
destroyed while an active API key uses it as its default; change that key's
`defaultWalletId` or revoke it first. Success returns `204`.

### `GET /v1/wallets`

Returns `{ "wallets": [...] }`.

### `POST /v1/wallets`

```json
{
  "name": "Research",
  "startingBalanceDollars": 10000
}
```

An account may have at most five wallets. This mutation requires
account-management authority.

### `PATCH /v1/wallets/:walletId`

```json
{
  "name": "Research 2",
  "config": {
    "currency": "USD",
    "maxSlippagePercent100": 3,
    "feeOverrides": {}
  }
}
```

`PATCH /v1/wallet` is the selector-based equivalent. Supply optional
`walletId` or `walletName` as a query parameter; an API key may omit both.

### `POST /v1/wallets/:walletId/reset`

Restores starting cash and clears positions, orders, fills, activity, and
portfolio history for the wallet. Reset is destructive.

`POST /v1/wallet/reset` is the selector-based equivalent. Supply optional
`walletId` or `walletName` as a query parameter; an API key may omit both.

### `DELETE /v1/wallets/:walletId`

Backward-compatible wallet-named equivalent of
`DELETE /v1/accounts/:accountId`.

### `GET /v1/wallets/:walletId/portfolio`

Marks all positions and returns:

```json
{
  "wallet": {},
  "positions": [],
  "warnings": []
}
```

The read writes at most one changed mark per distinct venue observation and
one changed minute-level equity point.

`GET /v1/portfolio` is the selector-based equivalent. Supply optional
`walletId` or `walletName`; an API key may omit both.

### `GET /v1/wallets/:walletId/portfolio/history`

Query:

- `start`: optional ISO timestamp.
- `end`: optional ISO timestamp.
- `limit`: 1–2000, default 500.

Returns `{ "points": [...] }` in chronological order. Points are rebuilt from
immutable fills and settlements at their business timestamps: acquisition fees
are recognized at entry, then sale and settlement proceeds are recognized
against the executed value still held. The API caches each wallet's replay and
rebuilds it only when a new account activity is recorded; `start` can include a
range-opening balance anchor.

`GET /v1/portfolio/history` accepts the same filters plus optional `walletId`
or `walletName`; an API key may omit both.

### `GET /v1/wallets/:walletId/activities`

Query:

- `type`: optional `fill`, `settlement`, or `reset`.
- `start`: inclusive ISO 8601 activity-occurrence time.
- `end`: inclusive ISO 8601 activity-occurrence time.
- `limit`: 1–200, default 50.
- `cursor`: opaque cursor from the previous response.

Returns `{ "activities": [...], "nextCursor": "..." }`.

`GET /v1/activities` accepts the same filters plus optional `walletId` or
`walletName`; an API key may omit both.

## Market-data endpoints

### `GET /v1/markets/search`

Required `q` and optional `venue=all|polymarket|kalshi`.

Returns normalized markets and warnings for any venue that failed. A
successful venue can still be used when the other is unavailable. Polymarket
outcome prices use current public CLOB best-bid/ask midpoints fetched in one
batch rather than Gamma's potentially stale `outcomePrices`.

### `GET /v1/markets/suggestions`

Returns actively tradable Polymarket and Kalshi market suggestions using recent
public price movement, volume, liquidity, and a live two-sided quote. Results
are interspersed across venues when both return qualifying markets.

### `GET /v1/markets/resolve`

Required URL-encoded `url` for a supported public Polymarket or Kalshi market
page. Polymarket `/event/{slug}` links, Kalshi
`/markets/{series}/{event-slug}/{event-ticker}` links, and Kalshi
`/markets_by_ticker/{market-ticker}` links are supported. A Kalshi
`op_market_ticker` query parameter selects that exact market when an event has
multiple markets. Polymarket event links resolve directly by slug, including
short-duration markets that may not yet appear in public search. Returns
matching normalized canonical markets. Polymarket outcome prices use current
public CLOB best-bid/ask midpoints.

### `GET /v1/markets/:venue/:marketId/outcomes/:outcomeId/book`

Returns normalized public depth:

```json
{
  "venue": "polymarket",
  "marketId": "venue-market-id",
  "outcomeId": "venue-outcome-id",
  "bids": [{ "pricePercent1": 0.53, "quantity": 100 }],
  "asks": [{ "pricePercent1": 0.55, "quantity": 80 }],
  "observedAt": "2026-07-26T00:00:00.000Z"
}
```

Market records are cached for 30 seconds, order books for 750 milliseconds, and
lifecycle state for 10 seconds per API process. Concurrent requests for the
same key share one upstream request.

### Historical market data

Historical endpoints use the same API-key or session authentication as trading.
The replacement data plane queries immutable Zstd Parquet/Iceberg objects in
S3. This is an implementation detail; the public contract remains unchanged.
Prices and quantities are stored as exact integer micros and returned as JSON
decimal values. Every row includes venue source time, collector observation
time, source precision, payload hash, native identifier/sequence when
available, and whether historical prices are non-executable proxies.

JSON pages contain at most 10,000 rows. Trade, quote, and candle requests use
inclusive `start` and `end` ISO 8601 bounds and may cover at most 31 days.
Continue with `nextCursor` without changing any filters; the cursor freezes the
query at its original observation watermark so concurrently collected rows do
not reorder later pages.

| Method | Endpoint | Result |
| --- | --- | --- |
| `GET` | `/v1/market-data/coverage` | Availability, fidelity, freshness, and gaps |
| `GET` | `/v1/market-data/markets` | Current or point-in-time definitions |
| `GET` | `/v1/market-data/markets/:venue/:marketId` | One point-in-time definition |
| `GET` | `/v1/market-data/trades` | Exact normalized public trades |
| `GET` | `/v1/market-data/quotes` | Change-only top-of-book observations |
| `GET` | `/v1/market-data/candles` | One-minute candles |
| `GET` | `/v1/market-data/books/:venue/:marketId/:outcomeId` | Causally reconstructed L2 book |
| `POST` | `/v1/market-data/exports` | Start a Parquet export |
| `GET` | `/v1/market-data/exports/:exportId` | Export status and temporary URL |

Historical list responses use this envelope:

```json
{
  "data": [],
  "nextCursor": null,
  "snapshotObservedAt": "2026-07-28T00:00:00.000Z",
  "coverageQuality": "partial"
}
```

Coverage quality applies to the requested range, including empty pages. A
`nextCursor` is opaque: do not inspect it, and resend every original filter
unchanged when requesting the next page.

#### `GET /v1/market-data/coverage`

Returns available start/end, highest fidelity, quality, freshness, known gap
count, and duplicate-suppression metrics. Each venue has a summary row with a
null `marketId`; markets receive individual rows once price, trade, or book
observations exist, keeping this unpaginated operational response bounded.
The optional `collectionMode` is `continuous` or `sampled`. A sampled response
also includes `scheduledNextCaptureAt`; callers must not assume that separate
sampled intervals are continuous simply because both are stored.
For market rows, `availableStart` and `availableEnd` describe the reported
highest fidelity itself: an earlier top-of-book quote does not extend a later
`replayable_l2` range. They are clamped to the inferred rolling market open and
scheduled close so retired in-memory books cannot extend a contract's usable
range. Market rows also include `marketTitle`, `underlying`,
`durationMinutes`, and `marketClosesAt`. The authenticated backtester matches
those fields to the edited strategy families and uses only matching
market-level `replayable_l2` rows to display, default, and constrain its
simulation window.
Fidelity is
`definition_only`, `proxy_price`, `top_of_book`, or `replayable_l2`. Quality is
`complete`, `partial`, `gapped`, or `stale`.

#### `GET /v1/market-data/markets`

Optional `venue`, `marketId`, `asOf`, inclusive scheduled-close bounds
`closesAtStart`/`closesAtEnd`, `limit`, and `cursor`. Returns the market
definition valid at `asOf`, including the exact outcome mapping, rules,
resolution source/evidence, tick size, minimum size, fee inputs, lifecycle, and
content-version hash. Close-time bounds are applied before pagination so
backtests and other narrow consumers do not scan the complete venue catalog.

#### `GET /v1/market-data/markets/:venue/:marketId`

Optional `asOf`. Returns one point-in-time definition or
`MARKET_DATA_NOT_FOUND`.

#### `GET /v1/market-data/trades`

Required `start` and `end`; optional `venue`, `marketId`, `outcomeId`, `limit`,
and `cursor`. Returns exact public trade price/quantity and aggressor side when
the venue supplies it.

#### `GET /v1/market-data/quotes`

Uses the same filters as trades. Returns only top-of-book changes with bid,
ask, quantities, and midpoint.

#### `GET /v1/market-data/candles`

Uses the same filters as trades. Returns one-minute OHLC, final bid/ask,
volume, open interest when available, and `isProxy`. Historical public prices
are explicitly proxies; they are never presented as replayable depth.

#### `GET /v1/market-data/books/:venue/:marketId/:outcomeId?at=...`

Reconstructs the last causally available book at the required `at` timestamp.
The outcome segment may be omitted to select the first outcome from the
point-in-time definition. The response includes the causal snapshot time, last
applied delta time, exact levels, provenance, and coverage quality. If no
snapshot is available or a sequence/collection gap intersects replay, the API
returns HTTP 422 with `INSUFFICIENT_MARKET_DATA`; it never approximates L2 from
trades or candles.

#### `POST /v1/market-data/exports`

Creates an asynchronous Zstd Parquet export:

```json
{
  "dataset": "trades",
  "venue": "polymarket",
  "marketId": "venue-market-id",
  "outcomeId": "venue-outcome-id",
  "start": "2026-07-01T00:00:00.000Z",
  "end": "2026-07-28T00:00:00.000Z"
}
```

`dataset` is `markets`, `trades`, `quotes`, `candles`, or `books`. Exports may
cover at most 90 days, expire after 24 hours, and are limited to one
pending/running export per authenticated credential. HTTP 202 returns the job.

#### `GET /v1/market-data/exports/:exportId`

Returns `pending`, `running`, `complete`, `failed`, or `expired`. A completed
job includes row count, compressed bytes, and a private bucket URL valid for 15
minutes. Download the URL without adding the PaperTiger authorization header;
the URL carries its own short-lived storage signature.

## Strategy and backtest endpoints

Strategies are declarative JSON rather than executable user code. V1 remains
accepted for backward compatibility. The AI builder now generates V2. Every
backtest creates an immutable strategy version and records definition and data
observation watermarks. Events are processed strictly after their `observedAt`
time. V2 reconstructs both outcome books from full snapshots and absolute-size
deltas, incorporates public trades, and invalidates an affected book across
known gaps until a new complete snapshot. Entry fills walk asks and exit fills
walk bids. Midpoint, microprice, candles, and historical proxy prices are never
treated as executable.

### `POST /v1/strategies/generate`

Authenticated natural-language form builder:

```json
{
  "prompt": "Buy Polymarket BTC 5-minute Up in the final 10 seconds when the ask is at least 96 cents and Chainlink agrees."
}
```

PaperTiger calls `@discomedia/utils` `disco.llm.call` with
`gpt-5.6-luna` and a strict strategy schema. The server-side OpenAI credential
is never returned to the browser. The response contains an editable
`strategy` plus token and cost usage:

```json
{
  "usage": {
    "provider": "openai",
    "model": "gpt-5.6-luna",
    "promptTokens": 840,
    "completionTokens": 310,
    "reasoningTokens": 0,
    "cacheHitTokens": 0,
    "cacheWriteTokens": 0,
    "costDollars": 0.0021
  }
}
```

The endpoint is limited to 20 generations per authenticated credential per
hour. Generated definitions must be reviewed; they are not executed against a
real or paper wallet.

### Strategy definition v1

```json
{
  "schemaVersion": 1,
  "name": "BTC final ten seconds",
  "description": "Buy the leading outcome late when its ask is at least 50 cents.",
  "universe": {
    "families": [
      {
        "venue": "polymarket",
        "underlying": "BTC",
        "durationMinutes": 5
      }
    ]
  },
  "entry": {
    "anchor": "market_close",
    "startOffsetSeconds": -10,
    "endOffsetSeconds": -1,
    "outcomeSelection": "leading",
    "conditionMatch": "all",
    "conditions": [
      {
        "type": "price",
        "field": "ask",
        "operator": "gte",
        "valuePercent1": 0.5
      }
    ],
    "order": {
      "type": "market",
      "limitPricePercent1": null,
      "sizing": "fixed_dollars",
      "size": 10
    },
    "maxEntriesPerMarket": 1
  },
  "exit": {
    "takeProfitPercent100": null,
    "stopLossPercent100": null,
    "exitBeforeCloseSeconds": 1
  },
  "portfolio": {
    "maxConcurrentPositions": 3,
    "maxPositionDollars": 25
  }
}
```

`underlying` is `BTC`, `ETH`, `SOL`, `XRP`, `DOGE`, or `BNB`; duration is 5 or
15 minutes. Conditions are:

- `price`: `bid`, `ask`, or `midpoint` compared with a scale-one probability.
- `trend`: signed midpoint return over `lookbackSeconds`, on scale 100.
- `book_imbalance`: `(bid quantity - ask quantity) / total`, on scale 100.
- `reference_direction`: selected Up/Down outcome must agree with causal
  `chainlink` or `binance` spot movement since the inferred market open.

### Strategy definition v2

```json
{
  "schemaVersion": 2,
  "name": "BTC five-level pressure",
  "description": "Buy late only when weighted L2 pressure leaves positive net edge.",
  "universe": {
    "families": [
      {
        "venue": "polymarket",
        "underlying": "BTC",
        "durationMinutes": 5
      }
    ]
  },
  "entry": {
    "anchor": "market_close",
    "startOffsetSeconds": -30,
    "endOffsetSeconds": -2,
    "outcomeSelection": "leading",
    "signals": [
      {
        "id": "depth",
        "weight": 0.45,
        "signal": {
          "type": "depth_imbalance",
          "depth": { "mode": "levels", "levels": 5 }
        }
      },
      {
        "id": "flow",
        "weight": 0.35,
        "signal": {
          "type": "order_flow_imbalance",
          "lookbackMilliseconds": 1000,
          "depth": {
            "mode": "price_distance",
            "distancePercent100": 2
          }
        }
      },
      {
        "id": "ephemeral-penalty",
        "weight": 0.2,
        "signal": {
          "type": "ephemeral_liquidity_score",
          "lookbackMilliseconds": 3000,
          "maximumRestingMilliseconds": 1000,
          "depth": { "mode": "levels", "levels": 5 }
        }
      }
    ],
    "minimumScorePercent100": 30,
    "minimumScorePersistenceMilliseconds": 750,
    "referenceAgreement": {
      "source": "chainlink",
      "lookbackMilliseconds": 1000,
      "minimumMomentumPercent100": 0.01
    },
    "maximumBookAgeMilliseconds": 1000,
    "maximumEphemeralLiquidityPenaltyPercent100": 40,
    "expectedMoveAtFullScorePercent1": 0.05,
    "minimumExpectedNetEdgePercent100": 0.25,
    "order": {
      "type": "market",
      "limitPricePercent1": null,
      "sizing": "fixed_dollars",
      "size": 10
    },
    "maxEntriesPerMarket": 1
  },
  "execution": {
    "latencyMilliseconds": 250,
    "displayedDepthParticipationPercent100": 30,
    "maximumSlippagePercent100": 2,
    "expectedEdgeSlippageBufferPercent100": 0.25
  },
  "exit": {
    "takeProfitPercent100": 10,
    "stopLossPercent100": 5,
    "maximumHoldingSeconds": 30,
    "signalReversalScorePercent100": -10,
    "pressureDecayScorePercent100": 5,
    "exitBeforeCloseSeconds": 1
  },
  "portfolio": {
    "maxConcurrentPositions": 3,
    "maxPositionDollars": 25
  }
}
```

V2 `signals` contain 1–24 uniquely named weighted components. Weights are
non-negative and normalized before the combined signed score is compared with
`minimumScorePercent100`. At least one weight must be positive.

V2 can also require the qualified score to remain continuous for
`minimumScorePersistenceMilliseconds`, hard-gate the selected outcome against
causal Chainlink or Binance momentum with `referenceAgreement`, reject stale
outcome books with `maximumBookAgeMilliseconds`, and abstain when
`maximumEphemeralLiquidityPenaltyPercent100` is reached. Coverage and sequence
gaps always fail closed. The ephemeral-liquidity threshold is an abstention
rule, not a contrarian signal or a claim about trader intent.

- `depth_imbalance`: displayed bid-versus-ask depth over 1–100 levels or a
  scale-100 price-distance band.
- `order_flow_imbalance`: bid/ask adds and cancels over 250 ms, 1 s, or 3 s.
- `aggressive_trade_imbalance`: public buy-versus-sell quantity over 250 ms,
  1 s, 3 s, or 10 s.
- `microprice_deviation`: microprice displacement within the current spread.
- `liquidity_persistence`: displayed depth that remained through its causal
  lookback.
- `book_resilience`: bid replenishment after removals and aggressive sells.
- `ephemeral_liquidity_score`: rapidly added and removed liquidity. This is a
  statistical penalty; public data cannot establish manipulative intent.
- `reference_momentum`: outcome-aligned Chainlink or Binance spot momentum.
- `reference_target_distance`: market-open target distance normalized by
  recent reference movement.
- V1 `price`, `trend`, `book_imbalance`, and `reference_direction` conditions
  may also be used as weighted V2 components.

`expectedMoveAtFullScorePercent1` translates a perfect positive score into an
expected probability move. Entry still requires
`minimumExpectedNetEdgePercent100` after the executable ask VWAP, immediately
executable bid, entry and estimated exit fees, spread, and walked-depth
slippage. `expectedEdgeSlippageBufferPercent100` subtracts an additional
conservative buffer from that modeled edge. These are user-supplied modeling
assumptions, not forecast guarantees.

### `GET /v1/strategies`

Returns up to 100 current owned strategies with `id`, immutable `versionId`,
monotonic `version`, definition, and creation/update times.

### `POST /v1/backtests`

Runs one bounded simulation synchronously:

```json
{
  "strategy": { "schemaVersion": 2 },
  "start": "2026-07-27T00:00:00.000Z",
  "end": "2026-07-28T00:00:00.000Z",
  "initialCapitalDollars": 1000
}
```

The abbreviated `strategy` above must be replaced by a complete V1 or V2
definition. `start` and `end` are inclusive and may span at most 31 days. A run
is rejected with `backtest_limit_exceeded` if it would process more than
500,000 L2/quote events or more than 500,000 requested reference-price events.
V2 returns HTTP 422 `INSUFFICIENT_MARKET_DATA` when an exposed position crosses
a collection/reconnect gap or cannot be closed from a valid executable book.

HTTP 201 returns `result` with:

- initial/final equity, net profit, return, maximum drawdown, trade/win/loss
  counts, win rate, profit factor, average/largest wins and losses, fees, and
  rejected-signal count;
- chronological equity points with total equity, cash, and open-position value;
- sortable trade-ready rows with entry/exit time and walked VWAP, quantity,
  fees, profit, return, exit reason, accepted entry/exit diagnostics, component
  scores, book and flow state, ephemeral-liquidity penalty, expected net edge,
  and fixed-horizon executable-bid markouts;
- a bounded `decisions` array with accepted/rejected score evaluations and
  stable rejection reasons such as `score_below_threshold`,
  `insufficient_expected_edge`, `coverage_gap`, `insufficient_depth`,
  `latency_window`, `limit_price`, and `slippage_limit`;
- data-quality and methodology warnings plus frozen quote and definition
  watermarks.

### `GET /v1/backtests` and `GET /v1/backtests/:backtestId`

Return recent owned run state and persisted results. A browser session or API
key can access only runs owned by the same PaperTiger user.

## Order endpoints

### `POST /v1/orders`

Canonical request:

```json
{
  "venue": "kalshi",
  "marketId": "venue-market-id",
  "outcomeId": "venue-outcome-id",
  "side": "buy",
  "quantity": 25,
  "type": "limit",
  "timeInForce": "gtc",
  "limitPricePercent1": 0.98,
  "executionFloorPricePercent1": 0.9,
  "clientOrderId": "optional-client-id",
  "maxSlippagePercent100": 4
}
```

Rules:

- `walletId` or exact `walletName` may select a wallet. Supply at most one.
  API keys may omit both to use their default wallet.
- Supply exactly one sizing field:
  - `quantity` requests a specific number of contracts.
  - `maxSpendDollars` is available for buys and chooses the largest
    contract-micro quantity whose gross consideration plus simulated fee does
    not exceed that dollar budget.
- `type` defaults to `market`.
- Market orders always use `fok`; omit `timeInForce` or send `fok`.
- Limit orders require `limitPricePercent1`.
- `executionFloorPricePercent1` is an optional buy-only minimum permitted
  execution price. Every consumed buy level must meet the floor. For a 90c–98c
  band, use `executionFloorPricePercent1: 0.9` and
  `limitPricePercent1: 0.98`; a buy limit alone is only an upper bound and may
  fill below 90c.
- A floor cannot exceed its buy limit. The execution engine applies the floor
  while it walks the same public-book snapshot that produces the fill, before
  persisting any fill; it is not a client-side advisory preflight check.
- Limit time in force defaults to `gtc`.
- `maxSlippagePercent100` applies to market orders.
- PaperTiger verifies the instrument and current lifecycle before reserving
  buying power or contracts.
- Buy limit reservations use limit price plus the configured fee assumption.
- Dollar-sized market buys use current slippage-constrained asks. If the
  budget exceeds available qualifying depth, the order uses that available
  depth without exceeding the budget.
- Dollar-sized limit buys derive quantity from the limit price and fee curve.
- An unfilled buy order does not create an empty position.

Execution:

- Market FOK walks current public depth and either fills completely or fails.
- An empty executable bid/ask side returns `insufficient_liquidity` with
  `No executable order-book liquidity is available.` This is distinct from a
  non-empty book that lacks enough depth inside the configured slippage limit.
- Limit fills attribute at most 30% of each displayed level satisfying the
  limit and optional execution floor to the paper order. The remaining 70%
  models queue priority, competing orders, and public-book latency.
- Limit FOK fills completely from that attributable depth or cancels.
- Limit IOC partially fills from that attributable depth and cancels the
  remainder.
- Limit GTC partially fills from that attributable depth and retains the
  remainder.
- The same unchanged executable book can fill a GTC order only once in a
  one-minute observation window. Changed depth is eligible sooner; the next
  window treats unchanged displayed depth as conservatively replenished.
- Accepted GTC orders are re-evaluated by bounded polling, not streaming.

Returns `201` and `{ "order": ... }`, including on an idempotent replay.

Dollar-sized market buy:

```json
{
  "venue": "polymarket",
  "marketId": "venue-market-id",
  "outcomeId": "venue-outcome-id",
  "side": "buy",
  "maxSpendDollars": 100,
  "type": "market",
  "maxSlippagePercent100": 4
}
```

### `POST /v1/orders/bulk`

Executes 1–50 place, replace, and cancel actions in input order:

```json
{
  "actions": [
    {
      "action": "place",
      "idempotencyKey": "retry-key-for-this-placement",
      "order": {
        "venue": "kalshi",
        "marketId": "venue-market-id",
        "outcomeId": "venue-outcome-id",
        "side": "buy",
        "maxSpendDollars": 50,
        "type": "limit",
        "timeInForce": "gtc",
        "limitPricePercent1": 0.98,
        "executionFloorPricePercent1": 0.9
      }
    },
    {
      "action": "replace",
      "orderId": "order-uuid",
      "order": {
        "limitPricePercent1": 0.5
      }
    },
    {
      "action": "cancel",
      "orderId": "another-order-uuid"
    }
  ]
}
```

The complete payload is validated before execution. After that, actions are
best-effort rather than atomic: one failure does not roll back earlier actions
or prevent later actions. Each placement can carry its own `idempotencyKey`.
Replacement and cancellation retain their standalone idempotent/transactional
behavior. Public market metadata, lifecycle state, and required buy-market
books are prefetched concurrently for every placement before ordered wallet
mutations begin. This keeps a near-close bulk wave on one coherent, low-latency
market-data observation instead of fetching each book only after earlier
actions finish.

Returns `200`:

```json
{
  "results": [
    {
      "index": 0,
      "action": "place",
      "status": "succeeded",
      "order": {}
    },
    {
      "index": 1,
      "action": "replace",
      "status": "failed",
      "error": {
        "statusCode": 404,
        "code": "not_found",
        "message": "The requested resource was not found."
      }
    }
  ],
  "succeeded": 1,
  "failed": 1
}
```

### `GET /v1/orders`

Optional wallet selectors and filters:

- `walletId` or exact `walletName`; API keys may omit both.
- `status`: `open`, `closed`, `all`, or an exact order status.
- `venue`: `polymarket` or `kalshi`.
- `marketId`.
- `side`: `buy` or `sell`.
- `start`: inclusive ISO 8601 order-creation time.
- `end`: inclusive ISO 8601 order-creation time.
- `limit`: 1–200, default 50.
- `cursor`.

Returns `{ "orders": [...], "nextCursor": "..." }`.

### `GET /v1/wallets/:walletId/orders`

Equivalent list endpoint without a `walletId` query parameter.

### `GET /v1/orders/:orderId`

Returns `{ "order": ..., "fills": [...] }`.

### `PATCH /v1/orders/:orderId`

Replaces an open GTC limit order. At least one field is required:

```json
{
  "quantity": 30,
  "limitPricePercent1": 0.98,
  "executionFloorPricePercent1": 0.9
}
```

Filled quantity cannot be removed. PaperTiger adjusts only the reservation
difference, then immediately evaluates the replacement against cached/current
public depth. A replacement may also set a buy execution floor; its effective
floor cannot exceed its effective limit.

### `DELETE /v1/orders/:orderId`

Cancels an open order and releases its remaining cash or contract reservation.
Cancellation is idempotent for an already terminal order.

## Position endpoints

### `GET /v1/wallets/:walletId/positions`

Optional query filters:

- `status`: `open`, `closed`, or `all`; default `open`.
- `start`: inclusive ISO 8601 position-creation time.
- `end`: inclusive ISO 8601 position-creation time.

Returns positions and any mark warnings.

`GET /v1/positions` accepts the same status filter plus optional `walletId` or
`walletName`; an API key may omit both.

### `GET /v1/wallets/:walletId/positions/:positionId`

Returns one marked position and warnings.

`GET /v1/positions/:positionId` is the selector-based equivalent.

### `DELETE /v1/wallets/:walletId/positions/:positionId`

Closes the available quantity with a simulated FOK market sell. Optional query
`maxSlippagePercent100=0..100`. Quantity already reserved by an open sell order
is not closed twice.

`DELETE /v1/positions/:positionId` is the selector-based equivalent and also
accepts optional `walletId` or `walletName`.

## API-key endpoints

All API-key endpoints require a browser session or an account-level API key.
A wallet-level key cannot promote itself.

### `GET /v1/api-keys`

Lists metadata; secrets are never returned.

### `POST /v1/api-keys`

```json
{
  "name": "Production strategy",
  "walletId": "uuid-of-the-currently-selected-wallet",
  "access": "all",
  "level": "account"
}
```

`access` and `level` are optional and default to `"wallet"`. The selected
wallet becomes the key's initial default wallet. Account-level keys are always
forced to `access: "all"`. Returns the `pt_key_...` secret once alongside
metadata containing `defaultWalletId`, `defaultWalletName`, `access`, and
`level`.

### `PATCH /v1/api-keys/:apiKeyId`

Changes the default wallet, wallet access, authority level, or any combination
immediately:

```json
{
  "defaultWalletId": "uuid-of-an-owned-wallet",
  "access": "all",
  "level": "account"
}
```

At least one field is required. `defaultWalletId` must identify a wallet owned
by the account. Changing the default changes which wallet is used whenever an
API call omits `walletId` and `walletName`. When access is `"wallet"`, it also
changes the key's only accessible wallet. Use `"all"` to allow every wallet or
`"wallet"` to restrict the key to its current default. Setting `level` to
`"account"` also forces access to `"all"`. Returns the updated API-key metadata.

### `DELETE /v1/api-keys/:apiKeyId`

Revokes a key and returns `204`.

### `GET /v1/admin/settings`

Requires an administrator browser session and returns the tier policy:

```json
{
  "freeWalletLimit": 1,
  "trialWalletLimit": 25,
  "paidWalletLimit": 25,
  "trialDurationDays": 7
}
```

### `PATCH /v1/admin/settings`

Requires a persisted administrator browser session. API keys cannot change application-wide limits. Replaces the policy immediately:

```json
{
  "freeWalletLimit": 1,
  "trialWalletLimit": 25,
  "paidWalletLimit": 25,
  "trialDurationDays": 7
}
```

Wallet limits must be whole numbers from 1 through 1,000; trial duration is 1 through 365 days. Accounts already over a new lower limit keep their existing wallets but cannot create another one.

### Administrator user management

All routes below require an administrator browser session; API keys cannot use
them. Email addresses are intentionally immutable.

- `GET /v1/admin/users` returns `{ "users": [...] }`, including email,
  verification state, role, effective access tier, trial end, and creation time.
- `POST /v1/admin/users` creates an unconfirmed account and sends its email
  confirmation: `{ "email": "trader@example.com", "password": "at-least-12-characters", "role": "user" }`.
- `PATCH /v1/admin/users/{userId}` accepts an optional 12–200 character
  `password` and/or a `role` of `"admin"` or `"user"`.
- `DELETE /v1/admin/users/{userId}` permanently deletes the selected account
  and all data it owns. Administrators cannot change or delete themselves from
  this surface.

### Billing

`POST /v1/billing/checkout` and `POST /v1/billing/portal` require a browser session and return a short-lived Stripe-hosted URL. Paid access is set only from verified Stripe webhooks. `POST /v1/entitlements/trial/cancel` requires an active browser-session trial and immediately moves that account to Free; it does not make the trial eligible to restart. Free accounts have one wallet; confirmed Trial and Paid accounts have up to 25. Backtests require Trial or Paid access and remain globally paused while historical data is unavailable.

## Execution and accounting semantics

- PaperTiger reads only public venue market and order-book endpoints.
- It never calls a real-order endpoint.
- Orders, reservations, fills, cash, positions, and activities update in
  database transactions.
- Monetary and quantity persistence uses integer micros.
- Fees follow venue/category defaults unless a wallet override is configured.
- Final settlement is idempotent and charges no taker fee.
- A passed close time never implies a winner. PaperTiger waits for
  authoritative venue resolution.
- Closed but unresolved positions remain visible.
- Public depth is evidence for simulation, not a promise that a real venue
  would execute identically.

## TypeScript client

```ts
import {
  PaperTigerApiError,
  PaperTigerClient,
} from "@discomedia/papertiger";

const apiKey = process.env.PAPERTIGER_API_KEY;
if (apiKey === undefined) {
  throw new Error("PAPERTIGER_API_KEY is required.");
}

const paperTiger = new PaperTigerClient({
  apiKey,
});

try {
  const portfolio = await paperTiger.getPortfolio("wallet-id");
  console.log(portfolio.wallet.equityDollars);
} catch (error: unknown) {
  if (error instanceof PaperTigerApiError) {
    console.error(error.code, error.requestId);
  }
}
```

`PaperTigerClient` covers every documented endpoint and accepts an optional
`AbortSignal` on every method. It does not automatically retry trading
mutations. When retrying `placeOrder()`, reuse the original `idempotencyKey`
only with the identical request.

## Operational limits

- 25 wallets per account by default; an administrator may set 1–1,000 from
  Settings.
- Wallet starting balance: $10 to $10,000,000.
- Order quantity: greater than zero and at most 1,000,000.
- Limit price: 0.001–0.999 on scale 1.
- API-key and wallet names: at most 80 characters.
- Client order ID: at most 500 characters.
- Market search query: 2–120 characters.
- List page: at most 200 records.
- Portfolio history: at most 2,000 points per request.

These bounds may tighten as usage evidence accumulates. Clients should handle
`429` and retry transient failures with exponential backoff and jitter. Reuse
the original `Idempotency-Key` when retrying order placement.
