This document defines the stability promise for ccxt_extract's output format. Consumers (Elixir, Rust, Go, Python) rely on schema_version to know what they can safely depend on.
Every per-exchange JSON file and _manifest.json includes a schema_version field (currently "4.1.0"). This version follows semver (MAJOR.MINOR.PATCH):
| Change Type | Version Bump | Consumer Impact |
|---|---|---|
| Additive fields — new nullable keys in existing sections | Patch (4.0.x) | Safe to ignore. Existing field access unaffected. |
| Backward-compatible structural changes — reorganized sections, renamed fields with temporary aliases that preserve old access paths | Minor (4.x.0) | Plan to update parsers. Old field access works during the alias period but aliases are eventually removed. |
| Breaking changes — removed fields, changed types, semantic changes to existing fields | Major (x.0.0) | Must update. Old parsers will fail or produce wrong results. |
Enum fields are open sets. Fields like has capabilities and AST node type values may gain new values at any version bump. Consumers must not exhaustively match these — use fallback/default branches.
Check version on load. Before parsing any exchange JSON, read schema_version and fail fast on incompatible data:
# Python
data = json.load(f)
major = int(data["schema_version"].split(".")[0])
if major != 4:
raise ValueError(f"Unsupported schema version: {data['schema_version']}")// Rust
let major: u32 = data["schema_version"].split('.').next().unwrap().parse()?;
assert_eq!(major, 4, "Unsupported schema version");# Elixir
case data do
%{"schema_version" => "4." <> _} -> :ok
%{"schema_version" => v} -> raise "Unsupported schema version: #{v}"
endHandle unknown fields gracefully. Patch versions may add new nullable keys. Consumers should ignore fields they don't recognize rather than failing on them.
Pin to a major version. Your consumer code targets a major schema version (currently 4). Within that major version, all changes are backward-compatible.
Every per-exchange JSON file has exactly these top-level keys (all required, non-null where the schema marks them so):
| Key | Type | Description |
|---|---|---|
schema_version |
"4.x.y" |
This contract version |
extracted_at |
string (ISO 8601) | When extraction ran |
ccxt_version |
string | CCXT npm package version used |
exchange |
ExchangeMeta | Exchange identity and metadata |
endpoints |
object | Unified ↔ interface mapping, request shape, pagination, transaction classification, handler dispatch |
auth |
object | sign_method AST, sign_recipe, authenticated_sections, headers |
errors |
object | handle_errors, class_hierarchy, status_map, retry_classification, dispatch |
rate_limits |
object | buckets, per_endpoint_cost, endpoint_cost_binding |
normalization |
object | parse_methods_digest, field_maps, response_envelopes |
websocket |
object | heartbeat — WebSocket ping/pong keep-alive (Task 93); auth — WebSocket connection auth flow (Task 92); subscribe — subscribe/unsubscribe frame envelope + channel templates (Task 91); dispatch — channel → parse-handler routing table (Task 94); orderbook_semantics — orderbook snapshot/delta semantics (Task 95a); trades_semantics — trades-channel append/snapshot semantics (Task 95b); ohlcv_semantics — OHLCV (candle) channel replace-latest-then-append semantics (Task 95c) |
markets |
object | symbols_index, patterns, currencies (Task 97), precision_mode (Task 98) |
testnet |
object | Structured testnet / sandbox URL catalog |
raw |
object | Raw passthroughs — describe, url_templates, class_info, method_inventory, overrides_meta |
_provenance |
ProvenanceMap | Per-path source tags (raw / derived / override) |
markets.currencies (Task 97) is the compact runtime view: unified code → {precision, networks, ...} with info stripped. null when the exchange had no load_markets data or the discovery predates the capture. Networks unlock deposit/withdraw + tx modeling in consumers. QuickBEAM "__undefined" sentinels (JS undefined) are dropped recursively — a field the exchange does not surface is simply absent. precision is typed number | string | null: usually numeric, but a few exchanges (hyperliquid) emit it as a decimal string for exact tick sizes, extracted verbatim.
markets.precision_mode (Task 98) decodes the two integers CCXT exposes in describe() — precisionMode and paddingMode — into the record {mode, padding_mode}. mode ∈ tick_size | decimal_places | significant_digits; padding_mode ∈ no_padding | pad_with_zero. null only when no describe data was available. This is the interpretation key for every market's precision.{amount,price,cost} value — derive a tick (price increment) / step (amount increment) from a raw precision value p as:
mode |
a precision value p means |
tick / step |
|---|---|---|
tick_size |
p is the increment itself |
round to the nearest multiple of p |
decimal_places |
p is a decimal-place count |
increment = 10^(-p) |
significant_digits |
p is a significant-digit count |
no fixed increment — round the value to p significant digits |
CCXT rounding directions are constant across exchanges (base Exchange.ts): price → ROUND, amount → TRUNCATE, cost → TRUNCATE (cost falls back to price precision when a market carries no cost precision). Most exchanges use tick_size; the non-default modes are rare (e.g. bitfinex / bithumb use significant_digits, foxbit uses decimal_places).
endpoints.descriptors (Task 122, schema 4.1.0) is a map of unified method name → descriptor, projected from priv/discoveries/method_descriptors.json. null means the descriptor discovery was missing for that exchange; an empty map means discovery ran and found no public Promise-returning unified methods.
Each descriptor carries:
| Field | Type | Meaning |
|---|---|---|
name |
string | CCXT unified method name. Must match the enclosing map key. |
async |
boolean | Whether the TypeScript method is async. |
signature.params |
array | Ordered TS params. Each param is {name, type, optional, default}. type is the raw TS type name or null; default is the raw default expression slice ("undefined", "{}", "[]", etc.) or null. |
signature.return_type |
string | null | Raw TS return type, typically Promise<...>. |
description |
string | null | JSDoc @description text. |
params_doc |
object | null | JSDoc @param prose keyed by parameter name. Values are string or null when the tag was malformed/empty. |
returns |
object | null | JSDoc @returns / @return as {type, description}. |
errors |
array | null | Method-local JSDoc @throws entries, each {class, description}. This is scoped to the method's doc block, not errors.handle_errors runtime routing. [] means JSDoc was present and no throws tags were found. |
source |
string | Byte-for-byte TypeScript method definition slice. |
unresolved_reason |
string | null | Currently null or "no_jsdoc". When "no_jsdoc", description, params_doc, returns, and errors are all null. |
Every data field uses exactly two states:
- Present — extraction succeeded; value is a map, list, or object
- null — layer is missing, empty, or does not apply to this exchange
All keys are always materialized (never absent). Consumers check for null, never for key existence.
The _provenance field is a flat map keyed by RFC 6901 JSON Pointer strings. Every value is one of three string tiers:
"raw"— direct passthrough from a discovery file. No transformation between the extractor and emission."derived"— computed at assembly time by a derivation module (e.g.SymbolPatterns.derive/2,AuthenticatedSections.derive/2,ErrorCodeFields.derive/1). Still AST-provable; nothing hand-curated reached it."override"— replaced by an entry inpriv/overrides/<id>.jsonat the tail ofPipeline.extract/1. The override file carries a requiredreasonexplaining the divergence — consumers who want that context should read the override file directly.
Source of truth for the pointer list: CcxtExtract.Provenance.raw_pointers/0 and derived_pointers/0 in lib/ccxt_extract/provenance.ex. The corpus-global lists are the canonical definition; this document does not duplicate them (drift hazard).
Granularity is section + direct children. The map does not recurse to every leaf; it describes which MODULE produced the field. The exception is /errors/handle_errors, whose sub-keys split between raw and derived and therefore carry per-subkey tags.
Override stamping. When priv/overrides/<id>.json contains {"path": "/auth/authenticated_sections", ...}, the provenance value at that pointer flips from "derived" to "override". Override paths deeper than default granularity (e.g. /auth/sign_method/params/timestamp) get added as new entries with value "override" — the ancestor entry (/auth/sign_method) keeps its "raw" tag because the rest of the sub-tree is still raw.
Null semantics. The tier describes where the field WOULD have come from, not whether it's currently populated. Many fields are null for many exchanges. Provenance still records the tier so consumers can distinguish "this is raw null, discovery had no data" from "this is override null, curator chose to erase upstream data."
Override-pointer translation. All committed override files in priv/overrides/<id>.json use legacy v3-shaped paths (/structure/..., /runtime/...). CcxtExtract.OverrideRegistry.translate_pointer/1 rewrites them to v4 locations at apply time. New override files may use v4 paths directly — paths without a known v3 prefix pass through unchanged.
JSON Schema: exchange_v4.json (included in every output directory).
Why a major bump (vs additive 3.x): the v4 cut reorganized top-level sections from producer-shaped (runtime / structure) to consumer-shaped (endpoints / auth / errors / rate_limits / normalization / websocket / markets / testnet / raw). Additive 3.x can grow new keys but cannot reorganize without breaking; one migration cost in exchange for a coherent stable contract.
structure.parse_methods was dropped at v3.0.0 (Task 117) precisely because the raw ESTree bodies blew the Hex 128 MB publish cap that ccxt_client downstream needed cleared. The v4 carrier (Task 129) re-introduces the surface as a compact digest — method name → {params, return_type, async, statement_count} — preserving the discoverability without re-blowing the cap. The full AST bodies remain in priv/discoveries/parse_methods.json for internal Phase 12 derivation; consumers that need them call the extractor's discovery file directly rather than reading them per-exchange.
Populated for exchanges whose parseOHLCV body is a single ReturnStatement with an ArrayExpression of safe-call elements. null for exchanges that inherit parseOHLCV from a base class (no override); a populated record with branches: [] and a non-nil _unresolved_reason for exchanges whose override exists but doesn't match a recognized shape (e.g. multiple distinct return arrays).
{
"branches": [
{
"guard": { "kind": "always" },
"shape": "array",
"field_map": {
"timestamp": { "index": 0, "key": null, "coercion": "safeInteger2", "format": "ms" },
"open": { "index": 1, "key": null, "coercion": "safeNumber2", "format": null },
"high": { "index": 2, "key": null, "coercion": "safeNumber2", "format": null },
"low": { "index": 3, "key": null, "coercion": "safeNumber2", "format": null },
"close": { "index": 4, "key": null, "coercion": "safeNumber2", "format": null },
"volume": {
"kind": "discriminated",
"discriminator": "market.inverse",
"true": { "index": 7, "coercion": "safeNumber2" },
"false": { "index": 5, "coercion": "safeNumber2" }
}
},
"_unresolved_reason": null
}
],
"extras": [],
"_unresolved_reason": null
}coercion and discriminator are closed-vocabulary exceptions to the file-wide open-enum rule (top of this doc). Unlike has capabilities or AST type values, these two enums are exhaustive within their scope: consumers MUST hard-error on unrecognized values rather than fall through to a permissive default. The producer never emits an out-of-vocab value; encountering one is a contract violation, not a forward-compat bump. Vocabulary expansion is signalled by a schema major-version bump (e.g. Task 78d/78e/78f), at which point consumers update their match exhaustiveness in lockstep.
Closed coercion vocabulary (initial Task 78 scope): ["safeInteger", "safeInteger2", "safeNumber", "safeNumber2"]. A safeXxx family member outside this set in any slot emits that slot as null with a per-branch _unresolved_reason: "<field>:non_safe_coercion:<method>". Task 78e (parse8601) extended this vocab — see the post-78b/78e vocabulary further below in "object-input shape (Tasks 78b + 78e)". Task 78d (safeTimestamp) is the next planned extension. The contract is that consumers should error on unrecognized coercion identifiers rather than silently coercing the wrong way.
Closed discriminator vocabulary (this scope): ["market.inverse", "market.spot"]. "market.inverse" matches market['inverse'] (and paren/safeBool/transitive ident aliases). "market.spot" matches (type === 'spot') / (type == 'spot') (operand order independent) or ident transitively bound to it (covers okx's const type = handleMarketTypeAndParams(...)[0]; const volumeIndex = (type === 'spot') ? 5 : 6). Unsupported tests emit volume=null + "volume:unsupported_discriminator". (Task 78f)
Slot vs discriminated_slot: pure slots (%{"index", "key", "coercion", "format"}) are scalar columns. Discriminated slots (%{"kind", "discriminator", "true", "false"}) emit when the column's index is not a literal — consumers branch on the discriminator (e.g. if market.inverse, read slot["true"], else slot["false"]). The extras list is populated by Task 78d for exchanges that emit non-OHLCV columns (e.g. kraken VWAP at index 5 alongside the standard six fields).
Honesty contract: every populated slot is provable from AST. Inheriting exchanges (no parseOHLCV override) emit field_maps["ohlcv"] = null. Override exists but body shape isn't recognized → populated record with non-nil _unresolved_reason. Slot-level unresolvability emits nil for that slot plus a per-branch reason; other slots in the same branch populate normally.
Exchanges whose parseOHLCV body accesses ohlcv by string key (not integer index) emit input_shape: "object" on the branch guard, and each slot has "index": null with a non-null "key". The branch's existing top-level "shape": "array" is unchanged — shape describes the parser's return value (always an array of [ts, o, h, l, c, v]), input_shape describes the parser's raw input shape; the two are independent. The coercion vocabulary gains "parse8601" (Task 78e) for exchanges that wrap the timestamp in parse8601(safeString(ohlcv, key)).
Object-input example (hyperliquid / lighter — fully resolved):
{
"branches": [
{
"guard": { "kind": "always", "input_shape": "object" },
"shape": "array",
"field_map": {
"timestamp": { "index": null, "key": "t", "coercion": "safeInteger", "format": "ms" },
"open": { "index": null, "key": "o", "coercion": "safeNumber", "format": null },
"high": { "index": null, "key": "h", "coercion": "safeNumber", "format": null },
"low": { "index": null, "key": "l", "coercion": "safeNumber", "format": null },
"close": { "index": null, "key": "c", "coercion": "safeNumber", "format": null },
"volume": { "index": null, "key": "v", "coercion": "safeNumber", "format": null }
},
"_unresolved_reason": null
}
],
"extras": [],
"_unresolved_reason": null
}parse8601 timestamp example (bitmex — timestamp slot only):
{
"timestamp": { "index": null, "key": "timestamp", "coercion": "parse8601", "format": "iso8601" }
}"coercion": "parse8601" means the raw value is an ISO-8601 string that must be parsed to a millisecond epoch integer. Consumers should apply their own parse8601 / DateTime.from_iso8601 equivalent. "format": "iso8601" is the companion annotation that communicates the wire format of the raw value before coercion, paralleling "format": "ms" on integer-millisecond columns.
Closed coercion vocabulary (extended by Tasks 78b + 78e): ["safeInteger", "safeInteger2", "safeNumber", "safeNumber2", "parse8601"]. The addition of "parse8601" is signalled by Task 78e; consumers must add an exhaustive match arm for it before consuming bitmex's timestamp slot.
Exchanges whose parseOHLCV body branches on Array.isArray(ohlcv) at the top-level IfStatement emit two branches instead of a single always branch. Both arms must return an ArrayExpression of safe-call elements — either as an if/else pair (gate, bitmart) or as an if-then return followed by a fallthrough return (bingx). The record gains a top-level discriminator alongside branches:
{
"discriminator": { "call": "Array.isArray", "variable": "ohlcv" },
"branches": [
{
"guard": { "kind": "array_input" },
"shape": "array",
"field_map": {
"timestamp": { "index": 0, "key": null, "coercion": "safeInteger", "format": "ms" },
"open": { "index": 1, "key": null, "coercion": "safeNumber", "format": null },
"...": "..."
},
"_unresolved_reason": null
},
{
"guard": { "kind": "object_input" },
"shape": "array",
"field_map": {
"timestamp": { "index": null, "key": "t", "coercion": "safeInteger", "format": "ms" },
"open": { "index": null, "key": "o", "coercion": "safeNumber", "format": null },
"...": "..."
},
"_unresolved_reason": null
}
],
"extras": [],
"_unresolved_reason": null
}Closed guard.kind vocabulary (this scope): ["always", "array_input", "object_input"]. Single-shape bodies (the 110-exchange majority, including binance) continue to emit "kind": "always" with optional "input_shape": "array" | "object". Hybrid bodies emit "array_input" on the consequent / if-true arm and "object_input" on the alternate / fallthrough arm — consumers branch on discriminator.variable with Array.isArray/1 (or equivalent) then select the matching branch by guard.kind. Non-Array.isArray shape discriminators (e.g. safeBool) are not handled here and still emit ambiguous_return_shape.
Verified no-op (binance). As of the linked CCXT corpus, binance's parseOHLCV is a single always-array body with no top-level Array.isArray branch — Task 78c adds the schema surface and extractor path without changing binance's emitted record.
field_maps["ticker"] carries the per-exchange parseTicker field map. Unlike OHLCV, parseTicker always reads its input by string key — no integer-index variant exists in the 110-exchange corpus — so the shape is flat (no branches wrapper).
Output shape:
{
"field_map": {
"timestamp": { "key": "closeTime", "coercion": "safeInteger2", "format": "ms" },
"high": { "key": "highPrice", "coercion": "safeString2", "format": null },
"symbol": null,
"datetime": null,
"info": null,
"bid": { "key": "bidPrice", "coercion": "safeNumber", "format": null },
"..."
},
"extras": [
{ "unified_key": "openInterest", "key": "oi", "coercion": "safeString" }
],
"_unresolved_reason": null
}Slot shape: %{"key" => string, "coercion" => method, "format" => "ms" | "s" | null}. No "index" field — tickers are always key-based.
22 unified ticker fields in field_map (always present as keys, value null when absent or outside closed vocab):
symbol, timestamp, datetime, high, low, bid, bidVolume, ask, askVolume, vwap, open, close, last, previousClose, change, percentage, average, baseVolume, quoteVolume, markPrice, indexPrice, info
Three structurally-null fields by design (always null):
symbol— usessafeSymbol(ticker, key, market), a three-arg call where the second arg is a fallback, not a raw wire key; outside the closed coercion vocabularydatetime— derived fromtimestampviathis.iso8601(timestamp), not from raw; theiso8601call doesn't match thethis.method(obj, key)pattern and emitsnullinfo— the raw ticker object pass-through (baretickeridentifier, no safe-call wrapping); emitsnull
Closed coercion vocabulary: ["safeString", "safeString2", "safeStringN", "safeNumber", "safeNumber2", "safeInteger", "safeInteger2", "safeTimestamp"]. Any coercion outside this set emits null for that slot (honest null).
Closed format vocabulary: ["ms", "s", null]. Only meaningful for the timestamp field:
"ms"— raw value is a millisecond epoch integer (safeInteger/safeInteger2)"s"— raw value is a second epoch integer; multiply by 1000 before storing (safeTimestamp)null— no time-unit annotation (all non-timestamp fields always emitnull)
extras list: properties in the safeTicker(objectExpr, market) call that are not among the 22 unified fields. Each entry is %{"unified_key" => key, "key" => wire_key, "coercion" => method}. An extras slot only appears when its coercion is in the closed vocabulary; non-vocab coercions are silently excluded (same honesty rule as unified slots).
_unresolved_reason: null when the safeTicker return pattern was found (even if many individual slots are null); a non-null string when the return structure isn't the slottable pattern — e.g. kucoin: "non_safe_ticker_return:parseContractTicker". Full vocabulary:
"no_return_statement"— noReturnStatementpresent in the body"non_safe_ticker_return:<callee>"— a differentthis.<callee>(...)call"identifier_return"— bare Identifier (pre-built variable returned directly)"unrecognized_return_shape"— any other non-slottable return argument
TSAsExpression wrappers are unwrapped before classification. Inheriting exchanges (no parseTicker override) emit field_maps["ticker"] = null.
Honesty contract: every populated slot is provable from AST. No field is synthesized or inferred from exchange documentation. _unresolved_reason is either null or one of the four strings above (one is prefix-bearing with an open suffix — non_safe_ticker_return:*; the other three are exact). coercion and format are closed (hard-error on unrecognized); key is open (any wire-format string from the exchange).
field_maps["trade"] carries the per-exchange parseTrade field map. Same flat shape as ticker (no branches wrapper), with three Trade-specific extensions: enum_map slot for enum fields, sub_field_map slot for nested fee, and shape-discriminator detection for multi-payload bodies.
Output shape:
{
"field_map": {
"id": { "key": "tradeId", "coercion": "safeString", "format": null },
"timestamp": { "key": "ts", "coercion": "safeInteger", "format": "ms" },
"datetime": null,
"symbol": null,
"order": { "key": "ordId", "coercion": "safeString", "format": null },
"type": null,
"side": { "key": "side", "coercion": "safeStringLower", "format": null, "enum_map": null },
"takerOrMaker": { "key": "execType","coercion": "safeString", "format": null, "enum_map": {"T": "taker", "M": "maker"} },
"price": { "key": "fillPx", "coercion": "safeString2", "format": null },
"amount": { "key": "fillSz", "coercion": "safeString2", "format": null },
"cost": null,
"fee": { "sub_field_map": { "cost": {...}, "currency": {...} } },
"info": null
},
"extras": [],
"_unresolved_reason": null
}Slot shape: scalar fields use %{"key", "coercion", "format"} (same as ticker). Enum fields (type, side, takerOrMaker) extend with "enum_map". The nested fee field uses %{"sub_field_map" => %{"cost" => slot, "currency" => slot, "rate" => slot}} instead of scalar key/coercion (rate may be nil when the fee object literal omits it). Per-slot unresolved cases (boolean ternary on side, non-ObjectExpression fee, etc.) add "unresolved_reason".
13 unified Trade fields in field_map (always present as keys, value null when absent or outside closed vocab):
id, timestamp, datetime, symbol, order, type, side, takerOrMaker, price, amount, cost, fee, info
Three structurally-null fields by design (always null):
symbol— derived from themarketargument, not the raw trade objectdatetime— derived fromtimestampviathis.iso8601(timestamp), not rawinfo— raw trade object pass-through
cost is a scalar field but typically null in the current corpus because CCXT computes price × amount downstream when the parseTrade body omits it. When an exchange supplies an explicit cost: this.safeNumber(...) property, the slot populates.
Enum fields (type, side, takerOrMaker) — enum_map slot:
safeStringLowerextraction →enum_map: null(passthrough — CCXT canonicalizes inside the safe call)- Explicit
safeString().toLowerCase()chain →enum_map: null(canonicalized via chain) - ConditionalExpression chain over a shared safe-call (
safeString === 'T' ? 'taker' : (safeString === 'M' ? 'maker' : undefined)) →enum_map: %{"T" => "taker", "M" => "maker"}mapping literal wire values to canonical-form arms - Boolean/numeric/char-code ternary (
x > 0 ? 'buy' : 'sell') → slot populated withenum_map: null+ per-slotunresolved_reasondescribing the test shape
Nested fee field — sub_field_map slot: resolves ObjectExpression literals returned at the fee property:
- Inline
{ cost: this.safeNumber(t, 'feeAmt'), currency: this.safeCurrencyCode(this.safeString(t, 'feeCcy')), rate: this.safeNumber(t, 'feeRate') }→sub_field_map: %{"cost" => scalar_slot, "currency" => currency_slot, "rate" => scalar_slot}.rateis optional and may be nil when the fee literal omits it. safeCurrencyCodeis a 1-arg vocab member; when bound to an Identifier (const feeCurrencyId = this.safeString(trade, 'feeCcy')),currencyslot traces the binding chain to the underlying safe call's wire key- Non-ObjectExpression fees (variable reference, externally-built object) →
%{"sub_field_map" => null, "unresolved_reason" => "fee_not_object_literal"}
Closed coercion vocabulary: ["safeString", "safeString2", "safeStringN", "safeStringLower", "safeNumber", "safeNumber2", "safeInteger", "safeInteger2", "safeTimestamp", "safeCurrencyCode"]. Extends the ticker vocab with safeStringLower (enum canonicalizer) and safeCurrencyCode (1-arg currency resolver, fee-only).
Closed format vocabulary: ["ms", "s", null] (same as ticker — only timestamp uses it).
_unresolved_reason — multi-payload detection: parseTrade bodies that dispatch on shape at the top-level IfStatement emit a single honest unresolved tag instead of committing to one shape. Discriminator tests detected:
Array.isArray(trade)— array-vs-object payload split'<lit>' in trade— presence-of-key dispatch (binance'sparseDustTradedelegation)typeof trade === 'string'— string-vs-object payload split
_unresolved_reason: "multi_payload_branching:<N>" where N is the count of distinct shape-discriminator IfStatements. Open suffix — consumers match on the multi_payload_branching: prefix, not the full string. null when a single canonical safeTrade return is found. Inheriting exchanges (no parseTrade override) emit field_maps["trade"] = null.
Honesty contract: every populated slot is provable from AST. No field is synthesized. Same open-closed distinction as ticker: _unresolved_reason is open-suffix (multi_payload_branching:<N>, non_safe_trade_return:<callee>) or one of the closed-set strings (no_return_statement — body has no ReturnStatement at all; unrecognized_return_shape — body has a return whose argument is neither a this.safeTrade* call nor a this.<other> call); coercion is closed; format is closed; key and enum_map arm values are open (from source).
field_maps["transaction"] carries the per-exchange parseTransaction field map. Same flat shape as ticker (no branches wrapper). TSAsExpression-wrapped returns are unwrapped before classification.
18 unified Transaction fields in field_map (always present as keys, value null when absent or outside closed vocab):
id, timestamp, datetime, txid, type, status, amount, currency, address, addressFrom, addressTo, tag, tagFrom, tagTo, network, updated, fee, info
Five structurally-null fields by design (always null):
info— raw pass-through identifier, not a safe-calldatetime— derived fromtimestampviaiso8601, not rawcurrency— resolved viasafeCurrencyCode1-arg form, not the 2-arg dict-lookup the classifier recognizesnetwork— typically resolved via resolver calls (networkIdToCode,getNetworkCodeByNetworkUrl) outside the closed vocabfee— built as an inline sub-object{cost, currency, rate}, not a flat safe-call on the top-level transaction dict
Enum fields (type, status) — enum_values slot:
type→["deposit", "withdrawal"]status→["ok", "pending", "canceled", "failed"]
enum_values is added to the slot map when the wire key resolves to a safe-call in the closed vocab. Missing or unresolvable wire keys emit null for the entire slot.
Slot shape: %{"key" => string, "coercion" => method, "format" => "ms" | "s" | null}. Timestamp-family fields (timestamp, updated) carry format: safeInteger/safeInteger2 → "ms", safeTimestamp → "s". Enum-family fields extend with "enum_values" => [string].
_unresolved_reason: null when an ObjectExpression return was found (per-field slots may still be null). "no_return_statement" when no ReturnStatement is present. "non_object_return:<type>" when the last return yields something other than an ObjectExpression.
Honesty contract: same as ticker/trade. Inheriting exchanges (no parseTransaction override) emit field_maps["transaction"] = null.
field_maps["deposit_address"] carries the per-exchange parseDepositAddress field map. Flat shape, same classification pipeline as transaction.
5 unified DepositAddress fields in field_map (always present as keys, value null when absent or outside closed vocab):
currency, address, tag, network, info
One structurally-null field by design:
info— raw pass-through, not a safe-call
network is nil when the exchange uses a resolver call outside the closed safe* vocab (e.g. getNetworkCodeByNetworkUrl); populates when safeString is used directly.
Slot shape: %{"key" => string, "coercion" => method, "format" => null} — no timestamp fields, so format is always null.
_unresolved_reason: same vocabulary as transaction.
Honesty contract: same as ticker/trade/transaction. Inheriting exchanges (no parseDepositAddress override) emit field_maps["deposit_address"] = null.
field_maps["balance"] carries the per-exchange parseBalance field map. Imperative pattern: balance bodies assign to account['free'|'used'|'total'|'debt'] inside loop bodies rather than returning an ObjectExpression, so derivation scans statement-level assignments instead of ObjectExpression properties.
7 unified Balance fields in field_map (always present as keys, value null when absent or outside closed vocab):
info, timestamp, datetime, free, used, total, debt
Structurally-null fields by design (always null):
info— raw balance object pass-throughdatetime— derived fromtimestampviaiso8601, not raw
Slot shape: same as ticker — %{"key", "coercion", "format"}. debt is rarely populated. extras is always [] (imperative assignment bodies have no ObjectExpression to scan for extras).
Closed coercion vocabulary: ["safeString", "safeString2", "safeStringN", "safeNumber", "safeNumber2", "safeInteger", "safeInteger2", "safeTimestamp"].
_unresolved_reason: null when safeBalance(Identifier) pattern found; "non_safe_balance_return:<callee>" when the return is a different this.<callee>(...) call; "no_return_statement" when no ReturnStatement is found; "identifier_return" when the return is a bare Identifier (pre-built balance map, e.g. lbank's return result); "unrecognized_return_shape" when the return argument matches none of the above (e.g. return foo() + bar()). Inheriting exchanges (no parseBalance override) emit field_maps["balance"] = null.
field_maps["market"] carries the per-exchange parseMarket field map. Two slottable return forms: this.safeMarketStructure({...}) (22 corpus exchanges) and direct {...} ObjectExpression (21 corpus exchanges). Non-ObjectExpression returns (e.g. extend(...), bare Identifier) are marked unresolved.
32 unified Market fields in field_map:
id, symbol, base, quote, settle, baseId, quoteId, settleId, type, subType, spot, margin, swap, future, option, active, contract, linear, inverse, tierBased, percentage, contractSize, expiry, expiryDatetime, strike, optionType, taker, maker, precision, limits, info, created
Structurally-null fields by design (always null):
symbol— computed from base/quote/settle, not a direct safe-callinfo— raw market object pass-throughprecision— nested ObjectExpression (deferred)limits— deeply-nested ObjectExpression (deferred)expiryDatetime— derived fromexpiryviaiso8601
Closed coercion vocabulary: adds safeBool to the standard set for boolean market-type flags (spot, swap, future, linear, inverse, option, contract, active, margin, tierBased, percentage).
extras list: ObjectExpression properties beyond the 32 unified fields that resolve to a literal wire key in the closed vocab.
_unresolved_reason: null when ObjectExpression pattern found; "non_safe_market_return:<callee>" for non-ObjectExpression this.<callee>(...) returns; "no_return_statement" when none found; "identifier_return" when the return is a bare Identifier (pre-built variable); "unrecognized_return_shape" when the return argument matches none of the above (e.g. return foo() + bar()). TSAsExpression wrappers (return {...} as Market) are unwrapped before classification, so a TS-cast around an otherwise-slottable ObjectExpression resolves cleanly (e.g. grvt). Inheriting exchanges emit field_maps["market"] = null.
response_envelopes carries per-parser-type, per-fetcher maps describing the outer wrapping a vendor REST endpoint returns before the matching parse* is dispatched. Recognizes the load-bearing N:1 fetcher→parser pattern: the same parser is reached from multiple fetchers, each with a different envelope (e.g. binance's parseTrades is reached from fetchTrades, fetchMyTrades, fetchMyDustTrades).
Top-level shape (one key per parser type, plus the closed-vocab _unresolved_reason slot):
"response_envelopes": {
"_unresolved_reason": null,
"trade": {
"fetchTrades": { "key": null, "fallback_keys": [], "default": null },
"fetchMyTrades": { "key": null, "fallback_keys": [], "default": null },
"fetchMyDustTrades": { "key": "userAssetDribblets", "fallback_keys": [], "default": [] }
},
"ticker": { "fetchTicker": { "key": null, "fallback_keys": [], "default": null }, ... },
"ohlcv": { ... },
"order": { ... },
"position": { ... },
"balance": { ... },
"market": { ... },
"transaction": { ... },
"deposit_address": { ... }
}Per-fetcher entry shape (populated):
| Key | Type | Meaning |
|---|---|---|
key |
string | null |
First arg literal to this.safeValue(response, "<KEY>", default) / this.safeList(response, ...). null when the response IS the list/object (no unwrap). |
fallback_keys |
string[] |
Additional literal keys from safeValue2(response, "K1", "K2", default) / safeValueN(response, ["K1", "K2"], default). Always present as a list (may be empty). |
default |
term | null |
Literal third arg (number, string, list literal, object literal, or null when omitted). |
Per-fetcher entry shape (unresolved):
{ "_unresolved_reason": "<closed-vocab-string>" }Carries the _unresolved_reason key INSTEAD of the {key, fallback_keys, default} triple. Consumers should branch on Map.has_key?(entry, "_unresolved_reason").
Per-fetcher _unresolved_reason vocabulary (closed):
"no_fetcher_method_body"—fetch_methods.jsonhas no body entry for this fetcher"no_safe_value_call"— fetcher body has nosafeValue/safeList/safeValueN/safeListNcall againstresponse"non_literal_key"— first key arg is a variable rather than a string literal (cannot statically derive)"nested_response_unwrap"— first arg is a sub-property access (e.g.response["foo"]["bar"]); recorded at fetcher level for now
Top-level _unresolved_reason: null when at least one parser-type slot resolved to a populated per-fetcher map; carries "not_yet_derived" when the carrier returned the stub (no parse_dispatch data, no fetch_methods entry, or the exchange has no override); carries "no_fetcher_dispatch" when parse_dispatch has entries but none are fetcher names (only mutators like createOrder / transfer / describe), so every parser-type slot is null.
Fetcher scope filter. Only names beginning with fetch are considered — mutators like createOrder / cancelOrder / editSpotOrder are excluded even when they share a parser callee. The per-parser-type fetcher list is the intersection of parse_dispatch callers and the parser-type's parse_fn set, restricted to fetch* names.
Inheriting exchanges (no parse_dispatch entry, no fetcher bodies) emit response_envelopes with every parser-type slot null plus _unresolved_reason: "not_yet_derived".
websocket is the WebSocket surface; Task 93 ships its first sub-section, heartbeat. The record describes how an exchange keeps a WS connection alive, derived from the Pro class's describe().streaming block and its ping / pong method definitions. It is always emitted — REST-only exchanges (no Pro class) carry the honest-empty record.
"websocket": {
"heartbeat": {
"ping_kind": "json_message",
"ping_payload": { "op": "ping" },
"ping_payload_kind": "partial",
"keep_alive_ms": 18000,
"max_ping_pong_misses": 2.0,
"has_pong_handler": true,
"keep_alive_resolved_from": "self",
"source": "pro_describe",
"unresolved_reason": null
}
}| Key | Type | Meaning |
|---|---|---|
ping_kind |
enum | Heartbeat strategy, classified structurally from the ping() return-node AST type: native_frame (no ping() override — base client uses protocol-level WS frames), string_message (ping() returns a string literal), json_message (returns an object literal), unknown (ping() defined but its return is not a statically resolvable literal), none (no Pro class). |
ping_payload |
string | object | null |
Decoded ping() return — a string, or an object carrying its statically-literal keys. null for native_frame / none / unknown. |
ping_payload_kind |
enum | null | literal (fully static), partial (some object keys runtime-computed, omitted from ping_payload), unresolved. null when there is no ping(). |
keep_alive_ms |
number | null |
Ping interval (ms), resolved after WS-class inheritance (binanceusdm inherits binance's 180000). Base CCXT default 30000 when a Pro class sets none. null for REST-only exchanges. |
max_ping_pong_misses |
number | null |
Missed-pong tolerance before the connection is treated as dead. Base CCXT default 2.0. |
has_pong_handler |
boolean | True when the Pro class (or an ancestor) defines pong / handlePong / handlePing. |
keep_alive_resolved_from |
string | null |
"self", an ancestor exchange id (inherited via the extends chain), or "base_default". |
source |
enum | pro_describe (own/inherited streaming block), base_default (Pro class with no keepAlive), none (no Pro class). |
unresolved_reason |
enum | null | no_ws_support (no Pro class — every field null/false/none), ping_return_not_literal (ping() defined but ping_kind is unknown), null when fully resolved. |
Honest-empty record — a REST-only exchange emits ping_kind: "none", source: "none", unresolved_reason: "no_ws_support", with null / false for the remaining fields.
auth describes how an exchange authenticates a private WebSocket connection, derived structurally from the Pro class's authenticate() method. Like heartbeat it is always emitted — REST-only and public-only-WS exchanges carry the honest-empty record.
"websocket": {
"auth": {
"mechanism": "sign_in_message",
"authenticate_defined": true,
"message": { "op": "login", "method": null, "keys": ["op", "args"] },
"credentials": ["apiKey", "password", "secret"],
"resolved_from": "self",
"source": "pro_authenticate",
"unresolved_reason": null
}
}| Key | Type | Meaning |
|---|---|---|
mechanism |
enum | How the connection authenticates, classified structurally from the authenticate() AST: sign_in_message (builds a request object and sends it over the socket — bybit op:'auth', okx op:'login', deribit method:'public/auth'), url_param (credentials / listenKey appended to the WS URL), header (HTTP upgrade header — named for completeness, override-only), unknown (authenticate() defined but matches no known shape), none (no authenticate()). |
authenticate_defined |
boolean | True when the Pro class (or an ancestor) defines authenticate(). |
message |
object | null |
For sign_in_message: { op, method, keys } — the op / method discriminant (string literal, or resolved through a local const binding) and the request object's top-level keys in source order. null for every other mechanism. |
credentials |
string[] |
The this.<credential> fields authenticate() reads (apiKey / secret / password / uid / privateKey / walletAddress), sorted. |
resolved_from |
string | null |
"self", or an ancestor exchange id when authenticate() is inherited via the extends chain (binanceusdm resolves from binance). null when there is no authenticate(). |
source |
enum | pro_authenticate (own / inherited authenticate()), none (no authenticate()). |
unresolved_reason |
enum | null | no_ws_support (no Pro class), no_ws_auth (Pro class but no authenticate() — public-only WS), auth_not_classifiable (mechanism is unknown), null when fully resolved. |
Honest-empty record — a REST-only exchange emits mechanism: "none", unresolved_reason: "no_ws_support"; a public-only-WS exchange (Pro class, no authenticate()) emits mechanism: "none", unresolved_reason: "no_ws_auth".
subscribe describes the WebSocket subscribe/unsubscribe frame envelope and the per-watch* channel-name templates a consumer needs to open and close streams. It is derived structurally from the Pro class watch* methods and is always emitted — REST-only exchanges carry the honest-empty record.
"websocket": {
"subscribe": {
"mechanism": "json_message",
"discriminant": "op",
"subscribe_op": "subscribe",
"unsubscribe_op": "unsubscribe",
"args_key": "args",
"envelope_keys": ["op", "req_id", "args"],
"channels": {
"watchOrderBook": ["book.{symbol}.raw"],
"watchTicker": ["tickers"]
},
"resolved_from": "self",
"source": "pro_watch",
"unresolved_reason": null
}
}| Key | Type | Meaning |
|---|---|---|
mechanism |
enum | Subscribe mechanism: json_message (a request object literal carries a discriminant such as op / method with a subscribe verb), unknown (Pro class exists but the envelope is not classifiable), or none (no Pro class). |
discriminant |
string | null |
Object key carrying the subscribe/unsubscribe verb, such as op or method. |
subscribe_op |
string | null |
Verb value that opens a subscription, resolved from a string literal or local const binding. |
unsubscribe_op |
string | null |
Verb value that closes a subscription. null when the class defines no classifiable unsubscribe frame. |
args_key |
string | null |
Top-level request key carrying the channel list, such as args or params. |
envelope_keys |
string[] |
Top-level keys of the subscribe request object literal, in source order. |
channels |
object | Method name → sorted channel templates statically resolved from watch* methods. Placeholders are {symbol} and {timeframe}. |
resolved_from |
string | null |
"self", or an ancestor exchange id when the envelope is inherited through the extends chain. |
source |
enum | pro_watch for Pro-class watch* facts, none for REST-only exchanges. |
unresolved_reason |
enum | null | no_ws_support (no Pro class), subscribe_not_classifiable (mechanism is unknown), or null when fully resolved. |
Honest-empty record — a REST-only exchange emits mechanism: "none", source: "none", unresolved_reason: "no_ws_support", with null / empty values for the remaining fields. A Pro class with unclassified subscribe frames emits mechanism: "unknown", source: "pro_watch", and still carries any channel templates that were statically resolvable.
dispatch describes how the Pro class routes an inbound WebSocket frame to a parse handler. It is derived structurally from handleMessage() handler maps, if-chains, and switch cases, and is always emitted — REST-only exchanges carry the honest-empty record.
"websocket": {
"dispatch": {
"kind": "routed",
"handle_message_defined": true,
"discriminators": ["topic"],
"entries": [
{"channel": "kline", "handler": "handleOHLCV"}
],
"unresolved": [],
"resolved_from": "self",
"source": "pro_handle_message",
"unresolved_reason": null
}
}| Key | Type | Meaning |
|---|---|---|
kind |
enum | routed when at least one channel → handler entry was resolved, opaque when handleMessage() exists but no entry was statically classifiable, none when no WebSocket class or no handleMessage() exists in the inheritance chain. |
handle_message_defined |
boolean | True when the Pro class or an ancestor defines handleMessage(). |
discriminators |
string[] |
Literal frame fields read to route messages, such as channel, topic, e, type, or op. |
entries |
array | {channel, handler} pairs where both values are literals; handler names are handle* methods. |
unresolved |
array | Per-shape unresolved reasons for handler-map properties: computed_channel_key, spread_property, or non_handler_value. |
resolved_from |
string | null |
"self", or an ancestor exchange id when handleMessage() is inherited through the extends chain. |
source |
enum | pro_handle_message for own / inherited handleMessage() facts, none for no dispatch source. |
unresolved_reason |
enum | null | no_ws_support, no_ws_dispatch, dispatch_not_classifiable, or null when fully routed. |
Honest-empty record — a REST-only exchange emits kind: "none", source: "none", unresolved_reason: "no_ws_support". A Pro class with no handleMessage() emits kind: "none", unresolved_reason: "no_ws_dispatch".
trades_semantics describes how a Pro class updates its trades cache from handleTrade(s) frames. It is derived structurally from cache constructors, safeString trade id reads, cache-limit option reads, and append/replace/snapshot calls. It is always emitted — REST-only exchanges carry the honest-empty record.
"websocket": {
"trades_semantics": {
"update_model": "append",
"trades_defined": true,
"cache_type": "ArrayCacheBySymbolById",
"dedup_key": "id",
"cache_limit_field": "tradesLimit",
"cache_limit_default": 1000,
"my_trades": {
"defined": true,
"cache_type": "ArrayCacheBySymbolById",
"dedup_key": "id",
"cache_limit_field": "myTradesLimit",
"cache_limit_default": 1000
},
"unresolved": [],
"resolved_from": "self",
"source": "pro_handle_trades",
"unresolved_reason": null
}
}| Field | Type | Notes |
|---|---|---|
update_model |
enum | append, replace, snapshot, unknown, or none. |
trades_defined |
boolean | Whether handleTrade() / handleTrades() resolved in the Pro extends chain. |
cache_type |
string | null |
Literal cache constructor such as ArrayCacheBySymbolById. |
dedup_key |
string | null |
Literal trade id field read by safeString. |
cache_limit_field |
enum | null | tradesLimit, myTradesLimit, or null; public trades normally use tradesLimit. |
cache_limit_default |
number | null | Literal numeric default from the cache-limit safe accessor. |
my_trades |
object | Private myTrades channel presence plus its own cache type, id key, and limit. |
unresolved |
object[] | Closed-vocabulary per-shape findings: cache_not_classifiable, dedup_key_not_classifiable, update_model_not_classifiable. |
resolved_from |
string | null |
self, an ancestor exchange id, or null when no public trades handler resolved. |
source |
enum | pro_handle_trades or none. |
unresolved_reason |
enum | null | no_ws_support, no_ws_trades, trades_not_classifiable, or null when resolved. |
Honest-empty record — a REST-only exchange emits update_model: "none", source: "none", unresolved_reason: "no_ws_support", with null/false/empty values for the remaining fields. A Pro class with no public trades handler emits unresolved_reason: "no_ws_trades". A handler whose update model is not statically classifiable emits update_model: "unknown" and unresolved_reason: "trades_not_classifiable".
orderbook_semantics describes how the Pro class's orderbook channel signals a full snapshot vs an incremental delta, and the fields a consumer needs to apply deltas correctly. It is derived structurally from the handleOrderBook() / handleOrderBookMessage() (and handleSnapshot / handleChecksum / checkOrderBookChecksum) handlers — safeString / safeInteger keys and === comparison literals — never inferred from server behavior. It is always emitted; REST-only exchanges carry the honest-empty record.
"websocket": {
"orderbook_semantics": {
"apply_mode": "both",
"handle_orderbook_defined": true,
"discriminator": {
"field": "action",
"snapshot_values": ["snapshot"],
"delta_values": ["update"]
},
"sequence_fields": ["prevSeqId", "seqId"],
"checksum": {"present": false, "field": null, "algorithm": null},
"resolved_from": "self",
"source": "pro_handle_orderbook",
"unresolved_reason": null
}
}| Key | Type | Meaning |
|---|---|---|
apply_mode |
enum | incremental (applies handleDeltas), replace (resets the local book via .reset() / this.orderBook()), both (does both), unknown (handler defined but apply strategy not structurally classifiable), none (no orderbook handler in the chain). |
handle_orderbook_defined |
boolean | True when the Pro class or an ancestor defines handleOrderBook() / handleOrderBookMessage(). |
discriminator |
object | {field, snapshot_values, delta_values} — the frame key compared against snapshot/delta literals, with the literals split by closed vocabulary. field is null when no in-vocabulary comparison exists (the delta is an else-branch). |
sequence_fields |
string[] |
Integer-accessor keys in the closed sequence vocabulary (first/final/previous update id, seqId, nonce, etc.) — U/u/pu on binance, seqId/prevSeqId on okx. |
checksum |
object | {present, field, algorithm} — present only when a checksum key is read via a safe-accessor or crc32 is computed in the handler. algorithm is "crc32" or null. |
resolved_from |
string | null |
"self", or an ancestor exchange id when the handler is inherited through the extends chain. |
source |
enum | pro_handle_orderbook for own / inherited orderbook-handler facts, none for no orderbook source. |
unresolved_reason |
enum | null | no_ws_support (no Pro class), no_ws_orderbook (Pro class with no orderbook handler), orderbook_not_classifiable (handler defined but apply_mode is unknown), or null when classified. |
Honest-empty record — a REST-only exchange emits apply_mode: "none", source: "none", unresolved_reason: "no_ws_support". A Pro class with no orderbook handler emits apply_mode: "none", unresolved_reason: "no_ws_orderbook".
websocket is the designated growth point for Phase 15 WS-derived sub-sections; sibling tasks add keys to it additively.
ohlcv_semantics describes how a Pro class updates its candle cache from handleOHLCV frames (replace-latest on same-timestamp ticks, append on bucket rollover via ArrayCacheByTimestamp). The timeframe dimension key (e.g. binance kline 'i') and closed/confirm signal (binance 'x', bybit 'confirm') are extracted from literal safe* arguments when present. It is always emitted — REST-only exchanges carry the honest-empty record.
"websocket": {
"ohlcv_semantics": {
"update_model": "replace_latest_then_append",
"ohlcv_defined": true,
"timeframe_key": "i",
"closed_signal": "x",
"cache_type": "ArrayCacheByTimestamp",
"cache_limit_field": "OHLCVLimit",
"cache_limit_default": 1000,
"unresolved": [],
"resolved_from": "self",
"source": "pro_handle_ohlcv",
"unresolved_reason": null
}
}| Key | Type | Meaning |
|---|---|---|
update_model |
enum | replace_latest_then_append (updates the latest candle in place and appends on bucket rollover), unknown, or none. |
ohlcv_defined |
boolean | True when the Pro class or an ancestor defines handleOHLCV(). |
timeframe_key |
string | null |
Literal timeframe/interval key read from the frame, such as binance kline 'i'. |
closed_signal |
string | null |
Literal candle-closed/confirm flag key, such as binance 'x' or bybit 'confirm'. |
cache_type |
string | null |
Literal cache constructor, currently ArrayCacheByTimestamp when classified. |
cache_limit_field |
enum | null | OHLCVLimit or null. |
cache_limit_default |
number | null | Literal numeric default for the cache-limit accessor. |
unresolved |
object[] | Closed-vocabulary per-shape findings: cache_not_classifiable, timeframe_key_not_classifiable, closed_signal_not_classifiable. |
resolved_from |
string | null |
self, an ancestor exchange id, or null when no OHLCV handler resolved. |
source |
enum | pro_handle_ohlcv or none. |
unresolved_reason |
enum | null | no_ws_support, no_ws_ohlcv, ohlcv_not_classifiable, or null when resolved. |
Honest-empty record — a REST-only exchange emits update_model: "none", source: "none", unresolved_reason: "no_ws_support". A Pro class with no handleOHLCV emits unresolved_reason: "no_ws_ohlcv". An unclassifiable handler shape emits update_model: "unknown" and unresolved_reason: "ohlcv_not_classifiable".
For complete type definitions (all fields, nesting, and constraints), see exchange_v4.json — the JSON Schema shipped in every output directory. The summary below covers the most-referenced types:
- MethodAST —
{ async, params, return_type, statements, body }wherebodyis a complete ESTree BlockStatement - InterfaceSignature —
{ name, params, return_type }(no body — these are type declarations, not implementations) - MethodParam —
{ name, type }wheretypeis the TypeScript type annotation or null - ASTNode — ESTree nodes with
type,start,end(byte offsets), plus node-specific fields - HandleErrorsData —
{ method, exceptions, http_exceptions, error_code_fields, throw_dispatches }wheremethodis a MethodAST,exceptionsmaps error strings to class names (keyed bybroad/exactplus market types),http_exceptionsmaps HTTP status codes to class names,error_code_fieldsis a list of ErrorCodeFieldEntry, andthrow_dispatchesis a list of ThrowDispatchEntry - ErrorCodeFieldEntry —
{ object, object_path, field, method, field2, roles, sentinel_values }— a singlethis.safe*()call from handleErrors() with role classification.objectis the first arg identifier (e.g.,"response","error"),object_pathis the derivation path tracing back toresponse(e.g.,["response", "data", "failure", "0"]) or null for trivial cases.fieldis the literal field name accessed,methodis"safeString"/"safeString2"/"safeValue",field2is the alternate field for safeString2.rolesis an array of"error_code"/"error_message"/"status_sentinel"classified by the CCXT helper:throwExactlyMatchedException→error_code(exact-map lookup key — not necessarily numeric, e.g., OKX'ssCodeis a string),throwBroadlyMatchedException→error_message(message text scanned for substrings ofexceptions.broadkeys),===/!==comparisons →status_sentinel. A field hit by both throw helpers in the same handleErrors() accumulates both roles.sentinel_valuesis an array of{ value, operator }objects whereoperatoris"==="or"!=="(for polarity detection), sorted by value, or null when no sentinel role. - ThrowDispatchEntry —
{ helper, exceptions_source, exceptions_source_raw, lookup, message_lookup }— a singlethis.throwExactlyMatchedException()/this.throwBroadlyMatchedException()call from handleErrors().exceptions_sourcenormalizes arg[0] to"exceptions","exceptions.exact","exceptions.broad","by_url.exact","by_url.broad", or"other";exceptions_source_rawpreserves the original expression as a compact string.lookupis the resolved safe* binding for arg[1].message_lookupis the unique resolved safe* binding referenced anywhere inside arg[2] (including aliases likeerrorInfo = messageand wrappers likethis.json(message)), or null when the message expression does not point at a single bound lookup value. - PaginationEntry —
{ strategy, containing_method, target_method, max_entries_per_request, ... }wherestrategyis one of"dynamic","deterministic","cursor","incremental".containing_methodis the method body where the call was found;target_methodis the method name passed tofetchPaginatedCall*(null for unresolved variable references). Strategy-specific fields: cursor hascursor_received,cursor_sent,cursor_increment; incremental haspage_key. Null values mean the parameter was not statically resolvable from source. - OverridesData —
{ extends, rest, ws }whereextendsis the parent exchange id, and each entry containsoverridden(methods redefined from parent, with AST),new_methods(methods not on parent), andinherited(method names only) - SignRecipeRecord —
{ crypto_op, canonical_string, signature_placement, auth_headers, nonce, pre_sign_transforms, unresolved_reason, patch_count }. All derivation fields are nullable — scaffold defaults every field tonull.crypto_op:{ algo ∈ "hmac_sha256" | "hmac_sha512" | "hmac_sha384" | "ed25519" | "rsa" | "custom", reason? }.canonical_string:{ family ∈ "hmac_simple" | "hmac_with_body" | "jwt" | "custom", components: [{ source, value? }], encoding ∈ "url_encoded" | "json" | "raw" }wheresource∈"timestamp" | "api_key" | "recv_window" | "method" | "path" | "query" | "body" | "literal".signature_placement:{ location ∈ "header" | "query" | "body", key }.auth_headers:[{ name, source ∈ "api_key" | "passphrase" | "timestamp" | "signature" | "recv_window" | "literal", value? }]— excludes the signature header itself (usesignature_placementfor that).nonce:{ source ∈ "timestamp_ms" | "timestamp_sec" | "timestamp_us" | "timestamp_ns" | "monotonic" | "exchange_supplied", format ∈ "integer" | "iso8601" | "hex" | "string" }.pre_sign_transforms:[{ op ∈ "hex_encode" | "base64_encode" | "lowercase" | "url_encode" | "json_encode", target ∈ "signature" | "body" | "canonical_string" }].unresolved_reason:null|"not_yet_derived"|"custom_signing_family"|"ambiguous_ast"|"no_sign_method"— must benullif and only if every derivation field above is non-null (enforced by thesign_recipe_honesty_validcontract invariant).patch_count: non-negative integer, Three-Strikes counter (see CLAUDE.md). Standalone JSON Schema atpriv/schema/sign_recipe_v1.json. - RequestDefaultsEntry —
{ value, kind, reason }wherekind∈"literal" | "unresolved". Forliteral:valueis the resolved primitive (string/number/boolean/null), a map of string keys → primitives (nested literal), or a list of primitives (array of literals);reasonis null. Forunresolved:valueis null;reason∈"conditional_value"(ternary/logical expression),"identifier_reference"(variable or member access),"dynamic_construction"(call, binary, template literal, partially-literal object/array),"computed_key"(key was a computed[expr]),"spread_elaboration"(reserved for future spread tracking). Keys preserve the exchange's own literal string keys; computed keys surface as the synthetic key"_computed". - ClassInfo —
{ rest, ws }where each is a ClassEntry withclass_name,extends_resolved,parent_key,file,method_count, and optionalmethod_details - MethodInventory —
{ rest, ws }where each is a list of MethodSignature (like MethodAST but withoutbody) - ExchangeMeta —
{ id, name, alias, certified, pro, version, country, referral, tier }— exchange identity, CCXT metadata, plustier∈"tier1" | "tier2" | "tier3" | "dex" | "unclassified"(hand-curated inpriv/priority_tiers.json, not extracted from CCXT;tier1/tier2/dexare the buckets this project commits to deriving recipes for)
These files are global (not per-exchange) and live alongside _manifest.json in the output directory:
Base class method signatures from Exchange.ts — shared by all exchanges.
| Key | Type | Description |
|---|---|---|
extracted_at |
string (ISO 8601) | Extraction timestamp |
source_file |
string | Always "base/Exchange.ts" |
method_count |
integer | Total methods extracted |
by_category |
object | Count per category (parse, safe) |
methods |
map(name -> BaseMethod) | Method signatures keyed by name |
BaseMethod — { name, category, params, return_type, async, source } where category is "parse" or "safe", params is a list of MethodParam, async is boolean, and source is "method_definition" (full method with signature) or "field_assignment" (class field alias to imported utility — no params/return type available).
| Key | Type | Description |
|---|---|---|
schema_version |
string | Same as per-exchange files |
ccxt_version |
string | CCXT version used |
extracted_at |
string (ISO 8601) | Extraction timestamp |
exchange_count |
integer | Number of exchange files |
exchanges |
string[] | Sorted list of exchange IDs |
tier_scope |
string | string[] | Active scope stamped on write by CcxtExtract.Scope.to_manifest_value/1. "all" for unscoped / --all runs; otherwise a canonicalized list of scope tokens — tier names in canonical order ("tier1", "tier2", "tier3", "dex") followed by alphabetized "exchange:<id>" entries. Lets consumers detect partial aggregates produced by scoped extraction runs. |
- CCXT version compatibility — a given schema version may be extracted from different CCXT releases. The
ccxt_versionfield tracks which release was used; the schema contract is independent. - Extraction timing —
extracted_atis informational. The contract makes no guarantees about freshness. - Market data accuracy —
loadMarkets()data reflects exchange state at extraction time. It may be stale. - AST node exhaustiveness — ESTree node types are permissive (
additionalProperties: true). The schema validates structure, not every possible AST node shape. - Field ordering — JSON key order is not guaranteed and must not be relied upon.
- Cross-field semantic invariants — the JSON Schema enforces shape, not coherence between fields.
mix ccxt_extract.contract_testowns that layer: it asserts, for example, that everyendpoints.unifiedkey is claimed inraw.describe.has, that everyauth.authenticated_sectionsentry is reachable inraw.describe.api, and (Task 73f) that noendpoints.transaction_classificationentry declareson_chain: truewithouttransactional: true(transaction_classification_promoted_flags_consistent) — the guarantee that lets a consumer truston_chain == falseas a negative safety gate. Schema-valid output can still fire contract-test findings; those are drift signals, not schema violations. endpoints.transaction_classificationkey set — base keys mirrorendpoints.unified(name-only convention, Task 73d), but Task 73f additionally promotes non-unified raw blockchain-broadcast endpoints (DEX signing-helper flows likesignL1Action/starknetSign/createSignedRequest, and implicit-APIsendTx/sendTxBatchpaths keyed by their CCXT implicit method name, e.g.publicPostSendTx). Promoted entries always carry bothon_chain: trueandtransactional: true. Absence from the map (within detection coverage) means off-chain.
Per-exchange curated overrides live at priv/overrides/<exchange_id>.json. They carry knowledge the AST walker and runtime probes cannot reach — imperative signing quirks, exchange-specific inversions, CCXT bugs — and form the third tier of the raw / derived / override model (see CLAUDE.md § "Raw vs Derived vs Override").
This contract versions independently of the exchange JSON schema_version. Override files carry their own "schema_version": "1". Bumping the exchange schema does not imply an override-contract bump, and vice versa.
JSON Schema: priv/schema/override_v1.json (validated by mix ccxt_extract.contract_test via the override_registry_valid invariant).
{
"schema_version": "1",
"overrides": [
{
"path": "/structure/authenticated_sections",
"value": ["private"],
"reason": "sign() has no checkRequiredCredentials() gate — credentials checked per-method",
"verified_against": "priv/ccxt/ts/src/hyperliquid.ts:4877"
}
]
}Note on legacy v3-shaped paths. Committed override files were authored against the v3 schema and still use /structure/... and /runtime/... pointers. OverrideRegistry.translate_pointer/1 rewrites them to v4 locations (/auth/..., /raw/..., /endpoints/..., etc.) at apply time. New override files may use v4 paths directly — unknown prefixes pass through unchanged.
| Key | Required | Notes |
|---|---|---|
schema_version |
yes | Must be "1". Any other value is a hard error. |
overrides |
yes | Non-empty array of entries. Paths within a file must be unique. |
overrides[].path |
yes | RFC 6901 JSON Pointer into the emitted exchange JSON. Must start with /. |
overrides[].value |
yes | Any JSON value. Replaces whatever derivation produced for that path. |
overrides[].reason |
yes | Non-empty string. Why this override exists. Overrides without a reason rot silently. |
overrides[].verified_against |
optional | Source citation (file:line) or runtime probe reference proving the override matches real behavior. Run mix ccxt_extract.validate_overrides to audit entries (authenticated_sections, url_templates probes today). |
overrides[].unverified |
optional (default false) |
Set true for best-effort overrides that have not been validated. Mutually exclusive with verified_against — the loader raises if both are present. |
Alias exchanges (e.g. gateio → gate, huobi → htx) inherit their parent's override when they don't have their own file. Inheritance walks the class_hierarchy.json parent chain; an alias can still override specific paths by shipping its own file that sets just those paths.
String-key and numeric array-index segments (e.g. /path/0/name via Access.at/1). Invalid override applications are rescued and logged at the callsite so one corrupt file cannot brick the full build; the override_paths_present_in_output contract-test invariant surfaces drift (override value absent at its pointer path) at build-check time. For strict pointer/apply failures before assembly, use mix ccxt_extract.validate_overrides --strict.
Provenance tagging. Override-applied paths get their _provenance entry flipped from "derived" (or "raw") to "override" at the tail of Pipeline.extract/1.
auth.sign_recipe is a declarative per-section signing recipe. A consumer reading the recipe for an authenticated section can construct an authenticated HTTP request without walking the raw sign() AST.
JSON Schema: priv/schema/sign_recipe_v1.json — standalone, reusable for external consumers. Kept in lockstep with exchange_v4.json#/$defs/SignRecipeRecord (parity checked by test/ccxt_extract/sign_recipe_test.exs).
See SignRecipeRecord under Key Type Definitions for the enum tables of every field.
Real exchanges vary signing per API section:
- binance emits 13 recipe entries (one per
private,sapi,sapiV2,fapiPrivate,fapiPrivateV3,eapiPrivate,papi, …). Most share HMAC-SHA256+query but some don't. - bybit splits GET (query canonical) vs POST (body canonical) inside
sign()— section-level granularity captures the divergence. - okx / coinbase / kucoin each carry multiple versioned private sections with different header conventions.
Keying the recipe on section name (mirroring raw.url_templates) is the only honest way to represent this. Identical-looking sections still get their own entry — a consumer reading sign_recipe[section] always finds a record without disambiguating upstream.
Map.keys(sign_recipe) must equal auth.authenticated_sections as sets. Enforced by the sign_recipe_keys_match_auth_sections contract-test invariant and maintained by Pipeline.sync_sign_recipe/1, which runs after override merge so override-driven changes to authenticated_sections propagate into recipe key coverage automatically. An override that wants to target specific recipe fields (e.g. /auth/sign_recipe/private/crypto_op) survives the sync — keys present in both the post-override authenticated_sections and the post-override recipe map keep their values.
Exchanges with no authenticated sections emit "sign_recipe": {}.
Every derivation field starts null with unresolved_reason: "not_yet_derived" and is flipped to a derived value as Phase 10 tasks 65–69 populate individual subsets of fields. When every derivation field is non-null, SignRecipe.Derive auto-flips unresolved_reason to null at emit time (biconditional enforced in both directions by the sign_recipe_honesty_valid contract invariant). Before the flip, the closed-vocabulary unresolved_reason enum (not_yet_derived / custom_signing_family / ambiguous_ast / no_sign_method) tells consumers why a field is still null.
Consumers that encounter a null derivation field must either read the raw auth.sign_method AST or fall back to an override — per the Honesty Rule, no silent guesses.
patch_count starts at 0 and bumps each time a Phase 10 derivation rule gets patched to handle a new edge case for a given recipe. At 3, the knowledge migrates to priv/overrides/<id>.json instead of accreting further special cases in derivation. See CLAUDE.md § "Three-Strikes Rule" for the full workflow.
sign_recipe_keys_match_auth_sections— per-exchange. Fails on any key inauthenticated_sectionswithout a recipe entry, or any recipe entry not inauthenticated_sections.sign_recipe_shape_valid— per-exchange. Belt-and-suspenders over each recipe record: required keys present,patch_countis a non-negative integer,unresolved_reasonis null or in the closed vocabulary. Deeper shape/enum validation lives inValidation.validate_schema/2againstexchange_v4.json#/$defs/SignRecipeRecord.sign_recipe_honesty_valid— per-exchange. Enforces the biconditional:unresolved_reason == nulliff every one of the six derivation fields is non-null. Fails loudly if a record carriesunresolved_reason: nullwith any null derivation field (left→right violation — upstream Derive bug), or carries a non-null tag with all six fields populated (right→left violation — stale tag that should have been auto-flipped).
JWT / RSA / Ed25519 and outlier signing families (Tasks 66c / 66d) are deferred — no Tier 1/2/DEX exchange in priv/priority_tiers.json needs them as of 2026-04-18.
testnet is a structured, required, derived top-level section that replaces reaching into the opaque raw.describe.urls.test / raw.describe.options.sandboxMode blobs.
"testnet": {
"pattern": "separate_host" | "sandbox_flag" | "none",
"urls": { "public": "...", "private": "..." } | null,
"sandbox_flag_field": "sandboxMode" | null,
"unresolved_reason": null | "no_testnet_data"
}See $defs/TestnetUrls in priv/schema/exchange_v4.json for the canonical definition.
pattern |
When | urls |
sandbox_flag_field |
unresolved_reason |
|---|---|---|---|---|
"separate_host" |
describe.urls.test is a non-empty map |
Non-null; CCXT's shape preserved (flat section map or nested host → section map), with {hostname} placeholders resolved against describe.hostname |
May be null OR "sandboxMode" — not mutually exclusive |
null |
"sandbox_flag" |
urls.test absent/empty but options.sandboxMode key exists |
null |
"sandboxMode" |
null |
"none" |
Neither signal present | null |
null |
"no_testnet_data" |
Several priority exchanges (okx, gate, hyperliquid) carry BOTH a testnet URL entry AND a sandboxMode flag. The separate host might be the same host with a different path, or a true separate testnet host that ALSO needs the flag set — the truth is "both." A pure enum on pattern alone would force a lossy choice. Tracking flag presence independently lets a consumer write one deterministic adapter:
testnet.urls → base URL to hit for sandbox traffic
testnet.sandbox_flag_field → name of the runtime flag to set (e.g. pass through to setSandboxMode)
When describe.urls.test string leaves contain {hostname} templates, they are substituted with describe.hostname at extract time so consumers never see placeholders. If hostname is absent the literal {hostname} survives and the testnet_urls_shape_valid contract invariant flags it as a finding. No silent fallback: either the URL is fully resolved or the extraction surfaces the gap.
testnet_urls_shape_valid— per-exchange. Fails on: key-set drift,patternnot in the closed enum, cross-field inconsistency (e.g.pattern: "none"withurlsnon-nil), or any residual{hostname}placeholder in a resolved URL string.unified_method_descriptors_shape_valid— per-exchange. Fails on: non-mapendpoints.descriptors, descriptor key/name drift, missing/extra descriptor keys, malformedsignature.params, malformed method-localerrors, or JSDoc-null fields that do not agree withunresolved_reason: "no_jsdoc".
/testnet is tagged "derived". The underlying raw inputs (describe.urls, describe.options) remain tagged "raw" as part of /raw/describe.
The roadmap entry mentioned "proxy patterns" alongside testnet URLs. Grep confirms no priority exchange (priv/priority_tiers.json tier1 + tier2 + DEX) ships a proxyUrl in describe(). The consumer contract (ccxt_client Exchange.new/2) also does not model proxies today, so there is no consumer action to unblock. If a future priority exchange surfaces proxyUrl, raw.proxy_patterns becomes a follow-up derivation — not an expansion of testnet.
{ "auth": { "sign_recipe": { "private": { "crypto_op": {"algo": "hmac_sha256"}, "canonical_string": { "GET": { "family": "hmac_simple", "components": [ {"source": "timestamp"}, {"source": "method"}, {"source": "path"}, {"source": "literal", "value": "?"}, {"source": "query"} ], "encoding": "url_encoded" }, "POST": { "family": "hmac_with_body", "components": [ {"source": "timestamp"}, {"source": "method"}, {"source": "path"}, {"source": "body"} ], "encoding": "url_encoded" } }, "signature_placement": { "location": "header", "key": "OK-ACCESS-SIGN" }, "auth_headers": [ {"name": "OK-ACCESS-KEY", "source": "api_key"}, {"name": "OK-ACCESS-PASSPHRASE", "source": "passphrase"}, {"name": "OK-ACCESS-TIMESTAMP", "source": "timestamp"} ], "nonce": {"source": "timestamp_ms", "format": "iso8601"}, "pre_sign_transforms": [ {"op": "base64_encode", "target": "signature"} ], "unresolved_reason": null, "patch_count": 0 }, "sapi": { /* … same shape … */ } } } }