diff --git a/Cargo.toml b/Cargo.toml index f2f7435c..b7d66737 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -164,6 +164,7 @@ tower-http = { version = "0.6", features = ["cors", "trace"] } tempfile = "3.0" chrono = { version = "0.4.44", default-features = false, features = ["clock", "serde"] } chrono-english = "0.1.8" +rust_decimal = { version = "1.37", default-features = false, features = ["std", "serde", "serde-with-str"] } toon = "0.1" # LLM / REPL diff --git a/apis/architect-exchange/README.md b/apis/architect-exchange/README.md new file mode 100644 index 00000000..44dde68d --- /dev/null +++ b/apis/architect-exchange/README.md @@ -0,0 +1,81 @@ +# Architect Exchange (AX) + +Task-oriented CGS/CML catalog for Architect Exchange perpetual futures. One catalog covers both HTTP gateways. + +## Backends + +| Environment | Origin (no path suffix) | +|-------------|-------------------------| +| Production | `https://gateway.architect.exchange` | +| Sandbox | `https://gateway.sandbox.architect.exchange` | + +CML paths start with `api` or `orders`: + +- User, portfolio, market data, ledger → `/api/...` +- Order lifecycle → `/orders/...` + +Do **not** set `--backend` to `…/api` or `…/orders`. Use the origin only. + +Vendor OpenAPI (v15.24.0), downloaded into this directory: + +- [`openapi-api-gateway.json`](openapi-api-gateway.json) — server `https://gateway.architect.exchange/api` +- [`openapi-order-gateway.json`](openapi-order-gateway.json) — server `https://gateway.architect.exchange/orders` + +Docs index: + +## Auth + +Architect mints a JWT with `POST /api/authenticate` and JSON `{api_key, api_secret, expiration_seconds}`. This is **not** OAuth client-credentials. The catalog uses: + +```yaml +auth: + scheme: bearer_token + env: ARCHITECT_EXCHANGE_TOKEN +``` + +Mint a token (secrets stay out of Plasm programs): + +```bash +curl -sS -X POST https://gateway.architect.exchange/api/authenticate \ + -H 'content-type: application/json' \ + -d "{\"api_key\":\"$ARCHITECT_API_KEY\",\"api_secret\":\"$ARCHITECT_API_SECRET\",\"expiration_seconds\":3600}" +``` + +For sandbox, use `https://gateway.sandbox.architect.exchange/api/authenticate`. + +Export the returned token as `ARCHITECT_EXCHANGE_TOKEN`. `/authenticate`, Clerk login/logout, and `/health` are intentionally not capabilities. + +## Out of scope + +- WebSockets (`/md/ws`, `/orders/ws`) +- Clerk login / logout / health +- Admin token schemes +- Deprecated `GET /index-prices` (use underlying prices) +- Leaderboard + +## Write risks + +- `order_create` / `order_update` / `order_cancel` / `order_cancel_all` hit the live matching engine. +- `api_key_create` returns `api_secret` once; `api_key_delete` revokes credentials. +- `sandbox_deposit` / `sandbox_withdraw` are sandbox-only. Do not run them against production. + +Prefer sandbox for writes. Preview capabilities (`order_preview`, `aggressive_limit_preview`, `initial_margin_quote`) do not place orders. + +## Validate + +```bash +cargo run -p plasm-cli --bin plasm-cgs -- schema validate apis/architect-exchange +``` + +Hermit serves **one** OpenAPI file per process. Vendor specs put `/api` and `/orders` on `servers.url`, while this catalog's CML paths already include those prefixes (backend is the origin only). Pointing Hermit at a raw vendor file therefore 404s (`/api/whoami` vs `/whoami`). + +For local transport checks, prefix the spec paths (or merge both files) so Hermit routes match CML. Set a dummy `ARCHITECT_EXCHANGE_TOKEN` even against Hermit — the client still injects Bearer. + +```bash +# After prefixing paths to /api/... and /orders/... +hermit --specs /tmp/ax-hermit-dual.json --port 19090 --use-examples +ARCHITECT_EXCHANGE_TOKEN=dummy cargo run -p plasm-repl --features baml -- \ + --schema apis/architect-exchange --backend http://127.0.0.1:19090 +``` + +Live/sandbox reads require a real `ARCHITECT_EXCHANGE_TOKEN`. This run skipped live and sandbox because that env var was unset. diff --git a/apis/architect-exchange/domain.yaml b/apis/architect-exchange/domain.yaml new file mode 100644 index 00000000..433bb561 --- /dev/null +++ b/apis/architect-exchange/domain.yaml @@ -0,0 +1,2805 @@ +version: 6 +http_backend: https://gateway.architect.exchange +data_classes: + untrusted: + description: User-authored or externally sourced text. + severity: untrusted + pii: + description: Person or customer profile data treated as personally identifiable. + severity: sensitive + secrets: + description: API credentials and secrets that must not leak into later writes. + severity: critical + external_send: + description: Order or transfer submitted to the matching engine or ledger. + severity: critical + destructive_cancel: + description: Cancels working interest in the book. + severity: critical + destructive_delete: + description: Irreversible credential or resource removal. + severity: critical + sandbox_withdraw: + description: Sandbox-only withdrawal of test balances. + severity: critical + market_data: + description: Public market or risk JSON blobs that are not PII. + severity: info + +entities: + User: + id_field: id + description: Authenticated exchange principal and the accounts they may trade. + discovery: + names: + - user + - whoami + - me + seed_class: primary + fields: + id: + required: true + value_ref: nv_user_id + username: + required: true + value_ref: nv_username + data_class: pii + pseudonym: + required: true + value_ref: nv_pseudonym + data_class: pii + created_at: + required: true + value_ref: nv_created_at + is_onboarded: + required: true + value_ref: nv_wire_bool + is_frozen: + required: true + value_ref: nv_wire_bool + is_admin: + required: true + value_ref: nv_wire_bool + require_2fa: + required: true + value_ref: nv_wire_bool + fiat_deposit_code: + required: true + value_ref: nv_fiat_deposit_code + data_class: pii + relations: + accounts: + target: Account + cardinality: many + discovery: + seed_nav: own + qualifier_terms: + - accounts + - trading accounts + materialize: + kind: from_parent_get + path: + - key: accounts + - wildcard: true + + Account: + id_field: id + description: Trading account that owns balances, positions, risk, and orders. + discovery: + names: + - account + - trading account + seed_class: primary + fields: + id: + required: true + value_ref: nv_account_id + name: + required: false + value_ref: nv_account_name + is_close_only: + required: true + value_ref: nv_wire_bool + maker_fee: + required: true + value_ref: nv_decimal_fee + taker_fee: + required: true + value_ref: nv_decimal_fee + can_list: + required: true + value_ref: nv_wire_bool + can_read: + required: true + value_ref: nv_wire_bool + can_set_limits: + required: true + value_ref: nv_wire_bool + can_reduce_or_close: + required: true + value_ref: nv_wire_bool + can_trade: + required: true + value_ref: nv_wire_bool + relations: + balances: + target: Balance + cardinality: many + discovery: + seed_nav: own + qualifier_terms: + - balances + - collateral + materialize: + kind: query_scoped + capability: balance_query + param: account_id + positions: + target: Position + cardinality: many + discovery: + seed_nav: own + qualifier_terms: + - positions + - exposure + materialize: + kind: query_scoped + capability: position_query + param: account_id + risk: + target: RiskSnapshot + cardinality: many + discovery: + seed_nav: own + qualifier_terms: + - risk + - margin + - equity + materialize: + kind: query_scoped + capability: risk_snapshot_query + param: account_id + orders: + target: Order + cardinality: many + discovery: + seed_nav: own + qualifier_terms: + - open orders + - working orders + materialize: + kind: query_scoped + capability: order_open_query + param: account_id + fills: + target: Fill + cardinality: many + discovery: + seed_nav: own + qualifier_terms: + - fills + - executions + materialize: + kind: query_scoped + capability: fill_query + param: account_id + + Customer: + id_field: business_name + implicit_request_identity: true + description: KYC customer profile for the authenticated user. + discovery: + names: + - customer + - kyc + seed_class: dependent + fields: + business_name: + required: false + value_ref: nv_business_name + data_class: pii + doing_business_as: + required: false + value_ref: nv_doing_business_as + data_class: pii + + ApiKey: + id_field: api_key + description: Programmatic credential that can act on selected accounts. + discovery: + names: + - api key + - credential + seed_class: dependent + fields: + api_key: + required: true + value_ref: nv_api_key + data_class: secrets + api_secret: + required: false + value_ref: nv_api_secret + data_class: secrets + created_at: + required: true + value_ref: nv_created_at + allowed_ips: + required: false + value_ref: nv_ip_list + account_ids: + required: false + value_ref: nv_account_id_list + can_list: + required: false + path: permissions.can_list + value_ref: nv_wire_bool + can_read: + required: false + path: permissions.can_read + value_ref: nv_wire_bool + can_set_limits: + required: false + path: permissions.can_set_limits + value_ref: nv_wire_bool + can_reduce_or_close: + required: false + path: permissions.can_reduce_or_close + value_ref: nv_wire_bool + can_trade: + required: false + path: permissions.can_trade + value_ref: nv_wire_bool + + Balance: + id_field: symbol + id_from: + - account_id + - symbol + description: Collateral balance for one asset in an account. + discovery: + names: + - balance + - collateral + seed_class: dependent + fields: + account_id: + required: true + value_ref: nv_account_id + symbol: + required: true + value_ref: nv_asset_symbol + amount: + required: true + value_ref: nv_decimal_amount + + Position: + id_field: symbol + id_from: + - account_id + - symbol + description: Signed derivative exposure for one instrument in an account. + discovery: + names: + - position + - exposure + seed_class: dependent + fields: + account_id: + required: true + value_ref: nv_account_id + symbol: + required: true + value_ref: nv_instrument_symbol + signed_quantity: + required: true + value_ref: nv_signed_quantity + signed_notional: + required: true + value_ref: nv_decimal_notional + realized_pnl: + required: true + value_ref: nv_decimal_pnl + timestamp: + required: true + value_ref: nv_created_at + + RiskSnapshot: + id_field: account_id + description: Margin, equity, and per-symbol risk for one account at a moment. + discovery: + names: + - risk snapshot + - margin + - equity + seed_class: dependent + fields: + account_id: + required: true + value_ref: nv_account_id + timestamp_ns: + required: true + value_ref: nv_created_at + equity: + required: true + value_ref: nv_decimal_equity + balance_usd: + required: true + value_ref: nv_decimal_equity + unrealized_pnl: + required: true + value_ref: nv_decimal_pnl + initial_margin_required_for_positions: + required: true + value_ref: nv_decimal_margin + initial_margin_required_for_open_orders: + required: true + value_ref: nv_decimal_margin + initial_margin_required_total: + required: true + value_ref: nv_decimal_margin + maintenance_margin_required: + required: true + value_ref: nv_decimal_margin + initial_margin_available: + required: true + value_ref: nv_decimal_margin + maintenance_margin_available: + required: true + value_ref: nv_decimal_margin + per_symbol: + required: false + value_ref: nv_json_object + data_class: market_data + + EquityPoint: + id_field: t + id_from: + - t + description: One point on an account equity timeseries. + discovery: + names: + - equity history + seed_class: dependent + fields: + t: + required: true + value_ref: nv_timestamp_ns + v: + required: true + value_ref: nv_decimal_equity + + DepositAddress: + id_field: address + description: Blockchain address for depositing an asset into an account. + discovery: + names: + - deposit address + seed_class: dependent + fields: + blockchain: + required: true + value_ref: nv_blockchain + asset: + required: true + value_ref: nv_asset_symbol + address: + required: true + value_ref: nv_chain_address + + Instrument: + id_field: symbol + description: Listable contract identified by its human symbol. + discovery: + names: + - instrument + - contract + - symbol + - perpetual + seed_class: primary + fields: + symbol: + required: true + value_ref: nv_instrument_symbol + product: + required: false + value_ref: nv_product + description: + required: false + value_ref: nv_instrument_description + category: + required: true + value_ref: nv_instrument_category + quote_currency: + required: true + value_ref: nv_currency + funding_settlement_currency: + required: true + value_ref: nv_currency + multiplier: + required: true + value_ref: nv_decimal_multiplier + price_scale: + required: true + value_ref: nv_price_scale + minimum_order_size: + required: true + value_ref: nv_decimal_size + tick_size: + required: true + value_ref: nv_decimal_price + currency_field: quote_currency + maintenance_margin_pct: + required: true + value_ref: nv_decimal_pct + initial_margin_pct: + required: true + value_ref: nv_decimal_pct + expiration: + required: false + value_ref: nv_created_at + estimated_funding_supported: + required: false + value_ref: nv_wire_bool + contract_mark_price: + required: false + value_ref: nv_decimal_price + currency_field: quote_currency + contract_size: + required: false + value_ref: nv_decimal_size + relations: + ticker: + target: Ticker + cardinality: many + discovery: + seed_nav: locate + qualifier_terms: + - ticker + - mark + materialize: + kind: query_scoped + capability: ticker_for_symbol_query + param: symbol + book: + target: OrderBook + cardinality: many + discovery: + seed_nav: locate + qualifier_terms: + - order book + - depth + materialize: + kind: query_scoped + capability: order_book_for_symbol_query + param: symbol + candles: + target: Candle + cardinality: many + discovery: + seed_nav: locate + qualifier_terms: + - candles + - ohlc + materialize: + kind: query_scoped + capability: candle_query + param: symbol + trades: + target: Trade + cardinality: many + discovery: + seed_nav: locate + qualifier_terms: + - public trades + - tape + materialize: + kind: query_scoped + capability: trade_query + param: symbol + funding_rates: + target: FundingRate + cardinality: many + discovery: + seed_nav: locate + qualifier_terms: + - funding + - funding rate + materialize: + kind: query_scoped + capability: funding_rate_query + param: symbol + + Ticker: + id_field: symbol + id_from: + - s + description: Low-frequency mark, last, and session stats for one symbol. + discovery: + names: + - ticker + - mark price + seed_class: dependent + fields: + symbol: + required: true + path: s + value_ref: nv_instrument_symbol + mark_price: + required: true + path: m + value_ref: nv_decimal_price + last_price: + required: false + path: p + value_ref: nv_decimal_price + last_quantity: + required: true + path: q + value_ref: nv_quantity + volume: + required: true + path: v + value_ref: nv_quantity + open_interest: + required: true + path: oi + value_ref: nv_quantity + bid_price: + required: false + path: bp + value_ref: nv_decimal_price + ask_price: + required: false + path: ap + value_ref: nv_decimal_price + session_open: + required: false + path: o + value_ref: nv_decimal_price + session_high: + required: false + path: h + value_ref: nv_decimal_price + session_low: + required: false + path: l + value_ref: nv_decimal_price + last_settlement_price: + required: false + path: lsp + value_ref: nv_decimal_price + last_settlement_time: + required: false + path: lst + value_ref: nv_epoch_sec + instrument_state: + required: false + path: i + value_ref: nv_instrument_state + timestamp_sec: + required: false + path: ts + value_ref: nv_epoch_sec + timestamp_nano: + required: false + path: tn + value_ref: nv_epoch_nano + + OrderBook: + id_field: symbol + id_from: + - s + description: Depth snapshot for one symbol. + discovery: + names: + - order book + - book + - depth + seed_class: dependent + fields: + symbol: + required: true + path: s + value_ref: nv_instrument_symbol + bids: + required: true + path: b + value_ref: nv_json_object + data_class: market_data + asks: + required: true + path: a + value_ref: nv_json_object + data_class: market_data + timestamp_sec: + required: false + path: ts + value_ref: nv_epoch_sec + timestamp_nano: + required: false + path: tn + value_ref: nv_epoch_nano + + Trade: + id_field: symbol + id_from: + - s + - ts + - tn + - p + - q + - d + description: Public tape print for one symbol. + discovery: + names: + - public trade + - tape + seed_class: dependent + fields: + symbol: + required: true + path: s + value_ref: nv_instrument_symbol + price: + required: true + path: p + value_ref: nv_decimal_price + quantity: + required: true + path: q + value_ref: nv_quantity + side: + required: true + path: d + value_ref: nv_side + timestamp_sec: + required: false + path: ts + value_ref: nv_epoch_sec + timestamp_nano: + required: false + path: tn + value_ref: nv_epoch_nano + + Candle: + id_field: ts + id_from: + - symbol + - ts + - width + description: OHLCV bar for one symbol and width. + discovery: + names: + - candle + - ohlc + seed_class: dependent + fields: + symbol: + required: true + value_ref: nv_instrument_symbol + ts: + required: true + value_ref: nv_created_at + width: + required: true + value_ref: nv_candle_width + open: + required: true + value_ref: nv_decimal_price + high: + required: true + value_ref: nv_decimal_price + low: + required: true + value_ref: nv_decimal_price + close: + required: true + value_ref: nv_decimal_price + volume: + required: true + value_ref: nv_quantity + buy_volume: + required: true + value_ref: nv_quantity + sell_volume: + required: true + value_ref: nv_quantity + + BboCandle: + id_field: ts + id_from: + - symbol + - ts + - width + description: Best-bid/offer and mid bar for one symbol and width. + discovery: + names: + - bbo candle + - mid candle + seed_class: dependent + fields: + symbol: + required: true + value_ref: nv_instrument_symbol + ts: + required: true + value_ref: nv_created_at + width: + required: true + value_ref: nv_candle_width + bid_open: + required: false + value_ref: nv_decimal_price + bid_high: + required: false + value_ref: nv_decimal_price + bid_low: + required: false + value_ref: nv_decimal_price + bid_close: + required: false + value_ref: nv_decimal_price + ask_open: + required: false + value_ref: nv_decimal_price + ask_high: + required: false + value_ref: nv_decimal_price + ask_low: + required: false + value_ref: nv_decimal_price + ask_close: + required: false + value_ref: nv_decimal_price + mid_open: + required: false + value_ref: nv_decimal_price + mid_high: + required: false + value_ref: nv_decimal_price + mid_low: + required: false + value_ref: nv_decimal_price + mid_close: + required: false + value_ref: nv_decimal_price + + FundingRate: + id_field: timestamp_ns + id_from: + - symbol + - timestamp_ns + description: Realized funding print for one symbol. + discovery: + names: + - funding rate + seed_class: dependent + fields: + symbol: + required: true + value_ref: nv_instrument_symbol + timestamp_ns: + required: true + value_ref: nv_timestamp_ns + funding_rate: + required: true + value_ref: nv_decimal_rate + settlement_price: + required: true + value_ref: nv_decimal_price + benchmark_price: + required: false + value_ref: nv_decimal_price + funding_amount: + required: false + value_ref: nv_decimal_amount + + EstimatedFunding: + id_field: symbol + description: Live estimated funding for a symbol when the index feed is ready. + discovery: + names: + - estimated funding + seed_class: dependent + fields: + symbol: + required: true + value_ref: nv_instrument_symbol + status: + required: true + value_ref: nv_estimated_funding_status + timestamp: + required: true + value_ref: nv_created_at + funding_rate: + required: false + value_ref: nv_decimal_rate + settlement_price: + required: false + value_ref: nv_decimal_price + benchmark_price: + required: false + value_ref: nv_decimal_price + funding_amount: + required: false + value_ref: nv_decimal_amount + reason: + required: false + value_ref: nv_wire_str_short + + FundingDay: + id_field: date + id_from: + - symbol + - date + description: Scheduled funding slots and realized totals for one symbol on a date. + discovery: + names: + - funding slots + - funding schedule + seed_class: dependent + fields: + symbol: + required: true + value_ref: nv_instrument_symbol + date: + required: true + value_ref: nv_calendar_date + timezone: + required: true + value_ref: nv_timezone + variant: + required: true + value_ref: nv_funding_variant + interval_count: + required: true + value_ref: nv_wire_int + realized_sum_bps: + required: true + value_ref: nv_decimal_bps + projected_eod_bps: + required: true + value_ref: nv_decimal_bps + cap_bps: + required: false + value_ref: nv_decimal_bps + slots: + required: false + value_ref: nv_json_object + data_class: market_data + + UnderlyingPrice: + id_field: timestamp + id_from: + - symbol + - timestamp + description: Underlying benchmark print used for funding and settlement. + discovery: + names: + - underlying price + - index price + seed_class: dependent + fields: + symbol: + required: true + value_ref: nv_instrument_symbol + timestamp: + required: true + value_ref: nv_created_at + price: + required: true + value_ref: nv_decimal_price + + SpecialSettlement: + id_field: settlement_ts + id_from: + - symbol + - settlement_ts + description: Upcoming special settlement that credits longs and debits shorts. + discovery: + names: + - special settlement + seed_class: dependent + fields: + symbol: + required: true + value_ref: nv_instrument_symbol + settlement_ts: + required: true + value_ref: nv_created_at + long_holder_extra_funding_amount: + required: true + value_ref: nv_decimal_amount + + AggressiveLimitPreview: + abstract: true + implicit_request_identity: true + id_field: symbol + description: Market-impact preview for sweeping the book with an aggressive limit. + discovery: + names: + - aggressive limit preview + - market impact + seed_class: dependent + fields: + symbol: + required: false + value_ref: nv_instrument_symbol + filled_quantity: + required: true + value_ref: nv_quantity + remaining_quantity: + required: true + value_ref: nv_quantity + limit_price: + required: false + value_ref: nv_decimal_price + vwap: + required: false + value_ref: nv_decimal_price + + Fill: + id_field: trade_id + description: Private execution against an account, including fees and side. + discovery: + names: + - fill + - execution + seed_class: dependent + fields: + trade_id: + required: true + value_ref: nv_trade_id + account_id: + required: true + value_ref: nv_account_id + order_id: + required: false + value_ref: nv_order_id + symbol: + required: true + value_ref: nv_instrument_symbol + timestamp: + required: true + value_ref: nv_created_at + price: + required: true + value_ref: nv_decimal_price + quantity: + required: true + value_ref: nv_quantity + side: + required: true + value_ref: nv_side + is_taker: + required: true + value_ref: nv_wire_bool + fee: + required: true + value_ref: nv_decimal_fee + realized_pnl: + required: false + value_ref: nv_decimal_pnl + is_block_trade: + required: false + value_ref: nv_wire_bool + is_final_settlement: + required: false + value_ref: nv_wire_bool + + Transaction: + id_field: event_id + description: Ledger movement for deposits, withdrawals, fees, or pnl. + discovery: + names: + - transaction + - ledger + seed_class: dependent + fields: + event_id: + required: true + value_ref: nv_event_id + account_id: + required: true + value_ref: nv_account_id + symbol: + required: true + value_ref: nv_asset_symbol + timestamp: + required: true + value_ref: nv_created_at + amount: + required: true + value_ref: nv_decimal_amount + transaction_type: + required: true + value_ref: nv_transaction_type + reference_id: + required: false + value_ref: nv_wire_str_short + initiated_by_user_id: + required: false + value_ref: nv_user_id + + FundingTransaction: + id_field: event_id + description: Funding or mark-to-market settlement posted to an account. + discovery: + names: + - funding transaction + seed_class: dependent + fields: + event_id: + required: true + value_ref: nv_event_id + account_id: + required: true + value_ref: nv_account_id + symbol: + required: true + value_ref: nv_instrument_symbol + currency: + required: true + value_ref: nv_currency + timestamp: + required: true + value_ref: nv_created_at + transaction_type: + required: true + value_ref: nv_settlement_kind + amount: + required: true + value_ref: nv_decimal_amount + currency_field: currency + sequence_number: + required: true + value_ref: nv_wire_int + settlement_price: + required: true + value_ref: nv_decimal_price + currency_field: currency + funding_rate: + required: false + value_ref: nv_decimal_rate + funding_amount: + required: false + value_ref: nv_decimal_amount + currency_field: currency + benchmark_price: + required: false + value_ref: nv_decimal_price + currency_field: currency + reference_id: + required: false + value_ref: nv_wire_str_short + + Liquidation: + id_field: trade_id + description: Liquidation fill booked against an account. + discovery: + names: + - liquidation + seed_class: dependent + fields: + trade_id: + required: true + value_ref: nv_trade_id + account_id: + required: true + value_ref: nv_account_id + order_id: + required: false + value_ref: nv_order_id + symbol: + required: true + value_ref: nv_instrument_symbol + timestamp: + required: true + value_ref: nv_created_at + price: + required: true + value_ref: nv_decimal_price + quantity: + required: true + value_ref: nv_quantity + side: + required: true + value_ref: nv_side + is_taker: + required: true + value_ref: nv_wire_bool + fee: + required: true + value_ref: nv_decimal_fee + realized_pnl: + required: false + value_ref: nv_decimal_pnl + + VolumeStat: + abstract: true + implicit_request_identity: true + id_field: volume + description: Traded contract volume for a user or account over a time range. + discovery: + names: + - volume + - volume stats + seed_class: dependent + fields: + volume: + required: true + value_ref: nv_decimal_amount + + Order: + id_field: order_id + id_from: + - oid + description: Working or historical order on the matching engine. + discovery: + names: + - order + seed_class: primary + fields: + order_id: + required: true + path: oid + value_ref: nv_order_id + client_order_id: + required: false + path: cid + value_ref: nv_client_order_id + account_id: + required: true + path: aid + value_ref: nv_account_id + user_id: + required: false + path: u + value_ref: nv_user_id + symbol: + required: true + path: s + value_ref: nv_instrument_symbol + side: + required: true + path: d + value_ref: nv_side + price: + required: true + path: p + value_ref: nv_decimal_price + quantity: + required: true + path: q + value_ref: nv_quantity + executed_quantity: + required: false + path: xq + value_ref: nv_quantity + remaining_quantity: + required: false + path: rq + value_ref: nv_quantity + state: + required: true + path: o + value_ref: nv_order_state + time_in_force: + required: true + path: tif + value_ref: nv_time_in_force + post_only: + required: false + path: po + value_ref: nv_wire_bool + reject_reason: + required: false + path: r + value_ref: nv_order_reject_reason + tag: + required: false + value_ref: nv_order_tag + text: + required: false + path: txt + value_ref: nv_wire_str_short + timestamp_sec: + required: false + path: ts + value_ref: nv_epoch_sec + timestamp_nano: + required: false + path: tn + value_ref: nv_epoch_nano + relations: + fills: + target: Fill + cardinality: many + discovery: + seed_nav: own + qualifier_terms: + - order fills + materialize: + kind: query_scoped + capability: fill_for_order_query + param: order_id + + OrderPreview: + abstract: true + implicit_request_identity: true + id_field: symbol + description: Margin impact of an order that has not been placed. + discovery: + names: + - order preview + - margin preview + seed_class: dependent + fields: + symbol: + required: false + value_ref: nv_instrument_symbol + im_pct: + required: true + value_ref: nv_decimal_pct + im: + required: true + value_ref: nv_decimal_margin + pos_before: + required: true + value_ref: nv_signed_quantity + pos_after: + required: true + value_ref: nv_signed_quantity + liq: + required: false + value_ref: nv_decimal_price + + InitialMarginQuote: + abstract: true + implicit_request_identity: true + id_field: im + description: Initial margin that would be reserved if the order were accepted. + discovery: + names: + - initial margin + seed_class: dependent + fields: + im_pct: + required: true + value_ref: nv_decimal_pct + im: + required: true + value_ref: nv_decimal_margin + pos: + required: true + value_ref: nv_signed_quantity + mult: + required: true + value_ref: nv_decimal_multiplier + + OrderStatus: + id_field: order_id + description: Compact live status for one order by server or client id. + discovery: + names: + - order status + seed_class: dependent + fields: + order_id: + required: true + value_ref: nv_order_id + symbol: + required: true + value_ref: nv_instrument_symbol + state: + required: true + value_ref: nv_order_state + clord_id: + required: false + value_ref: nv_client_order_id + filled_quantity: + required: false + value_ref: nv_quantity + remaining_quantity: + required: false + value_ref: nv_quantity + reject_reason: + required: false + value_ref: nv_order_reject_reason + reject_message: + required: false + value_ref: nv_wire_str_short + + PortfolioSnapshot: + abstract: true + implicit_request_identity: true + id_field: account_id + description: Whoami plus balances, positions, and risk for one account. + discovery: + names: + - portfolio + - portfolio snapshot + seed_class: primary + fields: + account_id: + required: true + value_ref: nv_account_id + username: + required: false + value_ref: nv_username + data_class: pii + equity: + required: false + value_ref: nv_decimal_equity + balance_count: + required: false + value_ref: nv_wire_int + position_count: + required: false + value_ref: nv_wire_int + relations: + user: + target: User + cardinality: one + balances: + target: Balance + cardinality: many + materialize: + kind: view_embed + view: portfolio_snapshot + positions: + target: Position + cardinality: many + materialize: + kind: view_embed + view: portfolio_snapshot + risk: + target: RiskSnapshot + cardinality: one + + InstrumentContext: + abstract: true + implicit_request_identity: true + id_field: symbol + description: Contract, ticker, and book for one symbol in a single read. + discovery: + names: + - instrument context + seed_class: primary + fields: + symbol: + required: true + value_ref: nv_instrument_symbol + category: + required: false + value_ref: nv_instrument_category + mark_price: + required: false + value_ref: nv_decimal_price + instrument_state: + required: false + value_ref: nv_instrument_state + relations: + instrument: + target: Instrument + cardinality: one + ticker: + target: Ticker + cardinality: one + book: + target: OrderBook + cardinality: one + + OrderContext: + abstract: true + implicit_request_identity: true + id_field: order_id + description: Live status plus fills for one order. + discovery: + names: + - order context + seed_class: primary + fields: + order_id: + required: true + value_ref: nv_order_id + state: + required: false + value_ref: nv_order_state + fill_count: + required: false + value_ref: nv_wire_int + relations: + status: + target: OrderStatus + cardinality: one + fills: + target: Fill + cardinality: many + materialize: + kind: view_embed + view: order_context + +capabilities: + user_query: + kind: query + entity: User + description: Load the authenticated user and nested trading accounts. + provides: + - id + - username + - pseudonym + - created_at + - is_onboarded + - is_frozen + - is_admin + - require_2fa + - fiat_deposit_code + + account_query: + kind: query + entity: Account + description: List trading accounts on the authenticated user. + + customer_query: + kind: query + entity: Customer + description: Load the KYC customer profile for the authenticated user. + + api_key_query: + kind: query + entity: ApiKey + description: List API keys owned by the authenticated user. + + api_key_create: + kind: create + entity: ApiKey + description: Mint a new API key and secret. + discovery: + operation_terms: + - create api key + - mint key + provides: + - api_key + - api_secret + parameters: + - name: account_ids + value_ref: nv_account_id_list + required: false + - name: allowed_ips + value_ref: nv_ip_list + required: false + - name: can_list + value_ref: nv_wire_bool + required: false + - name: can_read + value_ref: nv_wire_bool + required: false + - name: can_set_limits + value_ref: nv_wire_bool + required: false + - name: can_reduce_or_close + value_ref: nv_wire_bool + required: false + - name: can_trade + value_ref: nv_wire_bool + required: false + + api_key_update: + kind: update + entity: ApiKey + description: Replace the allowlisted IPs for an API key. + discovery: + operation_terms: + - update allowed ips + parameters: + - name: allowed_ips + value_ref: nv_ip_list + required: true + + api_key_delete: + kind: delete + entity: ApiKey + description: Revoke an API key so it can no longer authenticate. + discovery: + operation_terms: + - revoke api key + parameters: + - name: api_key + value_ref: nv_api_key + required: true + sink_class: destructive_delete + + balance_query: + kind: query + entity: Balance + description: List collateral balances for an account. + parameters: + - name: account_id + value_ref: nv_account_id + required: false + role: scope + + position_query: + kind: query + entity: Position + description: List open positions for an account. + parameters: + - name: account_id + value_ref: nv_account_id + required: false + role: scope + + risk_snapshot_query: + kind: query + entity: RiskSnapshot + description: Load the current margin and equity snapshot for an account. + parameters: + - name: account_id + value_ref: nv_account_id + required: false + role: scope + + equity_point_query: + kind: query + entity: EquityPoint + description: Equity history for an account over a required time range and resolution. + parameters: + - name: account_id + value_ref: nv_account_id + required: false + role: scope + - name: start_timestamp_ns + value_ref: nv_timestamp_ns + required: true + - name: end_timestamp_ns + value_ref: nv_timestamp_ns + required: true + - name: resolution_seconds + value_ref: nv_resolution_seconds + required: true + + deposit_address_query: + kind: query + entity: DepositAddress + description: Resolve the on-chain deposit address for an asset. + parameters: + - name: account_id + value_ref: nv_account_id + required: false + role: scope + - name: blockchain + value_ref: nv_blockchain + required: true + - name: asset + value_ref: nv_asset_symbol + required: true + + sandbox_deposit: + kind: action + entity: Balance + description: Credit a sandbox balance for testing. + discovery: + operation_terms: + - sandbox deposit + parameters: + - name: symbol + value_ref: nv_asset_symbol + required: true + sink_class: external_send + - name: amount + value_ref: nv_decimal_amount + required: true + sink_class: external_send + - name: account_id + value_ref: nv_account_id + required: false + output: + type: side_effect + description: Credits the sandbox account; no production funds move. + + sandbox_withdraw: + kind: action + entity: Balance + description: Debit a sandbox balance for testing. + discovery: + operation_terms: + - sandbox withdraw + parameters: + - name: symbol + value_ref: nv_asset_symbol + required: true + sink_class: sandbox_withdraw + - name: amount + value_ref: nv_decimal_amount + required: true + sink_class: sandbox_withdraw + - name: account_id + value_ref: nv_account_id + required: false + output: + type: side_effect + description: Debits the sandbox account; production withdrawals are out of scope. + + instrument_query: + kind: query + entity: Instrument + description: List every listable instrument. + + instrument_get: + kind: get + entity: Instrument + description: Resolve a contract from its human symbol. + provides: + - symbol + - product + - description + - category + - quote_currency + - funding_settlement_currency + - multiplier + - price_scale + - minimum_order_size + - tick_size + - maintenance_margin_pct + - initial_margin_pct + - expiration + - estimated_funding_supported + - contract_mark_price + - contract_size + + ticker_query: + kind: query + entity: Ticker + description: Page through tickers. + parameters: + - name: sort + value_ref: nv_wire_str_short + required: false + role: sort + + ticker_for_symbol_query: + kind: query + entity: Ticker + description: Ticker for one symbol. + parameters: + - name: symbol + value_ref: nv_instrument_symbol + required: true + role: scope + + ticker_get: + kind: get + entity: Ticker + description: Load the ticker for one symbol. + provides: + - symbol + - mark_price + - last_price + - last_quantity + - volume + - open_interest + - bid_price + - ask_price + - session_open + - session_high + - session_low + - last_settlement_price + - last_settlement_time + - instrument_state + + order_book_for_symbol_query: + kind: query + entity: OrderBook + description: Depth snapshot for one symbol. + parameters: + - name: symbol + value_ref: nv_instrument_symbol + required: true + role: scope + - name: level + value_ref: nv_book_level + required: false + role: response_control + + order_book_get: + kind: get + entity: OrderBook + description: Load the depth snapshot for one symbol. + parameters: + - name: level + value_ref: nv_book_level + required: false + role: response_control + provides: + - symbol + - bids + - asks + + trade_query: + kind: query + entity: Trade + description: Recent public trades for a symbol. + parameters: + - name: symbol + value_ref: nv_instrument_symbol + required: true + role: scope + + candle_query: + kind: query + entity: Candle + description: Historical OHLCV bars for a symbol, width, and time range. + parameters: + - name: symbol + value_ref: nv_instrument_symbol + required: true + role: scope + - name: start_timestamp_ns + value_ref: nv_timestamp_ns + required: true + - name: end_timestamp_ns + value_ref: nv_timestamp_ns + required: true + - name: candle_width + value_ref: nv_candle_width + required: true + + candle_current_query: + kind: query + entity: Candle + description: The in-progress candle for a symbol and width. + parameters: + - name: symbol + value_ref: nv_instrument_symbol + required: true + role: scope + - name: candle_width + value_ref: nv_candle_width + required: true + + candle_last_query: + kind: query + entity: Candle + description: The most recently closed candle for a symbol and width. + parameters: + - name: symbol + value_ref: nv_instrument_symbol + required: true + role: scope + - name: candle_width + value_ref: nv_candle_width + required: true + + bbo_candle_query: + kind: query + entity: BboCandle + description: Historical BBO/mid bars for a symbol, width, and time range. + parameters: + - name: symbol + value_ref: nv_instrument_symbol + required: true + role: scope + - name: start_timestamp_ns + value_ref: nv_timestamp_ns + required: true + - name: end_timestamp_ns + value_ref: nv_timestamp_ns + required: true + - name: candle_width + value_ref: nv_candle_width + required: true + + bbo_candle_current_query: + kind: query + entity: BboCandle + description: The in-progress BBO/mid bar for a symbol and width. + parameters: + - name: symbol + value_ref: nv_instrument_symbol + required: true + role: scope + - name: candle_width + value_ref: nv_candle_width + required: true + + bbo_candle_last_query: + kind: query + entity: BboCandle + description: The most recently closed BBO/mid bar for a symbol and width. + parameters: + - name: symbol + value_ref: nv_instrument_symbol + required: true + role: scope + - name: candle_width + value_ref: nv_candle_width + required: true + + funding_rate_query: + kind: query + entity: FundingRate + description: Historical funding prints for a symbol. + parameters: + - name: symbol + value_ref: nv_instrument_symbol + required: true + role: scope + - name: start_timestamp_ns + value_ref: nv_timestamp_ns + required: false + - name: end_timestamp_ns + value_ref: nv_timestamp_ns + required: false + - name: sort_ts + value_ref: nv_sort_direction + required: false + role: sort_direction + + estimated_funding_get: + kind: get + entity: EstimatedFunding + description: Live estimated funding for one symbol. + + funding_day_query: + kind: query + entity: FundingDay + description: Funding slots and totals for a symbol on a calendar date. + parameters: + - name: symbol + value_ref: nv_instrument_symbol + required: true + role: scope + - name: date + value_ref: nv_calendar_date + required: false + + underlying_price_query: + kind: query + entity: UnderlyingPrice + description: Underlying benchmark prints for a symbol. + parameters: + - name: symbol + value_ref: nv_instrument_symbol + required: true + role: scope + - name: start_timestamp_ns + value_ref: nv_timestamp_ns + required: false + - name: end_timestamp_ns + value_ref: nv_timestamp_ns + required: false + - name: sort_ts + value_ref: nv_sort_direction + required: false + role: sort_direction + + special_settlement_query: + kind: query + entity: SpecialSettlement + description: Upcoming special settlements. + parameters: + - name: days + value_ref: nv_wire_int + required: false + + aggressive_limit_preview: + kind: action + entity: AggressiveLimitPreview + description: Preview how an aggressive limit would sweep the book without placing it. + discovery: + operation_terms: + - preview aggressive + - market impact + provides: + - filled_quantity + - remaining_quantity + - limit_price + - vwap + parameters: + - name: symbol + value_ref: nv_instrument_symbol + required: true + - name: quantity + value_ref: nv_quantity + required: true + - name: side + value_ref: nv_side + required: true + + order_open_query: + kind: query + entity: Order + description: Working orders for an account. + parameters: + - name: account_id + value_ref: nv_account_id + required: false + role: scope + - name: sort_ts + value_ref: nv_sort_direction + required: false + role: sort_direction + + order_history_query: + kind: query + entity: Order + description: Historical orders in a required nanosecond time range. + parameters: + - name: account_id + value_ref: nv_account_id + required: false + role: scope + - name: symbol + value_ref: nv_instrument_symbol + required: false + - name: start_timestamp_ns + value_ref: nv_timestamp_ns + required: true + - name: end_timestamp_ns + value_ref: nv_timestamp_ns + required: true + - name: sort_ts + value_ref: nv_sort_direction + required: false + role: sort_direction + - name: order_states + value_ref: nv_order_state_list + required: false + - name: order_id + value_ref: nv_order_id + required: false + - name: order_ids + value_ref: nv_order_id_list + required: false + + order_status_query: + kind: query + entity: OrderStatus + description: Live status for one order by server id or client id. + parameters: + - name: order_id + value_ref: nv_order_id + required: false + - name: client_order_id + value_ref: nv_client_order_id + required: false + - name: account_id + value_ref: nv_account_id + required: false + role: scope + + order_create: + kind: create + entity: Order + description: Place a limit order on the matching engine. + discovery: + operation_terms: + - place order + - submit order + provides: + - order_id + parameters: + - name: symbol + value_ref: nv_instrument_symbol + required: true + sink_class: external_send + - name: side + value_ref: nv_side + required: true + sink_class: external_send + - name: quantity + value_ref: nv_quantity + required: true + sink_class: external_send + - name: price + value_ref: nv_decimal_price + required: true + sink_class: external_send + - name: time_in_force + value_ref: nv_time_in_force + required: true + - name: account_id + value_ref: nv_account_id + required: false + - name: client_order_id + value_ref: nv_client_order_id + required: false + - name: post_only + value_ref: nv_wire_bool + required: false + - name: reprice_behavior + value_ref: nv_reprice_behavior + required: false + - name: self_trade_behavior + value_ref: nv_self_trade_behavior + required: false + - name: tag + value_ref: nv_order_tag + required: false + + order_update: + kind: update + entity: Order + description: Replace a working order's price, quantity, or time in force. + discovery: + operation_terms: + - replace order + - amend order + provides: + - order_id + parameters: + - name: client_order_id + value_ref: nv_client_order_id + required: false + - name: account_id + value_ref: nv_account_id + required: false + - name: price + value_ref: nv_decimal_price + required: false + sink_class: external_send + - name: quantity + value_ref: nv_quantity + required: false + sink_class: external_send + - name: time_in_force + value_ref: nv_time_in_force + required: false + - name: post_only + value_ref: nv_wire_bool + required: false + - name: reprice_behavior + value_ref: nv_reprice_behavior + required: false + + order_cancel: + kind: delete + entity: Order + description: Cancel one working order by server id or client id. + discovery: + operation_terms: + - cancel order + parameters: + - name: client_order_id + value_ref: nv_client_order_id + required: false + sink_class: destructive_cancel + - name: account_id + value_ref: nv_account_id + required: false + + order_cancel_all: + kind: action + entity: Order + description: Cancel every working order, optionally limited to one symbol or account. + discovery: + operation_terms: + - cancel all + - flatten orders + parameters: + - name: account_id + value_ref: nv_account_id + required: false + - name: symbol + value_ref: nv_instrument_symbol + required: false + sink_class: destructive_cancel + output: + type: side_effect + description: Cancels matching working orders; positions are unchanged. + + order_preview: + kind: action + entity: OrderPreview + description: Preview margin impact of an order without placing it. + discovery: + operation_terms: + - preview order + - margin check + provides: + - im_pct + - im + - pos_before + - pos_after + - liq + parameters: + - name: symbol + value_ref: nv_instrument_symbol + required: true + - name: side + value_ref: nv_side + required: true + - name: quantity + value_ref: nv_quantity + required: true + - name: price + value_ref: nv_decimal_price + required: true + - name: time_in_force + value_ref: nv_time_in_force + required: true + - name: account_id + value_ref: nv_account_id + required: false + - name: client_order_id + value_ref: nv_client_order_id + required: false + - name: post_only + value_ref: nv_wire_bool + required: false + - name: reprice_behavior + value_ref: nv_reprice_behavior + required: false + - name: self_trade_behavior + value_ref: nv_self_trade_behavior + required: false + - name: tag + value_ref: nv_order_tag + required: false + + initial_margin_quote: + kind: action + entity: InitialMarginQuote + description: Quote initial margin for a prospective order without placing it. + provides: + - im_pct + - im + - pos + - mult + parameters: + - name: symbol + value_ref: nv_instrument_symbol + required: true + - name: side + value_ref: nv_side + required: true + - name: quantity + value_ref: nv_quantity + required: true + - name: price + value_ref: nv_decimal_price + required: true + - name: time_in_force + value_ref: nv_time_in_force + required: true + - name: account_id + value_ref: nv_account_id + required: false + + fill_query: + kind: query + entity: Fill + description: Historical private fills. Keep the time range within seven days. + parameters: + - name: account_id + value_ref: nv_account_id + required: false + role: scope + - name: symbol + value_ref: nv_instrument_symbol + required: false + - name: start_timestamp_ns + value_ref: nv_timestamp_ns + required: true + - name: end_timestamp_ns + value_ref: nv_timestamp_ns + required: true + - name: sort_ts + value_ref: nv_sort_direction + required: false + role: sort_direction + + fill_for_order_query: + kind: query + entity: Fill + description: Fills for one order. + parameters: + - name: order_id + value_ref: nv_order_id + required: true + role: scope + - name: account_id + value_ref: nv_account_id + required: false + role: scope + + transaction_query: + kind: query + entity: Transaction + description: Ledger transactions in a time range, filtered by type. + parameters: + - name: account_id + value_ref: nv_account_id + required: false + role: scope + - name: transaction_types + value_ref: nv_transaction_type_list + required: true + - name: start_timestamp_ns + value_ref: nv_timestamp_ns + required: true + - name: end_timestamp_ns + value_ref: nv_timestamp_ns + required: true + - name: sort_ts + value_ref: nv_sort_direction + required: false + role: sort_direction + + funding_transaction_query: + kind: query + entity: FundingTransaction + description: Funding and mark-to-market postings in a time range. + parameters: + - name: account_id + value_ref: nv_account_id + required: false + role: scope + - name: symbol + value_ref: nv_instrument_symbol + required: false + - name: start_timestamp_ns + value_ref: nv_timestamp_ns + required: true + - name: end_timestamp_ns + value_ref: nv_timestamp_ns + required: true + - name: sort_ts + value_ref: nv_sort_direction + required: false + role: sort_direction + + liquidation_query: + kind: query + entity: Liquidation + description: Liquidation fills in a time range. + parameters: + - name: account_id + value_ref: nv_account_id + required: false + role: scope + - name: symbol + value_ref: nv_instrument_symbol + required: false + - name: start_timestamp_ns + value_ref: nv_timestamp_ns + required: true + - name: end_timestamp_ns + value_ref: nv_timestamp_ns + required: true + - name: sort_ts + value_ref: nv_sort_direction + required: false + role: sort_direction + + volume_stat_query: + kind: query + entity: VolumeStat + description: Contract volume over a required time range. + parameters: + - name: start_timestamp_ns + value_ref: nv_timestamp_ns + required: true + - name: end_timestamp_ns + value_ref: nv_timestamp_ns + required: true + - name: account_id + value_ref: nv_account_id + required: false + role: scope + + portfolio_snapshot_query: + kind: query + entity: PortfolioSnapshot + description: Composed portfolio read for one account. + parameters: + - name: account_id + value_ref: nv_account_id + required: true + role: scope + + instrument_context_query: + kind: query + entity: InstrumentContext + description: Composed contract, ticker, and book read for one symbol. + parameters: + - name: symbol + value_ref: nv_instrument_symbol + required: true + role: scope + + order_context_query: + kind: query + entity: OrderContext + description: Composed status and fills for one order. + parameters: + - name: order_id + value_ref: nv_order_id + required: true + role: scope + +views: + portfolio_snapshot: + description: Authenticated user plus balances, positions, and risk for one account. + capability: portfolio_snapshot_query + entity: PortfolioSnapshot + scope: + - name: account_id + value_ref: nv_account_id + nodes: + - id: user_row + capability: user_query + bind: {} + - id: balances + capability: balance_query + bind: + account_id: + kind: scope + param: account_id + - id: positions + capability: position_query + bind: + account_id: + kind: scope + param: account_id + - id: risk + capability: risk_snapshot_query + bind: + account_id: + kind: scope + param: account_id + output: + account_id: + kind: scope + param: account_id + username: + kind: node_field + node: user_row + field: username + equity: + kind: node_field + node: risk + field: equity + balance_count: + kind: node_row_count + node: balances + position_count: + kind: node_row_count + node: positions + relation_outputs: + - relation: user + target: User + cardinality: one + binding: + kind: node_single_row + node: user_row + - relation: balances + target: Balance + cardinality: many + binding: + kind: node_all_rows + node: balances + - relation: positions + target: Position + cardinality: many + binding: + kind: node_all_rows + node: positions + - relation: risk + target: RiskSnapshot + cardinality: one + binding: + kind: node_single_row + node: risk + + instrument_context: + description: Contract, ticker, and book for one symbol. + capability: instrument_context_query + entity: InstrumentContext + scope: + - name: symbol + value_ref: nv_instrument_symbol + nodes: + - id: instrument_row + capability: instrument_get + bind: + id: + kind: scope + param: symbol + - id: ticker_row + capability: ticker_get + bind: + id: + kind: scope + param: symbol + - id: book_row + capability: order_book_get + bind: + id: + kind: scope + param: symbol + output: + symbol: + kind: scope + param: symbol + category: + kind: node_field + node: instrument_row + field: category + mark_price: + kind: node_field + node: ticker_row + field: mark_price + instrument_state: + kind: node_field + node: ticker_row + field: instrument_state + relation_outputs: + - relation: instrument + target: Instrument + cardinality: one + binding: + kind: node_single_row + node: instrument_row + - relation: ticker + target: Ticker + cardinality: one + binding: + kind: node_single_row + node: ticker_row + - relation: book + target: OrderBook + cardinality: one + binding: + kind: node_single_row + node: book_row + + order_context: + description: Live status plus fills for one order. + capability: order_context_query + entity: OrderContext + scope: + - name: order_id + value_ref: nv_order_id + nodes: + - id: status_row + capability: order_status_query + bind: + order_id: + kind: scope + param: order_id + - id: fills + capability: fill_for_order_query + bind: + order_id: + kind: scope + param: order_id + output: + order_id: + kind: scope + param: order_id + state: + kind: node_field + node: status_row + field: state + fill_count: + kind: node_row_count + node: fills + relation_outputs: + - relation: status + target: OrderStatus + cardinality: one + binding: + kind: node_single_row + node: status_row + - relation: fills + target: Fill + cardinality: many + binding: + kind: node_all_rows + node: fills + +auth: + scheme: bearer_token + env: ARCHITECT_EXCHANGE_TOKEN + +values: + nv_wire_bool: + type: boolean + nv_wire_int: + type: integer + nv_wire_str_short: + type: string + string_semantics: short + nv_user_id: + type: string + string_semantics: short + nv_username: + type: string + string_semantics: short + description: Login name for the authenticated user. + nv_pseudonym: + type: string + string_semantics: short + nv_created_at: + type: date + value_format: rfc3339 + nv_fiat_deposit_code: + type: string + string_semantics: short + nv_account_id: + type: entity_ref + target: Account + nv_account_name: + type: string + string_semantics: short + nv_decimal_fee: + type: money + value_format: + money: decimal_string + nv_business_name: + type: string + string_semantics: short + nv_doing_business_as: + type: string + string_semantics: short + nv_api_key: + type: string + string_semantics: short + nv_api_secret: + type: string + string_semantics: short + nv_ip: + type: string + string_semantics: short + nv_ip_list: + type: array + items: + value_ref: nv_ip + nv_account_id_elem: + type: string + string_semantics: short + nv_account_id_list: + type: array + items: + value_ref: nv_account_id_elem + nv_asset_symbol: + type: string + string_semantics: short + nv_instrument_symbol: + type: entity_ref + target: Instrument + nv_decimal_amount: + type: money + value_format: + money: decimal_string + nv_signed_quantity: + type: integer + nv_decimal_notional: + type: money + value_format: + money: decimal_string + nv_decimal_pnl: + type: money + value_format: + money: decimal_string + nv_decimal_equity: + type: money + value_format: + money: decimal_string + nv_decimal_margin: + type: money + value_format: + money: decimal_string + nv_json_object: + type: string + string_semantics: json_text + nv_timestamp_ns: + type: integer + description: Unix timestamp in nanoseconds. + nv_blockchain: + type: string + string_semantics: short + nv_chain_address: + type: string + string_semantics: short + nv_product: + type: string + string_semantics: short + nv_instrument_description: + type: string + string_semantics: short + nv_instrument_category: + type: select + allowed_values: + - compute + - crypto + - energy + - energy_etfs + - equities + - fx + - metals + - treasuries + nv_currency: + type: string + string_semantics: short + nv_decimal_multiplier: + type: number + nv_price_scale: + type: integer + nv_decimal_size: + type: number + nv_decimal_price: + type: money + value_format: + money: decimal_string + nv_decimal_pct: + type: number + nv_quantity: + type: integer + nv_epoch_sec: + type: integer + nv_epoch_nano: + type: integer + nv_instrument_state: + type: select + allowed_values: + - CLOSED_FROZEN + - PRE_OPEN + - OPEN + - CLOSED + - DELISTED + - HALTED + - MATCH_AND_CLOSE_AUCTION + - UNKNOWN + nv_side: + type: select + allowed_values: + - B + - S + nv_candle_width: + type: select + allowed_values: + - 1s + - 5s + - 1m + - 5m + - 15m + - 1h + - 1d + nv_decimal_rate: + type: number + nv_estimated_funding_status: + type: select + allowed_values: + - ready + - settlement_pending + - unavailable + nv_calendar_date: + type: date + value_format: iso8601_date + nv_timezone: + type: string + string_semantics: short + nv_funding_variant: + type: select + allowed_values: + - daily_close + - intraday_twap + nv_decimal_bps: + type: number + nv_trade_id: + type: string + string_semantics: short + nv_order_id: + type: entity_ref + target: Order + nv_event_id: + type: string + string_semantics: short + nv_transaction_type: + type: select + allowed_values: + - deposit + - withdrawal + - funding + - fee + - pnl + - lending_credit + - lending_debit + nv_transaction_type_list: + type: multi_select + allowed_values: + - deposit + - withdrawal + - funding + - fee + - pnl + - lending_credit + - lending_debit + nv_settlement_kind: + type: select + allowed_values: + - funding + - mark_to_market + - final_settlement + nv_client_order_id: + type: integer + nv_order_state: + type: select + allowed_values: + - PENDING + - ACCEPTED + - PARTIALLY_FILLED + - FILLED + - CANCELED + - REJECTED + - EXPIRED + - REPLACED + - DONE_FOR_DAY + - UNKNOWN + nv_order_state_list: + type: multi_select + allowed_values: + - PENDING + - ACCEPTED + - PARTIALLY_FILLED + - FILLED + - CANCELED + - REJECTED + - EXPIRED + - REPLACED + - DONE_FOR_DAY + - UNKNOWN + nv_time_in_force: + type: string + string_semantics: short + nv_order_reject_reason: + type: select + allowed_values: + - CLOSE_ONLY + - INSUFFICIENT_MARGIN + - MAX_OPEN_ORDERS_EXCEEDED + - UNKNOWN_SYMBOL + - EXCHANGE_CLOSED + - INCORRECT_QUANTITY + - INVALID_PRICE_INCREMENT + - INCORRECT_ORDER_TYPE + - PRICE_OUT_OF_BOUNDS + - NO_LIQUIDITY + - INSUFFICIENT_CREDIT_LIMIT + - ORIGINAL_ORDER_TERMINATED + - DUPLICATE_CLIENT_ORDER_ID + - UNKNOWN + nv_order_tag: + type: string + string_semantics: short + nv_reprice_behavior: + type: select + allowed_values: + - rej + - bo + - tbl + nv_self_trade_behavior: + type: select + allowed_values: + - CancelIncoming + - CancelResting + - CancelBoth + nv_order_id_elem: + type: string + string_semantics: short + nv_order_id_list: + type: array + items: + value_ref: nv_order_id_elem + nv_sort_direction: + type: select + allowed_values: + - asc + - desc + nv_resolution_seconds: + type: integer + nv_book_level: + type: integer diff --git a/apis/architect-exchange/eval/cases.yaml b/apis/architect-exchange/eval/cases.yaml new file mode 100644 index 00000000..455c045b --- /dev/null +++ b/apis/architect-exchange/eval/cases.yaml @@ -0,0 +1,247 @@ +# Architect Exchange — task goals plus adversarial coverage. +# CGS buckets: chain, create, delete, get, invoke, multi_step, page_next, +# projection, query_all, query_filtered, reverse, update +- id: ax-01 + schema: architect-exchange + goal: Who am I and which accounts can I trade? + tags: [query_all] + covers: [query_all] + expect: + entities_any: [User, Account] + +- id: ax-02 + schema: architect-exchange + goal: Show my KYC customer profile + tags: [query_all] + covers: [query_all] + expect: + entities_any: [Customer] + +- id: ax-03 + schema: architect-exchange + goal: List my API keys, mint a new key, then revoke it + tags: [query, create, delete] + covers: [query_all, create, delete] + expect: + entities_any: [ApiKey] + +- id: ax-04 + schema: architect-exchange + goal: Update allowed IPs on an existing API key + tags: [update] + covers: [update] + expect: + entities_any: [ApiKey] + +- id: ax-05 + schema: architect-exchange + goal: Show balances, positions, and risk for one account as a portfolio snapshot + tags: [multi_step, query_filtered] + covers: [query_filtered, multi_step, reverse] + expect: + entities_any: [Balance, Position, RiskSnapshot, PortfolioSnapshot] + +- id: ax-06 + schema: architect-exchange + goal: Equity history for my account from 1710000000000000000 to 1710003600000000000 ns at 60s resolution + tags: [query_filtered] + covers: [query_filtered] + expect: + entities_any: [EquityPoint] + +- id: ax-07 + schema: architect-exchange + goal: Blockchain deposit address for USD on ethereum + tags: [query_filtered] + covers: [query_filtered] + expect: + entities_any: [DepositAddress] + +- id: ax-08 + schema: architect-exchange + goal: List every instrument, then resolve XAU-PERP by symbol with only symbol and category + tags: [query_all, get, projection] + covers: [query_all, get, projection] + expect: + entities_any: [Instrument] + +- id: ax-09 + schema: architect-exchange + goal: Load the contract, ticker, and book for XAU-PERP + tags: [get, chain] + covers: [get, chain] + expect: + entities_any: [Instrument, Ticker, OrderBook, InstrumentContext] + +- id: ax-10 + schema: architect-exchange + goal: Page through tickers then continue with the next page handle + tags: [page_next] + covers: [page_next] + expect: + entities_any: [Ticker] + +- id: ax-11 + schema: architect-exchange + goal: Recent public trades for XAU-PERP + tags: [query_filtered] + covers: [query_filtered] + expect: + entities_any: [Trade] + +- id: ax-12 + schema: architect-exchange + goal: 1h candles for XAU-PERP from 1710000000000000000 to 1710003600000000000 ns + tags: [query_filtered] + covers: [query_filtered] + expect: + entities_any: [Candle] + +- id: ax-13 + schema: architect-exchange + goal: Current BBO candle for XAU-PERP at 1m width + tags: [query_filtered] + covers: [query_filtered] + expect: + entities_any: [BboCandle] + +- id: ax-14 + schema: architect-exchange + goal: Funding rates, estimated funding, and today's funding slots for XAU-PERP + tags: [query_filtered, get] + covers: [query_filtered, get] + expect: + entities_any: [FundingRate, EstimatedFunding, FundingDay] + +- id: ax-15 + schema: architect-exchange + goal: Underlying prices and upcoming special settlements for XAU-PERP + tags: [query_filtered] + covers: [query_filtered] + expect: + entities_any: [UnderlyingPrice, SpecialSettlement] + +- id: ax-16 + schema: architect-exchange + goal: Preview sweeping the XAU-PERP book with an aggressive buy of 10 + tags: [invoke] + covers: [invoke] + expect: + entities_any: [AggressiveLimitPreview] + +- id: ax-17 + schema: architect-exchange + goal: Place a GTC buy of 1 XAU-PERP at 2400.5 + tags: [create] + covers: [create] + expect: + entities_any: [Order] + +- id: ax-18 + schema: architect-exchange + goal: Preview that order's margin impact without placing it, then quote initial margin + tags: [invoke] + covers: [invoke] + expect: + entities_any: [OrderPreview, InitialMarginQuote] + +- id: ax-19 + schema: architect-exchange + goal: List open orders, replace one price, then cancel all XAU-PERP working orders + tags: [query_filtered, update, invoke] + covers: [query_filtered, update, invoke] + expect: + entities_any: [Order] + +- id: ax-20 + schema: architect-exchange + goal: Status and fills for order O-01HXYZ + tags: [query_filtered, chain] + covers: [query_filtered, chain, reverse] + expect: + entities_any: [OrderStatus, Fill, OrderContext] + +- id: ax-21 + schema: architect-exchange + goal: Historical fills for my account from 1710000000000000000 to 1710604800000000000 ns + tags: [query_filtered] + covers: [query_filtered] + expect: + entities_any: [Fill] + +- id: ax-22 + schema: architect-exchange + goal: Ledger deposits and withdrawals in that same nanosecond window + tags: [query_filtered] + covers: [query_filtered] + expect: + entities_any: [Transaction] + +- id: ax-23 + schema: architect-exchange + goal: Funding transactions and liquidations in that window + tags: [query_filtered] + covers: [query_filtered] + expect: + entities_any: [FundingTransaction, Liquidation] + +- id: ax-24 + schema: architect-exchange + goal: My traded volume over that window + tags: [query_filtered] + covers: [query_filtered] + expect: + entities_any: [VolumeStat] + +- id: ax-25 + schema: architect-exchange + goal: Sandbox deposit 1000 USD into my test account + tags: [invoke] + covers: [invoke] + expect: + entities_any: [Balance] + +- id: ax-adv-01 + schema: architect-exchange + goal: Get the user by id usr_123 + tags: [adversarial] + covers: [query_all] + expect: + entities_any: [User] + notes: User is singleton whoami only; there is no get-by-id. + +- id: ax-adv-02 + schema: architect-exchange + goal: List fills with no time range + tags: [adversarial] + covers: [query_filtered] + expect: + entities_any: [Fill] + notes: Fills require start_timestamp_ns and end_timestamp_ns (max seven days). + +- id: ax-adv-03 + schema: architect-exchange + goal: Use index prices instead of underlying prices for XAU-PERP + tags: [adversarial] + covers: [query_filtered] + expect: + entities_any: [UnderlyingPrice] + notes: Deprecated index prices are out of catalog. + +- id: ax-adv-04 + schema: architect-exchange + goal: Authenticate with my api_key and api_secret inside the program + tags: [adversarial] + covers: [query_all] + expect: + entities_any: [User] + notes: Token minting is not a capability. + +- id: ax-adv-05 + schema: architect-exchange + goal: Treat BBO mid candles as regular OHLCV candles + tags: [adversarial] + covers: [query_filtered] + expect: + entities_any: [BboCandle] + notes: BboCandle is a distinct shape with mid fields. diff --git a/apis/architect-exchange/mappings.yaml b/apis/architect-exchange/mappings.yaml new file mode 100644 index 00000000..5734ccad --- /dev/null +++ b/apis/architect-exchange/mappings.yaml @@ -0,0 +1,1169 @@ +# Architect Exchange — dual gateway on https://gateway.architect.exchange +# User/portfolio/marketdata → /api/... +# Order lifecycle → /orders/... +# Auth: Bearer ARCHITECT_EXCHANGE_TOKEN (mint via POST /api/authenticate; not modeled). + +user_query: + method: GET + path: + - { type: literal, value: api } + - { type: literal, value: whoami } + response: single + +account_query: + method: GET + path: + - { type: literal, value: api } + - { type: literal, value: whoami } + response: + items: accounts + +customer_query: + method: GET + path: + - { type: literal, value: api } + - { type: literal, value: whoami } + - { type: literal, value: customer } + response: single + +api_key_query: + method: GET + path: + - { type: literal, value: api } + - { type: literal, value: api-keys } + response: + items: api_keys + +api_key_create: + method: POST + path: + - { type: literal, value: api } + - { type: literal, value: api-keys } + body: + type: object + fields: + - - account_ids + - type: if + condition: { type: exists, var: account_ids } + then_expr: { type: var, name: account_ids } + else_expr: { type: const, value: null } + - - allowed_ips + - type: if + condition: { type: exists, var: allowed_ips } + then_expr: { type: var, name: allowed_ips } + else_expr: { type: const, value: null } + - - permissions + - type: object + fields: + - - can_list + - type: if + condition: { type: exists, var: can_list } + then_expr: { type: var, name: can_list } + else_expr: { type: const, value: null } + - - can_read + - type: if + condition: { type: exists, var: can_read } + then_expr: { type: var, name: can_read } + else_expr: { type: const, value: null } + - - can_set_limits + - type: if + condition: { type: exists, var: can_set_limits } + then_expr: { type: var, name: can_set_limits } + else_expr: { type: const, value: null } + - - can_reduce_or_close + - type: if + condition: { type: exists, var: can_reduce_or_close } + then_expr: { type: var, name: can_reduce_or_close } + else_expr: { type: const, value: null } + - - can_trade + - type: if + condition: { type: exists, var: can_trade } + then_expr: { type: var, name: can_trade } + else_expr: { type: const, value: null } + response: single + +api_key_update: + method: PATCH + path: + - { type: literal, value: api } + - { type: literal, value: api-keys } + - { type: literal, value: allowed-ips } + body: + type: object + fields: + - - api_key + - type: var + name: id + - - allowed_ips + - type: var + name: allowed_ips + +api_key_delete: + method: DELETE + path: + - { type: literal, value: api } + - { type: literal, value: api-keys } + query: + type: object + fields: + - - api_key + - type: var + name: api_key + +balance_query: + method: GET + path: + - { type: literal, value: api } + - { type: literal, value: balances } + query: + type: object + fields: + - - account_id + - type: if + condition: { type: exists, var: account_id } + then_expr: { type: var, name: account_id } + else_expr: { type: const, value: null } + response: + items: balances + +position_query: + method: GET + path: + - { type: literal, value: api } + - { type: literal, value: positions } + query: + type: object + fields: + - - account_id + - type: if + condition: { type: exists, var: account_id } + then_expr: { type: var, name: account_id } + else_expr: { type: const, value: null } + response: + items: positions + +risk_snapshot_query: + method: GET + path: + - { type: literal, value: api } + - { type: literal, value: risk-snapshot } + query: + type: object + fields: + - - account_id + - type: if + condition: { type: exists, var: account_id } + then_expr: { type: var, name: account_id } + else_expr: { type: const, value: null } + response: + single: true + items_path: [risk_snapshot] + +equity_point_query: + method: GET + path: + - { type: literal, value: api } + - { type: literal, value: account-equity-history } + query: + type: object + fields: + - - account_id + - type: if + condition: { type: exists, var: account_id } + then_expr: { type: var, name: account_id } + else_expr: { type: const, value: null } + - - start_timestamp_ns + - type: var + name: start_timestamp_ns + - - end_timestamp_ns + - type: var + name: end_timestamp_ns + - - resolution_seconds + - type: var + name: resolution_seconds + response: + items: data_points + +deposit_address_query: + method: GET + path: + - { type: literal, value: api } + - { type: literal, value: blockchain-deposit-address } + query: + type: object + fields: + - - account_id + - type: if + condition: { type: exists, var: account_id } + then_expr: { type: var, name: account_id } + else_expr: { type: const, value: null } + - - blockchain + - type: var + name: blockchain + - - asset + - type: var + name: asset + response: single + +sandbox_deposit: + method: POST + path: + - { type: literal, value: api } + - { type: literal, value: sandbox } + - { type: literal, value: deposit } + body: + type: object + fields: + - - symbol + - type: var + name: symbol + - - amount + - type: var + name: amount + - - account_id + - type: if + condition: { type: exists, var: account_id } + then_expr: { type: var, name: account_id } + else_expr: { type: const, value: null } + +sandbox_withdraw: + method: POST + path: + - { type: literal, value: api } + - { type: literal, value: sandbox } + - { type: literal, value: withdraw } + body: + type: object + fields: + - - symbol + - type: var + name: symbol + - - amount + - type: var + name: amount + - - account_id + - type: if + condition: { type: exists, var: account_id } + then_expr: { type: var, name: account_id } + else_expr: { type: const, value: null } + +instrument_query: + method: GET + path: + - { type: literal, value: api } + - { type: literal, value: instruments } + response: + items: instruments + +instrument_get: + method: GET + path: + - { type: literal, value: api } + - { type: literal, value: instrument } + query: + type: object + fields: + - - symbol + - type: var + name: id + response: single + +ticker_query: + method: GET + path: + - { type: literal, value: api } + - { type: literal, value: tickers } + query: + type: object + fields: + - - sort + - type: if + condition: { type: exists, var: sort } + then_expr: { type: var, name: sort } + else_expr: { type: const, value: null } + response: + items: tickers + pagination: + location: query + params: + offset: { counter: 0, step: 100 } + limit: { fixed: 100 } + +ticker_for_symbol_query: + method: GET + path: + - { type: literal, value: api } + - { type: literal, value: ticker } + query: + type: object + fields: + - - symbol + - type: var + name: symbol + response: + single: true + items_path: [ticker] + +ticker_get: + method: GET + path: + - { type: literal, value: api } + - { type: literal, value: ticker } + query: + type: object + fields: + - - symbol + - type: var + name: id + response: + single: true + items_path: [ticker] + +order_book_for_symbol_query: + method: GET + path: + - { type: literal, value: api } + - { type: literal, value: book } + query: + type: object + fields: + - - symbol + - type: var + name: symbol + - - level + - type: if + condition: { type: exists, var: level } + then_expr: { type: var, name: level } + else_expr: { type: const, value: null } + response: + single: true + items_path: [book] + +order_book_get: + method: GET + path: + - { type: literal, value: api } + - { type: literal, value: book } + query: + type: object + fields: + - - symbol + - type: var + name: id + - - level + - type: if + condition: { type: exists, var: level } + then_expr: { type: var, name: level } + else_expr: { type: const, value: null } + response: + single: true + items_path: [book] + +trade_query: + method: GET + path: + - { type: literal, value: api } + - { type: literal, value: trades } + query: + type: object + fields: + - - symbol + - type: var + name: symbol + response: + items: trades + pagination: + location: query + params: + limit: { fixed: 100 } + +candle_query: + method: GET + path: + - { type: literal, value: api } + - { type: literal, value: candles } + query: + type: object + fields: + - - symbol + - type: var + name: symbol + - - start_timestamp_ns + - type: var + name: start_timestamp_ns + - - end_timestamp_ns + - type: var + name: end_timestamp_ns + - - candle_width + - type: var + name: candle_width + response: + items: candles + +candle_current_query: + method: GET + path: + - { type: literal, value: api } + - { type: literal, value: candles } + - { type: literal, value: current } + query: + type: object + fields: + - - symbol + - type: var + name: symbol + - - candle_width + - type: var + name: candle_width + response: + single: true + items_path: [candle] + +candle_last_query: + method: GET + path: + - { type: literal, value: api } + - { type: literal, value: candles } + - { type: literal, value: last } + query: + type: object + fields: + - - symbol + - type: var + name: symbol + - - candle_width + - type: var + name: candle_width + response: + single: true + items_path: [candle] + +bbo_candle_query: + method: GET + path: + - { type: literal, value: api } + - { type: literal, value: bbo-candles } + query: + type: object + fields: + - - symbol + - type: var + name: symbol + - - start_timestamp_ns + - type: var + name: start_timestamp_ns + - - end_timestamp_ns + - type: var + name: end_timestamp_ns + - - candle_width + - type: var + name: candle_width + response: + items: candles + +bbo_candle_current_query: + method: GET + path: + - { type: literal, value: api } + - { type: literal, value: bbo-candles } + - { type: literal, value: current } + query: + type: object + fields: + - - symbol + - type: var + name: symbol + - - candle_width + - type: var + name: candle_width + response: + single: true + items_path: [candle] + +bbo_candle_last_query: + method: GET + path: + - { type: literal, value: api } + - { type: literal, value: bbo-candles } + - { type: literal, value: last } + query: + type: object + fields: + - - symbol + - type: var + name: symbol + - - candle_width + - type: var + name: candle_width + response: + single: true + items_path: [candle] + +funding_rate_query: + method: GET + path: + - { type: literal, value: api } + - { type: literal, value: funding-rates } + query: + type: object + fields: + - - symbol + - type: var + name: symbol + - - start_timestamp_ns + - type: if + condition: { type: exists, var: start_timestamp_ns } + then_expr: { type: var, name: start_timestamp_ns } + else_expr: { type: const, value: null } + - - end_timestamp_ns + - type: if + condition: { type: exists, var: end_timestamp_ns } + then_expr: { type: var, name: end_timestamp_ns } + else_expr: { type: const, value: null } + - - sort_ts + - type: if + condition: { type: exists, var: sort_ts } + then_expr: { type: var, name: sort_ts } + else_expr: { type: const, value: null } + response: + items: funding_rates + pagination: + location: query + params: + cursor: { from_response: next_cursor } + limit: { fixed: 1000 } + +estimated_funding_get: + method: GET + path: + - { type: literal, value: api } + - { type: literal, value: estimated-funding-rate } + query: + type: object + fields: + - - symbol + - type: var + name: id + response: single + +funding_day_query: + method: GET + path: + - { type: literal, value: api } + - { type: literal, value: funding-slots } + query: + type: object + fields: + - - symbol + - type: var + name: symbol + - - date + - type: if + condition: { type: exists, var: date } + then_expr: { type: var, name: date } + else_expr: { type: const, value: null } + response: single + +underlying_price_query: + method: GET + path: + - { type: literal, value: api } + - { type: literal, value: underlying-prices } + query: + type: object + fields: + - - symbol + - type: var + name: symbol + - - start_timestamp_ns + - type: if + condition: { type: exists, var: start_timestamp_ns } + then_expr: { type: var, name: start_timestamp_ns } + else_expr: { type: const, value: null } + - - end_timestamp_ns + - type: if + condition: { type: exists, var: end_timestamp_ns } + then_expr: { type: var, name: end_timestamp_ns } + else_expr: { type: const, value: null } + - - sort_ts + - type: if + condition: { type: exists, var: sort_ts } + then_expr: { type: var, name: sort_ts } + else_expr: { type: const, value: null } + response: + items: underlying_prices + pagination: + location: query + params: + cursor: { from_response: next_cursor } + limit: { fixed: 1000 } + +special_settlement_query: + method: GET + path: + - { type: literal, value: api } + - { type: literal, value: special-settlements-pending } + query: + type: object + fields: + - - days + - type: if + condition: { type: exists, var: days } + then_expr: { type: var, name: days } + else_expr: { type: const, value: null } + response: + items: special_settlements + +aggressive_limit_preview: + method: POST + path: + - { type: literal, value: api } + - { type: literal, value: preview-aggressive-limit-order } + body: + type: object + fields: + - - symbol + - type: var + name: symbol + - - quantity + - type: var + name: quantity + - - side + - type: var + name: side + response: single + +order_open_query: + method: GET + path: + - { type: literal, value: orders } + - { type: literal, value: open-orders } + query: + type: object + fields: + - - account_id + - type: if + condition: { type: exists, var: account_id } + then_expr: { type: var, name: account_id } + else_expr: { type: const, value: null } + - - sort_ts + - type: if + condition: { type: exists, var: sort_ts } + then_expr: { type: var, name: sort_ts } + else_expr: { type: const, value: null } + response: + items: orders + pagination: + location: query + params: + offset: { counter: 0, step: 100 } + limit: { fixed: 100 } + +order_history_query: + method: GET + path: + - { type: literal, value: orders } + - { type: literal, value: orders } + query: + type: object + fields: + - - symbol + - type: if + condition: { type: exists, var: symbol } + then_expr: { type: var, name: symbol } + else_expr: { type: const, value: null } + - - start_timestamp_ns + - type: var + name: start_timestamp_ns + - - end_timestamp_ns + - type: var + name: end_timestamp_ns + - - sort_ts + - type: if + condition: { type: exists, var: sort_ts } + then_expr: { type: var, name: sort_ts } + else_expr: { type: const, value: null } + - - order_states + - type: if + condition: { type: exists, var: order_states } + then_expr: { type: join, sep: ",", expr: { type: var, name: order_states } } + else_expr: { type: const, value: null } + - - order_id + - type: if + condition: { type: exists, var: order_id } + then_expr: { type: var, name: order_id } + else_expr: { type: const, value: null } + - - order_ids + - type: if + condition: { type: exists, var: order_ids } + then_expr: { type: join, sep: ",", expr: { type: var, name: order_ids } } + else_expr: { type: const, value: null } + - - account_id + - type: if + condition: { type: exists, var: account_id } + then_expr: { type: var, name: account_id } + else_expr: { type: const, value: null } + response: + items: orders + pagination: + location: query + params: + cursor: { from_response: next_cursor } + limit: { fixed: 100 } + +order_status_query: + method: GET + path: + - { type: literal, value: orders } + - { type: literal, value: order-status } + query: + type: object + fields: + - - oid + - type: if + condition: { type: exists, var: order_id } + then_expr: { type: var, name: order_id } + else_expr: { type: const, value: null } + - - cid + - type: if + condition: { type: exists, var: client_order_id } + then_expr: { type: var, name: client_order_id } + else_expr: { type: const, value: null } + - - aid + - type: if + condition: { type: exists, var: account_id } + then_expr: { type: var, name: account_id } + else_expr: { type: const, value: null } + response: + single: true + items_path: [status] + +order_create: + method: POST + path: + - { type: literal, value: orders } + - { type: literal, value: place-order } + body: + type: object + fields: + - - s + - type: var + name: symbol + - - d + - type: var + name: side + - - q + - type: var + name: quantity + - - p + - type: var + name: price + - - tif + - type: var + name: time_in_force + - - aid + - type: if + condition: { type: exists, var: account_id } + then_expr: { type: var, name: account_id } + else_expr: { type: const, value: null } + - - cid + - type: if + condition: { type: exists, var: client_order_id } + then_expr: { type: var, name: client_order_id } + else_expr: { type: const, value: null } + - - po + - type: if + condition: { type: exists, var: post_only } + then_expr: { type: var, name: post_only } + else_expr: { type: const, value: null } + - - rb + - type: if + condition: { type: exists, var: reprice_behavior } + then_expr: { type: var, name: reprice_behavior } + else_expr: { type: const, value: null } + - - st + - type: if + condition: { type: exists, var: self_trade_behavior } + then_expr: { type: var, name: self_trade_behavior } + else_expr: { type: const, value: null } + - - tag + - type: if + condition: { type: exists, var: tag } + then_expr: { type: var, name: tag } + else_expr: { type: const, value: null } + response: single + +order_update: + method: POST + path: + - { type: literal, value: orders } + - { type: literal, value: replace-order } + body: + type: object + fields: + - - oid + - type: var + name: id + - - cid + - type: if + condition: { type: exists, var: client_order_id } + then_expr: { type: var, name: client_order_id } + else_expr: { type: const, value: null } + - - aid + - type: if + condition: { type: exists, var: account_id } + then_expr: { type: var, name: account_id } + else_expr: { type: const, value: null } + - - p + - type: if + condition: { type: exists, var: price } + then_expr: { type: var, name: price } + else_expr: { type: const, value: null } + - - q + - type: if + condition: { type: exists, var: quantity } + then_expr: { type: var, name: quantity } + else_expr: { type: const, value: null } + - - tif + - type: if + condition: { type: exists, var: time_in_force } + then_expr: { type: var, name: time_in_force } + else_expr: { type: const, value: null } + - - po + - type: if + condition: { type: exists, var: post_only } + then_expr: { type: var, name: post_only } + else_expr: { type: const, value: null } + - - rb + - type: if + condition: { type: exists, var: reprice_behavior } + then_expr: { type: var, name: reprice_behavior } + else_expr: { type: const, value: null } + response: single + +order_cancel: + method: POST + path: + - { type: literal, value: orders } + - { type: literal, value: cancel-order } + body: + type: object + fields: + - - oid + - type: var + name: id + - - cid + - type: if + condition: { type: exists, var: client_order_id } + then_expr: { type: var, name: client_order_id } + else_expr: { type: const, value: null } + - - aid + - type: if + condition: { type: exists, var: account_id } + then_expr: { type: var, name: account_id } + else_expr: { type: const, value: null } + +order_cancel_all: + method: POST + path: + - { type: literal, value: orders } + - { type: literal, value: cancel-all-orders } + body: + type: object + fields: + - - account_id + - type: if + condition: { type: exists, var: account_id } + then_expr: { type: var, name: account_id } + else_expr: { type: const, value: null } + - - symbol + - type: if + condition: { type: exists, var: symbol } + then_expr: { type: var, name: symbol } + else_expr: { type: const, value: null } + +order_preview: + method: POST + path: + - { type: literal, value: orders } + - { type: literal, value: preview-order } + body: + type: object + fields: + - - s + - type: var + name: symbol + - - d + - type: var + name: side + - - q + - type: var + name: quantity + - - p + - type: var + name: price + - - tif + - type: var + name: time_in_force + - - aid + - type: if + condition: { type: exists, var: account_id } + then_expr: { type: var, name: account_id } + else_expr: { type: const, value: null } + - - cid + - type: if + condition: { type: exists, var: client_order_id } + then_expr: { type: var, name: client_order_id } + else_expr: { type: const, value: null } + - - po + - type: if + condition: { type: exists, var: post_only } + then_expr: { type: var, name: post_only } + else_expr: { type: const, value: null } + - - rb + - type: if + condition: { type: exists, var: reprice_behavior } + then_expr: { type: var, name: reprice_behavior } + else_expr: { type: const, value: null } + - - st + - type: if + condition: { type: exists, var: self_trade_behavior } + then_expr: { type: var, name: self_trade_behavior } + else_expr: { type: const, value: null } + - - tag + - type: if + condition: { type: exists, var: tag } + then_expr: { type: var, name: tag } + else_expr: { type: const, value: null } + response: single + +initial_margin_quote: + method: POST + path: + - { type: literal, value: orders } + - { type: literal, value: initial-margin-requirement } + body: + type: object + fields: + - - s + - type: var + name: symbol + - - d + - type: var + name: side + - - q + - type: var + name: quantity + - - p + - type: var + name: price + - - tif + - type: var + name: time_in_force + - - aid + - type: if + condition: { type: exists, var: account_id } + then_expr: { type: var, name: account_id } + else_expr: { type: const, value: null } + response: single + +fill_query: + method: GET + path: + - { type: literal, value: api } + - { type: literal, value: fills } + query: + type: object + fields: + - - account_id + - type: if + condition: { type: exists, var: account_id } + then_expr: { type: var, name: account_id } + else_expr: { type: const, value: null } + - - symbol + - type: if + condition: { type: exists, var: symbol } + then_expr: { type: var, name: symbol } + else_expr: { type: const, value: null } + - - start_timestamp_ns + - type: var + name: start_timestamp_ns + - - end_timestamp_ns + - type: var + name: end_timestamp_ns + - - sort_ts + - type: if + condition: { type: exists, var: sort_ts } + then_expr: { type: var, name: sort_ts } + else_expr: { type: const, value: null } + response: + items: fills + pagination: + location: query + params: + cursor: { from_response: next_cursor } + limit: { fixed: 100 } + +fill_for_order_query: + method: GET + path: + - { type: literal, value: orders } + - { type: literal, value: order-fills } + query: + type: object + fields: + - - order_id + - type: var + name: order_id + - - aid + - type: if + condition: { type: exists, var: account_id } + then_expr: { type: var, name: account_id } + else_expr: { type: const, value: null } + response: + items: fills + +transaction_query: + method: GET + path: + - { type: literal, value: api } + - { type: literal, value: transactions } + query: + type: object + fields: + - - account_id + - type: if + condition: { type: exists, var: account_id } + then_expr: { type: var, name: account_id } + else_expr: { type: const, value: null } + - - transaction_types + - type: join + sep: "," + expr: { type: var, name: transaction_types } + - - start_timestamp_ns + - type: var + name: start_timestamp_ns + - - end_timestamp_ns + - type: var + name: end_timestamp_ns + - - sort_ts + - type: if + condition: { type: exists, var: sort_ts } + then_expr: { type: var, name: sort_ts } + else_expr: { type: const, value: null } + response: + items: transactions + pagination: + location: query + params: + cursor: { from_response: next_cursor } + limit: { fixed: 100 } + +funding_transaction_query: + method: GET + path: + - { type: literal, value: api } + - { type: literal, value: funding-transactions } + query: + type: object + fields: + - - account_id + - type: if + condition: { type: exists, var: account_id } + then_expr: { type: var, name: account_id } + else_expr: { type: const, value: null } + - - symbol + - type: if + condition: { type: exists, var: symbol } + then_expr: { type: var, name: symbol } + else_expr: { type: const, value: null } + - - start_timestamp_ns + - type: var + name: start_timestamp_ns + - - end_timestamp_ns + - type: var + name: end_timestamp_ns + - - sort_ts + - type: if + condition: { type: exists, var: sort_ts } + then_expr: { type: var, name: sort_ts } + else_expr: { type: const, value: null } + response: + items: funding_transactions + pagination: + location: query + params: + cursor: { from_response: next_cursor } + limit: { fixed: 100 } + +liquidation_query: + method: GET + path: + - { type: literal, value: api } + - { type: literal, value: liquidations } + query: + type: object + fields: + - - account_id + - type: if + condition: { type: exists, var: account_id } + then_expr: { type: var, name: account_id } + else_expr: { type: const, value: null } + - - symbol + - type: if + condition: { type: exists, var: symbol } + then_expr: { type: var, name: symbol } + else_expr: { type: const, value: null } + - - start_timestamp_ns + - type: var + name: start_timestamp_ns + - - end_timestamp_ns + - type: var + name: end_timestamp_ns + - - sort_ts + - type: if + condition: { type: exists, var: sort_ts } + then_expr: { type: var, name: sort_ts } + else_expr: { type: const, value: null } + response: + items: fills + pagination: + location: query + params: + cursor: { from_response: next_cursor } + limit: { fixed: 100 } + +volume_stat_query: + method: GET + path: + - { type: literal, value: api } + - { type: literal, value: user } + - { type: literal, value: stats } + - { type: literal, value: volume } + query: + type: object + fields: + - - start_timestamp_ns + - type: var + name: start_timestamp_ns + - - end_timestamp_ns + - type: var + name: end_timestamp_ns + - - account_id + - type: if + condition: { type: exists, var: account_id } + then_expr: { type: var, name: account_id } + else_expr: { type: const, value: null } + response: single + +portfolio_snapshot_query: + transport: view + view: portfolio_snapshot + +instrument_context_query: + transport: view + view: instrument_context + +order_context_query: + transport: view + view: order_context diff --git a/apis/architect-exchange/openapi-api-gateway.json b/apis/architect-exchange/openapi-api-gateway.json new file mode 100644 index 00000000..ad2647e6 --- /dev/null +++ b/apis/architect-exchange/openapi-api-gateway.json @@ -0,0 +1,4838 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "ax-api-gateway", + "description": "", + "license": { + "name": "" + }, + "version": "15.24.0" + }, + "servers": [ + { + "url": "https://gateway.architect.exchange/api" + } + ], + "paths": { + "/account-equity-history": { + "get": { + "tags": [ + "api-gateway" + ], + "operationId": "get_equity_history", + "parameters": [ + { + "name": "account_id", + "in": "query", + "description": "Optional account ID. If omitted, default (primary) user account is used.", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "start_timestamp_ns", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "end_timestamp_ns", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "resolution_seconds", + "in": "query", + "description": "Desired duration between returned points.", + "required": true, + "schema": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "Equity history", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetAccountEquityHistoryResponse" + } + } + } + } + }, + "security": [ + { + "session_token": [] + } + ] + } + }, + "/api-keys": { + "get": { + "tags": [ + "api-gateway" + ], + "operationId": "get_api_keys", + "responses": { + "200": { + "description": "List of API keys", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetApiKeysResponse" + } + } + } + } + }, + "security": [ + { + "session_token": [] + } + ] + }, + "post": { + "tags": [ + "api-gateway" + ], + "operationId": "create_api_key", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateApiKeyRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "API key created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateApiKeyResponse" + } + } + } + }, + "401": { + "description": "Invalid credentials" + } + }, + "security": [ + { + "session_token": [] + } + ] + }, + "delete": { + "tags": [ + "api-gateway" + ], + "operationId": "revoke_api_key", + "parameters": [ + { + "name": "api_key", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "API key revoked successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RevokeApiKeyResponse" + } + } + } + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "session_token": [] + } + ] + } + }, + "/api-keys/allowed-ips": { + "patch": { + "tags": [ + "api-gateway" + ], + "operationId": "update_api_key_allowed_ips", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateApiKeyAllowedIpsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "API key allowed IPs updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateApiKeyAllowedIpsResponse" + } + } + } + }, + "400": { + "description": "Invalid request" + }, + "401": { + "description": "Invalid password" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "session_token": [] + } + ] + } + }, + "/authenticate": { + "post": { + "tags": [ + "api-gateway" + ], + "operationId": "get_user_token", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthenticateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Token generated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthenticateResponse" + } + } + } + }, + "400": { + "description": "2FA required but not provided" + }, + "401": { + "description": "Unauthorized" + } + } + } + }, + "/balances": { + "get": { + "tags": [ + "api-gateway" + ], + "operationId": "get_balances", + "parameters": [ + { + "name": "account_id", + "in": "query", + "description": "Optional account ID. If omitted, default (primary) user account is used.", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + } + ], + "responses": { + "200": { + "description": "List of balances", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetBalancesResponse" + } + } + } + } + }, + "security": [ + { + "session_token": [] + } + ] + } + }, + "/bbo-candles": { + "get": { + "tags": [ + "api-gateway" + ], + "operationId": "get_bbo_candles", + "parameters": [ + { + "name": "symbol", + "in": "query", + "description": "Instrument symbol (e.g. \"XAU-PERP\")", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "start_timestamp_ns", + "in": "query", + "description": "Start of the time range (nanoseconds since epoch, inclusive)", + "required": true, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "end_timestamp_ns", + "in": "query", + "description": "End of the time range (nanoseconds since epoch, inclusive)", + "required": true, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "candle_width", + "in": "query", + "description": "Candle width (e.g. \"1s\", \"1m\", \"1h\", \"1d\")", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of BBO candles", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetBboCandlesResponse" + } + } + } + } + }, + "security": [ + { + "session_token": [] + } + ] + } + }, + "/bbo-candles/current": { + "get": { + "tags": [ + "api-gateway" + ], + "operationId": "get_current_bbo_candle", + "parameters": [ + { + "name": "symbol", + "in": "query", + "description": "Instrument symbol (e.g. \"XAU-PERP\")", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "candle_width", + "in": "query", + "description": "Candle width (e.g. \"1s\", \"1m\", \"1h\", \"1d\")", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Current BBO candle", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetBboCandleResponse" + } + } + } + } + }, + "security": [ + { + "session_token": [] + } + ] + } + }, + "/bbo-candles/last": { + "get": { + "tags": [ + "api-gateway" + ], + "operationId": "get_last_bbo_candle", + "parameters": [ + { + "name": "symbol", + "in": "query", + "description": "Instrument symbol (e.g. \"XAU-PERP\")", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "candle_width", + "in": "query", + "description": "Candle width (e.g. \"1s\", \"1m\", \"1h\", \"1d\")", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Latest BBO candle", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetBboCandleResponse" + } + } + } + } + }, + "security": [ + { + "session_token": [] + } + ] + } + }, + "/blockchain-deposit-address": { + "get": { + "tags": [ + "api-gateway" + ], + "operationId": "get_blockchain_deposit_address_route", + "parameters": [ + { + "name": "account_id", + "in": "query", + "description": "Optional account ID. If set, returns the address for this account;\notherwise falls back to the caller's default account.", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "blockchain", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "asset", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Blockchain deposit address", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetBlockchainDepositAddressResponse" + } + } + } + }, + "404": { + "description": "Address not found" + } + }, + "security": [ + { + "session_token": [] + } + ] + } + }, + "/book": { + "get": { + "tags": [ + "api-gateway" + ], + "operationId": "get_book", + "parameters": [ + { + "name": "symbol", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "level", + "in": "query", + "description": "Orderbook depth level (2 or 3). Defaults to 2 if not specified.\n- 2: Returns aggregated quantity per price level\n- 3: Returns individual order quantities per price level", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "Orderbook snapshot for a symbol. Level 3 includes individual order quantities; Level 2 returns only aggregated quantities.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetBookResponse" + } + } + } + }, + "400": { + "description": "Invalid level parameter" + }, + "404": { + "description": "Symbol not found" + }, + "503": { + "description": "MdPub not available" + } + }, + "security": [ + { + "session_token": [] + } + ] + } + }, + "/candles": { + "get": { + "tags": [ + "api-gateway" + ], + "operationId": "get_candles", + "parameters": [ + { + "name": "symbol", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "start_timestamp_ns", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "end_timestamp_ns", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "candle_width", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of candles", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetCandlesResponse" + } + } + } + } + }, + "security": [ + { + "session_token": [] + } + ] + } + }, + "/candles/current": { + "get": { + "tags": [ + "api-gateway" + ], + "operationId": "get_current_candle", + "parameters": [ + { + "name": "symbol", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "candle_width", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Current candle", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetCandleResponse" + } + } + } + } + }, + "security": [ + { + "session_token": [] + } + ] + } + }, + "/candles/last": { + "get": { + "tags": [ + "api-gateway" + ], + "operationId": "get_last_candle", + "parameters": [ + { + "name": "symbol", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "candle_width", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Latest candle", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetCandleResponse" + } + } + } + } + }, + "security": [ + { + "session_token": [] + } + ] + } + }, + "/estimated-funding-rate": { + "get": { + "tags": [ + "api-gateway" + ], + "operationId": "get_estimated_funding_rate", + "parameters": [ + { + "name": "symbol", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Estimated intraday funding rate; `status` is `unavailable` when no estimate is computed yet", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetEstimatedFundingRateResponse" + } + } + } + }, + "404": { + "description": "Unknown symbol, or the symbol has no configured index source", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_token": [] + } + ] + } + }, + "/fills": { + "get": { + "tags": [ + "api-gateway" + ], + "description": "Returns historical fills for the authenticated user. Requires an explicit time range: both `start_timestamp_ns` and `end_timestamp_ns` must be provided and span no more than 7 days. Missing, inverted, or wider ranges are rejected with a 400.", + "operationId": "get_fills", + "parameters": [ + { + "name": "account_id", + "in": "query", + "description": "Optional account ID. If omitted, default (primary) user account is used.", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "symbol", + "in": "query", + "description": "Optional symbol filter. If provided, only fills for this symbol will be returned.", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "end_timestamp_ns", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + } + }, + { + "name": "start_timestamp_ns", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "minimum": 0 + } + }, + { + "name": "sort_ts", + "in": "query", + "required": false, + "schema": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/SortDirection", + "description": "Timestamp sort direction (defaults to `desc`)." + } + ] + } + } + ], + "responses": { + "200": { + "description": "List of fills", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetFillsResponse" + } + } + } + }, + "400": { + "description": "Time range missing or wider than the 7-day maximum", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_token": [] + } + ] + } + }, + "/funding-rates": { + "get": { + "tags": [ + "api-gateway" + ], + "operationId": "get_funding_rates", + "parameters": [ + { + "name": "symbol", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "end_timestamp_ns", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + } + }, + { + "name": "start_timestamp_ns", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "minimum": 0 + } + }, + { + "name": "sort_ts", + "in": "query", + "required": false, + "schema": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/SortDirection", + "description": "Timestamp sort direction (defaults to `desc`)." + } + ] + } + } + ], + "responses": { + "200": { + "description": "List of funding rates", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetFundingRatesResponse" + } + } + } + } + }, + "security": [ + { + "session_token": [] + } + ] + } + }, + "/funding-slots": { + "get": { + "tags": [ + "api-gateway" + ], + "description": "Returns a full trading day of funding slots for a symbol — realized, projected, skipped, and pending — with running realized and projected end-of-day totals. Symbols funded once daily report a single slot.", + "operationId": "get_funding_slots", + "parameters": [ + { + "name": "symbol", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "date", + "in": "query", + "description": "Trading date, interpreted in the symbol's funding schedule timezone.\nDefaults to the current date there.", + "required": false, + "schema": { + "type": [ + "string", + "null" + ], + "format": "date" + } + } + ], + "responses": { + "200": { + "description": "The day's funding slots with rollups", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetFundingSlotsResponse" + } + } + } + }, + "404": { + "description": "Unknown symbol, or the symbol has no funding schedule", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_token": [] + } + ] + } + }, + "/funding-transactions": { + "get": { + "tags": [ + "api-gateway" + ], + "description": "Returns historical funding transactions for the authenticated user, newest first by default, paged via the response cursor. The time range is optional and unbounded; an inverted range (`end_timestamp_ns` <= `start_timestamp_ns`) is rejected with a 400. A query too expensive to serve is also rejected with a 400 asking the caller to narrow the range. Pages may be returned partial.", + "operationId": "get_funding_transactions", + "parameters": [ + { + "name": "account_id", + "in": "query", + "description": "Optional account ID. If omitted, default (primary) user account is used.", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "symbol", + "in": "query", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "end_timestamp_ns", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + } + }, + { + "name": "start_timestamp_ns", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "minimum": 0 + } + }, + { + "name": "sort_ts", + "in": "query", + "required": false, + "schema": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/SortDirection", + "description": "Timestamp sort direction (defaults to `desc`)." + } + ] + } + } + ], + "responses": { + "200": { + "description": "List of funding transactions", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetFundingTransactionsResponse" + } + } + } + }, + "400": { + "description": "Invalid or too-expensive funding-transactions query" + } + }, + "security": [ + { + "session_token": [] + } + ] + } + }, + "/health": { + "get": { + "tags": [ + "api-gateway" + ], + "operationId": "health", + "responses": { + "200": { + "description": "Query the current health status of the service", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthResponse" + } + } + } + } + } + } + }, + "/index-prices": { + "get": { + "tags": [ + "api-gateway" + ], + "summary": "Deprecated alias of `/underlying-prices`.", + "operationId": "get_index_prices", + "parameters": [ + { + "name": "symbol", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "end_timestamp_ns", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + } + }, + { + "name": "start_timestamp_ns", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "minimum": 0 + } + }, + { + "name": "sort_ts", + "in": "query", + "required": false, + "schema": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/SortDirection", + "description": "Timestamp sort direction (defaults to `desc`)." + } + ] + } + } + ], + "responses": { + "200": { + "description": "List of underlying prices", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetIndexPricesResponse" + } + } + } + } + }, + "deprecated": true, + "security": [ + { + "session_token": [] + } + ] + } + }, + "/instrument": { + "get": { + "tags": [ + "api-gateway" + ], + "operationId": "get_instrument", + "parameters": [ + { + "name": "symbol", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Instrument details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetInstrumentResponse" + } + } + } + }, + "404": { + "description": "Instrument not found" + } + } + } + }, + "/instruments": { + "get": { + "tags": [ + "api-gateway" + ], + "operationId": "get_instruments", + "responses": { + "200": { + "description": "List of instruments", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetInstrumentsResponse" + } + } + } + } + } + } + }, + "/leaderboard": { + "get": { + "tags": [ + "api-gateway" + ], + "operationId": "leaderboard", + "parameters": [ + { + "name": "metric", + "in": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/LeaderboardMetric" + } + }, + { + "name": "cadence", + "in": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/LeaderboardCadence" + } + }, + { + "name": "period_offset", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "Leaderboard for the requested metric/cadence/period", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LeaderboardResponse" + } + } + } + } + }, + "security": [ + { + "session_token": [] + } + ] + } + }, + "/liquidations": { + "get": { + "tags": [ + "api-gateway" + ], + "description": "Returns historical liquidations for the authenticated user. Requires an explicit time range: both `start_timestamp_ns` and `end_timestamp_ns` must be provided and span no more than 7 days. Missing, inverted, or wider ranges are rejected with a 400.", + "operationId": "get_liquidations", + "parameters": [ + { + "name": "account_id", + "in": "query", + "description": "Optional account ID. If omitted, default (primary) user account is used.", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "symbol", + "in": "query", + "description": "Optional symbol filter. If provided, only fills for this symbol will be returned.", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "end_timestamp_ns", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + } + }, + { + "name": "start_timestamp_ns", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "minimum": 0 + } + }, + { + "name": "sort_ts", + "in": "query", + "required": false, + "schema": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/SortDirection", + "description": "Timestamp sort direction (defaults to `desc`)." + } + ] + } + } + ], + "responses": { + "200": { + "description": "List of liquidations", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetFillsResponse" + } + } + } + }, + "400": { + "description": "Time range missing or wider than the 7-day maximum", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_token": [] + } + ] + } + }, + "/login/clerk": { + "post": { + "tags": [ + "api-gateway" + ], + "operationId": "clerk_login", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClerkLoginRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Token generated successfully, session cookie set", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthenticateResponse" + } + } + } + }, + "401": { + "description": "Invalid Clerk session token" + }, + "403": { + "description": "Clerk user has no verified email address" + }, + "503": { + "description": "Clerk is not configured or unreachable" + } + } + } + }, + "/logout": { + "post": { + "tags": [ + "api-gateway" + ], + "operationId": "logout", + "responses": { + "200": { + "description": "Logout successful, session cookie cleared" + } + } + } + }, + "/positions": { + "get": { + "tags": [ + "api-gateway" + ], + "operationId": "get_positions", + "parameters": [ + { + "name": "account_id", + "in": "query", + "description": "Optional account ID. If omitted, default (primary) user account is used.", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + } + ], + "responses": { + "200": { + "description": "List of positions", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetPositionsResponse" + } + } + } + } + }, + "security": [ + { + "session_token": [] + } + ] + } + }, + "/preview-aggressive-limit-order": { + "post": { + "tags": [ + "api-gateway" + ], + "operationId": "preview_aggressive_limit_order", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreviewAggressiveLimitOrderRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Aggressive limit order preview", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreviewAggressiveLimitOrderResponse" + } + } + } + }, + "404": { + "description": "Symbol not found" + }, + "503": { + "description": "MdPub not available" + } + }, + "security": [ + { + "session_token": [] + } + ] + } + }, + "/risk-snapshot": { + "get": { + "tags": [ + "api-gateway" + ], + "operationId": "get_risk_snapshot", + "parameters": [ + { + "name": "account_id", + "in": "query", + "description": "Optional account ID. If omitted, default (primary) user account is used.", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + } + ], + "responses": { + "200": { + "description": "Risk snapshot", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetRiskSnapshotResponse" + } + } + } + } + }, + "security": [ + { + "session_token": [] + } + ] + } + }, + "/sandbox/deposit": { + "post": { + "tags": [ + "api-gateway" + ], + "operationId": "sandbox_deposit", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SandboxDepositRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Deposit successful" + }, + "400": { + "description": "Invalid symbol or amount" + }, + "403": { + "description": "Not a sandbox environment or monthly limit exceeded" + } + }, + "security": [ + { + "session_token": [] + } + ] + } + }, + "/sandbox/withdraw": { + "post": { + "tags": [ + "api-gateway" + ], + "operationId": "sandbox_withdraw", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SandboxWithdrawalRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Withdrawal successful" + }, + "400": { + "description": "Invalid symbol or amount" + }, + "403": { + "description": "Not a sandbox environment" + } + }, + "security": [ + { + "session_token": [] + } + ] + } + }, + "/special-settlements-pending": { + "get": { + "tags": [ + "api-gateway" + ], + "operationId": "get_upcoming_special_settlements", + "parameters": [ + { + "name": "days", + "in": "query", + "description": "Number of days ahead to include. Defaults to 7; capped at 365.", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "Upcoming special settlements", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetUpcomingSpecialSettlementsResponse" + } + } + } + } + }, + "security": [ + { + "session_token": [] + } + ] + } + }, + "/ticker": { + "get": { + "tags": [ + "api-gateway" + ], + "operationId": "get_ticker", + "parameters": [ + { + "name": "symbol", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Ticker details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetTickerResponse" + } + } + } + }, + "404": { + "description": "No ticker available for the symbol" + } + }, + "security": [ + { + "session_token": [] + } + ] + } + }, + "/tickers": { + "get": { + "tags": [ + "api-gateway" + ], + "operationId": "get_tickers", + "parameters": [ + { + "name": "limit", + "in": "query", + "description": "Maximum number of tickers to return; defaults to 100", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + }, + { + "name": "offset", + "in": "query", + "description": "Number of sorted tickers to skip; defaults to 0", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + }, + { + "name": "sort", + "in": "query", + "description": "Sort order. Only `symbol` is supported; defaults to `symbol:asc`", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of tickers", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetTickersResponse" + } + } + } + }, + "400": { + "description": "Invalid sort field", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_token": [] + } + ] + } + }, + "/trades": { + "get": { + "tags": [ + "api-gateway" + ], + "operationId": "get_trades", + "parameters": [ + { + "name": "symbol", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "description": "The maximum number of trades to return, up to 100 trades. Defaults to 10.", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "List of trades", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetTradesResponse" + } + } + } + } + }, + "security": [ + { + "session_token": [] + } + ] + } + }, + "/transactions": { + "get": { + "tags": [ + "api-gateway" + ], + "description": "Returns historical transactions for the authenticated user. An optional time range may be supplied via `start_timestamp_ns` / `end_timestamp_ns`; when both bounds are given, `end_timestamp_ns` must be greater than `start_timestamp_ns`. Inverted ranges are rejected with a 400.", + "operationId": "get_transactions", + "parameters": [ + { + "name": "account_id", + "in": "query", + "description": "Optional account ID. If omitted, default (primary) user account is used.", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "transaction_types", + "in": "query", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TransactionType" + }, + "description": "Transaction types to include, as a single comma-separated value\n(`transaction_types=deposit,withdrawal`). Repeating the parameter is\nrejected; an empty value includes every type. Values not listed here\nmatch nothing rather than erroring." + }, + "style": "form", + "explode": false + }, + { + "name": "end_timestamp_ns", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + } + }, + { + "name": "start_timestamp_ns", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "minimum": 0 + } + }, + { + "name": "sort_ts", + "in": "query", + "required": false, + "schema": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/SortDirection", + "description": "Timestamp sort direction (defaults to `desc`)." + } + ] + } + } + ], + "responses": { + "200": { + "description": "List of transactions", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetTransactionsResponse" + } + } + } + }, + "400": { + "description": "Time range inverted (end_timestamp_ns <= start_timestamp_ns)" + } + }, + "security": [ + { + "session_token": [] + } + ] + } + }, + "/underlying-prices": { + "get": { + "tags": [ + "api-gateway" + ], + "operationId": "get_underlying_prices", + "parameters": [ + { + "name": "symbol", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "end_timestamp_ns", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + } + }, + { + "name": "start_timestamp_ns", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "minimum": 0 + } + }, + { + "name": "sort_ts", + "in": "query", + "required": false, + "schema": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/SortDirection", + "description": "Timestamp sort direction (defaults to `desc`)." + } + ] + } + } + ], + "responses": { + "200": { + "description": "List of underlying prices", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetUnderlyingPricesResponse" + } + } + } + } + }, + "security": [ + { + "session_token": [] + } + ] + } + }, + "/user/stats/volume": { + "get": { + "tags": [ + "api-gateway" + ], + "operationId": "get_volume", + "parameters": [ + { + "name": "start_timestamp_ns", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "end_timestamp_ns", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "account_id", + "in": "query", + "description": "Optional account ID. If omitted, default (primary) user account is used.", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + } + ], + "responses": { + "200": { + "description": "Traded volume", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetVolumeResponse" + } + } + } + } + }, + "security": [ + { + "session_token": [] + } + ] + } + }, + "/whoami": { + "get": { + "tags": [ + "api-gateway" + ], + "operationId": "whoami", + "responses": { + "200": { + "description": "Current user information", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WhoAmIResponse" + } + } + } + } + }, + "security": [ + { + "session_token": [] + } + ] + } + }, + "/whoami/customer": { + "get": { + "tags": [ + "api-gateway" + ], + "operationId": "whoami_customer", + "responses": { + "200": { + "description": "Customer information for the current user", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetCustomerResponse" + } + } + } + } + }, + "security": [ + { + "session_token": [] + } + ] + } + } + }, + "components": { + "schemas": { + "AccountEquityPoint": { + "type": "object", + "required": [ + "t", + "v" + ], + "properties": { + "t": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "v": { + "type": "string" + } + } + }, + "AccountRiskSnapshot": { + "type": "object", + "required": [ + "account_id", + "timestamp_ns", + "per_symbol", + "initial_margin_required_for_positions", + "initial_margin_required_for_open_orders", + "initial_margin_required_total", + "maintenance_margin_required", + "unrealized_pnl", + "equity", + "initial_margin_available", + "maintenance_margin_available", + "balance_usd" + ], + "properties": { + "account_id": { + "type": "string" + }, + "balance_usd": { + "type": "string" + }, + "equity": { + "type": "string" + }, + "initial_margin_available": { + "type": "string" + }, + "initial_margin_required_for_open_orders": { + "type": "string" + }, + "initial_margin_required_for_positions": { + "type": "string" + }, + "initial_margin_required_total": { + "type": "string" + }, + "maintenance_margin_available": { + "type": "string" + }, + "maintenance_margin_required": { + "type": "string" + }, + "per_symbol": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/SymbolRiskSnapshot" + }, + "propertyNames": { + "type": "string" + } + }, + "timestamp_ns": { + "type": "string", + "format": "date-time" + }, + "unrealized_pnl": { + "type": "string" + } + } + }, + "ApiKeyInfo": { + "type": "object", + "required": [ + "api_key", + "created_at", + "permissions" + ], + "properties": { + "account_ids": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "description": "Accounts this key may act on. `null` means every account the owner can\naccess; otherwise the key is restricted to exactly these accounts." + }, + "allowed_ips": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "api_key": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "permissions": { + "$ref": "#/components/schemas/ApiKeyPermissions", + "description": "The key's granted permissions." + } + } + }, + "ApiKeyPermissions": { + "type": "object", + "description": "Per-key permission flags, scoping what an API key may do on the accounts it\ntargets. Mirrors the granular account permissions: the effective authority on\na request is these flags intersected with the user's current permissions on\nthe requested account, so a key can never out-rank its owner.", + "required": [ + "can_list", + "can_read", + "can_set_limits", + "can_reduce_or_close", + "can_trade" + ], + "properties": { + "can_list": { + "type": "boolean" + }, + "can_read": { + "type": "boolean" + }, + "can_reduce_or_close": { + "type": "boolean" + }, + "can_set_limits": { + "type": "boolean" + }, + "can_trade": { + "type": "boolean" + } + } + }, + "AuthenticateRequest": { + "allOf": [ + { + "$ref": "#/components/schemas/AuthenticationMethod" + }, + { + "type": "object", + "required": [ + "expiration_seconds" + ], + "properties": { + "expiration_seconds": { + "type": "integer", + "format": "int32" + } + } + } + ], + "description": "Exchange an API key and secret for a bearer token." + }, + "AuthenticateResponse": { + "type": "object", + "required": [ + "token" + ], + "properties": { + "token": { + "$ref": "#/components/schemas/Token" + } + } + }, + "AuthenticationMethod": { + "oneOf": [ + { + "type": "object", + "required": [ + "api_key", + "api_secret" + ], + "properties": { + "api_key": { + "type": "string" + }, + "api_secret": { + "type": "string" + } + } + } + ] + }, + "Balance": { + "type": "object", + "required": [ + "account_id", + "symbol", + "amount" + ], + "properties": { + "account_id": { + "type": "string" + }, + "amount": { + "type": "string" + }, + "symbol": { + "type": "string" + } + } + }, + "BboCandle": { + "type": "object", + "required": [ + "symbol", + "ts", + "width" + ], + "properties": { + "ask_close": { + "type": [ + "string", + "null" + ], + "description": "Best ask price at the end of the interval" + }, + "ask_high": { + "type": [ + "string", + "null" + ], + "description": "Highest best ask price during the interval" + }, + "ask_low": { + "type": [ + "string", + "null" + ], + "description": "Lowest best ask price during the interval" + }, + "ask_open": { + "type": [ + "string", + "null" + ], + "description": "Best ask price at the start of the interval" + }, + "bid_close": { + "type": [ + "string", + "null" + ], + "description": "Best bid price at the end of the interval" + }, + "bid_high": { + "type": [ + "string", + "null" + ], + "description": "Highest best bid price during the interval" + }, + "bid_low": { + "type": [ + "string", + "null" + ], + "description": "Lowest best bid price during the interval" + }, + "bid_open": { + "type": [ + "string", + "null" + ], + "description": "Best bid price at the start of the interval" + }, + "mid_close": { + "type": [ + "string", + "null" + ], + "description": "Mid-price at the end of the interval" + }, + "mid_high": { + "type": [ + "string", + "null" + ], + "description": "Highest mid-price during the interval" + }, + "mid_low": { + "type": [ + "string", + "null" + ], + "description": "Lowest mid-price during the interval" + }, + "mid_open": { + "type": [ + "string", + "null" + ], + "description": "Mid-price ((bid + ask) / 2) at the start of the interval" + }, + "symbol": { + "type": "string", + "description": "Instrument symbol (e.g. \"XAU-PERP\")" + }, + "ts": { + "type": "string", + "format": "date-time", + "description": "Start timestamp of the candle interval (epoch seconds)" + }, + "width": { + "$ref": "#/components/schemas/CandleWidth", + "description": "Duration of the candle interval" + } + } + }, + "Candle": { + "type": "object", + "required": [ + "symbol", + "ts", + "open", + "high", + "low", + "close", + "buy_volume", + "sell_volume", + "volume", + "width" + ], + "properties": { + "buy_volume": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "close": { + "type": "string" + }, + "high": { + "type": "string" + }, + "low": { + "type": "string" + }, + "open": { + "type": "string" + }, + "sell_volume": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "symbol": { + "type": "string" + }, + "ts": { + "type": "string", + "format": "date-time" + }, + "volume": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "width": { + "$ref": "#/components/schemas/CandleWidth" + } + } + }, + "CandleWidth": { + "type": "string", + "enum": [ + "1s", + "5s", + "1m", + "5m", + "15m", + "1h", + "1d" + ] + }, + "ClerkLoginRequest": { + "type": "object", + "description": "Log in with a Clerk session token, exchanging it for an AX session token.", + "required": [ + "clerk_token", + "expiration_seconds" + ], + "properties": { + "clerk_token": { + "type": "string", + "description": "Session JWT obtained from Clerk after completing sign-in." + }, + "expiration_seconds": { + "type": "integer", + "format": "int32" + } + } + }, + "CreateApiKeyRequest": { + "type": "object", + "properties": { + "account_ids": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "description": "Accounts the key may act on. Each must be one the caller has access to.\nWhen omitted, the key covers every account the caller can access. When\nprovided, the key is restricted to exactly those accounts." + }, + "allowed_ips": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "permissions": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ApiKeyPermissions", + "description": "Permissions to grant the key, intersected per request with the caller's\ncurrent permissions on the requested account (so a key can never\nout-rank its owner). When omitted, the key is granted full permissions\nand behaves as the user." + } + ] + } + } + }, + "CreateApiKeyResponse": { + "type": "object", + "required": [ + "api_key", + "api_secret" + ], + "properties": { + "api_key": { + "type": "string" + }, + "api_secret": { + "type": "string" + } + } + }, + "CursorPage": { + "type": "object", + "description": "Page metadata for cursor-paged responses.\n\nThis is intended for response bodies (not query params).", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "minimum": 0 + }, + "next_cursor": { + "type": [ + "string", + "null" + ] + }, + "total_count": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + } + } + }, + "CursorPagination": { + "type": "object", + "description": "Cursor-based paging.\n\nPass `cursor` from a previous response to get the next page. Set `limit` to control\npage size (accepts number or string).", + "properties": { + "cursor": { + "type": [ + "string", + "null" + ] + }, + "limit": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "minimum": 0 + } + } + }, + "DaysOfWeek": { + "type": "array", + "items": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "description": "Days of week using ISO 8601 numbering (1=Monday, 7=Sunday).\n\nThis type ensures that all day values are in the valid range [1, 7].\nIt is serialized as a JSON array of numbers for API compatibility." + }, + "ErrorResponse": { + "type": "object", + "description": "Standard error response format", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + }, + "EstimatedFundingRateStatus": { + "type": "string", + "enum": [ + "ready", + "settlement_pending", + "unavailable" + ] + }, + "Fill": { + "type": "object", + "required": [ + "trade_id", + "account_id", + "timestamp", + "symbol", + "price", + "quantity", + "is_taker", + "fee", + "side" + ], + "properties": { + "account_id": { + "type": "string" + }, + "fee": { + "type": "string" + }, + "is_block_trade": { + "type": "boolean", + "description": "True if this fill was generated by a block trade — a privately\nnegotiated trade booked away from the order book." + }, + "is_final_settlement": { + "type": "boolean", + "description": "True if this fill was generated by final settlement of a delisted or\nexpired contract at the final settlement price; the counterparty is the\nexchange settlement account. Implies `is_block_trade`." + }, + "is_taker": { + "type": "boolean" + }, + "order_id": { + "type": [ + "string", + "null" + ] + }, + "price": { + "type": "string" + }, + "quantity": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "realized_pnl": { + "type": [ + "string", + "null" + ] + }, + "side": { + "$ref": "#/components/schemas/Side" + }, + "symbol": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "trade_id": { + "type": "string" + } + } + }, + "FundingException": { + "type": "object", + "required": [ + "date", + "times" + ], + "properties": { + "date": { + "type": "string", + "format": "date", + "description": "The date this exception applies to" + }, + "reason": { + "type": [ + "string", + "null" + ], + "description": "Human-readable reason" + }, + "times": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TimeOfDay" + }, + "description": "Replacement times for this date" + } + } + }, + "FundingRate": { + "type": "object", + "required": [ + "symbol", + "timestamp_ns", + "funding_rate", + "settlement_price" + ], + "properties": { + "benchmark_price": { + "type": [ + "string", + "null" + ] + }, + "funding_amount": { + "type": [ + "string", + "null" + ] + }, + "funding_rate": { + "type": "string" + }, + "settlement_price": { + "type": "string" + }, + "symbol": { + "type": "string" + }, + "timestamp_ns": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "FundingRateSchedule": { + "type": "object", + "description": "Machine-readable funding rate schedule for perpetual contracts", + "required": [ + "timezone", + "times", + "exceptions" + ], + "properties": { + "exceptions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FundingException" + }, + "description": "Dates with modified schedules (e.g., half-days, special times, or holidays).\n\nNote: Exception dates are interpreted in the schedule's timezone (the benchmark timezone),\nnot UTC. For example, if the timezone is \"America/New_York\" and an exception date is\n\"2025-12-25\", it refers to December 25th in New York time." + }, + "times": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FundingTime" + }, + "description": "Recurring funding times" + }, + "timezone": { + "type": "string", + "description": "Timezone for all times (chrono_tz::Tz, serializes as IANA string)", + "example": "Europe/London" + } + } + }, + "FundingSlot": { + "type": "object", + "description": "One funding slot of a trading day.", + "required": [ + "index", + "funding_time", + "status", + "capped" + ], + "properties": { + "capped": { + "type": "boolean", + "description": "True when the rate was clamped by the symbol's funding rate cap." + }, + "funding_rate_bps": { + "type": [ + "string", + "null" + ], + "description": "The slot's funding rate in basis points; positive means longs pay\nshorts." + }, + "funding_time": { + "type": "string", + "format": "date-time" + }, + "index": { + "type": "integer", + "format": "int32", + "description": "1-based position within the day's schedule.", + "minimum": 0 + }, + "mark_twap": { + "type": [ + "string", + "null" + ] + }, + "premium_bps": { + "type": [ + "string", + "null" + ], + "description": "Premium of the mark TWAP over the underlying TWAP, in basis points." + }, + "reason": { + "type": [ + "string", + "null" + ], + "description": "Why the slot was skipped; present only on skipped slots." + }, + "status": { + "$ref": "#/components/schemas/FundingSlotStatus" + }, + "underlying_twap": { + "type": [ + "string", + "null" + ] + } + } + }, + "FundingSlotStatus": { + "type": "string", + "enum": [ + "realized", + "projected", + "skipped", + "pending" + ] + }, + "FundingTime": { + "type": "object", + "required": [ + "days_of_week", + "time_of_day" + ], + "properties": { + "days_of_week": { + "$ref": "#/components/schemas/DaysOfWeek", + "description": "Days of week (1=Monday, 7=Sunday)" + }, + "time_of_day": { + "$ref": "#/components/schemas/TimeOfDay", + "description": "Funding time" + } + } + }, + "FundingTransaction": { + "type": "object", + "description": "A cash leg booked against a position-bearing account by a settlement\nevent. Covers perpetual funding-rate payments, daily mark-to-market on\nany position-bearing contract, and the one-time final settlement at a\ndated contract's expiration — discriminated by `transaction_type`.\n\n`funding_rate`, `funding_amount`, and `benchmark_price` are populated\nonly for `Funding` and absent for the other kinds. `settlement_price`\nand `amount` apply to every kind.", + "required": [ + "account_id", + "currency", + "timestamp", + "transaction_type", + "amount", + "event_id", + "sequence_number", + "symbol", + "settlement_price" + ], + "properties": { + "account_id": { + "type": "string" + }, + "amount": { + "type": "string" + }, + "benchmark_price": { + "type": [ + "string", + "null" + ] + }, + "currency": { + "type": "string" + }, + "event_id": { + "type": "string" + }, + "funding_amount": { + "type": [ + "string", + "null" + ], + "description": "Per-contract funding cash for this event — same for every user on\nthe same `(symbol, timestamp)`. Multiply by signed position to\nreconstruct the per-user `amount`. (`amount` is the actual cash\nbooked to *this* account; `funding_amount` is the per-contract\nquantity that drove the calculation.)" + }, + "funding_rate": { + "type": [ + "string", + "null" + ] + }, + "reference_id": { + "type": [ + "string", + "null" + ] + }, + "sequence_number": { + "type": "integer", + "format": "int32" + }, + "settlement_price": { + "type": "string" + }, + "symbol": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "transaction_type": { + "$ref": "#/components/schemas/SettlementKind" + } + } + }, + "FundingVariant": { + "type": "string", + "description": "How a symbol's funding accrues over a trading day.", + "enum": [ + "daily_close", + "intraday_twap" + ] + }, + "GetAccountEquityHistoryResponse": { + "type": "object", + "required": [ + "data_points" + ], + "properties": { + "data_points": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AccountEquityPoint" + } + } + } + }, + "GetApiKeysResponse": { + "type": "object", + "required": [ + "api_keys" + ], + "properties": { + "api_keys": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ApiKeyInfo" + } + } + } + }, + "GetBalancesResponse": { + "type": "object", + "required": [ + "balances" + ], + "properties": { + "balances": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Balance" + } + }, + "usd_borrow": { + "type": [ + "string", + "null" + ] + } + } + }, + "GetBboCandleResponse": { + "type": "object", + "required": [ + "candle" + ], + "properties": { + "candle": { + "$ref": "#/components/schemas/BboCandle" + } + } + }, + "GetBboCandlesResponse": { + "type": "object", + "required": [ + "candles" + ], + "properties": { + "candles": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BboCandle" + } + } + } + }, + "GetBlockchainDepositAddressResponse": { + "type": "object", + "required": [ + "blockchain", + "asset", + "address" + ], + "properties": { + "address": { + "type": "string" + }, + "asset": { + "type": "string" + }, + "blockchain": { + "type": "string" + } + } + }, + "GetBookResponse": { + "type": "object", + "required": [ + "book" + ], + "properties": { + "book": { + "$ref": "#/components/schemas/GetBookResponseBook" + } + } + }, + "GetBookResponseBook": { + "allOf": [ + { + "$ref": "#/components/schemas/Timestamp" + }, + { + "type": "object", + "required": [ + "s", + "b", + "a" + ], + "properties": { + "a": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GetBookResponseBookLevel" + } + }, + "b": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GetBookResponseBookLevel" + } + }, + "s": { + "type": "string" + } + } + } + ] + }, + "GetBookResponseBookLevel": { + "type": "object", + "required": [ + "p", + "q" + ], + "properties": { + "o": { + "type": [ + "array", + "null" + ], + "items": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + "p": { + "type": "string" + }, + "q": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "GetCandleResponse": { + "type": "object", + "required": [ + "candle" + ], + "properties": { + "candle": { + "$ref": "#/components/schemas/Candle" + } + } + }, + "GetCandlesResponse": { + "type": "object", + "required": [ + "candles" + ], + "properties": { + "candles": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Candle" + } + } + } + }, + "GetCustomerResponse": { + "type": "object", + "properties": { + "business_name": { + "type": [ + "string", + "null" + ] + }, + "doing_business_as": { + "type": [ + "string", + "null" + ] + } + } + }, + "GetEstimatedFundingRateResponse": { + "type": "object", + "description": "Live estimated funding rate for a symbol, served verbatim from the cached\nestimate the settlement runner publishes.", + "required": [ + "symbol", + "status", + "timestamp" + ], + "properties": { + "benchmark_price": { + "type": [ + "string", + "null" + ] + }, + "funding_amount": { + "type": [ + "string", + "null" + ] + }, + "funding_rate": { + "type": [ + "string", + "null" + ] + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "settlement_price": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/components/schemas/EstimatedFundingRateStatus" + }, + "symbol": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "GetFillsResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/CursorPage" + }, + { + "type": "object", + "required": [ + "fills" + ], + "properties": { + "fills": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Fill" + } + } + } + } + ] + }, + "GetFundingRatesResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/CursorPage" + }, + { + "type": "object", + "required": [ + "funding_rates" + ], + "properties": { + "funding_rates": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FundingRate" + } + } + } + } + ] + }, + "GetFundingSlotsResponse": { + "type": "object", + "description": "A full trading day of funding slots — realized, projected, skipped, and\npending — with running totals. One surface for both funding variants:\n`daily_close` symbols report a single slot.", + "required": [ + "symbol", + "date", + "timezone", + "variant", + "interval_count", + "slots", + "realized_sum_bps", + "projected_eod_bps" + ], + "properties": { + "cap_bps": { + "type": [ + "string", + "null" + ], + "description": "Per-slot cap on the funding rate in basis points, if configured." + }, + "date": { + "type": "string", + "format": "date" + }, + "interval_count": { + "type": "integer", + "format": "int32", + "description": "Number of funding slots scheduled on `date`; 0 on holidays and\nweekends.", + "minimum": 0 + }, + "projected_eod_bps": { + "type": "string", + "description": "Projected end-of-day total in basis points: realized so far plus the\nprojection for the remaining slots." + }, + "realized_sum_bps": { + "type": "string", + "description": "Sum of realized slot rates so far, in basis points." + }, + "slots": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FundingSlot" + } + }, + "symbol": { + "type": "string" + }, + "timezone": { + "type": "string", + "description": "IANA name of the funding schedule's timezone." + }, + "variant": { + "$ref": "#/components/schemas/FundingVariant" + } + } + }, + "GetFundingTransactionsResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/CursorPage" + }, + { + "type": "object", + "required": [ + "funding_transactions" + ], + "properties": { + "funding_transactions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FundingTransaction" + } + } + } + } + ] + }, + "GetIndexPricesResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/CursorPage" + }, + { + "type": "object", + "required": [ + "index_prices" + ], + "properties": { + "index_prices": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UnderlyingPrice" + } + } + } + } + ] + }, + "GetInstrumentResponse": { + "$ref": "#/components/schemas/Instrument" + }, + "GetInstrumentsResponse": { + "type": "object", + "required": [ + "instruments" + ], + "properties": { + "instruments": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GetInstrumentResponse" + } + } + } + }, + "GetPositionsResponse": { + "type": "object", + "required": [ + "positions" + ], + "properties": { + "positions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Position" + } + } + } + }, + "GetRiskSnapshotResponse": { + "type": "object", + "required": [ + "risk_snapshot" + ], + "properties": { + "risk_snapshot": { + "$ref": "#/components/schemas/AccountRiskSnapshot" + } + } + }, + "GetTickerResponse": { + "type": "object", + "required": [ + "ticker" + ], + "properties": { + "ticker": { + "$ref": "#/components/schemas/Ticker" + } + } + }, + "GetTickersResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/LimitOffsetPage" + }, + { + "type": "object", + "required": [ + "tickers" + ], + "properties": { + "tickers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Ticker" + } + } + } + } + ] + }, + "GetTradesResponse": { + "type": "object", + "required": [ + "trades" + ], + "properties": { + "trades": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Trade" + } + } + } + }, + "GetTransactionsRequest": { + "type": "object", + "required": [ + "transaction_types" + ], + "properties": { + "transaction_types": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TransactionType" + }, + "description": "Transaction types to include, as a single comma-separated value\n(`transaction_types=deposit,withdrawal`). Repeating the parameter is\nrejected; an empty value includes every type. Values not listed here\nmatch nothing rather than erroring." + } + } + }, + "GetTransactionsResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/CursorPage" + }, + { + "type": "object", + "required": [ + "transactions" + ], + "properties": { + "transactions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Transaction" + } + } + } + } + ] + }, + "GetUnderlyingPricesResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/CursorPage" + }, + { + "type": "object", + "required": [ + "underlying_prices" + ], + "properties": { + "underlying_prices": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UnderlyingPrice" + } + } + } + } + ] + }, + "GetUpcomingSpecialSettlementsResponse": { + "type": "object", + "required": [ + "special_settlements" + ], + "properties": { + "special_settlements": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UpcomingSpecialSettlement" + }, + "description": "Upcoming special settlements, ordered by settlement time." + } + } + }, + "GetVolumeResponse": { + "type": "object", + "required": [ + "volume" + ], + "properties": { + "volume": { + "type": "string" + } + } + }, + "HealthResponse": { + "type": "object", + "description": "Service health response", + "required": [ + "status", + "timestamp" + ], + "properties": { + "environment": { + "type": [ + "string", + "null" + ] + }, + "status": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "version": { + "type": [ + "string", + "null" + ] + } + } + }, + "Instrument": { + "type": "object", + "required": [ + "symbol", + "multiplier", + "price_scale", + "minimum_order_size", + "tick_size", + "quote_currency", + "funding_settlement_currency", + "maintenance_margin_pct", + "initial_margin_pct", + "category", + "additional_product_specs" + ], + "properties": { + "additional_product_specs": { + "type": "object" + }, + "category": { + "$ref": "#/components/schemas/InstrumentCategory" + }, + "contract_mark_price": { + "type": [ + "string", + "null" + ] + }, + "contract_size": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "estimated_funding_supported": { + "type": "boolean", + "description": "Whether a live index feed is configured for this instrument, so an\nintraday funding-rate estimate can be produced. When `false`, the\nestimated-funding endpoint reports the symbol as unsupported and\nclients should not surface an estimate for it." + }, + "expiration": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "Absolute expiration time for dated contracts. `None` for perpetuals.\nPresence of a value is the discriminator between dated and perpetual contracts." + }, + "funding_rate_cap_lower_pct": { + "type": [ + "string", + "null" + ] + }, + "funding_rate_cap_upper_pct": { + "type": [ + "string", + "null" + ] + }, + "funding_schedule": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/FundingRateSchedule" + } + ] + }, + "funding_schedule_calendar_description": { + "type": [ + "string", + "null" + ] + }, + "funding_schedule_time_description": { + "type": [ + "string", + "null" + ] + }, + "funding_settlement_currency": { + "type": "string" + }, + "initial_margin_pct": { + "type": "string" + }, + "maintenance_margin_pct": { + "type": "string" + }, + "minimum_order_size": { + "type": "string" + }, + "multiplier": { + "type": "string" + }, + "price_band_lower_deviation_pct": { + "type": [ + "string", + "null" + ] + }, + "price_band_upper_deviation_pct": { + "type": [ + "string", + "null" + ] + }, + "price_bands": { + "type": [ + "string", + "null" + ] + }, + "price_quotation": { + "type": [ + "string", + "null" + ] + }, + "price_scale": { + "type": "integer", + "format": "int64" + }, + "product": { + "type": "string", + "description": "Umbrella product this instrument belongs to (e.g. `XAU` for `XAU-PERP`,\n`XAU-2026-SEP`, `XAU-2026-DEC`). Instruments that share a `product` are the\nsame underlying and can be grouped together in a product list." + }, + "quote_currency": { + "type": "string" + }, + "symbol": { + "type": "string" + }, + "tick_size": { + "type": "string" + }, + "trading_schedule": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/TradingSchedule" + } + ] + }, + "underlying_benchmark_price": { + "type": [ + "string", + "null" + ] + } + } + }, + "InstrumentCategory": { + "type": "string", + "enum": [ + "compute", + "crypto", + "energy", + "energy_etfs", + "equities", + "fx", + "metals", + "treasuries" + ] + }, + "InstrumentState": { + "type": "string", + "enum": [ + "CLOSED_FROZEN", + "PRE_OPEN", + "OPEN", + "CLOSED", + "DELISTED", + "HALTED", + "MATCH_AND_CLOSE_AUCTION", + "UNKNOWN" + ] + }, + "LeaderboardCadence": { + "type": "string", + "enum": [ + "monthly" + ] + }, + "LeaderboardEntry": { + "type": "object", + "required": [ + "rank", + "pseudonym", + "score" + ], + "properties": { + "pseudonym": { + "type": "string" + }, + "rank": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "score": { + "type": "string" + } + } + }, + "LeaderboardMetric": { + "type": "string", + "enum": [ + "volume" + ] + }, + "LeaderboardResponse": { + "type": "object", + "required": [ + "metric", + "cadence", + "period_start", + "period_end", + "updated_at", + "entries" + ], + "properties": { + "cadence": { + "$ref": "#/components/schemas/LeaderboardCadence" + }, + "entries": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LeaderboardEntry" + } + }, + "metric": { + "$ref": "#/components/schemas/LeaderboardMetric" + }, + "period_end": { + "type": "string", + "format": "date-time" + }, + "period_start": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "your_entry": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/LeaderboardEntry" + } + ] + } + } + }, + "LimitOffsetPage": { + "type": "object", + "description": "Page metadata for limit/offset paged responses.\n\nThis is intended for response bodies (not query params).", + "required": [ + "total_count", + "limit", + "offset" + ], + "properties": { + "limit": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "offset": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "total_count": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "Position": { + "type": "object", + "required": [ + "account_id", + "symbol", + "signed_quantity", + "signed_notional", + "timestamp", + "realized_pnl" + ], + "properties": { + "account_id": { + "type": "string" + }, + "realized_pnl": { + "type": "string" + }, + "signed_notional": { + "type": "string" + }, + "signed_quantity": { + "type": "integer", + "format": "int64" + }, + "symbol": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "PreviewAggressiveLimitOrderRequest": { + "type": "object", + "required": [ + "symbol", + "quantity", + "side" + ], + "properties": { + "quantity": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "side": { + "$ref": "#/components/schemas/Side" + }, + "symbol": { + "type": "string" + } + } + }, + "PreviewAggressiveLimitOrderResponse": { + "type": "object", + "required": [ + "filled_quantity", + "remaining_quantity" + ], + "properties": { + "filled_quantity": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "limit_price": { + "type": [ + "string", + "null" + ] + }, + "remaining_quantity": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "vwap": { + "type": [ + "string", + "null" + ] + } + } + }, + "RevokeApiKeyResponse": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + }, + "SandboxDepositRequest": { + "type": "object", + "required": [ + "symbol", + "amount" + ], + "properties": { + "account_id": { + "type": [ + "string", + "null" + ], + "description": "Optional account ID. If omitted, default (primary) user account is used." + }, + "amount": { + "type": "string" + }, + "symbol": { + "type": "string" + } + } + }, + "SandboxWithdrawalRequest": { + "type": "object", + "required": [ + "symbol", + "amount" + ], + "properties": { + "account_id": { + "type": [ + "string", + "null" + ], + "description": "Optional account ID. If omitted, default (primary) user account is used." + }, + "amount": { + "type": "string" + }, + "symbol": { + "type": "string" + } + } + }, + "SettlementKind": { + "type": "string", + "description": "Discriminator for `FundingTransaction`. All variants are a cash leg\nbooked at a settlement price against a position; only the trigger\ndiffers.", + "enum": [ + "funding", + "mark_to_market", + "final_settlement" + ] + }, + "Side": { + "type": "string", + "enum": [ + "B", + "S" + ] + }, + "SortDirection": { + "type": "string", + "description": "Sort order/direction.\n\n- `asc`: ascending order per field type (e.g. lexicographic, numeric, etc.)\n- `desc`: descending order per field type", + "enum": [ + "asc", + "desc" + ] + }, + "SymbolRiskSnapshot": { + "type": "object", + "required": [ + "signed_quantity", + "signed_notional", + "initial_margin_required_position", + "initial_margin_required_open_orders", + "initial_margin_required_total", + "maintenance_margin_required", + "unrealized_pnl" + ], + "properties": { + "average_price": { + "type": [ + "string", + "null" + ] + }, + "initial_margin_required_open_orders": { + "type": "string" + }, + "initial_margin_required_position": { + "type": "string" + }, + "initial_margin_required_total": { + "type": "string" + }, + "liquidation_price": { + "type": [ + "string", + "null" + ] + }, + "maintenance_margin_required": { + "type": "string" + }, + "signed_notional": { + "type": "string" + }, + "signed_quantity": { + "type": "integer", + "format": "int64" + }, + "unrealized_pnl": { + "type": "string" + } + } + }, + "Ticker": { + "allOf": [ + { + "$ref": "#/components/schemas/Timestamp" + }, + { + "type": "object", + "required": [ + "s", + "q", + "v", + "oi", + "m" + ], + "properties": { + "ap": { + "type": [ + "string", + "null" + ] + }, + "bp": { + "type": [ + "string", + "null" + ] + }, + "ef": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/GetEstimatedFundingRateResponse", + "description": "Live estimated funding rate, when available for this symbol." + } + ] + }, + "h": { + "type": [ + "string", + "null" + ], + "description": "Session high price in USD" + }, + "i": { + "$ref": "#/components/schemas/InstrumentState", + "description": "Instrument state" + }, + "l": { + "type": [ + "string", + "null" + ], + "description": "Session low price in USD" + }, + "lsp": { + "type": [ + "string", + "null" + ], + "description": "Last settlement price in USD" + }, + "lst": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Last settlement time as epoch seconds" + }, + "m": { + "type": "string" + }, + "o": { + "type": [ + "string", + "null" + ], + "description": "Session open price in USD" + }, + "oi": { + "type": "integer", + "format": "int64", + "description": "Open interest in contracts", + "minimum": 0 + }, + "p": { + "type": [ + "string", + "null" + ], + "description": "Last trade price in USD" + }, + "pl": { + "type": [ + "string", + "null" + ], + "description": "Price band lower limit in USD: the absolute lower bound of the price band that orders are checked against" + }, + "pu": { + "type": [ + "string", + "null" + ], + "description": "Price band upper limit in USD: the absolute upper bound of the price band that orders are checked against" + }, + "q": { + "type": "integer", + "format": "int64", + "description": "Last trade quantity in contracts", + "minimum": 0 + }, + "s": { + "type": "string", + "description": "Instrument symbol; e.g. XAU-PERP, EURUSD-PERP" + }, + "v": { + "type": "integer", + "format": "int64", + "description": "Total 24h volume in contracts (quantity traded, not notional value)", + "minimum": 0 + } + } + } + ], + "description": "Low frequency (e.g. ~1s or ~5s) stats update for a symbol." + }, + "TimeOfDay": { + "type": "object", + "description": "Time of day representation", + "required": [ + "hours", + "minutes" + ], + "properties": { + "hours": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "minutes": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "seconds": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + }, + "TimeRangeNs": { + "type": "object", + "description": "Time range params for API endpoints.\n\nBoth fields are optional nanosecond timestamps (UNIX epoch):\n- `start_timestamp_ns`: inclusive lower bound\n- `end_timestamp_ns`: exclusive upper bound\n\nLeave either field unset to query from the start (-∞) or through the end (+∞).", + "properties": { + "end_timestamp_ns": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + }, + "start_timestamp_ns": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + } + } + }, + "TimeseriesPagination": { + "allOf": [ + { + "$ref": "#/components/schemas/TimeRangeNs" + }, + { + "$ref": "#/components/schemas/CursorPagination" + }, + { + "type": "object", + "properties": { + "sort_ts": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/SortDirection", + "description": "Timestamp sort direction (defaults to `desc`)." + } + ] + } + } + } + ], + "description": "Query timeseries data with time range, sort, and paging.\n\nCombines time filtering, sort direction, and cursor paging. Set time bounds and\ncontrol result order." + }, + "Timestamp": { + "type": "object", + "required": [ + "ts", + "tn" + ], + "properties": { + "tn": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "ts": { + "type": "integer", + "format": "int32" + } + } + }, + "Token": { + "type": "string", + "description": "Strong type for Token with validation" + }, + "Trade": { + "allOf": [ + { + "$ref": "#/components/schemas/Timestamp" + }, + { + "type": "object", + "required": [ + "p", + "q", + "s", + "d" + ], + "properties": { + "d": { + "$ref": "#/components/schemas/Side" + }, + "p": { + "type": "string" + }, + "q": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "s": { + "type": "string" + } + } + } + ] + }, + "TradingHoursSegment": { + "type": "object", + "description": "A single trading hours segment with specific days, times, and state", + "required": [ + "days_of_week", + "time_of_day", + "duration_seconds", + "state", + "hide_market_data", + "expire_all_orders" + ], + "properties": { + "days_of_week": { + "$ref": "#/components/schemas/DaysOfWeek", + "description": "Days of the week (1=Monday, 2=Tuesday, ..., 7=Sunday)" + }, + "duration_seconds": { + "type": "integer", + "format": "int64", + "description": "Duration of this segment in seconds", + "minimum": 0 + }, + "expire_all_orders": { + "type": "boolean", + "description": "Whether to expire all orders during this segment" + }, + "hide_market_data": { + "type": "boolean", + "description": "Whether to hide market data during this segment" + }, + "state": { + "$ref": "#/components/schemas/InstrumentState", + "description": "Trading state during this segment" + }, + "time_of_day": { + "$ref": "#/components/schemas/TimeOfDay", + "description": "Time of day when this segment starts" + } + } + }, + "TradingSchedule": { + "type": "object", + "description": "Trading schedule for an instrument, containing multiple trading hour segments", + "required": [ + "segments" + ], + "properties": { + "segments": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TradingHoursSegment" + } + } + } + }, + "Transaction": { + "type": "object", + "required": [ + "account_id", + "event_id", + "symbol", + "timestamp", + "amount", + "transaction_type" + ], + "properties": { + "account_id": { + "type": "string" + }, + "amount": { + "type": "string" + }, + "event_id": { + "type": "string" + }, + "initiated_by_user_id": { + "type": [ + "string", + "null" + ], + "description": "Actor of record — the user who initiated the transaction. Present only\nfor directly-initiated kinds (`deposit`, `withdrawal`); `None` for\norder-derived and system-generated transactions." + }, + "reference_id": { + "type": [ + "string", + "null" + ] + }, + "symbol": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "transaction_type": { + "type": "string" + } + } + }, + "TransactionType": { + "type": "string", + "description": "Kinds of ledger entry that can appear as a `Transaction::transaction_type`.", + "enum": [ + "deposit", + "withdrawal", + "funding", + "fee", + "pnl", + "lending_credit", + "lending_debit" + ] + }, + "UnderlyingPrice": { + "type": "object", + "required": [ + "symbol", + "timestamp", + "price" + ], + "properties": { + "price": { + "type": "string" + }, + "symbol": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "UpcomingSpecialSettlement": { + "type": "object", + "description": "Public, user-facing view of an upcoming special settlement. Deliberately\nomits internal/operational fields (notes, retry bookkeeping).", + "required": [ + "symbol", + "settlement_ts", + "long_holder_extra_funding_amount" + ], + "properties": { + "long_holder_extra_funding_amount": { + "type": "string", + "description": "Per-contract amount credited to long holders (and debited from short\nholders) when the settlement executes. Always non-negative." + }, + "settlement_ts": { + "type": "string", + "format": "date-time", + "description": "Time at which the settlement will be applied. Positions held in the\nsymbol at this instant determine who is credited or debited." + }, + "symbol": { + "type": "string", + "description": "Symbol the settlement applies to." + } + } + }, + "UpdateApiKeyAllowedIpsRequest": { + "type": "object", + "required": [ + "api_key" + ], + "properties": { + "allowed_ips": { + "type": "array", + "items": { + "type": "string" + } + }, + "api_key": { + "type": "string" + } + } + }, + "UpdateApiKeyAllowedIpsResponse": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + }, + "WhoAmIAccount": { + "type": "object", + "required": [ + "id", + "is_close_only", + "maker_fee", + "taker_fee", + "can_list", + "can_read", + "can_set_limits", + "can_reduce_or_close", + "can_trade" + ], + "properties": { + "can_list": { + "type": "boolean" + }, + "can_read": { + "type": "boolean" + }, + "can_reduce_or_close": { + "type": "boolean" + }, + "can_set_limits": { + "type": "boolean" + }, + "can_trade": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "is_close_only": { + "type": "boolean" + }, + "maker_fee": { + "type": "string" + }, + "name": { + "type": [ + "string", + "null" + ], + "description": "Optional, owner-facing nickname for the account." + }, + "taker_fee": { + "type": "string" + } + } + }, + "WhoAmIResponse": { + "type": "object", + "required": [ + "id", + "username", + "pseudonym", + "created_at", + "is_onboarded", + "is_frozen", + "is_admin", + "require_2fa", + "fiat_deposit_code" + ], + "properties": { + "accounts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WhoAmIAccount" + } + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "fiat_deposit_code": { + "type": "string" + }, + "id": { + "type": "string" + }, + "is_admin": { + "type": "boolean" + }, + "is_frozen": { + "type": "boolean" + }, + "is_onboarded": { + "type": "boolean" + }, + "pseudonym": { + "type": "string" + }, + "require_2fa": { + "type": "boolean" + }, + "username": { + "type": "string" + } + } + } + }, + "securitySchemes": { + "admin_session_token": { + "type": "http", + "scheme": "bearer", + "description": "Admin session token" + }, + "admin_token": { + "type": "http", + "scheme": "bearer", + "description": "Admin token" + }, + "session_token": { + "type": "http", + "scheme": "bearer", + "description": "User session token" + } + } + }, + "tags": [ + { + "name": "api-gateway", + "description": "API gateway" + } + ] +} diff --git a/apis/architect-exchange/openapi-order-gateway.json b/apis/architect-exchange/openapi-order-gateway.json new file mode 100644 index 00000000..f65bcd71 --- /dev/null +++ b/apis/architect-exchange/openapi-order-gateway.json @@ -0,0 +1,1347 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "ax-order-gateway", + "description": "", + "license": { + "name": "" + }, + "version": "15.24.0" + }, + "servers": [ + { + "url": "https://gateway.architect.exchange/orders" + } + ], + "paths": { + "/cancel-all-orders": { + "post": { + "tags": [ + "order-gateway" + ], + "operationId": "cancel_all_orders", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CancelAllOrdersRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "All orders canceled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CancelAllOrdersResponse" + } + } + } + }, + "400": { + "description": "Unknown instrument symbol" + } + }, + "security": [ + { + "session_token": [] + } + ] + } + }, + "/cancel-order": { + "post": { + "tags": [ + "order-gateway" + ], + "operationId": "cancel_order", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CancelOrderRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Order cancel requested", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CancelOrderResponse" + } + } + } + }, + "400": { + "description": "Bad request" + } + }, + "security": [ + { + "session_token": [] + } + ] + } + }, + "/health": { + "get": { + "tags": [ + "order-gateway" + ], + "operationId": "health", + "responses": { + "200": { + "description": "Health check", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthResponse" + } + } + } + } + } + } + }, + "/initial-margin-requirement": { + "post": { + "tags": [ + "order-gateway" + ], + "operationId": "initial_margin_requirement", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlaceOrderRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Initial margin requirement preview", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InitialMarginRequirementResponse" + } + } + } + } + }, + "security": [ + { + "session_token": [] + } + ] + } + }, + "/open-orders": { + "get": { + "tags": [ + "order-gateway" + ], + "operationId": "open_orders", + "parameters": [ + { + "name": "account_id", + "in": "query", + "description": "Optional account ID. If omitted, default (primary) user account is used.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "description": "Page size. Defaults to 100.", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + }, + { + "name": "offset", + "in": "query", + "description": "Number of rows to skip. Defaults to 0.", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + }, + { + "name": "sort_ts", + "in": "query", + "description": "Timestamp sort direction. Defaults to desc.", + "required": false, + "schema": { + "$ref": "#/components/schemas/SortDirection" + } + } + ], + "responses": { + "200": { + "description": "List of open orders", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetOpenOrdersRestResponse" + } + } + } + } + }, + "security": [ + { + "session_token": [] + } + ] + } + }, + "/order-fills": { + "get": { + "tags": [ + "order-gateway" + ], + "operationId": "get_order_fills", + "parameters": [ + { + "name": "order_id", + "in": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/OrderId" + } + }, + { + "name": "aid", + "in": "query", + "description": "Optional account ID, selecting which account's fills to return for the\norder. If omitted, the user's default (primary) account is used.", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + } + ], + "responses": { + "200": { + "description": "List of fills associated with an order ID", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetOrderFillsResponse" + } + } + } + } + }, + "security": [ + { + "session_token": [] + } + ] + } + }, + "/order-status": { + "get": { + "tags": [ + "order-gateway" + ], + "operationId": "get_order_status", + "parameters": [ + { + "name": "oid", + "in": "query", + "description": "Server order ID to query; e.g. \"ORD-1234567890\". Mutually exclusive with cid.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "cid", + "in": "query", + "description": "Client order ID to query. Mutually exclusive with oid.", + "required": false, + "schema": { + "$ref": "#/components/schemas/ClientOrderId" + } + }, + { + "name": "aid", + "in": "query", + "description": "Optional account ID selecting which account's cid namespace to resolve against. Defaults to the primary account.", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Status of requested order", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetOrderStatusResponse" + } + } + } + }, + "400": { + "description": "Bad request - exactly one of oid or cid must be provided", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_token": [] + } + ] + } + }, + "/orders": { + "get": { + "tags": [ + "order-gateway" + ], + "description": "Returns historical orders for the authenticated user, newest first by default, paged via the response cursor. The time range is optional and unbounded; an inverted range (`end_timestamp_ns` <= `start_timestamp_ns`) is rejected with a 400. Optionally narrow the result to specific orders via `order_id` and/or `order_ids`. Pages may be returned partial.", + "operationId": "get_orders", + "parameters": [ + { + "name": "symbol", + "in": "query", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "timeseries", + "in": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/TimeseriesPagination" + } + }, + { + "name": "order_states", + "in": "query", + "description": "Optional comma-separated order state filter, e.g. `FILLED,CANCELED,REPLACED`", + "required": false, + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/OrderState" + } + } + }, + { + "name": "order_id", + "in": "query", + "description": "Optional single order ID filter, e.g. `ORD-1234567890`. Convenience alias\nfor a one-element `order_ids`; combined with `order_ids` if both are set.", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "order_ids", + "in": "query", + "description": "Optional comma-separated order ID filter, e.g. `ORD-1,ORD-2`. Combined\nwith `order_id` if both are set.", + "required": false, + "schema": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + }, + { + "name": "account_id", + "in": "query", + "description": "Optional account ID. If omitted, the user's default (primary) account is used.", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + } + ], + "responses": { + "200": { + "description": "List of orders", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetOrdersResponse" + } + } + } + }, + "400": { + "description": "Invalid historical order query", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "session_token": [] + } + ] + } + }, + "/place-order": { + "post": { + "tags": [ + "order-gateway" + ], + "operationId": "place_order", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlaceOrderRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Order placed successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlaceOrderResponse" + } + } + } + }, + "400": { + "description": "Bad request" + } + }, + "security": [ + { + "session_token": [] + } + ] + } + }, + "/preview-order": { + "post": { + "tags": [ + "order-gateway" + ], + "operationId": "preview_order", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlaceOrderRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Order preview with margin and liquidation estimate", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreviewOrderResponse" + } + } + } + } + }, + "security": [ + { + "session_token": [] + } + ] + } + }, + "/replace-order": { + "post": { + "tags": [ + "order-gateway" + ], + "operationId": "replace_order", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReplaceOrderRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Order replaced successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReplaceOrderResponse" + } + } + } + }, + "400": { + "description": "Bad request" + } + }, + "security": [ + { + "session_token": [] + } + ] + } + } + }, + "components": { + "schemas": { + "CancelAllOrdersRequest": { + "type": "object", + "description": "Request to cancel all orders for the authenticated user.", + "properties": { + "account_id": { + "type": [ + "string", + "null" + ], + "description": "Optional account ID. If omitted, the account is inferred from the connection: the\nuser's default account, or the session account for an account-scoped session." + }, + "symbol": { + "type": [ + "string", + "null" + ], + "description": "Optional symbol filter. If provided, only orders for this symbol will be canceled." + } + } + }, + "CancelAllOrdersResponse": { + "type": "object", + "description": "Response for canceling all orders." + }, + "CancelOrderRequest": { + "allOf": [ + { + "$ref": "#/components/schemas/OrderReference", + "description": "Identifier of the order to cancel; either `oid` (server order id) or\n`cid` (client order id)." + }, + { + "type": "object", + "properties": { + "aid": { + "type": [ + "string", + "null" + ], + "description": "Optional account ID, selecting which account's `cid` namespace the\nreference resolves against. Only meaningful when the order is given by\n`cid` — a `cid` is unique per account, not globally — and superfluous\nwhen `oid` is supplied (server order ids are globally unique). If\nomitted, the account is inferred from the connection: the user's default\naccount, or the session account for an account-scoped session." + } + } + } + ] + }, + "CancelOrderResponse": { + "type": "object", + "required": [ + "cxl_rx" + ], + "properties": { + "cxl_rx": { + "type": "boolean", + "description": "Whether the cancel request has been accepted; e.g. true, false" + } + } + }, + "ClientOrderId": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "CursorPage": { + "type": "object", + "description": "Page metadata for cursor-paged responses.\n\nThis is intended for response bodies (not query params).", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "minimum": 0 + }, + "next_cursor": { + "type": [ + "string", + "null" + ] + }, + "total_count": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + } + } + }, + "ErrorResponse": { + "type": "object", + "description": "Standard error response format", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + }, + "Fill": { + "type": "object", + "required": [ + "trade_id", + "account_id", + "timestamp", + "symbol", + "price", + "quantity", + "is_taker", + "fee", + "side" + ], + "properties": { + "account_id": { + "type": "string" + }, + "fee": { + "type": "string" + }, + "is_block_trade": { + "type": "boolean", + "description": "True if this fill was generated by a block trade — a privately\nnegotiated trade booked away from the order book." + }, + "is_final_settlement": { + "type": "boolean", + "description": "True if this fill was generated by final settlement of a delisted or\nexpired contract at the final settlement price; the counterparty is the\nexchange settlement account. Implies `is_block_trade`." + }, + "is_taker": { + "type": "boolean" + }, + "order_id": { + "type": [ + "string", + "null" + ] + }, + "price": { + "type": "string" + }, + "quantity": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "realized_pnl": { + "type": [ + "string", + "null" + ] + }, + "side": { + "$ref": "#/components/schemas/Side" + }, + "symbol": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "trade_id": { + "type": "string" + } + } + }, + "GetOpenOrdersRestResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/LimitOffsetPage" + }, + { + "type": "object", + "required": [ + "orders" + ], + "properties": { + "orders": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OrderDetails" + } + } + } + } + ] + }, + "GetOrderFillsResponse": { + "type": "object", + "required": [ + "fills" + ], + "properties": { + "fills": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Fill" + } + } + } + }, + "GetOrderStatusResponse": { + "type": "object", + "required": [ + "status" + ], + "properties": { + "status": { + "$ref": "#/components/schemas/OrderStatus" + } + } + }, + "GetOrdersResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/CursorPage" + }, + { + "type": "object", + "required": [ + "orders" + ], + "properties": { + "orders": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OrderDetails" + } + } + } + } + ] + }, + "HealthResponse": { + "type": "object", + "description": "Service health response", + "required": [ + "status", + "timestamp" + ], + "properties": { + "environment": { + "type": [ + "string", + "null" + ] + }, + "status": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "version": { + "type": [ + "string", + "null" + ] + } + } + }, + "InitialMarginRequirementResponse": { + "type": "object", + "required": [ + "im_pct", + "im", + "pos", + "mult" + ], + "properties": { + "im": { + "type": "string", + "description": "Initial margin requirement for the order; e.g. \"1000.00\"" + }, + "im_pct": { + "type": "string", + "description": "Initial margin percentage for the order symbol" + }, + "mult": { + "type": "string", + "description": "Multiplier for the order symbol" + }, + "pos": { + "type": "integer", + "format": "int64", + "description": "Current signed position in the order symbol" + } + } + }, + "LimitOffsetPage": { + "type": "object", + "description": "Page metadata for limit/offset paged responses.\n\nThis is intended for response bodies (not query params).", + "required": [ + "total_count", + "limit", + "offset" + ], + "properties": { + "limit": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "offset": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "total_count": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "OrderDetails": { + "allOf": [ + { + "$ref": "#/components/schemas/Timestamp" + }, + { + "type": "object", + "required": [ + "oid", + "u", + "aid", + "s", + "p", + "q", + "xq", + "rq", + "o", + "d", + "tif" + ], + "properties": { + "aid": { + "type": "string", + "description": "Owning account — whose positions, balance, and risk this order moves." + }, + "cid": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ClientOrderId" + } + ] + }, + "d": { + "$ref": "#/components/schemas/Side" + }, + "o": { + "$ref": "#/components/schemas/OrderState" + }, + "oid": { + "$ref": "#/components/schemas/OrderId" + }, + "p": { + "type": "string" + }, + "po": { + "type": "boolean" + }, + "q": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "r": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/OrderRejectReason" + } + ] + }, + "rq": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "s": { + "type": "string" + }, + "tag": { + "type": [ + "string", + "null" + ] + }, + "tif": { + "type": "string" + }, + "txt": { + "type": [ + "string", + "null" + ] + }, + "u": { + "type": "string", + "description": "Actor of record — the authenticated user who *placed* this order. This\nis distinct from the owning `account_id`: a user may place orders on an\naccount they do not own (e.g. a proxy or algo acting for another party)." + }, + "xq": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + } + ] + }, + "OrderId": { + "type": "string", + "description": "Strong type for Order IDs to prevent mixing with other string values\n\nOrder IDs are ULIDs with a prefix:\n\n- Regular orders: O-\n- Liquidation orders: L-" + }, + "OrderReference": { + "oneOf": [ + { + "type": "object", + "required": [ + "oid" + ], + "properties": { + "oid": { + "$ref": "#/components/schemas/OrderId" + } + } + }, + { + "type": "object", + "required": [ + "cid" + ], + "properties": { + "cid": { + "$ref": "#/components/schemas/ClientOrderId" + } + } + } + ] + }, + "OrderRejectReason": { + "type": "string", + "enum": [ + "CLOSE_ONLY", + "INSUFFICIENT_MARGIN", + "MAX_OPEN_ORDERS_EXCEEDED", + "UNKNOWN_SYMBOL", + "EXCHANGE_CLOSED", + "INCORRECT_QUANTITY", + "INVALID_PRICE_INCREMENT", + "INCORRECT_ORDER_TYPE", + "PRICE_OUT_OF_BOUNDS", + "NO_LIQUIDITY", + "INSUFFICIENT_CREDIT_LIMIT", + "ORIGINAL_ORDER_TERMINATED", + "DUPLICATE_CLIENT_ORDER_ID", + "UNKNOWN" + ] + }, + "OrderState": { + "type": "string", + "enum": [ + "PENDING", + "ACCEPTED", + "PARTIALLY_FILLED", + "FILLED", + "CANCELED", + "REJECTED", + "EXPIRED", + "REPLACED", + "DONE_FOR_DAY", + "UNKNOWN" + ] + }, + "OrderStatus": { + "type": "object", + "required": [ + "symbol", + "order_id", + "state" + ], + "properties": { + "clord_id": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ClientOrderId" + } + ] + }, + "filled_quantity": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + }, + "order_id": { + "type": "string" + }, + "reject_message": { + "type": [ + "string", + "null" + ] + }, + "reject_reason": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/OrderRejectReason" + } + ] + }, + "remaining_quantity": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + }, + "state": { + "$ref": "#/components/schemas/OrderState" + }, + "symbol": { + "type": "string" + } + } + }, + "PlaceOrderRequest": { + "type": "object", + "required": [ + "s", + "d", + "q", + "p", + "tif" + ], + "properties": { + "aid": { + "type": [ + "string", + "null" + ], + "description": "Optional account ID. If omitted, the account is inferred from the connection: the\nuser's default account, or the session account for an account-scoped session." + }, + "cid": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ClientOrderId", + "description": "Optional client order ID; 64 bit integer" + } + ] + }, + "d": { + "$ref": "#/components/schemas/Side", + "description": "Order side; buying (\"B\") or selling (\"S\")" + }, + "p": { + "type": "string", + "description": "Order price in USD as decimal string; e.g. \"1.2345\"" + }, + "po": { + "type": "boolean", + "description": "Post-only (\"maker-or-cancel\"): `false` (default) lets the order take;\n`true` makes it maker-only. When post-only, `reprice_behavior` selects\nwhat happens if the order would cross on entry (default: reject)." + }, + "q": { + "type": "integer", + "format": "int64", + "description": "Order quantity in contracts; e.g. 100, 1000", + "minimum": 0 + }, + "rb": { + "$ref": "#/components/schemas/RepriceBehavior", + "description": "Reprice behavior for an aggressive post-only order. Meaningful only when\n`post_only` is `true`; ignored otherwise. Defaults to `Reject` (the order\nis rejected if it would cross on entry)." + }, + "s": { + "type": "string", + "description": "Order symbol; e.g. XAU-PERP, EURUSD-PERP" + }, + "st": { + "$ref": "#/components/schemas/SelfTradeBehavior", + "description": "Self-trade prevention behavior. Defaults to `CancelIncoming`.\n\nThe short aliases `xi`, `xr`, and `xb` are also accepted for `CancelIncoming`,\n`CancelResting`, and `CancelBoth`, respectively.\n\n- `CancelIncoming`: cancel the incoming aggressor order; resting orders remain on the book.\n- `CancelResting`: cancel resting orders that would self-match; allow the incoming aggressor order.\n- `CancelBoth`: cancel both the resting orders and the incoming aggressor order." + }, + "tag": { + "type": [ + "string", + "null" + ], + "description": "Optional order tag; maximum 10 alphanumeric characters" + }, + "tif": { + "type": "string", + "description": "Order time in force; e.g. \"GTC\", \"IOC\".\n\"DAY\" is accepted but deprecated and will be removed in a future release — use \"GTC\" instead." + } + } + }, + "PlaceOrderResponse": { + "type": "object", + "required": [ + "oid" + ], + "properties": { + "oid": { + "type": "string", + "description": "Order ID of the placed order; e.g. \"ORD-1234567890\"" + } + } + }, + "PreviewOrderResponse": { + "type": "object", + "required": [ + "im_pct", + "im", + "pos_before", + "pos_after" + ], + "properties": { + "im": { + "type": "string", + "description": "Additional initial margin required to place this order; zero if the\norder would reduce the overall margin requirement (e.g. a closing trade)" + }, + "im_pct": { + "type": "string", + "description": "Initial margin percentage for the instrument (e.g. 10 means 10% IM)" + }, + "liq": { + "type": [ + "string", + "null" + ], + "description": "Estimated liquidation price after the order fills, based on current\nequity and maintenance margin; None if the resulting position is flat" + }, + "pos_after": { + "type": "integer", + "format": "int64", + "description": "Projected signed position in the symbol after the order fills" + }, + "pos_before": { + "type": "integer", + "format": "int64", + "description": "Current signed position in the symbol before the order fills" + } + } + }, + "ReplaceOrderRequest": { + "allOf": [ + { + "$ref": "#/components/schemas/OrderReference", + "description": "Identifier of the order to replace; either `oid` (server order id) or\n`cid` (client order id)." + }, + { + "type": "object", + "properties": { + "aid": { + "type": [ + "string", + "null" + ], + "description": "Optional account ID, selecting which account's `cid` namespace the\nreference resolves against. Only meaningful when the order is given by\n`cid` — a `cid` is unique per account, not globally — and superfluous\nwhen `oid` is supplied (server order ids are globally unique). If\nomitted, the account is inferred from the connection: the user's default\naccount, or the session account for an account-scoped session." + }, + "p": { + "type": [ + "string", + "null" + ], + "description": "New price for the replacement order (optional, inherits from original if not provided)" + }, + "po": { + "type": [ + "boolean", + "null" + ], + "description": "Post-only flag for the replacement order (optional, inherits from\noriginal if not provided). When set, `reprice_behavior` selects the\ncross-on-entry behavior just as on a place." + }, + "q": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "New quantity for the replacement order (optional, inherits from original if not provided)", + "minimum": 0 + }, + "rb": { + "$ref": "#/components/schemas/RepriceBehavior", + "description": "Reprice behavior for the replacement order. Applied only when `post_only`\nis explicitly set to `true`; ignored otherwise. Defaults to `Reject`." + }, + "tif": { + "type": [ + "string", + "null" + ], + "description": "New time in force for the replacement order (optional, inherits from original if not provided)" + } + } + } + ] + }, + "ReplaceOrderResponse": { + "type": "object", + "required": [ + "oid" + ], + "properties": { + "oid": { + "$ref": "#/components/schemas/OrderId", + "description": "Order ID of the new replacement order; e.g. \"ORD-1234567890\"" + } + } + }, + "RepriceBehavior": { + "type": "string", + "description": "Reprice behavior for an aggressive post-only order — the wire `rb` field,\nmeaningful only alongside `po = true`. Defaults to `Reject`.", + "enum": [ + "rej", + "bo", + "tbl" + ] + }, + "SelfTradeBehavior": { + "type": "string", + "description": "Controls how the matching engine handles an order that would trade against the\nsubmitter's own resting orders. Defaults to `CancelIncoming`.", + "enum": [ + "CancelIncoming", + "CancelResting", + "CancelBoth" + ] + }, + "Side": { + "type": "string", + "enum": [ + "B", + "S" + ] + }, + "Timestamp": { + "type": "object", + "required": [ + "ts", + "tn" + ], + "properties": { + "tn": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "ts": { + "type": "integer", + "format": "int32" + } + } + } + }, + "securitySchemes": { + "admin_session_token": { + "type": "http", + "scheme": "bearer", + "description": "Admin session token" + }, + "admin_token": { + "type": "http", + "scheme": "bearer", + "description": "Admin token" + }, + "session_token": { + "type": "http", + "scheme": "bearer", + "description": "User session token" + } + } + }, + "tags": [ + { + "name": "order-gateway", + "description": "Order gateway" + } + ] +} diff --git a/apis/tau2_retail/domain.yaml b/apis/tau2_retail/domain.yaml index dd9d9dee..45c0274c 100644 --- a/apis/tau2_retail/domain.yaml +++ b/apis/tau2_retail/domain.yaml @@ -1,4 +1,4 @@ -version: 3 +version: 4 auth: scheme: none http_backend: http://localhost:1080 @@ -340,7 +340,9 @@ values: nv_variant_available: type: boolean nv_variant_price: - type: number + type: money + value_format: + money: json_number nv_variant_product_id: type: entity_ref target: Product diff --git a/apis/vultr/domain.yaml b/apis/vultr/domain.yaml index e73899ff..133dc42d 100644 --- a/apis/vultr/domain.yaml +++ b/apis/vultr/domain.yaml @@ -1,4 +1,4 @@ -version: 19 +version: 20 http_backend: https://api.vultr.com data_classes: pii_email: @@ -30,13 +30,13 @@ entities: value_ref: nv_wire_str_short balance: required: false - value_ref: nv_wire_num + value_ref: nv_money_usd pending_charges: required: false - value_ref: nv_wire_num + value_ref: nv_money_usd last_payment_amount: required: false - value_ref: nv_wire_num + value_ref: nv_money_usd last_payment_date: required: false value_ref: nv_wire_date_rfc3339 @@ -131,10 +131,10 @@ entities: value_ref: nv_plan_locations monthly_cost: required: false - value_ref: nv_wire_num + value_ref: nv_money_usd hourly_cost: required: false - value_ref: nv_wire_num + value_ref: nv_money_usd relations: {} BareMetalPlan: id_field: id @@ -229,10 +229,10 @@ entities: value_ref: nv_wire_str_short amount: required: false - value_ref: nv_wire_num + value_ref: nv_money_usd balance: required: false - value_ref: nv_wire_num + value_ref: nv_money_usd date: required: false value_ref: nv_wire_date_rfc3339 @@ -249,7 +249,7 @@ entities: value_ref: nv_wire_str_short amount: required: false - value_ref: nv_wire_num + value_ref: nv_money_usd date: required: false value_ref: nv_wire_date_rfc3339 @@ -548,7 +548,7 @@ entities: value_ref: nv_wire_int pending_charges: required: false - value_ref: nv_wire_num + value_ref: nv_money_usd firewall_group_id: required: false value_ref: nv_instance_firewall_group_id @@ -615,7 +615,7 @@ entities: value_ref: nv_wire_date_rfc3339 pending_charges: required: false - value_ref: nv_wire_num + value_ref: nv_money_usd has_ssl: required: false value_ref: nv_wire_bool @@ -731,7 +731,7 @@ entities: value_ref: nv_wire_date_rfc3339 cost: required: false - value_ref: nv_wire_num + value_ref: nv_money_usd status: required: false value_ref: nv_wire_sel_302f7ca29e8dda16 @@ -912,7 +912,7 @@ entities: data_class: credentials pending_charges: required: false - value_ref: nv_wire_num + value_ref: nv_money_usd relations: {} ManagedDatabase: id_field: id @@ -993,7 +993,7 @@ entities: value_ref: nv_wire_str_short pending_charges: required: false - value_ref: nv_wire_num + value_ref: nv_money_usd relations: {} KubernetesCluster: id_field: id @@ -3230,6 +3230,11 @@ values: value_format: rfc3339 nv_wire_int: type: integer + nv_money_usd: + type: money + value_format: + money: json_number + currency: USD nv_wire_num: type: number nv_wire_sel_0780285c9fcb6c03: diff --git a/crates/plasm-agent-core/src/cli_builder.rs b/crates/plasm-agent-core/src/cli_builder.rs index 006edb38..e875ea47 100644 --- a/crates/plasm-agent-core/src/cli_builder.rs +++ b/crates/plasm-agent-core/src/cli_builder.rs @@ -639,6 +639,7 @@ mod tests { allowed_values: None, string_semantics: Some(StringSemantics::Short), array_items: None, + currency: None, }, ); } @@ -652,6 +653,7 @@ mod tests { allowed_values: None, string_semantics: None, array_items: None, + currency: None, }, ); } @@ -665,6 +667,7 @@ mod tests { allowed_values: Some(allowed), string_semantics: None, array_items: None, + currency: None, }, ); } @@ -678,6 +681,7 @@ mod tests { allowed_values: None, string_semantics: None, array_items: None, + currency: None, }, ); } @@ -691,6 +695,7 @@ mod tests { allowed_values: None, string_semantics: None, array_items: None, + currency: None, }, ); } @@ -739,6 +744,7 @@ mod tests { attachment_media: None, wire_path: None, derive: None, + currency_field: None, }, FieldSchema { name: "name".into(), @@ -753,6 +759,7 @@ mod tests { attachment_media: None, wire_path: None, derive: None, + currency_field: None, }, FieldSchema { name: "revenue".into(), @@ -767,6 +774,7 @@ mod tests { attachment_media: None, wire_path: None, derive: None, + currency_field: None, }, FieldSchema { name: "region".into(), @@ -781,6 +789,7 @@ mod tests { attachment_media: None, wire_path: None, derive: None, + currency_field: None, }, ], relations: vec![RelationSchema { @@ -821,6 +830,7 @@ mod tests { attachment_media: None, wire_path: None, derive: None, + currency_field: None, }, FieldSchema { name: "name".into(), @@ -835,6 +845,7 @@ mod tests { attachment_media: None, wire_path: None, derive: None, + currency_field: None, }, FieldSchema { name: "role".into(), @@ -849,6 +860,7 @@ mod tests { attachment_media: None, wire_path: None, derive: None, + currency_field: None, }, ], relations: vec![], @@ -1139,6 +1151,7 @@ mod tests { attachment_media: None, wire_path: None, derive: None, + currency_field: None, }, FieldSchema { name: "balance".into(), @@ -1153,6 +1166,7 @@ mod tests { attachment_media: None, wire_path: None, derive: None, + currency_field: None, }, ], relations: vec![], @@ -1224,6 +1238,7 @@ mod tests { attachment_media: None, wire_path: None, derive: None, + currency_field: None, }, FieldSchema { name: "petId".into(), @@ -1238,6 +1253,7 @@ mod tests { attachment_media: None, wire_path: None, derive: None, + currency_field: None, }, ], relations: vec![], @@ -1269,6 +1285,7 @@ mod tests { attachment_media: None, wire_path: None, derive: None, + currency_field: None, }, FieldSchema { name: "name".into(), @@ -1283,6 +1300,7 @@ mod tests { attachment_media: None, wire_path: None, derive: None, + currency_field: None, }, ], relations: vec![], diff --git a/crates/plasm-agent-core/src/dispatch.rs b/crates/plasm-agent-core/src/dispatch.rs index 54b71334..6fe79eeb 100644 --- a/crates/plasm-agent-core/src/dispatch.rs +++ b/crates/plasm-agent-core/src/dispatch.rs @@ -802,6 +802,7 @@ mod tests { allowed_values: None, string_semantics: sem, array_items: None, + currency: None, }, ); } @@ -825,6 +826,7 @@ mod tests { attachment_media: None, wire_path: None, derive: None, + currency_field: None, }, FieldSchema { name: "balance".into(), @@ -839,6 +841,7 @@ mod tests { attachment_media: None, wire_path: None, derive: None, + currency_field: None, }, ], relations: vec![], @@ -917,6 +920,7 @@ mod tests { allowed_values: None, string_semantics: Some(StringSemantics::Short), array_items: None, + currency: None, }, ); cgs.add_resource(ResourceSchema { @@ -938,6 +942,7 @@ mod tests { attachment_media: None, wire_path: None, derive: None, + currency_field: None, }], relations: vec![], expression_aliases: vec![], @@ -1018,6 +1023,7 @@ mod tests { allowed_values: None, string_semantics: Some(StringSemantics::Short), array_items: None, + currency: None, }, ); } @@ -1030,6 +1036,7 @@ mod tests { allowed_values: None, string_semantics: None, array_items: None, + currency: None, }, ); let mk = |n: &str, vk: &str| FieldSchema { @@ -1043,6 +1050,7 @@ mod tests { attachment_media: None, wire_path: None, derive: None, + currency_field: None, }; let mk_int = |n: &str, vk: &str| FieldSchema { name: n.into(), @@ -1055,6 +1063,7 @@ mod tests { attachment_media: None, wire_path: None, derive: None, + currency_field: None, }; cgs.add_resource(ResourceSchema { name: "Issue".into(), diff --git a/crates/plasm-agent-core/src/invoke_args.rs b/crates/plasm-agent-core/src/invoke_args.rs index d9940d9a..1c0fd8d1 100644 --- a/crates/plasm-agent-core/src/invoke_args.rs +++ b/crates/plasm-agent-core/src/invoke_args.rs @@ -52,6 +52,7 @@ mod tests { allowed_values: None, string_semantics: Some(StringSemantics::Short), array_items: None, + currency: None, }, ); cgs.values.insert( @@ -63,6 +64,7 @@ mod tests { allowed_values: None, string_semantics: None, array_items: None, + currency: None, }, ); cgs.values.insert( @@ -74,6 +76,7 @@ mod tests { allowed_values: Some(vec!["low".into(), "medium".into(), "high".into()]), string_semantics: None, array_items: None, + currency: None, }, ); cgs diff --git a/crates/plasm-agent-core/src/output/mod.rs b/crates/plasm-agent-core/src/output/mod.rs index 603a955a..b0d5269f 100644 --- a/crates/plasm-agent-core/src/output/mod.rs +++ b/crates/plasm-agent-core/src/output/mod.rs @@ -671,6 +671,7 @@ mod tests { allowed_values: None, string_semantics: Some(StringSemantics::Markdown), array_items: None, + currency: None, }, ); cgs.add_resource(ResourceSchema { @@ -690,6 +691,7 @@ mod tests { attachment_media: None, wire_path: None, derive: None, + currency_field: None, }], relations: vec![], expression_aliases: vec![], @@ -741,6 +743,7 @@ mod tests { allowed_values: None, string_semantics: Some(sem), array_items: None, + currency: None, }, ); } @@ -764,6 +767,7 @@ mod tests { attachment_media: None, wire_path: None, derive: None, + currency_field: None, }, FieldSchema { name: "name".into(), @@ -778,6 +782,7 @@ mod tests { attachment_media: None, wire_path: None, derive: None, + currency_field: None, }, FieldSchema { name: "desc".into(), @@ -792,6 +797,7 @@ mod tests { attachment_media: None, wire_path: None, derive: None, + currency_field: None, }, ], relations: vec![], @@ -918,6 +924,7 @@ mod tests { allowed_values: None, string_semantics: Some(StringSemantics::Short), array_items: None, + currency: None, }, ); cgs.values.insert( @@ -929,6 +936,7 @@ mod tests { allowed_values: None, string_semantics: None, array_items: None, + currency: None, }, ); cgs.add_resource(ResourceSchema { @@ -951,6 +959,7 @@ mod tests { attachment_media: None, wire_path: None, derive: None, + currency_field: None, }, FieldSchema { name: "content".into(), @@ -965,6 +974,7 @@ mod tests { attachment_media: None, wire_path: None, derive: None, + currency_field: None, }, ], relations: vec![], diff --git a/crates/plasm-agent-core/src/plasm_plan_run/compute_eval/eval.rs b/crates/plasm-agent-core/src/plasm_plan_run/compute_eval/eval.rs index 924ffc3f..f6ad0113 100644 --- a/crates/plasm-agent-core/src/plasm_plan_run/compute_eval/eval.rs +++ b/crates/plasm-agent-core/src/plasm_plan_run/compute_eval/eval.rs @@ -553,26 +553,8 @@ pub(crate) fn resolve_template_path<'a>( .and_then(|input| value_at_dotted(&input.row, rest)) } -pub(crate) fn json_to_plasm_value(v: &serde_json::Value) -> Value { - match v { - serde_json::Value::Null => Value::Null, - serde_json::Value::Bool(b) => Value::Bool(*b), - serde_json::Value::Number(n) => n - .as_i64() - .map(Value::Integer) - .or_else(|| n.as_f64().map(Value::Float)) - .unwrap_or(Value::Null), - serde_json::Value::String(s) => Value::String(s.clone()), - serde_json::Value::Array(items) => { - Value::Array(items.iter().map(json_to_plasm_value).collect()) - } - serde_json::Value::Object(obj) => Value::Object( - obj.iter() - .map(|(k, v)| (k.clone(), json_to_plasm_value(v))) - .collect::>(), - ), - } -} +pub(crate) use plasm_core::json_value_to_plasm_value as json_to_plasm_value; + pub(crate) fn synthetic_projection(node: &ValidatedPlanNode) -> Option> { match node { ValidatedPlanNode::Compute(compute) => Some( diff --git a/crates/plasm-agent-core/src/query_args.rs b/crates/plasm-agent-core/src/query_args.rs index 118332f6..a0934087 100644 --- a/crates/plasm-agent-core/src/query_args.rs +++ b/crates/plasm-agent-core/src/query_args.rs @@ -71,6 +71,7 @@ mod tests { allowed_values: Some(vec!["available".into(), "pending".into(), "sold".into()]), string_semantics: None, array_items: None, + currency: None, }, ); add( @@ -82,6 +83,7 @@ mod tests { allowed_values: Some(vec!["available".into()]), string_semantics: None, array_items: None, + currency: None, }, ); add( @@ -93,6 +95,7 @@ mod tests { allowed_values: None, string_semantics: Some(StringSemantics::Short), array_items: None, + currency: None, }, ); add( @@ -104,6 +107,7 @@ mod tests { allowed_values: None, string_semantics: Some(StringSemantics::Short), array_items: None, + currency: None, }, ); add( @@ -115,6 +119,7 @@ mod tests { allowed_values: None, string_semantics: None, array_items: None, + currency: None, }, ); add( @@ -126,6 +131,7 @@ mod tests { allowed_values: Some(vec!["available".into()]), string_semantics: None, array_items: None, + currency: None, }, ); add( @@ -137,6 +143,7 @@ mod tests { allowed_values: Some(vec!["EMEA".into(), "APAC".into(), "AMER".into()]), string_semantics: None, array_items: None, + currency: None, }, ); add( @@ -148,6 +155,7 @@ mod tests { allowed_values: None, string_semantics: None, array_items: None, + currency: None, }, ); add( @@ -159,6 +167,7 @@ mod tests { allowed_values: Some(vec!["EMEA".into()]), string_semantics: None, array_items: None, + currency: None, }, ); cgs diff --git a/crates/plasm-agent-core/src/run_ui_column_schema.rs b/crates/plasm-agent-core/src/run_ui_column_schema.rs index 9e2b7bc4..482957fc 100644 --- a/crates/plasm-agent-core/src/run_ui_column_schema.rs +++ b/crates/plasm-agent-core/src/run_ui_column_schema.rs @@ -125,6 +125,7 @@ fn field_type_wire_label(ft: &FieldType) -> &'static str { FieldType::Select => "select", FieldType::MultiSelect => "multi_select", FieldType::Date => "date", + FieldType::Money => "money", FieldType::Array => "array", FieldType::Json => "json", FieldType::EntityRef { .. } => "entity_ref", diff --git a/crates/plasm-agent-core/src/tool_model.rs b/crates/plasm-agent-core/src/tool_model.rs index fa545fb3..ce05e9b0 100644 --- a/crates/plasm-agent-core/src/tool_model.rs +++ b/crates/plasm-agent-core/src/tool_model.rs @@ -464,16 +464,11 @@ fn type_label_from_parts( "multi-select".into() } } - FieldType::Boolean => "boolean".into(), - FieldType::Number => "number · f64".into(), - FieldType::Integer => "integer · i64".into(), - FieldType::Uuid => "uuid".into(), FieldType::String => match string_subtype_keyword_from_semantics(string_semantics) { None => "string".into(), Some(kw) => format!("string · {kw}"), }, FieldType::Blob => "blob · binary".into(), - FieldType::Date => "date".into(), FieldType::Array => { if let Some(items) = array_items { format!("array[{}]", field_type_compact_label(&items.field_type)) @@ -481,7 +476,7 @@ fn type_label_from_parts( "array".into() } } - FieldType::Json => "json · object".into(), + _ => field_type_compact_label(field_type), } } @@ -569,6 +564,7 @@ fn field_type_compact_label(ft: &FieldType) -> String { FieldType::Select => "select".into(), FieldType::MultiSelect => "multi-select".into(), FieldType::Date => "date".into(), + FieldType::Money => "money".into(), FieldType::Array => "array".into(), FieldType::Json => "json · object".into(), FieldType::EntityRef { target } => format!("entity_ref → {target}"), diff --git a/crates/plasm-cli/src/bin/mock_server_demo.rs b/crates/plasm-cli/src/bin/mock_server_demo.rs index 7b44fb8a..6e27d5e3 100644 --- a/crates/plasm-cli/src/bin/mock_server_demo.rs +++ b/crates/plasm-cli/src/bin/mock_server_demo.rs @@ -22,6 +22,7 @@ async fn main() -> Result<(), Box> { allowed_values: None, string_semantics: Some(StringSemantics::Short), array_items: None, + currency: None, }, ), ( @@ -33,6 +34,7 @@ async fn main() -> Result<(), Box> { allowed_values: None, string_semantics: Some(StringSemantics::Short), array_items: None, + currency: None, }, ), ( @@ -44,6 +46,7 @@ async fn main() -> Result<(), Box> { allowed_values: None, string_semantics: None, array_items: None, + currency: None, }, ), ( @@ -59,6 +62,7 @@ async fn main() -> Result<(), Box> { ]), string_semantics: None, array_items: None, + currency: None, }, ), ]); @@ -81,6 +85,7 @@ async fn main() -> Result<(), Box> { wire_path: None, derive: None, data_class: None, + currency_field: None, }, FieldSchema { name: "name".into(), @@ -93,6 +98,7 @@ async fn main() -> Result<(), Box> { wire_path: None, derive: None, data_class: None, + currency_field: None, }, FieldSchema { name: "revenue".into(), @@ -105,6 +111,7 @@ async fn main() -> Result<(), Box> { wire_path: None, derive: None, data_class: None, + currency_field: None, }, FieldSchema { name: "region".into(), @@ -117,6 +124,7 @@ async fn main() -> Result<(), Box> { wire_path: None, derive: None, data_class: None, + currency_field: None, }, ], relations: vec![], diff --git a/crates/plasm-cli/src/commands/er_diagram.rs b/crates/plasm-cli/src/commands/er_diagram.rs index 718062c7..3e653f8c 100644 --- a/crates/plasm-cli/src/commands/er_diagram.rs +++ b/crates/plasm-cli/src/commands/er_diagram.rs @@ -101,6 +101,7 @@ fn field_type_mermaid(ft: &FieldType) -> String { FieldType::Blob => "blob".to_string(), FieldType::Json => "json".to_string(), FieldType::Date => "date".to_string(), + FieldType::Money => "money".to_string(), FieldType::Array => "string".to_string(), FieldType::EntityRef { target } => { format!("ref_{}", sanitize_type_prefix(target.as_str())) diff --git a/crates/plasm-cli/src/commands/validate.rs b/crates/plasm-cli/src/commands/validate.rs index a19aa843..fa0373fa 100644 --- a/crates/plasm-cli/src/commands/validate.rs +++ b/crates/plasm-cli/src/commands/validate.rs @@ -3,7 +3,8 @@ use indexmap::IndexMap; use plasm_compile::CmlRequest; use plasm_core::{ CapabilityKind, CreateExpr, DeleteExpr, Expr, FieldType, GetExpr, InputFieldSchema, - InputFieldWire, InputType, InvokeExpr, Predicate, QueryExpr, QueryPagination, Value, CGS, + InputFieldWire, InputType, InvokeExpr, MoneyWireFormat, Predicate, QueryExpr, QueryPagination, + Value, ValueWireFormat, CGS, }; use plasm_runtime::{ ExecuteOptions, ExecutionConfig, ExecutionEngine, ExecutionMode, SessionMaterialization, @@ -525,6 +526,7 @@ fn fake_value_for_input_field(f: &InputFieldSchema, cgs: &CGS) -> Option Some(fake_value_for_type( &nv.field_type, nv.allowed_values.as_deref(), + nv.value_format.as_ref(), )) } InputFieldWire::Inline(ty) => Some(fake_value_for_input_type(ty.as_ref(), cgs)), @@ -537,7 +539,7 @@ fn fake_value_for_input_type(ty: &InputType, cgs: &CGS) -> Value { InputType::Value { field_type, allowed_values, - } => fake_value_for_type(field_type, allowed_values.as_deref()), + } => fake_value_for_type(field_type, allowed_values.as_deref(), None), InputType::Object { fields, .. } => { let mut m = IndexMap::new(); for field in fields.iter().filter(|x| x.required) { @@ -613,7 +615,11 @@ fn build_fake_input(cap: &plasm_core::CapabilitySchema, cgs: &plasm_core::CGS) - } } -fn fake_value_for_type(ft: &FieldType, allowed: Option<&[String]>) -> Value { +fn fake_value_for_type( + ft: &FieldType, + allowed: Option<&[String]>, + value_format: Option<&ValueWireFormat>, +) -> Value { if let Some(vals) = allowed { if let Some(first) = vals.first() { return Value::String(first.clone()); @@ -622,6 +628,14 @@ fn fake_value_for_type(ft: &FieldType, allowed: Option<&[String]>) -> Value { match ft { FieldType::Integer => Value::Integer(1), FieldType::Number => Value::Float(1.0), + FieldType::Money => { + let fmt = match value_format { + Some(ValueWireFormat::Money(m)) => *m, + _ => MoneyWireFormat::decimal_string(), + }; + plasm_core::money::normalize(Value::String("1".into()), fmt, None) + .unwrap_or_else(|_| Value::String("1".into())) + } FieldType::Boolean => Value::Bool(false), FieldType::EntityRef { .. } => Value::String("1".into()), _ => Value::String("plasm-test".into()), diff --git a/crates/plasm-cml/src/cml.rs b/crates/plasm-cml/src/cml.rs index bc4d6efd..4e1ea7b4 100644 --- a/crates/plasm-cml/src/cml.rs +++ b/crates/plasm-cml/src/cml.rs @@ -712,13 +712,16 @@ pub fn eval_cml(expr: &CmlExpr, env: &CmlEnv) -> Result { let joined = arr .iter() .map(|v| match v { - Value::String(s) => s.clone(), - Value::Integer(i) => i.to_string(), - Value::Float(f) => f.to_string(), - Value::Bool(b) => b.to_string(), - other => format!("{:?}", other), + Value::String(s) => Ok(s.clone()), + Value::Integer(i) => Ok(i.to_string()), + Value::Float(f) => Ok(f.to_string()), + Value::Bool(b) => Ok(b.to_string()), + Value::Money(m) => m + .to_wire_text() + .map_err(|e| CmlError::SerializationError { message: e.into() }), + other => Ok(format!("{:?}", other)), }) - .collect::>() + .collect::, _>>()? .join(sep); Ok(Value::String(joined)) } @@ -743,7 +746,7 @@ pub fn eval_cml(expr: &CmlExpr, env: &CmlEnv) -> Result { message: format!("missing format var '{name}' for template '{template}'"), })?; let value = eval_cml(expr, env)?; - let replacement = value_to_string(&value); + let replacement = value_to_string(&value)?; rendered = rendered.replace(&format!("{{{name}}}"), &replacement); } Ok(Value::String(rendered)) @@ -758,7 +761,7 @@ pub fn eval_cml(expr: &CmlExpr, env: &CmlEnv) -> Result { let inner = eval_cml(value, env)?; let text = match inner { Value::String(s) => s, - other => value_to_string(&other), + other => value_to_string(&other)?, }; use base64::Engine; Ok(Value::String( @@ -768,15 +771,18 @@ pub fn eval_cml(expr: &CmlExpr, env: &CmlEnv) -> Result { } } -fn value_to_string(value: &Value) -> String { +fn value_to_string(value: &Value) -> Result { match value { - Value::PlasmInputRef(_) => format!("{value:?}"), - Value::String(s) | Value::PhraseIdent(s) => s.clone(), - Value::Integer(i) => i.to_string(), - Value::Float(f) => f.to_string(), - Value::Bool(b) => b.to_string(), - Value::Null => "null".to_string(), - Value::Array(_) | Value::Object(_) | Value::UnionCtor { .. } => format!("{:?}", value), + Value::PlasmInputRef(_) => Ok(format!("{value:?}")), + Value::String(s) | Value::PhraseIdent(s) => Ok(s.clone()), + Value::Integer(i) => Ok(i.to_string()), + Value::Float(f) => Ok(f.to_string()), + Value::Bool(b) => Ok(b.to_string()), + Value::Null => Ok("null".to_string()), + Value::Money(m) => m + .to_wire_text() + .map_err(|e| CmlError::SerializationError { message: e.into() }), + Value::Array(_) | Value::Object(_) | Value::UnionCtor { .. } => Ok(format!("{:?}", value)), } } diff --git a/crates/plasm-cml/src/evm_transport.rs b/crates/plasm-cml/src/evm_transport.rs index 13b7db6f..bd758426 100644 --- a/crates/plasm-cml/src/evm_transport.rs +++ b/crates/plasm-cml/src/evm_transport.rs @@ -279,11 +279,13 @@ pub fn coerce_dyn_value(value: &Value, ty: &DynSolType) -> Result Err(CmlError::EvaluationError { message: format!("cannot coerce null to solidity type '{ty}'"), }), - Value::Array(_) | Value::Object(_) | Value::UnionCtor { .. } => Err(CmlError::TypeError { - message: format!( - "complex CML values are not yet supported for solidity type coercion ('{ty}')" - ), - }), + Value::Array(_) | Value::Object(_) | Value::UnionCtor { .. } | Value::Money(_) => { + Err(CmlError::TypeError { + message: format!( + "complex CML values are not yet supported for solidity type coercion ('{ty}')" + ), + }) + } } } diff --git a/crates/plasm-compile/src/decoder.rs b/crates/plasm-compile/src/decoder.rs index 4f7b56af..1b9a9e56 100644 --- a/crates/plasm-compile/src/decoder.rs +++ b/crates/plasm-compile/src/decoder.rs @@ -48,6 +48,9 @@ pub struct FieldDecoder { /// Post-extraction derivation from wire JSON (before [`Transform`]). #[serde(default, skip_serializing_if = "Option::is_none")] pub derive: Option, + /// Money coerce after JSON extract (amount format + optional sibling currency). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub money: Option, } /// Relation decoder - specifies how to extract related entities @@ -194,6 +197,7 @@ impl FieldDecoder { from, transform: None, derive: None, + money: None, } } @@ -207,6 +211,11 @@ impl FieldDecoder { self.derive = Some(derive); self } + + pub fn with_money(mut self, money: plasm_core::MoneyDecodeSpec) -> Self { + self.money = Some(money); + self + } } impl EntityDecoder { @@ -503,7 +512,7 @@ pub fn apply_transform( value: &serde_json::Value, ) -> Result { match transform { - Transform::Identity => Ok(json_to_value(value)), + Transform::Identity => Ok(plasm_core::json_value_to_plasm_value(value)), Transform::ToString => match value { serde_json::Value::String(s) => Ok(Value::String(s.clone())), @@ -627,35 +636,6 @@ pub fn relation_decode_path_specified(value: &serde_json::Value, path: &PathExpr true } -/// Convert serde_json::Value to plasm_core::Value -pub(crate) fn json_to_value(json: &serde_json::Value) -> Value { - match json { - serde_json::Value::Null => Value::Null, - serde_json::Value::Bool(b) => Value::Bool(*b), - serde_json::Value::Number(n) => { - if let Some(i) = n.as_i64() { - Value::Integer(i) - } else if let Some(f) = n.as_f64() { - Value::Float(f) - } else { - Value::Null - } - } - serde_json::Value::String(s) => Value::String(s.clone()), - serde_json::Value::Array(arr) => { - let values = arr.iter().map(json_to_value).collect(); - Value::Array(values) - } - serde_json::Value::Object(obj) => { - let mut map = IndexMap::new(); - for (k, v) in obj { - map.insert(k.clone(), json_to_value(v)); - } - Value::Object(map) - } - } -} - /// Get the type name of a JSON value fn value_type_name(value: &serde_json::Value) -> &'static str { match value { diff --git a/crates/plasm-compile/src/embed_decode.rs b/crates/plasm-compile/src/embed_decode.rs index 8ecf1fce..5480f276 100644 --- a/crates/plasm-compile/src/embed_decode.rs +++ b/crates/plasm-compile/src/embed_decode.rs @@ -10,8 +10,8 @@ use plasm_core::{Ref, RelationMaterialization, Value, CGS, MAX_FROM_PARENT_GET_E use std::collections::{BTreeMap, VecDeque}; use crate::decoder::{ - apply_field_derive_rule, apply_transform, extract_path, json_to_value, - relation_decode_path_specified, DecodedEntity, DecodedRelation, EntityDecoder, + apply_field_derive_rule, apply_transform, extract_path, relation_decode_path_specified, + DecodedEntity, DecodedRelation, EntityDecoder, }; use crate::embed_target_decoder::entity_decoder_for_from_parent_get_target; use crate::json_path::path_expr_from_json_segments; @@ -120,6 +120,7 @@ fn value_to_key_slot(v: &Value) -> Option { } } Value::Bool(b) => Some(b.to_string()), + Value::Money(m) => m.to_wire_text().ok(), Value::Null | Value::Array(_) | Value::Object(_) | Value::UnionCtor { .. } => None, } } @@ -150,10 +151,12 @@ fn decode_entity_fields_and_ref( if let Some(ref dr) = field_decoder.derive { raw = apply_field_derive_rule(dr, &raw)?; } - let decoded_value = if let Some(transform) = &field_decoder.transform { + let decoded_value = if field_decoder.money.is_some() { + plasm_core::json_amount_to_value(&raw) + } else if let Some(transform) = &field_decoder.transform { apply_transform(transform, &raw)? } else { - json_to_value(&raw) + plasm_core::json_value_to_plasm_value(&raw) }; fields.insert(field_decoder.field.clone(), decoded_value); } @@ -170,7 +173,7 @@ fn decode_entity_fields_and_ref( serde_json::Value::String(_) | serde_json::Value::Number(_) ) { if let Some(ref name) = decoder.id_field { - fields.insert(name.clone(), json_to_value(source)); + fields.insert(name.clone(), plasm_core::json_value_to_plasm_value(source)); } } else { return Err(DecodeError::InvalidStructure { @@ -187,6 +190,16 @@ fn decode_entity_fields_and_ref( } } + let money_specs: Vec<_> = decoder + .fields + .iter() + .filter_map(|fd| fd.money.clone().map(|spec| (fd.field.clone(), spec))) + .collect(); + if !money_specs.is_empty() { + plasm_core::money::coerce_decoded_fields(&mut fields, money_specs) + .map_err(|e| DecodeError::InvalidStructure { message: e.into() })?; + } + let reference = build_decoded_reference(decoder, &fields, &id_value)?; Ok(DecodedEntityCore { reference, fields }) } diff --git a/crates/plasm-compile/src/predicate_compiler.rs b/crates/plasm-compile/src/predicate_compiler.rs index dc2c5bb9..417a03a3 100644 --- a/crates/plasm-compile/src/predicate_compiler.rs +++ b/crates/plasm-compile/src/predicate_compiler.rs @@ -212,6 +212,7 @@ mod tests { wire_path: None, derive: None, data_class: None, + currency_field: None, } } @@ -227,6 +228,7 @@ mod tests { allowed_values: None, string_semantics: Some(StringSemantics::Short), array_items: None, + currency: None, }, ); cgs.values.insert( @@ -238,6 +240,7 @@ mod tests { allowed_values: None, string_semantics: Some(StringSemantics::Short), array_items: None, + currency: None, }, ); cgs.values.insert( @@ -249,6 +252,7 @@ mod tests { allowed_values: None, string_semantics: None, array_items: None, + currency: None, }, ); cgs.values.insert( @@ -264,6 +268,7 @@ mod tests { ]), string_semantics: None, array_items: None, + currency: None, }, ); cgs.values.insert( @@ -275,6 +280,7 @@ mod tests { allowed_values: None, string_semantics: Some(StringSemantics::Short), array_items: None, + currency: None, }, ); cgs.values.insert( @@ -286,6 +292,7 @@ mod tests { allowed_values: None, string_semantics: Some(StringSemantics::Short), array_items: None, + currency: None, }, ); cgs.values.insert( @@ -297,6 +304,7 @@ mod tests { allowed_values: Some(vec!["Manager".to_string(), "Employee".to_string()]), string_semantics: None, array_items: None, + currency: None, }, ); diff --git a/crates/plasm-core/Cargo.toml b/crates/plasm-core/Cargo.toml index 33f51c7c..0c867eed 100644 --- a/crates/plasm-core/Cargo.toml +++ b/crates/plasm-core/Cargo.toml @@ -20,6 +20,7 @@ thiserror = { workspace = true } indexmap = { workspace = true } chrono = { workspace = true } chrono-english = { workspace = true } +rust_decimal = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true, features = ["env-filter"] } sha2 = { workspace = true } diff --git a/crates/plasm-core/src/capability_input.rs b/crates/plasm-core/src/capability_input.rs index aabbed4e..0ae5dbde 100644 --- a/crates/plasm-core/src/capability_input.rs +++ b/crates/plasm-core/src/capability_input.rs @@ -20,7 +20,7 @@ fn expected_type_phrase_for_placeholder(field_type: &FieldType) -> String { "a base64 or attachment-shaped value for this slot — never the literal `$`".into() } FieldType::Integer => "a concrete integer — never the literal `$`".into(), - FieldType::Number => "a concrete number — never the literal `$`".into(), + FieldType::Number | FieldType::Money => "a concrete number — never the literal `$`".into(), FieldType::Boolean => "`true` or `false` — never `$`".into(), FieldType::Select => { "one of the allowed values the schema lists for this field — never `$`".into() diff --git a/crates/plasm-core/src/cgs_expression_validate.rs b/crates/plasm-core/src/cgs_expression_validate.rs index 0f997f37..4009341b 100644 --- a/crates/plasm-core/src/cgs_expression_validate.rs +++ b/crates/plasm-core/src/cgs_expression_validate.rs @@ -41,7 +41,7 @@ fn scope_param_encodable(cgs: &CGS, f: &InputFieldSchema) -> bool { match &nv.field_type { FieldType::EntityRef { .. } => true, FieldType::String | FieldType::Uuid => true, - FieldType::Integer | FieldType::Number => true, + FieldType::Integer | FieldType::Number | FieldType::Money => true, FieldType::Boolean => true, FieldType::Select | FieldType::MultiSelect => { nv.allowed_values.as_ref().is_some_and(|v| !v.is_empty()) diff --git a/crates/plasm-core/src/cross_entity.rs b/crates/plasm-core/src/cross_entity.rs index 725c0955..4ea09b0c 100644 --- a/crates/plasm-core/src/cross_entity.rs +++ b/crates/plasm-core/src/cross_entity.rs @@ -233,6 +233,7 @@ mod tests { allowed_values: None, string_semantics: None, array_items: None, + currency: None, }, ); cgs.values.insert( @@ -244,6 +245,7 @@ mod tests { allowed_values: None, string_semantics: None, array_items: None, + currency: None, }, ); cgs.values.insert( @@ -255,6 +257,7 @@ mod tests { allowed_values: Some(vec!["available".into(), "pending".into(), "sold".into()]), string_semantics: None, array_items: None, + currency: None, }, ); cgs.values.insert( @@ -268,6 +271,7 @@ mod tests { allowed_values: None, string_semantics: None, array_items: None, + currency: None, }, ); diff --git a/crates/plasm-core/src/entity_ref_value.rs b/crates/plasm-core/src/entity_ref_value.rs index 647c4a00..377890eb 100644 --- a/crates/plasm-core/src/entity_ref_value.rs +++ b/crates/plasm-core/src/entity_ref_value.rs @@ -54,6 +54,7 @@ impl EntityRefPayload { Ok(Self::Atom(EntityRefAtom::String(s.clone()))) } Value::Array(_) => Err(EntityRefValueError::Array), + Value::Money(_) => Err(EntityRefValueError::Unsupported), Value::Object(m) => { if m.is_empty() { return Err(EntityRefValueError::EmptyCompound); diff --git a/crates/plasm-core/src/error.rs b/crates/plasm-core/src/error.rs index df5b90ba..2bace112 100644 --- a/crates/plasm-core/src/error.rs +++ b/crates/plasm-core/src/error.rs @@ -29,6 +29,9 @@ pub enum TypeError { description: Option, }, + #[error("Cannot compare money in '{left}' with money in '{right}'")] + CrossCurrencyCompare { left: String, right: String }, + #[error("Relation '{relation}' not found in entity '{entity}'")] RelationNotFound { relation: String, entity: String }, @@ -61,6 +64,15 @@ pub enum TypeError { }, } +impl From for TypeError { + fn from(e: crate::money::CrossCurrencyError) -> Self { + TypeError::CrossCurrencyCompare { + left: e.left().to_string(), + right: e.right().to_string(), + } + } +} + #[derive(Error, Debug, Clone)] pub enum SchemaError { #[error("Duplicate entity name: '{name}'")] @@ -164,9 +176,14 @@ pub enum SchemaError { DateFieldMissingValueFormat { entity: String, field: String }, #[error( - "Entity '{entity}' field '{field}': `value_format` is only allowed for `Date` / `datetime` fields" + "Entity '{entity}' field '{field}': field_type `money` requires `value_format` with `money:` (decimal_string, json_number, or minor_units)" + )] + MoneyFieldMissingValueFormat { entity: String, field: String }, + + #[error( + "Entity '{entity}' field '{field}': `value_format` is only allowed for `Date` / `datetime` or `money` fields" )] - ValueFormatOnNonDateField { entity: String, field: String }, + ValueFormatOnIncompatibleField { entity: String, field: String }, #[error( "Entity '{entity}' field '{field}': `string_semantics` is only allowed for `string` fields" @@ -194,9 +211,32 @@ pub enum SchemaError { DateParamMissingValueFormat { capability: String, param: String }, #[error( - "Capability '{capability}' parameter '{param}': `value_format` is only allowed for `Date` / `datetime` parameters" + "Capability '{capability}' parameter '{param}': field_type `money` requires `value_format` with `money:`" + )] + MoneyParamMissingValueFormat { capability: String, param: String }, + + #[error( + "Capability '{capability}' parameter '{param}': `value_format` is only allowed for `Date` / `datetime` or `money` parameters" + )] + ValueFormatOnIncompatibleParam { capability: String, param: String }, + + #[error( + "Entity '{entity}' field '{field}': `currency_field` '{currency_field}' is not a field on this entity" )] - ValueFormatOnNonDateParam { capability: String, param: String }, + CurrencyFieldUnknown { + entity: String, + field: String, + currency_field: String, + }, + + #[error( + "Entity '{entity}' field '{field}': `currency_field` '{currency_field}' must be a string or select field" + )] + CurrencyFieldNotString { + entity: String, + field: String, + currency_field: String, + }, #[error( "Entity '{entity}' field '{field}': field_type `array` requires non-empty `items:` describing element types" @@ -564,6 +604,9 @@ pub enum SchemaError { #[error("View '{view}': node '{node}' computed bind template must be non-empty")] ViewNodeBindEmptyTemplate { view: String, node: String }, + #[error("{message}")] + SchemaConstraint { message: String }, + #[error("schema_overlay: {detail}")] SchemaOverlayInvalid { detail: String }, } diff --git a/crates/plasm-core/src/error_render.rs b/crates/plasm-core/src/error_render.rs index 4c316c75..a5bc6047 100644 --- a/crates/plasm-core/src/error_render.rs +++ b/crates/plasm-core/src/error_render.rs @@ -2130,6 +2130,12 @@ For example: `{te}()` when you already know the id, instead of relying on `{ } StepError::type_correction(correction, error) } + TypeError::CrossCurrencyCompare { left, right } => { + let correction = format!( + "Cannot compare money amounts in `{left}` with `{right}`. Use the same currency, or drop one currency so compare is amount-only." + ); + StepError::type_correction(correction, error) + } TypeError::CapabilityNotFound { capability } => { let cap = ident_label_for_feedback(capability, &style); let correction = format!( diff --git a/crates/plasm-core/src/expr_parser/chained_groups_tests.rs b/crates/plasm-core/src/expr_parser/chained_groups_tests.rs index a18de077..7d1738cb 100644 --- a/crates/plasm-core/src/expr_parser/chained_groups_tests.rs +++ b/crates/plasm-core/src/expr_parser/chained_groups_tests.rs @@ -21,6 +21,7 @@ fn ticket_query_fixture_cgs() -> CGS { allowed_values: None, string_semantics: None, array_items: None, + currency: None, }, ); let f = |n: &str| registry_test_util::entity_field_from_values(&cgs, "fx_str", n, true, ""); diff --git a/crates/plasm-core/src/expr_parser/mod.rs b/crates/plasm-core/src/expr_parser/mod.rs index a87a1fff..32c24c14 100644 --- a/crates/plasm-core/src/expr_parser/mod.rs +++ b/crates/plasm-core/src/expr_parser/mod.rs @@ -3651,6 +3651,7 @@ mod tests { allowed_values: None, string_semantics: None, array_items: None, + currency: None, }, ); } @@ -5145,6 +5146,7 @@ mod tests { allowed_values: None, string_semantics: None, array_items: None, + currency: None, }, ); cgs.add_resource(ResourceSchema { diff --git a/crates/plasm-core/src/expr_surface_render/values.rs b/crates/plasm-core/src/expr_surface_render/values.rs index 2fb2f1b7..09fc7686 100644 --- a/crates/plasm-core/src/expr_surface_render/values.rs +++ b/crates/plasm-core/src/expr_surface_render/values.rs @@ -37,6 +37,7 @@ pub(crate) fn render_surface_value(v: &Value) -> String { .collect(); format!("{{{}}}", parts.join(", ")) } + Value::Money(m) => m.display(), } } diff --git a/crates/plasm-core/src/lib.rs b/crates/plasm-core/src/lib.rs index 66f71217..45c2e1b8 100644 --- a/crates/plasm-core/src/lib.rs +++ b/crates/plasm-core/src/lib.rs @@ -62,9 +62,9 @@ //! //! ## Value System //! -//! [`Value`] is the universal value type (Null, Bool, Number, String, Array, Object). +//! [`Value`] is the universal value type (Null, Bool, Number, String, Array, Object, Money). //! [`FieldType`] defines the schema-level types (String, Number, Integer, Boolean, -//! Select, MultiSelect, Date, Array). [`CompOp`] defines comparison operators +//! Select, MultiSelect, Date, Array, Money). [`CompOp`] defines comparison operators //! (Eq, Neq, Gt, Lt, Gte, Lte, In, Contains, Exists) with per-type compatibility rules. //! //! ## Input Validation @@ -119,6 +119,7 @@ pub mod expr_surface_render; pub mod identifiers; pub mod identity; pub mod loader; +pub mod money; pub mod normalizer; pub mod paging_handle; pub mod phrase_ident; @@ -291,7 +292,7 @@ pub use wire_coercion::{ collect_relation_binding_proofs, field_type_assignable_for_relation_binding, identity_slot_to_json, json_value_to_plasm_value, parent_entity_field_type, plasm_value_to_json, relation_binding_assignable, restore_id_field_from_compound_ref, - RelationBindingProof, + try_plasm_value_to_json, RelationBindingProof, }; pub mod relation_materialize; pub mod view_embed_proof; @@ -299,6 +300,10 @@ pub use expr_surface_render::{ render_expr_surface, render_expr_surface_federated, wire_surface_from_teaching_line, wire_surface_from_teaching_session_line, }; +pub use money::{ + json_amount_to_value, CrossCurrencyError, MoneyDecodeSpec, MoneyError, MoneyValue, + MoneyWireFormat, +}; pub use relation_materialize::{ extract_from_parent_get_value, flatten_from_parent_get_source_rows, from_parent_get_embed_edges, partition_prefer_resolutions, prefer_hydrate_embed_path, diff --git a/crates/plasm-core/src/loader.rs b/crates/plasm-core/src/loader.rs index 3becb939..f4a504f0 100644 --- a/crates/plasm-core/src/loader.rs +++ b/crates/plasm-core/src/loader.rs @@ -187,6 +187,9 @@ pub struct DomainNamedValue { pub items: Option, #[serde(default)] pub string_semantics: Option, + /// Default ISO-like currency token for [`FieldType::Money`] rows. + #[serde(default)] + pub currency: Option, } fn deserialize_optional_id_from<'de, D>(deserializer: D) -> Result>, D::Error> @@ -255,6 +258,9 @@ pub struct DomainField { /// Optional information-flow label for this field (must exist in top-level `data_classes:`). #[serde(default)] pub data_class: Option, + /// Sibling field that supplies currency for a money amount on the same entity. + #[serde(default)] + pub currency_field: Option, } #[derive(Debug, Deserialize)] @@ -618,6 +624,26 @@ fn compile_one_named_value( None }; let (field_type, string_semantics) = normalize_blob_field_type(field_type, d.string_semantics); + if matches!(field_type, FieldType::Money) { + if string_semantics.is_some() { + return Err(format!( + "{ctx}: `string_semantics` is not allowed on type 'money'" + )); + } + match &d.value_format { + Some(ValueWireFormat::Money(_)) => {} + Some(_) => { + return Err(format!( + "{ctx}: type 'money' requires `value_format: {{ money: … }}`" + )); + } + None => { + return Err(format!( + "{ctx}: type 'money' requires `value_format: {{ money: decimal_string | json_number | minor_units }}`" + )); + } + } + } Ok(NamedValueSchema { description: d.description.clone(), field_type, @@ -625,6 +651,11 @@ fn compile_one_named_value( allowed_values: d.allowed_values.clone(), string_semantics, array_items, + currency: d + .currency + .as_ref() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()), }) } @@ -661,6 +692,11 @@ fn field_schema_from_domain_field( attachment_media: f.attachment_media, wire_path: f.path.clone(), derive: f.derive.clone(), + currency_field: f + .currency_field + .as_ref() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()), }) } @@ -991,6 +1027,7 @@ fn parse_field_type_strict(s: &str, ctx: &str) -> Result { "date" | "datetime" => Ok(FieldType::Date), "array" => Ok(FieldType::Array), "json" => Ok(FieldType::Json), + "money" => Ok(FieldType::Money), "" => Err(format!("{ctx}: empty field type")), _ => Err(format!("{ctx}: unknown field type {t:?}")), } @@ -1198,6 +1235,20 @@ mod tests { ); } + #[test] + fn language_matrix_loads_money_offer() { + let dir = Path::new("../../fixtures/schemas/plasm_language_matrix"); + if !dir.exists() { + return; + } + let cgs = load_schema_dir(dir).expect("language matrix with money"); + let offer = cgs.get_entity("LangOffer").expect("LangOffer"); + let price = offer.fields.get("price").expect("price"); + assert_eq!(price.currency_field.as_deref(), Some("quote_currency")); + let nv = price.named_value(&cgs).expect("price nv"); + assert_eq!(nv.field_type, FieldType::Money); + } + #[test] fn parse_field_type_uuid() { assert_eq!( @@ -1214,6 +1265,176 @@ mod tests { ); } + #[test] + fn parse_field_type_money() { + assert_eq!( + parse_field_type_strict("money", "ctx").unwrap(), + FieldType::Money + ); + } + + #[test] + fn rejects_money_without_value_format() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("domain.yaml"), + r#"http_backend: http://localhost:1080 +values: + nv_id: + type: string + string_semantics: short + nv_price: + type: money +entities: + Offer: + id_field: id + fields: + id: + value_ref: nv_id + required: true + price: + value_ref: nv_price + required: true +capabilities: + q: + kind: query + entity: Offer +"#, + ) + .unwrap(); + std::fs::write(dir.path().join("mappings.yaml"), "q: {}\n").unwrap(); + let err = load_schema_dir(dir.path()).unwrap_err(); + assert!( + err.contains("money") && err.contains("value_format"), + "unexpected error: {err}" + ); + } + + #[test] + fn rejects_string_semantics_on_money() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("domain.yaml"), + r#"http_backend: http://localhost:1080 +values: + nv_id: + type: string + string_semantics: short + nv_price: + type: money + value_format: + money: decimal_string + string_semantics: short +entities: + Offer: + id_field: id + fields: + id: + value_ref: nv_id + required: true + price: + value_ref: nv_price + required: true +capabilities: + q: + kind: query + entity: Offer +"#, + ) + .unwrap(); + std::fs::write(dir.path().join("mappings.yaml"), "q: {}\n").unwrap(); + let err = load_schema_dir(dir.path()).unwrap_err(); + assert!( + err.contains("string_semantics") && err.contains("money"), + "unexpected error: {err}" + ); + } + + #[test] + fn rejects_dangling_currency_field() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("domain.yaml"), + r#"http_backend: http://localhost:1080 +values: + nv_id: + type: string + string_semantics: short + nv_price: + type: money + value_format: + money: decimal_string +entities: + Offer: + id_field: id + fields: + id: + value_ref: nv_id + required: true + price: + value_ref: nv_price + required: true + currency_field: quote_currency +capabilities: + q: + kind: query + entity: Offer +"#, + ) + .unwrap(); + std::fs::write(dir.path().join("mappings.yaml"), "q: {}\n").unwrap(); + let err = load_schema_dir(dir.path()).unwrap_err(); + assert!( + err.contains("quote_currency") && err.contains("currency_field"), + "unexpected error: {err}" + ); + } + + #[test] + fn rejects_non_string_currency_field() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("domain.yaml"), + r#"http_backend: http://localhost:1080 +values: + nv_id: + type: string + string_semantics: short + nv_qty: + type: integer + nv_price: + type: money + value_format: + money: decimal_string +entities: + Offer: + id_field: id + fields: + id: + value_ref: nv_id + required: true + qty: + value_ref: nv_qty + required: true + price: + value_ref: nv_price + required: true + currency_field: qty +capabilities: + q: + kind: query + entity: Offer +"#, + ) + .unwrap(); + std::fs::write(dir.path().join("mappings.yaml"), "q: {}\n").unwrap(); + let err = load_schema_dir(dir.path()).unwrap_err(); + assert!( + err.contains("qty") && err.contains("string"), + "unexpected error: {err}" + ); + } + #[test] fn normalize_string_blob_semantics_to_blob_type() { let (ft, sem) = normalize_blob_field_type(FieldType::String, Some(StringSemantics::Blob)); diff --git a/crates/plasm-core/src/money.rs b/crates/plasm-core/src/money.rs new file mode 100644 index 00000000..0ef094ad --- /dev/null +++ b/crates/plasm-core/src/money.rs @@ -0,0 +1,891 @@ +//! Fowler money: exact decimal amount plus optional currency. +//! +//! Wire encoding stays a scalar (decimal string, JSON number, or integer minor units). +//! Currency is CGS-side — fixed on a `values:` row or attached from a sibling field. + +use rust_decimal::Decimal; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use std::cmp::Ordering; +use std::fmt; +use std::str::FromStr; +use thiserror::Error; + +use crate::value::Value; + +/// `value_format` payload for money (see [`crate::ValueWireFormat::Money`]). +/// +/// Scale exists only on [`Self::MinorUnits`]. Catalog YAML `{ money, scale }` is +/// decoded at the [`crate::ValueWireFormat`] boundary; this type cannot represent +/// `scale` on `decimal_string` / `json_number`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum MoneyWireFormat { + DecimalString, + JsonNumber, + MinorUnits { scale: u8 }, +} + +impl MoneyWireFormat { + #[must_use] + pub fn decimal_string() -> Self { + Self::DecimalString + } + + #[must_use] + pub fn json_number() -> Self { + Self::JsonNumber + } + + pub fn minor_units(scale: u8) -> Result { + if scale > 28 { + return Err(MoneyError::ScaleTooLarge); + } + Ok(Self::MinorUnits { scale }) + } + + /// Lift catalog YAML/JSON `{ money, scale }` into a valid format. + pub(crate) fn from_catalog_parts( + encoding: &str, + scale: Option, + ) -> Result { + match encoding { + "decimal_string" => { + if scale.is_some() { + Err(MoneyError::ScaleOnlyForMinorUnits) + } else { + Ok(Self::DecimalString) + } + } + "json_number" => { + if scale.is_some() { + Err(MoneyError::ScaleOnlyForMinorUnits) + } else { + Ok(Self::JsonNumber) + } + } + "minor_units" => match scale { + Some(s) => Self::minor_units(s), + None => Err(MoneyError::ScaleRequired), + }, + other => Err(MoneyError::UnknownEncoding { + tag: other.to_string(), + }), + } + } + + pub(crate) fn encode_amount(self, amount: Decimal) -> Result { + match self { + Self::DecimalString => Ok(serde_json::Value::String(amount.to_string())), + Self::JsonNumber => decimal_to_json_number(amount), + Self::MinorUnits { scale } => { + let factor = ten_pow_scale(u32::from(scale))?; + let units = (amount * factor).round_dp(0); + let i = decimal_to_i64(units)?; + Ok(serde_json::Value::Number(i.into())) + } + } + } +} + +/// Serde shape `{ "encoding": "…", "scale": N }` for tagged money / compiled decoders. +#[derive(Serialize, Deserialize)] +struct MoneyWireFormatSerde { + encoding: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + scale: Option, +} + +impl Serialize for MoneyWireFormat { + fn serialize(&self, serializer: S) -> Result { + let wire = match *self { + Self::DecimalString => MoneyWireFormatSerde { + encoding: "decimal_string".into(), + scale: None, + }, + Self::JsonNumber => MoneyWireFormatSerde { + encoding: "json_number".into(), + scale: None, + }, + Self::MinorUnits { scale } => MoneyWireFormatSerde { + encoding: "minor_units".into(), + scale: Some(scale), + }, + }; + wire.serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for MoneyWireFormat { + fn deserialize>(deserializer: D) -> Result { + let wire = MoneyWireFormatSerde::deserialize(deserializer)?; + Self::from_catalog_parts(&wire.encoding, wire.scale).map_err(serde::de::Error::custom) + } +} + +/// Decode-time coerce spec for one money field (amount format + optional sibling currency). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MoneyDecodeSpec { + format: MoneyWireFormat, + #[serde(default, skip_serializing_if = "Option::is_none")] + default_currency: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + currency_field: Option, +} + +impl MoneyDecodeSpec { + #[must_use] + pub fn new( + format: MoneyWireFormat, + default_currency: Option, + currency_field: Option, + ) -> Self { + Self { + format, + default_currency: CurrencyCode::parse_opt(default_currency), + currency_field: currency_field.filter(|s| !s.is_empty()), + } + } + + #[must_use] + pub fn format(&self) -> MoneyWireFormat { + self.format + } + + #[must_use] + pub fn default_currency(&self) -> Option<&str> { + self.default_currency.as_ref().map(CurrencyCode::as_str) + } + + #[must_use] + pub fn currency_field(&self) -> Option<&str> { + self.currency_field.as_deref() + } +} + +/// ISO-ish unit label. Absence on [`MoneyValue`] means the unit is unknown (Fowler). +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] +#[serde(transparent)] +struct CurrencyCode(String); + +impl CurrencyCode { + fn parse(s: &str) -> Option { + let t = s.trim(); + if t.is_empty() { + None + } else { + Some(Self(t.to_string())) + } + } + + fn parse_opt(s: Option) -> Option { + s.and_then(|s| Self::parse(&s)) + } + + fn as_str(&self) -> &str { + &self.0 + } +} + +impl<'de> Deserialize<'de> for CurrencyCode { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::custom("currency code must be non-empty")) + } +} + +fn deserialize_opt_currency<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let s = Option::::deserialize(deserializer)?; + Ok(s.and_then(|s| CurrencyCode::parse(&s))) +} + +/// Runtime money value. Serde uses a tagged object so untagged [`Value`] does not collide with [`Value::Object`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MoneyValue { + #[serde(rename = "__plasm_money", with = "rust_decimal::serde::str")] + amount: Decimal, + #[serde( + default, + deserialize_with = "deserialize_opt_currency", + skip_serializing_if = "Option::is_none" + )] + currency: Option, + /// Wire encoding stamped at coerce time so HTTP emit does not need the CGS. + #[serde(default, skip_serializing_if = "Option::is_none")] + format: Option, +} + +impl PartialEq for MoneyValue { + fn eq(&self, other: &Self) -> bool { + self.amount == other.amount + && currencies_eq_opt( + self.currency.as_ref().map(CurrencyCode::as_str), + other.currency.as_ref().map(CurrencyCode::as_str), + ) + } +} + +impl MoneyValue { + #[must_use] + pub fn new(amount: Decimal, currency: Option) -> Self { + Self { + amount: normalize_decimal(amount), + currency: CurrencyCode::parse_opt(currency), + format: None, + } + } + + #[must_use] + pub fn amount(&self) -> Decimal { + self.amount + } + + #[must_use] + pub fn currency(&self) -> Option<&str> { + self.currency.as_ref().map(CurrencyCode::as_str) + } + + #[must_use] + pub fn with_format(mut self, format: MoneyWireFormat) -> Self { + self.format = Some(format); + self + } + + pub(crate) fn attach_currency_if_absent(&mut self, currency: Option<&str>) { + if self.currency.is_some() { + return; + } + if let Some(c) = currency.and_then(CurrencyCode::parse) { + self.currency = Some(c); + } + } + + /// HTTP/JSON scalar using the stamped format. Missing format is an error (no decimal-string default). + pub fn encode_stored(&self) -> Result { + let format = self.format.ok_or(MoneyError::UnstampedFormat)?; + format.encode_amount(self.amount) + } + + /// Form / multipart / key-slot text for the same scalar [`Self::encode_stored`] would emit. + pub fn to_wire_text(&self) -> Result { + match self.encode_stored()? { + serde_json::Value::String(s) => Ok(s), + serde_json::Value::Number(n) => Ok(n.to_string()), + other => Err(MoneyError::UnexpectedWireScalar { + got: other.to_string(), + }), + } + } + + #[must_use] + pub fn display(&self) -> String { + match self.currency() { + Some(c) => format!("{} {c}", self.amount), + None => self.amount.to_string(), + } + } +} + +/// Parse a program/wire token into [`Value::Money`]. +pub fn normalize( + val: Value, + format: MoneyWireFormat, + default_currency: Option<&str>, +) -> Result { + if val.is_domain_example_placeholder() { + return Ok(val); + } + match val { + Value::Money(m) => { + let mut m = m; + m.attach_currency_if_absent(default_currency); + Ok(Value::Money(m.with_format(format))) + } + Value::PlasmInputRef(_) => Ok(val), + other => { + let (amount, from_obj_ccy) = parse_amount_and_optional_currency(&other, format)?; + let currency = from_obj_ccy.or_else(|| default_currency.map(str::to_string)); + Ok(Value::Money( + MoneyValue::new(amount, currency).with_format(format), + )) + } + } +} + +/// Both currencies present and unequal (compare is illegal). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CrossCurrencyError { + left: String, + right: String, +} + +impl CrossCurrencyError { + #[must_use] + pub fn left(&self) -> &str { + &self.left + } + + #[must_use] + pub fn right(&self) -> &str { + &self.right + } +} + +/// Failures while parsing, stamping, or encoding money. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum MoneyError { + #[error("unknown money encoding `{tag}`")] + UnknownEncoding { tag: String }, + #[error("money minor_units scale must be ≤ 28")] + ScaleTooLarge, + #[error("money value_format minor_units requires `scale`")] + ScaleRequired, + #[error("money value_format `scale` is only valid with money: minor_units")] + ScaleOnlyForMinorUnits, + #[error("money value missing stamped wire format; cannot encode")] + UnstampedFormat, + #[error("empty money amount")] + EmptyAmount, + #[error("invalid minor-units money `{token}`")] + InvalidMinorUnits { token: String }, + #[error("invalid decimal money `{token}`")] + InvalidDecimal { token: String }, + #[error("money amount must be finite")] + NonFiniteAmount, + #[error("minor-units money must be a whole number")] + MinorUnitsNotWhole, + #[error("cannot represent float as money")] + FloatUnrepresentable, + #[error("money object requires `amount`")] + ObjectMissingAmount, + #[error("cannot coerce {type_name} to money")] + CannotCoerce { type_name: &'static str }, + #[error("money amount cannot be {type_name}")] + AmountWrongType { type_name: &'static str }, + #[error("money minor_units 10^{scale} overflow")] + ScaleOverflow { scale: u32 }, + #[error("money minor units overflow `{amount}`")] + MinorUnitsOverflow { amount: String }, + #[error("money wire scalar must be a JSON string or number, got {got}")] + UnexpectedWireScalar { got: String }, + #[error("currency_field `{field}` must be a string, got {got}")] + CurrencyFieldNotString { field: String, got: String }, +} + +impl From for String { + fn from(e: MoneyError) -> Self { + e.to_string() + } +} + +/// Error only when both currencies are present and differ. +pub(crate) fn currency_conflict( + left: Option<&str>, + right: Option<&str>, +) -> Result<(), CrossCurrencyError> { + match (left, right) { + (Some(a), Some(b)) if !currency_eq(a, b) => Err(CrossCurrencyError { + left: a.to_string(), + right: b.to_string(), + }), + _ => Ok(()), + } +} + +/// Compare two money values. Error only when both currencies are present and differ. +pub(crate) fn try_cmp( + left: &MoneyValue, + right: &MoneyValue, +) -> Result { + currency_conflict(left.currency(), right.currency())?; + Ok(left.amount().cmp(&right.amount())) +} + +/// Ordered compare when at least one side is already [`Value::Money`]. +/// +/// The non-money side is parsed as a **major-unit** decimal (program literals), never as +/// `minor_units`. `Ok(None)` means this is not a money compare — callers use `==` / numeric. +pub fn try_cmp_values(left: &Value, right: &Value) -> Result, CrossCurrencyError> { + match (left, right) { + (Value::Money(a), Value::Money(b)) => try_cmp(a, b).map(Some), + (Value::Money(a), other) => match parse_predicate_literal(other) { + Some(b) => try_cmp(a, &b).map(Some), + None => Ok(None), + }, + (other, Value::Money(b)) => match parse_predicate_literal(other) { + Some(a) => try_cmp(&a, b).map(Some), + None => Ok(None), + }, + _ => Ok(None), + } +} + +/// Equality for predicates: money compare only when a side is [`Value::Money`], else `==`. +pub fn values_eq(left: &Value, right: &Value) -> Result { + match try_cmp_values(left, right)? { + Some(ord) => Ok(ord.is_eq()), + None => Ok(left == right), + } +} + +/// Ordered compare for predicates: money when a side is [`Value::Money`], else numeric. +pub fn values_ord(left: &Value, right: &Value) -> Result, CrossCurrencyError> { + match try_cmp_values(left, right)? { + Some(ord) => Ok(Some(ord)), + None => Ok(match (left.as_number(), right.as_number()) { + (Some(a), Some(b)) => a.partial_cmp(&b), + _ => None, + }), + } +} + +/// Coerce money fields on a decoded entity row, then attach sibling currency when still absent. +pub fn coerce_decoded_fields( + fields: &mut indexmap::IndexMap, + specs: impl IntoIterator, +) -> Result<(), MoneyError> { + for (field, spec) in specs { + let Some(raw) = fields.get(&field).cloned() else { + continue; + }; + if matches!(raw, Value::Null) { + continue; + } + let mut coerced = normalize(raw, spec.format(), spec.default_currency())?; + if let Value::Money(ref mut m) = coerced { + if let Some(cf) = spec.currency_field() { + match fields.get(cf) { + None | Some(Value::Null) => {} + Some(sibling) => { + let Some(s) = sibling.as_str() else { + return Err(MoneyError::CurrencyFieldNotString { + field: cf.to_string(), + got: sibling.type_name().to_string(), + }); + }; + m.attach_currency_if_absent(Some(s)); + } + } + } + } + fields.insert(field, coerced); + } + Ok(()) +} + +/// Lift JSON so money coerce sees lexical decimal digits, not `f64`. +pub fn json_amount_to_value(v: &serde_json::Value) -> Value { + match v { + serde_json::Value::Number(n) => { + if let Some(i) = n.as_i64() { + Value::Integer(i) + } else { + Value::String(n.to_string()) + } + } + serde_json::Value::String(s) => Value::String(s.clone()), + other => crate::json_value_to_plasm_value(other), + } +} + +/// Lift a tagged JSON object (`__plasm_money`) into [`Value::Money`]. +pub(crate) fn try_from_json_object( + obj: &serde_json::Map, +) -> Option { + let amount_v = obj.get("__plasm_money")?; + let amount = match amount_v { + serde_json::Value::String(s) => Decimal::from_str(s).ok()?, + serde_json::Value::Number(n) => Decimal::from_str(&n.to_string()).ok()?, + _ => return None, + }; + let currency = obj + .get("currency") + .and_then(|v| v.as_str()) + .map(str::to_string); + let format = obj + .get("format") + .and_then(|v| serde_json::from_value::(v.clone()).ok()); + let mut m = MoneyValue::new(amount, currency); + if let Some(fmt) = format { + m = m.with_format(fmt); + } + Some(m) +} + +fn parse_predicate_literal(v: &Value) -> Option { + parse_amount_and_optional_currency(v, MoneyWireFormat::decimal_string()) + .ok() + .map(|(amount, currency)| MoneyValue::new(amount, currency)) +} + +fn parse_amount_and_optional_currency( + val: &Value, + format: MoneyWireFormat, +) -> Result<(Decimal, Option), MoneyError> { + match val { + Value::String(s) | Value::PhraseIdent(s) => Ok((parse_decimal_token(s, format)?, None)), + Value::Integer(i) => Ok((integer_to_amount(*i, format)?, None)), + Value::Float(f) => Ok((float_to_amount(*f, format)?, None)), + Value::Object(map) => { + let amount_v = map + .get("amount") + .or_else(|| map.get("__plasm_money")) + .ok_or(MoneyError::ObjectMissingAmount)?; + let amount = parse_amount_leaf(amount_v, format)?; + let currency = map + .get("currency") + .and_then(Value::as_str) + .map(str::to_string); + Ok((amount, currency)) + } + other => Err(MoneyError::CannotCoerce { + type_name: other.type_name(), + }), + } +} + +fn parse_amount_leaf(val: &Value, format: MoneyWireFormat) -> Result { + match val { + Value::String(s) | Value::PhraseIdent(s) => parse_decimal_token(s, format), + Value::Integer(i) => integer_to_amount(*i, format), + Value::Float(f) => float_to_amount(*f, format), + Value::Money(m) => Ok(m.amount()), + other => Err(MoneyError::AmountWrongType { + type_name: other.type_name(), + }), + } +} + +fn parse_decimal_token(s: &str, format: MoneyWireFormat) -> Result { + let t = s.trim(); + if t.is_empty() { + return Err(MoneyError::EmptyAmount); + } + match format { + MoneyWireFormat::MinorUnits { .. } => { + let i = t + .parse::() + .map_err(|_| MoneyError::InvalidMinorUnits { + token: t.to_string(), + })?; + integer_to_amount(i, format) + } + MoneyWireFormat::DecimalString | MoneyWireFormat::JsonNumber => Decimal::from_str(t) + .map(normalize_decimal) + .map_err(|_| MoneyError::InvalidDecimal { + token: t.to_string(), + }), + } +} + +fn integer_to_amount(i: i64, format: MoneyWireFormat) -> Result { + match format { + MoneyWireFormat::MinorUnits { scale } => { + let factor = ten_pow_scale(u32::from(scale))?; + Ok(normalize_decimal(Decimal::from(i) / factor)) + } + MoneyWireFormat::DecimalString | MoneyWireFormat::JsonNumber => Ok(Decimal::from(i)), + } +} + +fn float_to_amount(f: f64, format: MoneyWireFormat) -> Result { + if !f.is_finite() { + return Err(MoneyError::NonFiniteAmount); + } + match format { + MoneyWireFormat::MinorUnits { .. } => { + if f.fract() != 0.0 { + return Err(MoneyError::MinorUnitsNotWhole); + } + integer_to_amount(f as i64, format) + } + MoneyWireFormat::DecimalString | MoneyWireFormat::JsonNumber => Decimal::from_f64_retain(f) + .map(normalize_decimal) + .ok_or(MoneyError::FloatUnrepresentable), + } +} + +fn ten_pow_scale(scale: u32) -> Result { + let mut v = Decimal::ONE; + let ten = Decimal::TEN; + for _ in 0..scale { + v = v + .checked_mul(ten) + .ok_or(MoneyError::ScaleOverflow { scale })?; + } + Ok(v) +} + +fn decimal_to_json_number(d: Decimal) -> Result { + let s = d.normalize().to_string(); + match serde_json::Number::from_str(&s) { + Ok(n) => Ok(serde_json::Value::Number(n)), + Err(_) => Ok(serde_json::Value::String(s)), + } +} + +fn decimal_to_i64(d: Decimal) -> Result { + d.to_string() + .parse::() + .map_err(|_| MoneyError::MinorUnitsOverflow { + amount: d.to_string(), + }) +} + +fn normalize_decimal(mut d: Decimal) -> Decimal { + d.normalize_assign(); + d +} + +fn currencies_eq_opt(a: Option<&str>, b: Option<&str>) -> bool { + match (a, b) { + (Some(x), Some(y)) => currency_eq(x, y), + (None, None) => true, + _ => false, + } +} + +fn currency_eq(a: &str, b: &str) -> bool { + a.eq_ignore_ascii_case(b) +} + +impl fmt::Display for CrossCurrencyError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "Cannot compare money in '{}' with money in '{}'", + self.left, self.right + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decimal_string_round_trip() { + let v = normalize( + Value::String("5234.50".into()), + MoneyWireFormat::decimal_string(), + Some("USD"), + ) + .unwrap(); + let Value::Money(m) = v else { + panic!("expected money") + }; + assert_eq!(m.display(), "5234.5 USD"); + let wire = m.encode_stored().unwrap(); + assert_eq!(wire, serde_json::Value::String("5234.5".into())); + } + + #[test] + fn json_number_and_float_input() { + let v = normalize( + Value::Float(12.5), + MoneyWireFormat::json_number(), + Some("USD"), + ) + .unwrap(); + let Value::Money(m) = &v else { + panic!("expected money") + }; + let wire = m.encode_stored().unwrap(); + assert!(wire.is_number()); + } + + #[test] + fn minor_units_scale_2() { + let fmt = MoneyWireFormat::minor_units(2).unwrap(); + let v = normalize(Value::Integer(1050), fmt, Some("USD")).unwrap(); + let Value::Money(m) = v else { + panic!("expected money") + }; + assert_eq!(m.amount().to_string(), "10.5"); + let wire = m.encode_stored().unwrap(); + assert_eq!(wire, serde_json::json!(1050)); + } + + #[test] + fn compare_same_and_missing_currency() { + let a = MoneyValue::new(Decimal::from_str("1.5").unwrap(), Some("USD".into())); + let b = MoneyValue::new(Decimal::from_str("2").unwrap(), None); + assert_eq!(try_cmp(&a, &b).unwrap(), Ordering::Less); + let c = MoneyValue::new(Decimal::from_str("1.5").unwrap(), Some("EUR".into())); + let err = try_cmp(&a, &c).unwrap_err(); + assert_eq!(err.left(), "USD"); + assert_eq!(err.right(), "EUR"); + } + + #[test] + fn object_amount_currency_input() { + let mut map = indexmap::IndexMap::new(); + map.insert("amount".into(), Value::String("10".into())); + map.insert("currency".into(), Value::String("USD".into())); + let v = normalize(Value::Object(map), MoneyWireFormat::decimal_string(), None).unwrap(); + let Value::Money(m) = v else { + panic!("expected money") + }; + assert_eq!(m.currency(), Some("USD")); + } + + #[test] + fn minor_units_requires_scale() { + let err = serde_json::from_value::(serde_json::json!({ + "encoding": "minor_units" + })) + .unwrap_err(); + assert!(err.to_string().contains("scale")); + } + + #[test] + fn scale_rejected_on_decimal_string_wire() { + let err = serde_json::from_value::(serde_json::json!({ + "encoding": "decimal_string", + "scale": 2 + })) + .unwrap_err(); + assert!(err.to_string().contains("minor_units")); + } + + #[test] + fn minor_units_rejects_scale_above_28() { + assert!(matches!( + MoneyWireFormat::minor_units(29), + Err(MoneyError::ScaleTooLarge) + )); + } + + #[test] + fn sibling_currency_attaches_when_amount_has_none() { + let mut fields = indexmap::IndexMap::new(); + fields.insert("price".into(), Value::String("10.5".into())); + fields.insert("quote_currency".into(), Value::String("USD".into())); + coerce_decoded_fields( + &mut fields, + [( + "price".into(), + MoneyDecodeSpec::new( + MoneyWireFormat::decimal_string(), + None, + Some("quote_currency".into()), + ), + )], + ) + .unwrap(); + let Value::Money(m) = fields.get("price").unwrap() else { + panic!("expected money"); + }; + assert_eq!(m.currency(), Some("USD")); + } + + #[test] + fn json_number_nineteen_ninety_nine_is_exact_decimal() { + let raw: serde_json::Value = serde_json::from_str("19.99").unwrap(); + let v = normalize( + json_amount_to_value(&raw), + MoneyWireFormat::json_number(), + Some("USD"), + ) + .unwrap(); + let Value::Money(m) = v else { + panic!("expected money") + }; + assert_eq!(m.amount().to_string(), "19.99"); + } + + #[test] + fn minor_units_scale_18_does_not_panic() { + let fmt = MoneyWireFormat::minor_units(18).unwrap(); + let v = normalize(Value::Integer(1), fmt, Some("ETH")).unwrap(); + let Value::Money(m) = v else { + panic!("expected money") + }; + assert_eq!(m.amount().to_string(), "0.000000000000000001"); + let wire = m.encode_stored().unwrap(); + assert_eq!(wire, serde_json::json!(1)); + } + + #[test] + fn encode_stored_requires_stamped_format() { + let m = MoneyValue::new(Decimal::from_str("1.5").unwrap(), Some("USD".into())); + assert!(matches!( + m.encode_stored(), + Err(MoneyError::UnstampedFormat) + )); + assert!(m + .encode_stored() + .unwrap_err() + .to_string() + .contains("stamped wire format")); + } + + #[test] + fn string_equality_is_not_money_compare() { + let a = Value::String("1.50".into()); + let b = Value::String("1.5".into()); + assert!(!values_eq(&a, &b).unwrap()); + assert!( + values_ord(&Value::String("100".into()), &Value::String("20".into())) + .unwrap() + .is_none() + ); + } + + #[test] + fn neq_does_not_succeed_on_cross_currency() { + let a = Value::Money(MoneyValue::new( + Decimal::from_str("1").unwrap(), + Some("USD".into()), + )); + let b = Value::Money(MoneyValue::new( + Decimal::from_str("1").unwrap(), + Some("EUR".into()), + )); + assert!(values_eq(&a, &b).is_err()); + assert!(values_ord(&a, &b).is_err()); + } + + #[test] + fn sibling_currency_rejects_non_string() { + let mut fields = indexmap::IndexMap::new(); + fields.insert("price".into(), Value::String("10.5".into())); + fields.insert("quote_currency".into(), Value::Integer(1)); + let err = coerce_decoded_fields( + &mut fields, + [( + "price".into(), + MoneyDecodeSpec::new( + MoneyWireFormat::decimal_string(), + None, + Some("quote_currency".into()), + ), + )], + ) + .unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("quote_currency") && msg.contains("string")); + } + + #[test] + fn currency_eq_is_case_insensitive() { + let a = MoneyValue::new(Decimal::from_str("1").unwrap(), Some("usd".into())); + let b = MoneyValue::new(Decimal::from_str("1").unwrap(), Some("USD".into())); + assert_eq!(a, b); + } + + #[test] + fn wire_format_serde_omits_scale_for_decimal_string() { + let j = serde_json::to_value(MoneyWireFormat::decimal_string()).unwrap(); + assert_eq!(j, serde_json::json!({ "encoding": "decimal_string" })); + let back: MoneyWireFormat = serde_json::from_value(j).unwrap(); + assert_eq!(back, MoneyWireFormat::DecimalString); + } + + #[test] + fn unknown_catalog_encoding_is_error() { + let err = MoneyWireFormat::from_catalog_parts("float", None).unwrap_err(); + assert!(matches!(err, MoneyError::UnknownEncoding { tag } if tag == "float")); + } +} diff --git a/crates/plasm-core/src/phrase_ident.rs b/crates/plasm-core/src/phrase_ident.rs index d6cffd3e..191fd4ad 100644 --- a/crates/plasm-core/src/phrase_ident.rs +++ b/crates/plasm-core/src/phrase_ident.rs @@ -161,7 +161,8 @@ fn validate_value_phrase_idents( | Value::Bool(_) | Value::Integer(_) | Value::Float(_) - | Value::String(_) => Ok(()), + | Value::String(_) + | Value::Money(_) => Ok(()), } } diff --git a/crates/plasm-core/src/prompt_render/invoke_teaching/dotted_call.rs b/crates/plasm-core/src/prompt_render/invoke_teaching/dotted_call.rs index eb33c11f..544c274c 100644 --- a/crates/plasm-core/src/prompt_render/invoke_teaching/dotted_call.rs +++ b/crates/plasm-core/src/prompt_render/invoke_teaching/dotted_call.rs @@ -88,7 +88,8 @@ pub(crate) fn invoke_dotted_call_arg_example( | FieldType::Json | FieldType::Uuid | FieldType::Integer - | FieldType::Number => Some(format!("{n}={p}")), + | FieldType::Number + | FieldType::Money => Some(format!("{n}={p}")), FieldType::Select | FieldType::MultiSelect => Some(format!("{n}={p}")), FieldType::EntityRef { target } => Some(format!( "{n}={}", diff --git a/crates/plasm-core/src/prompt_render/query_teaching.rs b/crates/plasm-core/src/prompt_render/query_teaching.rs index 27a3a193..e088925e 100644 --- a/crates/plasm-core/src/prompt_render/query_teaching.rs +++ b/crates/plasm-core/src/prompt_render/query_teaching.rs @@ -100,7 +100,9 @@ fn query_param_slot_example( return format!("{n}={p}"); } match &nv.field_type { - FieldType::Integer | FieldType::Number | FieldType::Boolean => format!("{n}={p}"), + FieldType::Integer | FieldType::Number | FieldType::Money | FieldType::Boolean => { + format!("{n}={p}") + } FieldType::String | FieldType::Blob | FieldType::Uuid => format!("{n}={p}"), FieldType::Date => format!("{n}={p}"), FieldType::Select | FieldType::MultiSelect => format!("{n}={p}"), @@ -156,6 +158,7 @@ pub(crate) fn compound_get_expr_line( match &nv.field_type { FieldType::Integer | FieldType::Number + | FieldType::Money | FieldType::Boolean | FieldType::String | FieldType::Uuid diff --git a/crates/plasm-core/src/prompt_render/tests.rs b/crates/plasm-core/src/prompt_render/tests.rs index 911e3a3b..3d413c1f 100644 --- a/crates/plasm-core/src/prompt_render/tests.rs +++ b/crates/plasm-core/src/prompt_render/tests.rs @@ -2556,6 +2556,7 @@ fn prompt_stats_fixture_cgs() -> CGS { allowed_values: None, string_semantics: None, array_items: None, + currency: None, }, ); let id_field = FieldSchema { @@ -2569,6 +2570,7 @@ fn prompt_stats_fixture_cgs() -> CGS { wire_path: None, derive: None, data_class: None, + currency_field: None, }; cgs.add_resource(ResourceSchema { name: "Book".into(), @@ -2699,6 +2701,7 @@ fn string_id_field(description: &str) -> FieldSchema { wire_path: None, derive: None, data_class: None, + currency_field: None, } } @@ -2715,6 +2718,7 @@ fn p_slot_redefinition_fixture_cgs(id_desc_a: &str, id_desc_b: &str) -> CGS { allowed_values: None, string_semantics: None, array_items: None, + currency: None, }, ); for (name, desc) in [("Anvil", id_desc_a), ("Beryl", id_desc_b)] { diff --git a/crates/plasm-core/src/schema.rs b/crates/plasm-core/src/schema.rs index 21d33027..27445047 100644 --- a/crates/plasm-core/src/schema.rs +++ b/crates/plasm-core/src/schema.rs @@ -521,7 +521,7 @@ pub struct NamedValueSchema { pub description: String, #[serde(with = "serde_yaml::with::singleton_map")] pub field_type: FieldType, - /// Required when [`Self::field_type`] is [`FieldType::Date`]: wire shape for predicates / inputs. + /// Required when [`Self::field_type`] is [`FieldType::Date`] or [`FieldType::Money`]: wire shape for predicates / inputs / decode. #[serde(default, skip_serializing_if = "Option::is_none")] pub value_format: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -531,6 +531,9 @@ pub struct NamedValueSchema { /// When [`Self::field_type`] is [`FieldType::Array`], element typing for the named array domain. #[serde(default, skip_serializing_if = "Option::is_none")] pub array_items: Option, + /// Default currency token for [`FieldType::Money`] (optional). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub currency: Option, } /// Definition of a single field within a resource. @@ -571,6 +574,9 @@ pub struct FieldSchema { /// using a transport-agnostic rule (URL path segment, name/value array lookup, object key, …). #[serde(default, skip_serializing_if = "Option::is_none")] pub derive: Option, + /// Sibling field name that supplies currency for this money amount (same entity). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub currency_field: Option, } fn default_name_value_match_key_field() -> String { @@ -4038,52 +4044,18 @@ impl CGS { match &field.wire { InputFieldWire::Registry(_) => { let nv = field.named_value(cgs)?; - match &nv.field_type { - FieldType::Date => match &nv.value_format { - Some(ValueWireFormat::Temporal(_)) => {} - None => { - return Err(SchemaError::DateParamMissingValueFormat { - capability: cap_name.to_string(), - param: param_path, - }); - } - }, - FieldType::Array => { - if nv.value_format.is_some() { - return Err(SchemaError::ValueFormatOnNonDateParam { - capability: cap_name.to_string(), - param: param_path.clone(), - }); - } - if let Some(ai) = nv.array_items.as_ref() { - match &ai.field_type { - FieldType::Date => match &ai.value_format { - Some(ValueWireFormat::Temporal(_)) => {} - None => { - return Err(SchemaError::DateParamMissingValueFormat { - capability: cap_name.to_string(), - param: format!("{param_path}.items"), - }); - } - }, - _ => { - if ai.value_format.is_some() { - return Err(SchemaError::ValueFormatOnNonDateParam { - capability: cap_name.to_string(), - param: format!("{param_path}.items"), - }); - } - } - } - } - } - _ => { - if nv.value_format.is_some() { - return Err(SchemaError::ValueFormatOnNonDateParam { - capability: cap_name.to_string(), - param: param_path, - }); - } + leaf_format_param_error( + check_leaf_value_format(&nv.field_type, nv.value_format.as_ref()), + cap_name, + param_path.clone(), + )?; + if matches!(nv.field_type, FieldType::Array) { + if let Some(ai) = nv.array_items.as_ref() { + leaf_format_param_error( + check_leaf_value_format(&ai.field_type, ai.value_format.as_ref()), + cap_name, + format!("{param_path}.items"), + )?; } } Ok(()) @@ -4516,52 +4488,42 @@ impl CGS { for (entity_name, ent) in &self.entities { for (field_name, field) in &ent.fields { let nv = field.named_value(self)?; - match &nv.field_type { - FieldType::Date => match &nv.value_format { - Some(ValueWireFormat::Temporal(_)) => {} - None => { - return Err(SchemaError::DateFieldMissingValueFormat { - entity: entity_name.to_string(), - field: field_name.to_string(), - }); - } - }, - FieldType::Array => { - if nv.value_format.is_some() { - return Err(SchemaError::ValueFormatOnNonDateField { - entity: entity_name.to_string(), - field: field_name.to_string(), - }); - } - if let Some(ai) = nv.array_items.as_ref() { - match &ai.field_type { - FieldType::Date => match &ai.value_format { - Some(ValueWireFormat::Temporal(_)) => {} - None => { - return Err(SchemaError::DateFieldMissingValueFormat { - entity: entity_name.to_string(), - field: format!("{field_name}.items"), - }); - } - }, - _ => { - if ai.value_format.is_some() { - return Err(SchemaError::ValueFormatOnNonDateField { - entity: entity_name.to_string(), - field: format!("{field_name}.items"), - }); - } - } - } - } + leaf_format_field_error( + check_leaf_value_format(&nv.field_type, nv.value_format.as_ref()), + entity_name.as_str(), + field_name.to_string(), + )?; + if matches!(nv.field_type, FieldType::Array) { + if let Some(ai) = nv.array_items.as_ref() { + leaf_format_field_error( + check_leaf_value_format(&ai.field_type, ai.value_format.as_ref()), + entity_name.as_str(), + format!("{field_name}.items"), + )?; } - _ => { - if nv.value_format.is_some() { - return Err(SchemaError::ValueFormatOnNonDateField { - entity: entity_name.to_string(), - field: field_name.to_string(), - }); - } + } + if let Some(cf) = field.currency_field.as_deref() { + if !matches!(nv.field_type, FieldType::Money) { + return Err(SchemaError::SchemaConstraint { + message: format!( + "entity '{entity_name}' field '{field_name}': `currency_field` is only valid on money fields" + ), + }); + } + if !ent.fields.contains_key(cf) { + return Err(SchemaError::CurrencyFieldUnknown { + entity: entity_name.to_string(), + field: field_name.to_string(), + currency_field: cf.to_string(), + }); + } + let sibling_nv = ent.fields[cf].named_value(self)?; + if !matches!(sibling_nv.field_type, FieldType::String | FieldType::Select) { + return Err(SchemaError::CurrencyFieldNotString { + entity: entity_name.to_string(), + field: field_name.to_string(), + currency_field: cf.to_string(), + }); } } } @@ -5475,6 +5437,77 @@ impl CapabilitySchema { } } +enum LeafValueFormat { + Ok, + MissingDate, + MissingMoney, + Unexpected, +} + +fn check_leaf_value_format(ft: &FieldType, vf: Option<&ValueWireFormat>) -> LeafValueFormat { + match ft { + FieldType::Date => match vf { + Some(ValueWireFormat::Temporal(_)) => LeafValueFormat::Ok, + _ => LeafValueFormat::MissingDate, + }, + FieldType::Money => match vf { + Some(ValueWireFormat::Money(_)) => LeafValueFormat::Ok, + _ => LeafValueFormat::MissingMoney, + }, + _ => { + if vf.is_some() { + LeafValueFormat::Unexpected + } else { + LeafValueFormat::Ok + } + } + } +} + +fn leaf_format_field_error( + check: LeafValueFormat, + entity: &str, + field: String, +) -> Result<(), SchemaError> { + match check { + LeafValueFormat::Ok => Ok(()), + LeafValueFormat::MissingDate => Err(SchemaError::DateFieldMissingValueFormat { + entity: entity.to_string(), + field, + }), + LeafValueFormat::MissingMoney => Err(SchemaError::MoneyFieldMissingValueFormat { + entity: entity.to_string(), + field, + }), + LeafValueFormat::Unexpected => Err(SchemaError::ValueFormatOnIncompatibleField { + entity: entity.to_string(), + field, + }), + } +} + +fn leaf_format_param_error( + check: LeafValueFormat, + capability: &str, + param: String, +) -> Result<(), SchemaError> { + match check { + LeafValueFormat::Ok => Ok(()), + LeafValueFormat::MissingDate => Err(SchemaError::DateParamMissingValueFormat { + capability: capability.to_string(), + param, + }), + LeafValueFormat::MissingMoney => Err(SchemaError::MoneyParamMissingValueFormat { + capability: capability.to_string(), + param, + }), + LeafValueFormat::Unexpected => Err(SchemaError::ValueFormatOnIncompatibleParam { + capability: capability.to_string(), + param, + }), + } +} + impl Default for CGS { fn default() -> Self { Self::new() @@ -5515,6 +5548,7 @@ pub mod registry_test_util { attachment_media: None, wire_path: None, derive: None, + currency_field: None, } } diff --git a/crates/plasm-core/src/schema_overlay.rs b/crates/plasm-core/src/schema_overlay.rs index e6d33dd3..c71d1a38 100644 --- a/crates/plasm-core/src/schema_overlay.rs +++ b/crates/plasm-core/src/schema_overlay.rs @@ -527,6 +527,7 @@ fn field_from_value_ref( attachment_media: None, wire_path, derive, + currency_field: None, }) } diff --git a/crates/plasm-core/src/scope_entity_ref_infer.rs b/crates/plasm-core/src/scope_entity_ref_infer.rs index cf959a80..ed4b9521 100644 --- a/crates/plasm-core/src/scope_entity_ref_infer.rs +++ b/crates/plasm-core/src/scope_entity_ref_infer.rs @@ -250,6 +250,7 @@ mod tests { allowed_values: None, string_semantics: Some(crate::StringSemantics::Short), array_items: None, + currency: None, }, ); cgs.values.insert( @@ -263,6 +264,7 @@ mod tests { allowed_values: None, string_semantics: None, array_items: None, + currency: None, }, ); } diff --git a/crates/plasm-core/src/scope_entity_ref_splat.rs b/crates/plasm-core/src/scope_entity_ref_splat.rs index 17211d9b..4618b323 100644 --- a/crates/plasm-core/src/scope_entity_ref_splat.rs +++ b/crates/plasm-core/src/scope_entity_ref_splat.rs @@ -205,6 +205,7 @@ mod tests { allowed_values: None, string_semantics: Some(StringSemantics::Short), array_items: None, + currency: None, }, ); cgs.values.insert( @@ -218,6 +219,7 @@ mod tests { allowed_values: None, string_semantics: None, array_items: None, + currency: None, }, ); } diff --git a/crates/plasm-core/src/string_unescape.rs b/crates/plasm-core/src/string_unescape.rs index 742505a5..71471f62 100644 --- a/crates/plasm-core/src/string_unescape.rs +++ b/crates/plasm-core/src/string_unescape.rs @@ -188,6 +188,7 @@ mod tests { allowed_values: None, string_semantics: Some(StringSemantics::Markdown), array_items: None, + currency: None, }, ); let input_type = InputType::Object { @@ -233,6 +234,7 @@ mod tests { allowed_values: None, string_semantics: Some(StringSemantics::Short), array_items: None, + currency: None, }, ); let input_type = InputType::Object { @@ -280,6 +282,7 @@ mod tests { allowed_values: None, string_semantics: None, array_items: None, + currency: None, }, ); let input_type = InputType::Object { diff --git a/crates/plasm-core/src/summary_render.rs b/crates/plasm-core/src/summary_render.rs index 8117d957..f53bc109 100644 --- a/crates/plasm-core/src/summary_render.rs +++ b/crates/plasm-core/src/summary_render.rs @@ -430,6 +430,7 @@ fn value_short(v: &Value) -> String { Value::Null => "null".to_string(), Value::Array(a) => format!("[{} items]", a.len()), Value::Object(o) => format!("{{{} keys}}", o.len()), + Value::Money(m) => m.display(), } } diff --git a/crates/plasm-core/src/symbol_tuning/mod.rs b/crates/plasm-core/src/symbol_tuning/mod.rs index ebd36bf6..9df3821e 100644 --- a/crates/plasm-core/src/symbol_tuning/mod.rs +++ b/crates/plasm-core/src/symbol_tuning/mod.rs @@ -1210,15 +1210,12 @@ impl IdentMetadata { } IdentMetadata::RegistryBacked { field_type, - array_items, - string_semantics, allowed_values, wire_name, role, .. } => { - let type_label = - array_or_scalar_gloss_label(field_type, array_items, *string_semantics, map); + let type_label = registry_gloss_type_label(self, cgs, map); if matches!(field_type, FieldType::Select | FieldType::MultiSelect) { if let Some(ref av) = allowed_values { if !av.is_empty() { @@ -1285,8 +1282,6 @@ impl IdentMetadata { ) -> Option { let IdentMetadata::RegistryBacked { field_type, - array_items, - string_semantics, allowed_values, .. } = self @@ -1300,8 +1295,7 @@ impl IdentMetadata { value_row_description, )); } - let type_label = - array_or_scalar_gloss_label(field_type, array_items, *string_semantics, map); + let type_label = registry_gloss_type_label(self, cgs, map); if matches!(field_type, FieldType::Select | FieldType::MultiSelect) { if let Some(ref av) = allowed_values { if !av.is_empty() { @@ -1361,6 +1355,27 @@ pub(crate) fn string_semantics_gloss_label(sem: Option) -> Stri s.gloss_type_keyword().unwrap_or("str").to_string() } +fn registry_gloss_type_label( + meta: &IdentMetadata, + cgs: Option<&CGS>, + map: Option<&SymbolMap>, +) -> String { + let IdentMetadata::RegistryBacked { + field_type, + array_items, + string_semantics, + .. + } = meta + else { + return "str".to_string(); + }; + if matches!(field_type, FieldType::Money) { + money_value_domain_gloss_label(meta, cgs) + } else { + array_or_scalar_gloss_label(field_type, array_items, *string_semantics, map) + } +} + pub(crate) fn field_type_to_gloss_label(ft: &FieldType) -> String { match ft { FieldType::Boolean => "bool".to_string(), @@ -1372,12 +1387,33 @@ pub(crate) fn field_type_to_gloss_label(ft: &FieldType) -> String { FieldType::Select => "select".to_string(), FieldType::MultiSelect => "multiselect".to_string(), FieldType::Date => "date".to_string(), + FieldType::Money => "money".to_string(), FieldType::Array => "array".to_string(), FieldType::Json => "json".to_string(), FieldType::EntityRef { target } => format!("ref:{target}"), } } +fn money_value_domain_gloss_label(meta: &IdentMetadata, cgs: Option<&CGS>) -> String { + let IdentMetadata::RegistryBacked { + value_registry_key, .. + } = meta + else { + return "money".to_string(); + }; + let currency = cgs.and_then(|c| { + c.values + .get(value_registry_key.as_str()) + .and_then(|nv| nv.currency.as_deref()) + .map(str::trim) + .filter(|s| !s.is_empty()) + }); + match currency { + Some(ccy) => format!("money:{ccy}"), + None => "money".to_string(), + } +} + fn array_element_gloss_label(ai: &ArrayItemsSchema, map: Option<&SymbolMap>) -> String { match &ai.field_type { FieldType::EntityRef { target } => { @@ -4565,6 +4601,7 @@ mod tests { allowed_values: None, string_semantics: None, array_items: None, + currency: None, }, ); cgs.values.insert( @@ -4576,6 +4613,7 @@ mod tests { allowed_values: Some(vec!["alpha".into(), "beta".into()]), string_semantics: None, array_items: None, + currency: None, }, ); let vr = FieldValueKind::Registry(ValueDomainKey::new("shared_sel_vtest").expect("key")); @@ -4599,6 +4637,7 @@ mod tests { wire_path: None, derive: None, data_class: None, + currency_field: None, }, FieldSchema { name: "foo".into(), @@ -4611,6 +4650,7 @@ mod tests { wire_path: None, derive: None, data_class: None, + currency_field: None, }, FieldSchema { name: "bar".into(), @@ -4623,6 +4663,7 @@ mod tests { wire_path: None, derive: None, data_class: None, + currency_field: None, }, ], relations: vec![], diff --git a/crates/plasm-core/src/temporal.rs b/crates/plasm-core/src/temporal.rs index 284c2f22..9f373043 100644 --- a/crates/plasm-core/src/temporal.rs +++ b/crates/plasm-core/src/temporal.rs @@ -150,7 +150,7 @@ pub fn temporal_encoded_as_wire_string(encoded: &Value) -> String { Value::Array(_) | Value::Object(_) => { serde_json::to_string(&plasm_value_to_json_temporal(encoded)).unwrap_or_default() } - Value::PlasmInputRef(_) | Value::UnionCtor { .. } => String::new(), + Value::PlasmInputRef(_) | Value::UnionCtor { .. } | Value::Money(_) => String::new(), } } @@ -171,7 +171,9 @@ fn plasm_value_to_json_temporal(v: &Value) -> serde_json::Value { } serde_json::Value::Object(map) } - Value::PlasmInputRef(_) | Value::UnionCtor { .. } => serde_json::Value::Null, + Value::PlasmInputRef(_) | Value::UnionCtor { .. } | Value::Money(_) => { + serde_json::Value::Null + } } } diff --git a/crates/plasm-core/src/tests.rs b/crates/plasm-core/src/tests.rs index 62b9e093..52fdb5cb 100644 --- a/crates/plasm-core/src/tests.rs +++ b/crates/plasm-core/src/tests.rs @@ -66,6 +66,7 @@ mod property_tests { allowed_values: None, string_semantics: None, array_items: None, + currency: None, }, ); cgs.values.insert( @@ -77,6 +78,7 @@ mod property_tests { allowed_values: Some(vec!["EMEA".to_string(), "APAC".to_string()]), string_semantics: None, array_items: None, + currency: None, }, ); let account = ResourceSchema { diff --git a/crates/plasm-core/src/type_checker.rs b/crates/plasm-core/src/type_checker.rs index d98b95bc..fcb78442 100644 --- a/crates/plasm-core/src/type_checker.rs +++ b/crates/plasm-core/src/type_checker.rs @@ -9,7 +9,7 @@ use crate::scope_entity_ref_infer::{ use crate::{ CapabilityKind, ChainExpr, ChainStep, CompOp, CreateExpr, DeleteExpr, EntityDef, EntityKey, Expr, FieldType, GetExpr, InputFieldSchema, InvokeExpr, PageExpr, Predicate, QueryExpr, - RelationSchema, TypeError, Value, CGS, + RelationSchema, TypeError, Value, ValueWireFormat, CGS, }; use std::collections::HashSet; @@ -751,6 +751,9 @@ fn type_check_comparison( }, }); } + if matches!(pnv.field_type, FieldType::Money) { + validate_money_compare_value(field_name, &value, pnv)?; + } return Ok(()); } @@ -817,6 +820,10 @@ fn type_check_comparison( } } + if matches!(fnv.field_type, FieldType::Money) { + validate_money_compare_value(field_name, &value, fnv)?; + } + return Ok(()); } @@ -827,6 +834,35 @@ fn type_check_comparison( }) } +fn validate_money_compare_value( + field_name: &str, + value: &Value, + nv: &crate::NamedValueSchema, +) -> Result<(), TypeError> { + let fmt = match nv.value_format { + Some(ValueWireFormat::Money(f)) => f, + _ => { + return Err(TypeError::IncompatibleValue { + field: field_name.to_string(), + value_type: value.type_name().to_string(), + field_type: "money (missing value_format)".to_string(), + }); + } + }; + let coerced = + crate::money::normalize(value.clone(), fmt, nv.currency.as_deref()).map_err(|message| { + TypeError::IncompatibleValue { + field: field_name.to_string(), + value_type: format!("{} ({message})", value.type_name()), + field_type: "money".to_string(), + } + })?; + let Value::Money(m) = coerced else { + return Ok(()); + }; + crate::money::currency_conflict(nv.currency.as_deref(), m.currency()).map_err(TypeError::from) +} + /// Type-check a relation predicate. fn type_check_relation( relation_name: &str, @@ -888,6 +924,7 @@ mod tests { allowed_values: None, string_semantics: None, array_items: None, + currency: None, }, ); cgs.values.insert( @@ -899,6 +936,7 @@ mod tests { allowed_values: None, string_semantics: None, array_items: None, + currency: None, }, ); cgs.values.insert( @@ -914,6 +952,7 @@ mod tests { ]), string_semantics: None, array_items: None, + currency: None, }, ); cgs.values.insert( @@ -925,6 +964,7 @@ mod tests { allowed_values: Some(vec!["Manager".to_string(), "Employee".to_string()]), string_semantics: None, array_items: None, + currency: None, }, ); } @@ -1017,6 +1057,7 @@ mod tests { allowed_values: None, string_semantics: None, array_items: None, + currency: None, }, ); cgs.values.insert( @@ -1028,6 +1069,7 @@ mod tests { allowed_values: None, string_semantics: None, array_items: None, + currency: None, }, ); cgs.values.insert( @@ -1041,6 +1083,7 @@ mod tests { allowed_values: None, string_semantics: None, array_items: None, + currency: None, }, ); } @@ -1256,6 +1299,7 @@ mod tests { allowed_values: None, string_semantics: None, array_items: None, + currency: None, }, ); cgs.values.insert( @@ -1267,6 +1311,7 @@ mod tests { allowed_values: None, string_semantics: None, array_items: None, + currency: None, }, ); cgs.add_resource(ResourceSchema { @@ -1389,6 +1434,7 @@ mod tests { allowed_values: None, string_semantics: None, array_items: None, + currency: None, }, ); } @@ -1407,6 +1453,7 @@ mod tests { wire_path: None, derive: None, data_class: None, + currency_field: None, }, ); let entity = EntityDef { @@ -1461,6 +1508,7 @@ mod tests { allowed_values: Some(ent_allowed), string_semantics: None, array_items: None, + currency: None, }, ); cgs.values.insert( @@ -1472,6 +1520,7 @@ mod tests { allowed_values: Some(cap_allowed), string_semantics: None, array_items: None, + currency: None, }, ); @@ -1491,6 +1540,7 @@ mod tests { wire_path: None, derive: None, data_class: None, + currency_field: None, }, ); let entity = EntityDef { @@ -1571,6 +1621,7 @@ mod tests { allowed_values: None, string_semantics: None, array_items: None, + currency: None, }, ); let err = type_check_predicate(&pred, &entity, &cap_params, &cgs).unwrap_err(); @@ -1627,6 +1678,7 @@ mod tests { allowed_values: None, string_semantics: None, array_items: None, + currency: None, }, ); let err = type_check_predicate(&pred, &entity, &cap_params, &cgs).unwrap_err(); @@ -1681,6 +1733,7 @@ mod tests { allowed_values: None, string_semantics: None, array_items: None, + currency: None, }, ); let err = type_check_predicate(&pred, &entity, &cap_params, &cgs).unwrap_err(); @@ -1702,6 +1755,7 @@ mod tests { allowed_values: None, string_semantics: None, array_items: None, + currency: None, }, ); cgs.values.insert( @@ -1715,6 +1769,7 @@ mod tests { allowed_values: None, string_semantics: None, array_items: None, + currency: None, }, ); let str_id = |c: &CGS, name: &str| { @@ -1790,6 +1845,7 @@ mod tests { allowed_values: None, string_semantics: None, array_items: None, + currency: None, }, ); cgs.values.insert( @@ -1803,6 +1859,7 @@ mod tests { allowed_values: None, string_semantics: None, array_items: None, + currency: None, }, ); let str_id = |c: &CGS, name: &str| { diff --git a/crates/plasm-core/src/typed_invoke.rs b/crates/plasm-core/src/typed_invoke.rs index 0ab5b8ad..ed906ae8 100644 --- a/crates/plasm-core/src/typed_invoke.rs +++ b/crates/plasm-core/src/typed_invoke.rs @@ -479,6 +479,7 @@ mod tests { allowed_values: None, string_semantics: Some(StringSemantics::Short), array_items: None, + currency: None, }, ); let input_type = InputType::Object { diff --git a/crates/plasm-core/src/typed_literal.rs b/crates/plasm-core/src/typed_literal.rs index e4849085..c92d5f8e 100644 --- a/crates/plasm-core/src/typed_literal.rs +++ b/crates/plasm-core/src/typed_literal.rs @@ -5,6 +5,7 @@ //! for JSON/`entity_ref`/`PlasmInputRef` shapes that do not lift cleanly. use crate::entity_ref_value::{EntityRefPayload, EntityRefValueError}; +use crate::money::MoneyValue; use crate::value::{PlasmInputRef, Value}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; @@ -21,6 +22,8 @@ pub enum TypedLiteral { EntityRef(EntityRefPayload), /// Compile-time plan hole (`__plasm_hole`). InputRef(PlasmInputRef), + /// Fowler money (amount + optional currency). Must not collapse to [`TypedLiteral::String`]. + Money(MoneyValue), } #[derive(Debug, Clone, PartialEq)] @@ -45,6 +48,7 @@ impl TypedLiteral { TypedLiteral::Array(items) => Value::Array(items.iter().map(Self::to_value).collect()), TypedLiteral::EntityRef(p) => p.to_value(), TypedLiteral::InputRef(r) => Value::PlasmInputRef(r.clone()), + TypedLiteral::Money(m) => Value::Money(m.clone()), } } @@ -70,6 +74,7 @@ impl TypedLiteral { Ok(p) => Ok(TypedLiteral::EntityRef(p)), Err(e) => Err(TypedLiteralError::EntityRef(e)), }, + Value::Money(m) => Ok(TypedLiteral::Money(m.clone())), } } } @@ -217,6 +222,10 @@ mod tests { Value::Integer(-3), Value::Float(1.5), Value::String("x".into()), + Value::Money(MoneyValue::new( + "12.5".parse().expect("decimal"), + Some("USD".into()), + )), ] { let t = TypedLiteral::try_from_value(&v).expect("scalar"); assert_eq!(t.to_value(), v); diff --git a/crates/plasm-core/src/typed_row.rs b/crates/plasm-core/src/typed_row.rs index afd9a776..994bd14f 100644 --- a/crates/plasm-core/src/typed_row.rs +++ b/crates/plasm-core/src/typed_row.rs @@ -107,6 +107,7 @@ impl From for TypedFieldValue { Value::Float(f) => TypedFieldValue::Float(f), Value::String(s) | Value::PhraseIdent(s) => TypedFieldValue::String(s), Value::Array(a) => TypedFieldValue::Array(a.into_iter().map(Self::from).collect()), + Value::Money(_) => TypedFieldValue::Json(v), Value::Object(m) => { TypedFieldValue::Object(m.into_iter().map(|(k, v)| (k, Self::from(v))).collect()) } diff --git a/crates/plasm-core/src/value.rs b/crates/plasm-core/src/value.rs index 325963ec..0e77fd17 100644 --- a/crates/plasm-core/src/value.rs +++ b/crates/plasm-core/src/value.rs @@ -145,6 +145,8 @@ pub enum Value { #[serde(flatten)] ctor_fields: indexmap::IndexMap, }, + /// Fowler money (exact decimal + optional currency). Tagged so untagged serde does not collide with [`Value::Object`]. + Money(crate::money::MoneyValue), Object(indexmap::IndexMap), } @@ -227,7 +229,8 @@ impl Value { | Value::Bool(_) | Value::Integer(_) | Value::Float(_) - | Value::String(_) => {} + | Value::String(_) + | Value::Money(_) => {} } } @@ -261,7 +264,8 @@ impl Value { | Value::Null | Value::Bool(_) | Value::Integer(_) - | Value::Float(_) => false, + | Value::Float(_) + | Value::Money(_) => false, } } @@ -276,6 +280,7 @@ impl Value { Value::String(_) | Value::PhraseIdent(_) => "string", Value::Array(_) => "array", Value::UnionCtor { .. } => "union_ctor", + Value::Money(_) => "money", Value::Object(_) => "object", } } @@ -321,6 +326,15 @@ impl Value { (Value::String(s) | Value::PhraseIdent(s), FieldType::Json) => { parse_json_subtree_str(s).is_some() } + ( + Value::Money(_) + | Value::String(_) + | Value::PhraseIdent(_) + | Value::Integer(_) + | Value::Float(_) + | Value::Object(_), + FieldType::Money, + ) => true, _ => false, } } @@ -346,6 +360,7 @@ impl Value { match self { Value::Integer(i) => Some(*i as f64), Value::Float(f) => Some(*f), + Value::Money(m) => m.amount().to_string().parse().ok(), _ => None, } } @@ -480,6 +495,7 @@ impl Value { ); format!("{ctor_label}{{{inner}}}") } + Value::Money(m) => m.display(), Value::Object(o) => { if depth >= budget.max_depth { return format!("{{{} fields}}", o.len()); @@ -594,17 +610,16 @@ pub enum TemporalWireFormat { /// Narrowing of on-wire encoding for a field beyond its scalar [`FieldType`]. /// /// Used when **coercing user/agent input** (path expressions, predicates) to the API’s expected -/// wire shape — not for reformatting values shown for **display** after decoding. -/// -/// This is the extension point for **deterministic** normalisation: today time is the main case -/// ([`TemporalWireFormat`]); future variants can cover UUID layout, fixed-scale decimals, etc. +/// wire shape — and, for [`FieldType::Money`], when decoding/encoding HTTP scalars. /// /// **YAML / JSON:** A scalar such as `rfc3339` deserialises as [`ValueWireFormat::Temporal`]. -/// An explicit map `{ temporal: rfc3339 }` is also accepted (stable once other categories exist). +/// Money uses a map: `{ money: decimal_string }` or `{ money: minor_units, scale: 2 }`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum ValueWireFormat { /// Date/time on the wire (see [`TemporalWireFormat`]). Temporal(TemporalWireFormat), + /// Money amount on the wire (see [`crate::MoneyWireFormat`]). + Money(crate::money::MoneyWireFormat), } impl Serialize for ValueWireFormat { @@ -612,8 +627,25 @@ impl Serialize for ValueWireFormat { where S: Serializer, { + use serde::ser::SerializeMap; match self { ValueWireFormat::Temporal(t) => t.serialize(serializer), + ValueWireFormat::Money(m) => { + let (key, scale) = match m { + crate::money::MoneyWireFormat::DecimalString => ("decimal_string", None), + crate::money::MoneyWireFormat::JsonNumber => ("json_number", None), + crate::money::MoneyWireFormat::MinorUnits { scale } => { + ("minor_units", Some(*scale)) + } + }; + let mut map = + serializer.serialize_map(Some(if scale.is_some() { 2 } else { 1 }))?; + map.serialize_entry("money", key)?; + if let Some(scale) = scale { + map.serialize_entry("scale", &scale)?; + } + map.end() + } } } } @@ -629,7 +661,9 @@ impl<'de> Deserialize<'de> for ValueWireFormat { type Value = ValueWireFormat; fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { - formatter.write_str("value_format string or map with a `temporal` key") + formatter.write_str( + "value_format string (temporal) or map with `temporal` or `money` key", + ) } fn visit_str(self, v: &str) -> Result { @@ -643,6 +677,8 @@ impl<'de> Deserialize<'de> for ValueWireFormat { fn visit_map>(self, mut map: A) -> Result { let mut temporal: Option = None; + let mut money: Option = None; + let mut scale: Option = None; while let Some(key) = map.next_key::()? { match key.as_str() { "temporal" => { @@ -651,15 +687,44 @@ impl<'de> Deserialize<'de> for ValueWireFormat { } temporal = Some(map.next_value()?); } + "money" => { + if money.is_some() { + return Err(de::Error::duplicate_field("money")); + } + money = Some(map.next_value()?); + } + "scale" => { + if scale.is_some() { + return Err(de::Error::duplicate_field("scale")); + } + scale = Some(map.next_value()?); + } other => { - return Err(de::Error::unknown_field(other, &["temporal"])); + return Err(de::Error::unknown_field( + other, + &["temporal", "money", "scale"], + )); } } } - let t = temporal.ok_or_else(|| { - de::Error::custom("value_format map requires a `temporal` field") - })?; - Ok(ValueWireFormat::Temporal(t)) + match (temporal, money, scale) { + (Some(t), None, None) => Ok(ValueWireFormat::Temporal(t)), + (None, Some(encoding), scale) => { + let fmt = + crate::money::MoneyWireFormat::from_catalog_parts(&encoding, scale) + .map_err(de::Error::custom)?; + Ok(ValueWireFormat::Money(fmt)) + } + (Some(_), Some(_), _) => Err(de::Error::custom( + "value_format map cannot set both `temporal` and `money`", + )), + (Some(_), None, Some(_)) => Err(de::Error::custom( + "value_format `scale` is only valid with money: minor_units", + )), + (None, None, _) => Err(de::Error::custom( + "value_format map requires a `temporal` or `money` field", + )), + } } } @@ -688,6 +753,8 @@ pub enum FieldType { Array, /// Arbitrary JSON object/array subtree from the wire (not a scalar). Json, + /// Fowler money: exact decimal amount + optional currency. + Money, /// Foreign key: stores an ID referencing another entity. EntityRef { target: crate::identity::EntityName, @@ -699,7 +766,7 @@ impl FieldType { pub fn compatible_operators(&self) -> &[CompOp] { match self { FieldType::Boolean => &[CompOp::Eq, CompOp::Neq, CompOp::Exists], - FieldType::Number | FieldType::Integer => &[ + FieldType::Number | FieldType::Integer | FieldType::Money => &[ CompOp::Eq, CompOp::Neq, CompOp::Gt, @@ -867,4 +934,51 @@ mod value_wire_format_tests { .unwrap(); assert_eq!(j, "\"iso8601_date\""); } + + #[test] + fn deserializes_money_map() { + let v: ValueWireFormat = + serde_json::from_value(serde_json::json!({ "money": "decimal_string" })).unwrap(); + assert_eq!( + v, + ValueWireFormat::Money(crate::money::MoneyWireFormat::decimal_string()) + ); + let v: ValueWireFormat = serde_json::from_value(serde_json::json!({ + "money": "minor_units", + "scale": 2 + })) + .unwrap(); + assert_eq!( + v, + ValueWireFormat::Money(crate::money::MoneyWireFormat::minor_units(2).unwrap()) + ); + } + + #[test] + fn rejects_money_minor_units_without_scale() { + let err = serde_json::from_value::(serde_json::json!({ + "money": "minor_units" + })) + .unwrap_err(); + assert!(err.to_string().contains("scale")); + } + + #[test] + fn rejects_scale_on_decimal_string() { + let err = serde_json::from_value::(serde_json::json!({ + "money": "decimal_string", + "scale": 2 + })) + .unwrap_err(); + assert!(err.to_string().contains("minor_units")); + } + + #[test] + fn serializes_money_minor_units_with_scale() { + let j = serde_json::to_value(ValueWireFormat::Money( + crate::money::MoneyWireFormat::minor_units(2).unwrap(), + )) + .unwrap(); + assert_eq!(j, serde_json::json!({ "money": "minor_units", "scale": 2 })); + } } diff --git a/crates/plasm-core/src/wire_coercion.rs b/crates/plasm-core/src/wire_coercion.rs index 1b22a44a..0f47f3be 100644 --- a/crates/plasm-core/src/wire_coercion.rs +++ b/crates/plasm-core/src/wire_coercion.rs @@ -280,7 +280,9 @@ pub fn coerce_value_for_field_type_with_policy( Some(ValueWireFormat::Temporal(fmt)) => { crate::temporal::normalize_temporal_value(val, fmt) } - None => Err("Date field missing value_format in schema".to_string()), + None | Some(ValueWireFormat::Money(_)) => { + Err("Date field missing value_format in schema".to_string()) + } }, FieldType::String | FieldType::Uuid | FieldType::Select | FieldType::MultiSelect => { Ok(match val { @@ -317,6 +319,13 @@ pub fn coerce_value_for_field_type_with_policy( }), other => Ok(other), }, + FieldType::Money => { + let fmt = match value_format { + Some(ValueWireFormat::Money(f)) => f, + _ => return Err("Money field missing value_format in schema".to_string()), + }; + crate::money::normalize(val, fmt, None).map_err(String::from) + } _ => Ok(val), } } @@ -328,9 +337,12 @@ pub fn coerce_json_value_for_field_type( array_items: Option<&ArrayItemsSchema>, value: serde_json::Value, ) -> serde_json::Value { - let plasm = json_value_to_plasm_value(&value); + let plasm = json_to_plasm_for_field(ft, &value); match coerce_value_for_field_type(ft, value_format, array_items, plasm) { - Ok(v) => plasm_value_to_json(&v), + Ok(v) => match try_plasm_value_to_json(&v) { + Ok(j) => j, + Err(_) => value, + }, Err(_) => value, } } @@ -340,7 +352,7 @@ pub fn binding_value_as_plasm_value( raw: &serde_json::Value, target_nv: &NamedValueSchema, ) -> Value { - let plasm = json_value_to_plasm_value(raw); + let plasm = json_to_plasm_for_field(&target_nv.field_type, raw); coerce_value_for_field_type( &target_nv.field_type, target_nv.value_format, @@ -350,6 +362,14 @@ pub fn binding_value_as_plasm_value( .unwrap_or(plasm) } +fn json_to_plasm_for_field(ft: &FieldType, value: &serde_json::Value) -> Value { + if matches!(ft, FieldType::Money) { + crate::money::json_amount_to_value(value) + } else { + json_value_to_plasm_value(value) + } +} + pub fn json_value_to_plasm_value(v: &serde_json::Value) -> Value { match v { serde_json::Value::Null => Value::Null, @@ -367,40 +387,54 @@ pub fn json_value_to_plasm_value(v: &serde_json::Value) -> Value { serde_json::Value::Array(items) => { Value::Array(items.iter().map(json_value_to_plasm_value).collect()) } - serde_json::Value::Object(map) => Value::Object( - map.iter() - .map(|(k, v)| (k.clone(), json_value_to_plasm_value(v))) - .collect(), - ), + serde_json::Value::Object(map) => { + if let Some(m) = crate::money::try_from_json_object(map) { + Value::Money(m) + } else { + Value::Object( + map.iter() + .map(|(k, v)| (k.clone(), json_value_to_plasm_value(v))) + .collect(), + ) + } + } } } -pub fn plasm_value_to_json(v: &Value) -> serde_json::Value { +pub fn try_plasm_value_to_json(v: &Value) -> Result { if let Some(s) = v.as_string_or_phrase() { - return serde_json::Value::String(s.to_string()); + return Ok(serde_json::Value::String(s.to_string())); } match v { - Value::Null => serde_json::Value::Null, - Value::Bool(b) => serde_json::Value::Bool(*b), - Value::Integer(i) => serde_json::json!(i), - Value::Float(f) => serde_json::Number::from_f64(*f) + Value::Null => Ok(serde_json::Value::Null), + Value::Bool(b) => Ok(serde_json::Value::Bool(*b)), + Value::Integer(i) => Ok(serde_json::json!(i)), + Value::Float(f) => Ok(serde_json::Number::from_f64(*f) .map(serde_json::Value::Number) - .unwrap_or(serde_json::Value::Null), - Value::Array(items) => { - serde_json::Value::Array(items.iter().map(plasm_value_to_json).collect()) - } - Value::Object(map) => serde_json::Value::Object( + .unwrap_or(serde_json::Value::Null)), + Value::Array(items) => Ok(serde_json::Value::Array( + items + .iter() + .map(try_plasm_value_to_json) + .collect::, _>>()?, + )), + Value::Object(map) => Ok(serde_json::Value::Object( map.iter() - .map(|(k, v)| (k.clone(), plasm_value_to_json(v))) - .collect(), - ), + .map(|(k, v)| Ok((k.clone(), try_plasm_value_to_json(v)?))) + .collect::>()?, + )), Value::PlasmInputRef(_) | Value::UnionCtor { .. } | Value::String(_) - | Value::PhraseIdent(_) => serde_json::Value::Null, + | Value::PhraseIdent(_) => Ok(serde_json::Value::Null), + Value::Money(m) => m.encode_stored().map_err(String::from), } } +pub fn plasm_value_to_json(v: &Value) -> serde_json::Value { + try_plasm_value_to_json(v).unwrap_or(serde_json::Value::Null) +} + fn normalize_numeric_id_float(f: f64) -> String { if f.fract() == 0.0 && f.is_finite() { format!("{}", f as i64) diff --git a/crates/plasm-discovery/src/engine.rs b/crates/plasm-discovery/src/engine.rs index 432799ae..10bbd179 100644 --- a/crates/plasm-discovery/src/engine.rs +++ b/crates/plasm-discovery/src/engine.rs @@ -682,6 +682,7 @@ mod relation_intent_rank_tests { allowed_values: None, string_semantics: None, array_items: None, + currency: None, }, ); let id_key = ValueDomainKey::new("tid").unwrap(); @@ -696,6 +697,7 @@ mod relation_intent_rank_tests { wire_path: None, derive: None, data_class: None, + currency_field: None, }; cgs.add_resource(ResourceSchema { name: EntityName::from("Parent"), diff --git a/crates/plasm-e2e/tests/common/language_matrix.rs b/crates/plasm-e2e/tests/common/language_matrix.rs index c1b5d8ab..a8371494 100644 --- a/crates/plasm-e2e/tests/common/language_matrix.rs +++ b/crates/plasm-e2e/tests/common/language_matrix.rs @@ -56,7 +56,7 @@ pub fn matrix_execute_session(cgs: Arc) -> ExecuteSession { MATRIX_ENTRY_ID.into(), Arc::new(CgsContext::entry(MATRIX_ENTRY_ID, cgs.clone())), ); - let wave: &[&str] = &["LangItem", "LangLine", "LangTag"]; + let wave: &[&str] = &["LangItem", "LangLine", "LangTag", "LangOffer"]; let exp = TeachingExposureSession::new(cgs.as_ref(), MATRIX_ENTRY_ID, wave); ExecuteSession::new( "matrix_ph".into(), diff --git a/crates/plasm-e2e/tests/plasm_language_matrix.rs b/crates/plasm-e2e/tests/plasm_language_matrix.rs index ed4fb673..64301690 100644 --- a/crates/plasm-e2e/tests/plasm_language_matrix.rs +++ b/crates/plasm-e2e/tests/plasm_language_matrix.rs @@ -134,6 +134,8 @@ const REQUIRED_FEATURE_TAGS: &[&str] = &[ "utf8_dollar_interpolate", "host_wait_cancel", "monadic_comp_witness", + "money_predicate", + "money_create_body", ]; struct MatrixRow { @@ -257,6 +259,16 @@ fn json_value_contains_substring(v: &serde_json::Value, needle: &str) -> bool { } } +fn tcv_money_amount(v: &TypedComparisonValue) -> Option { + match v.to_value() { + Value::Money(m) => Some(m.amount().to_string()), + Value::Integer(n) => Some(n.to_string()), + Value::Float(f) => Some(f.to_string()), + Value::String(s) => Some(s), + _ => None, + } +} + fn tcv_integer(v: &TypedComparisonValue) -> Option { match v.to_value() { Value::Integer(n) => Some(n), @@ -1392,6 +1404,35 @@ fn assert_planning_ir( return Err(format!("unexpected create: {:?}", c.capability)); } } + "lang_money_predicate_gt" => { + let q = first_query(&surfaces)?; + if q.entity != "LangOffer" { + return Err(format!("expected LangOffer query, got {:?}", q.entity)); + } + let Some(pred) = q.predicate.as_ref() else { + return Err("expected money comparison predicate".into()); + }; + let Predicate::Comparison { + field, + op: CompOp::Gt, + value, + } = pred + else { + return Err(format!("expected price gt, got {pred:?}")); + }; + if field != "price" || tcv_money_amount(value).as_deref() != Some("10") { + return Err(format!("unexpected money predicate: {pred:?}")); + } + } + "lang_money_create_body" => { + let Some(Expr::Create(c)) = surfaces.iter().find(|e| matches!(e, Expr::Create(_))) + else { + return Err(format!("expected Create, got {:?}", surfaces)); + }; + if c.capability.as_str() != "langoffer_create" || c.entity != "LangOffer" { + return Err(format!("unexpected create: {:?}", c.capability)); + } + } "lang_effect_update" => { let Some(Expr::Invoke(InvokeExpr { capability, .. })) = surfaces.iter().find(|e| matches!(e, Expr::Invoke(_))) @@ -2206,6 +2247,24 @@ tags"#, min_node_results: 1, expect_markdown_substrings: &["```tsv", "MatrixCreated"], }, + MatrixRow { + id: "lang_money_predicate_gt", + program: r#"LangOffer{price>10}"#, + surface_line: false, + federated: false, + features: &["money_predicate", "predicate_brace_comparison"], + min_node_results: 1, + expect_markdown_substrings: &["```tsv", "price", "12.5 USD"], + }, + MatrixRow { + id: "lang_money_create_body", + program: r#"LangOffer.create(price="9.25", quote_currency="USD")"#, + surface_line: false, + federated: false, + features: &["money_create_body", "effect_create"], + min_node_results: 1, + expect_markdown_substrings: &["```tsv"], + }, MatrixRow { id: "lang_effect_update", program: r#"LangItem("i1").update(title="MatrixPatch", score=42, owner="alice")"#, diff --git a/crates/plasm-mock/src/handlers.rs b/crates/plasm-mock/src/handlers.rs index c16976b6..c1d1d5ca 100644 --- a/crates/plasm-mock/src/handlers.rs +++ b/crates/plasm-mock/src/handlers.rs @@ -224,6 +224,7 @@ mod tests { allowed_values: None, string_semantics: Some(StringSemantics::Short), array_items: None, + currency: None, }, ); } @@ -247,6 +248,7 @@ mod tests { wire_path: None, derive: None, data_class: None, + currency_field: None, }, FieldSchema { name: "name".into(), @@ -261,6 +263,7 @@ mod tests { wire_path: None, derive: None, data_class: None, + currency_field: None, }, ], relations: vec![], diff --git a/crates/plasm-mock/src/server.rs b/crates/plasm-mock/src/server.rs index f2a37c49..7979a4bc 100644 --- a/crates/plasm-mock/src/server.rs +++ b/crates/plasm-mock/src/server.rs @@ -104,6 +104,7 @@ mod tests { allowed_values: None, string_semantics: Some(StringSemantics::Short), array_items: None, + currency: None, }, ); } @@ -127,6 +128,7 @@ mod tests { wire_path: None, derive: None, data_class: None, + currency_field: None, }, FieldSchema { name: "name".into(), @@ -141,6 +143,7 @@ mod tests { wire_path: None, derive: None, data_class: None, + currency_field: None, }, ], relations: vec![], diff --git a/crates/plasm-mock/src/store.rs b/crates/plasm-mock/src/store.rs index e469cca8..401e8ceb 100644 --- a/crates/plasm-mock/src/store.rs +++ b/crates/plasm-mock/src/store.rs @@ -378,6 +378,7 @@ mod tests { allowed_values: None, string_semantics: Some(StringSemantics::Short), array_items: None, + currency: None, }, ); cgs.values.insert( @@ -389,6 +390,7 @@ mod tests { allowed_values: None, string_semantics: Some(StringSemantics::Short), array_items: None, + currency: None, }, ); cgs.values.insert( @@ -400,6 +402,7 @@ mod tests { allowed_values: None, string_semantics: None, array_items: None, + currency: None, }, ); @@ -423,6 +426,7 @@ mod tests { wire_path: None, derive: None, data_class: None, + currency_field: None, }, FieldSchema { name: "name".into(), @@ -437,6 +441,7 @@ mod tests { wire_path: None, derive: None, data_class: None, + currency_field: None, }, FieldSchema { name: "revenue".into(), @@ -451,6 +456,7 @@ mod tests { wire_path: None, derive: None, data_class: None, + currency_field: None, }, ], relations: vec![RelationSchema { @@ -490,6 +496,7 @@ mod tests { wire_path: None, derive: None, data_class: None, + currency_field: None, }, FieldSchema { name: "name".into(), @@ -504,6 +511,7 @@ mod tests { wire_path: None, derive: None, data_class: None, + currency_field: None, }, ], relations: vec![], diff --git a/crates/plasm-runtime/src/execution/entity_decoder.rs b/crates/plasm-runtime/src/execution/entity_decoder.rs index c079c4cc..da725d68 100644 --- a/crates/plasm-runtime/src/execution/entity_decoder.rs +++ b/crates/plasm-runtime/src/execution/entity_decoder.rs @@ -5,7 +5,7 @@ use plasm_compile::{ entity_decoder_for_from_parent_get_target, path_expr_from_json_segments, EntityDecoder, FieldDecoder, PathExpr, PathSegment, RelationDecoder, }; -use plasm_core::{Cardinality, RelationMaterialization, CGS}; +use plasm_core::{Cardinality, FieldType, RelationMaterialization, ValueWireFormat, CGS}; pub(crate) fn create_entity_decoder_for_capability( declared_entity: &str, @@ -115,7 +115,18 @@ fn create_entity_decoder_inner( name: field_name.as_str().to_string(), }]) }; - let fd = FieldDecoder::new(field_name.as_str(), from_path); + let mut fd = FieldDecoder::new(field_name.as_str(), from_path); + if let Ok(nv) = field_schema.named_value(cgs) { + if let (FieldType::Money, Some(ValueWireFormat::Money(fmt))) = + (&nv.field_type, nv.value_format) + { + fd = fd.with_money(plasm_core::MoneyDecodeSpec::new( + fmt, + nv.currency.clone(), + field_schema.currency_field.clone(), + )); + } + } field_decoders.push(match &field_schema.derive { Some(d) => fd.with_derive(d.clone()), None => fd, diff --git a/crates/plasm-runtime/src/execution/mod.rs b/crates/plasm-runtime/src/execution/mod.rs index e1b6525d..24377818 100644 --- a/crates/plasm-runtime/src/execution/mod.rs +++ b/crates/plasm-runtime/src/execution/mod.rs @@ -59,6 +59,7 @@ pub use compile_preflight::preflight_compile_expr; pub(crate) use pagination_state::PaginationLoopState; #[cfg(test)] pub(crate) use pagination_state::{merge_pagination_into_body, pagination_context_map}; +pub(crate) use plasm_core::json_value_to_plasm_value as json_to_plasm_value; /// Resolve the capability that backs a [`QueryExpr`] (delegates to [`plasm_core::resolve_query_capability`]). fn resolve_query_capability<'a>( @@ -1385,7 +1386,7 @@ impl ExecutionEngine { break; }; - if !client_side_predicate_matches(foreign, &cross.foreign_predicate) { + if !client_side_predicate_matches(foreign, &cross.foreign_predicate)? { passes = false; break; } @@ -2073,70 +2074,80 @@ fn normalize_cml_scope_entity_ref_value(value: &Value, ent: &EntityDef) -> Optio /// Only call this with predicates that have been stripped of non-entity-field comparisons /// (i.e. via `entity_field_predicate`). Every comparison field is expected to be a real /// entity field; if a field is absent the entity does not match. -fn client_side_predicate_matches(entity: &CachedEntity, predicate: &plasm_core::Predicate) -> bool { +fn client_side_predicate_matches( + entity: &CachedEntity, + predicate: &plasm_core::Predicate, +) -> Result { use plasm_core::CompOp; match predicate { - plasm_core::Predicate::True => true, - plasm_core::Predicate::False => false, + plasm_core::Predicate::True => Ok(true), + plasm_core::Predicate::False => Ok(false), plasm_core::Predicate::Comparison { field, op, value } => { let rhs = value.to_value(); let Some(actual_tf) = entity.get_field(field) else { - // Field genuinely absent from this entity instance — does not match. - // Non-entity-field predicates (scope, filter params) should have been - // stripped by `entity_field_predicate` before reaching here. - return *op == CompOp::Exists && matches!(rhs, Value::Null); + return Ok(*op == CompOp::Exists && matches!(rhs, Value::Null)); }; let actual = actual_tf.to_value(); - match op { - CompOp::Eq => actual == rhs, - CompOp::Neq => actual != rhs, - CompOp::Gt => { - if let (Some(a), Some(b)) = (actual.as_number(), rhs.as_number()) { - a > b - } else { - false - } - } - CompOp::Lt => { - if let (Some(a), Some(b)) = (actual.as_number(), rhs.as_number()) { - a < b - } else { - false - } - } - CompOp::Gte => { - if let (Some(a), Some(b)) = (actual.as_number(), rhs.as_number()) { - a >= b - } else { - false - } - } - CompOp::Lte => { - if let (Some(a), Some(b)) = (actual.as_number(), rhs.as_number()) { - a <= b - } else { - false - } - } + let money_err = |e: plasm_core::CrossCurrencyError| { + RuntimeError::from(plasm_core::TypeError::from(e)) + }; + Ok(match op { + CompOp::Eq => plasm_core::money::values_eq(&actual, &rhs).map_err(money_err)?, + CompOp::Neq => !plasm_core::money::values_eq(&actual, &rhs).map_err(money_err)?, + CompOp::Gt => plasm_core::money::values_ord(&actual, &rhs) + .map_err(money_err)? + .is_some_and(|o| o.is_gt()), + CompOp::Lt => plasm_core::money::values_ord(&actual, &rhs) + .map_err(money_err)? + .is_some_and(|o| o.is_lt()), + CompOp::Gte => plasm_core::money::values_ord(&actual, &rhs) + .map_err(money_err)? + .is_some_and(|o| o.is_ge()), + CompOp::Lte => plasm_core::money::values_ord(&actual, &rhs) + .map_err(money_err)? + .is_some_and(|o| o.is_le()), CompOp::Contains => actual.contains(&rhs), CompOp::In => match &rhs { Value::Array(arr) => arr.contains(&actual), _ => false, }, CompOp::Exists => !matches!(actual, Value::Null), + }) + } + plasm_core::Predicate::And { args } => { + for a in args { + if !client_side_predicate_matches(entity, a)? { + return Ok(false); + } } + Ok(true) + } + plasm_core::Predicate::Or { args } => { + for a in args { + if client_side_predicate_matches(entity, a)? { + return Ok(true); + } + } + Ok(false) } - plasm_core::Predicate::And { args } => args - .iter() - .all(|a| client_side_predicate_matches(entity, a)), - plasm_core::Predicate::Or { args } => args - .iter() - .any(|a| client_side_predicate_matches(entity, a)), plasm_core::Predicate::Not { predicate: inner } => { - !client_side_predicate_matches(entity, inner) + Ok(!client_side_predicate_matches(entity, inner)?) + } + plasm_core::Predicate::ExistsRelation { .. } => Ok(true), + } +} + +fn filter_entities_by_predicate( + entities: Vec, + pred: &plasm_core::Predicate, +) -> Result, RuntimeError> { + let mut out = Vec::with_capacity(entities.len()); + for e in entities { + if client_side_predicate_matches(&e, pred)? { + out.push(e); } - plasm_core::Predicate::ExistsRelation { .. } => true, } + Ok(out) } /// Strip comparisons against non-entity fields from a predicate, returning the @@ -3056,7 +3067,11 @@ fn value_to_ambient_string(v: &Value) -> Option { Value::Integer(i) => Some(i.to_string()), Value::Float(f) => Some(f.to_string()), Value::Bool(b) => Some(b.to_string()), - Value::Null | Value::Array(_) | Value::Object(_) | Value::UnionCtor { .. } => None, + Value::Null + | Value::Array(_) + | Value::Object(_) + | Value::UnionCtor { .. } + | Value::Money(_) => None, } } @@ -3125,35 +3140,6 @@ pub(crate) fn current_timestamp() -> u64 { .as_secs() } -/// Convert serde_json::Value to plasm_core::Value -pub(crate) fn json_to_plasm_value(json: &serde_json::Value) -> Value { - match json { - serde_json::Value::Null => Value::Null, - serde_json::Value::Bool(b) => Value::Bool(*b), - serde_json::Value::Number(n) => { - if let Some(i) = n.as_i64() { - Value::Integer(i) - } else if let Some(f) = n.as_f64() { - Value::Float(f) - } else { - Value::Null - } - } - serde_json::Value::String(s) => Value::String(s.clone()), - serde_json::Value::Array(arr) => { - let values = arr.iter().map(json_to_plasm_value).collect(); - Value::Array(values) - } - serde_json::Value::Object(obj) => { - let mut map = indexmap::IndexMap::new(); - for (k, v) in obj { - map.insert(k.clone(), json_to_plasm_value(v)); - } - Value::Object(map) - } - } -} - /// Execute Plasm [`Expr`] trees against a live or replay backend. /// /// Implemented by [`ExecutionEngine`]; implementors can stub this for tests or @@ -3213,6 +3199,7 @@ mod tests { allowed_values: None, string_semantics: Some(StringSemantics::Short), array_items: None, + currency: None, }, ); cgs.values.insert( @@ -3224,6 +3211,7 @@ mod tests { allowed_values: None, string_semantics: Some(StringSemantics::Short), array_items: None, + currency: None, }, ); @@ -3248,6 +3236,7 @@ mod tests { wire_path: None, derive: None, data_class: None, + currency_field: None, }, FieldSchema { name: "name".into(), @@ -3262,6 +3251,7 @@ mod tests { wire_path: None, derive: None, data_class: None, + currency_field: None, }, ], relations: vec![], @@ -3354,6 +3344,7 @@ mod tests { allowed_values: None, string_semantics: Some(StringSemantics::Short), array_items: None, + currency: None, }, ); cgs.values.insert( @@ -3367,6 +3358,7 @@ mod tests { allowed_values: None, string_semantics: None, array_items: None, + currency: None, }, ); cgs.add_resource(ResourceSchema { @@ -3386,6 +3378,7 @@ mod tests { wire_path: None, derive: None, data_class: None, + currency_field: None, }], relations: vec![], expression_aliases: vec![], diff --git a/crates/plasm-runtime/src/execution/query_stream.rs b/crates/plasm-runtime/src/execution/query_stream.rs index 96052434..508afbe9 100644 --- a/crates/plasm-runtime/src/execution/query_stream.rs +++ b/crates/plasm-runtime/src/execution/query_stream.rs @@ -230,8 +230,8 @@ impl ExecutionEngine { if let Some(entity_pred) = entity_field_predicate(pred, entity_def, Some(&cap_params)) { - res.entities - .retain(|e| client_side_predicate_matches(e, &entity_pred)); + res.entities = + filter_entities_by_predicate(res.entities, &entity_pred)?; res.count = res.entities.len(); } } @@ -416,10 +416,9 @@ impl ExecutionEngine { cgs.get_entity(&query.entity) .and_then(|e| entity_field_predicate(pred, e, Some(&cap_params))) }) { - Some(entity_pred) => hydrated - .into_iter() - .filter(|e| client_side_predicate_matches(e, &entity_pred)) - .collect(), + Some(entity_pred) => { + filter_entities_by_predicate(hydrated, &entity_pred)? + } None => hydrated, }; diff --git a/crates/plasm-runtime/src/http_transport.rs b/crates/plasm-runtime/src/http_transport.rs index 40d5d8bf..5d3dac37 100644 --- a/crates/plasm-runtime/src/http_transport.rs +++ b/crates/plasm-runtime/src/http_transport.rs @@ -49,7 +49,10 @@ fn append_compiled_query_pairs(url: &mut String, query: Option<&Value>) { let Some(query) = query else { return; }; - let json_val = plasm_value_to_json(query); + let json_val = match plasm_value_to_json(query) { + Ok(v) => v, + Err(_) => return, + }; let Some(obj) = json_val.as_object() else { return; }; @@ -218,7 +221,7 @@ fn build_compiled_reqwest( } else if let Some(body) = &request.body { match request.body_format { HttpBodyFormat::Json => { - let json_body = plasm_value_to_json(body); + let json_body = plasm_value_to_json(body)?; let stripped = strip_null_fields(json_body); let bytes = serde_json::to_vec(&stripped).map_err(|e| { RuntimeError::SerializationError { @@ -250,7 +253,7 @@ fn build_compiled_reqwest( } if let Some(query) = &request.query { - let json_val = plasm_value_to_json(query); + let json_val = plasm_value_to_json(query)?; if let Some(obj) = json_val.as_object() { for (key, value) in obj { match value { @@ -286,7 +289,7 @@ fn build_compiled_reqwest( req_builder = apply_resolved_auth(req_builder, auth); if let Some(headers) = &request.headers { - let json_val = plasm_value_to_json(headers); + let json_val = plasm_value_to_json(headers)?; if let Some(obj) = json_val.as_object() { for (key, value) in obj { let header_val = match value { @@ -438,7 +441,7 @@ fn add_multipart_part( } if matches!(&spec.content, Value::Object(_) | Value::Array(_)) { - let vec = serde_json::to_vec(&plasm_value_to_json(&spec.content)).map_err(|e| { + let vec = serde_json::to_vec(&plasm_value_to_json(&spec.content)?).map_err(|e| { RuntimeError::SerializationError { message: format!("multipart JSON encode for `{}`: {e}", spec.name), } @@ -490,6 +493,9 @@ fn add_multipart_part( }); } Value::Object(_) | Value::Array(_) => unreachable!("handled above"), + Value::Money(m) => m + .to_wire_text() + .map_err(|e| RuntimeError::SerializationError { message: e.into() })?, Value::UnionCtor { .. } => { return Err(RuntimeError::ConfigurationError { message: format!( @@ -590,6 +596,9 @@ fn plasm_value_to_form_urlencoded(body: &Value) -> Result Value::Bool(b) => b.to_string(), Value::Integer(i) => i.to_string(), Value::Float(f) => f.to_string(), + Value::Money(m) => m + .to_wire_text() + .map_err(|e| RuntimeError::SerializationError { message: e.into() })?, _ => { return Err(RuntimeError::ConfigurationError { message: format!( @@ -1030,27 +1039,36 @@ fn strip_null_fields(value: serde_json::Value) -> serde_json::Value { } } -fn plasm_value_to_json(value: &Value) -> serde_json::Value { +fn plasm_value_to_json(value: &Value) -> Result { match value { - Value::PlasmInputRef(_) => serde_json::to_value(value).unwrap_or(serde_json::Value::Null), - Value::Null => serde_json::Value::Null, - Value::Bool(b) => serde_json::Value::Bool(*b), - Value::Integer(i) => serde_json::Value::Number((*i).into()), - Value::Float(f) => serde_json::Number::from_f64(*f) - .map(serde_json::Value::Number) - .unwrap_or(serde_json::Value::Null), - Value::String(s) | Value::PhraseIdent(s) => serde_json::Value::String(s.clone()), - Value::Array(arr) => { - serde_json::Value::Array(arr.iter().map(plasm_value_to_json).collect()) + Value::PlasmInputRef(_) => { + Ok(serde_json::to_value(value).unwrap_or(serde_json::Value::Null)) } + Value::Null => Ok(serde_json::Value::Null), + Value::Bool(b) => Ok(serde_json::Value::Bool(*b)), + Value::Integer(i) => Ok(serde_json::Value::Number((*i).into())), + Value::Float(f) => Ok(serde_json::Number::from_f64(*f) + .map(serde_json::Value::Number) + .unwrap_or(serde_json::Value::Null)), + Value::String(s) | Value::PhraseIdent(s) => Ok(serde_json::Value::String(s.clone())), + Value::Array(arr) => Ok(serde_json::Value::Array( + arr.iter() + .map(plasm_value_to_json) + .collect::, _>>()?, + )), Value::Object(obj) => { let mut map = serde_json::Map::new(); for (k, v) in obj { - map.insert(k.clone(), plasm_value_to_json(v)); + map.insert(k.clone(), plasm_value_to_json(v)?); } - serde_json::Value::Object(map) + Ok(serde_json::Value::Object(map)) + } + Value::UnionCtor { .. } => { + Ok(serde_json::to_value(value).unwrap_or(serde_json::Value::Null)) } - Value::UnionCtor { .. } => serde_json::to_value(value).unwrap_or(serde_json::Value::Null), + Value::Money(m) => m + .encode_stored() + .map_err(|e| RuntimeError::SerializationError { message: e.into() }), } } diff --git a/crates/plasm-runtime/src/mockserver.rs b/crates/plasm-runtime/src/mockserver.rs index 68568a75..c6ed5f48 100644 --- a/crates/plasm-runtime/src/mockserver.rs +++ b/crates/plasm-runtime/src/mockserver.rs @@ -305,6 +305,7 @@ fn plasm_value_to_json(value: &Value) -> serde_json::Value { serde_json::Value::Object(map) } Value::UnionCtor { .. } => serde_json::to_value(value).unwrap_or(serde_json::Value::Null), + Value::Money(m) => m.encode_stored().unwrap_or(serde_json::Value::Null), } } diff --git a/crates/plasm-runtime/src/replay.rs b/crates/plasm-runtime/src/replay.rs index 604e3760..68b68407 100644 --- a/crates/plasm-runtime/src/replay.rs +++ b/crates/plasm-runtime/src/replay.rs @@ -343,6 +343,7 @@ fn value_to_json_value(value: &Value) -> serde_json::Value { serde_json::Value::Object(map) } Value::UnionCtor { .. } => serde_json::to_value(value).unwrap_or(serde_json::Value::Null), + Value::Money(m) => m.encode_stored().unwrap_or(serde_json::Value::Null), } } @@ -630,6 +631,7 @@ mod tests { allowed_values: None, string_semantics: Some(StringSemantics::Short), array_items: None, + currency: None, }, ); let input_type = InputType::Object { diff --git a/crates/plasm-runtime/src/view_plan.rs b/crates/plasm-runtime/src/view_plan.rs index da010937..f90bc7a1 100644 --- a/crates/plasm-runtime/src/view_plan.rs +++ b/crates/plasm-runtime/src/view_plan.rs @@ -10,8 +10,9 @@ use plasm_core::schema::{ ViewRelationBinding, ViewScopeInject, }; use plasm_core::{ - CapabilityKind, CapabilitySchema, Cardinality, CreateExpr, GetExpr, Predicate, QueryExpr, Ref, - TypedFieldValue, Value, ViewNodeCondition, ViewNodeWhen, WriteOutcome, CGS, + json_value_to_plasm_value as json_to_plasm_value, CapabilityKind, CapabilitySchema, + Cardinality, CreateExpr, GetExpr, Predicate, QueryExpr, Ref, TypedFieldValue, Value, + ViewNodeCondition, ViewNodeWhen, WriteOutcome, CGS, }; use crate::cache::CachedEntity; @@ -142,34 +143,6 @@ pub(crate) trait ViewNodeRunnerAsync { /// First-row field snapshots from prior view DAG nodes (for param bind resolution). pub type ViewNodeFieldMap = IndexMap>; -pub(crate) fn json_to_plasm_value(json: &serde_json::Value) -> Value { - match json { - serde_json::Value::Null => Value::Null, - serde_json::Value::Bool(b) => Value::Bool(*b), - serde_json::Value::Number(n) => { - if let Some(i) = n.as_i64() { - Value::Integer(i) - } else if let Some(f) = n.as_f64() { - Value::Float(f) - } else { - Value::Null - } - } - serde_json::Value::String(s) => Value::String(s.clone()), - serde_json::Value::Array(arr) => { - let values = arr.iter().map(json_to_plasm_value).collect(); - Value::Array(values) - } - serde_json::Value::Object(obj) => { - let mut map = IndexMap::new(); - for (k, v) in obj { - map.insert(k.clone(), json_to_plasm_value(v)); - } - Value::Object(map) - } - } -} - pub fn view_node_should_run( when: Option<&ViewNodeWhen>, node_results: &IndexMap, diff --git a/crates/plasm-runtime/src/view_stub_rows.rs b/crates/plasm-runtime/src/view_stub_rows.rs index 0f24f7c7..2bd214fc 100644 --- a/crates/plasm-runtime/src/view_stub_rows.rs +++ b/crates/plasm-runtime/src/view_stub_rows.rs @@ -21,6 +21,12 @@ fn placeholder_value(field_type: &FieldType) -> Value { | FieldType::Date => Value::String(String::new()), FieldType::MultiSelect | FieldType::Array => Value::Array(vec![]), FieldType::Json => Value::Object(IndexMap::new()), + FieldType::Money => plasm_core::money::normalize( + Value::String("0".into()), + plasm_core::MoneyWireFormat::decimal_string(), + None, + ) + .unwrap_or_else(|_| Value::String("0".into())), FieldType::EntityRef { target } => Value::String(format!("stub-{target}")), } } diff --git a/crates/plasm-runtime/src/view_template.rs b/crates/plasm-runtime/src/view_template.rs index 1ef9c7e1..3f6cb5d1 100644 --- a/crates/plasm-runtime/src/view_template.rs +++ b/crates/plasm-runtime/src/view_template.rs @@ -27,6 +27,7 @@ fn plasm_value_to_json(v: &Value) -> serde_json::Value { serde_json::Value::Object(map) } Value::PlasmInputRef(_) | Value::UnionCtor { .. } => serde_json::Value::Null, + Value::Money(m) => serde_json::Value::String(m.display()), } } @@ -137,6 +138,7 @@ fn register_view_template_filters(env: &mut Environment<'_>) { serde_json::to_string(&plasm_value_to_json(&out)).unwrap_or_default() } Value::PlasmInputRef(_) | Value::UnionCtor { .. } => String::new(), + Value::Money(m) => m.display(), }) }, ); diff --git a/fixtures/real_openapi_specs/plasm_language_matrix.yaml b/fixtures/real_openapi_specs/plasm_language_matrix.yaml index 9067fc3b..39ae9b29 100644 --- a/fixtures/real_openapi_specs/plasm_language_matrix.yaml +++ b/fixtures/real_openapi_specs/plasm_language_matrix.yaml @@ -329,6 +329,47 @@ paths: - id: "l2" item_id: "i1" note: "line-b" + /language/v1/offers: + get: + summary: List priced offers (money type lock) + responses: + "200": + description: Offers + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/LangOffer" + example: + - id: "o1" + price: "12.50" + quote_currency: "USD" + - id: "o2" + price: "3.00" + quote_currency: "USD" + post: + summary: Create offer + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/LangOfferCreate" + example: + price: "12.50" + quote_currency: "USD" + responses: + "201": + description: Created + content: + application/json: + schema: + $ref: "#/components/schemas/LangOffer" + example: + id: "o-new" + price: "12.50" + quote_currency: "USD" components: schemas: LangItem: @@ -377,6 +418,28 @@ components: type: string note: type: string + LangOffer: + type: object + required: [id, price, quote_currency] + properties: + id: + type: string + price: + type: string + quote_currency: + type: string + example: + id: "o1" + price: "12.50" + quote_currency: "USD" + LangOfferCreate: + type: object + required: [price, quote_currency] + properties: + price: + type: string + quote_currency: + type: string LangViewer: type: object required: [id, display_name] diff --git a/fixtures/schemas/plasm_language_matrix/domain.yaml b/fixtures/schemas/plasm_language_matrix/domain.yaml index 7cf5a2c0..78c404d9 100644 --- a/fixtures/schemas/plasm_language_matrix/domain.yaml +++ b/fixtures/schemas/plasm_language_matrix/domain.yaml @@ -1,4 +1,4 @@ -version: 1 +version: 2 auth: scheme: none http_backend: http://127.0.0.1:9 @@ -186,6 +186,21 @@ entities: path: - key: children - wildcard: true + LangOffer: + id_field: id + description: Priced offer used to lock money decode, compare, and create-body encode. + fields: + id: + required: true + value_ref: nv_lang_offer_id + price: + required: true + value_ref: nv_lang_offer_price + currency_field: quote_currency + quote_currency: + required: true + value_ref: nv_lang_offer_currency + relations: {} capabilities: langitem_query: description: List LangItem rows (optional tags filter homographs tags relation wire) @@ -298,6 +313,29 @@ capabilities: output: type: side_effect description: Records a ping against the item (matrix conformance). + langoffer_query: + description: List LangOffer rows + kind: query + entity: LangOffer + provides: + - id + - price + - quote_currency + langoffer_create: + description: Create LangOffer + kind: create + entity: LangOffer + parameters: + - name: price + value_ref: nv_lang_offer_price + required: true + - name: quote_currency + value_ref: nv_lang_offer_currency + required: true + provides: + - id + - price + - quote_currency langtag_get: description: Get LangTag by id (batch relation resolution for prefer_from_parent_get) kind: get @@ -492,3 +530,13 @@ values: nv_langline_query_item_id: type: entity_ref target: LangItem + nv_lang_offer_id: + type: string + string_semantics: short + nv_lang_offer_price: + type: money + value_format: + money: decimal_string + nv_lang_offer_currency: + type: string + string_semantics: short diff --git a/fixtures/schemas/plasm_language_matrix/mappings.yaml b/fixtures/schemas/plasm_language_matrix/mappings.yaml index 4bb1dfcb..f281e264 100644 --- a/fixtures/schemas/plasm_language_matrix/mappings.yaml +++ b/fixtures/schemas/plasm_language_matrix/mappings.yaml @@ -252,3 +252,28 @@ homographrowb_query: value: v1 - type: literal value: homograph-b + +langoffer_query: + method: GET + path: + - type: literal + value: language + - type: literal + value: v1 + - type: literal + value: offers + +langoffer_create: + method: POST + path: + - type: literal + value: language + - type: literal + value: v1 + - type: literal + value: offers + body: + type: object + fields: + - ["price", { type: var, name: price }] + - ["quote_currency", { type: var, name: quote_currency }] diff --git a/skills/plasm-authoring/reference.md b/skills/plasm-authoring/reference.md index 9813a412..fca8201c 100644 --- a/skills/plasm-authoring/reference.md +++ b/skills/plasm-authoring/reference.md @@ -77,14 +77,15 @@ The CGS is the semantic domain model. It declares what entities exist, how they Split **`domain.yaml`** declares a catalog-local registry of **named semantic slots** under top-level **`values:`** (stable keys, usually `snake_case`). Each row carries the **wire** `type:` and gloss-related keys — the same vocabulary as the former inline `field_type` / param `type` — but the **key** is a semantic identity for this catalog, not "dedupe by primitive wire shape alone": -- **`type:`** — `string`, `integer`, `number`, `boolean`, `select`, `multi_select`, `date`, `array`, `entity_ref`, **`blob`**, `uuid`. -- Type-specific keys on the **value row**: `target` (`entity_ref`), `allowed_values` (`select` / `multi_select`; multi_select must be non-empty), `value_format` (`date`), `string_semantics` (`string`), **`items: { value_ref: }`** (`array` — element shape is another `values` row). +- **`type:`** — `string`, `integer`, `number`, `boolean`, `select`, `multi_select`, `date`, **`money`**, `array`, `entity_ref`, **`blob`**, `uuid`. +- Type-specific keys on the **value row**: `target` (`entity_ref`), `allowed_values` (`select` / `multi_select`; multi_select must be non-empty), `value_format` (`date` or `money`), `currency` (`money`), `string_semantics` (`string`; forbidden on `money`), **`items: { value_ref: }`** (`array` — element shape is another `values` row). **Entity `fields:`** and **`capabilities.*.parameters:`** list entries declare **only** how that slot uses a shape: - **`value_ref: `** — required; must exist in **`values:`**. - **`required`**, **`description`**, **`path`**, **`derive`** — on fields (and parameter-specific keys: **`role`**, **`description`** on parameters). - Presentation / attachment hints (**`agent_presentation`**, **`mime_type_hint`**, **`attachment_media`**) live on the **field slot** when they apply (not duplicated on every reuse of the same value key). +- **`currency_field:`** on a money **field slot** names a sibling on the same entity whose string value supplies currency after decode. **Semantic slots (authoring judgement):** A **`values:`** key is not "the type `string`" or "the type `integer`" in the abstract — it is a **catalog-local semantic identity**: what teaching gloss, `string_semantics`, `description`, and validation **say** that value *means* in this API. Two different columns can share the same on-wire JSON type (`string`, RFC3339 `date`, …) yet must remain **different keys** when their **meaning** differs (e.g. `owner` vs `repo` vs `html_url`). **Sharing** one key across multiple `value_ref` sites is the same class of decision as **relation cardinality** or **whether two endpoints are one capability**: there is **no** deterministic rule from the wire alone — authors choose when two sites are intentionally **the same domain value space** (one enum, one id space, one taxonomy, aligned gloss). Prefer **distinct keys per field/param by default**; merge only when that identity story is obvious and descriptions stay compatible. @@ -113,8 +114,9 @@ values: type: # same vocabulary as Field Types below target: # when type is entity_ref allowed_values: [...] # select / multi_select (multi_select: non-empty) - value_format: # required when type is date - string_semantics: <...> # on string rows — prompts / summaries + value_format: # required when type is date or money + string_semantics: <...> # on string rows — prompts / summaries; forbidden on money + currency: USD # optional default unit on money values: rows items: value_ref: # when type is array @@ -128,6 +130,7 @@ entities: required: # default false path: ... # optional wire path (see below) derive: ... # optional + currency_field: quote_currency # optional; sibling on this entity for money description: "..." # optional relations: : @@ -238,6 +241,7 @@ In split `domain.yaml`, the **`type:`** column below is the keyword you put on a | Select | `select` | enum token from `allowed_values` | `=`, `!=`, `in`, `exists` | Single enum. Requires `allowed_values`. | | MultiSelect | `multi_select` | array of enum tokens | `contains`, `in`, `exists` | Multiple enum. Requires non-empty `allowed_values`. | | Date | `date` | string or integer per `value_format` | `=`, `!=`, `contains`, `exists` | **Requires `value_format`:** `rfc3339`, `iso8601_date`, `unix_ms`, or `unix_sec`. Predicate inputs are normalized to the wire shape (forgiving parse, UTC). Display of API responses is not rewritten via `value_format`. | +| **Money** | **`money`** | decimal string, JSON number, or `{amount, currency}` | `=`, `!=`, `>`, `<`, `>=`, `<=`, `exists` | Fowler amount + optional currency. **Requires `value_format`:** `{ money: decimal_string }`, `{ money: json_number }`, or `{ money: minor_units, scale: N }`. Currency may be fixed on the `values:` row (`currency: USD`) or attached from a sibling field (`currency_field:` on the entity slot). Compare is legal when either side lacks currency; both present and different is a typed error. HTTP encode emits a scalar — never `__plasm_money`. Do not use `string_semantics`. Not for oversized integer strings (e.g. EVM wei). | | Array | `array` | array literal / binding | `contains`, `in`, `exists` | Homogeneous list. Requires nested `items:`. | | EntityRef | `entity_ref` | id value or nested ref expr | `=`, `!=`, `exists` | Foreign key to another entity. Requires `target: EntityName`. | | **Blob** | **`blob`** | attachment-shaped value / binding | `=`, `!=`, `exists` | Opaque binary or base64-heavy payloads. Do not use `string_semantics`. | @@ -299,6 +303,15 @@ values: type: array items: value_ref: instant_rfc3339 + nv_price: + type: money + value_format: + money: decimal_string + nv_usd: + type: money + value_format: + money: json_number + currency: USD entities: Pet: