Skip to content

Polymarket Data Model — Ground Truth Reference ​

Status: living reference. Last fact-gathering: 2026-05-13 (§1–§7); 2026-07-29 (§8 — контракт отправки заявки). Sources: see §11 "Provenance". Audience: LLM agents and humans extending pm-engine / pm-sdk. No prescriptive recommendations — facts only.

This document is the synthesis of four parallel audit agents (docs+on-chain, code mapping, production probe over 40 500 markets, strategy consumption). Every claim is anchored to a file:line reference inside this repository, a permanent GitHub source link, a docs.polymarket.com URL, or a reproducible probe command. Statements without provenance are listed in §10 "UNRESOLVED".


Table of contents ​

  1. Entity hierarchy
  2. Neg-risk semantics
  3. Production cardinality (proven facts, 2026-05-13)
  4. Code mapping
  5. Assumptions matrix — verified vs unchecked
  6. Known gaps
  7. Edge cases & invariants
  8. Контракт отправки заявки (CLOB) — ответы, отказы, резерв баланса, задержка, идентификатор
  9. Glossary
  10. UNRESOLVED items & inter-report contradictions
  11. Provenance
  12. Change log

1. Entity hierarchy ​

Polymarket exposes three logical layers off-chain (Gamma API) that anchor onto two on-chain contracts (Conditional Tokens Framework + NegRiskAdapter).

1.1 Event ​

An Event is the Gamma-level container that groups one or more Markets. Off-chain only — there is no on-chain Event contract. Distinguishing fields observed on the wire (event JSON, top-level keys from .tmp/gamma-samples/event-neg-risk-augmented.json):

id, slug, title, ticker, description, startDate, endDate, createdAt, updatedAt,
active, closed, archived, restricted, automaticallyActive, deploying, pendingDeployment,
new, featured, competitive, cyom, showAllOutcomes, showMarketImages, requiresTranslation,
commentCount, openInterest, liquidity, liquidityClob, cumulativeMarkets,
volume, volume24hr, volume1wk, volume1mo, volume1yr,
icon, image, eventMetadata, resolutionSource, series, seriesSlug,
tags: [{id, label, slug, ...}],
enableNegRisk, negRisk, negRiskAugmented,
markets: [ ... ]   // embedded array of child markets

The event carries the authoritative neg-risk flags: enableNegRisk: bool, negRisk: bool|null, negRiskAugmented: bool. See §2.2 for the off-chain ↔ on-chain mapping of those flags and §3.4 for distribution numbers.

Sampled production singleton event:

{
  "slug": "cap-on-gambling-loss-deductions-repealed-before-2027",
  "negRisk": null, "negRiskAugmented": false, "enableNegRisk": false, "markets_count": 1
}

Source: .tmp/gamma-samples/event-singleton-binary.json.

Sampled production neg-risk event (139 child markets):

{
  "slug": "nhl-2025-26-hart-memorial-trophy",
  "negRisk": true, "negRiskAugmented": true, "enableNegRisk": true, "markets_count": 139
}

Source: .tmp/gamma-samples/event-neg-risk-large.json.

1.2 Market ​

A Market is the fundamental tradable unit. Per Polymarket docs:

"A market is the fundamental tradable unit on Polymarket. Each market represents a single binary question with Yes/No outcomes." — https://docs.polymarket.com/concepts/markets-events.md

Every Polymarket market is binary at the contract level: outcomeSlotCount = 2. See https://docs.polymarket.com/trading/ctf/overview.md ("outcomeSlotCount: 2 for all binary markets"). Confirmed by the production probe: 40 500/40 500 active markets sampled have exactly 2 tokens and 2 outcomes (§3.1).

Key wire-level fields (from Gamma /markets response, observed via probe):

FieldTypeRole
conditionId0x-prefixed 32-byte hexPrimary on-chain link to CTF bytes32 conditionId.
questionIDhex stringOn-chain question identifier; input to prepareCondition.
questionstringHuman-readable question text.
slugstringURL-safe identifier.
clobTokenIdsJSON array of decimal strings (2 entries)ERC1155 token IDs on CTF (or wcol on NegRiskAdapter). [0] = YES, [1] = NO (see §1.3, §2).
outcomesJSON array of strings (2 entries)Display labels — see §3.2 for production distribution.
outcomePricesJSON array of decimal strings (2 entries)Last-known prices, indexed parallel to outcomes.
negRiskboolTrue if this market is under NegRiskAdapter.
negRiskAugmentedbool or nullAlways null on per-market endpoint; meaningful only on the event. See §2.2 + §3.5.
enableOrderBookboolOrder book trading enabled.
acceptingOrdersboolOrder acceptance toggle.
active, closed, archived, resolvedboolLifecycle flags.
orderPriceMinTickSize0.01 or 0.001Production-relevant tick variability — see §3.6.
minimumOrderSize, volume, volume24hr, liquiditynumbersActivity metrics.
endDateRFC3339 timestamp stringMarket expiry.
gameStartTime%Y-%m-%d %H:%M:%S%z stringНе только у погоды — заполнено широко у спорта/киберспорта; живой обход Gamma /events/keyset 15.09.2026 (полный курсорный обход, теги 1/64/105907) дал 121 237 из 141 510 активных рынков спорта/киберспорта с непустым значением. Значение не всегда согласовано с endDate: наблюдён случай gameStartTime позже endDate примерно на 2 месяца (isp-ddy-ddy-2026-03-13) — см. §6.8.
updatedAtRFC3339Server-side last-update timestamp.

Sampled production neg-risk market (from the 139-market NHL event):

{
  "conditionId": "0x7976b8dbacf9077eb1453a62bcefd6ab2df199acd28aad276ff0d920d6992892",
  "negRisk": true, "negRiskAugmented": null,
  "clobTokenIds": "[\"4394372887385518214471608448209527405727552777602031099972143344338178308080\",\"112680630004798425069810935278212000865453267506345451433803052322987302357330\"]",
  "outcomes": "[\"Yes\", \"No\"]",
  "orderPriceMinTickSize": 0.001
}

Source: .tmp/gamma-samples/event-neg-risk-augmented.json (first child market).

1.3 Token ​

A Token is an on-chain ERC1155 position. Two tokens per market: index 0 (YES) and index 1 (NO). The clobTokenIds[i] decimal string is the ERC1155 tokenId on either:

  • the ConditionalTokens contract (standard markets — negRisk = false), or
  • the NegRiskAdapter contract (neg-risk markets — negRisk = true).

From https://docs.polymarket.com/quickstart.md:

"Save a token ID from clobTokenIds — you'll need it to place an order. The first ID is the Yes token, the second is the No token."

The outcomes array shares the same indexing — outcomes[0] and clobTokenIds[0] describe the same outcome. See §5 for the verification status of this convention (no machine-readable invariant; quickstart prose only).

1.4 On-chain ground truth for resolution ​

A market settles when the resolution oracle calls reportPayouts on the Conditional Tokens contract. Holders then redeem winning tokens via:

  • CTF.redeemPositions(IERC20 collateralToken, bytes32 parentCollectionId, bytes32 conditionId, uint[] calldata indexSets) — standard markets.
  • NegRiskAdapter.redeemPositions(bytes32 _conditionId, uint256[] calldata _amounts) — neg-risk markets; _amounts must have length 2: [yesAmount, noAmount].

Sources:

Payout vector for Polymarket binary markets: denominator = 1, vector ∈ {[1,0], [0,1]}. Source: Agent A §3 "CTF.redeemPositions" / crates/pm-sdk/src/onchain/redeem.rs:44.

1.5 Off-chain (Gamma) ↔ on-chain (CTF / NegRiskAdapter) mapping ​

Off-chain fieldOn-chain artifactNotes
Market.conditionIdbytes32 conditionId parameter to all CTF / NegRiskAdapter opsIdentical 32-byte value.
Market.clobTokenIds[i]ERC1155 tokenId (uint256)Decimal-encoded uint256; YES = i=0, NO = i=1.
Market.negRisk == trueNegRiskAdapter (NEG_RISK_ADAPTER_ADDRESS)All split/merge/redeem routed through adapter rather than CTF directly.
Market.negRisk == falseConditionalTokens (0x4D97DCd97eC945f40cF65F87097ACe5EA0476045)Standard CTF path.
Event.negRisk == true ∧ event.negRiskAugmented == trueSame NegRiskAdapter.sol contractAugmentation is off-chain only — no separate on-chain contract found (§2.5).

CTF mainnet address from https://docs.polymarket.com/resources/contracts.md and crates/pm-sdk/src/onchain/ctf.rs:40-76. NegRiskAdapter address referenced as NEG_RISK_ADAPTER_ADDRESS constant in crates/pm-sdk/src/onchain/ctf.rs:173-218.


2. Neg-risk semantics ​

2.1 Mandatory ≥2 markets per neg-risk event ​

Single binary events cannot be neg-risk at the contract level. The NegRiskAdapter.convertPositions function reverts when questionCount ≤ 1:

solidity
uint256 questionCount = md.questionCount();
if (md.oracle() == address(0)) revert MarketNotPrepared();
if (questionCount <= 1) revert NoConvertiblePositions();

Source: https://github.com/Polymarket/neg-risk-ctf-adapter/blob/f78b35b0863b4308a431ca307d06f49b2ea65e78/src/NegRiskAdapter.sol#L246-L249

Docs reinforce this: "Negative risk is a mechanism for multi-outcome events where only one outcome can win." — https://docs.polymarket.com/advanced/neg-risk.md.

