diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..53cdfef --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,92 @@ +# AGENTS.md + +Guidance for coding agents working in this repository. Humans: the README covers usage; this file covers how changes are made and verified. + +## What this is + +`github.com/UTXOnly/oddrip` is a Go client for the Kalshi Trade API (REST + WebSocket). Go 1.24+. Public module — do not rename it, and keep everything you commit tool-neutral and free of credentials, private project names, and personal trading setups. + +``` +oddrip/ client, services (one file per service), ws.go, errors.go, retry glue +oddrip/types/ request/response structs, WS payloads, Dollars/Count/ParseTime helpers +oddrip/internal/auth RSA-PSS signer (timestamp + METHOD + path, query excluded) +oddrip/internal/retry backoff, Retry-After, idempotency-aware retry loop +cmd/example/ REST and WebSocket example programs +openapi.yaml vendored Kalshi OpenAPI (REST) — source of truth for shapes and paths +asyncapi.yaml vendored Kalshi AsyncAPI (WS) — source of truth for channels and messages +CHANGELOG.md Keep a Changelog style; the release job extracts notes from it +``` + +## Source of truth + +The vendored specs at the repo root define what the client should do. When they change, update `oddrip/types/`, the service methods, tests, `CHANGELOG.md`, and the version numbers named in the README intro. Published copies: + +| Resource | URL | +|---|---| +| Changelog | https://docs.kalshi.com/changelog | +| OpenAPI | https://docs.kalshi.com/openapi.yaml | +| AsyncAPI | https://docs.kalshi.com/asyncapi.yaml | + +To refresh: `curl -sL -o openapi.yaml https://docs.kalshi.com/openapi.yaml` (same for `asyncapi.yaml`), read `info.version` in each, then diff `paths:`, `components/schemas`, and AsyncAPI `channels` / `components/messages` against the Go code. Note the AsyncAPI keeps `info.version: 2.0.0` while its content changes; compare content, not just the version string. + +Where the spec and production disagree, production wins, and the difference gets documented in the README. Known cases: + +- Error bodies. The spec's `ErrorResponse` is flat `{"code","message","details"}`. Production returns `{"error":{"code":...,"message":...}}` for most errors and `{"msg":"..."}` for parameter-binding 400s. `newAPIError` parses all three; keep it that way if the spec changes. `APIError.RawBody` always has the body. +- Hosts. Spec primary REST host is `external-api.kalshi.com`; `api.elections.kalshi.com` is listed as also supported and is the client default. AsyncAPI names `external-api-ws.kalshi.com`; the client defaults to `api.elections.kalshi.com` for WS too. Both answer. +- `client_order_id` deduplication is documented in Kalshi's quick-start guide, not in the OpenAPI field description. A replay the server already applied returns 409. + +## Conventions + +- One `*Service` per API area on `Client`: `Exchange`, `Markets`, `Events`, `Series`, `Orders`, `OrderGroups`, `Portfolio`, `Subaccounts`, `Account`, `LiveData`. Every public method takes `context.Context` first. +- Build paths with `joinPath(...)`. It path-escapes each segment and returns `""` for an empty / `.` / `..` segment, which `do()` turns into `ErrEmptyPathParam` before sending. Never concatenate paths by hand; a dropped segment can route to a different endpoint (empty order ID → CancelAll). +- Optional query params go on a `*Opts` struct: `string` fields are sent when non-empty, numeric and bool fields are pointers. Use the `encodeQuery*` helpers. Array params: check the spec — `explode: true` arrays use `v.Add` per value (`tickers` on `/markets/orderbooks`, `percentiles`); comma-separated ones are a single `string` (`market_tickers` on `/markets/candlesticks`, `tickers` on `/markets`). +- Retry policy is chosen per call: + - `get` / `put` / `delete` — idempotent: retried on 429, 5xx, and transport errors. + - `postIdempotent` — only for POSTs the server deduplicates (`client_order_id`, `client_transfer_id`) or that set absolute state. + - `post` / `postQuery` — everything else: retried on 429 only. A 5xx or dropped connection may mean the write landed. + - `CreateV2` / `BatchCreateV2` pick at runtime based on whether every order has a `client_order_id`. +- Endpoints whose spec response is empty return `error` only. +- Prefer fixed-point `_fp` and `_dollars` string fields. Legacy integer price/count fields are being removed by Kalshi; do not add new ones. +- WebSocket: command replies are matched by `id`; `get_snapshot` is the exception (answered by `orderbook_snapshot` frames keyed by `sid`). Multi-channel `Subscribe` expects one `subscribed` reply per channel. All socket writes go through `writeMu`. +- Match the surrounding code. No drive-by refactors, reformatting, or comment rewrites in files you are not otherwise changing. + +## Tests are mandatory + +Every new or changed public REST method, `json`-tagged type, or WebSocket command/message shape gets a test in the same change. + +| Change | Test file | Assert | +|---|---|---| +| REST method | `oddrip/*_test.go` (Series / OrderGroups / Subaccounts and extra Events / Markets / Portfolio methods live in `oddrip/services_extra_test.go`) | HTTP method, path, query params, request body when non-trivial. Use the `mockTransport` pattern from `oddrip/client_test.go`. | +| Request/response struct | `oddrip/types/*_test.go` | JSON unmarshal using the spec's example payload, not an invented one. Marshal too if the client constructs it. | +| WS channel, action, or payload | `oddrip/types/ws_test.go`, `oddrip/types/ws_messages_test.go`, `oddrip/ws_test.go` | Constant values, JSON round-trip from the AsyncAPI example, and — for commands that wait on a reply — that the call returns against a mock server. | + +If something cannot be unit-tested (live server behavior), add the closest test possible and say so in the PR. + +## Verify before finishing + +From the repo root: + +```bash +gofmt -l . && go vet ./... && go build ./... && go test -race -count=1 ./... +``` + +CI additionally runs `staticcheck`, `govulncheck`, `go mod tidy` (must be a no-op), and the test matrix on Go 1.24 and stable across Linux, macOS, and Windows. + +## Versioning and releases + +`oddrip.Version` in `oddrip/version.go` is the release. Merging to `main` tags `v` and publishes a GitHub Release when that tag does not exist yet. Tags are immutable once on proxy.golang.org. + +CI's `version` job enforces: + +- `oddrip/version.go`, the top numbered `## [X.Y.Z]` in `CHANGELOG.md`, and the `@vX.Y.Z` pin in `README.md` agree. +- If `v` is already tagged, a PR may only touch files outside `oddrip/`, `go.mod`, `go.sum`. Any code change on a released version must bump the version and add a CHANGELOG section. Docs-only PRs need no bump. +- Semver at v0: a minor release may break; breaking changes go first under `### Breaking` in that version's section with migration notes. `gorelease` runs against the previous tag and fails the build if it finds API-incompatible changes without that heading, or if a patch bump declares one. + +An `## [Unreleased]` heading above the numbered sections is ignored by the version check and is the place to accumulate notes between releases. + +## Documentation + +- README documents user-facing behavior: retry policy, WS lifecycle, coverage, where spec and production differ. It is not a method catalog; pkg.go.dev is. +- Keep the "N of 96 OpenAPI paths" count and the not-implemented list accurate. Count unique path templates the client hits (`joinPath` calls in non-test service files) against `paths:` in `openapi.yaml`. +- Every claim in README and CHANGELOG about behavior must be true of the code as merged. If a fix is not in yet, describe the current behavior, not the intended one. +- Local review notes (`*-pre-release-review.md`, `*PLAN.md`) are gitignored. Do not commit them or reference them from public docs. diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a3d3c4..75f1316 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,27 @@ All notable changes to this project are documented here. The client tracks [Kals **Versioning.** The module follows semver. While it is at major version 0, a **minor** release may contain breaking changes; when it does, they are listed first under a `### Breaking` heading with migration notes, and CI refuses a release that has API-incompatible changes (per `gorelease`) without that section, or that has one on a patch bump. Patch releases never break. From v1.0.0 on, breaking changes require a major bump. +## [0.6.1] — 2026-09-12 + +Error bodies from production now decode; specs synced to OpenAPI 3.30.0; four missing query filters; malformed WebSocket frames fail the connection. No API-incompatible changes. + +### Fixed + +- **`APIError.Code` / `Message` were empty for every production error** ([#7](https://github.com/UTXOnly/oddrip/issues/7)). The body was decoded as the spec's flat `ErrorResponse`, but production returns `{"error":{"code":...,"message":...}}` for most errors and `{"msg":"..."}` for parameter-binding 400s, so `Error()` printed only `api error 404`. All three shapes are parsed now; the flat shape still wins when present. `RawBody` is unchanged. +- **A WebSocket text frame that was not valid JSON was skipped silently** ([#10](https://github.com/UTXOnly/oddrip/issues/10)). The connection now fails with an error wrapping the new `ErrWSMalformedFrame` (check with `errors.Is`), `Messages()` closes, and pending commands return that error — the same path as `ErrWSSlowConsumer`. Frames received before the bad one are still delivered. + +### Added + +- Vendored Kalshi OpenAPI **3.30.0** (AsyncAPI remains **2.0.0**; its content change is CF Benchmarks index-ID documentation) ([#8](https://github.com/UTXOnly/oddrip/issues/8)). +- **Types:** `Series.Categories` — the full discovery-category list; `Series.Category` is now the primary one and the `category` filter on `Series.List` matches any entry in `Categories`. `GetTargetBalanceAllocationResponse.RestingMarginReservation`. +- **Query filters** ([#9](https://github.com/UTXOnly/oddrip/issues/9)): `ExchangeIndex` on `GetOrdersOpts`, `GetFillsOpts`, `GetPositionsOpts`; `Subaccount` on `GetHistoricalPositionsOpts`. All optional; nil omits the parameter as before. +- `ErrWSMalformedFrame`. +- **Tests:** error-body shapes observed from production; `Series.categories` and `resting_margin_reservation` unmarshal; the new filters (set and omitted); malformed-frame failure; `Unsubscribe` one-reply-per-sid and `ListSubscriptions` success paths ([#12](https://github.com/UTXOnly/oddrip/issues/12)). + +### Changed + +- `APIError` documents which body shapes populate `Code` / `Message`. `RequestID` is still read from `Request-Id`, which production does not currently send. + ## [0.6.0] — 2026-09-12 Fixes retry panics and WebSocket hangs, adds Series / OrderGroups / Subaccounts REST, and aligns typed WS payloads with AsyncAPI 2.0.0. @@ -38,7 +59,7 @@ Two source-incompatible API changes (the only ones `gorelease -base=v0.5.0` repo ### Changed -- **WebSocket messages are never dropped silently.** Previously a consumer that fell 256 messages behind lost messages with no signal. Now the buffer is `WSBufferSize(n)` (default 4096) and on overflow the connection fails with `ErrWSSlowConsumer` and closes — a gap in `orderbook_delta` is unrecoverable without a re-snapshot, so failing loudly is correct. Treat `Messages()` closing as "reconnect and re-subscribe". +- **WebSocket messages are never dropped for a slow consumer.** Previously a consumer that fell 256 messages behind lost messages with no signal. Now the buffer is `WSBufferSize(n)` (default 4096) and on overflow the connection fails with `ErrWSSlowConsumer` and closes — a gap in `orderbook_delta` is unrecoverable without a re-snapshot, so failing loudly is correct. Treat `Messages()` closing as "reconnect and re-subscribe". - **WebSocket keepalive and dead-connection detection.** Client pings every `WSPingInterval` (default 30s) and enforces a read deadline of `WSReadTimeout` (default 90s), extended on every frame. A half-open socket now surfaces as a timeout error instead of blocking `Messages()` forever. `<= 0` disables either. - **`Close()`** is idempotent and no longer writes a close frame on an already-dead connection. `Subscribe` and friends return `ErrWSClosed` (or the terminal error) immediately after close/disconnect instead of waiting on the context. - **`Retry-After`** is honored in HTTP-date form as well as delta-seconds. `RetryConfig.MaxAttempts` below 1 is treated as 1. diff --git a/README.md b/README.md index 75a8c87..3ad758a 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,10 @@ [![CI](https://github.com/UTXOnly/oddrip/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/UTXOnly/oddrip/actions/workflows/ci.yml) [![Go Reference](https://pkg.go.dev/badge/github.com/UTXOnly/oddrip/oddrip.svg)](https://pkg.go.dev/github.com/UTXOnly/oddrip/oddrip) -Go client for the [Kalshi Trade API](https://docs.kalshi.com/): REST plus WebSocket market data. Tracks vendored OpenAPI **3.29.0** / AsyncAPI **2.0.0**. +Go client for the [Kalshi Trade API](https://docs.kalshi.com/): REST plus WebSocket market data. Tracks vendored OpenAPI **3.30.0** / AsyncAPI **2.0.0**. ```bash -go get github.com/UTXOnly/oddrip/oddrip@v0.6.0 +go get github.com/UTXOnly/oddrip/oddrip@v0.6.1 ``` Go 1.24+. Import the client as `github.com/UTXOnly/oddrip/oddrip` and types as `github.com/UTXOnly/oddrip/oddrip/types`. `oddrip.Version` matches the module tag. @@ -26,11 +26,11 @@ client := oddrip.New( ) ``` -Default base URL is `https://api.elections.kalshi.com/trade-api/v2`. Pass `oddrip.BaseURL(...)` for demo (`https://demo-api.kalshi.co/trade-api/v2`) or `oddrip.HTTPClient(...)` for a custom transport. Auth is RSA-PSS (PKCS#8 or PKCS#1 PEM); the same signer is used for REST and the WebSocket handshake. +Default base URL is `https://api.elections.kalshi.com/trade-api/v2`. The spec lists `https://external-api.kalshi.com/trade-api/v2` as the primary production host and both as supported; pass `oddrip.BaseURL(...)` to switch, or for demo (`https://demo-api.kalshi.co/trade-api/v2`). `oddrip.HTTPClient(...)` swaps the transport. Auth is RSA-PSS (PKCS#8 or PKCS#1 PEM) over `timestamp + METHOD + path` (query string excluded); the same signer is used for REST and the WebSocket handshake. ## REST -Services: `Exchange`, `Markets`, `Events`, `Series`, `Orders`, `OrderGroups`, `Portfolio`, `Subaccounts`, `Account`, `LiveData`. Every call takes `context.Context`. Optional query params are pointer fields on `*Opts` structs — omit or leave nil. +Services: `Exchange`, `Markets`, `Events`, `Series`, `Orders`, `OrderGroups`, `Portfolio`, `Subaccounts`, `Account`, `LiveData`. Every call takes `context.Context`. Optional query params live on `*Opts` structs: strings are sent only when non-empty, numbers and bools are pointers — leave nil to omit. A nil `*Opts` is fine. ```go status, err := client.Exchange.GetStatus(ctx) @@ -38,16 +38,17 @@ market, err := client.Markets.Get(ctx, "TICKER-24JAN01") events, err := client.Events.List(ctx, &types.GetEventsOpts{Status: "open"}) _, err = client.Orders.CreateV2(ctx, &types.CreateOrderV2Request{ - Ticker: "TICKER-24JAN01", - ClientOrderID: "cli-1", // set this so retries cannot double-place - Side: types.BookSideBid, - Count: "1.00", - Price: "0.4500", - TimeInForce: types.TimeInForceGTC, + Ticker: "TICKER-24JAN01", + ClientOrderID: "cli-1", // set this so retries cannot double-place + Side: types.BookSideBid, + Count: "1.00", + Price: "0.4500", + TimeInForce: types.TimeInForceGTC, + SelfTradePreventionType: types.SelfTradeTakerAtCross, // required by the API }) ``` -64 of 96 OpenAPI paths. Not implemented: RFQ/quotes, FCM, API keys, milestones, search, structured targets, incentive programs. Method list: [pkg.go.dev](https://pkg.go.dev/github.com/UTXOnly/oddrip/oddrip). +64 of 96 OpenAPI paths. Not implemented: communications (RFQs, quotes, block-trade proposals), FCM, API keys, milestones and milestone live data (`/live_data/batch`, `/live_data/milestone/*`), search, structured targets, incentive programs, `GET /events/fee_changes`, `POST /portfolio/intra_exchange_instance_transfer`, and `/account/api_usage_level/*`. Method list: [pkg.go.dev](https://pkg.go.dev/github.com/UTXOnly/oddrip/oddrip). List responses include `Cursor` when there is another page: @@ -67,18 +68,18 @@ for { } ``` -Non-2xx responses are `*oddrip.APIError` (status, message, request ID, body). An empty ticker or ID path parameter returns `oddrip.ErrEmptyPathParam` without sending — an empty order ID would otherwise hit CancelAll. +Non-2xx responses are `*oddrip.APIError` with `StatusCode`, `Code`, `Message`, and `RawBody` (first 512 bytes). Kalshi's production error bodies nest `code` / `message` under `"error"` (the spec shows them flat) and parameter-binding 400s use `{"msg": ...}`; all three shapes are parsed. `RequestID` is read from a `Request-Id` header Kalshi does not currently send. An empty ticker or ID path parameter returns `oddrip.ErrEmptyPathParam` without sending — an empty order ID would otherwise hit CancelAll. ## Retries -Default 4 attempts, exponential backoff with jitter, honors `Retry-After` (delta-seconds or HTTP-date). A cancelled `ctx` aborts the wait. Tune with `RetryConfigOption`; `MaxAttempts` below 1 is treated as 1. +Default 4 attempts (500ms initial, ×2, ±20% jitter, 30s cap). `Retry-After` (delta-seconds or HTTP-date) replaces the backoff for that attempt, still capped at `MaxDelay`. A cancelled `ctx` aborts the wait. Tune with `RetryConfigOption`; `MaxAttempts` below 1 is treated as 1. | Request | 429 | 5xx / timeout | |---|---|---| -| Idempotent — GET, PUT, DELETE, and POSTs the server deduplicates (`CreateV2` / `BatchCreateV2` with `client_order_id` on every order, `Subaccounts.Transfer`, `SetTargetBalanceAllocation`) | retried | retried | +| Idempotent — GET, PUT, DELETE; POSTs the server deduplicates (`CreateV2` / `BatchCreateV2` with `client_order_id` on every order, `Subaccounts.Transfer` via `client_transfer_id`); `SetTargetBalanceAllocation`, which sets absolute state | retried | retried | | Non-idempotent — `AmendV2`, `DecreaseV2`, creates without `client_order_id`, `OrderGroups.Create`, `Subaccounts.Create`, `CreateMarketInMultivariateCollection` | retried | not retried | -A 429 means the server rejected the request before acting. A 5xx or dropped connection is ambiguous — the write may already be applied. After an ambiguous failure of a non-idempotent write, check `Orders.Get` before resending. +A 429 means the server rejected the request before acting. A 5xx or dropped connection is ambiguous — the write may already be applied. After an ambiguous failure of a non-idempotent write, check `Orders.Get` before resending. A replayed `CreateV2` whose first attempt did land is rejected with `409` ("order with this `client_order_id` already exists"), so treat a 409 `*APIError` after a retry as success and look the order up. ```go client := oddrip.New(oddrip.RetryConfigOption(oddrip.RetryConfig{MaxAttempts: 1})) // disable retries @@ -109,7 +110,7 @@ ts, _ := types.ParseTime("2022-11-22T20:44:01Z") ## WebSocket -Read-only market data. Auth is required. Place orders over REST. +Read-only streams: public market data plus your own fills, orders, positions, order-group and RFQ activity. Every connection needs auth, including for public channels. There are no order commands over WebSocket; place orders over REST. ```go conn, err := client.ConnectWS(ctx) @@ -136,14 +137,16 @@ for msg := range conn.Messages() { } } if err := conn.Err(); !errors.Is(err, oddrip.ErrWSClosed) { - // dead socket, slow consumer, or server close: reconnect and re-subscribe + // dead socket, slow consumer, malformed frame, or server close: reconnect and re-subscribe } ``` -Commands: `Subscribe`, `Unsubscribe`, `ListSubscriptions`, `UpdateSubscription`. Channel names and `WSType*` constants are in `types`. CF Benchmarks channels take `IndexIDs` (`[]string{"all"}` for every index). Server errors are `*oddrip.WSError`. Point at demo with `WSHost` / `WSPath` / `WSScheme`. +Commands: `Subscribe`, `Unsubscribe`, `ListSubscriptions`, `UpdateSubscription`. Channel names and `WSType*` constants are in `types`. CF Benchmarks channels take `IndexIDs` (`[]string{"all"}` for every index). Command rejections are returned as `*oddrip.WSError`. Default endpoint is `wss://api.elections.kalshi.com/trade-api/ws/v2`; the AsyncAPI names `external-api-ws.kalshi.com` as the production host — both accept connections. Point elsewhere with `WSHost` / `WSPath` / `WSScheme`. - If `Messages()` falls behind, the connection fails with `ErrWSSlowConsumer` (buffer default 4096) rather than dropping deltas. Reconnect and re-snapshot any local book. -- Keepalive ping every 30s and a 90s read deadline. `WSReadTimeout(0)` / `WSPingInterval(0)` disable either. +- Errors scoped to a subscription arrive on `Messages()` as `Type: "error"` with a `SID`, not as a returned `*WSError`. Codes 10 (channel error) and 25 (subscription buffer overflow) are terminal for that subscription — resubscribe. Decode into `types.ErrorMsg`. +- Client keepalive ping every 30s and a 90s read deadline (Kalshi also pings every 10s; any frame extends the deadline). `WSReadTimeout(0)` / `WSPingInterval(0)` disable either. +- A text frame that is not valid JSON fails the connection: `Err()` wraps `ErrWSMalformedFrame` and `Messages()` closes, same as a slow consumer. - `get_snapshot` needs `SID` or a one-element `Sids`. It returns when the first `orderbook_snapshot` for that subscription arrives (`Type` is `"orderbook_snapshot"`); the frames also go to `Messages()`. - `indexlist` / `underlying_list` replies are not `Type: "ok"`. - A multi-channel `Subscribe` that fails partway returns the accepted SIDs alongside the `*WSError`. diff --git a/asyncapi.yaml b/asyncapi.yaml index 74ff38d..6f5f500 100644 --- a/asyncapi.yaml +++ b/asyncapi.yaml @@ -338,7 +338,42 @@ channels: description: | Real-time CF Benchmarks index value updates, each carrying the raw upstream frame plus trailing 60-second and quarter-hour final-minute averages. Requires authentication. - **Requirements:** + ## Coins and index IDs + + Use the following CF Benchmarks index IDs to request coin data on this channel or through the [REST passthrough](/cfbenchmarks/rest-passthrough). The BTC and ETH examples on this page are illustrative; they are not the full list of coins. + + | Coin | CF Benchmarks index ID | + |------|------------------------| + | AAVE | `AAVEUSD_RTI` | + | ADA | `ADAUSD_RTI` | + | BCH | `BCHUSD_RTI` | + | BNB | `BNBUSD_RTI` | + | BTC | `BRTI` | + | DOGE | `DOGEUSD_RTI` | + | DOT | `DOTUSD_RTI` | + | ETH | `ETHUSD_RTI` | + | HBAR | `HBARUSD_RTI` | + | HYPE | `HYPEUSD_RTI` | + | LINK | `LINKUSD_RTI` | + | LTC | `LTCUSD_RTI` | + | NEAR | `NEARUSD_RTI` | + | SHIB / kSHIB | `SHIBUSD_RTI` | + | SOL | `SOLUSD_RTI` | + | SUI | `SUIUSD_RTI` | + | VVV | `VVVUSD_RTI` | + | WLD | `WLDUSD_RTI` | + | XLM | `XLMUSD_RTI` | + | XRP | `XRPUSD_RTI` | + | ZEC | `ZECUSD_RTI` | + + Pass the index ID exactly as shown, rather than a coin symbol or Kalshi market ticker. For kSHIB, request `SHIBUSD_RTI`: values are USD per SHIB and are not scaled to kSHIB or a perpetual contract size. + + Use the `indexlist` action below to check the index IDs available on your WebSocket connection. Availability can change; this table is a coin-to-index reference, not a guarantee that every index is streaming in every environment. + + The [5Hz feed](/websockets/cfbenchmarks-value-5hz) has a smaller coin set: BTC, ETH, SOL, XRP, and DOGE. Use `cfbenchmarks_value` for the other coins above. + + ## Requirements + - Authentication required - Index specification via `index_ids` (array of CF Benchmarks index IDs, for example `["BRTI", "ETHUSD_RTI"]`) - `market_ticker`/`market_tickers`/`market_id`/`market_ids` are not supported for this channel @@ -350,12 +385,41 @@ channels: **Use case:** Consuming CF Benchmarks reference index values and their short-window averages - **Subscription workflow:** + ## Subscription workflow + 1. Subscribe to `cfbenchmarks_value` (optionally seeding `index_ids`). A successful subscribe returns a `subscribed` response with the assigned `sid`. 2. Discover available index IDs with the `indexlist` action; the server replies with a `cfbenchmarks_value_indexlist` message. 3. Add or remove tracked index IDs with `subscribe_indices` / `unsubscribe_indices`, or use `index_ids: ["all"]` to track everything. - **Averaging semantics:** + For example, subscribe to BNB, HYPE, and SHIB values: + + ```json + { + "id": 1, + "cmd": "subscribe", + "params": { + "channels": ["cfbenchmarks_value"], + "index_ids": ["BNBUSD_RTI", "HYPEUSD_RTI", "SHIBUSD_RTI"] + } + } + ``` + + To discover the available indices, send the following after the `subscribed` response. Replace `sid: 1` with the subscription ID returned by the server: + + ```json + { + "id": 2, + "cmd": "update_subscription", + "params": { + "sid": 1, + "action": "indexlist" + } + } + ``` + + Read the available IDs from `msg.index_ids` in the `cfbenchmarks_value_indexlist` response. This lists the channel's available indices without changing which ones you subscribe to. A successful `subscribe` response alone does not confirm that a requested index is available. + + ## Averaging semantics `avg_60s_data` (always present): - Window is trailing and per tick: `[source_ts_ms - 60000, source_ts_ms)` @@ -368,7 +432,8 @@ channels: - This produces second-indexed counts: `:01 -> 1`, `:14 -> 14`, `:59 -> 59`, close tick (`:00/:15/:30/:45`) -> `60` - The field is omitted outside that final-minute window - **Integration notes:** + ## Integration notes + - If you subscribe without any `index_ids`, no value events flow until you add indices or switch to `["all"]` - `sid` identifies the subscription stream; use it for `update_subscription` and `unsubscribe` - Missing `index_ids` for `subscribe_indices`/`unsubscribe_indices` returns an `error` with `code: 24` ("Index IDs required"); unsupported actions return a standard websocket `error` @@ -385,7 +450,7 @@ channels: description: | Real-time CF Benchmarks index value updates at up to 5 updates per second, each carrying the raw upstream frame plus parsed value fields. Requires authentication. - This is the high-frequency sibling of the once-per-second [`cfbenchmarks_value`](/websockets/cfbenchmarks-value) channel. It carries the indices CF Benchmarks publishes at 200ms granularity (currently `BRTI`, `ETHUSD_RTI`, `SOLUSD_RTI`, `XRPUSD_RTI`, and `DOGEUSD_RTI`); all other indices remain available on `cfbenchmarks_value` only. Messages are lean raw ticks — they do not include the 60-second or quarter-hour averages, which stay on the per-second channel. + This is the high-frequency sibling of the once-per-second [`cfbenchmarks_value`](/websockets/cfbenchmarks-value) channel. It carries BTC (`BRTI`), ETH (`ETHUSD_RTI`), SOL (`SOLUSD_RTI`), XRP (`XRPUSD_RTI`), and DOGE (`DOGEUSD_RTI`) at up to five updates per second. For BNB, HYPE, NEAR, ZEC, SUI, BCH, LTC, LINK, SHIB/kSHIB, ADA, WLD, AAVE, VVV, and other per-second indices, see the [coin and index ID table](/websockets/cfbenchmarks-value#coins-and-index-ids). Use this channel's `indexlist` action to discover its available IDs. Messages are lean raw ticks — they do not include the 60-second or quarter-hour averages, which stay on the per-second channel. **Requirements:** - Authentication required @@ -1473,7 +1538,7 @@ components: $ref: '#/components/schemas/cfbenchmarksIndexListPayload' examples: - name: indexListResponse - summary: Available index IDs + summary: Example available index IDs (illustrative subset) payload: type: cfbenchmarks_value_indexlist id: 2 @@ -1630,6 +1695,7 @@ components: msg: trade_id: "d91bc706-ee49-470d-82d8-11418bda6fed" order_id: "ee587a1c-8b87-4dcf-b721-9f6f790619fa" + client_order_id: "my-order-1" market_ticker: "HIGHNY-22DEC23-B53.5" exchange_index: 2 is_taker: true diff --git a/cmd/example/README.md b/cmd/example/README.md index fb191dd..ddbf95b 100644 --- a/cmd/example/README.md +++ b/cmd/example/README.md @@ -27,6 +27,6 @@ KALSHI_ACCESS_KEY=$(cat key_id) KALSHI_PRIVATE_KEY_PATH=./private_key.pem go run | `KALSHI_ACCESS_KEY` | for auth | API key ID | | `KALSHI_PRIVATE_KEY_PATH` | for auth | Path to the PEM file | | `BASE_URL` | no | Default: demo. Production: `https://api.elections.kalshi.com/trade-api/v2` | -| `LIVE` | no | `1` places two 1¢ yes bids on the open 15m BTC market (`KXBTC15M`) and cancels the second. Uses production. Leaves the first order resting. | +| `LIVE` | no | `1` places two 1¢ bids on the open 15m BTC market (`KXBTC15M`) and cancels the second. Runs against `BASE_URL` (demo unless you set production). Leaves the first order resting. Both orders use fixed `client_order_id`s (`example-resting`, `example-cancel`), so a second run may be rejected with 409. | Without auth, only public endpoints run. diff --git a/oddrip/client_test.go b/oddrip/client_test.go index 64a13f6..8956dd3 100644 --- a/oddrip/client_test.go +++ b/oddrip/client_test.go @@ -423,9 +423,12 @@ func TestPortfolio_ListHistoricalPositions_RequestPathAndQuery(t *testing.T) { ctx := context.Background() limit := int64(10) + sub := 2 + _, err := client.Portfolio.ListHistoricalPositions(ctx, &types.GetHistoricalPositionsOpts{ Ticker: "MKT", EventTicker: "EVT", + Subaccount: &sub, Limit: &limit, Cursor: "c1", }) @@ -436,11 +439,75 @@ func TestPortfolio_ListHistoricalPositions_RequestPathAndQuery(t *testing.T) { t.Fatalf("path: %v", mt.req) } q := mt.req.URL.Query() - if q.Get("ticker") != "MKT" || q.Get("event_ticker") != "EVT" || q.Get("limit") != "10" || q.Get("cursor") != "c1" { + if q.Get("ticker") != "MKT" || q.Get("event_ticker") != "EVT" || q.Get("subaccount") != "2" || q.Get("limit") != "10" || q.Get("cursor") != "c1" { t.Fatalf("query: %v", q) } } +func TestExchangeIndexFilters(t *testing.T) { + // exchange_index is an optional shard filter on these list endpoints; nil + // omits it and the server returns every shard. + cases := []struct { + name string + body string + path string + call func(ctx context.Context, c *Client, idx *int) error + }{ + { + name: "Orders.List", + body: `{"orders":[],"cursor":""}`, + path: "/trade-api/v2/portfolio/orders", + call: func(ctx context.Context, c *Client, idx *int) error { + _, err := c.Orders.List(ctx, &types.GetOrdersOpts{ExchangeIndex: idx}) + return err + }, + }, + { + name: "Portfolio.GetFills", + body: `{"fills":[],"cursor":""}`, + path: "/trade-api/v2/portfolio/fills", + call: func(ctx context.Context, c *Client, idx *int) error { + _, err := c.Portfolio.GetFills(ctx, &types.GetFillsOpts{ExchangeIndex: idx}) + return err + }, + }, + { + name: "Portfolio.GetPositions", + body: `{"market_positions":[],"event_positions":[],"cursor":""}`, + path: "/trade-api/v2/portfolio/positions", + call: func(ctx context.Context, c *Client, idx *int) error { + _, err := c.Portfolio.GetPositions(ctx, &types.GetPositionsOpts{ExchangeIndex: idx}) + return err + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + idx := 1 + mt := &mockTransport{statusCode: 200, body: []byte(tc.body)} + client := New(HTTPClient(&http.Client{Transport: mt})) + if err := tc.call(context.Background(), client, &idx); err != nil { + t.Fatal(err) + } + if mt.req.URL.Path != tc.path { + t.Fatalf("path: %s", mt.req.URL.Path) + } + if got := mt.req.URL.Query().Get("exchange_index"); got != "1" { + t.Fatalf("exchange_index: %q (query %v)", got, mt.req.URL.Query()) + } + + mt = &mockTransport{statusCode: 200, body: []byte(tc.body)} + client = New(HTTPClient(&http.Client{Transport: mt})) + if err := tc.call(context.Background(), client, nil); err != nil { + t.Fatal(err) + } + if _, ok := mt.req.URL.Query()["exchange_index"]; ok { + t.Fatalf("nil ExchangeIndex should be omitted: %v", mt.req.URL.Query()) + } + }) + } +} + func TestMarkets_GetTrades_IsBlockTradeQuery(t *testing.T) { body := []byte(`{"trades":[],"cursor":""}`) mt := &mockTransport{statusCode: 200, body: body} diff --git a/oddrip/errors.go b/oddrip/errors.go index b973d89..69bbb9f 100644 --- a/oddrip/errors.go +++ b/oddrip/errors.go @@ -12,6 +12,13 @@ import ( const maxBodySnippet = 512 +// APIError is a non-2xx response. StatusCode and RawBody (the first 512 bytes +// of the body) are always set. Code, Message, and Details are filled from the +// body when it is one of the shapes Kalshi emits: the spec's flat +// ErrorResponse, the same object nested under "error" (what production +// returns for most errors), or {"msg": "..."} (parameter-binding 400s). +// RequestID is the Request-Id header when present; production does not +// currently send one. type APIError struct { StatusCode int Code string @@ -29,16 +36,33 @@ func (e *APIError) Error() string { return fmt.Sprintf("api error %d", e.StatusCode) } +// errorBody covers every error body shape observed from the API. The flat +// fields are the spec's ErrorResponse; Error is the production wrapper; Msg is +// the parameter-binding validator's shape. +type errorBody struct { + types.ErrorResponse + Error *types.ErrorResponse `json:"error,omitempty"` + Msg string `json:"msg,omitempty"` +} + func newAPIError(resp *http.Response) *APIError { e := &APIError{StatusCode: resp.StatusCode, RequestID: resp.Header.Get("Request-Id")} buf, _ := io.ReadAll(io.LimitReader(resp.Body, maxBodySnippet)) e.RawBody = string(buf) - var er types.ErrorResponse - if json.NewDecoder(bytes.NewReader(buf)).Decode(&er) == nil { - e.Code = er.Code - e.Message = er.Message - e.Details = er.Details - e.Service = er.Service + var body errorBody + if json.NewDecoder(bytes.NewReader(buf)).Decode(&body) != nil { + return e + } + er := body.ErrorResponse + if er.Code == "" && er.Message == "" && body.Error != nil { + er = *body.Error + } + e.Code = er.Code + e.Message = er.Message + e.Details = er.Details + e.Service = er.Service + if e.Message == "" && body.Msg != "" { + e.Message = body.Msg } return e } diff --git a/oddrip/errors_test.go b/oddrip/errors_test.go new file mode 100644 index 0000000..342dbe8 --- /dev/null +++ b/oddrip/errors_test.go @@ -0,0 +1,120 @@ +package oddrip + +import ( + "io" + "net/http" + "strings" + "testing" +) + +func TestNewAPIError_BodyShapes(t *testing.T) { + cases := []struct { + name string + status int + body string + wantCode string + wantMessage string + wantDetails string + wantErrString string + }{ + { + name: "spec flat shape", + status: 404, + body: `{"code":"NOT_FOUND","message":"resource not found","details":"ticker X"}`, + wantCode: "NOT_FOUND", + wantMessage: "resource not found", + wantDetails: "ticker X", + wantErrString: "api error 404: resource not found", + }, + { + // GET /markets/{ticker} for an unknown ticker, as returned by production. + name: "production nested under error", + status: 404, + body: `{"error":{"code":"not_found","message":"not found"}}`, + wantCode: "not_found", + wantMessage: "not found", + wantErrString: "api error 404: not found", + }, + { + // Unauthenticated GET /portfolio/balance, as returned by production. + name: "production nested auth failure", + status: 401, + body: `{"error":{"code":"token_authentication_failure","message":"token authentication failure"}}`, + wantCode: "token_authentication_failure", + wantMessage: "token authentication failure", + wantErrString: "api error 401: token authentication failure", + }, + { + // GET /markets?limit=abc, as returned by production. + name: "parameter binding msg shape", + status: 400, + body: `{"msg":"Invalid format for parameter limit: error binding string parameter"}`, + wantMessage: "Invalid format for parameter limit: error binding string parameter", + wantErrString: "api error 400: Invalid format for parameter limit: error binding string parameter", + }, + { + name: "flat wins over nested when both present", + status: 400, + body: `{"code":"flat","message":"flat msg","error":{"code":"nested","message":"nested msg"}}`, + wantCode: "flat", + wantMessage: "flat msg", + wantErrString: "api error 400: flat msg", + }, + { + name: "not json", + status: 502, + body: `bad gateway`, + wantErrString: "api error 502", + }, + { + name: "empty body", + status: 500, + body: ``, + wantErrString: "api error 500", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + resp := &http.Response{ + StatusCode: tc.status, + Header: http.Header{}, + Body: io.NopCloser(strings.NewReader(tc.body)), + } + e := newAPIError(resp) + if e.StatusCode != tc.status { + t.Errorf("StatusCode = %d, want %d", e.StatusCode, tc.status) + } + if e.Code != tc.wantCode { + t.Errorf("Code = %q, want %q", e.Code, tc.wantCode) + } + if e.Message != tc.wantMessage { + t.Errorf("Message = %q, want %q", e.Message, tc.wantMessage) + } + if e.Details != tc.wantDetails { + t.Errorf("Details = %q, want %q", e.Details, tc.wantDetails) + } + if e.RawBody != tc.body { + t.Errorf("RawBody = %q, want %q", e.RawBody, tc.body) + } + if got := e.Error(); got != tc.wantErrString { + t.Errorf("Error() = %q, want %q", got, tc.wantErrString) + } + }) + } +} + +func TestNewAPIError_RawBodyTruncatedAndRequestID(t *testing.T) { + long := strings.Repeat("x", maxBodySnippet*2) + resp := &http.Response{ + StatusCode: 503, + Header: http.Header{"Request-Id": []string{"req-123"}}, + Body: io.NopCloser(strings.NewReader(long)), + } + e := newAPIError(resp) + if len(e.RawBody) != maxBodySnippet { + t.Errorf("RawBody length = %d, want %d", len(e.RawBody), maxBodySnippet) + } + if e.RequestID != "req-123" { + t.Errorf("RequestID = %q, want req-123", e.RequestID) + } +} diff --git a/oddrip/orders.go b/oddrip/orders.go index f145f65..e2e3e6e 100644 --- a/oddrip/orders.go +++ b/oddrip/orders.go @@ -31,6 +31,7 @@ func (s *OrdersService) List(ctx context.Context, opts *types.GetOrdersOpts) (*t encodeQueryInt64(v, "limit", opts.Limit) encodeQuery(v, "cursor", opts.Cursor) encodeQueryInt(v, "subaccount", opts.Subaccount) + encodeQueryInt(v, "exchange_index", opts.ExchangeIndex) } var out types.GetOrdersResponse if err := s.client.get(ctx, joinPath("portfolio", "orders"), v, &out); err != nil { diff --git a/oddrip/portfolio.go b/oddrip/portfolio.go index 980d4a8..57b9508 100644 --- a/oddrip/portfolio.go +++ b/oddrip/portfolio.go @@ -36,6 +36,7 @@ func (s *PortfolioService) GetFills(ctx context.Context, opts *types.GetFillsOpt encodeQueryInt64(v, "limit", opts.Limit) encodeQuery(v, "cursor", opts.Cursor) encodeQueryInt(v, "subaccount", opts.Subaccount) + encodeQueryInt(v, "exchange_index", opts.ExchangeIndex) } var out types.GetFillsResponse if err := s.client.get(ctx, joinPath("portfolio", "fills"), v, &out); err != nil { @@ -52,6 +53,7 @@ func (s *PortfolioService) GetPositions(ctx context.Context, opts *types.GetPosi encodeQuery(v, "ticker", opts.Ticker) encodeQuery(v, "event_ticker", opts.EventTicker) encodeQueryInt(v, "subaccount", opts.Subaccount) + encodeQueryInt(v, "exchange_index", opts.ExchangeIndex) if opts.Limit != nil { v.Set("limit", fmt.Sprintf("%d", *opts.Limit)) } @@ -116,6 +118,7 @@ func (s *PortfolioService) ListHistoricalPositions(ctx context.Context, opts *ty if opts != nil { encodeQuery(v, "ticker", opts.Ticker) encodeQuery(v, "event_ticker", opts.EventTicker) + encodeQueryInt(v, "subaccount", opts.Subaccount) encodeQueryInt64(v, "limit", opts.Limit) encodeQuery(v, "cursor", opts.Cursor) } diff --git a/oddrip/types/order.go b/oddrip/types/order.go index 72e6627..92338ee 100644 --- a/oddrip/types/order.go +++ b/oddrip/types/order.go @@ -67,14 +67,15 @@ type GetOrdersResponse struct { } type GetOrdersOpts struct { - Ticker string - EventTicker string - MinTs *int64 - MaxTs *int64 - Status string - Limit *int64 - Cursor string - Subaccount *int + Ticker string + EventTicker string + MinTs *int64 + MaxTs *int64 + Status string + Limit *int64 + Cursor string + Subaccount *int + ExchangeIndex *int } type CancelOrderResponse struct { diff --git a/oddrip/types/portfolio.go b/oddrip/types/portfolio.go index bad2592..df7cc17 100644 --- a/oddrip/types/portfolio.go +++ b/oddrip/types/portfolio.go @@ -45,13 +45,14 @@ type GetFillsResponse struct { } type GetFillsOpts struct { - Ticker string - OrderID string - MinTs *int64 - MaxTs *int64 - Limit *int64 - Cursor string - Subaccount *int + Ticker string + OrderID string + MinTs *int64 + MaxTs *int64 + Limit *int64 + Cursor string + Subaccount *int + ExchangeIndex *int } type MarketPosition struct { @@ -81,12 +82,13 @@ type GetPositionsResponse struct { } type GetPositionsOpts struct { - Cursor string - Limit *int - CountFilter string - Ticker string - EventTicker string - Subaccount *int + Cursor string + Limit *int + CountFilter string + Ticker string + EventTicker string + Subaccount *int + ExchangeIndex *int } type BucketLimit struct { @@ -194,6 +196,7 @@ type GetSettlementsOpts struct { type GetHistoricalPositionsOpts struct { Ticker string EventTicker string + Subaccount *int Limit *int64 Cursor string } @@ -246,7 +249,8 @@ type TargetBalanceAllocation struct { } type GetTargetBalanceAllocationResponse struct { - Allocations []TargetBalanceAllocation `json:"allocations"` + Allocations []TargetBalanceAllocation `json:"allocations"` + RestingMarginReservation string `json:"resting_margin_reservation"` } // SetTargetBalanceAllocationRequest replaces the caller's allocation. An empty diff --git a/oddrip/types/portfolio_test.go b/oddrip/types/portfolio_test.go index 4ec9102..254f4fc 100644 --- a/oddrip/types/portfolio_test.go +++ b/oddrip/types/portfolio_test.go @@ -184,6 +184,21 @@ func TestGetIntraExchangeTransfersResponse_Unmarshal(t *testing.T) { } } +func TestGetTargetBalanceAllocationResponse_Unmarshal(t *testing.T) { + // OpenAPI 3.30.0 adds resting_margin_reservation to the response. + const payload = `{"allocations":[{"exchange_index":0,"percent":70},{"exchange_index":1,"percent":30}],"resting_margin_reservation":"max"}` + var out GetTargetBalanceAllocationResponse + if err := json.Unmarshal([]byte(payload), &out); err != nil { + t.Fatal(err) + } + if len(out.Allocations) != 2 || out.Allocations[1].ExchangeIndex != 1 || out.Allocations[1].Percent != 30 { + t.Fatalf("allocations: %+v", out.Allocations) + } + if out.RestingMarginReservation != RestingMarginReservationMax { + t.Fatalf("resting_margin_reservation: %q", out.RestingMarginReservation) + } +} + func TestSetTargetBalanceAllocationRequest_Marshal(t *testing.T) { req := SetTargetBalanceAllocationRequest{ Allocations: []TargetBalanceAllocation{{ExchangeIndex: 0, Percent: 70}, {ExchangeIndex: 1, Percent: 30}}, diff --git a/oddrip/types/series.go b/oddrip/types/series.go index f13f3ed..2fbca48 100644 --- a/oddrip/types/series.go +++ b/oddrip/types/series.go @@ -7,11 +7,15 @@ const ( FeeTypeFlat = "flat" ) +// Series is one series. Category is the primary discovery category; +// Categories is the full list, which is what the category filter on +// Series.List matches against. type Series struct { Ticker string `json:"ticker"` Frequency string `json:"frequency"` Title string `json:"title"` Category string `json:"category"` + Categories []string `json:"categories"` Tags []string `json:"tags"` SettlementSources []SettlementSource `json:"settlement_sources"` ContractURL string `json:"contract_url"` diff --git a/oddrip/types/series_extra_test.go b/oddrip/types/series_extra_test.go index 4bbd0cd..5944a33 100644 --- a/oddrip/types/series_extra_test.go +++ b/oddrip/types/series_extra_test.go @@ -60,6 +60,30 @@ func TestSeries_NullableArrays(t *testing.T) { } } +func TestSeries_Categories(t *testing.T) { + // OpenAPI 3.30.0: category is the primary category, categories is the full + // discovery list the Series.List category filter matches against. + const payload = `{"series":{"ticker":"KXHIGHNY","frequency":"daily","title":"NYC high temp","category":"Climate and Weather","categories":["Climate and Weather","Science"],"tags":["Weather"],"settlement_sources":[],"contract_url":"","contract_terms_url":"","fee_type":"quadratic","fee_multiplier":1,"additional_prohibitions":[]}}` + var out GetSeriesResponse + if err := json.Unmarshal([]byte(payload), &out); err != nil { + t.Fatal(err) + } + if out.Series.Category != "Climate and Weather" { + t.Fatalf("category: %q", out.Series.Category) + } + if len(out.Series.Categories) != 2 || out.Series.Categories[0] != "Climate and Weather" || out.Series.Categories[1] != "Science" { + t.Fatalf("categories: %v", out.Series.Categories) + } + + var empty GetSeriesResponse + if err := json.Unmarshal([]byte(`{"series":{"ticker":"S","categories":[]}}`), &empty); err != nil { + t.Fatal(err) + } + if empty.Series.Categories == nil || len(empty.Series.Categories) != 0 { + t.Fatalf("empty categories should be an empty slice: %#v", empty.Series.Categories) + } +} + func TestCreateOrderGroupRequest_OmitsUnset(t *testing.T) { limit := "10.00" out, err := json.Marshal(CreateOrderGroupRequest{ContractsLimitFp: &limit}) diff --git a/oddrip/version.go b/oddrip/version.go index d9eb679..25874ea 100644 --- a/oddrip/version.go +++ b/oddrip/version.go @@ -1,4 +1,4 @@ package oddrip // Version is the module release. CI tags v and publishes a release when main carries a version that is not yet tagged. -const Version = "0.6.0" +const Version = "0.6.1" diff --git a/oddrip/ws.go b/oddrip/ws.go index 9b73d65..346725e 100644 --- a/oddrip/ws.go +++ b/oddrip/ws.go @@ -30,6 +30,9 @@ var ( ErrWSClosed = errors.New("websocket closed") ErrWSAuthRequired = errors.New("websocket requires auth") ErrWSSlowConsumer = errors.New("websocket consumer too slow") + // ErrWSMalformedFrame is the terminal error when a text frame is not valid + // JSON; Err() wraps it with the decode error. + ErrWSMalformedFrame = errors.New("websocket malformed frame") ) type WSConn struct { @@ -219,7 +222,12 @@ func (ws *WSConn) readLoop() { ws.resetDeadline() var env wsEnvelope if err := json.Unmarshal(data, &env); err != nil { - continue + // The server only sends JSON text frames. Anything else means the + // stream is corrupt; fail loudly like the slow-consumer path rather + // than skip it and leave the connection looking healthy. + ws.setErr(fmt.Errorf("%w: %v", ErrWSMalformedFrame, err)) + ws.conn.Close() + return } if env.ID != 0 { ws.pendMu.Lock() @@ -518,7 +526,8 @@ func (ws *WSConn) Done() <-chan struct{} { } // Err is nil while the connection is healthy. After the read loop exits it is -// the terminal read error, ErrWSSlowConsumer, or ErrWSClosed after Close(). +// the terminal read error, ErrWSSlowConsumer, an error wrapping +// ErrWSMalformedFrame, or ErrWSClosed after Close(). func (ws *WSConn) Err() error { ws.mu.Lock() defer ws.mu.Unlock() diff --git a/oddrip/ws_test.go b/oddrip/ws_test.go index 726297c..2570994 100644 --- a/oddrip/ws_test.go +++ b/oddrip/ws_test.go @@ -621,6 +621,136 @@ func TestWS_Subscribe_MalformedReply(t *testing.T) { } } +func TestWS_Unsubscribe_OnePerSID(t *testing.T) { + // The server confirms each sid separately (AsyncAPI unsubscribedResponse: + // {"id":102,"sid":2,"seq":7,"type":"unsubscribed"}), so Unsubscribe waits for + // len(sids) id-matched replies. + type unsubCmd struct { + ID int `json:"id"` + Cmd string `json:"cmd"` + Params struct { + Sids []int `json:"sids"` + } `json:"params"` + } + sent := make(chan unsubCmd, 1) + ws := wsTestConnect(t, wsTestServer(t, func(conn *websocket.Conn) { + _, data, err := conn.ReadMessage() + if err != nil { + return + } + var cmd unsubCmd + json.Unmarshal(data, &cmd) + sent <- cmd + for i, sid := range cmd.Params.Sids { + body, _ := json.Marshal(map[string]interface{}{"id": cmd.ID, "sid": sid, "seq": 7 + i, "type": "unsubscribed"}) + if conn.WriteMessage(websocket.TextMessage, body) != nil { + return + } + } + wsDrain(conn) + })) + + start := time.Now() + if err := ws.Unsubscribe(wsTestCtx(t), []int{1, 2}); err != nil { + t.Fatalf("Unsubscribe: %v", err) + } + if d := time.Since(start); d > time.Second { + t.Errorf("Unsubscribe took %v", d) + } + cmd := <-sent + if cmd.Cmd != "unsubscribe" || len(cmd.Params.Sids) != 2 || cmd.Params.Sids[0] != 1 || cmd.Params.Sids[1] != 2 { + t.Fatalf("command sent: %+v", cmd) + } + if err := ws.Unsubscribe(wsTestCtx(t), nil); err == nil { + t.Fatal("empty sids should be rejected before sending") + } +} + +func TestWS_ListSubscriptions_OK(t *testing.T) { + // AsyncAPI listSubscriptionsResponse: type is "ok" and msg is an array. + ws := wsTestConnect(t, wsTestServer(t, func(conn *websocket.Conn) { + _, data, err := conn.ReadMessage() + if err != nil { + return + } + var cmd struct { + ID int `json:"id"` + Cmd string `json:"cmd"` + } + json.Unmarshal(data, &cmd) + if cmd.Cmd != "list_subscriptions" { + return + } + body, _ := json.Marshal(map[string]interface{}{ + "id": cmd.ID, + "type": "ok", + "msg": []map[string]interface{}{ + {"channel": "orderbook_delta", "sid": 1}, + {"channel": "ticker", "sid": 2}, + {"channel": "fill", "sid": 3}, + }, + }) + conn.WriteMessage(websocket.TextMessage, body) + wsDrain(conn) + })) + + list, err := ws.ListSubscriptions(wsTestCtx(t)) + if err != nil { + t.Fatalf("ListSubscriptions: %v", err) + } + if list.Type != types.WSTypeOK || list.ID == 0 { + t.Fatalf("reply envelope: %+v", list) + } + want := []types.ListSubscriptionsItem{{Channel: "orderbook_delta", SID: 1}, {Channel: "ticker", SID: 2}, {Channel: "fill", SID: 3}} + if len(list.Msg) != len(want) { + t.Fatalf("msg: %+v", list.Msg) + } + for i := range want { + if list.Msg[i] != want[i] { + t.Errorf("msg[%d] = %+v, want %+v", i, list.Msg[i], want[i]) + } + } +} + +func TestWS_MalformedFrame_FailsConnection(t *testing.T) { + ws := wsTestConnect(t, wsTestServer(t, func(conn *websocket.Conn) { + good, _ := json.Marshal(map[string]interface{}{"type": "ticker", "sid": 1, "seq": 1, "msg": map[string]interface{}{"market_ticker": "A"}}) + if conn.WriteMessage(websocket.TextMessage, good) != nil { + return + } + if conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"ticker","sid":1,`)) != nil { + return + } + wsDrain(conn) + })) + + var got []*types.WSMessage + for msg := range ws.Messages() { + got = append(got, msg) + } + if len(got) != 1 || got[0].Type != types.WSTypeTicker { + t.Fatalf("messages before the bad frame: %+v", got) + } + select { + case <-ws.Done(): + case <-time.After(2 * time.Second): + t.Fatal("Done() did not close") + } + err := ws.Err() + if !errors.Is(err, ErrWSMalformedFrame) { + t.Fatalf("Err() = %v, want ErrWSMalformedFrame", err) + } + if err == ErrWSMalformedFrame { + t.Fatalf("Err() should wrap the decode error, got bare sentinel") + } + if _, err := ws.Subscribe(wsTestCtx(t), types.SubscribeParams{Channels: []string{"ticker"}}); !errors.Is(err, ErrWSMalformedFrame) { + t.Fatalf("Subscribe after failure = %v, want ErrWSMalformedFrame", err) + } + if err := ws.Close(); err != nil { + t.Errorf("Close: %v", err) + } +} + func wsTestServer(t *testing.T, handler func(*websocket.Conn)) *httptest.Server { t.Helper() upgrader := websocket.Upgrader{} diff --git a/openapi.yaml b/openapi.yaml index 8eb66db..5db6f67 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.0.0 info: title: Kalshi Trade API Manual Endpoints - version: 3.29.0 + version: 3.30.0 description: Manually defined OpenAPI spec for endpoints being migrated to spec-first approach servers: @@ -339,6 +339,8 @@ paths: - name: category in: query required: false + description: >- + Return series whose `categories` list contains this value. A series can have more than one discovery category, so the `category` field of a returned series (its primary category) may differ from the filter value. Matching is exact and case-sensitive. schema: type: string x-go-type-skip-optional-pointer: true @@ -3531,10 +3533,10 @@ paths: - name: type in: query required: false - description: 'Type filter. Can be "all", "liquidity", "volume", or "margin_maker_volume". Default is "all".' + description: 'Type filter. Can be "all", "liquidity", "volume", "margin_maker_volume", or "margin_taker_volume". Default is "all".' schema: type: string - enum: [all, liquidity, volume, margin_maker_volume] + enum: [all, liquidity, volume, margin_maker_volume, margin_taker_volume] - name: incentive_description in: query required: false @@ -6464,7 +6466,7 @@ components: description: The ticker symbol of the market associated with this incentive program incentive_type: type: string - enum: ['liquidity', 'volume', 'margin_maker_volume'] + enum: ['liquidity', 'volume', 'margin_maker_volume', 'margin_taker_volume'] description: Type of incentive program incentive_description: type: string @@ -6718,11 +6720,14 @@ components: type: object required: - allocations + - resting_margin_reservation properties: allocations: type: array items: $ref: '#/components/schemas/TargetBalanceAllocation' + resting_margin_reservation: + $ref: '#/components/schemas/RestingMarginReservation' SetTargetBalanceAllocationRequest: type: object @@ -7224,6 +7229,12 @@ components: target_cost_dollars: $ref: '#/components/schemas/FixedPointDollars' description: Total value of the RFQ in dollars + target_cost_excludes_fees: + type: boolean + description: >- + True when the target cost is principal-only and quote sizes are + computed without reserving taker fees (fees are charged on top). + x-go-type-skip-optional-pointer: true status: type: string description: Current status of the RFQ (open, closed) @@ -7317,6 +7328,15 @@ components: $ref: '#/components/schemas/FixedPointDollars' description: The target cost for the RFQ in dollars x-go-type-skip-optional-pointer: true + target_cost_excludes_fees: + type: boolean + description: >- + Sizes quotes against the target cost as principal only (contracts = + target cost / price), with your taker fees charged on top of the + target cost. By default (false) the target cost caps principal plus + Kalshi fees, and quote sizes are reduced to make room for the fees. + Only valid together with a target cost. + x-go-type-skip-optional-pointer: true rest_remainder: type: boolean description: Whether to rest the remainder of the RFQ after execution @@ -7435,6 +7455,13 @@ components: rfq_target_cost_dollars: $ref: '#/components/schemas/FixedPointDollars' description: Total value requested in the RFQ in dollars + target_cost_excludes_fees: + type: boolean + description: >- + True when the RFQ's target cost is principal-only and the + contracts-offered sizes were computed without reserving taker fees + (fees are charged on top of the target cost). + x-go-type-skip-optional-pointer: true rfq_creator_order_id: type: string description: Order ID for the RFQ creator (private field) @@ -8829,6 +8856,7 @@ components: - frequency - title - category + - categories - tags - settlement_sources - contract_url @@ -8848,7 +8876,12 @@ components: description: Title describing the series. For full context use you should use this field with the title field of the events belonging to this series. category: type: string - description: Category specifies the category which this series belongs to. + description: Category is the primary category of this series. + categories: + type: array + items: + type: string + description: Categories is the list of discovery categories for this series. The `category` filter on Get Series List matches any entry in this list. May be empty. tags: type: array nullable: true