diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..01fffd3 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: gomod + directory: / + schedule: + interval: weekly + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..18339b1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,218 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +defaults: + run: + shell: bash + +jobs: + lint: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version: stable + - name: gofmt + run: | + out=$(gofmt -l .) + if [ -n "$out" ]; then echo "unformatted:"; echo "$out"; exit 1; fi + - name: go mod tidy is a no-op + run: | + go mod tidy + git diff --exit-code go.mod go.sum + - run: go vet ./... + - name: staticcheck + run: go run honnef.co/go/tools/cmd/staticcheck@latest ./... + - name: govulncheck + run: go run golang.org/x/vuln/cmd/govulncheck@latest ./... + + test: + name: test (go ${{ matrix.go }}, ${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - { os: ubuntu-latest, go: "1.24" } + - { os: ubuntu-latest, go: stable } + - { os: macos-latest, go: stable } + - { os: windows-latest, go: stable } + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version: ${{ matrix.go }} + - run: go build ./... + - run: go test -race -count=1 -shuffle=on -coverprofile=coverage.out ./... + - name: coverage summary + if: matrix.os == 'ubuntu-latest' && matrix.go == 'stable' + run: go tool cover -func=coverage.out | tail -1 + + version: + name: version consistency + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: version.go, CHANGELOG, README agree and the version is unreleased + env: + BASE_REF: ${{ github.base_ref || 'main' }} + run: | + set -euo pipefail + version=$(sed -n 's/^const Version = "\(.*\)"$/\1/p' oddrip/version.go) + echo "oddrip.Version = $version" + [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "::error::Version '$version' is not X.Y.Z"; exit 1; } + + top=$(grep -m1 -oE '^## \[[0-9]+\.[0-9]+\.[0-9]+\]' CHANGELOG.md | tr -d '[]# ') + [ "$top" = "$version" ] || { echo "::error::CHANGELOG top entry is $top but oddrip.Version is $version"; exit 1; } + + grep -q "@v$version" README.md || { echo "::error::README does not pin @v$version"; exit 1; } + + if git rev-parse -q --verify "refs/tags/v$version" >/dev/null; then + if [ "${{ github.event_name }}" = "pull_request" ]; then + git fetch -q origin "$BASE_REF" + if git diff --name-only "origin/$BASE_REF...HEAD" -- oddrip go.mod go.sum | grep -q .; then + echo "::error::v$version is already released; this PR changes code, so bump oddrip/version.go and add a CHANGELOG entry" + exit 1 + fi + echo "v$version already released; docs-only change, no bump required" + else + echo "v$version already released" + fi + echo "released=true" >> "$GITHUB_ENV" + else + echo "v$version is unreleased; merging to main will tag and publish it" + echo "released=false" >> "$GITHUB_ENV" + fi + echo "version=$version" >> "$GITHUB_ENV" + - uses: actions/setup-go@v7 + if: env.released == 'false' + with: + go-version: stable + - name: breaking changes are declared and the bump matches + if: env.released == 'false' + run: | + set -euo pipefail + prev=$(git tag -l 'v*' | sort -V | tail -1) + [ -n "$prev" ] || { echo "no previous tag; skipping"; exit 0; } + echo "previous release: $prev" + + [ "$(printf '%s\n' "${prev#v}" "$version" | sort -V | tail -1)" = "$version" ] && [ "${prev#v}" != "$version" ] \ + || { echo "::error::oddrip.Version $version is not greater than latest tag $prev"; exit 1; } + + awk -v v="$version" '$0 ~ "^## \\[" v "\\]" { on=1; next } on && /^## \[/ { exit } on { print }' CHANGELOG.md > "$RUNNER_TEMP/section.md" + declared=false; grep -q '^### Breaking' "$RUNNER_TEMP/section.md" && declared=true + + go run golang.org/x/exp/cmd/gorelease@latest -base="$prev" -version="v$version" > "$RUNNER_TEMP/gorelease.txt" 2>&1 || true + { echo "## gorelease $prev → v$version"; echo; echo '```'; cat "$RUNNER_TEMP/gorelease.txt"; echo '```'; } >> "$GITHUB_STEP_SUMMARY" + cat "$RUNNER_TEMP/gorelease.txt" + grep -q '^# summary' "$RUNNER_TEMP/gorelease.txt" || { echo "::error::gorelease did not complete; refusing to pass without an API diff"; exit 1; } + + api_breaks=$(awk '/^## incompatible changes/{on=1; next} /^## /{on=0} on && NF && !/^Version: value changed/' "$RUNNER_TEMP/gorelease.txt" || true) + if [ -n "$api_breaks" ] && [ "$declared" = false ]; then + echo "::error::gorelease found API-incompatible changes but the $version CHANGELOG section has no '### Breaking' heading:" + echo "$api_breaks" + exit 1 + fi + + IFS=. read -r pmaj pmin _ <<< "${prev#v}" + IFS=. read -r cmaj cmin _ <<< "$version" + if [ "$declared" = true ]; then + if [ "$cmaj" -eq 0 ] && [ "$pmaj" -eq 0 ]; then + [ "$cmin" -gt "$pmin" ] || { echo "::error::$version declares breaking changes but does not bump the minor version from $prev (v0 policy)"; exit 1; } + else + [ "$cmaj" -gt "$pmaj" ] || { echo "::error::$version declares breaking changes but does not bump the major version from $prev"; exit 1; } + fi + echo "breaking changes declared; bump from $prev to v$version is acceptable" + else + echo "no breaking changes declared or detected" + fi + + release: + name: release + needs: [lint, test, version] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - uses: actions/setup-go@v7 + with: + go-version: stable + # Tag and GitHub release are checked separately so a run that pushed the + # tag but failed to create the release is completed by the next push + # instead of being skipped as "already tagged". + - name: resolve version + id: v + env: + GH_TOKEN: ${{ github.token }} + run: | + version=$(sed -n 's/^const Version = "\(.*\)"$/\1/p' oddrip/version.go) + echo "version=$version" >> "$GITHUB_OUTPUT" + if git rev-parse -q --verify "refs/tags/v$version" >/dev/null; then + echo "tagged=true" >> "$GITHUB_OUTPUT" + echo "v$version already tagged" + else + echo "tagged=false" >> "$GITHUB_OUTPUT" + fi + if gh release view "v$version" >/dev/null 2>&1; then + echo "released=true" >> "$GITHUB_OUTPUT" + echo "v$version already has a GitHub release; nothing to do" + else + echo "released=false" >> "$GITHUB_OUTPUT" + fi + - name: extract release notes + if: steps.v.outputs.released == 'false' + run: | + awk -v v="${{ steps.v.outputs.version }}" ' + $0 ~ "^## \\[" v "\\]" { on=1; next } + on && /^## \[/ { exit } + on { print } + ' CHANGELOG.md > notes.md + [ -s notes.md ] || { echo "::error::no CHANGELOG section for ${{ steps.v.outputs.version }}"; exit 1; } + echo "--- notes.md ---"; cat notes.md + - name: tag + if: steps.v.outputs.tagged == 'false' + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "v${{ steps.v.outputs.version }}" -m "v${{ steps.v.outputs.version }}" + git push origin "v${{ steps.v.outputs.version }}" + - name: GitHub release + if: steps.v.outputs.released == 'false' + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "v${{ steps.v.outputs.version }}" \ + --title "v${{ steps.v.outputs.version }}" \ + --notes-file notes.md \ + --verify-tag + - name: warm module proxy + if: steps.v.outputs.released == 'false' + env: + GOPROXY: https://proxy.golang.org + GOFLAGS: -mod=mod + run: | + cd "$(mktemp -d)" && go mod init tmp >/dev/null + for i in 1 2 3 4 5; do + go list -m "github.com/UTXOnly/oddrip@v${{ steps.v.outputs.version }}" && exit 0 + sleep 15 + done + echo "::warning::proxy.golang.org has not indexed v${{ steps.v.outputs.version }} yet; it will on first fetch" diff --git a/.gitignore b/.gitignore index 4993d9e..276e392 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,8 @@ *.out .DS_Store + +# Example binary and local credentials +/example +*.pem +cmd/example/key_id diff --git a/CHANGELOG.md b/CHANGELOG.md index 57d7a43..ea79ef3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,70 @@ All notable changes to this project are documented here. The client tracks [Kalshi’s API changelog](https://docs.kalshi.com/changelog); repository root `openapi.yaml` / `asyncapi.yaml` are the source of truth for shapes and endpoints. +**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.0] — 2026-09-12 + +Audit release. Every item under **Fixed** was reproduced with a failing test before the fix; the shipped test suite now exercises retry exhaustion, context cancellation during backoff, multi-channel subscribes, concurrent WebSocket writes, slow consumers, and dead connections. + +### Breaking + +Two source-incompatible API changes (the only ones `gorelease -base=v0.5.0` reports) and three behavioral changes that a consumer must account for. + +- **`DoConcurrent` signature.** `DoConcurrent(ctx, n, fn)` is now `DoConcurrent(ctx, n, maxInFlight, fn)`. The old function was documented as bounded but ran all `n` calls at once; the new argument makes it true. Migrate by inserting a limit — `0` reproduces the old unbounded behavior exactly: + ```go + // before + oddrip.DoConcurrent(ctx, len(tickers), fn) + // after + oddrip.DoConcurrent(ctx, len(tickers), 8, fn) // or 0 for unbounded + ``` +- **`types.CFBenchmarksAvgData` fields renamed to match the AsyncAPI schema.** The struct shipped in 0.5.0 with tags (`index_id`, `value_usd`, `source_ts_ms`, `window_sec`) that the server never sends, so `Avg60sData` and `Last60sWindowedAverage15Min` always decoded empty. The fields are now `Value`, `WindowSize`, `WindowStartTsMs`, `WindowEndTsExclusive` (`value`, `window_size`, `window_start_ts_ms`, `window_end_ts_exclusive`). Code that read the old fields was reading zero values; switch to the new names. +- **Non-idempotent writes are no longer retried on 5xx or transport errors.** Previously every request was retried on 429, 5xx, and connection errors alike, so a `DecreaseV2` whose connection dropped after the server applied it was replayed and reduced the order twice. Now GET/PUT/DELETE and POSTs the server deduplicates (`CreateV2`/`BatchCreateV2` with `client_order_id` on every order, `Subaccounts.Transfer`, `SetTargetBalanceAllocation`) keep the full policy; `AmendV2`, `DecreaseV2`, creates without a `client_order_id`, `OrderGroups.Create`, `Subaccounts.Create`, and `CreateMarketInMultivariateCollection` are retried on 429 only and surface the 5xx `*APIError` or transport error on the first occurrence. Code that relied on those being retried through transient 5xx must now handle the error (reconcile with `Orders.Get`, then resend). Setting `client_order_id` on creates restores full retries for them. +- **WebSocket slow consumer now closes the connection.** Previously, if the reader of `Messages()` fell more than 256 messages behind, messages were dropped with no signal. Now the buffer is 4096 (`WSBufferSize`) and on overflow the connection fails with `ErrWSSlowConsumer`, `Messages()` closes, and `Err()` reports why. A consumer that tolerated silent gaps must now reconnect (and re-snapshot any local book) when `Messages()` closes. Consumers that already treat a closed `Messages()` as a disconnect need no change. +- **WebSocket read deadline.** Connections now enforce `WSReadTimeout` (default 90s, extended by every frame including keepalive pongs). A half-open socket that previously left `Messages()` open forever now closes it with a timeout error in `Err()`. Live connections are unaffected — the client pings every `WSPingInterval` (30s), so an idle-but-healthy subscription stays up. Pass `WSReadTimeout(0)` / `WSPingInterval(0)` to restore the old behavior. + +### Fixed + +- **Retry exhaustion panicked the caller.** When every attempt returned 429/5xx with no transport error, `retry.Do` returned a nil response and `client.do` dereferenced it. No `RetryConfig` avoided it (`MaxAttempts: 1` panicked on the first 429). The last response is now surfaced as `*APIError` with its real status, code, and message. +- **Retry backoff ignored the context.** Both waits used `time.Sleep`; a cancelled request could block for the full `Retry-After` or `MaxDelay` (30s by default). Waits now return `ctx.Err()` promptly. +- **Multi-channel `Subscribe` hung until the context deadline.** The read loop discarded the pending reply slot after the first `subscribed` message, so `Subscribe` with two or more channels (the README's own example) never completed. One `SubscribedResponse` per channel is now returned. Reply buffering is sized to the channel count, so subscribing to more than 8 channels at once also works. +- **Concurrent WebSocket commands raced.** `Subscribe`/`Unsubscribe`/`UpdateSubscription`/`Close` wrote to the socket without serialization, tripping gorilla's concurrent-writer check under `-race`. All writes are now serialized; `WSConn` is safe for concurrent use as documented. +- **`UpdateSubscription` with `get_snapshot` never returned.** The spec answers `get_snapshot` with `orderbook_snapshot` frames, which carry no command `id`, but the client waited for an id-matched reply and blocked until the context expired. The call now completes on the first `orderbook_snapshot` for the subscription (or an id-matched `ok`/`error` if the server sends one), returning `Type: "orderbook_snapshot"` with the frame's `SID`/`Seq`; the snapshots themselves arrive on `Messages()` as before. `get_snapshot` now requires `SID` or a single-element `Sids`, matching the command schema. +- **CF Benchmarks averages decoded empty.** See **Breaking** above: `CFBenchmarksAvgData` used field names that are not in the schema, so the typed 60-second and quarter-hour averages were always zero. The unmarshal test now uses the AsyncAPI example payload. +- **An empty path parameter routed the call to a different endpoint.** Paths were built with `path.Join`, which drops empty segments, so `Orders.CancelV2(ctx, "", nil)` sent `DELETE /portfolio/events/orders` — the **CancelAll** endpoint — and `Markets.Get(ctx, "")` quietly called the list endpoint. Every path segment is now required to be non-empty (and not `.`/`..`) and is path-escaped; an offending call returns `ErrEmptyPathParam` before any request is sent. +- **`Subscribe` discarded accepted channels on a partial failure.** The server confirms each channel separately; when it rejected one channel after accepting others, the call returned only the error while the accepted subscriptions stayed live on the server. The accepted `SubscribedResponse`s are now returned alongside the `*WSError`. +- **`MarketLifecycleV2Msg` dropped `exchange_index`.** `created` events carry the shard the market lives on; the field was missing from the struct and silently discarded. Added as `ExchangeIndex *int` (nil on every other event type, so shard 0 is distinguishable from absent). + +### 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 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. +- **`DoConcurrent`** now takes a `maxInFlight` argument — `DoConcurrent(ctx, n, maxInFlight, fn)` — and actually bounds concurrency with a semaphore (the README had claimed it did). `maxInFlight <= 0` is unbounded; workers blocked on the semaphore honor `ctx`. +- **Malformed command replies are errors.** `Subscribe`, `ListSubscriptions`, and `UpdateSubscription` previously ignored a JSON decode failure on the reply's `msg` and returned zero values (`sid: 0`) with a nil error; they now return the decode error. +- Retried responses are drained before being closed so the connection is reused for the next attempt. +- `http.Client` timeouts in `New()` use typed `time.Duration` constants. + +### Added + +- `ErrEmptyPathParam`, returned by any call whose ticker / ID path parameter is empty. +- **WebSocket:** `WSConn.Err()` (terminal error: `ErrWSClosed`, `ErrWSSlowConsumer`, or the read error) and `WSConn.Done()`; options `WSBufferSize`, `WSPingInterval`, `WSReadTimeout`; error `ErrWSSlowConsumer`. +- **Types — typed WebSocket payloads** for every server message: `TickerMsg`, `OrderbookSnapshotMsg`, `OrderbookDeltaMsg` (levels as `OrderbookLevel{PriceDollars, CountFp}` decoded from the spec's `[price, count]` pairs), `TradeMsg`, `FillMsg`, `MarketPositionMsg`, `UserOrderMsg`, `OrderGroupUpdatesMsg`, `MultivariateMarketLifecycleMsg`, `EventLifecycleMsg`, `EventFeeUpdateMsg`, RFQ/quote messages; `WSType*` constants for each `type` string; `WSMessage.Decode(&v)`. +- **Types — helpers:** `Dollars` (int64, 1e-6 scale — lossless for the 6 decimals responses emit) with `ParseDollars`/`String`/`Float64`/`Cents`; `Count` (int64, 1e-2 scale) with `ParseCount`/`String`/`Float64`; `ParseTime` for the RFC 3339 layouts Kalshi emits. +- **REST — `SeriesService`:** `List`, `Get`, `GetMarketCandlesticks`, `GetEventCandlesticks`, `GetForecastPercentileHistory`. +- **REST — `OrderGroupsService`:** `List`, `Create`, `Get`, `Delete`, `Reset`, `Trigger`, `UpdateLimit`. +- **REST — `SubaccountsService`:** `Create`, `GetBalances`, `Transfer`, `ListTransfers`, `GetNetting`, `UpdateNetting`. +- **REST:** `Markets.GetCandlesticks` (batch), `Portfolio.GetTotalRestingOrderValue`, `Events.ListMultivariateCollections` / `GetMultivariateCollection` / `CreateMarketInMultivariateCollection`. Coverage is 64 of 96 spec paths. Mutating order-group and subaccount calls whose spec response is empty return `error` only. +- **Types:** `Series`, `MarketCandlestick`, `BidAskDistribution`, `PriceDistribution`, `ForecastPercentilesPoint`, `OrderGroup`, `SubaccountBalance`, `SubaccountTransfer`, `SubaccountNettingConfig`, `MultivariateEventCollection`, `AssociatedEvent`, `TickerPair`; constants `FeeType*`, `CollectionStatus*`. +- **Tests:** `internal/retry` and `internal/auth` (signature verified with `rsa.VerifyPSS`, query string excluded from the signed path, PKCS#1/PKCS#8 parsing) had none; both are covered now. + +### Removed + +- The 8.5 MB compiled `example` binary and the empty `cmd/example/key_id` / `cmd/example/private_key.pem` placeholders are no longer tracked; `/example`, `*.pem`, and `cmd/example/key_id` are gitignored. See `cmd/example/README.md` for where to put credentials. +- Unused `internal/transport` and `internal/errors` packages, and the unused `BearerToken` / duplicate `StaticHeaders` from `internal/auth`. The public `oddrip.APIError` and `oddrip.StaticHeaders` are unchanged. +- `gorilla/websocket` is no longer marked `// indirect` in `go.mod`. + ## [0.5.0] — 2026-09-06 ### Added diff --git a/README.md b/README.md index 0401514..8cb8ae5 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ # Oddrip +[![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/openapi.yaml): REST for orders, portfolio, markets, events, and exchange info, plus WebSocket for real-time market data (ticker, orderbook, trades, fills, and related channels). One library, same auth; use REST to trade and WebSocket to stream. -REST coverage: **Exchange** (status, schedule, user_data_timestamp, historical cutoff, series fee changes), **Markets** (list, get, orderbook, **orderbooks**, trades, **historical** list/get/trades/candlesticks), **Events** (list, list multivariate, get, get metadata per [Get Events](https://docs.kalshi.com/api-reference/events/get-events)), **Orders** (list, get, queue positions, **V2 event orders** create/cancel/cancel-all/amend/decrease/batch), **Portfolio** (balance, fills, positions, **settlements**, **deposits**, **withdrawals**, **intra-exchange transfers**, **target balance allocation**, **historical** fills/orders/positions), **Account** (API limits, **endpoint costs**), **Live data** (**weather index** and calibrations, event live data). The OpenAPI spec also defines order groups, communications, milestones, and other endpoints; those can be added as needed. See `CHANGELOG.md` and [Kalshi changelog](https://docs.kalshi.com/changelog) for API-facing changes. +REST coverage: **Exchange** (status, schedule, user_data_timestamp, historical cutoff, series fee changes), **Markets** (list, get, orderbook, **orderbooks**, trades, **historical** list/get/trades/candlesticks), **Events** (list, list multivariate, get, get metadata per [Get Events](https://docs.kalshi.com/api-reference/events/get-events)), **Orders** (list, get, queue positions, **V2 event orders** create/cancel/cancel-all/amend/decrease/batch), **Portfolio** (balance, fills, positions, **settlements**, **deposits**, **withdrawals**, **intra-exchange transfers**, **target balance allocation**, **historical** fills/orders/positions), **Account** (API limits, **endpoint costs**), **Live data** (**weather index** and calibrations, event live data), **Series** (list, get, per-series **market and event candlesticks**, forecast percentile history), **Order groups** (list/create/get/delete/reset/trigger/limit), **Subaccounts** (create, balances, transfer, transfer history, netting). Also `Markets.GetCandlesticks` (batch), `Portfolio.GetTotalRestingOrderValue`, and multivariate event collections on `Events` (list/get/`CreateMarketInMultivariateCollection`). 66 of the 96 paths in the vendored spec are covered; communications (RFQ/quotes), milestones, API-key management, FCM, and search remain unimplemented. See `CHANGELOG.md` and [Kalshi changelog](https://docs.kalshi.com/changelog) for API-facing changes. -Module path: `github.com/UTXOnly/oddrip`. Import the client as `github.com/UTXOnly/oddrip/oddrip` and types as `github.com/UTXOnly/oddrip/oddrip/types`. Release **v0.5.0** — pin with `go get github.com/UTXOnly/oddrip/oddrip@v0.5.0` after tagging; runtime string `oddrip.Version` matches the module release. +Module path: `github.com/UTXOnly/oddrip`. Import the client as `github.com/UTXOnly/oddrip/oddrip` and types as `github.com/UTXOnly/oddrip/oddrip/types`. Current release **v0.6.0** — pin with `go get github.com/UTXOnly/oddrip/oddrip@v0.6.0`; runtime string `oddrip.Version` matches the module tag. --- @@ -12,7 +14,7 @@ Module path: `github.com/UTXOnly/oddrip`. Import the client as `github.com/UTXOn ```bash go get github.com/UTXOnly/oddrip/oddrip@latest -# or pin: go get github.com/UTXOnly/oddrip/oddrip@v0.5.0 +# or pin: go get github.com/UTXOnly/oddrip/oddrip@v0.6.0 ``` --- @@ -42,7 +44,7 @@ Kalshi uses request signing: you sign each HTTP request (method + path + timesta ## REST: requests and services -The client exposes services that match the API: `Exchange`, `Markets`, `Events`, `Orders`, `Portfolio`, `Account`, `LiveData`. All calls take `context.Context` (for timeouts and cancellation). +The client exposes services that match the API: `Exchange`, `Markets`, `Events`, `Series`, `Orders`, `OrderGroups`, `Portfolio`, `Subaccounts`, `Account`, `LiveData`. All calls take `context.Context` (for timeouts and cancellation). ```go ctx := context.Background() @@ -69,7 +71,11 @@ cal, err := client.LiveData.GetWeatherIndexCalibrations(ctx, "miami") Minutes where the index quorum failed are absent from `Timeseries`, so gaps in the series are real gaps. -Optional parameters use pointer fields in opts structs (e.g. `Limit *int64`, `Cursor string`). Omit or set to `nil` what you don’t need. +Optional parameters use pointer fields in opts structs (e.g. `Limit *int64`, `Cursor string`). Omit or set to `nil` what you don’t need. The `ptr` in these examples is not part of the module; it is the usual one-liner: + +```go +func ptr[T any](v T) *T { return &v } +``` --- @@ -93,7 +99,7 @@ for { ## Error handling -Non-2xx responses are returned as `*oddrip.APIError`. Use `errors.As` to inspect status, message, and body. +Non-2xx responses are returned as `*oddrip.APIError`. Use `errors.As` to inspect status, message, and body. This includes the case where every retry attempt was rate-limited or failed server-side: the last response is surfaced as an `APIError` with its real status code (e.g. 429), never as a nil response. A call whose ticker or ID path parameter is empty returns `oddrip.ErrEmptyPathParam` without sending anything — an empty order ID would otherwise turn `CancelV2` into `CancelAll`. ```go if err != nil { @@ -110,7 +116,16 @@ if err != nil { ## Retries -The client retries on 429 and 5xx with exponential backoff and jitter. It honors `Retry-After` when present. You can tune behavior with `RetryConfigOption`. +The client retries with exponential backoff and jitter, up to `MaxAttempts` (default 4), honoring `Retry-After` in both delta-seconds and HTTP-date forms. Backoff waits are cancelled by the request context, so a cancelled or expired `ctx` returns promptly instead of sleeping out the delay. Tune with `RetryConfigOption`; `MaxAttempts` below 1 is treated as 1. + +What is retried depends on whether the request is safe to replay: + +| Request | 429 | 5xx | Transport error / timeout | +|---|---|---|---| +| Idempotent — every GET, PUT, and DELETE (`CancelV2`, `CancelAll`, `BatchCancelV2`, order-group `Reset` / `Trigger` / `Delete` / `UpdateLimit`, `UpdateNetting`), plus POSTs the server deduplicates or that set absolute state: `CreateV2` / `BatchCreateV2` **with `client_order_id` on every order**, `Subaccounts.Transfer` (`client_transfer_id`), `SetTargetBalanceAllocation` | retried | retried | retried | +| Non-idempotent — `AmendV2`, `DecreaseV2`, `CreateV2` / `BatchCreateV2` without a `client_order_id`, `OrderGroups.Create`, `Subaccounts.Create`, `CreateMarketInMultivariateCollection` | retried | **not retried** | **not retried** | + +A 429 means the server rejected the request before acting on it. A 5xx or a dropped connection is ambiguous — the write may already be applied — and replaying a decrease would reduce the order twice, so those are surfaced to you instead. Always set `client_order_id` on creates: it is what makes a retried create place exactly one order. On an ambiguous failure of a non-idempotent write, reconcile with `Orders.Get` before deciding whether to resend. ```go client := oddrip.New( @@ -127,16 +142,34 @@ client := oddrip.New( ## Concurrent requests -The client is safe for concurrent use. For bounded concurrency (e.g. many tickers), use `DoConcurrent`: +The client is safe for concurrent use. `DoConcurrent` fans out `n` calls with at most `maxInFlight` running at once (pass `0` for unbounded) and returns results in index order; each result carries its own error. If `ctx` is cancelled, the results collected so far are returned along with `ctx.Err()`. ```go -results, err := oddrip.DoConcurrent(ctx, 3, func(i int) (*types.GetMarketResponse, error) { +results, err := oddrip.DoConcurrent(ctx, len(tickers), 8, func(i int) (*types.GetMarketResponse, error) { return client.Markets.Get(ctx, tickers[i]) }) ``` --- +## Prices, counts, timestamps + +The API emits prices as dollar strings (`"0.4500"`, up to 6 decimals in responses), contract counts as fixed-point strings (`"10.00"`), and times as RFC 3339 strings. The response structs keep those as `string` so nothing is lost; `types` provides lossless parsers when you need numbers. + +```go +price, err := types.ParseDollars("0.4500") // Dollars, int64 scaled 1e-6 +price.String() // "0.4500" — safe to send back in a request +price.Cents() // 45 (truncates toward zero) +price.Float64() // 0.45 + +qty, _ := types.ParseCount("10") // Count, int64 scaled 1e-2 +qty.String() // "10.00" + +ts, _ := types.ParseTime("2022-11-22T20:44:01Z") +``` + +--- + ## WebSocket (market data) The WebSocket API is **read-only**: subscribe to channels and receive streams. There is no order placement over WebSocket; use the REST client for that. Auth is required; the same signer used for REST is applied to the WebSocket handshake. @@ -158,25 +191,60 @@ if err != nil { for msg := range conn.Messages() { switch msg.Type { - case "ticker": - // decode msg.Msg - case "orderbook_snapshot", "orderbook_delta": - // ... + case types.WSTypeTicker: + var t types.TickerMsg + if err := msg.Decode(&t); err != nil { + return err + } + bid, _ := types.ParseDollars(t.YesBidDollars) + fmt.Println(t.MarketTicker, bid.Cents()) + case types.WSTypeOrderbookSnapshot: + var snap types.OrderbookSnapshotMsg + _ = msg.Decode(&snap) // YesDollarsFp / NoDollarsFp are []OrderbookLevel{PriceDollars, CountFp} + case types.WSTypeOrderbookDelta: + var d types.OrderbookDeltaMsg + _ = msg.Decode(&d) + case types.WSTypeFill: + var f types.FillMsg + _ = msg.Decode(&f) } } + +// Messages() closes when the connection is gone. Err() says why. +if err := conn.Err(); !errors.Is(err, oddrip.ErrWSClosed) { + // dead socket, slow consumer, or server close: reconnect and re-subscribe +} ``` -**Commands:** `Subscribe`, `Unsubscribe`, `ListSubscriptions`, `UpdateSubscription` (add/remove markets, underlyings, or CF Benchmarks indices on a subscription). **Channels** (see `types`): ticker, orderbook_delta, trade, fill, market_positions, market_lifecycle_v2, multivariate_market_lifecycle, communications, order_group_updates, user_orders, pyth_value, cfbenchmarks_value, cfbenchmarks_value_5hz. The cfbenchmarks channels take `IndexIDs` instead of market tickers (`[]string{"all"}` for every index). Server errors come back as `*oddrip.WSError` (Code and Message). Use `oddrip.WSHost`, `oddrip.WSPath`, and `oddrip.WSScheme` to point at a different host or path (e.g. demo). +Every server message type has a `types.WSType*` constant and a typed `*Msg` struct (`TickerMsg`, `OrderbookSnapshotMsg`, `OrderbookDeltaMsg`, `TradeMsg`, `FillMsg`, `MarketPositionMsg`, `UserOrderMsg`, `OrderGroupUpdatesMsg`, `MarketLifecycleV2Msg`, the RFQ/quote messages, `PythValueMsg`, `CFBenchmarksValueMsg`, ...). `msg.Decode(&v)` unmarshals the payload. + +**Connection lifecycle.** The connection sends keepalive pings and enforces a read deadline, so a half-open socket is detected within `WSReadTimeout` (default 90s) instead of blocking forever. Messages are never dropped silently: if the consumer of `Messages()` falls behind and the buffer (`WSBufferSize`, default 4096) fills, the connection is failed with `ErrWSSlowConsumer` and closed, because a gap in an `orderbook_delta` stream would otherwise corrupt your local book without warning. Treat `Messages()` closing as "reconnect and re-subscribe"; `Err()` returns the terminal error (`ErrWSClosed` after a clean `Close`, `ErrWSSlowConsumer`, or the underlying read error) and `Done()` is closed when the read loop exits. Options: `WSBufferSize(n)`, `WSPingInterval(d)` (default 30s, `<= 0` disables), `WSReadTimeout(d)` (default 90s, `<= 0` disables). `Close` is idempotent; all commands are safe to call concurrently. + +**Commands:** `Subscribe`, `Unsubscribe`, `ListSubscriptions`, `UpdateSubscription` (add/remove markets, underlyings, or CF Benchmarks indices on a subscription; `get_snapshot` re-sends `orderbook_snapshot` frames on `Messages()` and returns once the first one for that subscription arrives). **Channels** (see `types`): ticker, orderbook_delta, trade, fill, market_positions, market_lifecycle_v2, multivariate_market_lifecycle, communications, order_group_updates, user_orders, pyth_value, cfbenchmarks_value, cfbenchmarks_value_5hz. The cfbenchmarks channels take `IndexIDs` instead of market tickers (`[]string{"all"}` for every index). Server errors come back as `*oddrip.WSError` (Code and Message). Use `oddrip.WSHost`, `oddrip.WSPath`, and `oddrip.WSScheme` to point at a different host or path (e.g. demo). --- ## Package layout -- **`oddrip`** – REST client, `ConnectWS`, and service methods (`Exchange`, `Markets`, `Events`, `Orders`, `Portfolio`, `Account`, `LiveData`). -- **`oddrip/types`** – Request/response and enum types for both REST and WebSocket (e.g. `CreateOrderV2Request`, `SubscribeParams`, `WSMessage`, channel constants). -- **`oddrip/internal/errors`** – Parsing of API error responses. -- **`oddrip/internal/retry`** – Retry with backoff. -- **`oddrip/internal/auth`** – Auth provider interface and RSA-PSS signer. -- **`oddrip/internal/transport`** – Minimal HTTP `Doer` interface (not used directly by callers). +- **`oddrip`** – REST client, `ConnectWS`, and service methods (`Exchange`, `Markets`, `Events`, `Orders`, `Portfolio`, `Account`, `LiveData`, `Series`, `OrderGroups`, `Subaccounts`). +- **`oddrip/types`** – Request/response and enum types for both REST and WebSocket (e.g. `CreateOrderV2Request`, `SubscribeParams`, `WSMessage`, channel and message-type constants), typed WebSocket payloads, and the `Dollars`/`Count`/`ParseTime` helpers. +- **`oddrip/internal/retry`** – Retry with backoff and retryable-status classification. +- **`oddrip/internal/auth`** – RSA-PSS request signer. All public methods take `context.Context`. The client and WebSocket connection are safe for concurrent use. + +--- + +## Development and releases + +CI runs on every pull request and on `main`: `gofmt`, `go mod tidy` drift, `go vet`, `staticcheck`, `govulncheck`, and `go test -race -shuffle=on` on Go 1.24 and stable across Linux, macOS, and Windows. A `version` job checks that `oddrip/version.go`, the top `CHANGELOG.md` entry, and the README pin agree, and fails a code-changing PR whose version is already tagged. + +Releases are cut by merging to `main`. To ship a version: + +1. Bump `const Version` in `oddrip/version.go`. +2. Add a `## [X.Y.Z] — YYYY-MM-DD` section at the top of `CHANGELOG.md`; its body becomes the release notes. +3. Update the `@vX.Y.Z` pin in this README. + +When the merge lands and all checks pass, the `release` job tags `vX.Y.Z`, publishes a GitHub Release with the CHANGELOG section, and warms `proxy.golang.org`. A merge whose version is already tagged (docs-only changes) is a no-op. + +**Versioning.** Semver. While the module is at v0, a minor release may contain breaking changes; they are always listed first under `### Breaking` in the CHANGELOG with migration notes. CI runs `gorelease` against the previous tag and refuses a release that has API-incompatible changes without that section, or that declares one on a patch bump. Patch releases never break. Behavioral changes that `gorelease` cannot see (e.g. a connection now closing where it used to hang) are declared under the same heading. diff --git a/cmd/example/README.md b/cmd/example/README.md index a202ef7..1dfc9d6 100644 --- a/cmd/example/README.md +++ b/cmd/example/README.md @@ -9,18 +9,18 @@ Each log entry includes: - The HTTP method and **fully constructed URL** (base URL + path + query) - Response status code and **raw response body** (pretty-printed JSON) -Endpoints covered: exchange (status, announcements, schedule, user_data_timestamp, historical cutoff, series fee changes), markets (list, get, orderbook, trades), events (list, list multivariate, get, get metadata), orders (list, get, queue position(s)), portfolio (balance, fills, positions), account (API limits). +Endpoints covered: exchange (status, schedule, user_data_timestamp, historical cutoff, series fee changes), markets (list, get, orderbook, trades), events (list, list multivariate, get, get metadata), orders (list, get, queue position(s)), portfolio (balance, fills, positions), account (API limits). ## Keys from files in this directory -Place your Kalshi API credentials as files in this directory: +Create two files in this directory (they are not shipped with the repo and are gitignored via `cmd/example/key_id` and `*.pem`): | File | Description | |-------------------|--------------------------------------| | `key_id` | Your API key ID (single line). | | `private_key.pem` | RSA private key in PEM format (PKCS#8 or PKCS#1). | -Then run from the repo root: +The program does not read these files itself; it takes the key ID from `KALSHI_ACCESS_KEY` and the PEM path from `KALSHI_PRIVATE_KEY_PATH`, so the commands below feed them in: ```bash cd cmd/example @@ -50,4 +50,4 @@ Without auth, only public endpoints run. With auth, portfolio and orders endpoin **Live mode (place order):** Set `LIVE=1` and use **production** `BASE_URL` (demo does not support order placement). The example will: (1) find the current open 15‑minute BTC market (series `KXBTC15M`), (2) place a limit order for 1 contract yes at 1¢ and leave it resting so you can confirm, (3) place a second 1¢ yes bid, (4) cancel only the second order (to test cancel). The first order remains resting. Requires auth. -**Security:** Do not commit `key_id` or `private_key.pem`. Add them to `.gitignore` if they live under the repo. +**Security:** `key_id` and `*.pem` are gitignored so the local credential files stay out of commits. Do not force-add them, and do not copy them elsewhere under the repo. diff --git a/cmd/example/key_id b/cmd/example/key_id deleted file mode 100644 index e69de29..0000000 diff --git a/cmd/example/main.go b/cmd/example/main.go index 5f5bcb8..3cd5922 100644 --- a/cmd/example/main.go +++ b/cmd/example/main.go @@ -34,8 +34,8 @@ func main() { oddrip.BaseURL(baseURL), oddrip.HTTPClient(&http.Client{ Transport: &loggingTransport{ - base: http.DefaultTransport, - log: logFile, + base: http.DefaultTransport, + log: logFile, baseURL: baseURL, }, Timeout: 30 * time.Second, @@ -61,9 +61,9 @@ func main() { if sig != "" && ts != "" { opts = append(opts, oddrip.Auth(&oddrip.StaticHeaders{ Headers: map[string][]string{ - "KALSHI-ACCESS-KEY": {keyID}, - "KALSHI-ACCESS-SIGNATURE": {sig}, - "KALSHI-ACCESS-TIMESTAMP": {ts}, + "KALSHI-ACCESS-KEY": {keyID}, + "KALSHI-ACCESS-SIGNATURE": {sig}, + "KALSHI-ACCESS-TIMESTAMP": {ts}, }, })) hasAuth = true @@ -138,7 +138,9 @@ func runAll(ctx context.Context, client *oddrip.Client, log io.Writer) { logCall("Exchange.GetSeriesFeeChanges (KXBTC, historical)", func() { client.Exchange.GetSeriesFeeChanges(ctx, "KXBTC", true) }) logCall("Markets.List (limit=5)", func() { client.Markets.List(ctx, &types.GetMarketsOpts{Limit: &limit5}) }) - logCall("Markets.List (limit=10, status=open)", func() { client.Markets.List(ctx, &types.GetMarketsOpts{Limit: &limit10, Status: types.MarketStatusOpen}) }) + logCall("Markets.List (limit=10, status=open)", func() { + client.Markets.List(ctx, &types.GetMarketsOpts{Limit: &limit10, Status: types.MarketStatusOpen}) + }) logCall("Markets.List (limit=3, event_ticker=KXBTC)", func() { client.Markets.List(ctx, &types.GetMarketsOpts{Limit: &limit3, EventTicker: "KXBTC"}) }) var markets *types.GetMarketsResponse logCall("Markets.List (limit=5, for follow-up)", func() { @@ -173,12 +175,18 @@ func runAll(ctx context.Context, client *oddrip.Client, log io.Writer) { logCall("Events.GetMetadata "+eventTicker, func() { client.Events.GetMetadata(ctx, eventTicker) }) } logCall("Events.ListMultivariate (limit=3)", func() { client.Events.ListMultivariate(ctx, &types.GetMultivariateEventsOpts{Limit: &limit3}) }) - logCall("Events.ListMultivariate (limit=3, with_nested_markets=true)", func() { client.Events.ListMultivariate(ctx, &types.GetMultivariateEventsOpts{Limit: &limit3, WithNestedMarkets: &nestedTrue}) }) + logCall("Events.ListMultivariate (limit=3, with_nested_markets=true)", func() { + client.Events.ListMultivariate(ctx, &types.GetMultivariateEventsOpts{Limit: &limit3, WithNestedMarkets: &nestedTrue}) + }) logCall("Orders.List (no opts)", func() { client.Orders.List(ctx, nil) }) logCall("Orders.List (limit=5)", func() { client.Orders.List(ctx, &types.GetOrdersOpts{Limit: &limit5}) }) - logCall("Orders.List (status=resting, limit=5)", func() { client.Orders.List(ctx, &types.GetOrdersOpts{Status: types.OrderStatusResting, Limit: &limit5}) }) - logCall("Orders.List (status=executed, limit=3)", func() { client.Orders.List(ctx, &types.GetOrdersOpts{Status: types.OrderStatusExecuted, Limit: &limit3}) }) + logCall("Orders.List (status=resting, limit=5)", func() { + client.Orders.List(ctx, &types.GetOrdersOpts{Status: types.OrderStatusResting, Limit: &limit5}) + }) + logCall("Orders.List (status=executed, limit=3)", func() { + client.Orders.List(ctx, &types.GetOrdersOpts{Status: types.OrderStatusExecuted, Limit: &limit3}) + }) var ordersResp *types.GetOrdersResponse logCall("Orders.List (limit=5, for follow-up)", func() { ordersResp, _ = client.Orders.List(ctx, &types.GetOrdersOpts{Limit: &limit5}) @@ -254,5 +262,3 @@ func runLiveOrder(ctx context.Context, client *oddrip.Client, log io.Writer) { _, _ = client.Orders.CancelV2(ctx, createResp2.OrderID, nil) fmt.Fprintf(log, "Orders.CancelV2 called on second order. First order (%s) remains resting.\n\n", createResp.OrderID) } - -func ptr[T any](v T) *T { return &v } diff --git a/cmd/example/private_key.pem b/cmd/example/private_key.pem deleted file mode 100644 index e69de29..0000000 diff --git a/cmd/example/websocket_example/main.go b/cmd/example/websocket_example/main.go index fbf6777..d25160b 100644 --- a/cmd/example/websocket_example/main.go +++ b/cmd/example/websocket_example/main.go @@ -50,9 +50,9 @@ func main() { if sig != "" && ts != "" { opts = append(opts, oddrip.Auth(&oddrip.StaticHeaders{ Headers: map[string][]string{ - "KALSHI-ACCESS-KEY": {keyID}, - "KALSHI-ACCESS-SIGNATURE": {sig}, - "KALSHI-ACCESS-TIMESTAMP": {ts}, + "KALSHI-ACCESS-KEY": {keyID}, + "KALSHI-ACCESS-SIGNATURE": {sig}, + "KALSHI-ACCESS-TIMESTAMP": {ts}, }, })) } diff --git a/example b/example deleted file mode 100755 index 14b82fe..0000000 Binary files a/example and /dev/null differ diff --git a/go.mod b/go.mod index 6b08594..7d50f39 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,4 @@ module github.com/UTXOnly/oddrip go 1.24 -require github.com/gorilla/websocket v1.5.3 // indirect +require github.com/gorilla/websocket v1.5.3 diff --git a/oddrip/client.go b/oddrip/client.go index 1171458..1560598 100644 --- a/oddrip/client.go +++ b/oddrip/client.go @@ -4,17 +4,15 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net/http" "net/url" - "path" "strings" "time" - "github.com/UTXOnly/oddrip/oddrip/internal/errors" "github.com/UTXOnly/oddrip/oddrip/internal/retry" - "github.com/UTXOnly/oddrip/oddrip/types" ) const defaultBaseURL = "https://api.elections.kalshi.com/trade-api/v2" @@ -32,13 +30,16 @@ type Client struct { auth AuthProvider retry retry.Config - Exchange *ExchangeService - Markets *MarketsService - Orders *OrdersService - Portfolio *PortfolioService - Account *AccountService - Events *EventsService - LiveData *LiveDataService + Exchange *ExchangeService + Markets *MarketsService + Orders *OrdersService + Portfolio *PortfolioService + Account *AccountService + Events *EventsService + LiveData *LiveDataService + Series *SeriesService + OrderGroups *OrderGroupsService + Subaccounts *SubaccountsService } type Option func(*Client) @@ -79,9 +80,9 @@ func New(opts ...Option) *Client { Transport: &http.Transport{ MaxIdleConns: 100, MaxIdleConnsPerHost: 10, - IdleConnTimeout: 90e9, + IdleConnTimeout: 90 * time.Second, }, - Timeout: 30e9, + Timeout: 30 * time.Second, }, retry: retry.DefaultConfig, } @@ -95,10 +96,29 @@ func New(opts ...Option) *Client { c.Account = &AccountService{client: c} c.Events = &EventsService{client: c} c.LiveData = &LiveDataService{client: c} + c.Series = &SeriesService{client: c} + c.OrderGroups = &OrderGroupsService{client: c} + c.Subaccounts = &SubaccountsService{client: c} return c } -func (c *Client) do(ctx context.Context, method, path string, query url.Values, body interface{}, out interface{}) error { +// ErrEmptyPathParam is returned when a path parameter (ticker, order ID, +// series ticker, ...) is empty or a "." / ".." segment. Dropping the segment +// would route the request to a different endpoint — an empty order ID would +// turn DELETE /portfolio/events/orders/{id} into CancelAll — so the request is +// refused before anything is sent. +var ErrEmptyPathParam = errors.New("oddrip: empty path parameter") + +// do sends one request. idempotent selects the retry policy: idempotent +// requests are retried on 429, 5xx, and transport errors; non-idempotent ones +// only on 429, because a 5xx or a dropped connection may mean the write was +// applied and a replay would apply it again. GET, PUT, and DELETE are +// idempotent by construction; POST is only when the body carries a +// deduplication key (see postIdempotent). +func (c *Client) do(ctx context.Context, method, path string, query url.Values, body interface{}, out interface{}, idempotent bool) error { + if path == "" { + return ErrEmptyPathParam + } var bodyBytes []byte if body != nil { var err error @@ -113,8 +133,7 @@ func (c *Client) do(ctx context.Context, method, path string, query url.Values, u += "?" + query.Encode() } - var resp *http.Response - resp, doErr := retry.Do(ctx, c.retry, func() (*http.Response, error) { + resp, err := retry.Do(ctx, c.retry, idempotent, func() (*http.Response, error) { var bodyReader io.Reader if len(bodyBytes) > 0 { bodyReader = bytes.NewReader(bodyBytes) @@ -134,23 +153,13 @@ func (c *Client) do(ctx context.Context, method, path string, query url.Values, } return c.httpClient.Do(req) }) - if doErr != nil { - return doErr + if err != nil { + return err } defer resp.Body.Close() - requestID := resp.Header.Get("Request-Id") if resp.StatusCode < 200 || resp.StatusCode >= 300 { - apiErr := errors.ParseResponseError(resp.StatusCode, resp.Body, requestID) - dec := json.NewDecoder(strings.NewReader(apiErr.RawBody)) - var er types.ErrorResponse - if dec.Decode(&er) == nil { - apiErr.Code = er.Code - apiErr.Message = er.Message - apiErr.Details = er.Details - apiErr.Service = er.Service - } - return wrapAPIError(apiErr) + return newAPIError(resp) } if out != nil { @@ -162,23 +171,31 @@ func (c *Client) do(ctx context.Context, method, path string, query url.Values, } func (c *Client) get(ctx context.Context, path string, query url.Values, out interface{}) error { - return c.do(ctx, http.MethodGet, path, query, nil, out) + return c.do(ctx, http.MethodGet, path, query, nil, out, true) } +// post is for writes that are not safe to replay (amend, decrease, create +// without a deduplication key): retried on 429 only. func (c *Client) post(ctx context.Context, path string, body interface{}, out interface{}) error { return c.postQuery(ctx, path, nil, body, out) } func (c *Client) postQuery(ctx context.Context, path string, query url.Values, body interface{}, out interface{}) error { - return c.do(ctx, http.MethodPost, path, query, body, out) + return c.do(ctx, http.MethodPost, path, query, body, out, false) } -func (c *Client) put(ctx context.Context, path string, body interface{}, out interface{}) error { - return c.do(ctx, http.MethodPut, path, nil, body, out) +// postIdempotent is for POSTs the server deduplicates (client_order_id, +// client_transfer_id) or that set absolute state: full retry policy. +func (c *Client) postIdempotent(ctx context.Context, path string, body interface{}, out interface{}) error { + return c.do(ctx, http.MethodPost, path, nil, body, out, true) +} + +func (c *Client) put(ctx context.Context, path string, query url.Values, body interface{}, out interface{}) error { + return c.do(ctx, http.MethodPut, path, query, body, out, true) } func (c *Client) delete(ctx context.Context, path string, query url.Values, body interface{}, out interface{}) error { - return c.do(ctx, http.MethodDelete, path, query, body, out) + return c.do(ctx, http.MethodDelete, path, query, body, out, true) } func encodeQuery(v url.Values, key string, value string) { @@ -213,6 +230,18 @@ func encodeQueryStrings(v url.Values, key string, values []string) { } } +// joinPath builds a request path from literal and parameter segments. Each +// segment is path-escaped so a value containing "/" stays a single segment. +// It returns "" when any segment is empty, ".", or "..", and do() rejects that +// with ErrEmptyPathParam. func joinPath(elem ...string) string { - return "/" + path.Join(elem...) + var b strings.Builder + for _, e := range elem { + if e == "" || e == "." || e == ".." { + return "" + } + b.WriteByte('/') + b.WriteString(url.PathEscape(e)) + } + return b.String() } diff --git a/oddrip/client_test.go b/oddrip/client_test.go index 058c972..64a13f6 100644 --- a/oddrip/client_test.go +++ b/oddrip/client_test.go @@ -6,7 +6,10 @@ import ( "errors" "io" "net/http" + "net/http/httptest" + "sync/atomic" "testing" + "time" "github.com/UTXOnly/oddrip/oddrip/types" ) @@ -682,3 +685,214 @@ func TestLiveData_GetEvent_RequestPathAndQuery(t *testing.T) { t.Fatalf("live_data: %+v", got.LiveData) } } + +func TestClient_RetryExhaustedReturnsAPIError(t *testing.T) { + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + w.Header().Set("Retry-After", "0") + w.WriteHeader(http.StatusTooManyRequests) + w.Write([]byte(`{"code":"rate_limited","message":"slow down"}`)) + })) + defer srv.Close() + client := New(BaseURL(srv.URL), RetryConfigOption(RetryConfig{ + MaxAttempts: 2, + InitialDelay: time.Millisecond, + MaxDelay: time.Millisecond, + })) + + _, err := client.Exchange.GetStatus(context.Background()) + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected *APIError, got %T: %v", err, err) + } + if apiErr.StatusCode != 429 || apiErr.Code != "rate_limited" || apiErr.Message != "slow down" { + t.Fatalf("APIError: %+v", apiErr) + } + if n := atomic.LoadInt32(&calls); n != 2 { + t.Fatalf("calls: %d", n) + } +} + +func TestClient_RetryWaitHonorsContext(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Retry-After", "5") + w.WriteHeader(http.StatusServiceUnavailable) + })) + defer srv.Close() + client := New(BaseURL(srv.URL)) + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + start := time.Now() + _, err := client.Exchange.GetStatus(ctx) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("err: %v", err) + } + if elapsed := time.Since(start); elapsed > 200*time.Millisecond { + t.Fatalf("took %v", elapsed) + } +} + +// path.Join would drop an empty segment and route the call to a different +// endpoint; the worst case is CancelV2 with an empty order ID becoming +// DELETE /portfolio/events/orders, which is CancelAll. +func TestEmptyPathParam_Refused(t *testing.T) { + client, ct := newCaptureClient(200, `{}`) + ctx := context.Background() + calls := map[string]func() error{ + "Orders.CancelV2": func() error { _, err := client.Orders.CancelV2(ctx, "", nil); return err }, + "Orders.Get": func() error { _, err := client.Orders.Get(ctx, ""); return err }, + "Markets.Get": func() error { _, err := client.Markets.Get(ctx, ""); return err }, + "Events.Get": func() error { _, err := client.Events.Get(ctx, "", nil); return err }, + "Series.Get": func() error { _, err := client.Series.Get(ctx, "", nil); return err }, + "OrderGroups.Delete": func() error { return client.OrderGroups.Delete(ctx, "", nil) }, + "Series.GetMarketCandlesticks(dotdot)": func() error { + _, err := client.Series.GetMarketCandlesticks(ctx, "KXHIGHNY", "..", &types.GetMarketCandlesticksOpts{StartTs: 1, EndTs: 2, PeriodInterval: 60}) + return err + }, + } + for name, call := range calls { + ct.req = nil + err := call() + if !errors.Is(err, ErrEmptyPathParam) { + t.Errorf("%s: err = %v, want ErrEmptyPathParam", name, err) + } + if ct.req != nil { + t.Errorf("%s: request was sent: %s %s", name, ct.req.Method, ct.req.URL.Path) + } + } +} + +func TestJoinPath_EscapesSegments(t *testing.T) { + if got := joinPath("markets", "FED-23DEC-T3.00", "orderbook"); got != "/markets/FED-23DEC-T3.00/orderbook" { + t.Errorf("joinPath = %q", got) + } + if got := joinPath("live_data", "weather", "a/b c"); got != "/live_data/weather/a%2Fb%20c" { + t.Errorf("joinPath escaped = %q", got) + } +} + +// countingServer answers every request with status until the caller's ctx +// ends, recording how many attempts the client made. +func countingServer(t *testing.T, status int) (*Client, *int32) { + t.Helper() + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + w.WriteHeader(status) + w.Write([]byte(`{"code":"boom","message":"boom"}`)) + })) + t.Cleanup(srv.Close) + client := New(BaseURL(srv.URL), RetryConfigOption(RetryConfig{ + MaxAttempts: 3, InitialDelay: time.Millisecond, MaxDelay: time.Millisecond, + })) + return client, &calls +} + +// Writes that Kalshi cannot deduplicate must not be replayed on an ambiguous +// failure; writes it can (or reads) keep the full retry policy. +func TestRetryPolicy_ByIdempotency(t *testing.T) { + ctx := context.Background() + order := func(clientOrderID string) *types.CreateOrderV2Request { + return &types.CreateOrderV2Request{Ticker: "T", ClientOrderID: clientOrderID, Side: "yes", Count: "1", Price: "0.50"} + } + cases := []struct { + name string + status int + call func(*Client) error + wantCalls int32 + }{ + {"GET 503 retried", 503, func(c *Client) error { _, err := c.Exchange.GetStatus(ctx); return err }, 3}, + {"DELETE cancel 503 retried", 503, func(c *Client) error { _, err := c.Orders.CancelV2(ctx, "o1", nil); return err }, 3}, + {"PUT reset 503 retried", 503, func(c *Client) error { return c.OrderGroups.Reset(ctx, "g1", nil) }, 3}, + {"create with client_order_id 503 retried", 503, func(c *Client) error { _, err := c.Orders.CreateV2(ctx, order("cid-1")); return err }, 3}, + {"create without client_order_id 503 NOT retried", 503, func(c *Client) error { _, err := c.Orders.CreateV2(ctx, order("")); return err }, 1}, + {"create without client_order_id 429 retried", 429, func(c *Client) error { _, err := c.Orders.CreateV2(ctx, order("")); return err }, 3}, + {"decrease 502 NOT retried", 502, func(c *Client) error { + rb := "1" + _, err := c.Orders.DecreaseV2(ctx, "o1", &types.DecreaseOrderV2Request{ReduceBy: &rb}, nil) + return err + }, 1}, + {"amend 500 NOT retried", 500, func(c *Client) error { + _, err := c.Orders.AmendV2(ctx, "o1", &types.AmendOrderV2Request{}, nil) + return err + }, 1}, + {"batch create all keyed 503 retried", 503, func(c *Client) error { + _, err := c.Orders.BatchCreateV2(ctx, &types.BatchCreateOrdersV2Request{Orders: []types.CreateOrderV2Request{*order("a"), *order("b")}}) + return err + }, 3}, + {"batch create one unkeyed 503 NOT retried", 503, func(c *Client) error { + _, err := c.Orders.BatchCreateV2(ctx, &types.BatchCreateOrdersV2Request{Orders: []types.CreateOrderV2Request{*order("a"), *order("")}}) + return err + }, 1}, + {"transfer 503 retried", 503, func(c *Client) error { + return c.Subaccounts.Transfer(ctx, &types.ApplySubaccountTransferRequest{ClientTransferID: "t1", FromSubaccount: 0, ToSubaccount: 1, AmountCents: 100}) + }, 3}, + {"create order group 503 NOT retried", 503, func(c *Client) error { + lim := int64(10) + _, err := c.OrderGroups.Create(ctx, &types.CreateOrderGroupRequest{ContractsLimit: &lim}) + return err + }, 1}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + client, calls := countingServer(t, tc.status) + err := tc.call(client) + var apiErr *APIError + if !errors.As(err, &apiErr) || apiErr.StatusCode != tc.status { + t.Fatalf("err = %v, want *APIError %d", err, tc.status) + } + if got := atomic.LoadInt32(calls); got != tc.wantCalls { + t.Fatalf("attempts = %d, want %d", got, tc.wantCalls) + } + }) + } +} + +// A dropped connection with no response is the replay case that matters most. +func TestRetryPolicy_TransportError(t *testing.T) { + ctx := context.Background() + newClient := func(t *testing.T) (*Client, *int32) { + t.Helper() + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + hj, ok := w.(http.Hijacker) + if !ok { + t.Fatal("no hijacker") + } + conn, _, err := hj.Hijack() + if err != nil { + t.Fatal(err) + } + conn.Close() // reset with no response + })) + t.Cleanup(srv.Close) + return New(BaseURL(srv.URL), RetryConfigOption(RetryConfig{ + MaxAttempts: 3, InitialDelay: time.Millisecond, MaxDelay: time.Millisecond, + })), &calls + } + + client, calls := newClient(t) + if _, err := client.Exchange.GetStatus(ctx); err == nil { + t.Fatal("expected transport error") + } + if got := atomic.LoadInt32(calls); got != 3 { + t.Fatalf("GET attempts = %d, want 3", got) + } + + client, calls = newClient(t) + rb := "1" + _, err := client.Orders.DecreaseV2(ctx, "o1", &types.DecreaseOrderV2Request{ReduceBy: &rb}, nil) + if err == nil { + t.Fatal("expected transport error") + } + var apiErr *APIError + if errors.As(err, &apiErr) { + t.Fatalf("transport error must not be an *APIError: %v", err) + } + if got := atomic.LoadInt32(calls); got != 1 { + t.Fatalf("DecreaseV2 attempts = %d, want 1 (no replay)", got) + } +} diff --git a/oddrip/concurrent.go b/oddrip/concurrent.go index 74837bd..c4cada3 100644 --- a/oddrip/concurrent.go +++ b/oddrip/concurrent.go @@ -7,15 +7,30 @@ type ConcurrentResult[T any] struct { Err error } -func DoConcurrent[T any](ctx context.Context, n int, fn func(i int) (T, error)) ([]ConcurrentResult[T], error) { +// DoConcurrent runs fn for i in [0, n) with at most maxInFlight calls active at +// once (maxInFlight <= 0 means unbounded). Results are index-ordered. If ctx is +// cancelled, the results collected so far are returned along with ctx.Err(). +func DoConcurrent[T any](ctx context.Context, n, maxInFlight int, fn func(i int) (T, error)) ([]ConcurrentResult[T], error) { results := make([]ConcurrentResult[T], n) type pair struct { i int r ConcurrentResult[T] } ch := make(chan pair, n) + var sem chan struct{} + if maxInFlight > 0 { + sem = make(chan struct{}, maxInFlight) + } for i := 0; i < n; i++ { go func(idx int) { + if sem != nil { + select { + case sem <- struct{}{}: + defer func() { <-sem }() + case <-ctx.Done(): + return + } + } val, err := fn(idx) ch <- pair{idx, ConcurrentResult[T]{Value: val, Err: err}} }(i) diff --git a/oddrip/concurrent_test.go b/oddrip/concurrent_test.go new file mode 100644 index 0000000..b9647cf --- /dev/null +++ b/oddrip/concurrent_test.go @@ -0,0 +1,149 @@ +package oddrip + +import ( + "context" + "errors" + "runtime" + "sync/atomic" + "testing" + "time" +) + +func TestDoConcurrentBounded(t *testing.T) { + const n, limit = 20, 3 + var inFlight, peak atomic.Int32 + results, err := DoConcurrent(context.Background(), n, limit, func(i int) (int, error) { + cur := inFlight.Add(1) + defer inFlight.Add(-1) + for p := peak.Load(); cur > p && !peak.CompareAndSwap(p, cur); p = peak.Load() { + } + time.Sleep(10 * time.Millisecond) + return i, nil + }) + if err != nil { + t.Fatal(err) + } + if len(results) != n { + t.Fatalf("len(results) = %d, want %d", len(results), n) + } + if got := peak.Load(); got != limit { + t.Fatalf("peak in-flight = %d, want %d", got, limit) + } +} + +func TestDoConcurrentUnbounded(t *testing.T) { + for _, limit := range []int{0, -1} { + const n = 8 + var arrived atomic.Int32 + start := make(chan struct{}) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + _, err := DoConcurrent(ctx, n, limit, func(i int) (int, error) { + if arrived.Add(1) == n { + close(start) + } + select { + case <-start: + return i, nil + case <-ctx.Done(): + return 0, ctx.Err() + } + }) + cancel() + if err != nil { + t.Fatalf("maxInFlight=%d: all %d calls should run at once: %v", limit, n, err) + } + } +} + +func TestDoConcurrentOrderAndErrors(t *testing.T) { + const n = 10 + errOdd := errors.New("odd") + results, err := DoConcurrent(context.Background(), n, 4, func(i int) (int, error) { + time.Sleep(time.Duration(n-i) * time.Millisecond) + if i%2 == 1 { + return 0, errOdd + } + return i * i, nil + }) + if err != nil { + t.Fatal(err) + } + if len(results) != n { + t.Fatalf("len(results) = %d, want %d", len(results), n) + } + for i, r := range results { + if i%2 == 1 { + if !errors.Is(r.Err, errOdd) { + t.Errorf("results[%d].Err = %v, want %v", i, r.Err, errOdd) + } + continue + } + if r.Err != nil || r.Value != i*i { + t.Errorf("results[%d] = {%d %v}, want {%d nil}", i, r.Value, r.Err, i*i) + } + } +} + +func TestDoConcurrentCancel(t *testing.T) { + before := runtime.NumGoroutine() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + const n, fast = 50, 3 + var entered atomic.Int32 + type out struct { + results []ConcurrentResult[int] + err error + } + done := make(chan out, 1) + go func() { + r, err := DoConcurrent(ctx, n, 4, func(i int) (int, error) { + if entered.Add(1) <= fast { + return i + 1, nil + } + <-ctx.Done() + return 0, ctx.Err() + }) + done <- out{r, err} + }() + + for entered.Load() <= fast { + time.Sleep(time.Millisecond) + } + time.Sleep(20 * time.Millisecond) + cancel() + + var got out + select { + case got = <-done: + case <-time.After(2 * time.Second): + t.Fatal("DoConcurrent did not return after cancel") + } + if !errors.Is(got.err, context.Canceled) { + t.Fatalf("err = %v, want context.Canceled", got.err) + } + if len(got.results) != n { + t.Fatalf("len(results) = %d, want %d", len(got.results), n) + } + filled := 0 + for i, r := range got.results { + if r.Value == 0 { + continue + } + filled++ + if r.Err != nil || r.Value != i+1 { + t.Errorf("results[%d] = {%d %v}, want {%d nil}", i, r.Value, r.Err, i+1) + } + } + if filled != fast { + t.Errorf("partial results = %d, want %d", filled, fast) + } + + deadline := time.Now().Add(2 * time.Second) + for runtime.NumGoroutine() > before+2 { + if time.Now().After(deadline) { + t.Fatalf("goroutines leaked: before=%d after=%d", before, runtime.NumGoroutine()) + } + time.Sleep(5 * time.Millisecond) + } +} diff --git a/oddrip/errors.go b/oddrip/errors.go index 2c2a536..b973d89 100644 --- a/oddrip/errors.go +++ b/oddrip/errors.go @@ -1,11 +1,17 @@ package oddrip import ( + "bytes" + "encoding/json" "fmt" + "io" + "net/http" - "github.com/UTXOnly/oddrip/oddrip/internal/errors" + "github.com/UTXOnly/oddrip/oddrip/types" ) +const maxBodySnippet = 512 + type APIError struct { StatusCode int Code string @@ -23,17 +29,16 @@ func (e *APIError) Error() string { return fmt.Sprintf("api error %d", e.StatusCode) } -func wrapAPIError(e *errors.APIError) *APIError { - if e == nil { - return nil - } - return &APIError{ - StatusCode: e.StatusCode, - Code: e.Code, - Message: e.Message, - Details: e.Details, - Service: e.Service, - RequestID: e.RequestID, - RawBody: e.RawBody, +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 } + return e } diff --git a/oddrip/events.go b/oddrip/events.go index 259c578..9964aaf 100644 --- a/oddrip/events.go +++ b/oddrip/events.go @@ -2,6 +2,7 @@ package oddrip import ( "context" + "errors" "net/url" "github.com/UTXOnly/oddrip/oddrip/types" @@ -66,3 +67,41 @@ func (s *EventsService) GetMetadata(ctx context.Context, eventTicker string) (*t } return &out, nil } + +func (s *EventsService) ListMultivariateCollections(ctx context.Context, opts *types.GetMultivariateEventCollectionsOpts) (*types.GetMultivariateEventCollectionsResponse, error) { + v := url.Values{} + if opts != nil { + encodeQuery(v, "status", opts.Status) + encodeQuery(v, "associated_event_ticker", opts.AssociatedEventTicker) + encodeQuery(v, "series_ticker", opts.SeriesTicker) + encodeQueryInt64(v, "limit", opts.Limit) + encodeQuery(v, "cursor", opts.Cursor) + } + var out types.GetMultivariateEventCollectionsResponse + if err := s.client.get(ctx, joinPath("multivariate_event_collections"), v, &out); err != nil { + return nil, err + } + return &out, nil +} + +func (s *EventsService) GetMultivariateCollection(ctx context.Context, collectionTicker string) (*types.GetMultivariateEventCollectionResponse, error) { + var out types.GetMultivariateEventCollectionResponse + if err := s.client.get(ctx, joinPath("multivariate_event_collections", collectionTicker), nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// CreateMarketInMultivariateCollection creates (or ensures) the combination +// market selected by req.SelectedMarkets. Must be called before trading or +// looking up such a market; limited to 5000 creations per week. +func (s *EventsService) CreateMarketInMultivariateCollection(ctx context.Context, collectionTicker string, req *types.CreateMarketInMultivariateEventCollectionRequest) (*types.CreateMarketInMultivariateEventCollectionResponse, error) { + if req == nil || len(req.SelectedMarkets) == 0 { + return nil, errors.New("selected_markets required") + } + var out types.CreateMarketInMultivariateEventCollectionResponse + if err := s.client.post(ctx, joinPath("multivariate_event_collections", collectionTicker), req, &out); err != nil { + return nil, err + } + return &out, nil +} diff --git a/oddrip/internal/auth/auth.go b/oddrip/internal/auth/auth.go index 567bfda..7553f03 100644 --- a/oddrip/internal/auth/auth.go +++ b/oddrip/internal/auth/auth.go @@ -10,26 +10,8 @@ type Provider interface { Apply(req *http.Request) error } -type StaticHeaders struct { - Headers http.Header -} - -func (s *StaticHeaders) Apply(req *http.Request) error { - for k, v := range s.Headers { - req.Header[k] = v - } - return nil -} - -type BearerToken string - -func (b BearerToken) Apply(req *http.Request) error { - req.Header.Set("Authorization", "Bearer "+string(b)) - return nil -} - type KalshiSigner struct { - KeyID string + KeyID string SignRequest func(method, path string, timestamp int64) (signature string, err error) } diff --git a/oddrip/internal/auth/signer_test.go b/oddrip/internal/auth/signer_test.go new file mode 100644 index 0000000..575ab6d --- /dev/null +++ b/oddrip/internal/auth/signer_test.go @@ -0,0 +1,137 @@ +package auth + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "errors" + "net/http" + "strconv" + "strings" + "sync" + "testing" + "time" +) + +var testKey = sync.OnceValues(func() (*rsa.PrivateKey, error) { + return rsa.GenerateKey(rand.Reader, 2048) +}) + +func TestKalshiRSAPSSSignerApply(t *testing.T) { + key, err := testKey() + if err != nil { + t.Fatal(err) + } + req, err := http.NewRequest(http.MethodGet, "https://api.example.com/trade-api/v2/markets?limit=5", nil) + if err != nil { + t.Fatal(err) + } + if err := NewKalshiRSAPSSSigner("key-id", key).Apply(req); err != nil { + t.Fatal(err) + } + + if got := req.Header.Get("KALSHI-ACCESS-KEY"); got != "key-id" { + t.Errorf("KALSHI-ACCESS-KEY = %q, want %q", got, "key-id") + } + tsHeader := req.Header.Get("KALSHI-ACCESS-TIMESTAMP") + ts, err := strconv.ParseInt(tsHeader, 10, 64) + if err != nil { + t.Fatalf("KALSHI-ACCESS-TIMESTAMP = %q: %v", tsHeader, err) + } + if drift := time.Now().UnixMilli() - ts; drift < -1000 || drift > 60_000 { + t.Errorf("timestamp %d is not recent milliseconds (drift %dms)", ts, drift) + } + sig, err := base64.StdEncoding.DecodeString(req.Header.Get("KALSHI-ACCESS-SIGNATURE")) + if err != nil { + t.Fatalf("KALSHI-ACCESS-SIGNATURE not base64: %v", err) + } + + opts := &rsa.PSSOptions{SaltLength: rsa.PSSSaltLengthEqualsHash} + if req.URL.Path != "/trade-api/v2/markets" { + t.Fatalf("req.URL.Path = %q", req.URL.Path) + } + h := sha256.Sum256([]byte(tsHeader + http.MethodGet + req.URL.Path)) + if err := rsa.VerifyPSS(&key.PublicKey, crypto.SHA256, h[:], sig, opts); err != nil { + t.Fatalf("signature over timestamp+method+path did not verify: %v", err) + } + h = sha256.Sum256([]byte(tsHeader + http.MethodGet + req.URL.RequestURI())) + if err := rsa.VerifyPSS(&key.PublicKey, crypto.SHA256, h[:], sig, opts); err == nil { + t.Fatal("signature verifies over path with query string; query must be excluded") + } +} + +func TestKalshiSignerApplyError(t *testing.T) { + errSign := errors.New("sign failed") + s := &KalshiSigner{KeyID: "k", SignRequest: func(string, string, int64) (string, error) { + return "", errSign + }} + req, err := http.NewRequest(http.MethodGet, "https://api.example.com/x", nil) + if err != nil { + t.Fatal(err) + } + if err := s.Apply(req); !errors.Is(err, errSign) { + t.Fatalf("err = %v, want %v", err, errSign) + } + if req.Header.Get("KALSHI-ACCESS-KEY") != "" { + t.Error("headers set despite signing error") + } +} + +func TestParsePrivateKeyFromPEM(t *testing.T) { + key, err := testKey() + if err != nil { + t.Fatal(err) + } + pkcs8, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name, typ string + der []byte + }{ + {"pkcs8", "PRIVATE KEY", pkcs8}, + {"pkcs1", "RSA PRIVATE KEY", x509.MarshalPKCS1PrivateKey(key)}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := ParsePrivateKeyFromPEM(pem.EncodeToMemory(&pem.Block{Type: tc.typ, Bytes: tc.der})) + if err != nil { + t.Fatal(err) + } + if !got.Equal(key) { + t.Fatal("parsed key differs from original") + } + }) + } + + t.Run("garbage", func(t *testing.T) { + if _, err := ParsePrivateKeyFromPEM([]byte("not a pem")); err == nil { + t.Fatal("expected error for non-PEM input") + } + junk := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: []byte("garbage")}) + if _, err := ParsePrivateKeyFromPEM(junk); err == nil { + t.Fatal("expected error for PEM with garbage body") + } + }) + + t.Run("ecdsa", func(t *testing.T) { + ec, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + der, err := x509.MarshalPKCS8PrivateKey(ec) + if err != nil { + t.Fatal(err) + } + _, err = ParsePrivateKeyFromPEM(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})) + if err == nil || !strings.Contains(err.Error(), "not an RSA private key") { + t.Fatalf("err = %v, want \"not an RSA private key\"", err) + } + }) +} diff --git a/oddrip/internal/errors/errors.go b/oddrip/internal/errors/errors.go deleted file mode 100644 index 0b03eb8..0000000 --- a/oddrip/internal/errors/errors.go +++ /dev/null @@ -1,53 +0,0 @@ -package errors - -import ( - "fmt" - "io" -) - -const maxBodySnippet = 512 - -type APIError struct { - StatusCode int - Code string - Message string - Details string - Service string - RequestID string - RawBody string -} - -func (e *APIError) Error() string { - if e.Message != "" { - return fmt.Sprintf("api error %d: %s", e.StatusCode, e.Message) - } - return fmt.Sprintf("api error %d", e.StatusCode) -} - -func ParseResponseError(statusCode int, body io.Reader, requestID string) *APIError { - err := &APIError{StatusCode: statusCode, RequestID: requestID} - if body == nil { - return err - } - buf, _ := io.ReadAll(io.LimitReader(body, maxBodySnippet)) - if len(buf) > 0 { - err.RawBody = string(buf) - } - return err -} - -func ParseJSONError(statusCode int, code, message, details, service, requestID string, rawBody string) *APIError { - return &APIError{ - StatusCode: statusCode, - Code: code, - Message: message, - Details: details, - Service: service, - RequestID: requestID, - RawBody: rawBody, - } -} - -func IsRetryable(statusCode int) bool { - return statusCode == 429 || (statusCode >= 500 && statusCode < 600) -} diff --git a/oddrip/internal/retry/retry.go b/oddrip/internal/retry/retry.go index 64080a5..64d2019 100644 --- a/oddrip/internal/retry/retry.go +++ b/oddrip/internal/retry/retry.go @@ -2,13 +2,12 @@ package retry import ( "context" + "io" "math" "math/rand" "net/http" "strconv" "time" - - "github.com/UTXOnly/oddrip/oddrip/internal/errors" ) type Config struct { @@ -46,37 +45,80 @@ func (c Config) Delay(attempt int, retryAfter time.Duration) time.Duration { return d } -func Do(ctx context.Context, cfg Config, fn func() (*http.Response, error)) (*http.Response, error) { - var lastErr error - for attempt := 0; attempt < cfg.MaxAttempts; attempt++ { +// IsRetryable reports whether a status should be retried. 429 always is: the +// server rejected the request before acting on it. 5xx is ambiguous — a +// gateway timeout or a handler error after the write committed both look the +// same to the client — so it is retried only for idempotent requests. +func IsRetryable(statusCode int, idempotent bool) bool { + if statusCode == 429 { + return true + } + return idempotent && statusCode >= 500 && statusCode < 600 +} + +// Do never returns (nil, nil). When every attempt yields a retryable status +// the last response is returned with its body open so the caller can parse it. +// +// Transport errors (connection failures, resets, client timeouts) are retried +// only when idempotent is true: the server may have applied the request even +// though no response arrived, and replaying a non-idempotent write would apply +// it twice. +func Do(ctx context.Context, cfg Config, idempotent bool, fn func() (*http.Response, error)) (*http.Response, error) { + attempts := cfg.MaxAttempts + if attempts < 1 { + attempts = 1 + } + for attempt := 0; ; attempt++ { resp, err := fn() + last := attempt == attempts-1 + var retryAfter time.Duration if err != nil { - lastErr = err if ctx.Err() != nil { return nil, ctx.Err() } - if attempt < cfg.MaxAttempts-1 { - time.Sleep(cfg.Delay(attempt, 0)) + if last || !idempotent { + return nil, err } - continue - } - if resp.StatusCode < 400 || !errors.IsRetryable(resp.StatusCode) { - return resp, nil - } - var retryAfter time.Duration - if s := resp.Header.Get("Retry-After"); s != "" { - if sec, err := strconv.Atoi(s); err == nil { - retryAfter = time.Duration(sec) * time.Second + } else { + if last || !IsRetryable(resp.StatusCode, idempotent) { + return resp, nil } + retryAfter = parseRetryAfter(resp.Header.Get("Retry-After")) + // Drain a bounded amount so the connection can be reused for the retry. + io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10)) + resp.Body.Close() } - resp.Body.Close() - if attempt == cfg.MaxAttempts-1 { - return nil, lastErr + if err := wait(ctx, cfg.Delay(attempt, retryAfter)); err != nil { + return nil, err } - if ctx.Err() != nil { - return nil, ctx.Err() + } +} + +func parseRetryAfter(s string) time.Duration { + if s == "" { + return 0 + } + if sec, err := strconv.Atoi(s); err == nil { + return time.Duration(sec) * time.Second + } + if t, err := http.ParseTime(s); err == nil { + if d := time.Until(t); d > 0 { + return d } - time.Sleep(cfg.Delay(attempt, retryAfter)) } - return nil, lastErr + return 0 +} + +func wait(ctx context.Context, d time.Duration) error { + if err := ctx.Err(); err != nil { + return err + } + t := time.NewTimer(d) + defer t.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-t.C: + return nil + } } diff --git a/oddrip/internal/retry/retry_test.go b/oddrip/internal/retry/retry_test.go new file mode 100644 index 0000000..ac7898e --- /dev/null +++ b/oddrip/internal/retry/retry_test.go @@ -0,0 +1,276 @@ +package retry + +import ( + "context" + "errors" + "io" + "net/http" + "strings" + "testing" + "time" +) + +var fastConfig = Config{MaxAttempts: 3, InitialDelay: time.Millisecond, MaxDelay: 2 * time.Millisecond} + +type body struct { + io.Reader + closed bool +} + +func (b *body) Close() error { + b.closed = true + return nil +} + +func newResp(status int, text string, header http.Header) *http.Response { + if header == nil { + header = make(http.Header) + } + return &http.Response{StatusCode: status, Header: header, Body: &body{Reader: strings.NewReader(text)}} +} + +func readBody(t *testing.T, resp *http.Response) string { + t.Helper() + b, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + return string(b) +} + +func TestDo_ExhaustedReturnsLastResponse(t *testing.T) { + var calls int + var bodies []*body + resp, err := Do(context.Background(), fastConfig, true, func() (*http.Response, error) { + calls++ + r := newResp(429, "rate limited", nil) + bodies = append(bodies, r.Body.(*body)) + return r, nil + }) + if err != nil { + t.Fatalf("err: %v", err) + } + if resp == nil || resp.StatusCode != 429 { + t.Fatalf("resp: %+v", resp) + } + if got := readBody(t, resp); got != "rate limited" { + t.Fatalf("body: %q", got) + } + if calls != fastConfig.MaxAttempts { + t.Fatalf("calls: %d", calls) + } + for i, b := range bodies[:len(bodies)-1] { + if !b.closed { + t.Fatalf("attempt %d body not closed", i) + } + } + if bodies[len(bodies)-1].closed { + t.Fatal("final body closed") + } +} + +func TestDo_TransportErrorThenSuccess(t *testing.T) { + var calls int + resp, err := Do(context.Background(), fastConfig, true, func() (*http.Response, error) { + calls++ + if calls < fastConfig.MaxAttempts { + return nil, errors.New("dial") + } + return newResp(200, "ok", nil), nil + }) + if err != nil { + t.Fatalf("err: %v", err) + } + if resp.StatusCode != 200 || readBody(t, resp) != "ok" { + t.Fatalf("resp: %+v", resp) + } + if calls != fastConfig.MaxAttempts { + t.Fatalf("calls: %d", calls) + } +} + +func TestDo_TransportErrorExhausted(t *testing.T) { + sentinel := errors.New("dial") + var calls int + resp, err := Do(context.Background(), fastConfig, true, func() (*http.Response, error) { + calls++ + return nil, sentinel + }) + if !errors.Is(err, sentinel) { + t.Fatalf("err: %v", err) + } + if resp != nil { + t.Fatalf("resp: %+v", resp) + } + if calls != fastConfig.MaxAttempts { + t.Fatalf("calls: %d", calls) + } +} + +func TestDo_NonRetryableReturnedImmediately(t *testing.T) { + var calls int + resp, err := Do(context.Background(), fastConfig, true, func() (*http.Response, error) { + calls++ + return newResp(400, "bad request", nil), nil + }) + if err != nil { + t.Fatalf("err: %v", err) + } + if resp.StatusCode != 400 || readBody(t, resp) != "bad request" { + t.Fatalf("resp: %+v", resp) + } + if calls != 1 { + t.Fatalf("calls: %d", calls) + } +} + +func TestDo_ContextCancelledWhileWaiting(t *testing.T) { + cfg := Config{MaxAttempts: 3, InitialDelay: time.Second, MaxDelay: 30 * time.Second} + h := http.Header{"Retry-After": []string{"5"}} + + t.Run("deadline", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + start := time.Now() + resp, err := Do(ctx, cfg, true, func() (*http.Response, error) { + return newResp(503, "", h), nil + }) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("err: %v", err) + } + if resp != nil { + t.Fatalf("resp: %+v", resp) + } + if elapsed := time.Since(start); elapsed > 200*time.Millisecond { + t.Fatalf("took %v", elapsed) + } + }) + + t.Run("cancel", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + var calls int + start := time.Now() + _, err := Do(ctx, cfg, true, func() (*http.Response, error) { + calls++ + cancel() + return newResp(503, "", h), nil + }) + if !errors.Is(err, context.Canceled) { + t.Fatalf("err: %v", err) + } + if calls != 1 { + t.Fatalf("calls: %d", calls) + } + if elapsed := time.Since(start); elapsed > 200*time.Millisecond { + t.Fatalf("took %v", elapsed) + } + }) +} + +func TestParseRetryAfter(t *testing.T) { + if d := parseRetryAfter("2"); d != 2*time.Second { + t.Fatalf("seconds: %v", d) + } + future := time.Now().Add(2 * time.Second).UTC().Format(http.TimeFormat) + if d := parseRetryAfter(future); d <= 500*time.Millisecond || d > 2*time.Second { + t.Fatalf("http-date: %v", d) + } + past := time.Now().Add(-time.Minute).UTC().Format(http.TimeFormat) + if d := parseRetryAfter(past); d != 0 { + t.Fatalf("past http-date: %v", d) + } + for _, s := range []string{"", "soon", "-3"} { + if d := parseRetryAfter(s); d > 0 { + t.Fatalf("%q: %v", s, d) + } + } +} + +func TestDelay_RetryAfter(t *testing.T) { + cfg := Config{InitialDelay: time.Millisecond, MaxDelay: 30 * time.Second} + if d := cfg.Delay(0, 2*time.Second); d != 2*time.Second { + t.Fatalf("delay: %v", d) + } + cfg.MaxDelay = time.Second + if d := cfg.Delay(0, 2*time.Second); d != time.Second { + t.Fatalf("clamped delay: %v", d) + } +} + +func TestDo_ZeroMaxAttempts(t *testing.T) { + cfg := Config{MaxAttempts: 0, InitialDelay: time.Millisecond, MaxDelay: time.Millisecond} + var calls int + resp, err := Do(context.Background(), cfg, true, func() (*http.Response, error) { + calls++ + return newResp(429, "rate limited", nil), nil + }) + if err != nil { + t.Fatalf("err: %v", err) + } + if resp == nil || resp.StatusCode != 429 || readBody(t, resp) != "rate limited" { + t.Fatalf("resp: %+v", resp) + } + if calls != 1 { + t.Fatalf("calls: %d", calls) + } +} + +// A non-idempotent request must not be replayed after a transport error: the +// server may have applied it even though no response arrived. +func TestDo_NonIdempotent_TransportErrorNotRetried(t *testing.T) { + var calls int + want := errors.New("connection reset") + _, err := Do(context.Background(), fastConfig, false, func() (*http.Response, error) { + calls++ + return nil, want + }) + if !errors.Is(err, want) || calls != 1 { + t.Fatalf("err=%v calls=%d", err, calls) + } +} + +func TestDo_NonIdempotent_5xxNotRetried(t *testing.T) { + var calls int + resp, err := Do(context.Background(), fastConfig, false, func() (*http.Response, error) { + calls++ + return newResp(504, "gateway timeout", nil), nil + }) + if err != nil || resp.StatusCode != 504 || calls != 1 { + t.Fatalf("err=%v status=%d calls=%d", err, resp.StatusCode, calls) + } +} + +// 429 means the server rejected the request before acting on it, so it is +// safe to retry regardless of idempotency. +func TestDo_NonIdempotent_429Retried(t *testing.T) { + var calls int + resp, err := Do(context.Background(), fastConfig, false, func() (*http.Response, error) { + calls++ + if calls < 3 { + return newResp(429, "slow down", nil), nil + } + return newResp(200, "ok", nil), nil + }) + if err != nil || resp.StatusCode != 200 || calls != 3 { + t.Fatalf("err=%v status=%d calls=%d", err, resp.StatusCode, calls) + } +} + +func TestIsRetryable(t *testing.T) { + cases := []struct { + status int + idempotent bool + want bool + }{ + {429, false, true}, {429, true, true}, + {500, false, false}, {500, true, true}, + {503, false, false}, {503, true, true}, + {400, true, false}, {404, true, false}, {200, true, false}, + } + for _, c := range cases { + if got := IsRetryable(c.status, c.idempotent); got != c.want { + t.Errorf("IsRetryable(%d, %v) = %v, want %v", c.status, c.idempotent, got, c.want) + } + } +} diff --git a/oddrip/internal/transport/transport.go b/oddrip/internal/transport/transport.go deleted file mode 100644 index e6d9adb..0000000 --- a/oddrip/internal/transport/transport.go +++ /dev/null @@ -1,10 +0,0 @@ -package transport - -import ( - "context" - "net/http" -) - -type Doer interface { - Do(ctx context.Context, req *http.Request) (*http.Response, error) -} diff --git a/oddrip/markets.go b/oddrip/markets.go index 2e9ddbb..7971120 100644 --- a/oddrip/markets.go +++ b/oddrip/markets.go @@ -2,6 +2,7 @@ package oddrip import ( "context" + "errors" "fmt" "net/url" @@ -148,3 +149,25 @@ func (s *MarketsService) GetHistoricalCandlesticks(ctx context.Context, ticker s } return &out, nil } + +// GetCandlesticks fetches candlesticks for up to 100 comma-separated market +// tickers in one request. +func (s *MarketsService) GetCandlesticks(ctx context.Context, opts *types.BatchGetMarketCandlesticksOpts) (*types.BatchGetMarketCandlesticksResponse, error) { + if opts == nil || opts.MarketTickers == "" { + return nil, errors.New("market_tickers required") + } + if opts.PeriodInterval < 1 { + return nil, errors.New("period_interval must be >= 1") + } + v := url.Values{} + v.Set("market_tickers", opts.MarketTickers) + v.Set("start_ts", fmt.Sprintf("%d", opts.StartTs)) + v.Set("end_ts", fmt.Sprintf("%d", opts.EndTs)) + v.Set("period_interval", fmt.Sprintf("%d", opts.PeriodInterval)) + encodeQueryBool(v, "include_latest_before_start", opts.IncludeLatestBeforeStart) + var out types.BatchGetMarketCandlesticksResponse + if err := s.client.get(ctx, joinPath("markets", "candlesticks"), v, &out); err != nil { + return nil, err + } + return &out, nil +} diff --git a/oddrip/order_groups.go b/oddrip/order_groups.go new file mode 100644 index 0000000..d6c4807 --- /dev/null +++ b/oddrip/order_groups.go @@ -0,0 +1,81 @@ +package oddrip + +import ( + "context" + "errors" + "net/url" + + "github.com/UTXOnly/oddrip/oddrip/types" +) + +type OrderGroupsService struct { + client *Client +} + +func (s *OrderGroupsService) List(ctx context.Context, opts *types.GetOrderGroupsOpts) (*types.GetOrderGroupsResponse, error) { + v := url.Values{} + if opts != nil { + encodeQueryInt(v, "subaccount", opts.Subaccount) + } + var out types.GetOrderGroupsResponse + if err := s.client.get(ctx, joinPath("portfolio", "order_groups"), v, &out); err != nil { + return nil, err + } + return &out, nil +} + +func (s *OrderGroupsService) Create(ctx context.Context, req *types.CreateOrderGroupRequest) (*types.CreateOrderGroupResponse, error) { + if req == nil || (req.ContractsLimit == nil && req.ContractsLimitFp == nil) { + return nil, errors.New("contracts_limit or contracts_limit_fp required") + } + var out types.CreateOrderGroupResponse + if err := s.client.post(ctx, joinPath("portfolio", "order_groups", "create"), req, &out); err != nil { + return nil, err + } + return &out, nil +} + +func (s *OrderGroupsService) Get(ctx context.Context, orderGroupID string, opts *types.GetOrderGroupOpts) (*types.GetOrderGroupResponse, error) { + v := url.Values{} + if opts != nil { + encodeQueryInt(v, "subaccount", opts.Subaccount) + } + var out types.GetOrderGroupResponse + if err := s.client.get(ctx, joinPath("portfolio", "order_groups", orderGroupID), v, &out); err != nil { + return nil, err + } + return &out, nil +} + +// Delete removes the order group and cancels every order in it. +func (s *OrderGroupsService) Delete(ctx context.Context, orderGroupID string, opts *types.OrderGroupOpts) error { + return s.client.delete(ctx, joinPath("portfolio", "order_groups", orderGroupID), orderGroupQuery(opts), nil, nil) +} + +// Reset zeroes the group's matched-contracts counter so new orders can be placed. +func (s *OrderGroupsService) Reset(ctx context.Context, orderGroupID string, opts *types.OrderGroupOpts) error { + return s.client.put(ctx, joinPath("portfolio", "order_groups", orderGroupID, "reset"), orderGroupQuery(opts), nil, nil) +} + +// Trigger cancels every order in the group and blocks new orders until Reset. +func (s *OrderGroupsService) Trigger(ctx context.Context, orderGroupID string, opts *types.OrderGroupOpts) error { + return s.client.put(ctx, joinPath("portfolio", "order_groups", orderGroupID, "trigger"), orderGroupQuery(opts), nil, nil) +} + +// UpdateLimit changes the rolling 15-second contracts limit. A limit already +// exceeded triggers the group immediately. +func (s *OrderGroupsService) UpdateLimit(ctx context.Context, orderGroupID string, req *types.UpdateOrderGroupLimitRequest, opts *types.OrderGroupOpts) error { + if req == nil || (req.ContractsLimit == nil && req.ContractsLimitFp == nil) { + return errors.New("contracts_limit or contracts_limit_fp required") + } + return s.client.put(ctx, joinPath("portfolio", "order_groups", orderGroupID, "limit"), orderGroupQuery(opts), req, nil) +} + +func orderGroupQuery(opts *types.OrderGroupOpts) url.Values { + v := url.Values{} + if opts != nil { + encodeQueryInt(v, "subaccount", opts.Subaccount) + encodeQueryInt(v, "exchange_index", opts.ExchangeIndex) + } + return v +} diff --git a/oddrip/orders.go b/oddrip/orders.go index 8e514c2..f145f65 100644 --- a/oddrip/orders.go +++ b/oddrip/orders.go @@ -62,9 +62,20 @@ func (s *OrdersService) GetQueuePositions(ctx context.Context, opts *types.GetOr return &out, nil } +// CreateV2 places an order. Kalshi deduplicates on ClientOrderID; when it is +// set the call is retried on 5xx and transport errors like any idempotent +// request, otherwise only on 429 so a dropped connection cannot place the +// order twice. func (s *OrdersService) CreateV2(ctx context.Context, req *types.CreateOrderV2Request) (*types.CreateOrderV2Response, error) { + if req == nil { + return nil, errors.New("request required") + } + post := s.client.post + if req.ClientOrderID != "" { + post = s.client.postIdempotent + } var out types.CreateOrderV2Response - if err := s.client.post(ctx, joinPath("portfolio", "events", "orders"), req, &out); err != nil { + if err := post(ctx, joinPath("portfolio", "events", "orders"), req, &out); err != nil { return nil, err } return &out, nil @@ -116,9 +127,21 @@ func (s *OrdersService) DecreaseV2(ctx context.Context, orderID string, req *typ return &out, nil } +// BatchCreateV2 places up to 20 orders. The batch gets the idempotent retry +// policy only when every order carries a ClientOrderID (see CreateV2). func (s *OrdersService) BatchCreateV2(ctx context.Context, req *types.BatchCreateOrdersV2Request) (*types.BatchCreateOrdersV2Response, error) { + if req == nil { + return nil, errors.New("request required") + } + post := s.client.postIdempotent + for _, o := range req.Orders { + if o.ClientOrderID == "" { + post = s.client.post + break + } + } var out types.BatchCreateOrdersV2Response - if err := s.client.post(ctx, joinPath("portfolio", "events", "orders", "batched"), req, &out); err != nil { + if err := post(ctx, joinPath("portfolio", "events", "orders", "batched"), req, &out); err != nil { return nil, err } return &out, nil diff --git a/oddrip/portfolio.go b/oddrip/portfolio.go index cfd7009..980d4a8 100644 --- a/oddrip/portfolio.go +++ b/oddrip/portfolio.go @@ -199,5 +199,16 @@ func (s *PortfolioService) SetTargetBalanceAllocation(ctx context.Context, req * if len(req.Allocations) > 0 && total != 100 { return fmt.Errorf("allocations must total 100, got %d", total) } - return s.client.post(ctx, joinPath("portfolio", "target_balance_allocation"), req, nil) + // Sets absolute state, so a replay is harmless. + return s.client.postIdempotent(ctx, joinPath("portfolio", "target_balance_allocation"), req, nil) +} + +// GetTotalRestingOrderValue returns the total value of resting orders. Kalshi +// documents this as intended for FCM members only. +func (s *PortfolioService) GetTotalRestingOrderValue(ctx context.Context) (*types.GetPortfolioRestingOrderTotalValueResponse, error) { + var out types.GetPortfolioRestingOrderTotalValueResponse + if err := s.client.get(ctx, joinPath("portfolio", "summary", "total_resting_order_value"), nil, &out); err != nil { + return nil, err + } + return &out, nil } diff --git a/oddrip/series.go b/oddrip/series.go new file mode 100644 index 0000000..67b18c2 --- /dev/null +++ b/oddrip/series.go @@ -0,0 +1,110 @@ +package oddrip + +import ( + "context" + "errors" + "fmt" + "net/url" + "strconv" + + "github.com/UTXOnly/oddrip/oddrip/types" +) + +type SeriesService struct { + client *Client +} + +func (s *SeriesService) List(ctx context.Context, opts *types.GetSeriesListOpts) (*types.GetSeriesListResponse, error) { + v := url.Values{} + if opts != nil { + encodeQuery(v, "category", opts.Category) + encodeQuery(v, "tags", opts.Tags) + encodeQueryBool(v, "include_product_metadata", opts.IncludeProductMetadata) + encodeQueryBool(v, "include_volume", opts.IncludeVolume) + encodeQueryInt64(v, "min_updated_ts", opts.MinUpdatedTs) + } + var out types.GetSeriesListResponse + if err := s.client.get(ctx, joinPath("series"), v, &out); err != nil { + return nil, err + } + return &out, nil +} + +func (s *SeriesService) Get(ctx context.Context, seriesTicker string, opts *types.GetSeriesOpts) (*types.GetSeriesResponse, error) { + v := url.Values{} + if opts != nil { + encodeQueryBool(v, "include_volume", opts.IncludeVolume) + } + var out types.GetSeriesResponse + if err := s.client.get(ctx, joinPath("series", seriesTicker), v, &out); err != nil { + return nil, err + } + return &out, nil +} + +func (s *SeriesService) GetMarketCandlesticks(ctx context.Context, seriesTicker, ticker string, opts *types.GetMarketCandlesticksOpts) (*types.GetMarketCandlesticksResponse, error) { + if opts == nil { + return nil, errors.New("opts required") + } + switch opts.PeriodInterval { + case 1, 60, 1440: + default: + return nil, errors.New("period_interval must be 1, 60, or 1440") + } + v := url.Values{} + v.Set("start_ts", fmt.Sprintf("%d", opts.StartTs)) + v.Set("end_ts", fmt.Sprintf("%d", opts.EndTs)) + v.Set("period_interval", fmt.Sprintf("%d", opts.PeriodInterval)) + encodeQueryBool(v, "include_latest_before_start", opts.IncludeLatestBeforeStart) + var out types.GetMarketCandlesticksResponse + if err := s.client.get(ctx, joinPath("series", seriesTicker, "markets", ticker, "candlesticks"), v, &out); err != nil { + return nil, err + } + return &out, nil +} + +func (s *SeriesService) GetEventCandlesticks(ctx context.Context, seriesTicker, eventTicker string, opts *types.GetEventCandlesticksOpts) (*types.GetEventCandlesticksResponse, error) { + if opts == nil { + return nil, errors.New("opts required") + } + switch opts.PeriodInterval { + case 1, 60, 1440: + default: + return nil, errors.New("period_interval must be 1, 60, or 1440") + } + v := url.Values{} + v.Set("start_ts", fmt.Sprintf("%d", opts.StartTs)) + v.Set("end_ts", fmt.Sprintf("%d", opts.EndTs)) + v.Set("period_interval", fmt.Sprintf("%d", opts.PeriodInterval)) + var out types.GetEventCandlesticksResponse + if err := s.client.get(ctx, joinPath("series", seriesTicker, "events", eventTicker, "candlesticks"), v, &out); err != nil { + return nil, err + } + return &out, nil +} + +func (s *SeriesService) GetForecastPercentileHistory(ctx context.Context, seriesTicker, eventTicker string, opts *types.GetEventForecastPercentilesHistoryOpts) (*types.GetEventForecastPercentilesHistoryResponse, error) { + if opts == nil || len(opts.Percentiles) == 0 { + return nil, errors.New("percentiles required") + } + if len(opts.Percentiles) > 10 { + return nil, errors.New("at most 10 percentiles allowed") + } + switch opts.PeriodInterval { + case 0, 1, 60, 1440: + default: + return nil, errors.New("period_interval must be 0, 1, 60, or 1440") + } + v := url.Values{} + for _, p := range opts.Percentiles { + v.Add("percentiles", strconv.Itoa(p)) + } + v.Set("start_ts", fmt.Sprintf("%d", opts.StartTs)) + v.Set("end_ts", fmt.Sprintf("%d", opts.EndTs)) + v.Set("period_interval", fmt.Sprintf("%d", opts.PeriodInterval)) + var out types.GetEventForecastPercentilesHistoryResponse + if err := s.client.get(ctx, joinPath("series", seriesTicker, "events", eventTicker, "forecast_percentile_history"), v, &out); err != nil { + return nil, err + } + return &out, nil +} diff --git a/oddrip/services_extra_test.go b/oddrip/services_extra_test.go new file mode 100644 index 0000000..c648368 --- /dev/null +++ b/oddrip/services_extra_test.go @@ -0,0 +1,539 @@ +package oddrip + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/UTXOnly/oddrip/oddrip/types" +) + +type captureTransport struct { + status int + body string + req *http.Request + sent []byte +} + +func (c *captureTransport) RoundTrip(req *http.Request) (*http.Response, error) { + c.req = req + if req.Body != nil { + c.sent, _ = io.ReadAll(req.Body) + } + resp := &http.Response{ + StatusCode: c.status, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(c.body)), + Request: req, + } + resp.Header.Set("Content-Type", "application/json") + return resp, nil +} + +func newCaptureClient(status int, body string) (*Client, *captureTransport) { + ct := &captureTransport{status: status, body: body} + return New(HTTPClient(&http.Client{Transport: ct})), ct +} + +func (c *captureTransport) assertRequest(t *testing.T, method, path string) { + t.Helper() + if c.req == nil { + t.Fatal("no request sent") + } + if c.req.Method != method || c.req.URL.Path != "/trade-api/v2"+path { + t.Fatalf("request: got %s %s, want %s %s", c.req.Method, c.req.URL.Path, method, "/trade-api/v2"+path) + } +} + +func (c *captureTransport) assertQuery(t *testing.T, want map[string]string) { + t.Helper() + q := c.req.URL.Query() + for k, v := range want { + if q.Get(k) != v { + t.Fatalf("query %s: got %q, want %q (all: %v)", k, q.Get(k), v, q) + } + } + if len(q) != len(want) { + t.Fatalf("query has extra keys: %v", q) + } +} + +func (c *captureTransport) assertBody(t *testing.T, want string) { + t.Helper() + var got, exp interface{} + if err := json.Unmarshal(c.sent, &got); err != nil { + t.Fatalf("sent body %q: %v", c.sent, err) + } + if err := json.Unmarshal([]byte(want), &exp); err != nil { + t.Fatal(err) + } + g, _ := json.Marshal(got) + e, _ := json.Marshal(exp) + if string(g) != string(e) { + t.Fatalf("body: got %s, want %s", g, e) + } +} + +func ptrOf[T any](v T) *T { return &v } + +const seriesJSON = `{ + "ticker":"KXHIGHNY","frequency":"daily","title":"Highest temperature in NYC","category":"Climate and Weather", + "tags":["Weather"],"settlement_sources":[{"name":"NWS","url":"https://weather.gov"}], + "contract_url":"https://kalshi.com/c","contract_terms_url":"https://kalshi.com/t", + "product_metadata":{"k":"v"},"fee_type":"quadratic","fee_multiplier":1.5, + "additional_prohibitions":["none"],"volume_fp":"10.00","last_updated_ts":"2024-01-01T00:00:00Z","exchange_index":1 +}` + +func TestSeries_List(t *testing.T) { + client, ct := newCaptureClient(200, `{"series":[`+seriesJSON+`]}`) + got, err := client.Series.List(context.Background(), &types.GetSeriesListOpts{ + Category: "Climate and Weather", + Tags: "Weather", + IncludeProductMetadata: ptrOf(true), + IncludeVolume: ptrOf(false), + MinUpdatedTs: ptrOf[int64](1700000000), + }) + if err != nil { + t.Fatal(err) + } + ct.assertRequest(t, http.MethodGet, "/series") + ct.assertQuery(t, map[string]string{ + "category": "Climate and Weather", "tags": "Weather", + "include_product_metadata": "true", "include_volume": "false", "min_updated_ts": "1700000000", + }) + if len(got.Series) != 1 { + t.Fatalf("series: %+v", got.Series) + } + s := got.Series[0] + if s.Ticker != "KXHIGHNY" || s.FeeType != types.FeeTypeQuadratic || s.FeeMultiplier != 1.5 || + s.VolumeFp != "10.00" || s.ExchangeIndex != 1 || s.ProductMetadata["k"] != "v" || + len(s.SettlementSources) != 1 || s.SettlementSources[0].Name != "NWS" { + t.Fatalf("series: %+v", s) + } +} + +func TestSeries_Get(t *testing.T) { + client, ct := newCaptureClient(200, `{"series":`+seriesJSON+`}`) + got, err := client.Series.Get(context.Background(), "KXHIGHNY", &types.GetSeriesOpts{IncludeVolume: ptrOf(true)}) + if err != nil { + t.Fatal(err) + } + ct.assertRequest(t, http.MethodGet, "/series/KXHIGHNY") + ct.assertQuery(t, map[string]string{"include_volume": "true"}) + if got.Series.Title != "Highest temperature in NYC" || got.Series.Tags[0] != "Weather" { + t.Fatalf("series: %+v", got.Series) + } +} + +const candlestickJSON = `{ + "end_period_ts":1700003600, + "yes_bid":{"open_dollars":"0.5500","low_dollars":"0.5400","high_dollars":"0.5700","close_dollars":"0.5600"}, + "yes_ask":{"open_dollars":"0.5700","low_dollars":"0.5600","high_dollars":"0.5900","close_dollars":"0.5800"}, + "price":{"open_dollars":null,"low_dollars":null,"high_dollars":null,"close_dollars":null,"previous_dollars":"0.5600","mean_dollars":null}, + "volume_fp":"10.00","open_interest_fp":"100.00" +}` + +func TestSeries_GetMarketCandlesticks(t *testing.T) { + client, ct := newCaptureClient(200, `{"ticker":"KXHIGHNY-24JAN01-T60","candlesticks":[`+candlestickJSON+`]}`) + got, err := client.Series.GetMarketCandlesticks(context.Background(), "KXHIGHNY", "KXHIGHNY-24JAN01-T60", &types.GetMarketCandlesticksOpts{ + StartTs: 1700000000, EndTs: 1700003600, PeriodInterval: types.PeriodInterval1Hour, IncludeLatestBeforeStart: ptrOf(true), + }) + if err != nil { + t.Fatal(err) + } + ct.assertRequest(t, http.MethodGet, "/series/KXHIGHNY/markets/KXHIGHNY-24JAN01-T60/candlesticks") + ct.assertQuery(t, map[string]string{ + "start_ts": "1700000000", "end_ts": "1700003600", "period_interval": "60", "include_latest_before_start": "true", + }) + if got.Ticker != "KXHIGHNY-24JAN01-T60" || len(got.Candlesticks) != 1 { + t.Fatalf("resp: %+v", got) + } + c := got.Candlesticks[0] + if c.EndPeriodTs != 1700003600 || c.YesBid.CloseDollars != "0.5600" || c.YesAsk.HighDollars != "0.5900" || + c.Price.OpenDollars != nil || c.Price.PreviousDollars == nil || *c.Price.PreviousDollars != "0.5600" || + c.VolumeFp != "10.00" || c.OpenInterestFp != "100.00" { + t.Fatalf("candlestick: %+v", c) + } +} + +func TestSeries_GetMarketCandlesticks_Validation(t *testing.T) { + client, ct := newCaptureClient(200, `{}`) + ctx := context.Background() + if _, err := client.Series.GetMarketCandlesticks(ctx, "S", "M", nil); err == nil { + t.Fatal("expected error for nil opts") + } + if _, err := client.Series.GetMarketCandlesticks(ctx, "S", "M", &types.GetMarketCandlesticksOpts{PeriodInterval: 5}); err == nil { + t.Fatal("expected error for bad period_interval") + } + if ct.req != nil { + t.Fatalf("request should not be sent: %v", ct.req.URL) + } +} + +func TestSeries_GetEventCandlesticks(t *testing.T) { + client, ct := newCaptureClient(200, `{"market_tickers":["A","B"],"market_candlesticks":[[`+candlestickJSON+`],[]],"adjusted_end_ts":1700003600}`) + got, err := client.Series.GetEventCandlesticks(context.Background(), "KXHIGHNY", "KXHIGHNY-24JAN01", &types.GetEventCandlesticksOpts{ + StartTs: 1, EndTs: 2, PeriodInterval: types.PeriodInterval1Min, + }) + if err != nil { + t.Fatal(err) + } + ct.assertRequest(t, http.MethodGet, "/series/KXHIGHNY/events/KXHIGHNY-24JAN01/candlesticks") + ct.assertQuery(t, map[string]string{"start_ts": "1", "end_ts": "2", "period_interval": "1"}) + if len(got.MarketTickers) != 2 || len(got.MarketCandlesticks) != 2 || len(got.MarketCandlesticks[0]) != 1 || + got.MarketCandlesticks[0][0].YesBid.OpenDollars != "0.5500" || got.AdjustedEndTs != 1700003600 { + t.Fatalf("resp: %+v", got) + } + if _, err := client.Series.GetEventCandlesticks(context.Background(), "S", "E", &types.GetEventCandlesticksOpts{PeriodInterval: 0}); err == nil { + t.Fatal("expected error for bad period_interval") + } +} + +func TestSeries_GetForecastPercentileHistory(t *testing.T) { + client, ct := newCaptureClient(200, `{"forecast_history":[{ + "event_ticker":"KXHIGHNY-24JAN01","end_period_ts":1700003600,"period_interval":60, + "percentile_points":[{"percentile":5000,"raw_numerical_forecast":61.2,"numerical_forecast":61,"formatted_forecast":"61°F"}] + }]}`) + got, err := client.Series.GetForecastPercentileHistory(context.Background(), "KXHIGHNY", "KXHIGHNY-24JAN01", &types.GetEventForecastPercentilesHistoryOpts{ + Percentiles: []int{500, 5000, 9500}, StartTs: 1700000000, EndTs: 1700003600, PeriodInterval: 60, + }) + if err != nil { + t.Fatal(err) + } + ct.assertRequest(t, http.MethodGet, "/series/KXHIGHNY/events/KXHIGHNY-24JAN01/forecast_percentile_history") + q := ct.req.URL.Query() + if p := q["percentiles"]; len(p) != 3 || p[0] != "500" || p[1] != "5000" || p[2] != "9500" { + t.Fatalf("percentiles: %v", p) + } + if q.Get("start_ts") != "1700000000" || q.Get("end_ts") != "1700003600" || q.Get("period_interval") != "60" { + t.Fatalf("query: %v", q) + } + if len(got.ForecastHistory) != 1 { + t.Fatalf("resp: %+v", got) + } + fp := got.ForecastHistory[0] + if fp.EventTicker != "KXHIGHNY-24JAN01" || fp.PeriodInterval != 60 || len(fp.PercentilePoints) != 1 || + fp.PercentilePoints[0].Percentile != 5000 || fp.PercentilePoints[0].RawNumericalForecast != 61.2 || + fp.PercentilePoints[0].FormattedForecast != "61°F" { + t.Fatalf("point: %+v", fp) + } +} + +func TestSeries_GetForecastPercentileHistory_Validation(t *testing.T) { + client, ct := newCaptureClient(200, `{}`) + ctx := context.Background() + if _, err := client.Series.GetForecastPercentileHistory(ctx, "S", "E", nil); err == nil { + t.Fatal("expected error for nil opts") + } + if _, err := client.Series.GetForecastPercentileHistory(ctx, "S", "E", &types.GetEventForecastPercentilesHistoryOpts{Percentiles: make([]int, 11)}); err == nil { + t.Fatal("expected error for >10 percentiles") + } + if _, err := client.Series.GetForecastPercentileHistory(ctx, "S", "E", &types.GetEventForecastPercentilesHistoryOpts{Percentiles: []int{1}, PeriodInterval: 5}); err == nil { + t.Fatal("expected error for bad period_interval") + } + if ct.req != nil { + t.Fatalf("request should not be sent: %v", ct.req.URL) + } +} + +func TestMarkets_GetCandlesticks(t *testing.T) { + client, ct := newCaptureClient(200, `{"markets":[{"market_ticker":"INXD-24JAN01","candlesticks":[`+candlestickJSON+`]},{"market_ticker":"B","candlesticks":[]}]}`) + got, err := client.Markets.GetCandlesticks(context.Background(), &types.BatchGetMarketCandlesticksOpts{ + MarketTickers: "INXD-24JAN01,B", StartTs: 1, EndTs: 2, PeriodInterval: 1440, IncludeLatestBeforeStart: ptrOf(false), + }) + if err != nil { + t.Fatal(err) + } + ct.assertRequest(t, http.MethodGet, "/markets/candlesticks") + ct.assertQuery(t, map[string]string{ + "market_tickers": "INXD-24JAN01,B", "start_ts": "1", "end_ts": "2", "period_interval": "1440", "include_latest_before_start": "false", + }) + if len(got.Markets) != 2 || got.Markets[0].MarketTicker != "INXD-24JAN01" || len(got.Markets[0].Candlesticks) != 1 || + got.Markets[0].Candlesticks[0].OpenInterestFp != "100.00" { + t.Fatalf("resp: %+v", got) + } + + if _, err := client.Markets.GetCandlesticks(context.Background(), nil); err == nil { + t.Fatal("expected error for nil opts") + } + if _, err := client.Markets.GetCandlesticks(context.Background(), &types.BatchGetMarketCandlesticksOpts{MarketTickers: "A"}); err == nil { + t.Fatal("expected error for period_interval 0") + } +} + +func TestOrderGroups_List(t *testing.T) { + client, ct := newCaptureClient(200, `{"order_groups":[{"id":"og1","contracts_limit_fp":"10.00","is_auto_cancel_enabled":true,"exchange_index":0}]}`) + got, err := client.OrderGroups.List(context.Background(), &types.GetOrderGroupsOpts{Subaccount: ptrOf(3)}) + if err != nil { + t.Fatal(err) + } + ct.assertRequest(t, http.MethodGet, "/portfolio/order_groups") + ct.assertQuery(t, map[string]string{"subaccount": "3"}) + if len(got.OrderGroups) != 1 || got.OrderGroups[0].ID != "og1" || got.OrderGroups[0].ContractsLimitFp != "10.00" || !got.OrderGroups[0].IsAutoCancelEnabled { + t.Fatalf("resp: %+v", got) + } +} + +func TestOrderGroups_Create(t *testing.T) { + client, ct := newCaptureClient(201, `{"order_group_id":"og1","subaccount":2,"exchange_index":0}`) + got, err := client.OrderGroups.Create(context.Background(), &types.CreateOrderGroupRequest{ + Subaccount: ptrOf(2), ContractsLimitFp: ptrOf("10.00"), ExchangeIndex: ptrOf(0), + }) + if err != nil { + t.Fatal(err) + } + ct.assertRequest(t, http.MethodPost, "/portfolio/order_groups/create") + ct.assertBody(t, `{"subaccount":2,"contracts_limit_fp":"10.00","exchange_index":0}`) + if got.OrderGroupID != "og1" || got.Subaccount != 2 { + t.Fatalf("resp: %+v", got) + } + + if _, err := client.OrderGroups.Create(context.Background(), &types.CreateOrderGroupRequest{}); err == nil { + t.Fatal("expected error when no limit given") + } +} + +func TestOrderGroups_Get(t *testing.T) { + client, ct := newCaptureClient(200, `{"is_auto_cancel_enabled":false,"contracts_limit_fp":"5.00","orders":["o1","o2"],"exchange_index":1}`) + got, err := client.OrderGroups.Get(context.Background(), "og1", &types.GetOrderGroupOpts{Subaccount: ptrOf(0)}) + if err != nil { + t.Fatal(err) + } + ct.assertRequest(t, http.MethodGet, "/portfolio/order_groups/og1") + ct.assertQuery(t, map[string]string{"subaccount": "0"}) + if got.IsAutoCancelEnabled || got.ContractsLimitFp != "5.00" || len(got.Orders) != 2 || got.Orders[1] != "o2" || got.ExchangeIndex != 1 { + t.Fatalf("resp: %+v", got) + } +} + +func TestOrderGroups_Delete(t *testing.T) { + client, ct := newCaptureClient(200, `{}`) + if err := client.OrderGroups.Delete(context.Background(), "og1", &types.OrderGroupOpts{Subaccount: ptrOf(1), ExchangeIndex: ptrOf(2)}); err != nil { + t.Fatal(err) + } + ct.assertRequest(t, http.MethodDelete, "/portfolio/order_groups/og1") + ct.assertQuery(t, map[string]string{"subaccount": "1", "exchange_index": "2"}) + if len(ct.sent) != 0 { + t.Fatalf("unexpected body: %s", ct.sent) + } +} + +func TestOrderGroups_Reset(t *testing.T) { + client, ct := newCaptureClient(200, `{}`) + if err := client.OrderGroups.Reset(context.Background(), "og1", nil); err != nil { + t.Fatal(err) + } + ct.assertRequest(t, http.MethodPut, "/portfolio/order_groups/og1/reset") + ct.assertQuery(t, map[string]string{}) + if len(ct.sent) != 0 { + t.Fatalf("unexpected body: %s", ct.sent) + } +} + +func TestOrderGroups_Trigger(t *testing.T) { + client, ct := newCaptureClient(200, `{}`) + if err := client.OrderGroups.Trigger(context.Background(), "og1", &types.OrderGroupOpts{ExchangeIndex: ptrOf(1)}); err != nil { + t.Fatal(err) + } + ct.assertRequest(t, http.MethodPut, "/portfolio/order_groups/og1/trigger") + ct.assertQuery(t, map[string]string{"exchange_index": "1"}) +} + +func TestOrderGroups_UpdateLimit(t *testing.T) { + client, ct := newCaptureClient(200, `{}`) + if err := client.OrderGroups.UpdateLimit(context.Background(), "og1", &types.UpdateOrderGroupLimitRequest{ContractsLimit: ptrOf[int64](25)}, &types.OrderGroupOpts{Subaccount: ptrOf(0)}); err != nil { + t.Fatal(err) + } + ct.assertRequest(t, http.MethodPut, "/portfolio/order_groups/og1/limit") + ct.assertQuery(t, map[string]string{"subaccount": "0"}) + ct.assertBody(t, `{"contracts_limit":25}`) + + if err := client.OrderGroups.UpdateLimit(context.Background(), "og1", nil, nil); err == nil { + t.Fatal("expected error for nil request") + } +} + +func TestSubaccounts_Create(t *testing.T) { + client, ct := newCaptureClient(201, `{"subaccount_number":1}`) + got, err := client.Subaccounts.Create(context.Background(), &types.CreateSubaccountRequest{ExchangeIndex: ptrOf(1)}) + if err != nil { + t.Fatal(err) + } + ct.assertRequest(t, http.MethodPost, "/portfolio/subaccounts") + ct.assertBody(t, `{"exchange_index":1}`) + if got.SubaccountNumber != 1 { + t.Fatalf("resp: %+v", got) + } + + client, ct = newCaptureClient(201, `{"subaccount_number":2}`) + if _, err := client.Subaccounts.Create(context.Background(), nil); err != nil { + t.Fatal(err) + } + ct.assertBody(t, `{}`) +} + +func TestSubaccounts_GetBalances(t *testing.T) { + client, ct := newCaptureClient(200, `{"subaccount_balances":[ + {"subaccount_number":0,"exchange_index":0,"balance":"100.0000","updated_ts":1716300000}, + {"subaccount_number":1,"exchange_index":0,"balance":"0.5600","updated_ts":1716300001} + ]}`) + got, err := client.Subaccounts.GetBalances(context.Background()) + if err != nil { + t.Fatal(err) + } + ct.assertRequest(t, http.MethodGet, "/portfolio/subaccounts/balances") + if len(got.SubaccountBalances) != 2 || got.SubaccountBalances[1].SubaccountNumber != 1 || + got.SubaccountBalances[1].Balance != "0.5600" || got.SubaccountBalances[0].UpdatedTs != 1716300000 { + t.Fatalf("resp: %+v", got) + } +} + +func TestSubaccounts_Transfer(t *testing.T) { + client, ct := newCaptureClient(200, `{}`) + err := client.Subaccounts.Transfer(context.Background(), &types.ApplySubaccountTransferRequest{ + ClientTransferID: "8c35ecb3-328f-4f52-8c7c-0f4b9862f8d1", FromSubaccount: 0, ToSubaccount: 1, AmountCents: 5000, + }) + if err != nil { + t.Fatal(err) + } + ct.assertRequest(t, http.MethodPost, "/portfolio/subaccounts/transfer") + ct.assertBody(t, `{"client_transfer_id":"8c35ecb3-328f-4f52-8c7c-0f4b9862f8d1","from_subaccount":0,"to_subaccount":1,"amount_cents":5000}`) + + client, ct = newCaptureClient(200, `{}`) + if err := client.Subaccounts.Transfer(context.Background(), &types.ApplySubaccountTransferRequest{AmountCents: 1}); err == nil { + t.Fatal("expected error for missing client_transfer_id") + } + if ct.req != nil { + t.Fatalf("request should not be sent: %v", ct.req.URL) + } +} + +func TestSubaccounts_ListTransfers(t *testing.T) { + client, ct := newCaptureClient(200, `{"transfers":[{"transfer_id":"t1","from_subaccount":0,"to_subaccount":2,"amount_cents":250,"created_ts":1716300000,"exchange_index":0}],"cursor":"next"}`) + got, err := client.Subaccounts.ListTransfers(context.Background(), &types.GetSubaccountTransfersOpts{Limit: ptrOf[int64](50), Cursor: "abc"}) + if err != nil { + t.Fatal(err) + } + ct.assertRequest(t, http.MethodGet, "/portfolio/subaccounts/transfers") + ct.assertQuery(t, map[string]string{"limit": "50", "cursor": "abc"}) + if len(got.Transfers) != 1 || got.Transfers[0].TransferID != "t1" || got.Transfers[0].ToSubaccount != 2 || + got.Transfers[0].AmountCents != 250 || got.Cursor != "next" { + t.Fatalf("resp: %+v", got) + } +} + +func TestSubaccounts_GetNetting(t *testing.T) { + client, ct := newCaptureClient(200, `{"netting_configs":[{"subaccount_number":0,"enabled":true,"exchange_index":0},{"subaccount_number":1,"enabled":false,"exchange_index":0}]}`) + got, err := client.Subaccounts.GetNetting(context.Background()) + if err != nil { + t.Fatal(err) + } + ct.assertRequest(t, http.MethodGet, "/portfolio/subaccounts/netting") + if len(got.NettingConfigs) != 2 || !got.NettingConfigs[0].Enabled || got.NettingConfigs[1].Enabled || got.NettingConfigs[1].SubaccountNumber != 1 { + t.Fatalf("resp: %+v", got) + } +} + +func TestSubaccounts_UpdateNetting(t *testing.T) { + client, ct := newCaptureClient(200, ``) + if err := client.Subaccounts.UpdateNetting(context.Background(), &types.UpdateSubaccountNettingRequest{SubaccountNumber: 0, Enabled: false}); err != nil { + t.Fatal(err) + } + ct.assertRequest(t, http.MethodPut, "/portfolio/subaccounts/netting") + ct.assertBody(t, `{"subaccount_number":0,"enabled":false}`) + + if err := client.Subaccounts.UpdateNetting(context.Background(), nil); err == nil { + t.Fatal("expected error for nil request") + } +} + +func TestPortfolio_GetTotalRestingOrderValue(t *testing.T) { + client, ct := newCaptureClient(200, `{"total_resting_order_value":12345,"resting_order_value_breakdown":[{"exchange_index":0,"balance":"100.0000"},{"exchange_index":1,"balance":"23.4500"}]}`) + got, err := client.Portfolio.GetTotalRestingOrderValue(context.Background()) + if err != nil { + t.Fatal(err) + } + ct.assertRequest(t, http.MethodGet, "/portfolio/summary/total_resting_order_value") + if got.TotalRestingOrderValue != 12345 || len(got.RestingOrderValueBreakdown) != 2 || + got.RestingOrderValueBreakdown[1].ExchangeIndex != 1 || got.RestingOrderValueBreakdown[1].Balance != "23.4500" { + t.Fatalf("resp: %+v", got) + } +} + +const collectionJSON = `{ + "collection_ticker":"KXNBAPARLAY","series_ticker":"KXNBA","exchange_index":0,"title":"NBA Parlay","description":"d", + "open_date":"2024-01-01T00:00:00Z","close_date":"2024-12-31T00:00:00Z", + "associated_events":[{"ticker":"KXNBA-24JAN01","is_yes_only":true,"size_max":null,"size_min":1,"active_quoters":["q1"]}], + "associated_event_tickers":["KXNBA-24JAN01"],"is_ordered":false,"is_single_market_per_event":true,"is_all_yes":true, + "size_min":2,"size_max":5,"functional_description":"all legs must hit" +}` + +func TestEvents_ListMultivariateCollections(t *testing.T) { + client, ct := newCaptureClient(200, `{"multivariate_contracts":[`+collectionJSON+`],"cursor":"c2"}`) + got, err := client.Events.ListMultivariateCollections(context.Background(), &types.GetMultivariateEventCollectionsOpts{ + Status: types.CollectionStatusOpen, AssociatedEventTicker: "KXNBA-24JAN01", SeriesTicker: "KXNBA", Limit: ptrOf[int64](20), Cursor: "c1", + }) + if err != nil { + t.Fatal(err) + } + ct.assertRequest(t, http.MethodGet, "/multivariate_event_collections") + ct.assertQuery(t, map[string]string{ + "status": "open", "associated_event_ticker": "KXNBA-24JAN01", "series_ticker": "KXNBA", "limit": "20", "cursor": "c1", + }) + if len(got.MultivariateContracts) != 1 || got.Cursor != "c2" { + t.Fatalf("resp: %+v", got) + } + c := got.MultivariateContracts[0] + if c.CollectionTicker != "KXNBAPARLAY" || c.SizeMin != 2 || c.SizeMax != 5 || !c.IsAllYes || c.IsOrdered || + len(c.AssociatedEvents) != 1 || !c.AssociatedEvents[0].IsYesOnly || c.AssociatedEvents[0].SizeMax != nil || + c.AssociatedEvents[0].SizeMin == nil || *c.AssociatedEvents[0].SizeMin != 1 || c.AssociatedEvents[0].ActiveQuoters[0] != "q1" { + t.Fatalf("collection: %+v", c) + } +} + +func TestEvents_GetMultivariateCollection(t *testing.T) { + client, ct := newCaptureClient(200, `{"multivariate_contract":`+collectionJSON+`}`) + got, err := client.Events.GetMultivariateCollection(context.Background(), "KXNBAPARLAY") + if err != nil { + t.Fatal(err) + } + ct.assertRequest(t, http.MethodGet, "/multivariate_event_collections/KXNBAPARLAY") + ct.assertQuery(t, map[string]string{}) + if got.MultivariateContract.SeriesTicker != "KXNBA" || got.MultivariateContract.FunctionalDescription != "all legs must hit" { + t.Fatalf("resp: %+v", got) + } +} + +func TestEvents_CreateMarketInMultivariateCollection(t *testing.T) { + client, ct := newCaptureClient(200, `{"event_ticker":"KXNBAPARLAY-24JAN01","market_ticker":"KXNBAPARLAY-24JAN01-ABC","market":{"ticker":"KXNBAPARLAY-24JAN01-ABC","event_ticker":"KXNBAPARLAY-24JAN01","market_type":"binary","status":"open"}}`) + got, err := client.Events.CreateMarketInMultivariateCollection(context.Background(), "KXNBAPARLAY", &types.CreateMarketInMultivariateEventCollectionRequest{ + SelectedMarkets: []types.TickerPair{ + {MarketTicker: "KXNBA-24JAN01-LAL", EventTicker: "KXNBA-24JAN01", Side: types.OrderSideYes}, + {MarketTicker: "KXNBA-24JAN01-BOS", EventTicker: "KXNBA-24JAN01", Side: types.OrderSideNo}, + }, + WithMarketPayload: ptrOf(true), + }) + if err != nil { + t.Fatal(err) + } + ct.assertRequest(t, http.MethodPost, "/multivariate_event_collections/KXNBAPARLAY") + ct.assertBody(t, `{"selected_markets":[ + {"market_ticker":"KXNBA-24JAN01-LAL","event_ticker":"KXNBA-24JAN01","side":"yes"}, + {"market_ticker":"KXNBA-24JAN01-BOS","event_ticker":"KXNBA-24JAN01","side":"no"} + ],"with_market_payload":true}`) + if got.EventTicker != "KXNBAPARLAY-24JAN01" || got.MarketTicker != "KXNBAPARLAY-24JAN01-ABC" || + got.Market == nil || got.Market.Status != "open" { + t.Fatalf("resp: %+v", got) + } + + client, ct = newCaptureClient(200, `{}`) + if _, err := client.Events.CreateMarketInMultivariateCollection(context.Background(), "X", &types.CreateMarketInMultivariateEventCollectionRequest{}); err == nil { + t.Fatal("expected error for empty selected_markets") + } + if ct.req != nil { + t.Fatalf("request should not be sent: %v", ct.req.URL) + } +} diff --git a/oddrip/subaccounts.go b/oddrip/subaccounts.go new file mode 100644 index 0000000..b9b3d5f --- /dev/null +++ b/oddrip/subaccounts.go @@ -0,0 +1,70 @@ +package oddrip + +import ( + "context" + "errors" + "net/url" + + "github.com/UTXOnly/oddrip/oddrip/types" +) + +type SubaccountsService struct { + client *Client +} + +// Create adds a numbered subaccount (1-63). A nil req uses exchange index 0. +func (s *SubaccountsService) Create(ctx context.Context, req *types.CreateSubaccountRequest) (*types.CreateSubaccountResponse, error) { + if req == nil { + req = &types.CreateSubaccountRequest{} + } + var out types.CreateSubaccountResponse + if err := s.client.post(ctx, joinPath("portfolio", "subaccounts"), req, &out); err != nil { + return nil, err + } + return &out, nil +} + +func (s *SubaccountsService) GetBalances(ctx context.Context) (*types.GetSubaccountBalancesResponse, error) { + var out types.GetSubaccountBalancesResponse + if err := s.client.get(ctx, joinPath("portfolio", "subaccounts", "balances"), nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// Transfer moves funds between subaccounts. Kalshi deduplicates on +// ClientTransferID, so the call is safe to retry on transport errors. +func (s *SubaccountsService) Transfer(ctx context.Context, req *types.ApplySubaccountTransferRequest) error { + if req == nil || req.ClientTransferID == "" { + return errors.New("client_transfer_id required") + } + return s.client.postIdempotent(ctx, joinPath("portfolio", "subaccounts", "transfer"), req, nil) +} + +func (s *SubaccountsService) ListTransfers(ctx context.Context, opts *types.GetSubaccountTransfersOpts) (*types.GetSubaccountTransfersResponse, error) { + v := url.Values{} + if opts != nil { + encodeQueryInt64(v, "limit", opts.Limit) + encodeQuery(v, "cursor", opts.Cursor) + } + var out types.GetSubaccountTransfersResponse + if err := s.client.get(ctx, joinPath("portfolio", "subaccounts", "transfers"), v, &out); err != nil { + return nil, err + } + return &out, nil +} + +func (s *SubaccountsService) GetNetting(ctx context.Context) (*types.GetSubaccountNettingResponse, error) { + var out types.GetSubaccountNettingResponse + if err := s.client.get(ctx, joinPath("portfolio", "subaccounts", "netting"), nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +func (s *SubaccountsService) UpdateNetting(ctx context.Context, req *types.UpdateSubaccountNettingRequest) error { + if req == nil { + return errors.New("request required") + } + return s.client.put(ctx, joinPath("portfolio", "subaccounts", "netting"), nil, req, nil) +} diff --git a/oddrip/types/common.go b/oddrip/types/common.go index cdd2333..dd9dac7 100644 --- a/oddrip/types/common.go +++ b/oddrip/types/common.go @@ -14,38 +14,38 @@ type CursorPage struct { } const ( - OrderStatusResting = "resting" - OrderStatusCanceled = "canceled" - OrderStatusExecuted = "executed" - OrderSideYes = "yes" - OrderSideNo = "no" - BookSideBid = "bid" - BookSideAsk = "ask" - OutcomeSideYes = "yes" - OutcomeSideNo = "no" - OrderActionBuy = "buy" - OrderActionSell = "sell" - OrderTypeLimit = "limit" - OrderTypeMarket = "market" - TimeInForceFOK = "fill_or_kill" - TimeInForceGTC = "good_till_canceled" - TimeInForceIOC = "immediate_or_cancel" + OrderStatusResting = "resting" + OrderStatusCanceled = "canceled" + OrderStatusExecuted = "executed" + OrderSideYes = "yes" + OrderSideNo = "no" + BookSideBid = "bid" + BookSideAsk = "ask" + OutcomeSideYes = "yes" + OutcomeSideNo = "no" + OrderActionBuy = "buy" + OrderActionSell = "sell" + OrderTypeLimit = "limit" + OrderTypeMarket = "market" + TimeInForceFOK = "fill_or_kill" + TimeInForceGTC = "good_till_canceled" + TimeInForceIOC = "immediate_or_cancel" SelfTradeTakerAtCross = "taker_at_cross" SelfTradeMaker = "maker" ) const ( - PeriodInterval1Min = 1 - PeriodInterval1Hour = 60 - PeriodInterval1Day = 1440 + PeriodInterval1Min = 1 + PeriodInterval1Hour = 60 + PeriodInterval1Day = 1440 ) const ( - MarketStatusUnopened = "unopened" - MarketStatusOpen = "open" - MarketStatusPaused = "paused" - MarketStatusClosed = "closed" - MarketStatusSettled = "settled" + MarketStatusUnopened = "unopened" + MarketStatusOpen = "open" + MarketStatusPaused = "paused" + MarketStatusClosed = "closed" + MarketStatusSettled = "settled" ) const ( diff --git a/oddrip/types/decimal.go b/oddrip/types/decimal.go new file mode 100644 index 0000000..f6129d6 --- /dev/null +++ b/oddrip/types/decimal.go @@ -0,0 +1,129 @@ +package types + +import ( + "errors" + "fmt" + "math" + "strings" + "time" +) + +// Dollars is a fixed-point dollar amount scaled by 1e6: one unit is +// $0.000001. The API's FixedPointDollars strings carry up to six decimals in +// responses (fees, fill costs) and two to four in requests (prices), so this +// scale is lossless for every value the exchange emits. +type Dollars int64 + +// Count is a fixed-point contract count scaled by 100: one unit is 0.01 +// contracts, the minimum granularity of the API's FixedPointCount strings. +type Count int64 + +const ( + dollarsFrac = 6 + dollarsScale = 1_000_000 + countFrac = 2 + countScale = 100 +) + +// ParseDollars parses a FixedPointDollars string such as "0.4500", "12", +// "-3.25", or "0.010000". At most six decimal places are accepted. +func ParseDollars(s string) (Dollars, error) { + n, err := parseFixed(s, dollarsFrac) + if err != nil { + return 0, fmt.Errorf("parse dollars %q: %w", s, err) + } + return Dollars(n), nil +} + +// String formats with four decimals ("0.4500"), the form request fields +// accept. Values with non-zero digits in the fifth or sixth place, such as +// fees, are formatted with six. +func (d Dollars) String() string { + if d%100 == 0 { + return formatFixed(int64(d)/100, 4) + } + return formatFixed(int64(d), dollarsFrac) +} + +func (d Dollars) Float64() float64 { return float64(d) / dollarsScale } + +// Cents truncates toward zero: $0.4567 is 45 cents, -$0.4567 is -45. +func (d Dollars) Cents() int64 { return int64(d) / (dollarsScale / 100) } + +// ParseCount parses a FixedPointCount string such as "10", "10.5", or +// "136.00". At most two decimal places are accepted. +func ParseCount(s string) (Count, error) { + n, err := parseFixed(s, countFrac) + if err != nil { + return 0, fmt.Errorf("parse count %q: %w", s, err) + } + return Count(n), nil +} + +// String formats with two decimals ("10.00"), matching API responses. +func (c Count) String() string { return formatFixed(int64(c), countFrac) } + +func (c Count) Float64() float64 { return float64(c) / countScale } + +func parseFixed(s string, maxFrac int) (int64, error) { + if s == "" { + return 0, errors.New("empty string") + } + digits := s + neg := digits[0] == '-' + if neg { + digits = digits[1:] + } + intPart, fracPart, hasDot := strings.Cut(digits, ".") + if intPart == "" || (hasDot && fracPart == "") { + return 0, errors.New("malformed number") + } + if len(fracPart) > maxFrac { + return 0, fmt.Errorf("more than %d decimal places", maxFrac) + } + var n int64 + for _, part := range [2]string{intPart, fracPart + strings.Repeat("0", maxFrac-len(fracPart))} { + for i := 0; i < len(part); i++ { + c := part[i] + if c < '0' || c > '9' { + return 0, fmt.Errorf("invalid character %q", c) + } + d := int64(c - '0') + if n > (math.MaxInt64-d)/10 { + return 0, errors.New("overflow") + } + n = n*10 + d + } + } + if neg { + n = -n + } + return n, nil +} + +func formatFixed(n int64, frac int) string { + u := uint64(n) + sign := "" + if n < 0 { + u, sign = -u, "-" + } + pow := uint64(1) + for i := 0; i < frac; i++ { + pow *= 10 + } + return fmt.Sprintf("%s%d.%0*d", sign, u/pow, frac, u%pow) +} + +// ParseTime parses the RFC3339 timestamps the API emits: with or without +// fractional seconds, with a Z or numeric offset. A timestamp with no zone +// designator is treated as UTC. +func ParseTime(s string) (time.Time, error) { + t, err := time.Parse(time.RFC3339Nano, s) + if err == nil { + return t, nil + } + if t, err2 := time.Parse("2006-01-02T15:04:05.999999999", s); err2 == nil { + return t, nil + } + return time.Time{}, err +} diff --git a/oddrip/types/decimal_test.go b/oddrip/types/decimal_test.go new file mode 100644 index 0000000..4e62404 --- /dev/null +++ b/oddrip/types/decimal_test.go @@ -0,0 +1,222 @@ +package types + +import ( + "math" + "strings" + "testing" + "time" +) + +func TestParseDollars(t *testing.T) { + for _, tc := range []struct { + in string + want Dollars + str string + }{ + {"0.5600", 560000, "0.5600"}, + {"0.56", 560000, "0.5600"}, + {"0.4500", 450000, "0.4500"}, + {"0.480", 480000, "0.4800"}, + {"0.35", 350000, "0.3500"}, + {"50.0000", 50000000, "50.0000"}, + {"100.0000", 100000000, "100.0000"}, + {"0", 0, "0.0000"}, + {"0.5", 500000, "0.5000"}, + {"1", 1000000, "1.0000"}, + {"99.9999", 99999900, "99.9999"}, + {"-3.25", -3250000, "-3.2500"}, + {"-0.0001", -100, "-0.0001"}, + {"0.010000", 10000, "0.0100"}, + {"0.000000", 0, "0.0000"}, + {"0.000001", 1, "0.000001"}, + {"0.123456", 123456, "0.123456"}, + {"-0.123456", -123456, "-0.123456"}, + {"1234567.891011", 1234567891011, "1234567.891011"}, + } { + got, err := ParseDollars(tc.in) + if err != nil { + t.Errorf("%q: %v", tc.in, err) + continue + } + if got != tc.want { + t.Errorf("%q: got %d want %d", tc.in, got, tc.want) + } + if s := got.String(); s != tc.str { + t.Errorf("%q: String() = %q want %q", tc.in, s, tc.str) + } + back, err := ParseDollars(got.String()) + if err != nil || back != got { + t.Errorf("%q: round trip %q -> %d, %v", tc.in, got.String(), back, err) + } + } +} + +func TestParseDollars_Reject(t *testing.T) { + for _, in := range []string{ + "", "-", ".", "-.", "+1", "+1.00", "--1", "1.", ".5", "1..0", "1.2.3", + "abc", "1a", "a1", "0x10", "1e5", " 1", "1 ", "$1.00", "1,000.00", + "0.1234567", "1.0000000", + "9223372036854775808", "99999999999999999999", + } { + if got, err := ParseDollars(in); err == nil { + t.Errorf("%q: want error, got %d", in, got) + } + } + _, err := ParseDollars("0.1234567") + if err == nil || !strings.Contains(err.Error(), "6 decimal places") { + t.Errorf("precision error: %v", err) + } + _, err = ParseDollars("") + if err == nil || !strings.Contains(err.Error(), "empty") { + t.Errorf("empty error: %v", err) + } +} + +func TestDollars_Cents(t *testing.T) { + for _, tc := range []struct { + in string + want int64 + }{ + {"0.4500", 45}, + {"0.4567", 45}, + {"0.4599", 45}, + {"0.009999", 0}, + {"0.01", 1}, + {"1", 100}, + {"99.9999", 9999}, + {"-3.25", -325}, + {"-0.4567", -45}, + {"-0.009999", 0}, + } { + d, err := ParseDollars(tc.in) + if err != nil { + t.Fatal(err) + } + if got := d.Cents(); got != tc.want { + t.Errorf("%q: Cents() = %d want %d", tc.in, got, tc.want) + } + } +} + +func TestDollars_Float64(t *testing.T) { + for _, tc := range []struct { + in string + want float64 + }{ + {"0.4500", 0.45}, + {"1", 1}, + {"-3.25", -3.25}, + {"0.000001", 0.000001}, + } { + d, err := ParseDollars(tc.in) + if err != nil { + t.Fatal(err) + } + if got := d.Float64(); math.Abs(got-tc.want) > 1e-12 { + t.Errorf("%q: Float64() = %v want %v", tc.in, got, tc.want) + } + } +} + +func TestParseCount(t *testing.T) { + for _, tc := range []struct { + in string + want Count + str string + }{ + {"10.00", 1000, "10.00"}, + {"10.0", 1000, "10.00"}, + {"10", 1000, "10.00"}, + {"2.50", 250, "2.50"}, + {"0.01", 1, "0.01"}, + {"0", 0, "0.00"}, + {"0.5", 50, "0.50"}, + {"1", 100, "1.00"}, + {"136.00", 13600, "136.00"}, + {"33896.00", 3389600, "33896.00"}, + {"-54.00", -5400, "-54.00"}, + {"-0.01", -1, "-0.01"}, + } { + got, err := ParseCount(tc.in) + if err != nil { + t.Errorf("%q: %v", tc.in, err) + continue + } + if got != tc.want { + t.Errorf("%q: got %d want %d", tc.in, got, tc.want) + } + if s := got.String(); s != tc.str { + t.Errorf("%q: String() = %q want %q", tc.in, s, tc.str) + } + back, err := ParseCount(got.String()) + if err != nil || back != got { + t.Errorf("%q: round trip %q -> %d, %v", tc.in, got.String(), back, err) + } + } +} + +func TestParseCount_Reject(t *testing.T) { + for _, in := range []string{ + "", "-", "+10", "10.", ".5", "10.000", "0.001", "1..0", "ten", "1e2", " 10", "10 ", + } { + if got, err := ParseCount(in); err == nil { + t.Errorf("%q: want error, got %d", in, got) + } + } + _, err := ParseCount("10.000") + if err == nil || !strings.Contains(err.Error(), "2 decimal places") { + t.Errorf("precision error: %v", err) + } +} + +func TestCount_Float64(t *testing.T) { + c, err := ParseCount("2.50") + if err != nil { + t.Fatal(err) + } + if c.Float64() != 2.5 { + t.Errorf("Float64() = %v", c.Float64()) + } +} + +func TestParseTime(t *testing.T) { + utc := time.Date(2022, 11, 22, 20, 44, 1, 0, time.UTC) + for _, tc := range []struct { + in string + want time.Time + }{ + {"2022-11-22T20:44:01Z", utc}, + {"2022-11-22T20:44:01.5Z", utc.Add(500 * time.Millisecond)}, + {"2022-11-22T20:44:01.123456Z", utc.Add(123456 * time.Microsecond)}, + {"2022-11-22T20:44:01.123456789Z", utc.Add(123456789)}, + {"2022-11-22T15:44:01-05:00", utc}, + {"2022-11-22T21:44:01.25+01:00", utc.Add(250 * time.Millisecond)}, + {"2022-11-22T20:44:01+00:00", utc}, + {"2022-11-22T20:44:01", utc}, + {"2022-11-22T20:44:01.123456", utc.Add(123456 * time.Microsecond)}, + } { + got, err := ParseTime(tc.in) + if err != nil { + t.Errorf("%q: %v", tc.in, err) + continue + } + if !got.Equal(tc.want) { + t.Errorf("%q: got %v want %v", tc.in, got, tc.want) + } + } +} + +func TestParseTime_Reject(t *testing.T) { + for _, in := range []string{ + "", "garbage", "2022-11-22", "20:44:01", "2022-11-22 20:44:01Z", + "1669149841", "2022-13-01T00:00:00Z", "2022-11-22T25:00:00Z", + } { + if got, err := ParseTime(in); err == nil { + t.Errorf("%q: want error, got %v", in, got) + } + } + _, err := ParseTime("garbage") + if err == nil || !strings.Contains(err.Error(), "garbage") { + t.Errorf("error should quote input: %v", err) + } +} diff --git a/oddrip/types/event.go b/oddrip/types/event.go index ebbd966..02fbec7 100644 --- a/oddrip/types/event.go +++ b/oddrip/types/event.go @@ -1,22 +1,22 @@ package types type EventData struct { - EventTicker string `json:"event_ticker"` - SeriesTicker string `json:"series_ticker"` - SubTitle string `json:"sub_title"` - Title string `json:"title"` - CollateralReturnType string `json:"collateral_return_type"` - MutuallyExclusive bool `json:"mutually_exclusive"` - Category string `json:"category"` - StrikeDate *string `json:"strike_date,omitempty"` - StrikePeriod *string `json:"strike_period,omitempty"` - Markets []Market `json:"markets,omitempty"` - ProductMetadata map[string]interface{} `json:"product_metadata,omitempty"` - SettlementSources []SettlementSource `json:"settlement_sources,omitempty"` - LastUpdatedTs string `json:"last_updated_ts,omitempty"` - FeeTypeOverride string `json:"fee_type_override,omitempty"` - FeeMultiplierOverride *float64 `json:"fee_multiplier_override,omitempty"` - ExchangeIndex int `json:"exchange_index,omitempty"` + EventTicker string `json:"event_ticker"` + SeriesTicker string `json:"series_ticker"` + SubTitle string `json:"sub_title"` + Title string `json:"title"` + CollateralReturnType string `json:"collateral_return_type"` + MutuallyExclusive bool `json:"mutually_exclusive"` + Category string `json:"category"` + StrikeDate *string `json:"strike_date,omitempty"` + StrikePeriod *string `json:"strike_period,omitempty"` + Markets []Market `json:"markets,omitempty"` + ProductMetadata map[string]interface{} `json:"product_metadata,omitempty"` + SettlementSources []SettlementSource `json:"settlement_sources,omitempty"` + LastUpdatedTs string `json:"last_updated_ts,omitempty"` + FeeTypeOverride string `json:"fee_type_override,omitempty"` + FeeMultiplierOverride *float64 `json:"fee_multiplier_override,omitempty"` + ExchangeIndex int `json:"exchange_index,omitempty"` } type GetEventsOpts struct { @@ -32,31 +32,31 @@ type GetEventsOpts struct { } type GetEventsResponse struct { - Events []EventData `json:"events"` + Events []EventData `json:"events"` Milestones []Milestone `json:"milestones,omitempty"` - Cursor string `json:"cursor"` + Cursor string `json:"cursor"` } type Milestone struct { - ID string `json:"id"` - Category string `json:"category"` - Type string `json:"type"` - StartDate string `json:"start_date"` - EndDate *string `json:"end_date,omitempty"` - RelatedEventTickers []string `json:"related_event_tickers"` - Title string `json:"title"` - NotificationMessage string `json:"notification_message"` - SourceID *string `json:"source_id,omitempty"` - Details map[string]interface{} `json:"details"` - PrimaryEventTickers []string `json:"primary_event_tickers"` - LastUpdatedTs string `json:"last_updated_ts"` + ID string `json:"id"` + Category string `json:"category"` + Type string `json:"type"` + StartDate string `json:"start_date"` + EndDate *string `json:"end_date,omitempty"` + RelatedEventTickers []string `json:"related_event_tickers"` + Title string `json:"title"` + NotificationMessage string `json:"notification_message"` + SourceID *string `json:"source_id,omitempty"` + Details map[string]interface{} `json:"details"` + PrimaryEventTickers []string `json:"primary_event_tickers"` + LastUpdatedTs string `json:"last_updated_ts"` } type GetMultivariateEventsOpts struct { - Limit *int64 - Cursor string - SeriesTicker string - CollectionTicker string + Limit *int64 + Cursor string + SeriesTicker string + CollectionTicker string WithNestedMarkets *bool } @@ -86,10 +86,76 @@ type SettlementSource struct { } type GetEventMetadataResponse struct { - ImageURL string `json:"image_url"` - FeaturedImageURL string `json:"featured_image_url,omitempty"` - MarketDetails []MarketMetadata `json:"market_details"` + ImageURL string `json:"image_url"` + FeaturedImageURL string `json:"featured_image_url,omitempty"` + MarketDetails []MarketMetadata `json:"market_details"` SettlementSources []SettlementSource `json:"settlement_sources"` - Competition *string `json:"competition,omitempty"` - CompetitionScope *string `json:"competition_scope,omitempty"` + Competition *string `json:"competition,omitempty"` + CompetitionScope *string `json:"competition_scope,omitempty"` +} + +const ( + CollectionStatusUnopened = "unopened" + CollectionStatusOpen = "open" + CollectionStatusClosed = "closed" +) + +type AssociatedEvent struct { + Ticker string `json:"ticker"` + IsYesOnly bool `json:"is_yes_only"` + SizeMax *int `json:"size_max,omitempty"` + SizeMin *int `json:"size_min,omitempty"` + ActiveQuoters []string `json:"active_quoters"` +} + +type MultivariateEventCollection struct { + CollectionTicker string `json:"collection_ticker"` + SeriesTicker string `json:"series_ticker"` + ExchangeIndex int `json:"exchange_index,omitempty"` + Title string `json:"title"` + Description string `json:"description"` + OpenDate string `json:"open_date"` + CloseDate string `json:"close_date"` + AssociatedEvents []AssociatedEvent `json:"associated_events"` + AssociatedEventTickers []string `json:"associated_event_tickers"` + IsOrdered bool `json:"is_ordered"` + IsSingleMarketPerEvent bool `json:"is_single_market_per_event"` + IsAllYes bool `json:"is_all_yes"` + SizeMin int `json:"size_min"` + SizeMax int `json:"size_max"` + FunctionalDescription string `json:"functional_description"` +} + +type GetMultivariateEventCollectionResponse struct { + MultivariateContract MultivariateEventCollection `json:"multivariate_contract"` +} + +type GetMultivariateEventCollectionsResponse struct { + MultivariateContracts []MultivariateEventCollection `json:"multivariate_contracts"` + Cursor string `json:"cursor,omitempty"` +} + +type GetMultivariateEventCollectionsOpts struct { + Status string + AssociatedEventTicker string + SeriesTicker string + Limit *int64 + Cursor string +} + +type TickerPair struct { + MarketTicker string `json:"market_ticker"` + EventTicker string `json:"event_ticker"` + Side string `json:"side"` +} + +type CreateMarketInMultivariateEventCollectionRequest struct { + SelectedMarkets []TickerPair `json:"selected_markets"` + WithMarketPayload *bool `json:"with_market_payload,omitempty"` +} + +type CreateMarketInMultivariateEventCollectionResponse struct { + EventTicker string `json:"event_ticker"` + MarketTicker string `json:"market_ticker"` + Market *Market `json:"market,omitempty"` } diff --git a/oddrip/types/exchange.go b/oddrip/types/exchange.go index c1287a9..4c1a460 100644 --- a/oddrip/types/exchange.go +++ b/oddrip/types/exchange.go @@ -3,9 +3,9 @@ package types type ExchangeIndexStatus struct { ExchangeIndex int `json:"exchange_index"` Description string `json:"description,omitempty"` - ExchangeActive bool `json:"exchange_active"` - TradingActive bool `json:"trading_active"` - IntraExchangeTransfersActive bool `json:"intra_exchange_transfers_active"` + ExchangeActive bool `json:"exchange_active"` + TradingActive bool `json:"trading_active"` + IntraExchangeTransfersActive bool `json:"intra_exchange_transfers_active"` } type ExchangeStatus struct { @@ -17,10 +17,10 @@ type ExchangeStatus struct { } type GetHistoricalCutoffResponse struct { - MarketSettledTs string `json:"market_settled_ts"` - TradesCreatedTs string `json:"trades_created_ts"` - OrdersUpdatedTs string `json:"orders_updated_ts"` - MarketPositionsLastUpdatedTs string `json:"market_positions_last_updated_ts,omitempty"` + MarketSettledTs string `json:"market_settled_ts"` + TradesCreatedTs string `json:"trades_created_ts"` + OrdersUpdatedTs string `json:"orders_updated_ts"` + MarketPositionsLastUpdatedTs string `json:"market_positions_last_updated_ts,omitempty"` } type GetUserDataTimestampResponse struct { diff --git a/oddrip/types/historical.go b/oddrip/types/historical.go index 0807f4d..c8ba0dc 100644 --- a/oddrip/types/historical.go +++ b/oddrip/types/historical.go @@ -17,16 +17,16 @@ type PriceDistributionHistorical struct { } type MarketCandlestickHistorical struct { - EndPeriodTs int64 `json:"end_period_ts"` - YesBid BidAskDistributionHistorical `json:"yes_bid"` - YesAsk BidAskDistributionHistorical `json:"yes_ask"` - Price PriceDistributionHistorical `json:"price"` - Volume string `json:"volume"` - OpenInterest string `json:"open_interest"` + EndPeriodTs int64 `json:"end_period_ts"` + YesBid BidAskDistributionHistorical `json:"yes_bid"` + YesAsk BidAskDistributionHistorical `json:"yes_ask"` + Price PriceDistributionHistorical `json:"price"` + Volume string `json:"volume"` + OpenInterest string `json:"open_interest"` } type GetMarketCandlesticksHistoricalResponse struct { - Ticker string `json:"ticker"` + Ticker string `json:"ticker"` Candlesticks []MarketCandlestickHistorical `json:"candlesticks"` } @@ -46,8 +46,8 @@ type GetHistoricalMarketCandlesticksOpts struct { } type GetHistoricalArchiveOpts struct { - Ticker string - MaxTs *int64 - Limit *int64 - Cursor string + Ticker string + MaxTs *int64 + Limit *int64 + Cursor string } diff --git a/oddrip/types/market.go b/oddrip/types/market.go index 8ffe2c8..b123726 100644 --- a/oddrip/types/market.go +++ b/oddrip/types/market.go @@ -16,59 +16,59 @@ type MveSelectedLeg struct { } type Market struct { - Ticker string `json:"ticker"` - EventTicker string `json:"event_ticker"` - MarketType string `json:"market_type"` - Title string `json:"title,omitempty"` - Subtitle string `json:"subtitle,omitempty"` - YesSubTitle string `json:"yes_sub_title"` - NoSubTitle string `json:"no_sub_title"` - CreatedTime string `json:"created_time"` - UpdatedTime string `json:"updated_time"` - OpenTime string `json:"open_time"` - CloseTime string `json:"close_time"` - ExpectedExpirationTime *string `json:"expected_expiration_time,omitempty"` - ExpirationTime string `json:"expiration_time,omitempty"` - LatestExpirationTime string `json:"latest_expiration_time"` - SettlementTimerSeconds int `json:"settlement_timer_seconds"` - Status string `json:"status"` - NotionalValueDollars string `json:"notional_value_dollars"` - YesBidDollars string `json:"yes_bid_dollars"` - YesAskDollars string `json:"yes_ask_dollars"` - NoBidDollars string `json:"no_bid_dollars"` - NoAskDollars string `json:"no_ask_dollars"` - YesBidSizeFp string `json:"yes_bid_size_fp"` - YesAskSizeFp string `json:"yes_ask_size_fp"` - LastPriceDollars string `json:"last_price_dollars"` - PreviousYesBidDollars string `json:"previous_yes_bid_dollars"` - PreviousYesAskDollars string `json:"previous_yes_ask_dollars"` - PreviousPriceDollars string `json:"previous_price_dollars"` - VolumeFp string `json:"volume_fp"` - Volume24hFp string `json:"volume_24h_fp"` - LiquidityDollars string `json:"liquidity_dollars,omitempty"` - OpenInterestFp string `json:"open_interest_fp"` - Result string `json:"result"` - CanCloseEarly bool `json:"can_close_early"` - SettlementValueDollars *string `json:"settlement_value_dollars,omitempty"` - SettlementTs *string `json:"settlement_ts,omitempty"` - OccurrenceDatetime *string `json:"occurrence_datetime,omitempty"` - ExpirationValue string `json:"expiration_value"` - FeeWaiverExpirationTime *string `json:"fee_waiver_expiration_time,omitempty"` - EarlyCloseCondition string `json:"early_close_condition,omitempty"` - StrikeType string `json:"strike_type,omitempty"` - FloorStrike *float64 `json:"floor_strike,omitempty"` - CapStrike *float64 `json:"cap_strike,omitempty"` - FunctionalStrike *string `json:"functional_strike,omitempty"` - CustomStrike json.RawMessage `json:"custom_strike,omitempty"` - RulesPrimary string `json:"rules_primary"` - RulesSecondary string `json:"rules_secondary"` - PriceLevelStructure string `json:"price_level_structure"` - PriceRanges []PriceRange `json:"price_ranges"` - MveCollectionTicker string `json:"mve_collection_ticker,omitempty"` + Ticker string `json:"ticker"` + EventTicker string `json:"event_ticker"` + MarketType string `json:"market_type"` + Title string `json:"title,omitempty"` + Subtitle string `json:"subtitle,omitempty"` + YesSubTitle string `json:"yes_sub_title"` + NoSubTitle string `json:"no_sub_title"` + CreatedTime string `json:"created_time"` + UpdatedTime string `json:"updated_time"` + OpenTime string `json:"open_time"` + CloseTime string `json:"close_time"` + ExpectedExpirationTime *string `json:"expected_expiration_time,omitempty"` + ExpirationTime string `json:"expiration_time,omitempty"` + LatestExpirationTime string `json:"latest_expiration_time"` + SettlementTimerSeconds int `json:"settlement_timer_seconds"` + Status string `json:"status"` + NotionalValueDollars string `json:"notional_value_dollars"` + YesBidDollars string `json:"yes_bid_dollars"` + YesAskDollars string `json:"yes_ask_dollars"` + NoBidDollars string `json:"no_bid_dollars"` + NoAskDollars string `json:"no_ask_dollars"` + YesBidSizeFp string `json:"yes_bid_size_fp"` + YesAskSizeFp string `json:"yes_ask_size_fp"` + LastPriceDollars string `json:"last_price_dollars"` + PreviousYesBidDollars string `json:"previous_yes_bid_dollars"` + PreviousYesAskDollars string `json:"previous_yes_ask_dollars"` + PreviousPriceDollars string `json:"previous_price_dollars"` + VolumeFp string `json:"volume_fp"` + Volume24hFp string `json:"volume_24h_fp"` + LiquidityDollars string `json:"liquidity_dollars,omitempty"` + OpenInterestFp string `json:"open_interest_fp"` + Result string `json:"result"` + CanCloseEarly bool `json:"can_close_early"` + SettlementValueDollars *string `json:"settlement_value_dollars,omitempty"` + SettlementTs *string `json:"settlement_ts,omitempty"` + OccurrenceDatetime *string `json:"occurrence_datetime,omitempty"` + ExpirationValue string `json:"expiration_value"` + FeeWaiverExpirationTime *string `json:"fee_waiver_expiration_time,omitempty"` + EarlyCloseCondition string `json:"early_close_condition,omitempty"` + StrikeType string `json:"strike_type,omitempty"` + FloorStrike *float64 `json:"floor_strike,omitempty"` + CapStrike *float64 `json:"cap_strike,omitempty"` + FunctionalStrike *string `json:"functional_strike,omitempty"` + CustomStrike json.RawMessage `json:"custom_strike,omitempty"` + RulesPrimary string `json:"rules_primary"` + RulesSecondary string `json:"rules_secondary"` + PriceLevelStructure string `json:"price_level_structure"` + PriceRanges []PriceRange `json:"price_ranges"` + MveCollectionTicker string `json:"mve_collection_ticker,omitempty"` MveSelectedLegs []MveSelectedLeg `json:"mve_selected_legs,omitempty"` - PrimaryParticipantKey *string `json:"primary_participant_key,omitempty"` - IsProvisional *bool `json:"is_provisional,omitempty"` - ExchangeIndex int `json:"exchange_index,omitempty"` + PrimaryParticipantKey *string `json:"primary_participant_key,omitempty"` + IsProvisional *bool `json:"is_provisional,omitempty"` + ExchangeIndex int `json:"exchange_index,omitempty"` } type GetMarketResponse struct { diff --git a/oddrip/types/order.go b/oddrip/types/order.go index 539ade3..72e6627 100644 --- a/oddrip/types/order.go +++ b/oddrip/types/order.go @@ -1,33 +1,33 @@ package types type Order struct { - OrderID string `json:"order_id"` - UserID string `json:"user_id"` - ClientOrderID string `json:"client_order_id"` - Ticker string `json:"ticker"` - Side string `json:"side,omitempty"` - Action string `json:"action,omitempty"` - OutcomeSide string `json:"outcome_side"` - BookSide string `json:"book_side"` - Type string `json:"type"` - Status string `json:"status"` - YesPriceDollars string `json:"yes_price_dollars"` - NoPriceDollars string `json:"no_price_dollars"` - FillCountFp string `json:"fill_count_fp"` - RemainingCountFp string `json:"remaining_count_fp"` - InitialCountFp string `json:"initial_count_fp"` - TakerFeesDollars string `json:"taker_fees_dollars"` - MakerFeesDollars string `json:"maker_fees_dollars"` - TakerFillCostDollars string `json:"taker_fill_cost_dollars"` - MakerFillCostDollars string `json:"maker_fill_cost_dollars"` - ExpirationTime *string `json:"expiration_time,omitempty"` - CreatedTime *string `json:"created_time,omitempty"` - LastUpdateTime *string `json:"last_update_time,omitempty"` - SelfTradePreventionType *string `json:"self_trade_prevention_type,omitempty"` - OrderGroupID *string `json:"order_group_id,omitempty"` - CancelOrderOnPause *bool `json:"cancel_order_on_pause,omitempty"` - SubaccountNumber *int `json:"subaccount_number,omitempty"` - ExchangeIndex int `json:"exchange_index,omitempty"` + OrderID string `json:"order_id"` + UserID string `json:"user_id"` + ClientOrderID string `json:"client_order_id"` + Ticker string `json:"ticker"` + Side string `json:"side,omitempty"` + Action string `json:"action,omitempty"` + OutcomeSide string `json:"outcome_side"` + BookSide string `json:"book_side"` + Type string `json:"type"` + Status string `json:"status"` + YesPriceDollars string `json:"yes_price_dollars"` + NoPriceDollars string `json:"no_price_dollars"` + FillCountFp string `json:"fill_count_fp"` + RemainingCountFp string `json:"remaining_count_fp"` + InitialCountFp string `json:"initial_count_fp"` + TakerFeesDollars string `json:"taker_fees_dollars"` + MakerFeesDollars string `json:"maker_fees_dollars"` + TakerFillCostDollars string `json:"taker_fill_cost_dollars"` + MakerFillCostDollars string `json:"maker_fill_cost_dollars"` + ExpirationTime *string `json:"expiration_time,omitempty"` + CreatedTime *string `json:"created_time,omitempty"` + LastUpdateTime *string `json:"last_update_time,omitempty"` + SelfTradePreventionType *string `json:"self_trade_prevention_type,omitempty"` + OrderGroupID *string `json:"order_group_id,omitempty"` + CancelOrderOnPause *bool `json:"cancel_order_on_pause,omitempty"` + SubaccountNumber *int `json:"subaccount_number,omitempty"` + ExchangeIndex int `json:"exchange_index,omitempty"` } type CreateOrderRequest struct { diff --git a/oddrip/types/order_group.go b/oddrip/types/order_group.go new file mode 100644 index 0000000..a79ffeb --- /dev/null +++ b/oddrip/types/order_group.go @@ -0,0 +1,55 @@ +package types + +type OrderGroup struct { + ID string `json:"id"` + ContractsLimitFp string `json:"contracts_limit_fp,omitempty"` + IsAutoCancelEnabled bool `json:"is_auto_cancel_enabled"` + ExchangeIndex int `json:"exchange_index,omitempty"` +} + +type GetOrderGroupsResponse struct { + OrderGroups []OrderGroup `json:"order_groups"` +} + +// GetOrderGroupsOpts: Subaccount nil returns groups across all subaccounts. +type GetOrderGroupsOpts struct { + Subaccount *int +} + +type GetOrderGroupOpts struct { + Subaccount *int +} + +// OrderGroupOpts carries the query parameters for delete, reset, trigger, and +// limit updates. Both default to 0 (primary account, first shard) when nil. +type OrderGroupOpts struct { + Subaccount *int + ExchangeIndex *int +} + +// CreateOrderGroupRequest requires ContractsLimit or ContractsLimitFp; if both +// are set they must match. +type CreateOrderGroupRequest struct { + Subaccount *int `json:"subaccount,omitempty"` + ContractsLimit *int64 `json:"contracts_limit,omitempty"` + ContractsLimitFp *string `json:"contracts_limit_fp,omitempty"` + ExchangeIndex *int `json:"exchange_index,omitempty"` +} + +type CreateOrderGroupResponse struct { + OrderGroupID string `json:"order_group_id"` + Subaccount int `json:"subaccount"` + ExchangeIndex int `json:"exchange_index,omitempty"` +} + +type GetOrderGroupResponse struct { + IsAutoCancelEnabled bool `json:"is_auto_cancel_enabled"` + ContractsLimitFp string `json:"contracts_limit_fp,omitempty"` + Orders []string `json:"orders"` + ExchangeIndex int `json:"exchange_index,omitempty"` +} + +type UpdateOrderGroupLimitRequest struct { + ContractsLimit *int64 `json:"contracts_limit,omitempty"` + ContractsLimitFp *string `json:"contracts_limit_fp,omitempty"` +} diff --git a/oddrip/types/order_v2.go b/oddrip/types/order_v2.go index 5a77b29..dd78347 100644 --- a/oddrip/types/order_v2.go +++ b/oddrip/types/order_v2.go @@ -18,13 +18,13 @@ type CreateOrderV2Request struct { } type CreateOrderV2Response struct { - OrderID string `json:"order_id"` - ClientOrderID string `json:"client_order_id,omitempty"` - FillCount string `json:"fill_count"` - RemainingCount string `json:"remaining_count"` - AverageFillPrice string `json:"average_fill_price,omitempty"` - AverageFeePaid string `json:"average_fee_paid,omitempty"` - TsMs int64 `json:"ts_ms"` + OrderID string `json:"order_id"` + ClientOrderID string `json:"client_order_id,omitempty"` + FillCount string `json:"fill_count"` + RemainingCount string `json:"remaining_count"` + AverageFillPrice string `json:"average_fill_price,omitempty"` + AverageFeePaid string `json:"average_fee_paid,omitempty"` + TsMs int64 `json:"ts_ms"` } // CancelOrderV2Opts carries the query parameters for cancelling a single @@ -78,10 +78,10 @@ type DecreaseOrderV2Request struct { } type DecreaseOrderV2Response struct { - OrderID string `json:"order_id"` - ClientOrderID string `json:"client_order_id,omitempty"` - RemainingCount string `json:"remaining_count"` - TsMs int64 `json:"ts_ms"` + OrderID string `json:"order_id"` + ClientOrderID string `json:"client_order_id,omitempty"` + RemainingCount string `json:"remaining_count"` + TsMs int64 `json:"ts_ms"` } type BatchCreateOrdersV2Request struct { @@ -89,14 +89,14 @@ type BatchCreateOrdersV2Request struct { } type BatchCreateOrdersV2IndividualResponse struct { - OrderID string `json:"order_id,omitempty"` - ClientOrderID *string `json:"client_order_id,omitempty"` - FillCount *string `json:"fill_count,omitempty"` - RemainingCount *string `json:"remaining_count,omitempty"` - AverageFillPrice *string `json:"average_fill_price,omitempty"` - AverageFeePaid *string `json:"average_fee_paid,omitempty"` - TsMs *int64 `json:"ts_ms,omitempty"` - Error *ErrorResponse `json:"error,omitempty"` + OrderID string `json:"order_id,omitempty"` + ClientOrderID *string `json:"client_order_id,omitempty"` + FillCount *string `json:"fill_count,omitempty"` + RemainingCount *string `json:"remaining_count,omitempty"` + AverageFillPrice *string `json:"average_fill_price,omitempty"` + AverageFeePaid *string `json:"average_fee_paid,omitempty"` + TsMs *int64 `json:"ts_ms,omitempty"` + Error *ErrorResponse `json:"error,omitempty"` } type BatchCreateOrdersV2Response struct { diff --git a/oddrip/types/portfolio.go b/oddrip/types/portfolio.go index 2bc4eb7..bad2592 100644 --- a/oddrip/types/portfolio.go +++ b/oddrip/types/portfolio.go @@ -103,9 +103,9 @@ type ApiUsageLevelGrant struct { type GetAccountApiLimitsResponse struct { UsageTier string `json:"usage_tier"` - Read BucketLimit `json:"read"` - Write BucketLimit `json:"write"` - Grants []ApiUsageLevelGrant `json:"grants"` + Read BucketLimit `json:"read"` + Write BucketLimit `json:"write"` + Grants []ApiUsageLevelGrant `json:"grants"` } type EndpointTokenCost struct { @@ -256,3 +256,10 @@ type SetTargetBalanceAllocationRequest struct { Allocations []TargetBalanceAllocation `json:"allocations"` RestingMarginReservation string `json:"resting_margin_reservation,omitempty"` } + +// GetPortfolioRestingOrderTotalValueResponse: TotalRestingOrderValue is in +// cents; the breakdown balances are fixed-point dollar strings. +type GetPortfolioRestingOrderTotalValueResponse struct { + TotalRestingOrderValue int64 `json:"total_resting_order_value"` + RestingOrderValueBreakdown []IndexedBalance `json:"resting_order_value_breakdown"` +} diff --git a/oddrip/types/series.go b/oddrip/types/series.go new file mode 100644 index 0000000..f13f3ed --- /dev/null +++ b/oddrip/types/series.go @@ -0,0 +1,141 @@ +package types + +const ( + FeeTypeQuadratic = "quadratic" + FeeTypeQuadraticWithMakerFees = "quadratic_with_maker_fees" + FeeTypeQuadraticWithComboMakerFees = "quadratic_with_combo_maker_fees" + FeeTypeFlat = "flat" +) + +type Series struct { + Ticker string `json:"ticker"` + Frequency string `json:"frequency"` + Title string `json:"title"` + Category string `json:"category"` + Tags []string `json:"tags"` + SettlementSources []SettlementSource `json:"settlement_sources"` + ContractURL string `json:"contract_url"` + ContractTermsURL string `json:"contract_terms_url"` + ProductMetadata map[string]interface{} `json:"product_metadata,omitempty"` + FeeType string `json:"fee_type"` + FeeMultiplier float64 `json:"fee_multiplier"` + AdditionalProhibitions []string `json:"additional_prohibitions"` + VolumeFp string `json:"volume_fp,omitempty"` + LastUpdatedTs string `json:"last_updated_ts,omitempty"` + ExchangeIndex int `json:"exchange_index,omitempty"` +} + +type GetSeriesResponse struct { + Series Series `json:"series"` +} + +type GetSeriesListResponse struct { + Series []Series `json:"series"` +} + +type GetSeriesListOpts struct { + Category string + Tags string + IncludeProductMetadata *bool + IncludeVolume *bool + MinUpdatedTs *int64 +} + +type GetSeriesOpts struct { + IncludeVolume *bool +} + +type BidAskDistribution struct { + OpenDollars string `json:"open_dollars"` + LowDollars string `json:"low_dollars"` + HighDollars string `json:"high_dollars"` + CloseDollars string `json:"close_dollars"` +} + +type PriceDistribution struct { + OpenDollars *string `json:"open_dollars,omitempty"` + LowDollars *string `json:"low_dollars,omitempty"` + HighDollars *string `json:"high_dollars,omitempty"` + CloseDollars *string `json:"close_dollars,omitempty"` + MeanDollars *string `json:"mean_dollars,omitempty"` + PreviousDollars *string `json:"previous_dollars,omitempty"` + MinDollars *string `json:"min_dollars,omitempty"` + MaxDollars *string `json:"max_dollars,omitempty"` +} + +type MarketCandlestick struct { + EndPeriodTs int64 `json:"end_period_ts"` + YesBid BidAskDistribution `json:"yes_bid"` + YesAsk BidAskDistribution `json:"yes_ask"` + Price PriceDistribution `json:"price"` + VolumeFp string `json:"volume_fp"` + OpenInterestFp string `json:"open_interest_fp"` +} + +type GetMarketCandlesticksResponse struct { + Ticker string `json:"ticker"` + Candlesticks []MarketCandlestick `json:"candlesticks"` +} + +type GetMarketCandlesticksOpts struct { + StartTs int64 + EndTs int64 + PeriodInterval int + IncludeLatestBeforeStart *bool +} + +type MarketCandlesticksResponse struct { + MarketTicker string `json:"market_ticker"` + Candlesticks []MarketCandlestick `json:"candlesticks"` +} + +type BatchGetMarketCandlesticksResponse struct { + Markets []MarketCandlesticksResponse `json:"markets"` +} + +type BatchGetMarketCandlesticksOpts struct { + MarketTickers string + StartTs int64 + EndTs int64 + PeriodInterval int + IncludeLatestBeforeStart *bool +} + +type GetEventCandlesticksResponse struct { + MarketTickers []string `json:"market_tickers"` + MarketCandlesticks [][]MarketCandlestick `json:"market_candlesticks"` + AdjustedEndTs int64 `json:"adjusted_end_ts"` +} + +type GetEventCandlesticksOpts struct { + StartTs int64 + EndTs int64 + PeriodInterval int +} + +type PercentilePoint struct { + Percentile int `json:"percentile"` + RawNumericalForecast float64 `json:"raw_numerical_forecast"` + NumericalForecast float64 `json:"numerical_forecast"` + FormattedForecast string `json:"formatted_forecast"` +} + +type ForecastPercentilesPoint struct { + EventTicker string `json:"event_ticker"` + EndPeriodTs int64 `json:"end_period_ts"` + PeriodInterval int `json:"period_interval"` + PercentilePoints []PercentilePoint `json:"percentile_points"` +} + +type GetEventForecastPercentilesHistoryResponse struct { + ForecastHistory []ForecastPercentilesPoint `json:"forecast_history"` +} + +// GetEventForecastPercentilesHistoryOpts: Percentiles holds 1-10 values in +// 0-9999; PeriodInterval is 0 (5-second), 1, 60, or 1440. +type GetEventForecastPercentilesHistoryOpts struct { + Percentiles []int + StartTs int64 + EndTs int64 + PeriodInterval int +} diff --git a/oddrip/types/series_extra_test.go b/oddrip/types/series_extra_test.go new file mode 100644 index 0000000..4bbd0cd --- /dev/null +++ b/oddrip/types/series_extra_test.go @@ -0,0 +1,82 @@ +package types + +import ( + "encoding/json" + "testing" +) + +func TestMarketCandlestick_NullPrices(t *testing.T) { + const payload = `{ + "end_period_ts": 1700003600, + "yes_bid": {"open_dollars":"0.5500","low_dollars":"0.5400","high_dollars":"0.5700","close_dollars":"0.5600"}, + "yes_ask": {"open_dollars":"0.5700","low_dollars":"0.5600","high_dollars":"0.5900","close_dollars":"0.5800"}, + "price": {"open_dollars":null,"low_dollars":null,"high_dollars":null,"close_dollars":null,"mean_dollars":null,"previous_dollars":"0.5600","min_dollars":"0.1000","max_dollars":"0.9000"}, + "volume_fp": "10.00", + "open_interest_fp": "100.00" + }` + var c MarketCandlestick + if err := json.Unmarshal([]byte(payload), &c); err != nil { + t.Fatal(err) + } + if c.Price.OpenDollars != nil || c.Price.CloseDollars != nil || c.Price.MeanDollars != nil { + t.Fatalf("null prices should stay nil: %+v", c.Price) + } + if c.Price.PreviousDollars == nil || *c.Price.PreviousDollars != "0.5600" || + c.Price.MinDollars == nil || *c.Price.MinDollars != "0.1000" || + c.Price.MaxDollars == nil || *c.Price.MaxDollars != "0.9000" { + t.Fatalf("price: %+v", c.Price) + } + out, err := json.Marshal(c) + if err != nil { + t.Fatal(err) + } + var round map[string]json.RawMessage + if err := json.Unmarshal(out, &round); err != nil { + t.Fatal(err) + } + var price map[string]string + if err := json.Unmarshal(round["price"], &price); err != nil { + t.Fatal(err) + } + if _, ok := price["open_dollars"]; ok { + t.Fatalf("nil prices should be omitted on marshal: %s", round["price"]) + } + if price["previous_dollars"] != "0.5600" { + t.Fatalf("price: %s", round["price"]) + } +} + +func TestSeries_NullableArrays(t *testing.T) { + const payload = `{"series":{"ticker":"S","frequency":"daily","title":"T","category":"C","tags":null,"settlement_sources":null,"contract_url":"","contract_terms_url":"","fee_type":"flat","fee_multiplier":1,"additional_prohibitions":null}}` + var out GetSeriesResponse + if err := json.Unmarshal([]byte(payload), &out); err != nil { + t.Fatal(err) + } + if out.Series.Tags != nil || out.Series.SettlementSources != nil || out.Series.AdditionalProhibitions != nil { + t.Fatalf("null arrays should be nil: %+v", out.Series) + } + if out.Series.FeeType != FeeTypeFlat || out.Series.VolumeFp != "" || out.Series.ProductMetadata != nil { + t.Fatalf("series: %+v", out.Series) + } +} + +func TestCreateOrderGroupRequest_OmitsUnset(t *testing.T) { + limit := "10.00" + out, err := json.Marshal(CreateOrderGroupRequest{ContractsLimitFp: &limit}) + if err != nil { + t.Fatal(err) + } + if string(out) != `{"contracts_limit_fp":"10.00"}` { + t.Fatalf("got %s", out) + } +} + +func TestApplySubaccountTransferRequest_KeepsZeroSubaccount(t *testing.T) { + out, err := json.Marshal(ApplySubaccountTransferRequest{ClientTransferID: "id", FromSubaccount: 0, ToSubaccount: 1, AmountCents: 100}) + if err != nil { + t.Fatal(err) + } + if string(out) != `{"client_transfer_id":"id","from_subaccount":0,"to_subaccount":1,"amount_cents":100}` { + t.Fatalf("got %s", out) + } +} diff --git a/oddrip/types/subaccount.go b/oddrip/types/subaccount.go new file mode 100644 index 0000000..d9d0ae8 --- /dev/null +++ b/oddrip/types/subaccount.go @@ -0,0 +1,64 @@ +package types + +type CreateSubaccountRequest struct { + ExchangeIndex *int `json:"exchange_index,omitempty"` +} + +type CreateSubaccountResponse struct { + SubaccountNumber int `json:"subaccount_number"` +} + +// ApplySubaccountTransferRequest moves AmountCents between subaccounts; 0 is +// the primary account, 1-63 are numbered subaccounts. +type ApplySubaccountTransferRequest struct { + ClientTransferID string `json:"client_transfer_id"` + FromSubaccount int `json:"from_subaccount"` + ToSubaccount int `json:"to_subaccount"` + AmountCents int64 `json:"amount_cents"` + ExchangeIndex *int `json:"exchange_index,omitempty"` +} + +type SubaccountBalance struct { + SubaccountNumber int `json:"subaccount_number"` + ExchangeIndex int `json:"exchange_index"` + Balance string `json:"balance"` + UpdatedTs int64 `json:"updated_ts"` +} + +type GetSubaccountBalancesResponse struct { + SubaccountBalances []SubaccountBalance `json:"subaccount_balances"` +} + +type SubaccountTransfer struct { + TransferID string `json:"transfer_id"` + FromSubaccount int `json:"from_subaccount"` + ToSubaccount int `json:"to_subaccount"` + AmountCents int64 `json:"amount_cents"` + CreatedTs int64 `json:"created_ts"` + ExchangeIndex int `json:"exchange_index"` +} + +type GetSubaccountTransfersResponse struct { + Transfers []SubaccountTransfer `json:"transfers"` + Cursor string `json:"cursor,omitempty"` +} + +type GetSubaccountTransfersOpts struct { + Limit *int64 + Cursor string +} + +type UpdateSubaccountNettingRequest struct { + SubaccountNumber int `json:"subaccount_number"` + Enabled bool `json:"enabled"` +} + +type SubaccountNettingConfig struct { + SubaccountNumber int `json:"subaccount_number"` + Enabled bool `json:"enabled"` + ExchangeIndex int `json:"exchange_index"` +} + +type GetSubaccountNettingResponse struct { + NettingConfigs []SubaccountNettingConfig `json:"netting_configs"` +} diff --git a/oddrip/types/ws.go b/oddrip/types/ws.go index 04958f1..3140cb0 100644 --- a/oddrip/types/ws.go +++ b/oddrip/types/ws.go @@ -19,15 +19,15 @@ const ( ) const ( - WSUpdateSubscriptionAddMarkets = "add_markets" - WSUpdateSubscriptionDeleteMarkets = "delete_markets" - WSUpdateSubscriptionGetSnapshot = "get_snapshot" - WSUpdateSubscriptionSubscribeUnderlyings = "subscribe_underlyings" + WSUpdateSubscriptionAddMarkets = "add_markets" + WSUpdateSubscriptionDeleteMarkets = "delete_markets" + WSUpdateSubscriptionGetSnapshot = "get_snapshot" + WSUpdateSubscriptionSubscribeUnderlyings = "subscribe_underlyings" WSUpdateSubscriptionUnsubscribeUnderlyings = "unsubscribe_underlyings" - WSUpdateSubscriptionUnderlyingList = "underlying_list" - WSUpdateSubscriptionSubscribeIndices = "subscribe_indices" - WSUpdateSubscriptionUnsubscribeIndices = "unsubscribe_indices" - WSUpdateSubscriptionIndexList = "indexlist" + WSUpdateSubscriptionUnderlyingList = "underlying_list" + WSUpdateSubscriptionSubscribeIndices = "subscribe_indices" + WSUpdateSubscriptionUnsubscribeIndices = "unsubscribe_indices" + WSUpdateSubscriptionIndexList = "indexlist" ) type SubscribeParams struct { @@ -60,16 +60,16 @@ type UnsubscribeCommand struct { } type UpdateSubscriptionParams struct { - SID *int `json:"sid,omitempty"` - Sids []int `json:"sids,omitempty"` - MarketTicker string `json:"market_ticker,omitempty"` - MarketTickers []string `json:"market_tickers,omitempty"` - MarketID string `json:"market_id,omitempty"` - MarketIDs []string `json:"market_ids,omitempty"` - UnderlyingTickers []string `json:"underlying_tickers,omitempty"` - IndexIDs []string `json:"index_ids,omitempty"` - SendInitialSnapshot *bool `json:"send_initial_snapshot,omitempty"` - Action string `json:"action"` + SID *int `json:"sid,omitempty"` + Sids []int `json:"sids,omitempty"` + MarketTicker string `json:"market_ticker,omitempty"` + MarketTickers []string `json:"market_tickers,omitempty"` + MarketID string `json:"market_id,omitempty"` + MarketIDs []string `json:"market_ids,omitempty"` + UnderlyingTickers []string `json:"underlying_tickers,omitempty"` + IndexIDs []string `json:"index_ids,omitempty"` + SendInitialSnapshot *bool `json:"send_initial_snapshot,omitempty"` + Action string `json:"action"` } type UpdateSubscriptionCommand struct { @@ -169,41 +169,43 @@ type MarketLifecycleAdditionalMetadata struct { } type MarketLifecycleV2Msg struct { - EventType string `json:"event_type"` - MarketTicker string `json:"market_ticker"` - OpenTs *int64 `json:"open_ts,omitempty"` - CloseTs *int64 `json:"close_ts,omitempty"` - Result string `json:"result,omitempty"` - DeterminationTs *int64 `json:"determination_ts,omitempty"` - SettlementValue string `json:"settlement_value,omitempty"` - SettledTs *int64 `json:"settled_ts,omitempty"` - IsDeactivated *bool `json:"is_deactivated,omitempty"` - PriceLevelStructure string `json:"price_level_structure,omitempty"` - PriceRanges []PriceRange `json:"price_ranges,omitempty"` - StrikeType string `json:"strike_type,omitempty"` - FloorStrike *float64 `json:"floor_strike,omitempty"` - CapStrike *float64 `json:"cap_strike,omitempty"` - CustomStrike json.RawMessage `json:"custom_strike,omitempty"` - YesSubTitle string `json:"yes_sub_title,omitempty"` + EventType string `json:"event_type"` + MarketTicker string `json:"market_ticker"` + ExchangeIndex *int `json:"exchange_index,omitempty"` + OpenTs *int64 `json:"open_ts,omitempty"` + CloseTs *int64 `json:"close_ts,omitempty"` + Result string `json:"result,omitempty"` + DeterminationTs *int64 `json:"determination_ts,omitempty"` + SettlementValue string `json:"settlement_value,omitempty"` + SettledTs *int64 `json:"settled_ts,omitempty"` + IsDeactivated *bool `json:"is_deactivated,omitempty"` + PriceLevelStructure string `json:"price_level_structure,omitempty"` + PriceRanges []PriceRange `json:"price_ranges,omitempty"` + StrikeType string `json:"strike_type,omitempty"` + FloorStrike *float64 `json:"floor_strike,omitempty"` + CapStrike *float64 `json:"cap_strike,omitempty"` + CustomStrike json.RawMessage `json:"custom_strike,omitempty"` + YesSubTitle string `json:"yes_sub_title,omitempty"` AdditionalMetadata *MarketLifecycleAdditionalMetadata `json:"additional_metadata,omitempty"` } -// CFBenchmarksAvgData is an averaged index value carried on the once-per-second -// cfbenchmarks_value channel. +// CFBenchmarksAvgData is the windowed-average metadata carried on the +// once-per-second cfbenchmarks_value channel. Value is formatted to 8 decimal +// places; the window is [WindowStartTsMs, WindowEndTsExclusive) in unix ms. type CFBenchmarksAvgData struct { - IndexID string `json:"index_id,omitempty"` - ValueUSD string `json:"value_usd,omitempty"` - SourceTsMs *int64 `json:"source_ts_ms,omitempty"` - WindowSec *int `json:"window_sec,omitempty"` + Value string `json:"value"` + WindowSize int `json:"window_size"` + WindowStartTsMs int64 `json:"window_start_ts_ms"` + WindowEndTsExclusive int64 `json:"window_end_ts_exclusive"` } // CFBenchmarksValueMsg is a cfbenchmarks_value message: the raw upstream frame // plus the 60-second and quarter-hour averages. type CFBenchmarksValueMsg struct { - IndexID string `json:"index_id"` - ReceivedAt int64 `json:"received_at"` - Data string `json:"data"` - Avg60sData *CFBenchmarksAvgData `json:"avg_60s_data,omitempty"` + IndexID string `json:"index_id"` + ReceivedAt int64 `json:"received_at"` + Data string `json:"data"` + Avg60sData *CFBenchmarksAvgData `json:"avg_60s_data,omitempty"` Last60sWindowedAverage15Min *CFBenchmarksAvgData `json:"last_60s_windowed_average_15min,omitempty"` } diff --git a/oddrip/types/ws_messages.go b/oddrip/types/ws_messages.go new file mode 100644 index 0000000..b393356 --- /dev/null +++ b/oddrip/types/ws_messages.go @@ -0,0 +1,296 @@ +package types + +import ( + "encoding/json" + "errors" + "fmt" +) + +// Server message types carried in WSMessage.Type. The list_subscriptions +// reply reuses "ok". +const ( + WSTypeSubscribed = "subscribed" + WSTypeUnsubscribed = "unsubscribed" + WSTypeOK = "ok" + WSTypeError = "error" + WSTypeOrderbookSnapshot = "orderbook_snapshot" + WSTypeOrderbookDelta = "orderbook_delta" + WSTypeTicker = "ticker" + WSTypeTrade = "trade" + WSTypeFill = "fill" + WSTypeMarketPosition = "market_position" + WSTypeMarketLifecycleV2 = "market_lifecycle_v2" + WSTypeMultivariateMarketLifecycle = "multivariate_market_lifecycle" + WSTypeEventLifecycle = "event_lifecycle" + WSTypeEventFeeUpdate = "event_fee_update" + WSTypeOrderGroupUpdates = "order_group_updates" + WSTypeUserOrder = "user_order" + WSTypeRFQCreated = "rfq_created" + WSTypeRFQDeleted = "rfq_deleted" + WSTypeQuoteCreated = "quote_created" + WSTypeQuoteAccepted = "quote_accepted" + WSTypeQuoteExecuted = "quote_executed" + WSTypePythValue = "pyth_value" + WSTypePythValueUnderlyingList = "pyth_value_underlying_list" + WSTypeCFBenchmarksValue = "cfbenchmarks_value" + WSTypeCFBenchmarksValueIndexList = "cfbenchmarks_value_indexlist" + WSTypeCFBenchmarksValue5Hz = "cfbenchmarks_value_5hz" + WSTypeCFBenchmarksValue5HzIndexList = "cfbenchmarks_value_5hz_indexlist" +) + +// Decode unmarshals the msg payload into v, which should be a pointer to the +// *Msg struct matching m.Type. +func (m *WSMessage) Decode(v any) error { + if len(m.Msg) == 0 { + return fmt.Errorf("ws message %q has no msg payload", m.Type) + } + return json.Unmarshal(m.Msg, v) +} + +// OrderbookLevel is one aggregated price level, sent on the wire as a +// [price_dollars, count_fp] string pair. +type OrderbookLevel struct { + PriceDollars string + CountFp string +} + +func (l OrderbookLevel) MarshalJSON() ([]byte, error) { + return json.Marshal([2]string{l.PriceDollars, l.CountFp}) +} + +func (l *OrderbookLevel) UnmarshalJSON(b []byte) error { + var pair []string + if err := json.Unmarshal(b, &pair); err != nil { + return fmt.Errorf("orderbook level: %w", err) + } + if len(pair) != 2 { + return fmt.Errorf("orderbook level: want [price, count], got %d elements", len(pair)) + } + if pair[0] == "" || pair[1] == "" { + return errors.New("orderbook level: empty price or count") + } + l.PriceDollars, l.CountFp = pair[0], pair[1] + return nil +} + +type OrderbookSnapshotMsg struct { + MarketTicker string `json:"market_ticker"` + MarketID string `json:"market_id"` + YesDollarsFp []OrderbookLevel `json:"yes_dollars_fp,omitempty"` + NoDollarsFp []OrderbookLevel `json:"no_dollars_fp,omitempty"` +} + +type OrderbookDeltaMsg struct { + MarketTicker string `json:"market_ticker"` + MarketID string `json:"market_id"` + PriceDollars string `json:"price_dollars"` + DeltaFp string `json:"delta_fp"` + Side string `json:"side"` + ClientOrderID string `json:"client_order_id,omitempty"` + Subaccount *int `json:"subaccount,omitempty"` + Ts string `json:"ts,omitempty"` + TsMs *int64 `json:"ts_ms,omitempty"` +} + +type TickerMsg struct { + MarketTicker string `json:"market_ticker"` + MarketID string `json:"market_id"` + PriceDollars string `json:"price_dollars"` + YesBidDollars string `json:"yes_bid_dollars"` + YesAskDollars string `json:"yes_ask_dollars"` + VolumeFp string `json:"volume_fp"` + OpenInterestFp string `json:"open_interest_fp"` + DollarVolume int64 `json:"dollar_volume"` + DollarOpenInterest int64 `json:"dollar_open_interest"` + YesBidSizeFp string `json:"yes_bid_size_fp"` + YesAskSizeFp string `json:"yes_ask_size_fp"` + LastTradeSizeFp string `json:"last_trade_size_fp"` + Ts int64 `json:"ts"` + TsMs int64 `json:"ts_ms"` + Time string `json:"time"` +} + +type TradeMsg struct { + TradeID string `json:"trade_id"` + MarketTicker string `json:"market_ticker"` + YesPriceDollars string `json:"yes_price_dollars"` + NoPriceDollars string `json:"no_price_dollars"` + CountFp string `json:"count_fp"` + TakerSide string `json:"taker_side,omitempty"` + TakerOutcomeSide string `json:"taker_outcome_side"` + TakerBookSide string `json:"taker_book_side"` + IsBlockTrade bool `json:"is_block_trade"` + Ts int64 `json:"ts"` + TsMs int64 `json:"ts_ms"` +} + +type FillMsg struct { + TradeID string `json:"trade_id"` + OrderID string `json:"order_id"` + MarketTicker string `json:"market_ticker"` + ExchangeIndex int `json:"exchange_index"` + IsTaker bool `json:"is_taker"` + Side string `json:"side,omitempty"` + YesPriceDollars string `json:"yes_price_dollars"` + CountFp string `json:"count_fp"` + FeeCost string `json:"fee_cost"` + Action string `json:"action,omitempty"` + Ts int64 `json:"ts"` + TsMs int64 `json:"ts_ms"` + ClientOrderID string `json:"client_order_id,omitempty"` + PostPositionFp string `json:"post_position_fp"` + PurchasedSide string `json:"purchased_side,omitempty"` + OutcomeSide string `json:"outcome_side"` + BookSide string `json:"book_side"` + Subaccount *int `json:"subaccount,omitempty"` +} + +type MarketPositionMsg struct { + UserID string `json:"user_id"` + MarketTicker string `json:"market_ticker"` + PositionFp string `json:"position_fp"` + PositionCostDollars string `json:"position_cost_dollars"` + RealizedPnlDollars string `json:"realized_pnl_dollars"` + FeesPaidDollars string `json:"fees_paid_dollars"` + PositionFeeCostDollars string `json:"position_fee_cost_dollars"` + VolumeFp string `json:"volume_fp"` + Subaccount *int `json:"subaccount,omitempty"` +} + +type UserOrderMsg struct { + OrderID string `json:"order_id"` + UserID string `json:"user_id"` + Ticker string `json:"ticker"` + ExchangeIndex int `json:"exchange_index"` + Status string `json:"status"` + Side string `json:"side,omitempty"` + IsYes bool `json:"is_yes"` + OutcomeSide string `json:"outcome_side"` + BookSide string `json:"book_side"` + YesPriceDollars string `json:"yes_price_dollars"` + FillCountFp string `json:"fill_count_fp"` + RemainingCountFp string `json:"remaining_count_fp"` + InitialCountFp string `json:"initial_count_fp"` + TakerFillCostDollars string `json:"taker_fill_cost_dollars"` + MakerFillCostDollars string `json:"maker_fill_cost_dollars"` + TakerFeesDollars string `json:"taker_fees_dollars"` + MakerFeesDollars string `json:"maker_fees_dollars"` + ClientOrderID string `json:"client_order_id"` + OrderGroupID string `json:"order_group_id,omitempty"` + SelfTradePreventionType string `json:"self_trade_prevention_type,omitempty"` + CreatedTime string `json:"created_time"` + CreatedTsMs int64 `json:"created_ts_ms"` + LastUpdateTime string `json:"last_update_time,omitempty"` + LastUpdatedTsMs *int64 `json:"last_updated_ts_ms,omitempty"` + ExpirationTime string `json:"expiration_time,omitempty"` + ExpirationTsMs *int64 `json:"expiration_ts_ms,omitempty"` + SubaccountNumber *int `json:"subaccount_number,omitempty"` +} + +type OrderGroupUpdatesMsg struct { + EventType string `json:"event_type"` + OrderGroupID string `json:"order_group_id"` + ContractsLimitFp string `json:"contracts_limit_fp,omitempty"` + TsMs int64 `json:"ts_ms"` +} + +type MultivariateMarketLifecycleMsg struct { + EventType string `json:"event_type"` + MarketTicker string `json:"market_ticker"` + ExchangeIndex *int `json:"exchange_index,omitempty"` + OpenTs *int64 `json:"open_ts,omitempty"` + CloseTs *int64 `json:"close_ts,omitempty"` + Result string `json:"result,omitempty"` + DeterminationTs *int64 `json:"determination_ts,omitempty"` + SettlementValue string `json:"settlement_value,omitempty"` + SettledTs *int64 `json:"settled_ts,omitempty"` + IsDeactivated *bool `json:"is_deactivated,omitempty"` + PriceLevelStructure string `json:"price_level_structure,omitempty"` + AdditionalMetadata *MarketLifecycleAdditionalMetadata `json:"additional_metadata,omitempty"` +} + +type EventLifecycleMsg struct { + EventTicker string `json:"event_ticker"` + ExchangeIndex int `json:"exchange_index"` + Title string `json:"title"` + Subtitle string `json:"subtitle"` + CollateralReturnType string `json:"collateral_return_type"` + SeriesTicker string `json:"series_ticker"` + StrikeDate *int64 `json:"strike_date,omitempty"` + StrikePeriod string `json:"strike_period,omitempty"` +} + +// EventFeeUpdateMsg carries an event-level fee override; both override fields +// are null when the override has been cleared. +type EventFeeUpdateMsg struct { + EventTicker string `json:"event_ticker"` + FeeTypeOverride *string `json:"fee_type_override"` + FeeMultiplierOverride *float64 `json:"fee_multiplier_override"` +} + +type RFQCreatedMsg struct { + ID string `json:"id"` + CreatorID string `json:"creator_id"` + MarketTicker string `json:"market_ticker"` + EventTicker string `json:"event_ticker,omitempty"` + ContractsFp string `json:"contracts_fp,omitempty"` + TargetCostDollars string `json:"target_cost_dollars,omitempty"` + CreatedTs string `json:"created_ts"` + MveCollectionTicker string `json:"mve_collection_ticker,omitempty"` + MveSelectedLegs []MveSelectedLeg `json:"mve_selected_legs,omitempty"` +} + +type RFQDeletedMsg struct { + ID string `json:"id"` + CreatorID string `json:"creator_id"` + MarketTicker string `json:"market_ticker"` + EventTicker string `json:"event_ticker,omitempty"` + ContractsFp string `json:"contracts_fp,omitempty"` + TargetCostDollars string `json:"target_cost_dollars,omitempty"` + DeletedTs string `json:"deleted_ts"` +} + +type QuoteCreatedMsg struct { + QuoteID string `json:"quote_id"` + RFQID string `json:"rfq_id"` + QuoteCreatorID string `json:"quote_creator_id"` + RFQCreatorID string `json:"rfq_creator_id,omitempty"` + MarketTicker string `json:"market_ticker"` + EventTicker string `json:"event_ticker,omitempty"` + YesBidDollars string `json:"yes_bid_dollars"` + NoBidDollars string `json:"no_bid_dollars"` + YesContractsOfferedFp string `json:"yes_contracts_offered_fp,omitempty"` + NoContractsOfferedFp string `json:"no_contracts_offered_fp,omitempty"` + RFQTargetCostDollars string `json:"rfq_target_cost_dollars,omitempty"` + CreatedTs string `json:"created_ts"` + Subaccount *int `json:"subaccount,omitempty"` +} + +type QuoteAcceptedMsg struct { + QuoteID string `json:"quote_id"` + RFQID string `json:"rfq_id"` + QuoteCreatorID string `json:"quote_creator_id"` + RFQCreatorID string `json:"rfq_creator_id,omitempty"` + MarketTicker string `json:"market_ticker"` + EventTicker string `json:"event_ticker,omitempty"` + YesBidDollars string `json:"yes_bid_dollars"` + NoBidDollars string `json:"no_bid_dollars"` + AcceptedSide string `json:"accepted_side,omitempty"` + ContractsAcceptedFp string `json:"contracts_accepted_fp,omitempty"` + YesContractsOfferedFp string `json:"yes_contracts_offered_fp,omitempty"` + NoContractsOfferedFp string `json:"no_contracts_offered_fp,omitempty"` + RFQTargetCostDollars string `json:"rfq_target_cost_dollars,omitempty"` + Subaccount *int `json:"subaccount,omitempty"` +} + +type QuoteExecutedMsg struct { + QuoteID string `json:"quote_id"` + RFQID string `json:"rfq_id"` + QuoteCreatorID string `json:"quote_creator_id"` + RFQCreatorID string `json:"rfq_creator_id"` + OrderID string `json:"order_id"` + ClientOrderID string `json:"client_order_id"` + MarketTicker string `json:"market_ticker"` + ExecutedTs string `json:"executed_ts"` + Subaccount *int `json:"subaccount,omitempty"` +} diff --git a/oddrip/types/ws_messages_test.go b/oddrip/types/ws_messages_test.go new file mode 100644 index 0000000..746fb43 --- /dev/null +++ b/oddrip/types/ws_messages_test.go @@ -0,0 +1,483 @@ +package types + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestWSMessage_Decode(t *testing.T) { + const envelope = `{"type":"ticker","sid":11,"msg":{"market_ticker":"FED-23DEC-T3.00","market_id":"9b0f6b43-5b68-4f9f-9f02-9a2d1b8ac1a1","price_dollars":"0.480","yes_bid_dollars":"0.450","yes_ask_dollars":"0.530","volume_fp":"33896.00","open_interest_fp":"20422.00","dollar_volume":16948,"dollar_open_interest":10211,"yes_bid_size_fp":"300.00","yes_ask_size_fp":"150.00","last_trade_size_fp":"25.00","ts":1669149841,"ts_ms":1669149841000,"time":"2022-11-22T20:44:01Z"}}` + var m WSMessage + if err := json.Unmarshal([]byte(envelope), &m); err != nil { + t.Fatal(err) + } + if m.Type != WSTypeTicker || m.SID != 11 { + t.Fatalf("envelope: %+v", m) + } + var tick TickerMsg + if err := m.Decode(&tick); err != nil { + t.Fatal(err) + } + if tick.MarketTicker != "FED-23DEC-T3.00" || tick.YesBidDollars != "0.450" || tick.DollarVolume != 16948 || tick.TsMs != 1669149841000 || tick.Time != "2022-11-22T20:44:01Z" { + t.Fatalf("ticker: %+v", tick) + } +} + +func TestWSMessage_Decode_EmptyMsg(t *testing.T) { + m := WSMessage{Type: WSTypeUnsubscribed, SID: 2} + var v struct{} + err := m.Decode(&v) + if err == nil || !strings.Contains(err.Error(), "unsubscribed") { + t.Fatalf("want error naming the type, got %v", err) + } +} + +func TestWSMessage_Decode_ErrorMsg(t *testing.T) { + const envelope = `{"id":123,"type":"error","msg":{"code":7,"msg":"Unknown subscription ID"}}` + var m WSMessage + if err := json.Unmarshal([]byte(envelope), &m); err != nil { + t.Fatal(err) + } + var e ErrorMsg + if err := m.Decode(&e); err != nil { + t.Fatal(err) + } + if m.Type != WSTypeError || e.Code != 7 || e.Msg != "Unknown subscription ID" { + t.Fatalf("error: %+v", e) + } +} + +func TestOrderbookSnapshotMsg_Unmarshal(t *testing.T) { + const payload = `{ + "market_ticker": "FED-23DEC-T3.00", + "market_id": "9b0f6b43-5b68-4f9f-9f02-9a2d1b8ac1a1", + "yes_dollars_fp": [["0.0800", "300.00"], ["0.2200", "333.00"]], + "no_dollars_fp": [["0.5400", "20.00"], ["0.5600", "146.00"]] + }` + var msg OrderbookSnapshotMsg + if err := json.Unmarshal([]byte(payload), &msg); err != nil { + t.Fatal(err) + } + if len(msg.YesDollarsFp) != 2 || len(msg.NoDollarsFp) != 2 { + t.Fatalf("levels: %+v", msg) + } + if msg.YesDollarsFp[1] != (OrderbookLevel{PriceDollars: "0.2200", CountFp: "333.00"}) { + t.Fatalf("yes[1]: %+v", msg.YesDollarsFp[1]) + } + if msg.NoDollarsFp[0] != (OrderbookLevel{PriceDollars: "0.5400", CountFp: "20.00"}) { + t.Fatalf("no[0]: %+v", msg.NoDollarsFp[0]) + } +} + +func TestOrderbookSnapshotMsg_Unmarshal_OneSided(t *testing.T) { + const payload = `{"market_ticker":"MKT","market_id":"id","no_dollars_fp":[["0.9900","1.00"]]}` + var msg OrderbookSnapshotMsg + if err := json.Unmarshal([]byte(payload), &msg); err != nil { + t.Fatal(err) + } + if msg.YesDollarsFp != nil || len(msg.NoDollarsFp) != 1 { + t.Fatalf("unexpected: %+v", msg) + } +} + +func TestOrderbookLevel_RoundTrip(t *testing.T) { + in := OrderbookLevel{PriceDollars: "0.0800", CountFp: "300.00"} + data, err := json.Marshal(in) + if err != nil { + t.Fatal(err) + } + if string(data) != `["0.0800","300.00"]` { + t.Fatalf("marshal: %s", data) + } + var out OrderbookLevel + if err := json.Unmarshal(data, &out); err != nil { + t.Fatal(err) + } + if out != in { + t.Fatalf("round trip: %+v", out) + } +} + +func TestOrderbookLevel_Unmarshal_Malformed(t *testing.T) { + for _, in := range []string{ + `["0.0800"]`, + `["0.0800","300.00","extra"]`, + `[]`, + `[8, 300]`, + `{"price":"0.0800","count":"300.00"}`, + `"0.0800"`, + `["", "300.00"]`, + `["0.0800", ""]`, + `null`, + } { + var l OrderbookLevel + if err := json.Unmarshal([]byte(in), &l); err == nil { + t.Errorf("%s: want error, got %+v", in, l) + } + } +} + +func TestOrderbookDeltaMsg_Unmarshal(t *testing.T) { + const payload = `{ + "market_ticker": "FED-23DEC-T3.00", + "market_id": "9b0f6b43-5b68-4f9f-9f02-9a2d1b8ac1a1", + "price_dollars": "0.960", + "delta_fp": "-54.00", + "side": "yes", + "ts": "2022-11-22T20:44:01Z", + "ts_ms": 1669149841000 + }` + var msg OrderbookDeltaMsg + if err := json.Unmarshal([]byte(payload), &msg); err != nil { + t.Fatal(err) + } + if msg.PriceDollars != "0.960" || msg.DeltaFp != "-54.00" || msg.Side != OrderSideYes || msg.TsMs == nil || *msg.TsMs != 1669149841000 { + t.Fatalf("unexpected: %+v", msg) + } + if msg.ClientOrderID != "" || msg.Subaccount != nil { + t.Fatalf("optional fields should be unset: %+v", msg) + } +} + +func TestOrderbookDeltaMsg_Unmarshal_OwnOrder(t *testing.T) { + const payload = `{"market_ticker":"MKT","market_id":"id","price_dollars":"0.5000","delta_fp":"10.00","side":"no","client_order_id":"my-1","subaccount":3,"ts_ms":1}` + var msg OrderbookDeltaMsg + if err := json.Unmarshal([]byte(payload), &msg); err != nil { + t.Fatal(err) + } + if msg.ClientOrderID != "my-1" || msg.Subaccount == nil || *msg.Subaccount != 3 { + t.Fatalf("unexpected: %+v", msg) + } +} + +func TestTradeMsg_Unmarshal(t *testing.T) { + const payload = `{ + "trade_id": "d91bc706-ee49-470d-82d8-11418bda6fed", + "market_ticker": "HIGHNY-22DEC23-B53.5", + "yes_price_dollars": "0.3600", + "no_price_dollars": "0.6400", + "count_fp": "136.00", + "taker_side": "no", + "taker_outcome_side": "no", + "taker_book_side": "ask", + "is_block_trade": false, + "ts": 1669149841, + "ts_ms": 1669149841000 + }` + var msg TradeMsg + if err := json.Unmarshal([]byte(payload), &msg); err != nil { + t.Fatal(err) + } + if msg.TradeID != "d91bc706-ee49-470d-82d8-11418bda6fed" || msg.YesPriceDollars != "0.3600" || msg.NoPriceDollars != "0.6400" || msg.CountFp != "136.00" { + t.Fatalf("unexpected: %+v", msg) + } + if msg.TakerOutcomeSide != OutcomeSideNo || msg.TakerBookSide != BookSideAsk || msg.IsBlockTrade || msg.TsMs != 1669149841000 { + t.Fatalf("unexpected: %+v", msg) + } +} + +func TestFillMsg_Unmarshal(t *testing.T) { + const payload = `{ + "trade_id": "d91bc706-ee49-470d-82d8-11418bda6fed", + "order_id": "ee587a1c-8b87-4dcf-b721-9f6f790619fa", + "market_ticker": "HIGHNY-22DEC23-B53.5", + "exchange_index": 2, + "is_taker": true, + "side": "yes", + "yes_price_dollars": "0.7500", + "count_fp": "278.00", + "fee_cost": "0.010000", + "action": "buy", + "ts": 1671899397, + "ts_ms": 1671899397000, + "post_position_fp": "500.00", + "purchased_side": "yes", + "outcome_side": "yes", + "book_side": "bid", + "subaccount": 3 + }` + var msg FillMsg + if err := json.Unmarshal([]byte(payload), &msg); err != nil { + t.Fatal(err) + } + if msg.OrderID != "ee587a1c-8b87-4dcf-b721-9f6f790619fa" || msg.ExchangeIndex != 2 || !msg.IsTaker || msg.YesPriceDollars != "0.7500" || msg.CountFp != "278.00" { + t.Fatalf("unexpected: %+v", msg) + } + if msg.FeeCost != "0.010000" || msg.PostPositionFp != "500.00" || msg.OutcomeSide != OutcomeSideYes || msg.BookSide != BookSideBid { + t.Fatalf("unexpected: %+v", msg) + } + if msg.Subaccount == nil || *msg.Subaccount != 3 || msg.ClientOrderID != "" || msg.TsMs != 1671899397000 { + t.Fatalf("unexpected: %+v", msg) + } +} + +func TestMarketPositionMsg_Unmarshal(t *testing.T) { + const payload = `{ + "user_id": "user123", + "market_ticker": "FED-23DEC-T3.00", + "position_fp": "100.00", + "position_cost_dollars": "50.0000", + "realized_pnl_dollars": "10.0000", + "fees_paid_dollars": "1.0000", + "position_fee_cost_dollars": "0.5000", + "volume_fp": "15.00" + }` + var msg MarketPositionMsg + if err := json.Unmarshal([]byte(payload), &msg); err != nil { + t.Fatal(err) + } + if msg.UserID != "user123" || msg.PositionFp != "100.00" || msg.PositionCostDollars != "50.0000" || msg.RealizedPnlDollars != "10.0000" { + t.Fatalf("unexpected: %+v", msg) + } + if msg.FeesPaidDollars != "1.0000" || msg.PositionFeeCostDollars != "0.5000" || msg.VolumeFp != "15.00" || msg.Subaccount != nil { + t.Fatalf("unexpected: %+v", msg) + } +} + +func TestUserOrderMsg_Unmarshal(t *testing.T) { + const payload = `{ + "order_id": "ee587a1c-8b87-4dcf-b721-9f6f790619fa", + "user_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "ticker": "FED-23DEC-T3.00", + "exchange_index": 2, + "status": "resting", + "side": "yes", + "is_yes": true, + "outcome_side": "yes", + "book_side": "bid", + "yes_price_dollars": "0.3500", + "fill_count_fp": "0.00", + "remaining_count_fp": "10.00", + "initial_count_fp": "10.00", + "taker_fill_cost_dollars": "0.000000", + "maker_fill_cost_dollars": "0.000000", + "taker_fees_dollars": "0.000000", + "maker_fees_dollars": "0.000000", + "client_order_id": "my-order-1", + "order_group_id": "og_123", + "self_trade_prevention_type": "taker_at_cross", + "created_time": "2024-12-01T10:00:00Z", + "created_ts_ms": 1733047200000, + "expiration_time": "2024-12-01T11:00:00Z", + "expiration_ts_ms": 1733050800000, + "subaccount_number": 0 + }` + var msg UserOrderMsg + if err := json.Unmarshal([]byte(payload), &msg); err != nil { + t.Fatal(err) + } + if msg.OrderID != "ee587a1c-8b87-4dcf-b721-9f6f790619fa" || msg.Ticker != "FED-23DEC-T3.00" || msg.Status != OrderStatusResting || !msg.IsYes { + t.Fatalf("unexpected: %+v", msg) + } + if msg.YesPriceDollars != "0.3500" || msg.RemainingCountFp != "10.00" || msg.InitialCountFp != "10.00" || msg.MakerFeesDollars != "0.000000" { + t.Fatalf("unexpected: %+v", msg) + } + if msg.ClientOrderID != "my-order-1" || msg.OrderGroupID != "og_123" || msg.SelfTradePreventionType != SelfTradeTakerAtCross || msg.CreatedTsMs != 1733047200000 { + t.Fatalf("unexpected: %+v", msg) + } + if msg.ExpirationTsMs == nil || *msg.ExpirationTsMs != 1733050800000 || msg.LastUpdatedTsMs != nil || msg.SubaccountNumber == nil || *msg.SubaccountNumber != 0 { + t.Fatalf("unexpected: %+v", msg) + } +} + +func TestOrderGroupUpdatesMsg_Unmarshal(t *testing.T) { + const payload = `{"event_type":"limit_updated","order_group_id":"og_123","contracts_limit_fp":"150.00","ts_ms":1733047200000}` + var msg OrderGroupUpdatesMsg + if err := json.Unmarshal([]byte(payload), &msg); err != nil { + t.Fatal(err) + } + if msg.EventType != "limit_updated" || msg.OrderGroupID != "og_123" || msg.ContractsLimitFp != "150.00" || msg.TsMs != 1733047200000 { + t.Fatalf("unexpected: %+v", msg) + } +} + +func TestMultivariateMarketLifecycleMsg_Unmarshal(t *testing.T) { + const payload = `{ + "market_ticker": "KXMVE-TEST-EVENT-M1", + "event_type": "created", + "exchange_index": 0, + "open_ts": 1773936000, + "close_ts": 1774022400, + "additional_metadata": { + "name": "MVE One", + "title": "Market 1", + "yes_sub_title": "YES 1", + "no_sub_title": "NO 1", + "rules_primary": "Rule 1", + "rules_secondary": "Rule 2", + "can_close_early": true, + "event_ticker": "KXMVE-TEST-EVENT", + "expected_expiration_ts": 1774029600 + } + }` + var msg MultivariateMarketLifecycleMsg + if err := json.Unmarshal([]byte(payload), &msg); err != nil { + t.Fatal(err) + } + if msg.EventType != "created" || msg.ExchangeIndex == nil || *msg.ExchangeIndex != 0 || msg.OpenTs == nil || *msg.OpenTs != 1773936000 || msg.CloseTs == nil { + t.Fatalf("unexpected: %+v", msg) + } + if msg.AdditionalMetadata == nil || msg.AdditionalMetadata.EventTicker != "KXMVE-TEST-EVENT" || msg.AdditionalMetadata.CanCloseEarly == nil || !*msg.AdditionalMetadata.CanCloseEarly { + t.Fatalf("metadata: %+v", msg.AdditionalMetadata) + } + if msg.Result != "" || msg.SettledTs != nil || msg.IsDeactivated != nil { + t.Fatalf("optional fields should be unset: %+v", msg) + } +} + +func TestMultivariateMarketLifecycleMsg_Unmarshal_Determined(t *testing.T) { + const payload = `{"market_ticker":"KXMVE-TEST-EVENT-M1","event_type":"determined","result":"yes","determination_ts":1774022400,"settlement_value":"1.0000"}` + var msg MultivariateMarketLifecycleMsg + if err := json.Unmarshal([]byte(payload), &msg); err != nil { + t.Fatal(err) + } + if msg.EventType != "determined" || msg.Result != "yes" || msg.DeterminationTs == nil || msg.SettlementValue != "1.0000" || msg.AdditionalMetadata != nil { + t.Fatalf("unexpected: %+v", msg) + } +} + +func TestEventLifecycleMsg_Unmarshal(t *testing.T) { + const payload = `{ + "event_ticker": "KXQUICKSETTLE-26JAN25H2150", + "exchange_index": 0, + "title": "What will 1+1 equal on Jan 25 at 21:50?", + "subtitle": "Jan 25 at 21:50", + "collateral_return_type": "MECNET", + "series_ticker": "KXQUICKSETTLE" + }` + var msg EventLifecycleMsg + if err := json.Unmarshal([]byte(payload), &msg); err != nil { + t.Fatal(err) + } + if msg.EventTicker != "KXQUICKSETTLE-26JAN25H2150" || msg.CollateralReturnType != "MECNET" || msg.SeriesTicker != "KXQUICKSETTLE" || msg.StrikeDate != nil { + t.Fatalf("unexpected: %+v", msg) + } +} + +func TestEventFeeUpdateMsg_Unmarshal(t *testing.T) { + var set EventFeeUpdateMsg + if err := json.Unmarshal([]byte(`{"event_ticker":"KXBTCD-26MAY2018","fee_type_override":"quadratic","fee_multiplier_override":1}`), &set); err != nil { + t.Fatal(err) + } + if set.FeeTypeOverride == nil || *set.FeeTypeOverride != "quadratic" || set.FeeMultiplierOverride == nil || *set.FeeMultiplierOverride != 1 { + t.Fatalf("set: %+v", set) + } + var cleared EventFeeUpdateMsg + if err := json.Unmarshal([]byte(`{"event_ticker":"KXBTCD-26MAY2018","fee_type_override":null,"fee_multiplier_override":null}`), &cleared); err != nil { + t.Fatal(err) + } + if cleared.EventTicker != "KXBTCD-26MAY2018" || cleared.FeeTypeOverride != nil || cleared.FeeMultiplierOverride != nil { + t.Fatalf("cleared: %+v", cleared) + } +} + +func TestRFQCreatedMsg_Unmarshal(t *testing.T) { + const payload = `{ + "id": "rfq_456", + "creator_id": "", + "market_ticker": "KXMVE-24DEC-COMBO", + "event_ticker": "KXMVE-24DEC-EVENT", + "target_cost_dollars": "100.0000", + "created_ts": "2024-12-01T10:00:00Z", + "mve_collection_ticker": "KXMVE-24DEC", + "mve_selected_legs": [ + {"event_ticker": "KXEVENTA-24DEC", "market_ticker": "KXEVENTA-24DEC-YES", "side": "yes", "yes_settlement_value_dollars": "1.0000"}, + {"event_ticker": "KXEVENTB-24DEC", "market_ticker": "KXEVENTB-24DEC-YES", "side": "no"} + ] + }` + var msg RFQCreatedMsg + if err := json.Unmarshal([]byte(payload), &msg); err != nil { + t.Fatal(err) + } + if msg.ID != "rfq_456" || msg.TargetCostDollars != "100.0000" || msg.ContractsFp != "" || msg.CreatedTs != "2024-12-01T10:00:00Z" || msg.MveCollectionTicker != "KXMVE-24DEC" { + t.Fatalf("unexpected: %+v", msg) + } + if len(msg.MveSelectedLegs) != 2 || msg.MveSelectedLegs[0].YesSettlementValueDollars == nil || *msg.MveSelectedLegs[0].YesSettlementValueDollars != "1.0000" || msg.MveSelectedLegs[1].YesSettlementValueDollars != nil { + t.Fatalf("legs: %+v", msg.MveSelectedLegs) + } +} + +func TestRFQDeletedMsg_Unmarshal(t *testing.T) { + const payload = `{"id":"rfq_123","creator_id":"comm_abc123","market_ticker":"FED-23DEC-T3.00","event_ticker":"FED-23DEC","contracts_fp":"100.00","target_cost_dollars":"0.35","deleted_ts":"2024-12-01T10:05:00Z"}` + var msg RFQDeletedMsg + if err := json.Unmarshal([]byte(payload), &msg); err != nil { + t.Fatal(err) + } + if msg.ID != "rfq_123" || msg.CreatorID != "comm_abc123" || msg.ContractsFp != "100.00" || msg.DeletedTs != "2024-12-01T10:05:00Z" { + t.Fatalf("unexpected: %+v", msg) + } +} + +func TestQuoteCreatedMsg_Unmarshal(t *testing.T) { + const payload = `{ + "quote_id": "quote_456", + "rfq_id": "rfq_123", + "quote_creator_id": "comm_def456", + "rfq_creator_id": "comm_abc123", + "market_ticker": "FED-23DEC-T3.00", + "event_ticker": "FED-23DEC", + "yes_bid_dollars": "0.35", + "no_bid_dollars": "0.65", + "yes_contracts_offered_fp": "100.00", + "no_contracts_offered_fp": "200.00", + "rfq_target_cost_dollars": "0.35", + "created_ts": "2024-12-01T10:02:00Z", + "subaccount": 3 + }` + var msg QuoteCreatedMsg + if err := json.Unmarshal([]byte(payload), &msg); err != nil { + t.Fatal(err) + } + if msg.QuoteID != "quote_456" || msg.RFQID != "rfq_123" || msg.YesBidDollars != "0.35" || msg.NoBidDollars != "0.65" || msg.NoContractsOfferedFp != "200.00" { + t.Fatalf("unexpected: %+v", msg) + } + if msg.Subaccount == nil || *msg.Subaccount != 3 || msg.CreatedTs != "2024-12-01T10:02:00Z" { + t.Fatalf("unexpected: %+v", msg) + } +} + +func TestQuoteAcceptedMsg_Unmarshal(t *testing.T) { + const payload = `{"quote_id":"quote_456","rfq_id":"rfq_123","quote_creator_id":"comm_def456","rfq_creator_id":"comm_abc123","market_ticker":"FED-23DEC-T3.00","event_ticker":"FED-23DEC","yes_bid_dollars":"0.35","no_bid_dollars":"0.65","accepted_side":"yes","contracts_accepted_fp":"50.00","yes_contracts_offered_fp":"100.00","no_contracts_offered_fp":"200.00","rfq_target_cost_dollars":"0.35","subaccount":3}` + var msg QuoteAcceptedMsg + if err := json.Unmarshal([]byte(payload), &msg); err != nil { + t.Fatal(err) + } + if msg.AcceptedSide != OrderSideYes || msg.ContractsAcceptedFp != "50.00" || msg.RFQTargetCostDollars != "0.35" || msg.Subaccount == nil { + t.Fatalf("unexpected: %+v", msg) + } +} + +func TestQuoteExecutedMsg_Unmarshal(t *testing.T) { + const payload = `{"quote_id":"quote_456","rfq_id":"rfq_123","quote_creator_id":"a1b2c3d4e5f6...","rfq_creator_id":"f6e5d4c3b2a1...","order_id":"order_789","client_order_id":"my_client_order_123","market_ticker":"FED-23DEC-T3.00","executed_ts":"2024-12-01T10:05:00Z","subaccount":3}` + var msg QuoteExecutedMsg + if err := json.Unmarshal([]byte(payload), &msg); err != nil { + t.Fatal(err) + } + if msg.OrderID != "order_789" || msg.ClientOrderID != "my_client_order_123" || msg.ExecutedTs != "2024-12-01T10:05:00Z" || msg.Subaccount == nil || *msg.Subaccount != 3 { + t.Fatalf("unexpected: %+v", msg) + } +} + +func TestWSTypeConstants(t *testing.T) { + for _, tc := range []struct{ got, want string }{ + {WSTypeSubscribed, "subscribed"}, + {WSTypeError, "error"}, + {WSTypeOrderbookSnapshot, "orderbook_snapshot"}, + {WSTypeOrderbookDelta, WSChannelOrderbookDelta}, + {WSTypeTicker, WSChannelTicker}, + {WSTypeTrade, WSChannelTrade}, + {WSTypeFill, WSChannelFill}, + {WSTypeMarketPosition, "market_position"}, + {WSTypeMarketLifecycleV2, WSChannelMarketLifecycle}, + {WSTypeMultivariateMarketLifecycle, WSChannelMultivariateLifecycle}, + {WSTypeOrderGroupUpdates, WSChannelOrderGroup}, + {WSTypeUserOrder, "user_order"}, + {WSTypePythValue, WSChannelPythValue}, + {WSTypeCFBenchmarksValue5HzIndexList, "cfbenchmarks_value_5hz_indexlist"}, + } { + if tc.got != tc.want { + t.Errorf("got %q want %q", tc.got, tc.want) + } + } +} diff --git a/oddrip/types/ws_test.go b/oddrip/types/ws_test.go index 3a20a8e..65ab253 100644 --- a/oddrip/types/ws_test.go +++ b/oddrip/types/ws_test.go @@ -139,6 +139,30 @@ func TestPythValueMsg_Unmarshal(t *testing.T) { } } +// Payload is the marketCreated example from asyncapi.yaml; exchange_index is +// only present on created events and shard 0 must survive the decode. +func TestMarketLifecycleV2Msg_Unmarshal_Created(t *testing.T) { + const payload = `{ + "market_ticker": "INXD-23SEP14-B4487", + "event_type": "created", + "exchange_index": 0, + "open_ts": 1694635200, + "close_ts": 1694721600, + "price_level_structure": "linear_cent", + "additional_metadata": {"event_ticker": "INXD-23SEP14", "strike_type": "greater", "floor_strike": 4487} + }` + var msg MarketLifecycleV2Msg + if err := json.Unmarshal([]byte(payload), &msg); err != nil { + t.Fatal(err) + } + if msg.EventType != "created" || msg.ExchangeIndex == nil || *msg.ExchangeIndex != 0 { + t.Fatalf("unexpected: %+v", msg) + } + if msg.OpenTs == nil || *msg.OpenTs != 1694635200 || msg.AdditionalMetadata == nil || msg.AdditionalMetadata.EventTicker != "INXD-23SEP14" { + t.Fatalf("unexpected: %+v", msg) + } +} + func TestMarketLifecycleV2Msg_Unmarshal_PriceRanges(t *testing.T) { const payload = `{ "market_ticker": "INXD-23SEP14-B4487", @@ -153,6 +177,9 @@ func TestMarketLifecycleV2Msg_Unmarshal_PriceRanges(t *testing.T) { if msg.EventType != "price_level_structure_updated" || len(msg.PriceRanges) != 1 || msg.PriceRanges[0].Step != "0.0010" { t.Fatalf("unexpected: %+v", msg) } + if msg.ExchangeIndex != nil { + t.Fatalf("exchange_index should be nil when absent, got %d", *msg.ExchangeIndex) + } } func TestMarketLifecycleV2Msg_Unmarshal_MetadataUpdated(t *testing.T) { @@ -189,13 +216,49 @@ func TestCFBenchmarksValue5HzMsg_Unmarshal(t *testing.T) { } } +// Payload is the cfbenchmarksValueUpdate example from asyncapi.yaml. func TestCFBenchmarksValueMsg_Unmarshal(t *testing.T) { - const payload = `{"index_id":"BRTI","received_at":1716300000250,"data":"{}","avg_60s_data":{"index_id":"BRTI","value_usd":"64999.00000000","source_ts_ms":1716300000000}}` + const payload = `{ + "index_id": "BRTI", + "received_at": 1710000000123, + "data": "{\"type\":\"value\",\"id\":\"BRTI\",\"time\":1710000000123,\"value\":\"68000.12\"}", + "avg_60s_data": { + "value": "68000.12000000", + "window_size": 3, + "window_start_ts_ms": 1709999940123, + "window_end_ts_exclusive": 1710000000123 + }, + "last_60s_windowed_average_15min": { + "value": "68000.23000000", + "window_size": 14, + "window_start_ts_ms": 1709999980000, + "window_end_ts_exclusive": 1710000000123 + } + }` + var out CFBenchmarksValueMsg + if err := json.Unmarshal([]byte(payload), &out); err != nil { + t.Fatal(err) + } + if out.IndexID != "BRTI" || out.ReceivedAt != 1710000000123 { + t.Fatalf("unexpected: %+v", out) + } + avg := out.Avg60sData + if avg == nil || avg.Value != "68000.12000000" || avg.WindowSize != 3 || avg.WindowStartTsMs != 1709999940123 || avg.WindowEndTsExclusive != 1710000000123 { + t.Fatalf("avg_60s_data: %+v", avg) + } + q := out.Last60sWindowedAverage15Min + if q == nil || q.Value != "68000.23000000" || q.WindowSize != 14 || q.WindowStartTsMs != 1709999980000 { + t.Fatalf("last_60s_windowed_average_15min: %+v", q) + } +} + +func TestCFBenchmarksValueMsg_Unmarshal_NoQuarterHourAverage(t *testing.T) { + const payload = `{"index_id":"BRTI","received_at":1,"data":"{}","avg_60s_data":{"value":"1.00000000","window_size":0,"window_start_ts_ms":0,"window_end_ts_exclusive":1}}` var out CFBenchmarksValueMsg if err := json.Unmarshal([]byte(payload), &out); err != nil { t.Fatal(err) } - if out.Avg60sData == nil || out.Avg60sData.ValueUSD != "64999.00000000" { + if out.Avg60sData == nil || out.Avg60sData.Value != "1.00000000" { t.Fatalf("avg: %+v", out.Avg60sData) } if out.Last60sWindowedAverage15Min != nil { diff --git a/oddrip/version.go b/oddrip/version.go index 42623a1..d9eb679 100644 --- a/oddrip/version.go +++ b/oddrip/version.go @@ -1,4 +1,4 @@ package oddrip -// Version is the semantic version of this module (aligned with git tags v0.5.0, etc.). -const Version = "0.5.0" +// 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" diff --git a/oddrip/ws.go b/oddrip/ws.go index 79bea6d..9b73d65 100644 --- a/oddrip/ws.go +++ b/oddrip/ws.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "net" "net/http" "net/url" "sync" @@ -19,24 +20,34 @@ import ( const defaultWSHost = "api.elections.kalshi.com" const defaultWSPath = "/trade-api/ws/v2" +const ( + defaultWSBufferSize = 4096 + defaultWSPingInterval = 30 * time.Second + defaultWSReadTimeout = 90 * time.Second +) + var ( - ErrWSClosed = errors.New("websocket closed") + ErrWSClosed = errors.New("websocket closed") ErrWSAuthRequired = errors.New("websocket requires auth") + ErrWSSlowConsumer = errors.New("websocket consumer too slow") ) type WSConn struct { - conn *websocket.Conn - auth AuthProvider - host string - path string - nextID atomic.Int64 - mu sync.Mutex - closed bool - readErr error - pendMu sync.Mutex - pending map[int]chan *wsEnvelope - msgChan chan *types.WSMessage - readDone chan struct{} + conn *websocket.Conn + auth AuthProvider + host string + path string + readTimeout time.Duration + nextID atomic.Int64 + mu sync.Mutex + closed bool + readErr error + writeMu sync.Mutex + pendMu sync.Mutex + pending map[int]chan *wsEnvelope // command replies, by command id + snapshots map[int][]chan *wsEnvelope // get_snapshot waiters, by sid + msgChan chan *types.WSMessage + readDone chan struct{} } type wsEnvelope struct { @@ -50,9 +61,12 @@ type wsEnvelope struct { type WSOption func(*wsOpts) type wsOpts struct { - scheme string - host string - path string + scheme string + host string + path string + bufferSize int + pingInterval time.Duration + readTimeout time.Duration } func WSScheme(scheme string) WSOption { @@ -73,17 +87,50 @@ func WSPath(path string) WSOption { } } +// WSBufferSize sets the Messages() buffer. If the consumer lets it fill, the +// connection fails with ErrWSSlowConsumer rather than dropping messages. +func WSBufferSize(n int) WSOption { + return func(o *wsOpts) { + o.bufferSize = n + } +} + +// WSPingInterval sets how often a keepalive ping is sent. <= 0 disables pings. +func WSPingInterval(d time.Duration) WSOption { + return func(o *wsOpts) { + o.pingInterval = d + } +} + +// WSReadTimeout fails the connection if nothing (data, ping, or pong) is read +// for this long. <= 0 disables the read deadline. +func WSReadTimeout(d time.Duration) WSOption { + return func(o *wsOpts) { + o.readTimeout = d + } +} + func (c *Client) ConnectWS(ctx context.Context, opts ...WSOption) (*WSConn, error) { if c.auth == nil { return nil, ErrWSAuthRequired } - cfg := wsOpts{scheme: "wss", host: defaultWSHost, path: defaultWSPath} + cfg := wsOpts{ + scheme: "wss", + host: defaultWSHost, + path: defaultWSPath, + bufferSize: defaultWSBufferSize, + pingInterval: defaultWSPingInterval, + readTimeout: defaultWSReadTimeout, + } for _, o := range opts { o(&cfg) } if cfg.scheme == "" { cfg.scheme = "wss" } + if cfg.bufferSize <= 0 { + cfg.bufferSize = defaultWSBufferSize + } u := url.URL{Scheme: cfg.scheme, Host: cfg.host, Path: cfg.path} req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) if err != nil { @@ -100,59 +147,127 @@ func (c *Client) ConnectWS(ctx context.Context, opts ...WSOption) (*WSConn, erro return nil, fmt.Errorf("ws dial: %w", err) } ws := &WSConn{ - conn: conn, - auth: c.auth, - host: cfg.host, - path: cfg.path, - pending: make(map[int]chan *wsEnvelope), - msgChan: make(chan *types.WSMessage, 256), - readDone: make(chan struct{}), + conn: conn, + auth: c.auth, + host: cfg.host, + path: cfg.path, + readTimeout: cfg.readTimeout, + pending: make(map[int]chan *wsEnvelope), + snapshots: make(map[int][]chan *wsEnvelope), + msgChan: make(chan *types.WSMessage, cfg.bufferSize), + readDone: make(chan struct{}), } ws.nextID.Store(1) + ws.resetDeadline() + conn.SetPongHandler(func(string) error { + ws.resetDeadline() + return nil + }) + conn.SetPingHandler(func(data string) error { + ws.resetDeadline() + err := conn.WriteControl(websocket.PongMessage, []byte(data), time.Now().Add(time.Second)) + var ne net.Error + if errors.Is(err, websocket.ErrCloseSent) || errors.As(err, &ne) { + return nil + } + return err + }) go ws.readLoop() + if cfg.pingInterval > 0 { + go ws.keepalive(cfg.pingInterval) + } return ws, nil } +func (ws *WSConn) resetDeadline() { + if ws.readTimeout > 0 { + ws.conn.SetReadDeadline(time.Now().Add(ws.readTimeout)) + } +} + +func (ws *WSConn) setErr(err error) { + ws.mu.Lock() + if ws.readErr == nil { + ws.readErr = err + } + ws.mu.Unlock() +} + +func (ws *WSConn) keepalive(interval time.Duration) { + t := time.NewTicker(interval) + defer t.Stop() + for { + select { + case <-ws.readDone: + return + case <-t.C: + ws.conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(5*time.Second)) + } + } +} + func (ws *WSConn) readLoop() { defer close(ws.readDone) defer close(ws.msgChan) + defer ws.drainPending() for { _, data, err := ws.conn.ReadMessage() if err != nil { - ws.mu.Lock() - ws.readErr = err - ws.mu.Unlock() - ws.drainPending(err) + ws.setErr(err) return } + ws.resetDeadline() var env wsEnvelope if err := json.Unmarshal(data, &env); err != nil { continue } - ws.pendMu.Lock() - ch, ok := ws.pending[env.ID] - delete(ws.pending, env.ID) - ws.pendMu.Unlock() - if ok && ch != nil { - select { - case ch <- &env: - default: + if env.ID != 0 { + ws.pendMu.Lock() + ch := ws.pending[env.ID] + ws.pendMu.Unlock() + if ch != nil { + select { + case ch <- &env: + default: + } + } + } + // get_snapshot is answered with orderbook_snapshot frames that carry no + // command id, so its waiter is keyed by sid instead. + if env.Type == types.WSTypeOrderbookSnapshot && env.SID != 0 { + ws.pendMu.Lock() + waiters := ws.snapshots[env.SID] + ws.pendMu.Unlock() + for _, ch := range waiters { + select { + case ch <- &env: + default: + } } } msg := &types.WSMessage{Type: env.Type, SID: env.SID, Seq: env.Seq, Msg: env.Msg} select { case ws.msgChan <- msg: default: + ws.setErr(ErrWSSlowConsumer) + ws.conn.Close() + return } } } -func (ws *WSConn) drainPending(err error) { +func (ws *WSConn) drainPending() { ws.pendMu.Lock() for _, ch := range ws.pending { close(ch) } + for _, waiters := range ws.snapshots { + for _, ch := range waiters { + close(ch) + } + } ws.pending = make(map[int]chan *wsEnvelope) + ws.snapshots = make(map[int][]chan *wsEnvelope) ws.pendMu.Unlock() } @@ -160,28 +275,48 @@ func (ws *WSConn) nextIDVal() int { return int(ws.nextID.Add(1)) } -func (ws *WSConn) sendAndWait(ctx context.Context, id int, payload interface{}, expectCount int) ([]*wsEnvelope, error) { +// sendAndWait writes payload and collects expectCount id-matched replies. If +// snapshotSID is non-zero the call also completes on the first +// orderbook_snapshot frame for that sid, which is how get_snapshot is answered. +// An error reply ends the wait; the replies collected before it are returned +// alongside the *WSError so callers can report partial success. +func (ws *WSConn) sendAndWait(ctx context.Context, id int, payload interface{}, expectCount int, snapshotSID int) ([]*wsEnvelope, error) { data, err := json.Marshal(payload) if err != nil { return nil, err } ws.mu.Lock() - if ws.closed { + if ws.readErr != nil { + err := ws.readErr ws.mu.Unlock() - return nil, ErrWSClosed + return nil, err } - ch := make(chan *wsEnvelope, 8) + ch := make(chan *wsEnvelope, max(expectCount, 1)) + var snap chan *wsEnvelope // nil blocks forever in the select below ws.pendMu.Lock() ws.pending[id] = ch + if snapshotSID != 0 { + snap = make(chan *wsEnvelope, 1) + ws.snapshots[snapshotSID] = append(ws.snapshots[snapshotSID], snap) + } ws.pendMu.Unlock() ws.mu.Unlock() defer func() { ws.pendMu.Lock() delete(ws.pending, id) + if snap != nil { + ws.removeSnapshotWaiter(snapshotSID, snap) + } ws.pendMu.Unlock() }() - if err := ws.conn.WriteMessage(websocket.TextMessage, data); err != nil { + ws.writeMu.Lock() + err = ws.conn.WriteMessage(websocket.TextMessage, data) + ws.writeMu.Unlock() + if err != nil { + if e := ws.Err(); e != nil { + return nil, e + } return nil, err } var out []*wsEnvelope @@ -189,22 +324,23 @@ func (ws *WSConn) sendAndWait(ctx context.Context, id int, payload interface{}, select { case <-ctx.Done(): return nil, ctx.Err() + case <-ws.readDone: + return nil, ws.closedErr() + case env, ok := <-snap: + if !ok { + return nil, ws.closedErr() + } + return []*wsEnvelope{env}, nil case env, ok := <-ch: if !ok { - ws.mu.Lock() - e := ws.readErr - ws.mu.Unlock() - if e != nil { - return nil, e - } - return nil, ErrWSClosed + return nil, ws.closedErr() } - if env.Type == "error" { + if env.Type == types.WSTypeError { var errMsg types.ErrorMsg if len(env.Msg) > 0 { json.Unmarshal(env.Msg, &errMsg) } - return nil, &WSError{Code: errMsg.Code, Message: errMsg.Msg} + return out, &WSError{Code: errMsg.Code, Message: errMsg.Msg} } out = append(out, env) if expectCount <= 0 || len(out) >= expectCount { @@ -214,6 +350,33 @@ func (ws *WSConn) sendAndWait(ctx context.Context, id int, payload interface{}, } } +// removeSnapshotWaiter must be called with pendMu held. +func (ws *WSConn) removeSnapshotWaiter(sid int, ch chan *wsEnvelope) { + waiters := ws.snapshots[sid] + for i, w := range waiters { + if w == ch { + waiters = append(waiters[:i], waiters[i+1:]...) + break + } + } + if len(waiters) == 0 { + delete(ws.snapshots, sid) + } else { + ws.snapshots[sid] = waiters + } +} + +func (ws *WSConn) closedErr() error { + if err := ws.Err(); err != nil { + return err + } + return ErrWSClosed +} + +// Subscribe sends a subscribe command and returns one SubscribedResponse per +// channel. The server answers each channel separately, so if it rejects one +// channel after accepting others the accepted subscriptions are returned +// together with the *WSError; they are live and must be unsubscribed or used. func (ws *WSConn) Subscribe(ctx context.Context, params types.SubscribeParams) ([]types.SubscribedResponse, error) { if len(params.Channels) == 0 { return nil, errors.New("channels required") @@ -224,25 +387,30 @@ func (ws *WSConn) Subscribe(ctx context.Context, params types.SubscribeParams) ( Cmd: "subscribe", Params: params, } - envs, err := ws.sendAndWait(ctx, id, cmd, len(params.Channels)) - if err != nil { - return nil, err - } - result := make([]types.SubscribedResponse, 0, len(envs)) + envs, err := ws.sendAndWait(ctx, id, cmd, len(params.Channels), 0) + var result []types.SubscribedResponse for _, env := range envs { - if env.Type != "subscribed" { + if env.Type != types.WSTypeSubscribed { continue } var m types.SubscribedMsg if len(env.Msg) > 0 { - json.Unmarshal(env.Msg, &m) + if derr := json.Unmarshal(env.Msg, &m); derr != nil { + return result, fmt.Errorf("ws: decode subscribed reply: %w", derr) + } } result = append(result, types.SubscribedResponse{ ID: env.ID, - Type: "subscribed", + Type: types.WSTypeSubscribed, Msg: m, }) } + if err != nil { + return result, err + } + if result == nil { + result = []types.SubscribedResponse{} + } return result, nil } @@ -253,14 +421,14 @@ func (ws *WSConn) Unsubscribe(ctx context.Context, sids []int) error { id := ws.nextIDVal() cmd := types.UnsubscribeCommand{ID: id, Cmd: "unsubscribe"} cmd.Params.Sids = sids - _, err := ws.sendAndWait(ctx, id, cmd, len(sids)) + _, err := ws.sendAndWait(ctx, id, cmd, len(sids), 0) return err } func (ws *WSConn) ListSubscriptions(ctx context.Context) (*types.ListSubscriptionsResponse, error) { id := ws.nextIDVal() cmd := types.ListSubscriptionsCommand{ID: id, Cmd: "list_subscriptions"} - envs, err := ws.sendAndWait(ctx, id, cmd, 1) + envs, err := ws.sendAndWait(ctx, id, cmd, 1, 0) if err != nil { return nil, err } @@ -272,11 +440,29 @@ func (ws *WSConn) ListSubscriptions(ctx context.Context) (*types.ListSubscriptio list.ID = env.ID list.Type = env.Type if len(env.Msg) > 0 { - json.Unmarshal(env.Msg, &list.Msg) + if err := json.Unmarshal(env.Msg, &list.Msg); err != nil { + return nil, fmt.Errorf("ws: decode list_subscriptions reply: %w", err) + } } return &list, nil } +// UpdateSubscription sends an update_subscription command and returns the +// server's reply. The reply Type depends on the action: +// +// - add_markets, delete_markets, subscribe_underlyings, unsubscribe_underlyings, +// subscribe_indices, unsubscribe_indices: "ok"; Msg holds the full list +// after the update. +// - underlying_list / indexlist: "pyth_value_underlying_list" or +// "cfbenchmarks_value_indexlist" / "cfbenchmarks_value_5hz_indexlist"; +// Msg.UnderlyingTickers or Msg.IndexIDs holds the list. +// - get_snapshot: the server answers with orderbook_snapshot frames on +// Messages(), which carry no command id. The call returns once the first +// snapshot for the subscription (or an id-matched ok/error) arrives, with +// Type "orderbook_snapshot" and the frame's SID/Seq; the snapshots +// themselves are read from Messages(). +// +// Server-side rejections are returned as *WSError. func (ws *WSConn) UpdateSubscription(ctx context.Context, params types.UpdateSubscriptionParams) (*types.OKResponse, error) { switch params.Action { case types.WSUpdateSubscriptionAddMarkets, @@ -291,9 +477,20 @@ func (ws *WSConn) UpdateSubscription(ctx context.Context, params types.UpdateSub default: return nil, errors.New("action must be add_markets, delete_markets, get_snapshot, subscribe_underlyings, unsubscribe_underlyings, underlying_list, subscribe_indices, unsubscribe_indices, or indexlist") } + snapshotSID := 0 + if params.Action == types.WSUpdateSubscriptionGetSnapshot { + switch { + case params.SID != nil: + snapshotSID = *params.SID + case len(params.Sids) == 1: + snapshotSID = params.Sids[0] + default: + return nil, errors.New("get_snapshot requires sid or a single-element sids") + } + } id := ws.nextIDVal() cmd := types.UpdateSubscriptionCommand{ID: id, Cmd: "update_subscription", Params: params} - envs, err := ws.sendAndWait(ctx, id, cmd, 1) + envs, err := ws.sendAndWait(ctx, id, cmd, 1, snapshotSID) if err != nil { return nil, err } @@ -301,14 +498,12 @@ func (ws *WSConn) UpdateSubscription(ctx context.Context, params types.UpdateSub return nil, errors.New("no response") } env := envs[0] - var ok types.OKResponse - ok.ID = env.ID - ok.SID = env.SID - ok.Seq = env.Seq - ok.Type = env.Type - if len(env.Msg) > 0 { + ok := types.OKResponse{ID: id, SID: env.SID, Seq: env.Seq, Type: env.Type} + if env.Type != types.WSTypeOrderbookSnapshot && len(env.Msg) > 0 { ok.Msg = &types.OKMsg{} - json.Unmarshal(env.Msg, ok.Msg) + if err := json.Unmarshal(env.Msg, ok.Msg); err != nil { + return nil, fmt.Errorf("ws: decode %s reply: %w", env.Type, err) + } } return &ok, nil } @@ -317,6 +512,19 @@ func (ws *WSConn) Messages() <-chan *types.WSMessage { return ws.msgChan } +// Done is closed once the read loop has exited; Messages() is closed by then. +func (ws *WSConn) Done() <-chan struct{} { + return ws.readDone +} + +// Err is nil while the connection is healthy. After the read loop exits it is +// the terminal read error, ErrWSSlowConsumer, or ErrWSClosed after Close(). +func (ws *WSConn) Err() error { + ws.mu.Lock() + defer ws.mu.Unlock() + return ws.readErr +} + func (ws *WSConn) Close() error { ws.mu.Lock() if ws.closed { @@ -324,11 +532,22 @@ func (ws *WSConn) Close() error { return nil } ws.closed = true + healthy := ws.readErr == nil + if healthy { + ws.readErr = ErrWSClosed + } + ws.mu.Unlock() + if !healthy { + ws.conn.Close() + <-ws.readDone + return nil + } + ws.writeMu.Lock() err := ws.conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) + ws.writeMu.Unlock() if e := ws.conn.Close(); e != nil && err == nil { err = e } - ws.mu.Unlock() select { case <-ws.readDone: return err diff --git a/oddrip/ws_test.go b/oddrip/ws_test.go index 85c2021..726297c 100644 --- a/oddrip/ws_test.go +++ b/oddrip/ws_test.go @@ -3,9 +3,13 @@ package oddrip import ( "context" "encoding/json" + "errors" + "fmt" + "net" "net/http" "net/http/httptest" "net/url" + "sync" "testing" "time" @@ -134,6 +138,554 @@ func TestUpdateSubscription_RejectsUnknownAction(t *testing.T) { } } +func TestWS_Subscribe_MultiChannel(t *testing.T) { + ws := wsTestConnect(t, wsTestServer(t, wsSubscribeEcho)) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + channels := []string{types.WSChannelTicker, types.WSChannelOrderbookDelta} + start := time.Now() + subs, err := ws.Subscribe(ctx, types.SubscribeParams{Channels: channels}) + if err != nil { + t.Fatalf("Subscribe: %v", err) + } + if d := time.Since(start); d > time.Second { + t.Errorf("Subscribe took %v", d) + } + if len(subs) != len(channels) { + t.Fatalf("expected %d subscribed, got %d", len(channels), len(subs)) + } + for i, s := range subs { + if s.Msg.Channel != channels[i] || s.Msg.SID != i+1 { + t.Errorf("subs[%d]: channel=%s sid=%d", i, s.Msg.Channel, s.Msg.SID) + } + } +} + +func TestWS_Subscribe_ManyChannels(t *testing.T) { + ws := wsTestConnect(t, wsTestServer(t, wsSubscribeEcho)) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + channels := make([]string, 12) + for i := range channels { + channels[i] = fmt.Sprintf("ch%d", i) + } + subs, err := ws.Subscribe(ctx, types.SubscribeParams{Channels: channels}) + if err != nil { + t.Fatalf("Subscribe: %v", err) + } + if len(subs) != 12 { + t.Fatalf("expected 12 subscribed, got %d", len(subs)) + } + for i, s := range subs { + if s.Msg.Channel != channels[i] || s.Msg.SID != i+1 { + t.Errorf("subs[%d]: channel=%s sid=%d", i, s.Msg.Channel, s.Msg.SID) + } + } +} + +func TestWS_Subscribe_Concurrent(t *testing.T) { + ws := wsTestConnect(t, wsTestServer(t, wsSubscribeEcho)) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + const n = 20 + errs := make([]error, n) + var wg sync.WaitGroup + for i := 0; i < n; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + subs, err := ws.Subscribe(ctx, types.SubscribeParams{Channels: []string{fmt.Sprintf("ch%d", i)}}) + if err == nil && len(subs) != 1 { + err = fmt.Errorf("got %d subscribed", len(subs)) + } + errs[i] = err + }(i) + } + wg.Wait() + for i, err := range errs { + if err != nil { + t.Errorf("Subscribe %d: %v", i, err) + } + } +} + +func TestWS_SlowConsumer(t *testing.T) { + ws := wsTestConnect(t, wsTestServer(t, func(conn *websocket.Conn) { + for i := 0; i < 100; i++ { + body, _ := json.Marshal(map[string]interface{}{ + "type": "ticker", "sid": 1, "seq": i, "msg": map[string]interface{}{}, + }) + if conn.WriteMessage(websocket.TextMessage, body) != nil { + return + } + } + wsDrain(conn) + }), WSBufferSize(4)) + + select { + case <-ws.Done(): + case <-time.After(2 * time.Second): + t.Fatal("Done() did not close") + } + if err := ws.Err(); err != ErrWSSlowConsumer { + t.Fatalf("Err() = %v, want ErrWSSlowConsumer", err) + } + n := 0 + for range ws.Messages() { + n++ + } + if n > 4 { + t.Errorf("buffered %d messages, want <= 4", n) + } + if err := ws.Close(); err != nil { + t.Errorf("Close: %v", err) + } + if err := ws.Err(); err != ErrWSSlowConsumer { + t.Errorf("Err() after Close = %v, want ErrWSSlowConsumer", err) + } +} + +func TestWS_ReadTimeout(t *testing.T) { + block := make(chan struct{}) + t.Cleanup(func() { close(block) }) + ws := wsTestConnect(t, wsTestServer(t, func(*websocket.Conn) { <-block }), + WSReadTimeout(200*time.Millisecond), WSPingInterval(50*time.Millisecond)) + + start := time.Now() + select { + case <-ws.Done(): + case <-time.After(1500 * time.Millisecond): + t.Fatal("Done() did not close") + } + if d := time.Since(start); d > time.Second { + t.Errorf("dead connection detected after %v", d) + } + err := ws.Err() + var ne net.Error + if !errors.As(err, &ne) || !ne.Timeout() { + t.Fatalf("Err() = %v, want net timeout", err) + } + if _, ok := <-ws.Messages(); ok { + t.Error("Messages() not closed") + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if _, serr := ws.Subscribe(ctx, types.SubscribeParams{Channels: []string{types.WSChannelTicker}}); serr != err { + t.Errorf("Subscribe after timeout = %v, want %v", serr, err) + } +} + +func TestWS_Keepalive_Healthy(t *testing.T) { + ws := wsTestConnect(t, wsTestServer(t, wsDrain), + WSReadTimeout(200*time.Millisecond), WSPingInterval(50*time.Millisecond)) + + select { + case <-ws.Done(): + t.Fatalf("connection dropped: %v", ws.Err()) + case <-time.After(time.Second): + } + if err := ws.Err(); err != nil { + t.Fatalf("Err() = %v, want nil", err) + } + if err := ws.Close(); err != nil { + t.Errorf("Close: %v", err) + } + if err := ws.Err(); err != ErrWSClosed { + t.Errorf("Err() after Close = %v, want ErrWSClosed", err) + } +} + +func TestWS_Close_Idempotent(t *testing.T) { + ws := wsTestConnect(t, wsTestServer(t, wsSubscribeEcho)) + + if err := ws.Close(); err != nil { + t.Fatalf("first Close: %v", err) + } + if err := ws.Close(); err != nil { + t.Fatalf("second Close: %v", err) + } + if err := ws.Err(); err != ErrWSClosed { + t.Errorf("Err() = %v, want ErrWSClosed", err) + } + select { + case <-ws.Done(): + default: + t.Error("Done() not closed") + } + if _, ok := <-ws.Messages(); ok { + t.Error("Messages() not closed") + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + start := time.Now() + _, err := ws.Subscribe(ctx, types.SubscribeParams{Channels: []string{types.WSChannelTicker}}) + if err != ErrWSClosed { + t.Errorf("Subscribe after Close = %v, want ErrWSClosed", err) + } + if d := time.Since(start); d > 100*time.Millisecond { + t.Errorf("Subscribe after Close took %v", d) + } +} + +func TestWS_Subscribe_ServerError(t *testing.T) { + ws := wsTestConnect(t, wsTestServer(t, func(conn *websocket.Conn) { + _, data, err := conn.ReadMessage() + if err != nil { + return + } + var cmd struct { + ID int `json:"id"` + } + json.Unmarshal(data, &cmd) + body, _ := json.Marshal(map[string]interface{}{ + "id": cmd.ID, + "type": "error", + "msg": map[string]interface{}{"code": 8, "msg": "Unknown channel name"}, + }) + conn.WriteMessage(websocket.TextMessage, body) + wsDrain(conn) + })) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + _, err := ws.Subscribe(ctx, types.SubscribeParams{Channels: []string{"bogus"}}) + var wsErr *WSError + if !errors.As(err, &wsErr) { + t.Fatalf("Subscribe: %v, want *WSError", err) + } + if wsErr.Code != 8 || wsErr.Message != "Unknown channel name" { + t.Errorf("WSError = %+v", wsErr) + } + if err := ws.Err(); err != nil { + t.Errorf("Err() = %v, want nil", err) + } +} + +// wsUpdateReplier serves one update_subscription command: it decodes the +// command and writes whatever frames reply(id, params) returns, in order. +func wsUpdateReplier(reply func(id int, params types.UpdateSubscriptionParams) []map[string]interface{}) func(*websocket.Conn) { + return func(conn *websocket.Conn) { + _, data, err := conn.ReadMessage() + if err != nil { + return + } + var cmd types.UpdateSubscriptionCommand + if json.Unmarshal(data, &cmd) != nil || cmd.Cmd != "update_subscription" { + return + } + for _, frame := range reply(cmd.ID, cmd.Params) { + body, _ := json.Marshal(frame) + if conn.WriteMessage(websocket.TextMessage, body) != nil { + return + } + } + wsDrain(conn) + } +} + +func wsTestCtx(t *testing.T) context.Context { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + t.Cleanup(cancel) + return ctx +} + +// The spec answers get_snapshot with orderbook_snapshot frames that carry no +// command id; the call must complete on the first one for the subscription. +func TestWS_UpdateSubscription_GetSnapshot_SnapshotOnly(t *testing.T) { + snapshot := map[string]interface{}{ + "market_ticker": "FED-23DEC-T3.00", + "market_id": "9b0f6b43-5b68-4f9f-9f02-9a2d1b8ac1a1", + "yes_dollars_fp": [][]string{{"0.0800", "300.00"}}, + } + ws := wsTestConnect(t, wsTestServer(t, wsUpdateReplier(func(id int, p types.UpdateSubscriptionParams) []map[string]interface{} { + if p.Action != types.WSUpdateSubscriptionGetSnapshot || len(p.Sids) != 1 || p.Sids[0] != 7 { + return []map[string]interface{}{{"id": id, "type": "error", "msg": map[string]interface{}{"code": 1, "msg": "bad command"}}} + } + return []map[string]interface{}{ + // A snapshot for a different sid must not satisfy the waiter. + {"type": "orderbook_snapshot", "sid": 8, "seq": 1, "msg": snapshot}, + {"type": "orderbook_snapshot", "sid": 7, "seq": 12, "msg": snapshot}, + } + }))) + resp, err := ws.UpdateSubscription(wsTestCtx(t), types.UpdateSubscriptionParams{ + Sids: []int{7}, + MarketTickers: []string{"FED-23DEC-T3.00"}, + Action: types.WSUpdateSubscriptionGetSnapshot, + }) + if err != nil { + t.Fatalf("UpdateSubscription(get_snapshot): %v", err) + } + if resp.Type != types.WSTypeOrderbookSnapshot || resp.SID != 7 || resp.Seq != 12 || resp.Msg != nil { + t.Fatalf("resp = %+v", resp) + } + // Both frames still reach the consumer. + var got []int + for len(got) < 2 { + select { + case m := <-ws.Messages(): + if m.Type == types.WSTypeOrderbookSnapshot { + got = append(got, m.SID) + } + case <-time.After(2 * time.Second): + t.Fatalf("snapshots on Messages(): got %v", got) + } + } + if got[0] != 8 || got[1] != 7 { + t.Fatalf("snapshot sids on Messages() = %v", got) + } +} + +func TestWS_UpdateSubscription_GetSnapshot_OKReply(t *testing.T) { + ws := wsTestConnect(t, wsTestServer(t, wsUpdateReplier(func(id int, p types.UpdateSubscriptionParams) []map[string]interface{} { + return []map[string]interface{}{{"id": id, "sid": 7, "seq": 3, "type": "ok", "msg": map[string]interface{}{"market_tickers": []string{"A", "B"}}}} + }))) + sid := 7 + resp, err := ws.UpdateSubscription(wsTestCtx(t), types.UpdateSubscriptionParams{ + SID: &sid, MarketTickers: []string{"A"}, Action: types.WSUpdateSubscriptionGetSnapshot, + }) + if err != nil { + t.Fatalf("UpdateSubscription: %v", err) + } + if resp.Type != types.WSTypeOK || resp.SID != 7 || resp.Msg == nil || len(resp.Msg.MarketTickers) != 2 { + t.Fatalf("resp = %+v", resp) + } +} + +func TestWS_UpdateSubscription_GetSnapshot_ServerError(t *testing.T) { + ws := wsTestConnect(t, wsTestServer(t, wsUpdateReplier(func(id int, p types.UpdateSubscriptionParams) []map[string]interface{} { + return []map[string]interface{}{{"id": id, "type": "error", "msg": map[string]interface{}{"code": 6, "msg": "Invalid subscription id"}}} + }))) + sid := 99 + _, err := ws.UpdateSubscription(wsTestCtx(t), types.UpdateSubscriptionParams{ + SID: &sid, MarketTickers: []string{"A"}, Action: types.WSUpdateSubscriptionGetSnapshot, + }) + var wsErr *WSError + if !errors.As(err, &wsErr) || wsErr.Code != 6 { + t.Fatalf("err = %v, want *WSError code 6", err) + } +} + +func TestWS_UpdateSubscription_GetSnapshot_RequiresSID(t *testing.T) { + ws := &WSConn{} + _, err := ws.UpdateSubscription(context.Background(), types.UpdateSubscriptionParams{ + MarketTickers: []string{"A"}, Action: types.WSUpdateSubscriptionGetSnapshot, + }) + if err == nil { + t.Fatal("expected error when get_snapshot has no sid") + } +} + +func TestWS_UpdateSubscription_AddMarkets_OK(t *testing.T) { + ws := wsTestConnect(t, wsTestServer(t, wsUpdateReplier(func(id int, p types.UpdateSubscriptionParams) []map[string]interface{} { + if p.Action != types.WSUpdateSubscriptionAddMarkets { + return []map[string]interface{}{{"id": id, "type": "error", "msg": map[string]interface{}{"code": 1, "msg": "bad command"}}} + } + return []map[string]interface{}{ + {"id": id, "sid": 456, "seq": 222, "type": "ok", "msg": map[string]interface{}{"market_tickers": []string{"MARKET-1", "MARKET-2", "MARKET-3"}}}, + {"type": "orderbook_snapshot", "sid": 456, "seq": 223, "msg": map[string]interface{}{"market_ticker": "MARKET-3", "market_id": "x"}}, + } + }))) + resp, err := ws.UpdateSubscription(wsTestCtx(t), types.UpdateSubscriptionParams{ + Sids: []int{456}, MarketTickers: []string{"MARKET-3"}, Action: types.WSUpdateSubscriptionAddMarkets, + }) + if err != nil { + t.Fatalf("UpdateSubscription: %v", err) + } + if resp.Type != types.WSTypeOK || resp.ID == 0 || resp.SID != 456 || resp.Seq != 222 { + t.Fatalf("resp = %+v", resp) + } + if resp.Msg == nil || len(resp.Msg.MarketTickers) != 3 { + t.Fatalf("resp.Msg = %+v", resp.Msg) + } +} + +// indexlist / underlying_list replies carry the command id but a list-specific +// type; the list itself decodes into OKMsg. +func TestWS_UpdateSubscription_IndexList(t *testing.T) { + ws := wsTestConnect(t, wsTestServer(t, wsUpdateReplier(func(id int, p types.UpdateSubscriptionParams) []map[string]interface{} { + return []map[string]interface{}{{"id": id, "sid": 3, "seq": 9, "type": "cfbenchmarks_value_indexlist", "msg": map[string]interface{}{"index_ids": []string{"BRTI", "ETHUSD_RTI"}}}} + }))) + sid := 3 + resp, err := ws.UpdateSubscription(wsTestCtx(t), types.UpdateSubscriptionParams{SID: &sid, Action: types.WSUpdateSubscriptionIndexList}) + if err != nil { + t.Fatalf("UpdateSubscription: %v", err) + } + if resp.Type != types.WSTypeCFBenchmarksValueIndexList || resp.Msg == nil || len(resp.Msg.IndexIDs) != 2 || resp.Msg.IndexIDs[0] != "BRTI" { + t.Fatalf("resp = %+v msg=%+v", resp, resp.Msg) + } +} + +// A get_snapshot waiter must not survive the call: a later snapshot on the +// same sid goes only to Messages(). +func TestWS_UpdateSubscription_GetSnapshot_WaiterRemoved(t *testing.T) { + ws := wsTestConnect(t, wsTestServer(t, wsUpdateReplier(func(id int, p types.UpdateSubscriptionParams) []map[string]interface{} { + return []map[string]interface{}{{"type": "orderbook_snapshot", "sid": 7, "seq": 1, "msg": map[string]interface{}{"market_ticker": "A", "market_id": "x"}}} + }))) + sid := 7 + if _, err := ws.UpdateSubscription(wsTestCtx(t), types.UpdateSubscriptionParams{SID: &sid, MarketTickers: []string{"A"}, Action: types.WSUpdateSubscriptionGetSnapshot}); err != nil { + t.Fatalf("UpdateSubscription: %v", err) + } + ws.pendMu.Lock() + n := len(ws.snapshots) + len(ws.pending) + ws.pendMu.Unlock() + if n != 0 { + t.Fatalf("waiters left registered: %d", n) + } +} + +// Two get_snapshot calls in flight on the same sid both complete on the next +// snapshot; neither starves the other. +func TestWS_UpdateSubscription_GetSnapshot_ConcurrentSameSID(t *testing.T) { + ws := wsTestConnect(t, wsTestServer(t, func(conn *websocket.Conn) { + for i := 0; i < 2; i++ { + if _, _, err := conn.ReadMessage(); err != nil { + return + } + } + body, _ := json.Marshal(map[string]interface{}{"type": "orderbook_snapshot", "sid": 7, "seq": 1, "msg": map[string]interface{}{"market_ticker": "A", "market_id": "x"}}) + conn.WriteMessage(websocket.TextMessage, body) + wsDrain(conn) + })) + sid := 7 + errs := make(chan error, 2) + for i := 0; i < 2; i++ { + go func() { + _, err := ws.UpdateSubscription(wsTestCtx(t), types.UpdateSubscriptionParams{SID: &sid, MarketTickers: []string{"A"}, Action: types.WSUpdateSubscriptionGetSnapshot}) + errs <- err + }() + } + for i := 0; i < 2; i++ { + if err := <-errs; err != nil { + t.Fatalf("UpdateSubscription: %v", err) + } + } +} + +// A channel rejected after others were accepted: the accepted sids are live +// on the server and must be returned alongside the error. +func TestWS_Subscribe_PartialFailure(t *testing.T) { + ws := wsTestConnect(t, wsTestServer(t, func(conn *websocket.Conn) { + _, data, err := conn.ReadMessage() + if err != nil { + return + } + var cmd struct { + ID int `json:"id"` + } + json.Unmarshal(data, &cmd) + for _, frame := range []map[string]interface{}{ + {"id": cmd.ID, "type": "subscribed", "msg": map[string]interface{}{"channel": "ticker", "sid": 1}}, + {"id": cmd.ID, "type": "error", "msg": map[string]interface{}{"code": 8, "msg": "Unknown channel name"}}, + } { + body, _ := json.Marshal(frame) + conn.WriteMessage(websocket.TextMessage, body) + } + wsDrain(conn) + })) + subs, err := ws.Subscribe(wsTestCtx(t), types.SubscribeParams{Channels: []string{"ticker", "bogus"}}) + var wsErr *WSError + if !errors.As(err, &wsErr) || wsErr.Code != 8 { + t.Fatalf("err = %v, want *WSError code 8", err) + } + if len(subs) != 1 || subs[0].Msg.SID != 1 || subs[0].Msg.Channel != "ticker" { + t.Fatalf("partial subs = %+v, want the accepted ticker sid", subs) + } +} + +func TestWS_Subscribe_MalformedReply(t *testing.T) { + ws := wsTestConnect(t, wsTestServer(t, func(conn *websocket.Conn) { + _, data, err := conn.ReadMessage() + if err != nil { + return + } + var cmd struct { + ID int `json:"id"` + } + json.Unmarshal(data, &cmd) + body, _ := json.Marshal(map[string]interface{}{"id": cmd.ID, "type": "subscribed", "msg": "not-an-object"}) + conn.WriteMessage(websocket.TextMessage, body) + wsDrain(conn) + })) + subs, err := ws.Subscribe(wsTestCtx(t), types.SubscribeParams{Channels: []string{"ticker"}}) + if err == nil { + t.Fatalf("expected decode error, got subs=%+v", subs) + } + var wsErr *WSError + if errors.As(err, &wsErr) { + t.Fatalf("decode failure must not be reported as a server *WSError: %v", err) + } +} + +func wsTestServer(t *testing.T, handler func(*websocket.Conn)) *httptest.Server { + t.Helper() + upgrader := websocket.Upgrader{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + handler(conn) + })) + t.Cleanup(srv.Close) + return srv +} + +func wsTestConnect(t *testing.T, srv *httptest.Server, opts ...WSOption) *WSConn { + t.Helper() + u, _ := url.Parse(srv.URL) + client := New(Auth(&mockWSAuth{})) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + ws, err := client.ConnectWS(ctx, append([]WSOption{WSScheme("ws"), WSHost(u.Host), WSPath("/")}, opts...)...) + if err != nil { + t.Fatalf("ConnectWS: %v", err) + } + t.Cleanup(func() { ws.Close() }) + return ws +} + +func wsSubscribeEcho(conn *websocket.Conn) { + for { + _, data, err := conn.ReadMessage() + if err != nil { + return + } + var cmd struct { + ID int `json:"id"` + Params struct { + Channels []string `json:"channels"` + } `json:"params"` + } + if json.Unmarshal(data, &cmd) != nil { + return + } + for i, ch := range cmd.Params.Channels { + body, _ := json.Marshal(map[string]interface{}{ + "id": cmd.ID, + "type": "subscribed", + "msg": map[string]interface{}{"channel": ch, "sid": i + 1}, + }) + if conn.WriteMessage(websocket.TextMessage, body) != nil { + return + } + } + } +} + +func wsDrain(conn *websocket.Conn) { + for { + if _, _, err := conn.ReadMessage(); err != nil { + return + } + } +} + type mockWSAuth struct{} func (m *mockWSAuth) Apply(req *http.Request) error {