Production confirmation: among 2 500 sampled active events (§3.7), neg-risk events have an average markets_count of 16.6 vs non-neg-risk events ~2.4. Top-10 neg-risk events have 100+ child markets.

2.2 neg_risk vs neg_risk_augmented — field level placement ​

The flags carry different semantics and different placement:

FlagPlacement (Gamma response)Meaning
enableNegRiskevent-levelThe event uses NegRiskAdapter for its children.
negRiskevent-level (canonical) + mirrored on every child marketPer-market: true if this market lives under NegRiskAdapter.
negRiskAugmentedevent-level onlyAugmented mechanism (placeholder outcomes + "Other" catch-all).

The negRiskAugmented value on the market record is always null in the /markets Gamma endpoint. Verified by probe: head -100 .tmp/gamma-samples/markets-bulk-2026-05-13.jsonl | jq -r '.negRiskAugmented // "null"' | sort | uniq -c returns 100 null. The same value appears as null even on child markets of events whose negRiskAugmented=true (e.g. the first child of 2026-fifa-world-cup-winner-595 returns "negRiskAugmented": null). To learn whether a market is augmented you MUST query the parent event.

Code consequence: pm_common::models::market::Market.neg_risk_augmented: bool (crates/pm-common/src/models/market.rs:13-83) — serde default false — is therefore filled from the event payload when present, or false when constructed from /markets. See §6.3 for the dead-field implication.

2.3 On-chain operations — CTF vs NegRiskAdapter ​

OperationCTFNegRiskAdapter
splitPositionsplitPosition(collateral, parentCollectionId, conditionId, partition, amount)Long form (CTF-compatible) + short form splitPosition(bytes32 _conditionId, uint256 _amount). Wraps collateral into wcol internally.
mergePositionsmergePositions(collateral, parentCollectionId, conditionId, partition, amount)Long form + short form mergePositions(bytes32 _conditionId, uint256 _amount). Unwraps wcol back to collateral.
redeemPositionsredeemPositions(collateral, parentCollectionId, conditionId, indexSets) — redeems full balanceredeemPositions(bytes32 _conditionId, uint256[] calldata _amounts) — explicit [yesAmount, noAmount].
convertPositionsNot available.convertPositions(bytes32 _marketId, uint256 _indexSet, uint256 _amount) — converts NO tokens into sibling YES tokens + partial collateral. Only on NegRiskAdapter.
prepareConditionRequired first stepCalled internally via NegRiskAdapter.prepareQuestion().

Source: Agent A §3; CTF source https://github.com/Polymarket/conditional-tokens-contracts/blob/a927b5a52cf9ace712bf1b5fe1d92bf76399e692/contracts/ConditionalTokens.sol; NegRiskAdapter source https://github.com/Polymarket/neg-risk-ctf-adapter/blob/f78b35b0863b4308a431ca307d06f49b2ea65e78/src/NegRiskAdapter.sol#L110-L170.

Local repository implementation:

  • pm-sdk/src/onchain/ctf.rs:40-76 — split (CTF, with parentCollectionId = B256::ZERO, BINARY_PARTITION = [1, 2]).
  • pm-sdk/src/onchain/ctf.rs:96-133 — merge_positions (CTF, standard markets).
  • pm-sdk/src/onchain/ctf.rs:173-218 — merge_positions_neg_risk (NegRiskAdapter, with pre-flight isApprovedForAll check + auto-approve).
  • pm-sdk/src/onchain/redeem.rs:44-64 — redeem (CTF).
  • pm-sdk/src/onchain/redeem.rs:94-151 — redeem_neg_risk (NegRiskAdapter, takes explicit yes_token_id + no_token_id).

2.4 convertPositions — verbatim semantics ​

Function: convertPositions(bytes32 _marketId, uint256 _indexSet, uint256 _amount). Source: https://github.com/Polymarket/neg-risk-ctf-adapter/blob/f78b35b0863b4308a431ca307d06f49b2ea65e78/src/NegRiskAdapter.sol#L244-L365

Step-by-step semantics (verbatim from Agent A §2 Q-A2):

  1. Validate: marketId must be prepared, questionCount > 1, _indexSet nonempty and within bounds.
  2. Count noPositionCount (bits set in _indexSet) and yesPositionCount = questionCount - noPositionCount.
  3. Mint yesPositionCount * _amount wcol internally (collateral for new YES splits).
  4. For each question NOT in _indexSet (sibling questions): split wcol into YES[j]+NO[j]; accumulate YES[j] for output; accumulate NO[j] for burn.
  5. Transfer caller's NO tokens (from _indexSet) to NO_TOKEN_BURN_ADDRESS.
  6. Transfer all accumulated sibling NO[j] to NO_TOKEN_BURN_ADDRESS.
  7. Compute fee: feeAmount = _amount * feeBips / 10_000.
  8. If noPositionCount > 1: release (noPositionCount - 1) * (_amount - feeAmount) wcol (unwrapped) to caller; release fee portion to vault.
  9. If yesPositionCount > 0: transfer yesPositionCount * (_amount - feeAmount) YES tokens to caller; transfer fee portion of YES tokens to vault.

Key invariant: the classic single-NO-to-all-YES conversion is the special case k=1, N-1 siblings → caller gets (N-1) YES tokens + 0 collateral.

Gas estimate: not in NatSpec. The function loops over questionCount siblings with an internal CTF.splitPosition call per sibling. See §10 item 3.

2.5 Augmented neg-risk — same contract, off-chain bulletin board ​

From https://docs.polymarket.com/advanced/neg-risk.md:

"Standard negative risk requires the complete set of outcomes to be known at market creation. But sometimes new outcomes emerge after trading begins (e.g., a new candidate enters a race). Augmented negative risk solves this with: Named outcomes (known, e.g. 'Trump', 'Harris'), Placeholder outcomes (reserved slots that can be clarified later, e.g. 'Person A'), Explicit Other (catches any outcome not explicitly named)."

"When a new outcome emerges, a placeholder is clarified via the bulletin board. The 'Other' definition narrows as placeholders are assigned."

"Only trade on named outcomes. Placeholder outcomes should be ignored until they are named or until resolution occurs."

Identification in Gamma response (event level):

json
{ "enableNegRisk": true, "negRiskAugmented": true }

On-chain mechanics: no separate contract found in any Polymarket GitHub repository. The augmented logic is managed off-chain via the bulletin board mechanism; the on-chain adapter is the same NegRiskAdapter.sol. The negRiskAugmented flag is a Gamma API/UI metadata flag. The bulletin-board API itself is undocumented at a concrete endpoint — see §10 item 2.


3. Production cardinality (proven facts, 2026-05-13) ​

All numbers in this section come from the live probe sample recorded as .tmp/gamma-samples/markets-bulk-2026-05-13.jsonl (40 500 active markets) and .tmp/gamma-samples/events-bulk-2026-05-13.jsonl (2 500 active events). Reproduction commands in §3.8.

3.1 Token / outcome cardinality ​

Among the 40 500 active markets sampled:

tokens_lencount%
240 500100%
100%
000%

outcomes_len: 40 500 / 40 500 == 2 (100%).

Conclusion: every currently-active Polymarket market is binary (2 tokens, 2 outcomes). Multi-token markets do not exist in production.

3.2 Outcome labels — production distribution ​

The outcomes array always has length 2, but the label pair varies. From the 40 500-market sample:

Labelscount%
["Yes", "No"]33 19682%
["Over", "Under"]3 2028%
Team-vs-team (sports / esports pairings)3 1168%
["Odd", "Even"]9252%
["Up", "Down"]610.2%

Total active markets using non-["Yes","No"] labels: 7 304 (18% of active sample). All are still binary (2-token); the variation is purely in display labels.

Slug patterns observed: eth-updown-5m-<timestamp>, nba-*-over-*, lol-<team1>-vs-<team2>.

3.3 Lifecycle flags ​

accepting_orders: 40 446 true, 54 null (0.1%). All sampled markets had active=true, closed=false (the Gamma /markets endpoint without a closed filter returns only active markets in offset 0–40 000).

3.4 neg_risk distribution on active markets ​

neg_riskcount%
true25 73763.5%
false14 76336.5%

3.5 neg_risk_augmented field at market level ​

Field is null for all 40 500 sampled active markets. Confirmed for first 100 records via jq -r '.negRiskAugmented // "null"' | sort | uniq -c → 100 null. negRiskAugmented is an event-level field only (§2.2).

Event-level breakdown over 2 500 sampled events:

negRiskAugmented (event)count%
true1 02641.0%
false1 47459.0%

3.6 Tick size variability ​

tick_sizecount%
0.0130 25774.7%
0.00110 24325.3%

Both tick sizes are first-class production values. Strategy tick rounding must handle both.

3.7 Events distribution ​

Among 2 500 sampled active events:

markets_count bucketcount
1 (singleton)624
264
3232
475
597
6–9255
8 (NBA per-game week bundle spike)483
10–20340
21–50304
>5029
neg_risk (event)count%
true1 25450.2%
false70128.0%
null54521.8%

neg_risk=null events are all singleton, non-sports (e.g. GTA VI released before June 2026). neg-risk events average markets_count = 16.6 vs ~2.4 for non-neg-risk.

Top-10 events by markets_count (all neg-risk):

slugmarkets_count
nhl-2025-26-hart-memorial-trophy139
democratic-presidential-nominee-2028128
presidential-election-winner-2028128
republican-presidential-nominee-2028128
next-french-presidential-election128
iran-leader-end-of-2026123
mls-2026-defender-of-the-year111
2026-womens-singles-roland-garros-winner110
ncaa-football-2026-national-champion109
2026-mens-singles-roland-garros-winner103

3.8 Weather correction (tag 103040) ​

Weather events tagged 103040 are neg_risk=true. Sample highest-temperature-in-nyc-on-december-30:

{
  "negRisk": true, "negRiskAugmented": false, "enableNegRisk": true,
  "tag_ids": ["84","101757","102169","100091","103040","103037","104596"],
  "markets_count": 7, "first_market_outcomes": "[\"Yes\", \"No\"]"
}

Source: .tmp/gamma-samples/event-weather-multimarket.json. Each temperature bracket is a separate binary Yes/No market under the neg-risk event.

This corrects an earlier project assumption (referenced in Agent C §7) that weather markets were non-neg-risk; they ARE neg-risk and use bracket Yes/No children, not multi-outcome single markets.

3.9 Historical multi-token markets (2021–2022 only) ​

Among 5 000 sampled closed=true markets, 39 had tokens_len > 2:

yearcount
202137
20222
tokens_lencount
314
46
59
68
72

Examples: how-many-senators-will-vote-to-convict-donald-trump (5 buckets), which-film-will-win-best-picture-at-the-2021-oscars (5 buckets), who-will-be-the-mvp-of-super-bowl-55 (3 buckets), who-will-the-worlds-richest-person-be-on-february-27-2021 (3 buckets — outcomes: ["Jeff Bezos", "Elon Musk", "Other"]).

Conclusion: multi-token markets are a discontinued 2021–2022 artifact. No active market in 2026 has tokens_len > 2.

3.10 Sample files reference ​

FileDescription
.tmp/gamma-samples/event-singleton-binary.jsonSingleton binary event (markets=1, negRisk=null).
.tmp/gamma-samples/event-weather-multimarket.jsonWeather neg-risk event (tag 103040, markets=7).
.tmp/gamma-samples/event-neg-risk-large.jsonLarge neg-risk event (nhl-2025-26-hart-memorial-trophy, markets=139).
.tmp/gamma-samples/event-neg-risk-augmented.jsonAugmented neg-risk event (2026-fifa-world-cup-winner-595, markets=60).
.tmp/gamma-samples/market-multi-outcome-historical.json2021-era 3-outcome closed market (Bezos/Musk/Other).
.tmp/gamma-samples/markets-bulk-2026-05-13.jsonl40 500 active markets, slim records, 11 MB.
.tmp/gamma-samples/events-bulk-2026-05-13.jsonl2 500 active events, slim records, 421 KB.
.tmp/gamma-samples/closed-multi-outcome.jsonl39 historical multi-outcome closed markets, 7.3 KB.

3.11 Reproducibility ​

  • Markets bulk: GET https://gamma-api.polymarket.com/markets?limit=500&offset=<N> for N ∈ {0, 500, …, 40 000}.
  • Events bulk: GET https://gamma-api.polymarket.com/events?limit=500&offset=<N>&active=true&closed=false for N ∈ {0, 500, …, 2 000}.
  • Closed multi-outcome: add closed=true, post-filter with jq 'select(.tokens_len > 2)'.
  • Rate: 0.3–0.5 s sleep between requests.
  • GET /markets/{id} → 404; use ?slug= or ?id= query params.
  • GET /events/{id} → 404; use ?slug= or ?id= query params.
  • GET /markets-in-events → 404 (no such endpoint).
  • GET /events/keyset?closed=false&limit=<N> (текущий метод полного обхода — курсорная пагинация, введена карточкой gamma-keyset-pagination 2026-09-04, заменяет offset-обход выше): размер страницы (events[]) фиксирован API на 100 независимо от запрошенного limit — проверено limit=100/200/300, во всех трёх случаях вернулось ровно 100 записей и непустой next_cursor (замер 15.09.2026). Полноту обхода даёт курсор, не увеличение limit; запрошенное сверх 100 источник молча урезает (то же ограничение уже учтено в рантайме — KEYSET_EFFECTIVE_LIMIT_MAX = 100 в crates/pm-engine/src/adapters/market_data/gamma.rs:67).

4. Code mapping ​

Историко-архивные якоря pm-bot-эры. Пути и :NNN-строки в §4 ниже ссылаются на УДАЛЁННУЮ раскладку крейта pm-bot (cutover 2026-07-22) и НЕ воспроизводимы на текущем дереве. Не переиндексируются построчно в pm-engine (иначе — фабрикация якорей). Структурные факты (какие поля есть, порядок parse, семантика) — crate-agnostic и остаются в силе; чтобы найти текущий код, сопоставьте на уровне каталога:

  • io/gamma_client.rs → crates/pm-engine/src/adapters/market_data/gamma.rs
  • domain/market_cache.rs → crates/pm-engine/src/adapters/market_data/
  • application/executor.rs (merge/redeem) → crates/pm-engine/src/adapters/redemption/
    • crates/pm-engine/src/engine/redemption_service.rs
  • io/onchain/mod.rs → pm-sdk on-chain ops + crates/pm-engine/src/adapters/redemption/
  • domain/strategies/directional.rs → crates/pm-engine/src/strategies/directional/
  • config/mod.rs / persistence/strategy_config.rs → crates/pm-engine/src/config/
    • config.db (ADR-015) + crates/pm-engine/src/strategies/directional/config.rs
  • persistence/capture/schema.rs → crates/pm-engine/src/capture/ (плейн-JSONL пара, НЕ Parquet)

4.1 pm_common::models::market::Market (28 fields) ​

Path: crates/pm-common/src/models/market.rs:13-83.

FieldTypeRequiredSource (Gamma → field)
condition_idStringrequiredGamma conditionId.
question_idString (default "")requiredGamma questionID.
questionString (default "")requiredGamma question.
slugString (default "")requiredGamma slug.
categoryOption<String>optionalAlways None — parse_market does not populate it.
tokensVec<Token>requiredBuilt from Gamma clobTokenIds + outcomes + outcomePrices.
neg_riskboolrequired (default false)Gamma negRisk.
neg_risk_augmentedboolserde default falseGamma negRiskAugmented. Always false when constructed from /markets — see §3.5, §6.3.
activebool (default false)requiredGamma active.
closedbool (default false)requiredGamma closed.
archivedbool (default false)requiredGamma archived.
resolvedbool (default false)requiredGamma resolved.
enable_order_bookbool (default false)requiredGamma enableOrderBook.
accepting_ordersbool (default false)requiredGamma acceptingOrders.
volumeDecimal (default 0)requiredGamma volume.
volume_24hDecimal (default 0)requiredGamma volume24hr.
liquidityDecimal (default 0)requiredGamma liquidity.
min_order_sizeDecimal (default 0)requiredGamma minimumOrderSize.
tick_sizeDecimal (default 0.01)requiredGamma orderPriceMinTickSize else minimumTickSize else 0.01.
end_dateOption<DateTime<Utc>>optionalGamma endDate (RFC3339 only).
game_start_timeOption<DateTime<Utc>>serde default, skip_serializingGamma gameStartTime (%Y-%m-%d %H:%M:%S%z); передаётся как есть для любого рынка, где Gamma его прислала — заполнено широко у спорта/киберспорта, не только у погоды (замер 15.09.2026, §1.2). Действующий парсер (crates/pm-engine/src/adapters/market_data/gamma_market_data.rs::parse_game_start_time_ms) тега не проверяет; см. §6.8 про аномалию значения.
descriptionOption<String>optionalGamma description.
event_idOption<String>optionalEvent-level id.
event_titleOption<String>optionalEvent-level title.
event_slugOption<String>optionalEvent-level slug.
resolution_sourceOption<String>optionalGamma resolutionSource.
updated_atDateTime<Utc>requiredIngest wall-clock (Utc::now() inside parse_market) — NOT from Gamma.
source_updated_atOption<DateTime<Utc>>serde default, optionalGamma updatedAt.
content_hashu64 (default 0)computedXXH3-64 over CONTENT_HASH_FIELDS after construction.

Rustdoc invariants at market.rs:85-119:

  • content_hash is XXH3-64 over 21 semantic fields listed in CONTENT_HASH_FIELDS.
  • Excluded from hash: updated_at, description, category, event_title, resolution_source, WS runtime state (token.price, best_bid, best_ask, last_trade_*), source_updated_at, content_hash itself.
  • game_start_time is skip_serializing — not persisted to wire/journal.

4.2 pm_common::models::market::Token ​

Path: crates/pm-common/src/models/market.rs:187-231.

FieldTypeSource
token_idStringGamma clobTokenIds[i].
outcomeStringGamma outcomes[i].
priceOption<Decimal>Gamma outcomePrices[i].
best_bidOption<Decimal>Gamma bestBid (YES); 1 - YES_ask (NO).
best_askOption<Decimal>Gamma bestAsk (YES); 1 - YES_bid (NO).
last_trade_priceOption<Decimal>WS Market / RTDS / User WS (serde skip).
last_trade_tsOption<DateTime<Utc>>WS Market (serde skip).
last_trade_sideOption<String>WS Market last_trade_price arm only (serde skip).
last_trade_sizeOption<Decimal>WS Market last_trade_price arm only (serde skip).

4.3 parse_market flow ​

Path: crates/pm-bot/src/io/gamma_client.rs:459-679 (private fn). Step-by-step from Agent B §2:

  1. gamma_client.rs:460-471 — extract conditionId. None → early return None, emits tracing::warn!(event="gamma_market_rejected").
  2. gamma_client.rs:473-487 — extract questionID, question, slug. All default to "" when absent.
  3. gamma_client.rs:490-491 — extract_token_ids(market) → clobTokenIds (array or JSON-encoded string).
  4. gamma_client.rs:492 — extract_string_array(market, "outcomes").
  5. gamma_client.rs:493 — extract_string_array(market, "outcomePrices").
  6. gamma_client.rs:501-502 — gamma_best_bid / gamma_best_ask via parse_decimal_field (handles both string and number JSON types — regression fix 2026-04-18).
  7. gamma_client.rs:504-534 — build Vec<Token>: index i=0 → YES (direct bestBid/bestAsk), i=1 → NO (inverted: bid = 1-ask, ask = 1-bid).
  8. gamma_client.rs:536-567 — extract boolean flags with .unwrap_or(false) defaults.
  9. gamma_client.rs:569-581 — decimal fields with parse_decimal_field; tick_size tries orderPriceMinTickSize then minimumTickSize then default 0.01.
  10. gamma_client.rs:586 — parse_end_date(market) — RFC3339 only; naive strings → None.
  11. gamma_client.rs:591-611 (крейт pm-bot, снят 2026-07-22 — путь описывает историческое поведение, не текущий код) — здесь game_start_time заполнялся только при теге id="103040" или slug="daily-temperature". Действующий путь — crates/pm-engine/src/adapters/market_data/gamma_market_data.rs::parse_game_start_time_ms — такого ограничения не содержит и пропускает значение Gamma gameStartTime для любого рынка, где оно прислано; замер 15.09.2026 подтверждает широкую заполненность у спорта/киберспорта (§1.2).
  12. gamma_client.rs:622-634 — event-level fields (event_id, event_title, event_slug).
  13. gamma_client.rs:637-642 — source_updated_at from Gamma updatedAt (tolerant to absent/null).
  14. gamma_client.rs:646-677 — construct Market, then compute_content_hash overwrites the content_hash=0 placeholder.

Embedded assumptions (all enumerated in §5).

4.4 parse_gamma_events_response (public helper) ​

Path: crates/pm-bot/src/io/gamma_client.rs:384-424. Heuristic at gamma_client.rs:398-403: if root[0].markets is an array → /events envelope; else → flat /markets list. Returns empty Vec on malformed JSON. Used by replay engine and MarketCache::hydrate_from_gamma_response.

4.5 filter_events ​

Path: crates/pm-bot/src/io/gamma_client.rs:275-302. Order:

  1. Event-level negRisk filter.
  2. Per-market resolutionSource prefix filter.
  3. parse_market.

rejected_count increments only for step (3) failures — source-filter drops are not counted.

4.6 compute_content_hash ​

Path: crates/pm-common/src/models/market.rs:142-185 (public fn). Algorithm: XXH3-64 over 21 fields from CONTENT_HASH_FIELDS (market.rs:97-119). Token projection uses (token_id, outcome) pairs only — skips WS runtime state. Separator: \x1f.

4.7 pm_common::models::strategy::MarketSnapshot ​

Path: crates/pm-common/src/models/strategy.rs:50-90. Fields: condition_id, question, slug, active, closed, resolved, accepting_orders, resolution_source, neg_risk, neg_risk_augmented, min_tick_size, outcomes: Vec<OutcomeSnapshot>.

No end_date field — by design; deadline computation lives in TimezoneCache. Rustdoc at strategy.rs:5-18. This is repeated in the project root CLAUDE.md ("IMPORTANT: end_date").

OutcomeSnapshot (strategy.rs:103-117): token_id, outcome, probability, clob_midpoint, best_bid, best_ask, last_trade_price.

4.8 pm_common::models::position::PositionInfo ​

Path: crates/pm-common/src/models/position.rs:11-24. Fields: token_id, condition_id, outcome, size, avg_price, current_price, unrealized_pnl, simulated.

4.9 On-chain implementation in pm-sdk ​

OperationPathContractSignature
splitcrates/pm-sdk/src/onchain/ctf.rs:40-76CTFsplitPosition(collateral, B256::ZERO, conditionId, [1,2], amount)
merge (standard)crates/pm-sdk/src/onchain/ctf.rs:96-133CTFmergePositions(collateral, B256::ZERO, conditionId, [1,2], amount)
merge (neg-risk)crates/pm-sdk/src/onchain/ctf.rs:173-218NegRiskAdaptermergePositions(conditionId, amount) (2-arg). Pre-flight isApprovedForAll.
redeem (standard)crates/pm-sdk/src/onchain/redeem.rs:44-64CTFredeemPositions(USDC_E, B256::ZERO, conditionId, [1,2])
redeem (neg-risk)crates/pm-sdk/src/onchain/redeem.rs:94-151NegRiskAdapterredeemPositions(conditionId, [yes_bal, no_bal]) (2-arg).
convertPositionsMISSING—Not implemented anywhere in pm-sdk or pm-bot. Verified via search for convertPositions, INegRiskAdapter.*convert, convert_position. See §6.1.

Call sites (selected):

  • crates/pm-bot/src/application/executor.rs:640-646 — merge_positions_neg_risk when neg_risk == true.
  • crates/pm-bot/src/application/executor.rs:650 — pm_sdk::onchain::ctf::merge_positions(&provider, condition_id, amount) for standard markets.
  • crates/pm-bot/src/io/onchain/mod.rs:90 — redeem::redeem_neg_risk(...) when info.neg_risk.
  • crates/pm-bot/src/application/executor.rs:560 — SdkRedeemClient::redeem_neg_risk.

4.10 MarketCache storage ​

Path: crates/pm-bot/src/domain/market_cache.rs:43-633. Internal indexes:

  • markets: HashMap<String, Market> — keyed by condition_id.
  • token_to_market: HashMap<String, String> — token_id → condition_id.
  • market_to_tokens: HashMap<String, Vec<String>> — condition_id → [token_ids].
  • markets_by_event: HashMap<String, Vec<String>> — event_slug → [condition_ids].

Selected Market fields read internally:

  • condition_id (market_cache.rs:247, 261, 448, 597) — primary key.
  • tokens (market_cache.rs:130, 250, 426-437, 456-465) — index iteration.
  • neg_risk (market_cache.rs:182, 300) — via MarketTypeFilter.passes(m).
  • event_slug (market_cache.rs:138-145, 261-267) — populates markets_by_event.
  • content_hash (market_cache.rs:273-285) — BUG-04 P2 change detection.
  • source_updated_at (market_cache.rs:230-233) — metadata_change_detected log.
  • tick_size (market_cache.rs:374) — updated by update_tick_size.
  • best_bid, best_ask (market_cache.rs:429-435) — updated by update_best_prices.
  • last_trade_price, last_trade_ts, last_trade_side, last_trade_size (market_cache.rs:457-463).

4.11 Capture frames ​

Path: crates/pm-bot/src/persistence/capture/schema.rs:1-280. Schema version CAPTURE_SCHEMA_VERSION = 1 at schema.rs:30.

FrameFieldsMarket-data content
WsBookSnapshotcondition_id, asset_id, bids, asks, seq, ts_msToken-level book; BookLevel = {price: String, size: String}.
WsBookDeltacondition_id, asset_id, side, price, new_size, seq, ts_msIncremental level; new_size="0" = remove.
WsMarketResolvedcondition_id, winning_asset_id, losing_asset_id, ts_msResolution event.
WsLastTradeasset_id, condition_id, price, size, side, ts_msLast trade. condition_id may be "" when unknown at capture time (schema.rs:169 comment).
WsTickSizeChangecondition_id, new_tick_size, ts_msTick update.
WsUserMetakind, ts_msLifecycle only — no market data.
RtdsTradecondition_id, asset_id, side, size, price, taker_addr, ts_msPublic trade flow.
RtdsOrdersMatchedcondition_id, notional, match_count, ts_msAggregated match summary.
RestGammaResponserequest_url, body_json, ts_msFull wire Gamma response.
RestClobResponseendpoint, request_hash, body_json, ts_msCLOB REST.
RestDataResponseendpoint, body_json, ts_msData API.

SourceTag priority (schema.rs:60-88): WsUser(0) > WsMarket(1) > Rtds(2) > RestGamma(3) > RestClob(4) > RestData(5) > WebhookSignal(6). CaptureFrame envelope (schema.rs:252-259): schema_version, source, ts_ns, monotonic_seq, payload.

4.12 Strategy consumption matrix ​

Per ADR-024, only directional/Harvester is active. Fields consumed by directional (Market. prefix elided): condition_id, event_slug, neg_risk, active/closed/resolved/accepting_orders, tokens[], tick_size, end_date (deadline), game_start_time (Daily Temperature anchor), resolution_source (ICAO parse), question (OrderMeta label), asks (WS sweep).

Fields unused by directional: question_id, category, event_id, event_title, description, archived, enable_order_book, min_order_size, volume, volume_24h, liquidity, updated_at, source_updated_at, content_hash, neg_risk_augmented.

Strategy file index (active only):

StrategyPathStrategyType variant
Directionalcrates/pm-bot/src/domain/strategies/directional.rsDirectional

Archived strategies (bucket_arb, maker, maker_rebate, merge_arb, single-token-mm) and their per-field consumption patterns live in docs/restoration-backlog/README.md; the per-strategy field matrix from the pre-pivot snapshot is preserved in docs/history/liveness-audit-2026-05-06.md.

4.13 Strategy-config schema ​

DDL: crates/pm-bot/src/persistence/strategy_config.rs:206-228.

sql
CREATE TABLE IF NOT EXISTS strategies (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    label TEXT UNIQUE NOT NULL,
    strategy_type TEXT NOT NULL,       -- whitelisted string
    wallet_label TEXT,
    budget_limit_usd REAL NOT NULL,
    enabled INTEGER NOT NULL DEFAULT 0,
    definition TEXT NOT NULL,          -- JSON blob, strategy-specific knobs
    created_at TEXT NOT NULL,
    updated_at TEXT NOT NULL
);

STRATEGY_TYPES whitelist in strategy_config.rs still carries legacy archived labels alongside "directional" so existing config.db rows remain readable; per ADR-024 only directional is admissible at runtime. Archived labels and their env-prefix history live in docs/restoration-backlog/README.md.

BotConfig::hydrate_strategy_block_from_json (crates/pm-bot/src/config/mod.rs:1023) reads flat {KEY: "value"} pairs from definition and applies them using the same env-var key names. No directional.* nested-key parsing exists yet.


5. Assumptions matrix — verified vs unchecked ​

Captured in code at parse_market, on-chain wrappers, and strategy admission sites. Format: assumption × file:line × verified-by × fail-mode.

Assumptionfile:lineVerified byFail mode
conditionId is a non-null string.gamma_client.rs:460-471Runtime guard (reject + warn!).None returned; market dropped.
clobTokenIds[0] = YES, clobTokenIds[1] = NO.gamma_client.rs:513-521No explicit length check; documented as convention in quickstart (no spec invariant — §10 item 1). Unit test at gamma_client.rs:1067.Silent wrong-side mapping if order ever differs.
clobTokenIds.len() == 2 (binary).gamma_client.rs:504MISSING — no assertion; enumerate() stops at actual length. Production sample (40 500/40 500) confirms it holds today.Silent single-token or 3+-token market passes through.
NO best_bid = 1 - YES_best_ask, NO best_ask = 1 - YES_best_bid.gamma_client.rs:517-519Unit test test_parse_market_best_bid_ask_numeric (gamma_client.rs:1140).Silent wrong NO prices if YES bid/ask absent.
bestBid / bestAsk are market-level (YES token only).gamma_client.rs:494-499Regression comment 2026-04-18.Prior bug: dropped weather multi-outcome prices.
outcomes[0] = "Yes", outcomes[1] = "No".gamma_client.rs:508 via outcomes.get(i)MISSING explicit check. Unit test at gamma_client.rs:1069. Production probe shows 18% of active markets violate the label assumption (Over/Under, team names, Odd/Even, Up/Down — §3.2). The strategies use positional index (i=0/i=1), not the label string, so the assumption is semantically violated but not physically used.Code that filters or routes on the literal "Yes"/"No" label would mis-classify 7 304 active markets.
gameStartTime format = "%Y-%m-%d %H:%M:%S%z".gamma_client.rs:607MISSING — and_then returns None silently on mismatch.game_start_time = None → deadline falls back to end_date.
endDate is RFC3339 (not naive).gamma_client.rs:715-720Regression test gamma_end_date_parsing.rs (cited line 585).Naive strings → None (intentional fail-closed).
Gamma ?neg_risk=… URL param works.gamma_client.rs:46-50Verified broken — comment "silently ignored (verified 2026-04-20)".Gamma returns unfiltered response; client-side filter required.
neg_risk on market matches parent event.gamma_client.rs:279-286Runtime filter checks event-level negRisk.Mismatch possible if Gamma desyncs.
parentCollectionId = B256::ZERO for all splits/merges.crates/pm-sdk/src/onchain/ctf.rs:64, 117Unit test split_uses_zero_parent_collection (ctf.rs:232).Revert on-chain if nested positions ever appear.
conditionId is exactly 32 bytes hex.crates/pm-sdk/src/onchain/redeem.rs:230-236Unit tests (redeem.rs:243-287).anyhow::Error returned.
MarketCache::update resets WS-derived prices.crates/pm-bot/src/domain/market_cache.rs:109-115Rustdoc comment.Strategy sees stale-then-None prices until WS rebuilds.
WsLastTrade.condition_id may be "".crates/pm-bot/src/persistence/capture/schema.rs:169Code comment "empty string if condition_id unknown".Replay engine must handle empty condition_id.
Single binary event cannot be neg-risk.NegRiskAdapter.sol L246-249Hard on-chain revert NoConvertiblePositions() when questionCount ≤ 1.Contract revert. Off-chain negRisk can still be true on a 1-market event, but conversions are disabled.
Augmented neg-risk has same on-chain contract as standard neg-risk.https://docs.polymarket.com/advanced/neg-risk.mdNo separate AugmentedNegRiskAdapter.sol in any Polymarket GitHub repo.n/a — augmentation is off-chain bulletin-board only.

6. Known gaps ​

6.1 pm-sdk lacks convertPositions ​

Verified absent. Search for convertPositions, INegRiskAdapter.*convert, convert_position across all crates returns no results. Consequence: any future restored strategy that wants to convert a NO leg into the sibling-YES set on a neg-risk event has no implementation primitive available. See restoration-backlog for the historical multi-arb usage pattern.

6.2 No runtime length-assertion on clobTokenIds ​

parse_market does not enforce clobTokenIds.len() == 2. The 40 500-market production probe confirms 100% binary today, but any future drift would pass through silently as a single-token or N-token Market with degenerate fields.

6.3 Market.neg_risk_augmented field is effectively dead ​

Per §2.2 + §3.5, negRiskAugmented on the /markets Gamma endpoint is always null, so Market.neg_risk_augmented: bool is always false when constructed from /markets. The field carries information only when the Market is constructed inside /events parsing where the event-level value can be propagated. No active strategy consumes the field (the historical bucket_arb consumer is archived per ADR-024; see restoration-backlog). 41% of active events have negRiskAugmented=true — that information is not exposed at the market record returned by /markets and is therefore inaccessible to strategies reading the cache through that path.

6.4 Tick size 0.001 affects 25% of markets ​

tick_size = 0.001 covers 10 243 of 40 500 active markets (25.3%) — §3.6. Any price-rounding logic that assumes 0.01 would mis-quote a quarter of production.

6.5 /markets/{id} returns 404 — no REST path-by-ID ​

Per §3.11 (Agent C §6). All Gamma lookups must use ?slug= or ?id= query parameters. There is no /markets/{id} REST path.

6.6 Historical 2021 multi-outcome markets not filtered ​

parse_market tolerates tokens_len > 2 silently — the enumerate() loop iterates the full array. These markets are all closed (§3.9) and outside active scope, but a replay over 2021 capture data would parse them into degenerate Market records (binary code paths applied to ≥3 tokens).

6.7 4-algorithm taxonomy is documentation-only ​

Per §4.13 above: STRATEGY_TYPES whitelist still contains legacy labels for backward-compatible config.db reads. Per ADR-024 only directional is admissible at runtime. Archived strategies + restoration framework: restoration-backlog/README.md.

6.8 gameStartTime не валидируется перед использованием в якоре GstOffset ​

Поле широко заполнено у спорта/киберспорта (§1.2, §4.1 — замер 15.09.2026), но значение, присланное Gamma, не всегда согласовано с endDate: наблюдён случай gameStartTime позже endDate примерно на 2 месяца (слаг isp-ddy-ddy-2026-03-13). Действующий парсер (crates/pm-engine/src/adapters/market_data/gamma_market_data.rs::parse_game_start_time_ms) разбирает строку формата, но не проверяет её правдоподобность относительно endDate или текущего времени. compute_deadline для AnchorSource::GstOffset (crates/pm-engine/src/ports/deadline.rs:75-78) берёт metadata.game_start_time_ms напрямую (gst + offset_seconds) без сверки с end_date — недостоверное значение пройдёт в якорь как есть. Требуется проверка перед использованием (например, game_start_time не позже endDate и не в прошлом на момент обнаружения рынка) — на момент замера (15.09.2026, чтение deadline.rs) такой проверки в коде нет.


7. Edge cases & invariants ​

7.1 Outcome-label aliases — positional index is the only stable contract ​

outcomes labels in the production sample (§3.2):

  • 82% ["Yes", "No"]
  • 8% ["Over", "Under"]
  • 8% team-vs-team pairings
  • 2% ["Odd", "Even"]
  • 0.2% ["Up", "Down"]

All strategy code uses outcomes[i] / tokens[i] positionally (i=0 is the canonical "first" outcome — typically YES). The label is informational. Any code path that does if outcome == "Yes" would fail on 18% of active markets.

7.2 Tick size variability ​

Both 0.01 and 0.001 are first-class. See §3.6 and §6.4.

7.3 Weather events are neg-risk ​

Tag 103040 events are neg_risk=true (§3.8). Each temperature bracket is its own binary Yes/No market under a neg-risk event with 7 children (sample: highest-temperature-in-nyc-on-december-30).

7.4 Empty clobTokenIds ​

Zero markets with tokens_len == 0 in 40 500 active sample (§7 of Agent C). Empty arrays appear in closed markets only.

7.5 Token order ground-truth ​

Documentation prose at https://docs.polymarket.com/quickstart.md says "The first ID is the Yes token, the second is the No token." No formal OpenAPI invariant; no machine-readable contract. See §10 item 1.

7.6 Token degeneracy ​

In a 100-market probe, 0 markets had t0 == t1. All sampled markets have distinct YES / NO token IDs.

7.7 WsLastTrade.condition_id may be empty string ​

Comment at crates/pm-bot/src/persistence/capture/schema.rs:169 notes the field can be "" if condition_id is unknown at capture time. Replay code must tolerate it.

7.8 negRisk=null events ​

545 of 2 500 sampled events (21.8%) have negRisk=null at the event level — older singleton events (e.g. GTA VI released before June 2026). Parsing falls back to false via the serde default on Market.neg_risk.


8. Контракт отправки заявки (CLOB) — ответы, отказы, резерв баланса, задержка, идентификатор ​

Раздел о ПОВЕДЕНИИ контрагента на пути отправки заявки: какой формой приходит отказ, чему в ответе верить нельзя, как биржа держит баланс под открытыми заявками, что означает состояние задержки и чем на самом деле является идентификатор заявки. Знание дорогое в добыче: собрано 2026-07-29 из документации биржи, её OpenAPI-описания, исходников биржевого контракта и боевых данных нашего кошелька.

Класс источника назван у КАЖДОГО утверждения — иначе следующий читатель примет опубликованную формулу за измерение:

МеткаЗначение
[ДОК]Опубликовано контрагентом: docs.polymarket.com, OpenAPI-описание CLOB, исходник контракта.
[БОЙ]Наблюдалось на нашем боевом кошельке 0xba561b30…d0b7f4, в боевой базе или в боевом коде.
[ВЫВОД]Следует из [ДОК]/[БОЙ] логически; отдельным измерением не подтверждалось.
[НЕ ПРОВЕРЕНО]Ни документации, ни измерения — перечислено в §10.

8.1 Одиночный и пакетный эндпойнты — РАЗНЫЕ контракты ответа ​

[ДОК] POST /order (одиночный; ровно им ходит движок — V2_POST_ORDER_PATH = "/order", crates/pm-sdk/src/clob/wire.rs:54). Под кодом 200 опубликованы ТОЛЬКО успехи: три примера live / matched / delayed, у всех errorMsg: ''. Отказ документирован кодами 400 / 401 / 500 / 503 с телом ErrorResponse { error, code?, retry_after_seconds? } — поле называется error, не errorMsg.

[ДОК] POST /orders (пакетный; движок им НЕ пользуется). Возвращает массив SendOrderResponse, и там форма ответа другая: в примере post_only_mode элементы несут success: true при ПУСТОМ orderID, пустом status и непустом errorMsg; в примере mixed_results третий элемент — success: false, orderID: '', status: delayed, errorMsg: 'Rate limit exceeded for tokenId: …'. То есть в пакетном ответе ни success, ни status признаком приёма не являются.

[ВЫВОД] Правило «success ненадёжен, смотри errorMsg» относится к ПАКЕТНОМУ эндпойнту и на одиночный без измерения не переносится. Обратное тоже неверно: из того, что отказы одиночного эндпойнта задокументированы не-2xx кодами, НЕ следует, что 200 с пустым orderID невозможен, — это не проверено (§10).

8.2 Что наш код читает из ответа и где он слеп ​

[БОЙ] На пути ОТВЕТА НА POST ClobExecutor::send_prepared_order (crates/pm-sdk/src/clob/mod.rs:840) берёт из успешного тела ТОЛЬКО orderID (на пути чтения списка открытых заявок словарь статусов шире — см. 8.5). status, errorMsg, makingAmount, takingAmount, transactionsHashes, tradeIDs отбрасываются. Ответ 2xx без orderID → ClobError::Sdk → в движке OrderError::SubmitOutcomeUnknown, то есть НЕОПРЕДЕЛЁННОСТЬ (деньги удержаны, намерение заблокировано), а не окончательный отказ. Прежняя запись «→ OrderError::Upstream» устарела с расщеплением источника (ambiguous-submit-outcome, волны 6–7) и здесь исправлена.

[БОЙ] Отображение кодов отказа (classify_post_error, crates/pm-sdk/src/clob/wire.rs:594): 425 или тело с post_only_mode → MatchingEngineRestart{retry_after}; 429 → RateLimited; 401|403 → AuthExpired; 400|422 → OrderRejected{reason = тело целиком}; 404 → NotFound; всё прочее → Sdk. Тело отказа сохраняется дословно — это единственное, что потом можно разобрать.

[ВЫВОД] Слепые зоны названы прямо: статусы delayed и unmatched в ответе на POST движок не различает вовсе (см. 8.5), а различение видов отказа держится на разборе текста (см. 8.3).

8.3 Машиночитаемого кода отказа НЕТ ​

[ДОК] ErrorResponse.code описан как «Machine-readable error code, when provided». Во всём OpenAPI-описании поле code встречается ровно в двух примерах, и оба несут ОДНО значение — post_only_mode (HTTP 503, вместе с retry_after_seconds; один пример у одиночного эндпойнта, один у пакетного). Для отказа по нехватке баланса кода не публикуется вовсе.

[БОЙ] Дословный боевой отказ по балансу: HTTP 400, тело {"error":"not enough balance / allowance: the balance is not enough -> balance: 0, order amount: …"} (зафиксировано в crates/pm-engine/src/adapters/clob/live/client.rs:1317).

[ВЫВОД] Отличить «отказ по нехватке долей» от прочих отказов можно ТОЛЬКО разбором текста. Три классификатора по подстрокам (is_balance_settling_reject, is_post_only_crossing_reject, is_matching_engine_restart_reject в том же файле) — не небрежность, а единственный доступный различитель. Следствие для любого будущего проекта: перефразировка сообщения на стороне биржи молча переводит отказ в неразобранный класс, и защита, построенная на тексте, обязана иметь консервативное поведение по умолчанию.

8.4 Резерв баланса под открытыми заявками — главный факт раздела ​

[ДОК] Дословно (concepts/order-lifecycle.md, раздел «Requirements»):

Order size is limited by your available balance minus any amounts reserved by existing open orders. maxOrderSize = balance − Σ(openOrderSize − filledAmount)

[ДОК] Там же: баланс для покупки — pUSD, для продажи — сами доли («Sufficient pUSD (for buys) or tokens (for sells)»). Валидация баланса и allowance выполняется оператором при приёме заявки («The operator validates: … You have sufficient balance»).

[ВЫВОД] Прогон формулы по трём состояниям исходной продажи 100 долей — то, ради чего факт и нужен:

Состояние первой продажиДоли на кошелькеЗарезервированоДоступно к продажеОтвет на повтор
Покоится, не исполнена100100 − 0 = 1000отказ по балансу
Исполнена частично (40 из 100)60100 − 40 = 600отказ по балансу
Исполнена полностью000отказ по балансу

Три состояния, требующие РАЗНЫХ действий, дают ОДИН ответ. Отказ по балансу доказывает лишь, что инвентарь связан либо уже продан, и НЕ отвечает, жива ли первая заявка.

Таблица посчитана в предположении, что биржевой взгляд на баланс АКТУАЛЕН на момент повтора. Если между сопоставлением и расчётом биржа ещё считает нашими уже проданные доли, строка «исполнена частично» даёт ненулевой доступный остаток и повтор может быть принят — это не опровергнуто и вынесено в §10 пункт 6.

[ВЫВОД] Обратное направление информативно: успех повторной продажи означает, что доли не связаны и не проданы, то есть первой заявки на бирже нет — с оговоркой о свежести биржевого взгляда на баланс (последний пункт этого подраздела).

[ВЫВОД] Асимметрия сторон, из которой растёт всё поведение при потерянном ответе: продажа резервирует ровно тот инвентарь, который продаёт, поэтому потолок связывает; покупка резервирует деньги, и заявка на 2.50 USD против залога в сотни долларов потолка не достигает — повтор покупки будет ПРИНЯТ, и позиция удвоится.

[ДОК] Крайний рубеж против продажи несуществующих долей — расчёт: «Settlement is atomic: either the entire trade succeeds or nothing happens», а трейд имеет терминальный статус FAILED. Продать доли, которых нет, расчёт не даст; ценой станет неисполнившаяся сделка, а не отрицательный инвентарь.

[БОЙ] Резерв ИЗМЕРЕН прямой пробой 2026-07-29, и текст отказа получен именно от собственной покоящейся продажи (прежде наш единственный образец был получен из-за лага расчёта после исполнения — то есть из другого состояния, и совпадение текстов оставалось неизвестным). При живой первой продаже повтор той же продажи отвергается не-2xx с телом:

json
{"error":"not enough balance / allowance: the balance is not enough -> balance: 5000000, sum of active orders: 5000000, sum of matched orders: 0, order amount (inc. fees): 5000000"}

Отказ несёт РАЗБИВКУ из трёх чисел: сколько долей есть, сколько занято АКТИВНЫМИ заявками, сколько ИСПОЛНЕНО. Машиночитаемого кода у него нет (§8.3), поэтому читается только разбором строки.

[БОЙ] GET /balance-allowance резерва НЕ показывает. Баланс долей не меняется ни сразу после выставления продажи, ни через три секунды, ни после её снятия. Значит текст отказа выше — ЕДИНСТВЕННЫЙ канал этой информации.

[ВЫВОД] Разбивка НЕ отменяет вывод таблицы выше. Три числа — агрегаты по кошельку и исходу, а не по конкретной заявке: «занято активными» может держать посторонняя покоящаяся заявка того же исхода, а заявка FAK не связывает доли по построению. Судьбу конкретной отправки разбивка не устанавливает.

[НЕ ПРОВЕРЕНО] Свежесть биржевого взгляда на баланс в момент повторной заявки. Наблюдавшийся у нас лаг направлен в консервативную сторону (balance: 0 сразу после налитой ПОКУПКИ, то есть биржа недооценивает наш инвентарь и отказывает лишний раз). Лаг в противоположную сторону (биржа ещё считает нашими уже проданные доли) не наблюдался и не опровергнут — см. §10.

8.5 Состояние задержки: delayed, unmatched и асинхронный отказ ​

[ДОК] Задержек две (concepts/order-lifecycle.md, шаг «Match or Rest»):

  • taker delay — «used on selected crypto and finance up/down markets. The order is held for 250 ms, then validation runs again». Признак рынка: itode: true в ответе GET https://clob.polymarket.com/clob-markets/{condition_id}.
  • sports/game delay — «enabled on configured sports markets around live game conditions», окно задаёт поле рынка seconds_delay.

[ДОК] Дословно про обе: «During either delay, the order is pending and cannot be canceled. If the market, balance, allowance, or risk checks fail when the delay expires, the order is rejected instead of matching.» Это закрывает два вопроса разом: внутри окна заявку отменить НЕЛЬЗЯ, а ответ delayed НЕ является обещанием исполнения или покоя — отказ может прийти позже и уже не в ответе на POST.

[ДОК] Статусы ответа на размещение: live, matched, delayed, unmatched («Marketable order placed on the book after the delay expired without a match»). Расхождение внутри документации контрагента: OpenAPI-перечисление SendOrderResponse.status содержит только live|matched|delayed, без unmatched.

[БОЙ] Боевой набор движка задержки не имеет: у трёх активных погодных рынков области поиска (тег 104596) на 2026-07-29 seconds_delay = 0, признака itode в компактном описании GET /clob-markets/{condition_id} нет (проверены 0x09e90a76…33f53, 0x0e791fd5…6b8c, 0xa7aa01d5…b43c3). Путь задержки станет достижимым при расширении области поиска на спорт или крипту.

[БОЙ] В коде строки DELAYED / UNMATCHED известны только читателю списка открытых заявок (crates/pm-engine/src/lifecycle/bootstrap.rs:354); путь ответа на POST их не различает.

8.6 Отмена и частичное исполнение ​

[ДОК] «You can cancel orders at any time before they're matched via the CLOB API, except while a marketable order is in a pending delay window.»

[ДОК] «Partial fills cannot be cancelled: only the unfilled portion of an order can be cancelled.»

[ДОК] Отмена адресуется хешем: CancelOrderPayload.orderID — «Order ID (order hash) to cancel». Ответ CancelOrdersResponse { canceled: [...], not_canceled: { id: причина } } — причина отказа отмены приходит ТЕКСТОМ на идентификатор, кода там тоже нет.

8.7 Идентификатор заявки = подписной EIP-712-хеш (доказано, не предположено) ​

[ДОК] SendOrderResponse.orderID — «Unique identifier for the order (order hash)».

[ДОК] Исходник биржевого контракта (репозиторий Polymarket/ctf-exchange): hashOrder(order) = _hashTypedDataV4(keccak256(abi.encode(ORDER_TYPEHASH, …))) (mixins/Hashing.sol:19-20); ОДИН и тот же bytes32 идёт в проверку подписи (mixins/Trading.sol:230-233 → Signatures.sol:21-22) и в событие OrderFilled (mixins/Trading.sol:108).

[БОЙ] Сверка на боевых данных, без единой новой заявки: строка боевой базы polymarket_order_id = 0x31b1d4ff…18c0c (SELL, налито 25 долей, first_fill_at 1785130961517) → сделка data-api (SELL 25 по 0.40, transactionHash = 0xf8cdb352…3a15) → в квитанции транзакции лог контракта 0xe2222d279d744050d28e00520010520000310F59 с topics[1] = 0x31b1d4ff…18c0c и maker = наш кошелёк. Адрес эмитента — ровно тот verifyingContract, с которым pm-sdk строит EIP-712-домен V2 (crates/pm-sdk/src/contracts/mod.rs:151).

[ВЫВОД] Значит идентификатор, который биржа вернула в ответе, равен хешу, вычисленному контрактом, а тот равен хешу, который мы подписали (иначе восстановление подписи дало бы другого подписанта и исполнение бы не состоялось).

[БОЙ] Прямое ПОБАЙТОВОЕ измерение 2026-07-29 — вывод выше больше не единственная опора. Локально вычисленный подписной хеш 0xba953f0e…bebfaf6 совпал с orderID, который вернула биржа на ту же отправку: 0xba953f0e…bebfaf6. Цепочка «строка базы → сделка → лог контракта» остаётся верной, но равенство теперь измерено на ответе самой биржи, а не выведено через квитанцию транзакции.

Следствие практическое: локальный хеш, вычисленный ДО отправки, — законный ключ сверки. По нему можно спрашивать состояние заявки, отменять её и УЗНАВАТЬ СВОЮ заявку в перечне открытых ордеров, вовсе не дождавшись ответа на POST.

[НЕ ПРОВЕРЕНО] Исходник, прочитанный по ссылке, — контракт V1; исходник V2 (0xe2222d…) не читался. Структура «посчитали хеш → проверили им подпись → его же отдали в событие» взята из V1, а на V2 подтверждена совпадением боевых значений выше.

8.8 Ограничения ЧТЕНИЯ, важные для сверки ​

[ДОК] Get user orders / Get Open Orders — «Returns paginated results». Пустая страница выдачи НЕ является доказательством отсутствия открытых заявок.

[ДОК] Get Orders (история состояний заявок) и Get Fills — «Maximum of 100 entries returned per request». Любая сверка по истории обязана листать выдачу, иначе молча смотрит в хвост.

8.9 Поле deferExec ​

[ДОК] В теле SendOrder есть deferExec: boolean, default: false («Whether to defer execution»).

[БОЙ] Наш V2OrderPostBody (crates/pm-sdk/src/clob/wire.rs:150-160) содержит ровно четыре поля: order, owner, orderType, postOnly. Поле deferExec не отправляется НИКОГДА, то есть действует документированное умолчание false — исполнение не откладывается. На конструкцию защиты от потерянного ответа поле влияния не оказывает.


9. Glossary ​

TermMeaning
conditionId / condition_id32-byte hex identifier of the on-chain CTF condition. Primary key everywhere.
questionId / question_idOn-chain question identifier (input to prepareCondition).
clobTokenIdsERC1155 token IDs (uint256 decimal strings), 2 per binary market. Index 0 = YES, 1 = NO.
position_idSynonym for ERC1155 tokenId of a YES or NO position.
collection_idInternal CTF identifier used in splitPosition / mergePositions — Polymarket binary markets always use parentCollectionId = B256::ZERO.
wcolWrapped collateral token used inside NegRiskAdapter to bridge collateral to CTF splits.
outcomeSlotCountCTF parameter; always 2 for all Polymarket markets.
partitionCTF parameter; always [1, 2] for Polymarket binary YES/NO splits.
payout vectorResolution payouts indexed by outcome slot; Polymarket binary uses [1,0] or [0,1].
tag idGamma event categorization. 103040 = weather (Daily Temperature).
neg-riskMechanism for multi-outcome events where only one outcome can win, managed by NegRiskAdapter. Requires ≥2 questions.
augmented neg-riskExtension of neg-risk with placeholder outcomes + an explicit "Other" market; managed off-chain via bulletin board; same on-chain contract.
negRiskMarketIDEvent-level identifier under NegRiskAdapter (mentioned in NegRiskAdapter source; not exposed through the Market struct in pm-common).
event_slugURL slug of the parent Gamma event. Used by directional and historically by archived multi-leg strategies.
BINARY_PARTITIONLocal constant [1, 2] in crates/pm-sdk/src/onchain/ctf.rs:20.
MarketInputDTO synthesized from Market for directional strategy at crates/pm-bot/src/domain/strategies/directional.rs:3195.
MarketSnapshotView of Market exposed to strategy interface (without end_date; deadline via TimezoneCache).
SourceTagPriority enum for capture-frame de-duplication (WsUser > WsMarket > Rtds > RestGamma > RestClob > RestData > WebhookSignal).
BotEvent / EventEnvelope / BotSnapshotWire contract between pm-bot and pm-dashboard (crates/pm-bot-events/).

10. UNRESOLVED items & inter-report contradictions ​

UNRESOLVED facts (need external confirmation) ​

  1. clobTokenIds order — formal spec guarantee. The quickstart prose states the first ID is the Yes token; the OpenAPI YAML schema describes clobTokenIds only as array of string with no ordering invariant. Risk: a future API refactor could silently break this. Requires Polymarket dev inquiry for a machine-readable invariant.
  2. Augmented neg-risk bulletin-board API. Docs reference "the bulletin board" for placeholder clarification but provide no API endpoint or contract address. Requires Polymarket dev inquiry for a concrete API surface.
  3. NegRiskAdapter.convertPositions gas cost. No NatSpec gas estimate; empirical benchmarks not available without on-chain testing.
  4. Separate augmented-neg-risk on-chain contract. No AugmentedNegRiskAdapter.sol found in any Polymarket GitHub repo. Augmentation appears to be entirely off-chain; could not definitively confirm from public sources alone.
  5. Резервирует ли баланс заявка, находящаяся в окне задержки (delayed, §8.5). Документация говорит только, что заявка «pending» и не отменяема; о резерве под неё не сказано ничего. Попытка измерить 2026-07-29 НЕ УДАЛАСЬ: на выбранном рынке с seconds_delay = 1 заявка исполнилась раньше, чем окно удалось наблюдать; отрицательный результат неинформативен. Остаётся вопросом денег при расширении области поиска на спорт или крипту. Закрывается пробой на рынке с БОЛЬШОЙ задержкой либо ответом разработчиков биржи.
  6. Свежесть биржевого взгляда на баланс в момент повторной заявки (§8.4). Наблюдался лаг в консервативную сторону (balance: 0 сразу после налитой покупки). Лаг в противоположную сторону — биржа ещё считает нашими уже проданные доли — не наблюдался и не опровергнут. Если он существует, повторная продажа может быть ПРИНЯТА и упереться уже в расчёт (терминальный статус сделки FAILED), а не в отказ приёма.
  7. Дословная форма отказа, вызванного ИМЕННО собственной покоящейся заявкой — ЗАКРЫТО ИЗМЕРЕНИЕМ 2026-07-29 (§8.4). Отказ получен прямой пробой при живой первой продаже, дословный текст записан, и он несёт РАЗБИВКУ из трёх чисел (balance, sum of active orders, sum of matched orders). Побочно измерено: GET /balance-allowance резерва под открытыми заявками не показывает вовсе, то есть текст отказа — единственный канал этой информации.
  8. Единицы minimum_order_size. Биржа отдаёт minimum_order_size: 5 и в описании рынка, и в стакане, но единицы не названы ни в OpenAPI-описании, ни в документации. Проект читает величину как ДОЛИ (min_size_shares в настройках движка). Прочтение «5 долларов» не опровергнуто.
  9. Возможен ли у одиночного POST /order ответ 200 с пустым orderID (§8.1). Такая форма документирована ТОЛЬКО у пакетного эндпойнта. Проба 2026-07-29 её НЕ ВОСПРОИЗВЕЛА: заявка «только в стакан», пересекающая рынок, — самая правдоподобная кандидатура на этот ответ — получила не-2xx с телом {"error":"invalid post-only order: order crosses book"}, то есть отказ пришёл обычным путём. Вопрос остаётся открытым (одна проба не исчерпывает класс), но улик в пользу существования формы у одиночного эндпойнта нет. Наш код на неё отвечает ClobError::Sdk → OrderError::SubmitOutcomeUnknown, то есть НЕОПРЕДЕЛЁННОСТЬЮ (прежняя запись «→ OrderError::Upstream, окончательный отказ» устарела с волнами 6–7 и здесь исправлена).

Inter-report contradictions observed during synthesis ​

  • NHL Hart Trophy event classification. Agent C §5 lists nhl-2025-26-hart-memorial-trophy under "Large neg-risk event" with negRiskAugmented not noted; the on-disk sample .tmp/gamma-samples/event-neg-risk-large.json actually returns "negRisk": true, "negRiskAugmented": true, "enableNegRisk": true. The sample is therefore both neg-risk and augmented, not "plain" neg-risk as Agent C's tabular row suggests. This document classifies it as augmented per the live sample (§1.1, §3.7).
  • Archived per-strategy neg_risk handling. Pre-pivot per-strategy neg-risk admission rules (merge_arb / maker_rebate / bucket_arb) live in restoration-backlog/README.md and docs/history/liveness-audit-2026-05-06.md.
  • Weather neg-risk status. Agent C §7 frames the neg_risk=true weather classification as a "correction to earlier assumption", with the implication being that an older project belief held weather to be non-neg-risk. Project memory feedback_max_hours_expiry.md references weather neg-risk as established. The live probe (§3.8) settles the fact: weather IS neg-risk; bracket Yes/No children.
  • (Removed) Pre-pivot taxonomy notes about maker_rebate.rs unification — see restoration-backlog/README.md#maker-rebate and #paired-maker-rebate.

11. Provenance ​

Audit date: 2026-05-13 (§1–§7); 2026-07-29 (§8 — контракт отправки заявки, источники ниже).

Synthesis input — reports (under /home/me/RustroverProjects/polymarket-engine/.tmp/audit-reports/):

  • A-polymarket-docs-onchain.md — Polymarket docs sweep + on-chain Solidity (CTF, NegRiskAdapter) source links.
  • B-code-mapping.md — pm-common/pm-bot/pm-sdk mapping + assumptions matrix.
  • C-production-data.md — 40 500-market probe with distributions and edge cases.
  • D-strategy-consumption.md — strategy data consumption + 4-algorithm taxonomy migration impact.

Synthesis input — samples (under /home/me/RustroverProjects/polymarket-engine/.tmp/gamma-samples/):

  • event-singleton-binary.json — singleton binary event.
  • event-weather-multimarket.json — weather neg-risk event (tag 103040).
  • event-neg-risk-large.json — 139-market neg-risk event.
  • event-neg-risk-augmented.json — 60-market augmented neg-risk event.
  • event-neg-risk-large-meta.json — slim metadata sidecar.
  • market-multi-outcome-historical.json — 2021-era 3-outcome closed market.
  • markets-bulk-2026-05-13.jsonl — 40 500 active markets (slim records, 11 MB).
  • events-bulk-2026-05-13.jsonl — 2 500 active events (slim records, 421 KB).
  • closed-multi-outcome.jsonl — 39 historical multi-outcome closed markets.

On-chain source references:

Documentation references:

Источники §8 (сбор 2026-07-29):

  • Жизненный цикл заявки (резерв баланса, обе задержки, статусы, отмена): https://docs.polymarket.com/concepts/order-lifecycle.md
  • Справка по одиночному размещению (коды отказа, ErrorResponse, deferExec): https://docs.polymarket.com/api-reference/trade/post-a-new-order.md
  • OpenAPI-описание CLOB (SendOrderResponse, ErrorResponse, пакетный POST /orders, CancelOrderPayload): docs.polymarket.com/api-spec/clob-openapi.yaml, локальная копия .tmp/clob-openapi.yaml.
  • Исходник биржевого контракта (hashOrder → проверка подписи → OrderFilled): https://github.com/Polymarket/ctf-exchange — src/exchange/mixins/{Hashing,Trading,Signatures}.sol.
  • Боевая сверка идентификатора: строка orders боевой базы harvester-engine, сделка data-api кошелька 0xba561b30…d0b7f4, квитанция транзакции 0xf8cdb352…3a15 в сети Polygon (лог контракта 0xe2222d27…310F59).
  • Живое чтение рынков области поиска: GET https://clob.polymarket.com/markets/{condition_id} и GET https://clob.polymarket.com/clob-markets/{condition_id} по трём активным погодным рынкам тега 104596.

12. Change log ​

  • 2026-09-15 — Исправлено ложное утверждение «gameStartTime присутствует только у погоды (тег 103040)» (§1.2, §4.1, §4.3 п.11 — унаследовано из первичного синтеза 2026-05-13, отражало код удалённого крейта pm-bot): живой обход Gamma /events/keyset показал поле заполненным у 121 237 из 141 510 активных рынков спорта/киберспорта (теги 1, 64, 105907); действующий парсер (gamma_market_data.rs::parse_game_start_time_ms) тега не проверяет. Добавлен §6.8 — значение не проверяется на согласованность с endDate перед использованием в якоре GstOffset (наблюдена аномалия: gameStartTime позже endDate на ~2 месяца, isp-ddy-ddy-2026-03-13). Добавлен факт в §3.11: размер страницы /events/keyset фиксирован API на 100 независимо от limit (проверено limit=100/200/300).
  • 2026-07-29 (живые пробы контракта) — §8 приведён к измерениям, полученным пробами на боевом кошельке (crates/pm-engine/tests/live_order_contract_probes.rs, стоимость $0.22): равенство биржевого orderID подписному хешу измерено ПОБАЙТОВО, а не выведено через квитанцию (§8.7); отказ по нехватке долей получен ИМЕННО от собственной покоящейся продажи и несёт разбивку из трёх чисел, а GET /balance-allowance резерва не показывает вовсе (§8.4, §10 пункт 7 закрыт); одиночный POST /order отказывает не-2xx с полем error — форма «200 плюс errorMsg» пробой не воспроизведена (§10 пункт 9 уточнён); на успехе errorMsg присутствует и пуст; попытка измерить резерв внутри окна задержки не удалась (§10 пункт 5). Исправлены две устаревшие записи о нашем коде: 2xx без orderID даёт OrderError::SubmitOutcomeUnknown, а не Upstream (§8.2, §10 пункт 9).
  • 2026-07-29 — Добавлен §8 «Контракт отправки заявки»: резерв баланса под открытыми заявками (maxOrderSize = balance − Σ(openOrderSize − filledAmount)) и его следствие — отказ по балансу не различает «покоится / частично исполнена / исполнена»; отсутствие машиночитаемого кода отказа у одиночного POST /order; разная форма ответа у одиночного и пакетного эндпойнтов; семантика окна задержки (pending, отмена запрещена, отказ возможен по истечении окна) и статус unmatched, отсутствующий в OpenAPI-перечислении; доказанное равенство биржевого идентификатора заявки и подписного EIP-712-хеша (боевая сверка с событием OrderFilled); deferExec движком не отправляется. Разделы 8–11 сдвинуты на один номер вниз (Glossary → §9, UNRESOLVED → §10, Provenance → §11, Change log → §12).
  • 2026-05-13 — Initial synthesis from four parallel audit agents (A docs+on-chain, B code mapping, C production probe 40 500 markets, D strategy consumption). Established: 100% active binary markets, 63.5% neg-risk active, 18% non-Yes/No labels, 25% tick=0.001, weather neg-risk confirmed, Market.neg_risk_augmented field dead at /markets path, convertPositions absent from pm-sdk.