From e73248e0c4951435096998f3c327fc4b0c38fb79 Mon Sep 17 00:00:00 2001 From: Andrew Fuller Date: Fri, 28 Aug 2026 13:26:50 -0400 Subject: [PATCH 01/26] feat(sdk-go): add secure Go SDK --- .github/workflows/validate-go-sdk.yml | 65 + README.md | 1 + packages/sdk-go/Makefile | 57 + packages/sdk-go/README.md | 677 +++ packages/sdk-go/aliases.go | 219 + packages/sdk-go/auth/auth.go | 60 + packages/sdk-go/auth/bearer.go | 181 + packages/sdk-go/auth/bearer_test.go | 333 ++ packages/sdk-go/auth/fuzz_test.go | 44 + packages/sdk-go/auth/hmac.go | 322 ++ packages/sdk-go/auth/hmac_test.go | 356 ++ packages/sdk-go/auth/secrets.go | 79 + packages/sdk-go/client.go | 236 ++ packages/sdk-go/client_internal_test.go | 17 + packages/sdk-go/client_test.go | 482 +++ packages/sdk-go/cmd/demo/go.mod | 14 + packages/sdk-go/cmd/demo/go.sum | 2 + packages/sdk-go/cmd/demo/main.go | 265 ++ packages/sdk-go/doc.go | 95 + packages/sdk-go/environment.go | 38 + packages/sdk-go/errors.go | 276 ++ packages/sdk-go/errors_test.go | 173 + packages/sdk-go/example_test.go | 292 ++ packages/sdk-go/geminitest/mock_server.go | 338 ++ .../sdk-go/generated/account/types.gen.go | 3674 +++++++++++++++++ .../sdk-go/generated/clearing/types.gen.go | 3339 +++++++++++++++ packages/sdk-go/generated/margin/types.gen.go | 2832 +++++++++++++ .../sdk-go/generated/marketdata/types.gen.go | 2822 +++++++++++++ .../sdk-go/generated/perpetuals/types.gen.go | 2859 +++++++++++++ .../sdk-go/generated/predictions/types.gen.go | 3251 +++++++++++++++ .../sdk-go/generated/trading/types.gen.go | 3064 ++++++++++++++ packages/sdk-go/go.mod | 3 + packages/sdk-go/internal/runtime/union.go | 55 + .../sdk-go/internal/runtime/union_test.go | 32 + packages/sdk-go/memory_leak_test.go | 145 + packages/sdk-go/options.go | 147 + packages/sdk-go/pointers.go | 27 + packages/sdk-go/pointers_test.go | 63 + packages/sdk-go/scripts/contract_test.go | 274 ++ packages/sdk-go/scripts/generate.go | 399 ++ packages/sdk-go/scripts/go.mod | 30 + packages/sdk-go/scripts/go.sum | 179 + packages/sdk-go/scripts/release_smoke.sh | 77 + .../sdk-go/scripts/service_coverage_test.go | 205 + .../sdk-go/scripts/websocket_coverage_test.go | 109 + packages/sdk-go/services/account.go | 277 ++ packages/sdk-go/services/account_test.go | 282 ++ packages/sdk-go/services/base.go | 50 + packages/sdk-go/services/base_test.go | 25 + packages/sdk-go/services/clearing.go | 63 + packages/sdk-go/services/clearing_test.go | 83 + packages/sdk-go/services/heartbeat.go | 126 + packages/sdk-go/services/heartbeat_test.go | 91 + packages/sdk-go/services/margin.go | 53 + packages/sdk-go/services/margin_test.go | 64 + packages/sdk-go/services/marketdata.go | 100 + packages/sdk-go/services/marketdata_test.go | 113 + packages/sdk-go/services/perpetuals.go | 73 + packages/sdk-go/services/perpetuals_test.go | 103 + packages/sdk-go/services/predictions.go | 158 + .../sdk-go/services/predictions_combos.go | 54 + .../services/predictions_expansion_test.go | 239 ++ .../services/predictions_less_used_test.go | 136 + .../sdk-go/services/predictions_markets.go | 130 + .../sdk-go/services/predictions_pagination.go | 341 ++ .../services/predictions_pagination_test.go | 56 + .../sdk-go/services/predictions_positions.go | 88 + .../sdk-go/services/predictions_rewards.go | 159 + packages/sdk-go/services/predictions_test.go | 227 + .../sdk-go/services/predictions_trading.go | 65 + packages/sdk-go/services/reconciler.go | 592 +++ .../services/reconciler_concurrency_test.go | 102 + packages/sdk-go/services/reconciler_stream.go | 347 ++ .../reconciler_stream_internal_test.go | 37 + .../sdk-go/services/reconciler_stream_test.go | 491 +++ packages/sdk-go/services/reconciler_test.go | 712 ++++ packages/sdk-go/services/sports.go | 241 ++ packages/sdk-go/services/sports_test.go | 111 + packages/sdk-go/services/staking.go | 91 + packages/sdk-go/services/staking_test.go | 123 + packages/sdk-go/services/trading.go | 229 + packages/sdk-go/services/trading_test.go | 265 ++ packages/sdk-go/services/transfers.go | 93 + packages/sdk-go/services/transfers_test.go | 82 + .../sdk-go/transport/coverage_edges_test.go | 160 + packages/sdk-go/transport/errors.go | 274 ++ packages/sdk-go/transport/hooks.go | 147 + packages/sdk-go/transport/hooks_test.go | 31 + packages/sdk-go/transport/pagination.go | 60 + packages/sdk-go/transport/retries.go | 143 + packages/sdk-go/transport/retry_edges_test.go | 91 + packages/sdk-go/transport/trace.go | 160 + packages/sdk-go/transport/trace_test.go | 53 + packages/sdk-go/transport/transport.go | 480 +++ packages/sdk-go/transport/transport_test.go | 869 ++++ packages/sdk-go/types/date.go | 62 + packages/sdk-go/types/date_test.go | 31 + packages/sdk-go/types/decimal.go | 654 +++ packages/sdk-go/types/decimal_test.go | 411 ++ packages/sdk-go/types/finance.go | 128 + packages/sdk-go/types/finance_test.go | 126 + packages/sdk-go/websocket/client.go | 613 +++ .../sdk-go/websocket/client_connection.go | 810 ++++ .../sdk-go/websocket/client_control_test.go | 307 ++ packages/sdk-go/websocket/client_dispatch.go | 260 ++ .../client_dispatch_internal_test.go | 83 + packages/sdk-go/websocket/client_hardening.go | 131 + .../sdk-go/websocket/client_hardening_test.go | 339 ++ .../sdk-go/websocket/client_internal_test.go | 919 +++++ packages/sdk-go/websocket/client_requests.go | 250 ++ .../sdk-go/websocket/client_subscriptions.go | 906 ++++ packages/sdk-go/websocket/client_test.go | 1176 ++++++ packages/sdk-go/websocket/dialer.go | 47 + packages/sdk-go/websocket/fuzz_test.go | 48 + packages/sdk-go/websocket/gorilla/dialer.go | 139 + .../sdk-go/websocket/gorilla/dialer_test.go | 166 + packages/sdk-go/websocket/gorilla/go.mod | 10 + packages/sdk-go/websocket/gorilla/go.sum | 2 + .../gorilla/oauth_integration_test.go | 58 + packages/sdk-go/websocket/models.go | 253 ++ packages/sdk-go/websocket/models_test.go | 71 + packages/sdk-go/websocket/orderbook/book.go | 490 +++ .../websocket/orderbook/book_bench_test.go | 84 + .../websocket/orderbook/book_fuzz_test.go | 95 + .../sdk-go/websocket/orderbook/book_test.go | 801 ++++ .../sdk-go/websocket/orderbook/live_book.go | 443 ++ packages/sdk-go/websocket/rfq.go | 418 ++ packages/sdk-go/websocket/rfq_test.go | 248 ++ .../sdk-go/websocket/snapshot_isolation.go | 226 + packages/sdk-go/websocket/snapshot_routing.go | 127 + .../snapshot_routing_internal_test.go | 39 + .../sdk-go/websocket/snapshot_routing_test.go | 400 ++ .../sdk-go/websocket/test_helpers_test.go | 298 ++ .../sdk-go/websocket/typed_parity_test.go | 220 + packages/sdk-go/websocket/typed_requests.go | 314 ++ .../sdk-go/websocket/typed_requests_test.go | 217 + 136 files changed, 49768 insertions(+) create mode 100644 .github/workflows/validate-go-sdk.yml create mode 100644 packages/sdk-go/Makefile create mode 100644 packages/sdk-go/README.md create mode 100644 packages/sdk-go/aliases.go create mode 100644 packages/sdk-go/auth/auth.go create mode 100644 packages/sdk-go/auth/bearer.go create mode 100644 packages/sdk-go/auth/bearer_test.go create mode 100644 packages/sdk-go/auth/fuzz_test.go create mode 100644 packages/sdk-go/auth/hmac.go create mode 100644 packages/sdk-go/auth/hmac_test.go create mode 100644 packages/sdk-go/auth/secrets.go create mode 100644 packages/sdk-go/client.go create mode 100644 packages/sdk-go/client_internal_test.go create mode 100644 packages/sdk-go/client_test.go create mode 100644 packages/sdk-go/cmd/demo/go.mod create mode 100644 packages/sdk-go/cmd/demo/go.sum create mode 100644 packages/sdk-go/cmd/demo/main.go create mode 100644 packages/sdk-go/doc.go create mode 100644 packages/sdk-go/environment.go create mode 100644 packages/sdk-go/errors.go create mode 100644 packages/sdk-go/errors_test.go create mode 100644 packages/sdk-go/example_test.go create mode 100644 packages/sdk-go/geminitest/mock_server.go create mode 100644 packages/sdk-go/generated/account/types.gen.go create mode 100644 packages/sdk-go/generated/clearing/types.gen.go create mode 100644 packages/sdk-go/generated/margin/types.gen.go create mode 100644 packages/sdk-go/generated/marketdata/types.gen.go create mode 100644 packages/sdk-go/generated/perpetuals/types.gen.go create mode 100644 packages/sdk-go/generated/predictions/types.gen.go create mode 100644 packages/sdk-go/generated/trading/types.gen.go create mode 100644 packages/sdk-go/go.mod create mode 100644 packages/sdk-go/internal/runtime/union.go create mode 100644 packages/sdk-go/internal/runtime/union_test.go create mode 100644 packages/sdk-go/memory_leak_test.go create mode 100644 packages/sdk-go/options.go create mode 100644 packages/sdk-go/pointers.go create mode 100644 packages/sdk-go/pointers_test.go create mode 100644 packages/sdk-go/scripts/contract_test.go create mode 100644 packages/sdk-go/scripts/generate.go create mode 100644 packages/sdk-go/scripts/go.mod create mode 100644 packages/sdk-go/scripts/go.sum create mode 100644 packages/sdk-go/scripts/release_smoke.sh create mode 100644 packages/sdk-go/scripts/service_coverage_test.go create mode 100644 packages/sdk-go/scripts/websocket_coverage_test.go create mode 100644 packages/sdk-go/services/account.go create mode 100644 packages/sdk-go/services/account_test.go create mode 100644 packages/sdk-go/services/base.go create mode 100644 packages/sdk-go/services/base_test.go create mode 100644 packages/sdk-go/services/clearing.go create mode 100644 packages/sdk-go/services/clearing_test.go create mode 100644 packages/sdk-go/services/heartbeat.go create mode 100644 packages/sdk-go/services/heartbeat_test.go create mode 100644 packages/sdk-go/services/margin.go create mode 100644 packages/sdk-go/services/margin_test.go create mode 100644 packages/sdk-go/services/marketdata.go create mode 100644 packages/sdk-go/services/marketdata_test.go create mode 100644 packages/sdk-go/services/perpetuals.go create mode 100644 packages/sdk-go/services/perpetuals_test.go create mode 100644 packages/sdk-go/services/predictions.go create mode 100644 packages/sdk-go/services/predictions_combos.go create mode 100644 packages/sdk-go/services/predictions_expansion_test.go create mode 100644 packages/sdk-go/services/predictions_less_used_test.go create mode 100644 packages/sdk-go/services/predictions_markets.go create mode 100644 packages/sdk-go/services/predictions_pagination.go create mode 100644 packages/sdk-go/services/predictions_pagination_test.go create mode 100644 packages/sdk-go/services/predictions_positions.go create mode 100644 packages/sdk-go/services/predictions_rewards.go create mode 100644 packages/sdk-go/services/predictions_test.go create mode 100644 packages/sdk-go/services/predictions_trading.go create mode 100644 packages/sdk-go/services/reconciler.go create mode 100644 packages/sdk-go/services/reconciler_concurrency_test.go create mode 100644 packages/sdk-go/services/reconciler_stream.go create mode 100644 packages/sdk-go/services/reconciler_stream_internal_test.go create mode 100644 packages/sdk-go/services/reconciler_stream_test.go create mode 100644 packages/sdk-go/services/reconciler_test.go create mode 100644 packages/sdk-go/services/sports.go create mode 100644 packages/sdk-go/services/sports_test.go create mode 100644 packages/sdk-go/services/staking.go create mode 100644 packages/sdk-go/services/staking_test.go create mode 100644 packages/sdk-go/services/trading.go create mode 100644 packages/sdk-go/services/trading_test.go create mode 100644 packages/sdk-go/services/transfers.go create mode 100644 packages/sdk-go/services/transfers_test.go create mode 100644 packages/sdk-go/transport/coverage_edges_test.go create mode 100644 packages/sdk-go/transport/errors.go create mode 100644 packages/sdk-go/transport/hooks.go create mode 100644 packages/sdk-go/transport/hooks_test.go create mode 100644 packages/sdk-go/transport/pagination.go create mode 100644 packages/sdk-go/transport/retries.go create mode 100644 packages/sdk-go/transport/retry_edges_test.go create mode 100644 packages/sdk-go/transport/trace.go create mode 100644 packages/sdk-go/transport/trace_test.go create mode 100644 packages/sdk-go/transport/transport.go create mode 100644 packages/sdk-go/transport/transport_test.go create mode 100644 packages/sdk-go/types/date.go create mode 100644 packages/sdk-go/types/date_test.go create mode 100644 packages/sdk-go/types/decimal.go create mode 100644 packages/sdk-go/types/decimal_test.go create mode 100644 packages/sdk-go/types/finance.go create mode 100644 packages/sdk-go/types/finance_test.go create mode 100644 packages/sdk-go/websocket/client.go create mode 100644 packages/sdk-go/websocket/client_connection.go create mode 100644 packages/sdk-go/websocket/client_control_test.go create mode 100644 packages/sdk-go/websocket/client_dispatch.go create mode 100644 packages/sdk-go/websocket/client_dispatch_internal_test.go create mode 100644 packages/sdk-go/websocket/client_hardening.go create mode 100644 packages/sdk-go/websocket/client_hardening_test.go create mode 100644 packages/sdk-go/websocket/client_internal_test.go create mode 100644 packages/sdk-go/websocket/client_requests.go create mode 100644 packages/sdk-go/websocket/client_subscriptions.go create mode 100644 packages/sdk-go/websocket/client_test.go create mode 100644 packages/sdk-go/websocket/dialer.go create mode 100644 packages/sdk-go/websocket/fuzz_test.go create mode 100644 packages/sdk-go/websocket/gorilla/dialer.go create mode 100644 packages/sdk-go/websocket/gorilla/dialer_test.go create mode 100644 packages/sdk-go/websocket/gorilla/go.mod create mode 100644 packages/sdk-go/websocket/gorilla/go.sum create mode 100644 packages/sdk-go/websocket/gorilla/oauth_integration_test.go create mode 100644 packages/sdk-go/websocket/models.go create mode 100644 packages/sdk-go/websocket/models_test.go create mode 100644 packages/sdk-go/websocket/orderbook/book.go create mode 100644 packages/sdk-go/websocket/orderbook/book_bench_test.go create mode 100644 packages/sdk-go/websocket/orderbook/book_fuzz_test.go create mode 100644 packages/sdk-go/websocket/orderbook/book_test.go create mode 100644 packages/sdk-go/websocket/orderbook/live_book.go create mode 100644 packages/sdk-go/websocket/rfq.go create mode 100644 packages/sdk-go/websocket/rfq_test.go create mode 100644 packages/sdk-go/websocket/snapshot_isolation.go create mode 100644 packages/sdk-go/websocket/snapshot_routing.go create mode 100644 packages/sdk-go/websocket/snapshot_routing_internal_test.go create mode 100644 packages/sdk-go/websocket/snapshot_routing_test.go create mode 100644 packages/sdk-go/websocket/test_helpers_test.go create mode 100644 packages/sdk-go/websocket/typed_parity_test.go create mode 100644 packages/sdk-go/websocket/typed_requests.go create mode 100644 packages/sdk-go/websocket/typed_requests_test.go diff --git a/.github/workflows/validate-go-sdk.yml b/.github/workflows/validate-go-sdk.yml new file mode 100644 index 00000000..e643be48 --- /dev/null +++ b/.github/workflows/validate-go-sdk.yml @@ -0,0 +1,65 @@ +name: Validate Go SDK + +on: + pull_request: + paths: + - "packages/sdk-go/**" + - ".github/workflows/validate-go-sdk.yml" + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-24.04 + timeout-minutes: 45 + defaults: + run: + working-directory: packages/sdk-go + + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906953 # v4.3.0 + with: + persist-credentials: false + + - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + # The generator module pins the minimum Go toolchain required by the + # vulnerability-fixed code generator; the SDK itself supports Go 1.23. + go-version-file: packages/sdk-go/scripts/go.mod + cache: true + cache-dependency-path: | + packages/sdk-go/go.mod + packages/sdk-go/go.sum + packages/sdk-go/websocket/gorilla/go.mod + packages/sdk-go/websocket/gorilla/go.sum + packages/sdk-go/scripts/go.mod + packages/sdk-go/scripts/go.sum + packages/sdk-go/cmd/demo/go.mod + packages/sdk-go/cmd/demo/go.sum + + - name: Verify module dependencies + run: | + go mod verify + (cd websocket/gorilla && go mod verify) + (cd scripts && go mod verify) + (cd cmd/demo && go mod verify) + + - run: make test + - run: make race + - run: make vet + - run: make generate-check + - run: make release-smoke + + - name: Install pinned security analyzers + env: + GOBIN: ${{ runner.temp }}/go-bin + run: | + # Use immutable module revisions rather than mutable release tags. + go install golang.org/x/vuln/cmd/govulncheck@d1f380186385b4f64e00313f31743df8e4b89a77 # v1.1.4 + go install github.com/securego/gosec/v2/cmd/gosec@c9453023c4e81ebdb6dde29e22d9cd5e2285fb16 # v2.22.8 + go install honnef.co/go/tools/cmd/staticcheck@b8ec13ce4d00445d75da053c47498e6f9ec5d7d6 # 2025.1.1 + echo "$GOBIN" >> "$GITHUB_PATH" + + - name: Run security analyzers + run: make security diff --git a/README.md b/README.md index 6ffdead9..f2d68264 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ A suite of developer tools for integrating with the [Gemini](https://www.gemini. | Package | Description | |---------|-------------| | [`packages/mcp-server`](packages/mcp-server/) | MCP server exposing Gemini API as tools for AI assistants | +| [`packages/sdk-go`](packages/sdk-go/) | Official Go SDK for the Gemini REST and WebSocket APIs | | [`samples/`](samples/) | REST and WebSocket examples in TypeScript, Python, and Go | | [`skills/`](skills/) | Claude Code skills (e.g., terminal candlestick charts) | diff --git a/packages/sdk-go/Makefile b/packages/sdk-go/Makefile new file mode 100644 index 00000000..ad25aaa5 --- /dev/null +++ b/packages/sdk-go/Makefile @@ -0,0 +1,57 @@ +.PHONY: all test race bench fuzz vet security fmt generate generate-check integration release-smoke + +all: fmt vet race bench + +test: + go test -v ./... + cd websocket/gorilla && go test -v ./... + cd scripts && go test -v ./... + cd cmd/demo && go test -v ./... + +race: + go test -race -v ./... + cd websocket/gorilla && go test -race -v ./... + cd scripts && go test -race -v ./... + cd cmd/demo && go test -race -v ./... + +bench: + go test -bench=. -benchmem ./websocket/orderbook + +fuzz: + go test -fuzz=FuzzHMAC_BuildPayload -fuzztime=5s ./auth + go test -fuzz=FuzzWebSocket_FrameParsing -fuzztime=5s ./websocket + +vet: + go vet ./... + cd websocket/gorilla && go vet ./... + cd scripts && go vet ./... + cd cmd/demo && go vet ./... + +security: + govulncheck ./... + # Nested modules are scanned explicitly below; excluding them here avoids + # treating their imports as packages in the parent module. + gosec -exclude-generated -exclude-dir=cmd/demo -exclude-dir=scripts -exclude-dir=websocket/gorilla ./... + staticcheck ./... + cd websocket/gorilla && govulncheck ./... && gosec -exclude-generated ./... && staticcheck ./... + cd scripts && govulncheck ./... && gosec -exclude-generated ./... && staticcheck ./... + cd cmd/demo && govulncheck ./... && gosec -exclude-generated ./... && staticcheck ./... + +fmt: + gofmt -s -w . + cd websocket/gorilla && gofmt -s -w . + cd scripts && gofmt -s -w . + cd cmd/demo && gofmt -s -w . + +generate: + cd scripts && go run generate.go + +generate-check: generate + git diff --exit-code generated/ + +integration: + @test -n "$$GEMINI_OAUTH_ACCESS_TOKEN" || (echo "GEMINI_OAUTH_ACCESS_TOKEN is required"; exit 1) + cd websocket/gorilla && go test -tags=integration ./... + +release-smoke: + bash ./scripts/release_smoke.sh diff --git a/packages/sdk-go/README.md b/packages/sdk-go/README.md new file mode 100644 index 00000000..9df2a5ab --- /dev/null +++ b/packages/sdk-go/README.md @@ -0,0 +1,677 @@ +# Gemini Go SDK + +Official Go library for the Gemini Exchange REST and WebSocket APIs. + +[![Go Version](https://img.shields.io/badge/go-1.23%2B-blue.svg)](https://golang.org) +[![Dependencies](https://img.shields.io/badge/core%20dependencies-zero-brightgreen.svg)](#installation) +[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0) + +--- + +## Features + +- **Zero Core Dependencies**: The REST, authentication, decimal, and order-book packages use only the Go standard library. The optional Gorilla adapter is a separate module. +- **Predictable Concurrency**: Authenticated requests are nonce-serialized, WebSocket feeds apply backpressure, and order-book updates are atomic. +- **Accurate Financial Math**: Fixed-precision decimal arithmetic without floating-point errors. +- **Safe Retries**: Automatic backoff for idempotent requests with `Retry-After` header support. +- **Self-Healing WebSockets**: Automatic reconnection with exponential backoff and feed resumption. +- **Smart Quote Reconciler**: Preserves queue priority and minimizes exchange round-trips. +- **Secret Redaction**: Credentials never leak in `fmt.Printf`, `%#v`, `slog`, or JSON output. + +--- + +## Performance Benchmarks + +Benchmark numbers depend on the Go version, compiler, CPU, operating system, +and workload. Run the suite on the target environment instead of relying on +fixed measurements in documentation: + +```bash +go test -bench=. -benchmem ./... +``` + +The repository includes benchmarks for authentication, transport, decimal +arithmetic, WebSocket dispatch, and order-book operations. + +--- + +## Installation + +Install the core library: + +```bash +go get github.com/gemini/gemini-go +``` + +Optional: Install the Gorilla WebSocket adapter: + +```bash +go get github.com/gemini/gemini-go/websocket/gorilla +``` + +This repository is a monorepo, but `packages/sdk-go` is the published Go +module root. Release automation must publish that directory at the module tag; +the `release-smoke` target stages the package as a standalone module and +compiles the documented import paths, generated packages, service facade, +demo, and optional Gorilla module. + +Use `Production` or `Sandbox` explicitly when selecting an environment. For +applications that must reject invalid configuration during startup, use the +error-returning constructor: + +```go +client, err := gemini.NewClientWithError( + gemini.WithEnvironment(gemini.Sandbox), +) +if err != nil { + log.Fatal(err) +} +defer client.Close() +``` + +Custom REST endpoints must use `https`; custom WebSocket endpoints must use the +`wss` scheme. Endpoints may include a path prefix but cannot include userinfo, +a query string, or a fragment. `NewClientWithError` validates both before +returning a client. The lower-level transport adapters remain protocol +agnostic so they can be used with isolated test fixtures. + +--- + +## Authentication + +The library supports two authentication modes. + +### 1. API Keys (HMAC-SHA384) + +```go +client := gemini.NewClient( + gemini.WithAPIKey("your-api-key", "your-api-secret"), +) +``` + +For startup validation, `NewClientWithError` rejects blank API keys or secrets +with `gemini.ErrInvalidHMACCredentials`. + +### 2. OAuth 2.0 Bearer Tokens + +Static token: + +```go +client := gemini.NewClient( + gemini.WithBearerToken("oauth-access-token"), +) +``` + +Dynamic token refresh with a `TokenSource`: + +```go +tokenSource := auth.TokenFunc(func(ctx context.Context) (string, error) { + return tokenManager.GetValidToken(ctx) +}) + +client := gemini.NewClient( + gemini.WithTokenSource(tokenSource), +) +``` + +The token source is application-owned. It must be safe for concurrent calls, +honor the request context, and return a current non-expired access token. The +SDK calls it for each authenticated HTTP attempt and each WebSocket connection +or reconnect; it does not perform the OAuth authorization-code exchange, cache +tokens, or force-refresh after a `401` response. Use `gemini.EndpointsFor` to +look up the authorization and token endpoints for `Production` or `Sandbox`. +If a token source fails during an automatic WebSocket reconnect, the SDK stops +that reconnect loop, reports the underlying error through connection events, +and leaves the client disconnected so the source can be repaired before a +caller explicitly reconnects. + +For applications that need startup validation, `NewClientWithError` rejects a +nil or empty bearer configuration with `gemini.ErrInvalidTokenSource`. + +For private REST calls, the SDK encodes the request path and endpoint +parameters in Gemini's `X-GEMINI-PAYLOAD` header and sends no request body, as +required by Gemini OAuth. Callers only provide the generated request model. +Private REST methods fail locally with `gemini.ErrAuthenticationRequired` when +the client has no authentication strategy; no unauthenticated private request +is sent to the network. + +To revoke the active OAuth token, configure bearer authentication and call +`client.Account.RevokeOAuthToken(ctx)`. The endpoint revokes the token used by +that request; it is not available through API-key authentication. + +OAuth tokens can also authenticate private WebSocket streams and RFQ quote +methods. Gemini enforces the token's account capabilities server-side, so the +token must be authorized for the requested feed or operation. + +### OAuth Sandbox Integration Test + +An opt-in integration test validates one bearer REST request and one private +WebSocket handshake against the selected Gemini environment. It is skipped +when no token is supplied and never runs as part of the default test target: + +```bash +cd websocket/gorilla && \ +GEMINI_OAUTH_ACCESS_TOKEN="..." \ +GEMINI_OAUTH_ENVIRONMENT=sandbox \ +go test -tags=integration ./... +``` + +### 3. Webhook Signature Verification + +Verify incoming Gemini HMAC-SHA384 webhook signatures in constant time: + +```go +isValid := gemini.VerifySignature(secret, b64Payload, signatureHeader) +if !isValid { + http.Error(w, "invalid signature", http.StatusUnauthorized) + return +} +``` + +--- + +## Quick Start + +### Fetch Market Ticker + +```go +package main + +import ( + "context" + "fmt" + "log" + + "github.com/gemini/gemini-go" +) + +func main() { + client := gemini.NewClient() + ctx := context.Background() + + ticker, err := client.MarketData.GetTicker(ctx, "BTCUSD") + if err != nil { + log.Fatalf("failed to fetch ticker: %v", err) + } + + fmt.Printf("BTC/USD Bid: %s, Ask: %s, Last: %s\n", gemini.Val(ticker.Bid), gemini.Val(ticker.Ask), gemini.Val(ticker.Last)) +} +``` + +--- + +### Place Orders + +Place maker post-only or limit orders with exact decimals: + +```go +amount := gemini.MustDecimal("0.05") +price := gemini.MustDecimal("65000.00") + +// Guaranteed Maker (Post-Only) +order, err := client.Trading.PostOnlyBid(ctx, "BTCUSD", amount, price) + +// Standard Limit Buy +order, err := client.Trading.LimitBuy(ctx, "BTCUSD", amount, price) + +// Immediate-or-Cancel Sell +order, err := client.Trading.ImmediateOrCancelSell(ctx, "BTCUSD", amount, price) +``` + +--- + +### Account Management, Staking, and Transfers + +```go +// Import the generated request models used by typed service methods: +// "github.com/gemini/gemini-go/generated/account" + +// 1. Account balances and subaccounts +balances, err := client.Account.GetBalances(ctx, &account.GetAvailableBalancesJSONBody{Account: "primary"}) +accounts, err := client.Account.ListAccounts(ctx, nil) + +// 2. Staking lifecycle (provider ID is required by the API) +stkBalances, err := client.Staking.GetStakingBalances(ctx, nil) +stakeTx, err := client.Staking.Stake(ctx, &account.StakeCryptoFundsJSONBody{ProviderId: "provider-id", Currency: "ETH", Amount: "1.5"}) +unstakeRes, err := client.Staking.Unstake(ctx, &account.UnstakeCryptoFundsJSONBody{ProviderId: "provider-id", Currency: "ETH", Amount: "0.5"}) + +// 3. Multichain transfers and fee estimates +feeEst, err := client.Transfers.GetWithdrawalFeeEstimateV2(ctx, "solana", "sol", "address", "10.0") +withdrawRes, err := client.Transfers.WithdrawCryptoV2(ctx, "solana", "sol", "address", "10.0") +// Pass a generated *account.ListPastTransfersJSONBody for typed filters; +// nil requests the endpoint defaults. +pastTransfers, err := client.Transfers.GetTransfers(ctx, nil) +``` + +--- + +### Declarative Quote Reconciler + +Synchronize a market-making ladder. The reconciler diffs your desired orders against open orders, keeps matching orders in the exchange queue, and sends only required cancels and new orders: + +```go +// Create reconciler with a 0.5 bps tolerance band +reconciler := client.NewQuoteReconciler("BTCUSD", + gemini.WithToleranceBps(0.5), + gemini.WithQuantization(gemini.MustDecimal("0.01"), gemini.MustDecimal("0.0001")), +) + +// StartStreaming subscribes before the initial REST hydration and replays +// order events received during that handoff. +errChan, err := reconciler.StartStreaming(ctx) +if err != nil { + log.Fatalf("stream error: %v", err) +} + +// Define target quotes and sync +mid := gemini.MustDecimal("65000") +size := gemini.MustDecimal("0.05") +desired := []gemini.DesiredQuote{ + {Side: "buy", Price: mid.SubBps(5.0), Amount: size}, + {Side: "sell", Price: mid.AddBps(5.0), Amount: size}, +} + +result, err := reconciler.Sync(ctx, desired) +if err != nil { + log.Fatalf("reconciliation could not start: %v", err) +} +if err := result.Err(); err != nil { + log.Printf("reconciliation completed with partial failures: %v", err) +} +log.Printf("Sync: Kept=%d, Cancelled=%d, Placed=%d", + result.Kept, result.Cancelled, result.Placed) +``` + +Each reconciler supports one active stream; cancel its context before starting +another stream. Reconciler cleanup removes only its own WebSocket order-event +subscription, so other subscribers on the same private WebSocket remain active. + +--- + +### REST service surface + +The `generated` packages are regenerated from Gemini's deployed REST contracts +and contain request and response models for every documented REST operation. +The hand-written `services` facade intentionally exposes a smaller, curated +set of core trading, market-data, account, transfer, staking, margin, +perpetuals, clearing, and prediction-market operations. The supported subset +is tracked by an operation-to-method coverage test in `scripts/`; a spec change +cannot silently add an unclassified endpoint. + +For operations not yet surfaced by a high-level service, use the generated +models with `transport.Client.Request` or wait for a typed service method. The +prediction-market facade includes typed batch order/cancel, order history, +positions, settled positions, combos, volume metrics, maker-rebate, and +liquidity-rewards operations. Native Go iterators are available for the +paginated event, order, position, combo, and liquidity-rewards collections. +Some less common REST and clearing/reporting operations remain available through +the generated models and transport client. + +Prediction-market event responses preserve the complete sports metadata model: +`Event.SportsMarket` includes sport, market type, subject, scope, and metric, +and `PredictionsService.GetEvents` accepts all corresponding repeated filters. +For higher-level sports discovery, `services.ClusterSportsEvents` groups raw +events by contest root and `services.ResolveSportsContest` resolves a contest +from an already-fetched event set without adding network behavior to the REST +client. + +--- + +### Public and Private WebSocket Connections + +The SDK keeps public and private WebSocket traffic on separate client +instances and separate connections: + +- `client.PublicWebSocket()` is unauthenticated and is for public market data + such as depth, trades, book ticker, and contract status. +- `client.PrivateWebSocket()` is the authenticated connection for order, + balance, position, and settlement feeds. Configure `WithAPIKey`, + `WithBearerToken`, or another auth option first. + +```go +client := gemini.NewClient( + gemini.WithEnvironment(gemini.Sandbox), + gemini.WithAPIKey("your-api-key", "your-api-secret"), +) +defer client.Close() + +publicWS := client.PublicWebSocket() +depth, err := publicWS.SubscribeDepth(ctx, "BTCUSD") +if err != nil { + log.Fatal(err) +} + +privateWS := client.PrivateWebSocket() +orders, err := privateWS.SubscribeOrderEvents(ctx) +if err != nil { + log.Fatal(err) +} + +// UnsubscribeOrderEvents removes every order-event subscriber. To remove only +// this subscriber, use UnsubscribeOrderEventsChannel(ctx, orders). + +go func() { + for update := range depth { + log.Printf("public depth update: %d", update.LastUpdateID) + } +}() +go func() { + for update := range orders { + log.Printf("private order %d: %s", update.OrderID, update.OrderStatus) + } +}() +``` + +Typed stream options mirror the supported AsyncAPI variants. Use +`SubscribeDepthWithOptions` for the 100ms differential stream, +`SubscribePartialDepth` with `DepthLevel5`, `DepthLevel10`, or `DepthLevel20` +for top-of-book snapshots, `SubscribeOrderEventsWithScope` for account versus +session orders, and `SubscribeBalancesWithOptions` or +`SubscribePositionsWithOptions` with `Interval: time.Second` for throttled +snapshots. Settlements currently have only the documented account stream; +the SDK does not invent a session variant that is absent from the spec. + +The WebSocket control plane and authenticated order methods are typed as well: +use `ConnInfo`, `Time`, `ListSubscriptions`, `SubscribeStreams`, and +`UnsubscribeStreams` for protocol control requests, and `PlaceOrder`, +`CancelOrder`, `CancelAllOrders`, and `CancelSessionOrders` for trading +requests. Raw `SubscribeStreams` calls are direct protocol operations and are +not replayed automatically after reconnect; use the typed feed subscription +methods when feed resumption is required. `PlaceOrder` accepts `LIMIT` and `MARKET`. A stop-limit order uses +`Type: "LIMIT"` with both `Price` and `StopPrice`; Gemini reports the resulting +order as `STOP_LIMIT` on the order-event stream. `stopPrice` is not valid with +`MARKET`. The WebSocket contract allows `stopPrice == Price`; the legacy REST +`TradingService.NewOrder` contract requires a strict inequality (`stopPrice < +Price` for buys and `stopPrice > Price` for sells). Account-wide cancellation requires +`CancelAllOptions{Confirm: true}` so a destructive request cannot be issued by +omitting a parameter accidentally. + +Partial-depth subscriptions use one underlying connection per symbol because +their snapshot envelope may not include a symbol. Differential depth +subscriptions remain multiplexed because their snapshots include the market +symbol. The root SDK configures this behavior automatically. Low-level clients +that need the same behavior can use +`websocket.WithIsolatedPartialSnapshots()`; the older +`websocket.WithIsolatedSnapshots()` remains available when both feed types +must be isolated. + +An unauthenticated client still exposes `PrivateWebSocket()` so applications +can construct clients uniformly, but private subscriptions fail immediately +with `gemini.ErrAuthenticationRequired`; no unauthenticated connection is +silently upgraded or reused. The low-level `websocket.NewPublicClient` and +`websocket.NewPrivateClient` constructors provide the same separation when the +root `gemini.Client` facade is not used. + +The Go SDK exposes the public `requestForQuote` discovery stream, authenticated +`requestForQuote@account`/`@session` delivery streams, and typed +`SubmitRFQQuote`, `WithdrawRFQQuote`, and `ConfirmRFQQuote` methods. RFQ +deliveries are at-least-once; deduplicate them by `DeliveryID` before applying +lifecycle transitions. Quote methods require an authenticated WebSocket +client and preserve the API's explicit `Confirm` boolean—no action is taken on +the caller's behalf. Each `RFQLeg` includes its contract ID and outcome, plus +the optional leg-specific `InstrumentSymbol`; this is distinct from the +combo-level `RFQPublicEvent.Symbol`. + +```go +rfqs, err := client.PublicWebSocket().SubscribeRFQEvents(ctx) +if err != nil { + return err +} +for rfq := range rfqs { + if rfq.State != websocket.RFQStateOpen { + continue + } + // Insert application-specific pricing here. The SDK does not choose a + // price or submit a quote automatically. + quote, err := client.PrivateWebSocket().SubmitRFQQuote(ctx, websocket.RFQSubmitQuoteParams{ + RFQID: rfq.RFQID, Price: "0.55", Quantity: "100", + }) + if err != nil { + return err + } + _ = quote.QuoteID +} +``` + +The public and private WebSocket clients are intentionally separate. Use the +private client for RFQ quote methods and authenticated delivery streams; it +must be created with the same account credentials used for the relevant +capabilities. + +Inbound WebSocket messages are limited to 1 MiB by default. Configure a +different limit with `websocket.WithMaxMessageSize`, or pass a non-positive +value only when the transport is trusted and an unbounded payload is required. +Malformed JSON frames are reported through `ConnectionEvent.Err` as +`websocket.ErrMalformedFrame`; the connection remains alive so callers can +continue receiving valid frames. For unattended processes, opt into +application-level liveness checks with +`websocket.WithLiveness(interval, timeout)`. A failed check is reported as +`websocket.ErrLivenessFailed` and follows the normal reconnect policy. + +### Real-Time Order Book and BBO Callbacks + +This example uses the optional `github.com/gemini/gemini-go/websocket/orderbook` +package. Always drain subscription channels; the client applies backpressure +to preserve every update. Feed channels are bounded. If a consumer falls +behind far enough to fill the client's inbound dispatch queue, the client +reports `websocket.ErrSlowConsumer` through its lifecycle event channel and +reconnects when automatic reconnect is enabled. Treat that event as a data +recovery boundary: rebuild order-book state from a snapshot and reconcile +private order state with REST before continuing. + +```go +liveBook := orderbook.NewLiveOrderBook("BTCUSD") + +// Book returns a read-only view. Apply snapshots and diffs through liveBook +// so sequence and recovery state remains synchronized. +bookView := liveBook.Book() +_ = bookView.LastUpdateID() + +// Callback triggers only when the Top-of-Book changes +liveBook.OnBBOChanged(func(bbo orderbook.BBO) { + fmt.Printf("Bid: %s | Ask: %s | Mid: %.2f | Spread: %.2f bps\n", + bbo.BestBid, bbo.BestAsk, bbo.Mid, bbo.SpreadBps) +}) + +depthStream, err := client.PublicWebSocket().SubscribeDepth(ctx, "BTCUSD") +if err != nil { + log.Fatalf("subscribe error: %v", err) +} + +for diff := range depthStream { + if err := liveBook.IngestDiff(diff); err != nil { + log.Printf("Sequence gap detected, resyncing: %v", err) + break + } +} +``` + +The top-level SDK requests a full order-book snapshot when it connects the +public WebSocket. The first snapshot frame is marked internally and can be +passed directly to `IngestDiff`; subsequent frames are differential updates. +The live book intentionally does not infer snapshot state from `U == u`, since +that is also valid for a normal differential update. If you construct a +low-level WebSocket client directly, opt into the same behavior with +`websocket.WithSnapshot(-1)`. + +### Prediction Market Terms + +Prediction-market orders are sent to Gemini immediately. If the backend +returns `gemini.ErrAcceptTermsRequired`, explicitly call +`client.Predictions.AcceptTerms(ctx)` and retry the order. The SDK never checks +terms in advance or accepts them on the caller's behalf. + +--- + +### Concurrency and Recovery Guarantees + +- Private REST requests using HMAC authentication are serialized per client so + retries cannot send a lower nonce after a later request. +- Call `client.Close()` when the SDK client is no longer needed to stop WebSocket + pumps and release idle connections from the SDK-owned HTTP transport. A + caller-provided `WithHTTPClient` remains caller-owned. +- WebSocket `Request` and `Ping` calls wait for their correlated server response; + subscription methods return protocol errors instead of treating a write as + success. Use `errors.Is(err, websocket.ErrRequestFailed)` to classify a + rejected request. +- WebSocket lifecycle events are buffered and coalesced if the consumer falls + behind; use `State()` as the authoritative current state. +- WebSocket subscription channels are flow-controlled. If a consumer fills the + bounded inbound queue, the client closes feed channels and emits + `websocket.ErrSlowConsumer`; applications must resync state before + subscribing again. +- Order-book snapshots and diffs reject malformed or negative levels without + partially mutating the book. Sequence gaps return `gemini.ErrResyncRequired`. +- `LiveOrderBook.Reset` clears both sequence state and price levels. Call it + before applying a fresh snapshot after a disconnect or sequence gap. + +--- + +### Validation + +From this directory, the package checks are: + +```bash +gofmt -l . +go test ./... +go test -race ./... +go vet ./... +``` + +The `scripts` module fetches the allowlisted deployed OpenAPI/AsyncAPI contracts, +verifies their SHA-256 hashes, and contains contract-drift tests: + +```bash +(cd scripts && go test ./...) +``` + +--- + +### Managed Heartbeat (Dead-Man's Switch) + +Keep an active trading session alive in the background: + +```go +session := client.Heartbeat.Start(ctx, 5*time.Second) +defer session.Stop() + +go func() { + for err := range session.Errors() { + log.Printf("Heartbeat error: %v", err) + } +}() +``` + +--- + +### Fixed-Precision Decimals and Basis Points Math + +`types.Decimal` preserves the quoted-string representation used by string +decimal fields. Generated models for OpenAPI numeric decimal fields use +`types.DecimalNumber`, which accepts either quoted or numeric input but emits +an exact JSON number without converting through `float64`. + +```go +price := gemini.MustDecimal("65000.00") + +// Add and subtract basis points +ask := price.AddBps(10.0) // 65065.00 +bid := price.SubBps(10.0) // 64935.00 + +// Measure difference in basis points +diffBps := ask.BpsDiff(bid) // 20.0 bps + +// Quantize to market tick and lot rules +tickSize := gemini.MustDecimal("0.50") +lotSize := gemini.MustDecimal("0.001") + +quantizedPrice := gemini.MustDecimal("65000.37").QuantizePrice(tickSize) // 65000.00 +quantizedQty := gemini.MustDecimal("0.1237").QuantizeAmount(lotSize) // 0.123 +``` + +--- + +### Error Handling + +All errors returned by the SDK seamlessly unwrap to typed sentinels. You can inspect errors using standard `errors.Is()` or the top-level helper functions in a single flat `switch`: + +#### 1. Flat Top-Level Error Inspection (Recommended) + +```go +order, err := client.Trading.PostOnlyBid(ctx, "BTCUSD", amount, price) +if err != nil { + // Optional: Extract request ID for Gemini Support logs + if reqID := gemini.RequestIDFromError(err); reqID != "" { + log.Printf("Gemini Request ID: %s", reqID) + } + + switch { + case gemini.IsInsufficientFunds(err): + log.Println("Domain: Insufficient balance to place order") + + case gemini.IsMarketClosed(err): + log.Println("Domain: Market or trading pair is halted") + + case gemini.IsRateLimit(err): + log.Println("API: Rate limit exceeded (automatic backoff engaged)") + + case gemini.IsSelfCrossPrevented(err): + log.Println("Domain: Self-trade prevention triggered") + + case gemini.IsAuthError(err): + log.Fatalf("Auth: Invalid keys, signature, or nonce") + + case gemini.IsNotFound(err): + log.Println("API: Order or symbol not found") + + case gemini.IsResyncRequired(err): + log.Println("Stream: Sequence gap detected; resyncing book...") + + default: + log.Printf("Unhandled error: %v", err) + } +} +``` + +#### 2. Broad Category Inspection (For Middleware, Routing, & Alerting) + +```go +switch { +case gemini.IsDomainError(err): + // Exchange matching engine business rejections (do not retry) + log.Printf("Business logic rejection: %v", err) + +case gemini.IsAPIError(err): + // Gateway / HTTP 4xx / 5xx responses + if apiErr, ok := gemini.AsAPIError(err); ok { + log.Printf("HTTP %d (%s): %s", apiErr.StatusCode, apiErr.Reason, apiErr.Message) + } + +case gemini.IsTimeout(err): + // Client-side deadline exceeded + log.Println("Request timed out") +} +``` + +--- + +### Testing with `geminitest` + +Use the local mock server for testing without network requests: + +```go +server := geminitest.NewMockServer("test-key", "test-secret") +defer server.Close() + +client := gemini.NewClient( + gemini.WithCustomRESTURL(server.URL()), + gemini.WithAPIKey("test-key", "test-secret"), +) +``` + +--- + +## License + +Apache 2.0. See the [Apache 2.0 license](https://www.apache.org/licenses/LICENSE-2.0) +for details. diff --git a/packages/sdk-go/aliases.go b/packages/sdk-go/aliases.go new file mode 100644 index 00000000..73f7718e --- /dev/null +++ b/packages/sdk-go/aliases.go @@ -0,0 +1,219 @@ +package gemini + +import ( + "context" + "iter" + + "github.com/gemini/gemini-go/auth" + "github.com/gemini/gemini-go/services" + "github.com/gemini/gemini-go/transport" + "github.com/gemini/gemini-go/types" + "github.com/gemini/gemini-go/websocket" + "github.com/gemini/gemini-go/websocket/orderbook" +) + +// Re-exported types for streamlined developer experience without importing multiple sub-packages. +type ( + // APIKey represents a Gemini API Key identifier. + APIKey = auth.APIKey + + // APISecret represents a Gemini API secret used for HMAC-SHA384 signatures. + APISecret = auth.APISecret + + // BearerToken represents an OAuth2 bearer access token. + BearerToken = auth.BearerToken + + // Decimal represents an exact fixed-precision decimal number for financial calculations. + Decimal = types.Decimal + + // DecimalNumber represents an exact decimal encoded as a JSON number. + DecimalNumber = types.DecimalNumber + + // DesiredQuote specifies an intended target order in a market making grid. + DesiredQuote = services.DesiredQuote + + // RestingOrder represents an active order resting on the exchange order book. + RestingOrder = services.RestingOrder + + // ReconcileResult summarizes actions executed by the state reconciler. + ReconcileResult = services.ReconcileResult + + // CustodyFeeTransfer describes a custody fee charged to the account. + CustodyFeeTransfer = services.CustodyFeeTransfer + + // AddBankResponse is returned when a bank account is submitted for linking. + AddBankResponse = services.AddBankResponse + + // ApprovedAddressMessage is returned when an approved address is requested or removed. + ApprovedAddressMessage = services.ApprovedAddressMessage + + // PaymentMethodsResponse contains linked account payment methods. + PaymentMethodsResponse = services.PaymentMethodsResponse + + // ClearingOperationResponse reports a clearing cancellation or confirmation result. + ClearingOperationResponse = services.ClearingOperationResponse + + // PredictionOrderOperationResponse reports a prediction-market order action result. + PredictionOrderOperationResponse = services.PredictionOrderOperationResponse + + // SportsContestCluster groups raw prediction events into a sports contest view. + SportsContestCluster = services.SportsContestCluster + + // QuoteReconciler manages declarative target order diffing and execution. + QuoteReconciler = services.QuoteReconciler + + // OrderOption configures an outgoing order request. + OrderOption = services.OrderOption + + // BBO captures the current Best Bid and Offer top-of-book snapshot. + BBO = orderbook.BBO + + // OrderEvent represents a private order lifecycle update over WebSockets. + OrderEvent = websocket.OrderEvent + + // OrderPlaceParams contains the typed WebSocket order.place payload. + OrderPlaceParams = websocket.OrderPlaceParams + + // OrderCancelParams contains the typed WebSocket order.cancel payload. + OrderCancelParams = websocket.OrderCancelParams + + // CancelAllOptions protects account-wide WebSocket cancellation methods from accidental invocation. + CancelAllOptions = websocket.CancelAllOptions + + // OrderSide identifies whether a WebSocket order buys or sells. + OrderSide = websocket.OrderSide + + // OrderType identifies the WebSocket order execution style. + OrderType = websocket.OrderType + + // TimeInForce controls how long a WebSocket order remains eligible for execution. + TimeInForce = websocket.TimeInForce + + // EventOutcome identifies the prediction-market outcome attached to a WebSocket order. + EventOutcome = websocket.EventOutcome + + // BalanceUpdate represents an authenticated account balance update. + BalanceUpdate = websocket.BalanceUpdate + + // PositionReport represents an authenticated account position report. + PositionReport = websocket.PositionReport + + // SettlementUpdate represents an authenticated contract settlement update. + SettlementUpdate = websocket.SettlementUpdate + + // ContractStatusEvent represents a public prediction contract lifecycle event. + ContractStatusEvent = websocket.ContractStatusEvent + + // RFQPublicEvent represents an anonymous public combo RFQ discovery event. + RFQPublicEvent = websocket.RFQPublicEvent + + // RFQPrivateDelivery represents an authenticated combo RFQ lifecycle delivery. + RFQPrivateDelivery = websocket.RFQPrivateDelivery + + // RFQSubmitQuoteParams contains the quote submitted by a maker. + RFQSubmitQuoteParams = websocket.RFQSubmitQuoteParams + + // RFQWithdrawQuoteParams identifies a maker quote to withdraw. + RFQWithdrawQuoteParams = websocket.RFQWithdrawQuoteParams + + // RFQConfirmQuoteParams contains the winning maker's last-look decision. + RFQConfirmQuoteParams = websocket.RFQConfirmQuoteParams + + // DepthUpdate represents an incremental L2 order book diff. + DepthUpdate = websocket.DepthUpdate + + // TradeEvent represents a public trade execution. + TradeEvent = websocket.TradeEvent + + // BookTicker represents a top-of-book price and quantity update. + BookTicker = websocket.BookTicker + + // SimulatedFill contains the execution summary of a simulated market order through L2 book depth. + SimulatedFill = orderbook.SimulatedFill +) + +// PageFetcher is a function that fetches a page of items for Go 1.23+ pagination. +type PageFetcher[T any] func(ctx context.Context, offset, limit int) (items []T, hasMore bool, err error) + +// NewPaginator creates a native Go 1.23+ iter.Seq2 iterator for paginated endpoints. +func NewPaginator[T any](ctx context.Context, initialOffset, pageSize int, fetcher PageFetcher[T]) iter.Seq2[T, error] { + return transport.NewPaginator(ctx, initialOffset, pageSize, transport.PageFetcher[T](fetcher)) +} + +// Re-exported helper functions and options. +var ( + // ErrCancelConfirmationRequired indicates that a destructive WebSocket cancellation was not confirmed. + ErrCancelConfirmationRequired = websocket.ErrCancelConfirmationRequired + + // ErrNoDialerConfigured indicates that no WebSocket transport adapter was configured. + ErrNoDialerConfigured = websocket.ErrNoDialerConfigured + + // ParseDecimal parses a financial number string into a fixed-scale Decimal. + ParseDecimal = types.ParseDecimal + + // MustDecimal parses a decimal string or panics. + MustDecimal = types.MustParseDecimal + + // ParseDecimalNumber parses a decimal string for a generated JSON-number field. + ParseDecimalNumber = types.ParseDecimalNumber + + // MustDecimalNumber parses a decimal string for a generated JSON-number field or panics. + MustDecimalNumber = types.MustParseDecimalNumber + + // ZeroDecimal returns a zero-valued Decimal (0). + ZeroDecimal = types.Zero + + // MinDecimal returns the smaller of two Decimals. + MinDecimal = types.Min + + // MaxDecimal returns the larger of two Decimals. + MaxDecimal = types.Max + + // CalculateNotional computes the total notional value of an order (Price * Quantity). + CalculateNotional = types.CalculateNotional + + // CalculateFee computes the transaction fee in quote currency: Notional * (feeBps / 10000). + CalculateFee = types.CalculateFee + + // CalculatePnL computes the profit/loss and ROI percentage for a position or trade. + CalculatePnL = types.CalculatePnL + + // CalculateLiquidationPrice estimates the bankruptcy liquidation trigger price for a leveraged position. + CalculateLiquidationPrice = types.CalculateLiquidationPrice + + // PredictionMarketPayout calculates financial returns for binary prediction contracts ($1.00 settlement). + PredictionMarketPayout = types.PredictionMarketPayout + + // VerifySignature verifies an incoming Gemini HMAC-SHA384 signature in constant time against a Base64 payload. + VerifySignature = auth.VerifySignature + + // WithClientOrderID attaches a client-specified order ID to fluent order requests. + WithClientOrderID = services.WithClientOrderID + + // WithStopPrice attaches a stop trigger price to fluent order requests. + WithStopPrice = services.WithStopPrice + + // NormalizeContestRoot normalizes a sports event or instrument into its contest root. + NormalizeContestRoot = services.NormalizeContestRoot + + // ExtractCleanContestTitle removes market-family suffixes from sports titles. + ExtractCleanContestTitle = services.ExtractCleanContestTitle + + // BuildSportsContestCluster groups raw sports events into a contest view. + BuildSportsContestCluster = services.BuildSportsContestCluster + + // ClusterSportsEvents groups sports events by contest root. + ClusterSportsEvents = services.ClusterSportsEvents + + // ResolveSportsContest resolves a contest from already-fetched events. + ResolveSportsContest = services.ResolveSportsContest + + // WithToleranceBps configures the acceptable price drift tolerance in basis points. + WithToleranceBps = services.WithToleranceBps + + // WithQuantization configures tick and lot size rounding rules. + WithQuantization = services.WithQuantization + + // WithMaxConcurrentRequests limits concurrent quote cancel and placement requests. + WithMaxConcurrentRequests = services.WithMaxConcurrentRequests +) diff --git a/packages/sdk-go/auth/auth.go b/packages/sdk-go/auth/auth.go new file mode 100644 index 00000000..e4a8f9d7 --- /dev/null +++ b/packages/sdk-go/auth/auth.go @@ -0,0 +1,60 @@ +package auth + +import ( + "context" + "errors" + "net/http" +) + +// ErrInvalidTokenSource indicates that OAuth bearer authentication was +// configured without a usable token source. +var ErrInvalidTokenSource = errors.New("gemini auth: invalid OAuth token source") + +// ErrTokenSourceFailure identifies a runtime failure while obtaining a token +// from a configured OAuth token source. It is distinct from +// ErrInvalidTokenSource, which describes startup configuration. +var ErrTokenSourceFailure = errors.New("gemini auth: OAuth token source failed") + +// ErrInvalidHMACCredentials indicates that HMAC authentication was configured +// without both an API key and API secret. +var ErrInvalidHMACCredentials = errors.New("gemini auth: invalid HMAC credentials") + +const ( + authorizationHeader = "Authorization" + geminiAPIKeyHeader = "X-GEMINI-APIKEY" // #nosec G101 -- protocol header name, not a credential value + geminiNonceHeader = "X-GEMINI-NONCE" + geminiPayloadHeader = "X-GEMINI-PAYLOAD" + geminiSignatureHeader = "X-GEMINI-SIGNATURE" +) + +// clearAuthenticationHeaders removes every credential-bearing header owned by +// the SDK. Requests may be reused by callers or across authentication +// strategies, so authentication must never leave a previous scheme attached. +func clearAuthenticationHeaders(header http.Header) { + for _, key := range []string{ + authorizationHeader, + geminiAPIKeyHeader, + geminiNonceHeader, + geminiPayloadHeader, + geminiSignatureHeader, + } { + header.Del(key) + } +} + +// Strategy defines the interface for signing and authenticating HTTP requests to Gemini. +type Strategy interface { + // Authenticate modifies the HTTP request in-place with required authentication headers. + Authenticate(ctx context.Context, req *http.Request, payloadJSON []byte) error + + // Key returns the identifier/key for logging/telemetry purposes. + Key() string +} + +// RequestSequencer is implemented by authentication strategies whose protocol +// requires authenticated requests to be dispatched in order. The returned +// release function must be called when the complete request attempt sequence +// has finished. +type RequestSequencer interface { + AcquireRequest(ctx context.Context) (release func(), err error) +} diff --git a/packages/sdk-go/auth/bearer.go b/packages/sdk-go/auth/bearer.go new file mode 100644 index 00000000..cc39171e --- /dev/null +++ b/packages/sdk-go/auth/bearer.go @@ -0,0 +1,181 @@ +package auth + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "strings" +) + +// TokenSource provides OAuth 2.0 access tokens dynamically (e.g. for automatic token refresh). +// +// Token is called for each authenticated HTTP attempt, including retries, and +// for each WebSocket connection or reconnect. Implementations should return a +// currently valid token, honor context cancellation, and be safe for concurrent +// calls. The SDK does not cache tokens or perform the OAuth authorization-code +// exchange itself. +type TokenSource interface { + // Token returns an active, non-expired access token or an error if renewal fails. + Token(ctx context.Context) (string, error) +} + +// TokenFunc allows using a plain function as a TokenSource. +type TokenFunc func(ctx context.Context) (string, error) + +func (f TokenFunc) Token(ctx context.Context) (string, error) { + return f(ctx) +} + +var _ TokenSource = (TokenFunc)(nil) + +type staticTokenSource struct { + token BearerToken +} + +var _ TokenSource = (*staticTokenSource)(nil) + +func (s staticTokenSource) Token(ctx context.Context) (string, error) { + return string(s.token), nil +} + +// Bearer implements OAuth 2.0 Bearer Token authentication with static or dynamic token sources. +type Bearer struct { + tokenSource TokenSource +} + +var _ Strategy = (*Bearer)(nil) + +// NewBearer creates a new Bearer token authentication strategy using a static token. +func NewBearer(token BearerToken) *Bearer { + return &Bearer{ + tokenSource: staticTokenSource{token: token}, + } +} + +// NewBearerWithSource creates a new Bearer token authentication strategy using a dynamic TokenSource. +func NewBearerWithSource(source TokenSource) *Bearer { + return &Bearer{ + tokenSource: source, + } +} + +// Key returns the identifier for logging. +func (b *Bearer) Key() string { + return "[BEARER_AUTH]" +} + +// Validate reports configuration errors without calling a dynamic token +// source. It is consumed by the high-level client when available, while the +// authentication methods retain their own runtime checks for direct users. +func (b *Bearer) Validate() error { + if b == nil || b.tokenSource == nil { + return ErrInvalidTokenSource + } + if source, ok := b.tokenSource.(staticTokenSource); ok && source.token == "" { + return fmt.Errorf("%w: static token is empty", ErrInvalidTokenSource) + } + return nil +} + +// Authenticate attaches the Authorization Bearer header and encodes the +// request payload in Gemini's X-GEMINI-PAYLOAD header. OAuth REST requests +// carry no request body; the payload header contains the request path and +// endpoint parameters without a nonce. +func (b *Bearer) Authenticate(ctx context.Context, req *http.Request, payloadJSON []byte) error { + if ctx == nil { + ctx = context.Background() + } + if req == nil { + return fmt.Errorf("gemini auth: nil request") + } + if err := b.Validate(); err != nil { + return err + } + if req.URL == nil { + return fmt.Errorf("gemini auth: request URL is nil") + } + if req.Header == nil { + req.Header = make(http.Header) + } + clearAuthenticationHeaders(req.Header) + rawToken, err := b.token(ctx) + if err != nil { + return err + } + + req.Header.Set(authorizationHeader, fmt.Sprintf("Bearer %s", rawToken)) + + if len(payloadJSON) == 0 { + // Private OAuth endpoints still require X-GEMINI-PAYLOAD even when + // there are no endpoint parameters. Encode an empty JSON object so + // the payload always contains the request path. + payloadJSON = []byte(`{}`) + } + payload, err := buildOAuthPayload(req.URL.Path, payloadJSON) + if err != nil { + return err + } + req.Header.Set(geminiPayloadHeader, base64.StdEncoding.EncodeToString(payload)) + req.Header.Set("Content-Type", "text/plain") + req.Header.Set("Content-Length", "0") + req.Body = http.NoBody + req.ContentLength = 0 + + return nil +} + +// AuthenticateWebSocket attaches the Authorization Bearer header to the WebSocket handshake request. +func (b *Bearer) AuthenticateWebSocket(ctx context.Context, req *http.Request) error { + if ctx == nil { + ctx = context.Background() + } + if req == nil { + return fmt.Errorf("gemini auth: nil request") + } + if err := b.Validate(); err != nil { + return err + } + if req.Header == nil { + req.Header = make(http.Header) + } + clearAuthenticationHeaders(req.Header) + rawToken, err := b.token(ctx) + if err != nil { + return err + } + req.Header.Set(authorizationHeader, fmt.Sprintf("Bearer %s", rawToken)) + return nil +} + +func (b *Bearer) token(ctx context.Context) (string, error) { + if err := b.Validate(); err != nil { + return "", err + } + rawToken, err := b.tokenSource.Token(ctx) + if err != nil { + return "", fmt.Errorf("%w: %w", ErrTokenSourceFailure, err) + } + if strings.TrimSpace(rawToken) == "" { + return "", fmt.Errorf("%w: token source returned an empty token", ErrTokenSourceFailure) + } + return rawToken, nil +} + +func buildOAuthPayload(requestPath string, payloadJSON []byte) ([]byte, error) { + payloadMap := make(map[string]json.RawMessage) + if err := json.Unmarshal(payloadJSON, &payloadMap); err != nil { + return nil, fmt.Errorf("gemini auth: invalid json payload parameters: %w", err) + } + if payloadMap == nil { + payloadMap = make(map[string]json.RawMessage) + } + requestJSON, err := json.Marshal(requestPath) + if err != nil { + return nil, fmt.Errorf("gemini auth: encoding request path: %w", err) + } + payloadMap["request"] = requestJSON + delete(payloadMap, "nonce") + return json.Marshal(payloadMap) +} diff --git a/packages/sdk-go/auth/bearer_test.go b/packages/sdk-go/auth/bearer_test.go new file mode 100644 index 00000000..2481d9da --- /dev/null +++ b/packages/sdk-go/auth/bearer_test.go @@ -0,0 +1,333 @@ +package auth_test + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/gemini/gemini-go/auth" +) + +func TestBearer_StaticToken(t *testing.T) { + token := auth.BearerToken("oauth-access-token-abc-123") + strategy := auth.NewBearer(token) + + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "https://api.gemini.com/v1/balances", nil) + if err != nil { + t.Fatalf("failed creating request: %v", err) + } + + if err := strategy.Authenticate(context.Background(), req, nil); err != nil { + t.Fatalf("failed authenticating request: %v", err) + } + + authHeader := req.Header.Get("Authorization") + if authHeader != "Bearer oauth-access-token-abc-123" { + t.Fatalf("expected 'Bearer oauth-access-token-abc-123', got %s", authHeader) + } +} + +func TestBearer_AuthenticateEncodesPayloadInHeader(t *testing.T) { + strategy := auth.NewBearer(auth.BearerToken("oauth-access-token")) + req, err := http.NewRequest(http.MethodPost, "https://api.gemini.com/v1/mytrades", nil) + if err != nil { + t.Fatalf("failed creating request: %v", err) + } + payload := []byte(`{"request":"ignored","nonce":null,"symbol":"btcusd"}`) + if err := strategy.Authenticate(context.Background(), req, payload); err != nil { + t.Fatalf("failed authenticating request: %v", err) + } + encoded := req.Header.Get("X-GEMINI-PAYLOAD") + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + t.Fatalf("decoding payload header: %v", err) + } + var got map[string]any + if err := json.Unmarshal(decoded, &got); err != nil { + t.Fatalf("decoding payload JSON: %v", err) + } + if got["request"] != "/v1/mytrades" { + t.Fatalf("expected request path in payload, got %v", got["request"]) + } + if got["symbol"] != "btcusd" { + t.Fatalf("expected endpoint parameter in payload, got %v", got["symbol"]) + } + if _, ok := got["nonce"]; ok { + t.Fatal("OAuth payload must not contain a nonce") + } + if req.ContentLength != 0 || req.Body == nil { + t.Fatalf("expected bodyless OAuth request, content length %d", req.ContentLength) + } + body, err := io.ReadAll(req.Body) + if err != nil { + t.Fatalf("reading request body: %v", err) + } + if len(body) != 0 { + t.Fatalf("expected empty request body, got %q", body) + } +} + +func TestBearer_AuthenticateEncodesPayloadForEmptyRequest(t *testing.T) { + strategy := auth.NewBearer(auth.BearerToken("oauth-access-token")) + req, err := http.NewRequest(http.MethodGet, "https://api.gemini.com/v1/prediction-markets/terms/status", nil) + if err != nil { + t.Fatalf("failed creating request: %v", err) + } + if err := strategy.Authenticate(context.Background(), req, nil); err != nil { + t.Fatalf("failed authenticating request: %v", err) + } + + encoded := req.Header.Get("X-GEMINI-PAYLOAD") + if encoded == "" { + t.Fatal("expected X-GEMINI-PAYLOAD for an empty OAuth request") + } + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + t.Fatalf("decoding payload header: %v", err) + } + var got map[string]any + if err := json.Unmarshal(decoded, &got); err != nil { + t.Fatalf("decoding payload JSON: %v", err) + } + if got["request"] != "/v1/prediction-markets/terms/status" { + t.Fatalf("expected request path in payload, got %v", got["request"]) + } +} + +func TestBearer_DynamicTokenSource(t *testing.T) { + currentVal := "initial-token" + source := auth.TokenFunc(func(ctx context.Context) (string, error) { + return currentVal, nil + }) + + strategy := auth.NewBearerWithSource(source) + ctx := context.Background() + + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.gemini.com/v1/balances", nil) + if err := strategy.Authenticate(ctx, req, nil); err != nil { + t.Fatalf("expected nil auth error, got: %v", err) + } + if req.Header.Get("Authorization") != "Bearer initial-token" { + t.Fatalf("expected initial-token, got %s", req.Header.Get("Authorization")) + } + + // Token refresh simulation + currentVal = "refreshed-token-xyz" + req2, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.gemini.com/v1/balances", nil) + if err := strategy.Authenticate(ctx, req2, nil); err != nil { + t.Fatalf("expected nil auth error, got: %v", err) + } + if req2.Header.Get("Authorization") != "Bearer refreshed-token-xyz" { + t.Fatalf("expected refreshed-token-xyz, got %s", req2.Header.Get("Authorization")) + } + + // Token source failure simulation + errSource := auth.TokenFunc(func(ctx context.Context) (string, error) { + return "", fmt.Errorf("token provider unavailable") + }) + failingStrategy := auth.NewBearerWithSource(errSource) + req3, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.gemini.com/v1/balances", nil) + if err := failingStrategy.Authenticate(ctx, req3, nil); err == nil { + t.Fatal("expected error from failing TokenSource, got nil") + } else if !errors.Is(err, auth.ErrTokenSourceFailure) { + t.Fatalf("expected ErrTokenSourceFailure, got %v", err) + } +} + +func TestBearer_TokenSourceHonorsCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + strategy := auth.NewBearerWithSource(auth.TokenFunc(func(ctx context.Context) (string, error) { + <-ctx.Done() + return "", ctx.Err() + })) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.gemini.com/v1/balances", nil) + if err != nil { + t.Fatalf("failed creating request: %v", err) + } + + err = strategy.Authenticate(ctx, req, nil) + if !errors.Is(err, context.Canceled) { + t.Fatalf("Authenticate() error = %v, want context.Canceled", err) + } +} + +func TestBearer_TokenSourceSupportsConcurrentCalls(t *testing.T) { + const callers = 8 + entered := make(chan struct{}, callers) + release := make(chan struct{}) + var calls atomic.Int32 + var active atomic.Int32 + var maxActive atomic.Int32 + + source := auth.TokenFunc(func(ctx context.Context) (string, error) { + calls.Add(1) + current := active.Add(1) + for { + previous := maxActive.Load() + if current <= previous || maxActive.CompareAndSwap(previous, current) { + break + } + } + defer active.Add(-1) + entered <- struct{}{} + select { + case <-release: + return "concurrent-token", nil + case <-ctx.Done(): + return "", ctx.Err() + } + }) + strategy := auth.NewBearerWithSource(source) + + var wg sync.WaitGroup + errs := make(chan error, callers) + wg.Add(callers) + for i := 0; i < callers; i++ { + go func() { + defer wg.Done() + req, err := http.NewRequest(http.MethodGet, "https://api.gemini.com/v1/balances", nil) + if err != nil { + errs <- err + return + } + errs <- strategy.Authenticate(context.Background(), req, nil) + }() + } + + deadline := time.After(time.Second) + for i := 0; i < callers; i++ { + select { + case <-entered: + case <-deadline: + close(release) + wg.Wait() + t.Fatalf("only %d token-source calls entered concurrently", i) + } + } + close(release) + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatalf("concurrent Authenticate() failed: %v", err) + } + } + if got := calls.Load(); got != callers { + t.Fatalf("token source calls = %d, want %d", got, callers) + } + if got := maxActive.Load(); got < 2 { + t.Fatalf("token source max concurrency = %d, want concurrent calls", got) + } +} + +func TestBearer_Validate(t *testing.T) { + tests := []struct { + name string + strategy *auth.Bearer + wantErr bool + }{ + {name: "nil source", strategy: auth.NewBearerWithSource(nil), wantErr: true}, + {name: "empty static token", strategy: auth.NewBearer(""), wantErr: true}, + {name: "dynamic source", strategy: auth.NewBearerWithSource(auth.TokenFunc(func(context.Context) (string, error) { + return "token", nil + })), wantErr: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.strategy.Validate() + if (err != nil) != tt.wantErr { + t.Fatalf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr && !errors.Is(err, auth.ErrInvalidTokenSource) { + t.Fatalf("Validate() error = %v, want ErrInvalidTokenSource", err) + } + }) + } +} + +func TestBearerToken_Redaction(t *testing.T) { + token := auth.BearerToken("secret-oauth-bearer-token") + if token.String() != "[REDACTED_TOKEN]" { + t.Fatalf("expected [REDACTED_TOKEN], got %s", token.String()) + } + if token.LogValue().String() != "[REDACTED_TOKEN]" { + t.Fatalf("expected [REDACTED_TOKEN], got %s", token.LogValue().String()) + } + if goStr := token.GoString(); goStr != `auth.BearerToken("[REDACTED_TOKEN]")` { + t.Fatalf("expected GoString redaction, got %s", goStr) + } + + data, err := token.MarshalJSON() + if err != nil || string(data) != `"[REDACTED_TOKEN]"` { + t.Fatalf("expected JSON redaction, got %s (err: %v)", string(data), err) + } +} + +func TestCredentials_FullRedactionCoverage(t *testing.T) { + key := auth.APIKey("my-long-test-api-key-12345") + secret := auth.APISecret("super-secret-hmac-key") + + if key.String() != "my-l...2345" { + t.Fatalf("expected masked key, got %s", key.String()) + } + if key.GoString() != `auth.APIKey("my-l...2345")` { + t.Fatalf("expected GoString masked key, got %s", key.GoString()) + } + + keyJSON, _ := key.MarshalJSON() + if string(keyJSON) != `"my-l...2345"` { + t.Fatalf("expected JSON masked key, got %s", string(keyJSON)) + } + + if secret.String() != "[REDACTED_SECRET]" { + t.Fatalf("expected [REDACTED_SECRET], got %s", secret.String()) + } + if secret.GoString() != `auth.APISecret("[REDACTED_SECRET]")` { + t.Fatalf("expected GoString [REDACTED_SECRET], got %s", secret.GoString()) + } + + secretJSON, _ := secret.MarshalJSON() + if string(secretJSON) != `"[REDACTED_SECRET]"` { + t.Fatalf("expected JSON [REDACTED_SECRET], got %s", string(secretJSON)) + } +} + +func TestBearer_ErrorPropagation(t *testing.T) { + ctx := context.Background() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.gemini.com/v1/balances", nil) + if err != nil { + t.Fatalf("failed creating request: %v", err) + } + + // 1. NewBearerWithSource(nil) must return error on Authenticate + nilStrategy := auth.NewBearerWithSource(nil) + if err := nilStrategy.Authenticate(ctx, req, nil); err == nil { + t.Fatal("expected error with nil TokenSource, got nil") + } + + // 2. TokenSource error must propagate wrapped + sourceErr := fmt.Errorf("oauth token renewal failure") + failingSource := auth.TokenFunc(func(ctx context.Context) (string, error) { + return "", sourceErr + }) + + failingStrategy := auth.NewBearerWithSource(failingSource) + if err := failingStrategy.Authenticate(ctx, req, nil); err == nil { + t.Fatal("expected error from failing TokenSource, got nil") + } + + if key := failingStrategy.Key(); key != "[BEARER_AUTH]" { + t.Fatalf("expected key [BEARER_AUTH], got %s", key) + } +} diff --git a/packages/sdk-go/auth/fuzz_test.go b/packages/sdk-go/auth/fuzz_test.go new file mode 100644 index 00000000..e7936e4a --- /dev/null +++ b/packages/sdk-go/auth/fuzz_test.go @@ -0,0 +1,44 @@ +package auth_test + +import ( + "encoding/base64" + "encoding/json" + "testing" + "unicode/utf8" + + "github.com/gemini/gemini-go/auth" +) + +func FuzzHMAC_BuildPayload(f *testing.F) { + f.Add("/v1/order/new", []byte(`{"symbol":"btcusd","amount":"1.0","price":"65000.00","side":"buy"}`)) + f.Add("/v1/order/cancel", []byte(`{"order_id":"123456"}`)) + f.Add("/v1/balances", []byte(`{}`)) + f.Add("/v1/heartbeat", []byte(``)) + + signer := auth.NewHMAC(auth.APIKey("my-key"), auth.APISecret("my-secret")) + + f.Fuzz(func(t *testing.T, path string, customParams []byte) { + if !utf8.ValidString(path) { + return + } + payload, err := signer.BuildPayload(path, customParams) + if err != nil { + return + } + var envelope map[string]json.RawMessage + if err := json.Unmarshal(payload, &envelope); err != nil { + t.Fatalf("BuildPayload returned invalid JSON: %v", err) + } + var gotPath, gotNonce string + if err := json.Unmarshal(envelope["request"], &gotPath); err != nil || gotPath != path { + t.Fatalf("BuildPayload did not preserve request path %q", path) + } + if err := json.Unmarshal(envelope["nonce"], &gotNonce); err != nil || gotNonce == "" { + t.Fatal("BuildPayload did not inject a valid nonce") + } + b64Payload := base64.StdEncoding.EncodeToString(payload) + if !auth.VerifySignature(auth.APISecret("my-secret"), b64Payload, signer.Sign([]byte(b64Payload))) { + t.Fatal("generated signature did not verify against generated payload") + } + }) +} diff --git a/packages/sdk-go/auth/hmac.go b/packages/sdk-go/auth/hmac.go new file mode 100644 index 00000000..6131715c --- /dev/null +++ b/packages/sdk-go/auth/hmac.go @@ -0,0 +1,322 @@ +package auth + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha512" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "hash" + "io" + "net/http" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" +) + +// NonceGenerator generates strictly monotonic nonce strings. +type NonceGenerator interface { + Next() string +} + +type monotonicNonce struct { + lastNonce atomic.Int64 + skewOffset atomic.Int64 // Skew in nanoseconds + nowFunc func() time.Time + unit func(time.Time) int64 +} + +var _ NonceGenerator = (*monotonicNonce)(nil) + +func newMonotonicNonce(nowFunc func() time.Time) *monotonicNonce { + if nowFunc == nil { + nowFunc = time.Now + } + return &monotonicNonce{ + nowFunc: nowFunc, + unit: func(now time.Time) int64 { return now.UnixMilli() }, + } +} + +func newSecondNonce(nowFunc func() time.Time) *monotonicNonce { + if nowFunc == nil { + nowFunc = time.Now + } + return &monotonicNonce{ + nowFunc: nowFunc, + unit: func(now time.Time) int64 { return now.Unix() }, + } +} + +func (m *monotonicNonce) SetSkew(skew time.Duration) { + m.skewOffset.Store(int64(skew)) +} + +func (m *monotonicNonce) Next() string { + skew := time.Duration(m.skewOffset.Load()) + now := m.unit(m.nowFunc().Add(skew)) + for { + last := m.lastNonce.Load() + next := now + if next <= last { + next = last + 1 + } + if m.lastNonce.CompareAndSwap(last, next) { + return strconv.FormatInt(next, 10) + } + } +} + +// ClockSkewCalibrator allows calibrating local timestamp generation against remote server time. +type ClockSkewCalibrator interface { + CalibrateServerTime(serverTime time.Time) +} + +// HMAC implements Gemini's HMAC-SHA384 payload signing authentication strategy. +type HMAC struct { + key APIKey + secret APISecret + nonces NonceGenerator + wsNonces NonceGenerator + requestGate chan struct{} + hasherPool sync.Pool +} + +var ( + _ Strategy = (*HMAC)(nil) + _ ClockSkewCalibrator = (*HMAC)(nil) + _ RequestSequencer = (*HMAC)(nil) +) + +type HMACOption func(*HMAC) + +// WithCustomNonceGenerator allows injecting a deterministic or custom nonce generator. +func WithCustomNonceGenerator(gen NonceGenerator) HMACOption { + return func(h *HMAC) { + if gen != nil { + h.nonces = gen + } + } +} + +// NewHMAC creates a new HMAC-SHA384 authentication strategy with monotonic nonce generator. +func NewHMAC(key APIKey, secret APISecret, opts ...HMACOption) *HMAC { + h := &HMAC{ + key: key, + secret: secret, + nonces: newMonotonicNonce(time.Now), + wsNonces: newSecondNonce(time.Now), + requestGate: make(chan struct{}, 1), + } + h.requestGate <- struct{}{} + h.hasherPool.New = func() any { + return hmac.New(sha512.New384, []byte(secret)) + } + for _, opt := range opts { + opt(h) + } + return h +} + +// Validate reports whether the HMAC strategy has usable credentials. +func (h *HMAC) Validate() error { + if h == nil || strings.TrimSpace(string(h.key)) == "" || strings.TrimSpace(string(h.secret)) == "" { + return ErrInvalidHMACCredentials + } + return nil +} + +// Key returns a masked API-key identifier suitable for logs and telemetry. +func (h *HMAC) Key() string { + return h.key.String() +} + +// AcquireRequest serializes a complete authenticated request attempt sequence. +// Gemini rejects a request when its nonce arrives after a larger nonce from the +// same API key, so the gate must remain held through transport retries. +func (h *HMAC) AcquireRequest(ctx context.Context) (func(), error) { + if err := h.Validate(); err != nil { + return nil, err + } + if ctx == nil { + ctx = context.Background() + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-h.requestGate: + return func() { h.requestGate <- struct{}{} }, nil + } +} + +// NextNonce returns the next strictly monotonic nonce. +func (h *HMAC) NextNonce() string { + return h.nonces.Next() +} + +// CalibrateServerTime adjusts nonce timestamps based on remote Gemini server time. +func (h *HMAC) CalibrateServerTime(serverTime time.Time) { + skew := time.Until(serverTime) + if mono, ok := h.nonces.(*monotonicNonce); ok { + mono.SetSkew(skew) + } + if mono, ok := h.wsNonces.(*monotonicNonce); ok { + mono.SetSkew(skew) + } +} + +// BuildPayload constructs the Gemini payload map with request path and nonce injected. +func (h *HMAC) BuildPayload(requestPath string, customParams []byte) ([]byte, error) { + if err := h.Validate(); err != nil { + return nil, err + } + trimmed := bytes.TrimSpace(customParams) + nonce := h.NextNonce() + + payloadMap := make(map[string]json.RawMessage) + if len(trimmed) > 0 && !bytes.Equal(trimmed, []byte("null")) { + if err := json.Unmarshal(trimmed, &payloadMap); err != nil { + return nil, fmt.Errorf("gemini auth: invalid json payload parameters: %w", err) + } + if payloadMap == nil { + payloadMap = make(map[string]json.RawMessage) + } + } + + requestJSON, err := json.Marshal(requestPath) + if err != nil { + return nil, fmt.Errorf("gemini auth: encoding request path: %w", err) + } + nonceJSON, err := json.Marshal(nonce) + if err != nil { + return nil, fmt.Errorf("gemini auth: encoding nonce: %w", err) + } + payloadMap["request"] = requestJSON + payloadMap["nonce"] = nonceJSON + + return json.Marshal(payloadMap) +} + +func hashToHex(hasher hash.Hash) string { + var sumBuf [48]byte + sum := hasher.Sum(sumBuf[:0]) + var hexBuf [96]byte + hex.Encode(hexBuf[:], sum) + return string(hexBuf[:]) +} + +// Sign calculates the HMAC-SHA384 hex signature of a base64 encoded payload. +func (h *HMAC) Sign(b64Payload []byte) string { + if h == nil || h.Validate() != nil { + return "" + } + hasher, ok := h.hasherPool.Get().(hash.Hash) + if !ok || hasher == nil { + return "" + } + hasher.Reset() + hasher.Write(b64Payload) + sig := hashToHex(hasher) + h.hasherPool.Put(hasher) + return sig +} + +// SignString calculates the HMAC-SHA384 hex signature of a base64 encoded string payload. +func (h *HMAC) SignString(b64Payload string) string { + if h == nil || h.Validate() != nil { + return "" + } + hasher, ok := h.hasherPool.Get().(hash.Hash) + if !ok || hasher == nil { + return "" + } + hasher.Reset() + _, _ = io.WriteString(hasher, b64Payload) + sig := hashToHex(hasher) + h.hasherPool.Put(hasher) + return sig +} + +// Authenticate prepares the HTTP request according to Gemini private REST protocol. +func (h *HMAC) Authenticate(ctx context.Context, req *http.Request, payloadJSON []byte) error { + if ctx != nil { + if err := ctx.Err(); err != nil { + return err + } + } + if req == nil { + return fmt.Errorf("gemini auth: nil request") + } + if err := h.Validate(); err != nil { + return err + } + if req.Header == nil { + req.Header = make(http.Header) + } + clearAuthenticationHeaders(req.Header) + if req.URL == nil { + return fmt.Errorf("gemini auth: request URL is nil") + } + fullPayload, err := h.BuildPayload(req.URL.Path, payloadJSON) + if err != nil { + return err + } + + b64Payload := base64.StdEncoding.EncodeToString(fullPayload) + signature := h.SignString(b64Payload) + + req.Header.Set("Content-Type", "text/plain") + req.Header.Set("Content-Length", "0") + req.Header.Set(geminiAPIKeyHeader, string(h.key)) + req.Header.Set(geminiPayloadHeader, b64Payload) + req.Header.Set(geminiSignatureHeader, signature) + req.Header.Set("Cache-Control", "no-cache") + + // Ensure empty body on the wire + req.Body = http.NoBody + req.ContentLength = 0 + + return nil +} + +// AuthenticateWebSocket prepares handshake HTTP headers for Gemini private WebSocket feeds. +func (h *HMAC) AuthenticateWebSocket(ctx context.Context, req *http.Request) error { + if ctx != nil { + if err := ctx.Err(); err != nil { + return err + } + } + if req == nil { + return fmt.Errorf("gemini auth: nil request") + } + if err := h.Validate(); err != nil { + return err + } + if req.Header == nil { + req.Header = make(http.Header) + } + clearAuthenticationHeaders(req.Header) + nonce := h.wsNonces.Next() + b64Payload := base64.StdEncoding.EncodeToString([]byte(nonce)) + signature := h.SignString(b64Payload) + + req.Header.Set(geminiAPIKeyHeader, string(h.key)) + req.Header.Set(geminiNonceHeader, nonce) + req.Header.Set(geminiPayloadHeader, b64Payload) + req.Header.Set(geminiSignatureHeader, signature) + return nil +} + +// VerifySignature verifies an incoming Gemini HMAC-SHA384 signature in constant time against a Base64-encoded payload. +func VerifySignature(secret APISecret, b64Payload, signature string) bool { + hasher := hmac.New(sha512.New384, []byte(secret)) + _, _ = io.WriteString(hasher, b64Payload) + sig := hashToHex(hasher) + return subtle.ConstantTimeCompare([]byte(strings.ToLower(sig)), []byte(strings.ToLower(signature))) == 1 +} diff --git a/packages/sdk-go/auth/hmac_test.go b/packages/sdk-go/auth/hmac_test.go new file mode 100644 index 00000000..2a91d765 --- /dev/null +++ b/packages/sdk-go/auth/hmac_test.go @@ -0,0 +1,356 @@ +package auth + +import ( + "context" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync" + "testing" + "time" +) + +func TestMonotonicNonce_Concurrency(t *testing.T) { + fakeTime := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + gen := newMonotonicNonce(func() time.Time { + return fakeTime + }) + + const goroutines = 50 + const iterations = 500 + results := make([][]int64, goroutines) + var wg sync.WaitGroup + + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func(workerID int) { + defer wg.Done() + workerResults := make([]int64, iterations) + for j := 0; j < iterations; j++ { + nStr := gen.Next() + val, err := strconv.ParseInt(nStr, 10, 64) + if err != nil { + t.Errorf("worker %d: invalid int: %v", workerID, err) + return + } + workerResults[j] = val + } + results[workerID] = workerResults + }(i) + } + + wg.Wait() + + seen := make(map[int64]bool, goroutines*iterations) + for workerID, workerResults := range results { + for _, nonce := range workerResults { + if seen[nonce] { + t.Fatalf("duplicate nonce detected from worker %d: %d", workerID, nonce) + } + seen[nonce] = true + } + } + + if len(seen) != goroutines*iterations { + t.Fatalf("expected %d unique nonces, got %d", goroutines*iterations, len(seen)) + } +} + +func TestHMAC_Authenticate(t *testing.T) { + key := APIKey("my-test-api-key") + secret := APISecret("my-test-secret-12345") + + fixedTime := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + gen := newMonotonicNonce(func() time.Time { return fixedTime }) + + h := NewHMAC(key, secret, WithCustomNonceGenerator(gen)) + + req := httptest.NewRequest("POST", "https://api.gemini.com/v1/order/new", nil) + customParams := []byte(`{"symbol":"btcusd","amount":"1.5","price":"65000.00","side":"buy","type":"exchange limit"}`) + + err := h.Authenticate(context.Background(), req, customParams) + if err != nil { + t.Fatalf("Authenticate failed: %v", err) + } + + if req.Header.Get("X-GEMINI-APIKEY") != "my-test-api-key" { + t.Errorf("expected API key header, got %s", req.Header.Get("X-GEMINI-APIKEY")) + } + + payloadB64 := req.Header.Get("X-GEMINI-PAYLOAD") + if payloadB64 == "" { + t.Fatal("expected X-GEMINI-PAYLOAD header") + } + + decodedBytes, err := base64.StdEncoding.DecodeString(payloadB64) + if err != nil { + t.Fatalf("failed decoding payload base64: %v", err) + } + + var parsed map[string]any + if err := json.Unmarshal(decodedBytes, &parsed); err != nil { + t.Fatalf("failed unmarshaling payload: %v", err) + } + + if parsed["request"] != "/v1/order/new" { + t.Errorf("expected request path /v1/order/new, got %v", parsed["request"]) + } + if parsed["symbol"] != "btcusd" { + t.Errorf("expected symbol btcusd, got %v", parsed["symbol"]) + } + if parsed["nonce"] != strconv.FormatInt(fixedTime.UnixMilli(), 10) { + t.Errorf("expected nonce %d, got %v", fixedTime.UnixMilli(), parsed["nonce"]) + } + + sig := req.Header.Get("X-GEMINI-SIGNATURE") + if sig == "" { + t.Fatal("expected X-GEMINI-SIGNATURE header") + } + if !VerifySignature(secret, payloadB64, sig) { + t.Fatal("expected X-GEMINI-SIGNATURE to cryptographically match payload and secret") + } + if req.ContentLength != 0 { + t.Errorf("expected ContentLength 0, got %d", req.ContentLength) + } + if req.Body != http.NoBody { + t.Errorf("expected req.Body to be http.NoBody") + } +} + +func TestAuthenticationStrategiesClearReservedHeaders(t *testing.T) { + req := httptest.NewRequest("POST", "https://api.gemini.com/v1/order/new", nil) + req.Header = http.Header{ + "Authorization": {"Bearer stale-token"}, + "X-GEMINI-APIKEY": {"stale-key"}, + "X-GEMINI-NONCE": {"stale-nonce"}, + "X-GEMINI-PAYLOAD": {"stale-payload"}, + "X-GEMINI-SIGNATURE": {"stale-signature"}, + } + + hmacStrategy := NewHMAC(APIKey("hmac-key"), APISecret("hmac-secret")) + if err := hmacStrategy.Authenticate(context.Background(), req, []byte(`{"symbol":"btcusd"}`)); err != nil { + t.Fatalf("HMAC Authenticate failed: %v", err) + } + if got := req.Header.Get(authorizationHeader); got != "" { + t.Fatalf("HMAC authentication retained Authorization header %q", got) + } + if got := req.Header.Get(geminiNonceHeader); got != "" { + t.Fatalf("HMAC REST authentication retained nonce header %q", got) + } + + bearerStrategy := NewBearer(BearerToken("bearer-token")) + if err := bearerStrategy.Authenticate(context.Background(), req, []byte(`{"symbol":"btcusd"}`)); err != nil { + t.Fatalf("Bearer Authenticate failed: %v", err) + } + if got := req.Header.Get(authorizationHeader); got != "Bearer bearer-token" { + t.Fatalf("Bearer authentication header = %q", got) + } + for _, key := range []string{geminiAPIKeyHeader, geminiNonceHeader, geminiSignatureHeader} { + if got := req.Header.Get(key); got != "" { + t.Fatalf("Bearer authentication retained %s header %q", key, got) + } + } +} + +func TestHMAC_ValidateRejectsMissingCredentials(t *testing.T) { + for name, strategy := range map[string]*HMAC{ + "nil": nil, + "empty key": NewHMAC("", "secret"), + "empty secret": NewHMAC("key", ""), + "zero value": &HMAC{}, + } { + t.Run(name, func(t *testing.T) { + if err := strategy.Validate(); err != ErrInvalidHMACCredentials { + t.Fatalf("Validate() error = %v, want ErrInvalidHMACCredentials", err) + } + }) + } +} + +func TestHMAC_AuthenticateWebSocket(t *testing.T) { + key := APIKey("my-ws-api-key") + secret := APISecret("my-ws-secret-67890") + h := NewHMAC(key, secret) + + req := httptest.NewRequest("GET", "wss://ws.gemini.com/v1/marketdata", nil) + err := h.AuthenticateWebSocket(context.Background(), req) + if err != nil { + t.Fatalf("AuthenticateWebSocket failed: %v", err) + } + + apiKey := req.Header.Get("X-GEMINI-APIKEY") + if apiKey != "my-ws-api-key" { + t.Fatalf("expected X-GEMINI-APIKEY my-ws-api-key, got %s", apiKey) + } + + nonceStr := req.Header.Get("X-GEMINI-NONCE") + if nonceStr == "" { + t.Fatal("expected non-empty X-GEMINI-NONCE header") + } + nonceVal, err := strconv.ParseInt(nonceStr, 10, 64) + if err != nil || nonceVal <= 0 { + t.Fatalf("expected valid monotonic integer for X-GEMINI-NONCE, got %s", nonceStr) + } + if nonceVal < time.Now().Add(-time.Second).Unix() { + t.Fatalf("expected epoch-second nonce near current time, got %d", nonceVal) + } + + payloadB64 := req.Header.Get("X-GEMINI-PAYLOAD") + if payloadB64 == "" { + t.Fatal("expected non-empty X-GEMINI-PAYLOAD header") + } + decodedBytes, err := base64.StdEncoding.DecodeString(payloadB64) + if err != nil { + t.Fatalf("failed decoding X-GEMINI-PAYLOAD base64: %v", err) + } + if string(decodedBytes) != nonceStr { + t.Fatalf("expected decoded X-GEMINI-PAYLOAD %s, got %s", nonceStr, string(decodedBytes)) + } + + sig := req.Header.Get("X-GEMINI-SIGNATURE") + if sig == "" { + t.Fatal("expected non-empty X-GEMINI-SIGNATURE header") + } + if !VerifySignature(secret, payloadB64, sig) { + t.Fatal("expected X-GEMINI-SIGNATURE to cryptographically match X-GEMINI-PAYLOAD and secret") + } +} + +func TestHMAC_BuildPayloadPreservesJSONAndEscapesRequestPath(t *testing.T) { + h := NewHMAC("key", "secret", WithCustomNonceGenerator(newMonotonicNonce(func() time.Time { + return time.UnixMilli(123) + }))) + + built, err := h.BuildPayload(`/v1/quote/"slash\\value`, []byte(`{"amount":9007199254740993,"request":"spoofed","nonce":0}`)) + if err != nil { + t.Fatalf("BuildPayload failed: %v", err) + } + + var payload map[string]json.RawMessage + if err := json.Unmarshal(built, &payload); err != nil { + t.Fatalf("failed decoding payload: %v", err) + } + var request, nonce string + if err := json.Unmarshal(payload["request"], &request); err != nil { + t.Fatalf("request was not encoded as a string: %v", err) + } + if request != `/v1/quote/"slash\\value` { + t.Fatalf("unexpected request path %q", request) + } + if err := json.Unmarshal(payload["nonce"], &nonce); err != nil { + t.Fatalf("nonce was not encoded as a string: %v", err) + } + if nonce != "123" { + t.Fatalf("expected nonce 123, got %q", nonce) + } + if got := string(payload["amount"]); got != "9007199254740993" { + t.Fatalf("large JSON number was changed: got %s", got) + } +} + +func TestHMAC_KeyDoesNotExposeSecret(t *testing.T) { + h := NewHMAC(APIKey("public-key"), APISecret("super-secret")) + if got := h.Key(); got != APIKey("public-key").String() { + t.Fatalf("expected masked key identifier, got %q", got) + } + if h.Key() == "public-key" { + t.Fatal("HMAC Key exposed the full API key") + } + if strings.Contains(h.Key(), "super-secret") { + t.Fatal("HMAC Key exposed the API secret") + } +} + +func TestVerifySignature(t *testing.T) { + secret := APISecret("super-secret-key-98765") + b64Payload := "eyJyZXF1ZXN0IjoiL3YxL29yZGVyL3N0YXR1cyIsIm5vbmNlIjoiMTIzNDU2Nzg5In0=" + + h := NewHMAC("any-key", secret) + validSig := h.Sign([]byte(b64Payload)) + + if !VerifySignature(secret, b64Payload, validSig) { + t.Fatal("expected valid signature to verify successfully") + } + + // Test case insensitivity of hex string + if !VerifySignature(secret, b64Payload, strings.ToUpper(validSig)) { + t.Fatal("expected uppercase signature verification to succeed") + } + + // Invalid signature + if VerifySignature(secret, b64Payload, "deadbeef1234567890abcdef") { + t.Fatal("expected invalid signature to fail verification") + } + + // Wrong secret + if VerifySignature(APISecret("wrong-secret"), b64Payload, validSig) { + t.Fatal("expected wrong secret to fail verification") + } + + // Tampered payload + if VerifySignature(secret, "tampered-payload", validSig) { + t.Fatal("expected tampered payload to fail verification") + } +} + +func TestHMAC_BuildPayload_NoDuplicateEnvelopeKeys(t *testing.T) { + key := APIKey("test-key") + secret := APISecret("test-secret") + h := NewHMAC(key, secret) + + // Simulate generated OpenAPI struct payload with zero-valued envelope fields + structJSON := []byte(`{"amount":"1.0","nonce":0,"price":"50000","request":"","side":"buy","symbol":"btcusd"}`) + built, err := h.BuildPayload("/v1/order/new", structJSON) + if err != nil { + t.Fatalf("BuildPayload failed: %v", err) + } + + // Verify only one occurrence of "request" and "nonce" in wire JSON + s := string(built) + if strings.Count(s, `"request"`) != 1 { + t.Fatalf("expected exactly 1 'request' key in payload, got %d in: %s", strings.Count(s, `"request"`), s) + } + if strings.Count(s, `"nonce"`) != 1 { + t.Fatalf("expected exactly 1 'nonce' key in payload, got %d in: %s", strings.Count(s, `"nonce"`), s) + } + + var parsed map[string]any + if err := json.Unmarshal(built, &parsed); err != nil { + t.Fatalf("failed unmarshaling built payload: %v", err) + } + if parsed["request"] != "/v1/order/new" { + t.Fatalf("expected request /v1/order/new, got %v", parsed["request"]) + } + if parsed["amount"] != "1.0" { + t.Fatalf("expected amount 1.0, got %v", parsed["amount"]) + } +} + +func BenchmarkHMAC_Authenticate(b *testing.B) { + key := APIKey("my-test-api-key") + secret := APISecret("my-test-secret-12345") + h := NewHMAC(key, secret) + req := httptest.NewRequest("POST", "https://api.gemini.com/v1/order/new", nil) + customParams := []byte(`{"symbol":"btcusd","amount":"1.5","price":"65000.00","side":"buy","type":"exchange limit"}`) + ctx := context.Background() + + b.ResetTimer() + b.ReportAllocs() + + for i := 0; i < b.N; i++ { + _ = h.Authenticate(ctx, req, customParams) + } +} + +func BenchmarkMonotonicNonce_Next(b *testing.B) { + gen := newMonotonicNonce(time.Now) + b.ResetTimer() + b.ReportAllocs() + + for i := 0; i < b.N; i++ { + _ = gen.Next() + } +} diff --git a/packages/sdk-go/auth/secrets.go b/packages/sdk-go/auth/secrets.go new file mode 100644 index 00000000..1e51a859 --- /dev/null +++ b/packages/sdk-go/auth/secrets.go @@ -0,0 +1,79 @@ +package auth + +import ( + "fmt" + "log/slog" +) + +// APIKey represents a Gemini API Key identifier. +type APIKey string + +// String masks the API key for safe terminal/log output. +func (k APIKey) String() string { + raw := string(k) + if len(raw) <= 8 { + return "[REDACTED_KEY]" + } + return fmt.Sprintf("%s...%s", raw[:4], raw[len(raw)-4:]) +} + +// GoString masks the API key in %#v debug representations. +func (k APIKey) GoString() string { + return fmt.Sprintf("auth.APIKey(%q)", k.String()) +} + +// MarshalJSON redacts the API key during JSON serialization. +func (k APIKey) MarshalJSON() ([]byte, error) { + return []byte(fmt.Sprintf("%q", k.String())), nil +} + +// LogValue implements slog.LogValuer to prevent accidental leaking in structured logs. +func (k APIKey) LogValue() slog.Value { + return slog.StringValue(k.String()) +} + +// APISecret represents a Gemini API secret used for HMAC-SHA384 signatures. +type APISecret string + +// String completely redacts the API secret. +func (s APISecret) String() string { + return "[REDACTED_SECRET]" +} + +// GoString completely redacts the API secret in %#v debug representations. +func (s APISecret) GoString() string { + return `auth.APISecret("[REDACTED_SECRET]")` +} + +// MarshalJSON redacts the API secret during JSON serialization. +func (s APISecret) MarshalJSON() ([]byte, error) { + return []byte(`"[REDACTED_SECRET]"`), nil +} + +// LogValue implements slog.LogValuer. +func (s APISecret) LogValue() slog.Value { + return slog.StringValue("[REDACTED_SECRET]") +} + +// BearerToken represents an OAuth2 bearer access token. +type BearerToken string + +// String completely redacts the bearer token. +func (t BearerToken) String() string { + return "[REDACTED_TOKEN]" +} + +// GoString completely redacts the bearer token in %#v debug representations. +func (t BearerToken) GoString() string { + return `auth.BearerToken("[REDACTED_TOKEN]")` +} + +// MarshalJSON redacts the bearer token during JSON serialization. +func (t BearerToken) MarshalJSON() ([]byte, error) { + return []byte(`"[REDACTED_TOKEN]"`), nil +} + +// LogValue implements slog.LogValuer. +func (t BearerToken) LogValue() slog.Value { + return slog.StringValue("[REDACTED_TOKEN]") +} diff --git a/packages/sdk-go/client.go b/packages/sdk-go/client.go new file mode 100644 index 00000000..0340f7b3 --- /dev/null +++ b/packages/sdk-go/client.go @@ -0,0 +1,236 @@ +package gemini + +import ( + "fmt" + "log/slog" + "net/url" + "strings" + + "github.com/gemini/gemini-go/services" + "github.com/gemini/gemini-go/transport" + "github.com/gemini/gemini-go/websocket" +) + +// Client is the primary entrypoint to the Gemini Go SDK. +type Client struct { + config *clientConfig + + MarketData *services.MarketDataService + Trading *services.TradingService + Margin *services.MarginService + Perpetuals *services.PerpetualsService + Account *services.AccountService + Staking *services.StakingService + Transfers *services.TransfersService + Clearing *services.ClearingService + Predictions *services.PredictionsService + Heartbeat *services.HeartbeatService + + publicWS *websocket.Client + privateWS *websocket.Client +} + +// NewClient initializes a new Gemini SDK Client with functional options. +func NewClient(opts ...Option) *Client { + cfg := newClientConfig(opts...) + return newClientFromConfig(cfg) +} + +// NewClientWithError initializes a new Gemini SDK Client and returns an error +// when its options are invalid. Use this constructor when configuration errors +// must be detected before any request or WebSocket connection is attempted. +func NewClientWithError(opts ...Option) (*Client, error) { + cfg := newClientConfig(opts...) + if cfg.configErr != nil { + return nil, cfg.configErr + } + return newClientFromConfig(cfg), nil +} + +func newClientConfig(opts ...Option) *clientConfig { + cfg := &clientConfig{ + env: Production, + restURL: endpoints[Production].REST, + wsURL: endpoints[Production].WebSocket, + retry: transport.DefaultRetryPolicy(), + logger: slog.Default(), + userAgent: "gemini-go/0.1.0", + ownsHTTPClient: true, + } + + for _, opt := range opts { + if opt != nil { + opt(cfg) + } + } + validateClientConfig(cfg) + return cfg +} + +func validateClientConfig(cfg *clientConfig) { + if cfg.configErr != nil { + return + } + if validator, ok := cfg.auth.(interface{ Validate() error }); ok { + if err := validator.Validate(); err != nil { + cfg.configErr = err + return + } + } + if err := validateEndpointURL(cfg.restURL, "REST", "https"); err != nil { + cfg.configErr = err + return + } + if err := validateEndpointURL(cfg.wsURL, "WebSocket", "wss"); err != nil { + cfg.configErr = err + } +} + +func validateEndpointURL(raw, name string, schemes ...string) error { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil { + return fmt.Errorf("%w: %s endpoint: %v", ErrInvalidEndpointURL, name, err) + } + if parsed.Host == "" { + return fmt.Errorf("%w: %s endpoint must include a host", ErrInvalidEndpointURL, name) + } + if parsed.User != nil { + return fmt.Errorf("%w: %s endpoint must not include userinfo", ErrInvalidEndpointURL, name) + } + if parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" { + return fmt.Errorf("%w: %s endpoint must not include a query or fragment", ErrInvalidEndpointURL, name) + } + for _, scheme := range schemes { + if strings.EqualFold(parsed.Scheme, scheme) { + return nil + } + } + return fmt.Errorf("%w: %s endpoint must use %s", ErrInvalidEndpointURL, name, strings.Join(schemes, " or ")) +} + +// PublicWebSocket returns the dedicated unauthenticated WebSocket client for public market data streams. +func (c *Client) PublicWebSocket() *websocket.Client { + return c.publicWS +} + +// PrivateWebSocket returns the dedicated authenticated WebSocket client for account/order feeds. +func (c *Client) PrivateWebSocket() *websocket.Client { + return c.privateWS +} + +// NewQuoteReconciler creates a declarative order book quoting reconciler for a symbol. +// It uses the authenticated PrivateWebSocket connection to receive low-latency order updates. +func (c *Client) NewQuoteReconciler(symbol string, opts ...services.ReconcilerOption) *services.QuoteReconciler { + return services.NewQuoteReconciler(c.Trading, c.privateWS, symbol, opts...) +} + +// WithOptions creates a copy of the client with modified configuration options. +// SDK-owned HTTP transports are not shared between the two clients; caller- +// supplied HTTP clients remain shared and caller-owned. +func (c *Client) WithOptions(opts ...Option) *Client { + newCfg := *c.config + if c.config.ownsHTTPClient { + // A default client owns its transport. Let newClientFromConfig create a + // fresh client so closing either facade cannot close idle connections + // belonging to the other facade. + newCfg.httpClient = nil + newCfg.ownsHTTPClient = true + } + // Shallow copy slices to prevent mutating original client's hooks + if len(c.config.hooks) > 0 { + newCfg.hooks = make([]transport.Hook, len(c.config.hooks)) + copy(newCfg.hooks, c.config.hooks) + } + + for _, opt := range opts { + if opt != nil { + opt(&newCfg) + } + } + validateClientConfig(&newCfg) + + return newClientFromConfig(&newCfg) +} + +func newClientFromConfig(cfg *clientConfig) *Client { + if cfg.httpClient == nil { + cfg.httpClient = transport.DefaultHTTPClient() + cfg.ownsHTTPClient = true + } + + privateConfigErr := cfg.configErr + if privateConfigErr == nil && cfg.auth == nil { + privateConfigErr = transport.ErrAuthenticationRequired + } + transportClient := transport.NewClient( + transport.WithHTTPClient(cfg.httpClient), + transport.WithAuth(cfg.auth), + transport.WithConfigurationError(privateConfigErr), + transport.WithRetryPolicy(cfg.retry), + transport.WithLogger(cfg.logger), + transport.WithUserAgent(cfg.userAgent), + transport.WithHooks(cfg.hooks...), + ) + publicTransport := transport.NewClient( + transport.WithHTTPClient(cfg.httpClient), + transport.WithConfigurationError(cfg.configErr), + transport.WithRetryPolicy(cfg.retry), + transport.WithLogger(cfg.logger), + transport.WithUserAgent(cfg.userAgent), + transport.WithHooks(cfg.hooks...), + ) + + publicWSOptions := []websocket.ClientOption{ + websocket.WithDialer(cfg.wsDialer), + websocket.WithClientLogger(cfg.logger), + websocket.WithConfigurationError(cfg.configErr), + websocket.WithSnapshot(-1), + websocket.WithIsolatedPartialSnapshots(), + } + publicWS := websocket.NewPublicClient(cfg.wsURL, publicWSOptions...) + + privateWS := websocket.NewPrivateClient( + cfg.wsURL, + cfg.auth, + websocket.WithDialer(cfg.wsDialer), + websocket.WithClientLogger(cfg.logger), + websocket.WithConfigurationError(cfg.configErr), + ) + + return &Client{ + config: cfg, + MarketData: services.NewMarketDataService(publicTransport, cfg.restURL), + Trading: services.NewTradingService(transportClient, cfg.restURL), + Margin: services.NewMarginService(transportClient, cfg.restURL), + Perpetuals: services.NewPerpetualsServiceWithPublicClient(transportClient, publicTransport, cfg.restURL), + Account: services.NewAccountService(transportClient, cfg.restURL), + Staking: services.NewStakingServiceWithPublicClient(transportClient, publicTransport, cfg.restURL), + Transfers: services.NewTransfersService(transportClient, cfg.restURL), + Clearing: services.NewClearingService(transportClient, cfg.restURL), + Predictions: services.NewPredictionsServiceWithPublicClient(transportClient, publicTransport, cfg.restURL), + Heartbeat: services.NewHeartbeatService(transportClient, cfg.restURL), + publicWS: publicWS, + privateWS: privateWS, + } +} + +// Close gracefully closes active WebSocket connections and releases idle HTTP +// connections only for the HTTP client created by the SDK. A client supplied +// with WithHTTPClient remains owned by the caller. +func (c *Client) Close() error { + var wsErr error + if c.publicWS != nil { + if err := c.publicWS.Close(); err != nil && wsErr == nil { + wsErr = err + } + } + if c.privateWS != nil && c.privateWS != c.publicWS { + if err := c.privateWS.Close(); err != nil && wsErr == nil { + wsErr = err + } + } + if c.config != nil && c.config.ownsHTTPClient && c.config.httpClient != nil { + c.config.httpClient.CloseIdleConnections() + } + return wsErr +} diff --git a/packages/sdk-go/client_internal_test.go b/packages/sdk-go/client_internal_test.go new file mode 100644 index 00000000..fd04e7e3 --- /dev/null +++ b/packages/sdk-go/client_internal_test.go @@ -0,0 +1,17 @@ +package gemini + +import "testing" + +func TestWithOptionsDoesNotShareSDKOwnedHTTPClient(t *testing.T) { + base := NewClient() + clone := base.WithOptions(WithCustomRESTURL("https://custom.gemini.local")) + defer base.Close() + defer clone.Close() + + if base.config.httpClient == clone.config.httpClient { + t.Fatal("WithOptions shared an SDK-owned HTTP client") + } + if !base.config.ownsHTTPClient || !clone.config.ownsHTTPClient { + t.Fatal("expected both clients to own their independent default HTTP clients") + } +} diff --git a/packages/sdk-go/client_test.go b/packages/sdk-go/client_test.go new file mode 100644 index 00000000..c1f9ed23 --- /dev/null +++ b/packages/sdk-go/client_test.go @@ -0,0 +1,482 @@ +package gemini_test + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "github.com/gemini/gemini-go" + "github.com/gemini/gemini-go/auth" + "github.com/gemini/gemini-go/geminitest" + "github.com/gemini/gemini-go/generated/account" + "github.com/gemini/gemini-go/generated/predictions" + "github.com/gemini/gemini-go/generated/trading" + "github.com/gemini/gemini-go/transport" + "github.com/gemini/gemini-go/websocket" +) + +type trackingRoundTripper struct { + closeIdleCalls atomic.Int32 + roundTrips atomic.Int32 +} + +func (t *trackingRoundTripper) RoundTrip(*http.Request) (*http.Response, error) { + t.roundTrips.Add(1) + return nil, errors.New("tracking transport: RoundTrip not expected") +} + +func (t *trackingRoundTripper) CloseIdleConnections() { + t.closeIdleCalls.Add(1) +} + +func TestClient_InvalidEnvironmentFailsClosed(t *testing.T) { + _, err := gemini.NewClientWithError(gemini.WithEnvironment(gemini.Environment("sandbxo"))) + if !errors.Is(err, gemini.ErrInvalidEnvironment) { + t.Fatalf("expected invalid environment error, got %v", err) + } + + client := gemini.NewClient(gemini.WithEnvironment(gemini.Environment("sandbxo"))) + defer client.Close() + if _, err := client.MarketData.GetSymbols(context.Background()); !errors.Is(err, gemini.ErrInvalidEnvironment) { + t.Fatalf("expected invalid environment request error, got %v", err) + } + + validClient, err := gemini.NewClientWithError( + gemini.WithEnvironment(gemini.Environment("sandbxo")), + gemini.WithEnvironment(gemini.Sandbox), + ) + if err != nil { + t.Fatalf("expected a later valid environment option to recover configuration, got %v", err) + } + defer validClient.Close() +} + +func TestClient_InvalidCustomEndpointsFailEarly(t *testing.T) { + if _, err := gemini.NewClientWithError(gemini.WithCustomRESTURL("api.gemini.local")); !errors.Is(err, gemini.ErrInvalidEndpointURL) { + t.Fatalf("expected invalid REST endpoint error, got %v", err) + } + if _, err := gemini.NewClientWithError(gemini.WithCustomWSURL("https://ws.gemini.local")); !errors.Is(err, gemini.ErrInvalidEndpointURL) { + t.Fatalf("expected invalid WebSocket endpoint error, got %v", err) + } + + for _, endpoint := range []string{ + "http://api.gemini.local", + "https://api.gemini.local?tenant=sandbox", + "https://api.gemini.local#fragment", + "https://api.gemini.local?", + "https://user:password@api.gemini.local", + } { + if _, err := gemini.NewClientWithError(gemini.WithCustomRESTURL(endpoint)); !errors.Is(err, gemini.ErrInvalidEndpointURL) { + t.Errorf("REST endpoint %q error = %v, want ErrInvalidEndpointURL", endpoint, err) + } + } + for _, endpoint := range []string{ + "ws://ws.gemini.local", + "wss://ws.gemini.local?tenant=sandbox", + "wss://ws.gemini.local#fragment", + "wss://ws.gemini.local?", + "wss://user:password@ws.gemini.local", + } { + if _, err := gemini.NewClientWithError(gemini.WithCustomWSURL(endpoint)); !errors.Is(err, gemini.ErrInvalidEndpointURL) { + t.Errorf("WebSocket endpoint %q error = %v, want ErrInvalidEndpointURL", endpoint, err) + } + } +} + +func TestClient_InvalidBearerConfigurationFailsEarly(t *testing.T) { + _, err := gemini.NewClientWithError(gemini.WithTokenSource(nil)) + if !errors.Is(err, gemini.ErrInvalidTokenSource) { + t.Fatalf("expected ErrInvalidTokenSource, got %v", err) + } + + client := gemini.NewClient(gemini.WithBearerToken("")) + defer client.Close() + if _, err := client.Account.GetAccount(context.Background(), nil); !errors.Is(err, gemini.ErrInvalidTokenSource) { + t.Fatalf("expected invalid bearer configuration to fail requests, got %v", err) + } + + if _, err := gemini.NewClientWithError(gemini.WithAPIKey("", "secret")); !errors.Is(err, gemini.ErrInvalidHMACCredentials) { + t.Fatalf("expected empty HMAC key to fail early, got %v", err) + } + if _, err := gemini.NewClientWithError(gemini.WithAPIKey("key", "")); !errors.Is(err, gemini.ErrInvalidHMACCredentials) { + t.Fatalf("expected empty HMAC secret to fail early, got %v", err) + } +} + +func TestClient_PrivateRESTRequiresAuthenticationBeforeNetwork(t *testing.T) { + tracker := &trackingRoundTripper{} + client := gemini.NewClient( + gemini.WithCustomRESTURL("https://api.gemini.test"), + gemini.WithHTTPClient(&http.Client{Transport: tracker}), + ) + defer client.Close() + + _, err := client.Account.GetAccount(context.Background(), nil) + if !errors.Is(err, gemini.ErrAuthenticationRequired) { + t.Fatalf("expected ErrAuthenticationRequired, got %v", err) + } + if got := tracker.roundTrips.Load(); got != 0 { + t.Fatalf("private REST request reached the network %d time(s)", got) + } +} + +func TestClient_PredictionsSplitsPublicAndPrivateREST(t *testing.T) { + var privateHits atomic.Int32 + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/prediction-markets/events": + _, _ = w.Write([]byte(`{"data":[]}`)) + case "/v1/fundingamount/BTCUSD": + _, _ = w.Write([]byte(`{}`)) + case "/v1/nextfundingtimestamp/BTCUSD": + _, _ = w.Write([]byte(`0`)) + case "/v1/staking/rates": + _, _ = w.Write([]byte(`{}`)) + default: + privateHits.Add(1) + http.NotFound(w, r) + } + })) + defer server.Close() + + client := gemini.NewClient( + gemini.WithCustomRESTURL(server.URL), + gemini.WithHTTPClient(server.Client()), + ) + defer client.Close() + + if _, err := client.Predictions.GetEvents(context.Background(), nil); err != nil { + t.Fatalf("public prediction endpoint should work without auth: %v", err) + } + if _, err := client.Perpetuals.GetFundingAmount(context.Background(), "BTCUSD"); err != nil { + t.Fatalf("public perpetual funding endpoint should work without auth: %v", err) + } + if _, err := client.Perpetuals.GetNextFundingTimestamp(context.Background(), "BTCUSD"); err != nil { + t.Fatalf("public perpetual funding timestamp endpoint should work without auth: %v", err) + } + if _, err := client.Staking.GetStakingRates(context.Background()); err != nil { + t.Fatalf("public staking rates endpoint should work without auth: %v", err) + } + if _, err := client.Predictions.NewOrder(context.Background(), &predictions.OrderRequest{}); !errors.Is(err, gemini.ErrAuthenticationRequired) { + t.Fatalf("private prediction endpoint should fail closed without auth, got %v", err) + } + if got := privateHits.Load(); got != 0 { + t.Fatalf("private prediction request reached the network %d time(s)", got) + } +} + +func TestClient_EndToEnd(t *testing.T) { + apiKey := "test-key-123" + apiSecret := "test-secret-456" + + server := geminitest.NewMockServer(apiKey, apiSecret) + defer server.Close() + + client := gemini.NewClient( + gemini.WithCustomRESTURL(server.URL()), + gemini.WithHTTPClient(server.HTTPClient()), + gemini.WithAPIKey(apiKey, apiSecret), + ) + + ctx := context.Background() + + // 1. Public Market Data + symbols, err := client.MarketData.GetSymbols(ctx) + if err != nil { + t.Fatalf("failed getting symbols: %v", err) + } + if len(symbols) != 3 || symbols[0] != "btcusd" { + t.Fatalf("unexpected symbols response: %v", symbols) + } + + ticker, err := client.MarketData.GetTicker(ctx, "btcusd") + if err != nil { + t.Fatalf("failed getting ticker: %v", err) + } + if ticker.Bid == nil || *ticker.Bid != "65000.00" { + t.Fatalf("unexpected ticker response: %+v", ticker) + } + + book, err := client.MarketData.GetOrderBook(ctx, "btcusd", 10, 10) + if err != nil { + t.Fatalf("failed getting order book: %v", err) + } + if book.Bids == nil || len(*book.Bids) == 0 || (*book.Bids)[0].Price == nil || *(*book.Bids)[0].Price != "65000.00" { + t.Fatalf("unexpected book response: %+v", book) + } + + // 2. Private Authenticated Trading + newOrderReq := &trading.NewOrderRequest{ + Symbol: "btcusd", + Amount: "1.5", + Price: "65000.00", + Side: trading.NewOrderRequestSideBuy, + Type: trading.NewOrderRequestTypeExchangeLimit, + } + + orderRes, err := client.Trading.NewOrder(ctx, newOrderReq) + if err != nil { + t.Fatalf("failed placing new order: %v", err) + } + if orderRes.Symbol == nil || *orderRes.Symbol != "btcusd" || orderRes.RemainingAmount == nil || *orderRes.RemainingAmount != "1.5" { + t.Fatalf("unexpected order result: %+v", orderRes) + } + + // 3. Private Account Balances + balances, err := client.Account.GetBalances(ctx, &account.GetAvailableBalancesJSONBody{Account: "primary"}) + if err != nil { + t.Fatalf("failed getting balances: %v", err) + } + if len(balances) != 2 || balances[0].Currency == nil || *balances[0].Currency != "USD" { + t.Fatalf("unexpected balances: %+v", balances) + } + + // 4. Prediction Markets Terms Gating + predReq := &predictions.OrderRequest{ + OrderType: predictions.OrderTypeLimit, + Outcome: predictions.Yes, + Price: "0.50", + Quantity: "100", + Side: predictions.OrderSideBuy, + Symbol: "presidential-2028", + } + _, err = client.Predictions.NewOrder(ctx, predReq) + if !errors.Is(err, transport.ErrAcceptTermsRequired) { + t.Fatalf("expected ErrAcceptTermsRequired, got %v", err) + } + + // Accept terms and retry + if _, err := client.Predictions.AcceptTerms(ctx); err != nil { + t.Fatalf("failed accepting terms: %v", err) + } + + // Post-accept gating: order submission must succeed now + predOrder, err := client.Predictions.NewOrder(ctx, predReq) + if err != nil { + t.Fatalf("failed placing prediction order after terms accepted: %v", err) + } + if predOrder.OrderId == nil || *predOrder.OrderId != 12345 { + t.Fatalf("unexpected prediction order result: %+v", predOrder) + } + + // 5. Staking Service + stkBalances, err := client.Staking.GetStakingBalances(ctx, nil) + if err != nil || len(stkBalances) != 1 { + t.Fatalf("failed getting staking balances: %v", err) + } + + // 6. Transfers Service + transfers, err := client.Transfers.GetTransfers(ctx, nil) + if err != nil || len(transfers) != 1 { + t.Fatalf("failed getting transfers: %v", err) + } + + // 7. WebSocket missing dialer returns actionable ErrNoDialerConfigured + ws := client.PublicWebSocket() + _, wsErr := ws.SubscribeDepth(ctx, "btcusd") + if !errors.Is(wsErr, websocket.ErrNoDialerConfigured) { + t.Fatalf("expected ErrNoDialerConfigured when no dialer is set, got %v", wsErr) + } +} + +func TestClient_OAuthStaticBearer(t *testing.T) { + bearerToken := "oauth-bearer-token-12345" + server := geminitest.NewMockOAuthServer(bearerToken) + defer server.Close() + + client := gemini.NewClient( + gemini.WithCustomRESTURL(server.URL()), + gemini.WithHTTPClient(server.HTTPClient()), + gemini.WithBearerToken(bearerToken), + ) + + ctx := context.Background() + + // 1. Authenticated Account Balances via Bearer Token + balances, err := client.Account.GetBalances(ctx, &account.GetAvailableBalancesJSONBody{Account: "primary"}) + if err != nil { + t.Fatalf("failed getting balances via OAuth: %v", err) + } + if len(balances) != 2 || balances[0].Currency == nil || *balances[0].Currency != "USD" { + t.Fatalf("unexpected balances: %+v", balances) + } + + // 2. Authenticated Order Placement via Bearer Token + order, err := client.Trading.NewOrder(ctx, &trading.NewOrderRequest{ + Symbol: "btcusd", + Amount: "2.0", + Price: "64000.00", + Side: trading.NewOrderRequestSideBuy, + Type: trading.NewOrderRequestTypeExchangeLimit, + }) + if err != nil { + t.Fatalf("failed placing order via OAuth: %v", err) + } + if order.Symbol == nil || *order.Symbol != "btcusd" { + t.Fatalf("unexpected order: %+v", order) + } + + // 3. OAuth token revocation uses the bearer token making the request. + revoked, err := client.Account.RevokeOAuthToken(ctx) + if err != nil || revoked == nil || revoked.Message != "token revoked" { + t.Fatalf("failed revoking OAuth token: %v", err) + } +} + +func TestClient_OAuthDynamicTokenSource(t *testing.T) { + currentOAuthToken := "token-gen-1" + server := geminitest.NewMockOAuthServer("token-gen-2") // server expects token-gen-2 + defer server.Close() + + tokenFetcher := auth.TokenFunc(func(ctx context.Context) (string, error) { + return currentOAuthToken, nil + }) + + client := gemini.NewClient( + gemini.WithCustomRESTURL(server.URL()), + gemini.WithHTTPClient(server.HTTPClient()), + gemini.WithTokenSource(tokenFetcher), + ) + + ctx := context.Background() + + // Initial call with token-gen-1 must fail with 401 Unauthorized + _, err := client.Account.GetBalances(ctx, &account.GetAvailableBalancesJSONBody{Account: "primary"}) + if err == nil { + t.Fatal("expected unauthorized error with stale token, got nil") + } + + // Dynamic token renewal + currentOAuthToken = "token-gen-2" + + // Subsequent call automatically uses renewed token and succeeds + balances, err := client.Account.GetBalances(ctx, &account.GetAvailableBalancesJSONBody{Account: "primary"}) + if err != nil { + t.Fatalf("expected success with refreshed token, got %v", err) + } + if len(balances) != 2 { + t.Fatalf("unexpected balances length: %d", len(balances)) + } +} + +func TestClient_ErrorPredicates(t *testing.T) { + if !gemini.IsRateLimit(gemini.ErrRateLimited) { + t.Error("expected IsRateLimit(ErrRateLimited) to be true") + } + if !gemini.IsInsufficientFunds(gemini.ErrInsufficientFunds) { + t.Error("expected IsInsufficientFunds(ErrInsufficientFunds) to be true") + } + if !gemini.IsAuthError(gemini.ErrInvalidSignature) || !gemini.IsAuthError(gemini.ErrInvalidNonce) || !gemini.IsAuthError(gemini.ErrUnauthorized) { + t.Error("expected IsAuthError to be true for auth errors") + } + if !gemini.IsNotFound(gemini.ErrOrderNotFound) || !gemini.IsNotFound(gemini.ErrNotFound) { + t.Error("expected IsNotFound to be true for not found errors") + } + if !gemini.IsBadRequest(gemini.ErrBadRequest) { + t.Error("expected IsBadRequest(ErrBadRequest) to be true") + } + if !gemini.IsPermissionDenied(gemini.ErrPermissionDenied) || !gemini.IsPermissionDenied(gemini.ErrMissingRole) { + t.Error("expected IsPermissionDenied to be true") + } + if !gemini.IsConflict(gemini.ErrConflict) { + t.Error("expected IsConflict(ErrConflict) to be true") + } + if !gemini.IsInternalServerError(gemini.ErrInternalServer) { + t.Error("expected IsInternalServerError(ErrInternalServer) to be true") + } + if gemini.IsRateLimit(gemini.ErrOrderNotFound) { + t.Error("expected IsRateLimit(ErrOrderNotFound) to be false") + } +} + +func TestClient_RootAliases(t *testing.T) { + d := gemini.MustDecimal("65000.50") + if d.String() != "65000.5" { + t.Errorf("unexpected decimal: %s", d.String()) + } + + q := gemini.DesiredQuote{ + Side: "buy", + Price: d, + Amount: gemini.MustDecimal("1.0"), + } + if q.Side != "buy" || q.Price.String() != "65000.5" { + t.Errorf("unexpected quote alias struct: %+v", q) + } + + secret := gemini.APISecret("test-secret-root-alias") + b64Payload := "eyJyZXF1ZXN0IjoiL3YxL29yZGVycyIsIm5vbmNlIjoiOTg3NjU0MzIxIn0=" + sig := auth.NewHMAC("key", secret).Sign([]byte(b64Payload)) + if !gemini.VerifySignature(secret, b64Payload, sig) { + t.Error("expected VerifySignature root alias to verify successfully") + } +} + +func TestClient_WithOptionsAndClose(t *testing.T) { + baseClient := gemini.NewClient( + gemini.WithEnvironment(gemini.Sandbox), + gemini.WithAPIKey("base-key-12345", "base-secret-12345"), + ) + + // Clone client with new URL and sandbox options + scopedClient := baseClient.WithOptions( + gemini.WithCustomRESTURL("https://custom.gemini.local"), + ) + + if scopedClient == baseClient { + t.Fatal("expected WithOptions to return a new client instance") + } + if scopedClient.MarketData == nil || scopedClient.Trading == nil { + t.Fatal("expected services to be initialized on cloned client") + } + + if err := baseClient.Close(); err != nil { + t.Fatalf("unexpected error closing base client: %v", err) + } + if err := scopedClient.Close(); err != nil { + t.Fatalf("unexpected error closing scoped client: %v", err) + } +} + +func TestClient_DoesNotCloseCallerOwnedHTTPClient(t *testing.T) { + transport := &trackingRoundTripper{} + httpClient := &http.Client{Transport: transport} + client := gemini.NewClient(gemini.WithHTTPClient(httpClient)) + + if err := client.Close(); err != nil { + t.Fatalf("unexpected error closing client: %v", err) + } + if got := transport.closeIdleCalls.Load(); got != 0 { + t.Fatalf("expected caller-owned transport to remain open, got %d CloseIdleConnections calls", got) + } +} + +func TestClient_PublicAndPrivateWebSocketConnections(t *testing.T) { + client := gemini.NewClient( + gemini.WithAPIKey("key-123", "secret-456"), + ) + defer client.Close() + + pubWS := client.PublicWebSocket() + privWS := client.PrivateWebSocket() + + if pubWS == nil { + t.Fatal("expected PublicWebSocket to be non-nil") + } + if privWS == nil { + t.Fatal("expected PrivateWebSocket to be non-nil") + } + if pubWS == privWS { + t.Fatal("expected PublicWebSocket and PrivateWebSocket to be distinct connection instances") + } + // Unauthenticated client + anonClient := gemini.NewClient() + defer anonClient.Close() + + if _, err := anonClient.PrivateWebSocket().SubscribeOrderEvents(context.Background()); !errors.Is(err, gemini.ErrAuthenticationRequired) { + t.Fatalf("expected ErrAuthenticationRequired on anonymous private client, got %v", err) + } +} diff --git a/packages/sdk-go/cmd/demo/go.mod b/packages/sdk-go/cmd/demo/go.mod new file mode 100644 index 00000000..260798a8 --- /dev/null +++ b/packages/sdk-go/cmd/demo/go.mod @@ -0,0 +1,14 @@ +module github.com/gemini/gemini-go/cmd/demo + +go 1.23.0 + +replace github.com/gemini/gemini-go => ../.. + +replace github.com/gemini/gemini-go/websocket/gorilla => ../../websocket/gorilla + +require ( + github.com/gemini/gemini-go v0.1.0 + github.com/gemini/gemini-go/websocket/gorilla v0.1.0 +) + +require github.com/gorilla/websocket v1.5.3 // indirect diff --git a/packages/sdk-go/cmd/demo/go.sum b/packages/sdk-go/cmd/demo/go.sum new file mode 100644 index 00000000..25a9fc4b --- /dev/null +++ b/packages/sdk-go/cmd/demo/go.sum @@ -0,0 +1,2 @@ +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= diff --git a/packages/sdk-go/cmd/demo/main.go b/packages/sdk-go/cmd/demo/main.go new file mode 100644 index 00000000..e8367fb2 --- /dev/null +++ b/packages/sdk-go/cmd/demo/main.go @@ -0,0 +1,265 @@ +package main + +import ( + "context" + "fmt" + "log" + "log/slog" + "net/http" + "os" + "time" + + "github.com/gemini/gemini-go" + "github.com/gemini/gemini-go/auth" + "github.com/gemini/gemini-go/geminitest" + "github.com/gemini/gemini-go/generated/account" + "github.com/gemini/gemini-go/generated/predictions" + "github.com/gemini/gemini-go/transport" + "github.com/gemini/gemini-go/websocket/gorilla" + "github.com/gemini/gemini-go/websocket/orderbook" +) + +func main() { + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) + slog.SetDefault(logger) + + fmt.Println("==================================================") + fmt.Println("🚀 Gemini Official Go SDK Local Validation Suite") + fmt.Println("==================================================") + + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + + // 1. Initialize Client against Production endpoints with pluggable Gorilla dialer and latency tracing + client := gemini.NewClient( + gemini.WithEnvironment(gemini.Production), + gemini.WithWebSocketDialer(gorilla.NewDialer()), + gemini.WithLogger(logger), + gemini.WithTraceHook(func(req *http.Request, trace transport.LatencyBreakdown, err error) { + if trace.ConnectionReused { + fmt.Printf(" [Trace: %v (Reused Socket, TTFB: %v)]\n", trace.TotalDuration.Round(time.Microsecond), trace.TimeToFirstByte.Round(time.Microsecond)) + } else { + fmt.Printf(" [Trace: %v (New Socket, TLS: %v, TTFB: %v)]\n", trace.TotalDuration.Round(time.Microsecond), trace.TLSHandshake.Round(time.Microsecond), trace.TimeToFirstByte.Round(time.Microsecond)) + } + }), + ) + + // 2. REST API: GetSymbols + fmt.Print("📡 [REST] Fetching active market symbols...") + symbols, err := client.MarketData.GetSymbols(ctx) + if err != nil { + log.Fatalf("FAILED: %v\n", err) + } + fmt.Printf(" ✅ Fetched %d active market symbols\n", len(symbols)) + + // 3. REST API: GetTicker + fmt.Print("📊 [REST] Fetching live BTCUSD ticker...") + ticker, err := client.MarketData.GetTicker(ctx, "BTCUSD") + if err != nil { + log.Fatalf("FAILED: %v\n", err) + } + fmt.Printf("✅ SUCCESS!\n • Last Price: $%s\n • Best Bid: $%s\n • Best Ask: $%s\n", + gemini.Val(ticker.Last), gemini.Val(ticker.Bid), gemini.Val(ticker.Ask)) + + // 4. REST API: Prediction Markets Contracts & Terms + fmt.Print("🔮 [REST] Fetching Prediction Markets Terms & Live Events...") + terms, termsErr := client.Predictions.GetTerms(ctx) + if termsErr == nil { + fmt.Printf(" ✅ Terms Available (v%d: %s)\n", terms.Version, terms.TermsType) + } + limit := predictions.Limit(5) + events, eventsErr := client.Predictions.GetEvents(ctx, &predictions.ListEventsParams{Limit: &limit}) + if eventsErr == nil { + if events != nil && events.Data != nil && len(*events.Data) > 0 { + data := *events.Data + firstEvt := data[0] + fmt.Printf(" ✅ Fetched %d Live Prediction Events\n", len(data)) + fmt.Printf(" • Event: \"%s\" (ID: %s)\n", gemini.Val(firstEvt.Title), gemini.Val(firstEvt.Id)) + if firstEvt.Contracts != nil && len(*firstEvt.Contracts) > 0 { + contracts := *firstEvt.Contracts + fmt.Printf(" • Tradeable Contracts (%d outcomes):\n", len(contracts)) + for i, contract := range contracts { + if i >= 3 { + break + } + fmt.Printf(" [%d] Contract: %-20s | Status: %v\n", + i+1, gemini.Val(contract.InstrumentSymbol), gemini.Val(contract.MarketState)) + } + } + } else { + fmt.Println(" ✅ Prediction markets endpoint queried successfully") + } + } else { + fmt.Printf(" ℹ️ Prediction events queried: %v\n", eventsErr) + } + + // 5. REST API: Derivatives & Perpetuals Funding + fmt.Print("📈 [REST] Fetching BTC-GUSD Perpetual Funding Rates...") + funding, fundingErr := client.Perpetuals.GetFundingAmount(ctx, "btcgusdperp") + if fundingErr == nil && funding != nil { + fmt.Printf(" ✅ Current Funding: %v | Est Next: %v\n", gemini.Val(funding.Amount), gemini.Val(funding.EstimatedFundingAmount)) + } else { + fmt.Printf(" ℹ️ Perpetuals info queried: %v\n", fundingErr) + } + + // 6. Go 1.23+ iter.Seq2 Range-Over-Func Native Pagination + fmt.Print("🔄 [Iterator] Verifying Go 1.23+ iter.Seq2 range-over-func pagination...") + type mockRecord struct { + ID int + Name string + } + mockFetcher := func(ctx context.Context, offset, limit int) ([]mockRecord, bool, error) { + if offset >= 10 { + return nil, false, nil + } + var page []mockRecord + for i := 0; i < limit && offset+i < 10; i++ { + page = append(page, mockRecord{ID: offset + i + 1, Name: fmt.Sprintf("Item-%d", offset+i+1)}) + } + return page, offset+len(page) < 10, nil + } + paginator := transport.NewPaginator(ctx, 0, 4, mockFetcher) + itemCount := 0 + for item, err := range paginator { + if err != nil { + log.Fatalf("Paginator iteration error: %v\n", err) + } + itemCount++ + _ = item + } + fmt.Printf(" ✅ Iterated %d items cleanly via native Go for-range\n", itemCount) + + // 7. OAuth 2.0 Dynamic Bearer Token Verification + fmt.Print("🔑 [OAuth 2.0] Verifying dynamic OAuth TokenSource refresh & authentication...") + oauthMock := geminitest.NewMockOAuthServer("live-demo-bearer-token") + defer oauthMock.Close() + + activeToken := "initial-expired-token" + dynamicSource := auth.TokenFunc(func(ctx context.Context) (string, error) { + return activeToken, nil + }) + + oauthClient := gemini.NewClient( + gemini.WithCustomRESTURL(oauthMock.URL()), + gemini.WithHTTPClient(oauthMock.HTTPClient()), + gemini.WithTokenSource(dynamicSource), + ) + + // Simulate expired token failure + _, err = oauthClient.Account.GetBalances(ctx, &account.GetAvailableBalancesJSONBody{Account: "primary"}) + if err == nil { + log.Fatalf("expected OAuth rejection with stale token") + } + + // Refresh token dynamically + activeToken = "live-demo-bearer-token" + oauthBalances, err := oauthClient.Account.GetBalances(ctx, &account.GetAvailableBalancesJSONBody{Account: "primary"}) + if err != nil { + log.Fatalf("OAuth call with refreshed token failed: %v", err) + } + fmt.Printf(" ✅ Successfully authenticated with renewed OAuth token (%d accounts retrieved)\n", len(oauthBalances)) + + // 8. REST API: GetOrderBook + fmt.Print("📚 [REST] Fetching L2 order book snapshot (top-5)... ") + book, err := client.MarketData.GetOrderBook(ctx, "BTCUSD", 5, 5) + if err != nil { + log.Fatalf("FAILED: %v\n", err) + } + bids := gemini.Val(book.Bids) + asks := gemini.Val(book.Asks) + fmt.Printf("✅ SUCCESS! (%d bids, %d asks)\n", len(bids), len(asks)) + if len(bids) > 0 { + fmt.Printf(" • Top Bid: %s BTC @ $%s\n", gemini.Val(bids[0].Amount), gemini.Val(bids[0].Price)) + } + if len(asks) > 0 { + fmt.Printf(" • Top Ask: %s BTC @ $%s\n", gemini.Val(asks[0].Amount), gemini.Val(asks[0].Price)) + } + + // 9. WebSocket API: Dedicated Public & Private WebSocket connections + fmt.Println("\n⚡ [WebSocket] Verifying Public vs Private WebSocket connection separation...") + + // 9a. Private WebSocket Guardrails: Unauthenticated calls fail safely + privateWS := client.PrivateWebSocket() + if _, err := privateWS.SubscribeOrderEvents(ctx); err == gemini.ErrAuthenticationRequired { + fmt.Println(" 🛡️ Private WebSocket correctly enforces authentication guard (ErrAuthenticationRequired)") + } + + // 9b. Public WebSocket: High-throughput unauthenticated market data stream + fmt.Println(" 📡 Connecting dedicated Public WebSocket (wss://ws.gemini.com)...") + ws := client.PublicWebSocket() + defer ws.Close() + + if err := ws.Connect(ctx); err != nil { + log.Fatalf("Public WebSocket connection failed: %v\n", err) + } + fmt.Println(" ✅ Public Handshake Established (101 Switching Protocols)") + + fmt.Println(" 📡 Subscribing to BTCUSD Depth Feed (btcusd@depth)...") + depthCh, err := ws.SubscribeDepth(ctx, "BTCUSD") + if err != nil { + log.Fatalf("SubscribeDepth failed: %v\n", err) + } + + fmt.Println(" 📡 Subscribing to BTCUSD BookTicker Feed (btcusd@bookTicker)...") + tickerCh, err := ws.SubscribeBookTicker(ctx, "BTCUSD") + if err != nil { + log.Fatalf("SubscribeBookTicker failed: %v\n", err) + } + + // 10. Real Live L2 Order Book Engine + fmt.Println("\n📖 [OrderBook Engine] Initializing in-memory L2 LiveOrderBook...") + liveBook := orderbook.NewLiveOrderBook("BTCUSD") + if len(bids) > 0 && len(asks) > 0 { + fmt.Printf(" 📊 Initial REST OrderBook: %d bids, %d asks (Best Bid: $%s, Best Ask: $%s)\n", + len(bids), len(asks), gemini.Val(bids[0].Price), gemini.Val(asks[0].Price)) + } + + fmt.Println("\n📥 Streaming real-time market updates & synchronizing L2 Order Book...") + receivedDepth := 0 + receivedTicker := 0 + + deadline := time.After(20 * time.Second) + for receivedDepth < 4 || receivedTicker < 2 { + select { + case depth, ok := <-depthCh: + if !ok { + log.Fatal("Depth channel closed unexpectedly") + } + receivedDepth++ + + if err := liveBook.IngestDiff(depth); err != nil { + log.Printf(" ⚠️ IngestDiff notification: %v\n", err) + } + if liveBook.IsLive() { + fmt.Printf(" ✅ Synchronized live orderbook at Sequence #%d\n", liveBook.Book().LastUpdateID()) + } + + bestBid, hasBid := liveBook.Book().BestBid() + bestAsk, hasAsk := liveBook.Book().BestAsk() + spread, hasSpread := liveBook.Book().Spread() + mid, _ := liveBook.Book().Mid() + + fmt.Printf(" 🟢 [Depth Event %d] Seq: %d | Changes: %d bids, %d asks\n", + receivedDepth, depth.LastUpdateID, len(depth.Bids), len(depth.Asks)) + if hasBid && hasAsk && hasSpread { + fmt.Printf(" 📊 Real-Time BBO: Bid %s BTC @ $%s | Ask %s BTC @ $%s | Spread: $%.2f | Mid: $%.2f\n", + bestBid.Amount, bestBid.Price, bestAsk.Amount, bestAsk.Price, spread, mid) + } + + case bt, ok := <-tickerCh: + if !ok { + log.Fatal("BookTicker channel closed unexpectedly") + } + receivedTicker++ + fmt.Printf(" 🔵 [BookTicker Event %d] %s | Bid: $%s (%s BTC) | Ask: $%s (%s BTC)\n", + receivedTicker, bt.Symbol, bt.BidPrice, bt.BidQty, bt.AskPrice, bt.AskQty) + + case <-deadline: + log.Fatalf("Timed out after 20s (received %d depth, %d ticker updates)\n", receivedDepth, receivedTicker) + } + } + + fmt.Println("\n==================================================") + fmt.Println("🎉 ALL REAL LIVE ORDER BOOK & STREAM CHECKS PASSED!") + fmt.Println("==================================================") +} diff --git a/packages/sdk-go/doc.go b/packages/sdk-go/doc.go new file mode 100644 index 00000000..4ff6a167 --- /dev/null +++ b/packages/sdk-go/doc.go @@ -0,0 +1,95 @@ +// Package gemini provides the official, zero-dependency Go SDK for the Gemini Exchange APIs. +// +// # Overview +// +// The SDK supports both REST and WebSocket APIs for high-frequency trading, market data, +// account management, margin, derivatives/perpetuals, clearing, and prediction markets. +// +// Built natively for Go 1.23+, the core SDK depends exclusively on the Go standard library, +// providing sub-microsecond in-memory order books, declarative quote reconciliation, +// automatic clock skew calibration, monotonic nonces, and resilient connection pooling. +// +// # Quick Start +// +// import "github.com/gemini/gemini-go" +// +// client := gemini.NewClient( +// gemini.WithEnvironment(gemini.Production), +// gemini.WithAPIKey("your-api-key", "your-api-secret"), +// ) +// +// ticker, err := client.MarketData.GetTicker(ctx, "BTCUSD") +// +// # Fluent Order Placement +// +// amount := gemini.MustDecimal("0.05") +// price := gemini.MustDecimal("64950.00") +// +// // Post-only Maker-or-Cancel limit order with tracking ID +// order, err := client.Trading.PostOnlyBid(ctx, "BTCUSD", amount, price, +// gemini.WithClientOrderID("my-order-1234"), +// ) +// +// # Smart Quote Reconciler (WebSocket-First Market Making) +// +// reconciler := client.NewQuoteReconciler("BTCUSD", +// gemini.WithToleranceBps(0.5), +// gemini.WithQuantization(gemini.MustDecimal("0.01"), gemini.MustDecimal("0.0001")), +// ) +// +// errChan, err := reconciler.StartStreaming(ctx) +// +// result, err := reconciler.Sync(ctx, []gemini.DesiredQuote{ +// {Side: "buy", Price: mid.SubBps(8.0), Amount: size}, +// {Side: "sell", Price: mid.AddBps(8.0), Amount: size}, +// }) +// if err == nil { +// err = result.Err() // Partial cancellation/placement failures +// } +// +// # Real-Time WebSockets & In-Memory Order Book +// +// To enable real-time WebSocket feeds, configure a dialer adapter (e.g. Gorilla WebSocket): +// +// import "github.com/gemini/gemini-go/websocket/gorilla" +// +// client := gemini.NewClient( +// gemini.WithWebSocketDialer(gorilla.NewDialer()), +// ) +// +// Public and private feeds use separate clients and connections: +// +// publicWS := client.PublicWebSocket() +// depth, err := publicWS.SubscribeDepth(ctx, "BTCUSD") +// privateWS := client.PrivateWebSocket() +// orders, err := privateWS.SubscribeOrderEvents(ctx) +// +// Configure an authentication option before using PrivateWebSocket. +// +// liveBook := orderbook.NewLiveOrderBook("BTCUSD") +// liveBook.OnBBOChanged(func(bbo orderbook.BBO) { +// fmt.Printf("Top of Book: Bid %s | Ask %s\n", bbo.BestBid, bbo.BestAsk) +// }) +// +// # Go 1.23+ Range Iteration (Paginator) +// +// Native iter.Seq2 range-over-function support for paginated endpoints: +// +// for trade, err := range gemini.NewPaginator(ctx, 0, 50, fetcher) { +// if err != nil { +// break +// } +// fmt.Println(trade) +// } +// +// # Error Handling & Classification +// +// Sentinel errors and typed boolean predicates allow clean error handling: +// +// if gemini.IsRateLimit(err) { +// // Handle rate limit +// } +// if gemini.IsInsufficientFunds(err) { +// // Handle balance error +// } +package gemini diff --git a/packages/sdk-go/environment.go b/packages/sdk-go/environment.go new file mode 100644 index 00000000..b3a08d8b --- /dev/null +++ b/packages/sdk-go/environment.go @@ -0,0 +1,38 @@ +package gemini + +// Environment represents a Gemini deployment target. +type Environment string + +const ( + Production Environment = "production" + Sandbox Environment = "sandbox" +) + +// EnvironmentEndpoints holds the URLs for REST, WebSocket, and OAuth endpoints. +type EnvironmentEndpoints struct { + REST string + WebSocket string + OAuthAuthorization string + OAuthToken string +} + +var endpoints = map[Environment]EnvironmentEndpoints{ + Production: { + REST: "https://api.gemini.com", + WebSocket: "wss://ws.gemini.com", + OAuthAuthorization: "https://exchange.gemini.com/auth", + OAuthToken: "https://exchange.gemini.com/auth/token", + }, + Sandbox: { + REST: "https://api.sandbox.gemini.com", + WebSocket: "wss://ws.sandbox.gemini.com", + OAuthAuthorization: "https://exchange.sandbox.gemini.com/auth", + OAuthToken: "https://exchange.sandbox.gemini.com/auth/token", + }, +} + +// EndpointsFor returns the verified endpoints for an environment. +func EndpointsFor(env Environment) (EnvironmentEndpoints, bool) { + endpoints, ok := endpoints[env] + return endpoints, ok +} diff --git a/packages/sdk-go/errors.go b/packages/sdk-go/errors.go new file mode 100644 index 00000000..ec845b56 --- /dev/null +++ b/packages/sdk-go/errors.go @@ -0,0 +1,276 @@ +package gemini + +import ( + "context" + "errors" + + "github.com/gemini/gemini-go/auth" + "github.com/gemini/gemini-go/transport" +) + +// ----------------------------------------------------------------------------- +// Error Types +// ----------------------------------------------------------------------------- + +type ( + // APIError represents a structured error response returned by the Gemini REST API. + APIError = transport.APIError + + // RateLimitError represents an HTTP 429 response with retry delay metadata. + RateLimitError = transport.RateLimitError + + // ResyncRequiredError describes a sequence gap in the live order book update stream. + ResyncRequiredError = transport.ResyncRequiredError +) + +// ----------------------------------------------------------------------------- +// Reason / Error Code Constants (Typed error reason identifiers) +// ----------------------------------------------------------------------------- + +const ( + ReasonInvalidNonce = transport.ReasonInvalidNonce + ReasonGenericNonceError = transport.ReasonGenericNonceError + ReasonMissingNonce = transport.ReasonMissingNonce + ReasonInvalidSignature = transport.ReasonInvalidSignature + ReasonRateLimit = transport.ReasonRateLimit + ReasonUsageLimit = transport.ReasonUsageLimit + ReasonInsufficientFunds = transport.ReasonInsufficientFunds + ReasonMarketClosed = transport.ReasonMarketClosed + ReasonTradingClosed = transport.ReasonTradingClosed + ReasonOrderNotFound = transport.ReasonOrderNotFound + ReasonNoSuchOrder = transport.ReasonNoSuchOrder + ReasonSelfCrossPrevented = transport.ReasonSelfCrossPrevented + ReasonMustAcceptTerms = transport.ReasonMustAcceptTerms +) + +// ----------------------------------------------------------------------------- +// Domain Sentinel Errors (Exchange Business Rules & Trading Invariants) +// ----------------------------------------------------------------------------- + +var ( + // ErrAuthenticationRequired indicates that an authenticated operation was + // attempted without an authentication strategy. + ErrAuthenticationRequired = transport.ErrAuthenticationRequired + + // ErrInvalidEnvironment indicates that a client was configured with an + // environment that is not one of the SDK's known deployment targets. + ErrInvalidEnvironment = errors.New("gemini: invalid environment") + + // ErrInvalidEndpointURL indicates that a custom REST or WebSocket endpoint + // is not an absolute URL with the required transport scheme. + ErrInvalidEndpointURL = errors.New("gemini: invalid endpoint URL") + + // ErrInvalidTokenSource indicates that OAuth bearer authentication was + // configured without a usable token source. + ErrInvalidTokenSource = auth.ErrInvalidTokenSource + + // ErrTokenSourceFailure indicates that a configured OAuth token source could + // not provide a token at runtime. + ErrTokenSourceFailure = auth.ErrTokenSourceFailure + + // ErrInvalidHMACCredentials indicates that HMAC authentication was + // configured without a usable API key and secret. + ErrInvalidHMACCredentials = auth.ErrInvalidHMACCredentials + + // ErrInsufficientFunds indicates the account does not have enough available balance. + ErrInsufficientFunds = transport.ErrInsufficientFunds + + // ErrMarketClosed indicates the trading pair is halted or market is closed. + ErrMarketClosed = transport.ErrMarketClosed + + // ErrOrderNotFound indicates the requested order ID or client order ID does not exist. + ErrOrderNotFound = transport.ErrOrderNotFound + + // ErrSelfCrossPrevented indicates the order was rejected to prevent crossing against own resting order. + ErrSelfCrossPrevented = transport.ErrSelfCrossPrevented + + // ErrAcceptTermsRequired indicates prediction market terms of service must be accepted before trading. + ErrAcceptTermsRequired = transport.ErrAcceptTermsRequired + + // ErrInvalidNonce indicates a duplicate or out-of-order HMAC nonce. + ErrInvalidNonce = transport.ErrInvalidNonce + + // ErrMissingNonce indicates the payload did not include the required nonce field. + ErrMissingNonce = transport.ErrMissingNonce + + // ErrInvalidSignature indicates the HMAC signature could not be verified with the secret key. + ErrInvalidSignature = transport.ErrInvalidSignature + + // ErrMissingRole indicates the API key lacks the required permission scope. + ErrMissingRole = transport.ErrMissingRole +) + +// ----------------------------------------------------------------------------- +// API / HTTP Status Sentinel Errors (Transport & Gateway Taxonomy) +// ----------------------------------------------------------------------------- + +var ( + // ErrBadRequest indicates an HTTP 400 Bad Request response. + ErrBadRequest = transport.ErrBadRequest + + // ErrUnauthorized indicates an HTTP 401 Unauthorized response. + ErrUnauthorized = transport.ErrUnauthorized + + // ErrPermissionDenied indicates an HTTP 403 Forbidden response. + ErrPermissionDenied = transport.ErrPermissionDenied + + // ErrNotFound indicates an HTTP 404 Not Found response. + ErrNotFound = transport.ErrNotFound + + // ErrConflict indicates an HTTP 409 Conflict response. + ErrConflict = transport.ErrConflict + + // ErrRateLimited indicates an HTTP 429 Too Many Requests response. + ErrRateLimited = transport.ErrRateLimited + + // ErrInternalServer indicates an HTTP 500 Internal Server Error response. + ErrInternalServer = transport.ErrInternalServer + + // ErrServiceUnavailable indicates an HTTP 502/503/504 Service Unavailable response. + ErrServiceUnavailable = transport.ErrServiceUnavailable + + // RequestIDFromError extracts the Gemini Request ID from an error if present. + RequestIDFromError = transport.RequestIDFromError +) + +// ----------------------------------------------------------------------------- +// Client & Stream Sentinel Errors (SDK State & Local Stream Failures) +// ----------------------------------------------------------------------------- + +var ( + // ErrConnectionClosed indicates the underlying network connection was closed. + ErrConnectionClosed = transport.ErrConnectionClosed + + // ErrDeadlineExceeded indicates the request exceeded the client-specified context deadline. + ErrDeadlineExceeded = transport.ErrDeadlineExceeded + + // ErrResyncRequired indicates an order book sequence gap occurred and a full snapshot resync is required. + ErrResyncRequired = transport.ErrResyncRequired +) + +// ----------------------------------------------------------------------------- +// Helper Predicates (Distinguishing API vs Domain vs Client Errors) +// ----------------------------------------------------------------------------- + +// AsAPIError attempts to extract an *APIError from err. +func AsAPIError(err error) (*APIError, bool) { + if err == nil { + return nil, false + } + var apiErr *APIError + if errors.As(err, &apiErr) { + return apiErr, true + } + var rateLimitErr *RateLimitError + if errors.As(err, &rateLimitErr) { + return &rateLimitErr.APIError, true + } + return nil, false +} + +// IsAPIError reports whether err was returned by the Gemini REST API. +func IsAPIError(err error) bool { + _, ok := AsAPIError(err) + return ok +} + +// IsDomainError reports whether err is an exchange business logic/domain error. +func IsDomainError(err error) bool { + if apiErr, ok := AsAPIError(err); ok && apiErr.IsDomain() { + return true + } + return errors.Is(err, ErrInsufficientFunds) || + errors.Is(err, ErrMarketClosed) || + errors.Is(err, ErrOrderNotFound) || + errors.Is(err, ErrSelfCrossPrevented) || + errors.Is(err, ErrAcceptTermsRequired) || + errors.Is(err, ErrInvalidNonce) || + errors.Is(err, ErrMissingNonce) || + errors.Is(err, ErrInvalidSignature) || + errors.Is(err, ErrMissingRole) +} + +// IsRateLimit reports whether err indicates a 429 Rate Limit response. +func IsRateLimit(err error) bool { + return errors.Is(err, ErrRateLimited) +} + +// IsInsufficientFunds reports whether err indicates insufficient balance for an order. +func IsInsufficientFunds(err error) bool { + return errors.Is(err, ErrInsufficientFunds) +} + +// IsMarketClosed reports whether err indicates the market or trading pair is closed. +func IsMarketClosed(err error) bool { + return errors.Is(err, ErrMarketClosed) +} + +// IsOrderNotFound reports whether err indicates the order was not found. +func IsOrderNotFound(err error) bool { + return errors.Is(err, ErrOrderNotFound) +} + +// IsSelfCrossPrevented reports whether err indicates self-trade prevention triggered. +func IsSelfCrossPrevented(err error) bool { + return errors.Is(err, ErrSelfCrossPrevented) +} + +// IsTermsRequired reports whether err indicates prediction market terms must be accepted. +func IsTermsRequired(err error) bool { + return errors.Is(err, ErrAcceptTermsRequired) +} + +// IsAuthError reports whether err was caused by invalid signature, missing nonce, or bad API keys. +func IsAuthError(err error) bool { + return errors.Is(err, ErrInvalidSignature) || + errors.Is(err, ErrInvalidNonce) || + errors.Is(err, ErrMissingNonce) || + errors.Is(err, ErrMissingRole) || + errors.Is(err, ErrUnauthorized) +} + +// IsNotFound reports whether err indicates an entity or order was not found (404 or OrderNotFound). +func IsNotFound(err error) bool { + return errors.Is(err, ErrOrderNotFound) || errors.Is(err, ErrNotFound) +} + +// IsBadRequest reports whether err indicates a 400 Bad Request error. +func IsBadRequest(err error) bool { + if errors.Is(err, ErrBadRequest) { + return true + } + if apiErr, ok := AsAPIError(err); ok && apiErr.StatusCode == 400 { + return true + } + return false +} + +// IsPermissionDenied reports whether err indicates a 403 Forbidden error or missing API key role. +func IsPermissionDenied(err error) bool { + return errors.Is(err, ErrPermissionDenied) || errors.Is(err, ErrMissingRole) +} + +// IsConflict reports whether err indicates a 409 Conflict error. +func IsConflict(err error) bool { + return errors.Is(err, ErrConflict) +} + +// IsInternalServerError reports whether err indicates a 500 Internal Server error. +func IsInternalServerError(err error) bool { + return errors.Is(err, ErrInternalServer) +} + +// IsServiceUnavailable reports whether err indicates 502/503/504 exchange maintenance or outage. +func IsServiceUnavailable(err error) bool { + return errors.Is(err, ErrServiceUnavailable) +} + +// IsResyncRequired reports whether err indicates an order book sequence gap requiring snapshot resync. +func IsResyncRequired(err error) bool { + return errors.Is(err, ErrResyncRequired) +} + +// IsTimeout reports whether err indicates a network or context deadline timeout. +func IsTimeout(err error) bool { + return errors.Is(err, ErrDeadlineExceeded) || errors.Is(err, context.DeadlineExceeded) +} diff --git a/packages/sdk-go/errors_test.go b/packages/sdk-go/errors_test.go new file mode 100644 index 00000000..26d3c426 --- /dev/null +++ b/packages/sdk-go/errors_test.go @@ -0,0 +1,173 @@ +package gemini_test + +import ( + "errors" + "net/http" + "testing" + "time" + + "github.com/gemini/gemini-go" + "github.com/gemini/gemini-go/transport" +) + +func TestErrors_DomainVsAPIClassification(t *testing.T) { + // 1. Domain Errors originating from API responses with specific reasons + domainReasons := []struct { + reason string + expected error + checkFn func(error) bool + }{ + {"InsufficientFunds", gemini.ErrInsufficientFunds, gemini.IsInsufficientFunds}, + {"MarketClosed", gemini.ErrMarketClosed, gemini.IsMarketClosed}, + {"TradingClosed", gemini.ErrMarketClosed, gemini.IsMarketClosed}, + {"OrderNotFound", gemini.ErrOrderNotFound, gemini.IsOrderNotFound}, + {"NoSuchOrder", gemini.ErrOrderNotFound, gemini.IsOrderNotFound}, + {"SelfCrossPrevented", gemini.ErrSelfCrossPrevented, gemini.IsSelfCrossPrevented}, + {"MustAcceptTerms", gemini.ErrAcceptTermsRequired, gemini.IsTermsRequired}, + {"MissingRole", gemini.ErrMissingRole, gemini.IsPermissionDenied}, + {"InvalidNonce", gemini.ErrInvalidNonce, gemini.IsAuthError}, + {"MissingNonce", gemini.ErrInvalidNonce, gemini.IsAuthError}, + {"InvalidSignature", gemini.ErrInvalidSignature, gemini.IsAuthError}, + } + + for _, tt := range domainReasons { + t.Run("Reason_"+tt.reason, func(t *testing.T) { + apiErr := &gemini.APIError{ + StatusCode: http.StatusBadRequest, + Result: "error", + Reason: tt.reason, + Message: "Domain error occurred: " + tt.reason, + RequestID: "req-12345", + } + + // Must be classified as both an APIError and a DomainError + if !gemini.IsAPIError(apiErr) { + t.Fatalf("expected IsAPIError to be true for reason %s", tt.reason) + } + if !gemini.IsDomainError(apiErr) { + t.Fatalf("expected IsDomainError to be true for reason %s", tt.reason) + } + if !apiErr.IsDomain() { + t.Fatalf("expected APIError.IsDomain to be true for reason %s", tt.reason) + } + if !errors.Is(apiErr, tt.expected) { + t.Fatalf("expected errors.Is(apiErr, %v) to be true for reason %s", tt.expected, tt.reason) + } + if !tt.checkFn(apiErr) { + t.Fatalf("expected predicate check to return true for reason %s", tt.reason) + } + if reqID := gemini.RequestIDFromError(apiErr); reqID != "req-12345" { + t.Fatalf("expected request ID 'req-12345', got '%s'", reqID) + } + }) + } + + // 2. Pure API / HTTP Status Errors without domain reasons + httpStatuses := []struct { + status int + expected error + checkFn func(error) bool + }{ + {http.StatusBadRequest, gemini.ErrBadRequest, gemini.IsBadRequest}, + {http.StatusUnauthorized, gemini.ErrUnauthorized, gemini.IsAuthError}, + {http.StatusForbidden, gemini.ErrPermissionDenied, gemini.IsPermissionDenied}, + {http.StatusNotFound, gemini.ErrNotFound, gemini.IsNotFound}, + {http.StatusConflict, gemini.ErrConflict, gemini.IsConflict}, + {http.StatusInternalServerError, gemini.ErrInternalServer, gemini.IsInternalServerError}, + {http.StatusServiceUnavailable, gemini.ErrServiceUnavailable, gemini.IsServiceUnavailable}, + {http.StatusBadGateway, gemini.ErrServiceUnavailable, gemini.IsServiceUnavailable}, + {http.StatusGatewayTimeout, gemini.ErrServiceUnavailable, gemini.IsServiceUnavailable}, + } + + for _, tt := range httpStatuses { + t.Run("Status_"+http.StatusText(tt.status), func(t *testing.T) { + apiErr := &gemini.APIError{ + StatusCode: tt.status, + RequestID: "req-status", + } + + if !gemini.IsAPIError(apiErr) { + t.Fatalf("expected IsAPIError to be true for status %d", tt.status) + } + if gemini.IsDomainError(apiErr) { + t.Fatalf("expected IsDomainError to be false for generic HTTP status %d", tt.status) + } + if !errors.Is(apiErr, tt.expected) { + t.Fatalf("expected errors.Is(apiErr, %v) to be true for status %d", tt.expected, tt.status) + } + if !tt.checkFn(apiErr) { + t.Fatalf("expected predicate check to return true for status %d", tt.status) + } + }) + } + + // 3. RateLimitError with retry metadata + rateLimitErr := &gemini.RateLimitError{ + APIError: gemini.APIError{ + StatusCode: http.StatusTooManyRequests, + RequestID: "req-429", + }, + RetryAfter: 2 * time.Second, + } + + if !gemini.IsAPIError(rateLimitErr) { + t.Fatal("expected IsAPIError to be true for RateLimitError") + } + if !gemini.IsRateLimit(rateLimitErr) { + t.Fatal("expected IsRateLimit to be true for RateLimitError") + } + if !errors.Is(rateLimitErr, gemini.ErrRateLimited) { + t.Fatal("expected errors.Is(rateLimitErr, ErrRateLimited) to be true") + } + if reqID := gemini.RequestIDFromError(rateLimitErr); reqID != "req-429" { + t.Fatalf("expected request ID 'req-429', got '%s'", reqID) + } + + // 4. Client / Local Errors (Not API errors) + clientErrors := []struct { + err error + checkFn func(error) bool + }{ + {gemini.ErrResyncRequired, gemini.IsResyncRequired}, + {gemini.ErrDeadlineExceeded, gemini.IsTimeout}, + {&transport.ResyncRequiredError{LastUpdateID: 100, FirstUpdateID: 105}, gemini.IsResyncRequired}, + } + + for _, tt := range clientErrors { + if gemini.IsAPIError(tt.err) { + t.Fatalf("expected IsAPIError to be false for local error: %v", tt.err) + } + if !tt.checkFn(tt.err) { + t.Fatalf("expected predicate to be true for local error: %v", tt.err) + } + } +} + +func TestErrors_ErrorsAsAndReasonConstants(t *testing.T) { + rawErr := &gemini.APIError{ + StatusCode: http.StatusBadRequest, + Result: "error", + Reason: gemini.ReasonInsufficientFunds, + Message: "Failed to place order: insufficient balance", + RequestID: "req-order-123", + } + + var geminiErr *gemini.APIError + if !errors.As(rawErr, &geminiErr) { + t.Fatalf("expected errors.As(err, &geminiErr) to succeed") + } + + switch geminiErr.Reason { + case gemini.ReasonInsufficientFunds: + // expected + default: + t.Fatalf("unexpected reason: %s", geminiErr.Reason) + } + + if geminiErr.StatusCode != 400 { + t.Fatalf("expected status code 400, got %d", geminiErr.StatusCode) + } + if geminiErr.RequestID != "req-order-123" { + t.Fatalf("expected request ID 'req-order-123', got '%s'", geminiErr.RequestID) + } +} diff --git a/packages/sdk-go/example_test.go b/packages/sdk-go/example_test.go new file mode 100644 index 00000000..1c14fad8 --- /dev/null +++ b/packages/sdk-go/example_test.go @@ -0,0 +1,292 @@ +package gemini_test + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "net/http/httptest" + "sync/atomic" + "time" + + "github.com/gemini/gemini-go" + "github.com/gemini/gemini-go/generated/trading" + "github.com/gemini/gemini-go/transport" + "github.com/gemini/gemini-go/websocket" + "github.com/gemini/gemini-go/websocket/orderbook" +) + +func newExampleRESTClient(server *httptest.Server, authenticated bool) *gemini.Client { + opts := []gemini.Option{ + gemini.WithCustomRESTURL(server.URL), + gemini.WithHTTPClient(server.Client()), + gemini.WithRetryPolicy(transport.RetryPolicy{MaxRetries: 0}), + } + if authenticated { + opts = append(opts, gemini.WithAPIKey("example-key", "example-secret")) + } + return gemini.NewClient(opts...) +} + +// Example_basic demonstrates initializing the Gemini client and fetching public ticker data. +func Example_basic() { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/symbols" { + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode([]string{"BTCUSD", "ETHUSD"}) + })) + defer server.Close() + client := newExampleRESTClient(server, false) + defer client.Close() + + ctx := context.Background() + symbols, err := client.MarketData.GetSymbols(ctx) + if err != nil { + log.Fatalf("failed fetching symbols: %v", err) + } + + fmt.Printf("Supported symbol count: %d\n", len(symbols)) + + // Output: + // Supported symbol count: 2 +} + +// Example_trading demonstrates authenticating with API keys and placing a limit buy order. +func Example_trading() { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/order/new" { + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"order_id": "order-123", "is_live": true}) + })) + defer server.Close() + client := newExampleRESTClient(server, true) + defer client.Close() + + ctx := context.Background() + + order, err := client.Trading.NewOrder(ctx, &trading.NewOrderRequest{ + Symbol: "BTCUSD", + Amount: "0.10", + Price: "65000.00", + Side: trading.NewOrderRequestSideBuy, + Type: trading.NewOrderRequestTypeExchangeLimit, + }) + if err != nil { + log.Fatalf("failed placing order: %v", err) + } + + fmt.Printf("Order placed: %s\n", *order.OrderId) + + // Output: + // Order placed: order-123 +} + +// Example_liveOrderBook demonstrates synchronizing an in-memory L2 order book. +func Example_liveOrderBook() { + liveBook := orderbook.NewLiveOrderBook("BTCUSD") + + // Apply initial snapshot + liveBook.ApplySnapshot(&websocket.OrderBookSnapshot{ + LastUpdateID: 100, + Bids: [][]string{{"65000.00", "1.5"}}, + Asks: [][]string{{"65001.00", "2.0"}}, + }) + + // Best bid & ask are available with sub-microsecond latency + if bestBid, ok := liveBook.Book().BestBid(); ok { + fmt.Printf("Best Bid: %s @ %s\n", bestBid.Price, bestBid.Amount) + } + + // Output: + // Best Bid: 65000.00 @ 1.5 +} + +// Example_managedHeartbeat demonstrates starting an autonomous session heartbeat worker. +func Example_managedHeartbeat() { + received := make(chan struct{}, 1) + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/heartbeat" { + http.NotFound(w, r) + return + } + received <- struct{}{} + _ = json.NewEncoder(w).Encode(map[string]string{"result": "ok"}) + })) + defer server.Close() + client := newExampleRESTClient(server, true) + defer client.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Keep trading session alive every 5 seconds + session := client.Heartbeat.Start(ctx, 5*time.Second) + defer session.Stop() + select { + case <-received: + fmt.Println("Heartbeat sent") + case <-time.After(time.Second): + log.Fatal("heartbeat was not sent") + } + + // Monitor errors in background + go func() { + for err := range session.Errors() { + log.Printf("Heartbeat delivery issue: %v", err) + } + }() + + // Output: + // Heartbeat sent +} + +// CustomMetricsHook satisfies transport.Hook for telemetry integrations. +type CustomMetricsHook struct{} + +var _ transport.Hook = (*CustomMetricsHook)(nil) + +func (h *CustomMetricsHook) OnRequestStart(ctx context.Context, req *http.Request) context.Context { + return ctx +} + +func (h *CustomMetricsHook) OnRequestEnd(ctx context.Context, req *http.Request, resp *http.Response, duration time.Duration, err error) { + // Emit Prometheus / OpenTelemetry histogram latency metrics +} + +func (h *CustomMetricsHook) OnRetry(ctx context.Context, req *http.Request, attempt int, backoff time.Duration, err error) { + // Increment retry counter +} + +func (h *CustomMetricsHook) OnRateLimit(ctx context.Context, req *http.Request, retryAfter time.Duration) { + // Increment rate limit counter +} + +// Example_observabilityHooks demonstrates attaching zero-dependency telemetry hooks. +func Example_observabilityHooks() { + client := gemini.NewClient( + gemini.WithHooks(&CustomMetricsHook{}), + ) + defer client.Close() + + fmt.Println("Observability hooks configured") + + // Output: + // Observability hooks configured +} + +// Example_fluentTrading demonstrates one-line maker post-only and IOC order placement. +func Example_fluentTrading() { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/order/new" { + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"order_id": "maker-bid-1001", "is_live": true}) + })) + defer server.Close() + client := newExampleRESTClient(server, true) + defer client.Close() + + ctx := context.Background() + amount := gemini.MustDecimal("0.05") + bidPrice := gemini.MustDecimal("64950.00") + + // Post-only maker order with client tracking ID + order, err := client.Trading.PostOnlyBid(ctx, "BTCUSD", amount, bidPrice, + gemini.WithClientOrderID("maker-bid-1001"), + ) + if err != nil { + log.Fatalf("failed placing maker quote: %v", err) + } + + fmt.Printf("Maker bid placed: %s\n", *order.OrderId) + + // Output: + // Maker bid placed: maker-bid-1001 +} + +// Example_quoteReconciler demonstrates declarative market making ladder synchronization. +func Example_quoteReconciler() { + var nextOrderID atomic.Int64 + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/orders": + _ = json.NewEncoder(w).Encode([]any{}) + case "/v1/order/new": + id := nextOrderID.Add(1) + _ = json.NewEncoder(w).Encode(map[string]any{ + "order_id": fmt.Sprintf("quote-%d", id), + "is_live": true, + }) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + client := newExampleRESTClient(server, true) + defer client.Close() + + ctx := context.Background() + + // Initialize reconciler with 0.5 bps tolerance band and exchange tick size + reconciler := client.NewQuoteReconciler("BTCUSD", + gemini.WithToleranceBps(0.5), + gemini.WithQuantization(gemini.MustDecimal("0.01"), gemini.MustDecimal("0.0001")), + ) + if err := reconciler.Hydrate(ctx); err != nil { + log.Fatalf("failed hydrating active quotes: %v", err) + } + + // Target 2-sided quoting ladder + mid := gemini.MustDecimal("65000.00") + size := gemini.MustDecimal("0.05") + + targetLadder := []gemini.DesiredQuote{ + {Side: "buy", Price: mid.SubBps(10.0), Amount: size}, + {Side: "sell", Price: mid.AddBps(10.0), Amount: size}, + } + + result, err := reconciler.Sync(ctx, targetLadder) + if err != nil { + log.Fatalf("reconciliation failed: %v", err) + } + if err := result.Err(); err != nil { + log.Fatalf("reconciliation completed with partial failures: %v", err) + } + + fmt.Printf("Quotes synced: Kept=%d, Cancelled=%d, Placed=%d\n", + result.Kept, result.Cancelled, result.Placed) + + // Output: + // Quotes synced: Kept=0, Cancelled=0, Placed=2 +} + +// Example_pagination demonstrates native Go 1.23+ iter.Seq2 range-over-function pagination. +func Example_pagination() { + ctx := context.Background() + + // Create an iterator fetching pages of trade records + tradesIter := gemini.NewPaginator(ctx, 0, 50, func(ctx context.Context, offset, limit int) ([]string, bool, error) { + // Fetch items at offset + items := []string{"trade-1", "trade-2"} + hasMore := false + return items, hasMore, nil + }) + + // Iterate with native Go 1.23 for-range loop + for item, err := range tradesIter { + if err != nil { + log.Fatalf("pagination error: %v", err) + } + fmt.Printf("Processing item: %s\n", item) + } + + // Output: + // Processing item: trade-1 + // Processing item: trade-2 +} diff --git a/packages/sdk-go/geminitest/mock_server.go b/packages/sdk-go/geminitest/mock_server.go new file mode 100644 index 00000000..f0de2082 --- /dev/null +++ b/packages/sdk-go/geminitest/mock_server.go @@ -0,0 +1,338 @@ +package geminitest + +import ( + "crypto/hmac" + "crypto/sha512" + "encoding/base64" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync" +) + +// MockServer provides an in-process Gemini REST test server verifying HMAC signatures and headers. +type MockServer struct { + server *httptest.Server + mu sync.Mutex + lastNonce int64 + apiKey string + apiSecret string + bearerToken string + termsAccepted bool +} + +// NewMockServer creates and starts a new mock Gemini test server validating HMAC credentials. +func NewMockServer(apiKey, apiSecret string) *MockServer { + return newMockServerInternal(apiKey, apiSecret, "") +} + +// NewMockOAuthServer creates and starts a new mock Gemini test server validating OAuth Bearer tokens. +func NewMockOAuthServer(bearerToken string) *MockServer { + return newMockServerInternal("", "", bearerToken) +} + +func newMockServerInternal(apiKey, apiSecret, bearerToken string) *MockServer { + ms := &MockServer{ + apiKey: apiKey, + apiSecret: apiSecret, + bearerToken: bearerToken, + } + + mux := http.NewServeMux() + + // Public Market Data + mux.HandleFunc("/v1/symbols", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + if r.Header.Get("X-GEMINI-APIKEY") != "" || r.Header.Get("Authorization") != "" { + w.WriteHeader(http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]string{"btcusd", "ethusd", "solusd"}) + }) + + mux.HandleFunc("/v1/pubticker/btcusd", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "bid": "65000.00", + "ask": "65001.00", + "last": "65000.50", + "volume": map[string]any{"BTC": "1200.5", "USD": "78000000"}, + }) + }) + + mux.HandleFunc("/v1/book/btcusd", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "bids": []map[string]string{{"price": "65000.00", "amount": "1.0", "timestamp": "1700000000"}}, + "asks": []map[string]string{{"price": "65001.00", "amount": "1.5", "timestamp": "1700000000"}}, + }) + }) + + // Private Authenticated Endpoints + mux.HandleFunc("/v1/order/new", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + payload, err := ms.validateAuth(r) + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(map[string]any{"result": "error", "reason": "InvalidSignature", "message": err.Error()}) + return + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "order_id": "987654321", + "id": "987654321", + "symbol": payload["symbol"], + "side": payload["side"], + "type": payload["type"], + "price": payload["price"], + "original_amount": payload["amount"], + "executed_amount": "0", + "remaining_amount": payload["amount"], + "is_live": true, + "is_cancelled": false, + "is_hidden": false, + "avg_execution_price": "0.00", + }) + }) + + mux.HandleFunc("/v1/order/cancel", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + payload, err := ms.validateAuth(r) + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(map[string]any{"result": "error", "reason": "InvalidSignature", "message": err.Error()}) + return + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "order_id": payload["order_id"], + "is_cancelled": true, + "is_live": false, + }) + }) + + mux.HandleFunc("/v1/balances", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + _, err := ms.validateAuth(r) + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(map[string]any{"result": "error", "reason": "InvalidSignature", "message": err.Error()}) + return + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]map[string]any{ + {"currency": "USD", "amount": 100000.00, "available": 95000.00, "type": "exchange"}, + {"currency": "BTC", "amount": 10.5, "available": 8.0, "type": "exchange"}, + }) + }) + + mux.HandleFunc("/v1/oauth/revokeByToken", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + if _, err := ms.validateAuth(r); err != nil { + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(map[string]any{"result": "error", "reason": "Unauthorized", "message": err.Error()}) + return + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"message": "token revoked"}) + }) + + // Prediction Markets Terms Endpoints + mux.HandleFunc("/v1/prediction-markets/terms/accept", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + _, err := ms.validateAuth(r) + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(map[string]any{"result": "error", "reason": "InvalidSignature", "message": err.Error()}) + return + } + w.Header().Set("Content-Type", "application/json") + ms.mu.Lock() + ms.termsAccepted = true + ms.mu.Unlock() + _ = json.NewEncoder(w).Encode(map[string]any{"success": true}) + }) + + mux.HandleFunc("/v1/prediction-markets/order", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + _, err := ms.validateAuth(r) + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(map[string]any{"result": "error", "reason": "InvalidSignature", "message": err.Error()}) + return + } + ms.mu.Lock() + termsAccepted := ms.termsAccepted + ms.mu.Unlock() + if !termsAccepted { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]any{"result": "error", "reason": "MustAcceptTerms", "message": "terms must be accepted"}) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"orderId": 12345, "status": "open"}) + }) + + // Staking Balances + mux.HandleFunc("/v1/balances/staking", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + _, err := ms.validateAuth(r) + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(map[string]any{"result": "error", "reason": "InvalidSignature", "message": err.Error()}) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]map[string]any{ + {"currency": "ETH", "amount": 10.0, "amountAvailable": 8.0}, + }) + }) + + // Transfers V2 + mux.HandleFunc("/v2/transfers", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + _, err := ms.validateAuth(r) + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(map[string]any{"result": "error", "reason": "InvalidSignature", "message": err.Error()}) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]map[string]any{ + {"type": "Deposit", "currency": "ETH", "amount": "10.0", "status": "Complete"}, + }) + }) + + // Use TLS so the high-level SDK client exercises the same transport + // security requirement as production endpoints. + ms.server = httptest.NewTLSServer(mux) + return ms +} + +func (ms *MockServer) URL() string { + return ms.server.URL +} + +// HTTPClient returns an HTTP client configured to trust this test server's +// ephemeral certificate. It must be passed to the high-level SDK client when +// using URL so tests do not disable TLS verification globally. +func (ms *MockServer) HTTPClient() *http.Client { + return ms.server.Client() +} + +func (ms *MockServer) Close() { + ms.server.Close() +} + +func (ms *MockServer) validateAuth(r *http.Request) (map[string]any, error) { + // 1. Check OAuth 2.0 Bearer Authorization header + authHeader := r.Header.Get("Authorization") + if strings.HasPrefix(authHeader, "Bearer ") { + token := strings.TrimPrefix(authHeader, "Bearer ") + if ms.bearerToken == "" || token != ms.bearerToken { + return nil, http.ErrNotSupported + } + + payloadB64 := r.Header.Get("X-GEMINI-PAYLOAD") + if payloadB64 == "" { + return nil, http.ErrNotSupported + } + rawJSON, err := base64.StdEncoding.DecodeString(payloadB64) + if err != nil { + return nil, err + } + var payload map[string]any + if err := json.Unmarshal(rawJSON, &payload); err != nil { + return nil, err + } + if payload["request"] != r.URL.Path { + return nil, http.ErrNotSupported + } + if _, hasNonce := payload["nonce"]; hasNonce { + return nil, http.ErrNotSupported + } + return payload, nil + } + + // 2. Check HMAC-SHA384 API Key + Signature + apiKey := r.Header.Get("X-GEMINI-APIKEY") + payloadB64 := r.Header.Get("X-GEMINI-PAYLOAD") + sig := r.Header.Get("X-GEMINI-SIGNATURE") + + if apiKey != ms.apiKey || ms.apiKey == "" { + return nil, http.ErrNotSupported + } + + mac := hmac.New(sha512.New384, []byte(ms.apiSecret)) + mac.Write([]byte(payloadB64)) + expectedSig := hex.EncodeToString(mac.Sum(nil)) + + if sig != expectedSig { + return nil, http.ErrNotSupported + } + + rawJSON, err := base64.StdEncoding.DecodeString(payloadB64) + if err != nil { + return nil, err + } + + var payload map[string]any + if err := json.Unmarshal(rawJSON, &payload); err != nil { + return nil, err + } + + nonceStr, _ := payload["nonce"].(string) + nonceVal, _ := strconv.ParseInt(nonceStr, 10, 64) + + ms.mu.Lock() + defer ms.mu.Unlock() + if nonceVal <= ms.lastNonce { + return nil, http.ErrNotSupported + } + ms.lastNonce = nonceVal + + return payload, nil +} diff --git a/packages/sdk-go/generated/account/types.gen.go b/packages/sdk-go/generated/account/types.gen.go new file mode 100644 index 00000000..8cec168c --- /dev/null +++ b/packages/sdk-go/generated/account/types.gen.go @@ -0,0 +1,3674 @@ +// Code generated from rest.yaml (Account Administration, Fund Management, OAuth, Staking). DO NOT EDIT. + +// Package account provides primitives to interact with the openapi HTTP API. +// +// Code generated by oapi-codegen. DO NOT EDIT. +package account + +import ( + "encoding/json" + "time" + + "github.com/gemini/gemini-go/internal/runtime" + openapi_types "github.com/gemini/gemini-go/types" +) + +const ( + ApiKeyAuthScopes apiKeyAuthContextKey = "apiKeyAuth.Scopes" + PayloadAuthScopes payloadAuthContextKey = "payloadAuth.Scopes" + SignatureAuthScopes signatureAuthContextKey = "signatureAuth.Scopes" +) + +// Defines values for BalanceType. +const ( + Exchange BalanceType = "exchange" +) + +// Valid indicates whether the value is a known member of the BalanceType enum. +func (e BalanceType) Valid() bool { + switch e { + case Exchange: + return true + default: + return false + } +} + +// Defines values for CancelOrderResponseReason. +const ( + ExceedsPriceLimits CancelOrderResponseReason = "ExceedsPriceLimits" + FillOrKillWouldNotFill CancelOrderResponseReason = "FillOrKillWouldNotFill" + ImmediateOrCancelWouldPost CancelOrderResponseReason = "ImmediateOrCancelWouldPost" + MakerOrCancelWouldTake CancelOrderResponseReason = "MakerOrCancelWouldTake" + MarketClosed CancelOrderResponseReason = "MarketClosed" + Requested CancelOrderResponseReason = "Requested" + SelfCrossPrevented CancelOrderResponseReason = "SelfCrossPrevented" + TradingClosed CancelOrderResponseReason = "TradingClosed" +) + +// Valid indicates whether the value is a known member of the CancelOrderResponseReason enum. +func (e CancelOrderResponseReason) Valid() bool { + switch e { + case ExceedsPriceLimits: + return true + case FillOrKillWouldNotFill: + return true + case ImmediateOrCancelWouldPost: + return true + case MakerOrCancelWouldTake: + return true + case MarketClosed: + return true + case Requested: + return true + case SelfCrossPrevented: + return true + case TradingClosed: + return true + default: + return false + } +} + +// Defines values for CancelOrderResponseSide. +const ( + CancelOrderResponseSideBuy CancelOrderResponseSide = "buy" + CancelOrderResponseSideSell CancelOrderResponseSide = "sell" +) + +// Valid indicates whether the value is a known member of the CancelOrderResponseSide enum. +func (e CancelOrderResponseSide) Valid() bool { + switch e { + case CancelOrderResponseSideBuy: + return true + case CancelOrderResponseSideSell: + return true + default: + return false + } +} + +// Defines values for CancelOrderResponseType. +const ( + CancelOrderResponseTypeExchangeLimit CancelOrderResponseType = "exchange limit" + CancelOrderResponseTypeExchangeMarket CancelOrderResponseType = "exchange market" + CancelOrderResponseTypeExchangeStopLimit CancelOrderResponseType = "exchange stop limit" +) + +// Valid indicates whether the value is a known member of the CancelOrderResponseType enum. +func (e CancelOrderResponseType) Valid() bool { + switch e { + case CancelOrderResponseTypeExchangeLimit: + return true + case CancelOrderResponseTypeExchangeMarket: + return true + case CancelOrderResponseTypeExchangeStopLimit: + return true + default: + return false + } +} + +// Defines values for ClearingOrderSide. +const ( + ClearingOrderSideBuy ClearingOrderSide = "buy" + ClearingOrderSideSell ClearingOrderSide = "sell" +) + +// Valid indicates whether the value is a known member of the ClearingOrderSide enum. +func (e ClearingOrderSide) Valid() bool { + switch e { + case ClearingOrderSideBuy: + return true + case ClearingOrderSideSell: + return true + default: + return false + } +} + +// Defines values for FundingPaymentEventType. +const ( + FundingPaymentEventTypeHourlyFundingTransfer FundingPaymentEventType = "Hourly Funding Transfer" +) + +// Valid indicates whether the value is a known member of the FundingPaymentEventType enum. +func (e FundingPaymentEventType) Valid() bool { + switch e { + case FundingPaymentEventTypeHourlyFundingTransfer: + return true + default: + return false + } +} + +// Defines values for FundingPaymentReportItemAction. +const ( + FundingPaymentReportItemActionCredit FundingPaymentReportItemAction = "Credit" + FundingPaymentReportItemActionDebit FundingPaymentReportItemAction = "Debit" +) + +// Valid indicates whether the value is a known member of the FundingPaymentReportItemAction enum. +func (e FundingPaymentReportItemAction) Valid() bool { + switch e { + case FundingPaymentReportItemActionCredit: + return true + case FundingPaymentReportItemActionDebit: + return true + default: + return false + } +} + +// Defines values for FundingPaymentReportItemEventType. +const ( + FundingPaymentReportItemEventTypeHourlyFundingTransfer FundingPaymentReportItemEventType = "Hourly Funding Transfer" +) + +// Valid indicates whether the value is a known member of the FundingPaymentReportItemEventType enum. +func (e FundingPaymentReportItemEventType) Valid() bool { + switch e { + case FundingPaymentReportItemEventTypeHourlyFundingTransfer: + return true + default: + return false + } +} + +// Defines values for FundingTransferAction. +const ( + FundingTransferActionCredit FundingTransferAction = "Credit" + FundingTransferActionDebit FundingTransferAction = "Debit" +) + +// Valid indicates whether the value is a known member of the FundingTransferAction enum. +func (e FundingTransferAction) Valid() bool { + switch e { + case FundingTransferActionCredit: + return true + case FundingTransferActionDebit: + return true + default: + return false + } +} + +// Defines values for InstantQuoteSide. +const ( + InstantQuoteSideBuy InstantQuoteSide = "buy" + InstantQuoteSideSell InstantQuoteSide = "sell" +) + +// Valid indicates whether the value is a known member of the InstantQuoteSide enum. +func (e InstantQuoteSide) Valid() bool { + switch e { + case InstantQuoteSideBuy: + return true + case InstantQuoteSideSell: + return true + default: + return false + } +} + +// Defines values for InterestRateInfoInterval. +const ( + Hour InterestRateInfoInterval = "hour" +) + +// Valid indicates whether the value is a known member of the InterestRateInfoInterval enum. +func (e InterestRateInfoInterval) Valid() bool { + switch e { + case Hour: + return true + default: + return false + } +} + +// Defines values for LimitOrderResponseSide. +const ( + LimitOrderResponseSideBuy LimitOrderResponseSide = "buy" + LimitOrderResponseSideSell LimitOrderResponseSide = "sell" +) + +// Valid indicates whether the value is a known member of the LimitOrderResponseSide enum. +func (e LimitOrderResponseSide) Valid() bool { + switch e { + case LimitOrderResponseSideBuy: + return true + case LimitOrderResponseSideSell: + return true + default: + return false + } +} + +// Defines values for LimitOrderResponseType. +const ( + LimitOrderResponseTypeExchangeLimit LimitOrderResponseType = "exchange limit" + LimitOrderResponseTypeExchangeMarket LimitOrderResponseType = "exchange market" + LimitOrderResponseTypeExchangeStopLimit LimitOrderResponseType = "exchange stop limit" +) + +// Valid indicates whether the value is a known member of the LimitOrderResponseType enum. +func (e LimitOrderResponseType) Valid() bool { + switch e { + case LimitOrderResponseTypeExchangeLimit: + return true + case LimitOrderResponseTypeExchangeMarket: + return true + case LimitOrderResponseTypeExchangeStopLimit: + return true + default: + return false + } +} + +// Defines values for MyTradeBreak. +const ( + Empty MyTradeBreak = "" + TradeCorrect MyTradeBreak = "trade correct" +) + +// Valid indicates whether the value is a known member of the MyTradeBreak enum. +func (e MyTradeBreak) Valid() bool { + switch e { + case Empty: + return true + case TradeCorrect: + return true + default: + return false + } +} + +// Defines values for MyTradeType. +const ( + MyTradeTypeBuy MyTradeType = "Buy" + MyTradeTypeSell MyTradeType = "Sell" +) + +// Valid indicates whether the value is a known member of the MyTradeType enum. +func (e MyTradeType) Valid() bool { + switch e { + case MyTradeTypeBuy: + return true + case MyTradeTypeSell: + return true + default: + return false + } +} + +// Defines values for NewOrderRequestOptions. +const ( + FillOrKill NewOrderRequestOptions = "fill-or-kill" + ImmediateOrCancel NewOrderRequestOptions = "immediate-or-cancel" + MakerOrCancel NewOrderRequestOptions = "maker-or-cancel" +) + +// Valid indicates whether the value is a known member of the NewOrderRequestOptions enum. +func (e NewOrderRequestOptions) Valid() bool { + switch e { + case FillOrKill: + return true + case ImmediateOrCancel: + return true + case MakerOrCancel: + return true + default: + return false + } +} + +// Defines values for NewOrderRequestSide. +const ( + NewOrderRequestSideBuy NewOrderRequestSide = "buy" + NewOrderRequestSideSell NewOrderRequestSide = "sell" +) + +// Valid indicates whether the value is a known member of the NewOrderRequestSide enum. +func (e NewOrderRequestSide) Valid() bool { + switch e { + case NewOrderRequestSideBuy: + return true + case NewOrderRequestSideSell: + return true + default: + return false + } +} + +// Defines values for NewOrderRequestType. +const ( + NewOrderRequestTypeExchangeLimit NewOrderRequestType = "exchange limit" + NewOrderRequestTypeExchangeMarket NewOrderRequestType = "exchange market" + NewOrderRequestTypeExchangeStopLimit NewOrderRequestType = "exchange stop limit" +) + +// Valid indicates whether the value is a known member of the NewOrderRequestType enum. +func (e NewOrderRequestType) Valid() bool { + switch e { + case NewOrderRequestTypeExchangeLimit: + return true + case NewOrderRequestTypeExchangeMarket: + return true + case NewOrderRequestTypeExchangeStopLimit: + return true + default: + return false + } +} + +// Defines values for OrderSide. +const ( + OrderSideBuy OrderSide = "buy" + OrderSideSell OrderSide = "sell" +) + +// Valid indicates whether the value is a known member of the OrderSide enum. +func (e OrderSide) Valid() bool { + switch e { + case OrderSideBuy: + return true + case OrderSideSell: + return true + default: + return false + } +} + +// Defines values for OrderTradesType. +const ( + OrderTradesTypeBuy OrderTradesType = "Buy" + OrderTradesTypeSell OrderTradesType = "Sell" +) + +// Valid indicates whether the value is a known member of the OrderTradesType enum. +func (e OrderTradesType) Valid() bool { + switch e { + case OrderTradesTypeBuy: + return true + case OrderTradesTypeSell: + return true + default: + return false + } +} + +// Defines values for OrderType. +const ( + OrderTypeExchangeLimit OrderType = "exchange limit" + OrderTypeExchangeMarket OrderType = "exchange market" + OrderTypeExchangeStopLimit OrderType = "exchange stop limit" +) + +// Valid indicates whether the value is a known member of the OrderType enum. +func (e OrderType) Valid() bool { + switch e { + case OrderTypeExchangeLimit: + return true + case OrderTypeExchangeMarket: + return true + case OrderTypeExchangeStopLimit: + return true + default: + return false + } +} + +// Defines values for RiskStatsResponseProductType. +const ( + PerpetualSwapContract RiskStatsResponseProductType = "PerpetualSwapContract" +) + +// Valid indicates whether the value is a known member of the RiskStatsResponseProductType enum. +func (e RiskStatsResponseProductType) Valid() bool { + switch e { + case PerpetualSwapContract: + return true + default: + return false + } +} + +// Defines values for StakingTransactionTransactionType. +const ( + StakingTransactionTransactionTypeAdminCreditAdjustment StakingTransactionTransactionType = "AdminCreditAdjustment" + StakingTransactionTransactionTypeAdminDebitAdjustment StakingTransactionTransactionType = "AdminDebitAdjustment" + StakingTransactionTransactionTypeAdminRedeem StakingTransactionTransactionType = "AdminRedeem" + StakingTransactionTransactionTypeDeposit StakingTransactionTransactionType = "Deposit" + StakingTransactionTransactionTypeInterest StakingTransactionTransactionType = "Interest" + StakingTransactionTransactionTypeRedeem StakingTransactionTransactionType = "Redeem" + StakingTransactionTransactionTypeRedeemPayment StakingTransactionTransactionType = "RedeemPayment" +) + +// Valid indicates whether the value is a known member of the StakingTransactionTransactionType enum. +func (e StakingTransactionTransactionType) Valid() bool { + switch e { + case StakingTransactionTransactionTypeAdminCreditAdjustment: + return true + case StakingTransactionTransactionTypeAdminDebitAdjustment: + return true + case StakingTransactionTransactionTypeAdminRedeem: + return true + case StakingTransactionTransactionTypeDeposit: + return true + case StakingTransactionTransactionTypeInterest: + return true + case StakingTransactionTransactionTypeRedeem: + return true + case StakingTransactionTransactionTypeRedeemPayment: + return true + default: + return false + } +} + +// Defines values for StopLimitOrderResponseSide. +const ( + StopLimitOrderResponseSideBuy StopLimitOrderResponseSide = "buy" + StopLimitOrderResponseSideSell StopLimitOrderResponseSide = "sell" +) + +// Valid indicates whether the value is a known member of the StopLimitOrderResponseSide enum. +func (e StopLimitOrderResponseSide) Valid() bool { + switch e { + case StopLimitOrderResponseSideBuy: + return true + case StopLimitOrderResponseSideSell: + return true + default: + return false + } +} + +// Defines values for StopLimitOrderResponseType. +const ( + ExchangeStopLimit StopLimitOrderResponseType = "exchange stop limit" +) + +// Valid indicates whether the value is a known member of the StopLimitOrderResponseType enum. +func (e StopLimitOrderResponseType) Valid() bool { + switch e { + case ExchangeStopLimit: + return true + default: + return false + } +} + +// Defines values for TradeType. +const ( + TradeTypeBuy TradeType = "buy" + TradeTypeSell TradeType = "sell" +) + +// Valid indicates whether the value is a known member of the TradeType enum. +func (e TradeType) Valid() bool { + switch e { + case TradeTypeBuy: + return true + case TradeTypeSell: + return true + default: + return false + } +} + +// Defines values for TransferStatus. +const ( + TransferStatusComplete TransferStatus = "Complete" + TransferStatusPending TransferStatus = "Pending" +) + +// Valid indicates whether the value is a known member of the TransferStatus enum. +func (e TransferStatus) Valid() bool { + switch e { + case TransferStatusComplete: + return true + case TransferStatusPending: + return true + default: + return false + } +} + +// Defines values for TransferType. +const ( + TransferTypeDeposit TransferType = "Deposit" + TransferTypeWithdrawal TransferType = "Withdrawal" +) + +// Valid indicates whether the value is a known member of the TransferType enum. +func (e TransferType) Valid() bool { + switch e { + case TransferTypeDeposit: + return true + case TransferTypeWithdrawal: + return true + default: + return false + } +} + +// Defines values for V2TransferStatus. +const ( + V2TransferStatusAdvanced V2TransferStatus = "Advanced" + V2TransferStatusComplete V2TransferStatus = "Complete" + V2TransferStatusPending V2TransferStatus = "Pending" +) + +// Valid indicates whether the value is a known member of the V2TransferStatus enum. +func (e V2TransferStatus) Valid() bool { + switch e { + case V2TransferStatusAdvanced: + return true + case V2TransferStatusComplete: + return true + case V2TransferStatusPending: + return true + default: + return false + } +} + +// Defines values for V2TransferType. +const ( + AdminCredit V2TransferType = "AdminCredit" + AdminDebit V2TransferType = "AdminDebit" + Deposit V2TransferType = "Deposit" + Reward V2TransferType = "Reward" + Withdrawal V2TransferType = "Withdrawal" +) + +// Valid indicates whether the value is a known member of the V2TransferType enum. +func (e V2TransferType) Valid() bool { + switch e { + case AdminCredit: + return true + case AdminDebit: + return true + case Deposit: + return true + case Reward: + return true + case Withdrawal: + return true + default: + return false + } +} + +// Defines values for AddBankJSONBodyType. +const ( + AddBankJSONBodyTypeChecking AddBankJSONBodyType = "checking" + AddBankJSONBodyTypeSavings AddBankJSONBodyType = "savings" +) + +// Valid indicates whether the value is a known member of the AddBankJSONBodyType enum. +func (e AddBankJSONBodyType) Valid() bool { + switch e { + case AddBankJSONBodyTypeChecking: + return true + case AddBankJSONBodyTypeSavings: + return true + default: + return false + } +} + +// Defines values for AddBankCADJSONBodyType. +const ( + AddBankCADJSONBodyTypeChecking AddBankCADJSONBodyType = "checking" + AddBankCADJSONBodyTypeSavings AddBankCADJSONBodyType = "savings" +) + +// Valid indicates whether the value is a known member of the AddBankCADJSONBodyType enum. +func (e AddBankCADJSONBodyType) Valid() bool { + switch e { + case AddBankCADJSONBodyTypeChecking: + return true + case AddBankCADJSONBodyTypeSavings: + return true + default: + return false + } +} + +// Account defines model for Account. +type Account struct { + // AccountId The account ID + AccountId *string `json:"account_id,omitempty"` + + // Created The creation date + Created *string `json:"created,omitempty"` + + // IsDefault Whether the account is the default account + IsDefault *bool `json:"is_default,omitempty"` + + // Name The account name + Name *string `json:"name,omitempty"` +} + +// AddBankResponse defines model for AddBankResponse. +type AddBankResponse struct { + // ReferenceId Reference ID for the new bank addition request. Once received, send in a wire from the requested bank account to verify it and enable withdrawals to that account. + ReferenceId *string `json:"referenceId,omitempty"` + + // Result Status result (e.g. ok). + Result *string `json:"result,omitempty"` +} + +// Address defines model for Address. +type Address struct { + // Address String representation of the cryptocurrency address + Address *string `json:"address,omitempty"` + + // Label If you provided a label when creating the address, it will be echoed back here + Label *string `json:"label,omitempty"` + + // Memo It would be present if applicable, it will be present for cosmos address + Memo *string `json:"memo,omitempty"` + + // Network The blockchain network for the address + Network *string `json:"network,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` +} + +// ApprovedAddress defines model for ApprovedAddress. +type ApprovedAddress struct { + // Address The address on the approved address list. + Address *string `json:"address,omitempty"` + + // CreatedAt UTC timestamp in millisecond of when the address was created. + CreatedAt *string `json:"createdAt,omitempty"` + + // Label The label assigned to the address + Label *string `json:"label,omitempty"` + + // Network The network of the approved address. Network can be `bitcoin`, `ethereum`, `bitcoincash`, `litecoin`, `zcash`, `filecoin`, `dogecoin`, `tezos`, `solana`, `polkadot`, `avalanche`, `cosmos`, or `xrpl` + Network *string `json:"network,omitempty"` + + // Scope Will return the scope of the address as either "account" or "group" + Scope *string `json:"scope,omitempty"` + + // Status The status of the address that will return as "active", "pending-time" or "pending-mua". The remaining time is exactly 7 days after the initial request. "pending-mua" is for multi-user accounts and will require another administator or fund manager on the account to approve the address. + Status *string `json:"status,omitempty"` +} + +// ApprovedAddressMessage defines model for ApprovedAddressMessage. +type ApprovedAddressMessage struct { + // Message Status or confirmation message for the approved address request or removal. + Message *string `json:"message,omitempty"` + + // Result Result status (e.g. ok). + Result *string `json:"result,omitempty"` +} + +// ApprovedAddressesResponse Response envelope containing the approved withdrawal addresses. +type ApprovedAddressesResponse struct { + // ApprovedAddresses Array of approved addresses on both the account and group level. + ApprovedAddresses *[]ApprovedAddress `json:"approvedAddresses,omitempty"` +} + +// Balance defines model for Balance. +type Balance struct { + // UnderscoreTimestamp Server-side monotonically increasing clock value as an ISO 8601 timestamp. Clients can use this value to detect and filter out stale responses that may occur due to load balancing or potential stale servers. + UnderscoreTimestamp *time.Time `json:"_timestamp,omitempty"` + + // Amount The confirmed balance for the currency (also referred to as `confirmedBalance`). For crypto withdrawals, this value is **not** reduced until the withdrawal has been confirmed on the blockchain. This delay protects against blockchain reorganizations. Use the `available` field instead if you need balances that immediately reflect holds. + Amount *openapi_types.DecimalNumber `json:"amount,omitempty"` + + // Available The amount available for trading. This value is reduced **immediately** when an order hold or withdrawal hold is placed, making it the recommended field for tracking real-time spendable balances. + Available *openapi_types.DecimalNumber `json:"available,omitempty"` + + // AvailableForWithdrawal The amount available for withdrawal + AvailableForWithdrawal *openapi_types.DecimalNumber `json:"availableForWithdrawal,omitempty"` + + // Currency The currency symbol + Currency *string `json:"currency,omitempty"` + + // PendingDeposit The amount pending deposit + PendingDeposit *openapi_types.DecimalNumber `json:"pendingDeposit,omitempty"` + + // PendingWithdrawal The amount pending withdrawal + PendingWithdrawal *openapi_types.DecimalNumber `json:"pendingWithdrawal,omitempty"` + Type *BalanceType `json:"type,omitempty"` +} + +// BalanceType defines model for Balance.Type. +type BalanceType string + +// CancelAllOrdersBySessionRequest defines model for CancelAllOrdersBySessionRequest. +type CancelAllOrdersBySessionRequest struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Specifies the account on which you intend to cancel the orders. Only available for exchange accounts. + Account *string `json:"account,omitempty"` + + // Nonce timestamp + Nonce TimestampType `json:"nonce"` + + // Request The literal string "/v1/order/cancel/session" + Request string `json:"request"` +} + +// CancelAllOrdersRequest defines model for CancelAllOrdersRequest. +type CancelAllOrdersRequest struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Specifies the account on which you intend to cancel the orders. Only available for exchange accounts. + Account *string `json:"account,omitempty"` + + // Nonce timestamp + Nonce TimestampType `json:"nonce"` + + // Request The literal string "/v1/order/cancel/all" + Request string `json:"request"` +} + +// CancelAllResult defines model for CancelAllResult. +type CancelAllResult struct { + // Details cancelledOrders/cancelRejects with IDs of both + Details *struct { + CancelRejects *[]int `json:"cancelRejects,omitempty"` + CancelledOrders *[]int `json:"cancelledOrders,omitempty"` + } `json:"details,omitempty"` + Result *string `json:"result,omitempty"` +} + +// CancelOrderRequest defines model for CancelOrderRequest. +type CancelOrderRequest struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Specifies the account on which you intend to cancel the order. Only available for exchange accounts. + Account *string `json:"account,omitempty"` + + // Nonce timestamp + Nonce TimestampType `json:"nonce"` + + // OrderId The order ID given by `/order/new` + OrderId int64 `json:"order_id"` + + // Request The literal string "/v1/order/cancel" + Request string `json:"request"` +} + +// CancelOrderResponse defines model for CancelOrderResponse. +type CancelOrderResponse struct { + AvgExecutionPrice *string `json:"avg_execution_price,omitempty"` + Exchange *string `json:"exchange,omitempty"` + ExecutedAmount *string `json:"executed_amount,omitempty"` + Id *string `json:"id,omitempty"` + IsCancelled *bool `json:"is_cancelled,omitempty"` + IsHidden *bool `json:"is_hidden,omitempty"` + IsLive *bool `json:"is_live,omitempty"` + Options *[]string `json:"options,omitempty"` + OrderId *string `json:"order_id,omitempty"` + OriginalAmount *string `json:"original_amount,omitempty"` + Price *string `json:"price,omitempty"` + Reason *CancelOrderResponseReason `json:"reason,omitempty"` + RemainingAmount *string `json:"remaining_amount,omitempty"` + Side *CancelOrderResponseSide `json:"side,omitempty"` + Symbol *string `json:"symbol,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + Type *CancelOrderResponseType `json:"type,omitempty"` + WasForced *bool `json:"was_forced,omitempty"` +} + +// CancelOrderResponseReason defines model for CancelOrderResponse.Reason. +type CancelOrderResponseReason string + +// CancelOrderResponseSide defines model for CancelOrderResponse.Side. +type CancelOrderResponseSide string + +// CancelOrderResponseType defines model for CancelOrderResponse.Type. +type CancelOrderResponseType string + +// Candle defines model for Candle. +type Candle = []float64 + +// CandleResponse defines model for CandleResponse. +type CandleResponse = []Candle + +// ClearingOrder defines model for ClearingOrder. +type ClearingOrder struct { + // Amount The order amount + Amount *string `json:"amount,omitempty"` + + // ClearingId The clearing ID + ClearingId *string `json:"clearing_id,omitempty"` + + // IsConfirmed Whether the order is confirmed + IsConfirmed *bool `json:"is_confirmed,omitempty"` + + // Price The order price + Price *string `json:"price,omitempty"` + Side *ClearingOrderSide `json:"side,omitempty"` + + // Status The order status + Status *string `json:"status,omitempty"` + + // Symbol The trading pair + Symbol *string `json:"symbol,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + + // Timestampms The timestamp in milliseconds + Timestampms *int `json:"timestampms,omitempty"` +} + +// ClearingOrderSide defines model for ClearingOrder.Side. +type ClearingOrderSide string + +// CustodyFeeTransfer defines model for CustodyFeeTransfer. +type CustodyFeeTransfer struct { + // Eid Custody fee event id + Eid *int `json:"eid,omitempty"` + + // EventType Custody fee event type + EventType *string `json:"eventType,omitempty"` + + // FeeAmount The fee amount charged + FeeAmount *string `json:"feeAmount,omitempty"` + + // FeeCurrency Currency that the fee was paid in + FeeCurrency *string `json:"feeCurrency,omitempty"` + + // TxTime Time of Custody fee record in milliseconds + TxTime *int `json:"txTime,omitempty"` +} + +// ErrorResponse defines model for ErrorResponse. +type ErrorResponse struct { + // Message Detailed error message + Message *string `json:"message,omitempty"` + + // Reason A short description + Reason *string `json:"reason,omitempty"` + + // Result Error + Result *string `json:"result,omitempty"` +} + +// FeeEstimateRequest defines model for FeeEstimateRequest. +type FeeEstimateRequest struct { + // Account The name of the account within the subaccount group. + Account string `json:"account"` + + // Address Standard string format of cryptocurrency address + Address string `json:"address"` + + // Amount Quoted decimal amount to withdraw + Amount string `json:"amount"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce Nonce `json:"nonce"` + + // Request The string `/v1/withdraw/{currencyCodeLowerCase}/feeEstimate` where `:currencyCodeLowerCase` is replaced with the currency code of a supported crypto-currency, e.g. `eth`, `aave`, etc. See [Symbols and minimums](/market-data/symbols-and-minimums) + Request string `json:"request"` +} + +// FeeEstimateResponse defines model for FeeEstimateResponse. +type FeeEstimateResponse struct { + // Currency Currency code, see [symbols](/market-data/symbols-and-minimums). + Currency *string `json:"currency,omitempty"` + + // Fee The estimated gas fee + Fee *string `json:"fee,omitempty"` + + // IsOverride Value that shows if an override on the customer's account for free withdrawals exists + IsOverride *bool `json:"isOverride,omitempty"` + + // MonthlyLimit Total nunber of allowable fee-free withdrawals + MonthlyLimit *int `json:"monthlyLimit,omitempty"` + + // MonthlyRemaining Total number of allowable fee-free withdrawals left to use + MonthlyRemaining *int `json:"monthlyRemaining,omitempty"` +} + +// FeeEstimateV2Request defines model for FeeEstimateV2Request. +type FeeEstimateV2Request struct { + // Account Required for Master API keys. The name of the account within the subaccount group. + Account *string `json:"account,omitempty"` + + // Address Standard string format of the destination cryptocurrency address + Address string `json:"address"` + + // Amount Quoted decimal amount to withdraw + Amount string `json:"amount"` + + // Memo It would be present if applicable, it will be present for cosmos address. + Memo *string `json:"memo,omitempty"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce Nonce `json:"nonce"` + + // Request The string `/v2/withdraw/{network}/{ticker}/feeEstimate` where `{network}` is the blockchain network (e.g. `ethereum`, `bitcoin`, `solana`) and `{ticker}` is the currency code (e.g. `eth`, `btc`, `sol`). See [Symbols and minimums](/market-data/symbols-and-minimums) + Request string `json:"request"` +} + +// FeeEstimateV2Response defines model for FeeEstimateV2Response. +type FeeEstimateV2Response struct { + // Currency Currency code, see [symbols](/market-data/symbols-and-minimums). + Currency *string `json:"currency,omitempty"` + + // Fee The estimated withdrawal fee as a decimal amount + Fee *openapi_types.DecimalNumber `json:"fee,omitempty"` + + // IsOverride Whether an override on the customer's account for free withdrawals exists + IsOverride *bool `json:"isOverride,omitempty"` + + // MonthlyLimit Total number of allowable fee-free withdrawals + MonthlyLimit *int `json:"monthlyLimit,omitempty"` + + // MonthlyRemaining Total number of allowable fee-free withdrawals remaining + MonthlyRemaining *int `json:"monthlyRemaining,omitempty"` +} + +// FeePromos defines model for FeePromos. +type FeePromos struct { + // Symbols Symbols that currently have fee promos + Symbols *[]string `json:"symbols,omitempty"` +} + +// FundingAmountResponse defines model for FundingAmountResponse. +type FundingAmountResponse struct { + // Amount The dollar amount for a Long 1 position held in the symbol for funding period (1 hour) + Amount *openapi_types.DecimalNumber `json:"amount,omitempty"` + + // EstimatedFundingAmount The estimated dollar amount for a Long 1 position held in the symbol for next funding period (1 hour) + EstimatedFundingAmount *openapi_types.DecimalNumber `json:"estimatedFundingAmount,omitempty"` + + // FundingDateTime UTC date time in format `yyyy-MM-ddThh:mm:ss.SSSZ` format + FundingDateTime *string `json:"fundingDateTime,omitempty"` + + // FundingTimestampMilliSecs Current funding amount Epoc time. + FundingTimestampMilliSecs *int64 `json:"fundingTimestampMilliSecs,omitempty"` + + // NextFundingTimestamp Next funding amount Epoc time. + NextFundingTimestamp *int64 `json:"nextFundingTimestamp,omitempty"` + + // Symbol The requested symbol. See [**symbols and minimums**](/market-data/symbols-and-minimums#all-supported-symbols) + Symbol *string `json:"symbol,omitempty"` +} + +// FundingPayment defines model for FundingPayment. +type FundingPayment struct { + // EventType Event type + EventType FundingPaymentEventType `json:"eventType"` + HourlyFundingTransfer FundingTransfer `json:"hourlyFundingTransfer"` +} + +// FundingPaymentEventType Event type +type FundingPaymentEventType string + +// FundingPaymentReportItem defines model for FundingPaymentReportItem. +type FundingPaymentReportItem struct { + // Action Credit or Debit + Action FundingPaymentReportItemAction `json:"action"` + + // AssetCode Asset symbol + AssetCode string `json:"assetCode"` + + // EventType Event type + EventType FundingPaymentReportItemEventType `json:"eventType"` + + // InstrumentSymbol Symbol of the underlying instrument. **Note** that this is only attached to requests from 16th April 2024 onwards. + InstrumentSymbol *string `json:"instrumentSymbol,omitempty"` + + // Quantity A nested JSON object describing the transaction amount + Quantity Quantity `json:"quantity"` + + // Timestamp Time of the funding payment + Timestamp TimestampType `json:"timestamp"` +} + +// FundingPaymentReportItemAction Credit or Debit +type FundingPaymentReportItemAction string + +// FundingPaymentReportItemEventType Event type +type FundingPaymentReportItemEventType string + +// FundingTransfer defines model for FundingTransfer. +type FundingTransfer struct { + // Action Credit or Debit + Action FundingTransferAction `json:"action"` + + // AssetCode Asset symbol + AssetCode string `json:"assetCode"` + + // EventType Event type + EventType string `json:"eventType"` + + // InstrumentSymbol Symbol of the underlying instrument. **Note** that this is only attached to requests from 16th April 2024 onwards. + InstrumentSymbol *string `json:"instrumentSymbol,omitempty"` + + // Quantity A nested JSON object describing the transaction amount + Quantity Quantity `json:"quantity"` + + // Timestamp Time of the funding payment + Timestamp TimestampType `json:"timestamp"` +} + +// FundingTransferAction Credit or Debit +type FundingTransferAction string + +// FxRate defines model for FxRate. +type FxRate struct { + // AsOf timestamp + AsOf *TimestampType `json:"asOf,omitempty"` + + // Benchmark The market for which the retrieved price applies to + Benchmark *string `json:"benchmark,omitempty"` + + // FxPair The requested currency pair + FxPair *string `json:"fxPair,omitempty"` + + // Provider The market data provider + Provider *string `json:"provider,omitempty"` + + // Rate The exchange rate + Rate *float64 `json:"rate,omitempty"` +} + +// Heartbeat defines model for Heartbeat. +type Heartbeat struct { + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce *Heartbeat_Nonce `json:"nonce,omitempty"` + + // Request The literal string `/v1/heartbeat` + Request *string `json:"request,omitempty"` +} + +// HeartbeatNonce0 Gemini strongly recommends using milliseconds instead of seconds for timestamps. +// +// | Timestamp format | Example | Supported request type | +// |-----------------------|-----------------------|------------------------| +// | string (seconds) | `'1495127793'` | `POST` only | +// | string (milliseconds) | `'1495127793000'` | `POST` only | +type HeartbeatNonce0 = string + +// HeartbeatNonce1 Gemini strongly recommends using milliseconds instead of seconds for timestamps. +// +// | Timestamp format | Example | Supported request type | +// |-----------------------------|---------------------------|------------------------| +// | whole number (seconds) | `1495127793` | `GET`, `POST` | +// | whole number (milliseconds) | `1495127793000` | `GET`, `POST` | +type HeartbeatNonce1 = int64 + +// Heartbeat_Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) +type Heartbeat_Nonce struct { + union json.RawMessage +} + +// InstantQuote defines model for InstantQuote. +type InstantQuote struct { + // DepositFee The deposit fee quantity. Will be applied if a debit card is used for the order. Will return 0 if there is no `depositFee` + DepositFee *string `json:"depositFee,omitempty"` + + // DepositFeeCurrency Currency in which `depositFee` is taken + DepositFeeCurrency *string `json:"depositFeeCurrency,omitempty"` + + // Fee The fee quantity to be taken for the order upon execution + Fee *string `json:"fee,omitempty"` + + // FeeCurrency The currency label for the order + FeeCurrency *string `json:"feeCurrency,omitempty"` + + // MaxAgeMs Number of milliseconds until this quote price expires. Once expired, you will need to request a new quote + MaxAgeMs *int `json:"maxAgeMs,omitempty"` + + // Pair The symbol passed in the quote request + Pair *string `json:"pair,omitempty"` + + // Price The quoted price of the asset. This will not change when attempting execution + Price *string `json:"price,omitempty"` + + // PriceCurrency The currency in which the order is priced. Matches `CCY2` in the symbol + PriceCurrency *string `json:"priceCurrency,omitempty"` + + // Quantity The quantity of the asset to be bought or sold + Quantity *string `json:"quantity,omitempty"` + + // QuantityCurrency The currency label for the `quantity` field. Matches `CCY1` in the symbol + QuantityCurrency *string `json:"quantityCurrency,omitempty"` + + // QuoteId Unique ID for the quote. This is used in the execution of the order + QuoteId *int `json:"quoteId,omitempty"` + + // Side Either "buy" or "sell" + Side *InstantQuoteSide `json:"side,omitempty"` + + // TotalSpend Total quantity to spend for the order. Will be the sum inclusive of all fees and amount to be traded. + TotalSpend *string `json:"totalSpend,omitempty"` + + // TotalSpendCurrency Currency of the `totalSpend` to be spent on the order + TotalSpendCurrency *string `json:"totalSpendCurrency,omitempty"` +} + +// InstantQuoteSide Either "buy" or "sell" +type InstantQuoteSide string + +// InterestRateInfo defines model for InterestRateInfo. +type InterestRateInfo struct { + // Interval The time interval for the rate (currently only "hour" is supported) + Interval InterestRateInfoInterval `json:"interval"` + + // Rate The interest rate as a decimal string + Rate string `json:"rate"` +} + +// InterestRateInfoInterval The time interval for the rate (currently only "hour" is supported) +type InterestRateInfoInterval string + +// LimitOrderResponse defines model for LimitOrderResponse. +type LimitOrderResponse struct { + AvgExecutionPrice *string `json:"avg_execution_price,omitempty"` + ClientOrderId *string `json:"client_order_id,omitempty"` + Exchange *string `json:"exchange,omitempty"` + ExecutedAmount *string `json:"executed_amount,omitempty"` + Id *string `json:"id,omitempty"` + IsCancelled *bool `json:"is_cancelled,omitempty"` + IsHidden *bool `json:"is_hidden,omitempty"` + IsLive *bool `json:"is_live,omitempty"` + Options *[]string `json:"options,omitempty"` + OrderId *string `json:"order_id,omitempty"` + OriginalAmount *string `json:"original_amount,omitempty"` + Price *string `json:"price,omitempty"` + RemainingAmount *string `json:"remaining_amount,omitempty"` + Side *LimitOrderResponseSide `json:"side,omitempty"` + Symbol *string `json:"symbol,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + Type *LimitOrderResponseType `json:"type,omitempty"` + WasForced *bool `json:"was_forced,omitempty"` +} + +// LimitOrderResponseSide defines model for LimitOrderResponse.Side. +type LimitOrderResponseSide string + +// LimitOrderResponseType defines model for LimitOrderResponse.Type. +type LimitOrderResponseType string + +// LiquidationRisk defines model for LiquidationRisk. +type LiquidationRisk struct { + // LiquidationPrice The estimated price at which liquidation would occur (optional, may not be present for all positions) + LiquidationPrice *MoneyAmount `json:"liquidationPrice,omitempty"` + + // LossPercentage The percentage loss from current value that would trigger liquidation, formatted as decimal (e.g., "0.1550" = 15.50%) + LossPercentage string `json:"lossPercentage"` +} + +// MarginAccountSummary defines model for MarginAccountSummary. +type MarginAccountSummary struct { + // AvailableCollateral The amount of collateral available for new positions or withdrawals + AvailableCollateral MoneyAmount `json:"availableCollateral"` + + // BuyingPower The maximum value that can be purchased with available collateral + BuyingPower MoneyAmount `json:"buyingPower"` + + // InterestRate Current interest rate on borrowed amounts (only present if borrows exist) + InterestRate *InterestRateInfo `json:"interestRate,omitempty"` + + // Leverage The current leverage ratio (notionalValue / marginAssetValue) + Leverage string `json:"leverage"` + + // LiquidationRisk Liquidation risk information (only present if positions exist) + LiquidationRisk *LiquidationRisk `json:"liquidationRisk,omitempty"` + + // MarginAssetValue The total value of all assets available in the margin account that can contribute to funding positions + MarginAssetValue MoneyAmount `json:"marginAssetValue"` + + // NotionalValue The total value of all open positions + NotionalValue MoneyAmount `json:"notionalValue"` + + // ReservedBuyOrders Collateral reserved for open buy orders + ReservedBuyOrders MoneyAmount `json:"reservedBuyOrders"` + + // ReservedSellOrders Collateral reserved for open sell orders + ReservedSellOrders MoneyAmount `json:"reservedSellOrders"` + + // SellingPower The maximum value that can be sold with available collateral + SellingPower MoneyAmount `json:"sellingPower"` + + // TotalBorrowed The total amount currently borrowed across all currencies + TotalBorrowed MoneyAmount `json:"totalBorrowed"` +} + +// MarginInterestRate defines model for MarginInterestRate. +type MarginInterestRate struct { + // BorrowRate The hourly borrow rate as a decimal + BorrowRate string `json:"borrowRate"` + + // BorrowRateAnnual The annualized borrow rate (daily rate × 365) + BorrowRateAnnual string `json:"borrowRateAnnual"` + + // BorrowRateDaily The daily borrow rate (hourly rate × 24) + BorrowRateDaily string `json:"borrowRateDaily"` + + // Currency The currency code (e.g., "BTC", "ETH", "USD") + Currency string `json:"currency"` + + // LastUpdated Unix timestamp in milliseconds when the rate was last updated + LastUpdated int64 `json:"lastUpdated"` +} + +// MarginOrderPreview defines model for MarginOrderPreview. +type MarginOrderPreview struct { + // Postorder Margin risk statistics after the order would be executed + Postorder MarginRiskStats `json:"postorder"` + + // Preorder Margin risk statistics before the order would be executed + Preorder MarginRiskStats `json:"preorder"` +} + +// MarginRatesResponse defines model for MarginRatesResponse. +type MarginRatesResponse struct { + // Rates Array of interest rates for all borrowable currencies + Rates []MarginInterestRate `json:"rates"` +} + +// MarginResponse defines model for MarginResponse. +type MarginResponse struct { + // AvailableMargin The difference between the `margin_assets_value` and `initial_margin`. + AvailableMargin *string `json:"available_margin,omitempty"` + + // BuyingPower The amount of that product the account could purchase based on current `initial_margin` and `margin_assets_value`. + BuyingPower *string `json:"buying_power,omitempty"` + + // EstimatedLiquidationPrice The estimated price for the asset at which liquidation would occur. + EstimatedLiquidationPrice *string `json:"estimated_liquidation_price,omitempty"` + + // InitialMargin The $ amount that is being required by the accounts current positions and open orders. + InitialMargin *string `json:"initial_margin,omitempty"` + + // InitialMarginPositions The contribution to `initial_margin` from open positions. + InitialMarginPositions *string `json:"initial_margin_positions,omitempty"` + + // Leverage The ratio of Notional Value to Margin Assets Value. + Leverage *string `json:"leverage,omitempty"` + + // MarginAssetsValue The $ equivalent value of all the assets available in the current trading account that can contribute to funding a derivatives position. + MarginAssetsValue *string `json:"margin_assets_value,omitempty"` + + // MarginMaintenanceLimit The minimum amount of `margin_assets_value` required before the account is moved to liquidation status. + MarginMaintenanceLimit *string `json:"margin_maintenance_limit,omitempty"` + + // NotionalValue The $ value of the current position. + NotionalValue *string `json:"notional_value,omitempty"` + + // ReservedMargin The contribution to `initial_margin` from open orders. + ReservedMargin *string `json:"reserved_margin,omitempty"` + + // ReservedMarginBuys The contribution to `initial_margin` from open BUY orders. + ReservedMarginBuys *string `json:"reserved_margin_buys,omitempty"` + + // ReservedMarginSells The contribution to `initial_margin` from open SELL orders. + ReservedMarginSells *string `json:"reserved_margin_sells,omitempty"` + + // SellingPower The amount of that product the account could sell based on current `initial_margin` and `margin_assets_value`. + SellingPower *string `json:"selling_power,omitempty"` +} + +// MarginRiskStats defines model for MarginRiskStats. +type MarginRiskStats struct { + // AvailableCollateral The amount of collateral available for new positions + AvailableCollateral MoneyAmount `json:"availableCollateral"` + + // BuyingPower The maximum value that can be purchased + BuyingPower MoneyAmount `json:"buyingPower"` + + // Leverage The leverage ratio + Leverage string `json:"leverage"` + + // LiquidationRisk Liquidation risk information (only present if applicable) + LiquidationRisk *LiquidationRisk `json:"liquidationRisk,omitempty"` + + // MarginAssetValue The total value of all assets available in the margin account + MarginAssetValue MoneyAmount `json:"marginAssetValue"` + + // NotionalValue The total value of all open positions + NotionalValue MoneyAmount `json:"notionalValue"` + + // ReservedBuyOrders Collateral reserved for open buy orders + ReservedBuyOrders MoneyAmount `json:"reservedBuyOrders"` + + // ReservedSellOrders Collateral reserved for open sell orders + ReservedSellOrders MoneyAmount `json:"reservedSellOrders"` + + // SellingPower The maximum value that can be sold + SellingPower MoneyAmount `json:"sellingPower"` + + // TotalBorrowed The total amount currently borrowed + TotalBorrowed MoneyAmount `json:"totalBorrowed"` +} + +// MoneyAmount defines model for MoneyAmount. +type MoneyAmount struct { + // Currency The currency code (e.g., "USD", "BTC", "ETH") + Currency string `json:"currency"` + + // Value The amount in the specified currency + Value string `json:"value"` +} + +// MyTrade defines model for MyTrade. +type MyTrade struct { + Aggressor *bool `json:"aggressor,omitempty"` + Amount *string `json:"amount,omitempty"` + Break *MyTradeBreak `json:"break,omitempty"` + ClientOrderId *string `json:"client_order_id,omitempty"` + Exchange *string `json:"exchange,omitempty"` + FeeAmount *string `json:"fee_amount,omitempty"` + FeeCurrency *string `json:"fee_currency,omitempty"` + IsAuctionFill *bool `json:"is_auction_fill,omitempty"` + OrderId *string `json:"order_id,omitempty"` + Price *string `json:"price,omitempty"` + Tid *int64 `json:"tid,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + Type *MyTradeType `json:"type,omitempty"` +} + +// MyTradeBreak defines model for MyTrade.Break. +type MyTradeBreak string + +// MyTradeType defines model for MyTrade.Type. +type MyTradeType string + +// MyTradesRequest defines model for MyTradesRequest. +type MyTradesRequest struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Specifies the account on which you intend to place the order. Only available for exchange accounts. + Account *string `json:"account,omitempty"` + + // LimitTrades The maximum number of trades to return. Default is 50, max is 500. + LimitTrades *int `json:"limit_trades,omitempty"` + + // Nonce timestamp + Nonce TimestampType `json:"nonce"` + + // Request The API endpoint path + Request string `json:"request"` + + // Symbol The [symbol](/market-data/symbols-and-minimums) to retrieve trades for + Symbol *string `json:"symbol,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` +} + +// NetworkAssets defines model for NetworkAssets. +type NetworkAssets struct { + // Assets Alphabetically sorted array of enabled asset/token codes available on this network. Assets include both exchange-tradable and custody-supported tokens. + Assets *[]string `json:"assets,omitempty"` + + // Network The blockchain network identifier. + Network *string `json:"network,omitempty"` +} + +// NetworkToken defines model for NetworkToken. +type NetworkToken struct { + // Network Array of supported blockchain networks for the token. Many tokens (especially stablecoins like USDC, USDT) are available on multiple networks. + // + // Supported networks include: `bitcoin`, `ethereum`, `solana`, `optimism`, `arbitrum`, `base`, `monad`, `avalanche`, `litecoin`, `bitcoincash`, `dogecoin`, `zcash`, `filecoin`, `tezos`, `polkadot`, `cosmos`, `xrpl`, `linea`, and more. + Network *[]string `json:"network,omitempty"` + + // Token The requested token identifier. + Token *string `json:"token,omitempty"` +} + +// NewOrderRequest defines model for NewOrderRequest. +type NewOrderRequest struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Specifies the account on which you intend to place the order. Only available for exchange accounts. + Account *string `json:"account,omitempty"` + + // Amount Quoted decimal amount to purchase + Amount string `json:"amount"` + + // ClientOrderId *Recommended*. A [client-specified order id](/client-order-id) + ClientOrderId *string `json:"client_order_id,omitempty"` + + // MarginOrder Set to `true` to place this order on a margin account using borrowed funds. Defaults to `false`. Only available for margin-enabled accounts. See [Margin Trading](/margin/account-summary) for details. + MarginOrder *bool `json:"margin_order,omitempty"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce int64 `json:"nonce"` + + // Options An optional array containing at most one supported order execution option. See Order execution options for details. + Options *[]NewOrderRequestOptions `json:"options,omitempty"` + + // Price Quoted decimal amount to spend per unit + Price string `json:"price"` + + // Request The literal string "/v1/order/new" + Request string `json:"request"` + Side NewOrderRequestSide `json:"side"` + + // StopPrice The price to trigger a stop-limit order. Only available for stop-limit orders. + StopPrice *string `json:"stop_price,omitempty"` + + // Symbol The [symbol](/market-data/symbols-and-minimums) for the new order + Symbol string `json:"symbol"` + + // Type The order type. "exchange limit" for all order types except for stop-limit orders. "exchange stop limit" for stop-limit orders. + Type NewOrderRequestType `json:"type"` +} + +// NewOrderRequestOptions defines model for NewOrderRequest.Options. +type NewOrderRequestOptions string + +// NewOrderRequestSide defines model for NewOrderRequest.Side. +type NewOrderRequestSide string + +// NewOrderRequestType The order type. "exchange limit" for all order types except for stop-limit orders. "exchange stop limit" for stop-limit orders. +type NewOrderRequestType string + +// Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) +type Nonce struct { + union json.RawMessage +} + +// Nonce1 defines model for . +type Nonce1 = int + +// NotionalBalance defines model for NotionalBalance. +type NotionalBalance struct { + // Amount The current balance + Amount *string `json:"amount,omitempty"` + + // AmountNotional Amount, in notional + AmountNotional *string `json:"amountNotional,omitempty"` + + // Available The amount that is available to trade + Available *string `json:"available,omitempty"` + + // AvailableForWithdrawal The amount that is available to withdraw + AvailableForWithdrawal *string `json:"availableForWithdrawal,omitempty"` + + // AvailableForWithdrawalNotional AvailableForWithdrawal, in notional + AvailableForWithdrawalNotional *string `json:"availableForWithdrawalNotional,omitempty"` + + // AvailableNotional Available, in notional + AvailableNotional *string `json:"availableNotional,omitempty"` + + // Currency Currency code, see symbols and minimums + Currency *string `json:"currency,omitempty"` +} + +// NotionalVolume defines model for NotionalVolume. +type NotionalVolume struct { + ApiAuctionFeeBps *int `json:"api_auction_fee_bps,omitempty"` + ApiMakerFeeBps *int `json:"api_maker_fee_bps,omitempty"` + ApiNotional30dVolume *string `json:"api_notional_30d_volume,omitempty"` + ApiTakerFeeBps *int `json:"api_taker_fee_bps,omitempty"` + Date *openapi_types.Date `json:"date,omitempty"` + FeeTier *struct { + ApiMakerFeeBps *int `json:"api_maker_fee_bps,omitempty"` + ApiTakerFeeBps *int `json:"api_taker_fee_bps,omitempty"` + Tier *string `json:"tier,omitempty"` + } `json:"fee_tier,omitempty"` + FixAuctionFeeBps *int `json:"fix_auction_fee_bps,omitempty"` + FixMakerFeeBps *int `json:"fix_maker_fee_bps,omitempty"` + FixTakerFeeBps *int `json:"fix_taker_fee_bps,omitempty"` + LastUpdatedMs *int `json:"last_updated_ms,omitempty"` + Notional1dVolume *[]struct { + // Date UTC date in `yyyy-MM-dd` format + Date *string `json:"date,omitempty"` + + // NotionalVolume Notional volume value in USD for this single day + NotionalVolume *string `json:"notional_volume,omitempty"` + } `json:"notional_1d_volume,omitempty"` + Notional30dVolume *string `json:"notional_30d_volume,omitempty"` + WebAuctionFeeBps *int `json:"web_auction_fee_bps,omitempty"` + WebMakerFeeBps *int `json:"web_maker_fee_bps,omitempty"` + WebTakerFeeBps *int `json:"web_taker_fee_bps,omitempty"` +} + +// OpenPosition defines model for OpenPosition. +type OpenPosition struct { + // AverageCost The average price of the current position. + AverageCost *string `json:"average_cost,omitempty"` + + // InstrumentType The type of instrument. Either "spot" or "perp". + InstrumentType *string `json:"instrument_type,omitempty"` + + // MarkPrice The current Mark Price for the Asset or the position. + MarkPrice *string `json:"mark_price,omitempty"` + + // NotionalValue The value of position; calculated as (`quantity` * `mark_price`). Value will be negative for shorts. + NotionalValue *string `json:"notional_value,omitempty"` + + // Quantity The position size. Value will be negative for shorts. + Quantity *string `json:"quantity,omitempty"` + + // RealisedPnl The current P&L that has been realised from the position. + RealisedPnl *string `json:"realised_pnl,omitempty"` + + // Symbol The [symbol](/market-data/symbols-and-minimums) of the order. + Symbol *string `json:"symbol,omitempty"` + + // UnrealisedPnl Current Mark to Market value of the positions. + UnrealisedPnl *string `json:"unrealised_pnl,omitempty"` +} + +// Order defines model for Order. +type Order struct { + // AvgExecutionPrice The average price at which this order as been executed so far. 0 if the order has not been executed at all. + AvgExecutionPrice *string `json:"avg_execution_price,omitempty"` + + // ClientOrderId An optional [client-specified order id](/client-order-id#client-order-id) + ClientOrderId *string `json:"client_order_id,omitempty"` + + // Exchange Will always be "gemini" + Exchange *string `json:"exchange,omitempty"` + + // ExecutedAmount The amount of the order that has been filled. + ExecutedAmount *string `json:"executed_amount,omitempty"` + + // IsCancelled `true` if the order has been canceled. Note the spelling, "cancelled" instead of "canceled". This is for compatibility reasons. + IsCancelled *bool `json:"is_cancelled,omitempty"` + + // IsHidden Will always return `false`. + IsHidden *bool `json:"is_hidden,omitempty"` + + // IsLive `true` if the order is active on the book (has remaining quantity and has not been canceled) + IsLive *bool `json:"is_live,omitempty"` + + // Options An array containing at most one supported order execution option. See [Order execution options](/rest/orders#create-new-order) for details. + Options *[]string `json:"options,omitempty"` + + // OrderId The order id + OrderId *string `json:"order_id,omitempty"` + + // OriginalAmount The originally submitted amount of the order. + OriginalAmount *string `json:"original_amount,omitempty"` + + // Price The price the order was issued at + Price *string `json:"price,omitempty"` + + // Reason Populated with the reason your order was canceled, if available. + Reason *string `json:"reason,omitempty"` + + // RemainingAmount The amount of the order that has not been filled. + RemainingAmount *string `json:"remaining_amount,omitempty"` + Side *OrderSide `json:"side,omitempty"` + + // Symbol The [symbol](/market-data/symbols-and-minimums#symbols-and-minimums) of the order + Symbol *string `json:"symbol,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + + // Trades Contains an array of JSON objects with trade details. + Trades *[]struct { + // Aggressor If `true`, this order was the taker in the trade + Aggressor *bool `json:"aggressor,omitempty"` + + // Amount The quantity that was executed + Amount *string `json:"amount,omitempty"` + + // Break Will only be present if the trade is broken. See `Break Types` below for more information. + Break *string `json:"break,omitempty"` + + // Exchange Will always be "gemini" + Exchange *string `json:"exchange,omitempty"` + + // FeeAmount The amount charged + FeeAmount *string `json:"fee_amount,omitempty"` + + // FeeCurrency Currency that the fee was paid in + FeeCurrency *string `json:"fee_currency,omitempty"` + + // OrderId The order that this trade executed against + OrderId *string `json:"order_id,omitempty"` + + // Price The price that the execution happened at + Price *string `json:"price,omitempty"` + + // Tid Unique identifier for the trade + Tid *int `json:"tid,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + + // Type Will be either "Buy" or "Sell", indicating the side of the original order + Type *OrderTradesType `json:"type,omitempty"` + } `json:"trades,omitempty"` + + // Type Description of the order + Type *OrderType `json:"type,omitempty"` + + // WasForced Will always be `false`. + WasForced *bool `json:"was_forced,omitempty"` +} + +// OrderSide defines model for Order.Side. +type OrderSide string + +// OrderTradesType Will be either "Buy" or "Sell", indicating the side of the original order +type OrderTradesType string + +// OrderType Description of the order +type OrderType string + +// OrderBook defines model for OrderBook. +type OrderBook struct { + // Asks The ask price levels currently on the book. These are offers to sell at a given price. + Asks *[]OrderBookEntry `json:"asks,omitempty"` + + // Bids The bid price levels currently on the book. These are offers to buy at a given price. + Bids *[]OrderBookEntry `json:"bids,omitempty"` +} + +// OrderBookEntry defines model for OrderBookEntry. +type OrderBookEntry struct { + // Amount The total quantity remaining at the price + Amount *string `json:"amount,omitempty"` + + // Price The price + Price *string `json:"price,omitempty"` + + // Timestamp **DO NOT USE** - this field is included for compatibility reasons only and is just populated with a dummy value. + Timestamp *string `json:"timestamp,omitempty"` +} + +// OrderStatusRequest defines model for OrderStatusRequest. +type OrderStatusRequest struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Specifies the account on which you intend to place the order. Only available for exchange accounts. + Account *string `json:"account,omitempty"` + + // ClientOrderId The `client_order_id` used when placing the order. `client_order_id` cannot be used in combination with `order_id` + ClientOrderId *string `json:"client_order_id,omitempty"` + + // IncludeTrades Either `True` or `False`. If `True` the endpoint will return individual trade details of all fills from the order. + IncludeTrades *bool `json:"include_trades,omitempty"` + + // Nonce timestamp + Nonce TimestampType `json:"nonce"` + + // OrderId The order id to get information on. The `order_id` represents a whole number and is transmitted as an unsigned 64-bit integer in JSON format. `order_id` cannot be used in combination with `client_order_id`. + OrderId int64 `json:"order_id"` + + // Request The API endpoint path + Request string `json:"request"` +} + +// PaymentMethodBalance defines model for PaymentMethodBalance. +type PaymentMethodBalance struct { + // Amount Total account balance for currency. + Amount *string `json:"amount,omitempty"` + + // Available Total amount available for trading + Available *string `json:"available,omitempty"` + + // AvailableForWithdrawal Total amount available for withdrawal + AvailableForWithdrawal *string `json:"availableForWithdrawal,omitempty"` + + // Currency Symbol for fiat balance. + Currency *string `json:"currency,omitempty"` + + // Type Account type. Will always be `exchange` + Type *string `json:"type,omitempty"` +} + +// PaymentMethodBank defines model for PaymentMethodBank. +type PaymentMethodBank struct { + // Bank Name of bank account + Bank *string `json:"bank,omitempty"` + + // BankId Unique identifier for bank account + BankId *string `json:"bankId,omitempty"` +} + +// PaymentMethodsResponse defines model for PaymentMethodsResponse. +type PaymentMethodsResponse struct { + // Balances Array of JSON objects with available fiat currencies and their balances. + Balances *[]PaymentMethodBalance `json:"balances,omitempty"` + + // Banks Array of JSON objects with banking information + Banks *[]PaymentMethodBank `json:"banks,omitempty"` +} + +// PriceFeedResponse defines model for PriceFeedResponse. +type PriceFeedResponse = []struct { + // Pair Trading pair symbol. See [**symbols and minimums**](/market-data/symbols-and-minimums#all-supported-symbols) + Pair *string `json:"pair,omitempty"` + + // PercentChange24h 24 hour change in price of the pair on the Gemini order book + PercentChange24h *string `json:"percentChange24h,omitempty"` + + // Price Current price of the pair on the Gemini order book + Price *string `json:"price,omitempty"` +} + +// Quantity defines model for Quantity. +type Quantity struct { + // Currency The currency code of the quantity. + Currency string `json:"currency"` + + // Value The value of the quantity. + Value string `json:"value"` +} + +// RevokeOauthTokenResponse defines model for RevokeOauthTokenResponse. +type RevokeOauthTokenResponse struct { + // Message A message that indicates the token has been revoked for the account + Message *string `json:"message,omitempty"` +} + +// RiskStatsResponse defines model for RiskStatsResponse. +type RiskStatsResponse struct { + // IndexPrice Current index price at the time of request + IndexPrice *string `json:"index_price,omitempty"` + + // MarkPrice Current mark price at the time of request + MarkPrice *string `json:"mark_price,omitempty"` + + // OpenInterest string representation of decimal value of open interest + OpenInterest *string `json:"open_interest,omitempty"` + + // OpenInterestNotional string representation of decimal value of open interest notional + OpenInterestNotional *string `json:"open_interest_notional,omitempty"` + + // ProductType Contract type for which the symbol data is fetched + ProductType *RiskStatsResponseProductType `json:"product_type,omitempty"` +} + +// RiskStatsResponseProductType Contract type for which the symbol data is fetched +type RiskStatsResponseProductType string + +// RoleResponse defines model for RoleResponse. +type RoleResponse struct { + // CounterpartyId _Only returned for master-level API keys_. The Gemini clearing counterparty ID associated with the API key making the request. + CounterpartyId *string `json:"counterparty_id,omitempty"` + + // IsAccountAdmin _Only returned for master-level API keys_.`True` if the Administrator role is assigned to the API keys. `False` otherwise. + IsAccountAdmin *bool `json:"isAccountAdmin,omitempty"` + + // IsAuditor `True` if the Auditor role is assigned to the API keys. `False` otherwise. + IsAuditor bool `json:"isAuditor"` + + // IsFundManager `True` if the Fund Manager role is assigned to the API keys. `False` otherwise. + IsFundManager bool `json:"isFundManager"` + + // IsTrader `True` if the Trader role is assigned to the API keys. `False` otherwise. + IsTrader bool `json:"isTrader"` +} + +// StakingBalance defines model for StakingBalance. +type StakingBalance struct { + // Available The amount that is available to trade + Available *openapi_types.DecimalNumber `json:"available,omitempty"` + + // AvailableForWithdrawal The Staking amount that is available to redeem to exchange account + AvailableForWithdrawal *openapi_types.DecimalNumber `json:"availableForWithdrawal,omitempty"` + + // Balance The current Staking balance + Balance *openapi_types.DecimalNumber `json:"balance,omitempty"` + BalanceByProvider *map[string]struct { + // Balance The current Staking balance per providerId + Balance *openapi_types.DecimalNumber `json:"balance,omitempty"` + } `json:"balanceByProvider,omitempty"` + + // Currency Currency code, see symbols and minimums + Currency *string `json:"currency,omitempty"` + + // Type Will always be "Staking" + Type *string `json:"type,omitempty"` +} + +// StakingDeposit defines model for StakingDeposit. +type StakingDeposit struct { + // AccrualTotal The total accrual + AccrualTotal *openapi_types.DecimalNumber `json:"accrualTotal,omitempty"` + + // Amount The amount deposited + Amount *openapi_types.DecimalNumber `json:"amount,omitempty"` + + // Currency Currency code, see [symbols](/market-data/symbols-and-minimums) + Currency *string `json:"currency,omitempty"` + + // ProviderId Provider Id, in uuid4 format + ProviderId *string `json:"providerId,omitempty"` + + // Rates A JSON object including one or many rates. If more than one rate it would be an array of rates. + Rates *struct { + // Rate Staking interest rate in bps (Expressed as a simple rate. Interest on Staking balances compounds daily. In mobile and web applications, APYs are derived from this rate and rounded to 1/10th of a percent.) + Rate *int `json:"rate,omitempty"` + } `json:"rates,omitempty"` + + // TransactionId A unique identifier for the staking transaction + TransactionId *string `json:"transactionId,omitempty"` +} + +// StakingHistory defines model for StakingHistory. +type StakingHistory struct { + // ProviderId Provider Id, in uuid4 format + ProviderId *string `json:"providerId,omitempty"` + Transactions *[]StakingTransaction `json:"transactions,omitempty"` +} + +// StakingRate defines model for StakingRate. +type StakingRate struct { + // ApyPct Staking interest APY (Expressed as a percentage derived from the rate and rounded to 1/10th of a percent.) + ApyPct *openapi_types.DecimalNumber `json:"apyPct,omitempty"` + + // DepositUsdLimit Maximum new amount in USD notional of this crypto that can participate in Gemini Staking per account per month + DepositUsdLimit *int `json:"depositUsdLimit,omitempty"` + + // ProviderId Provider Id, in uuid4 format + ProviderId *string `json:"providerId,omitempty"` + + // Rate Staking interest rate in bps (Expressed as a simple rate. Interest on Staking balances compounds daily. In mobile and web applications, APYs are derived from this rate and rounded to 1/10th of a percent.) + Rate *openapi_types.DecimalNumber `json:"rate,omitempty"` + + // RatePct `rate` expressed as a percentage + RatePct *openapi_types.DecimalNumber `json:"ratePct,omitempty"` +} + +// StakingRateProvider Currency Symbol Keys +type StakingRateProvider struct { + CurrencySymbol *StakingRate `json:"currency_symbol,omitempty"` +} + +// StakingRateResponse Provider UUID Keys +type StakingRateResponse struct { + // ProviderUuid Currency Symbol Keys + ProviderUuid *StakingRateProvider `json:"provider_uuid,omitempty"` +} + +// StakingRewardPeriod defines model for StakingRewardPeriod. +type StakingRewardPeriod struct { + // AccrualTotal The total accrual + AccrualTotal *openapi_types.DecimalNumber `json:"accrualTotal,omitempty"` + + // ApyPct Staking reward rate expressed as an APY at time of accrual. Interest on Staking balances compounds daily based on the simple rate which is available from `/v1/staking/rates/` + ApyPct *openapi_types.DecimalNumber `json:"apyPct,omitempty"` + + // Currency Currency code, see [symbols](/market-data/symbols-and-minimums) + Currency *string `json:"currency,omitempty"` + + // FirstAccrualAt Time of first accrual. In iso datetime with timezone format + FirstAccrualAt *string `json:"firstAccrualAt,omitempty"` + + // LastAccrualAt Time of last accrual. In iso datetime with timezone format + LastAccrualAt *string `json:"lastAccrualAt,omitempty"` + + // NumberOfAccruals Number of accruals in the specific aggregate, typically one per day. If the rate is adjusted, new accruals are added. + NumberOfAccruals *int `json:"numberOfAccruals,omitempty"` + + // ProviderId Provider Id, in uuid4 format + ProviderId *string `json:"providerId,omitempty"` + + // RatePct Rate expressed as a percentage + RatePct *openapi_types.DecimalNumber `json:"ratePct,omitempty"` +} + +// StakingRewards defines model for StakingRewards. +type StakingRewards struct { + // AccrualTotal The total accrual + AccrualTotal *openapi_types.DecimalNumber `json:"accrualTotal,omitempty"` + + // Currency Currency code, see [symbols](/market-data/symbols-and-minimums) + Currency *string `json:"currency,omitempty"` + + // ProviderId Provider Id, in uuid4 format + ProviderId *string `json:"providerId,omitempty"` + + // RatePeriods Array of JSON objects with period accrual information + RatePeriods *[]StakingRewardPeriod `json:"ratePeriods,omitempty"` +} + +// StakingRewardsProvider Currency Symbol Keys +type StakingRewardsProvider struct { + CurrencySymbol *StakingRewards `json:"currency_symbol,omitempty"` +} + +// StakingRewardsResponse Provider UUID Keys +type StakingRewardsResponse struct { + // ProviderUuid Currency Symbol Keys + ProviderUuid *StakingRewardsProvider `json:"provider_uuid,omitempty"` +} + +// StakingTransaction defines model for StakingTransaction. +type StakingTransaction struct { + // Amount The amount that is defined by the transactionType above + Amount *openapi_types.DecimalNumber `json:"amount,omitempty"` + + // AmountCurrency Currency code + AmountCurrency *string `json:"amountCurrency,omitempty"` + + // DateTime timestamp + DateTime *TimestampType `json:"dateTime,omitempty"` + + // PriceAmount Current market price of the underlying token at the time of the reward + PriceAmount *openapi_types.DecimalNumber `json:"priceAmount,omitempty"` + + // PriceCurrency A supported three-letter fiat currency code, e.g. usd + PriceCurrency *string `json:"priceCurrency,omitempty"` + + // TransactionId A unique identifier for the staking transaction + TransactionId *string `json:"transactionId,omitempty"` + + // TransactionType Can be any one of the following - Deposit, Redeem, Interest, RedeemPayment, AdminRedeem, AdminCreditAdjustment, AdminDebitAdjustment + TransactionType *StakingTransactionTransactionType `json:"transactionType,omitempty"` +} + +// StakingTransactionTransactionType Can be any one of the following - Deposit, Redeem, Interest, RedeemPayment, AdminRedeem, AdminCreditAdjustment, AdminDebitAdjustment +type StakingTransactionTransactionType string + +// StakingWithdrawal defines model for StakingWithdrawal. +type StakingWithdrawal struct { + // Amount The amount deposited + Amount *openapi_types.DecimalNumber `json:"amount,omitempty"` + + // AmountPaidSoFar The amount redeemed successfully + AmountPaidSoFar *openapi_types.DecimalNumber `json:"amountPaidSoFar,omitempty"` + + // AmountRemaining The amount pending to be redeemed + AmountRemaining *openapi_types.DecimalNumber `json:"amountRemaining,omitempty"` + + // Currency Currency code + Currency *string `json:"currency,omitempty"` + + // RequestInitiated In ISO datetime with timezone format + RequestInitiated *string `json:"requestInitiated,omitempty"` + + // TransactionId A unique identifier for the staking transaction + TransactionId *string `json:"transactionId,omitempty"` +} + +// StopLimitOrderResponse defines model for StopLimitOrderResponse. +type StopLimitOrderResponse struct { + AvgExecutionPrice *string `json:"avg_execution_price,omitempty"` + Exchange *string `json:"exchange,omitempty"` + ExecutedAmount *string `json:"executed_amount,omitempty"` + Id *string `json:"id,omitempty"` + IsCancelled *bool `json:"is_cancelled,omitempty"` + IsHidden *bool `json:"is_hidden,omitempty"` + IsLive *bool `json:"is_live,omitempty"` + Options *[]string `json:"options,omitempty"` + OrderId *string `json:"order_id,omitempty"` + OriginalAmount *string `json:"original_amount,omitempty"` + Price *string `json:"price,omitempty"` + Side *StopLimitOrderResponseSide `json:"side,omitempty"` + StopPrice *string `json:"stop_price,omitempty"` + Symbol *string `json:"symbol,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + Type *StopLimitOrderResponseType `json:"type,omitempty"` + WasForced *bool `json:"was_forced,omitempty"` +} + +// StopLimitOrderResponseSide defines model for StopLimitOrderResponse.Side. +type StopLimitOrderResponseSide string + +// StopLimitOrderResponseType defines model for StopLimitOrderResponse.Type. +type StopLimitOrderResponseType string + +// SymbolDetails defines model for SymbolDetails. +type SymbolDetails struct { + // BaseCurrency CCY1 or the top currency. (i.e `BTC` in `BTCUSD`) + BaseCurrency *string `json:"base_currency,omitempty"` + + // ContractPriceCurrency CCY2 or the quote currency for spot instrument (i.e. `USD` in `BTCUSD`) + // Or collateral currency of the contract in case of perpetual swap instrument. + ContractPriceCurrency *string `json:"contract_price_currency,omitempty"` + + // ContractType `vanilla` / `linear` / `inverse` where `vanilla` is for spot + // while `linear` is for perpetual swap and `inverse` is a special case perpetual swap where the perpetual contract will be settled in base currency. + ContractType *string `json:"contract_type,omitempty"` + + // MinOrderSize The minimum order size in `base_currency` units (i.e `0.00001`) + MinOrderSize *string `json:"min_order_size,omitempty"` + + // ProductType Instrument type `spot` / `swap` -- where `swap` signifies `perpetual swap`. + ProductType *string `json:"product_type,omitempty"` + + // QuoteCurrency CCY2 or the quote currency. (i.e `USD` in `BTCUSD`) + QuoteCurrency *string `json:"quote_currency,omitempty"` + + // QuoteIncrement The number of decimal places in the `quote_currency` (i.e `0.01`) + QuoteIncrement *openapi_types.DecimalNumber `json:"quote_increment,omitempty"` + + // Status Status of the current order book. Can be `open`, `closed`, `cancel_only`, `post_only`, `limit_only`. + Status *string `json:"status,omitempty"` + + // Symbol The requested symbol. See [**symbols and minimums**](/market-data/symbols-and-minimums#all-supported-symbols) + Symbol *string `json:"symbol,omitempty"` + + // TickSize The number of decimal places in the `base_currency`. (i.e `1e-8`) + TickSize *openapi_types.DecimalNumber `json:"tick_size,omitempty"` + + // WrapEnabled When `True`, symbol can be wrapped using this endpoint: + // `POST https://api.gemini.com/v1/wrap/:symbol` + WrapEnabled *bool `json:"wrap_enabled,omitempty"` +} + +// Ticker defines model for Ticker. +type Ticker struct { + // Ask The lowest ask currently available + Ask *string `json:"ask,omitempty"` + + // Bid The highest bid currently available + Bid *string `json:"bid,omitempty"` + + // Last The price of the last executed trade + Last *string `json:"last,omitempty"` + + // Volume Information about the 24 hour volume on the exchange. See properties below + Volume *struct { + // PriceSymbol The volume denominated in the price currency + PriceSymbol *string `json:"price_symbol,omitempty"` + + // QuantitySymbol The volume denominated in the quantity currency + QuantitySymbol *string `json:"quantity_symbol,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + } `json:"volume,omitempty"` +} + +// TickerInfo defines model for TickerInfo. +type TickerInfo struct { + // Ask Current best offer + Ask *string `json:"ask,omitempty"` + + // Bid Current best bid + Bid *string `json:"bid,omitempty"` + + // Changes Hourly prices descending for past 24 hours + Changes *[]string `json:"changes,omitempty"` + + // Close Close price (most recent trade) + Close *string `json:"close,omitempty"` + + // High High price from 24 hours ago + High *string `json:"high,omitempty"` + + // Low Low price from 24 hours ago + Low *string `json:"low,omitempty"` + + // Open Open price from 24 hours ago + Open *string `json:"open,omitempty"` + + // Symbol The trading pair symbol + Symbol *string `json:"symbol,omitempty"` +} + +// TimestampType timestamp +type TimestampType struct { + union json.RawMessage +} + +// TimestampType0 Gemini strongly recommends using milliseconds instead of seconds for timestamps. +// +// | Timestamp format | Example | Supported request type | +// |-----------------------|-----------------------|------------------------| +// | string (seconds) | `1495127793` | `POST` only | +// | string (milliseconds) | `1495127793000` | `POST` only | +type TimestampType0 = string + +// TimestampType1 Gemini strongly recommends using milliseconds instead of seconds for timestamps. +// +// | Timestamp format | Example | Supported request type | +// |-----------------------------|---------------------------|------------------------| +// | whole number (seconds) | `1495127793` | `GET`, `POST` | +// | whole number (milliseconds) | `1495127793000` | `GET`, `POST` | +type TimestampType1 = int64 + +// Trade defines model for Trade. +type Trade struct { + // Amount The amount that was traded + Amount *string `json:"amount,omitempty"` + + // Broken Whether the trade was broken or not. Broken trades will not be displayed by default; use the `include_breaks` to display them. + Broken *bool `json:"broken,omitempty"` + + // Exchange Will always be "gemini" + Exchange *string `json:"exchange,omitempty"` + + // Price The price the trade was executed at + Price *string `json:"price,omitempty"` + + // Tid The trade ID number + Tid *int64 `json:"tid,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + + // Type - `buy` means that an ask was removed from the book by an incoming buy order. + // - `sell` means that a bid was removed from the book by an incoming sell order. + Type *TradeType `json:"type,omitempty"` +} + +// TradeType - `buy` means that an ask was removed from the book by an incoming buy order. +// - `sell` means that a bid was removed from the book by an incoming sell order. +type TradeType string + +// TradeVolume defines model for TradeVolume. +type TradeVolume struct { + BaseCurrency *string `json:"base_currency,omitempty"` + BuyMakerBase *string `json:"buy_maker_base,omitempty"` + BuyMakerCount *int `json:"buy_maker_count,omitempty"` + BuyMakerNotional *string `json:"buy_maker_notional,omitempty"` + BuyTakerBase *string `json:"buy_taker_base,omitempty"` + BuyTakerCount *int `json:"buy_taker_count,omitempty"` + BuyTakerNotional *string `json:"buy_taker_notional,omitempty"` + DataDate *string `json:"data_date,omitempty"` + MakerBuySellRatio *string `json:"maker_buy_sell_ratio,omitempty"` + NotionalCurrency *string `json:"notional_currency,omitempty"` + QuoteCurrency *string `json:"quote_currency,omitempty"` + SellMakerBase *string `json:"sell_maker_base,omitempty"` + SellMakerCount *int `json:"sell_maker_count,omitempty"` + SellMakerNotional *string `json:"sell_maker_notional,omitempty"` + SellTakerBase *string `json:"sell_taker_base,omitempty"` + SellTakerCount *int `json:"sell_taker_count,omitempty"` + SellTakerNotional *string `json:"sell_taker_notional,omitempty"` + Symbol *string `json:"symbol,omitempty"` + TotalVolumeBase *string `json:"total_volume_base,omitempty"` +} + +// Transaction defines model for Transaction. +type Transaction struct { + union json.RawMessage +} + +// Transaction0 Trade Reponse +type Transaction0 struct { + // Account The account. + Account *string `json:"account,omitempty"` + + // Amount The quantity that was executed. + Amount *string `json:"amount,omitempty"` + + // ClientOrderId The client order ID, if defined. Otherwise an empty string. + ClientOrderId *string `json:"clientOrderId,omitempty"` + + // Exchange Will always be "gemini". + Exchange *string `json:"exchange,omitempty"` + + // FeeAmount The fee amount charged + FeeAmount *string `json:"feeAmount,omitempty"` + + // FeeAssetCode The symbol that the trade was for + FeeAssetCode *string `json:"feeAssetCode,omitempty"` + + // IsAggressor If true, this order was the taker in the trade. + IsAggressor *bool `json:"isAggressor,omitempty"` + + // IsAuctionFill True if the trade was a auction trade and not an on-exchange trade. + IsAuctionFill *bool `json:"isAuctionFill,omitempty"` + + // IsClearingFill True if the trade was a clearing trade and not an on-exchange trade. + IsClearingFill *bool `json:"isClearingFill,omitempty"` + + // OrderId The order that this trade executed against. + OrderId *int64 `json:"orderId,omitempty"` + + // Price The price that the execution happened at. + Price *string `json:"price,omitempty"` + + // Side Indicating the side of the original order. + Side *string `json:"side,omitempty"` + + // Symbol The symbol that the trade was for. + Symbol *string `json:"symbol,omitempty"` + + // Tid The trade ID. + Tid *int64 `json:"tid,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` +} + +// Transaction1 Transfer Reponse +type Transaction1 struct { + // AdvanceEid Deposit advance event ID. + AdvanceEid *int64 `json:"advanceEid,omitempty"` + + // Amount The quantity that was transferred. + Amount *string `json:"amount,omitempty"` + + // BankId Bank ID. + BankId *string `json:"bankId,omitempty"` + + // ClientTransferId Client Transfer ID. Client transfer ID is an optional client-supplied unique identifier for each withdrawal or internal transfer. + ClientTransferId *string `json:"clientTransferId,omitempty"` + + // CorrelationId Correlation ID. + CorrelationId *int64 `json:"correlationId,omitempty"` + + // Currency Currency code, see symbols + Currency *string `json:"currency,omitempty"` + + // Destination The account you are transferring to. + Destination *string `json:"destination,omitempty"` + + // Eid Transfer event id. + Eid *int64 `json:"eid,omitempty"` + + // FeeId Fee ID. + FeeId *string `json:"feeId,omitempty"` + + // Method Type of transfer method. + Method *string `json:"method,omitempty"` + + // OperationReason The operation reason. + OperationReason *string `json:"operationReason,omitempty"` + + // PendingEid Pending event ID. + PendingEid *int64 `json:"pendingEid,omitempty"` + + // Purpose Purpose. + Purpose *string `json:"purpose,omitempty"` + + // Source The account you are transferring from. + Source *string `json:"source,omitempty"` + + // Status The status of the transfer. + Status *string `json:"status,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + + // TransactionHash Supplies the transaction hash when available. + TransactionHash *string `json:"transactionHash,omitempty"` + + // TransferId Transfer ID. + TransferId *string `json:"transferId,omitempty"` + + // TransferType Transfer type. + TransferType *string `json:"transferType,omitempty"` + + // WithdrawalEid Withdrawal event ID. + WithdrawalEid *int64 `json:"withdrawalEid,omitempty"` + + // WithdrawalId Withdrawal ID. + WithdrawalId *string `json:"withdrawalId,omitempty"` +} + +// Transfer defines model for Transfer. +type Transfer struct { + // Amount The amount transferred + Amount *string `json:"amount,omitempty"` + + // Currency The currency transferred + Currency *string `json:"currency,omitempty"` + + // Eid The transfer ID + Eid *int64 `json:"eid,omitempty"` + Status *TransferStatus `json:"status,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + + // TxHash The transaction hash if applicable + TxHash *string `json:"txHash,omitempty"` + Type *TransferType `json:"type,omitempty"` +} + +// TransferStatus defines model for Transfer.Status. +type TransferStatus string + +// TransferType defines model for Transfer.Type. +type TransferType string + +// V2Transfer defines model for V2Transfer. +type V2Transfer struct { + // Amount The amount transferred + Amount *string `json:"amount,omitempty"` + + // Currency The currency transferred + Currency *string `json:"currency,omitempty"` + + // Destination The destination address for withdrawals + Destination *string `json:"destination,omitempty"` + + // Eid The transfer event ID + Eid *int64 `json:"eid,omitempty"` + + // FeeAmount The fee charged for the transfer + FeeAmount *string `json:"feeAmount,omitempty"` + + // FeeCurrency The currency in which the fee was charged + FeeCurrency *string `json:"feeCurrency,omitempty"` + + // Method The transfer method (e.g., `ACH`, `CreditCard`) + Method *string `json:"method,omitempty"` + + // Network The blockchain network the transfer was executed on (e.g., `ethereum`, `solana`, `arbitrum`, `optimism`, `base`, `avalanche`). Not present for fiat or administrative transfers. + Network *string `json:"network,omitempty"` + + // OutputIdx The output index for withdrawals + OutputIdx *int `json:"outputIdx,omitempty"` + + // Purpose The purpose or reason for administrative transfers + Purpose *string `json:"purpose,omitempty"` + + // Status The status of the transfer + Status *V2TransferStatus `json:"status,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + + // TxHash The on-chain transaction hash, if applicable + TxHash *string `json:"txHash,omitempty"` + + // Type The type of the transfer + Type *V2TransferType `json:"type,omitempty"` + + // WithdrawalId The unique withdrawal identifier + WithdrawalId *string `json:"withdrawalId,omitempty"` +} + +// V2TransferStatus The status of the transfer +type V2TransferStatus string + +// V2TransferType The type of the transfer +type V2TransferType string + +// WithdrawCryptoFundsResponse Response returned after submitting a v2 cryptocurrency withdrawal. +type WithdrawCryptoFundsResponse struct { + // Address Standard string format of the withdrawal destination address + Address *string `json:"address,omitempty"` + + // Amount The withdrawal amount + Amount *string `json:"amount,omitempty"` + + // Currency The currency code of the withdrawn asset + Currency *string `json:"currency,omitempty"` + + // Fee The fee charged for the withdrawal + Fee *string `json:"fee,omitempty"` + + // WithdrawalId A unique ID for the withdrawal + WithdrawalId *string `json:"withdrawalId,omitempty"` +} + +// ApiKeyAuth defines model for apiKeyAuth. +type ApiKeyAuth = string + +// CacheControl defines model for cacheControl. +type CacheControl = string + +// ContentLength defines model for contentLength. +type ContentLength = string + +// ContentType defines model for contentType. +type ContentType = string + +// CurrencyParam defines model for currencyParam. +type CurrencyParam = string + +// NetworkParam defines model for networkParam. +type NetworkParam = string + +// PayloadAuth defines model for payloadAuth. +type PayloadAuth = string + +// SignatureAuth defines model for signatureAuth. +type SignatureAuth = string + +// SymbolParam defines model for symbolParam. +type SymbolParam = string + +// TimestampParam timestamp +type TimestampParam = TimestampType + +// ApiKeyIpFilteringFailure defines model for ApiKeyIpFilteringFailure. +type ApiKeyIpFilteringFailure = ErrorResponse + +// BadRequest defines model for BadRequest. +type BadRequest = ErrorResponse + +// InternalError defines model for InternalError. +type InternalError = ErrorResponse + +// NotFound defines model for NotFound. +type NotFound = ErrorResponse + +// TooManyRequests defines model for TooManyRequests. +type TooManyRequests = ErrorResponse + +// Unauthorized defines model for Unauthorized. +type Unauthorized = ErrorResponse + +// apiKeyAuthContextKey is the context key for apiKeyAuth security scheme +type apiKeyAuthContextKey string + +// payloadAuthContextKey is the context key for payloadAuth security scheme +type payloadAuthContextKey string + +// signatureAuthContextKey is the context key for signatureAuth security scheme +type signatureAuthContextKey string + +// GetAccountDetailJSONBody defines parameters for GetAccountDetail. +type GetAccountDetailJSONBody struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Master API keys can get all account names using the [Get Accounts endpoint](/rest/account-administration#list-accounts-in-group). + Account *string `json:"account,omitempty"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce Nonce `json:"nonce"` + + // Request The literal string "/v1/account" + Request string `json:"request"` +} + +// GetAccountDetailParams defines parameters for GetAccountDetail. +type GetAccountDetailParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// CreateNewAccountJSONBody defines parameters for CreateNewAccount. +type CreateNewAccountJSONBody struct { + // Name A unique name for the new account + Name string `json:"name"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce Nonce `json:"nonce"` + + // Request The literal string "/v1/account/create" + Request string `json:"request"` + + // Type Either `exchange` or `custody` is accepted. Will generate an exchange account if `exchange` or parameter is missing. Will generate a custody account if `custody`. + Type *string `json:"type,omitempty"` +} + +// CreateNewAccountParams defines parameters for CreateNewAccount. +type CreateNewAccountParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// ListAccountsInGroupJSONBody defines parameters for ListAccountsInGroup. +type ListAccountsInGroupJSONBody struct { + // LimitAccounts The maximum number of accounts to return. Maximum and default values are both 500. + LimitAccounts *int `json:"limit_accounts,omitempty"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce Nonce `json:"nonce"` + + // Request The literal string "/v1/account/list" + Request string `json:"request"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` +} + +// ListAccountsInGroupParams defines parameters for ListAccountsInGroup. +type ListAccountsInGroupParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// RenameAccountJSONBody defines parameters for RenameAccount. +type RenameAccountJSONBody struct { + // Account Only required when using a master api-key. The shortname of the account within the subaccount group. Master API keys can get all account shortnames from the `account` field returned by the [Get Accounts endpoint](/rest/account-administration#list-accounts-in-group). + Account *string `json:"account,omitempty"` + + // NewAccount A unique shortname for the new account. If not provided, shortname will not change. + NewAccount *string `json:"newAccount,omitempty"` + + // NewName A unique name for the new account. If not provided, name will not change. + NewName *string `json:"newName,omitempty"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce Nonce `json:"nonce"` + + // Request The literal string "/v1/account/rename". + Request string `json:"request"` +} + +// RenameAccountParams defines parameters for RenameAccount. +type RenameAccountParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// TransferBetweenAccountsJSONBody defines parameters for TransferBetweenAccounts. +type TransferBetweenAccountsJSONBody struct { + // Amount Quoted decimal amount to withdraw + Amount string `json:"amount"` + + // ClientTransferId A unique identifier for the internal transfer, in uuid4 format + ClientTransferId *string `json:"clientTransferId,omitempty"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce Nonce `json:"nonce"` + + // Request The string `/v1/account/transfer/:currency` where `:currency` is replaced with either `usd` or a supported crypto-currency, e.g. `gusd`, `btc`, `eth`, `aave`, etc. See [Symbols and minimums](/market-data/symbols-and-minimums). + Request string `json:"request"` + + // SourceAccount Nickname of the account you are transferring from. Use the [Get Accounts endpoint](/rest/account-administration#list-accounts-in-group) to get all account names in the group. + SourceAccount string `json:"sourceAccount"` + + // TargetAccount Nickname of the account you are transferring to. Use the [Get Accounts endpoint](/rest/account-administration#list-accounts-in-group) to get all account names in the group. + TargetAccount string `json:"targetAccount"` + + // WithdrawalId Unique ID of the requested withdrawal. + WithdrawalId *string `json:"withdrawalId,omitempty"` +} + +// TransferBetweenAccountsParams defines parameters for TransferBetweenAccounts. +type TransferBetweenAccountsParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// ListDepositAddressesJSONBody defines parameters for ListDepositAddresses. +type ListDepositAddressesJSONBody struct { + // Account Required for Master API keys. The name of the account within the subaccount group. + Account *string `json:"account,omitempty"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce Nonce `json:"nonce"` + + // Request The literal string "/v1/addresses/network" + Request string `json:"request"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` +} + +// ListDepositAddressesParams defines parameters for ListDepositAddresses. +type ListDepositAddressesParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// ListApprovedAddressesJSONBody defines parameters for ListApprovedAddresses. +type ListApprovedAddressesJSONBody struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Specifies the account on which you intend to view the approved address list. + Account *string `json:"account,omitempty"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce Nonce `json:"nonce"` + + // Request The literal string "/v1/approvedAddresses/account/:network" where `:network` can be `bitcoin`, `ethereum`, `bitcoincash`, `litecoin`, `zcash`, `filecoin`, `dogecoin`, `tezos`, `solana`, `polkadot`, `avalanche`, `cosmos`, or `xrpl` + Request string `json:"request"` +} + +// ListApprovedAddressesParams defines parameters for ListApprovedAddresses. +type ListApprovedAddressesParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// RemoveApprovedAddressJSONBody defines parameters for RemoveApprovedAddress. +type RemoveApprovedAddressJSONBody struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Specifies the account on which you intend to remove the approved address. + Account *string `json:"account,omitempty"` + + // Address A string of the address to be removed from the approved address list. + Address string `json:"address"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce Nonce `json:"nonce"` + + // Request The literal string "/v1/approvedAddresses/:network/remove" where `:network` can be `bitcoin`, `ethereum`, `bitcoincash`, `litecoin`, `zcash`, `filecoin`, `dogecoin`, `tezos`, `solana`, `polkadot`, `avalanche`, `cosmos`, or `xrpl` + Request string `json:"request"` +} + +// RemoveApprovedAddressParams defines parameters for RemoveApprovedAddress. +type RemoveApprovedAddressParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// CreateNewApprovedAddressJSONBody defines parameters for CreateNewApprovedAddress. +type CreateNewApprovedAddressJSONBody struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Specifies the account on which you intend to add the approved address. + Account *string `json:"account,omitempty"` + + // Address A string of the address to be added to the approved address list. + Address string `json:"address"` + + // Label The label of the approved address. + Label string `json:"label"` + + // Memo it would be present if applicable, it will be present for cosmos address. + Memo *string `json:"memo,omitempty"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce Nonce `json:"nonce"` + + // Request The literal string "/v1/approvedAddresses/:network/request" where `:network` can be `bitcoin`, `ethereum`, `bitcoincash`, `litecoin`, `zcash`, `filecoin`, `dogecoin`, `tezos`, `solana`, `polkadot`, `avalanche`, `cosmos`, or `xrpl` + Request string `json:"request"` +} + +// CreateNewApprovedAddressParams defines parameters for CreateNewApprovedAddress. +type CreateNewApprovedAddressParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// GetAvailableBalancesJSONBody defines parameters for GetAvailableBalances. +type GetAvailableBalancesJSONBody struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Specifies the account on which you intend to place the order. Only available for exchange accounts. + Account string `json:"account"` + + // Nonce timestamp + Nonce TimestampType `json:"nonce"` + + // Request The API endpoint path + Request string `json:"request"` + + // ShowPendingBalances Whether to include pending balances such as in-flight crypto deposits or withdrawals in the balances response. + // + // > **Note:** Setting this field to `true` will result in slower response times due to additional database lookups required to retrieve pending balance information. + ShowPendingBalances *bool `json:"showPendingBalances,omitempty"` +} + +// GetAvailableBalancesParams defines parameters for GetAvailableBalances. +type GetAvailableBalancesParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// ListStakingBalancesJSONBody defines parameters for ListStakingBalances. +type ListStakingBalancesJSONBody struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. + Account *string `json:"account,omitempty"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce Nonce `json:"nonce"` + + // Request The literal string "/v1/balances/staking" + Request string `json:"request"` +} + +// ListStakingBalancesParams defines parameters for ListStakingBalances. +type ListStakingBalancesParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// ListCustodyFeeTransfersJSONBody defines parameters for ListCustodyFeeTransfers. +type ListCustodyFeeTransfersJSONBody struct { + // Account Required for Master API keys. The name of the account within the subaccount group. + Account *string `json:"account,omitempty"` + + // LimitTransfers The maximum number of Custody fee records to return. The default is 10 and the maximum is 50. + LimitTransfers *int `json:"limit_transfers,omitempty"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce Nonce `json:"nonce"` + + // Request The literal string "/v1/custodyaccountfees" + Request string `json:"request"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` +} + +// ListCustodyFeeTransfersParams defines parameters for ListCustodyFeeTransfers. +type ListCustodyFeeTransfersParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// CreateNewDepositAddressJSONBody defines parameters for CreateNewDepositAddress. +type CreateNewDepositAddressJSONBody struct { + // Account Required for Master API keys. The name of the account within the subaccount group. + Account *string `json:"account,omitempty"` + + // Label A label for the address + Label *string `json:"label,omitempty"` + + // Legacy Whether to generate a legacy P2SH-P2PKH litecoin address. False by default. + Legacy *bool `json:"legacy,omitempty"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce Nonce `json:"nonce"` + + // Request The literal string "/v1/deposit/network/newAddress" + Request string `json:"request"` +} + +// CreateNewDepositAddressParams defines parameters for CreateNewDepositAddress. +type CreateNewDepositAddressParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// GetNotionalBalancesJSONBody defines parameters for GetNotionalBalances. +type GetNotionalBalancesJSONBody struct { + // Account Required for Master API keys. The name of the account within the subaccount group. + Account *string `json:"account,omitempty"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce Nonce `json:"nonce"` + + // Request The literal string "/v1/notionalbalances/currency" + Request string `json:"request"` +} + +// GetNotionalBalancesParams defines parameters for GetNotionalBalances. +type GetNotionalBalancesParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// RevokeOAuthTokenJSONBody defines parameters for RevokeOAuthToken. +type RevokeOAuthTokenJSONBody struct { + // Request The literal string "/v1/oauth/revokeByToken" + Request string `json:"request"` +} + +// RevokeOAuthTokenParams defines parameters for RevokeOAuthToken. +type RevokeOAuthTokenParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// AddBankJSONBody defines parameters for AddBank. +type AddBankJSONBody struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Master API keys can get all account names using the [Get Accounts endpoint](/rest/account-administration#list-accounts-in-group). + Account *string `json:"account,omitempty"` + + // Accountnumber Account number of bank account to be added + Accountnumber string `json:"accountnumber"` + + // Name The name of the bank account as shown on your account statements + Name string `json:"name"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce Nonce `json:"nonce"` + + // Request The literal string "/v1/payments/addbank" + Request string `json:"request"` + + // Routing Routing number of bank account to be added + Routing string `json:"routing"` + + // Type Type of bank account to be added. Accepts `checking` or `savings` + Type AddBankJSONBodyType `json:"type"` +} + +// AddBankParams defines parameters for AddBank. +type AddBankParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// AddBankJSONBodyType defines parameters for AddBank. +type AddBankJSONBodyType string + +// AddBankCADJSONBody defines parameters for AddBankCAD. +type AddBankCADJSONBody struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Master API keys can get all account names using the [Get Accounts endpoint](/rest/account-administration#list-accounts-in-group). + Account *string `json:"account,omitempty"` + + // AccountNumber Account number of bank account to be added + AccountNumber string `json:"accountNumber"` + + // Branchnnumber The branch number - optional but recommended. + Branchnnumber *string `json:"branchnnumber,omitempty"` + + // InstitutionNumber The institution number of the account - optional but recommended. + InstitutionNumber *string `json:"institutionNumber,omitempty"` + + // Name The name of the bank account as shown on your account statements + Name string `json:"name"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce Nonce `json:"nonce"` + + // Request The literal string "/v1/payments/addbank/cad" + Request string `json:"request"` + + // Swiftcode The account SWIFT code + Swiftcode string `json:"swiftcode"` + + // Type Type of bank account to be added. Accepts `checking` or `savings` + Type AddBankCADJSONBodyType `json:"type"` +} + +// AddBankCADParams defines parameters for AddBankCAD. +type AddBankCADParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// AddBankCADJSONBodyType defines parameters for AddBankCAD. +type AddBankCADJSONBodyType string + +// ListPaymentMethodsJSONBody defines parameters for ListPaymentMethods. +type ListPaymentMethodsJSONBody struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Master API keys can get all account names using the [Get Accounts endpoint](/rest/account-administration#list-accounts-in-group). + Account *string `json:"account,omitempty"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce Nonce `json:"nonce"` + + // Request The literal string "/v1/payments/methods" + Request string `json:"request"` +} + +// ListPaymentMethodsParams defines parameters for ListPaymentMethods. +type ListPaymentMethodsParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// GetRolesJSONBody defines parameters for GetRoles. +type GetRolesJSONBody struct { + // Nonce timestamp + Nonce TimestampType `json:"nonce"` + + // Request The literal string "/v1/roles" + Request string `json:"request"` +} + +// GetRolesParams defines parameters for GetRoles. +type GetRolesParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// ListStakingEventHistoryJSONBody defines parameters for ListStakingEventHistory. +type ListStakingEventHistoryJSONBody struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. + Account *string `json:"account,omitempty"` + + // Currency Currency code, see [symbols](/market-data/symbols-and-minimums) + Currency *string `json:"currency,omitempty"` + + // InterestOnly Toggles whether to only return daily interest transactions. Defaults to false. + InterestOnly *bool `json:"interestOnly,omitempty"` + + // Limit The maximum number of transactions to return. Default is 50, max is 500. + Limit *int `json:"limit,omitempty"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce Nonce `json:"nonce"` + + // ProviderId Borrower Id, in uuid4 format. providerId is accessible from the [Staking rates](#list-staking-rates) response + ProviderId *string `json:"providerId,omitempty"` + + // Request The literal string "/v1/staking/history" + Request string `json:"request"` + + // Since In iso datetime with timezone format. Defaults to the timestamp of the first deposit into Staking. + Since *TimestampType `json:"since,omitempty"` + + // SortAsc Toggles whether to sort the transactions in ascending order by datetime. Defaults to false. + SortAsc *bool `json:"sortAsc,omitempty"` + + // Until In iso datetime with timezone format, default to current time as of server time + Until *TimestampType `json:"until,omitempty"` +} + +// ListStakingEventHistoryParams defines parameters for ListStakingEventHistory. +type ListStakingEventHistoryParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// ListStakingRewardsJSONBody defines parameters for ListStakingRewards. +type ListStakingRewardsJSONBody struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. + Account *string `json:"account,omitempty"` + + // Currency Currency code, see [symbols](/market-data/symbols-and-minimums) + Currency *string `json:"currency,omitempty"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce Nonce `json:"nonce"` + + // ProviderId Borrower Id, in uuid4 format. providerId is accessible from the [Staking rates](#list-staking-rates) response + ProviderId *string `json:"providerId,omitempty"` + + // Request The literal string "/v1/staking/rewards" + Request string `json:"request"` + + // Since In iso datetime with timezone format + Since string `json:"since"` + + // Until In iso datetime with timezone format, default to current time as of server time + Until *string `json:"until,omitempty"` +} + +// ListStakingRewardsParams defines parameters for ListStakingRewards. +type ListStakingRewardsParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// StakeCryptoFundsJSONBody defines parameters for StakeCryptoFunds. +type StakeCryptoFundsJSONBody struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. + Account *string `json:"account,omitempty"` + + // Amount The amount of currency to deposit + Amount string `json:"amount"` + + // Currency Currency code, see [symbols](/market-data/symbols-and-minimums) + Currency string `json:"currency"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce Nonce `json:"nonce"` + + // ProviderId Provider Id, in uuid4 format. providerId is accessible from the [Staking rates](#list-staking-rates) response + ProviderId string `json:"providerId"` + + // Request The literal string "v1/staking/stake" + Request string `json:"request"` +} + +// StakeCryptoFundsParams defines parameters for StakeCryptoFunds. +type StakeCryptoFundsParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// UnstakeCryptoFundsJSONBody defines parameters for UnstakeCryptoFunds. +type UnstakeCryptoFundsJSONBody struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. + Account *string `json:"account,omitempty"` + + // Amount The amount of currency to withdraw + Amount string `json:"amount"` + + // Currency Currency code, see [symbols](/market-data/symbols-and-minimums) + Currency string `json:"currency"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce Nonce `json:"nonce"` + + // ProviderId Provider Id, in uuid4 format. providerId is accessible from the [Staking rates](#list-staking-rates) response + ProviderId string `json:"providerId"` + + // Request The literal string "v1/staking/unstake" + Request string `json:"request"` +} + +// UnstakeCryptoFundsParams defines parameters for UnstakeCryptoFunds. +type UnstakeCryptoFundsParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// GetTransactionHistoryJSONBody defines parameters for GetTransactionHistory. +type GetTransactionHistoryJSONBody struct { + // ContinuationToken For subsequent requests, use the returned `continuation_token` value for next page. If this is defined, do not define “timestamp_nanos”. + ContinuationToken *string `json:"continuation_token,omitempty"` + + // Limit The maximum number of transfers to return. The default is 100 and the maximum is 300. + Limit *int `json:"limit,omitempty"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce Nonce `json:"nonce"` + + // Request The literal string "/v1/transactions" + Request string `json:"request"` + + // TimestampNanos Only return transfers on or after this timestamp in nanos. If this is defined, do not define “continuation_token”. + TimestampNanos *TimestampType `json:"timestamp_nanos,omitempty"` +} + +// GetTransactionHistoryParams defines parameters for GetTransactionHistory. +type GetTransactionHistoryParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// ListPastTransfersJSONBody defines parameters for ListPastTransfers. +type ListPastTransfersJSONBody struct { + // Account Required for Master API keys. The name of the account within the subaccount group. + Account *string `json:"account,omitempty"` + + // Currency Currency code, see symbols and minimums + Currency *string `json:"currency,omitempty"` + + // LimitTransfers The maximum number of transfers to return. The default is 10 and the maximum is 50. + LimitTransfers *int `json:"limit_transfers,omitempty"` + + // Network Filter transfers by blockchain network (e.g., `ethereum`, `solana`, `arbitrum`, `optimism`, `base`, `avalanche`) + Network *string `json:"network,omitempty"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce Nonce `json:"nonce"` + + // Request The literal string "/v2/transfers" + Request string `json:"request"` + + // ShowCompletedDepositAdvances Whether to display completed deposit advances. True by default. + ShowCompletedDepositAdvances *bool `json:"show_completed_deposit_advances,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` +} + +// ListPastTransfersParams defines parameters for ListPastTransfers. +type ListPastTransfersParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// WithdrawCryptoFundsJSONBody defines parameters for WithdrawCryptoFunds. +type WithdrawCryptoFundsJSONBody struct { + // Address The destination address for the withdrawal + Address string `json:"address"` + + // Amount The amount to withdraw + Amount string `json:"amount"` + + // ClientTransferId A unique UUID for idempotent withdrawals. If provided, duplicate requests with the same `clientTransferId` will not create additional withdrawals. + ClientTransferId *openapi_types.UUID `json:"clientTransferId,omitempty"` + + // Memo Required for certain networks that use memos (e.g., Solana, XRP, Cosmos). The destination tag or memo for the withdrawal. + Memo *string `json:"memo,omitempty"` +} + +// WithdrawCryptoFundsParams defines parameters for WithdrawCryptoFunds. +type WithdrawCryptoFundsParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// GetGasFeeEstimationParams defines parameters for GetGasFeeEstimation. +type GetGasFeeEstimationParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// GetAccountDetailJSONRequestBody defines body for GetAccountDetail for application/json ContentType. +type GetAccountDetailJSONRequestBody GetAccountDetailJSONBody + +// CreateNewAccountJSONRequestBody defines body for CreateNewAccount for application/json ContentType. +type CreateNewAccountJSONRequestBody CreateNewAccountJSONBody + +// ListAccountsInGroupJSONRequestBody defines body for ListAccountsInGroup for application/json ContentType. +type ListAccountsInGroupJSONRequestBody ListAccountsInGroupJSONBody + +// RenameAccountJSONRequestBody defines body for RenameAccount for application/json ContentType. +type RenameAccountJSONRequestBody RenameAccountJSONBody + +// TransferBetweenAccountsJSONRequestBody defines body for TransferBetweenAccounts for application/json ContentType. +type TransferBetweenAccountsJSONRequestBody TransferBetweenAccountsJSONBody + +// ListDepositAddressesJSONRequestBody defines body for ListDepositAddresses for application/json ContentType. +type ListDepositAddressesJSONRequestBody ListDepositAddressesJSONBody + +// ListApprovedAddressesJSONRequestBody defines body for ListApprovedAddresses for application/json ContentType. +type ListApprovedAddressesJSONRequestBody ListApprovedAddressesJSONBody + +// RemoveApprovedAddressJSONRequestBody defines body for RemoveApprovedAddress for application/json ContentType. +type RemoveApprovedAddressJSONRequestBody RemoveApprovedAddressJSONBody + +// CreateNewApprovedAddressJSONRequestBody defines body for CreateNewApprovedAddress for application/json ContentType. +type CreateNewApprovedAddressJSONRequestBody CreateNewApprovedAddressJSONBody + +// GetAvailableBalancesJSONRequestBody defines body for GetAvailableBalances for application/json ContentType. +type GetAvailableBalancesJSONRequestBody GetAvailableBalancesJSONBody + +// ListStakingBalancesJSONRequestBody defines body for ListStakingBalances for application/json ContentType. +type ListStakingBalancesJSONRequestBody ListStakingBalancesJSONBody + +// ListCustodyFeeTransfersJSONRequestBody defines body for ListCustodyFeeTransfers for application/json ContentType. +type ListCustodyFeeTransfersJSONRequestBody ListCustodyFeeTransfersJSONBody + +// CreateNewDepositAddressJSONRequestBody defines body for CreateNewDepositAddress for application/json ContentType. +type CreateNewDepositAddressJSONRequestBody CreateNewDepositAddressJSONBody + +// GetNotionalBalancesJSONRequestBody defines body for GetNotionalBalances for application/json ContentType. +type GetNotionalBalancesJSONRequestBody GetNotionalBalancesJSONBody + +// RevokeOAuthTokenJSONRequestBody defines body for RevokeOAuthToken for application/json ContentType. +type RevokeOAuthTokenJSONRequestBody RevokeOAuthTokenJSONBody + +// AddBankJSONRequestBody defines body for AddBank for application/json ContentType. +type AddBankJSONRequestBody AddBankJSONBody + +// AddBankCADJSONRequestBody defines body for AddBankCAD for application/json ContentType. +type AddBankCADJSONRequestBody AddBankCADJSONBody + +// ListPaymentMethodsJSONRequestBody defines body for ListPaymentMethods for application/json ContentType. +type ListPaymentMethodsJSONRequestBody ListPaymentMethodsJSONBody + +// GetRolesJSONRequestBody defines body for GetRoles for application/json ContentType. +type GetRolesJSONRequestBody GetRolesJSONBody + +// ListStakingEventHistoryJSONRequestBody defines body for ListStakingEventHistory for application/json ContentType. +type ListStakingEventHistoryJSONRequestBody ListStakingEventHistoryJSONBody + +// ListStakingRewardsJSONRequestBody defines body for ListStakingRewards for application/json ContentType. +type ListStakingRewardsJSONRequestBody ListStakingRewardsJSONBody + +// StakeCryptoFundsJSONRequestBody defines body for StakeCryptoFunds for application/json ContentType. +type StakeCryptoFundsJSONRequestBody StakeCryptoFundsJSONBody + +// UnstakeCryptoFundsJSONRequestBody defines body for UnstakeCryptoFunds for application/json ContentType. +type UnstakeCryptoFundsJSONRequestBody UnstakeCryptoFundsJSONBody + +// GetTransactionHistoryJSONRequestBody defines body for GetTransactionHistory for application/json ContentType. +type GetTransactionHistoryJSONRequestBody GetTransactionHistoryJSONBody + +// ListPastTransfersJSONRequestBody defines body for ListPastTransfers for application/json ContentType. +type ListPastTransfersJSONRequestBody ListPastTransfersJSONBody + +// WithdrawCryptoFundsJSONRequestBody defines body for WithdrawCryptoFunds for application/json ContentType. +type WithdrawCryptoFundsJSONRequestBody WithdrawCryptoFundsJSONBody + +// GetGasFeeEstimationJSONRequestBody defines body for GetGasFeeEstimation for application/json ContentType. +type GetGasFeeEstimationJSONRequestBody = FeeEstimateV2Request + +// AsHeartbeatNonce0 returns the union data inside the Heartbeat_Nonce as a HeartbeatNonce0 +func (t Heartbeat_Nonce) AsHeartbeatNonce0() (HeartbeatNonce0, error) { + var body HeartbeatNonce0 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromHeartbeatNonce0 overwrites any union data inside the Heartbeat_Nonce as the provided HeartbeatNonce0 +func (t *Heartbeat_Nonce) FromHeartbeatNonce0(v HeartbeatNonce0) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeHeartbeatNonce0 performs a merge with any union data inside the Heartbeat_Nonce, using the provided HeartbeatNonce0 +func (t *Heartbeat_Nonce) MergeHeartbeatNonce0(v HeartbeatNonce0) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsHeartbeatNonce1 returns the union data inside the Heartbeat_Nonce as a HeartbeatNonce1 +func (t Heartbeat_Nonce) AsHeartbeatNonce1() (HeartbeatNonce1, error) { + var body HeartbeatNonce1 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromHeartbeatNonce1 overwrites any union data inside the Heartbeat_Nonce as the provided HeartbeatNonce1 +func (t *Heartbeat_Nonce) FromHeartbeatNonce1(v HeartbeatNonce1) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeHeartbeatNonce1 performs a merge with any union data inside the Heartbeat_Nonce, using the provided HeartbeatNonce1 +func (t *Heartbeat_Nonce) MergeHeartbeatNonce1(v HeartbeatNonce1) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t Heartbeat_Nonce) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *Heartbeat_Nonce) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + +// AsTimestampType returns the union data inside the Nonce as a TimestampType +func (t Nonce) AsTimestampType() (TimestampType, error) { + var body TimestampType + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromTimestampType overwrites any union data inside the Nonce as the provided TimestampType +func (t *Nonce) FromTimestampType(v TimestampType) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeTimestampType performs a merge with any union data inside the Nonce, using the provided TimestampType +func (t *Nonce) MergeTimestampType(v TimestampType) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsNonce1 returns the union data inside the Nonce as a Nonce1 +func (t Nonce) AsNonce1() (Nonce1, error) { + var body Nonce1 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromNonce1 overwrites any union data inside the Nonce as the provided Nonce1 +func (t *Nonce) FromNonce1(v Nonce1) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeNonce1 performs a merge with any union data inside the Nonce, using the provided Nonce1 +func (t *Nonce) MergeNonce1(v Nonce1) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t Nonce) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *Nonce) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + +// AsTimestampType0 returns the union data inside the TimestampType as a TimestampType0 +func (t TimestampType) AsTimestampType0() (TimestampType0, error) { + var body TimestampType0 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromTimestampType0 overwrites any union data inside the TimestampType as the provided TimestampType0 +func (t *TimestampType) FromTimestampType0(v TimestampType0) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeTimestampType0 performs a merge with any union data inside the TimestampType, using the provided TimestampType0 +func (t *TimestampType) MergeTimestampType0(v TimestampType0) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsTimestampType1 returns the union data inside the TimestampType as a TimestampType1 +func (t TimestampType) AsTimestampType1() (TimestampType1, error) { + var body TimestampType1 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromTimestampType1 overwrites any union data inside the TimestampType as the provided TimestampType1 +func (t *TimestampType) FromTimestampType1(v TimestampType1) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeTimestampType1 performs a merge with any union data inside the TimestampType, using the provided TimestampType1 +func (t *TimestampType) MergeTimestampType1(v TimestampType1) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t TimestampType) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *TimestampType) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + +// AsTransaction0 returns the union data inside the Transaction as a Transaction0 +func (t Transaction) AsTransaction0() (Transaction0, error) { + var body Transaction0 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromTransaction0 overwrites any union data inside the Transaction as the provided Transaction0 +func (t *Transaction) FromTransaction0(v Transaction0) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeTransaction0 performs a merge with any union data inside the Transaction, using the provided Transaction0 +func (t *Transaction) MergeTransaction0(v Transaction0) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsTransaction1 returns the union data inside the Transaction as a Transaction1 +func (t Transaction) AsTransaction1() (Transaction1, error) { + var body Transaction1 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromTransaction1 overwrites any union data inside the Transaction as the provided Transaction1 +func (t *Transaction) FromTransaction1(v Transaction1) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeTransaction1 performs a merge with any union data inside the Transaction, using the provided Transaction1 +func (t *Transaction) MergeTransaction1(v Transaction1) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t Transaction) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *Transaction) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} diff --git a/packages/sdk-go/generated/clearing/types.gen.go b/packages/sdk-go/generated/clearing/types.gen.go new file mode 100644 index 00000000..9af1cb75 --- /dev/null +++ b/packages/sdk-go/generated/clearing/types.gen.go @@ -0,0 +1,3339 @@ +// Code generated from rest.yaml (Clearing, Instant). DO NOT EDIT. + +// Package clearing provides primitives to interact with the openapi HTTP API. +// +// Code generated by oapi-codegen. DO NOT EDIT. +package clearing + +import ( + "encoding/json" + "time" + + "github.com/gemini/gemini-go/internal/runtime" + openapi_types "github.com/gemini/gemini-go/types" +) + +const ( + ApiKeyAuthScopes apiKeyAuthContextKey = "apiKeyAuth.Scopes" + PayloadAuthScopes payloadAuthContextKey = "payloadAuth.Scopes" + SignatureAuthScopes signatureAuthContextKey = "signatureAuth.Scopes" +) + +// Defines values for BalanceType. +const ( + Exchange BalanceType = "exchange" +) + +// Valid indicates whether the value is a known member of the BalanceType enum. +func (e BalanceType) Valid() bool { + switch e { + case Exchange: + return true + default: + return false + } +} + +// Defines values for CancelOrderResponseReason. +const ( + ExceedsPriceLimits CancelOrderResponseReason = "ExceedsPriceLimits" + FillOrKillWouldNotFill CancelOrderResponseReason = "FillOrKillWouldNotFill" + ImmediateOrCancelWouldPost CancelOrderResponseReason = "ImmediateOrCancelWouldPost" + MakerOrCancelWouldTake CancelOrderResponseReason = "MakerOrCancelWouldTake" + MarketClosed CancelOrderResponseReason = "MarketClosed" + Requested CancelOrderResponseReason = "Requested" + SelfCrossPrevented CancelOrderResponseReason = "SelfCrossPrevented" + TradingClosed CancelOrderResponseReason = "TradingClosed" +) + +// Valid indicates whether the value is a known member of the CancelOrderResponseReason enum. +func (e CancelOrderResponseReason) Valid() bool { + switch e { + case ExceedsPriceLimits: + return true + case FillOrKillWouldNotFill: + return true + case ImmediateOrCancelWouldPost: + return true + case MakerOrCancelWouldTake: + return true + case MarketClosed: + return true + case Requested: + return true + case SelfCrossPrevented: + return true + case TradingClosed: + return true + default: + return false + } +} + +// Defines values for CancelOrderResponseSide. +const ( + CancelOrderResponseSideBuy CancelOrderResponseSide = "buy" + CancelOrderResponseSideSell CancelOrderResponseSide = "sell" +) + +// Valid indicates whether the value is a known member of the CancelOrderResponseSide enum. +func (e CancelOrderResponseSide) Valid() bool { + switch e { + case CancelOrderResponseSideBuy: + return true + case CancelOrderResponseSideSell: + return true + default: + return false + } +} + +// Defines values for CancelOrderResponseType. +const ( + CancelOrderResponseTypeExchangeLimit CancelOrderResponseType = "exchange limit" + CancelOrderResponseTypeExchangeMarket CancelOrderResponseType = "exchange market" + CancelOrderResponseTypeExchangeStopLimit CancelOrderResponseType = "exchange stop limit" +) + +// Valid indicates whether the value is a known member of the CancelOrderResponseType enum. +func (e CancelOrderResponseType) Valid() bool { + switch e { + case CancelOrderResponseTypeExchangeLimit: + return true + case CancelOrderResponseTypeExchangeMarket: + return true + case CancelOrderResponseTypeExchangeStopLimit: + return true + default: + return false + } +} + +// Defines values for ClearingOrderSide. +const ( + ClearingOrderSideBuy ClearingOrderSide = "buy" + ClearingOrderSideSell ClearingOrderSide = "sell" +) + +// Valid indicates whether the value is a known member of the ClearingOrderSide enum. +func (e ClearingOrderSide) Valid() bool { + switch e { + case ClearingOrderSideBuy: + return true + case ClearingOrderSideSell: + return true + default: + return false + } +} + +// Defines values for FundingPaymentEventType. +const ( + FundingPaymentEventTypeHourlyFundingTransfer FundingPaymentEventType = "Hourly Funding Transfer" +) + +// Valid indicates whether the value is a known member of the FundingPaymentEventType enum. +func (e FundingPaymentEventType) Valid() bool { + switch e { + case FundingPaymentEventTypeHourlyFundingTransfer: + return true + default: + return false + } +} + +// Defines values for FundingPaymentReportItemAction. +const ( + FundingPaymentReportItemActionCredit FundingPaymentReportItemAction = "Credit" + FundingPaymentReportItemActionDebit FundingPaymentReportItemAction = "Debit" +) + +// Valid indicates whether the value is a known member of the FundingPaymentReportItemAction enum. +func (e FundingPaymentReportItemAction) Valid() bool { + switch e { + case FundingPaymentReportItemActionCredit: + return true + case FundingPaymentReportItemActionDebit: + return true + default: + return false + } +} + +// Defines values for FundingPaymentReportItemEventType. +const ( + FundingPaymentReportItemEventTypeHourlyFundingTransfer FundingPaymentReportItemEventType = "Hourly Funding Transfer" +) + +// Valid indicates whether the value is a known member of the FundingPaymentReportItemEventType enum. +func (e FundingPaymentReportItemEventType) Valid() bool { + switch e { + case FundingPaymentReportItemEventTypeHourlyFundingTransfer: + return true + default: + return false + } +} + +// Defines values for FundingTransferAction. +const ( + FundingTransferActionCredit FundingTransferAction = "Credit" + FundingTransferActionDebit FundingTransferAction = "Debit" +) + +// Valid indicates whether the value is a known member of the FundingTransferAction enum. +func (e FundingTransferAction) Valid() bool { + switch e { + case FundingTransferActionCredit: + return true + case FundingTransferActionDebit: + return true + default: + return false + } +} + +// Defines values for InstantQuoteSide. +const ( + InstantQuoteSideBuy InstantQuoteSide = "buy" + InstantQuoteSideSell InstantQuoteSide = "sell" +) + +// Valid indicates whether the value is a known member of the InstantQuoteSide enum. +func (e InstantQuoteSide) Valid() bool { + switch e { + case InstantQuoteSideBuy: + return true + case InstantQuoteSideSell: + return true + default: + return false + } +} + +// Defines values for InterestRateInfoInterval. +const ( + Hour InterestRateInfoInterval = "hour" +) + +// Valid indicates whether the value is a known member of the InterestRateInfoInterval enum. +func (e InterestRateInfoInterval) Valid() bool { + switch e { + case Hour: + return true + default: + return false + } +} + +// Defines values for LimitOrderResponseSide. +const ( + LimitOrderResponseSideBuy LimitOrderResponseSide = "buy" + LimitOrderResponseSideSell LimitOrderResponseSide = "sell" +) + +// Valid indicates whether the value is a known member of the LimitOrderResponseSide enum. +func (e LimitOrderResponseSide) Valid() bool { + switch e { + case LimitOrderResponseSideBuy: + return true + case LimitOrderResponseSideSell: + return true + default: + return false + } +} + +// Defines values for LimitOrderResponseType. +const ( + LimitOrderResponseTypeExchangeLimit LimitOrderResponseType = "exchange limit" + LimitOrderResponseTypeExchangeMarket LimitOrderResponseType = "exchange market" + LimitOrderResponseTypeExchangeStopLimit LimitOrderResponseType = "exchange stop limit" +) + +// Valid indicates whether the value is a known member of the LimitOrderResponseType enum. +func (e LimitOrderResponseType) Valid() bool { + switch e { + case LimitOrderResponseTypeExchangeLimit: + return true + case LimitOrderResponseTypeExchangeMarket: + return true + case LimitOrderResponseTypeExchangeStopLimit: + return true + default: + return false + } +} + +// Defines values for MyTradeBreak. +const ( + Empty MyTradeBreak = "" + TradeCorrect MyTradeBreak = "trade correct" +) + +// Valid indicates whether the value is a known member of the MyTradeBreak enum. +func (e MyTradeBreak) Valid() bool { + switch e { + case Empty: + return true + case TradeCorrect: + return true + default: + return false + } +} + +// Defines values for MyTradeType. +const ( + MyTradeTypeBuy MyTradeType = "Buy" + MyTradeTypeSell MyTradeType = "Sell" +) + +// Valid indicates whether the value is a known member of the MyTradeType enum. +func (e MyTradeType) Valid() bool { + switch e { + case MyTradeTypeBuy: + return true + case MyTradeTypeSell: + return true + default: + return false + } +} + +// Defines values for NewOrderRequestOptions. +const ( + FillOrKill NewOrderRequestOptions = "fill-or-kill" + ImmediateOrCancel NewOrderRequestOptions = "immediate-or-cancel" + MakerOrCancel NewOrderRequestOptions = "maker-or-cancel" +) + +// Valid indicates whether the value is a known member of the NewOrderRequestOptions enum. +func (e NewOrderRequestOptions) Valid() bool { + switch e { + case FillOrKill: + return true + case ImmediateOrCancel: + return true + case MakerOrCancel: + return true + default: + return false + } +} + +// Defines values for NewOrderRequestSide. +const ( + NewOrderRequestSideBuy NewOrderRequestSide = "buy" + NewOrderRequestSideSell NewOrderRequestSide = "sell" +) + +// Valid indicates whether the value is a known member of the NewOrderRequestSide enum. +func (e NewOrderRequestSide) Valid() bool { + switch e { + case NewOrderRequestSideBuy: + return true + case NewOrderRequestSideSell: + return true + default: + return false + } +} + +// Defines values for NewOrderRequestType. +const ( + NewOrderRequestTypeExchangeLimit NewOrderRequestType = "exchange limit" + NewOrderRequestTypeExchangeMarket NewOrderRequestType = "exchange market" + NewOrderRequestTypeExchangeStopLimit NewOrderRequestType = "exchange stop limit" +) + +// Valid indicates whether the value is a known member of the NewOrderRequestType enum. +func (e NewOrderRequestType) Valid() bool { + switch e { + case NewOrderRequestTypeExchangeLimit: + return true + case NewOrderRequestTypeExchangeMarket: + return true + case NewOrderRequestTypeExchangeStopLimit: + return true + default: + return false + } +} + +// Defines values for OrderSide. +const ( + OrderSideBuy OrderSide = "buy" + OrderSideSell OrderSide = "sell" +) + +// Valid indicates whether the value is a known member of the OrderSide enum. +func (e OrderSide) Valid() bool { + switch e { + case OrderSideBuy: + return true + case OrderSideSell: + return true + default: + return false + } +} + +// Defines values for OrderTradesType. +const ( + OrderTradesTypeBuy OrderTradesType = "Buy" + OrderTradesTypeSell OrderTradesType = "Sell" +) + +// Valid indicates whether the value is a known member of the OrderTradesType enum. +func (e OrderTradesType) Valid() bool { + switch e { + case OrderTradesTypeBuy: + return true + case OrderTradesTypeSell: + return true + default: + return false + } +} + +// Defines values for OrderType. +const ( + OrderTypeExchangeLimit OrderType = "exchange limit" + OrderTypeExchangeMarket OrderType = "exchange market" + OrderTypeExchangeStopLimit OrderType = "exchange stop limit" +) + +// Valid indicates whether the value is a known member of the OrderType enum. +func (e OrderType) Valid() bool { + switch e { + case OrderTypeExchangeLimit: + return true + case OrderTypeExchangeMarket: + return true + case OrderTypeExchangeStopLimit: + return true + default: + return false + } +} + +// Defines values for RiskStatsResponseProductType. +const ( + PerpetualSwapContract RiskStatsResponseProductType = "PerpetualSwapContract" +) + +// Valid indicates whether the value is a known member of the RiskStatsResponseProductType enum. +func (e RiskStatsResponseProductType) Valid() bool { + switch e { + case PerpetualSwapContract: + return true + default: + return false + } +} + +// Defines values for StakingTransactionTransactionType. +const ( + StakingTransactionTransactionTypeAdminCreditAdjustment StakingTransactionTransactionType = "AdminCreditAdjustment" + StakingTransactionTransactionTypeAdminDebitAdjustment StakingTransactionTransactionType = "AdminDebitAdjustment" + StakingTransactionTransactionTypeAdminRedeem StakingTransactionTransactionType = "AdminRedeem" + StakingTransactionTransactionTypeDeposit StakingTransactionTransactionType = "Deposit" + StakingTransactionTransactionTypeInterest StakingTransactionTransactionType = "Interest" + StakingTransactionTransactionTypeRedeem StakingTransactionTransactionType = "Redeem" + StakingTransactionTransactionTypeRedeemPayment StakingTransactionTransactionType = "RedeemPayment" +) + +// Valid indicates whether the value is a known member of the StakingTransactionTransactionType enum. +func (e StakingTransactionTransactionType) Valid() bool { + switch e { + case StakingTransactionTransactionTypeAdminCreditAdjustment: + return true + case StakingTransactionTransactionTypeAdminDebitAdjustment: + return true + case StakingTransactionTransactionTypeAdminRedeem: + return true + case StakingTransactionTransactionTypeDeposit: + return true + case StakingTransactionTransactionTypeInterest: + return true + case StakingTransactionTransactionTypeRedeem: + return true + case StakingTransactionTransactionTypeRedeemPayment: + return true + default: + return false + } +} + +// Defines values for StopLimitOrderResponseSide. +const ( + StopLimitOrderResponseSideBuy StopLimitOrderResponseSide = "buy" + StopLimitOrderResponseSideSell StopLimitOrderResponseSide = "sell" +) + +// Valid indicates whether the value is a known member of the StopLimitOrderResponseSide enum. +func (e StopLimitOrderResponseSide) Valid() bool { + switch e { + case StopLimitOrderResponseSideBuy: + return true + case StopLimitOrderResponseSideSell: + return true + default: + return false + } +} + +// Defines values for StopLimitOrderResponseType. +const ( + ExchangeStopLimit StopLimitOrderResponseType = "exchange stop limit" +) + +// Valid indicates whether the value is a known member of the StopLimitOrderResponseType enum. +func (e StopLimitOrderResponseType) Valid() bool { + switch e { + case ExchangeStopLimit: + return true + default: + return false + } +} + +// Defines values for TradeType. +const ( + TradeTypeBuy TradeType = "buy" + TradeTypeSell TradeType = "sell" +) + +// Valid indicates whether the value is a known member of the TradeType enum. +func (e TradeType) Valid() bool { + switch e { + case TradeTypeBuy: + return true + case TradeTypeSell: + return true + default: + return false + } +} + +// Defines values for TransferStatus. +const ( + TransferStatusComplete TransferStatus = "Complete" + TransferStatusPending TransferStatus = "Pending" +) + +// Valid indicates whether the value is a known member of the TransferStatus enum. +func (e TransferStatus) Valid() bool { + switch e { + case TransferStatusComplete: + return true + case TransferStatusPending: + return true + default: + return false + } +} + +// Defines values for TransferType. +const ( + TransferTypeDeposit TransferType = "Deposit" + TransferTypeWithdrawal TransferType = "Withdrawal" +) + +// Valid indicates whether the value is a known member of the TransferType enum. +func (e TransferType) Valid() bool { + switch e { + case TransferTypeDeposit: + return true + case TransferTypeWithdrawal: + return true + default: + return false + } +} + +// Defines values for V2TransferStatus. +const ( + V2TransferStatusAdvanced V2TransferStatus = "Advanced" + V2TransferStatusComplete V2TransferStatus = "Complete" + V2TransferStatusPending V2TransferStatus = "Pending" +) + +// Valid indicates whether the value is a known member of the V2TransferStatus enum. +func (e V2TransferStatus) Valid() bool { + switch e { + case V2TransferStatusAdvanced: + return true + case V2TransferStatusComplete: + return true + case V2TransferStatusPending: + return true + default: + return false + } +} + +// Defines values for V2TransferType. +const ( + AdminCredit V2TransferType = "AdminCredit" + AdminDebit V2TransferType = "AdminDebit" + Deposit V2TransferType = "Deposit" + Reward V2TransferType = "Reward" + Withdrawal V2TransferType = "Withdrawal" +) + +// Valid indicates whether the value is a known member of the V2TransferType enum. +func (e V2TransferType) Valid() bool { + switch e { + case AdminCredit: + return true + case AdminDebit: + return true + case Deposit: + return true + case Reward: + return true + case Withdrawal: + return true + default: + return false + } +} + +// Defines values for ListClearingBrokers200JSONResponseBodyOrdersSourceSide. +const ( + ListClearingBrokers200JSONResponseBodyOrdersSourceSideBuy ListClearingBrokers200JSONResponseBodyOrdersSourceSide = "buy" + ListClearingBrokers200JSONResponseBodyOrdersSourceSideSell ListClearingBrokers200JSONResponseBodyOrdersSourceSide = "sell" +) + +// Valid indicates whether the value is a known member of the ListClearingBrokers200JSONResponseBodyOrdersSourceSide enum. +func (e ListClearingBrokers200JSONResponseBodyOrdersSourceSide) Valid() bool { + switch e { + case ListClearingBrokers200JSONResponseBodyOrdersSourceSideBuy: + return true + case ListClearingBrokers200JSONResponseBodyOrdersSourceSideSell: + return true + default: + return false + } +} + +// Defines values for CreateNewBrokerOrderJSONBodySide. +const ( + CreateNewBrokerOrderJSONBodySideBuy CreateNewBrokerOrderJSONBodySide = "buy" + CreateNewBrokerOrderJSONBodySideSell CreateNewBrokerOrderJSONBodySide = "sell" +) + +// Valid indicates whether the value is a known member of the CreateNewBrokerOrderJSONBodySide enum. +func (e CreateNewBrokerOrderJSONBodySide) Valid() bool { + switch e { + case CreateNewBrokerOrderJSONBodySideBuy: + return true + case CreateNewBrokerOrderJSONBodySideSell: + return true + default: + return false + } +} + +// Defines values for ConfirmClearingOrderJSONBodySide. +const ( + ConfirmClearingOrderJSONBodySideBuy ConfirmClearingOrderJSONBodySide = "buy" + ConfirmClearingOrderJSONBodySideSell ConfirmClearingOrderJSONBodySide = "sell" +) + +// Valid indicates whether the value is a known member of the ConfirmClearingOrderJSONBodySide enum. +func (e ConfirmClearingOrderJSONBodySide) Valid() bool { + switch e { + case ConfirmClearingOrderJSONBodySideBuy: + return true + case ConfirmClearingOrderJSONBodySideSell: + return true + default: + return false + } +} + +// Defines values for ListClearingOrdersJSONBodySide. +const ( + ListClearingOrdersJSONBodySideBuy ListClearingOrdersJSONBodySide = "buy" + ListClearingOrdersJSONBodySideSell ListClearingOrdersJSONBodySide = "sell" +) + +// Valid indicates whether the value is a known member of the ListClearingOrdersJSONBodySide enum. +func (e ListClearingOrdersJSONBodySide) Valid() bool { + switch e { + case ListClearingOrdersJSONBodySideBuy: + return true + case ListClearingOrdersJSONBodySideSell: + return true + default: + return false + } +} + +// Defines values for ListClearingOrders200JSONResponseBodyOrdersSide. +const ( + ListClearingOrders200JSONResponseBodyOrdersSideBuy ListClearingOrders200JSONResponseBodyOrdersSide = "buy" + ListClearingOrders200JSONResponseBodyOrdersSideSell ListClearingOrders200JSONResponseBodyOrdersSide = "sell" +) + +// Valid indicates whether the value is a known member of the ListClearingOrders200JSONResponseBodyOrdersSide enum. +func (e ListClearingOrders200JSONResponseBodyOrdersSide) Valid() bool { + switch e { + case ListClearingOrders200JSONResponseBodyOrdersSideBuy: + return true + case ListClearingOrders200JSONResponseBodyOrdersSideSell: + return true + default: + return false + } +} + +// Defines values for CreateNewClearingOrderJSONBodySide. +const ( + CreateNewClearingOrderJSONBodySideBuy CreateNewClearingOrderJSONBodySide = "buy" + CreateNewClearingOrderJSONBodySideSell CreateNewClearingOrderJSONBodySide = "sell" +) + +// Valid indicates whether the value is a known member of the CreateNewClearingOrderJSONBodySide enum. +func (e CreateNewClearingOrderJSONBodySide) Valid() bool { + switch e { + case CreateNewClearingOrderJSONBodySideBuy: + return true + case CreateNewClearingOrderJSONBodySideSell: + return true + default: + return false + } +} + +// Defines values for ListClearingTrades200JSONResponseBodyResultsSourceSide. +const ( + ListClearingTrades200JSONResponseBodyResultsSourceSideBuy ListClearingTrades200JSONResponseBodyResultsSourceSide = "buy" + ListClearingTrades200JSONResponseBodyResultsSourceSideSell ListClearingTrades200JSONResponseBodyResultsSourceSide = "sell" +) + +// Valid indicates whether the value is a known member of the ListClearingTrades200JSONResponseBodyResultsSourceSide enum. +func (e ListClearingTrades200JSONResponseBodyResultsSourceSide) Valid() bool { + switch e { + case ListClearingTrades200JSONResponseBodyResultsSourceSideBuy: + return true + case ListClearingTrades200JSONResponseBodyResultsSourceSideSell: + return true + default: + return false + } +} + +// Defines values for ExecuteInstantOrderJSONBodySide. +const ( + ExecuteInstantOrderJSONBodySideBuy ExecuteInstantOrderJSONBodySide = "buy" + ExecuteInstantOrderJSONBodySideSell ExecuteInstantOrderJSONBodySide = "sell" +) + +// Valid indicates whether the value is a known member of the ExecuteInstantOrderJSONBodySide enum. +func (e ExecuteInstantOrderJSONBodySide) Valid() bool { + switch e { + case ExecuteInstantOrderJSONBodySideBuy: + return true + case ExecuteInstantOrderJSONBodySideSell: + return true + default: + return false + } +} + +// Defines values for GetInstantQuoteJSONBodySide. +const ( + Buy GetInstantQuoteJSONBodySide = "buy" + Sell GetInstantQuoteJSONBodySide = "sell" +) + +// Valid indicates whether the value is a known member of the GetInstantQuoteJSONBodySide enum. +func (e GetInstantQuoteJSONBodySide) Valid() bool { + switch e { + case Buy: + return true + case Sell: + return true + default: + return false + } +} + +// Account defines model for Account. +type Account struct { + // AccountId The account ID + AccountId *string `json:"account_id,omitempty"` + + // Created The creation date + Created *string `json:"created,omitempty"` + + // IsDefault Whether the account is the default account + IsDefault *bool `json:"is_default,omitempty"` + + // Name The account name + Name *string `json:"name,omitempty"` +} + +// AddBankResponse defines model for AddBankResponse. +type AddBankResponse struct { + // ReferenceId Reference ID for the new bank addition request. Once received, send in a wire from the requested bank account to verify it and enable withdrawals to that account. + ReferenceId *string `json:"referenceId,omitempty"` + + // Result Status result (e.g. ok). + Result *string `json:"result,omitempty"` +} + +// Address defines model for Address. +type Address struct { + // Address String representation of the cryptocurrency address + Address *string `json:"address,omitempty"` + + // Label If you provided a label when creating the address, it will be echoed back here + Label *string `json:"label,omitempty"` + + // Memo It would be present if applicable, it will be present for cosmos address + Memo *string `json:"memo,omitempty"` + + // Network The blockchain network for the address + Network *string `json:"network,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` +} + +// ApprovedAddress defines model for ApprovedAddress. +type ApprovedAddress struct { + // Address The address on the approved address list. + Address *string `json:"address,omitempty"` + + // CreatedAt UTC timestamp in millisecond of when the address was created. + CreatedAt *string `json:"createdAt,omitempty"` + + // Label The label assigned to the address + Label *string `json:"label,omitempty"` + + // Network The network of the approved address. Network can be `bitcoin`, `ethereum`, `bitcoincash`, `litecoin`, `zcash`, `filecoin`, `dogecoin`, `tezos`, `solana`, `polkadot`, `avalanche`, `cosmos`, or `xrpl` + Network *string `json:"network,omitempty"` + + // Scope Will return the scope of the address as either "account" or "group" + Scope *string `json:"scope,omitempty"` + + // Status The status of the address that will return as "active", "pending-time" or "pending-mua". The remaining time is exactly 7 days after the initial request. "pending-mua" is for multi-user accounts and will require another administator or fund manager on the account to approve the address. + Status *string `json:"status,omitempty"` +} + +// ApprovedAddressMessage defines model for ApprovedAddressMessage. +type ApprovedAddressMessage struct { + // Message Status or confirmation message for the approved address request or removal. + Message *string `json:"message,omitempty"` + + // Result Result status (e.g. ok). + Result *string `json:"result,omitempty"` +} + +// ApprovedAddressesResponse Response envelope containing the approved withdrawal addresses. +type ApprovedAddressesResponse struct { + // ApprovedAddresses Array of approved addresses on both the account and group level. + ApprovedAddresses *[]ApprovedAddress `json:"approvedAddresses,omitempty"` +} + +// Balance defines model for Balance. +type Balance struct { + // UnderscoreTimestamp Server-side monotonically increasing clock value as an ISO 8601 timestamp. Clients can use this value to detect and filter out stale responses that may occur due to load balancing or potential stale servers. + UnderscoreTimestamp *time.Time `json:"_timestamp,omitempty"` + + // Amount The confirmed balance for the currency (also referred to as `confirmedBalance`). For crypto withdrawals, this value is **not** reduced until the withdrawal has been confirmed on the blockchain. This delay protects against blockchain reorganizations. Use the `available` field instead if you need balances that immediately reflect holds. + Amount *openapi_types.DecimalNumber `json:"amount,omitempty"` + + // Available The amount available for trading. This value is reduced **immediately** when an order hold or withdrawal hold is placed, making it the recommended field for tracking real-time spendable balances. + Available *openapi_types.DecimalNumber `json:"available,omitempty"` + + // AvailableForWithdrawal The amount available for withdrawal + AvailableForWithdrawal *openapi_types.DecimalNumber `json:"availableForWithdrawal,omitempty"` + + // Currency The currency symbol + Currency *string `json:"currency,omitempty"` + + // PendingDeposit The amount pending deposit + PendingDeposit *openapi_types.DecimalNumber `json:"pendingDeposit,omitempty"` + + // PendingWithdrawal The amount pending withdrawal + PendingWithdrawal *openapi_types.DecimalNumber `json:"pendingWithdrawal,omitempty"` + Type *BalanceType `json:"type,omitempty"` +} + +// BalanceType defines model for Balance.Type. +type BalanceType string + +// CancelAllOrdersBySessionRequest defines model for CancelAllOrdersBySessionRequest. +type CancelAllOrdersBySessionRequest struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Specifies the account on which you intend to cancel the orders. Only available for exchange accounts. + Account *string `json:"account,omitempty"` + + // Nonce timestamp + Nonce TimestampType `json:"nonce"` + + // Request The literal string "/v1/order/cancel/session" + Request string `json:"request"` +} + +// CancelAllOrdersRequest defines model for CancelAllOrdersRequest. +type CancelAllOrdersRequest struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Specifies the account on which you intend to cancel the orders. Only available for exchange accounts. + Account *string `json:"account,omitempty"` + + // Nonce timestamp + Nonce TimestampType `json:"nonce"` + + // Request The literal string "/v1/order/cancel/all" + Request string `json:"request"` +} + +// CancelAllResult defines model for CancelAllResult. +type CancelAllResult struct { + // Details cancelledOrders/cancelRejects with IDs of both + Details *struct { + CancelRejects *[]int `json:"cancelRejects,omitempty"` + CancelledOrders *[]int `json:"cancelledOrders,omitempty"` + } `json:"details,omitempty"` + Result *string `json:"result,omitempty"` +} + +// CancelOrderRequest defines model for CancelOrderRequest. +type CancelOrderRequest struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Specifies the account on which you intend to cancel the order. Only available for exchange accounts. + Account *string `json:"account,omitempty"` + + // Nonce timestamp + Nonce TimestampType `json:"nonce"` + + // OrderId The order ID given by `/order/new` + OrderId int64 `json:"order_id"` + + // Request The literal string "/v1/order/cancel" + Request string `json:"request"` +} + +// CancelOrderResponse defines model for CancelOrderResponse. +type CancelOrderResponse struct { + AvgExecutionPrice *string `json:"avg_execution_price,omitempty"` + Exchange *string `json:"exchange,omitempty"` + ExecutedAmount *string `json:"executed_amount,omitempty"` + Id *string `json:"id,omitempty"` + IsCancelled *bool `json:"is_cancelled,omitempty"` + IsHidden *bool `json:"is_hidden,omitempty"` + IsLive *bool `json:"is_live,omitempty"` + Options *[]string `json:"options,omitempty"` + OrderId *string `json:"order_id,omitempty"` + OriginalAmount *string `json:"original_amount,omitempty"` + Price *string `json:"price,omitempty"` + Reason *CancelOrderResponseReason `json:"reason,omitempty"` + RemainingAmount *string `json:"remaining_amount,omitempty"` + Side *CancelOrderResponseSide `json:"side,omitempty"` + Symbol *string `json:"symbol,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + Type *CancelOrderResponseType `json:"type,omitempty"` + WasForced *bool `json:"was_forced,omitempty"` +} + +// CancelOrderResponseReason defines model for CancelOrderResponse.Reason. +type CancelOrderResponseReason string + +// CancelOrderResponseSide defines model for CancelOrderResponse.Side. +type CancelOrderResponseSide string + +// CancelOrderResponseType defines model for CancelOrderResponse.Type. +type CancelOrderResponseType string + +// Candle defines model for Candle. +type Candle = []float64 + +// CandleResponse defines model for CandleResponse. +type CandleResponse = []Candle + +// ClearingOrder defines model for ClearingOrder. +type ClearingOrder struct { + // Amount The order amount + Amount *string `json:"amount,omitempty"` + + // ClearingId The clearing ID + ClearingId *string `json:"clearing_id,omitempty"` + + // IsConfirmed Whether the order is confirmed + IsConfirmed *bool `json:"is_confirmed,omitempty"` + + // Price The order price + Price *string `json:"price,omitempty"` + Side *ClearingOrderSide `json:"side,omitempty"` + + // Status The order status + Status *string `json:"status,omitempty"` + + // Symbol The trading pair + Symbol *string `json:"symbol,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + + // Timestampms The timestamp in milliseconds + Timestampms *int `json:"timestampms,omitempty"` +} + +// ClearingOrderSide defines model for ClearingOrder.Side. +type ClearingOrderSide string + +// CustodyFeeTransfer defines model for CustodyFeeTransfer. +type CustodyFeeTransfer struct { + // Eid Custody fee event id + Eid *int `json:"eid,omitempty"` + + // EventType Custody fee event type + EventType *string `json:"eventType,omitempty"` + + // FeeAmount The fee amount charged + FeeAmount *string `json:"feeAmount,omitempty"` + + // FeeCurrency Currency that the fee was paid in + FeeCurrency *string `json:"feeCurrency,omitempty"` + + // TxTime Time of Custody fee record in milliseconds + TxTime *int `json:"txTime,omitempty"` +} + +// ErrorResponse defines model for ErrorResponse. +type ErrorResponse struct { + // Message Detailed error message + Message *string `json:"message,omitempty"` + + // Reason A short description + Reason *string `json:"reason,omitempty"` + + // Result Error + Result *string `json:"result,omitempty"` +} + +// FeeEstimateRequest defines model for FeeEstimateRequest. +type FeeEstimateRequest struct { + // Account The name of the account within the subaccount group. + Account string `json:"account"` + + // Address Standard string format of cryptocurrency address + Address string `json:"address"` + + // Amount Quoted decimal amount to withdraw + Amount string `json:"amount"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce Nonce `json:"nonce"` + + // Request The string `/v1/withdraw/{currencyCodeLowerCase}/feeEstimate` where `:currencyCodeLowerCase` is replaced with the currency code of a supported crypto-currency, e.g. `eth`, `aave`, etc. See [Symbols and minimums](/market-data/symbols-and-minimums) + Request string `json:"request"` +} + +// FeeEstimateResponse defines model for FeeEstimateResponse. +type FeeEstimateResponse struct { + // Currency Currency code, see [symbols](/market-data/symbols-and-minimums). + Currency *string `json:"currency,omitempty"` + + // Fee The estimated gas fee + Fee *string `json:"fee,omitempty"` + + // IsOverride Value that shows if an override on the customer's account for free withdrawals exists + IsOverride *bool `json:"isOverride,omitempty"` + + // MonthlyLimit Total nunber of allowable fee-free withdrawals + MonthlyLimit *int `json:"monthlyLimit,omitempty"` + + // MonthlyRemaining Total number of allowable fee-free withdrawals left to use + MonthlyRemaining *int `json:"monthlyRemaining,omitempty"` +} + +// FeeEstimateV2Request defines model for FeeEstimateV2Request. +type FeeEstimateV2Request struct { + // Account Required for Master API keys. The name of the account within the subaccount group. + Account *string `json:"account,omitempty"` + + // Address Standard string format of the destination cryptocurrency address + Address string `json:"address"` + + // Amount Quoted decimal amount to withdraw + Amount string `json:"amount"` + + // Memo It would be present if applicable, it will be present for cosmos address. + Memo *string `json:"memo,omitempty"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce Nonce `json:"nonce"` + + // Request The string `/v2/withdraw/{network}/{ticker}/feeEstimate` where `{network}` is the blockchain network (e.g. `ethereum`, `bitcoin`, `solana`) and `{ticker}` is the currency code (e.g. `eth`, `btc`, `sol`). See [Symbols and minimums](/market-data/symbols-and-minimums) + Request string `json:"request"` +} + +// FeeEstimateV2Response defines model for FeeEstimateV2Response. +type FeeEstimateV2Response struct { + // Currency Currency code, see [symbols](/market-data/symbols-and-minimums). + Currency *string `json:"currency,omitempty"` + + // Fee The estimated withdrawal fee as a decimal amount + Fee *openapi_types.DecimalNumber `json:"fee,omitempty"` + + // IsOverride Whether an override on the customer's account for free withdrawals exists + IsOverride *bool `json:"isOverride,omitempty"` + + // MonthlyLimit Total number of allowable fee-free withdrawals + MonthlyLimit *int `json:"monthlyLimit,omitempty"` + + // MonthlyRemaining Total number of allowable fee-free withdrawals remaining + MonthlyRemaining *int `json:"monthlyRemaining,omitempty"` +} + +// FeePromos defines model for FeePromos. +type FeePromos struct { + // Symbols Symbols that currently have fee promos + Symbols *[]string `json:"symbols,omitempty"` +} + +// FundingAmountResponse defines model for FundingAmountResponse. +type FundingAmountResponse struct { + // Amount The dollar amount for a Long 1 position held in the symbol for funding period (1 hour) + Amount *openapi_types.DecimalNumber `json:"amount,omitempty"` + + // EstimatedFundingAmount The estimated dollar amount for a Long 1 position held in the symbol for next funding period (1 hour) + EstimatedFundingAmount *openapi_types.DecimalNumber `json:"estimatedFundingAmount,omitempty"` + + // FundingDateTime UTC date time in format `yyyy-MM-ddThh:mm:ss.SSSZ` format + FundingDateTime *string `json:"fundingDateTime,omitempty"` + + // FundingTimestampMilliSecs Current funding amount Epoc time. + FundingTimestampMilliSecs *int64 `json:"fundingTimestampMilliSecs,omitempty"` + + // NextFundingTimestamp Next funding amount Epoc time. + NextFundingTimestamp *int64 `json:"nextFundingTimestamp,omitempty"` + + // Symbol The requested symbol. See [**symbols and minimums**](/market-data/symbols-and-minimums#all-supported-symbols) + Symbol *string `json:"symbol,omitempty"` +} + +// FundingPayment defines model for FundingPayment. +type FundingPayment struct { + // EventType Event type + EventType FundingPaymentEventType `json:"eventType"` + HourlyFundingTransfer FundingTransfer `json:"hourlyFundingTransfer"` +} + +// FundingPaymentEventType Event type +type FundingPaymentEventType string + +// FundingPaymentReportItem defines model for FundingPaymentReportItem. +type FundingPaymentReportItem struct { + // Action Credit or Debit + Action FundingPaymentReportItemAction `json:"action"` + + // AssetCode Asset symbol + AssetCode string `json:"assetCode"` + + // EventType Event type + EventType FundingPaymentReportItemEventType `json:"eventType"` + + // InstrumentSymbol Symbol of the underlying instrument. **Note** that this is only attached to requests from 16th April 2024 onwards. + InstrumentSymbol *string `json:"instrumentSymbol,omitempty"` + + // Quantity A nested JSON object describing the transaction amount + Quantity Quantity `json:"quantity"` + + // Timestamp Time of the funding payment + Timestamp TimestampType `json:"timestamp"` +} + +// FundingPaymentReportItemAction Credit or Debit +type FundingPaymentReportItemAction string + +// FundingPaymentReportItemEventType Event type +type FundingPaymentReportItemEventType string + +// FundingTransfer defines model for FundingTransfer. +type FundingTransfer struct { + // Action Credit or Debit + Action FundingTransferAction `json:"action"` + + // AssetCode Asset symbol + AssetCode string `json:"assetCode"` + + // EventType Event type + EventType string `json:"eventType"` + + // InstrumentSymbol Symbol of the underlying instrument. **Note** that this is only attached to requests from 16th April 2024 onwards. + InstrumentSymbol *string `json:"instrumentSymbol,omitempty"` + + // Quantity A nested JSON object describing the transaction amount + Quantity Quantity `json:"quantity"` + + // Timestamp Time of the funding payment + Timestamp TimestampType `json:"timestamp"` +} + +// FundingTransferAction Credit or Debit +type FundingTransferAction string + +// FxRate defines model for FxRate. +type FxRate struct { + // AsOf timestamp + AsOf *TimestampType `json:"asOf,omitempty"` + + // Benchmark The market for which the retrieved price applies to + Benchmark *string `json:"benchmark,omitempty"` + + // FxPair The requested currency pair + FxPair *string `json:"fxPair,omitempty"` + + // Provider The market data provider + Provider *string `json:"provider,omitempty"` + + // Rate The exchange rate + Rate *float64 `json:"rate,omitempty"` +} + +// Heartbeat defines model for Heartbeat. +type Heartbeat struct { + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce *Heartbeat_Nonce `json:"nonce,omitempty"` + + // Request The literal string `/v1/heartbeat` + Request *string `json:"request,omitempty"` +} + +// HeartbeatNonce0 Gemini strongly recommends using milliseconds instead of seconds for timestamps. +// +// | Timestamp format | Example | Supported request type | +// |-----------------------|-----------------------|------------------------| +// | string (seconds) | `'1495127793'` | `POST` only | +// | string (milliseconds) | `'1495127793000'` | `POST` only | +type HeartbeatNonce0 = string + +// HeartbeatNonce1 Gemini strongly recommends using milliseconds instead of seconds for timestamps. +// +// | Timestamp format | Example | Supported request type | +// |-----------------------------|---------------------------|------------------------| +// | whole number (seconds) | `1495127793` | `GET`, `POST` | +// | whole number (milliseconds) | `1495127793000` | `GET`, `POST` | +type HeartbeatNonce1 = int64 + +// Heartbeat_Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) +type Heartbeat_Nonce struct { + union json.RawMessage +} + +// InstantQuote defines model for InstantQuote. +type InstantQuote struct { + // DepositFee The deposit fee quantity. Will be applied if a debit card is used for the order. Will return 0 if there is no `depositFee` + DepositFee *string `json:"depositFee,omitempty"` + + // DepositFeeCurrency Currency in which `depositFee` is taken + DepositFeeCurrency *string `json:"depositFeeCurrency,omitempty"` + + // Fee The fee quantity to be taken for the order upon execution + Fee *string `json:"fee,omitempty"` + + // FeeCurrency The currency label for the order + FeeCurrency *string `json:"feeCurrency,omitempty"` + + // MaxAgeMs Number of milliseconds until this quote price expires. Once expired, you will need to request a new quote + MaxAgeMs *int `json:"maxAgeMs,omitempty"` + + // Pair The symbol passed in the quote request + Pair *string `json:"pair,omitempty"` + + // Price The quoted price of the asset. This will not change when attempting execution + Price *string `json:"price,omitempty"` + + // PriceCurrency The currency in which the order is priced. Matches `CCY2` in the symbol + PriceCurrency *string `json:"priceCurrency,omitempty"` + + // Quantity The quantity of the asset to be bought or sold + Quantity *string `json:"quantity,omitempty"` + + // QuantityCurrency The currency label for the `quantity` field. Matches `CCY1` in the symbol + QuantityCurrency *string `json:"quantityCurrency,omitempty"` + + // QuoteId Unique ID for the quote. This is used in the execution of the order + QuoteId *int `json:"quoteId,omitempty"` + + // Side Either "buy" or "sell" + Side *InstantQuoteSide `json:"side,omitempty"` + + // TotalSpend Total quantity to spend for the order. Will be the sum inclusive of all fees and amount to be traded. + TotalSpend *string `json:"totalSpend,omitempty"` + + // TotalSpendCurrency Currency of the `totalSpend` to be spent on the order + TotalSpendCurrency *string `json:"totalSpendCurrency,omitempty"` +} + +// InstantQuoteSide Either "buy" or "sell" +type InstantQuoteSide string + +// InterestRateInfo defines model for InterestRateInfo. +type InterestRateInfo struct { + // Interval The time interval for the rate (currently only "hour" is supported) + Interval InterestRateInfoInterval `json:"interval"` + + // Rate The interest rate as a decimal string + Rate string `json:"rate"` +} + +// InterestRateInfoInterval The time interval for the rate (currently only "hour" is supported) +type InterestRateInfoInterval string + +// LimitOrderResponse defines model for LimitOrderResponse. +type LimitOrderResponse struct { + AvgExecutionPrice *string `json:"avg_execution_price,omitempty"` + ClientOrderId *string `json:"client_order_id,omitempty"` + Exchange *string `json:"exchange,omitempty"` + ExecutedAmount *string `json:"executed_amount,omitempty"` + Id *string `json:"id,omitempty"` + IsCancelled *bool `json:"is_cancelled,omitempty"` + IsHidden *bool `json:"is_hidden,omitempty"` + IsLive *bool `json:"is_live,omitempty"` + Options *[]string `json:"options,omitempty"` + OrderId *string `json:"order_id,omitempty"` + OriginalAmount *string `json:"original_amount,omitempty"` + Price *string `json:"price,omitempty"` + RemainingAmount *string `json:"remaining_amount,omitempty"` + Side *LimitOrderResponseSide `json:"side,omitempty"` + Symbol *string `json:"symbol,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + Type *LimitOrderResponseType `json:"type,omitempty"` + WasForced *bool `json:"was_forced,omitempty"` +} + +// LimitOrderResponseSide defines model for LimitOrderResponse.Side. +type LimitOrderResponseSide string + +// LimitOrderResponseType defines model for LimitOrderResponse.Type. +type LimitOrderResponseType string + +// LiquidationRisk defines model for LiquidationRisk. +type LiquidationRisk struct { + // LiquidationPrice The estimated price at which liquidation would occur (optional, may not be present for all positions) + LiquidationPrice *MoneyAmount `json:"liquidationPrice,omitempty"` + + // LossPercentage The percentage loss from current value that would trigger liquidation, formatted as decimal (e.g., "0.1550" = 15.50%) + LossPercentage string `json:"lossPercentage"` +} + +// MarginAccountSummary defines model for MarginAccountSummary. +type MarginAccountSummary struct { + // AvailableCollateral The amount of collateral available for new positions or withdrawals + AvailableCollateral MoneyAmount `json:"availableCollateral"` + + // BuyingPower The maximum value that can be purchased with available collateral + BuyingPower MoneyAmount `json:"buyingPower"` + + // InterestRate Current interest rate on borrowed amounts (only present if borrows exist) + InterestRate *InterestRateInfo `json:"interestRate,omitempty"` + + // Leverage The current leverage ratio (notionalValue / marginAssetValue) + Leverage string `json:"leverage"` + + // LiquidationRisk Liquidation risk information (only present if positions exist) + LiquidationRisk *LiquidationRisk `json:"liquidationRisk,omitempty"` + + // MarginAssetValue The total value of all assets available in the margin account that can contribute to funding positions + MarginAssetValue MoneyAmount `json:"marginAssetValue"` + + // NotionalValue The total value of all open positions + NotionalValue MoneyAmount `json:"notionalValue"` + + // ReservedBuyOrders Collateral reserved for open buy orders + ReservedBuyOrders MoneyAmount `json:"reservedBuyOrders"` + + // ReservedSellOrders Collateral reserved for open sell orders + ReservedSellOrders MoneyAmount `json:"reservedSellOrders"` + + // SellingPower The maximum value that can be sold with available collateral + SellingPower MoneyAmount `json:"sellingPower"` + + // TotalBorrowed The total amount currently borrowed across all currencies + TotalBorrowed MoneyAmount `json:"totalBorrowed"` +} + +// MarginInterestRate defines model for MarginInterestRate. +type MarginInterestRate struct { + // BorrowRate The hourly borrow rate as a decimal + BorrowRate string `json:"borrowRate"` + + // BorrowRateAnnual The annualized borrow rate (daily rate × 365) + BorrowRateAnnual string `json:"borrowRateAnnual"` + + // BorrowRateDaily The daily borrow rate (hourly rate × 24) + BorrowRateDaily string `json:"borrowRateDaily"` + + // Currency The currency code (e.g., "BTC", "ETH", "USD") + Currency string `json:"currency"` + + // LastUpdated Unix timestamp in milliseconds when the rate was last updated + LastUpdated int64 `json:"lastUpdated"` +} + +// MarginOrderPreview defines model for MarginOrderPreview. +type MarginOrderPreview struct { + // Postorder Margin risk statistics after the order would be executed + Postorder MarginRiskStats `json:"postorder"` + + // Preorder Margin risk statistics before the order would be executed + Preorder MarginRiskStats `json:"preorder"` +} + +// MarginRatesResponse defines model for MarginRatesResponse. +type MarginRatesResponse struct { + // Rates Array of interest rates for all borrowable currencies + Rates []MarginInterestRate `json:"rates"` +} + +// MarginResponse defines model for MarginResponse. +type MarginResponse struct { + // AvailableMargin The difference between the `margin_assets_value` and `initial_margin`. + AvailableMargin *string `json:"available_margin,omitempty"` + + // BuyingPower The amount of that product the account could purchase based on current `initial_margin` and `margin_assets_value`. + BuyingPower *string `json:"buying_power,omitempty"` + + // EstimatedLiquidationPrice The estimated price for the asset at which liquidation would occur. + EstimatedLiquidationPrice *string `json:"estimated_liquidation_price,omitempty"` + + // InitialMargin The $ amount that is being required by the accounts current positions and open orders. + InitialMargin *string `json:"initial_margin,omitempty"` + + // InitialMarginPositions The contribution to `initial_margin` from open positions. + InitialMarginPositions *string `json:"initial_margin_positions,omitempty"` + + // Leverage The ratio of Notional Value to Margin Assets Value. + Leverage *string `json:"leverage,omitempty"` + + // MarginAssetsValue The $ equivalent value of all the assets available in the current trading account that can contribute to funding a derivatives position. + MarginAssetsValue *string `json:"margin_assets_value,omitempty"` + + // MarginMaintenanceLimit The minimum amount of `margin_assets_value` required before the account is moved to liquidation status. + MarginMaintenanceLimit *string `json:"margin_maintenance_limit,omitempty"` + + // NotionalValue The $ value of the current position. + NotionalValue *string `json:"notional_value,omitempty"` + + // ReservedMargin The contribution to `initial_margin` from open orders. + ReservedMargin *string `json:"reserved_margin,omitempty"` + + // ReservedMarginBuys The contribution to `initial_margin` from open BUY orders. + ReservedMarginBuys *string `json:"reserved_margin_buys,omitempty"` + + // ReservedMarginSells The contribution to `initial_margin` from open SELL orders. + ReservedMarginSells *string `json:"reserved_margin_sells,omitempty"` + + // SellingPower The amount of that product the account could sell based on current `initial_margin` and `margin_assets_value`. + SellingPower *string `json:"selling_power,omitempty"` +} + +// MarginRiskStats defines model for MarginRiskStats. +type MarginRiskStats struct { + // AvailableCollateral The amount of collateral available for new positions + AvailableCollateral MoneyAmount `json:"availableCollateral"` + + // BuyingPower The maximum value that can be purchased + BuyingPower MoneyAmount `json:"buyingPower"` + + // Leverage The leverage ratio + Leverage string `json:"leverage"` + + // LiquidationRisk Liquidation risk information (only present if applicable) + LiquidationRisk *LiquidationRisk `json:"liquidationRisk,omitempty"` + + // MarginAssetValue The total value of all assets available in the margin account + MarginAssetValue MoneyAmount `json:"marginAssetValue"` + + // NotionalValue The total value of all open positions + NotionalValue MoneyAmount `json:"notionalValue"` + + // ReservedBuyOrders Collateral reserved for open buy orders + ReservedBuyOrders MoneyAmount `json:"reservedBuyOrders"` + + // ReservedSellOrders Collateral reserved for open sell orders + ReservedSellOrders MoneyAmount `json:"reservedSellOrders"` + + // SellingPower The maximum value that can be sold + SellingPower MoneyAmount `json:"sellingPower"` + + // TotalBorrowed The total amount currently borrowed + TotalBorrowed MoneyAmount `json:"totalBorrowed"` +} + +// MoneyAmount defines model for MoneyAmount. +type MoneyAmount struct { + // Currency The currency code (e.g., "USD", "BTC", "ETH") + Currency string `json:"currency"` + + // Value The amount in the specified currency + Value string `json:"value"` +} + +// MyTrade defines model for MyTrade. +type MyTrade struct { + Aggressor *bool `json:"aggressor,omitempty"` + Amount *string `json:"amount,omitempty"` + Break *MyTradeBreak `json:"break,omitempty"` + ClientOrderId *string `json:"client_order_id,omitempty"` + Exchange *string `json:"exchange,omitempty"` + FeeAmount *string `json:"fee_amount,omitempty"` + FeeCurrency *string `json:"fee_currency,omitempty"` + IsAuctionFill *bool `json:"is_auction_fill,omitempty"` + OrderId *string `json:"order_id,omitempty"` + Price *string `json:"price,omitempty"` + Tid *int64 `json:"tid,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + Type *MyTradeType `json:"type,omitempty"` +} + +// MyTradeBreak defines model for MyTrade.Break. +type MyTradeBreak string + +// MyTradeType defines model for MyTrade.Type. +type MyTradeType string + +// MyTradesRequest defines model for MyTradesRequest. +type MyTradesRequest struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Specifies the account on which you intend to place the order. Only available for exchange accounts. + Account *string `json:"account,omitempty"` + + // LimitTrades The maximum number of trades to return. Default is 50, max is 500. + LimitTrades *int `json:"limit_trades,omitempty"` + + // Nonce timestamp + Nonce TimestampType `json:"nonce"` + + // Request The API endpoint path + Request string `json:"request"` + + // Symbol The [symbol](/market-data/symbols-and-minimums) to retrieve trades for + Symbol *string `json:"symbol,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` +} + +// NetworkAssets defines model for NetworkAssets. +type NetworkAssets struct { + // Assets Alphabetically sorted array of enabled asset/token codes available on this network. Assets include both exchange-tradable and custody-supported tokens. + Assets *[]string `json:"assets,omitempty"` + + // Network The blockchain network identifier. + Network *string `json:"network,omitempty"` +} + +// NetworkToken defines model for NetworkToken. +type NetworkToken struct { + // Network Array of supported blockchain networks for the token. Many tokens (especially stablecoins like USDC, USDT) are available on multiple networks. + // + // Supported networks include: `bitcoin`, `ethereum`, `solana`, `optimism`, `arbitrum`, `base`, `monad`, `avalanche`, `litecoin`, `bitcoincash`, `dogecoin`, `zcash`, `filecoin`, `tezos`, `polkadot`, `cosmos`, `xrpl`, `linea`, and more. + Network *[]string `json:"network,omitempty"` + + // Token The requested token identifier. + Token *string `json:"token,omitempty"` +} + +// NewOrderRequest defines model for NewOrderRequest. +type NewOrderRequest struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Specifies the account on which you intend to place the order. Only available for exchange accounts. + Account *string `json:"account,omitempty"` + + // Amount Quoted decimal amount to purchase + Amount string `json:"amount"` + + // ClientOrderId *Recommended*. A [client-specified order id](/client-order-id) + ClientOrderId *string `json:"client_order_id,omitempty"` + + // MarginOrder Set to `true` to place this order on a margin account using borrowed funds. Defaults to `false`. Only available for margin-enabled accounts. See [Margin Trading](/margin/account-summary) for details. + MarginOrder *bool `json:"margin_order,omitempty"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce int64 `json:"nonce"` + + // Options An optional array containing at most one supported order execution option. See Order execution options for details. + Options *[]NewOrderRequestOptions `json:"options,omitempty"` + + // Price Quoted decimal amount to spend per unit + Price string `json:"price"` + + // Request The literal string "/v1/order/new" + Request string `json:"request"` + Side NewOrderRequestSide `json:"side"` + + // StopPrice The price to trigger a stop-limit order. Only available for stop-limit orders. + StopPrice *string `json:"stop_price,omitempty"` + + // Symbol The [symbol](/market-data/symbols-and-minimums) for the new order + Symbol string `json:"symbol"` + + // Type The order type. "exchange limit" for all order types except for stop-limit orders. "exchange stop limit" for stop-limit orders. + Type NewOrderRequestType `json:"type"` +} + +// NewOrderRequestOptions defines model for NewOrderRequest.Options. +type NewOrderRequestOptions string + +// NewOrderRequestSide defines model for NewOrderRequest.Side. +type NewOrderRequestSide string + +// NewOrderRequestType The order type. "exchange limit" for all order types except for stop-limit orders. "exchange stop limit" for stop-limit orders. +type NewOrderRequestType string + +// Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) +type Nonce struct { + union json.RawMessage +} + +// Nonce1 defines model for . +type Nonce1 = int + +// NotionalBalance defines model for NotionalBalance. +type NotionalBalance struct { + // Amount The current balance + Amount *string `json:"amount,omitempty"` + + // AmountNotional Amount, in notional + AmountNotional *string `json:"amountNotional,omitempty"` + + // Available The amount that is available to trade + Available *string `json:"available,omitempty"` + + // AvailableForWithdrawal The amount that is available to withdraw + AvailableForWithdrawal *string `json:"availableForWithdrawal,omitempty"` + + // AvailableForWithdrawalNotional AvailableForWithdrawal, in notional + AvailableForWithdrawalNotional *string `json:"availableForWithdrawalNotional,omitempty"` + + // AvailableNotional Available, in notional + AvailableNotional *string `json:"availableNotional,omitempty"` + + // Currency Currency code, see symbols and minimums + Currency *string `json:"currency,omitempty"` +} + +// NotionalVolume defines model for NotionalVolume. +type NotionalVolume struct { + ApiAuctionFeeBps *int `json:"api_auction_fee_bps,omitempty"` + ApiMakerFeeBps *int `json:"api_maker_fee_bps,omitempty"` + ApiNotional30dVolume *string `json:"api_notional_30d_volume,omitempty"` + ApiTakerFeeBps *int `json:"api_taker_fee_bps,omitempty"` + Date *openapi_types.Date `json:"date,omitempty"` + FeeTier *struct { + ApiMakerFeeBps *int `json:"api_maker_fee_bps,omitempty"` + ApiTakerFeeBps *int `json:"api_taker_fee_bps,omitempty"` + Tier *string `json:"tier,omitempty"` + } `json:"fee_tier,omitempty"` + FixAuctionFeeBps *int `json:"fix_auction_fee_bps,omitempty"` + FixMakerFeeBps *int `json:"fix_maker_fee_bps,omitempty"` + FixTakerFeeBps *int `json:"fix_taker_fee_bps,omitempty"` + LastUpdatedMs *int `json:"last_updated_ms,omitempty"` + Notional1dVolume *[]struct { + // Date UTC date in `yyyy-MM-dd` format + Date *string `json:"date,omitempty"` + + // NotionalVolume Notional volume value in USD for this single day + NotionalVolume *string `json:"notional_volume,omitempty"` + } `json:"notional_1d_volume,omitempty"` + Notional30dVolume *string `json:"notional_30d_volume,omitempty"` + WebAuctionFeeBps *int `json:"web_auction_fee_bps,omitempty"` + WebMakerFeeBps *int `json:"web_maker_fee_bps,omitempty"` + WebTakerFeeBps *int `json:"web_taker_fee_bps,omitempty"` +} + +// OpenPosition defines model for OpenPosition. +type OpenPosition struct { + // AverageCost The average price of the current position. + AverageCost *string `json:"average_cost,omitempty"` + + // InstrumentType The type of instrument. Either "spot" or "perp". + InstrumentType *string `json:"instrument_type,omitempty"` + + // MarkPrice The current Mark Price for the Asset or the position. + MarkPrice *string `json:"mark_price,omitempty"` + + // NotionalValue The value of position; calculated as (`quantity` * `mark_price`). Value will be negative for shorts. + NotionalValue *string `json:"notional_value,omitempty"` + + // Quantity The position size. Value will be negative for shorts. + Quantity *string `json:"quantity,omitempty"` + + // RealisedPnl The current P&L that has been realised from the position. + RealisedPnl *string `json:"realised_pnl,omitempty"` + + // Symbol The [symbol](/market-data/symbols-and-minimums) of the order. + Symbol *string `json:"symbol,omitempty"` + + // UnrealisedPnl Current Mark to Market value of the positions. + UnrealisedPnl *string `json:"unrealised_pnl,omitempty"` +} + +// Order defines model for Order. +type Order struct { + // AvgExecutionPrice The average price at which this order as been executed so far. 0 if the order has not been executed at all. + AvgExecutionPrice *string `json:"avg_execution_price,omitempty"` + + // ClientOrderId An optional [client-specified order id](/client-order-id#client-order-id) + ClientOrderId *string `json:"client_order_id,omitempty"` + + // Exchange Will always be "gemini" + Exchange *string `json:"exchange,omitempty"` + + // ExecutedAmount The amount of the order that has been filled. + ExecutedAmount *string `json:"executed_amount,omitempty"` + + // IsCancelled `true` if the order has been canceled. Note the spelling, "cancelled" instead of "canceled". This is for compatibility reasons. + IsCancelled *bool `json:"is_cancelled,omitempty"` + + // IsHidden Will always return `false`. + IsHidden *bool `json:"is_hidden,omitempty"` + + // IsLive `true` if the order is active on the book (has remaining quantity and has not been canceled) + IsLive *bool `json:"is_live,omitempty"` + + // Options An array containing at most one supported order execution option. See [Order execution options](/rest/orders#create-new-order) for details. + Options *[]string `json:"options,omitempty"` + + // OrderId The order id + OrderId *string `json:"order_id,omitempty"` + + // OriginalAmount The originally submitted amount of the order. + OriginalAmount *string `json:"original_amount,omitempty"` + + // Price The price the order was issued at + Price *string `json:"price,omitempty"` + + // Reason Populated with the reason your order was canceled, if available. + Reason *string `json:"reason,omitempty"` + + // RemainingAmount The amount of the order that has not been filled. + RemainingAmount *string `json:"remaining_amount,omitempty"` + Side *OrderSide `json:"side,omitempty"` + + // Symbol The [symbol](/market-data/symbols-and-minimums#symbols-and-minimums) of the order + Symbol *string `json:"symbol,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + + // Trades Contains an array of JSON objects with trade details. + Trades *[]struct { + // Aggressor If `true`, this order was the taker in the trade + Aggressor *bool `json:"aggressor,omitempty"` + + // Amount The quantity that was executed + Amount *string `json:"amount,omitempty"` + + // Break Will only be present if the trade is broken. See `Break Types` below for more information. + Break *string `json:"break,omitempty"` + + // Exchange Will always be "gemini" + Exchange *string `json:"exchange,omitempty"` + + // FeeAmount The amount charged + FeeAmount *string `json:"fee_amount,omitempty"` + + // FeeCurrency Currency that the fee was paid in + FeeCurrency *string `json:"fee_currency,omitempty"` + + // OrderId The order that this trade executed against + OrderId *string `json:"order_id,omitempty"` + + // Price The price that the execution happened at + Price *string `json:"price,omitempty"` + + // Tid Unique identifier for the trade + Tid *int `json:"tid,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + + // Type Will be either "Buy" or "Sell", indicating the side of the original order + Type *OrderTradesType `json:"type,omitempty"` + } `json:"trades,omitempty"` + + // Type Description of the order + Type *OrderType `json:"type,omitempty"` + + // WasForced Will always be `false`. + WasForced *bool `json:"was_forced,omitempty"` +} + +// OrderSide defines model for Order.Side. +type OrderSide string + +// OrderTradesType Will be either "Buy" or "Sell", indicating the side of the original order +type OrderTradesType string + +// OrderType Description of the order +type OrderType string + +// OrderBook defines model for OrderBook. +type OrderBook struct { + // Asks The ask price levels currently on the book. These are offers to sell at a given price. + Asks *[]OrderBookEntry `json:"asks,omitempty"` + + // Bids The bid price levels currently on the book. These are offers to buy at a given price. + Bids *[]OrderBookEntry `json:"bids,omitempty"` +} + +// OrderBookEntry defines model for OrderBookEntry. +type OrderBookEntry struct { + // Amount The total quantity remaining at the price + Amount *string `json:"amount,omitempty"` + + // Price The price + Price *string `json:"price,omitempty"` + + // Timestamp **DO NOT USE** - this field is included for compatibility reasons only and is just populated with a dummy value. + Timestamp *string `json:"timestamp,omitempty"` +} + +// OrderStatusRequest defines model for OrderStatusRequest. +type OrderStatusRequest struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Specifies the account on which you intend to place the order. Only available for exchange accounts. + Account *string `json:"account,omitempty"` + + // ClientOrderId The `client_order_id` used when placing the order. `client_order_id` cannot be used in combination with `order_id` + ClientOrderId *string `json:"client_order_id,omitempty"` + + // IncludeTrades Either `True` or `False`. If `True` the endpoint will return individual trade details of all fills from the order. + IncludeTrades *bool `json:"include_trades,omitempty"` + + // Nonce timestamp + Nonce TimestampType `json:"nonce"` + + // OrderId The order id to get information on. The `order_id` represents a whole number and is transmitted as an unsigned 64-bit integer in JSON format. `order_id` cannot be used in combination with `client_order_id`. + OrderId int64 `json:"order_id"` + + // Request The API endpoint path + Request string `json:"request"` +} + +// PaymentMethodBalance defines model for PaymentMethodBalance. +type PaymentMethodBalance struct { + // Amount Total account balance for currency. + Amount *string `json:"amount,omitempty"` + + // Available Total amount available for trading + Available *string `json:"available,omitempty"` + + // AvailableForWithdrawal Total amount available for withdrawal + AvailableForWithdrawal *string `json:"availableForWithdrawal,omitempty"` + + // Currency Symbol for fiat balance. + Currency *string `json:"currency,omitempty"` + + // Type Account type. Will always be `exchange` + Type *string `json:"type,omitempty"` +} + +// PaymentMethodBank defines model for PaymentMethodBank. +type PaymentMethodBank struct { + // Bank Name of bank account + Bank *string `json:"bank,omitempty"` + + // BankId Unique identifier for bank account + BankId *string `json:"bankId,omitempty"` +} + +// PaymentMethodsResponse defines model for PaymentMethodsResponse. +type PaymentMethodsResponse struct { + // Balances Array of JSON objects with available fiat currencies and their balances. + Balances *[]PaymentMethodBalance `json:"balances,omitempty"` + + // Banks Array of JSON objects with banking information + Banks *[]PaymentMethodBank `json:"banks,omitempty"` +} + +// PriceFeedResponse defines model for PriceFeedResponse. +type PriceFeedResponse = []struct { + // Pair Trading pair symbol. See [**symbols and minimums**](/market-data/symbols-and-minimums#all-supported-symbols) + Pair *string `json:"pair,omitempty"` + + // PercentChange24h 24 hour change in price of the pair on the Gemini order book + PercentChange24h *string `json:"percentChange24h,omitempty"` + + // Price Current price of the pair on the Gemini order book + Price *string `json:"price,omitempty"` +} + +// Quantity defines model for Quantity. +type Quantity struct { + // Currency The currency code of the quantity. + Currency string `json:"currency"` + + // Value The value of the quantity. + Value string `json:"value"` +} + +// RevokeOauthTokenResponse defines model for RevokeOauthTokenResponse. +type RevokeOauthTokenResponse struct { + // Message A message that indicates the token has been revoked for the account + Message *string `json:"message,omitempty"` +} + +// RiskStatsResponse defines model for RiskStatsResponse. +type RiskStatsResponse struct { + // IndexPrice Current index price at the time of request + IndexPrice *string `json:"index_price,omitempty"` + + // MarkPrice Current mark price at the time of request + MarkPrice *string `json:"mark_price,omitempty"` + + // OpenInterest string representation of decimal value of open interest + OpenInterest *string `json:"open_interest,omitempty"` + + // OpenInterestNotional string representation of decimal value of open interest notional + OpenInterestNotional *string `json:"open_interest_notional,omitempty"` + + // ProductType Contract type for which the symbol data is fetched + ProductType *RiskStatsResponseProductType `json:"product_type,omitempty"` +} + +// RiskStatsResponseProductType Contract type for which the symbol data is fetched +type RiskStatsResponseProductType string + +// RoleResponse defines model for RoleResponse. +type RoleResponse struct { + // CounterpartyId _Only returned for master-level API keys_. The Gemini clearing counterparty ID associated with the API key making the request. + CounterpartyId *string `json:"counterparty_id,omitempty"` + + // IsAccountAdmin _Only returned for master-level API keys_.`True` if the Administrator role is assigned to the API keys. `False` otherwise. + IsAccountAdmin *bool `json:"isAccountAdmin,omitempty"` + + // IsAuditor `True` if the Auditor role is assigned to the API keys. `False` otherwise. + IsAuditor bool `json:"isAuditor"` + + // IsFundManager `True` if the Fund Manager role is assigned to the API keys. `False` otherwise. + IsFundManager bool `json:"isFundManager"` + + // IsTrader `True` if the Trader role is assigned to the API keys. `False` otherwise. + IsTrader bool `json:"isTrader"` +} + +// StakingBalance defines model for StakingBalance. +type StakingBalance struct { + // Available The amount that is available to trade + Available *openapi_types.DecimalNumber `json:"available,omitempty"` + + // AvailableForWithdrawal The Staking amount that is available to redeem to exchange account + AvailableForWithdrawal *openapi_types.DecimalNumber `json:"availableForWithdrawal,omitempty"` + + // Balance The current Staking balance + Balance *openapi_types.DecimalNumber `json:"balance,omitempty"` + BalanceByProvider *map[string]struct { + // Balance The current Staking balance per providerId + Balance *openapi_types.DecimalNumber `json:"balance,omitempty"` + } `json:"balanceByProvider,omitempty"` + + // Currency Currency code, see symbols and minimums + Currency *string `json:"currency,omitempty"` + + // Type Will always be "Staking" + Type *string `json:"type,omitempty"` +} + +// StakingDeposit defines model for StakingDeposit. +type StakingDeposit struct { + // AccrualTotal The total accrual + AccrualTotal *openapi_types.DecimalNumber `json:"accrualTotal,omitempty"` + + // Amount The amount deposited + Amount *openapi_types.DecimalNumber `json:"amount,omitempty"` + + // Currency Currency code, see [symbols](/market-data/symbols-and-minimums) + Currency *string `json:"currency,omitempty"` + + // ProviderId Provider Id, in uuid4 format + ProviderId *string `json:"providerId,omitempty"` + + // Rates A JSON object including one or many rates. If more than one rate it would be an array of rates. + Rates *struct { + // Rate Staking interest rate in bps (Expressed as a simple rate. Interest on Staking balances compounds daily. In mobile and web applications, APYs are derived from this rate and rounded to 1/10th of a percent.) + Rate *int `json:"rate,omitempty"` + } `json:"rates,omitempty"` + + // TransactionId A unique identifier for the staking transaction + TransactionId *string `json:"transactionId,omitempty"` +} + +// StakingHistory defines model for StakingHistory. +type StakingHistory struct { + // ProviderId Provider Id, in uuid4 format + ProviderId *string `json:"providerId,omitempty"` + Transactions *[]StakingTransaction `json:"transactions,omitempty"` +} + +// StakingRate defines model for StakingRate. +type StakingRate struct { + // ApyPct Staking interest APY (Expressed as a percentage derived from the rate and rounded to 1/10th of a percent.) + ApyPct *openapi_types.DecimalNumber `json:"apyPct,omitempty"` + + // DepositUsdLimit Maximum new amount in USD notional of this crypto that can participate in Gemini Staking per account per month + DepositUsdLimit *int `json:"depositUsdLimit,omitempty"` + + // ProviderId Provider Id, in uuid4 format + ProviderId *string `json:"providerId,omitempty"` + + // Rate Staking interest rate in bps (Expressed as a simple rate. Interest on Staking balances compounds daily. In mobile and web applications, APYs are derived from this rate and rounded to 1/10th of a percent.) + Rate *openapi_types.DecimalNumber `json:"rate,omitempty"` + + // RatePct `rate` expressed as a percentage + RatePct *openapi_types.DecimalNumber `json:"ratePct,omitempty"` +} + +// StakingRateProvider Currency Symbol Keys +type StakingRateProvider struct { + CurrencySymbol *StakingRate `json:"currency_symbol,omitempty"` +} + +// StakingRateResponse Provider UUID Keys +type StakingRateResponse struct { + // ProviderUuid Currency Symbol Keys + ProviderUuid *StakingRateProvider `json:"provider_uuid,omitempty"` +} + +// StakingRewardPeriod defines model for StakingRewardPeriod. +type StakingRewardPeriod struct { + // AccrualTotal The total accrual + AccrualTotal *openapi_types.DecimalNumber `json:"accrualTotal,omitempty"` + + // ApyPct Staking reward rate expressed as an APY at time of accrual. Interest on Staking balances compounds daily based on the simple rate which is available from `/v1/staking/rates/` + ApyPct *openapi_types.DecimalNumber `json:"apyPct,omitempty"` + + // Currency Currency code, see [symbols](/market-data/symbols-and-minimums) + Currency *string `json:"currency,omitempty"` + + // FirstAccrualAt Time of first accrual. In iso datetime with timezone format + FirstAccrualAt *string `json:"firstAccrualAt,omitempty"` + + // LastAccrualAt Time of last accrual. In iso datetime with timezone format + LastAccrualAt *string `json:"lastAccrualAt,omitempty"` + + // NumberOfAccruals Number of accruals in the specific aggregate, typically one per day. If the rate is adjusted, new accruals are added. + NumberOfAccruals *int `json:"numberOfAccruals,omitempty"` + + // ProviderId Provider Id, in uuid4 format + ProviderId *string `json:"providerId,omitempty"` + + // RatePct Rate expressed as a percentage + RatePct *openapi_types.DecimalNumber `json:"ratePct,omitempty"` +} + +// StakingRewards defines model for StakingRewards. +type StakingRewards struct { + // AccrualTotal The total accrual + AccrualTotal *openapi_types.DecimalNumber `json:"accrualTotal,omitempty"` + + // Currency Currency code, see [symbols](/market-data/symbols-and-minimums) + Currency *string `json:"currency,omitempty"` + + // ProviderId Provider Id, in uuid4 format + ProviderId *string `json:"providerId,omitempty"` + + // RatePeriods Array of JSON objects with period accrual information + RatePeriods *[]StakingRewardPeriod `json:"ratePeriods,omitempty"` +} + +// StakingRewardsProvider Currency Symbol Keys +type StakingRewardsProvider struct { + CurrencySymbol *StakingRewards `json:"currency_symbol,omitempty"` +} + +// StakingRewardsResponse Provider UUID Keys +type StakingRewardsResponse struct { + // ProviderUuid Currency Symbol Keys + ProviderUuid *StakingRewardsProvider `json:"provider_uuid,omitempty"` +} + +// StakingTransaction defines model for StakingTransaction. +type StakingTransaction struct { + // Amount The amount that is defined by the transactionType above + Amount *openapi_types.DecimalNumber `json:"amount,omitempty"` + + // AmountCurrency Currency code + AmountCurrency *string `json:"amountCurrency,omitempty"` + + // DateTime timestamp + DateTime *TimestampType `json:"dateTime,omitempty"` + + // PriceAmount Current market price of the underlying token at the time of the reward + PriceAmount *openapi_types.DecimalNumber `json:"priceAmount,omitempty"` + + // PriceCurrency A supported three-letter fiat currency code, e.g. usd + PriceCurrency *string `json:"priceCurrency,omitempty"` + + // TransactionId A unique identifier for the staking transaction + TransactionId *string `json:"transactionId,omitempty"` + + // TransactionType Can be any one of the following - Deposit, Redeem, Interest, RedeemPayment, AdminRedeem, AdminCreditAdjustment, AdminDebitAdjustment + TransactionType *StakingTransactionTransactionType `json:"transactionType,omitempty"` +} + +// StakingTransactionTransactionType Can be any one of the following - Deposit, Redeem, Interest, RedeemPayment, AdminRedeem, AdminCreditAdjustment, AdminDebitAdjustment +type StakingTransactionTransactionType string + +// StakingWithdrawal defines model for StakingWithdrawal. +type StakingWithdrawal struct { + // Amount The amount deposited + Amount *openapi_types.DecimalNumber `json:"amount,omitempty"` + + // AmountPaidSoFar The amount redeemed successfully + AmountPaidSoFar *openapi_types.DecimalNumber `json:"amountPaidSoFar,omitempty"` + + // AmountRemaining The amount pending to be redeemed + AmountRemaining *openapi_types.DecimalNumber `json:"amountRemaining,omitempty"` + + // Currency Currency code + Currency *string `json:"currency,omitempty"` + + // RequestInitiated In ISO datetime with timezone format + RequestInitiated *string `json:"requestInitiated,omitempty"` + + // TransactionId A unique identifier for the staking transaction + TransactionId *string `json:"transactionId,omitempty"` +} + +// StopLimitOrderResponse defines model for StopLimitOrderResponse. +type StopLimitOrderResponse struct { + AvgExecutionPrice *string `json:"avg_execution_price,omitempty"` + Exchange *string `json:"exchange,omitempty"` + ExecutedAmount *string `json:"executed_amount,omitempty"` + Id *string `json:"id,omitempty"` + IsCancelled *bool `json:"is_cancelled,omitempty"` + IsHidden *bool `json:"is_hidden,omitempty"` + IsLive *bool `json:"is_live,omitempty"` + Options *[]string `json:"options,omitempty"` + OrderId *string `json:"order_id,omitempty"` + OriginalAmount *string `json:"original_amount,omitempty"` + Price *string `json:"price,omitempty"` + Side *StopLimitOrderResponseSide `json:"side,omitempty"` + StopPrice *string `json:"stop_price,omitempty"` + Symbol *string `json:"symbol,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + Type *StopLimitOrderResponseType `json:"type,omitempty"` + WasForced *bool `json:"was_forced,omitempty"` +} + +// StopLimitOrderResponseSide defines model for StopLimitOrderResponse.Side. +type StopLimitOrderResponseSide string + +// StopLimitOrderResponseType defines model for StopLimitOrderResponse.Type. +type StopLimitOrderResponseType string + +// SymbolDetails defines model for SymbolDetails. +type SymbolDetails struct { + // BaseCurrency CCY1 or the top currency. (i.e `BTC` in `BTCUSD`) + BaseCurrency *string `json:"base_currency,omitempty"` + + // ContractPriceCurrency CCY2 or the quote currency for spot instrument (i.e. `USD` in `BTCUSD`) + // Or collateral currency of the contract in case of perpetual swap instrument. + ContractPriceCurrency *string `json:"contract_price_currency,omitempty"` + + // ContractType `vanilla` / `linear` / `inverse` where `vanilla` is for spot + // while `linear` is for perpetual swap and `inverse` is a special case perpetual swap where the perpetual contract will be settled in base currency. + ContractType *string `json:"contract_type,omitempty"` + + // MinOrderSize The minimum order size in `base_currency` units (i.e `0.00001`) + MinOrderSize *string `json:"min_order_size,omitempty"` + + // ProductType Instrument type `spot` / `swap` -- where `swap` signifies `perpetual swap`. + ProductType *string `json:"product_type,omitempty"` + + // QuoteCurrency CCY2 or the quote currency. (i.e `USD` in `BTCUSD`) + QuoteCurrency *string `json:"quote_currency,omitempty"` + + // QuoteIncrement The number of decimal places in the `quote_currency` (i.e `0.01`) + QuoteIncrement *openapi_types.DecimalNumber `json:"quote_increment,omitempty"` + + // Status Status of the current order book. Can be `open`, `closed`, `cancel_only`, `post_only`, `limit_only`. + Status *string `json:"status,omitempty"` + + // Symbol The requested symbol. See [**symbols and minimums**](/market-data/symbols-and-minimums#all-supported-symbols) + Symbol *string `json:"symbol,omitempty"` + + // TickSize The number of decimal places in the `base_currency`. (i.e `1e-8`) + TickSize *openapi_types.DecimalNumber `json:"tick_size,omitempty"` + + // WrapEnabled When `True`, symbol can be wrapped using this endpoint: + // `POST https://api.gemini.com/v1/wrap/:symbol` + WrapEnabled *bool `json:"wrap_enabled,omitempty"` +} + +// Ticker defines model for Ticker. +type Ticker struct { + // Ask The lowest ask currently available + Ask *string `json:"ask,omitempty"` + + // Bid The highest bid currently available + Bid *string `json:"bid,omitempty"` + + // Last The price of the last executed trade + Last *string `json:"last,omitempty"` + + // Volume Information about the 24 hour volume on the exchange. See properties below + Volume *struct { + // PriceSymbol The volume denominated in the price currency + PriceSymbol *string `json:"price_symbol,omitempty"` + + // QuantitySymbol The volume denominated in the quantity currency + QuantitySymbol *string `json:"quantity_symbol,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + } `json:"volume,omitempty"` +} + +// TickerInfo defines model for TickerInfo. +type TickerInfo struct { + // Ask Current best offer + Ask *string `json:"ask,omitempty"` + + // Bid Current best bid + Bid *string `json:"bid,omitempty"` + + // Changes Hourly prices descending for past 24 hours + Changes *[]string `json:"changes,omitempty"` + + // Close Close price (most recent trade) + Close *string `json:"close,omitempty"` + + // High High price from 24 hours ago + High *string `json:"high,omitempty"` + + // Low Low price from 24 hours ago + Low *string `json:"low,omitempty"` + + // Open Open price from 24 hours ago + Open *string `json:"open,omitempty"` + + // Symbol The trading pair symbol + Symbol *string `json:"symbol,omitempty"` +} + +// TimestampType timestamp +type TimestampType struct { + union json.RawMessage +} + +// TimestampType0 Gemini strongly recommends using milliseconds instead of seconds for timestamps. +// +// | Timestamp format | Example | Supported request type | +// |-----------------------|-----------------------|------------------------| +// | string (seconds) | `1495127793` | `POST` only | +// | string (milliseconds) | `1495127793000` | `POST` only | +type TimestampType0 = string + +// TimestampType1 Gemini strongly recommends using milliseconds instead of seconds for timestamps. +// +// | Timestamp format | Example | Supported request type | +// |-----------------------------|---------------------------|------------------------| +// | whole number (seconds) | `1495127793` | `GET`, `POST` | +// | whole number (milliseconds) | `1495127793000` | `GET`, `POST` | +type TimestampType1 = int64 + +// Trade defines model for Trade. +type Trade struct { + // Amount The amount that was traded + Amount *string `json:"amount,omitempty"` + + // Broken Whether the trade was broken or not. Broken trades will not be displayed by default; use the `include_breaks` to display them. + Broken *bool `json:"broken,omitempty"` + + // Exchange Will always be "gemini" + Exchange *string `json:"exchange,omitempty"` + + // Price The price the trade was executed at + Price *string `json:"price,omitempty"` + + // Tid The trade ID number + Tid *int64 `json:"tid,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + + // Type - `buy` means that an ask was removed from the book by an incoming buy order. + // - `sell` means that a bid was removed from the book by an incoming sell order. + Type *TradeType `json:"type,omitempty"` +} + +// TradeType - `buy` means that an ask was removed from the book by an incoming buy order. +// - `sell` means that a bid was removed from the book by an incoming sell order. +type TradeType string + +// TradeVolume defines model for TradeVolume. +type TradeVolume struct { + BaseCurrency *string `json:"base_currency,omitempty"` + BuyMakerBase *string `json:"buy_maker_base,omitempty"` + BuyMakerCount *int `json:"buy_maker_count,omitempty"` + BuyMakerNotional *string `json:"buy_maker_notional,omitempty"` + BuyTakerBase *string `json:"buy_taker_base,omitempty"` + BuyTakerCount *int `json:"buy_taker_count,omitempty"` + BuyTakerNotional *string `json:"buy_taker_notional,omitempty"` + DataDate *string `json:"data_date,omitempty"` + MakerBuySellRatio *string `json:"maker_buy_sell_ratio,omitempty"` + NotionalCurrency *string `json:"notional_currency,omitempty"` + QuoteCurrency *string `json:"quote_currency,omitempty"` + SellMakerBase *string `json:"sell_maker_base,omitempty"` + SellMakerCount *int `json:"sell_maker_count,omitempty"` + SellMakerNotional *string `json:"sell_maker_notional,omitempty"` + SellTakerBase *string `json:"sell_taker_base,omitempty"` + SellTakerCount *int `json:"sell_taker_count,omitempty"` + SellTakerNotional *string `json:"sell_taker_notional,omitempty"` + Symbol *string `json:"symbol,omitempty"` + TotalVolumeBase *string `json:"total_volume_base,omitempty"` +} + +// Transaction defines model for Transaction. +type Transaction struct { + union json.RawMessage +} + +// Transaction0 Trade Reponse +type Transaction0 struct { + // Account The account. + Account *string `json:"account,omitempty"` + + // Amount The quantity that was executed. + Amount *string `json:"amount,omitempty"` + + // ClientOrderId The client order ID, if defined. Otherwise an empty string. + ClientOrderId *string `json:"clientOrderId,omitempty"` + + // Exchange Will always be "gemini". + Exchange *string `json:"exchange,omitempty"` + + // FeeAmount The fee amount charged + FeeAmount *string `json:"feeAmount,omitempty"` + + // FeeAssetCode The symbol that the trade was for + FeeAssetCode *string `json:"feeAssetCode,omitempty"` + + // IsAggressor If true, this order was the taker in the trade. + IsAggressor *bool `json:"isAggressor,omitempty"` + + // IsAuctionFill True if the trade was a auction trade and not an on-exchange trade. + IsAuctionFill *bool `json:"isAuctionFill,omitempty"` + + // IsClearingFill True if the trade was a clearing trade and not an on-exchange trade. + IsClearingFill *bool `json:"isClearingFill,omitempty"` + + // OrderId The order that this trade executed against. + OrderId *int64 `json:"orderId,omitempty"` + + // Price The price that the execution happened at. + Price *string `json:"price,omitempty"` + + // Side Indicating the side of the original order. + Side *string `json:"side,omitempty"` + + // Symbol The symbol that the trade was for. + Symbol *string `json:"symbol,omitempty"` + + // Tid The trade ID. + Tid *int64 `json:"tid,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` +} + +// Transaction1 Transfer Reponse +type Transaction1 struct { + // AdvanceEid Deposit advance event ID. + AdvanceEid *int64 `json:"advanceEid,omitempty"` + + // Amount The quantity that was transferred. + Amount *string `json:"amount,omitempty"` + + // BankId Bank ID. + BankId *string `json:"bankId,omitempty"` + + // ClientTransferId Client Transfer ID. Client transfer ID is an optional client-supplied unique identifier for each withdrawal or internal transfer. + ClientTransferId *string `json:"clientTransferId,omitempty"` + + // CorrelationId Correlation ID. + CorrelationId *int64 `json:"correlationId,omitempty"` + + // Currency Currency code, see symbols + Currency *string `json:"currency,omitempty"` + + // Destination The account you are transferring to. + Destination *string `json:"destination,omitempty"` + + // Eid Transfer event id. + Eid *int64 `json:"eid,omitempty"` + + // FeeId Fee ID. + FeeId *string `json:"feeId,omitempty"` + + // Method Type of transfer method. + Method *string `json:"method,omitempty"` + + // OperationReason The operation reason. + OperationReason *string `json:"operationReason,omitempty"` + + // PendingEid Pending event ID. + PendingEid *int64 `json:"pendingEid,omitempty"` + + // Purpose Purpose. + Purpose *string `json:"purpose,omitempty"` + + // Source The account you are transferring from. + Source *string `json:"source,omitempty"` + + // Status The status of the transfer. + Status *string `json:"status,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + + // TransactionHash Supplies the transaction hash when available. + TransactionHash *string `json:"transactionHash,omitempty"` + + // TransferId Transfer ID. + TransferId *string `json:"transferId,omitempty"` + + // TransferType Transfer type. + TransferType *string `json:"transferType,omitempty"` + + // WithdrawalEid Withdrawal event ID. + WithdrawalEid *int64 `json:"withdrawalEid,omitempty"` + + // WithdrawalId Withdrawal ID. + WithdrawalId *string `json:"withdrawalId,omitempty"` +} + +// Transfer defines model for Transfer. +type Transfer struct { + // Amount The amount transferred + Amount *string `json:"amount,omitempty"` + + // Currency The currency transferred + Currency *string `json:"currency,omitempty"` + + // Eid The transfer ID + Eid *int64 `json:"eid,omitempty"` + Status *TransferStatus `json:"status,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + + // TxHash The transaction hash if applicable + TxHash *string `json:"txHash,omitempty"` + Type *TransferType `json:"type,omitempty"` +} + +// TransferStatus defines model for Transfer.Status. +type TransferStatus string + +// TransferType defines model for Transfer.Type. +type TransferType string + +// V2Transfer defines model for V2Transfer. +type V2Transfer struct { + // Amount The amount transferred + Amount *string `json:"amount,omitempty"` + + // Currency The currency transferred + Currency *string `json:"currency,omitempty"` + + // Destination The destination address for withdrawals + Destination *string `json:"destination,omitempty"` + + // Eid The transfer event ID + Eid *int64 `json:"eid,omitempty"` + + // FeeAmount The fee charged for the transfer + FeeAmount *string `json:"feeAmount,omitempty"` + + // FeeCurrency The currency in which the fee was charged + FeeCurrency *string `json:"feeCurrency,omitempty"` + + // Method The transfer method (e.g., `ACH`, `CreditCard`) + Method *string `json:"method,omitempty"` + + // Network The blockchain network the transfer was executed on (e.g., `ethereum`, `solana`, `arbitrum`, `optimism`, `base`, `avalanche`). Not present for fiat or administrative transfers. + Network *string `json:"network,omitempty"` + + // OutputIdx The output index for withdrawals + OutputIdx *int `json:"outputIdx,omitempty"` + + // Purpose The purpose or reason for administrative transfers + Purpose *string `json:"purpose,omitempty"` + + // Status The status of the transfer + Status *V2TransferStatus `json:"status,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + + // TxHash The on-chain transaction hash, if applicable + TxHash *string `json:"txHash,omitempty"` + + // Type The type of the transfer + Type *V2TransferType `json:"type,omitempty"` + + // WithdrawalId The unique withdrawal identifier + WithdrawalId *string `json:"withdrawalId,omitempty"` +} + +// V2TransferStatus The status of the transfer +type V2TransferStatus string + +// V2TransferType The type of the transfer +type V2TransferType string + +// WithdrawCryptoFundsResponse Response returned after submitting a v2 cryptocurrency withdrawal. +type WithdrawCryptoFundsResponse struct { + // Address Standard string format of the withdrawal destination address + Address *string `json:"address,omitempty"` + + // Amount The withdrawal amount + Amount *string `json:"amount,omitempty"` + + // Currency The currency code of the withdrawn asset + Currency *string `json:"currency,omitempty"` + + // Fee The fee charged for the withdrawal + Fee *string `json:"fee,omitempty"` + + // WithdrawalId A unique ID for the withdrawal + WithdrawalId *string `json:"withdrawalId,omitempty"` +} + +// ApiKeyAuth defines model for apiKeyAuth. +type ApiKeyAuth = string + +// CacheControl defines model for cacheControl. +type CacheControl = string + +// ContentLength defines model for contentLength. +type ContentLength = string + +// ContentType defines model for contentType. +type ContentType = string + +// CurrencyParam defines model for currencyParam. +type CurrencyParam = string + +// NetworkParam defines model for networkParam. +type NetworkParam = string + +// PayloadAuth defines model for payloadAuth. +type PayloadAuth = string + +// SignatureAuth defines model for signatureAuth. +type SignatureAuth = string + +// SymbolParam defines model for symbolParam. +type SymbolParam = string + +// TimestampParam timestamp +type TimestampParam = TimestampType + +// ApiKeyIpFilteringFailure defines model for ApiKeyIpFilteringFailure. +type ApiKeyIpFilteringFailure = ErrorResponse + +// BadRequest defines model for BadRequest. +type BadRequest = ErrorResponse + +// InternalError defines model for InternalError. +type InternalError = ErrorResponse + +// NotFound defines model for NotFound. +type NotFound = ErrorResponse + +// TooManyRequests defines model for TooManyRequests. +type TooManyRequests = ErrorResponse + +// Unauthorized defines model for Unauthorized. +type Unauthorized = ErrorResponse + +// apiKeyAuthContextKey is the context key for apiKeyAuth security scheme +type apiKeyAuthContextKey string + +// payloadAuthContextKey is the context key for payloadAuth security scheme +type payloadAuthContextKey string + +// signatureAuthContextKey is the context key for signatureAuth security scheme +type signatureAuthContextKey string + +// ListClearingBrokersJSONBody defines parameters for ListClearingBrokers. +type ListClearingBrokersJSONBody struct { + // Account Required for Master API keys. The name of the account within the subaccount group. + Account *string `json:"account,omitempty"` + + // ExpirationEnd timestamp + ExpirationEnd *TimestampType `json:"expiration_end,omitempty"` + + // ExpirationStart timestamp + ExpirationStart *TimestampType `json:"expiration_start,omitempty"` + + // Funded Default value false if not set + Funded *bool `json:"funded,omitempty"` + + // LimitOrders The maximum number of orders to return + LimitOrders *int `json:"limit_orders,omitempty"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce Nonce `json:"nonce"` + + // Request The literal string "/v1/clearing/broker/list" + Request string `json:"request"` + + // Status Filter by status + Status *string `json:"status,omitempty"` + + // SubmissionEnd timestamp + SubmissionEnd *TimestampType `json:"submission_end,omitempty"` + + // SubmissionStart timestamp + SubmissionStart *TimestampType `json:"submission_start,omitempty"` + + // Symbol Trading pair + Symbol *string `json:"symbol,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` +} + +// ListClearingBrokersParams defines parameters for ListClearingBrokers. +type ListClearingBrokersParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// ListClearingBrokers200JSONResponseBodyOrdersSourceSide defines parameters for ListClearingBrokers. +type ListClearingBrokers200JSONResponseBodyOrdersSourceSide string + +// CreateNewBrokerOrderJSONBody defines parameters for CreateNewBrokerOrder. +type CreateNewBrokerOrderJSONBody struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Specifies the broker account on which to place the order. Only available for exchange accounts. + Account *string `json:"account,omitempty"` + + // Amount Quoted decimal amount to purchase + Amount string `json:"amount"` + + // ExpiresInHrs The number of hours before the trade expires. Your counterparty will need to confirm the order before this time expires. + ExpiresInHrs int `json:"expires_in_hrs"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce Nonce `json:"nonce"` + + // Price Quoted decimal amount to spend per unit + Price string `json:"price"` + + // Request The literal string "/v1/clearing/broker/new" + Request string `json:"request"` + + // Side "buy" or "sell". This side will be assigned to the `source_counterparty_id`. The opposite side will be sent to the `target_counterparty_id` + Side CreateNewBrokerOrderJSONBodySide `json:"side"` + + // SourceCounterpartyId A symbol that corresponds with the counterparty sourcing the clearing trade + SourceCounterpartyId string `json:"source_counterparty_id"` + + // Symbol The [symbol](/market-data/symbols-and-minimums) of the order + Symbol string `json:"symbol"` + + // TargetCounterpartyId A symbol that corresponds with the counterparty where the clearing trade is targeted + TargetCounterpartyId string `json:"target_counterparty_id"` +} + +// CreateNewBrokerOrderParams defines parameters for CreateNewBrokerOrder. +type CreateNewBrokerOrderParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// CreateNewBrokerOrderJSONBodySide defines parameters for CreateNewBrokerOrder. +type CreateNewBrokerOrderJSONBodySide string + +// CancelClearingOrderJSONBody defines parameters for CancelClearingOrder. +type CancelClearingOrderJSONBody struct { + // Account Required for Master API keys. The name of the account within the subaccount group. + Account *string `json:"account,omitempty"` + + // ClearingId The clearing ID + ClearingId string `json:"clearing_id"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce Nonce `json:"nonce"` + + // Request The literal string "/v1/clearing/cancel" + Request string `json:"request"` +} + +// CancelClearingOrderParams defines parameters for CancelClearingOrder. +type CancelClearingOrderParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// ConfirmClearingOrderJSONBody defines parameters for ConfirmClearingOrder. +type ConfirmClearingOrderJSONBody struct { + // Account Required for Master API keys. The name of the account within the subaccount group. + Account *string `json:"account,omitempty"` + + // Amount The amount to trade + Amount string `json:"amount"` + + // ClearingId The clearing ID + ClearingId string `json:"clearing_id"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce Nonce `json:"nonce"` + + // Price The price + Price string `json:"price"` + + // Request The literal string "/v1/clearing/confirm" + Request string `json:"request"` + + // Side The direction of the trade + Side ConfirmClearingOrderJSONBodySide `json:"side"` + + // Symbol The trading pair + Symbol string `json:"symbol"` +} + +// ConfirmClearingOrderParams defines parameters for ConfirmClearingOrder. +type ConfirmClearingOrderParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// ConfirmClearingOrderJSONBodySide defines parameters for ConfirmClearingOrder. +type ConfirmClearingOrderJSONBodySide string + +// ListClearingOrdersJSONBody defines parameters for ListClearingOrders. +type ListClearingOrdersJSONBody struct { + // Account Required for Master API keys. The name of the account within the subaccount group. + Account *string `json:"account,omitempty"` + + // Counterparty counterparty_id or counterparty_alias + Counterparty *string `json:"counterparty,omitempty"` + + // ExpirationEnd timestamp + ExpirationEnd *TimestampType `json:"expiration_end,omitempty"` + + // ExpirationStart timestamp + ExpirationStart *TimestampType `json:"expiration_start,omitempty"` + + // Funded Default value false if not set + Funded *bool `json:"funded,omitempty"` + + // LimitOrders The maximum number of orders to return + LimitOrders *int `json:"limit_orders,omitempty"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce Nonce `json:"nonce"` + + // Request The literal string "/v1/clearing/list" + Request string `json:"request"` + + // Side "buy" or "sell" + Side *ListClearingOrdersJSONBodySide `json:"side,omitempty"` + + // Status Filter by status + Status *string `json:"status,omitempty"` + + // SubmissionEnd timestamp + SubmissionEnd *TimestampType `json:"submission_end,omitempty"` + + // SubmissionStart timestamp + SubmissionStart *TimestampType `json:"submission_start,omitempty"` + + // Symbol Trading pair + Symbol *string `json:"symbol,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` +} + +// ListClearingOrdersParams defines parameters for ListClearingOrders. +type ListClearingOrdersParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// ListClearingOrdersJSONBodySide defines parameters for ListClearingOrders. +type ListClearingOrdersJSONBodySide string + +// ListClearingOrders200JSONResponseBodyOrdersSide defines parameters for ListClearingOrders. +type ListClearingOrders200JSONResponseBodyOrdersSide string + +// CreateNewClearingOrderJSONBody defines parameters for CreateNewClearingOrder. +type CreateNewClearingOrderJSONBody struct { + // Account Required for Master API keys. The name of the account within the subaccount group. + Account *string `json:"account,omitempty"` + + // Amount The amount to trade + Amount string `json:"amount"` + + // CounterpartyId The counterparty ID + CounterpartyId *string `json:"counterparty_id,omitempty"` + + // ExpiresInHrs The number of hours until the order expires + ExpiresInHrs *int `json:"expires_in_hrs,omitempty"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce Nonce `json:"nonce"` + + // Price The price + Price string `json:"price"` + + // Request The literal string "/v1/clearing/new" + Request string `json:"request"` + + // Side The direction of the trade + Side CreateNewClearingOrderJSONBodySide `json:"side"` + + // Symbol The trading pair + Symbol string `json:"symbol"` +} + +// CreateNewClearingOrderParams defines parameters for CreateNewClearingOrder. +type CreateNewClearingOrderParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// CreateNewClearingOrderJSONBodySide defines parameters for CreateNewClearingOrder. +type CreateNewClearingOrderJSONBodySide string + +// GetClearingOrderJSONBody defines parameters for GetClearingOrder. +type GetClearingOrderJSONBody struct { + // Account Required for Master API keys. The name of the account within the subaccount group. + Account *string `json:"account,omitempty"` + + // ClearingId The clearing ID + ClearingId string `json:"clearing_id"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce Nonce `json:"nonce"` + + // Request The literal string "/v1/clearing/status" + Request string `json:"request"` +} + +// GetClearingOrderParams defines parameters for GetClearingOrder. +type GetClearingOrderParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// ListClearingTradesJSONBody defines parameters for ListClearingTrades. +type ListClearingTradesJSONBody struct { + // Account Only required when using a master api-key. The name of the account within the subaccount group. + Account *string `json:"account,omitempty"` + + // LimitPerAccount The maximum number of clearing trades to return. The default is 100 and the maximum is 300. + LimitPerAccount *int `json:"limit_per_account,omitempty"` + + // LimitTrades The maximum number of trades to return + LimitTrades *int `json:"limit_trades,omitempty"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce Nonce `json:"nonce"` + + // Request The literal string "/v1/clearing/trades" + Request string `json:"request"` + + // Symbol The trading pair + Symbol *string `json:"symbol,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + + // TimestampNanos Only return transfers on or after this timestamp in nanos + TimestampNanos *int `json:"timestamp_nanos,omitempty"` +} + +// ListClearingTradesParams defines parameters for ListClearingTrades. +type ListClearingTradesParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// ListClearingTrades200JSONResponseBodyResultsSourceSide defines parameters for ListClearingTrades. +type ListClearingTrades200JSONResponseBodyResultsSourceSide string + +// ExecuteInstantOrderJSONBody defines parameters for ExecuteInstantOrder. +type ExecuteInstantOrderJSONBody struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Specifies the account on which you intend to place the order. Only available for exchange accounts. + Account *string `json:"account,omitempty"` + + // Fee The fee for the order. fee must match fee returned in the quote + Fee string `json:"fee"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce Nonce `json:"nonce"` + + // Price The price from the quote. price must match price returned in the quote + Price string `json:"price"` + + // Quantity The quantity of the asset bought or sold. quantity must match quantity returned in the quote + Quantity string `json:"quantity"` + + // QuoteId Unique ID for the quote. quoteId must match quoteId returned in the quote + QuoteId int `json:"quoteId"` + + // Request The literal string "/v1/instant/execute" + Request string `json:"request"` + + // Side "buy" or "sell" + Side ExecuteInstantOrderJSONBodySide `json:"side"` + + // Symbol The symbol for the order. + Symbol string `json:"symbol"` +} + +// ExecuteInstantOrderParams defines parameters for ExecuteInstantOrder. +type ExecuteInstantOrderParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// ExecuteInstantOrderJSONBodySide defines parameters for ExecuteInstantOrder. +type ExecuteInstantOrderJSONBodySide string + +// GetInstantQuoteJSONBody defines parameters for GetInstantQuote. +type GetInstantQuoteJSONBody struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Specifies the account on which you intend to place the order. Only available for exchange accounts. + Account *string `json:"account,omitempty"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce Nonce `json:"nonce"` + + // PaymentMethodType Method used to specify payment method in `buy` order. Can be "AccountBalancePaymentType" to use funds available in USD balance held on Gemini, "BankAccountType" to initial an ACH from a linked bank account, or "CardAccountType" to use a linked debit card to fund the purchase. + PaymentMethodType *string `json:"paymentMethodType,omitempty"` + + // PaymentMethodUuid uuid provided as `bankId` in [Payment Methods API](/fund-management#list-payment-methods) + PaymentMethodUuid *string `json:"paymentMethodUuid,omitempty"` + + // Request The literal string "/v1/instant/quote/" + Request string `json:"request"` + + // Side "buy" or "sell" + Side GetInstantQuoteJSONBodySide `json:"side"` + + // Symbol The [symbol](/market-data/symbols-and-minimums) for the order. Instant includes order books denominated in a [supported currency](https://support.gemini.com/hc/en-us/articles/360000032663-Does-Gemini-support-fiat-currencies-other-than-USD), as `CCY2` + Symbol string `json:"symbol"` + + // TotalSpend Quoted decimal amount to spend on the order. Must comply with [stated minimums](/market-data/symbols-and-minimums). The `totalSpend` will be `CCY2` in `buy` orders and `CCY1` in `sell` orders. + TotalSpend string `json:"totalSpend"` +} + +// GetInstantQuoteParams defines parameters for GetInstantQuote. +type GetInstantQuoteParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// GetInstantQuoteJSONBodySide defines parameters for GetInstantQuote. +type GetInstantQuoteJSONBodySide string + +// ListClearingBrokersJSONRequestBody defines body for ListClearingBrokers for application/json ContentType. +type ListClearingBrokersJSONRequestBody ListClearingBrokersJSONBody + +// CreateNewBrokerOrderJSONRequestBody defines body for CreateNewBrokerOrder for application/json ContentType. +type CreateNewBrokerOrderJSONRequestBody CreateNewBrokerOrderJSONBody + +// CancelClearingOrderJSONRequestBody defines body for CancelClearingOrder for application/json ContentType. +type CancelClearingOrderJSONRequestBody CancelClearingOrderJSONBody + +// ConfirmClearingOrderJSONRequestBody defines body for ConfirmClearingOrder for application/json ContentType. +type ConfirmClearingOrderJSONRequestBody ConfirmClearingOrderJSONBody + +// ListClearingOrdersJSONRequestBody defines body for ListClearingOrders for application/json ContentType. +type ListClearingOrdersJSONRequestBody ListClearingOrdersJSONBody + +// CreateNewClearingOrderJSONRequestBody defines body for CreateNewClearingOrder for application/json ContentType. +type CreateNewClearingOrderJSONRequestBody CreateNewClearingOrderJSONBody + +// GetClearingOrderJSONRequestBody defines body for GetClearingOrder for application/json ContentType. +type GetClearingOrderJSONRequestBody GetClearingOrderJSONBody + +// ListClearingTradesJSONRequestBody defines body for ListClearingTrades for application/json ContentType. +type ListClearingTradesJSONRequestBody ListClearingTradesJSONBody + +// ExecuteInstantOrderJSONRequestBody defines body for ExecuteInstantOrder for application/json ContentType. +type ExecuteInstantOrderJSONRequestBody ExecuteInstantOrderJSONBody + +// GetInstantQuoteJSONRequestBody defines body for GetInstantQuote for application/json ContentType. +type GetInstantQuoteJSONRequestBody GetInstantQuoteJSONBody + +// AsHeartbeatNonce0 returns the union data inside the Heartbeat_Nonce as a HeartbeatNonce0 +func (t Heartbeat_Nonce) AsHeartbeatNonce0() (HeartbeatNonce0, error) { + var body HeartbeatNonce0 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromHeartbeatNonce0 overwrites any union data inside the Heartbeat_Nonce as the provided HeartbeatNonce0 +func (t *Heartbeat_Nonce) FromHeartbeatNonce0(v HeartbeatNonce0) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeHeartbeatNonce0 performs a merge with any union data inside the Heartbeat_Nonce, using the provided HeartbeatNonce0 +func (t *Heartbeat_Nonce) MergeHeartbeatNonce0(v HeartbeatNonce0) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsHeartbeatNonce1 returns the union data inside the Heartbeat_Nonce as a HeartbeatNonce1 +func (t Heartbeat_Nonce) AsHeartbeatNonce1() (HeartbeatNonce1, error) { + var body HeartbeatNonce1 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromHeartbeatNonce1 overwrites any union data inside the Heartbeat_Nonce as the provided HeartbeatNonce1 +func (t *Heartbeat_Nonce) FromHeartbeatNonce1(v HeartbeatNonce1) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeHeartbeatNonce1 performs a merge with any union data inside the Heartbeat_Nonce, using the provided HeartbeatNonce1 +func (t *Heartbeat_Nonce) MergeHeartbeatNonce1(v HeartbeatNonce1) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t Heartbeat_Nonce) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *Heartbeat_Nonce) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + +// AsTimestampType returns the union data inside the Nonce as a TimestampType +func (t Nonce) AsTimestampType() (TimestampType, error) { + var body TimestampType + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromTimestampType overwrites any union data inside the Nonce as the provided TimestampType +func (t *Nonce) FromTimestampType(v TimestampType) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeTimestampType performs a merge with any union data inside the Nonce, using the provided TimestampType +func (t *Nonce) MergeTimestampType(v TimestampType) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsNonce1 returns the union data inside the Nonce as a Nonce1 +func (t Nonce) AsNonce1() (Nonce1, error) { + var body Nonce1 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromNonce1 overwrites any union data inside the Nonce as the provided Nonce1 +func (t *Nonce) FromNonce1(v Nonce1) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeNonce1 performs a merge with any union data inside the Nonce, using the provided Nonce1 +func (t *Nonce) MergeNonce1(v Nonce1) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t Nonce) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *Nonce) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + +// AsTimestampType0 returns the union data inside the TimestampType as a TimestampType0 +func (t TimestampType) AsTimestampType0() (TimestampType0, error) { + var body TimestampType0 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromTimestampType0 overwrites any union data inside the TimestampType as the provided TimestampType0 +func (t *TimestampType) FromTimestampType0(v TimestampType0) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeTimestampType0 performs a merge with any union data inside the TimestampType, using the provided TimestampType0 +func (t *TimestampType) MergeTimestampType0(v TimestampType0) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsTimestampType1 returns the union data inside the TimestampType as a TimestampType1 +func (t TimestampType) AsTimestampType1() (TimestampType1, error) { + var body TimestampType1 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromTimestampType1 overwrites any union data inside the TimestampType as the provided TimestampType1 +func (t *TimestampType) FromTimestampType1(v TimestampType1) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeTimestampType1 performs a merge with any union data inside the TimestampType, using the provided TimestampType1 +func (t *TimestampType) MergeTimestampType1(v TimestampType1) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t TimestampType) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *TimestampType) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + +// AsTransaction0 returns the union data inside the Transaction as a Transaction0 +func (t Transaction) AsTransaction0() (Transaction0, error) { + var body Transaction0 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromTransaction0 overwrites any union data inside the Transaction as the provided Transaction0 +func (t *Transaction) FromTransaction0(v Transaction0) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeTransaction0 performs a merge with any union data inside the Transaction, using the provided Transaction0 +func (t *Transaction) MergeTransaction0(v Transaction0) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsTransaction1 returns the union data inside the Transaction as a Transaction1 +func (t Transaction) AsTransaction1() (Transaction1, error) { + var body Transaction1 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromTransaction1 overwrites any union data inside the Transaction as the provided Transaction1 +func (t *Transaction) FromTransaction1(v Transaction1) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeTransaction1 performs a merge with any union data inside the Transaction, using the provided Transaction1 +func (t *Transaction) MergeTransaction1(v Transaction1) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t Transaction) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *Transaction) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} diff --git a/packages/sdk-go/generated/margin/types.gen.go b/packages/sdk-go/generated/margin/types.gen.go new file mode 100644 index 00000000..aa19852e --- /dev/null +++ b/packages/sdk-go/generated/margin/types.gen.go @@ -0,0 +1,2832 @@ +// Code generated from rest.yaml (Margin Trading). DO NOT EDIT. + +// Package margin provides primitives to interact with the openapi HTTP API. +// +// Code generated by oapi-codegen. DO NOT EDIT. +package margin + +import ( + "encoding/json" + "time" + + "github.com/gemini/gemini-go/internal/runtime" + openapi_types "github.com/gemini/gemini-go/types" +) + +const ( + ApiKeyAuthScopes apiKeyAuthContextKey = "apiKeyAuth.Scopes" + PayloadAuthScopes payloadAuthContextKey = "payloadAuth.Scopes" + SignatureAuthScopes signatureAuthContextKey = "signatureAuth.Scopes" +) + +// Defines values for BalanceType. +const ( + Exchange BalanceType = "exchange" +) + +// Valid indicates whether the value is a known member of the BalanceType enum. +func (e BalanceType) Valid() bool { + switch e { + case Exchange: + return true + default: + return false + } +} + +// Defines values for CancelOrderResponseReason. +const ( + ExceedsPriceLimits CancelOrderResponseReason = "ExceedsPriceLimits" + FillOrKillWouldNotFill CancelOrderResponseReason = "FillOrKillWouldNotFill" + ImmediateOrCancelWouldPost CancelOrderResponseReason = "ImmediateOrCancelWouldPost" + MakerOrCancelWouldTake CancelOrderResponseReason = "MakerOrCancelWouldTake" + MarketClosed CancelOrderResponseReason = "MarketClosed" + Requested CancelOrderResponseReason = "Requested" + SelfCrossPrevented CancelOrderResponseReason = "SelfCrossPrevented" + TradingClosed CancelOrderResponseReason = "TradingClosed" +) + +// Valid indicates whether the value is a known member of the CancelOrderResponseReason enum. +func (e CancelOrderResponseReason) Valid() bool { + switch e { + case ExceedsPriceLimits: + return true + case FillOrKillWouldNotFill: + return true + case ImmediateOrCancelWouldPost: + return true + case MakerOrCancelWouldTake: + return true + case MarketClosed: + return true + case Requested: + return true + case SelfCrossPrevented: + return true + case TradingClosed: + return true + default: + return false + } +} + +// Defines values for CancelOrderResponseSide. +const ( + CancelOrderResponseSideBuy CancelOrderResponseSide = "buy" + CancelOrderResponseSideSell CancelOrderResponseSide = "sell" +) + +// Valid indicates whether the value is a known member of the CancelOrderResponseSide enum. +func (e CancelOrderResponseSide) Valid() bool { + switch e { + case CancelOrderResponseSideBuy: + return true + case CancelOrderResponseSideSell: + return true + default: + return false + } +} + +// Defines values for CancelOrderResponseType. +const ( + CancelOrderResponseTypeExchangeLimit CancelOrderResponseType = "exchange limit" + CancelOrderResponseTypeExchangeMarket CancelOrderResponseType = "exchange market" + CancelOrderResponseTypeExchangeStopLimit CancelOrderResponseType = "exchange stop limit" +) + +// Valid indicates whether the value is a known member of the CancelOrderResponseType enum. +func (e CancelOrderResponseType) Valid() bool { + switch e { + case CancelOrderResponseTypeExchangeLimit: + return true + case CancelOrderResponseTypeExchangeMarket: + return true + case CancelOrderResponseTypeExchangeStopLimit: + return true + default: + return false + } +} + +// Defines values for ClearingOrderSide. +const ( + ClearingOrderSideBuy ClearingOrderSide = "buy" + ClearingOrderSideSell ClearingOrderSide = "sell" +) + +// Valid indicates whether the value is a known member of the ClearingOrderSide enum. +func (e ClearingOrderSide) Valid() bool { + switch e { + case ClearingOrderSideBuy: + return true + case ClearingOrderSideSell: + return true + default: + return false + } +} + +// Defines values for FundingPaymentEventType. +const ( + FundingPaymentEventTypeHourlyFundingTransfer FundingPaymentEventType = "Hourly Funding Transfer" +) + +// Valid indicates whether the value is a known member of the FundingPaymentEventType enum. +func (e FundingPaymentEventType) Valid() bool { + switch e { + case FundingPaymentEventTypeHourlyFundingTransfer: + return true + default: + return false + } +} + +// Defines values for FundingPaymentReportItemAction. +const ( + FundingPaymentReportItemActionCredit FundingPaymentReportItemAction = "Credit" + FundingPaymentReportItemActionDebit FundingPaymentReportItemAction = "Debit" +) + +// Valid indicates whether the value is a known member of the FundingPaymentReportItemAction enum. +func (e FundingPaymentReportItemAction) Valid() bool { + switch e { + case FundingPaymentReportItemActionCredit: + return true + case FundingPaymentReportItemActionDebit: + return true + default: + return false + } +} + +// Defines values for FundingPaymentReportItemEventType. +const ( + FundingPaymentReportItemEventTypeHourlyFundingTransfer FundingPaymentReportItemEventType = "Hourly Funding Transfer" +) + +// Valid indicates whether the value is a known member of the FundingPaymentReportItemEventType enum. +func (e FundingPaymentReportItemEventType) Valid() bool { + switch e { + case FundingPaymentReportItemEventTypeHourlyFundingTransfer: + return true + default: + return false + } +} + +// Defines values for FundingTransferAction. +const ( + FundingTransferActionCredit FundingTransferAction = "Credit" + FundingTransferActionDebit FundingTransferAction = "Debit" +) + +// Valid indicates whether the value is a known member of the FundingTransferAction enum. +func (e FundingTransferAction) Valid() bool { + switch e { + case FundingTransferActionCredit: + return true + case FundingTransferActionDebit: + return true + default: + return false + } +} + +// Defines values for InstantQuoteSide. +const ( + InstantQuoteSideBuy InstantQuoteSide = "buy" + InstantQuoteSideSell InstantQuoteSide = "sell" +) + +// Valid indicates whether the value is a known member of the InstantQuoteSide enum. +func (e InstantQuoteSide) Valid() bool { + switch e { + case InstantQuoteSideBuy: + return true + case InstantQuoteSideSell: + return true + default: + return false + } +} + +// Defines values for InterestRateInfoInterval. +const ( + Hour InterestRateInfoInterval = "hour" +) + +// Valid indicates whether the value is a known member of the InterestRateInfoInterval enum. +func (e InterestRateInfoInterval) Valid() bool { + switch e { + case Hour: + return true + default: + return false + } +} + +// Defines values for LimitOrderResponseSide. +const ( + LimitOrderResponseSideBuy LimitOrderResponseSide = "buy" + LimitOrderResponseSideSell LimitOrderResponseSide = "sell" +) + +// Valid indicates whether the value is a known member of the LimitOrderResponseSide enum. +func (e LimitOrderResponseSide) Valid() bool { + switch e { + case LimitOrderResponseSideBuy: + return true + case LimitOrderResponseSideSell: + return true + default: + return false + } +} + +// Defines values for LimitOrderResponseType. +const ( + LimitOrderResponseTypeExchangeLimit LimitOrderResponseType = "exchange limit" + LimitOrderResponseTypeExchangeMarket LimitOrderResponseType = "exchange market" + LimitOrderResponseTypeExchangeStopLimit LimitOrderResponseType = "exchange stop limit" +) + +// Valid indicates whether the value is a known member of the LimitOrderResponseType enum. +func (e LimitOrderResponseType) Valid() bool { + switch e { + case LimitOrderResponseTypeExchangeLimit: + return true + case LimitOrderResponseTypeExchangeMarket: + return true + case LimitOrderResponseTypeExchangeStopLimit: + return true + default: + return false + } +} + +// Defines values for MyTradeBreak. +const ( + Empty MyTradeBreak = "" + TradeCorrect MyTradeBreak = "trade correct" +) + +// Valid indicates whether the value is a known member of the MyTradeBreak enum. +func (e MyTradeBreak) Valid() bool { + switch e { + case Empty: + return true + case TradeCorrect: + return true + default: + return false + } +} + +// Defines values for MyTradeType. +const ( + MyTradeTypeBuy MyTradeType = "Buy" + MyTradeTypeSell MyTradeType = "Sell" +) + +// Valid indicates whether the value is a known member of the MyTradeType enum. +func (e MyTradeType) Valid() bool { + switch e { + case MyTradeTypeBuy: + return true + case MyTradeTypeSell: + return true + default: + return false + } +} + +// Defines values for NewOrderRequestOptions. +const ( + FillOrKill NewOrderRequestOptions = "fill-or-kill" + ImmediateOrCancel NewOrderRequestOptions = "immediate-or-cancel" + MakerOrCancel NewOrderRequestOptions = "maker-or-cancel" +) + +// Valid indicates whether the value is a known member of the NewOrderRequestOptions enum. +func (e NewOrderRequestOptions) Valid() bool { + switch e { + case FillOrKill: + return true + case ImmediateOrCancel: + return true + case MakerOrCancel: + return true + default: + return false + } +} + +// Defines values for NewOrderRequestSide. +const ( + NewOrderRequestSideBuy NewOrderRequestSide = "buy" + NewOrderRequestSideSell NewOrderRequestSide = "sell" +) + +// Valid indicates whether the value is a known member of the NewOrderRequestSide enum. +func (e NewOrderRequestSide) Valid() bool { + switch e { + case NewOrderRequestSideBuy: + return true + case NewOrderRequestSideSell: + return true + default: + return false + } +} + +// Defines values for NewOrderRequestType. +const ( + NewOrderRequestTypeExchangeLimit NewOrderRequestType = "exchange limit" + NewOrderRequestTypeExchangeMarket NewOrderRequestType = "exchange market" + NewOrderRequestTypeExchangeStopLimit NewOrderRequestType = "exchange stop limit" +) + +// Valid indicates whether the value is a known member of the NewOrderRequestType enum. +func (e NewOrderRequestType) Valid() bool { + switch e { + case NewOrderRequestTypeExchangeLimit: + return true + case NewOrderRequestTypeExchangeMarket: + return true + case NewOrderRequestTypeExchangeStopLimit: + return true + default: + return false + } +} + +// Defines values for OrderSide. +const ( + OrderSideBuy OrderSide = "buy" + OrderSideSell OrderSide = "sell" +) + +// Valid indicates whether the value is a known member of the OrderSide enum. +func (e OrderSide) Valid() bool { + switch e { + case OrderSideBuy: + return true + case OrderSideSell: + return true + default: + return false + } +} + +// Defines values for OrderTradesType. +const ( + OrderTradesTypeBuy OrderTradesType = "Buy" + OrderTradesTypeSell OrderTradesType = "Sell" +) + +// Valid indicates whether the value is a known member of the OrderTradesType enum. +func (e OrderTradesType) Valid() bool { + switch e { + case OrderTradesTypeBuy: + return true + case OrderTradesTypeSell: + return true + default: + return false + } +} + +// Defines values for OrderType. +const ( + OrderTypeExchangeLimit OrderType = "exchange limit" + OrderTypeExchangeMarket OrderType = "exchange market" + OrderTypeExchangeStopLimit OrderType = "exchange stop limit" +) + +// Valid indicates whether the value is a known member of the OrderType enum. +func (e OrderType) Valid() bool { + switch e { + case OrderTypeExchangeLimit: + return true + case OrderTypeExchangeMarket: + return true + case OrderTypeExchangeStopLimit: + return true + default: + return false + } +} + +// Defines values for RiskStatsResponseProductType. +const ( + PerpetualSwapContract RiskStatsResponseProductType = "PerpetualSwapContract" +) + +// Valid indicates whether the value is a known member of the RiskStatsResponseProductType enum. +func (e RiskStatsResponseProductType) Valid() bool { + switch e { + case PerpetualSwapContract: + return true + default: + return false + } +} + +// Defines values for StakingTransactionTransactionType. +const ( + StakingTransactionTransactionTypeAdminCreditAdjustment StakingTransactionTransactionType = "AdminCreditAdjustment" + StakingTransactionTransactionTypeAdminDebitAdjustment StakingTransactionTransactionType = "AdminDebitAdjustment" + StakingTransactionTransactionTypeAdminRedeem StakingTransactionTransactionType = "AdminRedeem" + StakingTransactionTransactionTypeDeposit StakingTransactionTransactionType = "Deposit" + StakingTransactionTransactionTypeInterest StakingTransactionTransactionType = "Interest" + StakingTransactionTransactionTypeRedeem StakingTransactionTransactionType = "Redeem" + StakingTransactionTransactionTypeRedeemPayment StakingTransactionTransactionType = "RedeemPayment" +) + +// Valid indicates whether the value is a known member of the StakingTransactionTransactionType enum. +func (e StakingTransactionTransactionType) Valid() bool { + switch e { + case StakingTransactionTransactionTypeAdminCreditAdjustment: + return true + case StakingTransactionTransactionTypeAdminDebitAdjustment: + return true + case StakingTransactionTransactionTypeAdminRedeem: + return true + case StakingTransactionTransactionTypeDeposit: + return true + case StakingTransactionTransactionTypeInterest: + return true + case StakingTransactionTransactionTypeRedeem: + return true + case StakingTransactionTransactionTypeRedeemPayment: + return true + default: + return false + } +} + +// Defines values for StopLimitOrderResponseSide. +const ( + StopLimitOrderResponseSideBuy StopLimitOrderResponseSide = "buy" + StopLimitOrderResponseSideSell StopLimitOrderResponseSide = "sell" +) + +// Valid indicates whether the value is a known member of the StopLimitOrderResponseSide enum. +func (e StopLimitOrderResponseSide) Valid() bool { + switch e { + case StopLimitOrderResponseSideBuy: + return true + case StopLimitOrderResponseSideSell: + return true + default: + return false + } +} + +// Defines values for StopLimitOrderResponseType. +const ( + ExchangeStopLimit StopLimitOrderResponseType = "exchange stop limit" +) + +// Valid indicates whether the value is a known member of the StopLimitOrderResponseType enum. +func (e StopLimitOrderResponseType) Valid() bool { + switch e { + case ExchangeStopLimit: + return true + default: + return false + } +} + +// Defines values for TradeType. +const ( + TradeTypeBuy TradeType = "buy" + TradeTypeSell TradeType = "sell" +) + +// Valid indicates whether the value is a known member of the TradeType enum. +func (e TradeType) Valid() bool { + switch e { + case TradeTypeBuy: + return true + case TradeTypeSell: + return true + default: + return false + } +} + +// Defines values for TransferStatus. +const ( + TransferStatusComplete TransferStatus = "Complete" + TransferStatusPending TransferStatus = "Pending" +) + +// Valid indicates whether the value is a known member of the TransferStatus enum. +func (e TransferStatus) Valid() bool { + switch e { + case TransferStatusComplete: + return true + case TransferStatusPending: + return true + default: + return false + } +} + +// Defines values for TransferType. +const ( + TransferTypeDeposit TransferType = "Deposit" + TransferTypeWithdrawal TransferType = "Withdrawal" +) + +// Valid indicates whether the value is a known member of the TransferType enum. +func (e TransferType) Valid() bool { + switch e { + case TransferTypeDeposit: + return true + case TransferTypeWithdrawal: + return true + default: + return false + } +} + +// Defines values for V2TransferStatus. +const ( + V2TransferStatusAdvanced V2TransferStatus = "Advanced" + V2TransferStatusComplete V2TransferStatus = "Complete" + V2TransferStatusPending V2TransferStatus = "Pending" +) + +// Valid indicates whether the value is a known member of the V2TransferStatus enum. +func (e V2TransferStatus) Valid() bool { + switch e { + case V2TransferStatusAdvanced: + return true + case V2TransferStatusComplete: + return true + case V2TransferStatusPending: + return true + default: + return false + } +} + +// Defines values for V2TransferType. +const ( + AdminCredit V2TransferType = "AdminCredit" + AdminDebit V2TransferType = "AdminDebit" + Deposit V2TransferType = "Deposit" + Reward V2TransferType = "Reward" + Withdrawal V2TransferType = "Withdrawal" +) + +// Valid indicates whether the value is a known member of the V2TransferType enum. +func (e V2TransferType) Valid() bool { + switch e { + case AdminCredit: + return true + case AdminDebit: + return true + case Deposit: + return true + case Reward: + return true + case Withdrawal: + return true + default: + return false + } +} + +// Defines values for PreviewMarginOrderJSONBodySide. +const ( + Buy PreviewMarginOrderJSONBodySide = "buy" + Sell PreviewMarginOrderJSONBodySide = "sell" +) + +// Valid indicates whether the value is a known member of the PreviewMarginOrderJSONBodySide enum. +func (e PreviewMarginOrderJSONBodySide) Valid() bool { + switch e { + case Buy: + return true + case Sell: + return true + default: + return false + } +} + +// Defines values for PreviewMarginOrderJSONBodyType. +const ( + Limit PreviewMarginOrderJSONBodyType = "limit" + Market PreviewMarginOrderJSONBodyType = "market" +) + +// Valid indicates whether the value is a known member of the PreviewMarginOrderJSONBodyType enum. +func (e PreviewMarginOrderJSONBodyType) Valid() bool { + switch e { + case Limit: + return true + case Market: + return true + default: + return false + } +} + +// Account defines model for Account. +type Account struct { + // AccountId The account ID + AccountId *string `json:"account_id,omitempty"` + + // Created The creation date + Created *string `json:"created,omitempty"` + + // IsDefault Whether the account is the default account + IsDefault *bool `json:"is_default,omitempty"` + + // Name The account name + Name *string `json:"name,omitempty"` +} + +// AddBankResponse defines model for AddBankResponse. +type AddBankResponse struct { + // ReferenceId Reference ID for the new bank addition request. Once received, send in a wire from the requested bank account to verify it and enable withdrawals to that account. + ReferenceId *string `json:"referenceId,omitempty"` + + // Result Status result (e.g. ok). + Result *string `json:"result,omitempty"` +} + +// Address defines model for Address. +type Address struct { + // Address String representation of the cryptocurrency address + Address *string `json:"address,omitempty"` + + // Label If you provided a label when creating the address, it will be echoed back here + Label *string `json:"label,omitempty"` + + // Memo It would be present if applicable, it will be present for cosmos address + Memo *string `json:"memo,omitempty"` + + // Network The blockchain network for the address + Network *string `json:"network,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` +} + +// ApprovedAddress defines model for ApprovedAddress. +type ApprovedAddress struct { + // Address The address on the approved address list. + Address *string `json:"address,omitempty"` + + // CreatedAt UTC timestamp in millisecond of when the address was created. + CreatedAt *string `json:"createdAt,omitempty"` + + // Label The label assigned to the address + Label *string `json:"label,omitempty"` + + // Network The network of the approved address. Network can be `bitcoin`, `ethereum`, `bitcoincash`, `litecoin`, `zcash`, `filecoin`, `dogecoin`, `tezos`, `solana`, `polkadot`, `avalanche`, `cosmos`, or `xrpl` + Network *string `json:"network,omitempty"` + + // Scope Will return the scope of the address as either "account" or "group" + Scope *string `json:"scope,omitempty"` + + // Status The status of the address that will return as "active", "pending-time" or "pending-mua". The remaining time is exactly 7 days after the initial request. "pending-mua" is for multi-user accounts and will require another administator or fund manager on the account to approve the address. + Status *string `json:"status,omitempty"` +} + +// ApprovedAddressMessage defines model for ApprovedAddressMessage. +type ApprovedAddressMessage struct { + // Message Status or confirmation message for the approved address request or removal. + Message *string `json:"message,omitempty"` + + // Result Result status (e.g. ok). + Result *string `json:"result,omitempty"` +} + +// ApprovedAddressesResponse Response envelope containing the approved withdrawal addresses. +type ApprovedAddressesResponse struct { + // ApprovedAddresses Array of approved addresses on both the account and group level. + ApprovedAddresses *[]ApprovedAddress `json:"approvedAddresses,omitempty"` +} + +// Balance defines model for Balance. +type Balance struct { + // UnderscoreTimestamp Server-side monotonically increasing clock value as an ISO 8601 timestamp. Clients can use this value to detect and filter out stale responses that may occur due to load balancing or potential stale servers. + UnderscoreTimestamp *time.Time `json:"_timestamp,omitempty"` + + // Amount The confirmed balance for the currency (also referred to as `confirmedBalance`). For crypto withdrawals, this value is **not** reduced until the withdrawal has been confirmed on the blockchain. This delay protects against blockchain reorganizations. Use the `available` field instead if you need balances that immediately reflect holds. + Amount *openapi_types.DecimalNumber `json:"amount,omitempty"` + + // Available The amount available for trading. This value is reduced **immediately** when an order hold or withdrawal hold is placed, making it the recommended field for tracking real-time spendable balances. + Available *openapi_types.DecimalNumber `json:"available,omitempty"` + + // AvailableForWithdrawal The amount available for withdrawal + AvailableForWithdrawal *openapi_types.DecimalNumber `json:"availableForWithdrawal,omitempty"` + + // Currency The currency symbol + Currency *string `json:"currency,omitempty"` + + // PendingDeposit The amount pending deposit + PendingDeposit *openapi_types.DecimalNumber `json:"pendingDeposit,omitempty"` + + // PendingWithdrawal The amount pending withdrawal + PendingWithdrawal *openapi_types.DecimalNumber `json:"pendingWithdrawal,omitempty"` + Type *BalanceType `json:"type,omitempty"` +} + +// BalanceType defines model for Balance.Type. +type BalanceType string + +// CancelAllOrdersBySessionRequest defines model for CancelAllOrdersBySessionRequest. +type CancelAllOrdersBySessionRequest struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Specifies the account on which you intend to cancel the orders. Only available for exchange accounts. + Account *string `json:"account,omitempty"` + + // Nonce timestamp + Nonce TimestampType `json:"nonce"` + + // Request The literal string "/v1/order/cancel/session" + Request string `json:"request"` +} + +// CancelAllOrdersRequest defines model for CancelAllOrdersRequest. +type CancelAllOrdersRequest struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Specifies the account on which you intend to cancel the orders. Only available for exchange accounts. + Account *string `json:"account,omitempty"` + + // Nonce timestamp + Nonce TimestampType `json:"nonce"` + + // Request The literal string "/v1/order/cancel/all" + Request string `json:"request"` +} + +// CancelAllResult defines model for CancelAllResult. +type CancelAllResult struct { + // Details cancelledOrders/cancelRejects with IDs of both + Details *struct { + CancelRejects *[]int `json:"cancelRejects,omitempty"` + CancelledOrders *[]int `json:"cancelledOrders,omitempty"` + } `json:"details,omitempty"` + Result *string `json:"result,omitempty"` +} + +// CancelOrderRequest defines model for CancelOrderRequest. +type CancelOrderRequest struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Specifies the account on which you intend to cancel the order. Only available for exchange accounts. + Account *string `json:"account,omitempty"` + + // Nonce timestamp + Nonce TimestampType `json:"nonce"` + + // OrderId The order ID given by `/order/new` + OrderId int64 `json:"order_id"` + + // Request The literal string "/v1/order/cancel" + Request string `json:"request"` +} + +// CancelOrderResponse defines model for CancelOrderResponse. +type CancelOrderResponse struct { + AvgExecutionPrice *string `json:"avg_execution_price,omitempty"` + Exchange *string `json:"exchange,omitempty"` + ExecutedAmount *string `json:"executed_amount,omitempty"` + Id *string `json:"id,omitempty"` + IsCancelled *bool `json:"is_cancelled,omitempty"` + IsHidden *bool `json:"is_hidden,omitempty"` + IsLive *bool `json:"is_live,omitempty"` + Options *[]string `json:"options,omitempty"` + OrderId *string `json:"order_id,omitempty"` + OriginalAmount *string `json:"original_amount,omitempty"` + Price *string `json:"price,omitempty"` + Reason *CancelOrderResponseReason `json:"reason,omitempty"` + RemainingAmount *string `json:"remaining_amount,omitempty"` + Side *CancelOrderResponseSide `json:"side,omitempty"` + Symbol *string `json:"symbol,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + Type *CancelOrderResponseType `json:"type,omitempty"` + WasForced *bool `json:"was_forced,omitempty"` +} + +// CancelOrderResponseReason defines model for CancelOrderResponse.Reason. +type CancelOrderResponseReason string + +// CancelOrderResponseSide defines model for CancelOrderResponse.Side. +type CancelOrderResponseSide string + +// CancelOrderResponseType defines model for CancelOrderResponse.Type. +type CancelOrderResponseType string + +// Candle defines model for Candle. +type Candle = []float64 + +// CandleResponse defines model for CandleResponse. +type CandleResponse = []Candle + +// ClearingOrder defines model for ClearingOrder. +type ClearingOrder struct { + // Amount The order amount + Amount *string `json:"amount,omitempty"` + + // ClearingId The clearing ID + ClearingId *string `json:"clearing_id,omitempty"` + + // IsConfirmed Whether the order is confirmed + IsConfirmed *bool `json:"is_confirmed,omitempty"` + + // Price The order price + Price *string `json:"price,omitempty"` + Side *ClearingOrderSide `json:"side,omitempty"` + + // Status The order status + Status *string `json:"status,omitempty"` + + // Symbol The trading pair + Symbol *string `json:"symbol,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + + // Timestampms The timestamp in milliseconds + Timestampms *int `json:"timestampms,omitempty"` +} + +// ClearingOrderSide defines model for ClearingOrder.Side. +type ClearingOrderSide string + +// CustodyFeeTransfer defines model for CustodyFeeTransfer. +type CustodyFeeTransfer struct { + // Eid Custody fee event id + Eid *int `json:"eid,omitempty"` + + // EventType Custody fee event type + EventType *string `json:"eventType,omitempty"` + + // FeeAmount The fee amount charged + FeeAmount *string `json:"feeAmount,omitempty"` + + // FeeCurrency Currency that the fee was paid in + FeeCurrency *string `json:"feeCurrency,omitempty"` + + // TxTime Time of Custody fee record in milliseconds + TxTime *int `json:"txTime,omitempty"` +} + +// ErrorResponse defines model for ErrorResponse. +type ErrorResponse struct { + // Message Detailed error message + Message *string `json:"message,omitempty"` + + // Reason A short description + Reason *string `json:"reason,omitempty"` + + // Result Error + Result *string `json:"result,omitempty"` +} + +// FeeEstimateRequest defines model for FeeEstimateRequest. +type FeeEstimateRequest struct { + // Account The name of the account within the subaccount group. + Account string `json:"account"` + + // Address Standard string format of cryptocurrency address + Address string `json:"address"` + + // Amount Quoted decimal amount to withdraw + Amount string `json:"amount"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce Nonce `json:"nonce"` + + // Request The string `/v1/withdraw/{currencyCodeLowerCase}/feeEstimate` where `:currencyCodeLowerCase` is replaced with the currency code of a supported crypto-currency, e.g. `eth`, `aave`, etc. See [Symbols and minimums](/market-data/symbols-and-minimums) + Request string `json:"request"` +} + +// FeeEstimateResponse defines model for FeeEstimateResponse. +type FeeEstimateResponse struct { + // Currency Currency code, see [symbols](/market-data/symbols-and-minimums). + Currency *string `json:"currency,omitempty"` + + // Fee The estimated gas fee + Fee *string `json:"fee,omitempty"` + + // IsOverride Value that shows if an override on the customer's account for free withdrawals exists + IsOverride *bool `json:"isOverride,omitempty"` + + // MonthlyLimit Total nunber of allowable fee-free withdrawals + MonthlyLimit *int `json:"monthlyLimit,omitempty"` + + // MonthlyRemaining Total number of allowable fee-free withdrawals left to use + MonthlyRemaining *int `json:"monthlyRemaining,omitempty"` +} + +// FeeEstimateV2Request defines model for FeeEstimateV2Request. +type FeeEstimateV2Request struct { + // Account Required for Master API keys. The name of the account within the subaccount group. + Account *string `json:"account,omitempty"` + + // Address Standard string format of the destination cryptocurrency address + Address string `json:"address"` + + // Amount Quoted decimal amount to withdraw + Amount string `json:"amount"` + + // Memo It would be present if applicable, it will be present for cosmos address. + Memo *string `json:"memo,omitempty"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce Nonce `json:"nonce"` + + // Request The string `/v2/withdraw/{network}/{ticker}/feeEstimate` where `{network}` is the blockchain network (e.g. `ethereum`, `bitcoin`, `solana`) and `{ticker}` is the currency code (e.g. `eth`, `btc`, `sol`). See [Symbols and minimums](/market-data/symbols-and-minimums) + Request string `json:"request"` +} + +// FeeEstimateV2Response defines model for FeeEstimateV2Response. +type FeeEstimateV2Response struct { + // Currency Currency code, see [symbols](/market-data/symbols-and-minimums). + Currency *string `json:"currency,omitempty"` + + // Fee The estimated withdrawal fee as a decimal amount + Fee *openapi_types.DecimalNumber `json:"fee,omitempty"` + + // IsOverride Whether an override on the customer's account for free withdrawals exists + IsOverride *bool `json:"isOverride,omitempty"` + + // MonthlyLimit Total number of allowable fee-free withdrawals + MonthlyLimit *int `json:"monthlyLimit,omitempty"` + + // MonthlyRemaining Total number of allowable fee-free withdrawals remaining + MonthlyRemaining *int `json:"monthlyRemaining,omitempty"` +} + +// FeePromos defines model for FeePromos. +type FeePromos struct { + // Symbols Symbols that currently have fee promos + Symbols *[]string `json:"symbols,omitempty"` +} + +// FundingAmountResponse defines model for FundingAmountResponse. +type FundingAmountResponse struct { + // Amount The dollar amount for a Long 1 position held in the symbol for funding period (1 hour) + Amount *openapi_types.DecimalNumber `json:"amount,omitempty"` + + // EstimatedFundingAmount The estimated dollar amount for a Long 1 position held in the symbol for next funding period (1 hour) + EstimatedFundingAmount *openapi_types.DecimalNumber `json:"estimatedFundingAmount,omitempty"` + + // FundingDateTime UTC date time in format `yyyy-MM-ddThh:mm:ss.SSSZ` format + FundingDateTime *string `json:"fundingDateTime,omitempty"` + + // FundingTimestampMilliSecs Current funding amount Epoc time. + FundingTimestampMilliSecs *int64 `json:"fundingTimestampMilliSecs,omitempty"` + + // NextFundingTimestamp Next funding amount Epoc time. + NextFundingTimestamp *int64 `json:"nextFundingTimestamp,omitempty"` + + // Symbol The requested symbol. See [**symbols and minimums**](/market-data/symbols-and-minimums#all-supported-symbols) + Symbol *string `json:"symbol,omitempty"` +} + +// FundingPayment defines model for FundingPayment. +type FundingPayment struct { + // EventType Event type + EventType FundingPaymentEventType `json:"eventType"` + HourlyFundingTransfer FundingTransfer `json:"hourlyFundingTransfer"` +} + +// FundingPaymentEventType Event type +type FundingPaymentEventType string + +// FundingPaymentReportItem defines model for FundingPaymentReportItem. +type FundingPaymentReportItem struct { + // Action Credit or Debit + Action FundingPaymentReportItemAction `json:"action"` + + // AssetCode Asset symbol + AssetCode string `json:"assetCode"` + + // EventType Event type + EventType FundingPaymentReportItemEventType `json:"eventType"` + + // InstrumentSymbol Symbol of the underlying instrument. **Note** that this is only attached to requests from 16th April 2024 onwards. + InstrumentSymbol *string `json:"instrumentSymbol,omitempty"` + + // Quantity A nested JSON object describing the transaction amount + Quantity Quantity `json:"quantity"` + + // Timestamp Time of the funding payment + Timestamp TimestampType `json:"timestamp"` +} + +// FundingPaymentReportItemAction Credit or Debit +type FundingPaymentReportItemAction string + +// FundingPaymentReportItemEventType Event type +type FundingPaymentReportItemEventType string + +// FundingTransfer defines model for FundingTransfer. +type FundingTransfer struct { + // Action Credit or Debit + Action FundingTransferAction `json:"action"` + + // AssetCode Asset symbol + AssetCode string `json:"assetCode"` + + // EventType Event type + EventType string `json:"eventType"` + + // InstrumentSymbol Symbol of the underlying instrument. **Note** that this is only attached to requests from 16th April 2024 onwards. + InstrumentSymbol *string `json:"instrumentSymbol,omitempty"` + + // Quantity A nested JSON object describing the transaction amount + Quantity Quantity `json:"quantity"` + + // Timestamp Time of the funding payment + Timestamp TimestampType `json:"timestamp"` +} + +// FundingTransferAction Credit or Debit +type FundingTransferAction string + +// FxRate defines model for FxRate. +type FxRate struct { + // AsOf timestamp + AsOf *TimestampType `json:"asOf,omitempty"` + + // Benchmark The market for which the retrieved price applies to + Benchmark *string `json:"benchmark,omitempty"` + + // FxPair The requested currency pair + FxPair *string `json:"fxPair,omitempty"` + + // Provider The market data provider + Provider *string `json:"provider,omitempty"` + + // Rate The exchange rate + Rate *float64 `json:"rate,omitempty"` +} + +// Heartbeat defines model for Heartbeat. +type Heartbeat struct { + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce *Heartbeat_Nonce `json:"nonce,omitempty"` + + // Request The literal string `/v1/heartbeat` + Request *string `json:"request,omitempty"` +} + +// HeartbeatNonce0 Gemini strongly recommends using milliseconds instead of seconds for timestamps. +// +// | Timestamp format | Example | Supported request type | +// |-----------------------|-----------------------|------------------------| +// | string (seconds) | `'1495127793'` | `POST` only | +// | string (milliseconds) | `'1495127793000'` | `POST` only | +type HeartbeatNonce0 = string + +// HeartbeatNonce1 Gemini strongly recommends using milliseconds instead of seconds for timestamps. +// +// | Timestamp format | Example | Supported request type | +// |-----------------------------|---------------------------|------------------------| +// | whole number (seconds) | `1495127793` | `GET`, `POST` | +// | whole number (milliseconds) | `1495127793000` | `GET`, `POST` | +type HeartbeatNonce1 = int64 + +// Heartbeat_Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) +type Heartbeat_Nonce struct { + union json.RawMessage +} + +// InstantQuote defines model for InstantQuote. +type InstantQuote struct { + // DepositFee The deposit fee quantity. Will be applied if a debit card is used for the order. Will return 0 if there is no `depositFee` + DepositFee *string `json:"depositFee,omitempty"` + + // DepositFeeCurrency Currency in which `depositFee` is taken + DepositFeeCurrency *string `json:"depositFeeCurrency,omitempty"` + + // Fee The fee quantity to be taken for the order upon execution + Fee *string `json:"fee,omitempty"` + + // FeeCurrency The currency label for the order + FeeCurrency *string `json:"feeCurrency,omitempty"` + + // MaxAgeMs Number of milliseconds until this quote price expires. Once expired, you will need to request a new quote + MaxAgeMs *int `json:"maxAgeMs,omitempty"` + + // Pair The symbol passed in the quote request + Pair *string `json:"pair,omitempty"` + + // Price The quoted price of the asset. This will not change when attempting execution + Price *string `json:"price,omitempty"` + + // PriceCurrency The currency in which the order is priced. Matches `CCY2` in the symbol + PriceCurrency *string `json:"priceCurrency,omitempty"` + + // Quantity The quantity of the asset to be bought or sold + Quantity *string `json:"quantity,omitempty"` + + // QuantityCurrency The currency label for the `quantity` field. Matches `CCY1` in the symbol + QuantityCurrency *string `json:"quantityCurrency,omitempty"` + + // QuoteId Unique ID for the quote. This is used in the execution of the order + QuoteId *int `json:"quoteId,omitempty"` + + // Side Either "buy" or "sell" + Side *InstantQuoteSide `json:"side,omitempty"` + + // TotalSpend Total quantity to spend for the order. Will be the sum inclusive of all fees and amount to be traded. + TotalSpend *string `json:"totalSpend,omitempty"` + + // TotalSpendCurrency Currency of the `totalSpend` to be spent on the order + TotalSpendCurrency *string `json:"totalSpendCurrency,omitempty"` +} + +// InstantQuoteSide Either "buy" or "sell" +type InstantQuoteSide string + +// InterestRateInfo defines model for InterestRateInfo. +type InterestRateInfo struct { + // Interval The time interval for the rate (currently only "hour" is supported) + Interval InterestRateInfoInterval `json:"interval"` + + // Rate The interest rate as a decimal string + Rate string `json:"rate"` +} + +// InterestRateInfoInterval The time interval for the rate (currently only "hour" is supported) +type InterestRateInfoInterval string + +// LimitOrderResponse defines model for LimitOrderResponse. +type LimitOrderResponse struct { + AvgExecutionPrice *string `json:"avg_execution_price,omitempty"` + ClientOrderId *string `json:"client_order_id,omitempty"` + Exchange *string `json:"exchange,omitempty"` + ExecutedAmount *string `json:"executed_amount,omitempty"` + Id *string `json:"id,omitempty"` + IsCancelled *bool `json:"is_cancelled,omitempty"` + IsHidden *bool `json:"is_hidden,omitempty"` + IsLive *bool `json:"is_live,omitempty"` + Options *[]string `json:"options,omitempty"` + OrderId *string `json:"order_id,omitempty"` + OriginalAmount *string `json:"original_amount,omitempty"` + Price *string `json:"price,omitempty"` + RemainingAmount *string `json:"remaining_amount,omitempty"` + Side *LimitOrderResponseSide `json:"side,omitempty"` + Symbol *string `json:"symbol,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + Type *LimitOrderResponseType `json:"type,omitempty"` + WasForced *bool `json:"was_forced,omitempty"` +} + +// LimitOrderResponseSide defines model for LimitOrderResponse.Side. +type LimitOrderResponseSide string + +// LimitOrderResponseType defines model for LimitOrderResponse.Type. +type LimitOrderResponseType string + +// LiquidationRisk defines model for LiquidationRisk. +type LiquidationRisk struct { + // LiquidationPrice The estimated price at which liquidation would occur (optional, may not be present for all positions) + LiquidationPrice *MoneyAmount `json:"liquidationPrice,omitempty"` + + // LossPercentage The percentage loss from current value that would trigger liquidation, formatted as decimal (e.g., "0.1550" = 15.50%) + LossPercentage string `json:"lossPercentage"` +} + +// MarginAccountSummary defines model for MarginAccountSummary. +type MarginAccountSummary struct { + // AvailableCollateral The amount of collateral available for new positions or withdrawals + AvailableCollateral MoneyAmount `json:"availableCollateral"` + + // BuyingPower The maximum value that can be purchased with available collateral + BuyingPower MoneyAmount `json:"buyingPower"` + + // InterestRate Current interest rate on borrowed amounts (only present if borrows exist) + InterestRate *InterestRateInfo `json:"interestRate,omitempty"` + + // Leverage The current leverage ratio (notionalValue / marginAssetValue) + Leverage string `json:"leverage"` + + // LiquidationRisk Liquidation risk information (only present if positions exist) + LiquidationRisk *LiquidationRisk `json:"liquidationRisk,omitempty"` + + // MarginAssetValue The total value of all assets available in the margin account that can contribute to funding positions + MarginAssetValue MoneyAmount `json:"marginAssetValue"` + + // NotionalValue The total value of all open positions + NotionalValue MoneyAmount `json:"notionalValue"` + + // ReservedBuyOrders Collateral reserved for open buy orders + ReservedBuyOrders MoneyAmount `json:"reservedBuyOrders"` + + // ReservedSellOrders Collateral reserved for open sell orders + ReservedSellOrders MoneyAmount `json:"reservedSellOrders"` + + // SellingPower The maximum value that can be sold with available collateral + SellingPower MoneyAmount `json:"sellingPower"` + + // TotalBorrowed The total amount currently borrowed across all currencies + TotalBorrowed MoneyAmount `json:"totalBorrowed"` +} + +// MarginInterestRate defines model for MarginInterestRate. +type MarginInterestRate struct { + // BorrowRate The hourly borrow rate as a decimal + BorrowRate string `json:"borrowRate"` + + // BorrowRateAnnual The annualized borrow rate (daily rate × 365) + BorrowRateAnnual string `json:"borrowRateAnnual"` + + // BorrowRateDaily The daily borrow rate (hourly rate × 24) + BorrowRateDaily string `json:"borrowRateDaily"` + + // Currency The currency code (e.g., "BTC", "ETH", "USD") + Currency string `json:"currency"` + + // LastUpdated Unix timestamp in milliseconds when the rate was last updated + LastUpdated int64 `json:"lastUpdated"` +} + +// MarginOrderPreview defines model for MarginOrderPreview. +type MarginOrderPreview struct { + // Postorder Margin risk statistics after the order would be executed + Postorder MarginRiskStats `json:"postorder"` + + // Preorder Margin risk statistics before the order would be executed + Preorder MarginRiskStats `json:"preorder"` +} + +// MarginRatesResponse defines model for MarginRatesResponse. +type MarginRatesResponse struct { + // Rates Array of interest rates for all borrowable currencies + Rates []MarginInterestRate `json:"rates"` +} + +// MarginResponse defines model for MarginResponse. +type MarginResponse struct { + // AvailableMargin The difference between the `margin_assets_value` and `initial_margin`. + AvailableMargin *string `json:"available_margin,omitempty"` + + // BuyingPower The amount of that product the account could purchase based on current `initial_margin` and `margin_assets_value`. + BuyingPower *string `json:"buying_power,omitempty"` + + // EstimatedLiquidationPrice The estimated price for the asset at which liquidation would occur. + EstimatedLiquidationPrice *string `json:"estimated_liquidation_price,omitempty"` + + // InitialMargin The $ amount that is being required by the accounts current positions and open orders. + InitialMargin *string `json:"initial_margin,omitempty"` + + // InitialMarginPositions The contribution to `initial_margin` from open positions. + InitialMarginPositions *string `json:"initial_margin_positions,omitempty"` + + // Leverage The ratio of Notional Value to Margin Assets Value. + Leverage *string `json:"leverage,omitempty"` + + // MarginAssetsValue The $ equivalent value of all the assets available in the current trading account that can contribute to funding a derivatives position. + MarginAssetsValue *string `json:"margin_assets_value,omitempty"` + + // MarginMaintenanceLimit The minimum amount of `margin_assets_value` required before the account is moved to liquidation status. + MarginMaintenanceLimit *string `json:"margin_maintenance_limit,omitempty"` + + // NotionalValue The $ value of the current position. + NotionalValue *string `json:"notional_value,omitempty"` + + // ReservedMargin The contribution to `initial_margin` from open orders. + ReservedMargin *string `json:"reserved_margin,omitempty"` + + // ReservedMarginBuys The contribution to `initial_margin` from open BUY orders. + ReservedMarginBuys *string `json:"reserved_margin_buys,omitempty"` + + // ReservedMarginSells The contribution to `initial_margin` from open SELL orders. + ReservedMarginSells *string `json:"reserved_margin_sells,omitempty"` + + // SellingPower The amount of that product the account could sell based on current `initial_margin` and `margin_assets_value`. + SellingPower *string `json:"selling_power,omitempty"` +} + +// MarginRiskStats defines model for MarginRiskStats. +type MarginRiskStats struct { + // AvailableCollateral The amount of collateral available for new positions + AvailableCollateral MoneyAmount `json:"availableCollateral"` + + // BuyingPower The maximum value that can be purchased + BuyingPower MoneyAmount `json:"buyingPower"` + + // Leverage The leverage ratio + Leverage string `json:"leverage"` + + // LiquidationRisk Liquidation risk information (only present if applicable) + LiquidationRisk *LiquidationRisk `json:"liquidationRisk,omitempty"` + + // MarginAssetValue The total value of all assets available in the margin account + MarginAssetValue MoneyAmount `json:"marginAssetValue"` + + // NotionalValue The total value of all open positions + NotionalValue MoneyAmount `json:"notionalValue"` + + // ReservedBuyOrders Collateral reserved for open buy orders + ReservedBuyOrders MoneyAmount `json:"reservedBuyOrders"` + + // ReservedSellOrders Collateral reserved for open sell orders + ReservedSellOrders MoneyAmount `json:"reservedSellOrders"` + + // SellingPower The maximum value that can be sold + SellingPower MoneyAmount `json:"sellingPower"` + + // TotalBorrowed The total amount currently borrowed + TotalBorrowed MoneyAmount `json:"totalBorrowed"` +} + +// MoneyAmount defines model for MoneyAmount. +type MoneyAmount struct { + // Currency The currency code (e.g., "USD", "BTC", "ETH") + Currency string `json:"currency"` + + // Value The amount in the specified currency + Value string `json:"value"` +} + +// MyTrade defines model for MyTrade. +type MyTrade struct { + Aggressor *bool `json:"aggressor,omitempty"` + Amount *string `json:"amount,omitempty"` + Break *MyTradeBreak `json:"break,omitempty"` + ClientOrderId *string `json:"client_order_id,omitempty"` + Exchange *string `json:"exchange,omitempty"` + FeeAmount *string `json:"fee_amount,omitempty"` + FeeCurrency *string `json:"fee_currency,omitempty"` + IsAuctionFill *bool `json:"is_auction_fill,omitempty"` + OrderId *string `json:"order_id,omitempty"` + Price *string `json:"price,omitempty"` + Tid *int64 `json:"tid,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + Type *MyTradeType `json:"type,omitempty"` +} + +// MyTradeBreak defines model for MyTrade.Break. +type MyTradeBreak string + +// MyTradeType defines model for MyTrade.Type. +type MyTradeType string + +// MyTradesRequest defines model for MyTradesRequest. +type MyTradesRequest struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Specifies the account on which you intend to place the order. Only available for exchange accounts. + Account *string `json:"account,omitempty"` + + // LimitTrades The maximum number of trades to return. Default is 50, max is 500. + LimitTrades *int `json:"limit_trades,omitempty"` + + // Nonce timestamp + Nonce TimestampType `json:"nonce"` + + // Request The API endpoint path + Request string `json:"request"` + + // Symbol The [symbol](/market-data/symbols-and-minimums) to retrieve trades for + Symbol *string `json:"symbol,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` +} + +// NetworkAssets defines model for NetworkAssets. +type NetworkAssets struct { + // Assets Alphabetically sorted array of enabled asset/token codes available on this network. Assets include both exchange-tradable and custody-supported tokens. + Assets *[]string `json:"assets,omitempty"` + + // Network The blockchain network identifier. + Network *string `json:"network,omitempty"` +} + +// NetworkToken defines model for NetworkToken. +type NetworkToken struct { + // Network Array of supported blockchain networks for the token. Many tokens (especially stablecoins like USDC, USDT) are available on multiple networks. + // + // Supported networks include: `bitcoin`, `ethereum`, `solana`, `optimism`, `arbitrum`, `base`, `monad`, `avalanche`, `litecoin`, `bitcoincash`, `dogecoin`, `zcash`, `filecoin`, `tezos`, `polkadot`, `cosmos`, `xrpl`, `linea`, and more. + Network *[]string `json:"network,omitempty"` + + // Token The requested token identifier. + Token *string `json:"token,omitempty"` +} + +// NewOrderRequest defines model for NewOrderRequest. +type NewOrderRequest struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Specifies the account on which you intend to place the order. Only available for exchange accounts. + Account *string `json:"account,omitempty"` + + // Amount Quoted decimal amount to purchase + Amount string `json:"amount"` + + // ClientOrderId *Recommended*. A [client-specified order id](/client-order-id) + ClientOrderId *string `json:"client_order_id,omitempty"` + + // MarginOrder Set to `true` to place this order on a margin account using borrowed funds. Defaults to `false`. Only available for margin-enabled accounts. See [Margin Trading](/margin/account-summary) for details. + MarginOrder *bool `json:"margin_order,omitempty"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce int64 `json:"nonce"` + + // Options An optional array containing at most one supported order execution option. See Order execution options for details. + Options *[]NewOrderRequestOptions `json:"options,omitempty"` + + // Price Quoted decimal amount to spend per unit + Price string `json:"price"` + + // Request The literal string "/v1/order/new" + Request string `json:"request"` + Side NewOrderRequestSide `json:"side"` + + // StopPrice The price to trigger a stop-limit order. Only available for stop-limit orders. + StopPrice *string `json:"stop_price,omitempty"` + + // Symbol The [symbol](/market-data/symbols-and-minimums) for the new order + Symbol string `json:"symbol"` + + // Type The order type. "exchange limit" for all order types except for stop-limit orders. "exchange stop limit" for stop-limit orders. + Type NewOrderRequestType `json:"type"` +} + +// NewOrderRequestOptions defines model for NewOrderRequest.Options. +type NewOrderRequestOptions string + +// NewOrderRequestSide defines model for NewOrderRequest.Side. +type NewOrderRequestSide string + +// NewOrderRequestType The order type. "exchange limit" for all order types except for stop-limit orders. "exchange stop limit" for stop-limit orders. +type NewOrderRequestType string + +// Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) +type Nonce struct { + union json.RawMessage +} + +// Nonce1 defines model for . +type Nonce1 = int + +// NotionalBalance defines model for NotionalBalance. +type NotionalBalance struct { + // Amount The current balance + Amount *string `json:"amount,omitempty"` + + // AmountNotional Amount, in notional + AmountNotional *string `json:"amountNotional,omitempty"` + + // Available The amount that is available to trade + Available *string `json:"available,omitempty"` + + // AvailableForWithdrawal The amount that is available to withdraw + AvailableForWithdrawal *string `json:"availableForWithdrawal,omitempty"` + + // AvailableForWithdrawalNotional AvailableForWithdrawal, in notional + AvailableForWithdrawalNotional *string `json:"availableForWithdrawalNotional,omitempty"` + + // AvailableNotional Available, in notional + AvailableNotional *string `json:"availableNotional,omitempty"` + + // Currency Currency code, see symbols and minimums + Currency *string `json:"currency,omitempty"` +} + +// NotionalVolume defines model for NotionalVolume. +type NotionalVolume struct { + ApiAuctionFeeBps *int `json:"api_auction_fee_bps,omitempty"` + ApiMakerFeeBps *int `json:"api_maker_fee_bps,omitempty"` + ApiNotional30dVolume *string `json:"api_notional_30d_volume,omitempty"` + ApiTakerFeeBps *int `json:"api_taker_fee_bps,omitempty"` + Date *openapi_types.Date `json:"date,omitempty"` + FeeTier *struct { + ApiMakerFeeBps *int `json:"api_maker_fee_bps,omitempty"` + ApiTakerFeeBps *int `json:"api_taker_fee_bps,omitempty"` + Tier *string `json:"tier,omitempty"` + } `json:"fee_tier,omitempty"` + FixAuctionFeeBps *int `json:"fix_auction_fee_bps,omitempty"` + FixMakerFeeBps *int `json:"fix_maker_fee_bps,omitempty"` + FixTakerFeeBps *int `json:"fix_taker_fee_bps,omitempty"` + LastUpdatedMs *int `json:"last_updated_ms,omitempty"` + Notional1dVolume *[]struct { + // Date UTC date in `yyyy-MM-dd` format + Date *string `json:"date,omitempty"` + + // NotionalVolume Notional volume value in USD for this single day + NotionalVolume *string `json:"notional_volume,omitempty"` + } `json:"notional_1d_volume,omitempty"` + Notional30dVolume *string `json:"notional_30d_volume,omitempty"` + WebAuctionFeeBps *int `json:"web_auction_fee_bps,omitempty"` + WebMakerFeeBps *int `json:"web_maker_fee_bps,omitempty"` + WebTakerFeeBps *int `json:"web_taker_fee_bps,omitempty"` +} + +// OpenPosition defines model for OpenPosition. +type OpenPosition struct { + // AverageCost The average price of the current position. + AverageCost *string `json:"average_cost,omitempty"` + + // InstrumentType The type of instrument. Either "spot" or "perp". + InstrumentType *string `json:"instrument_type,omitempty"` + + // MarkPrice The current Mark Price for the Asset or the position. + MarkPrice *string `json:"mark_price,omitempty"` + + // NotionalValue The value of position; calculated as (`quantity` * `mark_price`). Value will be negative for shorts. + NotionalValue *string `json:"notional_value,omitempty"` + + // Quantity The position size. Value will be negative for shorts. + Quantity *string `json:"quantity,omitempty"` + + // RealisedPnl The current P&L that has been realised from the position. + RealisedPnl *string `json:"realised_pnl,omitempty"` + + // Symbol The [symbol](/market-data/symbols-and-minimums) of the order. + Symbol *string `json:"symbol,omitempty"` + + // UnrealisedPnl Current Mark to Market value of the positions. + UnrealisedPnl *string `json:"unrealised_pnl,omitempty"` +} + +// Order defines model for Order. +type Order struct { + // AvgExecutionPrice The average price at which this order as been executed so far. 0 if the order has not been executed at all. + AvgExecutionPrice *string `json:"avg_execution_price,omitempty"` + + // ClientOrderId An optional [client-specified order id](/client-order-id#client-order-id) + ClientOrderId *string `json:"client_order_id,omitempty"` + + // Exchange Will always be "gemini" + Exchange *string `json:"exchange,omitempty"` + + // ExecutedAmount The amount of the order that has been filled. + ExecutedAmount *string `json:"executed_amount,omitempty"` + + // IsCancelled `true` if the order has been canceled. Note the spelling, "cancelled" instead of "canceled". This is for compatibility reasons. + IsCancelled *bool `json:"is_cancelled,omitempty"` + + // IsHidden Will always return `false`. + IsHidden *bool `json:"is_hidden,omitempty"` + + // IsLive `true` if the order is active on the book (has remaining quantity and has not been canceled) + IsLive *bool `json:"is_live,omitempty"` + + // Options An array containing at most one supported order execution option. See [Order execution options](/rest/orders#create-new-order) for details. + Options *[]string `json:"options,omitempty"` + + // OrderId The order id + OrderId *string `json:"order_id,omitempty"` + + // OriginalAmount The originally submitted amount of the order. + OriginalAmount *string `json:"original_amount,omitempty"` + + // Price The price the order was issued at + Price *string `json:"price,omitempty"` + + // Reason Populated with the reason your order was canceled, if available. + Reason *string `json:"reason,omitempty"` + + // RemainingAmount The amount of the order that has not been filled. + RemainingAmount *string `json:"remaining_amount,omitempty"` + Side *OrderSide `json:"side,omitempty"` + + // Symbol The [symbol](/market-data/symbols-and-minimums#symbols-and-minimums) of the order + Symbol *string `json:"symbol,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + + // Trades Contains an array of JSON objects with trade details. + Trades *[]struct { + // Aggressor If `true`, this order was the taker in the trade + Aggressor *bool `json:"aggressor,omitempty"` + + // Amount The quantity that was executed + Amount *string `json:"amount,omitempty"` + + // Break Will only be present if the trade is broken. See `Break Types` below for more information. + Break *string `json:"break,omitempty"` + + // Exchange Will always be "gemini" + Exchange *string `json:"exchange,omitempty"` + + // FeeAmount The amount charged + FeeAmount *string `json:"fee_amount,omitempty"` + + // FeeCurrency Currency that the fee was paid in + FeeCurrency *string `json:"fee_currency,omitempty"` + + // OrderId The order that this trade executed against + OrderId *string `json:"order_id,omitempty"` + + // Price The price that the execution happened at + Price *string `json:"price,omitempty"` + + // Tid Unique identifier for the trade + Tid *int `json:"tid,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + + // Type Will be either "Buy" or "Sell", indicating the side of the original order + Type *OrderTradesType `json:"type,omitempty"` + } `json:"trades,omitempty"` + + // Type Description of the order + Type *OrderType `json:"type,omitempty"` + + // WasForced Will always be `false`. + WasForced *bool `json:"was_forced,omitempty"` +} + +// OrderSide defines model for Order.Side. +type OrderSide string + +// OrderTradesType Will be either "Buy" or "Sell", indicating the side of the original order +type OrderTradesType string + +// OrderType Description of the order +type OrderType string + +// OrderBook defines model for OrderBook. +type OrderBook struct { + // Asks The ask price levels currently on the book. These are offers to sell at a given price. + Asks *[]OrderBookEntry `json:"asks,omitempty"` + + // Bids The bid price levels currently on the book. These are offers to buy at a given price. + Bids *[]OrderBookEntry `json:"bids,omitempty"` +} + +// OrderBookEntry defines model for OrderBookEntry. +type OrderBookEntry struct { + // Amount The total quantity remaining at the price + Amount *string `json:"amount,omitempty"` + + // Price The price + Price *string `json:"price,omitempty"` + + // Timestamp **DO NOT USE** - this field is included for compatibility reasons only and is just populated with a dummy value. + Timestamp *string `json:"timestamp,omitempty"` +} + +// OrderStatusRequest defines model for OrderStatusRequest. +type OrderStatusRequest struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Specifies the account on which you intend to place the order. Only available for exchange accounts. + Account *string `json:"account,omitempty"` + + // ClientOrderId The `client_order_id` used when placing the order. `client_order_id` cannot be used in combination with `order_id` + ClientOrderId *string `json:"client_order_id,omitempty"` + + // IncludeTrades Either `True` or `False`. If `True` the endpoint will return individual trade details of all fills from the order. + IncludeTrades *bool `json:"include_trades,omitempty"` + + // Nonce timestamp + Nonce TimestampType `json:"nonce"` + + // OrderId The order id to get information on. The `order_id` represents a whole number and is transmitted as an unsigned 64-bit integer in JSON format. `order_id` cannot be used in combination with `client_order_id`. + OrderId int64 `json:"order_id"` + + // Request The API endpoint path + Request string `json:"request"` +} + +// PaymentMethodBalance defines model for PaymentMethodBalance. +type PaymentMethodBalance struct { + // Amount Total account balance for currency. + Amount *string `json:"amount,omitempty"` + + // Available Total amount available for trading + Available *string `json:"available,omitempty"` + + // AvailableForWithdrawal Total amount available for withdrawal + AvailableForWithdrawal *string `json:"availableForWithdrawal,omitempty"` + + // Currency Symbol for fiat balance. + Currency *string `json:"currency,omitempty"` + + // Type Account type. Will always be `exchange` + Type *string `json:"type,omitempty"` +} + +// PaymentMethodBank defines model for PaymentMethodBank. +type PaymentMethodBank struct { + // Bank Name of bank account + Bank *string `json:"bank,omitempty"` + + // BankId Unique identifier for bank account + BankId *string `json:"bankId,omitempty"` +} + +// PaymentMethodsResponse defines model for PaymentMethodsResponse. +type PaymentMethodsResponse struct { + // Balances Array of JSON objects with available fiat currencies and their balances. + Balances *[]PaymentMethodBalance `json:"balances,omitempty"` + + // Banks Array of JSON objects with banking information + Banks *[]PaymentMethodBank `json:"banks,omitempty"` +} + +// PriceFeedResponse defines model for PriceFeedResponse. +type PriceFeedResponse = []struct { + // Pair Trading pair symbol. See [**symbols and minimums**](/market-data/symbols-and-minimums#all-supported-symbols) + Pair *string `json:"pair,omitempty"` + + // PercentChange24h 24 hour change in price of the pair on the Gemini order book + PercentChange24h *string `json:"percentChange24h,omitempty"` + + // Price Current price of the pair on the Gemini order book + Price *string `json:"price,omitempty"` +} + +// Quantity defines model for Quantity. +type Quantity struct { + // Currency The currency code of the quantity. + Currency string `json:"currency"` + + // Value The value of the quantity. + Value string `json:"value"` +} + +// RevokeOauthTokenResponse defines model for RevokeOauthTokenResponse. +type RevokeOauthTokenResponse struct { + // Message A message that indicates the token has been revoked for the account + Message *string `json:"message,omitempty"` +} + +// RiskStatsResponse defines model for RiskStatsResponse. +type RiskStatsResponse struct { + // IndexPrice Current index price at the time of request + IndexPrice *string `json:"index_price,omitempty"` + + // MarkPrice Current mark price at the time of request + MarkPrice *string `json:"mark_price,omitempty"` + + // OpenInterest string representation of decimal value of open interest + OpenInterest *string `json:"open_interest,omitempty"` + + // OpenInterestNotional string representation of decimal value of open interest notional + OpenInterestNotional *string `json:"open_interest_notional,omitempty"` + + // ProductType Contract type for which the symbol data is fetched + ProductType *RiskStatsResponseProductType `json:"product_type,omitempty"` +} + +// RiskStatsResponseProductType Contract type for which the symbol data is fetched +type RiskStatsResponseProductType string + +// RoleResponse defines model for RoleResponse. +type RoleResponse struct { + // CounterpartyId _Only returned for master-level API keys_. The Gemini clearing counterparty ID associated with the API key making the request. + CounterpartyId *string `json:"counterparty_id,omitempty"` + + // IsAccountAdmin _Only returned for master-level API keys_.`True` if the Administrator role is assigned to the API keys. `False` otherwise. + IsAccountAdmin *bool `json:"isAccountAdmin,omitempty"` + + // IsAuditor `True` if the Auditor role is assigned to the API keys. `False` otherwise. + IsAuditor bool `json:"isAuditor"` + + // IsFundManager `True` if the Fund Manager role is assigned to the API keys. `False` otherwise. + IsFundManager bool `json:"isFundManager"` + + // IsTrader `True` if the Trader role is assigned to the API keys. `False` otherwise. + IsTrader bool `json:"isTrader"` +} + +// StakingBalance defines model for StakingBalance. +type StakingBalance struct { + // Available The amount that is available to trade + Available *openapi_types.DecimalNumber `json:"available,omitempty"` + + // AvailableForWithdrawal The Staking amount that is available to redeem to exchange account + AvailableForWithdrawal *openapi_types.DecimalNumber `json:"availableForWithdrawal,omitempty"` + + // Balance The current Staking balance + Balance *openapi_types.DecimalNumber `json:"balance,omitempty"` + BalanceByProvider *map[string]struct { + // Balance The current Staking balance per providerId + Balance *openapi_types.DecimalNumber `json:"balance,omitempty"` + } `json:"balanceByProvider,omitempty"` + + // Currency Currency code, see symbols and minimums + Currency *string `json:"currency,omitempty"` + + // Type Will always be "Staking" + Type *string `json:"type,omitempty"` +} + +// StakingDeposit defines model for StakingDeposit. +type StakingDeposit struct { + // AccrualTotal The total accrual + AccrualTotal *openapi_types.DecimalNumber `json:"accrualTotal,omitempty"` + + // Amount The amount deposited + Amount *openapi_types.DecimalNumber `json:"amount,omitempty"` + + // Currency Currency code, see [symbols](/market-data/symbols-and-minimums) + Currency *string `json:"currency,omitempty"` + + // ProviderId Provider Id, in uuid4 format + ProviderId *string `json:"providerId,omitempty"` + + // Rates A JSON object including one or many rates. If more than one rate it would be an array of rates. + Rates *struct { + // Rate Staking interest rate in bps (Expressed as a simple rate. Interest on Staking balances compounds daily. In mobile and web applications, APYs are derived from this rate and rounded to 1/10th of a percent.) + Rate *int `json:"rate,omitempty"` + } `json:"rates,omitempty"` + + // TransactionId A unique identifier for the staking transaction + TransactionId *string `json:"transactionId,omitempty"` +} + +// StakingHistory defines model for StakingHistory. +type StakingHistory struct { + // ProviderId Provider Id, in uuid4 format + ProviderId *string `json:"providerId,omitempty"` + Transactions *[]StakingTransaction `json:"transactions,omitempty"` +} + +// StakingRate defines model for StakingRate. +type StakingRate struct { + // ApyPct Staking interest APY (Expressed as a percentage derived from the rate and rounded to 1/10th of a percent.) + ApyPct *openapi_types.DecimalNumber `json:"apyPct,omitempty"` + + // DepositUsdLimit Maximum new amount in USD notional of this crypto that can participate in Gemini Staking per account per month + DepositUsdLimit *int `json:"depositUsdLimit,omitempty"` + + // ProviderId Provider Id, in uuid4 format + ProviderId *string `json:"providerId,omitempty"` + + // Rate Staking interest rate in bps (Expressed as a simple rate. Interest on Staking balances compounds daily. In mobile and web applications, APYs are derived from this rate and rounded to 1/10th of a percent.) + Rate *openapi_types.DecimalNumber `json:"rate,omitempty"` + + // RatePct `rate` expressed as a percentage + RatePct *openapi_types.DecimalNumber `json:"ratePct,omitempty"` +} + +// StakingRateProvider Currency Symbol Keys +type StakingRateProvider struct { + CurrencySymbol *StakingRate `json:"currency_symbol,omitempty"` +} + +// StakingRateResponse Provider UUID Keys +type StakingRateResponse struct { + // ProviderUuid Currency Symbol Keys + ProviderUuid *StakingRateProvider `json:"provider_uuid,omitempty"` +} + +// StakingRewardPeriod defines model for StakingRewardPeriod. +type StakingRewardPeriod struct { + // AccrualTotal The total accrual + AccrualTotal *openapi_types.DecimalNumber `json:"accrualTotal,omitempty"` + + // ApyPct Staking reward rate expressed as an APY at time of accrual. Interest on Staking balances compounds daily based on the simple rate which is available from `/v1/staking/rates/` + ApyPct *openapi_types.DecimalNumber `json:"apyPct,omitempty"` + + // Currency Currency code, see [symbols](/market-data/symbols-and-minimums) + Currency *string `json:"currency,omitempty"` + + // FirstAccrualAt Time of first accrual. In iso datetime with timezone format + FirstAccrualAt *string `json:"firstAccrualAt,omitempty"` + + // LastAccrualAt Time of last accrual. In iso datetime with timezone format + LastAccrualAt *string `json:"lastAccrualAt,omitempty"` + + // NumberOfAccruals Number of accruals in the specific aggregate, typically one per day. If the rate is adjusted, new accruals are added. + NumberOfAccruals *int `json:"numberOfAccruals,omitempty"` + + // ProviderId Provider Id, in uuid4 format + ProviderId *string `json:"providerId,omitempty"` + + // RatePct Rate expressed as a percentage + RatePct *openapi_types.DecimalNumber `json:"ratePct,omitempty"` +} + +// StakingRewards defines model for StakingRewards. +type StakingRewards struct { + // AccrualTotal The total accrual + AccrualTotal *openapi_types.DecimalNumber `json:"accrualTotal,omitempty"` + + // Currency Currency code, see [symbols](/market-data/symbols-and-minimums) + Currency *string `json:"currency,omitempty"` + + // ProviderId Provider Id, in uuid4 format + ProviderId *string `json:"providerId,omitempty"` + + // RatePeriods Array of JSON objects with period accrual information + RatePeriods *[]StakingRewardPeriod `json:"ratePeriods,omitempty"` +} + +// StakingRewardsProvider Currency Symbol Keys +type StakingRewardsProvider struct { + CurrencySymbol *StakingRewards `json:"currency_symbol,omitempty"` +} + +// StakingRewardsResponse Provider UUID Keys +type StakingRewardsResponse struct { + // ProviderUuid Currency Symbol Keys + ProviderUuid *StakingRewardsProvider `json:"provider_uuid,omitempty"` +} + +// StakingTransaction defines model for StakingTransaction. +type StakingTransaction struct { + // Amount The amount that is defined by the transactionType above + Amount *openapi_types.DecimalNumber `json:"amount,omitempty"` + + // AmountCurrency Currency code + AmountCurrency *string `json:"amountCurrency,omitempty"` + + // DateTime timestamp + DateTime *TimestampType `json:"dateTime,omitempty"` + + // PriceAmount Current market price of the underlying token at the time of the reward + PriceAmount *openapi_types.DecimalNumber `json:"priceAmount,omitempty"` + + // PriceCurrency A supported three-letter fiat currency code, e.g. usd + PriceCurrency *string `json:"priceCurrency,omitempty"` + + // TransactionId A unique identifier for the staking transaction + TransactionId *string `json:"transactionId,omitempty"` + + // TransactionType Can be any one of the following - Deposit, Redeem, Interest, RedeemPayment, AdminRedeem, AdminCreditAdjustment, AdminDebitAdjustment + TransactionType *StakingTransactionTransactionType `json:"transactionType,omitempty"` +} + +// StakingTransactionTransactionType Can be any one of the following - Deposit, Redeem, Interest, RedeemPayment, AdminRedeem, AdminCreditAdjustment, AdminDebitAdjustment +type StakingTransactionTransactionType string + +// StakingWithdrawal defines model for StakingWithdrawal. +type StakingWithdrawal struct { + // Amount The amount deposited + Amount *openapi_types.DecimalNumber `json:"amount,omitempty"` + + // AmountPaidSoFar The amount redeemed successfully + AmountPaidSoFar *openapi_types.DecimalNumber `json:"amountPaidSoFar,omitempty"` + + // AmountRemaining The amount pending to be redeemed + AmountRemaining *openapi_types.DecimalNumber `json:"amountRemaining,omitempty"` + + // Currency Currency code + Currency *string `json:"currency,omitempty"` + + // RequestInitiated In ISO datetime with timezone format + RequestInitiated *string `json:"requestInitiated,omitempty"` + + // TransactionId A unique identifier for the staking transaction + TransactionId *string `json:"transactionId,omitempty"` +} + +// StopLimitOrderResponse defines model for StopLimitOrderResponse. +type StopLimitOrderResponse struct { + AvgExecutionPrice *string `json:"avg_execution_price,omitempty"` + Exchange *string `json:"exchange,omitempty"` + ExecutedAmount *string `json:"executed_amount,omitempty"` + Id *string `json:"id,omitempty"` + IsCancelled *bool `json:"is_cancelled,omitempty"` + IsHidden *bool `json:"is_hidden,omitempty"` + IsLive *bool `json:"is_live,omitempty"` + Options *[]string `json:"options,omitempty"` + OrderId *string `json:"order_id,omitempty"` + OriginalAmount *string `json:"original_amount,omitempty"` + Price *string `json:"price,omitempty"` + Side *StopLimitOrderResponseSide `json:"side,omitempty"` + StopPrice *string `json:"stop_price,omitempty"` + Symbol *string `json:"symbol,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + Type *StopLimitOrderResponseType `json:"type,omitempty"` + WasForced *bool `json:"was_forced,omitempty"` +} + +// StopLimitOrderResponseSide defines model for StopLimitOrderResponse.Side. +type StopLimitOrderResponseSide string + +// StopLimitOrderResponseType defines model for StopLimitOrderResponse.Type. +type StopLimitOrderResponseType string + +// SymbolDetails defines model for SymbolDetails. +type SymbolDetails struct { + // BaseCurrency CCY1 or the top currency. (i.e `BTC` in `BTCUSD`) + BaseCurrency *string `json:"base_currency,omitempty"` + + // ContractPriceCurrency CCY2 or the quote currency for spot instrument (i.e. `USD` in `BTCUSD`) + // Or collateral currency of the contract in case of perpetual swap instrument. + ContractPriceCurrency *string `json:"contract_price_currency,omitempty"` + + // ContractType `vanilla` / `linear` / `inverse` where `vanilla` is for spot + // while `linear` is for perpetual swap and `inverse` is a special case perpetual swap where the perpetual contract will be settled in base currency. + ContractType *string `json:"contract_type,omitempty"` + + // MinOrderSize The minimum order size in `base_currency` units (i.e `0.00001`) + MinOrderSize *string `json:"min_order_size,omitempty"` + + // ProductType Instrument type `spot` / `swap` -- where `swap` signifies `perpetual swap`. + ProductType *string `json:"product_type,omitempty"` + + // QuoteCurrency CCY2 or the quote currency. (i.e `USD` in `BTCUSD`) + QuoteCurrency *string `json:"quote_currency,omitempty"` + + // QuoteIncrement The number of decimal places in the `quote_currency` (i.e `0.01`) + QuoteIncrement *openapi_types.DecimalNumber `json:"quote_increment,omitempty"` + + // Status Status of the current order book. Can be `open`, `closed`, `cancel_only`, `post_only`, `limit_only`. + Status *string `json:"status,omitempty"` + + // Symbol The requested symbol. See [**symbols and minimums**](/market-data/symbols-and-minimums#all-supported-symbols) + Symbol *string `json:"symbol,omitempty"` + + // TickSize The number of decimal places in the `base_currency`. (i.e `1e-8`) + TickSize *openapi_types.DecimalNumber `json:"tick_size,omitempty"` + + // WrapEnabled When `True`, symbol can be wrapped using this endpoint: + // `POST https://api.gemini.com/v1/wrap/:symbol` + WrapEnabled *bool `json:"wrap_enabled,omitempty"` +} + +// Ticker defines model for Ticker. +type Ticker struct { + // Ask The lowest ask currently available + Ask *string `json:"ask,omitempty"` + + // Bid The highest bid currently available + Bid *string `json:"bid,omitempty"` + + // Last The price of the last executed trade + Last *string `json:"last,omitempty"` + + // Volume Information about the 24 hour volume on the exchange. See properties below + Volume *struct { + // PriceSymbol The volume denominated in the price currency + PriceSymbol *string `json:"price_symbol,omitempty"` + + // QuantitySymbol The volume denominated in the quantity currency + QuantitySymbol *string `json:"quantity_symbol,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + } `json:"volume,omitempty"` +} + +// TickerInfo defines model for TickerInfo. +type TickerInfo struct { + // Ask Current best offer + Ask *string `json:"ask,omitempty"` + + // Bid Current best bid + Bid *string `json:"bid,omitempty"` + + // Changes Hourly prices descending for past 24 hours + Changes *[]string `json:"changes,omitempty"` + + // Close Close price (most recent trade) + Close *string `json:"close,omitempty"` + + // High High price from 24 hours ago + High *string `json:"high,omitempty"` + + // Low Low price from 24 hours ago + Low *string `json:"low,omitempty"` + + // Open Open price from 24 hours ago + Open *string `json:"open,omitempty"` + + // Symbol The trading pair symbol + Symbol *string `json:"symbol,omitempty"` +} + +// TimestampType timestamp +type TimestampType struct { + union json.RawMessage +} + +// TimestampType0 Gemini strongly recommends using milliseconds instead of seconds for timestamps. +// +// | Timestamp format | Example | Supported request type | +// |-----------------------|-----------------------|------------------------| +// | string (seconds) | `1495127793` | `POST` only | +// | string (milliseconds) | `1495127793000` | `POST` only | +type TimestampType0 = string + +// TimestampType1 Gemini strongly recommends using milliseconds instead of seconds for timestamps. +// +// | Timestamp format | Example | Supported request type | +// |-----------------------------|---------------------------|------------------------| +// | whole number (seconds) | `1495127793` | `GET`, `POST` | +// | whole number (milliseconds) | `1495127793000` | `GET`, `POST` | +type TimestampType1 = int64 + +// Trade defines model for Trade. +type Trade struct { + // Amount The amount that was traded + Amount *string `json:"amount,omitempty"` + + // Broken Whether the trade was broken or not. Broken trades will not be displayed by default; use the `include_breaks` to display them. + Broken *bool `json:"broken,omitempty"` + + // Exchange Will always be "gemini" + Exchange *string `json:"exchange,omitempty"` + + // Price The price the trade was executed at + Price *string `json:"price,omitempty"` + + // Tid The trade ID number + Tid *int64 `json:"tid,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + + // Type - `buy` means that an ask was removed from the book by an incoming buy order. + // - `sell` means that a bid was removed from the book by an incoming sell order. + Type *TradeType `json:"type,omitempty"` +} + +// TradeType - `buy` means that an ask was removed from the book by an incoming buy order. +// - `sell` means that a bid was removed from the book by an incoming sell order. +type TradeType string + +// TradeVolume defines model for TradeVolume. +type TradeVolume struct { + BaseCurrency *string `json:"base_currency,omitempty"` + BuyMakerBase *string `json:"buy_maker_base,omitempty"` + BuyMakerCount *int `json:"buy_maker_count,omitempty"` + BuyMakerNotional *string `json:"buy_maker_notional,omitempty"` + BuyTakerBase *string `json:"buy_taker_base,omitempty"` + BuyTakerCount *int `json:"buy_taker_count,omitempty"` + BuyTakerNotional *string `json:"buy_taker_notional,omitempty"` + DataDate *string `json:"data_date,omitempty"` + MakerBuySellRatio *string `json:"maker_buy_sell_ratio,omitempty"` + NotionalCurrency *string `json:"notional_currency,omitempty"` + QuoteCurrency *string `json:"quote_currency,omitempty"` + SellMakerBase *string `json:"sell_maker_base,omitempty"` + SellMakerCount *int `json:"sell_maker_count,omitempty"` + SellMakerNotional *string `json:"sell_maker_notional,omitempty"` + SellTakerBase *string `json:"sell_taker_base,omitempty"` + SellTakerCount *int `json:"sell_taker_count,omitempty"` + SellTakerNotional *string `json:"sell_taker_notional,omitempty"` + Symbol *string `json:"symbol,omitempty"` + TotalVolumeBase *string `json:"total_volume_base,omitempty"` +} + +// Transaction defines model for Transaction. +type Transaction struct { + union json.RawMessage +} + +// Transaction0 Trade Reponse +type Transaction0 struct { + // Account The account. + Account *string `json:"account,omitempty"` + + // Amount The quantity that was executed. + Amount *string `json:"amount,omitempty"` + + // ClientOrderId The client order ID, if defined. Otherwise an empty string. + ClientOrderId *string `json:"clientOrderId,omitempty"` + + // Exchange Will always be "gemini". + Exchange *string `json:"exchange,omitempty"` + + // FeeAmount The fee amount charged + FeeAmount *string `json:"feeAmount,omitempty"` + + // FeeAssetCode The symbol that the trade was for + FeeAssetCode *string `json:"feeAssetCode,omitempty"` + + // IsAggressor If true, this order was the taker in the trade. + IsAggressor *bool `json:"isAggressor,omitempty"` + + // IsAuctionFill True if the trade was a auction trade and not an on-exchange trade. + IsAuctionFill *bool `json:"isAuctionFill,omitempty"` + + // IsClearingFill True if the trade was a clearing trade and not an on-exchange trade. + IsClearingFill *bool `json:"isClearingFill,omitempty"` + + // OrderId The order that this trade executed against. + OrderId *int64 `json:"orderId,omitempty"` + + // Price The price that the execution happened at. + Price *string `json:"price,omitempty"` + + // Side Indicating the side of the original order. + Side *string `json:"side,omitempty"` + + // Symbol The symbol that the trade was for. + Symbol *string `json:"symbol,omitempty"` + + // Tid The trade ID. + Tid *int64 `json:"tid,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` +} + +// Transaction1 Transfer Reponse +type Transaction1 struct { + // AdvanceEid Deposit advance event ID. + AdvanceEid *int64 `json:"advanceEid,omitempty"` + + // Amount The quantity that was transferred. + Amount *string `json:"amount,omitempty"` + + // BankId Bank ID. + BankId *string `json:"bankId,omitempty"` + + // ClientTransferId Client Transfer ID. Client transfer ID is an optional client-supplied unique identifier for each withdrawal or internal transfer. + ClientTransferId *string `json:"clientTransferId,omitempty"` + + // CorrelationId Correlation ID. + CorrelationId *int64 `json:"correlationId,omitempty"` + + // Currency Currency code, see symbols + Currency *string `json:"currency,omitempty"` + + // Destination The account you are transferring to. + Destination *string `json:"destination,omitempty"` + + // Eid Transfer event id. + Eid *int64 `json:"eid,omitempty"` + + // FeeId Fee ID. + FeeId *string `json:"feeId,omitempty"` + + // Method Type of transfer method. + Method *string `json:"method,omitempty"` + + // OperationReason The operation reason. + OperationReason *string `json:"operationReason,omitempty"` + + // PendingEid Pending event ID. + PendingEid *int64 `json:"pendingEid,omitempty"` + + // Purpose Purpose. + Purpose *string `json:"purpose,omitempty"` + + // Source The account you are transferring from. + Source *string `json:"source,omitempty"` + + // Status The status of the transfer. + Status *string `json:"status,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + + // TransactionHash Supplies the transaction hash when available. + TransactionHash *string `json:"transactionHash,omitempty"` + + // TransferId Transfer ID. + TransferId *string `json:"transferId,omitempty"` + + // TransferType Transfer type. + TransferType *string `json:"transferType,omitempty"` + + // WithdrawalEid Withdrawal event ID. + WithdrawalEid *int64 `json:"withdrawalEid,omitempty"` + + // WithdrawalId Withdrawal ID. + WithdrawalId *string `json:"withdrawalId,omitempty"` +} + +// Transfer defines model for Transfer. +type Transfer struct { + // Amount The amount transferred + Amount *string `json:"amount,omitempty"` + + // Currency The currency transferred + Currency *string `json:"currency,omitempty"` + + // Eid The transfer ID + Eid *int64 `json:"eid,omitempty"` + Status *TransferStatus `json:"status,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + + // TxHash The transaction hash if applicable + TxHash *string `json:"txHash,omitempty"` + Type *TransferType `json:"type,omitempty"` +} + +// TransferStatus defines model for Transfer.Status. +type TransferStatus string + +// TransferType defines model for Transfer.Type. +type TransferType string + +// V2Transfer defines model for V2Transfer. +type V2Transfer struct { + // Amount The amount transferred + Amount *string `json:"amount,omitempty"` + + // Currency The currency transferred + Currency *string `json:"currency,omitempty"` + + // Destination The destination address for withdrawals + Destination *string `json:"destination,omitempty"` + + // Eid The transfer event ID + Eid *int64 `json:"eid,omitempty"` + + // FeeAmount The fee charged for the transfer + FeeAmount *string `json:"feeAmount,omitempty"` + + // FeeCurrency The currency in which the fee was charged + FeeCurrency *string `json:"feeCurrency,omitempty"` + + // Method The transfer method (e.g., `ACH`, `CreditCard`) + Method *string `json:"method,omitempty"` + + // Network The blockchain network the transfer was executed on (e.g., `ethereum`, `solana`, `arbitrum`, `optimism`, `base`, `avalanche`). Not present for fiat or administrative transfers. + Network *string `json:"network,omitempty"` + + // OutputIdx The output index for withdrawals + OutputIdx *int `json:"outputIdx,omitempty"` + + // Purpose The purpose or reason for administrative transfers + Purpose *string `json:"purpose,omitempty"` + + // Status The status of the transfer + Status *V2TransferStatus `json:"status,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + + // TxHash The on-chain transaction hash, if applicable + TxHash *string `json:"txHash,omitempty"` + + // Type The type of the transfer + Type *V2TransferType `json:"type,omitempty"` + + // WithdrawalId The unique withdrawal identifier + WithdrawalId *string `json:"withdrawalId,omitempty"` +} + +// V2TransferStatus The status of the transfer +type V2TransferStatus string + +// V2TransferType The type of the transfer +type V2TransferType string + +// WithdrawCryptoFundsResponse Response returned after submitting a v2 cryptocurrency withdrawal. +type WithdrawCryptoFundsResponse struct { + // Address Standard string format of the withdrawal destination address + Address *string `json:"address,omitempty"` + + // Amount The withdrawal amount + Amount *string `json:"amount,omitempty"` + + // Currency The currency code of the withdrawn asset + Currency *string `json:"currency,omitempty"` + + // Fee The fee charged for the withdrawal + Fee *string `json:"fee,omitempty"` + + // WithdrawalId A unique ID for the withdrawal + WithdrawalId *string `json:"withdrawalId,omitempty"` +} + +// ApiKeyAuth defines model for apiKeyAuth. +type ApiKeyAuth = string + +// CacheControl defines model for cacheControl. +type CacheControl = string + +// ContentLength defines model for contentLength. +type ContentLength = string + +// ContentType defines model for contentType. +type ContentType = string + +// CurrencyParam defines model for currencyParam. +type CurrencyParam = string + +// NetworkParam defines model for networkParam. +type NetworkParam = string + +// PayloadAuth defines model for payloadAuth. +type PayloadAuth = string + +// SignatureAuth defines model for signatureAuth. +type SignatureAuth = string + +// SymbolParam defines model for symbolParam. +type SymbolParam = string + +// TimestampParam timestamp +type TimestampParam = TimestampType + +// ApiKeyIpFilteringFailure defines model for ApiKeyIpFilteringFailure. +type ApiKeyIpFilteringFailure = ErrorResponse + +// BadRequest defines model for BadRequest. +type BadRequest = ErrorResponse + +// InternalError defines model for InternalError. +type InternalError = ErrorResponse + +// NotFound defines model for NotFound. +type NotFound = ErrorResponse + +// TooManyRequests defines model for TooManyRequests. +type TooManyRequests = ErrorResponse + +// Unauthorized defines model for Unauthorized. +type Unauthorized = ErrorResponse + +// apiKeyAuthContextKey is the context key for apiKeyAuth security scheme +type apiKeyAuthContextKey string + +// payloadAuthContextKey is the context key for payloadAuth security scheme +type payloadAuthContextKey string + +// signatureAuthContextKey is the context key for signatureAuth security scheme +type signatureAuthContextKey string + +// GetMarginAccountJSONBody defines parameters for GetMarginAccount. +type GetMarginAccountJSONBody struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. + Account *string `json:"account,omitempty"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce Nonce `json:"nonce"` + + // Request The literal string "/v1/margin/account" + Request string `json:"request"` +} + +// GetMarginAccountParams defines parameters for GetMarginAccount. +type GetMarginAccountParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// PreviewMarginOrderJSONBody defines parameters for PreviewMarginOrder. +type PreviewMarginOrderJSONBody struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. + Account *string `json:"account,omitempty"` + + // Amount The order amount in base currency (required for limit orders and sell market orders) + Amount *string `json:"amount,omitempty"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce Nonce `json:"nonce"` + + // Price The limit price (required for limit orders) + Price *string `json:"price,omitempty"` + + // Request The literal string "/v1/margin/order/preview" + Request string `json:"request"` + + // Side The order side + Side PreviewMarginOrderJSONBodySide `json:"side"` + + // Symbol The trading pair symbol (e.g., "btcusd") + Symbol string `json:"symbol"` + + // TotalSpend Total spend in quote currency (required for buy market orders) + TotalSpend *string `json:"totalSpend,omitempty"` + + // Type The order type + Type PreviewMarginOrderJSONBodyType `json:"type"` +} + +// PreviewMarginOrderParams defines parameters for PreviewMarginOrder. +type PreviewMarginOrderParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// PreviewMarginOrderJSONBodySide defines parameters for PreviewMarginOrder. +type PreviewMarginOrderJSONBodySide string + +// PreviewMarginOrderJSONBodyType defines parameters for PreviewMarginOrder. +type PreviewMarginOrderJSONBodyType string + +// GetMarginRatesJSONBody defines parameters for GetMarginRates. +type GetMarginRatesJSONBody struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. + Account *string `json:"account,omitempty"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce Nonce `json:"nonce"` + + // Request The literal string "/v1/margin/rates" + Request string `json:"request"` +} + +// GetMarginRatesParams defines parameters for GetMarginRates. +type GetMarginRatesParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// GetMarginAccountJSONRequestBody defines body for GetMarginAccount for application/json ContentType. +type GetMarginAccountJSONRequestBody GetMarginAccountJSONBody + +// PreviewMarginOrderJSONRequestBody defines body for PreviewMarginOrder for application/json ContentType. +type PreviewMarginOrderJSONRequestBody PreviewMarginOrderJSONBody + +// GetMarginRatesJSONRequestBody defines body for GetMarginRates for application/json ContentType. +type GetMarginRatesJSONRequestBody GetMarginRatesJSONBody + +// AsHeartbeatNonce0 returns the union data inside the Heartbeat_Nonce as a HeartbeatNonce0 +func (t Heartbeat_Nonce) AsHeartbeatNonce0() (HeartbeatNonce0, error) { + var body HeartbeatNonce0 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromHeartbeatNonce0 overwrites any union data inside the Heartbeat_Nonce as the provided HeartbeatNonce0 +func (t *Heartbeat_Nonce) FromHeartbeatNonce0(v HeartbeatNonce0) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeHeartbeatNonce0 performs a merge with any union data inside the Heartbeat_Nonce, using the provided HeartbeatNonce0 +func (t *Heartbeat_Nonce) MergeHeartbeatNonce0(v HeartbeatNonce0) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsHeartbeatNonce1 returns the union data inside the Heartbeat_Nonce as a HeartbeatNonce1 +func (t Heartbeat_Nonce) AsHeartbeatNonce1() (HeartbeatNonce1, error) { + var body HeartbeatNonce1 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromHeartbeatNonce1 overwrites any union data inside the Heartbeat_Nonce as the provided HeartbeatNonce1 +func (t *Heartbeat_Nonce) FromHeartbeatNonce1(v HeartbeatNonce1) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeHeartbeatNonce1 performs a merge with any union data inside the Heartbeat_Nonce, using the provided HeartbeatNonce1 +func (t *Heartbeat_Nonce) MergeHeartbeatNonce1(v HeartbeatNonce1) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t Heartbeat_Nonce) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *Heartbeat_Nonce) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + +// AsTimestampType returns the union data inside the Nonce as a TimestampType +func (t Nonce) AsTimestampType() (TimestampType, error) { + var body TimestampType + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromTimestampType overwrites any union data inside the Nonce as the provided TimestampType +func (t *Nonce) FromTimestampType(v TimestampType) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeTimestampType performs a merge with any union data inside the Nonce, using the provided TimestampType +func (t *Nonce) MergeTimestampType(v TimestampType) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsNonce1 returns the union data inside the Nonce as a Nonce1 +func (t Nonce) AsNonce1() (Nonce1, error) { + var body Nonce1 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromNonce1 overwrites any union data inside the Nonce as the provided Nonce1 +func (t *Nonce) FromNonce1(v Nonce1) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeNonce1 performs a merge with any union data inside the Nonce, using the provided Nonce1 +func (t *Nonce) MergeNonce1(v Nonce1) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t Nonce) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *Nonce) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + +// AsTimestampType0 returns the union data inside the TimestampType as a TimestampType0 +func (t TimestampType) AsTimestampType0() (TimestampType0, error) { + var body TimestampType0 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromTimestampType0 overwrites any union data inside the TimestampType as the provided TimestampType0 +func (t *TimestampType) FromTimestampType0(v TimestampType0) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeTimestampType0 performs a merge with any union data inside the TimestampType, using the provided TimestampType0 +func (t *TimestampType) MergeTimestampType0(v TimestampType0) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsTimestampType1 returns the union data inside the TimestampType as a TimestampType1 +func (t TimestampType) AsTimestampType1() (TimestampType1, error) { + var body TimestampType1 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromTimestampType1 overwrites any union data inside the TimestampType as the provided TimestampType1 +func (t *TimestampType) FromTimestampType1(v TimestampType1) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeTimestampType1 performs a merge with any union data inside the TimestampType, using the provided TimestampType1 +func (t *TimestampType) MergeTimestampType1(v TimestampType1) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t TimestampType) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *TimestampType) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + +// AsTransaction0 returns the union data inside the Transaction as a Transaction0 +func (t Transaction) AsTransaction0() (Transaction0, error) { + var body Transaction0 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromTransaction0 overwrites any union data inside the Transaction as the provided Transaction0 +func (t *Transaction) FromTransaction0(v Transaction0) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeTransaction0 performs a merge with any union data inside the Transaction, using the provided Transaction0 +func (t *Transaction) MergeTransaction0(v Transaction0) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsTransaction1 returns the union data inside the Transaction as a Transaction1 +func (t Transaction) AsTransaction1() (Transaction1, error) { + var body Transaction1 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromTransaction1 overwrites any union data inside the Transaction as the provided Transaction1 +func (t *Transaction) FromTransaction1(v Transaction1) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeTransaction1 performs a merge with any union data inside the Transaction, using the provided Transaction1 +func (t *Transaction) MergeTransaction1(v Transaction1) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t Transaction) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *Transaction) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} diff --git a/packages/sdk-go/generated/marketdata/types.gen.go b/packages/sdk-go/generated/marketdata/types.gen.go new file mode 100644 index 00000000..46af976e --- /dev/null +++ b/packages/sdk-go/generated/marketdata/types.gen.go @@ -0,0 +1,2822 @@ +// Code generated from rest.yaml (Market Data). DO NOT EDIT. + +// Package marketdata provides primitives to interact with the openapi HTTP API. +// +// Code generated by oapi-codegen. DO NOT EDIT. +package marketdata + +import ( + "encoding/json" + "time" + + "github.com/gemini/gemini-go/internal/runtime" + openapi_types "github.com/gemini/gemini-go/types" +) + +const ( + ApiKeyAuthScopes apiKeyAuthContextKey = "apiKeyAuth.Scopes" + PayloadAuthScopes payloadAuthContextKey = "payloadAuth.Scopes" + SignatureAuthScopes signatureAuthContextKey = "signatureAuth.Scopes" +) + +// Defines values for BalanceType. +const ( + Exchange BalanceType = "exchange" +) + +// Valid indicates whether the value is a known member of the BalanceType enum. +func (e BalanceType) Valid() bool { + switch e { + case Exchange: + return true + default: + return false + } +} + +// Defines values for CancelOrderResponseReason. +const ( + ExceedsPriceLimits CancelOrderResponseReason = "ExceedsPriceLimits" + FillOrKillWouldNotFill CancelOrderResponseReason = "FillOrKillWouldNotFill" + ImmediateOrCancelWouldPost CancelOrderResponseReason = "ImmediateOrCancelWouldPost" + MakerOrCancelWouldTake CancelOrderResponseReason = "MakerOrCancelWouldTake" + MarketClosed CancelOrderResponseReason = "MarketClosed" + Requested CancelOrderResponseReason = "Requested" + SelfCrossPrevented CancelOrderResponseReason = "SelfCrossPrevented" + TradingClosed CancelOrderResponseReason = "TradingClosed" +) + +// Valid indicates whether the value is a known member of the CancelOrderResponseReason enum. +func (e CancelOrderResponseReason) Valid() bool { + switch e { + case ExceedsPriceLimits: + return true + case FillOrKillWouldNotFill: + return true + case ImmediateOrCancelWouldPost: + return true + case MakerOrCancelWouldTake: + return true + case MarketClosed: + return true + case Requested: + return true + case SelfCrossPrevented: + return true + case TradingClosed: + return true + default: + return false + } +} + +// Defines values for CancelOrderResponseSide. +const ( + CancelOrderResponseSideBuy CancelOrderResponseSide = "buy" + CancelOrderResponseSideSell CancelOrderResponseSide = "sell" +) + +// Valid indicates whether the value is a known member of the CancelOrderResponseSide enum. +func (e CancelOrderResponseSide) Valid() bool { + switch e { + case CancelOrderResponseSideBuy: + return true + case CancelOrderResponseSideSell: + return true + default: + return false + } +} + +// Defines values for CancelOrderResponseType. +const ( + CancelOrderResponseTypeExchangeLimit CancelOrderResponseType = "exchange limit" + CancelOrderResponseTypeExchangeMarket CancelOrderResponseType = "exchange market" + CancelOrderResponseTypeExchangeStopLimit CancelOrderResponseType = "exchange stop limit" +) + +// Valid indicates whether the value is a known member of the CancelOrderResponseType enum. +func (e CancelOrderResponseType) Valid() bool { + switch e { + case CancelOrderResponseTypeExchangeLimit: + return true + case CancelOrderResponseTypeExchangeMarket: + return true + case CancelOrderResponseTypeExchangeStopLimit: + return true + default: + return false + } +} + +// Defines values for ClearingOrderSide. +const ( + ClearingOrderSideBuy ClearingOrderSide = "buy" + ClearingOrderSideSell ClearingOrderSide = "sell" +) + +// Valid indicates whether the value is a known member of the ClearingOrderSide enum. +func (e ClearingOrderSide) Valid() bool { + switch e { + case ClearingOrderSideBuy: + return true + case ClearingOrderSideSell: + return true + default: + return false + } +} + +// Defines values for FundingPaymentEventType. +const ( + FundingPaymentEventTypeHourlyFundingTransfer FundingPaymentEventType = "Hourly Funding Transfer" +) + +// Valid indicates whether the value is a known member of the FundingPaymentEventType enum. +func (e FundingPaymentEventType) Valid() bool { + switch e { + case FundingPaymentEventTypeHourlyFundingTransfer: + return true + default: + return false + } +} + +// Defines values for FundingPaymentReportItemAction. +const ( + FundingPaymentReportItemActionCredit FundingPaymentReportItemAction = "Credit" + FundingPaymentReportItemActionDebit FundingPaymentReportItemAction = "Debit" +) + +// Valid indicates whether the value is a known member of the FundingPaymentReportItemAction enum. +func (e FundingPaymentReportItemAction) Valid() bool { + switch e { + case FundingPaymentReportItemActionCredit: + return true + case FundingPaymentReportItemActionDebit: + return true + default: + return false + } +} + +// Defines values for FundingPaymentReportItemEventType. +const ( + FundingPaymentReportItemEventTypeHourlyFundingTransfer FundingPaymentReportItemEventType = "Hourly Funding Transfer" +) + +// Valid indicates whether the value is a known member of the FundingPaymentReportItemEventType enum. +func (e FundingPaymentReportItemEventType) Valid() bool { + switch e { + case FundingPaymentReportItemEventTypeHourlyFundingTransfer: + return true + default: + return false + } +} + +// Defines values for FundingTransferAction. +const ( + FundingTransferActionCredit FundingTransferAction = "Credit" + FundingTransferActionDebit FundingTransferAction = "Debit" +) + +// Valid indicates whether the value is a known member of the FundingTransferAction enum. +func (e FundingTransferAction) Valid() bool { + switch e { + case FundingTransferActionCredit: + return true + case FundingTransferActionDebit: + return true + default: + return false + } +} + +// Defines values for InstantQuoteSide. +const ( + InstantQuoteSideBuy InstantQuoteSide = "buy" + InstantQuoteSideSell InstantQuoteSide = "sell" +) + +// Valid indicates whether the value is a known member of the InstantQuoteSide enum. +func (e InstantQuoteSide) Valid() bool { + switch e { + case InstantQuoteSideBuy: + return true + case InstantQuoteSideSell: + return true + default: + return false + } +} + +// Defines values for InterestRateInfoInterval. +const ( + Hour InterestRateInfoInterval = "hour" +) + +// Valid indicates whether the value is a known member of the InterestRateInfoInterval enum. +func (e InterestRateInfoInterval) Valid() bool { + switch e { + case Hour: + return true + default: + return false + } +} + +// Defines values for LimitOrderResponseSide. +const ( + LimitOrderResponseSideBuy LimitOrderResponseSide = "buy" + LimitOrderResponseSideSell LimitOrderResponseSide = "sell" +) + +// Valid indicates whether the value is a known member of the LimitOrderResponseSide enum. +func (e LimitOrderResponseSide) Valid() bool { + switch e { + case LimitOrderResponseSideBuy: + return true + case LimitOrderResponseSideSell: + return true + default: + return false + } +} + +// Defines values for LimitOrderResponseType. +const ( + LimitOrderResponseTypeExchangeLimit LimitOrderResponseType = "exchange limit" + LimitOrderResponseTypeExchangeMarket LimitOrderResponseType = "exchange market" + LimitOrderResponseTypeExchangeStopLimit LimitOrderResponseType = "exchange stop limit" +) + +// Valid indicates whether the value is a known member of the LimitOrderResponseType enum. +func (e LimitOrderResponseType) Valid() bool { + switch e { + case LimitOrderResponseTypeExchangeLimit: + return true + case LimitOrderResponseTypeExchangeMarket: + return true + case LimitOrderResponseTypeExchangeStopLimit: + return true + default: + return false + } +} + +// Defines values for MyTradeBreak. +const ( + Empty MyTradeBreak = "" + TradeCorrect MyTradeBreak = "trade correct" +) + +// Valid indicates whether the value is a known member of the MyTradeBreak enum. +func (e MyTradeBreak) Valid() bool { + switch e { + case Empty: + return true + case TradeCorrect: + return true + default: + return false + } +} + +// Defines values for MyTradeType. +const ( + MyTradeTypeBuy MyTradeType = "Buy" + MyTradeTypeSell MyTradeType = "Sell" +) + +// Valid indicates whether the value is a known member of the MyTradeType enum. +func (e MyTradeType) Valid() bool { + switch e { + case MyTradeTypeBuy: + return true + case MyTradeTypeSell: + return true + default: + return false + } +} + +// Defines values for NewOrderRequestOptions. +const ( + FillOrKill NewOrderRequestOptions = "fill-or-kill" + ImmediateOrCancel NewOrderRequestOptions = "immediate-or-cancel" + MakerOrCancel NewOrderRequestOptions = "maker-or-cancel" +) + +// Valid indicates whether the value is a known member of the NewOrderRequestOptions enum. +func (e NewOrderRequestOptions) Valid() bool { + switch e { + case FillOrKill: + return true + case ImmediateOrCancel: + return true + case MakerOrCancel: + return true + default: + return false + } +} + +// Defines values for NewOrderRequestSide. +const ( + NewOrderRequestSideBuy NewOrderRequestSide = "buy" + NewOrderRequestSideSell NewOrderRequestSide = "sell" +) + +// Valid indicates whether the value is a known member of the NewOrderRequestSide enum. +func (e NewOrderRequestSide) Valid() bool { + switch e { + case NewOrderRequestSideBuy: + return true + case NewOrderRequestSideSell: + return true + default: + return false + } +} + +// Defines values for NewOrderRequestType. +const ( + NewOrderRequestTypeExchangeLimit NewOrderRequestType = "exchange limit" + NewOrderRequestTypeExchangeMarket NewOrderRequestType = "exchange market" + NewOrderRequestTypeExchangeStopLimit NewOrderRequestType = "exchange stop limit" +) + +// Valid indicates whether the value is a known member of the NewOrderRequestType enum. +func (e NewOrderRequestType) Valid() bool { + switch e { + case NewOrderRequestTypeExchangeLimit: + return true + case NewOrderRequestTypeExchangeMarket: + return true + case NewOrderRequestTypeExchangeStopLimit: + return true + default: + return false + } +} + +// Defines values for OrderSide. +const ( + OrderSideBuy OrderSide = "buy" + OrderSideSell OrderSide = "sell" +) + +// Valid indicates whether the value is a known member of the OrderSide enum. +func (e OrderSide) Valid() bool { + switch e { + case OrderSideBuy: + return true + case OrderSideSell: + return true + default: + return false + } +} + +// Defines values for OrderTradesType. +const ( + OrderTradesTypeBuy OrderTradesType = "Buy" + OrderTradesTypeSell OrderTradesType = "Sell" +) + +// Valid indicates whether the value is a known member of the OrderTradesType enum. +func (e OrderTradesType) Valid() bool { + switch e { + case OrderTradesTypeBuy: + return true + case OrderTradesTypeSell: + return true + default: + return false + } +} + +// Defines values for OrderType. +const ( + OrderTypeExchangeLimit OrderType = "exchange limit" + OrderTypeExchangeMarket OrderType = "exchange market" + OrderTypeExchangeStopLimit OrderType = "exchange stop limit" +) + +// Valid indicates whether the value is a known member of the OrderType enum. +func (e OrderType) Valid() bool { + switch e { + case OrderTypeExchangeLimit: + return true + case OrderTypeExchangeMarket: + return true + case OrderTypeExchangeStopLimit: + return true + default: + return false + } +} + +// Defines values for RiskStatsResponseProductType. +const ( + PerpetualSwapContract RiskStatsResponseProductType = "PerpetualSwapContract" +) + +// Valid indicates whether the value is a known member of the RiskStatsResponseProductType enum. +func (e RiskStatsResponseProductType) Valid() bool { + switch e { + case PerpetualSwapContract: + return true + default: + return false + } +} + +// Defines values for StakingTransactionTransactionType. +const ( + StakingTransactionTransactionTypeAdminCreditAdjustment StakingTransactionTransactionType = "AdminCreditAdjustment" + StakingTransactionTransactionTypeAdminDebitAdjustment StakingTransactionTransactionType = "AdminDebitAdjustment" + StakingTransactionTransactionTypeAdminRedeem StakingTransactionTransactionType = "AdminRedeem" + StakingTransactionTransactionTypeDeposit StakingTransactionTransactionType = "Deposit" + StakingTransactionTransactionTypeInterest StakingTransactionTransactionType = "Interest" + StakingTransactionTransactionTypeRedeem StakingTransactionTransactionType = "Redeem" + StakingTransactionTransactionTypeRedeemPayment StakingTransactionTransactionType = "RedeemPayment" +) + +// Valid indicates whether the value is a known member of the StakingTransactionTransactionType enum. +func (e StakingTransactionTransactionType) Valid() bool { + switch e { + case StakingTransactionTransactionTypeAdminCreditAdjustment: + return true + case StakingTransactionTransactionTypeAdminDebitAdjustment: + return true + case StakingTransactionTransactionTypeAdminRedeem: + return true + case StakingTransactionTransactionTypeDeposit: + return true + case StakingTransactionTransactionTypeInterest: + return true + case StakingTransactionTransactionTypeRedeem: + return true + case StakingTransactionTransactionTypeRedeemPayment: + return true + default: + return false + } +} + +// Defines values for StopLimitOrderResponseSide. +const ( + StopLimitOrderResponseSideBuy StopLimitOrderResponseSide = "buy" + StopLimitOrderResponseSideSell StopLimitOrderResponseSide = "sell" +) + +// Valid indicates whether the value is a known member of the StopLimitOrderResponseSide enum. +func (e StopLimitOrderResponseSide) Valid() bool { + switch e { + case StopLimitOrderResponseSideBuy: + return true + case StopLimitOrderResponseSideSell: + return true + default: + return false + } +} + +// Defines values for StopLimitOrderResponseType. +const ( + ExchangeStopLimit StopLimitOrderResponseType = "exchange stop limit" +) + +// Valid indicates whether the value is a known member of the StopLimitOrderResponseType enum. +func (e StopLimitOrderResponseType) Valid() bool { + switch e { + case ExchangeStopLimit: + return true + default: + return false + } +} + +// Defines values for TradeType. +const ( + TradeTypeBuy TradeType = "buy" + TradeTypeSell TradeType = "sell" +) + +// Valid indicates whether the value is a known member of the TradeType enum. +func (e TradeType) Valid() bool { + switch e { + case TradeTypeBuy: + return true + case TradeTypeSell: + return true + default: + return false + } +} + +// Defines values for TransferStatus. +const ( + TransferStatusComplete TransferStatus = "Complete" + TransferStatusPending TransferStatus = "Pending" +) + +// Valid indicates whether the value is a known member of the TransferStatus enum. +func (e TransferStatus) Valid() bool { + switch e { + case TransferStatusComplete: + return true + case TransferStatusPending: + return true + default: + return false + } +} + +// Defines values for TransferType. +const ( + TransferTypeDeposit TransferType = "Deposit" + TransferTypeWithdrawal TransferType = "Withdrawal" +) + +// Valid indicates whether the value is a known member of the TransferType enum. +func (e TransferType) Valid() bool { + switch e { + case TransferTypeDeposit: + return true + case TransferTypeWithdrawal: + return true + default: + return false + } +} + +// Defines values for V2TransferStatus. +const ( + V2TransferStatusAdvanced V2TransferStatus = "Advanced" + V2TransferStatusComplete V2TransferStatus = "Complete" + V2TransferStatusPending V2TransferStatus = "Pending" +) + +// Valid indicates whether the value is a known member of the V2TransferStatus enum. +func (e V2TransferStatus) Valid() bool { + switch e { + case V2TransferStatusAdvanced: + return true + case V2TransferStatusComplete: + return true + case V2TransferStatusPending: + return true + default: + return false + } +} + +// Defines values for V2TransferType. +const ( + AdminCredit V2TransferType = "AdminCredit" + AdminDebit V2TransferType = "AdminDebit" + Deposit V2TransferType = "Deposit" + Reward V2TransferType = "Reward" + Withdrawal V2TransferType = "Withdrawal" +) + +// Valid indicates whether the value is a known member of the V2TransferType enum. +func (e V2TransferType) Valid() bool { + switch e { + case AdminCredit: + return true + case AdminDebit: + return true + case Deposit: + return true + case Reward: + return true + case Withdrawal: + return true + default: + return false + } +} + +// Defines values for ListCandlesParamsTimeFrame. +const ( + ListCandlesParamsTimeFrameN15m ListCandlesParamsTimeFrame = "15m" + ListCandlesParamsTimeFrameN1d ListCandlesParamsTimeFrame = "1d" + ListCandlesParamsTimeFrameN1h ListCandlesParamsTimeFrame = "1h" + ListCandlesParamsTimeFrameN1m ListCandlesParamsTimeFrame = "1m" + ListCandlesParamsTimeFrameN30m ListCandlesParamsTimeFrame = "30m" + ListCandlesParamsTimeFrameN5m ListCandlesParamsTimeFrame = "5m" + ListCandlesParamsTimeFrameN6h ListCandlesParamsTimeFrame = "6h" +) + +// Valid indicates whether the value is a known member of the ListCandlesParamsTimeFrame enum. +func (e ListCandlesParamsTimeFrame) Valid() bool { + switch e { + case ListCandlesParamsTimeFrameN15m: + return true + case ListCandlesParamsTimeFrameN1d: + return true + case ListCandlesParamsTimeFrameN1h: + return true + case ListCandlesParamsTimeFrameN1m: + return true + case ListCandlesParamsTimeFrameN30m: + return true + case ListCandlesParamsTimeFrameN5m: + return true + case ListCandlesParamsTimeFrameN6h: + return true + default: + return false + } +} + +// Defines values for ListDerivativeCandlesParamsTimeFrame. +const ( + ListDerivativeCandlesParamsTimeFrameN1m ListDerivativeCandlesParamsTimeFrame = "1m" +) + +// Valid indicates whether the value is a known member of the ListDerivativeCandlesParamsTimeFrame enum. +func (e ListDerivativeCandlesParamsTimeFrame) Valid() bool { + switch e { + case ListDerivativeCandlesParamsTimeFrameN1m: + return true + default: + return false + } +} + +// Account defines model for Account. +type Account struct { + // AccountId The account ID + AccountId *string `json:"account_id,omitempty"` + + // Created The creation date + Created *string `json:"created,omitempty"` + + // IsDefault Whether the account is the default account + IsDefault *bool `json:"is_default,omitempty"` + + // Name The account name + Name *string `json:"name,omitempty"` +} + +// AddBankResponse defines model for AddBankResponse. +type AddBankResponse struct { + // ReferenceId Reference ID for the new bank addition request. Once received, send in a wire from the requested bank account to verify it and enable withdrawals to that account. + ReferenceId *string `json:"referenceId,omitempty"` + + // Result Status result (e.g. ok). + Result *string `json:"result,omitempty"` +} + +// Address defines model for Address. +type Address struct { + // Address String representation of the cryptocurrency address + Address *string `json:"address,omitempty"` + + // Label If you provided a label when creating the address, it will be echoed back here + Label *string `json:"label,omitempty"` + + // Memo It would be present if applicable, it will be present for cosmos address + Memo *string `json:"memo,omitempty"` + + // Network The blockchain network for the address + Network *string `json:"network,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` +} + +// ApprovedAddress defines model for ApprovedAddress. +type ApprovedAddress struct { + // Address The address on the approved address list. + Address *string `json:"address,omitempty"` + + // CreatedAt UTC timestamp in millisecond of when the address was created. + CreatedAt *string `json:"createdAt,omitempty"` + + // Label The label assigned to the address + Label *string `json:"label,omitempty"` + + // Network The network of the approved address. Network can be `bitcoin`, `ethereum`, `bitcoincash`, `litecoin`, `zcash`, `filecoin`, `dogecoin`, `tezos`, `solana`, `polkadot`, `avalanche`, `cosmos`, or `xrpl` + Network *string `json:"network,omitempty"` + + // Scope Will return the scope of the address as either "account" or "group" + Scope *string `json:"scope,omitempty"` + + // Status The status of the address that will return as "active", "pending-time" or "pending-mua". The remaining time is exactly 7 days after the initial request. "pending-mua" is for multi-user accounts and will require another administator or fund manager on the account to approve the address. + Status *string `json:"status,omitempty"` +} + +// ApprovedAddressMessage defines model for ApprovedAddressMessage. +type ApprovedAddressMessage struct { + // Message Status or confirmation message for the approved address request or removal. + Message *string `json:"message,omitempty"` + + // Result Result status (e.g. ok). + Result *string `json:"result,omitempty"` +} + +// ApprovedAddressesResponse Response envelope containing the approved withdrawal addresses. +type ApprovedAddressesResponse struct { + // ApprovedAddresses Array of approved addresses on both the account and group level. + ApprovedAddresses *[]ApprovedAddress `json:"approvedAddresses,omitempty"` +} + +// Balance defines model for Balance. +type Balance struct { + // UnderscoreTimestamp Server-side monotonically increasing clock value as an ISO 8601 timestamp. Clients can use this value to detect and filter out stale responses that may occur due to load balancing or potential stale servers. + UnderscoreTimestamp *time.Time `json:"_timestamp,omitempty"` + + // Amount The confirmed balance for the currency (also referred to as `confirmedBalance`). For crypto withdrawals, this value is **not** reduced until the withdrawal has been confirmed on the blockchain. This delay protects against blockchain reorganizations. Use the `available` field instead if you need balances that immediately reflect holds. + Amount *openapi_types.DecimalNumber `json:"amount,omitempty"` + + // Available The amount available for trading. This value is reduced **immediately** when an order hold or withdrawal hold is placed, making it the recommended field for tracking real-time spendable balances. + Available *openapi_types.DecimalNumber `json:"available,omitempty"` + + // AvailableForWithdrawal The amount available for withdrawal + AvailableForWithdrawal *openapi_types.DecimalNumber `json:"availableForWithdrawal,omitempty"` + + // Currency The currency symbol + Currency *string `json:"currency,omitempty"` + + // PendingDeposit The amount pending deposit + PendingDeposit *openapi_types.DecimalNumber `json:"pendingDeposit,omitempty"` + + // PendingWithdrawal The amount pending withdrawal + PendingWithdrawal *openapi_types.DecimalNumber `json:"pendingWithdrawal,omitempty"` + Type *BalanceType `json:"type,omitempty"` +} + +// BalanceType defines model for Balance.Type. +type BalanceType string + +// CancelAllOrdersBySessionRequest defines model for CancelAllOrdersBySessionRequest. +type CancelAllOrdersBySessionRequest struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Specifies the account on which you intend to cancel the orders. Only available for exchange accounts. + Account *string `json:"account,omitempty"` + + // Nonce timestamp + Nonce TimestampType `json:"nonce"` + + // Request The literal string "/v1/order/cancel/session" + Request string `json:"request"` +} + +// CancelAllOrdersRequest defines model for CancelAllOrdersRequest. +type CancelAllOrdersRequest struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Specifies the account on which you intend to cancel the orders. Only available for exchange accounts. + Account *string `json:"account,omitempty"` + + // Nonce timestamp + Nonce TimestampType `json:"nonce"` + + // Request The literal string "/v1/order/cancel/all" + Request string `json:"request"` +} + +// CancelAllResult defines model for CancelAllResult. +type CancelAllResult struct { + // Details cancelledOrders/cancelRejects with IDs of both + Details *struct { + CancelRejects *[]int `json:"cancelRejects,omitempty"` + CancelledOrders *[]int `json:"cancelledOrders,omitempty"` + } `json:"details,omitempty"` + Result *string `json:"result,omitempty"` +} + +// CancelOrderRequest defines model for CancelOrderRequest. +type CancelOrderRequest struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Specifies the account on which you intend to cancel the order. Only available for exchange accounts. + Account *string `json:"account,omitempty"` + + // Nonce timestamp + Nonce TimestampType `json:"nonce"` + + // OrderId The order ID given by `/order/new` + OrderId int64 `json:"order_id"` + + // Request The literal string "/v1/order/cancel" + Request string `json:"request"` +} + +// CancelOrderResponse defines model for CancelOrderResponse. +type CancelOrderResponse struct { + AvgExecutionPrice *string `json:"avg_execution_price,omitempty"` + Exchange *string `json:"exchange,omitempty"` + ExecutedAmount *string `json:"executed_amount,omitempty"` + Id *string `json:"id,omitempty"` + IsCancelled *bool `json:"is_cancelled,omitempty"` + IsHidden *bool `json:"is_hidden,omitempty"` + IsLive *bool `json:"is_live,omitempty"` + Options *[]string `json:"options,omitempty"` + OrderId *string `json:"order_id,omitempty"` + OriginalAmount *string `json:"original_amount,omitempty"` + Price *string `json:"price,omitempty"` + Reason *CancelOrderResponseReason `json:"reason,omitempty"` + RemainingAmount *string `json:"remaining_amount,omitempty"` + Side *CancelOrderResponseSide `json:"side,omitempty"` + Symbol *string `json:"symbol,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + Type *CancelOrderResponseType `json:"type,omitempty"` + WasForced *bool `json:"was_forced,omitempty"` +} + +// CancelOrderResponseReason defines model for CancelOrderResponse.Reason. +type CancelOrderResponseReason string + +// CancelOrderResponseSide defines model for CancelOrderResponse.Side. +type CancelOrderResponseSide string + +// CancelOrderResponseType defines model for CancelOrderResponse.Type. +type CancelOrderResponseType string + +// Candle defines model for Candle. +type Candle = []float64 + +// CandleResponse defines model for CandleResponse. +type CandleResponse = []Candle + +// ClearingOrder defines model for ClearingOrder. +type ClearingOrder struct { + // Amount The order amount + Amount *string `json:"amount,omitempty"` + + // ClearingId The clearing ID + ClearingId *string `json:"clearing_id,omitempty"` + + // IsConfirmed Whether the order is confirmed + IsConfirmed *bool `json:"is_confirmed,omitempty"` + + // Price The order price + Price *string `json:"price,omitempty"` + Side *ClearingOrderSide `json:"side,omitempty"` + + // Status The order status + Status *string `json:"status,omitempty"` + + // Symbol The trading pair + Symbol *string `json:"symbol,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + + // Timestampms The timestamp in milliseconds + Timestampms *int `json:"timestampms,omitempty"` +} + +// ClearingOrderSide defines model for ClearingOrder.Side. +type ClearingOrderSide string + +// CustodyFeeTransfer defines model for CustodyFeeTransfer. +type CustodyFeeTransfer struct { + // Eid Custody fee event id + Eid *int `json:"eid,omitempty"` + + // EventType Custody fee event type + EventType *string `json:"eventType,omitempty"` + + // FeeAmount The fee amount charged + FeeAmount *string `json:"feeAmount,omitempty"` + + // FeeCurrency Currency that the fee was paid in + FeeCurrency *string `json:"feeCurrency,omitempty"` + + // TxTime Time of Custody fee record in milliseconds + TxTime *int `json:"txTime,omitempty"` +} + +// ErrorResponse defines model for ErrorResponse. +type ErrorResponse struct { + // Message Detailed error message + Message *string `json:"message,omitempty"` + + // Reason A short description + Reason *string `json:"reason,omitempty"` + + // Result Error + Result *string `json:"result,omitempty"` +} + +// FeeEstimateRequest defines model for FeeEstimateRequest. +type FeeEstimateRequest struct { + // Account The name of the account within the subaccount group. + Account string `json:"account"` + + // Address Standard string format of cryptocurrency address + Address string `json:"address"` + + // Amount Quoted decimal amount to withdraw + Amount string `json:"amount"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce Nonce `json:"nonce"` + + // Request The string `/v1/withdraw/{currencyCodeLowerCase}/feeEstimate` where `:currencyCodeLowerCase` is replaced with the currency code of a supported crypto-currency, e.g. `eth`, `aave`, etc. See [Symbols and minimums](/market-data/symbols-and-minimums) + Request string `json:"request"` +} + +// FeeEstimateResponse defines model for FeeEstimateResponse. +type FeeEstimateResponse struct { + // Currency Currency code, see [symbols](/market-data/symbols-and-minimums). + Currency *string `json:"currency,omitempty"` + + // Fee The estimated gas fee + Fee *string `json:"fee,omitempty"` + + // IsOverride Value that shows if an override on the customer's account for free withdrawals exists + IsOverride *bool `json:"isOverride,omitempty"` + + // MonthlyLimit Total nunber of allowable fee-free withdrawals + MonthlyLimit *int `json:"monthlyLimit,omitempty"` + + // MonthlyRemaining Total number of allowable fee-free withdrawals left to use + MonthlyRemaining *int `json:"monthlyRemaining,omitempty"` +} + +// FeeEstimateV2Request defines model for FeeEstimateV2Request. +type FeeEstimateV2Request struct { + // Account Required for Master API keys. The name of the account within the subaccount group. + Account *string `json:"account,omitempty"` + + // Address Standard string format of the destination cryptocurrency address + Address string `json:"address"` + + // Amount Quoted decimal amount to withdraw + Amount string `json:"amount"` + + // Memo It would be present if applicable, it will be present for cosmos address. + Memo *string `json:"memo,omitempty"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce Nonce `json:"nonce"` + + // Request The string `/v2/withdraw/{network}/{ticker}/feeEstimate` where `{network}` is the blockchain network (e.g. `ethereum`, `bitcoin`, `solana`) and `{ticker}` is the currency code (e.g. `eth`, `btc`, `sol`). See [Symbols and minimums](/market-data/symbols-and-minimums) + Request string `json:"request"` +} + +// FeeEstimateV2Response defines model for FeeEstimateV2Response. +type FeeEstimateV2Response struct { + // Currency Currency code, see [symbols](/market-data/symbols-and-minimums). + Currency *string `json:"currency,omitempty"` + + // Fee The estimated withdrawal fee as a decimal amount + Fee *openapi_types.DecimalNumber `json:"fee,omitempty"` + + // IsOverride Whether an override on the customer's account for free withdrawals exists + IsOverride *bool `json:"isOverride,omitempty"` + + // MonthlyLimit Total number of allowable fee-free withdrawals + MonthlyLimit *int `json:"monthlyLimit,omitempty"` + + // MonthlyRemaining Total number of allowable fee-free withdrawals remaining + MonthlyRemaining *int `json:"monthlyRemaining,omitempty"` +} + +// FeePromos defines model for FeePromos. +type FeePromos struct { + // Symbols Symbols that currently have fee promos + Symbols *[]string `json:"symbols,omitempty"` +} + +// FundingAmountResponse defines model for FundingAmountResponse. +type FundingAmountResponse struct { + // Amount The dollar amount for a Long 1 position held in the symbol for funding period (1 hour) + Amount *openapi_types.DecimalNumber `json:"amount,omitempty"` + + // EstimatedFundingAmount The estimated dollar amount for a Long 1 position held in the symbol for next funding period (1 hour) + EstimatedFundingAmount *openapi_types.DecimalNumber `json:"estimatedFundingAmount,omitempty"` + + // FundingDateTime UTC date time in format `yyyy-MM-ddThh:mm:ss.SSSZ` format + FundingDateTime *string `json:"fundingDateTime,omitempty"` + + // FundingTimestampMilliSecs Current funding amount Epoc time. + FundingTimestampMilliSecs *int64 `json:"fundingTimestampMilliSecs,omitempty"` + + // NextFundingTimestamp Next funding amount Epoc time. + NextFundingTimestamp *int64 `json:"nextFundingTimestamp,omitempty"` + + // Symbol The requested symbol. See [**symbols and minimums**](/market-data/symbols-and-minimums#all-supported-symbols) + Symbol *string `json:"symbol,omitempty"` +} + +// FundingPayment defines model for FundingPayment. +type FundingPayment struct { + // EventType Event type + EventType FundingPaymentEventType `json:"eventType"` + HourlyFundingTransfer FundingTransfer `json:"hourlyFundingTransfer"` +} + +// FundingPaymentEventType Event type +type FundingPaymentEventType string + +// FundingPaymentReportItem defines model for FundingPaymentReportItem. +type FundingPaymentReportItem struct { + // Action Credit or Debit + Action FundingPaymentReportItemAction `json:"action"` + + // AssetCode Asset symbol + AssetCode string `json:"assetCode"` + + // EventType Event type + EventType FundingPaymentReportItemEventType `json:"eventType"` + + // InstrumentSymbol Symbol of the underlying instrument. **Note** that this is only attached to requests from 16th April 2024 onwards. + InstrumentSymbol *string `json:"instrumentSymbol,omitempty"` + + // Quantity A nested JSON object describing the transaction amount + Quantity Quantity `json:"quantity"` + + // Timestamp Time of the funding payment + Timestamp TimestampType `json:"timestamp"` +} + +// FundingPaymentReportItemAction Credit or Debit +type FundingPaymentReportItemAction string + +// FundingPaymentReportItemEventType Event type +type FundingPaymentReportItemEventType string + +// FundingTransfer defines model for FundingTransfer. +type FundingTransfer struct { + // Action Credit or Debit + Action FundingTransferAction `json:"action"` + + // AssetCode Asset symbol + AssetCode string `json:"assetCode"` + + // EventType Event type + EventType string `json:"eventType"` + + // InstrumentSymbol Symbol of the underlying instrument. **Note** that this is only attached to requests from 16th April 2024 onwards. + InstrumentSymbol *string `json:"instrumentSymbol,omitempty"` + + // Quantity A nested JSON object describing the transaction amount + Quantity Quantity `json:"quantity"` + + // Timestamp Time of the funding payment + Timestamp TimestampType `json:"timestamp"` +} + +// FundingTransferAction Credit or Debit +type FundingTransferAction string + +// FxRate defines model for FxRate. +type FxRate struct { + // AsOf timestamp + AsOf *TimestampType `json:"asOf,omitempty"` + + // Benchmark The market for which the retrieved price applies to + Benchmark *string `json:"benchmark,omitempty"` + + // FxPair The requested currency pair + FxPair *string `json:"fxPair,omitempty"` + + // Provider The market data provider + Provider *string `json:"provider,omitempty"` + + // Rate The exchange rate + Rate *float64 `json:"rate,omitempty"` +} + +// Heartbeat defines model for Heartbeat. +type Heartbeat struct { + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce *Heartbeat_Nonce `json:"nonce,omitempty"` + + // Request The literal string `/v1/heartbeat` + Request *string `json:"request,omitempty"` +} + +// HeartbeatNonce0 Gemini strongly recommends using milliseconds instead of seconds for timestamps. +// +// | Timestamp format | Example | Supported request type | +// |-----------------------|-----------------------|------------------------| +// | string (seconds) | `'1495127793'` | `POST` only | +// | string (milliseconds) | `'1495127793000'` | `POST` only | +type HeartbeatNonce0 = string + +// HeartbeatNonce1 Gemini strongly recommends using milliseconds instead of seconds for timestamps. +// +// | Timestamp format | Example | Supported request type | +// |-----------------------------|---------------------------|------------------------| +// | whole number (seconds) | `1495127793` | `GET`, `POST` | +// | whole number (milliseconds) | `1495127793000` | `GET`, `POST` | +type HeartbeatNonce1 = int64 + +// Heartbeat_Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) +type Heartbeat_Nonce struct { + union json.RawMessage +} + +// InstantQuote defines model for InstantQuote. +type InstantQuote struct { + // DepositFee The deposit fee quantity. Will be applied if a debit card is used for the order. Will return 0 if there is no `depositFee` + DepositFee *string `json:"depositFee,omitempty"` + + // DepositFeeCurrency Currency in which `depositFee` is taken + DepositFeeCurrency *string `json:"depositFeeCurrency,omitempty"` + + // Fee The fee quantity to be taken for the order upon execution + Fee *string `json:"fee,omitempty"` + + // FeeCurrency The currency label for the order + FeeCurrency *string `json:"feeCurrency,omitempty"` + + // MaxAgeMs Number of milliseconds until this quote price expires. Once expired, you will need to request a new quote + MaxAgeMs *int `json:"maxAgeMs,omitempty"` + + // Pair The symbol passed in the quote request + Pair *string `json:"pair,omitempty"` + + // Price The quoted price of the asset. This will not change when attempting execution + Price *string `json:"price,omitempty"` + + // PriceCurrency The currency in which the order is priced. Matches `CCY2` in the symbol + PriceCurrency *string `json:"priceCurrency,omitempty"` + + // Quantity The quantity of the asset to be bought or sold + Quantity *string `json:"quantity,omitempty"` + + // QuantityCurrency The currency label for the `quantity` field. Matches `CCY1` in the symbol + QuantityCurrency *string `json:"quantityCurrency,omitempty"` + + // QuoteId Unique ID for the quote. This is used in the execution of the order + QuoteId *int `json:"quoteId,omitempty"` + + // Side Either "buy" or "sell" + Side *InstantQuoteSide `json:"side,omitempty"` + + // TotalSpend Total quantity to spend for the order. Will be the sum inclusive of all fees and amount to be traded. + TotalSpend *string `json:"totalSpend,omitempty"` + + // TotalSpendCurrency Currency of the `totalSpend` to be spent on the order + TotalSpendCurrency *string `json:"totalSpendCurrency,omitempty"` +} + +// InstantQuoteSide Either "buy" or "sell" +type InstantQuoteSide string + +// InterestRateInfo defines model for InterestRateInfo. +type InterestRateInfo struct { + // Interval The time interval for the rate (currently only "hour" is supported) + Interval InterestRateInfoInterval `json:"interval"` + + // Rate The interest rate as a decimal string + Rate string `json:"rate"` +} + +// InterestRateInfoInterval The time interval for the rate (currently only "hour" is supported) +type InterestRateInfoInterval string + +// LimitOrderResponse defines model for LimitOrderResponse. +type LimitOrderResponse struct { + AvgExecutionPrice *string `json:"avg_execution_price,omitempty"` + ClientOrderId *string `json:"client_order_id,omitempty"` + Exchange *string `json:"exchange,omitempty"` + ExecutedAmount *string `json:"executed_amount,omitempty"` + Id *string `json:"id,omitempty"` + IsCancelled *bool `json:"is_cancelled,omitempty"` + IsHidden *bool `json:"is_hidden,omitempty"` + IsLive *bool `json:"is_live,omitempty"` + Options *[]string `json:"options,omitempty"` + OrderId *string `json:"order_id,omitempty"` + OriginalAmount *string `json:"original_amount,omitempty"` + Price *string `json:"price,omitempty"` + RemainingAmount *string `json:"remaining_amount,omitempty"` + Side *LimitOrderResponseSide `json:"side,omitempty"` + Symbol *string `json:"symbol,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + Type *LimitOrderResponseType `json:"type,omitempty"` + WasForced *bool `json:"was_forced,omitempty"` +} + +// LimitOrderResponseSide defines model for LimitOrderResponse.Side. +type LimitOrderResponseSide string + +// LimitOrderResponseType defines model for LimitOrderResponse.Type. +type LimitOrderResponseType string + +// LiquidationRisk defines model for LiquidationRisk. +type LiquidationRisk struct { + // LiquidationPrice The estimated price at which liquidation would occur (optional, may not be present for all positions) + LiquidationPrice *MoneyAmount `json:"liquidationPrice,omitempty"` + + // LossPercentage The percentage loss from current value that would trigger liquidation, formatted as decimal (e.g., "0.1550" = 15.50%) + LossPercentage string `json:"lossPercentage"` +} + +// MarginAccountSummary defines model for MarginAccountSummary. +type MarginAccountSummary struct { + // AvailableCollateral The amount of collateral available for new positions or withdrawals + AvailableCollateral MoneyAmount `json:"availableCollateral"` + + // BuyingPower The maximum value that can be purchased with available collateral + BuyingPower MoneyAmount `json:"buyingPower"` + + // InterestRate Current interest rate on borrowed amounts (only present if borrows exist) + InterestRate *InterestRateInfo `json:"interestRate,omitempty"` + + // Leverage The current leverage ratio (notionalValue / marginAssetValue) + Leverage string `json:"leverage"` + + // LiquidationRisk Liquidation risk information (only present if positions exist) + LiquidationRisk *LiquidationRisk `json:"liquidationRisk,omitempty"` + + // MarginAssetValue The total value of all assets available in the margin account that can contribute to funding positions + MarginAssetValue MoneyAmount `json:"marginAssetValue"` + + // NotionalValue The total value of all open positions + NotionalValue MoneyAmount `json:"notionalValue"` + + // ReservedBuyOrders Collateral reserved for open buy orders + ReservedBuyOrders MoneyAmount `json:"reservedBuyOrders"` + + // ReservedSellOrders Collateral reserved for open sell orders + ReservedSellOrders MoneyAmount `json:"reservedSellOrders"` + + // SellingPower The maximum value that can be sold with available collateral + SellingPower MoneyAmount `json:"sellingPower"` + + // TotalBorrowed The total amount currently borrowed across all currencies + TotalBorrowed MoneyAmount `json:"totalBorrowed"` +} + +// MarginInterestRate defines model for MarginInterestRate. +type MarginInterestRate struct { + // BorrowRate The hourly borrow rate as a decimal + BorrowRate string `json:"borrowRate"` + + // BorrowRateAnnual The annualized borrow rate (daily rate × 365) + BorrowRateAnnual string `json:"borrowRateAnnual"` + + // BorrowRateDaily The daily borrow rate (hourly rate × 24) + BorrowRateDaily string `json:"borrowRateDaily"` + + // Currency The currency code (e.g., "BTC", "ETH", "USD") + Currency string `json:"currency"` + + // LastUpdated Unix timestamp in milliseconds when the rate was last updated + LastUpdated int64 `json:"lastUpdated"` +} + +// MarginOrderPreview defines model for MarginOrderPreview. +type MarginOrderPreview struct { + // Postorder Margin risk statistics after the order would be executed + Postorder MarginRiskStats `json:"postorder"` + + // Preorder Margin risk statistics before the order would be executed + Preorder MarginRiskStats `json:"preorder"` +} + +// MarginRatesResponse defines model for MarginRatesResponse. +type MarginRatesResponse struct { + // Rates Array of interest rates for all borrowable currencies + Rates []MarginInterestRate `json:"rates"` +} + +// MarginResponse defines model for MarginResponse. +type MarginResponse struct { + // AvailableMargin The difference between the `margin_assets_value` and `initial_margin`. + AvailableMargin *string `json:"available_margin,omitempty"` + + // BuyingPower The amount of that product the account could purchase based on current `initial_margin` and `margin_assets_value`. + BuyingPower *string `json:"buying_power,omitempty"` + + // EstimatedLiquidationPrice The estimated price for the asset at which liquidation would occur. + EstimatedLiquidationPrice *string `json:"estimated_liquidation_price,omitempty"` + + // InitialMargin The $ amount that is being required by the accounts current positions and open orders. + InitialMargin *string `json:"initial_margin,omitempty"` + + // InitialMarginPositions The contribution to `initial_margin` from open positions. + InitialMarginPositions *string `json:"initial_margin_positions,omitempty"` + + // Leverage The ratio of Notional Value to Margin Assets Value. + Leverage *string `json:"leverage,omitempty"` + + // MarginAssetsValue The $ equivalent value of all the assets available in the current trading account that can contribute to funding a derivatives position. + MarginAssetsValue *string `json:"margin_assets_value,omitempty"` + + // MarginMaintenanceLimit The minimum amount of `margin_assets_value` required before the account is moved to liquidation status. + MarginMaintenanceLimit *string `json:"margin_maintenance_limit,omitempty"` + + // NotionalValue The $ value of the current position. + NotionalValue *string `json:"notional_value,omitempty"` + + // ReservedMargin The contribution to `initial_margin` from open orders. + ReservedMargin *string `json:"reserved_margin,omitempty"` + + // ReservedMarginBuys The contribution to `initial_margin` from open BUY orders. + ReservedMarginBuys *string `json:"reserved_margin_buys,omitempty"` + + // ReservedMarginSells The contribution to `initial_margin` from open SELL orders. + ReservedMarginSells *string `json:"reserved_margin_sells,omitempty"` + + // SellingPower The amount of that product the account could sell based on current `initial_margin` and `margin_assets_value`. + SellingPower *string `json:"selling_power,omitempty"` +} + +// MarginRiskStats defines model for MarginRiskStats. +type MarginRiskStats struct { + // AvailableCollateral The amount of collateral available for new positions + AvailableCollateral MoneyAmount `json:"availableCollateral"` + + // BuyingPower The maximum value that can be purchased + BuyingPower MoneyAmount `json:"buyingPower"` + + // Leverage The leverage ratio + Leverage string `json:"leverage"` + + // LiquidationRisk Liquidation risk information (only present if applicable) + LiquidationRisk *LiquidationRisk `json:"liquidationRisk,omitempty"` + + // MarginAssetValue The total value of all assets available in the margin account + MarginAssetValue MoneyAmount `json:"marginAssetValue"` + + // NotionalValue The total value of all open positions + NotionalValue MoneyAmount `json:"notionalValue"` + + // ReservedBuyOrders Collateral reserved for open buy orders + ReservedBuyOrders MoneyAmount `json:"reservedBuyOrders"` + + // ReservedSellOrders Collateral reserved for open sell orders + ReservedSellOrders MoneyAmount `json:"reservedSellOrders"` + + // SellingPower The maximum value that can be sold + SellingPower MoneyAmount `json:"sellingPower"` + + // TotalBorrowed The total amount currently borrowed + TotalBorrowed MoneyAmount `json:"totalBorrowed"` +} + +// MoneyAmount defines model for MoneyAmount. +type MoneyAmount struct { + // Currency The currency code (e.g., "USD", "BTC", "ETH") + Currency string `json:"currency"` + + // Value The amount in the specified currency + Value string `json:"value"` +} + +// MyTrade defines model for MyTrade. +type MyTrade struct { + Aggressor *bool `json:"aggressor,omitempty"` + Amount *string `json:"amount,omitempty"` + Break *MyTradeBreak `json:"break,omitempty"` + ClientOrderId *string `json:"client_order_id,omitempty"` + Exchange *string `json:"exchange,omitempty"` + FeeAmount *string `json:"fee_amount,omitempty"` + FeeCurrency *string `json:"fee_currency,omitempty"` + IsAuctionFill *bool `json:"is_auction_fill,omitempty"` + OrderId *string `json:"order_id,omitempty"` + Price *string `json:"price,omitempty"` + Tid *int64 `json:"tid,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + Type *MyTradeType `json:"type,omitempty"` +} + +// MyTradeBreak defines model for MyTrade.Break. +type MyTradeBreak string + +// MyTradeType defines model for MyTrade.Type. +type MyTradeType string + +// MyTradesRequest defines model for MyTradesRequest. +type MyTradesRequest struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Specifies the account on which you intend to place the order. Only available for exchange accounts. + Account *string `json:"account,omitempty"` + + // LimitTrades The maximum number of trades to return. Default is 50, max is 500. + LimitTrades *int `json:"limit_trades,omitempty"` + + // Nonce timestamp + Nonce TimestampType `json:"nonce"` + + // Request The API endpoint path + Request string `json:"request"` + + // Symbol The [symbol](/market-data/symbols-and-minimums) to retrieve trades for + Symbol *string `json:"symbol,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` +} + +// NetworkAssets defines model for NetworkAssets. +type NetworkAssets struct { + // Assets Alphabetically sorted array of enabled asset/token codes available on this network. Assets include both exchange-tradable and custody-supported tokens. + Assets *[]string `json:"assets,omitempty"` + + // Network The blockchain network identifier. + Network *string `json:"network,omitempty"` +} + +// NetworkToken defines model for NetworkToken. +type NetworkToken struct { + // Network Array of supported blockchain networks for the token. Many tokens (especially stablecoins like USDC, USDT) are available on multiple networks. + // + // Supported networks include: `bitcoin`, `ethereum`, `solana`, `optimism`, `arbitrum`, `base`, `monad`, `avalanche`, `litecoin`, `bitcoincash`, `dogecoin`, `zcash`, `filecoin`, `tezos`, `polkadot`, `cosmos`, `xrpl`, `linea`, and more. + Network *[]string `json:"network,omitempty"` + + // Token The requested token identifier. + Token *string `json:"token,omitempty"` +} + +// NewOrderRequest defines model for NewOrderRequest. +type NewOrderRequest struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Specifies the account on which you intend to place the order. Only available for exchange accounts. + Account *string `json:"account,omitempty"` + + // Amount Quoted decimal amount to purchase + Amount string `json:"amount"` + + // ClientOrderId *Recommended*. A [client-specified order id](/client-order-id) + ClientOrderId *string `json:"client_order_id,omitempty"` + + // MarginOrder Set to `true` to place this order on a margin account using borrowed funds. Defaults to `false`. Only available for margin-enabled accounts. See [Margin Trading](/margin/account-summary) for details. + MarginOrder *bool `json:"margin_order,omitempty"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce int64 `json:"nonce"` + + // Options An optional array containing at most one supported order execution option. See Order execution options for details. + Options *[]NewOrderRequestOptions `json:"options,omitempty"` + + // Price Quoted decimal amount to spend per unit + Price string `json:"price"` + + // Request The literal string "/v1/order/new" + Request string `json:"request"` + Side NewOrderRequestSide `json:"side"` + + // StopPrice The price to trigger a stop-limit order. Only available for stop-limit orders. + StopPrice *string `json:"stop_price,omitempty"` + + // Symbol The [symbol](/market-data/symbols-and-minimums) for the new order + Symbol string `json:"symbol"` + + // Type The order type. "exchange limit" for all order types except for stop-limit orders. "exchange stop limit" for stop-limit orders. + Type NewOrderRequestType `json:"type"` +} + +// NewOrderRequestOptions defines model for NewOrderRequest.Options. +type NewOrderRequestOptions string + +// NewOrderRequestSide defines model for NewOrderRequest.Side. +type NewOrderRequestSide string + +// NewOrderRequestType The order type. "exchange limit" for all order types except for stop-limit orders. "exchange stop limit" for stop-limit orders. +type NewOrderRequestType string + +// Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) +type Nonce struct { + union json.RawMessage +} + +// Nonce1 defines model for . +type Nonce1 = int + +// NotionalBalance defines model for NotionalBalance. +type NotionalBalance struct { + // Amount The current balance + Amount *string `json:"amount,omitempty"` + + // AmountNotional Amount, in notional + AmountNotional *string `json:"amountNotional,omitempty"` + + // Available The amount that is available to trade + Available *string `json:"available,omitempty"` + + // AvailableForWithdrawal The amount that is available to withdraw + AvailableForWithdrawal *string `json:"availableForWithdrawal,omitempty"` + + // AvailableForWithdrawalNotional AvailableForWithdrawal, in notional + AvailableForWithdrawalNotional *string `json:"availableForWithdrawalNotional,omitempty"` + + // AvailableNotional Available, in notional + AvailableNotional *string `json:"availableNotional,omitempty"` + + // Currency Currency code, see symbols and minimums + Currency *string `json:"currency,omitempty"` +} + +// NotionalVolume defines model for NotionalVolume. +type NotionalVolume struct { + ApiAuctionFeeBps *int `json:"api_auction_fee_bps,omitempty"` + ApiMakerFeeBps *int `json:"api_maker_fee_bps,omitempty"` + ApiNotional30dVolume *string `json:"api_notional_30d_volume,omitempty"` + ApiTakerFeeBps *int `json:"api_taker_fee_bps,omitempty"` + Date *openapi_types.Date `json:"date,omitempty"` + FeeTier *struct { + ApiMakerFeeBps *int `json:"api_maker_fee_bps,omitempty"` + ApiTakerFeeBps *int `json:"api_taker_fee_bps,omitempty"` + Tier *string `json:"tier,omitempty"` + } `json:"fee_tier,omitempty"` + FixAuctionFeeBps *int `json:"fix_auction_fee_bps,omitempty"` + FixMakerFeeBps *int `json:"fix_maker_fee_bps,omitempty"` + FixTakerFeeBps *int `json:"fix_taker_fee_bps,omitempty"` + LastUpdatedMs *int `json:"last_updated_ms,omitempty"` + Notional1dVolume *[]struct { + // Date UTC date in `yyyy-MM-dd` format + Date *string `json:"date,omitempty"` + + // NotionalVolume Notional volume value in USD for this single day + NotionalVolume *string `json:"notional_volume,omitempty"` + } `json:"notional_1d_volume,omitempty"` + Notional30dVolume *string `json:"notional_30d_volume,omitempty"` + WebAuctionFeeBps *int `json:"web_auction_fee_bps,omitempty"` + WebMakerFeeBps *int `json:"web_maker_fee_bps,omitempty"` + WebTakerFeeBps *int `json:"web_taker_fee_bps,omitempty"` +} + +// OpenPosition defines model for OpenPosition. +type OpenPosition struct { + // AverageCost The average price of the current position. + AverageCost *string `json:"average_cost,omitempty"` + + // InstrumentType The type of instrument. Either "spot" or "perp". + InstrumentType *string `json:"instrument_type,omitempty"` + + // MarkPrice The current Mark Price for the Asset or the position. + MarkPrice *string `json:"mark_price,omitempty"` + + // NotionalValue The value of position; calculated as (`quantity` * `mark_price`). Value will be negative for shorts. + NotionalValue *string `json:"notional_value,omitempty"` + + // Quantity The position size. Value will be negative for shorts. + Quantity *string `json:"quantity,omitempty"` + + // RealisedPnl The current P&L that has been realised from the position. + RealisedPnl *string `json:"realised_pnl,omitempty"` + + // Symbol The [symbol](/market-data/symbols-and-minimums) of the order. + Symbol *string `json:"symbol,omitempty"` + + // UnrealisedPnl Current Mark to Market value of the positions. + UnrealisedPnl *string `json:"unrealised_pnl,omitempty"` +} + +// Order defines model for Order. +type Order struct { + // AvgExecutionPrice The average price at which this order as been executed so far. 0 if the order has not been executed at all. + AvgExecutionPrice *string `json:"avg_execution_price,omitempty"` + + // ClientOrderId An optional [client-specified order id](/client-order-id#client-order-id) + ClientOrderId *string `json:"client_order_id,omitempty"` + + // Exchange Will always be "gemini" + Exchange *string `json:"exchange,omitempty"` + + // ExecutedAmount The amount of the order that has been filled. + ExecutedAmount *string `json:"executed_amount,omitempty"` + + // IsCancelled `true` if the order has been canceled. Note the spelling, "cancelled" instead of "canceled". This is for compatibility reasons. + IsCancelled *bool `json:"is_cancelled,omitempty"` + + // IsHidden Will always return `false`. + IsHidden *bool `json:"is_hidden,omitempty"` + + // IsLive `true` if the order is active on the book (has remaining quantity and has not been canceled) + IsLive *bool `json:"is_live,omitempty"` + + // Options An array containing at most one supported order execution option. See [Order execution options](/rest/orders#create-new-order) for details. + Options *[]string `json:"options,omitempty"` + + // OrderId The order id + OrderId *string `json:"order_id,omitempty"` + + // OriginalAmount The originally submitted amount of the order. + OriginalAmount *string `json:"original_amount,omitempty"` + + // Price The price the order was issued at + Price *string `json:"price,omitempty"` + + // Reason Populated with the reason your order was canceled, if available. + Reason *string `json:"reason,omitempty"` + + // RemainingAmount The amount of the order that has not been filled. + RemainingAmount *string `json:"remaining_amount,omitempty"` + Side *OrderSide `json:"side,omitempty"` + + // Symbol The [symbol](/market-data/symbols-and-minimums#symbols-and-minimums) of the order + Symbol *string `json:"symbol,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + + // Trades Contains an array of JSON objects with trade details. + Trades *[]struct { + // Aggressor If `true`, this order was the taker in the trade + Aggressor *bool `json:"aggressor,omitempty"` + + // Amount The quantity that was executed + Amount *string `json:"amount,omitempty"` + + // Break Will only be present if the trade is broken. See `Break Types` below for more information. + Break *string `json:"break,omitempty"` + + // Exchange Will always be "gemini" + Exchange *string `json:"exchange,omitempty"` + + // FeeAmount The amount charged + FeeAmount *string `json:"fee_amount,omitempty"` + + // FeeCurrency Currency that the fee was paid in + FeeCurrency *string `json:"fee_currency,omitempty"` + + // OrderId The order that this trade executed against + OrderId *string `json:"order_id,omitempty"` + + // Price The price that the execution happened at + Price *string `json:"price,omitempty"` + + // Tid Unique identifier for the trade + Tid *int `json:"tid,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + + // Type Will be either "Buy" or "Sell", indicating the side of the original order + Type *OrderTradesType `json:"type,omitempty"` + } `json:"trades,omitempty"` + + // Type Description of the order + Type *OrderType `json:"type,omitempty"` + + // WasForced Will always be `false`. + WasForced *bool `json:"was_forced,omitempty"` +} + +// OrderSide defines model for Order.Side. +type OrderSide string + +// OrderTradesType Will be either "Buy" or "Sell", indicating the side of the original order +type OrderTradesType string + +// OrderType Description of the order +type OrderType string + +// OrderBook defines model for OrderBook. +type OrderBook struct { + // Asks The ask price levels currently on the book. These are offers to sell at a given price. + Asks *[]OrderBookEntry `json:"asks,omitempty"` + + // Bids The bid price levels currently on the book. These are offers to buy at a given price. + Bids *[]OrderBookEntry `json:"bids,omitempty"` +} + +// OrderBookEntry defines model for OrderBookEntry. +type OrderBookEntry struct { + // Amount The total quantity remaining at the price + Amount *string `json:"amount,omitempty"` + + // Price The price + Price *string `json:"price,omitempty"` + + // Timestamp **DO NOT USE** - this field is included for compatibility reasons only and is just populated with a dummy value. + Timestamp *string `json:"timestamp,omitempty"` +} + +// OrderStatusRequest defines model for OrderStatusRequest. +type OrderStatusRequest struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Specifies the account on which you intend to place the order. Only available for exchange accounts. + Account *string `json:"account,omitempty"` + + // ClientOrderId The `client_order_id` used when placing the order. `client_order_id` cannot be used in combination with `order_id` + ClientOrderId *string `json:"client_order_id,omitempty"` + + // IncludeTrades Either `True` or `False`. If `True` the endpoint will return individual trade details of all fills from the order. + IncludeTrades *bool `json:"include_trades,omitempty"` + + // Nonce timestamp + Nonce TimestampType `json:"nonce"` + + // OrderId The order id to get information on. The `order_id` represents a whole number and is transmitted as an unsigned 64-bit integer in JSON format. `order_id` cannot be used in combination with `client_order_id`. + OrderId int64 `json:"order_id"` + + // Request The API endpoint path + Request string `json:"request"` +} + +// PaymentMethodBalance defines model for PaymentMethodBalance. +type PaymentMethodBalance struct { + // Amount Total account balance for currency. + Amount *string `json:"amount,omitempty"` + + // Available Total amount available for trading + Available *string `json:"available,omitempty"` + + // AvailableForWithdrawal Total amount available for withdrawal + AvailableForWithdrawal *string `json:"availableForWithdrawal,omitempty"` + + // Currency Symbol for fiat balance. + Currency *string `json:"currency,omitempty"` + + // Type Account type. Will always be `exchange` + Type *string `json:"type,omitempty"` +} + +// PaymentMethodBank defines model for PaymentMethodBank. +type PaymentMethodBank struct { + // Bank Name of bank account + Bank *string `json:"bank,omitempty"` + + // BankId Unique identifier for bank account + BankId *string `json:"bankId,omitempty"` +} + +// PaymentMethodsResponse defines model for PaymentMethodsResponse. +type PaymentMethodsResponse struct { + // Balances Array of JSON objects with available fiat currencies and their balances. + Balances *[]PaymentMethodBalance `json:"balances,omitempty"` + + // Banks Array of JSON objects with banking information + Banks *[]PaymentMethodBank `json:"banks,omitempty"` +} + +// PriceFeedResponse defines model for PriceFeedResponse. +type PriceFeedResponse = []struct { + // Pair Trading pair symbol. See [**symbols and minimums**](/market-data/symbols-and-minimums#all-supported-symbols) + Pair *string `json:"pair,omitempty"` + + // PercentChange24h 24 hour change in price of the pair on the Gemini order book + PercentChange24h *string `json:"percentChange24h,omitempty"` + + // Price Current price of the pair on the Gemini order book + Price *string `json:"price,omitempty"` +} + +// Quantity defines model for Quantity. +type Quantity struct { + // Currency The currency code of the quantity. + Currency string `json:"currency"` + + // Value The value of the quantity. + Value string `json:"value"` +} + +// RevokeOauthTokenResponse defines model for RevokeOauthTokenResponse. +type RevokeOauthTokenResponse struct { + // Message A message that indicates the token has been revoked for the account + Message *string `json:"message,omitempty"` +} + +// RiskStatsResponse defines model for RiskStatsResponse. +type RiskStatsResponse struct { + // IndexPrice Current index price at the time of request + IndexPrice *string `json:"index_price,omitempty"` + + // MarkPrice Current mark price at the time of request + MarkPrice *string `json:"mark_price,omitempty"` + + // OpenInterest string representation of decimal value of open interest + OpenInterest *string `json:"open_interest,omitempty"` + + // OpenInterestNotional string representation of decimal value of open interest notional + OpenInterestNotional *string `json:"open_interest_notional,omitempty"` + + // ProductType Contract type for which the symbol data is fetched + ProductType *RiskStatsResponseProductType `json:"product_type,omitempty"` +} + +// RiskStatsResponseProductType Contract type for which the symbol data is fetched +type RiskStatsResponseProductType string + +// RoleResponse defines model for RoleResponse. +type RoleResponse struct { + // CounterpartyId _Only returned for master-level API keys_. The Gemini clearing counterparty ID associated with the API key making the request. + CounterpartyId *string `json:"counterparty_id,omitempty"` + + // IsAccountAdmin _Only returned for master-level API keys_.`True` if the Administrator role is assigned to the API keys. `False` otherwise. + IsAccountAdmin *bool `json:"isAccountAdmin,omitempty"` + + // IsAuditor `True` if the Auditor role is assigned to the API keys. `False` otherwise. + IsAuditor bool `json:"isAuditor"` + + // IsFundManager `True` if the Fund Manager role is assigned to the API keys. `False` otherwise. + IsFundManager bool `json:"isFundManager"` + + // IsTrader `True` if the Trader role is assigned to the API keys. `False` otherwise. + IsTrader bool `json:"isTrader"` +} + +// StakingBalance defines model for StakingBalance. +type StakingBalance struct { + // Available The amount that is available to trade + Available *openapi_types.DecimalNumber `json:"available,omitempty"` + + // AvailableForWithdrawal The Staking amount that is available to redeem to exchange account + AvailableForWithdrawal *openapi_types.DecimalNumber `json:"availableForWithdrawal,omitempty"` + + // Balance The current Staking balance + Balance *openapi_types.DecimalNumber `json:"balance,omitempty"` + BalanceByProvider *map[string]struct { + // Balance The current Staking balance per providerId + Balance *openapi_types.DecimalNumber `json:"balance,omitempty"` + } `json:"balanceByProvider,omitempty"` + + // Currency Currency code, see symbols and minimums + Currency *string `json:"currency,omitempty"` + + // Type Will always be "Staking" + Type *string `json:"type,omitempty"` +} + +// StakingDeposit defines model for StakingDeposit. +type StakingDeposit struct { + // AccrualTotal The total accrual + AccrualTotal *openapi_types.DecimalNumber `json:"accrualTotal,omitempty"` + + // Amount The amount deposited + Amount *openapi_types.DecimalNumber `json:"amount,omitempty"` + + // Currency Currency code, see [symbols](/market-data/symbols-and-minimums) + Currency *string `json:"currency,omitempty"` + + // ProviderId Provider Id, in uuid4 format + ProviderId *string `json:"providerId,omitempty"` + + // Rates A JSON object including one or many rates. If more than one rate it would be an array of rates. + Rates *struct { + // Rate Staking interest rate in bps (Expressed as a simple rate. Interest on Staking balances compounds daily. In mobile and web applications, APYs are derived from this rate and rounded to 1/10th of a percent.) + Rate *int `json:"rate,omitempty"` + } `json:"rates,omitempty"` + + // TransactionId A unique identifier for the staking transaction + TransactionId *string `json:"transactionId,omitempty"` +} + +// StakingHistory defines model for StakingHistory. +type StakingHistory struct { + // ProviderId Provider Id, in uuid4 format + ProviderId *string `json:"providerId,omitempty"` + Transactions *[]StakingTransaction `json:"transactions,omitempty"` +} + +// StakingRate defines model for StakingRate. +type StakingRate struct { + // ApyPct Staking interest APY (Expressed as a percentage derived from the rate and rounded to 1/10th of a percent.) + ApyPct *openapi_types.DecimalNumber `json:"apyPct,omitempty"` + + // DepositUsdLimit Maximum new amount in USD notional of this crypto that can participate in Gemini Staking per account per month + DepositUsdLimit *int `json:"depositUsdLimit,omitempty"` + + // ProviderId Provider Id, in uuid4 format + ProviderId *string `json:"providerId,omitempty"` + + // Rate Staking interest rate in bps (Expressed as a simple rate. Interest on Staking balances compounds daily. In mobile and web applications, APYs are derived from this rate and rounded to 1/10th of a percent.) + Rate *openapi_types.DecimalNumber `json:"rate,omitempty"` + + // RatePct `rate` expressed as a percentage + RatePct *openapi_types.DecimalNumber `json:"ratePct,omitempty"` +} + +// StakingRateProvider Currency Symbol Keys +type StakingRateProvider struct { + CurrencySymbol *StakingRate `json:"currency_symbol,omitempty"` +} + +// StakingRateResponse Provider UUID Keys +type StakingRateResponse struct { + // ProviderUuid Currency Symbol Keys + ProviderUuid *StakingRateProvider `json:"provider_uuid,omitempty"` +} + +// StakingRewardPeriod defines model for StakingRewardPeriod. +type StakingRewardPeriod struct { + // AccrualTotal The total accrual + AccrualTotal *openapi_types.DecimalNumber `json:"accrualTotal,omitempty"` + + // ApyPct Staking reward rate expressed as an APY at time of accrual. Interest on Staking balances compounds daily based on the simple rate which is available from `/v1/staking/rates/` + ApyPct *openapi_types.DecimalNumber `json:"apyPct,omitempty"` + + // Currency Currency code, see [symbols](/market-data/symbols-and-minimums) + Currency *string `json:"currency,omitempty"` + + // FirstAccrualAt Time of first accrual. In iso datetime with timezone format + FirstAccrualAt *string `json:"firstAccrualAt,omitempty"` + + // LastAccrualAt Time of last accrual. In iso datetime with timezone format + LastAccrualAt *string `json:"lastAccrualAt,omitempty"` + + // NumberOfAccruals Number of accruals in the specific aggregate, typically one per day. If the rate is adjusted, new accruals are added. + NumberOfAccruals *int `json:"numberOfAccruals,omitempty"` + + // ProviderId Provider Id, in uuid4 format + ProviderId *string `json:"providerId,omitempty"` + + // RatePct Rate expressed as a percentage + RatePct *openapi_types.DecimalNumber `json:"ratePct,omitempty"` +} + +// StakingRewards defines model for StakingRewards. +type StakingRewards struct { + // AccrualTotal The total accrual + AccrualTotal *openapi_types.DecimalNumber `json:"accrualTotal,omitempty"` + + // Currency Currency code, see [symbols](/market-data/symbols-and-minimums) + Currency *string `json:"currency,omitempty"` + + // ProviderId Provider Id, in uuid4 format + ProviderId *string `json:"providerId,omitempty"` + + // RatePeriods Array of JSON objects with period accrual information + RatePeriods *[]StakingRewardPeriod `json:"ratePeriods,omitempty"` +} + +// StakingRewardsProvider Currency Symbol Keys +type StakingRewardsProvider struct { + CurrencySymbol *StakingRewards `json:"currency_symbol,omitempty"` +} + +// StakingRewardsResponse Provider UUID Keys +type StakingRewardsResponse struct { + // ProviderUuid Currency Symbol Keys + ProviderUuid *StakingRewardsProvider `json:"provider_uuid,omitempty"` +} + +// StakingTransaction defines model for StakingTransaction. +type StakingTransaction struct { + // Amount The amount that is defined by the transactionType above + Amount *openapi_types.DecimalNumber `json:"amount,omitempty"` + + // AmountCurrency Currency code + AmountCurrency *string `json:"amountCurrency,omitempty"` + + // DateTime timestamp + DateTime *TimestampType `json:"dateTime,omitempty"` + + // PriceAmount Current market price of the underlying token at the time of the reward + PriceAmount *openapi_types.DecimalNumber `json:"priceAmount,omitempty"` + + // PriceCurrency A supported three-letter fiat currency code, e.g. usd + PriceCurrency *string `json:"priceCurrency,omitempty"` + + // TransactionId A unique identifier for the staking transaction + TransactionId *string `json:"transactionId,omitempty"` + + // TransactionType Can be any one of the following - Deposit, Redeem, Interest, RedeemPayment, AdminRedeem, AdminCreditAdjustment, AdminDebitAdjustment + TransactionType *StakingTransactionTransactionType `json:"transactionType,omitempty"` +} + +// StakingTransactionTransactionType Can be any one of the following - Deposit, Redeem, Interest, RedeemPayment, AdminRedeem, AdminCreditAdjustment, AdminDebitAdjustment +type StakingTransactionTransactionType string + +// StakingWithdrawal defines model for StakingWithdrawal. +type StakingWithdrawal struct { + // Amount The amount deposited + Amount *openapi_types.DecimalNumber `json:"amount,omitempty"` + + // AmountPaidSoFar The amount redeemed successfully + AmountPaidSoFar *openapi_types.DecimalNumber `json:"amountPaidSoFar,omitempty"` + + // AmountRemaining The amount pending to be redeemed + AmountRemaining *openapi_types.DecimalNumber `json:"amountRemaining,omitempty"` + + // Currency Currency code + Currency *string `json:"currency,omitempty"` + + // RequestInitiated In ISO datetime with timezone format + RequestInitiated *string `json:"requestInitiated,omitempty"` + + // TransactionId A unique identifier for the staking transaction + TransactionId *string `json:"transactionId,omitempty"` +} + +// StopLimitOrderResponse defines model for StopLimitOrderResponse. +type StopLimitOrderResponse struct { + AvgExecutionPrice *string `json:"avg_execution_price,omitempty"` + Exchange *string `json:"exchange,omitempty"` + ExecutedAmount *string `json:"executed_amount,omitempty"` + Id *string `json:"id,omitempty"` + IsCancelled *bool `json:"is_cancelled,omitempty"` + IsHidden *bool `json:"is_hidden,omitempty"` + IsLive *bool `json:"is_live,omitempty"` + Options *[]string `json:"options,omitempty"` + OrderId *string `json:"order_id,omitempty"` + OriginalAmount *string `json:"original_amount,omitempty"` + Price *string `json:"price,omitempty"` + Side *StopLimitOrderResponseSide `json:"side,omitempty"` + StopPrice *string `json:"stop_price,omitempty"` + Symbol *string `json:"symbol,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + Type *StopLimitOrderResponseType `json:"type,omitempty"` + WasForced *bool `json:"was_forced,omitempty"` +} + +// StopLimitOrderResponseSide defines model for StopLimitOrderResponse.Side. +type StopLimitOrderResponseSide string + +// StopLimitOrderResponseType defines model for StopLimitOrderResponse.Type. +type StopLimitOrderResponseType string + +// SymbolDetails defines model for SymbolDetails. +type SymbolDetails struct { + // BaseCurrency CCY1 or the top currency. (i.e `BTC` in `BTCUSD`) + BaseCurrency *string `json:"base_currency,omitempty"` + + // ContractPriceCurrency CCY2 or the quote currency for spot instrument (i.e. `USD` in `BTCUSD`) + // Or collateral currency of the contract in case of perpetual swap instrument. + ContractPriceCurrency *string `json:"contract_price_currency,omitempty"` + + // ContractType `vanilla` / `linear` / `inverse` where `vanilla` is for spot + // while `linear` is for perpetual swap and `inverse` is a special case perpetual swap where the perpetual contract will be settled in base currency. + ContractType *string `json:"contract_type,omitempty"` + + // MinOrderSize The minimum order size in `base_currency` units (i.e `0.00001`) + MinOrderSize *string `json:"min_order_size,omitempty"` + + // ProductType Instrument type `spot` / `swap` -- where `swap` signifies `perpetual swap`. + ProductType *string `json:"product_type,omitempty"` + + // QuoteCurrency CCY2 or the quote currency. (i.e `USD` in `BTCUSD`) + QuoteCurrency *string `json:"quote_currency,omitempty"` + + // QuoteIncrement The number of decimal places in the `quote_currency` (i.e `0.01`) + QuoteIncrement *openapi_types.DecimalNumber `json:"quote_increment,omitempty"` + + // Status Status of the current order book. Can be `open`, `closed`, `cancel_only`, `post_only`, `limit_only`. + Status *string `json:"status,omitempty"` + + // Symbol The requested symbol. See [**symbols and minimums**](/market-data/symbols-and-minimums#all-supported-symbols) + Symbol *string `json:"symbol,omitempty"` + + // TickSize The number of decimal places in the `base_currency`. (i.e `1e-8`) + TickSize *openapi_types.DecimalNumber `json:"tick_size,omitempty"` + + // WrapEnabled When `True`, symbol can be wrapped using this endpoint: + // `POST https://api.gemini.com/v1/wrap/:symbol` + WrapEnabled *bool `json:"wrap_enabled,omitempty"` +} + +// Ticker defines model for Ticker. +type Ticker struct { + // Ask The lowest ask currently available + Ask *string `json:"ask,omitempty"` + + // Bid The highest bid currently available + Bid *string `json:"bid,omitempty"` + + // Last The price of the last executed trade + Last *string `json:"last,omitempty"` + + // Volume Information about the 24 hour volume on the exchange. See properties below + Volume *struct { + // PriceSymbol The volume denominated in the price currency + PriceSymbol *string `json:"price_symbol,omitempty"` + + // QuantitySymbol The volume denominated in the quantity currency + QuantitySymbol *string `json:"quantity_symbol,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + } `json:"volume,omitempty"` +} + +// TickerInfo defines model for TickerInfo. +type TickerInfo struct { + // Ask Current best offer + Ask *string `json:"ask,omitempty"` + + // Bid Current best bid + Bid *string `json:"bid,omitempty"` + + // Changes Hourly prices descending for past 24 hours + Changes *[]string `json:"changes,omitempty"` + + // Close Close price (most recent trade) + Close *string `json:"close,omitempty"` + + // High High price from 24 hours ago + High *string `json:"high,omitempty"` + + // Low Low price from 24 hours ago + Low *string `json:"low,omitempty"` + + // Open Open price from 24 hours ago + Open *string `json:"open,omitempty"` + + // Symbol The trading pair symbol + Symbol *string `json:"symbol,omitempty"` +} + +// TimestampType timestamp +type TimestampType struct { + union json.RawMessage +} + +// TimestampType0 Gemini strongly recommends using milliseconds instead of seconds for timestamps. +// +// | Timestamp format | Example | Supported request type | +// |-----------------------|-----------------------|------------------------| +// | string (seconds) | `1495127793` | `POST` only | +// | string (milliseconds) | `1495127793000` | `POST` only | +type TimestampType0 = string + +// TimestampType1 Gemini strongly recommends using milliseconds instead of seconds for timestamps. +// +// | Timestamp format | Example | Supported request type | +// |-----------------------------|---------------------------|------------------------| +// | whole number (seconds) | `1495127793` | `GET`, `POST` | +// | whole number (milliseconds) | `1495127793000` | `GET`, `POST` | +type TimestampType1 = int64 + +// Trade defines model for Trade. +type Trade struct { + // Amount The amount that was traded + Amount *string `json:"amount,omitempty"` + + // Broken Whether the trade was broken or not. Broken trades will not be displayed by default; use the `include_breaks` to display them. + Broken *bool `json:"broken,omitempty"` + + // Exchange Will always be "gemini" + Exchange *string `json:"exchange,omitempty"` + + // Price The price the trade was executed at + Price *string `json:"price,omitempty"` + + // Tid The trade ID number + Tid *int64 `json:"tid,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + + // Type - `buy` means that an ask was removed from the book by an incoming buy order. + // - `sell` means that a bid was removed from the book by an incoming sell order. + Type *TradeType `json:"type,omitempty"` +} + +// TradeType - `buy` means that an ask was removed from the book by an incoming buy order. +// - `sell` means that a bid was removed from the book by an incoming sell order. +type TradeType string + +// TradeVolume defines model for TradeVolume. +type TradeVolume struct { + BaseCurrency *string `json:"base_currency,omitempty"` + BuyMakerBase *string `json:"buy_maker_base,omitempty"` + BuyMakerCount *int `json:"buy_maker_count,omitempty"` + BuyMakerNotional *string `json:"buy_maker_notional,omitempty"` + BuyTakerBase *string `json:"buy_taker_base,omitempty"` + BuyTakerCount *int `json:"buy_taker_count,omitempty"` + BuyTakerNotional *string `json:"buy_taker_notional,omitempty"` + DataDate *string `json:"data_date,omitempty"` + MakerBuySellRatio *string `json:"maker_buy_sell_ratio,omitempty"` + NotionalCurrency *string `json:"notional_currency,omitempty"` + QuoteCurrency *string `json:"quote_currency,omitempty"` + SellMakerBase *string `json:"sell_maker_base,omitempty"` + SellMakerCount *int `json:"sell_maker_count,omitempty"` + SellMakerNotional *string `json:"sell_maker_notional,omitempty"` + SellTakerBase *string `json:"sell_taker_base,omitempty"` + SellTakerCount *int `json:"sell_taker_count,omitempty"` + SellTakerNotional *string `json:"sell_taker_notional,omitempty"` + Symbol *string `json:"symbol,omitempty"` + TotalVolumeBase *string `json:"total_volume_base,omitempty"` +} + +// Transaction defines model for Transaction. +type Transaction struct { + union json.RawMessage +} + +// Transaction0 Trade Reponse +type Transaction0 struct { + // Account The account. + Account *string `json:"account,omitempty"` + + // Amount The quantity that was executed. + Amount *string `json:"amount,omitempty"` + + // ClientOrderId The client order ID, if defined. Otherwise an empty string. + ClientOrderId *string `json:"clientOrderId,omitempty"` + + // Exchange Will always be "gemini". + Exchange *string `json:"exchange,omitempty"` + + // FeeAmount The fee amount charged + FeeAmount *string `json:"feeAmount,omitempty"` + + // FeeAssetCode The symbol that the trade was for + FeeAssetCode *string `json:"feeAssetCode,omitempty"` + + // IsAggressor If true, this order was the taker in the trade. + IsAggressor *bool `json:"isAggressor,omitempty"` + + // IsAuctionFill True if the trade was a auction trade and not an on-exchange trade. + IsAuctionFill *bool `json:"isAuctionFill,omitempty"` + + // IsClearingFill True if the trade was a clearing trade and not an on-exchange trade. + IsClearingFill *bool `json:"isClearingFill,omitempty"` + + // OrderId The order that this trade executed against. + OrderId *int64 `json:"orderId,omitempty"` + + // Price The price that the execution happened at. + Price *string `json:"price,omitempty"` + + // Side Indicating the side of the original order. + Side *string `json:"side,omitempty"` + + // Symbol The symbol that the trade was for. + Symbol *string `json:"symbol,omitempty"` + + // Tid The trade ID. + Tid *int64 `json:"tid,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` +} + +// Transaction1 Transfer Reponse +type Transaction1 struct { + // AdvanceEid Deposit advance event ID. + AdvanceEid *int64 `json:"advanceEid,omitempty"` + + // Amount The quantity that was transferred. + Amount *string `json:"amount,omitempty"` + + // BankId Bank ID. + BankId *string `json:"bankId,omitempty"` + + // ClientTransferId Client Transfer ID. Client transfer ID is an optional client-supplied unique identifier for each withdrawal or internal transfer. + ClientTransferId *string `json:"clientTransferId,omitempty"` + + // CorrelationId Correlation ID. + CorrelationId *int64 `json:"correlationId,omitempty"` + + // Currency Currency code, see symbols + Currency *string `json:"currency,omitempty"` + + // Destination The account you are transferring to. + Destination *string `json:"destination,omitempty"` + + // Eid Transfer event id. + Eid *int64 `json:"eid,omitempty"` + + // FeeId Fee ID. + FeeId *string `json:"feeId,omitempty"` + + // Method Type of transfer method. + Method *string `json:"method,omitempty"` + + // OperationReason The operation reason. + OperationReason *string `json:"operationReason,omitempty"` + + // PendingEid Pending event ID. + PendingEid *int64 `json:"pendingEid,omitempty"` + + // Purpose Purpose. + Purpose *string `json:"purpose,omitempty"` + + // Source The account you are transferring from. + Source *string `json:"source,omitempty"` + + // Status The status of the transfer. + Status *string `json:"status,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + + // TransactionHash Supplies the transaction hash when available. + TransactionHash *string `json:"transactionHash,omitempty"` + + // TransferId Transfer ID. + TransferId *string `json:"transferId,omitempty"` + + // TransferType Transfer type. + TransferType *string `json:"transferType,omitempty"` + + // WithdrawalEid Withdrawal event ID. + WithdrawalEid *int64 `json:"withdrawalEid,omitempty"` + + // WithdrawalId Withdrawal ID. + WithdrawalId *string `json:"withdrawalId,omitempty"` +} + +// Transfer defines model for Transfer. +type Transfer struct { + // Amount The amount transferred + Amount *string `json:"amount,omitempty"` + + // Currency The currency transferred + Currency *string `json:"currency,omitempty"` + + // Eid The transfer ID + Eid *int64 `json:"eid,omitempty"` + Status *TransferStatus `json:"status,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + + // TxHash The transaction hash if applicable + TxHash *string `json:"txHash,omitempty"` + Type *TransferType `json:"type,omitempty"` +} + +// TransferStatus defines model for Transfer.Status. +type TransferStatus string + +// TransferType defines model for Transfer.Type. +type TransferType string + +// V2Transfer defines model for V2Transfer. +type V2Transfer struct { + // Amount The amount transferred + Amount *string `json:"amount,omitempty"` + + // Currency The currency transferred + Currency *string `json:"currency,omitempty"` + + // Destination The destination address for withdrawals + Destination *string `json:"destination,omitempty"` + + // Eid The transfer event ID + Eid *int64 `json:"eid,omitempty"` + + // FeeAmount The fee charged for the transfer + FeeAmount *string `json:"feeAmount,omitempty"` + + // FeeCurrency The currency in which the fee was charged + FeeCurrency *string `json:"feeCurrency,omitempty"` + + // Method The transfer method (e.g., `ACH`, `CreditCard`) + Method *string `json:"method,omitempty"` + + // Network The blockchain network the transfer was executed on (e.g., `ethereum`, `solana`, `arbitrum`, `optimism`, `base`, `avalanche`). Not present for fiat or administrative transfers. + Network *string `json:"network,omitempty"` + + // OutputIdx The output index for withdrawals + OutputIdx *int `json:"outputIdx,omitempty"` + + // Purpose The purpose or reason for administrative transfers + Purpose *string `json:"purpose,omitempty"` + + // Status The status of the transfer + Status *V2TransferStatus `json:"status,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + + // TxHash The on-chain transaction hash, if applicable + TxHash *string `json:"txHash,omitempty"` + + // Type The type of the transfer + Type *V2TransferType `json:"type,omitempty"` + + // WithdrawalId The unique withdrawal identifier + WithdrawalId *string `json:"withdrawalId,omitempty"` +} + +// V2TransferStatus The status of the transfer +type V2TransferStatus string + +// V2TransferType The type of the transfer +type V2TransferType string + +// WithdrawCryptoFundsResponse Response returned after submitting a v2 cryptocurrency withdrawal. +type WithdrawCryptoFundsResponse struct { + // Address Standard string format of the withdrawal destination address + Address *string `json:"address,omitempty"` + + // Amount The withdrawal amount + Amount *string `json:"amount,omitempty"` + + // Currency The currency code of the withdrawn asset + Currency *string `json:"currency,omitempty"` + + // Fee The fee charged for the withdrawal + Fee *string `json:"fee,omitempty"` + + // WithdrawalId A unique ID for the withdrawal + WithdrawalId *string `json:"withdrawalId,omitempty"` +} + +// ApiKeyAuth defines model for apiKeyAuth. +type ApiKeyAuth = string + +// CacheControl defines model for cacheControl. +type CacheControl = string + +// ContentLength defines model for contentLength. +type ContentLength = string + +// ContentType defines model for contentType. +type ContentType = string + +// CurrencyParam defines model for currencyParam. +type CurrencyParam = string + +// NetworkParam defines model for networkParam. +type NetworkParam = string + +// PayloadAuth defines model for payloadAuth. +type PayloadAuth = string + +// SignatureAuth defines model for signatureAuth. +type SignatureAuth = string + +// SymbolParam defines model for symbolParam. +type SymbolParam = string + +// TimestampParam timestamp +type TimestampParam = TimestampType + +// ApiKeyIpFilteringFailure defines model for ApiKeyIpFilteringFailure. +type ApiKeyIpFilteringFailure = ErrorResponse + +// BadRequest defines model for BadRequest. +type BadRequest = ErrorResponse + +// InternalError defines model for InternalError. +type InternalError = ErrorResponse + +// NotFound defines model for NotFound. +type NotFound = ErrorResponse + +// TooManyRequests defines model for TooManyRequests. +type TooManyRequests = ErrorResponse + +// Unauthorized defines model for Unauthorized. +type Unauthorized = ErrorResponse + +// apiKeyAuthContextKey is the context key for apiKeyAuth security scheme +type apiKeyAuthContextKey string + +// payloadAuthContextKey is the context key for payloadAuth security scheme +type payloadAuthContextKey string + +// signatureAuthContextKey is the context key for signatureAuth security scheme +type signatureAuthContextKey string + +// GetCurrentOrderBookParams defines parameters for GetCurrentOrderBook. +type GetCurrentOrderBookParams struct { + // LimitBids Limit the number of bid (offers to buy) price levels returned. Default is 50. May be 0 to return the full order book on this side. + LimitBids *float32 `form:"limit_bids,omitempty" json:"limit_bids,omitempty"` + + // LimitAsks Limit the number of ask (offers to sell) price levels returned. Default is 50. May be 0 to return the full order book on this side. + LimitAsks *float32 `form:"limit_asks,omitempty" json:"limit_asks,omitempty"` +} + +// GetFundingAmountReportFileParams defines parameters for GetFundingAmountReportFile. +type GetFundingAmountReportFileParams struct { + // Symbol Trading pair symbol

+ // + // `BTCGUSDPERP`, etc. See [**symbols and minimums**](/market-data/symbols-and-minimums#all-supported-symbols). + Symbol string `form:"symbol" json:"symbol"` + + // FromDate Mandatory if `toDate` is specified, else optional. If empty, will only fetch records by numRows value. + FromDate *openapi_types.Date `form:"fromDate,omitempty" json:"fromDate,omitempty"` + + // ToDate Mandatory if `fromDate` is specified, else optional. If empty, will only fetch records by numRows value. + ToDate *openapi_types.Date `form:"toDate,omitempty" json:"toDate,omitempty"` + + // NumRows If empty, default value '8760' + NumRows *int `form:"numRows,omitempty" json:"numRows,omitempty"` +} + +// ListTradesParams defines parameters for ListTrades. +type ListTradesParams struct { + // Timestamp Only return trades after this timestamp. See [**Timestamps**](/rest/~schemas#timestamp-type) for more information. If not present, will show the most recent trades. For backwards compatibility, you may also use the alias `since`. With timestamp, there is a 90-day hard limit. + Timestamp *TimestampType `form:"timestamp,omitempty" json:"timestamp,omitempty"` + + // SinceTid Only retuns trades that executed after this tid. since_tid trumps timestamp parameter which has no effect if provided too. You may set since_tid to zero to get the earliest available trade history data. + SinceTid *float32 `form:"since_tid,omitempty" json:"since_tid,omitempty"` + + // LimitTrades The maximum number of trades to return. The default is 50. + LimitTrades *float32 `form:"limit_trades,omitempty" json:"limit_trades,omitempty"` + + // IncludeBreaks Whether to display broken trades. False by default. Can be `1` or `true` to activate + IncludeBreaks *bool `form:"include_breaks,omitempty" json:"include_breaks,omitempty"` +} + +// ListCandlesParamsTimeFrame defines parameters for ListCandles. +type ListCandlesParamsTimeFrame string + +// ListDerivativeCandlesParamsTimeFrame defines parameters for ListDerivativeCandles. +type ListDerivativeCandlesParamsTimeFrame string + +// GetFXRateParams defines parameters for GetFXRate. +type GetFXRateParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// GetTokenNetworkV2Params defines parameters for GetTokenNetworkV2. +type GetTokenNetworkV2Params struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// GetAssetsForNetworkParams defines parameters for GetAssetsForNetwork. +type GetAssetsForNetworkParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// AsHeartbeatNonce0 returns the union data inside the Heartbeat_Nonce as a HeartbeatNonce0 +func (t Heartbeat_Nonce) AsHeartbeatNonce0() (HeartbeatNonce0, error) { + var body HeartbeatNonce0 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromHeartbeatNonce0 overwrites any union data inside the Heartbeat_Nonce as the provided HeartbeatNonce0 +func (t *Heartbeat_Nonce) FromHeartbeatNonce0(v HeartbeatNonce0) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeHeartbeatNonce0 performs a merge with any union data inside the Heartbeat_Nonce, using the provided HeartbeatNonce0 +func (t *Heartbeat_Nonce) MergeHeartbeatNonce0(v HeartbeatNonce0) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsHeartbeatNonce1 returns the union data inside the Heartbeat_Nonce as a HeartbeatNonce1 +func (t Heartbeat_Nonce) AsHeartbeatNonce1() (HeartbeatNonce1, error) { + var body HeartbeatNonce1 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromHeartbeatNonce1 overwrites any union data inside the Heartbeat_Nonce as the provided HeartbeatNonce1 +func (t *Heartbeat_Nonce) FromHeartbeatNonce1(v HeartbeatNonce1) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeHeartbeatNonce1 performs a merge with any union data inside the Heartbeat_Nonce, using the provided HeartbeatNonce1 +func (t *Heartbeat_Nonce) MergeHeartbeatNonce1(v HeartbeatNonce1) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t Heartbeat_Nonce) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *Heartbeat_Nonce) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + +// AsTimestampType returns the union data inside the Nonce as a TimestampType +func (t Nonce) AsTimestampType() (TimestampType, error) { + var body TimestampType + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromTimestampType overwrites any union data inside the Nonce as the provided TimestampType +func (t *Nonce) FromTimestampType(v TimestampType) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeTimestampType performs a merge with any union data inside the Nonce, using the provided TimestampType +func (t *Nonce) MergeTimestampType(v TimestampType) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsNonce1 returns the union data inside the Nonce as a Nonce1 +func (t Nonce) AsNonce1() (Nonce1, error) { + var body Nonce1 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromNonce1 overwrites any union data inside the Nonce as the provided Nonce1 +func (t *Nonce) FromNonce1(v Nonce1) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeNonce1 performs a merge with any union data inside the Nonce, using the provided Nonce1 +func (t *Nonce) MergeNonce1(v Nonce1) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t Nonce) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *Nonce) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + +// AsTimestampType0 returns the union data inside the TimestampType as a TimestampType0 +func (t TimestampType) AsTimestampType0() (TimestampType0, error) { + var body TimestampType0 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromTimestampType0 overwrites any union data inside the TimestampType as the provided TimestampType0 +func (t *TimestampType) FromTimestampType0(v TimestampType0) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeTimestampType0 performs a merge with any union data inside the TimestampType, using the provided TimestampType0 +func (t *TimestampType) MergeTimestampType0(v TimestampType0) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsTimestampType1 returns the union data inside the TimestampType as a TimestampType1 +func (t TimestampType) AsTimestampType1() (TimestampType1, error) { + var body TimestampType1 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromTimestampType1 overwrites any union data inside the TimestampType as the provided TimestampType1 +func (t *TimestampType) FromTimestampType1(v TimestampType1) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeTimestampType1 performs a merge with any union data inside the TimestampType, using the provided TimestampType1 +func (t *TimestampType) MergeTimestampType1(v TimestampType1) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t TimestampType) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *TimestampType) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + +// AsTransaction0 returns the union data inside the Transaction as a Transaction0 +func (t Transaction) AsTransaction0() (Transaction0, error) { + var body Transaction0 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromTransaction0 overwrites any union data inside the Transaction as the provided Transaction0 +func (t *Transaction) FromTransaction0(v Transaction0) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeTransaction0 performs a merge with any union data inside the Transaction, using the provided Transaction0 +func (t *Transaction) MergeTransaction0(v Transaction0) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsTransaction1 returns the union data inside the Transaction as a Transaction1 +func (t Transaction) AsTransaction1() (Transaction1, error) { + var body Transaction1 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromTransaction1 overwrites any union data inside the Transaction as the provided Transaction1 +func (t *Transaction) FromTransaction1(v Transaction1) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeTransaction1 performs a merge with any union data inside the Transaction, using the provided Transaction1 +func (t *Transaction) MergeTransaction1(v Transaction1) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t Transaction) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *Transaction) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} diff --git a/packages/sdk-go/generated/perpetuals/types.gen.go b/packages/sdk-go/generated/perpetuals/types.gen.go new file mode 100644 index 00000000..46b804fb --- /dev/null +++ b/packages/sdk-go/generated/perpetuals/types.gen.go @@ -0,0 +1,2859 @@ +// Code generated from rest.yaml (Derivatives). DO NOT EDIT. + +// Package perpetuals provides primitives to interact with the openapi HTTP API. +// +// Code generated by oapi-codegen. DO NOT EDIT. +package perpetuals + +import ( + "encoding/json" + "time" + + "github.com/gemini/gemini-go/internal/runtime" + openapi_types "github.com/gemini/gemini-go/types" +) + +const ( + ApiKeyAuthScopes apiKeyAuthContextKey = "apiKeyAuth.Scopes" + PayloadAuthScopes payloadAuthContextKey = "payloadAuth.Scopes" + SignatureAuthScopes signatureAuthContextKey = "signatureAuth.Scopes" +) + +// Defines values for BalanceType. +const ( + Exchange BalanceType = "exchange" +) + +// Valid indicates whether the value is a known member of the BalanceType enum. +func (e BalanceType) Valid() bool { + switch e { + case Exchange: + return true + default: + return false + } +} + +// Defines values for CancelOrderResponseReason. +const ( + ExceedsPriceLimits CancelOrderResponseReason = "ExceedsPriceLimits" + FillOrKillWouldNotFill CancelOrderResponseReason = "FillOrKillWouldNotFill" + ImmediateOrCancelWouldPost CancelOrderResponseReason = "ImmediateOrCancelWouldPost" + MakerOrCancelWouldTake CancelOrderResponseReason = "MakerOrCancelWouldTake" + MarketClosed CancelOrderResponseReason = "MarketClosed" + Requested CancelOrderResponseReason = "Requested" + SelfCrossPrevented CancelOrderResponseReason = "SelfCrossPrevented" + TradingClosed CancelOrderResponseReason = "TradingClosed" +) + +// Valid indicates whether the value is a known member of the CancelOrderResponseReason enum. +func (e CancelOrderResponseReason) Valid() bool { + switch e { + case ExceedsPriceLimits: + return true + case FillOrKillWouldNotFill: + return true + case ImmediateOrCancelWouldPost: + return true + case MakerOrCancelWouldTake: + return true + case MarketClosed: + return true + case Requested: + return true + case SelfCrossPrevented: + return true + case TradingClosed: + return true + default: + return false + } +} + +// Defines values for CancelOrderResponseSide. +const ( + CancelOrderResponseSideBuy CancelOrderResponseSide = "buy" + CancelOrderResponseSideSell CancelOrderResponseSide = "sell" +) + +// Valid indicates whether the value is a known member of the CancelOrderResponseSide enum. +func (e CancelOrderResponseSide) Valid() bool { + switch e { + case CancelOrderResponseSideBuy: + return true + case CancelOrderResponseSideSell: + return true + default: + return false + } +} + +// Defines values for CancelOrderResponseType. +const ( + CancelOrderResponseTypeExchangeLimit CancelOrderResponseType = "exchange limit" + CancelOrderResponseTypeExchangeMarket CancelOrderResponseType = "exchange market" + CancelOrderResponseTypeExchangeStopLimit CancelOrderResponseType = "exchange stop limit" +) + +// Valid indicates whether the value is a known member of the CancelOrderResponseType enum. +func (e CancelOrderResponseType) Valid() bool { + switch e { + case CancelOrderResponseTypeExchangeLimit: + return true + case CancelOrderResponseTypeExchangeMarket: + return true + case CancelOrderResponseTypeExchangeStopLimit: + return true + default: + return false + } +} + +// Defines values for ClearingOrderSide. +const ( + ClearingOrderSideBuy ClearingOrderSide = "buy" + ClearingOrderSideSell ClearingOrderSide = "sell" +) + +// Valid indicates whether the value is a known member of the ClearingOrderSide enum. +func (e ClearingOrderSide) Valid() bool { + switch e { + case ClearingOrderSideBuy: + return true + case ClearingOrderSideSell: + return true + default: + return false + } +} + +// Defines values for FundingPaymentEventType. +const ( + FundingPaymentEventTypeHourlyFundingTransfer FundingPaymentEventType = "Hourly Funding Transfer" +) + +// Valid indicates whether the value is a known member of the FundingPaymentEventType enum. +func (e FundingPaymentEventType) Valid() bool { + switch e { + case FundingPaymentEventTypeHourlyFundingTransfer: + return true + default: + return false + } +} + +// Defines values for FundingPaymentReportItemAction. +const ( + FundingPaymentReportItemActionCredit FundingPaymentReportItemAction = "Credit" + FundingPaymentReportItemActionDebit FundingPaymentReportItemAction = "Debit" +) + +// Valid indicates whether the value is a known member of the FundingPaymentReportItemAction enum. +func (e FundingPaymentReportItemAction) Valid() bool { + switch e { + case FundingPaymentReportItemActionCredit: + return true + case FundingPaymentReportItemActionDebit: + return true + default: + return false + } +} + +// Defines values for FundingPaymentReportItemEventType. +const ( + FundingPaymentReportItemEventTypeHourlyFundingTransfer FundingPaymentReportItemEventType = "Hourly Funding Transfer" +) + +// Valid indicates whether the value is a known member of the FundingPaymentReportItemEventType enum. +func (e FundingPaymentReportItemEventType) Valid() bool { + switch e { + case FundingPaymentReportItemEventTypeHourlyFundingTransfer: + return true + default: + return false + } +} + +// Defines values for FundingTransferAction. +const ( + FundingTransferActionCredit FundingTransferAction = "Credit" + FundingTransferActionDebit FundingTransferAction = "Debit" +) + +// Valid indicates whether the value is a known member of the FundingTransferAction enum. +func (e FundingTransferAction) Valid() bool { + switch e { + case FundingTransferActionCredit: + return true + case FundingTransferActionDebit: + return true + default: + return false + } +} + +// Defines values for InstantQuoteSide. +const ( + InstantQuoteSideBuy InstantQuoteSide = "buy" + InstantQuoteSideSell InstantQuoteSide = "sell" +) + +// Valid indicates whether the value is a known member of the InstantQuoteSide enum. +func (e InstantQuoteSide) Valid() bool { + switch e { + case InstantQuoteSideBuy: + return true + case InstantQuoteSideSell: + return true + default: + return false + } +} + +// Defines values for InterestRateInfoInterval. +const ( + Hour InterestRateInfoInterval = "hour" +) + +// Valid indicates whether the value is a known member of the InterestRateInfoInterval enum. +func (e InterestRateInfoInterval) Valid() bool { + switch e { + case Hour: + return true + default: + return false + } +} + +// Defines values for LimitOrderResponseSide. +const ( + LimitOrderResponseSideBuy LimitOrderResponseSide = "buy" + LimitOrderResponseSideSell LimitOrderResponseSide = "sell" +) + +// Valid indicates whether the value is a known member of the LimitOrderResponseSide enum. +func (e LimitOrderResponseSide) Valid() bool { + switch e { + case LimitOrderResponseSideBuy: + return true + case LimitOrderResponseSideSell: + return true + default: + return false + } +} + +// Defines values for LimitOrderResponseType. +const ( + LimitOrderResponseTypeExchangeLimit LimitOrderResponseType = "exchange limit" + LimitOrderResponseTypeExchangeMarket LimitOrderResponseType = "exchange market" + LimitOrderResponseTypeExchangeStopLimit LimitOrderResponseType = "exchange stop limit" +) + +// Valid indicates whether the value is a known member of the LimitOrderResponseType enum. +func (e LimitOrderResponseType) Valid() bool { + switch e { + case LimitOrderResponseTypeExchangeLimit: + return true + case LimitOrderResponseTypeExchangeMarket: + return true + case LimitOrderResponseTypeExchangeStopLimit: + return true + default: + return false + } +} + +// Defines values for MyTradeBreak. +const ( + Empty MyTradeBreak = "" + TradeCorrect MyTradeBreak = "trade correct" +) + +// Valid indicates whether the value is a known member of the MyTradeBreak enum. +func (e MyTradeBreak) Valid() bool { + switch e { + case Empty: + return true + case TradeCorrect: + return true + default: + return false + } +} + +// Defines values for MyTradeType. +const ( + MyTradeTypeBuy MyTradeType = "Buy" + MyTradeTypeSell MyTradeType = "Sell" +) + +// Valid indicates whether the value is a known member of the MyTradeType enum. +func (e MyTradeType) Valid() bool { + switch e { + case MyTradeTypeBuy: + return true + case MyTradeTypeSell: + return true + default: + return false + } +} + +// Defines values for NewOrderRequestOptions. +const ( + FillOrKill NewOrderRequestOptions = "fill-or-kill" + ImmediateOrCancel NewOrderRequestOptions = "immediate-or-cancel" + MakerOrCancel NewOrderRequestOptions = "maker-or-cancel" +) + +// Valid indicates whether the value is a known member of the NewOrderRequestOptions enum. +func (e NewOrderRequestOptions) Valid() bool { + switch e { + case FillOrKill: + return true + case ImmediateOrCancel: + return true + case MakerOrCancel: + return true + default: + return false + } +} + +// Defines values for NewOrderRequestSide. +const ( + NewOrderRequestSideBuy NewOrderRequestSide = "buy" + NewOrderRequestSideSell NewOrderRequestSide = "sell" +) + +// Valid indicates whether the value is a known member of the NewOrderRequestSide enum. +func (e NewOrderRequestSide) Valid() bool { + switch e { + case NewOrderRequestSideBuy: + return true + case NewOrderRequestSideSell: + return true + default: + return false + } +} + +// Defines values for NewOrderRequestType. +const ( + NewOrderRequestTypeExchangeLimit NewOrderRequestType = "exchange limit" + NewOrderRequestTypeExchangeMarket NewOrderRequestType = "exchange market" + NewOrderRequestTypeExchangeStopLimit NewOrderRequestType = "exchange stop limit" +) + +// Valid indicates whether the value is a known member of the NewOrderRequestType enum. +func (e NewOrderRequestType) Valid() bool { + switch e { + case NewOrderRequestTypeExchangeLimit: + return true + case NewOrderRequestTypeExchangeMarket: + return true + case NewOrderRequestTypeExchangeStopLimit: + return true + default: + return false + } +} + +// Defines values for OrderSide. +const ( + OrderSideBuy OrderSide = "buy" + OrderSideSell OrderSide = "sell" +) + +// Valid indicates whether the value is a known member of the OrderSide enum. +func (e OrderSide) Valid() bool { + switch e { + case OrderSideBuy: + return true + case OrderSideSell: + return true + default: + return false + } +} + +// Defines values for OrderTradesType. +const ( + OrderTradesTypeBuy OrderTradesType = "Buy" + OrderTradesTypeSell OrderTradesType = "Sell" +) + +// Valid indicates whether the value is a known member of the OrderTradesType enum. +func (e OrderTradesType) Valid() bool { + switch e { + case OrderTradesTypeBuy: + return true + case OrderTradesTypeSell: + return true + default: + return false + } +} + +// Defines values for OrderType. +const ( + OrderTypeExchangeLimit OrderType = "exchange limit" + OrderTypeExchangeMarket OrderType = "exchange market" + OrderTypeExchangeStopLimit OrderType = "exchange stop limit" +) + +// Valid indicates whether the value is a known member of the OrderType enum. +func (e OrderType) Valid() bool { + switch e { + case OrderTypeExchangeLimit: + return true + case OrderTypeExchangeMarket: + return true + case OrderTypeExchangeStopLimit: + return true + default: + return false + } +} + +// Defines values for RiskStatsResponseProductType. +const ( + PerpetualSwapContract RiskStatsResponseProductType = "PerpetualSwapContract" +) + +// Valid indicates whether the value is a known member of the RiskStatsResponseProductType enum. +func (e RiskStatsResponseProductType) Valid() bool { + switch e { + case PerpetualSwapContract: + return true + default: + return false + } +} + +// Defines values for StakingTransactionTransactionType. +const ( + StakingTransactionTransactionTypeAdminCreditAdjustment StakingTransactionTransactionType = "AdminCreditAdjustment" + StakingTransactionTransactionTypeAdminDebitAdjustment StakingTransactionTransactionType = "AdminDebitAdjustment" + StakingTransactionTransactionTypeAdminRedeem StakingTransactionTransactionType = "AdminRedeem" + StakingTransactionTransactionTypeDeposit StakingTransactionTransactionType = "Deposit" + StakingTransactionTransactionTypeInterest StakingTransactionTransactionType = "Interest" + StakingTransactionTransactionTypeRedeem StakingTransactionTransactionType = "Redeem" + StakingTransactionTransactionTypeRedeemPayment StakingTransactionTransactionType = "RedeemPayment" +) + +// Valid indicates whether the value is a known member of the StakingTransactionTransactionType enum. +func (e StakingTransactionTransactionType) Valid() bool { + switch e { + case StakingTransactionTransactionTypeAdminCreditAdjustment: + return true + case StakingTransactionTransactionTypeAdminDebitAdjustment: + return true + case StakingTransactionTransactionTypeAdminRedeem: + return true + case StakingTransactionTransactionTypeDeposit: + return true + case StakingTransactionTransactionTypeInterest: + return true + case StakingTransactionTransactionTypeRedeem: + return true + case StakingTransactionTransactionTypeRedeemPayment: + return true + default: + return false + } +} + +// Defines values for StopLimitOrderResponseSide. +const ( + StopLimitOrderResponseSideBuy StopLimitOrderResponseSide = "buy" + StopLimitOrderResponseSideSell StopLimitOrderResponseSide = "sell" +) + +// Valid indicates whether the value is a known member of the StopLimitOrderResponseSide enum. +func (e StopLimitOrderResponseSide) Valid() bool { + switch e { + case StopLimitOrderResponseSideBuy: + return true + case StopLimitOrderResponseSideSell: + return true + default: + return false + } +} + +// Defines values for StopLimitOrderResponseType. +const ( + ExchangeStopLimit StopLimitOrderResponseType = "exchange stop limit" +) + +// Valid indicates whether the value is a known member of the StopLimitOrderResponseType enum. +func (e StopLimitOrderResponseType) Valid() bool { + switch e { + case ExchangeStopLimit: + return true + default: + return false + } +} + +// Defines values for TradeType. +const ( + TradeTypeBuy TradeType = "buy" + TradeTypeSell TradeType = "sell" +) + +// Valid indicates whether the value is a known member of the TradeType enum. +func (e TradeType) Valid() bool { + switch e { + case TradeTypeBuy: + return true + case TradeTypeSell: + return true + default: + return false + } +} + +// Defines values for TransferStatus. +const ( + TransferStatusComplete TransferStatus = "Complete" + TransferStatusPending TransferStatus = "Pending" +) + +// Valid indicates whether the value is a known member of the TransferStatus enum. +func (e TransferStatus) Valid() bool { + switch e { + case TransferStatusComplete: + return true + case TransferStatusPending: + return true + default: + return false + } +} + +// Defines values for TransferType. +const ( + TransferTypeDeposit TransferType = "Deposit" + TransferTypeWithdrawal TransferType = "Withdrawal" +) + +// Valid indicates whether the value is a known member of the TransferType enum. +func (e TransferType) Valid() bool { + switch e { + case TransferTypeDeposit: + return true + case TransferTypeWithdrawal: + return true + default: + return false + } +} + +// Defines values for V2TransferStatus. +const ( + V2TransferStatusAdvanced V2TransferStatus = "Advanced" + V2TransferStatusComplete V2TransferStatus = "Complete" + V2TransferStatusPending V2TransferStatus = "Pending" +) + +// Valid indicates whether the value is a known member of the V2TransferStatus enum. +func (e V2TransferStatus) Valid() bool { + switch e { + case V2TransferStatusAdvanced: + return true + case V2TransferStatusComplete: + return true + case V2TransferStatusPending: + return true + default: + return false + } +} + +// Defines values for V2TransferType. +const ( + AdminCredit V2TransferType = "AdminCredit" + AdminDebit V2TransferType = "AdminDebit" + Deposit V2TransferType = "Deposit" + Reward V2TransferType = "Reward" + Withdrawal V2TransferType = "Withdrawal" +) + +// Valid indicates whether the value is a known member of the V2TransferType enum. +func (e V2TransferType) Valid() bool { + switch e { + case AdminCredit: + return true + case AdminDebit: + return true + case Deposit: + return true + case Reward: + return true + case Withdrawal: + return true + default: + return false + } +} + +// Account defines model for Account. +type Account struct { + // AccountId The account ID + AccountId *string `json:"account_id,omitempty"` + + // Created The creation date + Created *string `json:"created,omitempty"` + + // IsDefault Whether the account is the default account + IsDefault *bool `json:"is_default,omitempty"` + + // Name The account name + Name *string `json:"name,omitempty"` +} + +// AddBankResponse defines model for AddBankResponse. +type AddBankResponse struct { + // ReferenceId Reference ID for the new bank addition request. Once received, send in a wire from the requested bank account to verify it and enable withdrawals to that account. + ReferenceId *string `json:"referenceId,omitempty"` + + // Result Status result (e.g. ok). + Result *string `json:"result,omitempty"` +} + +// Address defines model for Address. +type Address struct { + // Address String representation of the cryptocurrency address + Address *string `json:"address,omitempty"` + + // Label If you provided a label when creating the address, it will be echoed back here + Label *string `json:"label,omitempty"` + + // Memo It would be present if applicable, it will be present for cosmos address + Memo *string `json:"memo,omitempty"` + + // Network The blockchain network for the address + Network *string `json:"network,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` +} + +// ApprovedAddress defines model for ApprovedAddress. +type ApprovedAddress struct { + // Address The address on the approved address list. + Address *string `json:"address,omitempty"` + + // CreatedAt UTC timestamp in millisecond of when the address was created. + CreatedAt *string `json:"createdAt,omitempty"` + + // Label The label assigned to the address + Label *string `json:"label,omitempty"` + + // Network The network of the approved address. Network can be `bitcoin`, `ethereum`, `bitcoincash`, `litecoin`, `zcash`, `filecoin`, `dogecoin`, `tezos`, `solana`, `polkadot`, `avalanche`, `cosmos`, or `xrpl` + Network *string `json:"network,omitempty"` + + // Scope Will return the scope of the address as either "account" or "group" + Scope *string `json:"scope,omitempty"` + + // Status The status of the address that will return as "active", "pending-time" or "pending-mua". The remaining time is exactly 7 days after the initial request. "pending-mua" is for multi-user accounts and will require another administator or fund manager on the account to approve the address. + Status *string `json:"status,omitempty"` +} + +// ApprovedAddressMessage defines model for ApprovedAddressMessage. +type ApprovedAddressMessage struct { + // Message Status or confirmation message for the approved address request or removal. + Message *string `json:"message,omitempty"` + + // Result Result status (e.g. ok). + Result *string `json:"result,omitempty"` +} + +// ApprovedAddressesResponse Response envelope containing the approved withdrawal addresses. +type ApprovedAddressesResponse struct { + // ApprovedAddresses Array of approved addresses on both the account and group level. + ApprovedAddresses *[]ApprovedAddress `json:"approvedAddresses,omitempty"` +} + +// Balance defines model for Balance. +type Balance struct { + // UnderscoreTimestamp Server-side monotonically increasing clock value as an ISO 8601 timestamp. Clients can use this value to detect and filter out stale responses that may occur due to load balancing or potential stale servers. + UnderscoreTimestamp *time.Time `json:"_timestamp,omitempty"` + + // Amount The confirmed balance for the currency (also referred to as `confirmedBalance`). For crypto withdrawals, this value is **not** reduced until the withdrawal has been confirmed on the blockchain. This delay protects against blockchain reorganizations. Use the `available` field instead if you need balances that immediately reflect holds. + Amount *openapi_types.DecimalNumber `json:"amount,omitempty"` + + // Available The amount available for trading. This value is reduced **immediately** when an order hold or withdrawal hold is placed, making it the recommended field for tracking real-time spendable balances. + Available *openapi_types.DecimalNumber `json:"available,omitempty"` + + // AvailableForWithdrawal The amount available for withdrawal + AvailableForWithdrawal *openapi_types.DecimalNumber `json:"availableForWithdrawal,omitempty"` + + // Currency The currency symbol + Currency *string `json:"currency,omitempty"` + + // PendingDeposit The amount pending deposit + PendingDeposit *openapi_types.DecimalNumber `json:"pendingDeposit,omitempty"` + + // PendingWithdrawal The amount pending withdrawal + PendingWithdrawal *openapi_types.DecimalNumber `json:"pendingWithdrawal,omitempty"` + Type *BalanceType `json:"type,omitempty"` +} + +// BalanceType defines model for Balance.Type. +type BalanceType string + +// CancelAllOrdersBySessionRequest defines model for CancelAllOrdersBySessionRequest. +type CancelAllOrdersBySessionRequest struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Specifies the account on which you intend to cancel the orders. Only available for exchange accounts. + Account *string `json:"account,omitempty"` + + // Nonce timestamp + Nonce TimestampType `json:"nonce"` + + // Request The literal string "/v1/order/cancel/session" + Request string `json:"request"` +} + +// CancelAllOrdersRequest defines model for CancelAllOrdersRequest. +type CancelAllOrdersRequest struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Specifies the account on which you intend to cancel the orders. Only available for exchange accounts. + Account *string `json:"account,omitempty"` + + // Nonce timestamp + Nonce TimestampType `json:"nonce"` + + // Request The literal string "/v1/order/cancel/all" + Request string `json:"request"` +} + +// CancelAllResult defines model for CancelAllResult. +type CancelAllResult struct { + // Details cancelledOrders/cancelRejects with IDs of both + Details *struct { + CancelRejects *[]int `json:"cancelRejects,omitempty"` + CancelledOrders *[]int `json:"cancelledOrders,omitempty"` + } `json:"details,omitempty"` + Result *string `json:"result,omitempty"` +} + +// CancelOrderRequest defines model for CancelOrderRequest. +type CancelOrderRequest struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Specifies the account on which you intend to cancel the order. Only available for exchange accounts. + Account *string `json:"account,omitempty"` + + // Nonce timestamp + Nonce TimestampType `json:"nonce"` + + // OrderId The order ID given by `/order/new` + OrderId int64 `json:"order_id"` + + // Request The literal string "/v1/order/cancel" + Request string `json:"request"` +} + +// CancelOrderResponse defines model for CancelOrderResponse. +type CancelOrderResponse struct { + AvgExecutionPrice *string `json:"avg_execution_price,omitempty"` + Exchange *string `json:"exchange,omitempty"` + ExecutedAmount *string `json:"executed_amount,omitempty"` + Id *string `json:"id,omitempty"` + IsCancelled *bool `json:"is_cancelled,omitempty"` + IsHidden *bool `json:"is_hidden,omitempty"` + IsLive *bool `json:"is_live,omitempty"` + Options *[]string `json:"options,omitempty"` + OrderId *string `json:"order_id,omitempty"` + OriginalAmount *string `json:"original_amount,omitempty"` + Price *string `json:"price,omitempty"` + Reason *CancelOrderResponseReason `json:"reason,omitempty"` + RemainingAmount *string `json:"remaining_amount,omitempty"` + Side *CancelOrderResponseSide `json:"side,omitempty"` + Symbol *string `json:"symbol,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + Type *CancelOrderResponseType `json:"type,omitempty"` + WasForced *bool `json:"was_forced,omitempty"` +} + +// CancelOrderResponseReason defines model for CancelOrderResponse.Reason. +type CancelOrderResponseReason string + +// CancelOrderResponseSide defines model for CancelOrderResponse.Side. +type CancelOrderResponseSide string + +// CancelOrderResponseType defines model for CancelOrderResponse.Type. +type CancelOrderResponseType string + +// Candle defines model for Candle. +type Candle = []float64 + +// CandleResponse defines model for CandleResponse. +type CandleResponse = []Candle + +// ClearingOrder defines model for ClearingOrder. +type ClearingOrder struct { + // Amount The order amount + Amount *string `json:"amount,omitempty"` + + // ClearingId The clearing ID + ClearingId *string `json:"clearing_id,omitempty"` + + // IsConfirmed Whether the order is confirmed + IsConfirmed *bool `json:"is_confirmed,omitempty"` + + // Price The order price + Price *string `json:"price,omitempty"` + Side *ClearingOrderSide `json:"side,omitempty"` + + // Status The order status + Status *string `json:"status,omitempty"` + + // Symbol The trading pair + Symbol *string `json:"symbol,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + + // Timestampms The timestamp in milliseconds + Timestampms *int `json:"timestampms,omitempty"` +} + +// ClearingOrderSide defines model for ClearingOrder.Side. +type ClearingOrderSide string + +// CustodyFeeTransfer defines model for CustodyFeeTransfer. +type CustodyFeeTransfer struct { + // Eid Custody fee event id + Eid *int `json:"eid,omitempty"` + + // EventType Custody fee event type + EventType *string `json:"eventType,omitempty"` + + // FeeAmount The fee amount charged + FeeAmount *string `json:"feeAmount,omitempty"` + + // FeeCurrency Currency that the fee was paid in + FeeCurrency *string `json:"feeCurrency,omitempty"` + + // TxTime Time of Custody fee record in milliseconds + TxTime *int `json:"txTime,omitempty"` +} + +// ErrorResponse defines model for ErrorResponse. +type ErrorResponse struct { + // Message Detailed error message + Message *string `json:"message,omitempty"` + + // Reason A short description + Reason *string `json:"reason,omitempty"` + + // Result Error + Result *string `json:"result,omitempty"` +} + +// FeeEstimateRequest defines model for FeeEstimateRequest. +type FeeEstimateRequest struct { + // Account The name of the account within the subaccount group. + Account string `json:"account"` + + // Address Standard string format of cryptocurrency address + Address string `json:"address"` + + // Amount Quoted decimal amount to withdraw + Amount string `json:"amount"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce Nonce `json:"nonce"` + + // Request The string `/v1/withdraw/{currencyCodeLowerCase}/feeEstimate` where `:currencyCodeLowerCase` is replaced with the currency code of a supported crypto-currency, e.g. `eth`, `aave`, etc. See [Symbols and minimums](/market-data/symbols-and-minimums) + Request string `json:"request"` +} + +// FeeEstimateResponse defines model for FeeEstimateResponse. +type FeeEstimateResponse struct { + // Currency Currency code, see [symbols](/market-data/symbols-and-minimums). + Currency *string `json:"currency,omitempty"` + + // Fee The estimated gas fee + Fee *string `json:"fee,omitempty"` + + // IsOverride Value that shows if an override on the customer's account for free withdrawals exists + IsOverride *bool `json:"isOverride,omitempty"` + + // MonthlyLimit Total nunber of allowable fee-free withdrawals + MonthlyLimit *int `json:"monthlyLimit,omitempty"` + + // MonthlyRemaining Total number of allowable fee-free withdrawals left to use + MonthlyRemaining *int `json:"monthlyRemaining,omitempty"` +} + +// FeeEstimateV2Request defines model for FeeEstimateV2Request. +type FeeEstimateV2Request struct { + // Account Required for Master API keys. The name of the account within the subaccount group. + Account *string `json:"account,omitempty"` + + // Address Standard string format of the destination cryptocurrency address + Address string `json:"address"` + + // Amount Quoted decimal amount to withdraw + Amount string `json:"amount"` + + // Memo It would be present if applicable, it will be present for cosmos address. + Memo *string `json:"memo,omitempty"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce Nonce `json:"nonce"` + + // Request The string `/v2/withdraw/{network}/{ticker}/feeEstimate` where `{network}` is the blockchain network (e.g. `ethereum`, `bitcoin`, `solana`) and `{ticker}` is the currency code (e.g. `eth`, `btc`, `sol`). See [Symbols and minimums](/market-data/symbols-and-minimums) + Request string `json:"request"` +} + +// FeeEstimateV2Response defines model for FeeEstimateV2Response. +type FeeEstimateV2Response struct { + // Currency Currency code, see [symbols](/market-data/symbols-and-minimums). + Currency *string `json:"currency,omitempty"` + + // Fee The estimated withdrawal fee as a decimal amount + Fee *openapi_types.DecimalNumber `json:"fee,omitempty"` + + // IsOverride Whether an override on the customer's account for free withdrawals exists + IsOverride *bool `json:"isOverride,omitempty"` + + // MonthlyLimit Total number of allowable fee-free withdrawals + MonthlyLimit *int `json:"monthlyLimit,omitempty"` + + // MonthlyRemaining Total number of allowable fee-free withdrawals remaining + MonthlyRemaining *int `json:"monthlyRemaining,omitempty"` +} + +// FeePromos defines model for FeePromos. +type FeePromos struct { + // Symbols Symbols that currently have fee promos + Symbols *[]string `json:"symbols,omitempty"` +} + +// FundingAmountResponse defines model for FundingAmountResponse. +type FundingAmountResponse struct { + // Amount The dollar amount for a Long 1 position held in the symbol for funding period (1 hour) + Amount *openapi_types.DecimalNumber `json:"amount,omitempty"` + + // EstimatedFundingAmount The estimated dollar amount for a Long 1 position held in the symbol for next funding period (1 hour) + EstimatedFundingAmount *openapi_types.DecimalNumber `json:"estimatedFundingAmount,omitempty"` + + // FundingDateTime UTC date time in format `yyyy-MM-ddThh:mm:ss.SSSZ` format + FundingDateTime *string `json:"fundingDateTime,omitempty"` + + // FundingTimestampMilliSecs Current funding amount Epoc time. + FundingTimestampMilliSecs *int64 `json:"fundingTimestampMilliSecs,omitempty"` + + // NextFundingTimestamp Next funding amount Epoc time. + NextFundingTimestamp *int64 `json:"nextFundingTimestamp,omitempty"` + + // Symbol The requested symbol. See [**symbols and minimums**](/market-data/symbols-and-minimums#all-supported-symbols) + Symbol *string `json:"symbol,omitempty"` +} + +// FundingPayment defines model for FundingPayment. +type FundingPayment struct { + // EventType Event type + EventType FundingPaymentEventType `json:"eventType"` + HourlyFundingTransfer FundingTransfer `json:"hourlyFundingTransfer"` +} + +// FundingPaymentEventType Event type +type FundingPaymentEventType string + +// FundingPaymentReportItem defines model for FundingPaymentReportItem. +type FundingPaymentReportItem struct { + // Action Credit or Debit + Action FundingPaymentReportItemAction `json:"action"` + + // AssetCode Asset symbol + AssetCode string `json:"assetCode"` + + // EventType Event type + EventType FundingPaymentReportItemEventType `json:"eventType"` + + // InstrumentSymbol Symbol of the underlying instrument. **Note** that this is only attached to requests from 16th April 2024 onwards. + InstrumentSymbol *string `json:"instrumentSymbol,omitempty"` + + // Quantity A nested JSON object describing the transaction amount + Quantity Quantity `json:"quantity"` + + // Timestamp Time of the funding payment + Timestamp TimestampType `json:"timestamp"` +} + +// FundingPaymentReportItemAction Credit or Debit +type FundingPaymentReportItemAction string + +// FundingPaymentReportItemEventType Event type +type FundingPaymentReportItemEventType string + +// FundingTransfer defines model for FundingTransfer. +type FundingTransfer struct { + // Action Credit or Debit + Action FundingTransferAction `json:"action"` + + // AssetCode Asset symbol + AssetCode string `json:"assetCode"` + + // EventType Event type + EventType string `json:"eventType"` + + // InstrumentSymbol Symbol of the underlying instrument. **Note** that this is only attached to requests from 16th April 2024 onwards. + InstrumentSymbol *string `json:"instrumentSymbol,omitempty"` + + // Quantity A nested JSON object describing the transaction amount + Quantity Quantity `json:"quantity"` + + // Timestamp Time of the funding payment + Timestamp TimestampType `json:"timestamp"` +} + +// FundingTransferAction Credit or Debit +type FundingTransferAction string + +// FxRate defines model for FxRate. +type FxRate struct { + // AsOf timestamp + AsOf *TimestampType `json:"asOf,omitempty"` + + // Benchmark The market for which the retrieved price applies to + Benchmark *string `json:"benchmark,omitempty"` + + // FxPair The requested currency pair + FxPair *string `json:"fxPair,omitempty"` + + // Provider The market data provider + Provider *string `json:"provider,omitempty"` + + // Rate The exchange rate + Rate *float64 `json:"rate,omitempty"` +} + +// Heartbeat defines model for Heartbeat. +type Heartbeat struct { + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce *Heartbeat_Nonce `json:"nonce,omitempty"` + + // Request The literal string `/v1/heartbeat` + Request *string `json:"request,omitempty"` +} + +// HeartbeatNonce0 Gemini strongly recommends using milliseconds instead of seconds for timestamps. +// +// | Timestamp format | Example | Supported request type | +// |-----------------------|-----------------------|------------------------| +// | string (seconds) | `'1495127793'` | `POST` only | +// | string (milliseconds) | `'1495127793000'` | `POST` only | +type HeartbeatNonce0 = string + +// HeartbeatNonce1 Gemini strongly recommends using milliseconds instead of seconds for timestamps. +// +// | Timestamp format | Example | Supported request type | +// |-----------------------------|---------------------------|------------------------| +// | whole number (seconds) | `1495127793` | `GET`, `POST` | +// | whole number (milliseconds) | `1495127793000` | `GET`, `POST` | +type HeartbeatNonce1 = int64 + +// Heartbeat_Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) +type Heartbeat_Nonce struct { + union json.RawMessage +} + +// InstantQuote defines model for InstantQuote. +type InstantQuote struct { + // DepositFee The deposit fee quantity. Will be applied if a debit card is used for the order. Will return 0 if there is no `depositFee` + DepositFee *string `json:"depositFee,omitempty"` + + // DepositFeeCurrency Currency in which `depositFee` is taken + DepositFeeCurrency *string `json:"depositFeeCurrency,omitempty"` + + // Fee The fee quantity to be taken for the order upon execution + Fee *string `json:"fee,omitempty"` + + // FeeCurrency The currency label for the order + FeeCurrency *string `json:"feeCurrency,omitempty"` + + // MaxAgeMs Number of milliseconds until this quote price expires. Once expired, you will need to request a new quote + MaxAgeMs *int `json:"maxAgeMs,omitempty"` + + // Pair The symbol passed in the quote request + Pair *string `json:"pair,omitempty"` + + // Price The quoted price of the asset. This will not change when attempting execution + Price *string `json:"price,omitempty"` + + // PriceCurrency The currency in which the order is priced. Matches `CCY2` in the symbol + PriceCurrency *string `json:"priceCurrency,omitempty"` + + // Quantity The quantity of the asset to be bought or sold + Quantity *string `json:"quantity,omitempty"` + + // QuantityCurrency The currency label for the `quantity` field. Matches `CCY1` in the symbol + QuantityCurrency *string `json:"quantityCurrency,omitempty"` + + // QuoteId Unique ID for the quote. This is used in the execution of the order + QuoteId *int `json:"quoteId,omitempty"` + + // Side Either "buy" or "sell" + Side *InstantQuoteSide `json:"side,omitempty"` + + // TotalSpend Total quantity to spend for the order. Will be the sum inclusive of all fees and amount to be traded. + TotalSpend *string `json:"totalSpend,omitempty"` + + // TotalSpendCurrency Currency of the `totalSpend` to be spent on the order + TotalSpendCurrency *string `json:"totalSpendCurrency,omitempty"` +} + +// InstantQuoteSide Either "buy" or "sell" +type InstantQuoteSide string + +// InterestRateInfo defines model for InterestRateInfo. +type InterestRateInfo struct { + // Interval The time interval for the rate (currently only "hour" is supported) + Interval InterestRateInfoInterval `json:"interval"` + + // Rate The interest rate as a decimal string + Rate string `json:"rate"` +} + +// InterestRateInfoInterval The time interval for the rate (currently only "hour" is supported) +type InterestRateInfoInterval string + +// LimitOrderResponse defines model for LimitOrderResponse. +type LimitOrderResponse struct { + AvgExecutionPrice *string `json:"avg_execution_price,omitempty"` + ClientOrderId *string `json:"client_order_id,omitempty"` + Exchange *string `json:"exchange,omitempty"` + ExecutedAmount *string `json:"executed_amount,omitempty"` + Id *string `json:"id,omitempty"` + IsCancelled *bool `json:"is_cancelled,omitempty"` + IsHidden *bool `json:"is_hidden,omitempty"` + IsLive *bool `json:"is_live,omitempty"` + Options *[]string `json:"options,omitempty"` + OrderId *string `json:"order_id,omitempty"` + OriginalAmount *string `json:"original_amount,omitempty"` + Price *string `json:"price,omitempty"` + RemainingAmount *string `json:"remaining_amount,omitempty"` + Side *LimitOrderResponseSide `json:"side,omitempty"` + Symbol *string `json:"symbol,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + Type *LimitOrderResponseType `json:"type,omitempty"` + WasForced *bool `json:"was_forced,omitempty"` +} + +// LimitOrderResponseSide defines model for LimitOrderResponse.Side. +type LimitOrderResponseSide string + +// LimitOrderResponseType defines model for LimitOrderResponse.Type. +type LimitOrderResponseType string + +// LiquidationRisk defines model for LiquidationRisk. +type LiquidationRisk struct { + // LiquidationPrice The estimated price at which liquidation would occur (optional, may not be present for all positions) + LiquidationPrice *MoneyAmount `json:"liquidationPrice,omitempty"` + + // LossPercentage The percentage loss from current value that would trigger liquidation, formatted as decimal (e.g., "0.1550" = 15.50%) + LossPercentage string `json:"lossPercentage"` +} + +// MarginAccountSummary defines model for MarginAccountSummary. +type MarginAccountSummary struct { + // AvailableCollateral The amount of collateral available for new positions or withdrawals + AvailableCollateral MoneyAmount `json:"availableCollateral"` + + // BuyingPower The maximum value that can be purchased with available collateral + BuyingPower MoneyAmount `json:"buyingPower"` + + // InterestRate Current interest rate on borrowed amounts (only present if borrows exist) + InterestRate *InterestRateInfo `json:"interestRate,omitempty"` + + // Leverage The current leverage ratio (notionalValue / marginAssetValue) + Leverage string `json:"leverage"` + + // LiquidationRisk Liquidation risk information (only present if positions exist) + LiquidationRisk *LiquidationRisk `json:"liquidationRisk,omitempty"` + + // MarginAssetValue The total value of all assets available in the margin account that can contribute to funding positions + MarginAssetValue MoneyAmount `json:"marginAssetValue"` + + // NotionalValue The total value of all open positions + NotionalValue MoneyAmount `json:"notionalValue"` + + // ReservedBuyOrders Collateral reserved for open buy orders + ReservedBuyOrders MoneyAmount `json:"reservedBuyOrders"` + + // ReservedSellOrders Collateral reserved for open sell orders + ReservedSellOrders MoneyAmount `json:"reservedSellOrders"` + + // SellingPower The maximum value that can be sold with available collateral + SellingPower MoneyAmount `json:"sellingPower"` + + // TotalBorrowed The total amount currently borrowed across all currencies + TotalBorrowed MoneyAmount `json:"totalBorrowed"` +} + +// MarginInterestRate defines model for MarginInterestRate. +type MarginInterestRate struct { + // BorrowRate The hourly borrow rate as a decimal + BorrowRate string `json:"borrowRate"` + + // BorrowRateAnnual The annualized borrow rate (daily rate × 365) + BorrowRateAnnual string `json:"borrowRateAnnual"` + + // BorrowRateDaily The daily borrow rate (hourly rate × 24) + BorrowRateDaily string `json:"borrowRateDaily"` + + // Currency The currency code (e.g., "BTC", "ETH", "USD") + Currency string `json:"currency"` + + // LastUpdated Unix timestamp in milliseconds when the rate was last updated + LastUpdated int64 `json:"lastUpdated"` +} + +// MarginOrderPreview defines model for MarginOrderPreview. +type MarginOrderPreview struct { + // Postorder Margin risk statistics after the order would be executed + Postorder MarginRiskStats `json:"postorder"` + + // Preorder Margin risk statistics before the order would be executed + Preorder MarginRiskStats `json:"preorder"` +} + +// MarginRatesResponse defines model for MarginRatesResponse. +type MarginRatesResponse struct { + // Rates Array of interest rates for all borrowable currencies + Rates []MarginInterestRate `json:"rates"` +} + +// MarginResponse defines model for MarginResponse. +type MarginResponse struct { + // AvailableMargin The difference between the `margin_assets_value` and `initial_margin`. + AvailableMargin *string `json:"available_margin,omitempty"` + + // BuyingPower The amount of that product the account could purchase based on current `initial_margin` and `margin_assets_value`. + BuyingPower *string `json:"buying_power,omitempty"` + + // EstimatedLiquidationPrice The estimated price for the asset at which liquidation would occur. + EstimatedLiquidationPrice *string `json:"estimated_liquidation_price,omitempty"` + + // InitialMargin The $ amount that is being required by the accounts current positions and open orders. + InitialMargin *string `json:"initial_margin,omitempty"` + + // InitialMarginPositions The contribution to `initial_margin` from open positions. + InitialMarginPositions *string `json:"initial_margin_positions,omitempty"` + + // Leverage The ratio of Notional Value to Margin Assets Value. + Leverage *string `json:"leverage,omitempty"` + + // MarginAssetsValue The $ equivalent value of all the assets available in the current trading account that can contribute to funding a derivatives position. + MarginAssetsValue *string `json:"margin_assets_value,omitempty"` + + // MarginMaintenanceLimit The minimum amount of `margin_assets_value` required before the account is moved to liquidation status. + MarginMaintenanceLimit *string `json:"margin_maintenance_limit,omitempty"` + + // NotionalValue The $ value of the current position. + NotionalValue *string `json:"notional_value,omitempty"` + + // ReservedMargin The contribution to `initial_margin` from open orders. + ReservedMargin *string `json:"reserved_margin,omitempty"` + + // ReservedMarginBuys The contribution to `initial_margin` from open BUY orders. + ReservedMarginBuys *string `json:"reserved_margin_buys,omitempty"` + + // ReservedMarginSells The contribution to `initial_margin` from open SELL orders. + ReservedMarginSells *string `json:"reserved_margin_sells,omitempty"` + + // SellingPower The amount of that product the account could sell based on current `initial_margin` and `margin_assets_value`. + SellingPower *string `json:"selling_power,omitempty"` +} + +// MarginRiskStats defines model for MarginRiskStats. +type MarginRiskStats struct { + // AvailableCollateral The amount of collateral available for new positions + AvailableCollateral MoneyAmount `json:"availableCollateral"` + + // BuyingPower The maximum value that can be purchased + BuyingPower MoneyAmount `json:"buyingPower"` + + // Leverage The leverage ratio + Leverage string `json:"leverage"` + + // LiquidationRisk Liquidation risk information (only present if applicable) + LiquidationRisk *LiquidationRisk `json:"liquidationRisk,omitempty"` + + // MarginAssetValue The total value of all assets available in the margin account + MarginAssetValue MoneyAmount `json:"marginAssetValue"` + + // NotionalValue The total value of all open positions + NotionalValue MoneyAmount `json:"notionalValue"` + + // ReservedBuyOrders Collateral reserved for open buy orders + ReservedBuyOrders MoneyAmount `json:"reservedBuyOrders"` + + // ReservedSellOrders Collateral reserved for open sell orders + ReservedSellOrders MoneyAmount `json:"reservedSellOrders"` + + // SellingPower The maximum value that can be sold + SellingPower MoneyAmount `json:"sellingPower"` + + // TotalBorrowed The total amount currently borrowed + TotalBorrowed MoneyAmount `json:"totalBorrowed"` +} + +// MoneyAmount defines model for MoneyAmount. +type MoneyAmount struct { + // Currency The currency code (e.g., "USD", "BTC", "ETH") + Currency string `json:"currency"` + + // Value The amount in the specified currency + Value string `json:"value"` +} + +// MyTrade defines model for MyTrade. +type MyTrade struct { + Aggressor *bool `json:"aggressor,omitempty"` + Amount *string `json:"amount,omitempty"` + Break *MyTradeBreak `json:"break,omitempty"` + ClientOrderId *string `json:"client_order_id,omitempty"` + Exchange *string `json:"exchange,omitempty"` + FeeAmount *string `json:"fee_amount,omitempty"` + FeeCurrency *string `json:"fee_currency,omitempty"` + IsAuctionFill *bool `json:"is_auction_fill,omitempty"` + OrderId *string `json:"order_id,omitempty"` + Price *string `json:"price,omitempty"` + Tid *int64 `json:"tid,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + Type *MyTradeType `json:"type,omitempty"` +} + +// MyTradeBreak defines model for MyTrade.Break. +type MyTradeBreak string + +// MyTradeType defines model for MyTrade.Type. +type MyTradeType string + +// MyTradesRequest defines model for MyTradesRequest. +type MyTradesRequest struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Specifies the account on which you intend to place the order. Only available for exchange accounts. + Account *string `json:"account,omitempty"` + + // LimitTrades The maximum number of trades to return. Default is 50, max is 500. + LimitTrades *int `json:"limit_trades,omitempty"` + + // Nonce timestamp + Nonce TimestampType `json:"nonce"` + + // Request The API endpoint path + Request string `json:"request"` + + // Symbol The [symbol](/market-data/symbols-and-minimums) to retrieve trades for + Symbol *string `json:"symbol,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` +} + +// NetworkAssets defines model for NetworkAssets. +type NetworkAssets struct { + // Assets Alphabetically sorted array of enabled asset/token codes available on this network. Assets include both exchange-tradable and custody-supported tokens. + Assets *[]string `json:"assets,omitempty"` + + // Network The blockchain network identifier. + Network *string `json:"network,omitempty"` +} + +// NetworkToken defines model for NetworkToken. +type NetworkToken struct { + // Network Array of supported blockchain networks for the token. Many tokens (especially stablecoins like USDC, USDT) are available on multiple networks. + // + // Supported networks include: `bitcoin`, `ethereum`, `solana`, `optimism`, `arbitrum`, `base`, `monad`, `avalanche`, `litecoin`, `bitcoincash`, `dogecoin`, `zcash`, `filecoin`, `tezos`, `polkadot`, `cosmos`, `xrpl`, `linea`, and more. + Network *[]string `json:"network,omitempty"` + + // Token The requested token identifier. + Token *string `json:"token,omitempty"` +} + +// NewOrderRequest defines model for NewOrderRequest. +type NewOrderRequest struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Specifies the account on which you intend to place the order. Only available for exchange accounts. + Account *string `json:"account,omitempty"` + + // Amount Quoted decimal amount to purchase + Amount string `json:"amount"` + + // ClientOrderId *Recommended*. A [client-specified order id](/client-order-id) + ClientOrderId *string `json:"client_order_id,omitempty"` + + // MarginOrder Set to `true` to place this order on a margin account using borrowed funds. Defaults to `false`. Only available for margin-enabled accounts. See [Margin Trading](/margin/account-summary) for details. + MarginOrder *bool `json:"margin_order,omitempty"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce int64 `json:"nonce"` + + // Options An optional array containing at most one supported order execution option. See Order execution options for details. + Options *[]NewOrderRequestOptions `json:"options,omitempty"` + + // Price Quoted decimal amount to spend per unit + Price string `json:"price"` + + // Request The literal string "/v1/order/new" + Request string `json:"request"` + Side NewOrderRequestSide `json:"side"` + + // StopPrice The price to trigger a stop-limit order. Only available for stop-limit orders. + StopPrice *string `json:"stop_price,omitempty"` + + // Symbol The [symbol](/market-data/symbols-and-minimums) for the new order + Symbol string `json:"symbol"` + + // Type The order type. "exchange limit" for all order types except for stop-limit orders. "exchange stop limit" for stop-limit orders. + Type NewOrderRequestType `json:"type"` +} + +// NewOrderRequestOptions defines model for NewOrderRequest.Options. +type NewOrderRequestOptions string + +// NewOrderRequestSide defines model for NewOrderRequest.Side. +type NewOrderRequestSide string + +// NewOrderRequestType The order type. "exchange limit" for all order types except for stop-limit orders. "exchange stop limit" for stop-limit orders. +type NewOrderRequestType string + +// Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) +type Nonce struct { + union json.RawMessage +} + +// Nonce1 defines model for . +type Nonce1 = int + +// NotionalBalance defines model for NotionalBalance. +type NotionalBalance struct { + // Amount The current balance + Amount *string `json:"amount,omitempty"` + + // AmountNotional Amount, in notional + AmountNotional *string `json:"amountNotional,omitempty"` + + // Available The amount that is available to trade + Available *string `json:"available,omitempty"` + + // AvailableForWithdrawal The amount that is available to withdraw + AvailableForWithdrawal *string `json:"availableForWithdrawal,omitempty"` + + // AvailableForWithdrawalNotional AvailableForWithdrawal, in notional + AvailableForWithdrawalNotional *string `json:"availableForWithdrawalNotional,omitempty"` + + // AvailableNotional Available, in notional + AvailableNotional *string `json:"availableNotional,omitempty"` + + // Currency Currency code, see symbols and minimums + Currency *string `json:"currency,omitempty"` +} + +// NotionalVolume defines model for NotionalVolume. +type NotionalVolume struct { + ApiAuctionFeeBps *int `json:"api_auction_fee_bps,omitempty"` + ApiMakerFeeBps *int `json:"api_maker_fee_bps,omitempty"` + ApiNotional30dVolume *string `json:"api_notional_30d_volume,omitempty"` + ApiTakerFeeBps *int `json:"api_taker_fee_bps,omitempty"` + Date *openapi_types.Date `json:"date,omitempty"` + FeeTier *struct { + ApiMakerFeeBps *int `json:"api_maker_fee_bps,omitempty"` + ApiTakerFeeBps *int `json:"api_taker_fee_bps,omitempty"` + Tier *string `json:"tier,omitempty"` + } `json:"fee_tier,omitempty"` + FixAuctionFeeBps *int `json:"fix_auction_fee_bps,omitempty"` + FixMakerFeeBps *int `json:"fix_maker_fee_bps,omitempty"` + FixTakerFeeBps *int `json:"fix_taker_fee_bps,omitempty"` + LastUpdatedMs *int `json:"last_updated_ms,omitempty"` + Notional1dVolume *[]struct { + // Date UTC date in `yyyy-MM-dd` format + Date *string `json:"date,omitempty"` + + // NotionalVolume Notional volume value in USD for this single day + NotionalVolume *string `json:"notional_volume,omitempty"` + } `json:"notional_1d_volume,omitempty"` + Notional30dVolume *string `json:"notional_30d_volume,omitempty"` + WebAuctionFeeBps *int `json:"web_auction_fee_bps,omitempty"` + WebMakerFeeBps *int `json:"web_maker_fee_bps,omitempty"` + WebTakerFeeBps *int `json:"web_taker_fee_bps,omitempty"` +} + +// OpenPosition defines model for OpenPosition. +type OpenPosition struct { + // AverageCost The average price of the current position. + AverageCost *string `json:"average_cost,omitempty"` + + // InstrumentType The type of instrument. Either "spot" or "perp". + InstrumentType *string `json:"instrument_type,omitempty"` + + // MarkPrice The current Mark Price for the Asset or the position. + MarkPrice *string `json:"mark_price,omitempty"` + + // NotionalValue The value of position; calculated as (`quantity` * `mark_price`). Value will be negative for shorts. + NotionalValue *string `json:"notional_value,omitempty"` + + // Quantity The position size. Value will be negative for shorts. + Quantity *string `json:"quantity,omitempty"` + + // RealisedPnl The current P&L that has been realised from the position. + RealisedPnl *string `json:"realised_pnl,omitempty"` + + // Symbol The [symbol](/market-data/symbols-and-minimums) of the order. + Symbol *string `json:"symbol,omitempty"` + + // UnrealisedPnl Current Mark to Market value of the positions. + UnrealisedPnl *string `json:"unrealised_pnl,omitempty"` +} + +// Order defines model for Order. +type Order struct { + // AvgExecutionPrice The average price at which this order as been executed so far. 0 if the order has not been executed at all. + AvgExecutionPrice *string `json:"avg_execution_price,omitempty"` + + // ClientOrderId An optional [client-specified order id](/client-order-id#client-order-id) + ClientOrderId *string `json:"client_order_id,omitempty"` + + // Exchange Will always be "gemini" + Exchange *string `json:"exchange,omitempty"` + + // ExecutedAmount The amount of the order that has been filled. + ExecutedAmount *string `json:"executed_amount,omitempty"` + + // IsCancelled `true` if the order has been canceled. Note the spelling, "cancelled" instead of "canceled". This is for compatibility reasons. + IsCancelled *bool `json:"is_cancelled,omitempty"` + + // IsHidden Will always return `false`. + IsHidden *bool `json:"is_hidden,omitempty"` + + // IsLive `true` if the order is active on the book (has remaining quantity and has not been canceled) + IsLive *bool `json:"is_live,omitempty"` + + // Options An array containing at most one supported order execution option. See [Order execution options](/rest/orders#create-new-order) for details. + Options *[]string `json:"options,omitempty"` + + // OrderId The order id + OrderId *string `json:"order_id,omitempty"` + + // OriginalAmount The originally submitted amount of the order. + OriginalAmount *string `json:"original_amount,omitempty"` + + // Price The price the order was issued at + Price *string `json:"price,omitempty"` + + // Reason Populated with the reason your order was canceled, if available. + Reason *string `json:"reason,omitempty"` + + // RemainingAmount The amount of the order that has not been filled. + RemainingAmount *string `json:"remaining_amount,omitempty"` + Side *OrderSide `json:"side,omitempty"` + + // Symbol The [symbol](/market-data/symbols-and-minimums#symbols-and-minimums) of the order + Symbol *string `json:"symbol,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + + // Trades Contains an array of JSON objects with trade details. + Trades *[]struct { + // Aggressor If `true`, this order was the taker in the trade + Aggressor *bool `json:"aggressor,omitempty"` + + // Amount The quantity that was executed + Amount *string `json:"amount,omitempty"` + + // Break Will only be present if the trade is broken. See `Break Types` below for more information. + Break *string `json:"break,omitempty"` + + // Exchange Will always be "gemini" + Exchange *string `json:"exchange,omitempty"` + + // FeeAmount The amount charged + FeeAmount *string `json:"fee_amount,omitempty"` + + // FeeCurrency Currency that the fee was paid in + FeeCurrency *string `json:"fee_currency,omitempty"` + + // OrderId The order that this trade executed against + OrderId *string `json:"order_id,omitempty"` + + // Price The price that the execution happened at + Price *string `json:"price,omitempty"` + + // Tid Unique identifier for the trade + Tid *int `json:"tid,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + + // Type Will be either "Buy" or "Sell", indicating the side of the original order + Type *OrderTradesType `json:"type,omitempty"` + } `json:"trades,omitempty"` + + // Type Description of the order + Type *OrderType `json:"type,omitempty"` + + // WasForced Will always be `false`. + WasForced *bool `json:"was_forced,omitempty"` +} + +// OrderSide defines model for Order.Side. +type OrderSide string + +// OrderTradesType Will be either "Buy" or "Sell", indicating the side of the original order +type OrderTradesType string + +// OrderType Description of the order +type OrderType string + +// OrderBook defines model for OrderBook. +type OrderBook struct { + // Asks The ask price levels currently on the book. These are offers to sell at a given price. + Asks *[]OrderBookEntry `json:"asks,omitempty"` + + // Bids The bid price levels currently on the book. These are offers to buy at a given price. + Bids *[]OrderBookEntry `json:"bids,omitempty"` +} + +// OrderBookEntry defines model for OrderBookEntry. +type OrderBookEntry struct { + // Amount The total quantity remaining at the price + Amount *string `json:"amount,omitempty"` + + // Price The price + Price *string `json:"price,omitempty"` + + // Timestamp **DO NOT USE** - this field is included for compatibility reasons only and is just populated with a dummy value. + Timestamp *string `json:"timestamp,omitempty"` +} + +// OrderStatusRequest defines model for OrderStatusRequest. +type OrderStatusRequest struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Specifies the account on which you intend to place the order. Only available for exchange accounts. + Account *string `json:"account,omitempty"` + + // ClientOrderId The `client_order_id` used when placing the order. `client_order_id` cannot be used in combination with `order_id` + ClientOrderId *string `json:"client_order_id,omitempty"` + + // IncludeTrades Either `True` or `False`. If `True` the endpoint will return individual trade details of all fills from the order. + IncludeTrades *bool `json:"include_trades,omitempty"` + + // Nonce timestamp + Nonce TimestampType `json:"nonce"` + + // OrderId The order id to get information on. The `order_id` represents a whole number and is transmitted as an unsigned 64-bit integer in JSON format. `order_id` cannot be used in combination with `client_order_id`. + OrderId int64 `json:"order_id"` + + // Request The API endpoint path + Request string `json:"request"` +} + +// PaymentMethodBalance defines model for PaymentMethodBalance. +type PaymentMethodBalance struct { + // Amount Total account balance for currency. + Amount *string `json:"amount,omitempty"` + + // Available Total amount available for trading + Available *string `json:"available,omitempty"` + + // AvailableForWithdrawal Total amount available for withdrawal + AvailableForWithdrawal *string `json:"availableForWithdrawal,omitempty"` + + // Currency Symbol for fiat balance. + Currency *string `json:"currency,omitempty"` + + // Type Account type. Will always be `exchange` + Type *string `json:"type,omitempty"` +} + +// PaymentMethodBank defines model for PaymentMethodBank. +type PaymentMethodBank struct { + // Bank Name of bank account + Bank *string `json:"bank,omitempty"` + + // BankId Unique identifier for bank account + BankId *string `json:"bankId,omitempty"` +} + +// PaymentMethodsResponse defines model for PaymentMethodsResponse. +type PaymentMethodsResponse struct { + // Balances Array of JSON objects with available fiat currencies and their balances. + Balances *[]PaymentMethodBalance `json:"balances,omitempty"` + + // Banks Array of JSON objects with banking information + Banks *[]PaymentMethodBank `json:"banks,omitempty"` +} + +// PriceFeedResponse defines model for PriceFeedResponse. +type PriceFeedResponse = []struct { + // Pair Trading pair symbol. See [**symbols and minimums**](/market-data/symbols-and-minimums#all-supported-symbols) + Pair *string `json:"pair,omitempty"` + + // PercentChange24h 24 hour change in price of the pair on the Gemini order book + PercentChange24h *string `json:"percentChange24h,omitempty"` + + // Price Current price of the pair on the Gemini order book + Price *string `json:"price,omitempty"` +} + +// Quantity defines model for Quantity. +type Quantity struct { + // Currency The currency code of the quantity. + Currency string `json:"currency"` + + // Value The value of the quantity. + Value string `json:"value"` +} + +// RevokeOauthTokenResponse defines model for RevokeOauthTokenResponse. +type RevokeOauthTokenResponse struct { + // Message A message that indicates the token has been revoked for the account + Message *string `json:"message,omitempty"` +} + +// RiskStatsResponse defines model for RiskStatsResponse. +type RiskStatsResponse struct { + // IndexPrice Current index price at the time of request + IndexPrice *string `json:"index_price,omitempty"` + + // MarkPrice Current mark price at the time of request + MarkPrice *string `json:"mark_price,omitempty"` + + // OpenInterest string representation of decimal value of open interest + OpenInterest *string `json:"open_interest,omitempty"` + + // OpenInterestNotional string representation of decimal value of open interest notional + OpenInterestNotional *string `json:"open_interest_notional,omitempty"` + + // ProductType Contract type for which the symbol data is fetched + ProductType *RiskStatsResponseProductType `json:"product_type,omitempty"` +} + +// RiskStatsResponseProductType Contract type for which the symbol data is fetched +type RiskStatsResponseProductType string + +// RoleResponse defines model for RoleResponse. +type RoleResponse struct { + // CounterpartyId _Only returned for master-level API keys_. The Gemini clearing counterparty ID associated with the API key making the request. + CounterpartyId *string `json:"counterparty_id,omitempty"` + + // IsAccountAdmin _Only returned for master-level API keys_.`True` if the Administrator role is assigned to the API keys. `False` otherwise. + IsAccountAdmin *bool `json:"isAccountAdmin,omitempty"` + + // IsAuditor `True` if the Auditor role is assigned to the API keys. `False` otherwise. + IsAuditor bool `json:"isAuditor"` + + // IsFundManager `True` if the Fund Manager role is assigned to the API keys. `False` otherwise. + IsFundManager bool `json:"isFundManager"` + + // IsTrader `True` if the Trader role is assigned to the API keys. `False` otherwise. + IsTrader bool `json:"isTrader"` +} + +// StakingBalance defines model for StakingBalance. +type StakingBalance struct { + // Available The amount that is available to trade + Available *openapi_types.DecimalNumber `json:"available,omitempty"` + + // AvailableForWithdrawal The Staking amount that is available to redeem to exchange account + AvailableForWithdrawal *openapi_types.DecimalNumber `json:"availableForWithdrawal,omitempty"` + + // Balance The current Staking balance + Balance *openapi_types.DecimalNumber `json:"balance,omitempty"` + BalanceByProvider *map[string]struct { + // Balance The current Staking balance per providerId + Balance *openapi_types.DecimalNumber `json:"balance,omitempty"` + } `json:"balanceByProvider,omitempty"` + + // Currency Currency code, see symbols and minimums + Currency *string `json:"currency,omitempty"` + + // Type Will always be "Staking" + Type *string `json:"type,omitempty"` +} + +// StakingDeposit defines model for StakingDeposit. +type StakingDeposit struct { + // AccrualTotal The total accrual + AccrualTotal *openapi_types.DecimalNumber `json:"accrualTotal,omitempty"` + + // Amount The amount deposited + Amount *openapi_types.DecimalNumber `json:"amount,omitempty"` + + // Currency Currency code, see [symbols](/market-data/symbols-and-minimums) + Currency *string `json:"currency,omitempty"` + + // ProviderId Provider Id, in uuid4 format + ProviderId *string `json:"providerId,omitempty"` + + // Rates A JSON object including one or many rates. If more than one rate it would be an array of rates. + Rates *struct { + // Rate Staking interest rate in bps (Expressed as a simple rate. Interest on Staking balances compounds daily. In mobile and web applications, APYs are derived from this rate and rounded to 1/10th of a percent.) + Rate *int `json:"rate,omitempty"` + } `json:"rates,omitempty"` + + // TransactionId A unique identifier for the staking transaction + TransactionId *string `json:"transactionId,omitempty"` +} + +// StakingHistory defines model for StakingHistory. +type StakingHistory struct { + // ProviderId Provider Id, in uuid4 format + ProviderId *string `json:"providerId,omitempty"` + Transactions *[]StakingTransaction `json:"transactions,omitempty"` +} + +// StakingRate defines model for StakingRate. +type StakingRate struct { + // ApyPct Staking interest APY (Expressed as a percentage derived from the rate and rounded to 1/10th of a percent.) + ApyPct *openapi_types.DecimalNumber `json:"apyPct,omitempty"` + + // DepositUsdLimit Maximum new amount in USD notional of this crypto that can participate in Gemini Staking per account per month + DepositUsdLimit *int `json:"depositUsdLimit,omitempty"` + + // ProviderId Provider Id, in uuid4 format + ProviderId *string `json:"providerId,omitempty"` + + // Rate Staking interest rate in bps (Expressed as a simple rate. Interest on Staking balances compounds daily. In mobile and web applications, APYs are derived from this rate and rounded to 1/10th of a percent.) + Rate *openapi_types.DecimalNumber `json:"rate,omitempty"` + + // RatePct `rate` expressed as a percentage + RatePct *openapi_types.DecimalNumber `json:"ratePct,omitempty"` +} + +// StakingRateProvider Currency Symbol Keys +type StakingRateProvider struct { + CurrencySymbol *StakingRate `json:"currency_symbol,omitempty"` +} + +// StakingRateResponse Provider UUID Keys +type StakingRateResponse struct { + // ProviderUuid Currency Symbol Keys + ProviderUuid *StakingRateProvider `json:"provider_uuid,omitempty"` +} + +// StakingRewardPeriod defines model for StakingRewardPeriod. +type StakingRewardPeriod struct { + // AccrualTotal The total accrual + AccrualTotal *openapi_types.DecimalNumber `json:"accrualTotal,omitempty"` + + // ApyPct Staking reward rate expressed as an APY at time of accrual. Interest on Staking balances compounds daily based on the simple rate which is available from `/v1/staking/rates/` + ApyPct *openapi_types.DecimalNumber `json:"apyPct,omitempty"` + + // Currency Currency code, see [symbols](/market-data/symbols-and-minimums) + Currency *string `json:"currency,omitempty"` + + // FirstAccrualAt Time of first accrual. In iso datetime with timezone format + FirstAccrualAt *string `json:"firstAccrualAt,omitempty"` + + // LastAccrualAt Time of last accrual. In iso datetime with timezone format + LastAccrualAt *string `json:"lastAccrualAt,omitempty"` + + // NumberOfAccruals Number of accruals in the specific aggregate, typically one per day. If the rate is adjusted, new accruals are added. + NumberOfAccruals *int `json:"numberOfAccruals,omitempty"` + + // ProviderId Provider Id, in uuid4 format + ProviderId *string `json:"providerId,omitempty"` + + // RatePct Rate expressed as a percentage + RatePct *openapi_types.DecimalNumber `json:"ratePct,omitempty"` +} + +// StakingRewards defines model for StakingRewards. +type StakingRewards struct { + // AccrualTotal The total accrual + AccrualTotal *openapi_types.DecimalNumber `json:"accrualTotal,omitempty"` + + // Currency Currency code, see [symbols](/market-data/symbols-and-minimums) + Currency *string `json:"currency,omitempty"` + + // ProviderId Provider Id, in uuid4 format + ProviderId *string `json:"providerId,omitempty"` + + // RatePeriods Array of JSON objects with period accrual information + RatePeriods *[]StakingRewardPeriod `json:"ratePeriods,omitempty"` +} + +// StakingRewardsProvider Currency Symbol Keys +type StakingRewardsProvider struct { + CurrencySymbol *StakingRewards `json:"currency_symbol,omitempty"` +} + +// StakingRewardsResponse Provider UUID Keys +type StakingRewardsResponse struct { + // ProviderUuid Currency Symbol Keys + ProviderUuid *StakingRewardsProvider `json:"provider_uuid,omitempty"` +} + +// StakingTransaction defines model for StakingTransaction. +type StakingTransaction struct { + // Amount The amount that is defined by the transactionType above + Amount *openapi_types.DecimalNumber `json:"amount,omitempty"` + + // AmountCurrency Currency code + AmountCurrency *string `json:"amountCurrency,omitempty"` + + // DateTime timestamp + DateTime *TimestampType `json:"dateTime,omitempty"` + + // PriceAmount Current market price of the underlying token at the time of the reward + PriceAmount *openapi_types.DecimalNumber `json:"priceAmount,omitempty"` + + // PriceCurrency A supported three-letter fiat currency code, e.g. usd + PriceCurrency *string `json:"priceCurrency,omitempty"` + + // TransactionId A unique identifier for the staking transaction + TransactionId *string `json:"transactionId,omitempty"` + + // TransactionType Can be any one of the following - Deposit, Redeem, Interest, RedeemPayment, AdminRedeem, AdminCreditAdjustment, AdminDebitAdjustment + TransactionType *StakingTransactionTransactionType `json:"transactionType,omitempty"` +} + +// StakingTransactionTransactionType Can be any one of the following - Deposit, Redeem, Interest, RedeemPayment, AdminRedeem, AdminCreditAdjustment, AdminDebitAdjustment +type StakingTransactionTransactionType string + +// StakingWithdrawal defines model for StakingWithdrawal. +type StakingWithdrawal struct { + // Amount The amount deposited + Amount *openapi_types.DecimalNumber `json:"amount,omitempty"` + + // AmountPaidSoFar The amount redeemed successfully + AmountPaidSoFar *openapi_types.DecimalNumber `json:"amountPaidSoFar,omitempty"` + + // AmountRemaining The amount pending to be redeemed + AmountRemaining *openapi_types.DecimalNumber `json:"amountRemaining,omitempty"` + + // Currency Currency code + Currency *string `json:"currency,omitempty"` + + // RequestInitiated In ISO datetime with timezone format + RequestInitiated *string `json:"requestInitiated,omitempty"` + + // TransactionId A unique identifier for the staking transaction + TransactionId *string `json:"transactionId,omitempty"` +} + +// StopLimitOrderResponse defines model for StopLimitOrderResponse. +type StopLimitOrderResponse struct { + AvgExecutionPrice *string `json:"avg_execution_price,omitempty"` + Exchange *string `json:"exchange,omitempty"` + ExecutedAmount *string `json:"executed_amount,omitempty"` + Id *string `json:"id,omitempty"` + IsCancelled *bool `json:"is_cancelled,omitempty"` + IsHidden *bool `json:"is_hidden,omitempty"` + IsLive *bool `json:"is_live,omitempty"` + Options *[]string `json:"options,omitempty"` + OrderId *string `json:"order_id,omitempty"` + OriginalAmount *string `json:"original_amount,omitempty"` + Price *string `json:"price,omitempty"` + Side *StopLimitOrderResponseSide `json:"side,omitempty"` + StopPrice *string `json:"stop_price,omitempty"` + Symbol *string `json:"symbol,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + Type *StopLimitOrderResponseType `json:"type,omitempty"` + WasForced *bool `json:"was_forced,omitempty"` +} + +// StopLimitOrderResponseSide defines model for StopLimitOrderResponse.Side. +type StopLimitOrderResponseSide string + +// StopLimitOrderResponseType defines model for StopLimitOrderResponse.Type. +type StopLimitOrderResponseType string + +// SymbolDetails defines model for SymbolDetails. +type SymbolDetails struct { + // BaseCurrency CCY1 or the top currency. (i.e `BTC` in `BTCUSD`) + BaseCurrency *string `json:"base_currency,omitempty"` + + // ContractPriceCurrency CCY2 or the quote currency for spot instrument (i.e. `USD` in `BTCUSD`) + // Or collateral currency of the contract in case of perpetual swap instrument. + ContractPriceCurrency *string `json:"contract_price_currency,omitempty"` + + // ContractType `vanilla` / `linear` / `inverse` where `vanilla` is for spot + // while `linear` is for perpetual swap and `inverse` is a special case perpetual swap where the perpetual contract will be settled in base currency. + ContractType *string `json:"contract_type,omitempty"` + + // MinOrderSize The minimum order size in `base_currency` units (i.e `0.00001`) + MinOrderSize *string `json:"min_order_size,omitempty"` + + // ProductType Instrument type `spot` / `swap` -- where `swap` signifies `perpetual swap`. + ProductType *string `json:"product_type,omitempty"` + + // QuoteCurrency CCY2 or the quote currency. (i.e `USD` in `BTCUSD`) + QuoteCurrency *string `json:"quote_currency,omitempty"` + + // QuoteIncrement The number of decimal places in the `quote_currency` (i.e `0.01`) + QuoteIncrement *openapi_types.DecimalNumber `json:"quote_increment,omitempty"` + + // Status Status of the current order book. Can be `open`, `closed`, `cancel_only`, `post_only`, `limit_only`. + Status *string `json:"status,omitempty"` + + // Symbol The requested symbol. See [**symbols and minimums**](/market-data/symbols-and-minimums#all-supported-symbols) + Symbol *string `json:"symbol,omitempty"` + + // TickSize The number of decimal places in the `base_currency`. (i.e `1e-8`) + TickSize *openapi_types.DecimalNumber `json:"tick_size,omitempty"` + + // WrapEnabled When `True`, symbol can be wrapped using this endpoint: + // `POST https://api.gemini.com/v1/wrap/:symbol` + WrapEnabled *bool `json:"wrap_enabled,omitempty"` +} + +// Ticker defines model for Ticker. +type Ticker struct { + // Ask The lowest ask currently available + Ask *string `json:"ask,omitempty"` + + // Bid The highest bid currently available + Bid *string `json:"bid,omitempty"` + + // Last The price of the last executed trade + Last *string `json:"last,omitempty"` + + // Volume Information about the 24 hour volume on the exchange. See properties below + Volume *struct { + // PriceSymbol The volume denominated in the price currency + PriceSymbol *string `json:"price_symbol,omitempty"` + + // QuantitySymbol The volume denominated in the quantity currency + QuantitySymbol *string `json:"quantity_symbol,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + } `json:"volume,omitempty"` +} + +// TickerInfo defines model for TickerInfo. +type TickerInfo struct { + // Ask Current best offer + Ask *string `json:"ask,omitempty"` + + // Bid Current best bid + Bid *string `json:"bid,omitempty"` + + // Changes Hourly prices descending for past 24 hours + Changes *[]string `json:"changes,omitempty"` + + // Close Close price (most recent trade) + Close *string `json:"close,omitempty"` + + // High High price from 24 hours ago + High *string `json:"high,omitempty"` + + // Low Low price from 24 hours ago + Low *string `json:"low,omitempty"` + + // Open Open price from 24 hours ago + Open *string `json:"open,omitempty"` + + // Symbol The trading pair symbol + Symbol *string `json:"symbol,omitempty"` +} + +// TimestampType timestamp +type TimestampType struct { + union json.RawMessage +} + +// TimestampType0 Gemini strongly recommends using milliseconds instead of seconds for timestamps. +// +// | Timestamp format | Example | Supported request type | +// |-----------------------|-----------------------|------------------------| +// | string (seconds) | `1495127793` | `POST` only | +// | string (milliseconds) | `1495127793000` | `POST` only | +type TimestampType0 = string + +// TimestampType1 Gemini strongly recommends using milliseconds instead of seconds for timestamps. +// +// | Timestamp format | Example | Supported request type | +// |-----------------------------|---------------------------|------------------------| +// | whole number (seconds) | `1495127793` | `GET`, `POST` | +// | whole number (milliseconds) | `1495127793000` | `GET`, `POST` | +type TimestampType1 = int64 + +// Trade defines model for Trade. +type Trade struct { + // Amount The amount that was traded + Amount *string `json:"amount,omitempty"` + + // Broken Whether the trade was broken or not. Broken trades will not be displayed by default; use the `include_breaks` to display them. + Broken *bool `json:"broken,omitempty"` + + // Exchange Will always be "gemini" + Exchange *string `json:"exchange,omitempty"` + + // Price The price the trade was executed at + Price *string `json:"price,omitempty"` + + // Tid The trade ID number + Tid *int64 `json:"tid,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + + // Type - `buy` means that an ask was removed from the book by an incoming buy order. + // - `sell` means that a bid was removed from the book by an incoming sell order. + Type *TradeType `json:"type,omitempty"` +} + +// TradeType - `buy` means that an ask was removed from the book by an incoming buy order. +// - `sell` means that a bid was removed from the book by an incoming sell order. +type TradeType string + +// TradeVolume defines model for TradeVolume. +type TradeVolume struct { + BaseCurrency *string `json:"base_currency,omitempty"` + BuyMakerBase *string `json:"buy_maker_base,omitempty"` + BuyMakerCount *int `json:"buy_maker_count,omitempty"` + BuyMakerNotional *string `json:"buy_maker_notional,omitempty"` + BuyTakerBase *string `json:"buy_taker_base,omitempty"` + BuyTakerCount *int `json:"buy_taker_count,omitempty"` + BuyTakerNotional *string `json:"buy_taker_notional,omitempty"` + DataDate *string `json:"data_date,omitempty"` + MakerBuySellRatio *string `json:"maker_buy_sell_ratio,omitempty"` + NotionalCurrency *string `json:"notional_currency,omitempty"` + QuoteCurrency *string `json:"quote_currency,omitempty"` + SellMakerBase *string `json:"sell_maker_base,omitempty"` + SellMakerCount *int `json:"sell_maker_count,omitempty"` + SellMakerNotional *string `json:"sell_maker_notional,omitempty"` + SellTakerBase *string `json:"sell_taker_base,omitempty"` + SellTakerCount *int `json:"sell_taker_count,omitempty"` + SellTakerNotional *string `json:"sell_taker_notional,omitempty"` + Symbol *string `json:"symbol,omitempty"` + TotalVolumeBase *string `json:"total_volume_base,omitempty"` +} + +// Transaction defines model for Transaction. +type Transaction struct { + union json.RawMessage +} + +// Transaction0 Trade Reponse +type Transaction0 struct { + // Account The account. + Account *string `json:"account,omitempty"` + + // Amount The quantity that was executed. + Amount *string `json:"amount,omitempty"` + + // ClientOrderId The client order ID, if defined. Otherwise an empty string. + ClientOrderId *string `json:"clientOrderId,omitempty"` + + // Exchange Will always be "gemini". + Exchange *string `json:"exchange,omitempty"` + + // FeeAmount The fee amount charged + FeeAmount *string `json:"feeAmount,omitempty"` + + // FeeAssetCode The symbol that the trade was for + FeeAssetCode *string `json:"feeAssetCode,omitempty"` + + // IsAggressor If true, this order was the taker in the trade. + IsAggressor *bool `json:"isAggressor,omitempty"` + + // IsAuctionFill True if the trade was a auction trade and not an on-exchange trade. + IsAuctionFill *bool `json:"isAuctionFill,omitempty"` + + // IsClearingFill True if the trade was a clearing trade and not an on-exchange trade. + IsClearingFill *bool `json:"isClearingFill,omitempty"` + + // OrderId The order that this trade executed against. + OrderId *int64 `json:"orderId,omitempty"` + + // Price The price that the execution happened at. + Price *string `json:"price,omitempty"` + + // Side Indicating the side of the original order. + Side *string `json:"side,omitempty"` + + // Symbol The symbol that the trade was for. + Symbol *string `json:"symbol,omitempty"` + + // Tid The trade ID. + Tid *int64 `json:"tid,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` +} + +// Transaction1 Transfer Reponse +type Transaction1 struct { + // AdvanceEid Deposit advance event ID. + AdvanceEid *int64 `json:"advanceEid,omitempty"` + + // Amount The quantity that was transferred. + Amount *string `json:"amount,omitempty"` + + // BankId Bank ID. + BankId *string `json:"bankId,omitempty"` + + // ClientTransferId Client Transfer ID. Client transfer ID is an optional client-supplied unique identifier for each withdrawal or internal transfer. + ClientTransferId *string `json:"clientTransferId,omitempty"` + + // CorrelationId Correlation ID. + CorrelationId *int64 `json:"correlationId,omitempty"` + + // Currency Currency code, see symbols + Currency *string `json:"currency,omitempty"` + + // Destination The account you are transferring to. + Destination *string `json:"destination,omitempty"` + + // Eid Transfer event id. + Eid *int64 `json:"eid,omitempty"` + + // FeeId Fee ID. + FeeId *string `json:"feeId,omitempty"` + + // Method Type of transfer method. + Method *string `json:"method,omitempty"` + + // OperationReason The operation reason. + OperationReason *string `json:"operationReason,omitempty"` + + // PendingEid Pending event ID. + PendingEid *int64 `json:"pendingEid,omitempty"` + + // Purpose Purpose. + Purpose *string `json:"purpose,omitempty"` + + // Source The account you are transferring from. + Source *string `json:"source,omitempty"` + + // Status The status of the transfer. + Status *string `json:"status,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + + // TransactionHash Supplies the transaction hash when available. + TransactionHash *string `json:"transactionHash,omitempty"` + + // TransferId Transfer ID. + TransferId *string `json:"transferId,omitempty"` + + // TransferType Transfer type. + TransferType *string `json:"transferType,omitempty"` + + // WithdrawalEid Withdrawal event ID. + WithdrawalEid *int64 `json:"withdrawalEid,omitempty"` + + // WithdrawalId Withdrawal ID. + WithdrawalId *string `json:"withdrawalId,omitempty"` +} + +// Transfer defines model for Transfer. +type Transfer struct { + // Amount The amount transferred + Amount *string `json:"amount,omitempty"` + + // Currency The currency transferred + Currency *string `json:"currency,omitempty"` + + // Eid The transfer ID + Eid *int64 `json:"eid,omitempty"` + Status *TransferStatus `json:"status,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + + // TxHash The transaction hash if applicable + TxHash *string `json:"txHash,omitempty"` + Type *TransferType `json:"type,omitempty"` +} + +// TransferStatus defines model for Transfer.Status. +type TransferStatus string + +// TransferType defines model for Transfer.Type. +type TransferType string + +// V2Transfer defines model for V2Transfer. +type V2Transfer struct { + // Amount The amount transferred + Amount *string `json:"amount,omitempty"` + + // Currency The currency transferred + Currency *string `json:"currency,omitempty"` + + // Destination The destination address for withdrawals + Destination *string `json:"destination,omitempty"` + + // Eid The transfer event ID + Eid *int64 `json:"eid,omitempty"` + + // FeeAmount The fee charged for the transfer + FeeAmount *string `json:"feeAmount,omitempty"` + + // FeeCurrency The currency in which the fee was charged + FeeCurrency *string `json:"feeCurrency,omitempty"` + + // Method The transfer method (e.g., `ACH`, `CreditCard`) + Method *string `json:"method,omitempty"` + + // Network The blockchain network the transfer was executed on (e.g., `ethereum`, `solana`, `arbitrum`, `optimism`, `base`, `avalanche`). Not present for fiat or administrative transfers. + Network *string `json:"network,omitempty"` + + // OutputIdx The output index for withdrawals + OutputIdx *int `json:"outputIdx,omitempty"` + + // Purpose The purpose or reason for administrative transfers + Purpose *string `json:"purpose,omitempty"` + + // Status The status of the transfer + Status *V2TransferStatus `json:"status,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + + // TxHash The on-chain transaction hash, if applicable + TxHash *string `json:"txHash,omitempty"` + + // Type The type of the transfer + Type *V2TransferType `json:"type,omitempty"` + + // WithdrawalId The unique withdrawal identifier + WithdrawalId *string `json:"withdrawalId,omitempty"` +} + +// V2TransferStatus The status of the transfer +type V2TransferStatus string + +// V2TransferType The type of the transfer +type V2TransferType string + +// WithdrawCryptoFundsResponse Response returned after submitting a v2 cryptocurrency withdrawal. +type WithdrawCryptoFundsResponse struct { + // Address Standard string format of the withdrawal destination address + Address *string `json:"address,omitempty"` + + // Amount The withdrawal amount + Amount *string `json:"amount,omitempty"` + + // Currency The currency code of the withdrawn asset + Currency *string `json:"currency,omitempty"` + + // Fee The fee charged for the withdrawal + Fee *string `json:"fee,omitempty"` + + // WithdrawalId A unique ID for the withdrawal + WithdrawalId *string `json:"withdrawalId,omitempty"` +} + +// ApiKeyAuth defines model for apiKeyAuth. +type ApiKeyAuth = string + +// CacheControl defines model for cacheControl. +type CacheControl = string + +// ContentLength defines model for contentLength. +type ContentLength = string + +// ContentType defines model for contentType. +type ContentType = string + +// CurrencyParam defines model for currencyParam. +type CurrencyParam = string + +// NetworkParam defines model for networkParam. +type NetworkParam = string + +// PayloadAuth defines model for payloadAuth. +type PayloadAuth = string + +// SignatureAuth defines model for signatureAuth. +type SignatureAuth = string + +// SymbolParam defines model for symbolParam. +type SymbolParam = string + +// TimestampParam timestamp +type TimestampParam = TimestampType + +// ApiKeyIpFilteringFailure defines model for ApiKeyIpFilteringFailure. +type ApiKeyIpFilteringFailure = ErrorResponse + +// BadRequest defines model for BadRequest. +type BadRequest = ErrorResponse + +// InternalError defines model for InternalError. +type InternalError = ErrorResponse + +// NotFound defines model for NotFound. +type NotFound = ErrorResponse + +// TooManyRequests defines model for TooManyRequests. +type TooManyRequests = ErrorResponse + +// Unauthorized defines model for Unauthorized. +type Unauthorized = ErrorResponse + +// apiKeyAuthContextKey is the context key for apiKeyAuth security scheme +type apiKeyAuthContextKey string + +// payloadAuthContextKey is the context key for payloadAuth security scheme +type payloadAuthContextKey string + +// signatureAuthContextKey is the context key for signatureAuth security scheme +type signatureAuthContextKey string + +// GetAccountMarginJSONBody defines parameters for GetAccountMargin. +type GetAccountMarginJSONBody struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Specifies the account on which you intend to place the order. Only available for exchange accounts. + Account *string `json:"account,omitempty"` + + // Nonce timestamp + Nonce TimestampType `json:"nonce"` + + // Request The API endpoint path + Request string `json:"request"` + + // Symbol Trading pair symbol. See [symbols and minimums](/market-data/symbols-and-minimums) + Symbol string `json:"symbol"` +} + +// GetAccountMarginParams defines parameters for GetAccountMargin. +type GetAccountMarginParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// ListFundingPaymentsJSONBody defines parameters for ListFundingPayments. +type ListFundingPaymentsJSONBody struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Specifies the account on which you intend to place the order. Only available for exchange accounts. + Account *string `json:"account,omitempty"` + + // Nonce timestamp + Nonce TimestampType `json:"nonce"` + + // Request The API endpoint path + Request string `json:"request"` +} + +// ListFundingPaymentsParams defines parameters for ListFundingPayments. +type ListFundingPaymentsParams struct { + // Since If specified, only return funding payments after this point. Default value is 24h in past. See [**Timestamps**](/rest/~schemas#timestamp-type) for more information + Since *TimestampType `form:"since,omitempty" json:"since,omitempty"` + + // To If specified, only returns funding payment until this point. Default value is now. See [**Timestamps**](/rest/~schemas#timestamp-type) for more information + To *TimestampType `form:"to,omitempty" json:"to,omitempty"` + + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// GetFundingPaymentReportJsonJSONBody defines parameters for GetFundingPaymentReportJson. +type GetFundingPaymentReportJsonJSONBody struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Specifies the account on which you intend to place the order. Only available for exchange accounts. + Account *string `json:"account,omitempty"` + + // Nonce timestamp + Nonce TimestampType `json:"nonce"` + + // Request The API endpoint path + Request string `json:"request"` +} + +// GetFundingPaymentReportJsonParams defines parameters for GetFundingPaymentReportJson. +type GetFundingPaymentReportJsonParams struct { + // FromDate If empty, will only fetch records by numRows value. + FromDate *openapi_types.Date `form:"fromDate,omitempty" json:"fromDate,omitempty"` + + // ToDate If empty, will only fetch records by numRows value. + ToDate *openapi_types.Date `form:"toDate,omitempty" json:"toDate,omitempty"` + + // NumRows If empty, default value '8760' + NumRows *int `form:"numRows,omitempty" json:"numRows,omitempty"` + + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// GetFundingPaymentReportFileJSONBody defines parameters for GetFundingPaymentReportFile. +type GetFundingPaymentReportFileJSONBody struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Specifies the account on which you intend to place the order. Only available for exchange accounts. + Account *string `json:"account,omitempty"` + + // Nonce timestamp + Nonce TimestampType `json:"nonce"` + + // Request The API endpoint path + Request string `json:"request"` +} + +// GetFundingPaymentReportFileParams defines parameters for GetFundingPaymentReportFile. +type GetFundingPaymentReportFileParams struct { + // FromDate If empty, will only fetch records by numRows value. + FromDate *openapi_types.Date `form:"fromDate,omitempty" json:"fromDate,omitempty"` + + // ToDate If empty, will only fetch records by numRows value. + ToDate *openapi_types.Date `form:"toDate,omitempty" json:"toDate,omitempty"` + + // NumRows If empty, default value '8760' + NumRows *int `form:"numRows,omitempty" json:"numRows,omitempty"` + + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// GetOpenPositionsJSONBody defines parameters for GetOpenPositions. +type GetOpenPositionsJSONBody struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Specifies the account on which the orders were placed. Only available for exchange accounts. + Account *string `json:"account,omitempty"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce Nonce `json:"nonce"` + + // Request The literal string "/v1/positions" + Request string `json:"request"` +} + +// GetOpenPositionsParams defines parameters for GetOpenPositions. +type GetOpenPositionsParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// GetAccountMarginJSONRequestBody defines body for GetAccountMargin for application/json ContentType. +type GetAccountMarginJSONRequestBody GetAccountMarginJSONBody + +// ListFundingPaymentsJSONRequestBody defines body for ListFundingPayments for application/json ContentType. +type ListFundingPaymentsJSONRequestBody ListFundingPaymentsJSONBody + +// GetFundingPaymentReportJsonJSONRequestBody defines body for GetFundingPaymentReportJson for application/json ContentType. +type GetFundingPaymentReportJsonJSONRequestBody GetFundingPaymentReportJsonJSONBody + +// GetFundingPaymentReportFileJSONRequestBody defines body for GetFundingPaymentReportFile for application/json ContentType. +type GetFundingPaymentReportFileJSONRequestBody GetFundingPaymentReportFileJSONBody + +// GetOpenPositionsJSONRequestBody defines body for GetOpenPositions for application/json ContentType. +type GetOpenPositionsJSONRequestBody GetOpenPositionsJSONBody + +// AsHeartbeatNonce0 returns the union data inside the Heartbeat_Nonce as a HeartbeatNonce0 +func (t Heartbeat_Nonce) AsHeartbeatNonce0() (HeartbeatNonce0, error) { + var body HeartbeatNonce0 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromHeartbeatNonce0 overwrites any union data inside the Heartbeat_Nonce as the provided HeartbeatNonce0 +func (t *Heartbeat_Nonce) FromHeartbeatNonce0(v HeartbeatNonce0) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeHeartbeatNonce0 performs a merge with any union data inside the Heartbeat_Nonce, using the provided HeartbeatNonce0 +func (t *Heartbeat_Nonce) MergeHeartbeatNonce0(v HeartbeatNonce0) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsHeartbeatNonce1 returns the union data inside the Heartbeat_Nonce as a HeartbeatNonce1 +func (t Heartbeat_Nonce) AsHeartbeatNonce1() (HeartbeatNonce1, error) { + var body HeartbeatNonce1 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromHeartbeatNonce1 overwrites any union data inside the Heartbeat_Nonce as the provided HeartbeatNonce1 +func (t *Heartbeat_Nonce) FromHeartbeatNonce1(v HeartbeatNonce1) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeHeartbeatNonce1 performs a merge with any union data inside the Heartbeat_Nonce, using the provided HeartbeatNonce1 +func (t *Heartbeat_Nonce) MergeHeartbeatNonce1(v HeartbeatNonce1) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t Heartbeat_Nonce) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *Heartbeat_Nonce) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + +// AsTimestampType returns the union data inside the Nonce as a TimestampType +func (t Nonce) AsTimestampType() (TimestampType, error) { + var body TimestampType + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromTimestampType overwrites any union data inside the Nonce as the provided TimestampType +func (t *Nonce) FromTimestampType(v TimestampType) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeTimestampType performs a merge with any union data inside the Nonce, using the provided TimestampType +func (t *Nonce) MergeTimestampType(v TimestampType) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsNonce1 returns the union data inside the Nonce as a Nonce1 +func (t Nonce) AsNonce1() (Nonce1, error) { + var body Nonce1 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromNonce1 overwrites any union data inside the Nonce as the provided Nonce1 +func (t *Nonce) FromNonce1(v Nonce1) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeNonce1 performs a merge with any union data inside the Nonce, using the provided Nonce1 +func (t *Nonce) MergeNonce1(v Nonce1) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t Nonce) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *Nonce) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + +// AsTimestampType0 returns the union data inside the TimestampType as a TimestampType0 +func (t TimestampType) AsTimestampType0() (TimestampType0, error) { + var body TimestampType0 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromTimestampType0 overwrites any union data inside the TimestampType as the provided TimestampType0 +func (t *TimestampType) FromTimestampType0(v TimestampType0) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeTimestampType0 performs a merge with any union data inside the TimestampType, using the provided TimestampType0 +func (t *TimestampType) MergeTimestampType0(v TimestampType0) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsTimestampType1 returns the union data inside the TimestampType as a TimestampType1 +func (t TimestampType) AsTimestampType1() (TimestampType1, error) { + var body TimestampType1 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromTimestampType1 overwrites any union data inside the TimestampType as the provided TimestampType1 +func (t *TimestampType) FromTimestampType1(v TimestampType1) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeTimestampType1 performs a merge with any union data inside the TimestampType, using the provided TimestampType1 +func (t *TimestampType) MergeTimestampType1(v TimestampType1) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t TimestampType) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *TimestampType) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + +// AsTransaction0 returns the union data inside the Transaction as a Transaction0 +func (t Transaction) AsTransaction0() (Transaction0, error) { + var body Transaction0 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromTransaction0 overwrites any union data inside the Transaction as the provided Transaction0 +func (t *Transaction) FromTransaction0(v Transaction0) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeTransaction0 performs a merge with any union data inside the Transaction, using the provided Transaction0 +func (t *Transaction) MergeTransaction0(v Transaction0) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsTransaction1 returns the union data inside the Transaction as a Transaction1 +func (t Transaction) AsTransaction1() (Transaction1, error) { + var body Transaction1 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromTransaction1 overwrites any union data inside the Transaction as the provided Transaction1 +func (t *Transaction) FromTransaction1(v Transaction1) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeTransaction1 performs a merge with any union data inside the Transaction, using the provided Transaction1 +func (t *Transaction) MergeTransaction1(v Transaction1) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t Transaction) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *Transaction) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} diff --git a/packages/sdk-go/generated/predictions/types.gen.go b/packages/sdk-go/generated/predictions/types.gen.go new file mode 100644 index 00000000..14800c9d --- /dev/null +++ b/packages/sdk-go/generated/predictions/types.gen.go @@ -0,0 +1,3251 @@ +// Code generated from prediction-markets.yaml (Combos, Markets, Positions, Rewards, Terms, Trading, Volume). DO NOT EDIT. + +// Package predictions provides primitives to interact with the openapi HTTP API. +// +// Code generated by oapi-codegen. DO NOT EDIT. +package predictions + +import ( + "encoding/json" + "time" + + "github.com/gemini/gemini-go/internal/runtime" + openapi_types "github.com/gemini/gemini-go/types" +) + +const ( + ApiKeyScopes apiKeyContextKey = "apiKey.Scopes" + PayloadAuthScopes payloadAuthContextKey = "payloadAuth.Scopes" + SignatureAuthScopes signatureAuthContextKey = "signatureAuth.Scopes" +) + +// Defines values for AccountGroupBlockedErrorCode. +const ( + ACCOUNTGROUPBLOCKED AccountGroupBlockedErrorCode = "ACCOUNT_GROUP_BLOCKED" +) + +// Valid indicates whether the value is a known member of the AccountGroupBlockedErrorCode enum. +func (e AccountGroupBlockedErrorCode) Valid() bool { + switch e { + case ACCOUNTGROUPBLOCKED: + return true + default: + return false + } +} + +// Defines values for AccountGroupBlockedErrorError. +const ( + ThisAccountIsNotPermittedToTradePredictionMarkets AccountGroupBlockedErrorError = "This account is not permitted to trade prediction markets" +) + +// Valid indicates whether the value is a known member of the AccountGroupBlockedErrorError enum. +func (e AccountGroupBlockedErrorError) Valid() bool { + switch e { + case ThisAccountIsNotPermittedToTradePredictionMarkets: + return true + default: + return false + } +} + +// Defines values for AuthErrorResponseResult. +const ( + AuthErrorResponseResultError AuthErrorResponseResult = "error" +) + +// Valid indicates whether the value is a known member of the AuthErrorResponseResult enum. +func (e AuthErrorResponseResult) Valid() bool { + switch e { + case AuthErrorResponseResultError: + return true + default: + return false + } +} + +// Defines values for BatchOrderResponseStatus. +const ( + BatchOrderResponseStatusCancelled BatchOrderResponseStatus = "cancelled" + BatchOrderResponseStatusClosed BatchOrderResponseStatus = "closed" + BatchOrderResponseStatusFilled BatchOrderResponseStatus = "filled" + BatchOrderResponseStatusOpen BatchOrderResponseStatus = "open" +) + +// Valid indicates whether the value is a known member of the BatchOrderResponseStatus enum. +func (e BatchOrderResponseStatus) Valid() bool { + switch e { + case BatchOrderResponseStatusCancelled: + return true + case BatchOrderResponseStatusClosed: + return true + case BatchOrderResponseStatusFilled: + return true + case BatchOrderResponseStatusOpen: + return true + default: + return false + } +} + +// Defines values for BatchOrderResponseTimeInForce. +const ( + BatchOrderResponseTimeInForceFillOrKill BatchOrderResponseTimeInForce = "fill-or-kill" + BatchOrderResponseTimeInForceGoodTilCancel BatchOrderResponseTimeInForce = "good-til-cancel" + BatchOrderResponseTimeInForceImmediateOrCancel BatchOrderResponseTimeInForce = "immediate-or-cancel" + BatchOrderResponseTimeInForceMakerOrCancel BatchOrderResponseTimeInForce = "maker-or-cancel" +) + +// Valid indicates whether the value is a known member of the BatchOrderResponseTimeInForce enum. +func (e BatchOrderResponseTimeInForce) Valid() bool { + switch e { + case BatchOrderResponseTimeInForceFillOrKill: + return true + case BatchOrderResponseTimeInForceGoodTilCancel: + return true + case BatchOrderResponseTimeInForceImmediateOrCancel: + return true + case BatchOrderResponseTimeInForceMakerOrCancel: + return true + default: + return false + } +} + +// Defines values for CancelOrderBatchSuccessResultResult. +const ( + Ok CancelOrderBatchSuccessResultResult = "ok" +) + +// Valid indicates whether the value is a known member of the CancelOrderBatchSuccessResultResult enum. +func (e CancelOrderBatchSuccessResultResult) Valid() bool { + switch e { + case Ok: + return true + default: + return false + } +} + +// Defines values for CashedOutPositionSide. +const ( + CashedOutPositionSideSell CashedOutPositionSide = "sell" +) + +// Valid indicates whether the value is a known member of the CashedOutPositionSide enum. +func (e CashedOutPositionSide) Valid() bool { + switch e { + case CashedOutPositionSideSell: + return true + default: + return false + } +} + +// Defines values for ComboLegRequiredOutcome. +const ( + ComboLegRequiredOutcomeNo ComboLegRequiredOutcome = "No" + ComboLegRequiredOutcomeYes ComboLegRequiredOutcome = "Yes" +) + +// Valid indicates whether the value is a known member of the ComboLegRequiredOutcome enum. +func (e ComboLegRequiredOutcome) Valid() bool { + switch e { + case ComboLegRequiredOutcomeNo: + return true + case ComboLegRequiredOutcomeYes: + return true + default: + return false + } +} + +// Defines values for ComboSummaryLegLegOutcome. +const ( + ComboSummaryLegLegOutcomeNo ComboSummaryLegLegOutcome = "No" + ComboSummaryLegLegOutcomeYes ComboSummaryLegLegOutcome = "Yes" +) + +// Valid indicates whether the value is a known member of the ComboSummaryLegLegOutcome enum. +func (e ComboSummaryLegLegOutcome) Valid() bool { + switch e { + case ComboSummaryLegLegOutcomeNo: + return true + case ComboSummaryLegLegOutcomeYes: + return true + default: + return false + } +} + +// Defines values for ComboSummaryLegRequiredOutcome. +const ( + ComboSummaryLegRequiredOutcomeNo ComboSummaryLegRequiredOutcome = "No" + ComboSummaryLegRequiredOutcomeYes ComboSummaryLegRequiredOutcome = "Yes" +) + +// Valid indicates whether the value is a known member of the ComboSummaryLegRequiredOutcome enum. +func (e ComboSummaryLegRequiredOutcome) Valid() bool { + switch e { + case ComboSummaryLegRequiredOutcomeNo: + return true + case ComboSummaryLegRequiredOutcomeYes: + return true + default: + return false + } +} + +// Defines values for ContractMarketState. +const ( + ContractMarketStateClosed ContractMarketState = "closed" + ContractMarketStateOpen ContractMarketState = "open" +) + +// Valid indicates whether the value is a known member of the ContractMarketState enum. +func (e ContractMarketState) Valid() bool { + switch e { + case ContractMarketStateClosed: + return true + case ContractMarketStateOpen: + return true + default: + return false + } +} + +// Defines values for CreateComboLegRequiredOutcome. +const ( + CreateComboLegRequiredOutcomeNo CreateComboLegRequiredOutcome = "No" + CreateComboLegRequiredOutcomeYes CreateComboLegRequiredOutcome = "Yes" +) + +// Valid indicates whether the value is a known member of the CreateComboLegRequiredOutcome enum. +func (e CreateComboLegRequiredOutcome) Valid() bool { + switch e { + case CreateComboLegRequiredOutcomeNo: + return true + case CreateComboLegRequiredOutcomeYes: + return true + default: + return false + } +} + +// Defines values for LiquidityRewardEventPoolSource. +const ( + CategoryDefault LiquidityRewardEventPoolSource = "category_default" + EventOverride LiquidityRewardEventPoolSource = "event_override" + Unspecified LiquidityRewardEventPoolSource = "unspecified" +) + +// Valid indicates whether the value is a known member of the LiquidityRewardEventPoolSource enum. +func (e LiquidityRewardEventPoolSource) Valid() bool { + switch e { + case CategoryDefault: + return true + case EventOverride: + return true + case Unspecified: + return true + default: + return false + } +} + +// Defines values for MarketStatus. +const ( + MarketStatusActive MarketStatus = "active" + MarketStatusApproved MarketStatus = "approved" + MarketStatusClosed MarketStatus = "closed" + MarketStatusInvalid MarketStatus = "invalid" + MarketStatusSettled MarketStatus = "settled" + MarketStatusUnderReview MarketStatus = "under_review" +) + +// Valid indicates whether the value is a known member of the MarketStatus enum. +func (e MarketStatus) Valid() bool { + switch e { + case MarketStatusActive: + return true + case MarketStatusApproved: + return true + case MarketStatusClosed: + return true + case MarketStatusInvalid: + return true + case MarketStatusSettled: + return true + case MarketStatusUnderReview: + return true + default: + return false + } +} + +// Defines values for MarketType. +const ( + Binary MarketType = "binary" + Categorical MarketType = "categorical" +) + +// Valid indicates whether the value is a known member of the MarketType enum. +func (e MarketType) Valid() bool { + switch e { + case Binary: + return true + case Categorical: + return true + default: + return false + } +} + +// Defines values for OrderSide. +const ( + OrderSideBuy OrderSide = "buy" + OrderSideSell OrderSide = "sell" +) + +// Valid indicates whether the value is a known member of the OrderSide enum. +func (e OrderSide) Valid() bool { + switch e { + case OrderSideBuy: + return true + case OrderSideSell: + return true + default: + return false + } +} + +// Defines values for OrderStatus. +const ( + OrderStatusCancelled OrderStatus = "cancelled" + OrderStatusFilled OrderStatus = "filled" + OrderStatusOpen OrderStatus = "open" +) + +// Valid indicates whether the value is a known member of the OrderStatus enum. +func (e OrderStatus) Valid() bool { + switch e { + case OrderStatusCancelled: + return true + case OrderStatusFilled: + return true + case OrderStatusOpen: + return true + default: + return false + } +} + +// Defines values for OrderType. +const ( + OrderTypeLimit OrderType = "limit" + OrderTypeStopLimit OrderType = "stop-limit" +) + +// Valid indicates whether the value is a known member of the OrderType enum. +func (e OrderType) Valid() bool { + switch e { + case OrderTypeLimit: + return true + case OrderTypeStopLimit: + return true + default: + return false + } +} + +// Defines values for Outcome. +const ( + No Outcome = "no" + Yes Outcome = "yes" +) + +// Valid indicates whether the value is a known member of the Outcome enum. +func (e Outcome) Valid() bool { + switch e { + case No: + return true + case Yes: + return true + default: + return false + } +} + +// Defines values for PositionStatus. +const ( + PositionStatusActive PositionStatus = "active" + PositionStatusCancelled PositionStatus = "cancelled" + PositionStatusResolved PositionStatus = "resolved" +) + +// Valid indicates whether the value is a known member of the PositionStatus enum. +func (e PositionStatus) Valid() bool { + switch e { + case PositionStatusActive: + return true + case PositionStatusCancelled: + return true + case PositionStatusResolved: + return true + default: + return false + } +} + +// Defines values for RestrictedSellOnlyErrorError. +const ( + ACCOUNTRESTRICTEDSELLONLY RestrictedSellOnlyErrorError = "ACCOUNT_RESTRICTED_SELL_ONLY" +) + +// Valid indicates whether the value is a known member of the RestrictedSellOnlyErrorError enum. +func (e RestrictedSellOnlyErrorError) Valid() bool { + switch e { + case ACCOUNTRESTRICTEDSELLONLY: + return true + default: + return false + } +} + +// Defines values for RestrictedSellOnlyErrorMessage. +const ( + YourAccountIsRestrictedToSellingExistingPositionsBuyingIsNotPermitted RestrictedSellOnlyErrorMessage = "Your account is restricted to selling existing positions; buying is not permitted." +) + +// Valid indicates whether the value is a known member of the RestrictedSellOnlyErrorMessage enum. +func (e RestrictedSellOnlyErrorMessage) Valid() bool { + switch e { + case YourAccountIsRestrictedToSellingExistingPositionsBuyingIsNotPermitted: + return true + default: + return false + } +} + +// Defines values for SportsMarketMetric. +const ( + SportsMarketMetricAces SportsMarketMetric = "aces" + SportsMarketMetricAssists SportsMarketMetric = "assists" + SportsMarketMetricBallsFaced SportsMarketMetric = "balls_faced" + SportsMarketMetricBirdies SportsMarketMetric = "birdies" + SportsMarketMetricBlockedShots SportsMarketMetric = "blocked_shots" + SportsMarketMetricBlocks SportsMarketMetric = "blocks" + SportsMarketMetricBogeys SportsMarketMetric = "bogeys" + SportsMarketMetricBoundaries SportsMarketMetric = "boundaries" + SportsMarketMetricBreakPointsWon SportsMarketMetric = "break_points_won" + SportsMarketMetricCards SportsMarketMetric = "cards" + SportsMarketMetricCatches SportsMarketMetric = "catches" + SportsMarketMetricCleanSheets SportsMarketMetric = "clean_sheets" + SportsMarketMetricCompletedPasses SportsMarketMetric = "completed_passes" + SportsMarketMetricControlTime SportsMarketMetric = "control_time" + SportsMarketMetricCorners SportsMarketMetric = "corners" + SportsMarketMetricDefensiveRebounds SportsMarketMetric = "defensive_rebounds" + SportsMarketMetricDoubleDouble SportsMarketMetric = "double_double" + SportsMarketMetricDoubleFaults SportsMarketMetric = "double_faults" + SportsMarketMetricDoubles SportsMarketMetric = "doubles" + SportsMarketMetricEagles SportsMarketMetric = "eagles" + SportsMarketMetricEarnedRuns SportsMarketMetric = "earned_runs" + SportsMarketMetricErrors SportsMarketMetric = "errors" + SportsMarketMetricFaceoffWins SportsMarketMetric = "faceoff_wins" + SportsMarketMetricFairwaysHit SportsMarketMetric = "fairways_hit" + SportsMarketMetricFantasyPoints SportsMarketMetric = "fantasy_points" + SportsMarketMetricFastestLap SportsMarketMetric = "fastest_lap" + SportsMarketMetricFieldGoalsMade SportsMarketMetric = "field_goals_made" + SportsMarketMetricFinishingPosition SportsMarketMetric = "finishing_position" + SportsMarketMetricFouls SportsMarketMetric = "fouls" + SportsMarketMetricFours SportsMarketMetric = "fours" + SportsMarketMetricFreeThrowsMade SportsMarketMetric = "free_throws_made" + SportsMarketMetricFumbles SportsMarketMetric = "fumbles" + SportsMarketMetricGames SportsMarketMetric = "games" + SportsMarketMetricGoals SportsMarketMetric = "goals" + SportsMarketMetricGoalsAllowed SportsMarketMetric = "goals_allowed" + SportsMarketMetricGreensInRegulation SportsMarketMetric = "greens_in_regulation" + SportsMarketMetricGridPosition SportsMarketMetric = "grid_position" + SportsMarketMetricHits SportsMarketMetric = "hits" + SportsMarketMetricHitsAllowed SportsMarketMetric = "hits_allowed" + SportsMarketMetricHitsRunsRbis SportsMarketMetric = "hits_runs_rbis" + SportsMarketMetricHolesInOne SportsMarketMetric = "holes_in_one" + SportsMarketMetricHomeRuns SportsMarketMetric = "home_runs" + SportsMarketMetricInningsPitched SportsMarketMetric = "innings_pitched" + SportsMarketMetricInterceptionsThrown SportsMarketMetric = "interceptions_thrown" + SportsMarketMetricKickingPoints SportsMarketMetric = "kicking_points" + SportsMarketMetricKnockdowns SportsMarketMetric = "knockdowns" + SportsMarketMetricLapTime SportsMarketMetric = "lap_time" + SportsMarketMetricLapsCompleted SportsMarketMetric = "laps_completed" + SportsMarketMetricLapsLed SportsMarketMetric = "laps_led" + SportsMarketMetricLongestPassCompletion SportsMarketMetric = "longest_pass_completion" + SportsMarketMetricLongestReception SportsMarketMetric = "longest_reception" + SportsMarketMetricLongestRush SportsMarketMetric = "longest_rush" + SportsMarketMetricMaidenOvers SportsMarketMetric = "maiden_overs" + SportsMarketMetricOffensiveRebounds SportsMarketMetric = "offensive_rebounds" + SportsMarketMetricOffsides SportsMarketMetric = "offsides" + SportsMarketMetricOther SportsMarketMetric = "other" + SportsMarketMetricPars SportsMarketMetric = "pars" + SportsMarketMetricPassAttempts SportsMarketMetric = "pass_attempts" + SportsMarketMetricPassCompletions SportsMarketMetric = "pass_completions" + SportsMarketMetricPasses SportsMarketMetric = "passes" + SportsMarketMetricPassingTouchdowns SportsMarketMetric = "passing_touchdowns" + SportsMarketMetricPassingYards SportsMarketMetric = "passing_yards" + SportsMarketMetricPenaltyMinutes SportsMarketMetric = "penalty_minutes" + SportsMarketMetricPitStops SportsMarketMetric = "pit_stops" + SportsMarketMetricPitchingOutsRecorded SportsMarketMetric = "pitching_outs_recorded" + SportsMarketMetricPoints SportsMarketMetric = "points" + SportsMarketMetricPointsAssists SportsMarketMetric = "points_assists" + SportsMarketMetricPointsRebounds SportsMarketMetric = "points_rebounds" + SportsMarketMetricPointsReboundsAssists SportsMarketMetric = "points_rebounds_assists" + SportsMarketMetricPositionsGained SportsMarketMetric = "positions_gained" + SportsMarketMetricPowerPlayPoints SportsMarketMetric = "power_play_points" + SportsMarketMetricPutts SportsMarketMetric = "putts" + SportsMarketMetricQualifyingPosition SportsMarketMetric = "qualifying_position" + SportsMarketMetricRebounds SportsMarketMetric = "rebounds" + SportsMarketMetricReboundsAssists SportsMarketMetric = "rebounds_assists" + SportsMarketMetricReceivingTouchdowns SportsMarketMetric = "receiving_touchdowns" + SportsMarketMetricReceivingYards SportsMarketMetric = "receiving_yards" + SportsMarketMetricReceptions SportsMarketMetric = "receptions" + SportsMarketMetricRedCards SportsMarketMetric = "red_cards" + SportsMarketMetricRetirements SportsMarketMetric = "retirements" + SportsMarketMetricRounds SportsMarketMetric = "rounds" + SportsMarketMetricRuns SportsMarketMetric = "runs" + SportsMarketMetricRunsBattedIn SportsMarketMetric = "runs_batted_in" + SportsMarketMetricRunsConceded SportsMarketMetric = "runs_conceded" + SportsMarketMetricRushAttempts SportsMarketMetric = "rush_attempts" + SportsMarketMetricRushingTouchdowns SportsMarketMetric = "rushing_touchdowns" + SportsMarketMetricRushingYards SportsMarketMetric = "rushing_yards" + SportsMarketMetricSacks SportsMarketMetric = "sacks" + SportsMarketMetricSafetyCars SportsMarketMetric = "safety_cars" + SportsMarketMetricSaves SportsMarketMetric = "saves" + SportsMarketMetricSets SportsMarketMetric = "sets" + SportsMarketMetricShots SportsMarketMetric = "shots" + SportsMarketMetricShotsOnGoal SportsMarketMetric = "shots_on_goal" + SportsMarketMetricShotsOnTarget SportsMarketMetric = "shots_on_target" + SportsMarketMetricShutouts SportsMarketMetric = "shutouts" + SportsMarketMetricSignificantStrikes SportsMarketMetric = "significant_strikes" + SportsMarketMetricSingles SportsMarketMetric = "singles" + SportsMarketMetricSixes SportsMarketMetric = "sixes" + SportsMarketMetricSteals SportsMarketMetric = "steals" + SportsMarketMetricStolenBases SportsMarketMetric = "stolen_bases" + SportsMarketMetricStrikeouts SportsMarketMetric = "strikeouts" + SportsMarketMetricStrokes SportsMarketMetric = "strokes" + SportsMarketMetricSubmissionAttempts SportsMarketMetric = "submission_attempts" + SportsMarketMetricTackles SportsMarketMetric = "tackles" + SportsMarketMetricTakedowns SportsMarketMetric = "takedowns" + SportsMarketMetricThreePointersMade SportsMarketMetric = "three_pointers_made" + SportsMarketMetricTiebreaksWon SportsMarketMetric = "tiebreaks_won" + SportsMarketMetricTotalBases SportsMarketMetric = "total_bases" + SportsMarketMetricTotalPointsWon SportsMarketMetric = "total_points_won" + SportsMarketMetricTotalStrikes SportsMarketMetric = "total_strikes" + SportsMarketMetricTouchdowns SportsMarketMetric = "touchdowns" + SportsMarketMetricTripleDouble SportsMarketMetric = "triple_double" + SportsMarketMetricTriples SportsMarketMetric = "triples" + SportsMarketMetricTurnovers SportsMarketMetric = "turnovers" + SportsMarketMetricWalks SportsMarketMetric = "walks" + SportsMarketMetricWickets SportsMarketMetric = "wickets" + SportsMarketMetricWins SportsMarketMetric = "wins" + SportsMarketMetricYellowCards SportsMarketMetric = "yellow_cards" +) + +// Valid indicates whether the value is a known member of the SportsMarketMetric enum. +func (e SportsMarketMetric) Valid() bool { + switch e { + case SportsMarketMetricAces: + return true + case SportsMarketMetricAssists: + return true + case SportsMarketMetricBallsFaced: + return true + case SportsMarketMetricBirdies: + return true + case SportsMarketMetricBlockedShots: + return true + case SportsMarketMetricBlocks: + return true + case SportsMarketMetricBogeys: + return true + case SportsMarketMetricBoundaries: + return true + case SportsMarketMetricBreakPointsWon: + return true + case SportsMarketMetricCards: + return true + case SportsMarketMetricCatches: + return true + case SportsMarketMetricCleanSheets: + return true + case SportsMarketMetricCompletedPasses: + return true + case SportsMarketMetricControlTime: + return true + case SportsMarketMetricCorners: + return true + case SportsMarketMetricDefensiveRebounds: + return true + case SportsMarketMetricDoubleDouble: + return true + case SportsMarketMetricDoubleFaults: + return true + case SportsMarketMetricDoubles: + return true + case SportsMarketMetricEagles: + return true + case SportsMarketMetricEarnedRuns: + return true + case SportsMarketMetricErrors: + return true + case SportsMarketMetricFaceoffWins: + return true + case SportsMarketMetricFairwaysHit: + return true + case SportsMarketMetricFantasyPoints: + return true + case SportsMarketMetricFastestLap: + return true + case SportsMarketMetricFieldGoalsMade: + return true + case SportsMarketMetricFinishingPosition: + return true + case SportsMarketMetricFouls: + return true + case SportsMarketMetricFours: + return true + case SportsMarketMetricFreeThrowsMade: + return true + case SportsMarketMetricFumbles: + return true + case SportsMarketMetricGames: + return true + case SportsMarketMetricGoals: + return true + case SportsMarketMetricGoalsAllowed: + return true + case SportsMarketMetricGreensInRegulation: + return true + case SportsMarketMetricGridPosition: + return true + case SportsMarketMetricHits: + return true + case SportsMarketMetricHitsAllowed: + return true + case SportsMarketMetricHitsRunsRbis: + return true + case SportsMarketMetricHolesInOne: + return true + case SportsMarketMetricHomeRuns: + return true + case SportsMarketMetricInningsPitched: + return true + case SportsMarketMetricInterceptionsThrown: + return true + case SportsMarketMetricKickingPoints: + return true + case SportsMarketMetricKnockdowns: + return true + case SportsMarketMetricLapTime: + return true + case SportsMarketMetricLapsCompleted: + return true + case SportsMarketMetricLapsLed: + return true + case SportsMarketMetricLongestPassCompletion: + return true + case SportsMarketMetricLongestReception: + return true + case SportsMarketMetricLongestRush: + return true + case SportsMarketMetricMaidenOvers: + return true + case SportsMarketMetricOffensiveRebounds: + return true + case SportsMarketMetricOffsides: + return true + case SportsMarketMetricOther: + return true + case SportsMarketMetricPars: + return true + case SportsMarketMetricPassAttempts: + return true + case SportsMarketMetricPassCompletions: + return true + case SportsMarketMetricPasses: + return true + case SportsMarketMetricPassingTouchdowns: + return true + case SportsMarketMetricPassingYards: + return true + case SportsMarketMetricPenaltyMinutes: + return true + case SportsMarketMetricPitStops: + return true + case SportsMarketMetricPitchingOutsRecorded: + return true + case SportsMarketMetricPoints: + return true + case SportsMarketMetricPointsAssists: + return true + case SportsMarketMetricPointsRebounds: + return true + case SportsMarketMetricPointsReboundsAssists: + return true + case SportsMarketMetricPositionsGained: + return true + case SportsMarketMetricPowerPlayPoints: + return true + case SportsMarketMetricPutts: + return true + case SportsMarketMetricQualifyingPosition: + return true + case SportsMarketMetricRebounds: + return true + case SportsMarketMetricReboundsAssists: + return true + case SportsMarketMetricReceivingTouchdowns: + return true + case SportsMarketMetricReceivingYards: + return true + case SportsMarketMetricReceptions: + return true + case SportsMarketMetricRedCards: + return true + case SportsMarketMetricRetirements: + return true + case SportsMarketMetricRounds: + return true + case SportsMarketMetricRuns: + return true + case SportsMarketMetricRunsBattedIn: + return true + case SportsMarketMetricRunsConceded: + return true + case SportsMarketMetricRushAttempts: + return true + case SportsMarketMetricRushingTouchdowns: + return true + case SportsMarketMetricRushingYards: + return true + case SportsMarketMetricSacks: + return true + case SportsMarketMetricSafetyCars: + return true + case SportsMarketMetricSaves: + return true + case SportsMarketMetricSets: + return true + case SportsMarketMetricShots: + return true + case SportsMarketMetricShotsOnGoal: + return true + case SportsMarketMetricShotsOnTarget: + return true + case SportsMarketMetricShutouts: + return true + case SportsMarketMetricSignificantStrikes: + return true + case SportsMarketMetricSingles: + return true + case SportsMarketMetricSixes: + return true + case SportsMarketMetricSteals: + return true + case SportsMarketMetricStolenBases: + return true + case SportsMarketMetricStrikeouts: + return true + case SportsMarketMetricStrokes: + return true + case SportsMarketMetricSubmissionAttempts: + return true + case SportsMarketMetricTackles: + return true + case SportsMarketMetricTakedowns: + return true + case SportsMarketMetricThreePointersMade: + return true + case SportsMarketMetricTiebreaksWon: + return true + case SportsMarketMetricTotalBases: + return true + case SportsMarketMetricTotalPointsWon: + return true + case SportsMarketMetricTotalStrikes: + return true + case SportsMarketMetricTouchdowns: + return true + case SportsMarketMetricTripleDouble: + return true + case SportsMarketMetricTriples: + return true + case SportsMarketMetricTurnovers: + return true + case SportsMarketMetricWalks: + return true + case SportsMarketMetricWickets: + return true + case SportsMarketMetricWins: + return true + case SportsMarketMetricYellowCards: + return true + default: + return false + } +} + +// Defines values for SportsMarketScopeType. +const ( + SportsMarketScopeTypeCompetition SportsMarketScopeType = "competition" + SportsMarketScopeTypeFullContest SportsMarketScopeType = "full_contest" + SportsMarketScopeTypeGame SportsMarketScopeType = "game" + SportsMarketScopeTypeHalf SportsMarketScopeType = "half" + SportsMarketScopeTypeHole SportsMarketScopeType = "hole" + SportsMarketScopeTypeInning SportsMarketScopeType = "inning" + SportsMarketScopeTypeLap SportsMarketScopeType = "lap" + SportsMarketScopeTypeMatchDay SportsMarketScopeType = "match_day" + SportsMarketScopeTypeOther SportsMarketScopeType = "other" + SportsMarketScopeTypeOver SportsMarketScopeType = "over" + SportsMarketScopeTypePeriod SportsMarketScopeType = "period" + SportsMarketScopeTypePowerplay SportsMarketScopeType = "powerplay" + SportsMarketScopeTypePractice SportsMarketScopeType = "practice" + SportsMarketScopeTypeQualifying SportsMarketScopeType = "qualifying" + SportsMarketScopeTypeQuarter SportsMarketScopeType = "quarter" + SportsMarketScopeTypeRace SportsMarketScopeType = "race" + SportsMarketScopeTypeRegulation SportsMarketScopeType = "regulation" + SportsMarketScopeTypeRound SportsMarketScopeType = "round" + SportsMarketScopeTypeSeason SportsMarketScopeType = "season" + SportsMarketScopeTypeSeries SportsMarketScopeType = "series" + SportsMarketScopeTypeSession SportsMarketScopeType = "session" + SportsMarketScopeTypeSet SportsMarketScopeType = "set" + SportsMarketScopeTypeSprint SportsMarketScopeType = "sprint" + SportsMarketScopeTypeStage SportsMarketScopeType = "stage" + SportsMarketScopeTypeSuperOver SportsMarketScopeType = "super_over" + SportsMarketScopeTypeTeamInnings SportsMarketScopeType = "team_innings" + SportsMarketScopeTypeTournament SportsMarketScopeType = "tournament" +) + +// Valid indicates whether the value is a known member of the SportsMarketScopeType enum. +func (e SportsMarketScopeType) Valid() bool { + switch e { + case SportsMarketScopeTypeCompetition: + return true + case SportsMarketScopeTypeFullContest: + return true + case SportsMarketScopeTypeGame: + return true + case SportsMarketScopeTypeHalf: + return true + case SportsMarketScopeTypeHole: + return true + case SportsMarketScopeTypeInning: + return true + case SportsMarketScopeTypeLap: + return true + case SportsMarketScopeTypeMatchDay: + return true + case SportsMarketScopeTypeOther: + return true + case SportsMarketScopeTypeOver: + return true + case SportsMarketScopeTypePeriod: + return true + case SportsMarketScopeTypePowerplay: + return true + case SportsMarketScopeTypePractice: + return true + case SportsMarketScopeTypeQualifying: + return true + case SportsMarketScopeTypeQuarter: + return true + case SportsMarketScopeTypeRace: + return true + case SportsMarketScopeTypeRegulation: + return true + case SportsMarketScopeTypeRound: + return true + case SportsMarketScopeTypeSeason: + return true + case SportsMarketScopeTypeSeries: + return true + case SportsMarketScopeTypeSession: + return true + case SportsMarketScopeTypeSet: + return true + case SportsMarketScopeTypeSprint: + return true + case SportsMarketScopeTypeStage: + return true + case SportsMarketScopeTypeSuperOver: + return true + case SportsMarketScopeTypeTeamInnings: + return true + case SportsMarketScopeTypeTournament: + return true + default: + return false + } +} + +// Defines values for SportsMarketSport. +const ( + AmericanFootball SportsMarketSport = "american_football" + Athletics SportsMarketSport = "athletics" + AustralianRulesFootball SportsMarketSport = "australian_rules_football" + Baseball SportsMarketSport = "baseball" + Basketball SportsMarketSport = "basketball" + Boxing SportsMarketSport = "boxing" + Chess SportsMarketSport = "chess" + Cricket SportsMarketSport = "cricket" + Cycling SportsMarketSport = "cycling" + Darts SportsMarketSport = "darts" + Esports SportsMarketSport = "esports" + Golf SportsMarketSport = "golf" + Hockey SportsMarketSport = "hockey" + Lacrosse SportsMarketSport = "lacrosse" + MixedMartialArts SportsMarketSport = "mixed_martial_arts" + Motorsports SportsMarketSport = "motorsports" + Rugby SportsMarketSport = "rugby" + Sailing SportsMarketSport = "sailing" + Soccer SportsMarketSport = "soccer" + Tennis SportsMarketSport = "tennis" +) + +// Valid indicates whether the value is a known member of the SportsMarketSport enum. +func (e SportsMarketSport) Valid() bool { + switch e { + case AmericanFootball: + return true + case Athletics: + return true + case AustralianRulesFootball: + return true + case Baseball: + return true + case Basketball: + return true + case Boxing: + return true + case Chess: + return true + case Cricket: + return true + case Cycling: + return true + case Darts: + return true + case Esports: + return true + case Golf: + return true + case Hockey: + return true + case Lacrosse: + return true + case MixedMartialArts: + return true + case Motorsports: + return true + case Rugby: + return true + case Sailing: + return true + case Soccer: + return true + case Tennis: + return true + default: + return false + } +} + +// Defines values for SportsMarketSubject. +const ( + SportsMarketSubjectContest SportsMarketSubject = "contest" + SportsMarketSubjectOther SportsMarketSubject = "other" + SportsMarketSubjectParticipant SportsMarketSubject = "participant" + SportsMarketSubjectPlayer SportsMarketSubject = "player" + SportsMarketSubjectTeam SportsMarketSubject = "team" +) + +// Valid indicates whether the value is a known member of the SportsMarketSubject enum. +func (e SportsMarketSubject) Valid() bool { + switch e { + case SportsMarketSubjectContest: + return true + case SportsMarketSubjectOther: + return true + case SportsMarketSubjectParticipant: + return true + case SportsMarketSubjectPlayer: + return true + case SportsMarketSubjectTeam: + return true + default: + return false + } +} + +// Defines values for SportsMarketType. +const ( + SportsMarketTypeCorrectScore SportsMarketType = "correct_score" + SportsMarketTypeFutures SportsMarketType = "futures" + SportsMarketTypeMoneyline SportsMarketType = "moneyline" + SportsMarketTypeOther SportsMarketType = "other" + SportsMarketTypeProp SportsMarketType = "prop" + SportsMarketTypeSpread SportsMarketType = "spread" + SportsMarketTypeToAdvance SportsMarketType = "to_advance" + SportsMarketTypeTotal SportsMarketType = "total" +) + +// Valid indicates whether the value is a known member of the SportsMarketType enum. +func (e SportsMarketType) Valid() bool { + switch e { + case SportsMarketTypeCorrectScore: + return true + case SportsMarketTypeFutures: + return true + case SportsMarketTypeMoneyline: + return true + case SportsMarketTypeOther: + return true + case SportsMarketTypeProp: + return true + case SportsMarketTypeSpread: + return true + case SportsMarketTypeToAdvance: + return true + case SportsMarketTypeTotal: + return true + default: + return false + } +} + +// Defines values for StrikeType. +const ( + Above StrikeType = "above" + Over StrikeType = "over" + OverOrEqual StrikeType = "over_or_equal" + Reference StrikeType = "reference" + Spread StrikeType = "spread" + Under StrikeType = "under" + UnderOrEqual StrikeType = "under_or_equal" +) + +// Valid indicates whether the value is a known member of the StrikeType enum. +func (e StrikeType) Valid() bool { + switch e { + case Above: + return true + case Over: + return true + case OverOrEqual: + return true + case Reference: + return true + case Spread: + return true + case Under: + return true + case UnderOrEqual: + return true + default: + return false + } +} + +// Defines values for TermsNotAcceptedErrorError. +const ( + TERMSNOTACCEPTED TermsNotAcceptedErrorError = "TERMS_NOT_ACCEPTED" +) + +// Valid indicates whether the value is a known member of the TermsNotAcceptedErrorError enum. +func (e TermsNotAcceptedErrorError) Valid() bool { + switch e { + case TERMSNOTACCEPTED: + return true + default: + return false + } +} + +// Defines values for TermsNotAcceptedErrorMessage. +const ( + PredictionMarketsTermsMustBeAcceptedBeforePlacingOrders TermsNotAcceptedErrorMessage = "Prediction markets terms must be accepted before placing orders" +) + +// Valid indicates whether the value is a known member of the TermsNotAcceptedErrorMessage enum. +func (e TermsNotAcceptedErrorMessage) Valid() bool { + switch e { + case PredictionMarketsTermsMustBeAcceptedBeforePlacingOrders: + return true + default: + return false + } +} + +// Defines values for TimeInForce. +const ( + TimeInForceFillOrKill TimeInForce = "fill-or-kill" + TimeInForceGoodTilCancel TimeInForce = "good-til-cancel" + TimeInForceImmediateOrCancel TimeInForce = "immediate-or-cancel" +) + +// Valid indicates whether the value is a known member of the TimeInForce enum. +func (e TimeInForce) Valid() bool { + switch e { + case TimeInForceFillOrKill: + return true + case TimeInForceGoodTilCancel: + return true + case TimeInForceImmediateOrCancel: + return true + default: + return false + } +} + +// Defines values for ListLiquidityRewardsEventsParamsSort. +const ( + CategoryAsc ListLiquidityRewardsEventsParamsSort = "category_asc" + CategoryDesc ListLiquidityRewardsEventsParamsSort = "category_desc" + CompetitionAsc ListLiquidityRewardsEventsParamsSort = "competition_asc" + CompetitionDesc ListLiquidityRewardsEventsParamsSort = "competition_desc" + DailyPoolAsc ListLiquidityRewardsEventsParamsSort = "daily_pool_asc" + DailyPoolDesc ListLiquidityRewardsEventsParamsSort = "daily_pool_desc" + EndsLatest ListLiquidityRewardsEventsParamsSort = "ends_latest" + EndsSoonest ListLiquidityRewardsEventsParamsSort = "ends_soonest" + TitleAsc ListLiquidityRewardsEventsParamsSort = "title_asc" + TitleDesc ListLiquidityRewardsEventsParamsSort = "title_desc" +) + +// Valid indicates whether the value is a known member of the ListLiquidityRewardsEventsParamsSort enum. +func (e ListLiquidityRewardsEventsParamsSort) Valid() bool { + switch e { + case CategoryAsc: + return true + case CategoryDesc: + return true + case CompetitionAsc: + return true + case CompetitionDesc: + return true + case DailyPoolAsc: + return true + case DailyPoolDesc: + return true + case EndsLatest: + return true + case EndsSoonest: + return true + case TitleAsc: + return true + case TitleDesc: + return true + default: + return false + } +} + +// Defines values for GetOrderHistoryJSONBodyStatus. +const ( + GetOrderHistoryJSONBodyStatusCancelled GetOrderHistoryJSONBodyStatus = "cancelled" + GetOrderHistoryJSONBodyStatusFilled GetOrderHistoryJSONBodyStatus = "filled" +) + +// Valid indicates whether the value is a known member of the GetOrderHistoryJSONBodyStatus enum. +func (e GetOrderHistoryJSONBodyStatus) Valid() bool { + switch e { + case GetOrderHistoryJSONBodyStatusCancelled: + return true + case GetOrderHistoryJSONBodyStatusFilled: + return true + default: + return false + } +} + +// Defines values for GetPositionsParamsSort. +const ( + ExpiryDate GetPositionsParamsSort = "expiryDate" + MinusExpiryDate GetPositionsParamsSort = "-expiryDate" + MinusPositionValue GetPositionsParamsSort = "-positionValue" + MinusUnrealizedPnl GetPositionsParamsSort = "-unrealizedPnl" + PlusExpiryDate GetPositionsParamsSort = "+expiryDate" + PlusPositionValue GetPositionsParamsSort = "+positionValue" + PlusUnrealizedPnl GetPositionsParamsSort = "+unrealizedPnl" + PositionValue GetPositionsParamsSort = "positionValue" + UnrealizedPnl GetPositionsParamsSort = "unrealizedPnl" +) + +// Valid indicates whether the value is a known member of the GetPositionsParamsSort enum. +func (e GetPositionsParamsSort) Valid() bool { + switch e { + case ExpiryDate: + return true + case MinusExpiryDate: + return true + case MinusPositionValue: + return true + case MinusUnrealizedPnl: + return true + case PlusExpiryDate: + return true + case PlusPositionValue: + return true + case PlusUnrealizedPnl: + return true + case PositionValue: + return true + case UnrealizedPnl: + return true + default: + return false + } +} + +// Defines values for GetSettledPositionsParamsSort. +const ( + Date GetSettledPositionsParamsSort = "date" + MinusDate GetSettledPositionsParamsSort = "-date" + MinusPayout GetSettledPositionsParamsSort = "-payout" + Payout GetSettledPositionsParamsSort = "payout" + PlusPayout GetSettledPositionsParamsSort = "+payout" +) + +// Valid indicates whether the value is a known member of the GetSettledPositionsParamsSort enum. +func (e GetSettledPositionsParamsSort) Valid() bool { + switch e { + case Date: + return true + case MinusDate: + return true + case MinusPayout: + return true + case Payout: + return true + case PlusPayout: + return true + default: + return false + } +} + +// AcceptPredictionMarketsTermsResponse defines model for AcceptPredictionMarketsTermsResponse. +type AcceptPredictionMarketsTermsResponse struct { + Success bool `json:"success"` +} + +// AccountGroupBlockedError defines model for AccountGroupBlockedError. +type AccountGroupBlockedError struct { + Code AccountGroupBlockedErrorCode `json:"code"` + Error AccountGroupBlockedErrorError `json:"error"` +} + +// AccountGroupBlockedErrorCode defines model for AccountGroupBlockedError.Code. +type AccountGroupBlockedErrorCode string + +// AccountGroupBlockedErrorError defines model for AccountGroupBlockedError.Error. +type AccountGroupBlockedErrorError string + +// AuthErrorResponse defines model for AuthErrorResponse. +type AuthErrorResponse struct { + // Message Human-readable authentication or authorization detail + Message string `json:"message"` + + // Reason Authentication or authorization error class + Reason string `json:"reason"` + Result AuthErrorResponseResult `json:"result"` +} + +// AuthErrorResponseResult defines model for AuthErrorResponse.Result. +type AuthErrorResponseResult string + +// BatchOrderResponse An accepted order returned for one batch entry. +type BatchOrderResponse struct { + // AvgExecutionPrice Average price of fills; omitted when unavailable + AvgExecutionPrice *string `json:"avgExecutionPrice,omitempty"` + + // CancelledAt Cancellation time; omitted unless the order was cancelled + CancelledAt *time.Time `json:"cancelledAt,omitempty"` + + // ClientOrderId Client-provided order ID; omitted when unavailable + ClientOrderId *string `json:"clientOrderId,omitempty"` + ContractMetadata *ContractMetadata `json:"contractMetadata,omitempty"` + CreatedAt time.Time `json:"createdAt"` + + // FilledQuantity Amount filled so far + FilledQuantity string `json:"filledQuantity"` + + // FundsOnHold Cash reserved for the unfilled portion of a resting buy order; omitted when unavailable + FundsOnHold *string `json:"fundsOnHold,omitempty"` + + // GlobalOrderId Global order ID; omitted when unavailable + GlobalOrderId *string `json:"globalOrderId,omitempty"` + + // HashOrderId Hashed order ID; omitted when unavailable + HashOrderId *string `json:"hashOrderId,omitempty"` + OrderId int64 `json:"orderId"` + + // OrderType Order type. `stop-limit` orders require a `stopPrice` that triggers a limit order at `price` when the market reaches the trigger. + OrderType OrderType `json:"orderType"` + + // Outcome The outcome being traded (Yes or No) + Outcome Outcome `json:"outcome"` + + // Price Limit price + Price string `json:"price"` + + // PromoCashApplied Promotional cash reserved or applied to the order; omitted when unavailable + PromoCashApplied *string `json:"promoCashApplied,omitempty"` + + // Quantity Original order quantity + Quantity string `json:"quantity"` + + // RemainingQuantity Amount remaining to fill + RemainingQuantity string `json:"remainingQuantity"` + Side OrderSide `json:"side"` + Status BatchOrderResponseStatus `json:"status"` + + // StopPrice Stop trigger price; omitted unless populated for a `stop-limit` order + StopPrice *string `json:"stopPrice,omitempty"` + Symbol string `json:"symbol"` + TimeInForce BatchOrderResponseTimeInForce `json:"timeInForce"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// BatchOrderResponseStatus defines model for BatchOrderResponse.Status. +type BatchOrderResponseStatus string + +// BatchOrderResponseTimeInForce defines model for BatchOrderResponse.TimeInForce. +type BatchOrderResponseTimeInForce string + +// CancelOrderBatchErrorResult defines model for CancelOrderBatchErrorResult. +type CancelOrderBatchErrorResult struct { + // Error Error class for a rejected cancellation + Error string `json:"error"` + + // Message Human-readable detail for a rejected cancellation + Message string `json:"message"` + + // OrderId Order ID from the corresponding request entry. + OrderId int64 `json:"orderId"` +} + +// CancelOrderBatchRequest defines model for CancelOrderBatchRequest. +type CancelOrderBatchRequest struct { + // OrderIds Order IDs to cancel. Each ID may be an integer or a quoted numeric string. All IDs are validated before any cancellation is attempted. + OrderIds []CancelOrderBatchRequest_OrderIds_Item `json:"orderIds"` +} + +// CancelOrderBatchRequestOrderIds0 defines model for . +type CancelOrderBatchRequestOrderIds0 = int64 + +// CancelOrderBatchRequestOrderIds1 defines model for . +type CancelOrderBatchRequestOrderIds1 = string + +// CancelOrderBatchRequest_OrderIds_Item defines model for CancelOrderBatchRequest.orderIds.Item. +type CancelOrderBatchRequest_OrderIds_Item struct { + union json.RawMessage +} + +// CancelOrderBatchResponse defines model for CancelOrderBatchResponse. +type CancelOrderBatchResponse struct { + // Results One result for each requested cancellation, in request order. + Results []CancelOrderBatchResult `json:"results"` +} + +// CancelOrderBatchResult Exactly one outcome is present. Successful entries contain `orderId` and `result`; rejected entries contain `orderId`, `error`, and `message`. +type CancelOrderBatchResult struct { + union json.RawMessage +} + +// CancelOrderBatchSuccessResult defines model for CancelOrderBatchSuccessResult. +type CancelOrderBatchSuccessResult struct { + // OrderId Order ID from the corresponding request entry. + OrderId int64 `json:"orderId"` + Result CancelOrderBatchSuccessResultResult `json:"result"` +} + +// CancelOrderBatchSuccessResultResult defines model for CancelOrderBatchSuccessResult.Result. +type CancelOrderBatchSuccessResultResult string + +// CashedOutPosition A qualifying cash-out (early sell before contract resolution) with cost-basis context. Exposed only via the `withCashOuts=true` sibling array on `POST /v1/prediction-markets/positions/settled`. Distinct from `SettledPosition` — cash-outs don't have a `payout` or `resolutionSide` since the contract hadn't resolved when the user sold. +type CashedOutPosition struct { + // AccountId Account that held the position. + AccountId int64 `json:"accountId"` + ContractMetadata *ContractMetadata `json:"contractMetadata,omitempty"` + + // CostBasis Cost basis allocated proportionally to the filled quantity (`(costBasisSpend / costBasisPositionBalance) * filledQuantity`). + CostBasis string `json:"costBasis"` + + // FilledQuantity Quantity sold (cumulative filled quantity on the cash-out order). + FilledQuantity string `json:"filledQuantity"` + + // InstrumentId Contract instrument ID. + InstrumentId int64 `json:"instrumentId"` + + // InstrumentSymbol Contract instrument symbol. + InstrumentSymbol string `json:"instrumentSymbol"` + + // NetProfit Realized P&L from this cash-out fill (`proceeds - costBasis`). Equals the ledger `realized_pl` delta on the position-balance row pair around the fill; falls back to `0` under transient market-data lag so a missing post-fill snapshot can't poison the page. + NetProfit string `json:"netProfit"` + + // Proceeds Amount received from the sale in USD. For prediction sells, proceeds flow through `cash_balance` rather than `closed_orders.total_spend`, so the value is derived from position-balance snapshots before/after the fill. + Proceeds string `json:"proceeds"` + + // Side Always `sell` for cash-outs. + Side CashedOutPositionSide `json:"side"` + + // Timestamp Wall-clock timestamp when the cash-out order closed (ISO 8601). + Timestamp time.Time `json:"timestamp"` +} + +// CashedOutPositionSide Always `sell` for cash-outs. +type CashedOutPositionSide string + +// ComboLeg defines model for ComboLeg. +type ComboLeg struct { + // ComboId Internal ID of the parent combo contract + ComboId int64 `json:"comboId"` + + // Contract Full metadata for the underlying single contract + Contract *ContractMetadata `json:"contract,omitempty"` + + // ContractId Internal ID of the underlying single contract, represented as a decimal string + ContractId string `json:"contractId"` + + // LegIndex Zero-based position of this leg in the combo + LegIndex int `json:"legIndex"` + + // LegOutcome The outcome this leg has settled to, if resolved (`"Yes"` or `"No"`). Null while the leg is still active. + LegOutcome *string `json:"legOutcome,omitempty"` + + // RequiredOutcome The outcome this leg must settle for the combo to settle YES + RequiredOutcome ComboLegRequiredOutcome `json:"requiredOutcome"` + + // ResolvedAt UTC timestamp when this leg resolved. Null while still active. + ResolvedAt *time.Time `json:"resolvedAt,omitempty"` +} + +// ComboLegRequiredOutcome The outcome this leg must settle for the combo to settle YES +type ComboLegRequiredOutcome string + +// ComboResponse defines model for ComboResponse. +type ComboResponse struct { + // Contract Metadata for the combo contract itself (ticker, status, expiry, etc.) + Contract ContractMetadata `json:"contract"` + + // Legs Ordered list of legs that make up this combo + Legs []ComboLeg `json:"legs"` +} + +// ComboSummary defines model for ComboSummary. +type ComboSummary struct { + // CanonicalLegKey Canonical identity of the complete combo leg set. + CanonicalLegKey string `json:"canonicalLegKey"` + + // CreatedAt Creation time, when available. + CreatedAt *time.Time `json:"createdAt,omitempty"` + + // DisplayName Human-readable combo name, when available. + DisplayName *string `json:"displayName,omitempty"` + + // Id Internal combo ID. + Id int64 `json:"id"` + + // InstrumentId Associated instrument ID, when available. + InstrumentId *int64 `json:"instrumentId,omitempty"` + + // InstrumentRegistered Whether the combo has been registered with an instrument symbol. + InstrumentRegistered bool `json:"instrumentRegistered"` + + // InstrumentSymbol Associated instrument symbol, when available. + InstrumentSymbol *string `json:"instrumentSymbol,omitempty"` + + // LatestExpiryDate Latest expiry among the underlying legs, when available. + LatestExpiryDate *time.Time `json:"latestExpiryDate,omitempty"` + + // LegCount Number of legs in the combo. + LegCount int32 `json:"legCount"` + + // Legs Canonically ordered combo legs. + Legs []ComboSummaryLeg `json:"legs"` + + // Status Current combo status, when available. + Status *string `json:"status,omitempty"` + + // UpdatedAt Most recent update time, when available. + UpdatedAt *time.Time `json:"updatedAt,omitempty"` +} + +// ComboSummaryLeg defines model for ComboSummaryLeg. +type ComboSummaryLeg struct { + // ComboId Parent combo ID. + ComboId int64 `json:"comboId"` + + // Contract Underlying contract metadata, when available. + Contract *ContractMetadata `json:"contract,omitempty"` + + // ContractId Underlying contract ID as a decimal string. + ContractId string `json:"contractId"` + + // LegIndex Zero-based leg position in canonical order. + LegIndex int32 `json:"legIndex"` + + // LegOutcome Settled outcome for the leg, when resolved. + LegOutcome *ComboSummaryLegLegOutcome `json:"legOutcome,omitempty"` + + // RequiredOutcome Required settlement outcome for the leg. + RequiredOutcome ComboSummaryLegRequiredOutcome `json:"requiredOutcome"` + + // ResolvedAt Resolution time for the leg, when resolved. + ResolvedAt *time.Time `json:"resolvedAt,omitempty"` +} + +// ComboSummaryLegLegOutcome Settled outcome for the leg, when resolved. +type ComboSummaryLegLegOutcome string + +// ComboSummaryLegRequiredOutcome Required settlement outcome for the leg. +type ComboSummaryLegRequiredOutcome string + +// ComboWriteError defines model for ComboWriteError. +type ComboWriteError struct { + // Code Machine-readable code for validation or missing-leg errors, when available. + Code *string `json:"code,omitempty"` + + // Error Error class. + Error string `json:"error"` + + // Message Human-readable error detail. + Message string `json:"message"` +} + +// Contract Contract quantity and price validation is instrument-specific. Clients must validate order quantities and prices against the returned increment and minimum fields rather than assuming a fixed grid. +type Contract struct { + // AbbreviatedName Short form label (e.g., ">$90") + AbbreviatedName *string `json:"abbreviatedName,omitempty"` + Color *string `json:"color,omitempty"` + CreatedAt *time.Time `json:"createdAt,omitempty"` + + // Description Rich text description + Description *map[string]interface{} `json:"description,omitempty"` + EffectiveDate *time.Time `json:"effectiveDate,omitempty"` + ExpiryDate *time.Time `json:"expiryDate,omitempty"` + Id *string `json:"id,omitempty"` + ImageUrl *string `json:"imageUrl,omitempty"` + InstrumentSymbol *string `json:"instrumentSymbol,omitempty"` + + // Label Human-readable label for the contract's YES-space proposition (e.g., "SOL > $90") + Label *string `json:"label,omitempty"` + + // MarketState Trading state of the contract + MarketState *ContractMarketState `json:"marketState,omitempty"` + PriceHistory *[]PricePoint `json:"priceHistory,omitempty"` + + // PriceIncrement Contract price grid from instrument refdata (for example, "0.0001"). + PriceIncrement *string `json:"priceIncrement,omitempty"` + + // PriceMinimum Minimum contract price and anchor for the instrument price grid (for example, "0.0001"). + PriceMinimum *string `json:"priceMinimum,omitempty"` + + // Prices Current bid/ask pricing for the contract + Prices *ContractPrices `json:"prices,omitempty"` + + // QuantityIncrement Contract quantity grid from instrument refdata (for example, "0.01"). + QuantityIncrement *string `json:"quantityIncrement,omitempty"` + + // QuantityMinimum Minimum contract quantity from instrument refdata (for example, "1.00"). + QuantityMinimum *string `json:"quantityMinimum,omitempty"` + + // QuoteAssetPrecision Decimal places supported by the instrument's quote asset. + QuoteAssetPrecision *int `json:"quoteAssetPrecision,omitempty"` + + // ResolutionSide The outcome being traded (Yes or No) + ResolutionSide *Outcome `json:"resolutionSide,omitempty"` + ResolvedAt *time.Time `json:"resolvedAt,omitempty"` + + // SettlementValue The observed settlement price. Only present after the contract is settled. + SettlementValue *string `json:"settlementValue,omitempty"` + + // SortOrder Display order within the event + SortOrder *int `json:"sortOrder,omitempty"` + + // Source Deprecated: use the event-level `sourceDetails` (`agency` + `index`) instead. Data source identifier for price observation (e.g., "GRR-KAIKO_BTCUSD_60S"). Present for crypto Up/Down contracts. + // Deprecated: this property has been marked as deprecated upstream, but no `x-deprecated-reason` was set + Source *string `json:"source,omitempty"` + + // Status Status of a prediction market + Status *MarketStatus `json:"status,omitempty"` + + // Strike Strike price or contract threshold information for Up/Down crypto contracts and sports prediction market contracts. + Strike *Strike `json:"strike,omitempty"` + TermsAndConditionsUrl *string `json:"termsAndConditionsUrl,omitempty"` + Ticker *string `json:"ticker,omitempty"` + TotalShares *string `json:"totalShares,omitempty"` +} + +// ContractMarketState Trading state of the contract +type ContractMarketState string + +// ContractMetadata defines model for ContractMetadata. +type ContractMetadata struct { + Category *string `json:"category,omitempty"` + ContractId *string `json:"contractId,omitempty"` + ContractName *string `json:"contractName,omitempty"` + ContractStatus *string `json:"contractStatus,omitempty"` + ContractTicker *string `json:"contractTicker,omitempty"` + EventName *string `json:"eventName,omitempty"` + EventTicker *string `json:"eventTicker,omitempty"` + + // EventType Event type ("binary" or "categorical") + EventType *string `json:"eventType,omitempty"` + ExpiryDate *time.Time `json:"expiryDate,omitempty"` + + // ParentEventTicker Parent event ticker for sub-events + ParentEventTicker *string `json:"parentEventTicker,omitempty"` + + // ResolutionSide Winning outcome if resolved ("yes" or "no") + ResolutionSide *string `json:"resolutionSide,omitempty"` + ResolvedAt *time.Time `json:"resolvedAt,omitempty"` + + // StartTime Start datetime (ISO 8601) + StartTime *time.Time `json:"startTime,omitempty"` +} + +// ContractPrices Current bid/ask pricing for the contract +type ContractPrices struct { + // BestAsk Lowest sell offer + BestAsk *string `json:"bestAsk,omitempty"` + + // BestBid Highest buy offer + BestBid *string `json:"bestBid,omitempty"` + + // Buy Buy prices for each outcome + Buy *struct { + // No Price to buy NO outcome + No *string `json:"no,omitempty"` + + // Yes Price to buy YES outcome + Yes *string `json:"yes,omitempty"` + } `json:"buy,omitempty"` + + // LastTradePrice Most recent transaction price + LastTradePrice *string `json:"lastTradePrice,omitempty"` + + // Sell Sell prices for each outcome + Sell *struct { + // No Price to sell NO outcome + No *string `json:"no,omitempty"` + + // Yes Price to sell YES outcome + Yes *string `json:"yes,omitempty"` + } `json:"sell,omitempty"` +} + +// ContractShareVolume defines model for ContractShareVolume. +type ContractShareVolume struct { + // Symbol Contract instrument symbol + Symbol *string `json:"symbol,omitempty"` + + // TotalQty Total taker volume across all participants (in shares) + TotalQty *string `json:"totalQty,omitempty"` + + // UserAggressorQty The authenticated user's taker (aggressor) volume (in shares) + UserAggressorQty *string `json:"userAggressorQty,omitempty"` + + // UserRestingQty The authenticated user's maker (resting) volume (in shares) + UserRestingQty *string `json:"userRestingQty,omitempty"` +} + +// CreateComboLeg defines model for CreateComboLeg. +type CreateComboLeg struct { + // ContractId Underlying contract ID as a decimal string. + ContractId string `json:"contractId"` + + // RequiredOutcome Required settlement outcome for this leg. + RequiredOutcome CreateComboLegRequiredOutcome `json:"requiredOutcome"` +} + +// CreateComboLegRequiredOutcome Required settlement outcome for this leg. +type CreateComboLegRequiredOutcome string + +// CreateComboRequest A canonical combo definition. The authenticated account is derived from the signed request and is not a request field. +type CreateComboRequest struct { + // Legs Two to six distinct underlying contract legs. The service canonicalizes their complete set, so leg order does not create a distinct combo. + Legs []CreateComboLeg `json:"legs"` +} + +// CreateComboResponse defines model for CreateComboResponse. +type CreateComboResponse struct { + // AlreadyExisted `false` when this request created the canonical combo; `true` when the canonical combo already existed. + AlreadyExisted bool `json:"alreadyExisted"` + Combo ComboSummary `json:"combo"` +} + +// Error defines model for Error. +type Error struct { + // Error Error code + Error *string `json:"error,omitempty"` + + // Message Human-readable error message + Message *string `json:"message,omitempty"` +} + +// Event A prediction market event containing one or more tradeable contracts +type Event struct { + Category *string `json:"category,omitempty"` + ContractOrderbooks *map[string]OrderBook `json:"contractOrderbooks,omitempty"` + + // Contracts Tradeable contracts within this event + Contracts *[]Contract `json:"contracts,omitempty"` + CreatedAt *time.Time `json:"createdAt,omitempty"` + Description *string `json:"description,omitempty"` + EffectiveDate *time.Time `json:"effectiveDate,omitempty"` + ExpiryDate *time.Time `json:"expiryDate,omitempty"` + Id *string `json:"id,omitempty"` + ImageUrl *string `json:"imageUrl,omitempty"` + + // Liquidity Total liquidity in USD + Liquidity *string `json:"liquidity,omitempty"` + ResolvedAt *time.Time `json:"resolvedAt,omitempty"` + Series *string `json:"series,omitempty"` + + // Settlement Settlement information for resolved events + Settlement *Settlement `json:"settlement,omitempty"` + Slug *string `json:"slug,omitempty"` + + // Source Deprecated: use `sourceDetails` (`agency` + `index`) instead. Data source identifier for price observation. Aggregated from contracts for crypto Up/Down events. + // Deprecated: this property has been marked as deprecated upstream, but no `x-deprecated-reason` was set + Source *string `json:"source,omitempty"` + + // SourceDetails Structured data source information for price observation. Replaces the deprecated flat `source` string on the event and contract. Present for crypto Up/Down events. Both fields are omitted when not available. + SourceDetails *SourceDetails `json:"sourceDetails,omitempty"` + + // SportsMarket Atomic sports-market classification shared by every contract grouped under the event. Present only for sports events. All fields except `metric` are required together. + SportsMarket *SportsMarket `json:"sportsMarket,omitempty"` + + // Status Status of a prediction market + Status *MarketStatus `json:"status,omitempty"` + + // Subcategory Nested category information for the event + Subcategory *Subcategory `json:"subcategory,omitempty"` + Tags *[]string `json:"tags,omitempty"` + + // Ticker The event ticker (e.g., "BTC100K2028") + Ticker *string `json:"ticker,omitempty"` + Title *string `json:"title,omitempty"` + + // Type Type of prediction market + Type *MarketType `json:"type,omitempty"` + + // Volume Total trading volume in USD + Volume *string `json:"volume,omitempty"` +} + +// EventsResponse defines model for EventsResponse. +type EventsResponse struct { + Data *[]Event `json:"data,omitempty"` + Pagination *Pagination `json:"pagination,omitempty"` +} + +// LiquidityDailySummary defines model for LiquidityDailySummary. +type LiquidityDailySummary struct { + // Events Per-event score breakdown showing how the day's total was distributed. + Events []LiquidityEventScore `json:"events"` + + // PaidAt ISO-8601 timestamp the day's payout was credited. Always present; `null` if not yet paid. + PaidAt *time.Time `json:"paid_at"` + + // PayoutDate Date the payout applies to (Eastern Time). + PayoutDate openapi_types.Date `json:"payout_date"` + + // PayoutStatus Status of the day's payout (e.g. `PENDING`, `PAID`, `ZERO_AMOUNT`). + PayoutStatus string `json:"payout_status"` + + // TotalRewardUsd Total USD reward for the day across all events the account scored on. + TotalRewardUsd string `json:"total_reward_usd"` +} + +// LiquidityEventScore defines model for LiquidityEventScore. +type LiquidityEventScore struct { + // CategoryName Market category. + CategoryName string `json:"category_name"` + + // EventId Stable event identifier. + EventId int64 `json:"event_id"` + + // EventName Event title. + EventName string `json:"event_name"` + + // EventRewardUsd Portion of the day's total reward attributed to this event. + EventRewardUsd string `json:"event_reward_usd"` + + // NormalizedScore This account's normalized score for the event on the scoring date (0-1 range as a decimal string). + NormalizedScore string `json:"normalized_score"` + + // SnapshotCount Number of snapshots in which this account had a qualifying quote. + SnapshotCount int32 `json:"snapshot_count"` + + // TotalSnapshots Total snapshots taken for the event on the scoring date. + TotalSnapshots int32 `json:"total_snapshots"` +} + +// LiquidityRewardEvent defines model for LiquidityRewardEvent. +type LiquidityRewardEvent struct { + // Category Market category. + Category string `json:"category"` + + // DailyPoolUsd Daily USD reward pool budgeted for this event. + DailyPoolUsd string `json:"daily_pool_usd"` + + // EndsAt ISO-8601 timestamp at which the event ends and stops scoring. `null` when the underlying event has no end timestamp set. + EndsAt *time.Time `json:"ends_at"` + + // EventTicker Event ticker (e.g. `BTC2605202100`). + EventTicker string `json:"event_ticker"` + + // IconUrl Optional URL for the event icon. Omitted when not configured. + IconUrl *string `json:"icon_url,omitempty"` + + // PoolSource Whether the pool came from a per-event override or the category default. + PoolSource LiquidityRewardEventPoolSource `json:"pool_source"` + + // QualifyingMakerCount Number of accounts that met qualifying-maker criteria in the most recent snapshot window for this event. + QualifyingMakerCount int32 `json:"qualifying_maker_count"` + + // Title Event title. + Title string `json:"title"` +} + +// LiquidityRewardEventPoolSource Whether the pool came from a per-event override or the category default. +type LiquidityRewardEventPoolSource string + +// LiquidityRewardsConfig defines model for LiquidityRewardsConfig. +type LiquidityRewardsConfig struct { + // Enabled True when the program is fully configured upstream. When false, the response collapses to `{ "enabled": false }` only. + Enabled bool `json:"enabled"` + + // MaxSpreadCents Quotes wider than this spread score zero in the scoring algorithm. Only present when `enabled` is `true`. + MaxSpreadCents *int32 `json:"max_spread_cents,omitempty"` + + // MinPayoutThresholdUsd Daily reward amounts below this threshold are suppressed (sub-threshold accounts get no row at all). Only present when `enabled` is `true`. + MinPayoutThresholdUsd *string `json:"min_payout_threshold_usd,omitempty"` +} + +// LiquidityRewardsDailySummaryResponse defines model for LiquidityRewardsDailySummaryResponse. +type LiquidityRewardsDailySummaryResponse struct { + DailySummaries []LiquidityDailySummary `json:"daily_summaries"` +} + +// LiquidityRewardsEventsResponse defines model for LiquidityRewardsEventsResponse. +type LiquidityRewardsEventsResponse struct { + Events []LiquidityRewardEvent `json:"events"` + + // LastScoreDate Most recent date for which scoring has been written. `null` when no scoring has run yet. + LastScoreDate *openapi_types.Date `json:"last_score_date"` + Pagination Pagination `json:"pagination"` +} + +// LiquidityRewardsLifetimeSummary defines model for LiquidityRewardsLifetimeSummary. +type LiquidityRewardsLifetimeSummary struct { + // FirstPayoutDate Date of the earliest payout in the window, or `null` if no payouts exist. + FirstPayoutDate *openapi_types.Date `json:"first_payout_date"` + + // LastPayoutDate Date of the most recent payout in the window, or `null` if no payouts exist. + LastPayoutDate *openapi_types.Date `json:"last_payout_date"` + + // PayoutCount Number of daily payouts in the window. Always present; `0` when no payouts exist in the window. + PayoutCount int32 `json:"payout_count"` + + // TotalEarnedUsd Sum of `total_reward_usd` across daily payouts in the window. + TotalEarnedUsd string `json:"total_earned_usd"` +} + +// ListCombosResponse defines model for ListCombosResponse. +type ListCombosResponse struct { + // Combos List of combo contracts matching the query + Combos []ComboResponse `json:"combos"` + Pagination Pagination `json:"pagination"` +} + +// MakerRebateLifetimeSummary defines model for MakerRebateLifetimeSummary. +type MakerRebateLifetimeSummary struct { + // FirstPayoutDate Date of the earliest payout in the window, or `null` if no payouts exist. + FirstPayoutDate *openapi_types.Date `json:"first_payout_date"` + + // LastPayoutDate Date of the most recent payout in the window, or `null` if no payouts exist. + LastPayoutDate *openapi_types.Date `json:"last_payout_date"` + + // PayoutCount Number of payouts in the window. Always present; `0` when no payouts exist in the window. + PayoutCount int32 `json:"payout_count"` + + // TotalEarnedUsd Sum of `total_rebate_usd` across payouts in the window. + TotalEarnedUsd string `json:"total_earned_usd"` + + // TotalFillCount Sum of qualifying maker fills across payouts in the window. + TotalFillCount int64 `json:"total_fill_count"` + + // TotalVolumeUsd Sum of qualifying maker volume (USD) across payouts in the window. + TotalVolumeUsd string `json:"total_volume_usd"` +} + +// MakerRebatePayout defines model for MakerRebatePayout. +type MakerRebatePayout struct { + // CreatedAt ISO-8601 timestamp at which the payout row was created. Always present. + CreatedAt *time.Time `json:"created_at"` + + // Id Stable payout identifier. + Id int64 `json:"id"` + + // PaidAt ISO-8601 timestamp at which the rebate was credited. Always present; `null` for payouts that have not yet been paid. + PaidAt *time.Time `json:"paid_at"` + + // Status Payout status (e.g. `PENDING`, `PAID`). + Status string `json:"status"` + + // TotalFillCount Number of qualifying maker fills that contributed to the payout. + TotalFillCount int32 `json:"total_fill_count"` + + // TotalRebateUsd Total rebate paid, in USD. + TotalRebateUsd string `json:"total_rebate_usd"` + + // TotalVolumeUsd Total qualifying maker volume contributing to this payout, in USD. + TotalVolumeUsd string `json:"total_volume_usd"` +} + +// MakerRebatePayoutsResponse defines model for MakerRebatePayoutsResponse. +type MakerRebatePayoutsResponse struct { + Payouts []MakerRebatePayout `json:"payouts"` +} + +// MakerRebateRateRule defines model for MakerRebateRateRule. +type MakerRebateRateRule struct { + // Category Market category this rule applies to. When absent, the rule applies to all categories. + Category *string `json:"category,omitempty"` + + // EffectiveFrom ISO-8601 timestamp at which this rule becomes effective. Always present; in practice never `null`. + EffectiveFrom *time.Time `json:"effective_from"` + + // EffectiveTo ISO-8601 timestamp after which this rule is superseded. Omitted when the rule is still current. + EffectiveTo *time.Time `json:"effective_to,omitempty"` + + // Id Stable identifier for this rate rule. + Id int64 `json:"id"` + + // RebateMultiplierBps Portion of the maker fee that is rebated, in basis points (10000 bps = 100%). + RebateMultiplierBps int32 `json:"rebate_multiplier_bps"` +} + +// MakerRebateRatesResponse defines model for MakerRebateRatesResponse. +type MakerRebateRatesResponse struct { + RateRules []MakerRebateRateRule `json:"rate_rules"` +} + +// MarketStatus Status of a prediction market +type MarketStatus string + +// MarketType Type of prediction market +type MarketType string + +// OrderBook defines model for OrderBook. +type OrderBook struct { + Asks *[]OrderBookEntry `json:"asks,omitempty"` + Bids *[]OrderBookEntry `json:"bids,omitempty"` +} + +// OrderBookDepth defines model for OrderBookDepth. +type OrderBookDepth struct { + Asks *[]OrderBookLevel `json:"asks,omitempty"` + Bids *[]OrderBookLevel `json:"bids,omitempty"` + LastUpdateTime *time.Time `json:"lastUpdateTime,omitempty"` +} + +// OrderBookEntry defines model for OrderBookEntry. +type OrderBookEntry struct { + Price *string `json:"price,omitempty"` + Quantity *string `json:"quantity,omitempty"` + Side *OrderSide `json:"side,omitempty"` +} + +// OrderBookLevel defines model for OrderBookLevel. +type OrderBookLevel struct { + OrderCount *int `json:"orderCount,omitempty"` + Price *string `json:"price,omitempty"` + Quantity *string `json:"quantity,omitempty"` +} + +// OrderRequest defines model for OrderRequest. +type OrderRequest struct { + // MakerOrCancel Set to `true` to require maker-only behavior. If the order would immediately take liquidity, the order is cancelled instead of filling. + MakerOrCancel *bool `json:"makerOrCancel,omitempty"` + + // OrderType Order type. `stop-limit` orders require a `stopPrice` that triggers a limit order at `price` when the market reaches the trigger. + OrderType OrderType `json:"orderType"` + + // Outcome The outcome being traded (Yes or No) + Outcome Outcome `json:"outcome"` + + // Price Limit price (0-1 range) + Price string `json:"price"` + + // Quantity Number of contracts + Quantity string `json:"quantity"` + Side OrderSide `json:"side"` + + // StopPrice The price to trigger a stop-limit order (0-1 range). Only available for stop-limit orders. See [Stop-Limit Orders](#operation/placeOrder) above for `stopPrice`/`price` constraints. + StopPrice *string `json:"stopPrice,omitempty"` + + // Symbol Contract instrument symbol + Symbol string `json:"symbol"` + + // TimeInForce Order execution behavior: + // - `good-til-cancel` - Order remains active until filled or cancelled (default) + // - `immediate-or-cancel` - Fill immediately or cancel remaining + // - `fill-or-kill` - Fill entire order immediately or cancel + TimeInForce *TimeInForce `json:"timeInForce,omitempty"` +} + +// OrderResponse defines model for OrderResponse. +type OrderResponse struct { + // AvgExecutionPrice Average price of fills + AvgExecutionPrice *string `json:"avgExecutionPrice,omitempty"` + CancelledAt *time.Time `json:"cancelledAt,omitempty"` + ClientOrderId *string `json:"clientOrderId,omitempty"` + ContractMetadata *ContractMetadata `json:"contractMetadata,omitempty"` + CreatedAt *time.Time `json:"createdAt,omitempty"` + + // FilledQuantity Amount filled so far + FilledQuantity *string `json:"filledQuantity,omitempty"` + GlobalOrderId *string `json:"globalOrderId,omitempty"` + HashOrderId *string `json:"hashOrderId,omitempty"` + OrderId *int64 `json:"orderId,omitempty"` + + // OrderType Order type. `stop-limit` orders require a `stopPrice` that triggers a limit order at `price` when the market reaches the trigger. + OrderType *OrderType `json:"orderType,omitempty"` + + // Outcome The outcome being traded (Yes or No) + Outcome *Outcome `json:"outcome,omitempty"` + + // Price Limit price + Price *string `json:"price,omitempty"` + + // Quantity Original order quantity + Quantity *string `json:"quantity,omitempty"` + + // RemainingQuantity Amount remaining to fill + RemainingQuantity *string `json:"remainingQuantity,omitempty"` + Side *OrderSide `json:"side,omitempty"` + Status *OrderStatus `json:"status,omitempty"` + + // StopPrice Stop trigger price (populated for `stop-limit` orders) + StopPrice *string `json:"stopPrice,omitempty"` + Symbol *string `json:"symbol,omitempty"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` +} + +// OrderSide defines model for OrderSide. +type OrderSide string + +// OrderStatus defines model for OrderStatus. +type OrderStatus string + +// OrderType Order type. `stop-limit` orders require a `stopPrice` that triggers a limit order at `price` when the market reaches the trigger. +type OrderType string + +// OrdersResponse defines model for OrdersResponse. +type OrdersResponse struct { + Orders *[]OrderResponse `json:"orders,omitempty"` + Pagination *PaginationSimple `json:"pagination,omitempty"` +} + +// Outcome The outcome being traded (Yes or No) +type Outcome string + +// Pagination defines model for Pagination. +type Pagination struct { + Limit *int `json:"limit,omitempty"` + Offset *int `json:"offset,omitempty"` + Total *int `json:"total,omitempty"` +} + +// PaginationSimple defines model for PaginationSimple. +type PaginationSimple struct { + // Count Number of items in current response + Count *int `json:"count,omitempty"` + Limit *int `json:"limit,omitempty"` + Offset *int `json:"offset,omitempty"` +} + +// PlaceOrderBatchErrorResult defines model for PlaceOrderBatchErrorResult. +type PlaceOrderBatchErrorResult struct { + // Error Error class for a rejected entry + Error string `json:"error"` + + // Message Human-readable detail for a rejected entry + Message string `json:"message"` +} + +// PlaceOrderBatchRequest defines model for PlaceOrderBatchRequest. +type PlaceOrderBatchRequest struct { + // Orders Orders to submit. Every entry is validated before any order is submitted. All orders use the account associated with the authenticated request. + Orders []OrderRequest `json:"orders"` +} + +// PlaceOrderBatchResponse defines model for PlaceOrderBatchResponse. +type PlaceOrderBatchResponse struct { + // Results One result for each submitted order, in request order. + Results []PlaceOrderBatchResult `json:"results"` +} + +// PlaceOrderBatchResult Exactly one outcome is present. Accepted entries contain `order`; rejected entries contain `error` and `message`. +type PlaceOrderBatchResult struct { + union json.RawMessage +} + +// PlaceOrderBatchSuccessResult defines model for PlaceOrderBatchSuccessResult. +type PlaceOrderBatchSuccessResult struct { + // Order An accepted order returned for one batch entry. + Order BatchOrderResponse `json:"order"` +} + +// Position defines model for Position. +type Position struct { + // AvgPrice Average entry price + AvgPrice *string `json:"avgPrice,omitempty"` + ContractMetadata *ContractMetadata `json:"contractMetadata,omitempty"` + InstrumentId *int64 `json:"instrumentId,omitempty"` + + // IsAboveAutoStartThreshold Whether the position is above the auto-start threshold + IsAboveAutoStartThreshold *bool `json:"isAboveAutoStartThreshold,omitempty"` + + // IsLive Whether the market is currently live/active + IsLive *bool `json:"isLive,omitempty"` + + // MarketValue Mark-to-market value of the position in USD at the current sell price (bestBid for YES, bestAsk for NO). **Absent** from the response when the held outcome has no live sell quote (no liquidity to sell into) — surface a no-liquidity state rather than a price the user cannot transact at. `lastTradePrice` is still returned for display. Treat as `Optional`. + MarketValue *string `json:"marketValue,omitempty"` + + // Outcome The outcome being traded (Yes or No) + Outcome *Outcome `json:"outcome,omitempty"` + + // Prices Current bid/ask/last-trade prices for the contract + Prices *PositionPrices `json:"prices,omitempty"` + + // QuantityOnHold Quantity currently on hold from open orders + QuantityOnHold *string `json:"quantityOnHold,omitempty"` + + // RealizedPl Realized profit/loss from sells + RealizedPl *string `json:"realizedPl,omitempty"` + + // ResolutionSide Winning outcome ("yes" or "no") if the contract has resolved + ResolutionSide *string `json:"resolutionSide,omitempty"` + Symbol *string `json:"symbol,omitempty"` + + // TotalQuantity Total position size + TotalQuantity *string `json:"totalQuantity,omitempty"` + + // UnrealizedPct Unrealized P&L as a percentage of cost basis. Expressed as a percent (e.g. `12.5` represents 12.5%, **not** `0.125`); rounded to 4 decimal places. **Absent** when there is no live sell quote, or when cost basis is zero. Treat as `Optional`. + UnrealizedPct *float64 `json:"unrealizedPct,omitempty"` + + // UnrealizedPnl Unrealized P&L in USD (`marketValue - costBasis`). **Absent** whenever `marketValue` is absent. Treat as `Optional`. + UnrealizedPnl *string `json:"unrealizedPnl,omitempty"` +} + +// PositionPrices Current bid/ask/last-trade prices for the contract +type PositionPrices struct { + BestAsk *string `json:"bestAsk,omitempty"` + BestBid *string `json:"bestBid,omitempty"` + Buy struct { + No *string `json:"no,omitempty"` + Yes *string `json:"yes,omitempty"` + } `json:"buy"` + LastTradePrice *string `json:"lastTradePrice,omitempty"` + Sell struct { + No *string `json:"no,omitempty"` + Yes *string `json:"yes,omitempty"` + } `json:"sell"` +} + +// PositionStatus defines model for PositionStatus. +type PositionStatus string + +// PositionsResponse defines model for PositionsResponse. +type PositionsResponse struct { + Positions *[]Position `json:"positions,omitempty"` + + // Total Total number of positions (for pagination) + Total *int `json:"total,omitempty"` +} + +// PredictionMarketHourlyVolumeCategory defines model for PredictionMarketHourlyVolumeCategory. +type PredictionMarketHourlyVolumeCategory struct { + // CategoryPath Display-name path from the top-level category to this category. It replaces recursive child nodes. + CategoryPath []string `json:"categoryPath"` + + // PeriodStart Inclusive UTC start of this hourly period. + PeriodStart time.Time `json:"periodStart"` + + // Volume Total volume for this category in this hour, including all descendant categories. + Volume PredictionMarketVolumeDecimal `json:"volume"` +} + +// PredictionMarketVolumeCategory defines model for PredictionMarketVolumeCategory. +type PredictionMarketVolumeCategory struct { + // CategoryPath Display-name path from the top-level category to this category. It replaces recursive child nodes. + CategoryPath []string `json:"categoryPath"` + + // Volume Total volume for this category, including all descendant categories. + Volume PredictionMarketVolumeDecimal `json:"volume"` +} + +// PredictionMarketVolumeDecimal Non-negative decimal string. Preserve it as a string to avoid floating-point precision loss. +type PredictionMarketVolumeDecimal = string + +// PredictionMarketsError defines model for PredictionMarketsError. +type PredictionMarketsError struct { + // Error Prediction Markets error class + Error string `json:"error"` + + // Field Request field associated with the error, when available + Field *string `json:"field,omitempty"` + + // Message Human-readable error detail, when available + Message *string `json:"message,omitempty"` +} + +// PredictionMarketsTerms defines model for PredictionMarketsTerms. +type PredictionMarketsTerms struct { + // Content Terms content to display before acceptance + Content string `json:"content"` + + // TermsType Terms type identifier + TermsType string `json:"termsType"` + + // UpdatedAt UTC timestamp when the terms content was last updated + UpdatedAt time.Time `json:"updatedAt"` + + // Version Latest terms version + Version int `json:"version"` +} + +// PredictionMarketsTermsStatus defines model for PredictionMarketsTermsStatus. +type PredictionMarketsTermsStatus struct { + // AcceptedVersion Latest terms version accepted by the account group, if any + AcceptedVersion *int `json:"acceptedVersion,omitempty"` + + // HasAcceptedLatest Whether the account group has accepted the latest configured Prediction Markets terms + HasAcceptedLatest bool `json:"hasAcceptedLatest"` + + // LatestVersion Latest configured Prediction Markets terms version, if available + LatestVersion *int `json:"latestVersion,omitempty"` +} + +// PricePoint defines model for PricePoint. +type PricePoint struct { + Price *string `json:"price,omitempty"` + Timestamp *time.Time `json:"timestamp,omitempty"` +} + +// RestrictedSellOnlyError defines model for RestrictedSellOnlyError. +type RestrictedSellOnlyError struct { + Error RestrictedSellOnlyErrorError `json:"error"` + Message RestrictedSellOnlyErrorMessage `json:"message"` +} + +// RestrictedSellOnlyErrorError defines model for RestrictedSellOnlyError.Error. +type RestrictedSellOnlyErrorError string + +// RestrictedSellOnlyErrorMessage defines model for RestrictedSellOnlyError.Message. +type RestrictedSellOnlyErrorMessage string + +// SettledPosition A historically settled position in a resolved prediction market contract. +type SettledPosition struct { + // AccountId Account that held the position + AccountId *int64 `json:"accountId,omitempty"` + ContractMetadata *ContractMetadata `json:"contractMetadata,omitempty"` + + // CostBasis Total amount spent to enter the position, net of any prior realized P&L from partial sells. Omitted when cost-basis data is not available. + CostBasis *string `json:"costBasis,omitempty"` + + // InstrumentId Unique instrument identifier for the contract + InstrumentId *int64 `json:"instrumentId,omitempty"` + + // InstrumentSymbol Contract instrument symbol + InstrumentSymbol *string `json:"instrumentSymbol,omitempty"` + + // NetProfit Net profit for the position, computed as `payout - costBasis + realizedPnl`. Omitted when `costBasis` is not available. + NetProfit *string `json:"netProfit,omitempty"` + + // Outcome The outcome being traded (Yes or No) + Outcome *Outcome `json:"outcome,omitempty"` + + // Payout Payout received from settlement. `0` when the position lost. + Payout *string `json:"payout,omitempty"` + + // Position Signed position held at settlement. Positive values represent a `yes` position; negative values represent a `no` position. + Position *string `json:"position,omitempty"` + + // PositionQuantity Absolute quantity held at settlement (unsigned) + PositionQuantity *string `json:"positionQuantity,omitempty"` + + // RealizedPnl Realized profit or loss recorded from sells prior to settlement. Omitted when not available. + RealizedPnl *string `json:"realizedPnl,omitempty"` + + // ResolutionSide The winning outcome of the contract + ResolutionSide *Outcome `json:"resolutionSide,omitempty"` + + // SettledAt Settlement timestamp (ISO 8601) + SettledAt *time.Time `json:"settledAt,omitempty"` +} + +// SettledPositionsResponse defines model for SettledPositionsResponse. +type SettledPositionsResponse struct { + // CashOuts Cash-outs (early sells before contract resolution) in the same account-scoped time window as the returned page's settled positions. Field is absent (not `null`) when `withCashOuts=true` is not passed on the request. `positions[]` pagination is unaffected — `limit`/`offset` continue to scope `positions[]` only. + CashOuts *[]CashedOutPosition `json:"cashOuts,omitempty"` + Positions *[]SettledPosition `json:"positions,omitempty"` + + // Total Total number of settled positions across all pages for the current filter set. + Total *int `json:"total,omitempty"` + + // TotalCashOutCostBasis Sum of `cashOuts[].costBasis` over the returned cash-outs. Field is absent (not `null`) when `withCashOuts=true` is not passed on the request. + TotalCashOutCostBasis *string `json:"totalCashOutCostBasis,omitempty"` + + // TotalCashOutNetProfit Sum of `cashOuts[].netProfit` over the returned cash-outs. Field is absent (not `null`) when `withCashOuts=true` is not passed on the request. + TotalCashOutNetProfit *string `json:"totalCashOutNetProfit,omitempty"` + + // TotalCashOutProceeds Sum of `cashOuts[].proceeds` over the returned cash-outs. Field is absent (not `null`) when `withCashOuts=true` is not passed on the request. + TotalCashOutProceeds *string `json:"totalCashOutProceeds,omitempty"` + + // TotalCostBasis Sum of `costBasis` across all settled positions in the filter set. Retained for binary back-compat; **field is absent (not `null`) on the unified backend** (see `totalPayout`). + TotalCostBasis *string `json:"totalCostBasis,omitempty"` + + // TotalNetProfit Sum of `netProfit` across all settled positions in the filter set. Retained for binary back-compat; **field is absent (not `null`) on the unified backend** (see `totalPayout`). + TotalNetProfit *string `json:"totalNetProfit,omitempty"` + + // TotalPayout Sum of `payout` across all settled positions in the filter set. Retained for binary back-compat with the legacy response shape; **field is absent (not `null`) on the unified backend** because computing a roll-up over the full filtered set would require a separate aggregate query (deferred until a partner asks). Play's default `OptionHandlers` omits absent `Option` fields rather than emitting `null`. + TotalPayout *string `json:"totalPayout,omitempty"` +} + +// Settlement Settlement information for resolved events +type Settlement struct { + // Value The observed settlement value (e.g., the price at expiry for crypto contracts) + Value *string `json:"value,omitempty"` +} + +// SourceDetails Structured data source information for price observation. Replaces the deprecated flat `source` string on the event and contract. Present for crypto Up/Down events. Both fields are omitted when not available. +type SourceDetails struct { + // Agency The data provider / vendor name. + Agency *string `json:"agency,omitempty"` + + // Index The specific data feed identifier (the value previously carried by the flat `source` field). + Index *string `json:"index,omitempty"` +} + +// SportsMarket Atomic sports-market classification shared by every contract grouped under the event. Present only for sports events. All fields except `metric` are required together. +type SportsMarket struct { + // Metric Statistic measured by the market. Interpret shared metric names using `sportsMarket.sport`. + Metric *SportsMarketMetric `json:"metric,omitempty"` + + // Scope Settlement scope. `ordinal` identifies one unit; `start` and `end` identify an inclusive range of units. + Scope SportsMarketScope `json:"scope"` + + // Sport Sport whose rules give the market's scope and metric their sport-specific meaning. + Sport SportsMarketSport `json:"sport"` + + // Subject What the market is about. `participant` covers non-player entrants such as drivers and horses. + Subject SportsMarketSubject `json:"subject"` + + // Type Conventional sports-market family. `subject`, `scope`, and `metric` provide detail within the family. This classification is independent of the event's structural `type` (`binary` or `categorical`). + Type SportsMarketType `json:"type"` +} + +// SportsMarketMetric Statistic measured by the market. Interpret shared metric names using `sportsMarket.sport`. +type SportsMarketMetric string + +// SportsMarketScope Settlement scope. `ordinal` identifies one unit; `start` and `end` identify an inclusive range of units. +type SportsMarketScope struct { + // End Optional inclusive end of a scope range, such as inning `5`. + End *int32 `json:"end,omitempty"` + + // Ordinal Optional ordinal within the scope type, such as half `1` or quarter `4`. + Ordinal *int32 `json:"ordinal,omitempty"` + + // Start Optional inclusive start of a scope range, such as inning `1`. + Start *int32 `json:"start,omitempty"` + + // Type Unit covered by the market. `full_contest` follows the market's official final-result rules; `regulation` covers scheduled regulation play only. Ordinal and range qualifiers are represented separately on `SportsMarketScope`. + Type SportsMarketScopeType `json:"type"` +} + +// SportsMarketScopeType Unit covered by the market. `full_contest` follows the market's official final-result rules; `regulation` covers scheduled regulation play only. Ordinal and range qualifiers are represented separately on `SportsMarketScope`. +type SportsMarketScopeType string + +// SportsMarketSport Sport whose rules give the market's scope and metric their sport-specific meaning. +type SportsMarketSport string + +// SportsMarketSubject What the market is about. `participant` covers non-player entrants such as drivers and horses. +type SportsMarketSubject string + +// SportsMarketType Conventional sports-market family. `subject`, `scope`, and `metric` provide detail within the family. This classification is independent of the event's structural `type` (`binary` or `categorical`). +type SportsMarketType string + +// Strike Strike price or contract threshold information for Up/Down crypto contracts and sports prediction market contracts. +type Strike struct { + // AvailableAt When the strike price becomes available + AvailableAt *time.Time `json:"availableAt,omitempty"` + + // Type Strike or condition inequality type for contract threshold evaluation. - `reference`: Crypto Up/Down reference strike price captured at `availableAt` time. - `above`: Higher/Lower contract threshold. - `spread`: Point, run, or goal handicap spread line. - `over`: Total or prop threshold evaluated as strict greater than (`>`). - `over_or_equal`: Total or prop threshold evaluated as greater than or equal to (`>=`). - `under`: Total or prop threshold evaluated as strict less than (`<`). - `under_or_equal`: Position, rank, or total threshold evaluated as less than or equal to (`<=`). + Type *StrikeType `json:"type,omitempty"` + + // Value The strike price value. Null for "reference" type strikes where the value is determined at availableAt time. For sports contracts, this represents the derived numeric strike value (e.g. spread margin, total line, or position/rank threshold). + Value *string `json:"value,omitempty"` +} + +// StrikeType Strike or condition inequality type for contract threshold evaluation. - `reference`: Crypto Up/Down reference strike price captured at `availableAt` time. - `above`: Higher/Lower contract threshold. - `spread`: Point, run, or goal handicap spread line. - `over`: Total or prop threshold evaluated as strict greater than (`>`). - `over_or_equal`: Total or prop threshold evaluated as greater than or equal to (`>=`). - `under`: Total or prop threshold evaluated as strict less than (`<`). - `under_or_equal`: Position, rank, or total threshold evaluated as less than or equal to (`<=`). +type StrikeType string + +// Subcategory Nested category information for the event +type Subcategory struct { + // Id Category identifier + Id *int `json:"id,omitempty"` + + // Name Display name + Name *string `json:"name,omitempty"` + + // Path Category hierarchy path + Path *[]string `json:"path,omitempty"` + + // Slug URL-friendly category identifier + Slug *string `json:"slug,omitempty"` +} + +// TermsNotAcceptedError defines model for TermsNotAcceptedError. +type TermsNotAcceptedError struct { + Error TermsNotAcceptedErrorError `json:"error"` + Message TermsNotAcceptedErrorMessage `json:"message"` +} + +// TermsNotAcceptedErrorError defines model for TermsNotAcceptedError.Error. +type TermsNotAcceptedErrorError string + +// TermsNotAcceptedErrorMessage defines model for TermsNotAcceptedError.Message. +type TermsNotAcceptedErrorMessage string + +// TimeInForce Order execution behavior: +// - `good-til-cancel` - Order remains active until filled or cancelled (default) +// - `immediate-or-cancel` - Fill immediately or cancel remaining +// - `fill-or-kill` - Fill entire order immediately or cancel +type TimeInForce string + +// VolumeMetricsResponse defines model for VolumeMetricsResponse. +type VolumeMetricsResponse struct { + Contracts *[]ContractShareVolume `json:"contracts,omitempty"` + + // EventTicker The event ticker + EventTicker *string `json:"eventTicker,omitempty"` +} + +// Limit defines model for Limit. +type Limit = int + +// Offset defines model for Offset. +type Offset = int + +// SportFilter defines model for SportFilter. +type SportFilter = []SportsMarketSport + +// SportsMarketMetricFilter defines model for SportsMarketMetricFilter. +type SportsMarketMetricFilter = []SportsMarketMetric + +// SportsMarketScopeFilter defines model for SportsMarketScopeFilter. +type SportsMarketScopeFilter = []SportsMarketScopeType + +// SportsMarketSubjectFilter defines model for SportsMarketSubjectFilter. +type SportsMarketSubjectFilter = []SportsMarketSubject + +// SportsMarketTypeFilter defines model for SportsMarketTypeFilter. +type SportsMarketTypeFilter = []SportsMarketType + +// BadRequest defines model for BadRequest. +type BadRequest = Error + +// InternalError defines model for InternalError. +type InternalError = Error + +// ServiceUnavailable defines model for ServiceUnavailable. +type ServiceUnavailable = Error + +// Unauthorized defines model for Unauthorized. +type Unauthorized = Error + +// apiKeyContextKey is the context key for apiKey security scheme +type apiKeyContextKey string + +// payloadAuthContextKey is the context key for payloadAuth security scheme +type payloadAuthContextKey string + +// signatureAuthContextKey is the context key for signatureAuth security scheme +type signatureAuthContextKey string + +// GetCategoriesParams defines parameters for GetCategories. +type GetCategoriesParams struct { + // Status Filter categories by event status + Status *[]MarketStatus `form:"status,omitempty" json:"status,omitempty"` +} + +// ListCombosParams defines parameters for ListCombos. +type ListCombosParams struct { + // Status Filter by combo contract status (for example, `Active`, `Settled`, or `Voided`). Defaults to `Active` when omitted. + Status *string `form:"status,omitempty" json:"status,omitempty"` + + // ContractId Filter to combos that contain a specific underlying contract ID as a leg + ContractId *int64 `form:"contractId,omitempty" json:"contractId,omitempty"` + + // InstrumentRegistered Filter by whether the combo has been registered with an instrument symbol + InstrumentRegistered *bool `form:"instrumentRegistered,omitempty" json:"instrumentRegistered,omitempty"` + + // Limit Maximum number of results to return (max 500) + Limit *Limit `form:"limit,omitempty" json:"limit,omitempty"` + + // Offset Number of results to skip for pagination + Offset *Offset `form:"offset,omitempty" json:"offset,omitempty"` +} + +// ListEventsParams defines parameters for ListEvents. +type ListEventsParams struct { + // Status Filter by event status (can specify multiple) + Status *[]MarketStatus `form:"status,omitempty" json:"status,omitempty"` + + // Category Filter by category (can specify multiple). If omitted, returns events from all categories. + Category *[]string `form:"category,omitempty" json:"category,omitempty"` + + // Sport Filter by `sportsMarket.sport`. Repeat the parameter to match any supplied value (OR). Sports-market filters compose independently using AND. Unsupported enum values return `400 Bad Request`; valid combinations with no matching events return an empty result. + Sport *SportFilter `form:"sport,omitempty" json:"sport,omitempty"` + + // SportsMarketType Filter by `sportsMarket.type`. Repeat the parameter to match any supplied value (OR). Sports-market filters compose independently using AND. Unsupported enum values return `400 Bad Request`. + SportsMarketType *SportsMarketTypeFilter `form:"sports_market_type,omitempty" json:"sports_market_type,omitempty"` + + // SportsMarketSubject Filter by `sportsMarket.subject`. Repeat the parameter to match any supplied value (OR). Sports-market filters compose independently using AND. Unsupported enum values return `400 Bad Request`. + SportsMarketSubject *SportsMarketSubjectFilter `form:"sports_market_subject,omitempty" json:"sports_market_subject,omitempty"` + + // SportsMarketScope Filter by `sportsMarket.scope.type`. Repeat the parameter to match any supplied value (OR). Ordinal and range qualifiers are not inferred by this filter. Sports-market filters compose independently using AND. Unsupported enum values return `400 Bad Request`. + SportsMarketScope *SportsMarketScopeFilter `form:"sports_market_scope,omitempty" json:"sports_market_scope,omitempty"` + + // SportsMarketMetric Filter by `sportsMarket.metric`. Repeat the parameter to match any supplied value (OR). A metric can be queried without `sport`; use `sport` when its sport-specific meaning matters. Sports-market filters compose independently using AND. Unsupported enum values return `400 Bad Request`. + SportsMarketMetric *SportsMarketMetricFilter `form:"sports_market_metric,omitempty" json:"sports_market_metric,omitempty"` + + // Search Search text to filter events by title + Search *string `form:"search,omitempty" json:"search,omitempty"` + + // Limit Maximum number of results to return (max 500) + Limit *Limit `form:"limit,omitempty" json:"limit,omitempty"` + + // Offset Number of results to skip for pagination + Offset *Offset `form:"offset,omitempty" json:"offset,omitempty"` +} + +// ListNewlyListedEventsParams defines parameters for ListNewlyListedEvents. +type ListNewlyListedEventsParams struct { + // Category Filter by category (can specify multiple). If omitted, returns events from all categories. + Category *[]string `form:"category,omitempty" json:"category,omitempty"` + + // Sport Filter by `sportsMarket.sport`. Repeat the parameter to match any supplied value (OR). Sports-market filters compose independently using AND. Unsupported enum values return `400 Bad Request`; valid combinations with no matching events return an empty result. + Sport *SportFilter `form:"sport,omitempty" json:"sport,omitempty"` + + // SportsMarketType Filter by `sportsMarket.type`. Repeat the parameter to match any supplied value (OR). Sports-market filters compose independently using AND. Unsupported enum values return `400 Bad Request`. + SportsMarketType *SportsMarketTypeFilter `form:"sports_market_type,omitempty" json:"sports_market_type,omitempty"` + + // SportsMarketSubject Filter by `sportsMarket.subject`. Repeat the parameter to match any supplied value (OR). Sports-market filters compose independently using AND. Unsupported enum values return `400 Bad Request`. + SportsMarketSubject *SportsMarketSubjectFilter `form:"sports_market_subject,omitempty" json:"sports_market_subject,omitempty"` + + // SportsMarketScope Filter by `sportsMarket.scope.type`. Repeat the parameter to match any supplied value (OR). Ordinal and range qualifiers are not inferred by this filter. Sports-market filters compose independently using AND. Unsupported enum values return `400 Bad Request`. + SportsMarketScope *SportsMarketScopeFilter `form:"sports_market_scope,omitempty" json:"sports_market_scope,omitempty"` + + // SportsMarketMetric Filter by `sportsMarket.metric`. Repeat the parameter to match any supplied value (OR). A metric can be queried without `sport`; use `sport` when its sport-specific meaning matters. Sports-market filters compose independently using AND. Unsupported enum values return `400 Bad Request`. + SportsMarketMetric *SportsMarketMetricFilter `form:"sports_market_metric,omitempty" json:"sports_market_metric,omitempty"` + + // Limit Maximum number of results to return (max 500) + Limit *Limit `form:"limit,omitempty" json:"limit,omitempty"` + + // Offset Number of results to skip for pagination + Offset *Offset `form:"offset,omitempty" json:"offset,omitempty"` +} + +// ListRecentlySettledEventsParams defines parameters for ListRecentlySettledEvents. +type ListRecentlySettledEventsParams struct { + // Category Filter by category (can specify multiple). If omitted, returns events from all categories. + Category *[]string `form:"category,omitempty" json:"category,omitempty"` + + // Sport Filter by `sportsMarket.sport`. Repeat the parameter to match any supplied value (OR). Sports-market filters compose independently using AND. Unsupported enum values return `400 Bad Request`; valid combinations with no matching events return an empty result. + Sport *SportFilter `form:"sport,omitempty" json:"sport,omitempty"` + + // SportsMarketType Filter by `sportsMarket.type`. Repeat the parameter to match any supplied value (OR). Sports-market filters compose independently using AND. Unsupported enum values return `400 Bad Request`. + SportsMarketType *SportsMarketTypeFilter `form:"sports_market_type,omitempty" json:"sports_market_type,omitempty"` + + // SportsMarketSubject Filter by `sportsMarket.subject`. Repeat the parameter to match any supplied value (OR). Sports-market filters compose independently using AND. Unsupported enum values return `400 Bad Request`. + SportsMarketSubject *SportsMarketSubjectFilter `form:"sports_market_subject,omitempty" json:"sports_market_subject,omitempty"` + + // SportsMarketScope Filter by `sportsMarket.scope.type`. Repeat the parameter to match any supplied value (OR). Ordinal and range qualifiers are not inferred by this filter. Sports-market filters compose independently using AND. Unsupported enum values return `400 Bad Request`. + SportsMarketScope *SportsMarketScopeFilter `form:"sports_market_scope,omitempty" json:"sports_market_scope,omitempty"` + + // SportsMarketMetric Filter by `sportsMarket.metric`. Repeat the parameter to match any supplied value (OR). A metric can be queried without `sport`; use `sport` when its sport-specific meaning matters. Sports-market filters compose independently using AND. Unsupported enum values return `400 Bad Request`. + SportsMarketMetric *SportsMarketMetricFilter `form:"sports_market_metric,omitempty" json:"sports_market_metric,omitempty"` + + // Limit Maximum number of results to return (max 500) + Limit *Limit `form:"limit,omitempty" json:"limit,omitempty"` + + // Offset Number of results to skip for pagination + Offset *Offset `form:"offset,omitempty" json:"offset,omitempty"` +} + +// ListUpcomingEventsParams defines parameters for ListUpcomingEvents. +type ListUpcomingEventsParams struct { + // Category Filter by category (can specify multiple). If omitted, returns events from all categories. + Category *[]string `form:"category,omitempty" json:"category,omitempty"` + + // Sport Filter by `sportsMarket.sport`. Repeat the parameter to match any supplied value (OR). Sports-market filters compose independently using AND. Unsupported enum values return `400 Bad Request`; valid combinations with no matching events return an empty result. + Sport *SportFilter `form:"sport,omitempty" json:"sport,omitempty"` + + // SportsMarketType Filter by `sportsMarket.type`. Repeat the parameter to match any supplied value (OR). Sports-market filters compose independently using AND. Unsupported enum values return `400 Bad Request`. + SportsMarketType *SportsMarketTypeFilter `form:"sports_market_type,omitempty" json:"sports_market_type,omitempty"` + + // SportsMarketSubject Filter by `sportsMarket.subject`. Repeat the parameter to match any supplied value (OR). Sports-market filters compose independently using AND. Unsupported enum values return `400 Bad Request`. + SportsMarketSubject *SportsMarketSubjectFilter `form:"sports_market_subject,omitempty" json:"sports_market_subject,omitempty"` + + // SportsMarketScope Filter by `sportsMarket.scope.type`. Repeat the parameter to match any supplied value (OR). Ordinal and range qualifiers are not inferred by this filter. Sports-market filters compose independently using AND. Unsupported enum values return `400 Bad Request`. + SportsMarketScope *SportsMarketScopeFilter `form:"sports_market_scope,omitempty" json:"sports_market_scope,omitempty"` + + // SportsMarketMetric Filter by `sportsMarket.metric`. Repeat the parameter to match any supplied value (OR). A metric can be queried without `sport`; use `sport` when its sport-specific meaning matters. Sports-market filters compose independently using AND. Unsupported enum values return `400 Bad Request`. + SportsMarketMetric *SportsMarketMetricFilter `form:"sports_market_metric,omitempty" json:"sports_market_metric,omitempty"` + + // Limit Maximum number of results to return (max 500) + Limit *Limit `form:"limit,omitempty" json:"limit,omitempty"` + + // Offset Number of results to skip for pagination + Offset *Offset `form:"offset,omitempty" json:"offset,omitempty"` +} + +// ListLiquidityRewardsEventsParams defines parameters for ListLiquidityRewardsEvents. +type ListLiquidityRewardsEventsParams struct { + // Category Comma-separated list of category names. Whitespace is trimmed and empty entries are dropped. + Category *string `form:"category,omitempty" json:"category,omitempty"` + + // Search Filter events by title substring (case-insensitive). + Search *string `form:"search,omitempty" json:"search,omitempty"` + + // Sort Sort order for the returned events. Defaults to `daily_pool_desc`. + Sort *ListLiquidityRewardsEventsParamsSort `form:"sort,omitempty" json:"sort,omitempty"` + + // Limit Maximum number of events to return (default 50, clamped to [1, 100]). + Limit *int `form:"limit,omitempty" json:"limit,omitempty"` + + // Offset Number of events to skip (default 0). + Offset *int `form:"offset,omitempty" json:"offset,omitempty"` +} + +// ListLiquidityRewardsEventsParamsSort defines parameters for ListLiquidityRewardsEvents. +type ListLiquidityRewardsEventsParamsSort string + +// GetLiquidityRewardsDailySummaryParams defines parameters for GetLiquidityRewardsDailySummary. +type GetLiquidityRewardsDailySummaryParams struct { + // DateFrom Inclusive start of the date window (`YYYY-MM-DD`, Eastern Time). + DateFrom openapi_types.Date `form:"dateFrom" json:"dateFrom"` + + // DateTo Inclusive end of the date window (`YYYY-MM-DD`, Eastern Time). Must be on or after `dateFrom`. + DateTo openapi_types.Date `form:"dateTo" json:"dateTo"` +} + +// GetLiquidityRewardsLifetimeSummaryParams defines parameters for GetLiquidityRewardsLifetimeSummary. +type GetLiquidityRewardsLifetimeSummaryParams struct { + // DateFrom Inclusive start of the payout date window (`YYYY-MM-DD`, Eastern Time). Must be provided together with `dateTo`. + DateFrom *openapi_types.Date `form:"dateFrom,omitempty" json:"dateFrom,omitempty"` + + // DateTo Inclusive end of the payout date window (`YYYY-MM-DD`, Eastern Time). Must be on or after `dateFrom` and within 5 years of it. + DateTo *openapi_types.Date `form:"dateTo,omitempty" json:"dateTo,omitempty"` +} + +// ListMakerRebatePayoutsParams defines parameters for ListMakerRebatePayouts. +type ListMakerRebatePayoutsParams struct { + // Limit Maximum number of payouts to return (default 50, clamped to [1, 100]). + Limit *int `form:"limit,omitempty" json:"limit,omitempty"` + + // Offset Number of payouts to skip (default 0). + Offset *int `form:"offset,omitempty" json:"offset,omitempty"` +} + +// GetMakerRebateRatesParams defines parameters for GetMakerRebateRates. +type GetMakerRebateRatesParams struct { + // Category Filter to rules that apply to this category (e.g. `Crypto`, `Sports`). When omitted, returns all rules. + Category *string `form:"category,omitempty" json:"category,omitempty"` +} + +// GetMakerRebateLifetimeSummaryParams defines parameters for GetMakerRebateLifetimeSummary. +type GetMakerRebateLifetimeSummaryParams struct { + // DateFrom Inclusive start of the payout date window (`YYYY-MM-DD`, Eastern Time). Must be provided together with `dateTo`. + DateFrom *openapi_types.Date `form:"dateFrom,omitempty" json:"dateFrom,omitempty"` + + // DateTo Inclusive end of the payout date window (`YYYY-MM-DD`, Eastern Time). Must be on or after `dateFrom` and within 5 years of it. + DateTo *openapi_types.Date `form:"dateTo,omitempty" json:"dateTo,omitempty"` +} + +// GetVolumeMetricsJSONBody defines parameters for GetVolumeMetrics. +type GetVolumeMetricsJSONBody struct { + // EndTime End of time range filter (epoch milliseconds). If omitted, includes all trades up to now. + EndTime *int64 `json:"endTime,omitempty"` + + // EventTicker The event ticker symbol + EventTicker string `json:"eventTicker"` + + // StartTime Start of time range filter (epoch milliseconds). If omitted, defaults to the earliest contract creation time. + StartTime *int64 `json:"startTime,omitempty"` +} + +// PlaceOrderBatch400JSONResponseBody defines parameters for PlaceOrderBatch. +type PlaceOrderBatch400JSONResponseBody struct { + union json.RawMessage +} + +// PlaceOrderBatch403JSONResponseBody defines parameters for PlaceOrderBatch. +type PlaceOrderBatch403JSONResponseBody struct { + union json.RawMessage +} + +// CancelOrderBatch400JSONResponseBody defines parameters for CancelOrderBatch. +type CancelOrderBatch400JSONResponseBody struct { + union json.RawMessage +} + +// CancelOrderJSONBody defines parameters for CancelOrder. +type CancelOrderJSONBody struct { + // OrderId The order ID to cancel + OrderId int64 `json:"orderId"` +} + +// GetActiveOrdersJSONBody defines parameters for GetActiveOrders. +type GetActiveOrdersJSONBody struct { + // Limit Maximum number of results to return (default 50, max 100) + Limit *int `json:"limit,omitempty"` + + // Offset Number of results to skip for pagination + Offset *int `json:"offset,omitempty"` + + // Symbol Filter by contract instrument symbol + Symbol *string `json:"symbol,omitempty"` +} + +// GetOrderHistoryJSONBody defines parameters for GetOrderHistory. +type GetOrderHistoryJSONBody struct { + // From Inclusive start of the order-closed time range, expressed as Unix epoch milliseconds. Use with `to` for a UTC daily window. + From *int64 `json:"from,omitempty"` + + // Limit Maximum number of results to return. Defaults to 50 and is capped at 1000. + Limit *int `json:"limit,omitempty"` + + // Offset Number of results to skip for pagination. Offset is ignored when `from` or `to` is supplied. + Offset *int `json:"offset,omitempty"` + + // Status Filter by order status + Status *GetOrderHistoryJSONBodyStatus `json:"status,omitempty"` + + // Symbol Filter by contract instrument symbol + Symbol *string `json:"symbol,omitempty"` + + // To Exclusive end of the order-closed time range, expressed as Unix epoch milliseconds. `from` must not be later than `to`. + To *int64 `json:"to,omitempty"` +} + +// GetOrderHistoryJSONBodyStatus defines parameters for GetOrderHistory. +type GetOrderHistoryJSONBodyStatus string + +// GetPositionsParams defines parameters for GetPositions. +type GetPositionsParams struct { + // EventTicker Filter positions to a single event ticker (e.g. `FEDJAN26`). Positions on sub-events whose `parentEventTicker` matches the value may also be included. + EventTicker *string `form:"eventTicker,omitempty" json:"eventTicker,omitempty"` + + // Limit Maximum number of positions to return. Clamped to `[1, 1000]` when supplied. Omit for legacy unpaginated behavior. + Limit *int `form:"limit,omitempty" json:"limit,omitempty"` + + // Offset Number of positions to skip for pagination. Floor-clamped to `0` when supplied. Ignored when `limit` is omitted (the response is unpaginated). + Offset *int `form:"offset,omitempty" json:"offset,omitempty"` + + // Sort Sort order. Accepts `positionValue`, `unrealizedPnl`, or `expiryDate` (case-insensitive), optionally prefixed with `+` (ascending) or `-` (descending). A bare field name uses each field's default direction: `positionValue` and `unrealizedPnl` default to descending; `expiryDate` defaults to ascending (soonest-first). `unrealizedPnl` and `expiryDate` sort NULLS LAST so positions without the sort key sink to the bottom regardless of direction. `instrumentId` ascending is the final tiebreaker for stable pagination across quote ticks. A malformed `sort` value silently falls back to `-positionValue` — no `400` is returned. + Sort *GetPositionsParamsSort `form:"sort,omitempty" json:"sort,omitempty"` +} + +// GetPositionsParamsSort defines parameters for GetPositions. +type GetPositionsParamsSort string + +// GetSettledPositionsParams defines parameters for GetSettledPositions. +type GetSettledPositionsParams struct { + // EventTicker Optional event ticker to filter settled positions to a single event (e.g. `FEDJAN26`). If omitted, all settled positions for the account are returned. + EventTicker *string `form:"eventTicker,omitempty" json:"eventTicker,omitempty"` + + // Limit Maximum number of settled positions to return. + Limit *int `form:"limit,omitempty" json:"limit,omitempty"` + + // Offset Number of settled positions to skip for pagination. + Offset *int `form:"offset,omitempty" json:"offset,omitempty"` + + // Sort Sort order. Accepts `date` or `payout`, optionally prefixed with `+` (ascending) or `-` (descending). A bare field name defaults to descending. `date` ascending is rejected and silently falls back to the default order — settled positions are conceptually ordered most-recent-first. A malformed `sort` value also falls back silently; no `400` is returned. + Sort *GetSettledPositionsParamsSort `form:"sort,omitempty" json:"sort,omitempty"` + + // Search Case-insensitive substring filter. Matches against the event name, contract name, event ticker, or any ancestor category name in the contract's category subtree (up to four levels). Whitespace is trimmed; inputs under 3 characters are dropped (GIN trigram lookup floor); inputs over 64 characters are truncated. + Search *string `form:"search,omitempty" json:"search,omitempty"` + + // Category Filter to settled positions whose contract's event belongs to the named category (or any of its descendants in the category tree). Whitespace is trimmed; empty values are ignored. + Category *string `form:"category,omitempty" json:"category,omitempty"` + + // WithCashOuts Opt-in flag. When `true`, the response carries new sibling fields (`cashOuts`, `totalCashOutProceeds`, `totalCashOutCostBasis`, `totalCashOutNetProfit`) populated with the qualifying cash-outs in the same account-scoped time window as the returned page's settled positions. When `false` (default) the response shape is byte-identical to the pre-`withCashOuts` contract: the `positions[]` element schema is unchanged regardless of the flag. + WithCashOuts *bool `form:"withCashOuts,omitempty" json:"withCashOuts,omitempty"` +} + +// GetSettledPositionsParamsSort defines parameters for GetSettledPositions. +type GetSettledPositionsParamsSort string + +// CreateComboJSONRequestBody defines body for CreateCombo for application/json ContentType. +type CreateComboJSONRequestBody = CreateComboRequest + +// GetVolumeMetricsJSONRequestBody defines body for GetVolumeMetrics for application/json ContentType. +type GetVolumeMetricsJSONRequestBody GetVolumeMetricsJSONBody + +// PlaceOrderJSONRequestBody defines body for PlaceOrder for application/json ContentType. +type PlaceOrderJSONRequestBody = OrderRequest + +// PlaceOrderBatchJSONRequestBody defines body for PlaceOrderBatch for application/json ContentType. +type PlaceOrderBatchJSONRequestBody = PlaceOrderBatchRequest + +// CancelOrderBatchJSONRequestBody defines body for CancelOrderBatch for application/json ContentType. +type CancelOrderBatchJSONRequestBody = CancelOrderBatchRequest + +// CancelOrderJSONRequestBody defines body for CancelOrder for application/json ContentType. +type CancelOrderJSONRequestBody CancelOrderJSONBody + +// GetActiveOrdersJSONRequestBody defines body for GetActiveOrders for application/json ContentType. +type GetActiveOrdersJSONRequestBody GetActiveOrdersJSONBody + +// GetOrderHistoryJSONRequestBody defines body for GetOrderHistory for application/json ContentType. +type GetOrderHistoryJSONRequestBody GetOrderHistoryJSONBody + +// AsCancelOrderBatchRequestOrderIds0 returns the union data inside the CancelOrderBatchRequest_OrderIds_Item as a CancelOrderBatchRequestOrderIds0 +func (t CancelOrderBatchRequest_OrderIds_Item) AsCancelOrderBatchRequestOrderIds0() (CancelOrderBatchRequestOrderIds0, error) { + var body CancelOrderBatchRequestOrderIds0 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromCancelOrderBatchRequestOrderIds0 overwrites any union data inside the CancelOrderBatchRequest_OrderIds_Item as the provided CancelOrderBatchRequestOrderIds0 +func (t *CancelOrderBatchRequest_OrderIds_Item) FromCancelOrderBatchRequestOrderIds0(v CancelOrderBatchRequestOrderIds0) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeCancelOrderBatchRequestOrderIds0 performs a merge with any union data inside the CancelOrderBatchRequest_OrderIds_Item, using the provided CancelOrderBatchRequestOrderIds0 +func (t *CancelOrderBatchRequest_OrderIds_Item) MergeCancelOrderBatchRequestOrderIds0(v CancelOrderBatchRequestOrderIds0) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsCancelOrderBatchRequestOrderIds1 returns the union data inside the CancelOrderBatchRequest_OrderIds_Item as a CancelOrderBatchRequestOrderIds1 +func (t CancelOrderBatchRequest_OrderIds_Item) AsCancelOrderBatchRequestOrderIds1() (CancelOrderBatchRequestOrderIds1, error) { + var body CancelOrderBatchRequestOrderIds1 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromCancelOrderBatchRequestOrderIds1 overwrites any union data inside the CancelOrderBatchRequest_OrderIds_Item as the provided CancelOrderBatchRequestOrderIds1 +func (t *CancelOrderBatchRequest_OrderIds_Item) FromCancelOrderBatchRequestOrderIds1(v CancelOrderBatchRequestOrderIds1) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeCancelOrderBatchRequestOrderIds1 performs a merge with any union data inside the CancelOrderBatchRequest_OrderIds_Item, using the provided CancelOrderBatchRequestOrderIds1 +func (t *CancelOrderBatchRequest_OrderIds_Item) MergeCancelOrderBatchRequestOrderIds1(v CancelOrderBatchRequestOrderIds1) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t CancelOrderBatchRequest_OrderIds_Item) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *CancelOrderBatchRequest_OrderIds_Item) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + +// AsCancelOrderBatchSuccessResult returns the union data inside the CancelOrderBatchResult as a CancelOrderBatchSuccessResult +func (t CancelOrderBatchResult) AsCancelOrderBatchSuccessResult() (CancelOrderBatchSuccessResult, error) { + var body CancelOrderBatchSuccessResult + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromCancelOrderBatchSuccessResult overwrites any union data inside the CancelOrderBatchResult as the provided CancelOrderBatchSuccessResult +func (t *CancelOrderBatchResult) FromCancelOrderBatchSuccessResult(v CancelOrderBatchSuccessResult) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeCancelOrderBatchSuccessResult performs a merge with any union data inside the CancelOrderBatchResult, using the provided CancelOrderBatchSuccessResult +func (t *CancelOrderBatchResult) MergeCancelOrderBatchSuccessResult(v CancelOrderBatchSuccessResult) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsCancelOrderBatchErrorResult returns the union data inside the CancelOrderBatchResult as a CancelOrderBatchErrorResult +func (t CancelOrderBatchResult) AsCancelOrderBatchErrorResult() (CancelOrderBatchErrorResult, error) { + var body CancelOrderBatchErrorResult + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromCancelOrderBatchErrorResult overwrites any union data inside the CancelOrderBatchResult as the provided CancelOrderBatchErrorResult +func (t *CancelOrderBatchResult) FromCancelOrderBatchErrorResult(v CancelOrderBatchErrorResult) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeCancelOrderBatchErrorResult performs a merge with any union data inside the CancelOrderBatchResult, using the provided CancelOrderBatchErrorResult +func (t *CancelOrderBatchResult) MergeCancelOrderBatchErrorResult(v CancelOrderBatchErrorResult) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t CancelOrderBatchResult) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *CancelOrderBatchResult) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + +// AsPlaceOrderBatchSuccessResult returns the union data inside the PlaceOrderBatchResult as a PlaceOrderBatchSuccessResult +func (t PlaceOrderBatchResult) AsPlaceOrderBatchSuccessResult() (PlaceOrderBatchSuccessResult, error) { + var body PlaceOrderBatchSuccessResult + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromPlaceOrderBatchSuccessResult overwrites any union data inside the PlaceOrderBatchResult as the provided PlaceOrderBatchSuccessResult +func (t *PlaceOrderBatchResult) FromPlaceOrderBatchSuccessResult(v PlaceOrderBatchSuccessResult) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergePlaceOrderBatchSuccessResult performs a merge with any union data inside the PlaceOrderBatchResult, using the provided PlaceOrderBatchSuccessResult +func (t *PlaceOrderBatchResult) MergePlaceOrderBatchSuccessResult(v PlaceOrderBatchSuccessResult) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsPlaceOrderBatchErrorResult returns the union data inside the PlaceOrderBatchResult as a PlaceOrderBatchErrorResult +func (t PlaceOrderBatchResult) AsPlaceOrderBatchErrorResult() (PlaceOrderBatchErrorResult, error) { + var body PlaceOrderBatchErrorResult + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromPlaceOrderBatchErrorResult overwrites any union data inside the PlaceOrderBatchResult as the provided PlaceOrderBatchErrorResult +func (t *PlaceOrderBatchResult) FromPlaceOrderBatchErrorResult(v PlaceOrderBatchErrorResult) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergePlaceOrderBatchErrorResult performs a merge with any union data inside the PlaceOrderBatchResult, using the provided PlaceOrderBatchErrorResult +func (t *PlaceOrderBatchResult) MergePlaceOrderBatchErrorResult(v PlaceOrderBatchErrorResult) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t PlaceOrderBatchResult) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *PlaceOrderBatchResult) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + +// AsAuthErrorResponse returns the union data inside the PlaceOrderBatch400JSONResponseBody as a AuthErrorResponse +func (t PlaceOrderBatch400JSONResponseBody) AsAuthErrorResponse() (AuthErrorResponse, error) { + var body AuthErrorResponse + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromAuthErrorResponse overwrites any union data inside the PlaceOrderBatch400JSONResponseBody as the provided AuthErrorResponse +func (t *PlaceOrderBatch400JSONResponseBody) FromAuthErrorResponse(v AuthErrorResponse) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeAuthErrorResponse performs a merge with any union data inside the PlaceOrderBatch400JSONResponseBody, using the provided AuthErrorResponse +func (t *PlaceOrderBatch400JSONResponseBody) MergeAuthErrorResponse(v AuthErrorResponse) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsPredictionMarketsError returns the union data inside the PlaceOrderBatch400JSONResponseBody as a PredictionMarketsError +func (t PlaceOrderBatch400JSONResponseBody) AsPredictionMarketsError() (PredictionMarketsError, error) { + var body PredictionMarketsError + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromPredictionMarketsError overwrites any union data inside the PlaceOrderBatch400JSONResponseBody as the provided PredictionMarketsError +func (t *PlaceOrderBatch400JSONResponseBody) FromPredictionMarketsError(v PredictionMarketsError) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergePredictionMarketsError performs a merge with any union data inside the PlaceOrderBatch400JSONResponseBody, using the provided PredictionMarketsError +func (t *PlaceOrderBatch400JSONResponseBody) MergePredictionMarketsError(v PredictionMarketsError) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t PlaceOrderBatch400JSONResponseBody) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *PlaceOrderBatch400JSONResponseBody) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + +// AsAuthErrorResponse returns the union data inside the PlaceOrderBatch403JSONResponseBody as a AuthErrorResponse +func (t PlaceOrderBatch403JSONResponseBody) AsAuthErrorResponse() (AuthErrorResponse, error) { + var body AuthErrorResponse + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromAuthErrorResponse overwrites any union data inside the PlaceOrderBatch403JSONResponseBody as the provided AuthErrorResponse +func (t *PlaceOrderBatch403JSONResponseBody) FromAuthErrorResponse(v AuthErrorResponse) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeAuthErrorResponse performs a merge with any union data inside the PlaceOrderBatch403JSONResponseBody, using the provided AuthErrorResponse +func (t *PlaceOrderBatch403JSONResponseBody) MergeAuthErrorResponse(v AuthErrorResponse) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsAccountGroupBlockedError returns the union data inside the PlaceOrderBatch403JSONResponseBody as a AccountGroupBlockedError +func (t PlaceOrderBatch403JSONResponseBody) AsAccountGroupBlockedError() (AccountGroupBlockedError, error) { + var body AccountGroupBlockedError + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromAccountGroupBlockedError overwrites any union data inside the PlaceOrderBatch403JSONResponseBody as the provided AccountGroupBlockedError +func (t *PlaceOrderBatch403JSONResponseBody) FromAccountGroupBlockedError(v AccountGroupBlockedError) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeAccountGroupBlockedError performs a merge with any union data inside the PlaceOrderBatch403JSONResponseBody, using the provided AccountGroupBlockedError +func (t *PlaceOrderBatch403JSONResponseBody) MergeAccountGroupBlockedError(v AccountGroupBlockedError) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsTermsNotAcceptedError returns the union data inside the PlaceOrderBatch403JSONResponseBody as a TermsNotAcceptedError +func (t PlaceOrderBatch403JSONResponseBody) AsTermsNotAcceptedError() (TermsNotAcceptedError, error) { + var body TermsNotAcceptedError + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromTermsNotAcceptedError overwrites any union data inside the PlaceOrderBatch403JSONResponseBody as the provided TermsNotAcceptedError +func (t *PlaceOrderBatch403JSONResponseBody) FromTermsNotAcceptedError(v TermsNotAcceptedError) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeTermsNotAcceptedError performs a merge with any union data inside the PlaceOrderBatch403JSONResponseBody, using the provided TermsNotAcceptedError +func (t *PlaceOrderBatch403JSONResponseBody) MergeTermsNotAcceptedError(v TermsNotAcceptedError) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsRestrictedSellOnlyError returns the union data inside the PlaceOrderBatch403JSONResponseBody as a RestrictedSellOnlyError +func (t PlaceOrderBatch403JSONResponseBody) AsRestrictedSellOnlyError() (RestrictedSellOnlyError, error) { + var body RestrictedSellOnlyError + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromRestrictedSellOnlyError overwrites any union data inside the PlaceOrderBatch403JSONResponseBody as the provided RestrictedSellOnlyError +func (t *PlaceOrderBatch403JSONResponseBody) FromRestrictedSellOnlyError(v RestrictedSellOnlyError) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeRestrictedSellOnlyError performs a merge with any union data inside the PlaceOrderBatch403JSONResponseBody, using the provided RestrictedSellOnlyError +func (t *PlaceOrderBatch403JSONResponseBody) MergeRestrictedSellOnlyError(v RestrictedSellOnlyError) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t PlaceOrderBatch403JSONResponseBody) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *PlaceOrderBatch403JSONResponseBody) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + +// AsAuthErrorResponse returns the union data inside the CancelOrderBatch400JSONResponseBody as a AuthErrorResponse +func (t CancelOrderBatch400JSONResponseBody) AsAuthErrorResponse() (AuthErrorResponse, error) { + var body AuthErrorResponse + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromAuthErrorResponse overwrites any union data inside the CancelOrderBatch400JSONResponseBody as the provided AuthErrorResponse +func (t *CancelOrderBatch400JSONResponseBody) FromAuthErrorResponse(v AuthErrorResponse) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeAuthErrorResponse performs a merge with any union data inside the CancelOrderBatch400JSONResponseBody, using the provided AuthErrorResponse +func (t *CancelOrderBatch400JSONResponseBody) MergeAuthErrorResponse(v AuthErrorResponse) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsPredictionMarketsError returns the union data inside the CancelOrderBatch400JSONResponseBody as a PredictionMarketsError +func (t CancelOrderBatch400JSONResponseBody) AsPredictionMarketsError() (PredictionMarketsError, error) { + var body PredictionMarketsError + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromPredictionMarketsError overwrites any union data inside the CancelOrderBatch400JSONResponseBody as the provided PredictionMarketsError +func (t *CancelOrderBatch400JSONResponseBody) FromPredictionMarketsError(v PredictionMarketsError) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergePredictionMarketsError performs a merge with any union data inside the CancelOrderBatch400JSONResponseBody, using the provided PredictionMarketsError +func (t *CancelOrderBatch400JSONResponseBody) MergePredictionMarketsError(v PredictionMarketsError) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t CancelOrderBatch400JSONResponseBody) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *CancelOrderBatch400JSONResponseBody) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} diff --git a/packages/sdk-go/generated/trading/types.gen.go b/packages/sdk-go/generated/trading/types.gen.go new file mode 100644 index 00000000..02711cf5 --- /dev/null +++ b/packages/sdk-go/generated/trading/types.gen.go @@ -0,0 +1,3064 @@ +// Code generated from rest.yaml (Orders, Session). DO NOT EDIT. + +// Package trading provides primitives to interact with the openapi HTTP API. +// +// Code generated by oapi-codegen. DO NOT EDIT. +package trading + +import ( + "encoding/json" + "time" + + "github.com/gemini/gemini-go/internal/runtime" + openapi_types "github.com/gemini/gemini-go/types" +) + +const ( + ApiKeyAuthScopes apiKeyAuthContextKey = "apiKeyAuth.Scopes" + PayloadAuthScopes payloadAuthContextKey = "payloadAuth.Scopes" + SignatureAuthScopes signatureAuthContextKey = "signatureAuth.Scopes" +) + +// Defines values for BalanceType. +const ( + Exchange BalanceType = "exchange" +) + +// Valid indicates whether the value is a known member of the BalanceType enum. +func (e BalanceType) Valid() bool { + switch e { + case Exchange: + return true + default: + return false + } +} + +// Defines values for CancelOrderResponseReason. +const ( + ExceedsPriceLimits CancelOrderResponseReason = "ExceedsPriceLimits" + FillOrKillWouldNotFill CancelOrderResponseReason = "FillOrKillWouldNotFill" + ImmediateOrCancelWouldPost CancelOrderResponseReason = "ImmediateOrCancelWouldPost" + MakerOrCancelWouldTake CancelOrderResponseReason = "MakerOrCancelWouldTake" + MarketClosed CancelOrderResponseReason = "MarketClosed" + Requested CancelOrderResponseReason = "Requested" + SelfCrossPrevented CancelOrderResponseReason = "SelfCrossPrevented" + TradingClosed CancelOrderResponseReason = "TradingClosed" +) + +// Valid indicates whether the value is a known member of the CancelOrderResponseReason enum. +func (e CancelOrderResponseReason) Valid() bool { + switch e { + case ExceedsPriceLimits: + return true + case FillOrKillWouldNotFill: + return true + case ImmediateOrCancelWouldPost: + return true + case MakerOrCancelWouldTake: + return true + case MarketClosed: + return true + case Requested: + return true + case SelfCrossPrevented: + return true + case TradingClosed: + return true + default: + return false + } +} + +// Defines values for CancelOrderResponseSide. +const ( + CancelOrderResponseSideBuy CancelOrderResponseSide = "buy" + CancelOrderResponseSideSell CancelOrderResponseSide = "sell" +) + +// Valid indicates whether the value is a known member of the CancelOrderResponseSide enum. +func (e CancelOrderResponseSide) Valid() bool { + switch e { + case CancelOrderResponseSideBuy: + return true + case CancelOrderResponseSideSell: + return true + default: + return false + } +} + +// Defines values for CancelOrderResponseType. +const ( + CancelOrderResponseTypeExchangeLimit CancelOrderResponseType = "exchange limit" + CancelOrderResponseTypeExchangeMarket CancelOrderResponseType = "exchange market" + CancelOrderResponseTypeExchangeStopLimit CancelOrderResponseType = "exchange stop limit" +) + +// Valid indicates whether the value is a known member of the CancelOrderResponseType enum. +func (e CancelOrderResponseType) Valid() bool { + switch e { + case CancelOrderResponseTypeExchangeLimit: + return true + case CancelOrderResponseTypeExchangeMarket: + return true + case CancelOrderResponseTypeExchangeStopLimit: + return true + default: + return false + } +} + +// Defines values for ClearingOrderSide. +const ( + ClearingOrderSideBuy ClearingOrderSide = "buy" + ClearingOrderSideSell ClearingOrderSide = "sell" +) + +// Valid indicates whether the value is a known member of the ClearingOrderSide enum. +func (e ClearingOrderSide) Valid() bool { + switch e { + case ClearingOrderSideBuy: + return true + case ClearingOrderSideSell: + return true + default: + return false + } +} + +// Defines values for FundingPaymentEventType. +const ( + FundingPaymentEventTypeHourlyFundingTransfer FundingPaymentEventType = "Hourly Funding Transfer" +) + +// Valid indicates whether the value is a known member of the FundingPaymentEventType enum. +func (e FundingPaymentEventType) Valid() bool { + switch e { + case FundingPaymentEventTypeHourlyFundingTransfer: + return true + default: + return false + } +} + +// Defines values for FundingPaymentReportItemAction. +const ( + FundingPaymentReportItemActionCredit FundingPaymentReportItemAction = "Credit" + FundingPaymentReportItemActionDebit FundingPaymentReportItemAction = "Debit" +) + +// Valid indicates whether the value is a known member of the FundingPaymentReportItemAction enum. +func (e FundingPaymentReportItemAction) Valid() bool { + switch e { + case FundingPaymentReportItemActionCredit: + return true + case FundingPaymentReportItemActionDebit: + return true + default: + return false + } +} + +// Defines values for FundingPaymentReportItemEventType. +const ( + FundingPaymentReportItemEventTypeHourlyFundingTransfer FundingPaymentReportItemEventType = "Hourly Funding Transfer" +) + +// Valid indicates whether the value is a known member of the FundingPaymentReportItemEventType enum. +func (e FundingPaymentReportItemEventType) Valid() bool { + switch e { + case FundingPaymentReportItemEventTypeHourlyFundingTransfer: + return true + default: + return false + } +} + +// Defines values for FundingTransferAction. +const ( + FundingTransferActionCredit FundingTransferAction = "Credit" + FundingTransferActionDebit FundingTransferAction = "Debit" +) + +// Valid indicates whether the value is a known member of the FundingTransferAction enum. +func (e FundingTransferAction) Valid() bool { + switch e { + case FundingTransferActionCredit: + return true + case FundingTransferActionDebit: + return true + default: + return false + } +} + +// Defines values for InstantQuoteSide. +const ( + InstantQuoteSideBuy InstantQuoteSide = "buy" + InstantQuoteSideSell InstantQuoteSide = "sell" +) + +// Valid indicates whether the value is a known member of the InstantQuoteSide enum. +func (e InstantQuoteSide) Valid() bool { + switch e { + case InstantQuoteSideBuy: + return true + case InstantQuoteSideSell: + return true + default: + return false + } +} + +// Defines values for InterestRateInfoInterval. +const ( + Hour InterestRateInfoInterval = "hour" +) + +// Valid indicates whether the value is a known member of the InterestRateInfoInterval enum. +func (e InterestRateInfoInterval) Valid() bool { + switch e { + case Hour: + return true + default: + return false + } +} + +// Defines values for LimitOrderResponseSide. +const ( + LimitOrderResponseSideBuy LimitOrderResponseSide = "buy" + LimitOrderResponseSideSell LimitOrderResponseSide = "sell" +) + +// Valid indicates whether the value is a known member of the LimitOrderResponseSide enum. +func (e LimitOrderResponseSide) Valid() bool { + switch e { + case LimitOrderResponseSideBuy: + return true + case LimitOrderResponseSideSell: + return true + default: + return false + } +} + +// Defines values for LimitOrderResponseType. +const ( + LimitOrderResponseTypeExchangeLimit LimitOrderResponseType = "exchange limit" + LimitOrderResponseTypeExchangeMarket LimitOrderResponseType = "exchange market" + LimitOrderResponseTypeExchangeStopLimit LimitOrderResponseType = "exchange stop limit" +) + +// Valid indicates whether the value is a known member of the LimitOrderResponseType enum. +func (e LimitOrderResponseType) Valid() bool { + switch e { + case LimitOrderResponseTypeExchangeLimit: + return true + case LimitOrderResponseTypeExchangeMarket: + return true + case LimitOrderResponseTypeExchangeStopLimit: + return true + default: + return false + } +} + +// Defines values for MyTradeBreak. +const ( + Empty MyTradeBreak = "" + TradeCorrect MyTradeBreak = "trade correct" +) + +// Valid indicates whether the value is a known member of the MyTradeBreak enum. +func (e MyTradeBreak) Valid() bool { + switch e { + case Empty: + return true + case TradeCorrect: + return true + default: + return false + } +} + +// Defines values for MyTradeType. +const ( + MyTradeTypeBuy MyTradeType = "Buy" + MyTradeTypeSell MyTradeType = "Sell" +) + +// Valid indicates whether the value is a known member of the MyTradeType enum. +func (e MyTradeType) Valid() bool { + switch e { + case MyTradeTypeBuy: + return true + case MyTradeTypeSell: + return true + default: + return false + } +} + +// Defines values for NewOrderRequestOptions. +const ( + FillOrKill NewOrderRequestOptions = "fill-or-kill" + ImmediateOrCancel NewOrderRequestOptions = "immediate-or-cancel" + MakerOrCancel NewOrderRequestOptions = "maker-or-cancel" +) + +// Valid indicates whether the value is a known member of the NewOrderRequestOptions enum. +func (e NewOrderRequestOptions) Valid() bool { + switch e { + case FillOrKill: + return true + case ImmediateOrCancel: + return true + case MakerOrCancel: + return true + default: + return false + } +} + +// Defines values for NewOrderRequestSide. +const ( + NewOrderRequestSideBuy NewOrderRequestSide = "buy" + NewOrderRequestSideSell NewOrderRequestSide = "sell" +) + +// Valid indicates whether the value is a known member of the NewOrderRequestSide enum. +func (e NewOrderRequestSide) Valid() bool { + switch e { + case NewOrderRequestSideBuy: + return true + case NewOrderRequestSideSell: + return true + default: + return false + } +} + +// Defines values for NewOrderRequestType. +const ( + NewOrderRequestTypeExchangeLimit NewOrderRequestType = "exchange limit" + NewOrderRequestTypeExchangeMarket NewOrderRequestType = "exchange market" + NewOrderRequestTypeExchangeStopLimit NewOrderRequestType = "exchange stop limit" +) + +// Valid indicates whether the value is a known member of the NewOrderRequestType enum. +func (e NewOrderRequestType) Valid() bool { + switch e { + case NewOrderRequestTypeExchangeLimit: + return true + case NewOrderRequestTypeExchangeMarket: + return true + case NewOrderRequestTypeExchangeStopLimit: + return true + default: + return false + } +} + +// Defines values for OrderSide. +const ( + OrderSideBuy OrderSide = "buy" + OrderSideSell OrderSide = "sell" +) + +// Valid indicates whether the value is a known member of the OrderSide enum. +func (e OrderSide) Valid() bool { + switch e { + case OrderSideBuy: + return true + case OrderSideSell: + return true + default: + return false + } +} + +// Defines values for OrderTradesType. +const ( + OrderTradesTypeBuy OrderTradesType = "Buy" + OrderTradesTypeSell OrderTradesType = "Sell" +) + +// Valid indicates whether the value is a known member of the OrderTradesType enum. +func (e OrderTradesType) Valid() bool { + switch e { + case OrderTradesTypeBuy: + return true + case OrderTradesTypeSell: + return true + default: + return false + } +} + +// Defines values for OrderType. +const ( + OrderTypeExchangeLimit OrderType = "exchange limit" + OrderTypeExchangeMarket OrderType = "exchange market" + OrderTypeExchangeStopLimit OrderType = "exchange stop limit" +) + +// Valid indicates whether the value is a known member of the OrderType enum. +func (e OrderType) Valid() bool { + switch e { + case OrderTypeExchangeLimit: + return true + case OrderTypeExchangeMarket: + return true + case OrderTypeExchangeStopLimit: + return true + default: + return false + } +} + +// Defines values for RiskStatsResponseProductType. +const ( + PerpetualSwapContract RiskStatsResponseProductType = "PerpetualSwapContract" +) + +// Valid indicates whether the value is a known member of the RiskStatsResponseProductType enum. +func (e RiskStatsResponseProductType) Valid() bool { + switch e { + case PerpetualSwapContract: + return true + default: + return false + } +} + +// Defines values for StakingTransactionTransactionType. +const ( + StakingTransactionTransactionTypeAdminCreditAdjustment StakingTransactionTransactionType = "AdminCreditAdjustment" + StakingTransactionTransactionTypeAdminDebitAdjustment StakingTransactionTransactionType = "AdminDebitAdjustment" + StakingTransactionTransactionTypeAdminRedeem StakingTransactionTransactionType = "AdminRedeem" + StakingTransactionTransactionTypeDeposit StakingTransactionTransactionType = "Deposit" + StakingTransactionTransactionTypeInterest StakingTransactionTransactionType = "Interest" + StakingTransactionTransactionTypeRedeem StakingTransactionTransactionType = "Redeem" + StakingTransactionTransactionTypeRedeemPayment StakingTransactionTransactionType = "RedeemPayment" +) + +// Valid indicates whether the value is a known member of the StakingTransactionTransactionType enum. +func (e StakingTransactionTransactionType) Valid() bool { + switch e { + case StakingTransactionTransactionTypeAdminCreditAdjustment: + return true + case StakingTransactionTransactionTypeAdminDebitAdjustment: + return true + case StakingTransactionTransactionTypeAdminRedeem: + return true + case StakingTransactionTransactionTypeDeposit: + return true + case StakingTransactionTransactionTypeInterest: + return true + case StakingTransactionTransactionTypeRedeem: + return true + case StakingTransactionTransactionTypeRedeemPayment: + return true + default: + return false + } +} + +// Defines values for StopLimitOrderResponseSide. +const ( + StopLimitOrderResponseSideBuy StopLimitOrderResponseSide = "buy" + StopLimitOrderResponseSideSell StopLimitOrderResponseSide = "sell" +) + +// Valid indicates whether the value is a known member of the StopLimitOrderResponseSide enum. +func (e StopLimitOrderResponseSide) Valid() bool { + switch e { + case StopLimitOrderResponseSideBuy: + return true + case StopLimitOrderResponseSideSell: + return true + default: + return false + } +} + +// Defines values for StopLimitOrderResponseType. +const ( + ExchangeStopLimit StopLimitOrderResponseType = "exchange stop limit" +) + +// Valid indicates whether the value is a known member of the StopLimitOrderResponseType enum. +func (e StopLimitOrderResponseType) Valid() bool { + switch e { + case ExchangeStopLimit: + return true + default: + return false + } +} + +// Defines values for TradeType. +const ( + TradeTypeBuy TradeType = "buy" + TradeTypeSell TradeType = "sell" +) + +// Valid indicates whether the value is a known member of the TradeType enum. +func (e TradeType) Valid() bool { + switch e { + case TradeTypeBuy: + return true + case TradeTypeSell: + return true + default: + return false + } +} + +// Defines values for TransferStatus. +const ( + TransferStatusComplete TransferStatus = "Complete" + TransferStatusPending TransferStatus = "Pending" +) + +// Valid indicates whether the value is a known member of the TransferStatus enum. +func (e TransferStatus) Valid() bool { + switch e { + case TransferStatusComplete: + return true + case TransferStatusPending: + return true + default: + return false + } +} + +// Defines values for TransferType. +const ( + TransferTypeDeposit TransferType = "Deposit" + TransferTypeWithdrawal TransferType = "Withdrawal" +) + +// Valid indicates whether the value is a known member of the TransferType enum. +func (e TransferType) Valid() bool { + switch e { + case TransferTypeDeposit: + return true + case TransferTypeWithdrawal: + return true + default: + return false + } +} + +// Defines values for V2TransferStatus. +const ( + V2TransferStatusAdvanced V2TransferStatus = "Advanced" + V2TransferStatusComplete V2TransferStatus = "Complete" + V2TransferStatusPending V2TransferStatus = "Pending" +) + +// Valid indicates whether the value is a known member of the V2TransferStatus enum. +func (e V2TransferStatus) Valid() bool { + switch e { + case V2TransferStatusAdvanced: + return true + case V2TransferStatusComplete: + return true + case V2TransferStatusPending: + return true + default: + return false + } +} + +// Defines values for V2TransferType. +const ( + AdminCredit V2TransferType = "AdminCredit" + AdminDebit V2TransferType = "AdminDebit" + Deposit V2TransferType = "Deposit" + Reward V2TransferType = "Reward" + Withdrawal V2TransferType = "Withdrawal" +) + +// Valid indicates whether the value is a known member of the V2TransferType enum. +func (e V2TransferType) Valid() bool { + switch e { + case AdminCredit: + return true + case AdminDebit: + return true + case Deposit: + return true + case Reward: + return true + case Withdrawal: + return true + default: + return false + } +} + +// Defines values for WrapOrderJSONBodySide. +const ( + Buy WrapOrderJSONBodySide = "buy" + Sell WrapOrderJSONBodySide = "sell" +) + +// Valid indicates whether the value is a known member of the WrapOrderJSONBodySide enum. +func (e WrapOrderJSONBodySide) Valid() bool { + switch e { + case Buy: + return true + case Sell: + return true + default: + return false + } +} + +// Account defines model for Account. +type Account struct { + // AccountId The account ID + AccountId *string `json:"account_id,omitempty"` + + // Created The creation date + Created *string `json:"created,omitempty"` + + // IsDefault Whether the account is the default account + IsDefault *bool `json:"is_default,omitempty"` + + // Name The account name + Name *string `json:"name,omitempty"` +} + +// AddBankResponse defines model for AddBankResponse. +type AddBankResponse struct { + // ReferenceId Reference ID for the new bank addition request. Once received, send in a wire from the requested bank account to verify it and enable withdrawals to that account. + ReferenceId *string `json:"referenceId,omitempty"` + + // Result Status result (e.g. ok). + Result *string `json:"result,omitempty"` +} + +// Address defines model for Address. +type Address struct { + // Address String representation of the cryptocurrency address + Address *string `json:"address,omitempty"` + + // Label If you provided a label when creating the address, it will be echoed back here + Label *string `json:"label,omitempty"` + + // Memo It would be present if applicable, it will be present for cosmos address + Memo *string `json:"memo,omitempty"` + + // Network The blockchain network for the address + Network *string `json:"network,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` +} + +// ApprovedAddress defines model for ApprovedAddress. +type ApprovedAddress struct { + // Address The address on the approved address list. + Address *string `json:"address,omitempty"` + + // CreatedAt UTC timestamp in millisecond of when the address was created. + CreatedAt *string `json:"createdAt,omitempty"` + + // Label The label assigned to the address + Label *string `json:"label,omitempty"` + + // Network The network of the approved address. Network can be `bitcoin`, `ethereum`, `bitcoincash`, `litecoin`, `zcash`, `filecoin`, `dogecoin`, `tezos`, `solana`, `polkadot`, `avalanche`, `cosmos`, or `xrpl` + Network *string `json:"network,omitempty"` + + // Scope Will return the scope of the address as either "account" or "group" + Scope *string `json:"scope,omitempty"` + + // Status The status of the address that will return as "active", "pending-time" or "pending-mua". The remaining time is exactly 7 days after the initial request. "pending-mua" is for multi-user accounts and will require another administator or fund manager on the account to approve the address. + Status *string `json:"status,omitempty"` +} + +// ApprovedAddressMessage defines model for ApprovedAddressMessage. +type ApprovedAddressMessage struct { + // Message Status or confirmation message for the approved address request or removal. + Message *string `json:"message,omitempty"` + + // Result Result status (e.g. ok). + Result *string `json:"result,omitempty"` +} + +// ApprovedAddressesResponse Response envelope containing the approved withdrawal addresses. +type ApprovedAddressesResponse struct { + // ApprovedAddresses Array of approved addresses on both the account and group level. + ApprovedAddresses *[]ApprovedAddress `json:"approvedAddresses,omitempty"` +} + +// Balance defines model for Balance. +type Balance struct { + // UnderscoreTimestamp Server-side monotonically increasing clock value as an ISO 8601 timestamp. Clients can use this value to detect and filter out stale responses that may occur due to load balancing or potential stale servers. + UnderscoreTimestamp *time.Time `json:"_timestamp,omitempty"` + + // Amount The confirmed balance for the currency (also referred to as `confirmedBalance`). For crypto withdrawals, this value is **not** reduced until the withdrawal has been confirmed on the blockchain. This delay protects against blockchain reorganizations. Use the `available` field instead if you need balances that immediately reflect holds. + Amount *openapi_types.DecimalNumber `json:"amount,omitempty"` + + // Available The amount available for trading. This value is reduced **immediately** when an order hold or withdrawal hold is placed, making it the recommended field for tracking real-time spendable balances. + Available *openapi_types.DecimalNumber `json:"available,omitempty"` + + // AvailableForWithdrawal The amount available for withdrawal + AvailableForWithdrawal *openapi_types.DecimalNumber `json:"availableForWithdrawal,omitempty"` + + // Currency The currency symbol + Currency *string `json:"currency,omitempty"` + + // PendingDeposit The amount pending deposit + PendingDeposit *openapi_types.DecimalNumber `json:"pendingDeposit,omitempty"` + + // PendingWithdrawal The amount pending withdrawal + PendingWithdrawal *openapi_types.DecimalNumber `json:"pendingWithdrawal,omitempty"` + Type *BalanceType `json:"type,omitempty"` +} + +// BalanceType defines model for Balance.Type. +type BalanceType string + +// CancelAllOrdersBySessionRequest defines model for CancelAllOrdersBySessionRequest. +type CancelAllOrdersBySessionRequest struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Specifies the account on which you intend to cancel the orders. Only available for exchange accounts. + Account *string `json:"account,omitempty"` + + // Nonce timestamp + Nonce TimestampType `json:"nonce"` + + // Request The literal string "/v1/order/cancel/session" + Request string `json:"request"` +} + +// CancelAllOrdersRequest defines model for CancelAllOrdersRequest. +type CancelAllOrdersRequest struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Specifies the account on which you intend to cancel the orders. Only available for exchange accounts. + Account *string `json:"account,omitempty"` + + // Nonce timestamp + Nonce TimestampType `json:"nonce"` + + // Request The literal string "/v1/order/cancel/all" + Request string `json:"request"` +} + +// CancelAllResult defines model for CancelAllResult. +type CancelAllResult struct { + // Details cancelledOrders/cancelRejects with IDs of both + Details *struct { + CancelRejects *[]int `json:"cancelRejects,omitempty"` + CancelledOrders *[]int `json:"cancelledOrders,omitempty"` + } `json:"details,omitempty"` + Result *string `json:"result,omitempty"` +} + +// CancelOrderRequest defines model for CancelOrderRequest. +type CancelOrderRequest struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Specifies the account on which you intend to cancel the order. Only available for exchange accounts. + Account *string `json:"account,omitempty"` + + // Nonce timestamp + Nonce TimestampType `json:"nonce"` + + // OrderId The order ID given by `/order/new` + OrderId int64 `json:"order_id"` + + // Request The literal string "/v1/order/cancel" + Request string `json:"request"` +} + +// CancelOrderResponse defines model for CancelOrderResponse. +type CancelOrderResponse struct { + AvgExecutionPrice *string `json:"avg_execution_price,omitempty"` + Exchange *string `json:"exchange,omitempty"` + ExecutedAmount *string `json:"executed_amount,omitempty"` + Id *string `json:"id,omitempty"` + IsCancelled *bool `json:"is_cancelled,omitempty"` + IsHidden *bool `json:"is_hidden,omitempty"` + IsLive *bool `json:"is_live,omitempty"` + Options *[]string `json:"options,omitempty"` + OrderId *string `json:"order_id,omitempty"` + OriginalAmount *string `json:"original_amount,omitempty"` + Price *string `json:"price,omitempty"` + Reason *CancelOrderResponseReason `json:"reason,omitempty"` + RemainingAmount *string `json:"remaining_amount,omitempty"` + Side *CancelOrderResponseSide `json:"side,omitempty"` + Symbol *string `json:"symbol,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + Type *CancelOrderResponseType `json:"type,omitempty"` + WasForced *bool `json:"was_forced,omitempty"` +} + +// CancelOrderResponseReason defines model for CancelOrderResponse.Reason. +type CancelOrderResponseReason string + +// CancelOrderResponseSide defines model for CancelOrderResponse.Side. +type CancelOrderResponseSide string + +// CancelOrderResponseType defines model for CancelOrderResponse.Type. +type CancelOrderResponseType string + +// Candle defines model for Candle. +type Candle = []float64 + +// CandleResponse defines model for CandleResponse. +type CandleResponse = []Candle + +// ClearingOrder defines model for ClearingOrder. +type ClearingOrder struct { + // Amount The order amount + Amount *string `json:"amount,omitempty"` + + // ClearingId The clearing ID + ClearingId *string `json:"clearing_id,omitempty"` + + // IsConfirmed Whether the order is confirmed + IsConfirmed *bool `json:"is_confirmed,omitempty"` + + // Price The order price + Price *string `json:"price,omitempty"` + Side *ClearingOrderSide `json:"side,omitempty"` + + // Status The order status + Status *string `json:"status,omitempty"` + + // Symbol The trading pair + Symbol *string `json:"symbol,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + + // Timestampms The timestamp in milliseconds + Timestampms *int `json:"timestampms,omitempty"` +} + +// ClearingOrderSide defines model for ClearingOrder.Side. +type ClearingOrderSide string + +// CustodyFeeTransfer defines model for CustodyFeeTransfer. +type CustodyFeeTransfer struct { + // Eid Custody fee event id + Eid *int `json:"eid,omitempty"` + + // EventType Custody fee event type + EventType *string `json:"eventType,omitempty"` + + // FeeAmount The fee amount charged + FeeAmount *string `json:"feeAmount,omitempty"` + + // FeeCurrency Currency that the fee was paid in + FeeCurrency *string `json:"feeCurrency,omitempty"` + + // TxTime Time of Custody fee record in milliseconds + TxTime *int `json:"txTime,omitempty"` +} + +// ErrorResponse defines model for ErrorResponse. +type ErrorResponse struct { + // Message Detailed error message + Message *string `json:"message,omitempty"` + + // Reason A short description + Reason *string `json:"reason,omitempty"` + + // Result Error + Result *string `json:"result,omitempty"` +} + +// FeeEstimateRequest defines model for FeeEstimateRequest. +type FeeEstimateRequest struct { + // Account The name of the account within the subaccount group. + Account string `json:"account"` + + // Address Standard string format of cryptocurrency address + Address string `json:"address"` + + // Amount Quoted decimal amount to withdraw + Amount string `json:"amount"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce Nonce `json:"nonce"` + + // Request The string `/v1/withdraw/{currencyCodeLowerCase}/feeEstimate` where `:currencyCodeLowerCase` is replaced with the currency code of a supported crypto-currency, e.g. `eth`, `aave`, etc. See [Symbols and minimums](/market-data/symbols-and-minimums) + Request string `json:"request"` +} + +// FeeEstimateResponse defines model for FeeEstimateResponse. +type FeeEstimateResponse struct { + // Currency Currency code, see [symbols](/market-data/symbols-and-minimums). + Currency *string `json:"currency,omitempty"` + + // Fee The estimated gas fee + Fee *string `json:"fee,omitempty"` + + // IsOverride Value that shows if an override on the customer's account for free withdrawals exists + IsOverride *bool `json:"isOverride,omitempty"` + + // MonthlyLimit Total nunber of allowable fee-free withdrawals + MonthlyLimit *int `json:"monthlyLimit,omitempty"` + + // MonthlyRemaining Total number of allowable fee-free withdrawals left to use + MonthlyRemaining *int `json:"monthlyRemaining,omitempty"` +} + +// FeeEstimateV2Request defines model for FeeEstimateV2Request. +type FeeEstimateV2Request struct { + // Account Required for Master API keys. The name of the account within the subaccount group. + Account *string `json:"account,omitempty"` + + // Address Standard string format of the destination cryptocurrency address + Address string `json:"address"` + + // Amount Quoted decimal amount to withdraw + Amount string `json:"amount"` + + // Memo It would be present if applicable, it will be present for cosmos address. + Memo *string `json:"memo,omitempty"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce Nonce `json:"nonce"` + + // Request The string `/v2/withdraw/{network}/{ticker}/feeEstimate` where `{network}` is the blockchain network (e.g. `ethereum`, `bitcoin`, `solana`) and `{ticker}` is the currency code (e.g. `eth`, `btc`, `sol`). See [Symbols and minimums](/market-data/symbols-and-minimums) + Request string `json:"request"` +} + +// FeeEstimateV2Response defines model for FeeEstimateV2Response. +type FeeEstimateV2Response struct { + // Currency Currency code, see [symbols](/market-data/symbols-and-minimums). + Currency *string `json:"currency,omitempty"` + + // Fee The estimated withdrawal fee as a decimal amount + Fee *openapi_types.DecimalNumber `json:"fee,omitempty"` + + // IsOverride Whether an override on the customer's account for free withdrawals exists + IsOverride *bool `json:"isOverride,omitempty"` + + // MonthlyLimit Total number of allowable fee-free withdrawals + MonthlyLimit *int `json:"monthlyLimit,omitempty"` + + // MonthlyRemaining Total number of allowable fee-free withdrawals remaining + MonthlyRemaining *int `json:"monthlyRemaining,omitempty"` +} + +// FeePromos defines model for FeePromos. +type FeePromos struct { + // Symbols Symbols that currently have fee promos + Symbols *[]string `json:"symbols,omitempty"` +} + +// FundingAmountResponse defines model for FundingAmountResponse. +type FundingAmountResponse struct { + // Amount The dollar amount for a Long 1 position held in the symbol for funding period (1 hour) + Amount *openapi_types.DecimalNumber `json:"amount,omitempty"` + + // EstimatedFundingAmount The estimated dollar amount for a Long 1 position held in the symbol for next funding period (1 hour) + EstimatedFundingAmount *openapi_types.DecimalNumber `json:"estimatedFundingAmount,omitempty"` + + // FundingDateTime UTC date time in format `yyyy-MM-ddThh:mm:ss.SSSZ` format + FundingDateTime *string `json:"fundingDateTime,omitempty"` + + // FundingTimestampMilliSecs Current funding amount Epoc time. + FundingTimestampMilliSecs *int64 `json:"fundingTimestampMilliSecs,omitempty"` + + // NextFundingTimestamp Next funding amount Epoc time. + NextFundingTimestamp *int64 `json:"nextFundingTimestamp,omitempty"` + + // Symbol The requested symbol. See [**symbols and minimums**](/market-data/symbols-and-minimums#all-supported-symbols) + Symbol *string `json:"symbol,omitempty"` +} + +// FundingPayment defines model for FundingPayment. +type FundingPayment struct { + // EventType Event type + EventType FundingPaymentEventType `json:"eventType"` + HourlyFundingTransfer FundingTransfer `json:"hourlyFundingTransfer"` +} + +// FundingPaymentEventType Event type +type FundingPaymentEventType string + +// FundingPaymentReportItem defines model for FundingPaymentReportItem. +type FundingPaymentReportItem struct { + // Action Credit or Debit + Action FundingPaymentReportItemAction `json:"action"` + + // AssetCode Asset symbol + AssetCode string `json:"assetCode"` + + // EventType Event type + EventType FundingPaymentReportItemEventType `json:"eventType"` + + // InstrumentSymbol Symbol of the underlying instrument. **Note** that this is only attached to requests from 16th April 2024 onwards. + InstrumentSymbol *string `json:"instrumentSymbol,omitempty"` + + // Quantity A nested JSON object describing the transaction amount + Quantity Quantity `json:"quantity"` + + // Timestamp Time of the funding payment + Timestamp TimestampType `json:"timestamp"` +} + +// FundingPaymentReportItemAction Credit or Debit +type FundingPaymentReportItemAction string + +// FundingPaymentReportItemEventType Event type +type FundingPaymentReportItemEventType string + +// FundingTransfer defines model for FundingTransfer. +type FundingTransfer struct { + // Action Credit or Debit + Action FundingTransferAction `json:"action"` + + // AssetCode Asset symbol + AssetCode string `json:"assetCode"` + + // EventType Event type + EventType string `json:"eventType"` + + // InstrumentSymbol Symbol of the underlying instrument. **Note** that this is only attached to requests from 16th April 2024 onwards. + InstrumentSymbol *string `json:"instrumentSymbol,omitempty"` + + // Quantity A nested JSON object describing the transaction amount + Quantity Quantity `json:"quantity"` + + // Timestamp Time of the funding payment + Timestamp TimestampType `json:"timestamp"` +} + +// FundingTransferAction Credit or Debit +type FundingTransferAction string + +// FxRate defines model for FxRate. +type FxRate struct { + // AsOf timestamp + AsOf *TimestampType `json:"asOf,omitempty"` + + // Benchmark The market for which the retrieved price applies to + Benchmark *string `json:"benchmark,omitempty"` + + // FxPair The requested currency pair + FxPair *string `json:"fxPair,omitempty"` + + // Provider The market data provider + Provider *string `json:"provider,omitempty"` + + // Rate The exchange rate + Rate *float64 `json:"rate,omitempty"` +} + +// Heartbeat defines model for Heartbeat. +type Heartbeat struct { + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce *Heartbeat_Nonce `json:"nonce,omitempty"` + + // Request The literal string `/v1/heartbeat` + Request *string `json:"request,omitempty"` +} + +// HeartbeatNonce0 Gemini strongly recommends using milliseconds instead of seconds for timestamps. +// +// | Timestamp format | Example | Supported request type | +// |-----------------------|-----------------------|------------------------| +// | string (seconds) | `'1495127793'` | `POST` only | +// | string (milliseconds) | `'1495127793000'` | `POST` only | +type HeartbeatNonce0 = string + +// HeartbeatNonce1 Gemini strongly recommends using milliseconds instead of seconds for timestamps. +// +// | Timestamp format | Example | Supported request type | +// |-----------------------------|---------------------------|------------------------| +// | whole number (seconds) | `1495127793` | `GET`, `POST` | +// | whole number (milliseconds) | `1495127793000` | `GET`, `POST` | +type HeartbeatNonce1 = int64 + +// Heartbeat_Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) +type Heartbeat_Nonce struct { + union json.RawMessage +} + +// InstantQuote defines model for InstantQuote. +type InstantQuote struct { + // DepositFee The deposit fee quantity. Will be applied if a debit card is used for the order. Will return 0 if there is no `depositFee` + DepositFee *string `json:"depositFee,omitempty"` + + // DepositFeeCurrency Currency in which `depositFee` is taken + DepositFeeCurrency *string `json:"depositFeeCurrency,omitempty"` + + // Fee The fee quantity to be taken for the order upon execution + Fee *string `json:"fee,omitempty"` + + // FeeCurrency The currency label for the order + FeeCurrency *string `json:"feeCurrency,omitempty"` + + // MaxAgeMs Number of milliseconds until this quote price expires. Once expired, you will need to request a new quote + MaxAgeMs *int `json:"maxAgeMs,omitempty"` + + // Pair The symbol passed in the quote request + Pair *string `json:"pair,omitempty"` + + // Price The quoted price of the asset. This will not change when attempting execution + Price *string `json:"price,omitempty"` + + // PriceCurrency The currency in which the order is priced. Matches `CCY2` in the symbol + PriceCurrency *string `json:"priceCurrency,omitempty"` + + // Quantity The quantity of the asset to be bought or sold + Quantity *string `json:"quantity,omitempty"` + + // QuantityCurrency The currency label for the `quantity` field. Matches `CCY1` in the symbol + QuantityCurrency *string `json:"quantityCurrency,omitempty"` + + // QuoteId Unique ID for the quote. This is used in the execution of the order + QuoteId *int `json:"quoteId,omitempty"` + + // Side Either "buy" or "sell" + Side *InstantQuoteSide `json:"side,omitempty"` + + // TotalSpend Total quantity to spend for the order. Will be the sum inclusive of all fees and amount to be traded. + TotalSpend *string `json:"totalSpend,omitempty"` + + // TotalSpendCurrency Currency of the `totalSpend` to be spent on the order + TotalSpendCurrency *string `json:"totalSpendCurrency,omitempty"` +} + +// InstantQuoteSide Either "buy" or "sell" +type InstantQuoteSide string + +// InterestRateInfo defines model for InterestRateInfo. +type InterestRateInfo struct { + // Interval The time interval for the rate (currently only "hour" is supported) + Interval InterestRateInfoInterval `json:"interval"` + + // Rate The interest rate as a decimal string + Rate string `json:"rate"` +} + +// InterestRateInfoInterval The time interval for the rate (currently only "hour" is supported) +type InterestRateInfoInterval string + +// LimitOrderResponse defines model for LimitOrderResponse. +type LimitOrderResponse struct { + AvgExecutionPrice *string `json:"avg_execution_price,omitempty"` + ClientOrderId *string `json:"client_order_id,omitempty"` + Exchange *string `json:"exchange,omitempty"` + ExecutedAmount *string `json:"executed_amount,omitempty"` + Id *string `json:"id,omitempty"` + IsCancelled *bool `json:"is_cancelled,omitempty"` + IsHidden *bool `json:"is_hidden,omitempty"` + IsLive *bool `json:"is_live,omitempty"` + Options *[]string `json:"options,omitempty"` + OrderId *string `json:"order_id,omitempty"` + OriginalAmount *string `json:"original_amount,omitempty"` + Price *string `json:"price,omitempty"` + RemainingAmount *string `json:"remaining_amount,omitempty"` + Side *LimitOrderResponseSide `json:"side,omitempty"` + Symbol *string `json:"symbol,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + Type *LimitOrderResponseType `json:"type,omitempty"` + WasForced *bool `json:"was_forced,omitempty"` +} + +// LimitOrderResponseSide defines model for LimitOrderResponse.Side. +type LimitOrderResponseSide string + +// LimitOrderResponseType defines model for LimitOrderResponse.Type. +type LimitOrderResponseType string + +// LiquidationRisk defines model for LiquidationRisk. +type LiquidationRisk struct { + // LiquidationPrice The estimated price at which liquidation would occur (optional, may not be present for all positions) + LiquidationPrice *MoneyAmount `json:"liquidationPrice,omitempty"` + + // LossPercentage The percentage loss from current value that would trigger liquidation, formatted as decimal (e.g., "0.1550" = 15.50%) + LossPercentage string `json:"lossPercentage"` +} + +// MarginAccountSummary defines model for MarginAccountSummary. +type MarginAccountSummary struct { + // AvailableCollateral The amount of collateral available for new positions or withdrawals + AvailableCollateral MoneyAmount `json:"availableCollateral"` + + // BuyingPower The maximum value that can be purchased with available collateral + BuyingPower MoneyAmount `json:"buyingPower"` + + // InterestRate Current interest rate on borrowed amounts (only present if borrows exist) + InterestRate *InterestRateInfo `json:"interestRate,omitempty"` + + // Leverage The current leverage ratio (notionalValue / marginAssetValue) + Leverage string `json:"leverage"` + + // LiquidationRisk Liquidation risk information (only present if positions exist) + LiquidationRisk *LiquidationRisk `json:"liquidationRisk,omitempty"` + + // MarginAssetValue The total value of all assets available in the margin account that can contribute to funding positions + MarginAssetValue MoneyAmount `json:"marginAssetValue"` + + // NotionalValue The total value of all open positions + NotionalValue MoneyAmount `json:"notionalValue"` + + // ReservedBuyOrders Collateral reserved for open buy orders + ReservedBuyOrders MoneyAmount `json:"reservedBuyOrders"` + + // ReservedSellOrders Collateral reserved for open sell orders + ReservedSellOrders MoneyAmount `json:"reservedSellOrders"` + + // SellingPower The maximum value that can be sold with available collateral + SellingPower MoneyAmount `json:"sellingPower"` + + // TotalBorrowed The total amount currently borrowed across all currencies + TotalBorrowed MoneyAmount `json:"totalBorrowed"` +} + +// MarginInterestRate defines model for MarginInterestRate. +type MarginInterestRate struct { + // BorrowRate The hourly borrow rate as a decimal + BorrowRate string `json:"borrowRate"` + + // BorrowRateAnnual The annualized borrow rate (daily rate × 365) + BorrowRateAnnual string `json:"borrowRateAnnual"` + + // BorrowRateDaily The daily borrow rate (hourly rate × 24) + BorrowRateDaily string `json:"borrowRateDaily"` + + // Currency The currency code (e.g., "BTC", "ETH", "USD") + Currency string `json:"currency"` + + // LastUpdated Unix timestamp in milliseconds when the rate was last updated + LastUpdated int64 `json:"lastUpdated"` +} + +// MarginOrderPreview defines model for MarginOrderPreview. +type MarginOrderPreview struct { + // Postorder Margin risk statistics after the order would be executed + Postorder MarginRiskStats `json:"postorder"` + + // Preorder Margin risk statistics before the order would be executed + Preorder MarginRiskStats `json:"preorder"` +} + +// MarginRatesResponse defines model for MarginRatesResponse. +type MarginRatesResponse struct { + // Rates Array of interest rates for all borrowable currencies + Rates []MarginInterestRate `json:"rates"` +} + +// MarginResponse defines model for MarginResponse. +type MarginResponse struct { + // AvailableMargin The difference between the `margin_assets_value` and `initial_margin`. + AvailableMargin *string `json:"available_margin,omitempty"` + + // BuyingPower The amount of that product the account could purchase based on current `initial_margin` and `margin_assets_value`. + BuyingPower *string `json:"buying_power,omitempty"` + + // EstimatedLiquidationPrice The estimated price for the asset at which liquidation would occur. + EstimatedLiquidationPrice *string `json:"estimated_liquidation_price,omitempty"` + + // InitialMargin The $ amount that is being required by the accounts current positions and open orders. + InitialMargin *string `json:"initial_margin,omitempty"` + + // InitialMarginPositions The contribution to `initial_margin` from open positions. + InitialMarginPositions *string `json:"initial_margin_positions,omitempty"` + + // Leverage The ratio of Notional Value to Margin Assets Value. + Leverage *string `json:"leverage,omitempty"` + + // MarginAssetsValue The $ equivalent value of all the assets available in the current trading account that can contribute to funding a derivatives position. + MarginAssetsValue *string `json:"margin_assets_value,omitempty"` + + // MarginMaintenanceLimit The minimum amount of `margin_assets_value` required before the account is moved to liquidation status. + MarginMaintenanceLimit *string `json:"margin_maintenance_limit,omitempty"` + + // NotionalValue The $ value of the current position. + NotionalValue *string `json:"notional_value,omitempty"` + + // ReservedMargin The contribution to `initial_margin` from open orders. + ReservedMargin *string `json:"reserved_margin,omitempty"` + + // ReservedMarginBuys The contribution to `initial_margin` from open BUY orders. + ReservedMarginBuys *string `json:"reserved_margin_buys,omitempty"` + + // ReservedMarginSells The contribution to `initial_margin` from open SELL orders. + ReservedMarginSells *string `json:"reserved_margin_sells,omitempty"` + + // SellingPower The amount of that product the account could sell based on current `initial_margin` and `margin_assets_value`. + SellingPower *string `json:"selling_power,omitempty"` +} + +// MarginRiskStats defines model for MarginRiskStats. +type MarginRiskStats struct { + // AvailableCollateral The amount of collateral available for new positions + AvailableCollateral MoneyAmount `json:"availableCollateral"` + + // BuyingPower The maximum value that can be purchased + BuyingPower MoneyAmount `json:"buyingPower"` + + // Leverage The leverage ratio + Leverage string `json:"leverage"` + + // LiquidationRisk Liquidation risk information (only present if applicable) + LiquidationRisk *LiquidationRisk `json:"liquidationRisk,omitempty"` + + // MarginAssetValue The total value of all assets available in the margin account + MarginAssetValue MoneyAmount `json:"marginAssetValue"` + + // NotionalValue The total value of all open positions + NotionalValue MoneyAmount `json:"notionalValue"` + + // ReservedBuyOrders Collateral reserved for open buy orders + ReservedBuyOrders MoneyAmount `json:"reservedBuyOrders"` + + // ReservedSellOrders Collateral reserved for open sell orders + ReservedSellOrders MoneyAmount `json:"reservedSellOrders"` + + // SellingPower The maximum value that can be sold + SellingPower MoneyAmount `json:"sellingPower"` + + // TotalBorrowed The total amount currently borrowed + TotalBorrowed MoneyAmount `json:"totalBorrowed"` +} + +// MoneyAmount defines model for MoneyAmount. +type MoneyAmount struct { + // Currency The currency code (e.g., "USD", "BTC", "ETH") + Currency string `json:"currency"` + + // Value The amount in the specified currency + Value string `json:"value"` +} + +// MyTrade defines model for MyTrade. +type MyTrade struct { + Aggressor *bool `json:"aggressor,omitempty"` + Amount *string `json:"amount,omitempty"` + Break *MyTradeBreak `json:"break,omitempty"` + ClientOrderId *string `json:"client_order_id,omitempty"` + Exchange *string `json:"exchange,omitempty"` + FeeAmount *string `json:"fee_amount,omitempty"` + FeeCurrency *string `json:"fee_currency,omitempty"` + IsAuctionFill *bool `json:"is_auction_fill,omitempty"` + OrderId *string `json:"order_id,omitempty"` + Price *string `json:"price,omitempty"` + Tid *int64 `json:"tid,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + Type *MyTradeType `json:"type,omitempty"` +} + +// MyTradeBreak defines model for MyTrade.Break. +type MyTradeBreak string + +// MyTradeType defines model for MyTrade.Type. +type MyTradeType string + +// MyTradesRequest defines model for MyTradesRequest. +type MyTradesRequest struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Specifies the account on which you intend to place the order. Only available for exchange accounts. + Account *string `json:"account,omitempty"` + + // LimitTrades The maximum number of trades to return. Default is 50, max is 500. + LimitTrades *int `json:"limit_trades,omitempty"` + + // Nonce timestamp + Nonce TimestampType `json:"nonce"` + + // Request The API endpoint path + Request string `json:"request"` + + // Symbol The [symbol](/market-data/symbols-and-minimums) to retrieve trades for + Symbol *string `json:"symbol,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` +} + +// NetworkAssets defines model for NetworkAssets. +type NetworkAssets struct { + // Assets Alphabetically sorted array of enabled asset/token codes available on this network. Assets include both exchange-tradable and custody-supported tokens. + Assets *[]string `json:"assets,omitempty"` + + // Network The blockchain network identifier. + Network *string `json:"network,omitempty"` +} + +// NetworkToken defines model for NetworkToken. +type NetworkToken struct { + // Network Array of supported blockchain networks for the token. Many tokens (especially stablecoins like USDC, USDT) are available on multiple networks. + // + // Supported networks include: `bitcoin`, `ethereum`, `solana`, `optimism`, `arbitrum`, `base`, `monad`, `avalanche`, `litecoin`, `bitcoincash`, `dogecoin`, `zcash`, `filecoin`, `tezos`, `polkadot`, `cosmos`, `xrpl`, `linea`, and more. + Network *[]string `json:"network,omitempty"` + + // Token The requested token identifier. + Token *string `json:"token,omitempty"` +} + +// NewOrderRequest defines model for NewOrderRequest. +type NewOrderRequest struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Specifies the account on which you intend to place the order. Only available for exchange accounts. + Account *string `json:"account,omitempty"` + + // Amount Quoted decimal amount to purchase + Amount string `json:"amount"` + + // ClientOrderId *Recommended*. A [client-specified order id](/client-order-id) + ClientOrderId *string `json:"client_order_id,omitempty"` + + // MarginOrder Set to `true` to place this order on a margin account using borrowed funds. Defaults to `false`. Only available for margin-enabled accounts. See [Margin Trading](/margin/account-summary) for details. + MarginOrder *bool `json:"margin_order,omitempty"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce int64 `json:"nonce"` + + // Options An optional array containing at most one supported order execution option. See Order execution options for details. + Options *[]NewOrderRequestOptions `json:"options,omitempty"` + + // Price Quoted decimal amount to spend per unit + Price string `json:"price"` + + // Request The literal string "/v1/order/new" + Request string `json:"request"` + Side NewOrderRequestSide `json:"side"` + + // StopPrice The price to trigger a stop-limit order. Only available for stop-limit orders. + StopPrice *string `json:"stop_price,omitempty"` + + // Symbol The [symbol](/market-data/symbols-and-minimums) for the new order + Symbol string `json:"symbol"` + + // Type The order type. "exchange limit" for all order types except for stop-limit orders. "exchange stop limit" for stop-limit orders. + Type NewOrderRequestType `json:"type"` +} + +// NewOrderRequestOptions defines model for NewOrderRequest.Options. +type NewOrderRequestOptions string + +// NewOrderRequestSide defines model for NewOrderRequest.Side. +type NewOrderRequestSide string + +// NewOrderRequestType The order type. "exchange limit" for all order types except for stop-limit orders. "exchange stop limit" for stop-limit orders. +type NewOrderRequestType string + +// Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) +type Nonce struct { + union json.RawMessage +} + +// Nonce1 defines model for . +type Nonce1 = int + +// NotionalBalance defines model for NotionalBalance. +type NotionalBalance struct { + // Amount The current balance + Amount *string `json:"amount,omitempty"` + + // AmountNotional Amount, in notional + AmountNotional *string `json:"amountNotional,omitempty"` + + // Available The amount that is available to trade + Available *string `json:"available,omitempty"` + + // AvailableForWithdrawal The amount that is available to withdraw + AvailableForWithdrawal *string `json:"availableForWithdrawal,omitempty"` + + // AvailableForWithdrawalNotional AvailableForWithdrawal, in notional + AvailableForWithdrawalNotional *string `json:"availableForWithdrawalNotional,omitempty"` + + // AvailableNotional Available, in notional + AvailableNotional *string `json:"availableNotional,omitempty"` + + // Currency Currency code, see symbols and minimums + Currency *string `json:"currency,omitempty"` +} + +// NotionalVolume defines model for NotionalVolume. +type NotionalVolume struct { + ApiAuctionFeeBps *int `json:"api_auction_fee_bps,omitempty"` + ApiMakerFeeBps *int `json:"api_maker_fee_bps,omitempty"` + ApiNotional30dVolume *string `json:"api_notional_30d_volume,omitempty"` + ApiTakerFeeBps *int `json:"api_taker_fee_bps,omitempty"` + Date *openapi_types.Date `json:"date,omitempty"` + FeeTier *struct { + ApiMakerFeeBps *int `json:"api_maker_fee_bps,omitempty"` + ApiTakerFeeBps *int `json:"api_taker_fee_bps,omitempty"` + Tier *string `json:"tier,omitempty"` + } `json:"fee_tier,omitempty"` + FixAuctionFeeBps *int `json:"fix_auction_fee_bps,omitempty"` + FixMakerFeeBps *int `json:"fix_maker_fee_bps,omitempty"` + FixTakerFeeBps *int `json:"fix_taker_fee_bps,omitempty"` + LastUpdatedMs *int `json:"last_updated_ms,omitempty"` + Notional1dVolume *[]struct { + // Date UTC date in `yyyy-MM-dd` format + Date *string `json:"date,omitempty"` + + // NotionalVolume Notional volume value in USD for this single day + NotionalVolume *string `json:"notional_volume,omitempty"` + } `json:"notional_1d_volume,omitempty"` + Notional30dVolume *string `json:"notional_30d_volume,omitempty"` + WebAuctionFeeBps *int `json:"web_auction_fee_bps,omitempty"` + WebMakerFeeBps *int `json:"web_maker_fee_bps,omitempty"` + WebTakerFeeBps *int `json:"web_taker_fee_bps,omitempty"` +} + +// OpenPosition defines model for OpenPosition. +type OpenPosition struct { + // AverageCost The average price of the current position. + AverageCost *string `json:"average_cost,omitempty"` + + // InstrumentType The type of instrument. Either "spot" or "perp". + InstrumentType *string `json:"instrument_type,omitempty"` + + // MarkPrice The current Mark Price for the Asset or the position. + MarkPrice *string `json:"mark_price,omitempty"` + + // NotionalValue The value of position; calculated as (`quantity` * `mark_price`). Value will be negative for shorts. + NotionalValue *string `json:"notional_value,omitempty"` + + // Quantity The position size. Value will be negative for shorts. + Quantity *string `json:"quantity,omitempty"` + + // RealisedPnl The current P&L that has been realised from the position. + RealisedPnl *string `json:"realised_pnl,omitempty"` + + // Symbol The [symbol](/market-data/symbols-and-minimums) of the order. + Symbol *string `json:"symbol,omitempty"` + + // UnrealisedPnl Current Mark to Market value of the positions. + UnrealisedPnl *string `json:"unrealised_pnl,omitempty"` +} + +// Order defines model for Order. +type Order struct { + // AvgExecutionPrice The average price at which this order as been executed so far. 0 if the order has not been executed at all. + AvgExecutionPrice *string `json:"avg_execution_price,omitempty"` + + // ClientOrderId An optional [client-specified order id](/client-order-id#client-order-id) + ClientOrderId *string `json:"client_order_id,omitempty"` + + // Exchange Will always be "gemini" + Exchange *string `json:"exchange,omitempty"` + + // ExecutedAmount The amount of the order that has been filled. + ExecutedAmount *string `json:"executed_amount,omitempty"` + + // IsCancelled `true` if the order has been canceled. Note the spelling, "cancelled" instead of "canceled". This is for compatibility reasons. + IsCancelled *bool `json:"is_cancelled,omitempty"` + + // IsHidden Will always return `false`. + IsHidden *bool `json:"is_hidden,omitempty"` + + // IsLive `true` if the order is active on the book (has remaining quantity and has not been canceled) + IsLive *bool `json:"is_live,omitempty"` + + // Options An array containing at most one supported order execution option. See [Order execution options](/rest/orders#create-new-order) for details. + Options *[]string `json:"options,omitempty"` + + // OrderId The order id + OrderId *string `json:"order_id,omitempty"` + + // OriginalAmount The originally submitted amount of the order. + OriginalAmount *string `json:"original_amount,omitempty"` + + // Price The price the order was issued at + Price *string `json:"price,omitempty"` + + // Reason Populated with the reason your order was canceled, if available. + Reason *string `json:"reason,omitempty"` + + // RemainingAmount The amount of the order that has not been filled. + RemainingAmount *string `json:"remaining_amount,omitempty"` + Side *OrderSide `json:"side,omitempty"` + + // Symbol The [symbol](/market-data/symbols-and-minimums#symbols-and-minimums) of the order + Symbol *string `json:"symbol,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + + // Trades Contains an array of JSON objects with trade details. + Trades *[]struct { + // Aggressor If `true`, this order was the taker in the trade + Aggressor *bool `json:"aggressor,omitempty"` + + // Amount The quantity that was executed + Amount *string `json:"amount,omitempty"` + + // Break Will only be present if the trade is broken. See `Break Types` below for more information. + Break *string `json:"break,omitempty"` + + // Exchange Will always be "gemini" + Exchange *string `json:"exchange,omitempty"` + + // FeeAmount The amount charged + FeeAmount *string `json:"fee_amount,omitempty"` + + // FeeCurrency Currency that the fee was paid in + FeeCurrency *string `json:"fee_currency,omitempty"` + + // OrderId The order that this trade executed against + OrderId *string `json:"order_id,omitempty"` + + // Price The price that the execution happened at + Price *string `json:"price,omitempty"` + + // Tid Unique identifier for the trade + Tid *int `json:"tid,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + + // Type Will be either "Buy" or "Sell", indicating the side of the original order + Type *OrderTradesType `json:"type,omitempty"` + } `json:"trades,omitempty"` + + // Type Description of the order + Type *OrderType `json:"type,omitempty"` + + // WasForced Will always be `false`. + WasForced *bool `json:"was_forced,omitempty"` +} + +// OrderSide defines model for Order.Side. +type OrderSide string + +// OrderTradesType Will be either "Buy" or "Sell", indicating the side of the original order +type OrderTradesType string + +// OrderType Description of the order +type OrderType string + +// OrderBook defines model for OrderBook. +type OrderBook struct { + // Asks The ask price levels currently on the book. These are offers to sell at a given price. + Asks *[]OrderBookEntry `json:"asks,omitempty"` + + // Bids The bid price levels currently on the book. These are offers to buy at a given price. + Bids *[]OrderBookEntry `json:"bids,omitempty"` +} + +// OrderBookEntry defines model for OrderBookEntry. +type OrderBookEntry struct { + // Amount The total quantity remaining at the price + Amount *string `json:"amount,omitempty"` + + // Price The price + Price *string `json:"price,omitempty"` + + // Timestamp **DO NOT USE** - this field is included for compatibility reasons only and is just populated with a dummy value. + Timestamp *string `json:"timestamp,omitempty"` +} + +// OrderStatusRequest defines model for OrderStatusRequest. +type OrderStatusRequest struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Specifies the account on which you intend to place the order. Only available for exchange accounts. + Account *string `json:"account,omitempty"` + + // ClientOrderId The `client_order_id` used when placing the order. `client_order_id` cannot be used in combination with `order_id` + ClientOrderId *string `json:"client_order_id,omitempty"` + + // IncludeTrades Either `True` or `False`. If `True` the endpoint will return individual trade details of all fills from the order. + IncludeTrades *bool `json:"include_trades,omitempty"` + + // Nonce timestamp + Nonce TimestampType `json:"nonce"` + + // OrderId The order id to get information on. The `order_id` represents a whole number and is transmitted as an unsigned 64-bit integer in JSON format. `order_id` cannot be used in combination with `client_order_id`. + OrderId int64 `json:"order_id"` + + // Request The API endpoint path + Request string `json:"request"` +} + +// PaymentMethodBalance defines model for PaymentMethodBalance. +type PaymentMethodBalance struct { + // Amount Total account balance for currency. + Amount *string `json:"amount,omitempty"` + + // Available Total amount available for trading + Available *string `json:"available,omitempty"` + + // AvailableForWithdrawal Total amount available for withdrawal + AvailableForWithdrawal *string `json:"availableForWithdrawal,omitempty"` + + // Currency Symbol for fiat balance. + Currency *string `json:"currency,omitempty"` + + // Type Account type. Will always be `exchange` + Type *string `json:"type,omitempty"` +} + +// PaymentMethodBank defines model for PaymentMethodBank. +type PaymentMethodBank struct { + // Bank Name of bank account + Bank *string `json:"bank,omitempty"` + + // BankId Unique identifier for bank account + BankId *string `json:"bankId,omitempty"` +} + +// PaymentMethodsResponse defines model for PaymentMethodsResponse. +type PaymentMethodsResponse struct { + // Balances Array of JSON objects with available fiat currencies and their balances. + Balances *[]PaymentMethodBalance `json:"balances,omitempty"` + + // Banks Array of JSON objects with banking information + Banks *[]PaymentMethodBank `json:"banks,omitempty"` +} + +// PriceFeedResponse defines model for PriceFeedResponse. +type PriceFeedResponse = []struct { + // Pair Trading pair symbol. See [**symbols and minimums**](/market-data/symbols-and-minimums#all-supported-symbols) + Pair *string `json:"pair,omitempty"` + + // PercentChange24h 24 hour change in price of the pair on the Gemini order book + PercentChange24h *string `json:"percentChange24h,omitempty"` + + // Price Current price of the pair on the Gemini order book + Price *string `json:"price,omitempty"` +} + +// Quantity defines model for Quantity. +type Quantity struct { + // Currency The currency code of the quantity. + Currency string `json:"currency"` + + // Value The value of the quantity. + Value string `json:"value"` +} + +// RevokeOauthTokenResponse defines model for RevokeOauthTokenResponse. +type RevokeOauthTokenResponse struct { + // Message A message that indicates the token has been revoked for the account + Message *string `json:"message,omitempty"` +} + +// RiskStatsResponse defines model for RiskStatsResponse. +type RiskStatsResponse struct { + // IndexPrice Current index price at the time of request + IndexPrice *string `json:"index_price,omitempty"` + + // MarkPrice Current mark price at the time of request + MarkPrice *string `json:"mark_price,omitempty"` + + // OpenInterest string representation of decimal value of open interest + OpenInterest *string `json:"open_interest,omitempty"` + + // OpenInterestNotional string representation of decimal value of open interest notional + OpenInterestNotional *string `json:"open_interest_notional,omitempty"` + + // ProductType Contract type for which the symbol data is fetched + ProductType *RiskStatsResponseProductType `json:"product_type,omitempty"` +} + +// RiskStatsResponseProductType Contract type for which the symbol data is fetched +type RiskStatsResponseProductType string + +// RoleResponse defines model for RoleResponse. +type RoleResponse struct { + // CounterpartyId _Only returned for master-level API keys_. The Gemini clearing counterparty ID associated with the API key making the request. + CounterpartyId *string `json:"counterparty_id,omitempty"` + + // IsAccountAdmin _Only returned for master-level API keys_.`True` if the Administrator role is assigned to the API keys. `False` otherwise. + IsAccountAdmin *bool `json:"isAccountAdmin,omitempty"` + + // IsAuditor `True` if the Auditor role is assigned to the API keys. `False` otherwise. + IsAuditor bool `json:"isAuditor"` + + // IsFundManager `True` if the Fund Manager role is assigned to the API keys. `False` otherwise. + IsFundManager bool `json:"isFundManager"` + + // IsTrader `True` if the Trader role is assigned to the API keys. `False` otherwise. + IsTrader bool `json:"isTrader"` +} + +// StakingBalance defines model for StakingBalance. +type StakingBalance struct { + // Available The amount that is available to trade + Available *openapi_types.DecimalNumber `json:"available,omitempty"` + + // AvailableForWithdrawal The Staking amount that is available to redeem to exchange account + AvailableForWithdrawal *openapi_types.DecimalNumber `json:"availableForWithdrawal,omitempty"` + + // Balance The current Staking balance + Balance *openapi_types.DecimalNumber `json:"balance,omitempty"` + BalanceByProvider *map[string]struct { + // Balance The current Staking balance per providerId + Balance *openapi_types.DecimalNumber `json:"balance,omitempty"` + } `json:"balanceByProvider,omitempty"` + + // Currency Currency code, see symbols and minimums + Currency *string `json:"currency,omitempty"` + + // Type Will always be "Staking" + Type *string `json:"type,omitempty"` +} + +// StakingDeposit defines model for StakingDeposit. +type StakingDeposit struct { + // AccrualTotal The total accrual + AccrualTotal *openapi_types.DecimalNumber `json:"accrualTotal,omitempty"` + + // Amount The amount deposited + Amount *openapi_types.DecimalNumber `json:"amount,omitempty"` + + // Currency Currency code, see [symbols](/market-data/symbols-and-minimums) + Currency *string `json:"currency,omitempty"` + + // ProviderId Provider Id, in uuid4 format + ProviderId *string `json:"providerId,omitempty"` + + // Rates A JSON object including one or many rates. If more than one rate it would be an array of rates. + Rates *struct { + // Rate Staking interest rate in bps (Expressed as a simple rate. Interest on Staking balances compounds daily. In mobile and web applications, APYs are derived from this rate and rounded to 1/10th of a percent.) + Rate *int `json:"rate,omitempty"` + } `json:"rates,omitempty"` + + // TransactionId A unique identifier for the staking transaction + TransactionId *string `json:"transactionId,omitempty"` +} + +// StakingHistory defines model for StakingHistory. +type StakingHistory struct { + // ProviderId Provider Id, in uuid4 format + ProviderId *string `json:"providerId,omitempty"` + Transactions *[]StakingTransaction `json:"transactions,omitempty"` +} + +// StakingRate defines model for StakingRate. +type StakingRate struct { + // ApyPct Staking interest APY (Expressed as a percentage derived from the rate and rounded to 1/10th of a percent.) + ApyPct *openapi_types.DecimalNumber `json:"apyPct,omitempty"` + + // DepositUsdLimit Maximum new amount in USD notional of this crypto that can participate in Gemini Staking per account per month + DepositUsdLimit *int `json:"depositUsdLimit,omitempty"` + + // ProviderId Provider Id, in uuid4 format + ProviderId *string `json:"providerId,omitempty"` + + // Rate Staking interest rate in bps (Expressed as a simple rate. Interest on Staking balances compounds daily. In mobile and web applications, APYs are derived from this rate and rounded to 1/10th of a percent.) + Rate *openapi_types.DecimalNumber `json:"rate,omitempty"` + + // RatePct `rate` expressed as a percentage + RatePct *openapi_types.DecimalNumber `json:"ratePct,omitempty"` +} + +// StakingRateProvider Currency Symbol Keys +type StakingRateProvider struct { + CurrencySymbol *StakingRate `json:"currency_symbol,omitempty"` +} + +// StakingRateResponse Provider UUID Keys +type StakingRateResponse struct { + // ProviderUuid Currency Symbol Keys + ProviderUuid *StakingRateProvider `json:"provider_uuid,omitempty"` +} + +// StakingRewardPeriod defines model for StakingRewardPeriod. +type StakingRewardPeriod struct { + // AccrualTotal The total accrual + AccrualTotal *openapi_types.DecimalNumber `json:"accrualTotal,omitempty"` + + // ApyPct Staking reward rate expressed as an APY at time of accrual. Interest on Staking balances compounds daily based on the simple rate which is available from `/v1/staking/rates/` + ApyPct *openapi_types.DecimalNumber `json:"apyPct,omitempty"` + + // Currency Currency code, see [symbols](/market-data/symbols-and-minimums) + Currency *string `json:"currency,omitempty"` + + // FirstAccrualAt Time of first accrual. In iso datetime with timezone format + FirstAccrualAt *string `json:"firstAccrualAt,omitempty"` + + // LastAccrualAt Time of last accrual. In iso datetime with timezone format + LastAccrualAt *string `json:"lastAccrualAt,omitempty"` + + // NumberOfAccruals Number of accruals in the specific aggregate, typically one per day. If the rate is adjusted, new accruals are added. + NumberOfAccruals *int `json:"numberOfAccruals,omitempty"` + + // ProviderId Provider Id, in uuid4 format + ProviderId *string `json:"providerId,omitempty"` + + // RatePct Rate expressed as a percentage + RatePct *openapi_types.DecimalNumber `json:"ratePct,omitempty"` +} + +// StakingRewards defines model for StakingRewards. +type StakingRewards struct { + // AccrualTotal The total accrual + AccrualTotal *openapi_types.DecimalNumber `json:"accrualTotal,omitempty"` + + // Currency Currency code, see [symbols](/market-data/symbols-and-minimums) + Currency *string `json:"currency,omitempty"` + + // ProviderId Provider Id, in uuid4 format + ProviderId *string `json:"providerId,omitempty"` + + // RatePeriods Array of JSON objects with period accrual information + RatePeriods *[]StakingRewardPeriod `json:"ratePeriods,omitempty"` +} + +// StakingRewardsProvider Currency Symbol Keys +type StakingRewardsProvider struct { + CurrencySymbol *StakingRewards `json:"currency_symbol,omitempty"` +} + +// StakingRewardsResponse Provider UUID Keys +type StakingRewardsResponse struct { + // ProviderUuid Currency Symbol Keys + ProviderUuid *StakingRewardsProvider `json:"provider_uuid,omitempty"` +} + +// StakingTransaction defines model for StakingTransaction. +type StakingTransaction struct { + // Amount The amount that is defined by the transactionType above + Amount *openapi_types.DecimalNumber `json:"amount,omitempty"` + + // AmountCurrency Currency code + AmountCurrency *string `json:"amountCurrency,omitempty"` + + // DateTime timestamp + DateTime *TimestampType `json:"dateTime,omitempty"` + + // PriceAmount Current market price of the underlying token at the time of the reward + PriceAmount *openapi_types.DecimalNumber `json:"priceAmount,omitempty"` + + // PriceCurrency A supported three-letter fiat currency code, e.g. usd + PriceCurrency *string `json:"priceCurrency,omitempty"` + + // TransactionId A unique identifier for the staking transaction + TransactionId *string `json:"transactionId,omitempty"` + + // TransactionType Can be any one of the following - Deposit, Redeem, Interest, RedeemPayment, AdminRedeem, AdminCreditAdjustment, AdminDebitAdjustment + TransactionType *StakingTransactionTransactionType `json:"transactionType,omitempty"` +} + +// StakingTransactionTransactionType Can be any one of the following - Deposit, Redeem, Interest, RedeemPayment, AdminRedeem, AdminCreditAdjustment, AdminDebitAdjustment +type StakingTransactionTransactionType string + +// StakingWithdrawal defines model for StakingWithdrawal. +type StakingWithdrawal struct { + // Amount The amount deposited + Amount *openapi_types.DecimalNumber `json:"amount,omitempty"` + + // AmountPaidSoFar The amount redeemed successfully + AmountPaidSoFar *openapi_types.DecimalNumber `json:"amountPaidSoFar,omitempty"` + + // AmountRemaining The amount pending to be redeemed + AmountRemaining *openapi_types.DecimalNumber `json:"amountRemaining,omitempty"` + + // Currency Currency code + Currency *string `json:"currency,omitempty"` + + // RequestInitiated In ISO datetime with timezone format + RequestInitiated *string `json:"requestInitiated,omitempty"` + + // TransactionId A unique identifier for the staking transaction + TransactionId *string `json:"transactionId,omitempty"` +} + +// StopLimitOrderResponse defines model for StopLimitOrderResponse. +type StopLimitOrderResponse struct { + AvgExecutionPrice *string `json:"avg_execution_price,omitempty"` + Exchange *string `json:"exchange,omitempty"` + ExecutedAmount *string `json:"executed_amount,omitempty"` + Id *string `json:"id,omitempty"` + IsCancelled *bool `json:"is_cancelled,omitempty"` + IsHidden *bool `json:"is_hidden,omitempty"` + IsLive *bool `json:"is_live,omitempty"` + Options *[]string `json:"options,omitempty"` + OrderId *string `json:"order_id,omitempty"` + OriginalAmount *string `json:"original_amount,omitempty"` + Price *string `json:"price,omitempty"` + Side *StopLimitOrderResponseSide `json:"side,omitempty"` + StopPrice *string `json:"stop_price,omitempty"` + Symbol *string `json:"symbol,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + Type *StopLimitOrderResponseType `json:"type,omitempty"` + WasForced *bool `json:"was_forced,omitempty"` +} + +// StopLimitOrderResponseSide defines model for StopLimitOrderResponse.Side. +type StopLimitOrderResponseSide string + +// StopLimitOrderResponseType defines model for StopLimitOrderResponse.Type. +type StopLimitOrderResponseType string + +// SymbolDetails defines model for SymbolDetails. +type SymbolDetails struct { + // BaseCurrency CCY1 or the top currency. (i.e `BTC` in `BTCUSD`) + BaseCurrency *string `json:"base_currency,omitempty"` + + // ContractPriceCurrency CCY2 or the quote currency for spot instrument (i.e. `USD` in `BTCUSD`) + // Or collateral currency of the contract in case of perpetual swap instrument. + ContractPriceCurrency *string `json:"contract_price_currency,omitempty"` + + // ContractType `vanilla` / `linear` / `inverse` where `vanilla` is for spot + // while `linear` is for perpetual swap and `inverse` is a special case perpetual swap where the perpetual contract will be settled in base currency. + ContractType *string `json:"contract_type,omitempty"` + + // MinOrderSize The minimum order size in `base_currency` units (i.e `0.00001`) + MinOrderSize *string `json:"min_order_size,omitempty"` + + // ProductType Instrument type `spot` / `swap` -- where `swap` signifies `perpetual swap`. + ProductType *string `json:"product_type,omitempty"` + + // QuoteCurrency CCY2 or the quote currency. (i.e `USD` in `BTCUSD`) + QuoteCurrency *string `json:"quote_currency,omitempty"` + + // QuoteIncrement The number of decimal places in the `quote_currency` (i.e `0.01`) + QuoteIncrement *openapi_types.DecimalNumber `json:"quote_increment,omitempty"` + + // Status Status of the current order book. Can be `open`, `closed`, `cancel_only`, `post_only`, `limit_only`. + Status *string `json:"status,omitempty"` + + // Symbol The requested symbol. See [**symbols and minimums**](/market-data/symbols-and-minimums#all-supported-symbols) + Symbol *string `json:"symbol,omitempty"` + + // TickSize The number of decimal places in the `base_currency`. (i.e `1e-8`) + TickSize *openapi_types.DecimalNumber `json:"tick_size,omitempty"` + + // WrapEnabled When `True`, symbol can be wrapped using this endpoint: + // `POST https://api.gemini.com/v1/wrap/:symbol` + WrapEnabled *bool `json:"wrap_enabled,omitempty"` +} + +// Ticker defines model for Ticker. +type Ticker struct { + // Ask The lowest ask currently available + Ask *string `json:"ask,omitempty"` + + // Bid The highest bid currently available + Bid *string `json:"bid,omitempty"` + + // Last The price of the last executed trade + Last *string `json:"last,omitempty"` + + // Volume Information about the 24 hour volume on the exchange. See properties below + Volume *struct { + // PriceSymbol The volume denominated in the price currency + PriceSymbol *string `json:"price_symbol,omitempty"` + + // QuantitySymbol The volume denominated in the quantity currency + QuantitySymbol *string `json:"quantity_symbol,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + } `json:"volume,omitempty"` +} + +// TickerInfo defines model for TickerInfo. +type TickerInfo struct { + // Ask Current best offer + Ask *string `json:"ask,omitempty"` + + // Bid Current best bid + Bid *string `json:"bid,omitempty"` + + // Changes Hourly prices descending for past 24 hours + Changes *[]string `json:"changes,omitempty"` + + // Close Close price (most recent trade) + Close *string `json:"close,omitempty"` + + // High High price from 24 hours ago + High *string `json:"high,omitempty"` + + // Low Low price from 24 hours ago + Low *string `json:"low,omitempty"` + + // Open Open price from 24 hours ago + Open *string `json:"open,omitempty"` + + // Symbol The trading pair symbol + Symbol *string `json:"symbol,omitempty"` +} + +// TimestampType timestamp +type TimestampType struct { + union json.RawMessage +} + +// TimestampType0 Gemini strongly recommends using milliseconds instead of seconds for timestamps. +// +// | Timestamp format | Example | Supported request type | +// |-----------------------|-----------------------|------------------------| +// | string (seconds) | `1495127793` | `POST` only | +// | string (milliseconds) | `1495127793000` | `POST` only | +type TimestampType0 = string + +// TimestampType1 Gemini strongly recommends using milliseconds instead of seconds for timestamps. +// +// | Timestamp format | Example | Supported request type | +// |-----------------------------|---------------------------|------------------------| +// | whole number (seconds) | `1495127793` | `GET`, `POST` | +// | whole number (milliseconds) | `1495127793000` | `GET`, `POST` | +type TimestampType1 = int64 + +// Trade defines model for Trade. +type Trade struct { + // Amount The amount that was traded + Amount *string `json:"amount,omitempty"` + + // Broken Whether the trade was broken or not. Broken trades will not be displayed by default; use the `include_breaks` to display them. + Broken *bool `json:"broken,omitempty"` + + // Exchange Will always be "gemini" + Exchange *string `json:"exchange,omitempty"` + + // Price The price the trade was executed at + Price *string `json:"price,omitempty"` + + // Tid The trade ID number + Tid *int64 `json:"tid,omitempty"` + + // Timestamp timestamp + Timestamp *TimestampType `json:"timestamp,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + + // Type - `buy` means that an ask was removed from the book by an incoming buy order. + // - `sell` means that a bid was removed from the book by an incoming sell order. + Type *TradeType `json:"type,omitempty"` +} + +// TradeType - `buy` means that an ask was removed from the book by an incoming buy order. +// - `sell` means that a bid was removed from the book by an incoming sell order. +type TradeType string + +// TradeVolume defines model for TradeVolume. +type TradeVolume struct { + BaseCurrency *string `json:"base_currency,omitempty"` + BuyMakerBase *string `json:"buy_maker_base,omitempty"` + BuyMakerCount *int `json:"buy_maker_count,omitempty"` + BuyMakerNotional *string `json:"buy_maker_notional,omitempty"` + BuyTakerBase *string `json:"buy_taker_base,omitempty"` + BuyTakerCount *int `json:"buy_taker_count,omitempty"` + BuyTakerNotional *string `json:"buy_taker_notional,omitempty"` + DataDate *string `json:"data_date,omitempty"` + MakerBuySellRatio *string `json:"maker_buy_sell_ratio,omitempty"` + NotionalCurrency *string `json:"notional_currency,omitempty"` + QuoteCurrency *string `json:"quote_currency,omitempty"` + SellMakerBase *string `json:"sell_maker_base,omitempty"` + SellMakerCount *int `json:"sell_maker_count,omitempty"` + SellMakerNotional *string `json:"sell_maker_notional,omitempty"` + SellTakerBase *string `json:"sell_taker_base,omitempty"` + SellTakerCount *int `json:"sell_taker_count,omitempty"` + SellTakerNotional *string `json:"sell_taker_notional,omitempty"` + Symbol *string `json:"symbol,omitempty"` + TotalVolumeBase *string `json:"total_volume_base,omitempty"` +} + +// Transaction defines model for Transaction. +type Transaction struct { + union json.RawMessage +} + +// Transaction0 Trade Reponse +type Transaction0 struct { + // Account The account. + Account *string `json:"account,omitempty"` + + // Amount The quantity that was executed. + Amount *string `json:"amount,omitempty"` + + // ClientOrderId The client order ID, if defined. Otherwise an empty string. + ClientOrderId *string `json:"clientOrderId,omitempty"` + + // Exchange Will always be "gemini". + Exchange *string `json:"exchange,omitempty"` + + // FeeAmount The fee amount charged + FeeAmount *string `json:"feeAmount,omitempty"` + + // FeeAssetCode The symbol that the trade was for + FeeAssetCode *string `json:"feeAssetCode,omitempty"` + + // IsAggressor If true, this order was the taker in the trade. + IsAggressor *bool `json:"isAggressor,omitempty"` + + // IsAuctionFill True if the trade was a auction trade and not an on-exchange trade. + IsAuctionFill *bool `json:"isAuctionFill,omitempty"` + + // IsClearingFill True if the trade was a clearing trade and not an on-exchange trade. + IsClearingFill *bool `json:"isClearingFill,omitempty"` + + // OrderId The order that this trade executed against. + OrderId *int64 `json:"orderId,omitempty"` + + // Price The price that the execution happened at. + Price *string `json:"price,omitempty"` + + // Side Indicating the side of the original order. + Side *string `json:"side,omitempty"` + + // Symbol The symbol that the trade was for. + Symbol *string `json:"symbol,omitempty"` + + // Tid The trade ID. + Tid *int64 `json:"tid,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` +} + +// Transaction1 Transfer Reponse +type Transaction1 struct { + // AdvanceEid Deposit advance event ID. + AdvanceEid *int64 `json:"advanceEid,omitempty"` + + // Amount The quantity that was transferred. + Amount *string `json:"amount,omitempty"` + + // BankId Bank ID. + BankId *string `json:"bankId,omitempty"` + + // ClientTransferId Client Transfer ID. Client transfer ID is an optional client-supplied unique identifier for each withdrawal or internal transfer. + ClientTransferId *string `json:"clientTransferId,omitempty"` + + // CorrelationId Correlation ID. + CorrelationId *int64 `json:"correlationId,omitempty"` + + // Currency Currency code, see symbols + Currency *string `json:"currency,omitempty"` + + // Destination The account you are transferring to. + Destination *string `json:"destination,omitempty"` + + // Eid Transfer event id. + Eid *int64 `json:"eid,omitempty"` + + // FeeId Fee ID. + FeeId *string `json:"feeId,omitempty"` + + // Method Type of transfer method. + Method *string `json:"method,omitempty"` + + // OperationReason The operation reason. + OperationReason *string `json:"operationReason,omitempty"` + + // PendingEid Pending event ID. + PendingEid *int64 `json:"pendingEid,omitempty"` + + // Purpose Purpose. + Purpose *string `json:"purpose,omitempty"` + + // Source The account you are transferring from. + Source *string `json:"source,omitempty"` + + // Status The status of the transfer. + Status *string `json:"status,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + + // TransactionHash Supplies the transaction hash when available. + TransactionHash *string `json:"transactionHash,omitempty"` + + // TransferId Transfer ID. + TransferId *string `json:"transferId,omitempty"` + + // TransferType Transfer type. + TransferType *string `json:"transferType,omitempty"` + + // WithdrawalEid Withdrawal event ID. + WithdrawalEid *int64 `json:"withdrawalEid,omitempty"` + + // WithdrawalId Withdrawal ID. + WithdrawalId *string `json:"withdrawalId,omitempty"` +} + +// Transfer defines model for Transfer. +type Transfer struct { + // Amount The amount transferred + Amount *string `json:"amount,omitempty"` + + // Currency The currency transferred + Currency *string `json:"currency,omitempty"` + + // Eid The transfer ID + Eid *int64 `json:"eid,omitempty"` + Status *TransferStatus `json:"status,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + + // TxHash The transaction hash if applicable + TxHash *string `json:"txHash,omitempty"` + Type *TransferType `json:"type,omitempty"` +} + +// TransferStatus defines model for Transfer.Status. +type TransferStatus string + +// TransferType defines model for Transfer.Type. +type TransferType string + +// V2Transfer defines model for V2Transfer. +type V2Transfer struct { + // Amount The amount transferred + Amount *string `json:"amount,omitempty"` + + // Currency The currency transferred + Currency *string `json:"currency,omitempty"` + + // Destination The destination address for withdrawals + Destination *string `json:"destination,omitempty"` + + // Eid The transfer event ID + Eid *int64 `json:"eid,omitempty"` + + // FeeAmount The fee charged for the transfer + FeeAmount *string `json:"feeAmount,omitempty"` + + // FeeCurrency The currency in which the fee was charged + FeeCurrency *string `json:"feeCurrency,omitempty"` + + // Method The transfer method (e.g., `ACH`, `CreditCard`) + Method *string `json:"method,omitempty"` + + // Network The blockchain network the transfer was executed on (e.g., `ethereum`, `solana`, `arbitrum`, `optimism`, `base`, `avalanche`). Not present for fiat or administrative transfers. + Network *string `json:"network,omitempty"` + + // OutputIdx The output index for withdrawals + OutputIdx *int `json:"outputIdx,omitempty"` + + // Purpose The purpose or reason for administrative transfers + Purpose *string `json:"purpose,omitempty"` + + // Status The status of the transfer + Status *V2TransferStatus `json:"status,omitempty"` + + // Timestampms timestamp + Timestampms *TimestampType `json:"timestampms,omitempty"` + + // TxHash The on-chain transaction hash, if applicable + TxHash *string `json:"txHash,omitempty"` + + // Type The type of the transfer + Type *V2TransferType `json:"type,omitempty"` + + // WithdrawalId The unique withdrawal identifier + WithdrawalId *string `json:"withdrawalId,omitempty"` +} + +// V2TransferStatus The status of the transfer +type V2TransferStatus string + +// V2TransferType The type of the transfer +type V2TransferType string + +// WithdrawCryptoFundsResponse Response returned after submitting a v2 cryptocurrency withdrawal. +type WithdrawCryptoFundsResponse struct { + // Address Standard string format of the withdrawal destination address + Address *string `json:"address,omitempty"` + + // Amount The withdrawal amount + Amount *string `json:"amount,omitempty"` + + // Currency The currency code of the withdrawn asset + Currency *string `json:"currency,omitempty"` + + // Fee The fee charged for the withdrawal + Fee *string `json:"fee,omitempty"` + + // WithdrawalId A unique ID for the withdrawal + WithdrawalId *string `json:"withdrawalId,omitempty"` +} + +// ApiKeyAuth defines model for apiKeyAuth. +type ApiKeyAuth = string + +// CacheControl defines model for cacheControl. +type CacheControl = string + +// ContentLength defines model for contentLength. +type ContentLength = string + +// ContentType defines model for contentType. +type ContentType = string + +// CurrencyParam defines model for currencyParam. +type CurrencyParam = string + +// NetworkParam defines model for networkParam. +type NetworkParam = string + +// PayloadAuth defines model for payloadAuth. +type PayloadAuth = string + +// SignatureAuth defines model for signatureAuth. +type SignatureAuth = string + +// SymbolParam defines model for symbolParam. +type SymbolParam = string + +// TimestampParam timestamp +type TimestampParam = TimestampType + +// ApiKeyIpFilteringFailure defines model for ApiKeyIpFilteringFailure. +type ApiKeyIpFilteringFailure = ErrorResponse + +// BadRequest defines model for BadRequest. +type BadRequest = ErrorResponse + +// InternalError defines model for InternalError. +type InternalError = ErrorResponse + +// NotFound defines model for NotFound. +type NotFound = ErrorResponse + +// TooManyRequests defines model for TooManyRequests. +type TooManyRequests = ErrorResponse + +// Unauthorized defines model for Unauthorized. +type Unauthorized = ErrorResponse + +// apiKeyAuthContextKey is the context key for apiKeyAuth security scheme +type apiKeyAuthContextKey string + +// payloadAuthContextKey is the context key for payloadAuth security scheme +type payloadAuthContextKey string + +// signatureAuthContextKey is the context key for signatureAuth security scheme +type signatureAuthContextKey string + +// SendHeartbeatParams defines parameters for SendHeartbeat. +type SendHeartbeatParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// ListPastTradesParams defines parameters for ListPastTrades. +type ListPastTradesParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// GetNotionalTradingVolumeJSONBody defines parameters for GetNotionalTradingVolume. +type GetNotionalTradingVolumeJSONBody struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Specifies the account on which you intend to place the order. Only available for exchange accounts. + Account *string `json:"account,omitempty"` + + // Nonce timestamp + Nonce TimestampType `json:"nonce"` + + // Request The API endpoint path + Request string `json:"request"` +} + +// GetNotionalTradingVolumeParams defines parameters for GetNotionalTradingVolume. +type GetNotionalTradingVolumeParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// CancelOrderParams defines parameters for CancelOrder. +type CancelOrderParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// CancelAllActiveOrdersParams defines parameters for CancelAllActiveOrders. +type CancelAllActiveOrdersParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// CancelAllSessionOrdersParams defines parameters for CancelAllSessionOrders. +type CancelAllSessionOrdersParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// CreateNewOrderParams defines parameters for CreateNewOrder. +type CreateNewOrderParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// CreateNewOrder200JSONResponseBody defines parameters for CreateNewOrder. +type CreateNewOrder200JSONResponseBody struct { + union json.RawMessage +} + +// GetOrderStatusParams defines parameters for GetOrderStatus. +type GetOrderStatusParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// ListActiveOrdersJSONBody defines parameters for ListActiveOrders. +type ListActiveOrdersJSONBody struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Specifies the account on which you intend to place the order. Only available for exchange accounts. + Account *string `json:"account,omitempty"` + + // Nonce timestamp + Nonce TimestampType `json:"nonce"` + + // Request The API endpoint path + Request string `json:"request"` +} + +// ListActiveOrdersParams defines parameters for ListActiveOrders. +type ListActiveOrdersParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// ListPastOrdersJSONBody defines parameters for ListPastOrders. +type ListPastOrdersJSONBody struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. + Account *string `json:"account,omitempty"` + + // LimitOrders The maximum number of orders to return. Default is 50, max is 500. + LimitOrders *int `json:"limit_orders,omitempty"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce Nonce `json:"nonce"` + + // Request The API endpoint `/v1/orders/history` + Request string `json:"request"` + + // Symbol The symbol to retrieve orders for + Symbol *string `json:"symbol,omitempty"` + + // Timestamp In iso datetime with timezone format from that date you will get order history + Timestamp *TimestampType `json:"timestamp,omitempty"` +} + +// ListPastOrdersParams defines parameters for ListPastOrders. +type ListPastOrdersParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// GetTradingVolumeJSONBody defines parameters for GetTradingVolume. +type GetTradingVolumeJSONBody struct { + // Account Required for Master API keys as described in [Private API Invocation](/authentication/api-key#private-api-invocation). The name of the account within the subaccount group. Specifies the account on which you intend to place the order. Only available for exchange accounts. + Account *string `json:"account,omitempty"` + + // Nonce timestamp + Nonce TimestampType `json:"nonce"` + + // Request The API endpoint path + Request string `json:"request"` +} + +// GetTradingVolumeParams defines parameters for GetTradingVolume. +type GetTradingVolumeParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// WrapOrderJSONBody defines parameters for WrapOrder. +type WrapOrderJSONBody struct { + // Account Required for Master API keys. The name of the account within the subaccount group. + Account *string `json:"account,omitempty"` + + // Amount The amount to wrap + Amount string `json:"amount"` + + // ClientOrderId A client-specified order id + ClientOrderId *string `json:"client_order_id,omitempty"` + + // Nonce The nonce, as described in [Private API Invocation](/authentication/api-key#private-api-invocation) + Nonce Nonce `json:"nonce"` + + // Request The literal string "/v1/wrap/symbol" + Request string `json:"request"` + + // Side "buy" or "sell" + Side *WrapOrderJSONBodySide `json:"side,omitempty"` +} + +// WrapOrderParams defines parameters for WrapOrder. +type WrapOrderParams struct { + // XGEMINIAPIKEY Your API key + XGEMINIAPIKEY ApiKeyAuth `json:"X-GEMINI-APIKEY"` + + // XGEMINISIGNATURE HEX-encoded HMAC-SHA384 of payload signed with API secret + XGEMINISIGNATURE SignatureAuth `json:"X-GEMINI-SIGNATURE"` + + // XGEMINIPAYLOAD Base64-encoded JSON payload + XGEMINIPAYLOAD PayloadAuth `json:"X-GEMINI-PAYLOAD"` + ContentType *ContentType `json:"Content-Type,omitempty"` + ContentLength *ContentLength `json:"Content-Length,omitempty"` + CacheControl *CacheControl `json:"Cache-Control,omitempty"` +} + +// WrapOrderJSONBodySide defines parameters for WrapOrder. +type WrapOrderJSONBodySide string + +// SendHeartbeatJSONRequestBody defines body for SendHeartbeat for application/json ContentType. +type SendHeartbeatJSONRequestBody = Heartbeat + +// ListPastTradesJSONRequestBody defines body for ListPastTrades for application/json ContentType. +type ListPastTradesJSONRequestBody = MyTradesRequest + +// GetNotionalTradingVolumeJSONRequestBody defines body for GetNotionalTradingVolume for application/json ContentType. +type GetNotionalTradingVolumeJSONRequestBody GetNotionalTradingVolumeJSONBody + +// CancelOrderJSONRequestBody defines body for CancelOrder for application/json ContentType. +type CancelOrderJSONRequestBody = CancelOrderRequest + +// CancelAllActiveOrdersJSONRequestBody defines body for CancelAllActiveOrders for application/json ContentType. +type CancelAllActiveOrdersJSONRequestBody = CancelAllOrdersRequest + +// CancelAllSessionOrdersJSONRequestBody defines body for CancelAllSessionOrders for application/json ContentType. +type CancelAllSessionOrdersJSONRequestBody = CancelAllOrdersBySessionRequest + +// CreateNewOrderJSONRequestBody defines body for CreateNewOrder for application/json ContentType. +type CreateNewOrderJSONRequestBody = NewOrderRequest + +// GetOrderStatusJSONRequestBody defines body for GetOrderStatus for application/json ContentType. +type GetOrderStatusJSONRequestBody = OrderStatusRequest + +// ListActiveOrdersJSONRequestBody defines body for ListActiveOrders for application/json ContentType. +type ListActiveOrdersJSONRequestBody ListActiveOrdersJSONBody + +// ListPastOrdersJSONRequestBody defines body for ListPastOrders for application/json ContentType. +type ListPastOrdersJSONRequestBody ListPastOrdersJSONBody + +// GetTradingVolumeJSONRequestBody defines body for GetTradingVolume for application/json ContentType. +type GetTradingVolumeJSONRequestBody GetTradingVolumeJSONBody + +// WrapOrderJSONRequestBody defines body for WrapOrder for application/json ContentType. +type WrapOrderJSONRequestBody WrapOrderJSONBody + +// AsHeartbeatNonce0 returns the union data inside the Heartbeat_Nonce as a HeartbeatNonce0 +func (t Heartbeat_Nonce) AsHeartbeatNonce0() (HeartbeatNonce0, error) { + var body HeartbeatNonce0 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromHeartbeatNonce0 overwrites any union data inside the Heartbeat_Nonce as the provided HeartbeatNonce0 +func (t *Heartbeat_Nonce) FromHeartbeatNonce0(v HeartbeatNonce0) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeHeartbeatNonce0 performs a merge with any union data inside the Heartbeat_Nonce, using the provided HeartbeatNonce0 +func (t *Heartbeat_Nonce) MergeHeartbeatNonce0(v HeartbeatNonce0) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsHeartbeatNonce1 returns the union data inside the Heartbeat_Nonce as a HeartbeatNonce1 +func (t Heartbeat_Nonce) AsHeartbeatNonce1() (HeartbeatNonce1, error) { + var body HeartbeatNonce1 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromHeartbeatNonce1 overwrites any union data inside the Heartbeat_Nonce as the provided HeartbeatNonce1 +func (t *Heartbeat_Nonce) FromHeartbeatNonce1(v HeartbeatNonce1) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeHeartbeatNonce1 performs a merge with any union data inside the Heartbeat_Nonce, using the provided HeartbeatNonce1 +func (t *Heartbeat_Nonce) MergeHeartbeatNonce1(v HeartbeatNonce1) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t Heartbeat_Nonce) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *Heartbeat_Nonce) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + +// AsTimestampType returns the union data inside the Nonce as a TimestampType +func (t Nonce) AsTimestampType() (TimestampType, error) { + var body TimestampType + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromTimestampType overwrites any union data inside the Nonce as the provided TimestampType +func (t *Nonce) FromTimestampType(v TimestampType) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeTimestampType performs a merge with any union data inside the Nonce, using the provided TimestampType +func (t *Nonce) MergeTimestampType(v TimestampType) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsNonce1 returns the union data inside the Nonce as a Nonce1 +func (t Nonce) AsNonce1() (Nonce1, error) { + var body Nonce1 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromNonce1 overwrites any union data inside the Nonce as the provided Nonce1 +func (t *Nonce) FromNonce1(v Nonce1) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeNonce1 performs a merge with any union data inside the Nonce, using the provided Nonce1 +func (t *Nonce) MergeNonce1(v Nonce1) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t Nonce) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *Nonce) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + +// AsTimestampType0 returns the union data inside the TimestampType as a TimestampType0 +func (t TimestampType) AsTimestampType0() (TimestampType0, error) { + var body TimestampType0 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromTimestampType0 overwrites any union data inside the TimestampType as the provided TimestampType0 +func (t *TimestampType) FromTimestampType0(v TimestampType0) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeTimestampType0 performs a merge with any union data inside the TimestampType, using the provided TimestampType0 +func (t *TimestampType) MergeTimestampType0(v TimestampType0) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsTimestampType1 returns the union data inside the TimestampType as a TimestampType1 +func (t TimestampType) AsTimestampType1() (TimestampType1, error) { + var body TimestampType1 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromTimestampType1 overwrites any union data inside the TimestampType as the provided TimestampType1 +func (t *TimestampType) FromTimestampType1(v TimestampType1) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeTimestampType1 performs a merge with any union data inside the TimestampType, using the provided TimestampType1 +func (t *TimestampType) MergeTimestampType1(v TimestampType1) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t TimestampType) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *TimestampType) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + +// AsTransaction0 returns the union data inside the Transaction as a Transaction0 +func (t Transaction) AsTransaction0() (Transaction0, error) { + var body Transaction0 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromTransaction0 overwrites any union data inside the Transaction as the provided Transaction0 +func (t *Transaction) FromTransaction0(v Transaction0) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeTransaction0 performs a merge with any union data inside the Transaction, using the provided Transaction0 +func (t *Transaction) MergeTransaction0(v Transaction0) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsTransaction1 returns the union data inside the Transaction as a Transaction1 +func (t Transaction) AsTransaction1() (Transaction1, error) { + var body Transaction1 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromTransaction1 overwrites any union data inside the Transaction as the provided Transaction1 +func (t *Transaction) FromTransaction1(v Transaction1) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeTransaction1 performs a merge with any union data inside the Transaction, using the provided Transaction1 +func (t *Transaction) MergeTransaction1(v Transaction1) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t Transaction) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *Transaction) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + +// AsLimitOrderResponse returns the union data inside the CreateNewOrder200JSONResponseBody as a LimitOrderResponse +func (t CreateNewOrder200JSONResponseBody) AsLimitOrderResponse() (LimitOrderResponse, error) { + var body LimitOrderResponse + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromLimitOrderResponse overwrites any union data inside the CreateNewOrder200JSONResponseBody as the provided LimitOrderResponse +func (t *CreateNewOrder200JSONResponseBody) FromLimitOrderResponse(v LimitOrderResponse) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeLimitOrderResponse performs a merge with any union data inside the CreateNewOrder200JSONResponseBody, using the provided LimitOrderResponse +func (t *CreateNewOrder200JSONResponseBody) MergeLimitOrderResponse(v LimitOrderResponse) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsStopLimitOrderResponse returns the union data inside the CreateNewOrder200JSONResponseBody as a StopLimitOrderResponse +func (t CreateNewOrder200JSONResponseBody) AsStopLimitOrderResponse() (StopLimitOrderResponse, error) { + var body StopLimitOrderResponse + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromStopLimitOrderResponse overwrites any union data inside the CreateNewOrder200JSONResponseBody as the provided StopLimitOrderResponse +func (t *CreateNewOrder200JSONResponseBody) FromStopLimitOrderResponse(v StopLimitOrderResponse) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeStopLimitOrderResponse performs a merge with any union data inside the CreateNewOrder200JSONResponseBody, using the provided StopLimitOrderResponse +func (t *CreateNewOrder200JSONResponseBody) MergeStopLimitOrderResponse(v StopLimitOrderResponse) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t CreateNewOrder200JSONResponseBody) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *CreateNewOrder200JSONResponseBody) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} diff --git a/packages/sdk-go/go.mod b/packages/sdk-go/go.mod new file mode 100644 index 00000000..bffc2dcc --- /dev/null +++ b/packages/sdk-go/go.mod @@ -0,0 +1,3 @@ +module github.com/gemini/gemini-go + +go 1.23 diff --git a/packages/sdk-go/internal/runtime/union.go b/packages/sdk-go/internal/runtime/union.go new file mode 100644 index 00000000..4484458b --- /dev/null +++ b/packages/sdk-go/internal/runtime/union.go @@ -0,0 +1,55 @@ +package runtime + +import ( + "bytes" + "encoding/json" + "fmt" + "io" +) + +// JSONMerge merges JSON object b on top of JSON object a. +func JSONMerge(a []byte, b []byte) ([]byte, error) { + if len(a) == 0 || bytes.Equal(a, []byte("null")) { + return b, nil + } + if len(b) == 0 || bytes.Equal(b, []byte("null")) { + return a, nil + } + left, err := decodeJSONValue(a) + if err != nil { + return nil, err + } + right, err := decodeJSONValue(b) + if err != nil { + return nil, err + } + leftMap, leftOK := left.(map[string]any) + rightMap, rightOK := right.(map[string]any) + if !leftOK || !rightOK { + // Union branches are occasionally scalar values. The later branch still + // has precedence, just as object members from b do. + return b, nil + } + for k, v := range rightMap { + leftMap[k] = v + } + return json.Marshal(leftMap) +} + +func decodeJSONValue(value []byte) (any, error) { + decoder := json.NewDecoder(bytes.NewReader(value)) + decoder.UseNumber() + + var decoded any + if err := decoder.Decode(&decoded); err != nil { + return nil, err + } + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + if err == nil { + return nil, fmt.Errorf("json merge: multiple JSON values") + } + return nil, err + } + return decoded, nil +} diff --git a/packages/sdk-go/internal/runtime/union_test.go b/packages/sdk-go/internal/runtime/union_test.go new file mode 100644 index 00000000..7e7515f9 --- /dev/null +++ b/packages/sdk-go/internal/runtime/union_test.go @@ -0,0 +1,32 @@ +package runtime + +import ( + "encoding/json" + "testing" +) + +func TestJSONMergePreservesLargeNumbers(t *testing.T) { + merged, err := JSONMerge( + []byte(`{"nonce":9007199254740993,"nested":{"value":1}}`), + []byte(`{"nested":{"value":9007199254740995}}`), + ) + if err != nil { + t.Fatalf("JSONMerge returned error: %v", err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(merged, &fields); err != nil { + t.Fatalf("merged JSON is invalid: %v", err) + } + if string(fields["nonce"]) != "9007199254740993" { + t.Fatalf("JSONMerge changed nonce precision: %s", fields["nonce"]) + } + if string(fields["nested"]) != `{"value":9007199254740995}` { + t.Fatalf("JSONMerge changed nested precision: %s", fields["nested"]) + } +} + +func TestJSONMergeRejectsMultipleValues(t *testing.T) { + if _, err := JSONMerge([]byte(`{} {}`), []byte(`{}`)); err == nil { + t.Fatal("expected multiple JSON values to be rejected") + } +} diff --git a/packages/sdk-go/memory_leak_test.go b/packages/sdk-go/memory_leak_test.go new file mode 100644 index 00000000..8379944e --- /dev/null +++ b/packages/sdk-go/memory_leak_test.go @@ -0,0 +1,145 @@ +package gemini_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "runtime" + "runtime/pprof" + "testing" + "time" + + "github.com/gemini/gemini-go" + "github.com/gemini/gemini-go/types" + "github.com/gemini/gemini-go/websocket" + "github.com/gemini/gemini-go/websocket/orderbook" +) + +type mockDialer struct{} +type mockConn struct { + closed chan struct{} + responses chan []byte +} + +func (m *mockDialer) Dial(ctx context.Context, urlStr string, requestHeader http.Header) (websocket.Conn, *http.Response, error) { + return &mockConn{ + closed: make(chan struct{}), + responses: make(chan []byte, 4), + }, &http.Response{StatusCode: 101}, nil +} + +func (m *mockConn) ReadMessage(ctx context.Context) (int, []byte, error) { + select { + case <-m.closed: + return 0, nil, context.Canceled + case <-ctx.Done(): + return 0, nil, ctx.Err() + case response := <-m.responses: + return websocket.TextMessage, response, nil + } +} + +func (m *mockConn) WriteMessage(ctx context.Context, messageType int, data []byte) error { + var request struct { + ID int64 `json:"id"` + Method string `json:"method"` + } + if err := json.Unmarshal(data, &request); err != nil || request.ID == 0 || request.Method == "" { + return nil + } + response, err := json.Marshal(map[string]any{ + "id": request.ID, + "status": http.StatusOK, + "result": map[string]any{}, + }) + if err != nil { + return err + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-m.closed: + return context.Canceled + case m.responses <- response: + return nil + } +} + +func (m *mockConn) Close() error { + select { + case <-m.closed: + default: + close(m.closed) + } + return nil +} + +func TestZeroGoroutineAndMemoryLeak(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"result":"ok"}`)) + })) + defer server.Close() + httpClient := server.Client() + defer httpClient.CloseIdleConnections() + + // Baseline goroutine count + runtime.GC() + time.Sleep(50 * time.Millisecond) + initialGoroutines := runtime.NumGoroutine() + + for cycle := 0; cycle < 50; cycle++ { + // 1. WebSocket Client Lifecycle + ws := websocket.NewClient("wss://api.gemini.com/v1/marketdata", + websocket.WithDialer(&mockDialer{}), + websocket.WithAutoReconnect(false), + ) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + _ = ws.Connect(ctx) + subChan, _ := ws.SubscribeDepth(ctx, "BTCUSD") + cancel() + + _ = ws.Send(context.Background(), map[string]string{"type": "subscribe"}) + + // 2. OrderBook Ingestion & Memory Allocation + book := orderbook.NewOrderBook("BTCUSD") + for i := 0; i < 100; i++ { + book.ApplySnapshot(int64(i), [][]string{{"60000", "1.0"}}, [][]string{{"60100", "1.0"}}) + _, _ = book.VWAP(true, types.MustParseDecimal("0.5")) + _, _ = book.Imbalance(5) + } + + // 3. Heartbeat Session Lifecycle with mock server + hbClient := gemini.NewClient( + gemini.WithAPIKey("test-key", "test-secret"), + gemini.WithCustomRESTURL(server.URL), + gemini.WithHTTPClient(httpClient), + ) + session := hbClient.Heartbeat.Start(context.Background(), 20*time.Millisecond) + time.Sleep(5 * time.Millisecond) + session.Stop() + _ = hbClient.Close() + httpClient.CloseIdleConnections() + + // 4. Close WebSocket + _ = ws.Close() + + // Drain subChan + for range subChan { + } + } + + runtime.GC() + time.Sleep(100 * time.Millisecond) + finalGoroutines := runtime.NumGoroutine() + + if finalGoroutines > initialGoroutines+1 { + _ = pprof.Lookup("goroutine").WriteTo(os.Stdout, 1) + t.Fatalf("Goroutine leak detected: started with %d, ended with %d", initialGoroutines, finalGoroutines) + } + + t.Logf("Memory test passed: 50 full cycles completed with 0 leaked goroutines (initial: %d, final: %d)", initialGoroutines, finalGoroutines) +} diff --git a/packages/sdk-go/options.go b/packages/sdk-go/options.go new file mode 100644 index 00000000..f292801e --- /dev/null +++ b/packages/sdk-go/options.go @@ -0,0 +1,147 @@ +package gemini + +import ( + "fmt" + "log/slog" + "net/http" + "strings" + + "github.com/gemini/gemini-go/auth" + "github.com/gemini/gemini-go/transport" + "github.com/gemini/gemini-go/websocket" +) + +type clientConfig struct { + env Environment + restURL string + wsURL string + configErr error + auth auth.Strategy + wsDialer websocket.Dialer + httpClient *http.Client + // ownsHTTPClient is true only for the transport created by NewClient. A + // caller-provided HTTP client, and its underlying transport, remain owned by + // the caller and must not be closed by Client.Close. + ownsHTTPClient bool + retry transport.RetryPolicy + logger *slog.Logger + userAgent string + hooks []transport.Hook +} + +// Option configures the Gemini client facade. +type Option func(*clientConfig) + +// WithHooks appends observability hooks to the client transport pipeline. +func WithHooks(hooks ...transport.Hook) Option { + return func(c *clientConfig) { + c.hooks = append(c.hooks, hooks...) + } +} + +// WithEnvironment sets the deployment environment (Production or Sandbox). +func WithEnvironment(env Environment) Option { + return func(c *clientConfig) { + c.env = env + if ep, ok := EndpointsFor(env); ok { + c.restURL = ep.REST + c.wsURL = ep.WebSocket + c.configErr = nil + return + } + // Keep invalid configuration fail-closed. In particular, do not leave + // the production defaults in place after a misspelled environment. + c.restURL = "" + c.wsURL = "" + c.configErr = fmt.Errorf("%w: %q", ErrInvalidEnvironment, env) + } +} + +// WithAuth sets the authentication strategy (e.g. auth.NewHMAC). +func WithAuth(strategy auth.Strategy) Option { + return func(c *clientConfig) { + c.auth = strategy + } +} + +// WithAPIKey is a convenience option configuring HMAC-SHA384 auth. +func WithAPIKey(key string, secret string) Option { + return func(c *clientConfig) { + c.auth = auth.NewHMAC(auth.APIKey(key), auth.APISecret(secret)) + } +} + +// WithBearerToken is a convenience option configuring OAuth2 Bearer Token auth with a static token. +func WithBearerToken(token string) Option { + return func(c *clientConfig) { + c.auth = auth.NewBearer(auth.BearerToken(token)) + } +} + +// WithTokenSource configures dynamic OAuth2 Bearer Token auth. The source is +// called for each HTTP attempt and WebSocket connection; it owns token caching, +// expiry checks, and refresh behavior. +func WithTokenSource(source auth.TokenSource) Option { + return func(c *clientConfig) { + c.auth = auth.NewBearerWithSource(source) + } +} + +// WithCustomRESTURL overrides the default REST base URL. The URL must use +// HTTPS and may include a path prefix, but must not include userinfo, a query +// string, or a fragment. +func WithCustomRESTURL(url string) Option { + return func(c *clientConfig) { + c.restURL = strings.TrimSpace(url) + } +} + +// WithCustomWSURL overrides the default WebSocket base URL. The URL must use +// WSS and may include a path prefix, but must not include userinfo, a query +// string, or a fragment. +func WithCustomWSURL(url string) Option { + return func(c *clientConfig) { + c.wsURL = strings.TrimSpace(url) + } +} + +// WithWebSocketDialer sets the WebSocket dialer adapter (e.g. gorilla.NewDialer()). +func WithWebSocketDialer(dialer websocket.Dialer) Option { + return func(c *clientConfig) { + c.wsDialer = dialer + } +} + +// WithHTTPClient provides a custom standard library http.Client. The caller +// retains ownership; Client.Close does not close its idle connections. +func WithHTTPClient(hc *http.Client) Option { + return func(c *clientConfig) { + c.httpClient = hc + c.ownsHTTPClient = false + } +} + +// WithRetryPolicy sets a custom retry policy. +func WithRetryPolicy(p transport.RetryPolicy) Option { + return func(c *clientConfig) { + c.retry = p + } +} + +// WithLogger sets the structured logger for the SDK. +func WithLogger(l *slog.Logger) Option { + return func(c *clientConfig) { + if l == nil { + c.logger = slog.Default() + } else { + c.logger = l + } + } +} + +// WithTraceHook registers a microsecond-level HTTP connection latency tracer. +func WithTraceHook(callback transport.TraceCallback) Option { + return func(c *clientConfig) { + c.hooks = append(c.hooks, transport.NewTraceHook(callback)) + } +} diff --git a/packages/sdk-go/pointers.go b/packages/sdk-go/pointers.go new file mode 100644 index 00000000..7697b113 --- /dev/null +++ b/packages/sdk-go/pointers.go @@ -0,0 +1,27 @@ +package gemini + +// Ptr returns a pointer to the passed value. Useful for constructing request structs with optional fields. +func Ptr[T any](v T) *T { return &v } + +// Val returns the value behind the pointer or the type's zero value if the pointer is nil. +func Val[T any](v *T) T { + if v == nil { + var zero T + return zero + } + return *v +} + +// Convenience typed pointer constructors and dereferencers. +func String(v string) *string { return Ptr(v) } +func StringValue(v *string) string { return Val(v) } +func Int(v int) *int { return Ptr(v) } +func IntValue(v *int) int { return Val(v) } +func Int32(v int32) *int32 { return Ptr(v) } +func Int32Value(v *int32) int32 { return Val(v) } +func Int64(v int64) *int64 { return Ptr(v) } +func Int64Value(v *int64) int64 { return Val(v) } +func Bool(v bool) *bool { return Ptr(v) } +func BoolValue(v *bool) bool { return Val(v) } +func Float64(v float64) *float64 { return Ptr(v) } +func Float64Value(v *float64) float64 { return Val(v) } diff --git a/packages/sdk-go/pointers_test.go b/packages/sdk-go/pointers_test.go new file mode 100644 index 00000000..56b9e9ca --- /dev/null +++ b/packages/sdk-go/pointers_test.go @@ -0,0 +1,63 @@ +package gemini_test + +import ( + "testing" + + "github.com/gemini/gemini-go" +) + +func TestPointerHelpers(t *testing.T) { + // String + s := gemini.String("hello") + if *s != "hello" || gemini.StringValue(s) != "hello" { + t.Errorf("unexpected string pointer result") + } + if gemini.StringValue(nil) != "" { + t.Errorf("expected empty string for nil") + } + + // Int + i := gemini.Int(42) + if *i != 42 || gemini.IntValue(i) != 42 { + t.Errorf("unexpected int pointer result") + } + if gemini.IntValue(nil) != 0 { + t.Errorf("expected 0 for nil int") + } + + // Int32 + i32 := gemini.Int32(100) + if *i32 != 100 || gemini.Int32Value(i32) != 100 { + t.Errorf("unexpected int32 pointer result") + } + if gemini.Int32Value(nil) != 0 { + t.Errorf("expected 0 for nil int32") + } + + // Int64 + i64 := gemini.Int64(1000) + if *i64 != 1000 || gemini.Int64Value(i64) != 1000 { + t.Errorf("unexpected int64 pointer result") + } + if gemini.Int64Value(nil) != 0 { + t.Errorf("expected 0 for nil int64") + } + + // Bool + b := gemini.Bool(true) + if *b != true || gemini.BoolValue(b) != true { + t.Errorf("unexpected bool pointer result") + } + if gemini.BoolValue(nil) != false { + t.Errorf("expected false for nil bool") + } + + // Float64 + f := gemini.Float64(3.14159) + if *f != 3.14159 || gemini.Float64Value(f) != 3.14159 { + t.Errorf("unexpected float64 pointer result") + } + if gemini.Float64Value(nil) != 0.0 { + t.Errorf("expected 0.0 for nil float64") + } +} diff --git a/packages/sdk-go/scripts/contract_test.go b/packages/sdk-go/scripts/contract_test.go new file mode 100644 index 00000000..fcebc607 --- /dev/null +++ b/packages/sdk-go/scripts/contract_test.go @@ -0,0 +1,274 @@ +package main + +import ( + "bytes" + "go/ast" + "go/format" + "go/parser" + "go/token" + "os" + "path" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/getkin/kin-openapi/openapi3" + "gopkg.in/yaml.v3" +) + +func TestGeneratedDecimalFieldsPreserveWireType(t *testing.T) { + for _, mod := range Modules { + t.Run(mod.ID, func(t *testing.T) { + raw, err := loadPublishedSpec(mod.SpecURL) + if err != nil { + t.Fatalf("reading spec %s: %v", mod.SpecURL, err) + } + loader := openapi3.NewLoader() + doc, err := loader.LoadFromData(sanitizeSpecBytes(raw)) + if err != nil { + t.Fatalf("loading spec %s: %v", mod.SpecURL, err) + } + code, err := RenderModule(mod) + if err != nil { + t.Fatalf("rendering module %s: %v", mod.ID, err) + } + fields, err := generatedFieldTypes(code) + if err != nil { + t.Fatalf("parsing generated module %s: %v", mod.ID, err) + } + for schemaName, schemaRef := range doc.Components.Schemas { + if schemaRef == nil || schemaRef.Value == nil { + continue + } + for field, property := range schemaRef.Value.Properties { + if property.Value == nil || property.Value.Type == nil || !property.Value.Type.Is("number") || property.Value.Format != "decimal" { + continue + } + want := "*openapi_types.DecimalNumber" + got, ok := fields[schemaName][field] + if !ok { + t.Errorf("%s.%s was not found in generated code", schemaName, field) + } else if got != want { + t.Errorf("%s.%s generated as %s, want %s", schemaName, field, got, want) + } + } + } + }) + } +} + +func generatedFieldTypes(code string) (map[string]map[string]string, error) { + file, err := parser.ParseFile(token.NewFileSet(), "generated.go", code, 0) + if err != nil { + return nil, err + } + fields := make(map[string]map[string]string) + for _, declaration := range file.Decls { + genDecl, ok := declaration.(*ast.GenDecl) + if !ok || genDecl.Tok != token.TYPE { + continue + } + for _, spec := range genDecl.Specs { + typeSpec, ok := spec.(*ast.TypeSpec) + if !ok { + continue + } + structType, ok := typeSpec.Type.(*ast.StructType) + if !ok { + continue + } + byJSONName := make(map[string]string) + for _, field := range structType.Fields.List { + if field.Tag == nil { + continue + } + jsonTag := reflect.StructTag(strings.Trim(field.Tag.Value, "`")).Get("json") + jsonName := strings.Split(jsonTag, ",")[0] + if jsonName == "" || jsonName == "-" { + continue + } + var typeText bytes.Buffer + if err := format.Node(&typeText, token.NewFileSet(), field.Type); err != nil { + return nil, err + } + byJSONName[jsonName] = typeText.String() + } + fields[typeSpec.Name.Name] = byJSONName + } + } + return fields, nil +} + +func TestOpenAPIContractDrift(t *testing.T) { + for _, mod := range Modules { + t.Run(mod.ID, func(t *testing.T) { + expectedCode, err := RenderModule(mod) + if err != nil { + t.Fatalf("failed to render module %s from spec %s: %v", mod.ID, mod.SpecURL, err) + } + + committedFile := filepath.Join("..", "generated", mod.Package, "types.gen.go") + committedBytes, err := os.ReadFile(committedFile) + if err != nil { + t.Fatalf("missing committed generated file %s. Run 'go run ./scripts/generate.go': %v", committedFile, err) + } + + committedStr := string(committedBytes) + if committedStr != expectedCode { + t.Logf("committed len=%d, expected len=%d", len(committedStr), len(expectedCode)) + // Find first difference + minLen := len(committedStr) + if len(expectedCode) < minLen { + minLen = len(expectedCode) + } + for i := 0; i < minLen; i++ { + if committedStr[i] != expectedCode[i] { + start := i - 20 + if start < 0 { + start = 0 + } + end := i + 40 + if end > minLen { + end = minLen + } + t.Logf("diff at char %d:\ncommitted: %q\nexpected: %q", i, committedStr[start:end], expectedCode[start:end]) + break + } + } + t.Fatalf("Contract drift detected in %s! The committed code in %s does not match the current OpenAPI specification %s.\n"+ + "Run 'go run ./scripts/generate.go' to regenerate and commit the updated models.", + mod.ID, committedFile, mod.SpecURL) + } + }) + } +} + +func TestNoOrphanedEndpoints(t *testing.T) { + specs := []string{restSpecURL, predictionMarketsSpecURL} + + allMappedTags := make(map[string]bool) + for _, mod := range Modules { + for _, tag := range mod.Tags { + allMappedTags[tag] = true + } + } + + for _, specURL := range specs { + t.Run(path.Base(specURL), func(t *testing.T) { + raw, err := loadPublishedSpec(specURL) + if err != nil { + t.Fatalf("reading spec %s: %v", specURL, err) + } + + loader := openapi3.NewLoader() + doc, err := loader.LoadFromData(sanitizeSpecBytes(raw)) + if err != nil { + t.Fatalf("loading openapi doc %s: %v", specURL, err) + } + + if doc.Paths == nil { + t.Fatalf("spec %s has no paths", specURL) + } + + var orphanedOps []string + for pathStr, pathItem := range doc.Paths.Map() { + if pathItem == nil { + continue + } + for method, op := range pathItem.Operations() { + if op == nil { + continue + } + isCovered := false + for _, tag := range op.Tags { + if allMappedTags[tag] { + isCovered = true + break + } + } + if !isCovered { + orphanedOps = append(orphanedOps, method+" "+pathStr+" (operationId: "+op.OperationID+")") + } + } + } + + if len(orphanedOps) > 0 { + t.Errorf("Found %d orphaned operations in %s not covered by any SDK module tags:\n - %s", + len(orphanedOps), path.Base(specURL), strings.Join(orphanedOps, "\n - ")) + } + }) + } +} + +func TestOperationIDsUnique(t *testing.T) { + specs := []string{restSpecURL, predictionMarketsSpecURL} + + for _, specURL := range specs { + t.Run(path.Base(specURL), func(t *testing.T) { + raw, err := loadPublishedSpec(specURL) + if err != nil { + t.Fatalf("reading spec %s: %v", specURL, err) + } + + loader := openapi3.NewLoader() + doc, err := loader.LoadFromData(sanitizeSpecBytes(raw)) + if err != nil { + t.Fatalf("loading openapi doc %s: %v", specURL, err) + } + + seenIDs := make(map[string]string) + for pathStr, pathItem := range doc.Paths.Map() { + if pathItem == nil { + continue + } + for method, op := range pathItem.Operations() { + if op == nil || op.OperationID == "" { + continue + } + if existing, exists := seenIDs[op.OperationID]; exists { + t.Errorf("Duplicate operationId '%s' found in %s %s and %s", op.OperationID, method, pathStr, existing) + } + seenIDs[op.OperationID] = method + " " + pathStr + } + } + }) + } +} + +func TestAsyncAPIWebSocketStreams(t *testing.T) { + specURL := websocketSpecURL + raw, err := loadPublishedSpec(specURL) + if err != nil { + t.Fatalf("reading websocket spec %s: %v", specURL, err) + } + + var root struct { + XGeminiCoverage struct { + Streams []struct { + Name string `yaml:"name"` + Status string `yaml:"status"` + Auth string `yaml:"auth"` + } `yaml:"streams"` + } `yaml:"x-gemini-coverage"` + } + + if err := yaml.Unmarshal(raw, &root); err != nil { + t.Fatalf("unmarshaling asyncapi yaml: %v", err) + } + + if len(root.XGeminiCoverage.Streams) == 0 { + t.Fatal("no streams found in x-gemini-coverage") + } + + machineReadableCount := 0 + for _, stream := range root.XGeminiCoverage.Streams { + if stream.Status == "machine-readable" { + machineReadableCount++ + } + } + + if machineReadableCount < 8 { + t.Fatalf("expected at least 8 machine-readable streams, found %d", machineReadableCount) + } +} diff --git a/packages/sdk-go/scripts/generate.go b/packages/sdk-go/scripts/generate.go new file mode 100644 index 00000000..209d11ae --- /dev/null +++ b/packages/sdk-go/scripts/generate.go @@ -0,0 +1,399 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "go/format" + "io" + "net/http" + "os" + "path" + "path/filepath" + "regexp" + "strings" + "sync" + "time" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/oapi-codegen/oapi-codegen/v2/pkg/codegen" +) + +type ModuleConfig struct { + ID string + Package string + SpecURL string + Tags []string +} + +const ( + restSpecURL = "https://developer.gemini.com/specs/openapi/rest.yaml" + predictionMarketsSpecURL = "https://developer.gemini.com/specs/openapi/prediction-markets.yaml" + websocketSpecURL = "https://developer.gemini.com/specs/asyncapi/websocket.yaml" +) + +// Update these values only in a reviewed change that also updates generated output. +var publishedSpecSHA256 = map[string]string{ + restSpecURL: "79a0dc4061f3942dca8b30a589bbd406c781d2c6c19283d87cb21177afdcab5e", + predictionMarketsSpecURL: "0c70a976f4553ae39d14d6851416cb974f081919216b94ebd851f044d108cfe7", + websocketSpecURL: "904160ee9d2f5ba4c4a789e0173877ca936019fefa83a2eba1dcf2a1fef2a796", +} + +var ( + publishedSpecClient = &http.Client{ + Timeout: 30 * time.Second, + // The specification URLs are allowlisted and pinned by digest. Do not + // follow redirects because that would permit an allowlisted HTTPS URL to + // fetch from an unexpected host or downgrade to HTTP. + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + } + publishedSpecCache = struct { + sync.Mutex + values map[string][]byte + }{values: make(map[string][]byte)} +) + +const maxPublishedSpecBytes int64 = 16 << 20 + +var Modules = []ModuleConfig{ + { + ID: "marketdata", + Package: "marketdata", + SpecURL: restSpecURL, + Tags: []string{"Market Data"}, + }, + { + ID: "trading", + Package: "trading", + SpecURL: restSpecURL, + Tags: []string{"Orders", "Session"}, + }, + { + ID: "margin", + Package: "margin", + SpecURL: restSpecURL, + Tags: []string{"Margin Trading"}, + }, + { + ID: "perpetuals", + Package: "perpetuals", + SpecURL: restSpecURL, + Tags: []string{"Derivatives"}, + }, + { + ID: "account", + Package: "account", + SpecURL: restSpecURL, + Tags: []string{"Account Administration", "Fund Management", "OAuth", "Staking"}, + }, + { + ID: "clearing", + Package: "clearing", + SpecURL: restSpecURL, + Tags: []string{"Clearing", "Instant"}, + }, + { + ID: "predictions", + Package: "predictions", + SpecURL: predictionMarketsSpecURL, + Tags: []string{"Combos", "Markets", "Positions", "Rewards", "Terms", "Trading", "Volume"}, + }, +} + +var ( + longFormatRegex = regexp.MustCompile(`(?m)^(\s*)format:\s*long\s*$`) + integerFormatRegex = regexp.MustCompile(`(?m)^(\s*)format:\s*integer\s*$`) +) + +func sanitizeSpecBytes(data []byte) []byte { + out := longFormatRegex.ReplaceAll(data, []byte("${1}format: int64")) + out = integerFormatRegex.ReplaceAll(out, []byte("${1}format: int64")) + return out +} + +func fixSchema(s *openapi3.Schema) { + if s == nil { + return + } + if s.Type != nil { + if s.Type.Is("number") { + if s.Format == "long" || s.Format == "integer" || s.Format == "int64" { + s.Type = &openapi3.Types{"integer"} + s.Format = "int64" + } else if s.Format == "decimal" { + setDecimalOverride(s) + } + } else if s.Type.Is("integer") { + if s.Format == "long" || s.Format == "integer" { + s.Format = "int64" + } + } + } + for _, prop := range s.Properties { + if prop.Value != nil { + fixSchema(prop.Value) + } + } + for _, allOf := range s.AllOf { + if allOf.Value != nil { + fixSchema(allOf.Value) + } + } + for _, anyOf := range s.AnyOf { + if anyOf.Value != nil { + fixSchema(anyOf.Value) + } + } + for _, oneOf := range s.OneOf { + if oneOf.Value != nil { + fixSchema(oneOf.Value) + } + } + if s.Items != nil && s.Items.Value != nil { + fixSchema(s.Items.Value) + } + if s.AdditionalProperties.Schema != nil && s.AdditionalProperties.Schema.Value != nil { + fixSchema(s.AdditionalProperties.Schema.Value) + } +} + +func setDecimalOverride(s *openapi3.Schema) { + if s.Extensions == nil { + s.Extensions = make(map[string]any) + } + goType := "types.Decimal" + if s.Type != nil && s.Type.Is("number") { + goType = "types.DecimalNumber" + } + s.Extensions["x-go-type"] = goType + s.Extensions["x-go-type-import"] = map[string]any{ + "path": "github.com/gemini/gemini-go/types", + "name": "types", + } +} + +func loadPublishedSpec(specURL string) ([]byte, error) { + expectedHash, ok := publishedSpecSHA256[specURL] + if !ok { + return nil, fmt.Errorf("unallowlisted published specification URL: %s", specURL) + } + + publishedSpecCache.Lock() + cached := publishedSpecCache.values[specURL] + publishedSpecCache.Unlock() + if cached != nil { + return cached, nil + } + + response, err := publishedSpecClient.Get(specURL) + if err != nil { + return nil, fmt.Errorf("fetching spec %s: %w", specURL, err) + } + defer response.Body.Close() + + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + return nil, fmt.Errorf("fetching spec %s: HTTP %s", specURL, response.Status) + } + if response.ContentLength > maxPublishedSpecBytes { + return nil, fmt.Errorf("published specification %s exceeds %d-byte limit", specURL, maxPublishedSpecBytes) + } + + raw, err := io.ReadAll(io.LimitReader(response.Body, maxPublishedSpecBytes+1)) + if err != nil { + return nil, fmt.Errorf("reading spec %s: %w", specURL, err) + } + if int64(len(raw)) > maxPublishedSpecBytes { + return nil, fmt.Errorf("published specification %s exceeds %d-byte limit", specURL, maxPublishedSpecBytes) + } + + digest := sha256.Sum256(raw) + actualHash := hex.EncodeToString(digest[:]) + if actualHash != expectedHash { + return nil, fmt.Errorf("published specification hash mismatch for %s: expected %s, got %s", specURL, expectedHash, actualHash) + } + + publishedSpecCache.Lock() + if existing := publishedSpecCache.values[specURL]; existing != nil { + raw = existing + } else { + publishedSpecCache.values[specURL] = raw + } + publishedSpecCache.Unlock() + + return raw, nil +} + +func fixDoc(doc *openapi3.T) { + if doc == nil { + return + } + if doc.Components != nil { + for _, schemaRef := range doc.Components.Schemas { + if schemaRef != nil && schemaRef.Value != nil { + fixSchema(schemaRef.Value) + } + } + for _, paramRef := range doc.Components.Parameters { + if paramRef != nil && paramRef.Value != nil && paramRef.Value.Schema != nil && paramRef.Value.Schema.Value != nil { + fixSchema(paramRef.Value.Schema.Value) + } + } + for _, respRef := range doc.Components.Responses { + if respRef != nil && respRef.Value != nil { + for _, content := range respRef.Value.Content { + if content != nil && content.Schema != nil && content.Schema.Value != nil { + fixSchema(content.Schema.Value) + } + } + } + } + } + if doc.Paths != nil { + for _, pathItem := range doc.Paths.Map() { + if pathItem == nil { + continue + } + for _, op := range pathItem.Operations() { + if op == nil { + continue + } + for _, paramRef := range op.Parameters { + if paramRef != nil && paramRef.Value != nil && paramRef.Value.Schema != nil && paramRef.Value.Schema.Value != nil { + fixSchema(paramRef.Value.Schema.Value) + } + } + if op.RequestBody != nil && op.RequestBody.Value != nil { + for _, content := range op.RequestBody.Value.Content { + if content != nil && content.Schema != nil && content.Schema.Value != nil { + fixSchema(content.Schema.Value) + } + } + } + if op.Responses != nil { + for _, respRef := range op.Responses.Map() { + if respRef != nil && respRef.Value != nil { + for _, content := range respRef.Value.Content { + if content != nil && content.Schema != nil && content.Schema.Value != nil { + fixSchema(content.Schema.Value) + } + } + } + } + } + } + } + } + applySDKTypeOverrides(doc) +} + +// applySDKTypeOverrides contains Go-specific model decisions that are owned by +// this SDK repository rather than the shared REST description. Keeping these +// transformations here makes generation reproducible without changing the +// API source specification. +func applySDKTypeOverrides(doc *openapi3.T) { + if doc.Components == nil { + return + } + if balance := doc.Components.Schemas["Balance"]; balance != nil && balance.Value != nil { + for _, field := range []string{"amount", "available", "availableForWithdrawal", "pendingWithdrawal", "pendingDeposit"} { + if prop := balance.Value.Properties[field]; prop != nil && prop.Value != nil { + setDecimalOverride(prop.Value) + } + } + } + if order := doc.Components.Schemas["NewOrderRequest"]; order != nil && order.Value != nil { + if nonce := order.Value.Properties["nonce"]; nonce != nil && nonce.Value != nil { + nonce.Value.Type = &openapi3.Types{"integer"} + nonce.Value.Format = "int64" + } + } +} + +// RenderModule generates the Go code for a given module configuration from its OpenAPI spec. +func RenderModule(mod ModuleConfig) (string, error) { + raw, err := loadPublishedSpec(mod.SpecURL) + if err != nil { + return "", err + } + + sanitized := sanitizeSpecBytes(raw) + + loader := openapi3.NewLoader() + loader.IsExternalRefsAllowed = false + doc, err := loader.LoadFromData(sanitized) + if err != nil { + return "", fmt.Errorf("loading openapi doc: %w", err) + } + + fixDoc(doc) + + cfg := codegen.Configuration{ + PackageName: mod.Package, + Generate: codegen.GenerateOptions{ + Models: true, + }, + OutputOptions: codegen.OutputOptions{ + SkipPrune: true, + IncludeTags: mod.Tags, + }, + } + + code, err := codegen.Generate(doc, cfg) + if err != nil { + return "", fmt.Errorf("generating code for %s: %w", mod.ID, err) + } + + // Rewire third-party runtime imports to stdlib-backed internal packages + code = strings.ReplaceAll(code, "\"github.com/oapi-codegen/runtime/types\"", "\"github.com/gemini/gemini-go/types\"") + code = strings.ReplaceAll(code, "\"github.com/oapi-codegen/runtime\"", "\"github.com/gemini/gemini-go/internal/runtime\"") + // The generated files already import the shared types package as + // openapi_types for dates and UUIDs. Reuse that alias for Decimal fields so + // generation does not emit two imports of the same package under different + // names. + code = strings.ReplaceAll(code, "types.Decimal", "openapi_types.Decimal") + code = strings.ReplaceAll(code, "\ttypes \"github.com/gemini/gemini-go/types\"\n", "") + + // Normalize tool version comment line to ensure deterministic comparison across environments + versionRegex := regexp.MustCompile(`(?m)^// Code generated by .* DO NOT EDIT\.\r?\n`) + code = versionRegex.ReplaceAllString(code, "// Code generated by oapi-codegen. DO NOT EDIT.\n") + + header := fmt.Sprintf("// Code generated from %s (%s). DO NOT EDIT.\n\n", path.Base(mod.SpecURL), strings.Join(mod.Tags, ", ")) + formatted, err := format.Source([]byte(header + code)) + if err != nil { + return "", fmt.Errorf("formatting generated code for %s: %w", mod.ID, err) + } + return string(formatted), nil +} + +// GenerateModule generates and writes Go code for a given module to disk. +func GenerateModule(mod ModuleConfig) error { + finalCode, err := RenderModule(mod) + if err != nil { + return err + } + + outDir := filepath.Join("..", "generated", mod.Package) + if err := os.MkdirAll(outDir, 0750); err != nil { + return fmt.Errorf("creating dir %s: %w", outDir, err) + } + + outFile := filepath.Join(outDir, "types.gen.go") + if err := os.WriteFile(outFile, []byte(finalCode), 0600); err != nil { + return fmt.Errorf("writing file %s: %w", outFile, err) + } + + fmt.Printf("✓ Generated %s (%d bytes) -> %s\n", mod.ID, len(finalCode), outFile) + return nil +} + +func main() { + for _, mod := range Modules { + if err := GenerateModule(mod); err != nil { + fmt.Fprintf(os.Stderr, "Error generating %s: %v\n", mod.ID, err) + os.Exit(1) + } + } + fmt.Println("All modules successfully generated!") +} diff --git a/packages/sdk-go/scripts/go.mod b/packages/sdk-go/scripts/go.mod new file mode 100644 index 00000000..211afe28 --- /dev/null +++ b/packages/sdk-go/scripts/go.mod @@ -0,0 +1,30 @@ +module generate-tools + +go 1.24.3 + +require ( + github.com/getkin/kin-openapi v0.135.0 + github.com/oapi-codegen/oapi-codegen/v2 v2.7.1 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect + github.com/go-openapi/jsonpointer v0.22.4 // indirect + github.com/go-openapi/swag/jsonname v0.25.4 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/mailru/easyjson v0.9.1 // indirect + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/oasdiff/yaml v0.0.9 // indirect + github.com/oasdiff/yaml3 v0.0.9 // indirect + github.com/onsi/gomega v1.27.6 // indirect + github.com/perimeterx/marshmallow v1.1.5 // indirect + github.com/speakeasy-api/jsonpath v0.6.3 // indirect + github.com/speakeasy-api/openapi v1.19.2 // indirect + github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect + github.com/woodsbury/decimal128 v1.4.0 // indirect + golang.org/x/mod v0.33.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/text v0.34.0 // indirect + golang.org/x/tools v0.42.0 // indirect +) diff --git a/packages/sdk-go/scripts/go.sum b/packages/sdk-go/scripts/go.sum new file mode 100644 index 00000000..d5ca9daa --- /dev/null +++ b/packages/sdk-go/scripts/go.sum @@ -0,0 +1,179 @@ +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58= +github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w= +github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/getkin/kin-openapi v0.135.0 h1:751SjYfbiwqukYuVjwYEIKNfrSwS5YpA7DZnKSwQgtg= +github.com/getkin/kin-openapi v0.135.0/go.mod h1:6dd5FJl6RdX4usBtFBaQhk9q62Yb2J0Mk5IhUO/QqFI= +github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4= +github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80= +github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= +github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= +github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= +github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= +github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= +github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/oapi-codegen/oapi-codegen/v2 v2.7.1 h1:a7Ab7YlpqkVG5HKrTaeFstm32Z5QOnyjnbsCO0jiMYM= +github.com/oapi-codegen/oapi-codegen/v2 v2.7.1/go.mod h1:qzFy6iuobJw/hD1aRILee4G87/ShmhR0xYCwcUtZMCw= +github.com/oasdiff/yaml v0.0.9 h1:zQOvd2UKoozsSsAknnWoDJlSK4lC0mpmjfDsfqNwX48= +github.com/oasdiff/yaml v0.0.9/go.mod h1:8lvhgJG4xiKPj3HN5lDow4jZHPlx1i7dIwzkdAo6oAM= +github.com/oasdiff/yaml3 v0.0.9 h1:rWPrKccrdUm8J0F3sGuU+fuh9+1K/RdJlWF7O/9yw2g= +github.com/oasdiff/yaml3 v0.0.9/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= +github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= +github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= +github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= +github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= +github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE= +github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg= +github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= +github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= +github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/speakeasy-api/jsonpath v0.6.3 h1:c+QPwzAOdrWvzycuc9HFsIZcxKIaWcNpC+xhOW9rJxU= +github.com/speakeasy-api/jsonpath v0.6.3/go.mod h1:2cXloNuQ+RSXi5HTRaeBh7JEmjRXTiaKpFTdZiL7URI= +github.com/speakeasy-api/openapi v1.19.2 h1:md90tE71/M8jS3cuRlsuWP5Aed4xoG5PSRvXeZgCv/M= +github.com/speakeasy-api/openapi v1.19.2/go.mod h1:UfKa7FqE4jgexJZuj51MmdHAFGmDv0Zaw3+yOd81YKU= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= +github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk= +github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ= +github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc= +github.com/woodsbury/decimal128 v1.4.0/go.mod h1:BP46FUrVjVhdTbKT+XuQh2xfQaGki9LMIRJSFuh6THU= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/packages/sdk-go/scripts/release_smoke.sh b/packages/sdk-go/scripts/release_smoke.sh new file mode 100644 index 00000000..fcdb7dad --- /dev/null +++ b/packages/sdk-go/scripts/release_smoke.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +sdk_root="$(cd "$script_dir/.." && pwd)" +staging_root="$(mktemp -d)" +trap 'rm -rf "$staging_root"' EXIT + +staged_sdk="$staging_root/packages/sdk-go" +consumer="$staging_root/consumer" +mkdir -p "$(dirname "$staged_sdk")" +cp -R "$sdk_root" "$staged_sdk" + +go test -C "$staged_sdk" ./... +go test -C "$staged_sdk/websocket/gorilla" ./... +go test -C "$staged_sdk/scripts" ./... +go test -C "$staged_sdk/cmd/demo" ./... +go vet -C "$staged_sdk/cmd/demo" ./... + +mkdir -p "$consumer" +cat >"$consumer/go.mod" < $staged_sdk +replace github.com/gemini/gemini-go/websocket/gorilla => $staged_sdk/websocket/gorilla +EOF +cp "$staged_sdk/websocket/gorilla/go.sum" "$consumer/go.sum" + +cat >"$consumer/import_test.go" <<'EOF' +package consumer + +import ( + "testing" + + gemini "github.com/gemini/gemini-go" + "github.com/gemini/gemini-go/types" + "github.com/gemini/gemini-go/generated/account" + "github.com/gemini/gemini-go/generated/trading" + "github.com/gemini/gemini-go/services" + "github.com/gemini/gemini-go/websocket" + "github.com/gemini/gemini-go/websocket/gorilla" +) + +func TestPublishedModuleImports(t *testing.T) { + if gemini.NewClient() == nil { + t.Fatal("NewClient returned nil") + } + if types.MustParseDecimal("1.25").String() != "1.25" { + t.Fatal("decimal import is not usable") + } + if websocket.OpOrderNew == "" { + t.Fatal("websocket import is not usable") + } + if gorilla.NewDialer() == nil { + t.Fatal("gorilla import is not usable") + } + if _, err := gemini.NewClientWithError(gemini.WithEnvironment(gemini.Sandbox)); err != nil { + t.Fatalf("constructor import is not usable: %v", err) + } + var _ account.Balance + var _ trading.NewOrderRequest + var _ services.OrderOption +} +EOF + +go test -C "$consumer" -mod=mod ./... + +echo "release smoke passed: standalone modules, demo, generated packages, services, and Gorilla imports are usable" diff --git a/packages/sdk-go/scripts/service_coverage_test.go b/packages/sdk-go/scripts/service_coverage_test.go new file mode 100644 index 00000000..dc60fcec --- /dev/null +++ b/packages/sdk-go/scripts/service_coverage_test.go @@ -0,0 +1,205 @@ +package main + +import ( + "path" + "strings" + "testing" + + "github.com/getkin/kin-openapi/openapi3" +) + +// restOperationCoverage is deliberately maintained next to the contract tests. +// Generated models track the complete API schema, while the hand-written +// services facade intentionally exposes a smaller, curated surface. +type restOperationCoverage struct { + method string + reason string +} + +var unsupportedFacadeOperation = restOperationCoverage{ + reason: "generated request/response models exist, but no high-level services facade method is exposed yet", +} + +func coverageKey(spec, operationID string) string { + return spec + ":" + operationID +} + +var sdkRESTOperationCoverage = map[string]restOperationCoverage{ + coverageKey("prediction-markets.yaml", "listEvents"): {method: "services.PredictionsService.GetEvents"}, + coverageKey("prediction-markets.yaml", "getEvent"): {method: "services.PredictionsService.GetEvent"}, + coverageKey("prediction-markets.yaml", "getCategories"): {method: "services.PredictionsService.GetCategories"}, + coverageKey("prediction-markets.yaml", "getPredictionMarketsTerms"): {method: "services.PredictionsService.GetTerms"}, + coverageKey("prediction-markets.yaml", "getPredictionMarketsTermsStatus"): {method: "services.PredictionsService.GetTermsStatus"}, + coverageKey("prediction-markets.yaml", "acceptPredictionMarketsTerms"): {method: "services.PredictionsService.AcceptTerms"}, + coverageKey("prediction-markets.yaml", "placeOrder"): {method: "services.PredictionsService.NewOrder"}, + coverageKey("prediction-markets.yaml", "getEventStrike"): {method: "services.PredictionsService.GetEventStrike"}, + coverageKey("prediction-markets.yaml", "listNewlyListedEvents"): {method: "services.PredictionsService.ListNewlyListedEvents"}, + coverageKey("prediction-markets.yaml", "listRecentlySettledEvents"): {method: "services.PredictionsService.ListRecentlySettledEvents"}, + coverageKey("prediction-markets.yaml", "listUpcomingEvents"): {method: "services.PredictionsService.ListUpcomingEvents"}, + coverageKey("prediction-markets.yaml", "getPredictionMarketDailyVolume"): {method: "services.PredictionsService.GetDailyVolume"}, + coverageKey("prediction-markets.yaml", "getPredictionMarketHourlyVolume"): {method: "services.PredictionsService.GetHourlyVolume"}, + coverageKey("prediction-markets.yaml", "placeOrderBatch"): {method: "services.PredictionsService.PlaceOrderBatch"}, + coverageKey("prediction-markets.yaml", "cancelOrder"): {method: "services.PredictionsService.CancelOrder"}, + coverageKey("prediction-markets.yaml", "cancelOrderBatch"): {method: "services.PredictionsService.CancelOrderBatch"}, + coverageKey("prediction-markets.yaml", "getActiveOrders"): {method: "services.PredictionsService.GetActiveOrders"}, + coverageKey("prediction-markets.yaml", "getOrderHistory"): {method: "services.PredictionsService.GetOrderHistory"}, + coverageKey("prediction-markets.yaml", "getPositions"): {method: "services.PredictionsService.GetPositions"}, + coverageKey("prediction-markets.yaml", "getSettledPositions"): {method: "services.PredictionsService.GetSettledPositions"}, + coverageKey("prediction-markets.yaml", "getVolumeMetrics"): {method: "services.PredictionsService.GetVolumeMetrics"}, + coverageKey("prediction-markets.yaml", "listCombos"): {method: "services.PredictionsService.ListCombos"}, + coverageKey("prediction-markets.yaml", "createCombo"): {method: "services.PredictionsService.CreateCombo"}, + coverageKey("prediction-markets.yaml", "getComboByInstrumentSymbol"): {method: "services.PredictionsService.GetCombo"}, + coverageKey("prediction-markets.yaml", "getMakerRebateRates"): {method: "services.PredictionsService.GetMakerRebateRates"}, + coverageKey("prediction-markets.yaml", "listMakerRebatePayouts"): {method: "services.PredictionsService.ListMakerRebatePayouts"}, + coverageKey("prediction-markets.yaml", "getMakerRebateLifetimeSummary"): {method: "services.PredictionsService.GetMakerRebateLifetimeSummary"}, + coverageKey("prediction-markets.yaml", "getLiquidityRewardsConfig"): {method: "services.PredictionsService.GetLiquidityRewardsConfig"}, + coverageKey("prediction-markets.yaml", "listLiquidityRewardsEvents"): {method: "services.PredictionsService.ListLiquidityRewardsEvents"}, + coverageKey("prediction-markets.yaml", "getLiquidityRewardsDailySummary"): {method: "services.PredictionsService.GetLiquidityRewardsDailySummary"}, + coverageKey("prediction-markets.yaml", "getLiquidityRewardsLifetimeSummary"): {method: "services.PredictionsService.GetLiquidityRewardsLifetimeSummary"}, + + coverageKey("rest.yaml", "listSymbols"): {method: "services.MarketDataService.GetSymbols"}, + coverageKey("rest.yaml", "getSymbolDetails"): {method: "services.MarketDataService.GetSymbolDetails"}, + coverageKey("rest.yaml", "getTicker"): {method: "services.MarketDataService.GetTicker"}, + coverageKey("rest.yaml", "getCurrentOrderBook"): {method: "services.MarketDataService.GetOrderBook"}, + coverageKey("rest.yaml", "listTrades"): {method: "services.MarketDataService.GetTrades"}, + coverageKey("rest.yaml", "listCandles"): {method: "services.MarketDataService.GetCandles"}, + coverageKey("rest.yaml", "getTickerV2"): {method: "services.MarketDataService.GetTickerV2"}, + coverageKey("rest.yaml", "getFundingAmount"): {method: "services.PerpetualsService.GetFundingAmount"}, + coverageKey("rest.yaml", "getNextFundingTimestamp"): {method: "services.PerpetualsService.GetNextFundingTimestamp"}, + coverageKey("rest.yaml", "listFundingPayments"): {method: "services.PerpetualsService.GetFundingPayments"}, + coverageKey("rest.yaml", "createNewOrder"): {method: "services.TradingService.NewOrder"}, + coverageKey("rest.yaml", "cancelOrder"): {method: "services.TradingService.CancelOrder"}, + coverageKey("rest.yaml", "cancelAllActiveOrders"): {method: "services.TradingService.CancelAllOrders"}, + coverageKey("rest.yaml", "getOrderStatus"): {method: "services.TradingService.GetOrderStatus"}, + coverageKey("rest.yaml", "listActiveOrders"): {method: "services.TradingService.GetActiveOrders"}, + coverageKey("rest.yaml", "listPastTrades"): {method: "services.TradingService.GetPastTrades"}, + coverageKey("rest.yaml", "getAvailableBalances"): {method: "services.AccountService.GetBalances"}, + coverageKey("rest.yaml", "getNotionalBalances"): {method: "services.AccountService.GetNotionalBalances"}, + coverageKey("rest.yaml", "getMarginAccount"): {method: "services.MarginService.GetAccountSummary"}, + coverageKey("rest.yaml", "getMarginRates"): {method: "services.MarginService.GetInterestRates"}, + coverageKey("rest.yaml", "previewMarginOrder"): {method: "services.MarginService.PreviewOrder"}, + coverageKey("rest.yaml", "sendHeartbeat"): {method: "services.HeartbeatService.Send"}, + coverageKey("rest.yaml", "createNewDepositAddress"): {method: "services.AccountService.GetDepositAddress"}, + coverageKey("rest.yaml", "listPastTransfers"): {method: "services.TransfersService.GetTransfers"}, + coverageKey("rest.yaml", "listCustodyFeeTransfers"): {method: "services.TransfersService.GetCustodyFeeTransfers"}, + coverageKey("rest.yaml", "getGasFeeEstimation"): {method: "services.TransfersService.GetWithdrawalFeeEstimateV2"}, + coverageKey("rest.yaml", "withdrawCryptoFunds"): {method: "services.TransfersService.WithdrawCryptoV2"}, + coverageKey("rest.yaml", "createNewClearingOrder"): {method: "services.ClearingService.NewClearingOrder"}, + coverageKey("rest.yaml", "getClearingOrder"): {method: "services.ClearingService.GetClearingOrder"}, + coverageKey("rest.yaml", "cancelClearingOrder"): {method: "services.ClearingService.CancelClearingOrder"}, + coverageKey("rest.yaml", "confirmClearingOrder"): {method: "services.ClearingService.ConfirmClearingOrder"}, + coverageKey("rest.yaml", "addBank"): {method: "services.AccountService.AddBankUSD"}, + coverageKey("rest.yaml", "addBankCAD"): {method: "services.AccountService.AddBankCAD"}, + coverageKey("rest.yaml", "listPaymentMethods"): {method: "services.AccountService.ListPaymentMethods"}, + coverageKey("rest.yaml", "getAccountDetail"): {method: "services.AccountService.GetAccount"}, + coverageKey("rest.yaml", "listApprovedAddresses"): {method: "services.AccountService.ListApprovedAddresses"}, + coverageKey("rest.yaml", "createNewApprovedAddress"): {method: "services.AccountService.RequestApprovedAddress"}, + coverageKey("rest.yaml", "removeApprovedAddress"): {method: "services.AccountService.RemoveApprovedAddress"}, + coverageKey("rest.yaml", "createNewAccount"): {method: "services.AccountService.CreateAccount"}, + coverageKey("rest.yaml", "renameAccount"): {method: "services.AccountService.RenameAccount"}, + coverageKey("rest.yaml", "listAccountsInGroup"): {method: "services.AccountService.ListAccounts"}, + coverageKey("rest.yaml", "transferBetweenAccounts"): {method: "services.AccountService.TransferBetweenAccounts"}, + coverageKey("rest.yaml", "listStakingBalances"): {method: "services.StakingService.GetStakingBalances"}, + coverageKey("rest.yaml", "stakeCryptoFunds"): {method: "services.StakingService.Stake"}, + coverageKey("rest.yaml", "listStakingEventHistory"): {method: "services.StakingService.GetStakingHistory"}, + coverageKey("rest.yaml", "listStakingRates"): {method: "services.StakingService.GetStakingRates"}, + coverageKey("rest.yaml", "listStakingRewards"): {method: "services.StakingService.GetStakingRewards"}, + coverageKey("rest.yaml", "unstakeCryptoFunds"): {method: "services.StakingService.Unstake"}, + coverageKey("rest.yaml", "getRoles"): {method: "services.AccountService.GetRoles"}, + coverageKey("rest.yaml", "revokeOAuthToken"): {method: "services.AccountService.RevokeOAuthToken"}, + coverageKey("rest.yaml", "getFundingAmountReportFile"): unsupportedFacadeOperation, + coverageKey("rest.yaml", "cancelAllSessionOrders"): unsupportedFacadeOperation, + coverageKey("rest.yaml", "listPastOrders"): unsupportedFacadeOperation, + coverageKey("rest.yaml", "getTradingVolume"): unsupportedFacadeOperation, + coverageKey("rest.yaml", "getNotionalTradingVolume"): unsupportedFacadeOperation, + coverageKey("rest.yaml", "wrapOrder"): unsupportedFacadeOperation, + coverageKey("rest.yaml", "listDepositAddresses"): unsupportedFacadeOperation, + coverageKey("rest.yaml", "listClearingOrders"): unsupportedFacadeOperation, + coverageKey("rest.yaml", "listClearingBrokers"): unsupportedFacadeOperation, + coverageKey("rest.yaml", "createNewBrokerOrder"): unsupportedFacadeOperation, + coverageKey("rest.yaml", "listClearingTrades"): unsupportedFacadeOperation, + coverageKey("rest.yaml", "getInstantQuote"): unsupportedFacadeOperation, + coverageKey("rest.yaml", "executeInstantOrder"): unsupportedFacadeOperation, + coverageKey("rest.yaml", "getTransactionHistory"): unsupportedFacadeOperation, + coverageKey("rest.yaml", "getAccountMargin"): unsupportedFacadeOperation, + coverageKey("rest.yaml", "getFundingPaymentReportFile"): unsupportedFacadeOperation, + coverageKey("rest.yaml", "getFundingPaymentReportJson"): unsupportedFacadeOperation, + coverageKey("rest.yaml", "getOpenPositions"): unsupportedFacadeOperation, + coverageKey("rest.yaml", "getRiskStats"): unsupportedFacadeOperation, + coverageKey("rest.yaml", "listDerivativeCandles"): unsupportedFacadeOperation, + coverageKey("rest.yaml", "getFXRate"): unsupportedFacadeOperation, + coverageKey("rest.yaml", "listPrices"): unsupportedFacadeOperation, + coverageKey("rest.yaml", "getAssetsForNetwork"): unsupportedFacadeOperation, + coverageKey("rest.yaml", "getTokenNetworkV2"): unsupportedFacadeOperation, + coverageKey("rest.yaml", "listFeePromos"): unsupportedFacadeOperation, +} + +func TestRESTOperationCoverageManifest(t *testing.T) { + specs := []string{restSpecURL, predictionMarketsSpecURL} + seen := make(map[string]struct{}) + + for _, specURL := range specs { + specName := path.Base(specURL) + raw, err := loadPublishedSpec(specURL) + if err != nil { + t.Fatalf("reading spec %s: %v", specURL, err) + } + loader := openapi3.NewLoader() + doc, err := loader.LoadFromData(sanitizeSpecBytes(raw)) + if err != nil { + t.Fatalf("loading openapi doc %s: %v", specURL, err) + } + + for path, item := range doc.Paths.Map() { + if item == nil { + continue + } + for method, operation := range item.Operations() { + if operation == nil || operation.OperationID == "" { + continue + } + key := coverageKey(specName, operation.OperationID) + seen[key] = struct{}{} + coverage, ok := sdkRESTOperationCoverage[key] + if !ok { + t.Errorf("%s %s operation %q is missing from the SDK coverage manifest", method, path, key) + continue + } + if (coverage.method == "") == (coverage.reason == "") { + t.Errorf("coverage entry %q must contain exactly one of method or reason", key) + } + } + } + } + + for key := range sdkRESTOperationCoverage { + if _, ok := seen[key]; !ok { + t.Errorf("coverage manifest entry %q does not match an operation in the current specs", key) + } + } + if len(seen) == 0 { + t.Fatal("no REST operations found in specs") + } + t.Logf("classified %d REST operations (%d high-level service methods, %d intentionally unsupported)", len(seen), supportedOperationCount(), len(seen)-supportedOperationCount()) +} + +func supportedOperationCount() int { + count := 0 + for _, coverage := range sdkRESTOperationCoverage { + if coverage.method != "" { + count++ + } + } + return count +} + +func TestRESTOperationCoverageKeysAreReadable(t *testing.T) { + for key, coverage := range sdkRESTOperationCoverage { + if strings.TrimSpace(key) == "" || (coverage.method == "" && coverage.reason == "") { + t.Errorf("invalid REST coverage entry: %q (%+v)", key, coverage) + } + } + if supportedOperationCount() == 0 { + t.Fatal("coverage manifest has no supported operations") + } +} diff --git a/packages/sdk-go/scripts/websocket_coverage_test.go b/packages/sdk-go/scripts/websocket_coverage_test.go new file mode 100644 index 00000000..1b088a46 --- /dev/null +++ b/packages/sdk-go/scripts/websocket_coverage_test.go @@ -0,0 +1,109 @@ +package main + +import ( + "testing" + + "gopkg.in/yaml.v3" +) + +type websocketCoverage struct { + method string + reason string +} + +var sdkWebSocketCoverage = map[string]websocketCoverage{ + "stream:{symbol}@bookTicker": {method: "websocket.Client.SubscribeBookTicker"}, + "stream:{symbol}@depth": {method: "websocket.Client.SubscribeDepth"}, + "stream:{symbol}@trade": {method: "websocket.Client.SubscribeTrades"}, + "stream:contractStatus": {method: "websocket.Client.SubscribeContractStatus"}, + "stream:orders@account": {method: "websocket.Client.SubscribeOrderEvents"}, + "stream:balances@account": {method: "websocket.Client.SubscribeBalances"}, + "stream:positions@account": {method: "websocket.Client.SubscribePositions"}, + "stream:settlements@account": {method: "websocket.Client.SubscribeSettlements"}, + + "stream:{symbol}@depth5": {method: "websocket.Client.SubscribePartialDepth"}, + "stream:{symbol}@depth10": {method: "websocket.Client.SubscribePartialDepth"}, + "stream:{symbol}@depth20": {method: "websocket.Client.SubscribePartialDepth"}, + "stream:{symbol}@depth5@100ms": {method: "websocket.Client.SubscribePartialDepth"}, + "stream:{symbol}@depth10@100ms": {method: "websocket.Client.SubscribePartialDepth"}, + "stream:{symbol}@depth20@100ms": {method: "websocket.Client.SubscribePartialDepth"}, + "stream:{symbol}@depth@100ms": {method: "websocket.Client.SubscribeDepthWithOptions"}, + "stream:orders@session": {method: "websocket.Client.SubscribeOrderEventsWithScope"}, + "stream:balances@account@1s": {method: "websocket.Client.SubscribeBalancesWithOptions"}, + "stream:positions@account@1s": {method: "websocket.Client.SubscribePositionsWithOptions"}, + "stream:requestForQuote": {method: "websocket.Client.SubscribeRFQEvents"}, + "stream:requestForQuote@account": {method: "websocket.Client.SubscribeRFQDeliveries"}, + "stream:requestForQuote@session": {method: "websocket.Client.SubscribeRFQDeliveries"}, + + "method:conninfo": {method: "websocket.Client.ConnInfo"}, + "method:ping": {method: "websocket.Client.Ping"}, + "method:time": {method: "websocket.Client.Time"}, + "method:SUBSCRIBE": {method: "websocket.Client.SubscribeStreams"}, + "method:subscribe": {method: "websocket.Client.SubscribeStreams"}, + "method:UNSUBSCRIBE": {method: "websocket.Client.UnsubscribeStreams"}, + "method:unsubscribe": {method: "websocket.Client.UnsubscribeStreams"}, + "method:LIST_SUBSCRIPTIONS": {method: "websocket.Client.ListSubscriptions"}, + "method:list_subscriptions": {method: "websocket.Client.ListSubscriptions"}, + "method:depth": {method: "websocket.Client.GetDepthSnapshot"}, + "method:order.place": {method: "websocket.Client.PlaceOrder"}, + "method:order.cancel": {method: "websocket.Client.CancelOrder"}, + "method:order.cancel_all": {method: "websocket.Client.CancelAllOrders"}, + "method:order.cancel_session": {method: "websocket.Client.CancelSessionOrders"}, + "method:rfq.submit_quote": {method: "websocket.Client.SubmitRFQQuote"}, + "method:rfq.withdraw_quote": {method: "websocket.Client.WithdrawRFQQuote"}, + "method:rfq.confirm_quote": {method: "websocket.Client.ConfirmRFQQuote"}, +} + +func TestAsyncAPIWebSocketCoverageManifest(t *testing.T) { + specURL := websocketSpecURL + raw, err := loadPublishedSpec(specURL) + if err != nil { + t.Fatalf("reading websocket spec %s: %v", specURL, err) + } + var root struct { + XGeminiCoverage struct { + Streams []struct { + Name string `yaml:"name"` + } `yaml:"streams"` + Methods []struct { + Name string `yaml:"name"` + } `yaml:"methods"` + } `yaml:"x-gemini-coverage"` + } + if err := yaml.Unmarshal(raw, &root); err != nil { + t.Fatalf("unmarshaling websocket spec: %v", err) + } + + seen := make(map[string]struct{}) + for _, stream := range root.XGeminiCoverage.Streams { + key := "stream:" + stream.Name + seen[key] = struct{}{} + assertWebSocketCoverage(t, key) + } + for _, method := range root.XGeminiCoverage.Methods { + key := "method:" + method.Name + seen[key] = struct{}{} + assertWebSocketCoverage(t, key) + } + for key := range sdkWebSocketCoverage { + if _, ok := seen[key]; !ok { + t.Errorf("WebSocket coverage entry %q does not match the current spec", key) + } + } + if len(seen) == 0 { + t.Fatal("no WebSocket coverage entries found in spec") + } + t.Logf("classified %d WebSocket streams and methods", len(seen)) +} + +func assertWebSocketCoverage(t *testing.T, key string) { + t.Helper() + coverage, ok := sdkWebSocketCoverage[key] + if !ok { + t.Errorf("WebSocket operation %q is missing from the SDK coverage manifest", key) + return + } + if (coverage.method == "") == (coverage.reason == "") { + t.Errorf("WebSocket coverage entry %q must contain exactly one of method or reason", key) + } +} diff --git a/packages/sdk-go/services/account.go b/packages/sdk-go/services/account.go new file mode 100644 index 00000000..21c07935 --- /dev/null +++ b/packages/sdk-go/services/account.go @@ -0,0 +1,277 @@ +package services + +import ( + "context" + "errors" + "net/url" + "strings" + + "github.com/gemini/gemini-go/generated/account" + "github.com/gemini/gemini-go/transport" +) + +// AccountService provides access to account management, banking, deposit addresses, and role information. +type AccountService struct { + baseService +} + +// AddBankResponse is returned when a bank account is submitted for linking. +type AddBankResponse = account.AddBankResponse + +// ApprovedAddressMessage is returned when an approved address is requested or removed. +type ApprovedAddressMessage = account.ApprovedAddressMessage + +// PaymentMethodsResponse contains linked payment balances and bank accounts. +type PaymentMethodsResponse = account.PaymentMethodsResponse + +// RevokeOAuthTokenResponse confirms that the access token used for the +// request was revoked. +type RevokeOAuthTokenResponse struct { + Message string `json:"message,omitempty"` +} + +// CreateAccountResponse is returned when a subaccount is created. Account is +// the shortname used to target the new account with a Master API key. +type CreateAccountResponse struct { + Account string `json:"account"` + Type string `json:"type"` +} + +// AccountListItem is an account descriptor returned by the group account list +// endpoint. Account is the shortname required when a Master API key targets a +// subaccount. +type AccountListItem struct { + Name string `json:"name"` + Account string `json:"account"` + Type string `json:"type"` + CounterpartyID *string `json:"counterparty_id"` + Created account.TimestampType `json:"created"` + Status string `json:"status"` +} + +// AccountDetailResponse is the response shape returned by /v1/account. +type AccountDetailResponse struct { + Account AccountDetail `json:"account"` + Users []AccountUser `json:"users,omitempty"` + MemoReferenceCode string `json:"memo_reference_code,omitempty"` + VirtualAccountNumber string `json:"virtual_account_number,omitempty"` +} + +// AccountDetail contains the account metadata returned by /v1/account. +type AccountDetail struct { + AccountName string `json:"accountName,omitempty"` + ShortName string `json:"shortName,omitempty"` + Type string `json:"type,omitempty"` + Created account.TimestampType `json:"created,omitempty"` +} + +// AccountUser contains an account user's status and verification information. +type AccountUser struct { + Name string `json:"name,omitempty"` + LastSignIn string `json:"lastSignIn,omitempty"` + Status string `json:"status,omitempty"` + CountryCode string `json:"countryCode,omitempty"` + IsVerified bool `json:"isVerified,omitempty"` +} + +// RenameAccountResponse contains the fields changed by /v1/account/rename. +type RenameAccountResponse struct { + Name string `json:"name,omitempty"` + Account string `json:"account,omitempty"` +} + +// NewAccountService creates a new AccountService. +func NewAccountService(client *transport.Client, baseURL string) *AccountService { + return &AccountService{ + baseService: newBaseService(client, baseURL), + } +} + +// 1. GetBalances returns the available and amount balances for all currencies +// in the selected account. The REST contract requires Account. +func (s *AccountService) GetBalances(ctx context.Context, req *account.GetAvailableBalancesJSONBody) ([]account.Balance, error) { + if req == nil || strings.TrimSpace(req.Account) == "" { + return nil, errors.New("gemini account: account is required for GetBalances") + } + var balances []account.Balance + if err := s.post(ctx, "/v1/balances", req, &balances); err != nil { + return nil, err + } + return balances, nil +} + +// 2. GetNotionalBalances returns account balances valued in a specified fiat currency (e.g. USD). +func (s *AccountService) GetNotionalBalances(ctx context.Context, currency string, req *account.GetNotionalBalancesJSONBody) ([]account.NotionalBalance, error) { + if req == nil { + req = &account.GetNotionalBalancesJSONBody{} + } + var balances []account.NotionalBalance + if err := s.post(ctx, "/v1/notionalbalances/"+url.PathEscape(currency), req, &balances); err != nil { + return nil, err + } + return balances, nil +} + +// 3. GetAccount retrieves account details. Set Account in req when using a +// Master API key to select a subaccount; account-level keys may omit it. +func (s *AccountService) GetAccount(ctx context.Context, req *account.GetAccountDetailJSONBody) (*AccountDetailResponse, error) { + if req == nil { + req = &account.GetAccountDetailJSONBody{} + } + var res AccountDetailResponse + if err := s.post(ctx, "/v1/account", req, &res); err != nil { + return nil, err + } + return &res, nil +} + +// 4. CreateAccount creates a new subaccount in the account group. +func (s *AccountService) CreateAccount(ctx context.Context, req *account.CreateNewAccountJSONBody) (*CreateAccountResponse, error) { + var res CreateAccountResponse + if err := s.post(ctx, "/v1/account/create", req, &res); err != nil { + return nil, err + } + return &res, nil +} + +// CreateAccountByName creates a new subaccount in the account group by name and optional type. +func (s *AccountService) CreateAccountByName(ctx context.Context, name, accountType string) (*CreateAccountResponse, error) { + req := &account.CreateNewAccountJSONBody{Name: name} + if accountType != "" { + req.Type = &accountType + } + return s.CreateAccount(ctx, req) +} + +// 5. RenameAccount renames an existing subaccount. +func (s *AccountService) RenameAccount(ctx context.Context, req *account.RenameAccountJSONBody) (*RenameAccountResponse, error) { + var res RenameAccountResponse + if err := s.post(ctx, "/v1/account/rename", req, &res); err != nil { + return nil, err + } + return &res, nil +} + +// 6. ListAccounts returns all subaccounts in the account group. +func (s *AccountService) ListAccounts(ctx context.Context, req *account.ListAccountsInGroupJSONBody) ([]AccountListItem, error) { + if req == nil { + req = &account.ListAccountsInGroupJSONBody{} + } + var res []AccountListItem + if err := s.post(ctx, "/v1/account/list", req, &res); err != nil { + return nil, err + } + return res, nil +} + +// 7. TransferBetweenAccounts moves funds between subaccounts in the same account group. +func (s *AccountService) TransferBetweenAccounts(ctx context.Context, currency string, req *account.TransferBetweenAccountsJSONBody) (*account.Transfer, error) { + var res account.Transfer + if err := s.post(ctx, "/v1/account/transfer/"+url.PathEscape(currency), req, &res); err != nil { + return nil, err + } + return &res, nil +} + +// 8. GetDepositAddress generates or retrieves the crypto deposit address for a +// currency. Set Account in req when using a Master API key. +func (s *AccountService) GetDepositAddress(ctx context.Context, currency string, req *account.CreateNewDepositAddressJSONBody) (*account.Address, error) { + if req == nil { + req = &account.CreateNewDepositAddressJSONBody{} + } + var res account.Address + if err := s.post(ctx, "/v1/deposit/"+url.PathEscape(currency)+"/newAddress", req, &res); err != nil { + return nil, err + } + return &res, nil +} + +// 9. ListApprovedAddresses retrieves the list of whitelisted withdrawal +// addresses for a network. Set Account in req when using a Master API key. +func (s *AccountService) ListApprovedAddresses(ctx context.Context, network string, req *account.ListApprovedAddressesJSONBody) ([]account.ApprovedAddress, error) { + if req == nil { + req = &account.ListApprovedAddressesJSONBody{} + } + var res account.ApprovedAddressesResponse + if err := s.post(ctx, "/v1/approvedAddresses/account/"+url.PathEscape(network), req, &res); err != nil { + return nil, err + } + if res.ApprovedAddresses == nil { + return nil, nil + } + return *res.ApprovedAddresses, nil +} + +// 10. RequestApprovedAddress requests addition of a new approved withdrawal +// address. Set Account in req when a Master API key targets a subaccount. +func (s *AccountService) RequestApprovedAddress(ctx context.Context, network string, req *account.CreateNewApprovedAddressJSONBody) (*account.ApprovedAddressMessage, error) { + var res account.ApprovedAddressMessage + if err := s.post(ctx, "/v1/approvedAddresses/"+url.PathEscape(network)+"/request", req, &res); err != nil { + return nil, err + } + return &res, nil +} + +// 11. RemoveApprovedAddress requests removal of an approved withdrawal address. +// Set Account in req when a Master API key targets a subaccount. +func (s *AccountService) RemoveApprovedAddress(ctx context.Context, network string, req *account.RemoveApprovedAddressJSONBody) (*account.ApprovedAddressMessage, error) { + var res account.ApprovedAddressMessage + if err := s.post(ctx, "/v1/approvedAddresses/"+url.PathEscape(network)+"/remove", req, &res); err != nil { + return nil, err + } + return &res, nil +} + +// 12. AddBankUSD links a new US bank account. +func (s *AccountService) AddBankUSD(ctx context.Context, req *account.AddBankJSONBody) (*account.AddBankResponse, error) { + var res account.AddBankResponse + if err := s.post(ctx, "/v1/payments/addbank", req, &res); err != nil { + return nil, err + } + return &res, nil +} + +// 13. AddBankCAD links a new Canadian bank account. +func (s *AccountService) AddBankCAD(ctx context.Context, req *account.AddBankCADJSONBody) (*account.AddBankResponse, error) { + var res account.AddBankResponse + if err := s.post(ctx, "/v1/payments/addbank/cad", req, &res); err != nil { + return nil, err + } + return &res, nil +} + +// 14. ListPaymentMethods returns all linked payment methods (bank accounts, wire instructions). +func (s *AccountService) ListPaymentMethods(ctx context.Context, req *account.ListPaymentMethodsJSONBody) (*PaymentMethodsResponse, error) { + if req == nil { + req = &account.ListPaymentMethodsJSONBody{} + } + var res PaymentMethodsResponse + if err := s.post(ctx, "/v1/payments/methods", req, &res); err != nil { + return nil, err + } + return &res, nil +} + +// 15. GetRoles returns API key roles and permission scopes. +func (s *AccountService) GetRoles(ctx context.Context, req *account.GetRolesJSONBody) (*account.RoleResponse, error) { + if req == nil { + req = &account.GetRolesJSONBody{} + } + var res account.RoleResponse + if err := s.post(ctx, "/v1/roles", req, &res); err != nil { + return nil, err + } + return &res, nil +} + +// RevokeOAuthToken revokes the OAuth access token used for this request. The +// endpoint only accepts OAuth bearer authentication; configure the client with +// WithBearerToken or WithTokenSource before calling it. +func (s *AccountService) RevokeOAuthToken(ctx context.Context) (*RevokeOAuthTokenResponse, error) { + var res RevokeOAuthTokenResponse + req := &account.RevokeOAuthTokenJSONBody{Request: "/v1/oauth/revokeByToken"} + if err := s.post(ctx, "/v1/oauth/revokeByToken", req, &res); err != nil { + return nil, err + } + return &res, nil +} diff --git a/packages/sdk-go/services/account_test.go b/packages/sdk-go/services/account_test.go new file mode 100644 index 00000000..c030c547 --- /dev/null +++ b/packages/sdk-go/services/account_test.go @@ -0,0 +1,282 @@ +package services_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gemini/gemini-go/generated/account" + "github.com/gemini/gemini-go/services" + "github.com/gemini/gemini-go/transport" +) + +func TestAccountService_Methods(t *testing.T) { + var capturedPath string + requestPayloads := make(map[string]map[string]any) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + if r.Method != http.MethodPost { + t.Errorf("expected POST for %s, got %s", r.URL.Path, r.Method) + } + body, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("reading request body for %s: %v", r.URL.Path, err) + } else if len(body) > 0 && string(body) != "null" { + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + t.Errorf("decoding request body for %s: %v", r.URL.Path, err) + } else { + requestPayloads[r.URL.Path] = payload + } + } + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/v1/balances": + _ = json.NewEncoder(w).Encode([]map[string]any{ + {"currency": "USD", "amount": 1000.0, "available": 900.0, "type": "exchange"}, + }) + case "/v1/notionalbalances/USD": + _ = json.NewEncoder(w).Encode([]map[string]any{ + {"currency": "USD", "amountNotional": "1000.0"}, + }) + case "/v1/account": + _ = json.NewEncoder(w).Encode(map[string]any{ + "account": map[string]any{ + "accountName": "Primary", + "shortName": "primary", + "type": "exchange", + }, + "users": []map[string]any{{"name": "Satoshi Nakamoto", "isVerified": true}}, + }) + case "/v1/account/create": + _ = json.NewEncoder(w).Encode(map[string]any{"account": "trading-2", "type": "exchange"}) + case "/v1/account/rename": + _ = json.NewEncoder(w).Encode(map[string]any{"name": "Trading Renamed", "account": "trading-renamed"}) + case "/v1/account/list": + _ = json.NewEncoder(w).Encode([]map[string]any{ + {"name": "Primary", "account": "primary", "type": "exchange", "counterparty_id": "EMONNYXH", "created": 1495127793000, "status": "open"}, + {"name": "Trading Renamed", "account": "trading-renamed", "type": "exchange", "counterparty_id": nil, "created": 1565970772000, "status": "closed"}, + }) + case "/v1/account/transfer/USD": + amt := "500.00" + status := account.TransferStatus("Complete") + _ = json.NewEncoder(w).Encode(account.Transfer{Amount: &amt, Status: &status}) + case "/v1/deposit/BTC/newAddress": + _ = json.NewEncoder(w).Encode(map[string]any{"address": "bc1qtestaddress", "network": "bitcoin"}) + case "/v1/approvedAddresses/account/ethereum": + _ = json.NewEncoder(w).Encode(map[string]any{ + "approvedAddresses": []map[string]any{ + {"address": "0x1234567890abcdef", "network": "ethereum", "status": "active"}, + }, + }) + case "/v1/approvedAddresses/ethereum/request": + _ = json.NewEncoder(w).Encode(map[string]any{"result": "ok", "status": "pending-time"}) + case "/v1/approvedAddresses/ethereum/remove": + _ = json.NewEncoder(w).Encode(map[string]any{"result": "ok"}) + case "/v1/payments/addbank": + _ = json.NewEncoder(w).Encode(map[string]any{"result": "ok"}) + case "/v1/payments/addbank/cad": + _ = json.NewEncoder(w).Encode(map[string]any{"result": "ok"}) + case "/v1/payments/methods": + _ = json.NewEncoder(w).Encode(map[string]any{ + "balances": []map[string]any{{"type": "exchange", "currency": "USD", "amount": "1000"}}, + "banks": []map[string]any{{"bank": "Test Bank", "bankId": "bank-1"}}, + }) + case "/v1/roles": + _ = json.NewEncoder(w).Encode(map[string]any{ + "isTrader": true, + "isFundManager": true, + "isAuditor": false, + }) + case "/v1/oauth/revokeByToken": + _ = json.NewEncoder(w).Encode(map[string]any{"message": "token revoked"}) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + trans := transport.NewClient(transport.WithHTTPClient(server.Client())) + svc := services.NewAccountService(trans, server.URL) + ctx := context.Background() + accountName := "primary" + label := "MyLabel" + + // The balances contract requires an account selector. Rejecting invalid + // input locally prevents an authenticated request with an unusable payload. + for _, req := range []*account.GetAvailableBalancesJSONBody{ + nil, + {Account: " "}, + } { + if _, err := svc.GetBalances(ctx, req); err == nil { + t.Fatalf("GetBalances(%#v) accepted a missing account selector", req) + } + } + if capturedPath != "" { + t.Fatalf("invalid GetBalances input made an HTTP request to %s", capturedPath) + } + + assertPayloadField := func(path, field, want string) { + t.Helper() + payload, ok := requestPayloads[path] + if !ok { + t.Fatalf("no request payload captured for %s", path) + } + if got := payload[field]; got != want { + t.Fatalf("%s payload field %s: got %v, want %q", path, field, got, want) + } + } + + // 1. GetBalances + balances, err := svc.GetBalances(ctx, &account.GetAvailableBalancesJSONBody{Account: accountName}) + if err != nil || len(balances) != 1 || capturedPath != "/v1/balances" { + t.Fatalf("GetBalances failed: %v", err) + } + assertPayloadField("/v1/balances", "account", accountName) + + // 2. GetNotionalBalances + notional, err := svc.GetNotionalBalances(ctx, "USD", &account.GetNotionalBalancesJSONBody{Account: &accountName}) + if err != nil || len(notional) != 1 || capturedPath != "/v1/notionalbalances/USD" { + t.Fatalf("GetNotionalBalances failed: %v", err) + } + assertPayloadField("/v1/notionalbalances/USD", "account", accountName) + + // 3. GetAccount + acc, err := svc.GetAccount(ctx, &account.GetAccountDetailJSONBody{Account: &accountName}) + if err != nil || acc == nil || acc.Account.AccountName != "Primary" || len(acc.Users) != 1 { + t.Fatalf("GetAccount failed: %v", err) + } + assertPayloadField("/v1/account", "account", accountName) + + // 4. CreateAccount + created, err := svc.CreateAccountByName(ctx, "Trading 2", "exchange") + if err != nil || created == nil || created.Account != "trading-2" || created.Type != "exchange" { + t.Fatalf("CreateAccount failed: %v", err) + } + + // 5. RenameAccount + newName := "Trading Renamed" + renamed, err := svc.RenameAccount(ctx, &account.RenameAccountJSONBody{ + Account: &accountName, + NewName: &newName, + }) + if err != nil || renamed == nil || renamed.Name != "Trading Renamed" || renamed.Account != "trading-renamed" { + t.Fatalf("RenameAccount failed: %v", err) + } + assertPayloadField("/v1/account/rename", "account", accountName) + assertPayloadField("/v1/account/rename", "newName", newName) + + // 6. ListAccounts + limit := 100 + list, err := svc.ListAccounts(ctx, &account.ListAccountsInGroupJSONBody{LimitAccounts: &limit}) + if err != nil || len(list) != 2 || list[0].Account != "primary" || list[0].Type != "exchange" || list[1].CounterpartyID != nil { + t.Fatalf("ListAccounts failed: %v", err) + } + if got := requestPayloads["/v1/account/list"]["limit_accounts"]; got != float64(limit) { + t.Fatalf("ListAccounts did not serialize limit: got %v", got) + } + createdAt, err := list[0].Created.AsTimestampType1() + if err != nil || createdAt != 1495127793000 { + t.Fatalf("ListAccounts decoded created timestamp incorrectly: %d, %v", createdAt, err) + } + + // 7. TransferBetweenAccounts + transferRes, err := svc.TransferBetweenAccounts(ctx, "USD", &account.TransferBetweenAccountsJSONBody{ + Amount: "500.00", + SourceAccount: "acc-123", + TargetAccount: "acc-456", + }) + if err != nil || transferRes.Status == nil || string(*transferRes.Status) != "Complete" { + t.Fatalf("TransferBetweenAccounts failed: %v", err) + } + + // 8. GetDepositAddress + addr, err := svc.GetDepositAddress(ctx, "BTC", &account.CreateNewDepositAddressJSONBody{Account: &accountName, Label: &label}) + if err != nil || addr.Address == nil || *addr.Address != "bc1qtestaddress" { + t.Fatalf("GetDepositAddress failed: %v", err) + } + assertPayloadField("/v1/deposit/BTC/newAddress", "account", accountName) + assertPayloadField("/v1/deposit/BTC/newAddress", "label", label) + + // 9. ListApprovedAddresses + approved, err := svc.ListApprovedAddresses(ctx, "ethereum", &account.ListApprovedAddressesJSONBody{Account: &accountName}) + if err != nil || len(approved) != 1 { + t.Fatalf("ListApprovedAddresses failed: %v", err) + } + assertPayloadField("/v1/approvedAddresses/account/ethereum", "account", accountName) + + // 10. RequestApprovedAddress + reqRes, err := svc.RequestApprovedAddress(ctx, "ethereum", &account.CreateNewApprovedAddressJSONBody{ + Account: &accountName, + Address: "0x123", + Label: "MyEth", + }) + if err != nil || reqRes.Result == nil || *reqRes.Result != "ok" { + t.Fatalf("RequestApprovedAddress failed: %v", err) + } + assertPayloadField("/v1/approvedAddresses/ethereum/request", "account", accountName) + + // 11. RemoveApprovedAddress + remRes, err := svc.RemoveApprovedAddress(ctx, "ethereum", &account.RemoveApprovedAddressJSONBody{ + Account: &accountName, + Address: "0x123", + }) + if err != nil || remRes.Result == nil || *remRes.Result != "ok" { + t.Fatalf("RemoveApprovedAddress failed: %v", err) + } + assertPayloadField("/v1/approvedAddresses/ethereum/remove", "account", accountName) + + // 12. AddBankUSD + bankRes, err := svc.AddBankUSD(ctx, &account.AddBankJSONBody{ + Account: &accountName, + Accountnumber: "123", + Routing: "021000021", + Name: "Checking Account", + Type: account.AddBankJSONBodyTypeChecking, + }) + if err != nil || bankRes.Result == nil || *bankRes.Result != "ok" { + t.Fatalf("AddBankUSD failed: %v", err) + } + assertPayloadField("/v1/payments/addbank", "account", accountName) + + // 13. AddBankCAD + institutionNumber := "001" + branchNumber := "12345" + bankCADRes, err := svc.AddBankCAD(ctx, &account.AddBankCADJSONBody{ + Account: &accountName, + AccountNumber: "456", + InstitutionNumber: &institutionNumber, + Branchnnumber: &branchNumber, + Swiftcode: "swift-code", + Name: "Canadian Account", + Type: account.AddBankCADJSONBodyTypeChecking, + }) + if err != nil || bankCADRes.Result == nil || *bankCADRes.Result != "ok" { + t.Fatalf("AddBankCAD failed: %v", err) + } + assertPayloadField("/v1/payments/addbank/cad", "account", accountName) + + // 14. ListPaymentMethods + methods, err := svc.ListPaymentMethods(ctx, &account.ListPaymentMethodsJSONBody{Account: &accountName}) + if err != nil || methods == nil || methods.Balances == nil || methods.Banks == nil || len(*methods.Balances) != 1 || len(*methods.Banks) != 1 { + t.Fatalf("ListPaymentMethods failed: %v", err) + } + assertPayloadField("/v1/payments/methods", "account", accountName) + + // 15. GetRoles + roles, err := svc.GetRoles(ctx, nil) + if err != nil || roles == nil || !roles.IsTrader || !roles.IsFundManager || roles.IsAuditor { + t.Fatalf("GetRoles failed: %v", err) + } + + // 16. RevokeOAuthToken + revoked, err := svc.RevokeOAuthToken(ctx) + if err != nil || revoked == nil || revoked.Message != "token revoked" { + t.Fatalf("RevokeOAuthToken failed: %v", err) + } + assertPayloadField("/v1/oauth/revokeByToken", "request", "/v1/oauth/revokeByToken") + +} diff --git a/packages/sdk-go/services/base.go b/packages/sdk-go/services/base.go new file mode 100644 index 00000000..28ac52b3 --- /dev/null +++ b/packages/sdk-go/services/base.go @@ -0,0 +1,50 @@ +package services + +import ( + "context" + "errors" + "net/http" + "strings" + + "github.com/gemini/gemini-go/transport" +) + +// ErrInvalidServiceConfiguration indicates that a service was constructed +// without a usable transport client or base URL. +var ErrInvalidServiceConfiguration = errors.New("gemini services: invalid service configuration") + +// baseService encapsulates the shared HTTP transport client and base URL for all domain services. +type baseService struct { + client *transport.Client + baseURL string +} + +func newBaseService(client *transport.Client, baseURL string) baseService { + return baseService{ + client: client, + baseURL: baseURL, + } +} + +func (b *baseService) request(ctx context.Context, method, path string, payload any, target any) error { + if b == nil || b.client == nil { + return ErrInvalidServiceConfiguration + } + if strings.TrimSpace(b.baseURL) == "" { + if err := b.client.ConfigurationError(); err != nil { + return err + } + return ErrInvalidServiceConfiguration + } + baseURL := strings.TrimRight(b.baseURL, "/") + requestPath := "/" + strings.TrimLeft(path, "/") + return b.client.Request(ctx, method, baseURL+requestPath, payload, target) +} + +func (b *baseService) post(ctx context.Context, path string, payload any, target any) error { + return b.request(ctx, http.MethodPost, path, payload, target) +} + +func (b *baseService) get(ctx context.Context, path string, target any) error { + return b.request(ctx, http.MethodGet, path, nil, target) +} diff --git a/packages/sdk-go/services/base_test.go b/packages/sdk-go/services/base_test.go new file mode 100644 index 00000000..33e23b92 --- /dev/null +++ b/packages/sdk-go/services/base_test.go @@ -0,0 +1,25 @@ +package services + +import ( + "context" + "errors" + "testing" + + "github.com/gemini/gemini-go/transport" +) + +func TestBaseServiceRejectsNilTransport(t *testing.T) { + service := NewMarketDataService(nil, "https://api.gemini.com") + _, err := service.GetSymbols(context.Background()) + if !errors.Is(err, ErrInvalidServiceConfiguration) { + t.Fatalf("expected invalid service configuration, got %v", err) + } +} + +func TestBaseServiceRejectsEmptyBaseURL(t *testing.T) { + service := NewMarketDataService(transport.NewClient(), "") + _, err := service.GetSymbols(context.Background()) + if !errors.Is(err, ErrInvalidServiceConfiguration) { + t.Fatalf("expected invalid service configuration, got %v", err) + } +} diff --git a/packages/sdk-go/services/clearing.go b/packages/sdk-go/services/clearing.go new file mode 100644 index 00000000..2ca0045d --- /dev/null +++ b/packages/sdk-go/services/clearing.go @@ -0,0 +1,63 @@ +package services + +import ( + "context" + + "github.com/gemini/gemini-go/generated/clearing" + "github.com/gemini/gemini-go/transport" +) + +// ClearingService provides access to off-exchange Gemini Clearing operations. +type ClearingService struct { + baseService +} + +// ClearingOperationResponse reports the result of a clearing cancellation or +// confirmation request. +type ClearingOperationResponse struct { + Result string `json:"result,omitempty"` + Details string `json:"details,omitempty"` +} + +// NewClearingService creates a new ClearingService. +func NewClearingService(client *transport.Client, baseURL string) *ClearingService { + return &ClearingService{ + baseService: newBaseService(client, baseURL), + } +} + +// NewClearingOrder creates a new off-exchange bilateral clearing settlement. +func (s *ClearingService) NewClearingOrder(ctx context.Context, req *clearing.CreateNewClearingOrderJSONBody) (*clearing.ClearingOrder, error) { + var res clearing.ClearingOrder + if err := s.post(ctx, "/v1/clearing/new", req, &res); err != nil { + return nil, err + } + return &res, nil +} + +// GetClearingOrder returns the current status and details of a clearing order. +func (s *ClearingService) GetClearingOrder(ctx context.Context, req *clearing.GetClearingOrderJSONBody) (*clearing.ClearingOrder, error) { + var res clearing.ClearingOrder + if err := s.post(ctx, "/v1/clearing/status", req, &res); err != nil { + return nil, err + } + return &res, nil +} + +// CancelClearingOrder cancels an unconfirmed clearing order. +func (s *ClearingService) CancelClearingOrder(ctx context.Context, req *clearing.CancelClearingOrderJSONBody) (*ClearingOperationResponse, error) { + var res ClearingOperationResponse + if err := s.post(ctx, "/v1/clearing/cancel", req, &res); err != nil { + return nil, err + } + return &res, nil +} + +// ConfirmClearingOrder confirms acceptance of a broker or counterparty clearing order. +func (s *ClearingService) ConfirmClearingOrder(ctx context.Context, req *clearing.ConfirmClearingOrderJSONBody) (*ClearingOperationResponse, error) { + var res ClearingOperationResponse + if err := s.post(ctx, "/v1/clearing/confirm", req, &res); err != nil { + return nil, err + } + return &res, nil +} diff --git a/packages/sdk-go/services/clearing_test.go b/packages/sdk-go/services/clearing_test.go new file mode 100644 index 00000000..44fbb3fb --- /dev/null +++ b/packages/sdk-go/services/clearing_test.go @@ -0,0 +1,83 @@ +package services_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gemini/gemini-go/generated/clearing" + "github.com/gemini/gemini-go/services" + "github.com/gemini/gemini-go/transport" +) + +func TestClearingService_Methods(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/v1/clearing/new": + _ = json.NewEncoder(w).Encode(map[string]any{ + "clearing_id": "clr-999", + "status": "AwaitingConfirm", + }) + case "/v1/clearing/status": + _ = json.NewEncoder(w).Encode(map[string]any{ + "clearing_id": "clr-999", + "status": "Confirmed", + }) + case "/v1/clearing/cancel": + _ = json.NewEncoder(w).Encode(map[string]any{ + "result": "ok", + "details": "clr-999 order canceled", + }) + case "/v1/clearing/confirm": + _ = json.NewEncoder(w).Encode(map[string]any{ + "result": "confirmed", + }) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + trans := transport.NewClient(transport.WithHTTPClient(server.Client())) + svc := services.NewClearingService(trans, server.URL) + ctx := context.Background() + + cpID := "cp-123" + res, err := svc.NewClearingOrder(ctx, &clearing.CreateNewClearingOrderJSONBody{ + CounterpartyId: &cpID, + Symbol: "btcusd", + Amount: "1.5", + Price: "50000.00", + Side: clearing.CreateNewClearingOrderJSONBodySideBuy, + }) + if err != nil || res == nil || res.ClearingId == nil || *res.ClearingId != "clr-999" { + t.Fatalf("NewClearingOrder failed: %v", err) + } + + // GetClearingOrder + status, err := svc.GetClearingOrder(ctx, &clearing.GetClearingOrderJSONBody{ClearingId: "clr-999"}) + if err != nil || status == nil || status.Status == nil || *status.Status != "Confirmed" { + t.Fatalf("GetClearingOrder failed: %v", err) + } + + // CancelClearingOrder + cancelRes, err := svc.CancelClearingOrder(ctx, &clearing.CancelClearingOrderJSONBody{ClearingId: "clr-999"}) + if err != nil || cancelRes.Result != "ok" { + t.Fatalf("CancelClearingOrder failed: %v", err) + } + + // ConfirmClearingOrder + confirmRes, err := svc.ConfirmClearingOrder(ctx, &clearing.ConfirmClearingOrderJSONBody{ + ClearingId: "clr-999", + Symbol: "btcusd", + Amount: "1.5", + Price: "50000.00", + Side: clearing.ConfirmClearingOrderJSONBodySideBuy, + }) + if err != nil || confirmRes.Result != "confirmed" { + t.Fatalf("ConfirmClearingOrder failed: %v", err) + } +} diff --git a/packages/sdk-go/services/heartbeat.go b/packages/sdk-go/services/heartbeat.go new file mode 100644 index 00000000..86966f6a --- /dev/null +++ b/packages/sdk-go/services/heartbeat.go @@ -0,0 +1,126 @@ +package services + +import ( + "context" + "sync" + "sync/atomic" + "time" + + "github.com/gemini/gemini-go/generated/account" + "github.com/gemini/gemini-go/transport" +) + +// HeartbeatService manages Gemini session heartbeats (dead-man's switch). +type HeartbeatService struct { + baseService +} + +// NewHeartbeatService creates a new HeartbeatService. +func NewHeartbeatService(client *transport.Client, baseURL string) *HeartbeatService { + return &HeartbeatService{ + baseService: newBaseService(client, baseURL), + } +} + +// Send submits a single heartbeat pulse to prevent order cancellation. +func (s *HeartbeatService) Send(ctx context.Context) error { + var res struct { + Result string `json:"result,omitempty"` + } + return s.post(ctx, "/v1/heartbeat", &account.Heartbeat{}, &res) +} + +// HeartbeatSession coordinates background heartbeat intervals and error broadcasting. +type HeartbeatSession struct { + service *HeartbeatService + interval time.Duration + cancel context.CancelFunc + errChan chan error + lastBeat atomic.Int64 // Unix milli + alive atomic.Bool + stopOnce sync.Once +} + +// Start launches an autonomous background heartbeat loop sending pulses every interval. +func (s *HeartbeatService) Start(parentCtx context.Context, interval time.Duration) *HeartbeatSession { + if parentCtx == nil { + parentCtx = context.Background() + } + if interval <= 0 { + interval = 5 * time.Second + } + + ctx, cancel := context.WithCancel(parentCtx) + session := &HeartbeatSession{ + service: s, + interval: interval, + cancel: cancel, + errChan: make(chan error, 16), + } + session.alive.Store(true) + + go session.run(ctx) + return session +} + +func (s *HeartbeatSession) run(ctx context.Context) { + ticker := time.NewTicker(s.interval) + defer ticker.Stop() + defer s.alive.Store(false) + defer close(s.errChan) + + // Send initial beat immediately + if err := s.service.Send(ctx); err != nil { + s.notifyErr(err) + } else { + s.lastBeat.Store(time.Now().UnixMilli()) + } + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := s.service.Send(ctx); err != nil { + s.notifyErr(err) + } else { + s.lastBeat.Store(time.Now().UnixMilli()) + } + } + } +} + +func (s *HeartbeatSession) notifyErr(err error) { + select { + case s.errChan <- err: + default: + // Do not block if error channel is full + } +} + +// Stop gracefully shuts down the background heartbeat session. +func (s *HeartbeatSession) Stop() { + s.stopOnce.Do(func() { + s.cancel() + s.alive.Store(false) + }) +} + +// IsAlive returns true if the heartbeat session is actively ticking. +func (s *HeartbeatSession) IsAlive() bool { + return s.alive.Load() +} + +// LastBeat returns the time of the latest successful heartbeat. +func (s *HeartbeatSession) LastBeat() time.Time { + milli := s.lastBeat.Load() + if milli == 0 { + return time.Time{} + } + return time.UnixMilli(milli) +} + +// Errors returns a channel notifying callers of any heartbeat transmission failures. +func (s *HeartbeatSession) Errors() <-chan error { + return s.errChan +} diff --git a/packages/sdk-go/services/heartbeat_test.go b/packages/sdk-go/services/heartbeat_test.go new file mode 100644 index 00000000..c5099e3b --- /dev/null +++ b/packages/sdk-go/services/heartbeat_test.go @@ -0,0 +1,91 @@ +package services_test + +import ( + "context" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/gemini/gemini-go/services" + "github.com/gemini/gemini-go/transport" +) + +func TestHeartbeatSession_LifecycleAndErrors(t *testing.T) { + var heartbeatCount int32 + var failMode atomic.Bool + beats := make(chan struct{}, 16) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/heartbeat" { + http.NotFound(w, r) + return + } + atomic.AddInt32(&heartbeatCount, 1) + beats <- struct{}{} + + if failMode.Load() { + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte(`{"result":"error","reason":"Maintenance"}`)) + return + } + + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"result":"ok"}`)) + })) + defer server.Close() + + transportClient := transport.NewClient( + transport.WithHTTPClient(server.Client()), + ) + + service := services.NewHeartbeatService(transportClient, server.URL) + + // 1. Start heartbeat ticking every 5ms. The handler signal makes this + // deterministic without sleeping for an assumed number of intervals. + session := service.Start(context.Background(), 5*time.Millisecond) + t.Cleanup(session.Stop) + + if !session.IsAlive() { + t.Fatal("expected session to be alive") + } + + for i := 0; i < 3; i++ { + select { + case <-beats: + case <-time.After(time.Second): + t.Fatalf("timed out waiting for heartbeat pulse %d", i+1) + } + } + + if session.LastBeat().IsZero() { + t.Fatal("expected non-zero LastBeat timestamp") + } + + // 2. Trigger error failure mode + failMode.Store(true) + + select { + case err := <-session.Errors(): + if err == nil { + t.Fatal("expected error, got nil") + } + case <-time.After(500 * time.Millisecond): + t.Fatal("timed out waiting for heartbeat error broadcast") + } + + // 3. Stop session + session.Stop() + if session.IsAlive() { + t.Fatal("expected session to be dead after Stop()") + } + + for range session.Errors() { + // Drain until the session has fully stopped. + } + countAfterStop := atomic.LoadInt32(&heartbeatCount) + if countAfterStop < 3 { + t.Fatalf("expected at least three heartbeats before stopping, got %d", countAfterStop) + } +} diff --git a/packages/sdk-go/services/margin.go b/packages/sdk-go/services/margin.go new file mode 100644 index 00000000..6ae1d4f1 --- /dev/null +++ b/packages/sdk-go/services/margin.go @@ -0,0 +1,53 @@ +package services + +import ( + "context" + + "github.com/gemini/gemini-go/generated/margin" + "github.com/gemini/gemini-go/transport" +) + +// MarginService provides access to margin trading and account margin information. +type MarginService struct { + baseService +} + +// NewMarginService creates a new MarginService. +func NewMarginService(client *transport.Client, baseURL string) *MarginService { + return &MarginService{ + baseService: newBaseService(client, baseURL), + } +} + +// GetAccountSummary returns margin account summary and purchasing power. +func (s *MarginService) GetAccountSummary(ctx context.Context, req *margin.GetMarginAccountJSONBody) (*margin.MarginAccountSummary, error) { + if req == nil { + req = &margin.GetMarginAccountJSONBody{} + } + var res margin.MarginAccountSummary + if err := s.post(ctx, "/v1/margin/account", req, &res); err != nil { + return nil, err + } + return &res, nil +} + +// GetInterestRates returns active margin interest rates across supported currencies. +func (s *MarginService) GetInterestRates(ctx context.Context, req *margin.GetMarginRatesJSONBody) (*margin.MarginRatesResponse, error) { + if req == nil { + req = &margin.GetMarginRatesJSONBody{} + } + var res margin.MarginRatesResponse + if err := s.post(ctx, "/v1/margin/rates", req, &res); err != nil { + return nil, err + } + return &res, nil +} + +// PreviewOrder previews margin requirements and impact for a prospective order. +func (s *MarginService) PreviewOrder(ctx context.Context, req *margin.PreviewMarginOrderJSONBody) (*margin.MarginOrderPreview, error) { + var res margin.MarginOrderPreview + if err := s.post(ctx, "/v1/margin/order/preview", req, &res); err != nil { + return nil, err + } + return &res, nil +} diff --git a/packages/sdk-go/services/margin_test.go b/packages/sdk-go/services/margin_test.go new file mode 100644 index 00000000..ad8d5121 --- /dev/null +++ b/packages/sdk-go/services/margin_test.go @@ -0,0 +1,64 @@ +package services_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gemini/gemini-go" + "github.com/gemini/gemini-go/generated/margin" + "github.com/gemini/gemini-go/services" + "github.com/gemini/gemini-go/transport" +) + +func TestMarginService_Methods(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/v1/margin/account": + _ = json.NewEncoder(w).Encode(map[string]any{ + "account": "primary", + }) + case "/v1/margin/rates": + _ = json.NewEncoder(w).Encode(map[string]any{ + "rates": []map[string]any{ + {"currency": "USD", "rate": 0.05}, + }, + }) + case "/v1/margin/order/preview": + _ = json.NewEncoder(w).Encode(map[string]any{ + "result": "ok", + }) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + trans := transport.NewClient(transport.WithHTTPClient(server.Client())) + svc := services.NewMarginService(trans, server.URL) + ctx := context.Background() + + summary, err := svc.GetAccountSummary(ctx, nil) + if err != nil || summary == nil { + t.Fatalf("GetAccountSummary failed: %v", err) + } + + rates, err := svc.GetInterestRates(ctx, nil) + if err != nil || rates == nil { + t.Fatalf("GetInterestRates failed: %v", err) + } + + preview, err := svc.PreviewOrder(ctx, &margin.PreviewMarginOrderJSONBody{ + Symbol: "btcusd", + Amount: gemini.Ptr("1.0"), + Price: gemini.Ptr("50000.00"), + Side: margin.Buy, + Type: margin.Limit, + }) + if err != nil || preview == nil { + t.Fatalf("PreviewOrder failed: %v", err) + } +} diff --git a/packages/sdk-go/services/marketdata.go b/packages/sdk-go/services/marketdata.go new file mode 100644 index 00000000..7cdcc08e --- /dev/null +++ b/packages/sdk-go/services/marketdata.go @@ -0,0 +1,100 @@ +package services + +import ( + "context" + "fmt" + "net/url" + + "github.com/gemini/gemini-go/generated/marketdata" + "github.com/gemini/gemini-go/transport" +) + +// MarketDataService provides access to public market data endpoints. +type MarketDataService struct { + baseService +} + +func NewMarketDataService(client *transport.Client, baseURL string) *MarketDataService { + return &MarketDataService{ + baseService: newBaseService(client, baseURL), + } +} + +// GetSymbols returns a list of all available trading pairs on Gemini. +func (s *MarketDataService) GetSymbols(ctx context.Context) ([]string, error) { + var symbols []string + if err := s.get(ctx, "/v1/symbols", &symbols); err != nil { + return nil, err + } + return symbols, nil +} + +// GetSymbolDetails returns detailed specifications (min tick size, min order size) for a symbol. +func (s *MarketDataService) GetSymbolDetails(ctx context.Context, symbol string) (*marketdata.SymbolDetails, error) { + var res marketdata.SymbolDetails + if err := s.get(ctx, "/v1/symbols/details/"+url.PathEscape(symbol), &res); err != nil { + return nil, err + } + return &res, nil +} + +// GetTicker returns the latest price, bid/ask, and 24h volume for a symbol. +func (s *MarketDataService) GetTicker(ctx context.Context, symbol string) (*marketdata.Ticker, error) { + var ticker marketdata.Ticker + if err := s.get(ctx, "/v1/pubticker/"+url.PathEscape(symbol), &ticker); err != nil { + return nil, err + } + return &ticker, nil +} + +// GetTickerV2 returns the extended V2 ticker information. +func (s *MarketDataService) GetTickerV2(ctx context.Context, symbol string) (*marketdata.TickerInfo, error) { + var ticker marketdata.TickerInfo + if err := s.get(ctx, "/v2/ticker/"+url.PathEscape(symbol), &ticker); err != nil { + return nil, err + } + return &ticker, nil +} + +// GetOrderBook returns the current L2 order book snapshot for a symbol. +// Pass limitBids or limitAsks < 0 to omit the parameter and use default depth. +func (s *MarketDataService) GetOrderBook(ctx context.Context, symbol string, limitBids, limitAsks int) (*marketdata.OrderBook, error) { + path := "/v1/book/" + url.PathEscape(symbol) + q := url.Values{} + if limitBids >= 0 { + q.Set("limit_bids", fmt.Sprintf("%d", limitBids)) + } + if limitAsks >= 0 { + q.Set("limit_asks", fmt.Sprintf("%d", limitAsks)) + } + if len(q) > 0 { + path = fmt.Sprintf("%s?%s", path, q.Encode()) + } + var book marketdata.OrderBook + if err := s.get(ctx, path, &book); err != nil { + return nil, err + } + return &book, nil +} + +// GetTrades returns recent historical trades for a symbol. +func (s *MarketDataService) GetTrades(ctx context.Context, symbol string, limitTrades int) ([]marketdata.Trade, error) { + path := "/v1/trades/" + url.PathEscape(symbol) + if limitTrades > 0 { + path = fmt.Sprintf("%s?limit_trades=%d", path, limitTrades) + } + var trades []marketdata.Trade + if err := s.get(ctx, path, &trades); err != nil { + return nil, err + } + return trades, nil +} + +// GetCandles returns OHLCV candlestick data for a symbol and timeframe. +func (s *MarketDataService) GetCandles(ctx context.Context, symbol string, timeframe string) (marketdata.CandleResponse, error) { + var candles marketdata.CandleResponse + if err := s.get(ctx, "/v2/candles/"+url.PathEscape(symbol)+"/"+url.PathEscape(timeframe), &candles); err != nil { + return nil, err + } + return candles, nil +} diff --git a/packages/sdk-go/services/marketdata_test.go b/packages/sdk-go/services/marketdata_test.go new file mode 100644 index 00000000..6ee35480 --- /dev/null +++ b/packages/sdk-go/services/marketdata_test.go @@ -0,0 +1,113 @@ +package services_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gemini/gemini-go/services" + "github.com/gemini/gemini-go/transport" +) + +func TestMarketDataService_Methods(t *testing.T) { + var capturedPath string + var capturedQuery string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + capturedQuery = r.URL.RawQuery + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/v1/symbols": + _ = json.NewEncoder(w).Encode([]string{"btcusd", "ethusd"}) + case "/v1/symbols/details/btcusd": + _ = json.NewEncoder(w).Encode(map[string]any{ + "symbol": "BTCUSD", + "base_currency": "BTC", + "quote_currency": "USD", + "tick_size": 1e-8, + "quote_increment": 0.01, + "min_order_size": "0.00001", + "status": "open", + "wrap_enabled": false, + }) + case "/v1/pubticker/btcusd": + _ = json.NewEncoder(w).Encode(map[string]any{ + "bid": "50000.00", + "ask": "50001.00", + "last": "50000.50", + }) + case "/v2/ticker/btcusd": + _ = json.NewEncoder(w).Encode(map[string]any{ + "symbol": "BTCUSD", + "open": "49000.00", + "high": "51000.00", + "low": "48500.00", + "close": "50000.50", + }) + case "/v1/book/btcusd": + _ = json.NewEncoder(w).Encode(map[string]any{ + "bids": []map[string]any{{"price": "50000.00", "amount": "1.0", "timestamp": "1600000000"}}, + "asks": []map[string]any{{"price": "50001.00", "amount": "2.0", "timestamp": "1600000000"}}, + }) + case "/v1/trades/btcusd": + _ = json.NewEncoder(w).Encode([]map[string]any{ + {"timestamp": 1600000000, "tid": 12345, "price": "50000.00", "amount": "0.5", "exchange": "gemini", "type": "buy"}, + }) + case "/v2/candles/btcusd/1hr": + _ = json.NewEncoder(w).Encode([][]float64{ + {1600000000000, 50000, 50100, 49900, 50050, 100}, + }) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + trans := transport.NewClient(transport.WithHTTPClient(server.Client())) + svc := services.NewMarketDataService(trans, server.URL) + ctx := context.Background() + + // 1. GetSymbols + symbols, err := svc.GetSymbols(ctx) + if err != nil || len(symbols) != 2 || capturedPath != "/v1/symbols" { + t.Fatalf("GetSymbols failed: %v", err) + } + + // 2. GetSymbolDetails + details, err := svc.GetSymbolDetails(ctx, "btcusd") + if err != nil || details.Symbol == nil || *details.Symbol != "BTCUSD" || capturedPath != "/v1/symbols/details/btcusd" { + t.Fatalf("GetSymbolDetails failed: %v", err) + } + + // 3. GetTicker + ticker, err := svc.GetTicker(ctx, "btcusd") + if err != nil || ticker.Bid == nil || *ticker.Bid != "50000.00" || capturedPath != "/v1/pubticker/btcusd" { + t.Fatalf("GetTicker failed: %v", err) + } + + // 4. GetTickerV2 + tickerV2, err := svc.GetTickerV2(ctx, "btcusd") + if err != nil || tickerV2.Symbol == nil || *tickerV2.Symbol != "BTCUSD" || capturedPath != "/v2/ticker/btcusd" { + t.Fatalf("GetTickerV2 failed: %v", err) + } + + // 5. GetOrderBook + book, err := svc.GetOrderBook(ctx, "btcusd", 10, 10) + if err != nil || book.Bids == nil || len(*book.Bids) != 1 || capturedPath != "/v1/book/btcusd" || capturedQuery != "limit_asks=10&limit_bids=10" { + t.Fatalf("GetOrderBook failed: %v", err) + } + + // 6. GetTrades + trades, err := svc.GetTrades(ctx, "btcusd", 5) + if err != nil || len(trades) != 1 || capturedPath != "/v1/trades/btcusd" || capturedQuery != "limit_trades=5" { + t.Fatalf("GetTrades failed: %v", err) + } + + // 7. GetCandles + candles, err := svc.GetCandles(ctx, "btcusd", "1hr") + if err != nil || len(candles) != 1 || capturedPath != "/v2/candles/btcusd/1hr" { + t.Fatalf("GetCandles failed: %v", err) + } +} diff --git a/packages/sdk-go/services/perpetuals.go b/packages/sdk-go/services/perpetuals.go new file mode 100644 index 00000000..e320905b --- /dev/null +++ b/packages/sdk-go/services/perpetuals.go @@ -0,0 +1,73 @@ +package services + +import ( + "context" + "net/url" + "strconv" + + "github.com/gemini/gemini-go/generated/perpetuals" + "github.com/gemini/gemini-go/transport" +) + +// PerpetualsService provides access to derivatives and perpetual contract trading. +type PerpetualsService struct { + baseService + public baseService +} + +// NewPerpetualsService creates a new PerpetualsService. +func NewPerpetualsService(client *transport.Client, baseURL string) *PerpetualsService { + return NewPerpetualsServiceWithPublicClient(client, client, baseURL) +} + +// NewPerpetualsServiceWithPublicClient creates a derivatives service with +// separate transports for public funding metadata and authenticated funding +// payment history. +func NewPerpetualsServiceWithPublicClient(privateClient, publicClient *transport.Client, baseURL string) *PerpetualsService { + return &PerpetualsService{ + baseService: newBaseService(privateClient, baseURL), + public: newBaseService(publicClient, baseURL), + } +} + +// GetFundingAmount returns current and estimated funding amount for a perpetual contract. +func (s *PerpetualsService) GetFundingAmount(ctx context.Context, symbol string) (*perpetuals.FundingAmountResponse, error) { + var res perpetuals.FundingAmountResponse + if err := s.public.get(ctx, "/v1/fundingamount/"+url.PathEscape(symbol), &res); err != nil { + return nil, err + } + return &res, nil +} + +// GetNextFundingTimestamp returns the timestamp for the next funding period for a perpetual contract. +func (s *PerpetualsService) GetNextFundingTimestamp(ctx context.Context, symbol string) (int64, error) { + var res int64 + if err := s.public.get(ctx, "/v1/nextfundingtimestamp/"+url.PathEscape(symbol), &res); err != nil { + return 0, err + } + return res, nil +} + +// GetFundingPayments returns funding payment records, optionally filtered by +// millisecond timestamps. It uses the documented perpetuals endpoint. +func (s *PerpetualsService) GetFundingPayments(ctx context.Context, req *perpetuals.ListFundingPaymentsJSONBody, since, to *int64) ([]perpetuals.FundingPayment, error) { + if req == nil { + req = &perpetuals.ListFundingPaymentsJSONBody{} + } + path := "/v1/perpetuals/fundingPayment" + query := url.Values{} + if since != nil { + query.Set("since", strconv.FormatInt(*since, 10)) + } + if to != nil { + query.Set("to", strconv.FormatInt(*to, 10)) + } + if encoded := query.Encode(); encoded != "" { + path += "?" + encoded + } + var res []perpetuals.FundingPayment + if err := s.post(ctx, path, req, &res); err != nil { + return nil, err + } + return res, nil +} diff --git a/packages/sdk-go/services/perpetuals_test.go b/packages/sdk-go/services/perpetuals_test.go new file mode 100644 index 00000000..9a2fbad4 --- /dev/null +++ b/packages/sdk-go/services/perpetuals_test.go @@ -0,0 +1,103 @@ +package services_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gemini/gemini-go/auth" + "github.com/gemini/gemini-go/services" + "github.com/gemini/gemini-go/transport" +) + +func TestPerpetualsService_FundingEndpoints(t *testing.T) { + mux := http.NewServeMux() + + // 1. Funding Amount + mux.HandleFunc("/v1/fundingamount/btcgusdperp", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "symbol": "btcgusdperp", + "amount": 0.000125, + "estimatedFundingAmount": 0.000150, + "fundingDateTime": "2026-08-20T14:00:00.000Z", + "fundingTimestampMilliSecs": 1750000000000, + "nextFundingTimestamp": 1750003600000, + }) + }) + + // 2. Next Funding Timestamp + mux.HandleFunc("/v1/nextfundingtimestamp/btcgusdperp", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(int64(1750000000000)) + }) + + // 3. Historical Funding Rates + mux.HandleFunc("/v1/perpetuals/fundingPayment", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]map[string]any{ + {"eventType": "Hourly Funding Transfer", "hourlyFundingTransfer": map[string]any{ + "eventType": "Hourly Funding Transfer", "timestamp": 1750000000000, + "assetCode": "GUSD", "action": "Debit", + "quantity": map[string]any{"currency": "GUSD", "value": "0.000120"}, + }}, + {"eventType": "Hourly Funding Transfer", "hourlyFundingTransfer": map[string]any{ + "eventType": "Hourly Funding Transfer", "timestamp": 1750003600000, + "assetCode": "GUSD", "action": "Debit", + "quantity": map[string]any{"currency": "GUSD", "value": "0.000125"}, + }}, + }) + }) + + server := httptest.NewServer(mux) + defer server.Close() + + tr := transport.NewClient( + transport.WithHTTPClient(server.Client()), + transport.WithAuth(auth.NewHMAC("key", "secret")), + ) + svc := services.NewPerpetualsService(tr, server.URL) + + ctx := context.Background() + + // 1. Test GetFundingAmount + fundingAmt, err := svc.GetFundingAmount(ctx, "btcgusdperp") + if err != nil { + t.Fatalf("GetFundingAmount failed: %v", err) + } + if fundingAmt == nil || *fundingAmt.Symbol != "btcgusdperp" || fundingAmt.Amount == nil || fundingAmt.Amount.String() != "0.000125" { + t.Fatalf("unexpected fundingAmount response: %+v", fundingAmt) + } + + // 2. Test GetNextFundingTimestamp + nextTs, err := svc.GetNextFundingTimestamp(ctx, "btcgusdperp") + if err != nil { + t.Fatalf("GetNextFundingTimestamp failed: %v", err) + } + if nextTs != int64(1750000000000) { + t.Fatalf("unexpected nextFundingTimestamp response: %d", nextTs) + } + + // 3. Test GetFundingPayments + rates, err := svc.GetFundingPayments(ctx, nil, nil, nil) + if err != nil { + t.Fatalf("GetFundingPayments failed: %v", err) + } + if len(rates) != 2 { + t.Fatalf("expected 2 funding rate records, got %d", len(rates)) + } +} diff --git a/packages/sdk-go/services/predictions.go b/packages/sdk-go/services/predictions.go new file mode 100644 index 00000000..a2d97da4 --- /dev/null +++ b/packages/sdk-go/services/predictions.go @@ -0,0 +1,158 @@ +package services + +import ( + "context" + "fmt" + "net/url" + "strconv" + + "github.com/gemini/gemini-go/generated/predictions" + "github.com/gemini/gemini-go/transport" +) + +// PredictionsService provides access to prediction markets endpoints. +type PredictionsService struct { + baseService + public baseService +} + +// NewPredictionsService creates a prediction markets service. Terms are +// enforced by Gemini; callers must explicitly accept them when the backend +// returns ErrAcceptTermsRequired. +func NewPredictionsService(client *transport.Client, baseURL string) *PredictionsService { + return NewPredictionsServiceWithPublicClient(client, client, baseURL) +} + +// NewPredictionsServiceWithPublicClient creates a prediction markets service +// with separate transports for public and authenticated operations. The +// high-level client uses this split so public market discovery remains +// available without credentials while private account and trading methods +// fail closed when authentication is absent. +func NewPredictionsServiceWithPublicClient(privateClient, publicClient *transport.Client, baseURL string) *PredictionsService { + return &PredictionsService{ + baseService: newBaseService(privateClient, baseURL), + public: newBaseService(publicClient, baseURL), + } +} + +// AcceptTerms explicitly accepts the prediction markets terms of service. +func (s *PredictionsService) AcceptTerms(ctx context.Context) (*predictions.AcceptPredictionMarketsTermsResponse, error) { + var res predictions.AcceptPredictionMarketsTermsResponse + if err := s.post(ctx, "/v1/prediction-markets/terms/accept", struct{}{}, &res); err != nil { + return nil, err + } + if !res.Success { + return nil, fmt.Errorf("gemini predictions: accepting terms was not successful: %w", transport.ErrAcceptTermsRequired) + } + return &res, nil +} + +// AcceptPredictionMarketsTerms is an alias for AcceptTerms matching the OpenAPI operation ID. +func (s *PredictionsService) AcceptPredictionMarketsTerms(ctx context.Context) (*predictions.AcceptPredictionMarketsTermsResponse, error) { + return s.AcceptTerms(ctx) +} + +// GetTerms fetches the terms of service agreement content. +func (s *PredictionsService) GetTerms(ctx context.Context) (*predictions.PredictionMarketsTerms, error) { + var res predictions.PredictionMarketsTerms + if err := s.public.get(ctx, "/v1/prediction-markets/terms", &res); err != nil { + return nil, err + } + return &res, nil +} + +// GetTermsStatus checks whether the account group has accepted the latest prediction terms. +func (s *PredictionsService) GetTermsStatus(ctx context.Context) (*predictions.PredictionMarketsTermsStatus, error) { + var res predictions.PredictionMarketsTermsStatus + if err := s.get(ctx, "/v1/prediction-markets/terms/status", &res); err != nil { + return nil, err + } + return &res, nil +} + +// GetCategories returns active prediction market categories. +func (s *PredictionsService) GetCategories(ctx context.Context) ([]string, error) { + var res []string + if err := s.public.get(ctx, "/v1/prediction-markets/categories", &res); err != nil { + return nil, err + } + return res, nil +} + +// GetEvents lists prediction market events with optional spec-defined filters. +func (s *PredictionsService) GetEvents(ctx context.Context, params *predictions.ListEventsParams) (*predictions.EventsResponse, error) { + path := "/v1/prediction-markets/events" + if q := listEventsQuery(params); len(q) > 0 { + path = fmt.Sprintf("%s?%s", path, q.Encode()) + } + + var res predictions.EventsResponse + if err := s.public.get(ctx, path, &res); err != nil { + return nil, err + } + return &res, nil +} + +// GetEvent returns details and contract listings for a specific event ticker. +func (s *PredictionsService) GetEvent(ctx context.Context, eventTicker string) (*predictions.Event, error) { + var res predictions.Event + if err := s.public.get(ctx, "/v1/prediction-markets/events/"+url.PathEscape(eventTicker), &res); err != nil { + return nil, err + } + return &res, nil +} + +func listEventsQuery(params *predictions.ListEventsParams) url.Values { + q := url.Values{} + if params == nil { + return q + } + for _, status := range valueOrEmpty(params.Status) { + q.Add("status", string(status)) + } + for _, category := range valueOrEmpty(params.Category) { + q.Add("category", category) + } + for _, sport := range valueOrEmpty(params.Sport) { + q.Add("sport", string(sport)) + } + for _, marketType := range valueOrEmpty(params.SportsMarketType) { + q.Add("sports_market_type", string(marketType)) + } + for _, subject := range valueOrEmpty(params.SportsMarketSubject) { + q.Add("sports_market_subject", string(subject)) + } + for _, scope := range valueOrEmpty(params.SportsMarketScope) { + q.Add("sports_market_scope", string(scope)) + } + for _, metric := range valueOrEmpty(params.SportsMarketMetric) { + q.Add("sports_market_metric", string(metric)) + } + if params.Search != nil { + q.Set("search", *params.Search) + } + if params.Limit != nil { + q.Set("limit", strconv.Itoa(*params.Limit)) + } + if params.Offset != nil { + q.Set("offset", strconv.Itoa(*params.Offset)) + } + return q +} + +func valueOrEmpty[T any](value *[]T) []T { + if value == nil { + return nil + } + return *value +} + +// NewOrder places a typed order on a prediction market. If Gemini returns +// ErrAcceptTermsRequired, callers must explicitly call AcceptTerms and retry. +func (s *PredictionsService) NewOrder(ctx context.Context, orderReq *predictions.OrderRequest) (*predictions.OrderResponse, error) { + var res predictions.OrderResponse + if err := s.post(ctx, "/v1/prediction-markets/order", orderReq, &res); err != nil { + return nil, err + } + return &res, nil +} diff --git a/packages/sdk-go/services/predictions_combos.go b/packages/sdk-go/services/predictions_combos.go new file mode 100644 index 00000000..42689954 --- /dev/null +++ b/packages/sdk-go/services/predictions_combos.go @@ -0,0 +1,54 @@ +package services + +import ( + "context" + "net/url" + "strconv" + + "github.com/gemini/gemini-go/generated/predictions" +) + +// ListCombos returns paginated combo contracts matching params. +func (s *PredictionsService) ListCombos(ctx context.Context, params *predictions.ListCombosParams) (*predictions.ListCombosResponse, error) { + var res predictions.ListCombosResponse + if err := s.public.get(ctx, withQuery("/v1/prediction-markets/combos", listCombosQuery(params)), &res); err != nil { + return nil, err + } + return &res, nil +} + +// CreateCombo creates or retrieves the canonical combo for a set of legs. +func (s *PredictionsService) CreateCombo(ctx context.Context, req *predictions.CreateComboJSONRequestBody) (*predictions.CreateComboResponse, error) { + var res predictions.CreateComboResponse + if err := s.post(ctx, "/v1/prediction-markets/combos", req, &res); err != nil { + return nil, err + } + return &res, nil +} + +// GetCombo returns a combo by instrument symbol. +func (s *PredictionsService) GetCombo(ctx context.Context, instrumentSymbol string) (*predictions.ComboResponse, error) { + var res predictions.ComboResponse + if err := s.public.get(ctx, "/v1/prediction-markets/combos/"+url.PathEscape(instrumentSymbol), &res); err != nil { + return nil, err + } + return &res, nil +} + +func listCombosQuery(params *predictions.ListCombosParams) url.Values { + q := url.Values{} + if params == nil { + return q + } + if params.Status != nil { + q.Set("status", *params.Status) + } + if params.ContractId != nil { + q.Set("contractId", strconv.FormatInt(*params.ContractId, 10)) + } + if params.InstrumentRegistered != nil { + q.Set("instrumentRegistered", strconv.FormatBool(*params.InstrumentRegistered)) + } + addLimitOffset(q, params.Limit, params.Offset) + return q +} diff --git a/packages/sdk-go/services/predictions_expansion_test.go b/packages/sdk-go/services/predictions_expansion_test.go new file mode 100644 index 00000000..b0a81b22 --- /dev/null +++ b/packages/sdk-go/services/predictions_expansion_test.go @@ -0,0 +1,239 @@ +package services_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "testing" + "time" + + "github.com/gemini/gemini-go/generated/predictions" + "github.com/gemini/gemini-go/services" + "github.com/gemini/gemini-go/transport" + "github.com/gemini/gemini-go/types" +) + +func TestPredictionsServicePriorityEndpoints(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/v1/prediction-markets/order/batch": + var body predictions.PlaceOrderBatchRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || len(body.Orders) != 1 { + t.Errorf("unexpected batch order body: err=%v body=%+v", err, body) + } + _, _ = w.Write([]byte(`{"results":[{"order":{"orderId":123,"status":"open","symbol":"GEMI-FEDJAN26-DN25","side":"buy","outcome":"yes","orderType":"limit","quantity":"1","filledQuantity":"0","remainingQuantity":"1","price":"0.50","timeInForce":"good-til-cancel","createdAt":"2026-08-20T00:00:00Z","updatedAt":"2026-08-20T00:00:00Z"}}]}`)) + case "/v1/prediction-markets/order/cancel": + var body predictions.CancelOrderJSONBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.OrderId != 123 { + t.Errorf("unexpected cancel body: err=%v body=%+v", err, body) + } + _, _ = w.Write([]byte(`{"result":"ok","message":"cancelled"}`)) + case "/v1/prediction-markets/order/batch/cancel": + var body predictions.CancelOrderBatchRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || len(body.OrderIds) != 1 { + t.Errorf("unexpected batch cancel body: err=%v body=%+v", err, body) + } + _, _ = w.Write([]byte(`{"results":[{"orderId":123,"result":"ok"}]}`)) + case "/v1/prediction-markets/orders/active": + if got := r.URL.Query().Get("unused"); got != "" { + t.Errorf("unexpected active-order query: %q", got) + } + var body predictions.GetActiveOrdersJSONBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Limit == nil || *body.Limit != 2 || body.Symbol == nil || *body.Symbol != "GEMI-FEDJAN26-DN25" { + t.Errorf("unexpected active-order body: err=%v body=%+v", err, body) + } + _, _ = w.Write([]byte(`{"orders":[{"orderId":123,"status":"open","symbol":"GEMI-FEDJAN26-DN25","side":"buy","outcome":"yes","orderType":"limit","quantity":"1","filledQuantity":"0","remainingQuantity":"1","price":"0.50","timeInForce":"good-til-cancel","createdAt":"2026-08-20T00:00:00Z","updatedAt":"2026-08-20T00:00:00Z"}],"pagination":{"count":1,"limit":2,"offset":0}}`)) + case "/v1/prediction-markets/positions": + if r.Method != http.MethodPost || r.URL.Query().Get("eventTicker") != "FEDJAN26" || r.URL.Query().Get("sort") != "-positionValue" { + t.Errorf("unexpected positions request: %s %s", r.Method, r.URL.String()) + } + _, _ = w.Write([]byte(`{"positions":[{"symbol":"GEMI-FEDJAN26-DN25","totalQuantity":"2"}],"total":1}`)) + case "/v1/prediction-markets/positions/settled": + if r.URL.Query().Get("withCashOuts") != "true" || r.URL.Query().Get("category") != "Sports" { + t.Errorf("unexpected settled-position query: %s", r.URL.RawQuery) + } + _, _ = w.Write([]byte(`{"positions":[{"instrumentSymbol":"GEMI-FEDJAN26-DN25","payout":"2"}],"total":1,"cashOuts":[]}`)) + case "/v1/prediction-markets/metrics/volume": + _, _ = w.Write([]byte(`{"eventTicker":"FEDJAN26","contracts":[{"symbol":"GEMI-FEDJAN26-DN25","totalQty":"10"}]}`)) + case "/v1/prediction-markets/combos": + if r.Method == http.MethodGet { + if r.URL.Query().Get("status") != "Active" || r.URL.Query().Get("contractId") != "42" || r.URL.Query().Get("instrumentRegistered") != "true" { + t.Errorf("unexpected combo query: %s", r.URL.RawQuery) + } + _, _ = w.Write([]byte(`{"combos":[{"contract":{"contractTicker":"GEMI-CMB-1"},"legs":[]}],"pagination":{"limit":1,"offset":0,"total":1}}`)) + return + } + var body predictions.CreateComboRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || len(body.Legs) != 2 { + t.Errorf("unexpected create combo body: err=%v body=%+v", err, body) + } + _, _ = w.Write([]byte(`{"alreadyExisted":false,"combo":{"id":42,"canonicalLegKey":"1:Yes|2:No","legCount":2,"legs":[]}}`)) + case "/v1/prediction-markets/combos/GEMI-CMB-1": + _, _ = w.Write([]byte(`{"contract":{"contractTicker":"GEMI-CMB-1"},"legs":[]}`)) + case "/v1/prediction-markets/maker-rebate/rates": + if r.URL.Query().Get("category") != "Sports" { + t.Errorf("unexpected rebate rate query: %s", r.URL.RawQuery) + } + _, _ = w.Write([]byte(`{"rate_rules":[]}`)) + case "/v1/prediction-markets/maker-rebate/payouts": + if r.URL.Query().Get("limit") != "10" || r.URL.Query().Get("offset") != "20" { + t.Errorf("unexpected rebate payout query: %s", r.URL.RawQuery) + } + _, _ = w.Write([]byte(`{"payouts":[]}`)) + case "/v1/prediction-markets/maker-rebate/summary/total": + if r.URL.Query().Get("dateFrom") != "2026-08-01" || r.URL.Query().Get("dateTo") != "2026-08-20" { + t.Errorf("unexpected rebate summary query: %s", r.URL.RawQuery) + } + _, _ = w.Write([]byte(`{"total_earned_usd":"1.00","payout_count":1}`)) + case "/v1/prediction-markets/liquidity-rewards/config": + _, _ = w.Write([]byte(`{"enabled":true,"max_spread_cents":10}`)) + case "/v1/prediction-markets/liquidity-rewards/events": + if r.URL.Query().Get("sort") != "ends_soonest" { + t.Errorf("unexpected liquidity event query: %s", r.URL.RawQuery) + } + _, _ = w.Write([]byte(`{"events":[],"pagination":{"limit":1,"offset":0,"total":0},"last_score_date":null}`)) + case "/v1/prediction-markets/liquidity-rewards/summary/daily": + if r.URL.Query().Get("dateFrom") != "2026-08-01" || r.URL.Query().Get("dateTo") != "2026-08-20" { + t.Errorf("unexpected daily reward query: %s", r.URL.RawQuery) + } + _, _ = w.Write([]byte(`{"daily_summaries":[]}`)) + case "/v1/prediction-markets/liquidity-rewards/summary/total": + _, _ = w.Write([]byte(`{"total_earned_usd":"2.00","payout_count":2}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + service := services.NewPredictionsService(transport.NewClient(transport.WithHTTPClient(server.Client())), server.URL) + ctx := context.Background() + + batch := &predictions.PlaceOrderBatchJSONRequestBody{Orders: []predictions.OrderRequest{{Symbol: "GEMI-FEDJAN26-DN25", Side: predictions.OrderSideBuy, Outcome: predictions.Yes, OrderType: predictions.OrderTypeLimit, Quantity: "1", Price: "0.50"}}} + batchRes, err := service.PlaceOrderBatch(ctx, batch) + if err != nil || len(batchRes.Results) != 1 { + t.Fatalf("PlaceOrderBatch failed: %v %+v", err, batchRes) + } + + cancelRes, err := service.CancelOrder(ctx, &predictions.CancelOrderJSONRequestBody{OrderId: 123}) + if err != nil || cancelRes.Result != "ok" { + t.Fatalf("CancelOrder failed: %v %+v", err, cancelRes) + } + var cancelID predictions.CancelOrderBatchRequest_OrderIds_Item + if err := cancelID.FromCancelOrderBatchRequestOrderIds0(123); err != nil { + t.Fatal(err) + } + if _, err := service.CancelOrderBatch(ctx, &predictions.CancelOrderBatchJSONRequestBody{OrderIds: []predictions.CancelOrderBatchRequest_OrderIds_Item{cancelID}}); err != nil { + t.Fatalf("CancelOrderBatch failed: %v", err) + } + + limit, offset := 2, 0 + active, err := service.GetActiveOrders(ctx, &predictions.GetActiveOrdersJSONRequestBody{Limit: &limit, Offset: &offset, Symbol: stringPointer("GEMI-FEDJAN26-DN25")}) + if err != nil || active.Orders == nil || len(*active.Orders) != 1 { + t.Fatalf("GetActiveOrders failed: %v %+v", err, active) + } + + eventTicker := "FEDJAN26" + sort := predictions.GetPositionsParamsSort("-positionValue") + positions, err := service.GetPositions(ctx, &predictions.GetPositionsParams{EventTicker: &eventTicker, Sort: &sort}) + if err != nil || positions.Positions == nil || len(*positions.Positions) != 1 { + t.Fatalf("GetPositions failed: %v %+v", err, positions) + } + withCashOuts := true + category := "Sports" + settled, err := service.GetSettledPositions(ctx, &predictions.GetSettledPositionsParams{WithCashOuts: &withCashOuts, Category: &category}) + if err != nil || settled.Positions == nil || len(*settled.Positions) != 1 { + t.Fatalf("GetSettledPositions failed: %v %+v", err, settled) + } + + volume, err := service.GetVolumeMetrics(ctx, &predictions.GetVolumeMetricsJSONRequestBody{EventTicker: "FEDJAN26"}) + if err != nil || volume.EventTicker == nil || *volume.EventTicker != "FEDJAN26" { + t.Fatalf("GetVolumeMetrics failed: %v %+v", err, volume) + } + contractID := int64(42) + registered := true + combos, err := service.ListCombos(ctx, &predictions.ListCombosParams{Status: stringPointer("Active"), ContractId: &contractID, InstrumentRegistered: ®istered}) + if err != nil || len(combos.Combos) != 1 { + t.Fatalf("ListCombos failed: %v %+v", err, combos) + } + created, err := service.CreateCombo(ctx, &predictions.CreateComboJSONRequestBody{Legs: []predictions.CreateComboLeg{{ContractId: "1", RequiredOutcome: predictions.CreateComboLegRequiredOutcomeYes}, {ContractId: "2", RequiredOutcome: predictions.CreateComboLegRequiredOutcomeNo}}}) + if err != nil || created.Combo.Id != 42 { + t.Fatalf("CreateCombo failed: %v %+v", err, created) + } + combo, err := service.GetCombo(ctx, "GEMI-CMB-1") + if err != nil || combo.Contract.ContractTicker == nil || *combo.Contract.ContractTicker != "GEMI-CMB-1" { + t.Fatalf("GetCombo failed: %v %+v", err, combo) + } + + rebateCategory := "Sports" + if _, err := service.GetMakerRebateRates(ctx, &predictions.GetMakerRebateRatesParams{Category: &rebateCategory}); err != nil { + t.Fatalf("GetMakerRebateRates failed: %v", err) + } + pageLimit, pageOffset := 10, 20 + if _, err := service.ListMakerRebatePayouts(ctx, &predictions.ListMakerRebatePayoutsParams{Limit: &pageLimit, Offset: &pageOffset}); err != nil { + t.Fatalf("ListMakerRebatePayouts failed: %v", err) + } + from := mustDate("2026-08-01") + to := mustDate("2026-08-20") + if _, err := service.GetMakerRebateLifetimeSummary(ctx, &predictions.GetMakerRebateLifetimeSummaryParams{DateFrom: &from, DateTo: &to}); err != nil { + t.Fatalf("GetMakerRebateLifetimeSummary failed: %v", err) + } + if config, err := service.GetLiquidityRewardsConfig(ctx); err != nil || !config.Enabled { + t.Fatalf("GetLiquidityRewardsConfig failed: %v %+v", err, config) + } + sortOrder := predictions.ListLiquidityRewardsEventsParamsSort("ends_soonest") + if _, err := service.ListLiquidityRewardsEvents(ctx, &predictions.ListLiquidityRewardsEventsParams{Sort: &sortOrder}); err != nil { + t.Fatalf("ListLiquidityRewardsEvents failed: %v", err) + } + dailyFrom, dailyTo := mustDate("2026-08-01"), mustDate("2026-08-20") + if _, err := service.GetLiquidityRewardsDailySummary(ctx, &predictions.GetLiquidityRewardsDailySummaryParams{DateFrom: dailyFrom, DateTo: dailyTo}); err != nil { + t.Fatalf("GetLiquidityRewardsDailySummary failed: %v", err) + } + if _, err := service.GetLiquidityRewardsLifetimeSummary(ctx, nil); err != nil { + t.Fatalf("GetLiquidityRewardsLifetimeSummary failed: %v", err) + } +} + +func TestPredictionsServiceIteratePositions(t *testing.T) { + var offsets []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + offsets = append(offsets, r.URL.Query().Get("offset")) + w.Header().Set("Content-Type", "application/json") + switch r.URL.Query().Get("offset") { + case "0": + _, _ = w.Write([]byte(`{"positions":[{"symbol":"A"},{"symbol":"B"}],"total":3}`)) + case "2": + _, _ = w.Write([]byte(`{"positions":[{"symbol":"C"}],"total":3}`)) + default: + t.Errorf("unexpected iterator offset: %s", r.URL.Query().Get("offset")) + } + })) + defer server.Close() + + service := services.NewPredictionsService(transport.NewClient(transport.WithHTTPClient(server.Client())), server.URL) + limit := 2 + var symbols []string + for position, err := range service.IteratePositions(context.Background(), &predictions.GetPositionsParams{Limit: &limit}) { + if err != nil { + t.Fatalf("iterator failed: %v", err) + } + if position.Symbol != nil { + symbols = append(symbols, *position.Symbol) + } + } + if !reflect.DeepEqual(symbols, []string{"A", "B", "C"}) || !reflect.DeepEqual(offsets, []string{"0", "2"}) { + t.Fatalf("unexpected iterator output symbols=%v offsets=%v", symbols, offsets) + } +} + +func stringPointer(value string) *string { return &value } + +func mustDate(value string) types.Date { + parsed, err := time.Parse("2006-01-02", value) + if err != nil { + panic(err) + } + return types.NewDate(parsed) +} diff --git a/packages/sdk-go/services/predictions_less_used_test.go b/packages/sdk-go/services/predictions_less_used_test.go new file mode 100644 index 00000000..58582ecd --- /dev/null +++ b/packages/sdk-go/services/predictions_less_used_test.go @@ -0,0 +1,136 @@ +package services_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "testing" + "time" + + "github.com/gemini/gemini-go/generated/predictions" + "github.com/gemini/gemini-go/services" + "github.com/gemini/gemini-go/transport" +) + +func TestPredictionsServiceLessUsedMethods(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/v1/prediction-markets/events/EVENT-1/strike": + _, _ = w.Write([]byte(`{"value":"0.55","type":"above"}`)) + case "/v1/prediction-markets/events/newly-listed", "/v1/prediction-markets/events/recently-settled", "/v1/prediction-markets/events/upcoming": + query := r.URL.Query() + if !reflect.DeepEqual(query["category"], []string{"sports", "crypto"}) || query.Get("limit") != "2" || query.Get("offset") != "1" { + t.Errorf("unexpected event-list query for %s: %s", r.URL.Path, r.URL.RawQuery) + } + _, _ = w.Write([]byte(`{"data":[],"pagination":{"limit":2,"offset":1,"total":0}}`)) + case "/v1/prediction-markets/volume/2026-08-20": + _, _ = w.Write([]byte(`[{"categoryPath":["Sports","Soccer"],"volume":"12.345"}]`)) + case "/v1/prediction-markets/volume/2026-08-20/hourly": + _, _ = w.Write([]byte(`[{"categoryPath":["Sports"],"periodStart":"2026-08-20T14:00:00Z","volume":"1.25"}]`)) + case "/v1/prediction-markets/orders/history": + if r.Method != http.MethodPost { + t.Errorf("order history method = %s, want POST", r.Method) + } + var body predictions.GetOrderHistoryJSONBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode order history body: %v", err) + } else if body.Limit == nil || *body.Limit != 2 || body.Offset == nil || *body.Offset != 1 || body.Symbol == nil || *body.Symbol != "EVENT-1" || body.Status == nil || *body.Status != predictions.GetOrderHistoryJSONBodyStatusFilled { + t.Errorf("unexpected order history body: %+v", body) + } + _, _ = w.Write([]byte(`{"orders":[{"orderId":7,"symbol":"EVENT-1","status":"filled"}],"pagination":{"count":1,"limit":2,"offset":1}}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + service := services.NewPredictionsService( + transport.NewClient(transport.WithHTTPClient(server.Client())), + server.URL, + ) + ctx := context.Background() + + strike, err := service.GetEventStrike(ctx, "EVENT-1") + if err != nil || strike.Value == nil || *strike.Value != "0.55" || strike.Type == nil || *strike.Type != predictions.Above { + t.Fatalf("GetEventStrike = %+v, %v", strike, err) + } + + categories := []string{"sports", "crypto"} + limit, offset := 2, 1 + params := func() (*predictions.ListNewlyListedEventsParams, *predictions.ListRecentlySettledEventsParams, *predictions.ListUpcomingEventsParams) { + return &predictions.ListNewlyListedEventsParams{Category: &categories, Limit: &limit, Offset: &offset}, + &predictions.ListRecentlySettledEventsParams{Category: &categories, Limit: &limit, Offset: &offset}, + &predictions.ListUpcomingEventsParams{Category: &categories, Limit: &limit, Offset: &offset} + } + newly, recently, upcoming := params() + if _, err := service.ListNewlyListedEvents(ctx, newly); err != nil { + t.Fatalf("ListNewlyListedEvents failed: %v", err) + } + if _, err := service.ListRecentlySettledEvents(ctx, recently); err != nil { + t.Fatalf("ListRecentlySettledEvents failed: %v", err) + } + if _, err := service.ListUpcomingEvents(ctx, upcoming); err != nil { + t.Fatalf("ListUpcomingEvents failed: %v", err) + } + + daily, err := service.GetDailyVolume(ctx, "2026-08-20") + if err != nil || len(daily) != 1 || !reflect.DeepEqual(daily[0].CategoryPath, []string{"Sports", "Soccer"}) || daily[0].Volume != "12.345" { + t.Fatalf("GetDailyVolume = %+v, %v", daily, err) + } + hourly, err := service.GetHourlyVolume(ctx, "2026-08-20") + if err != nil || len(hourly) != 1 || hourly[0].Volume != "1.25" || !hourly[0].PeriodStart.Equal(time.Date(2026, 8, 20, 14, 0, 0, 0, time.UTC)) { + t.Fatalf("GetHourlyVolume = %+v, %v", hourly, err) + } + + symbol := "EVENT-1" + status := predictions.GetOrderHistoryJSONBodyStatusFilled + history, err := service.GetOrderHistory(ctx, &predictions.GetOrderHistoryJSONRequestBody{Limit: &limit, Offset: &offset, Symbol: &symbol, Status: &status}) + if err != nil || history.Orders == nil || len(*history.Orders) != 1 || (*history.Orders)[0].OrderId == nil || *(*history.Orders)[0].OrderId != 7 { + t.Fatalf("GetOrderHistory = %+v, %v", history, err) + } +} + +func TestPredictionsServiceOrderHistoryIterator(t *testing.T) { + var offsets []int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body predictions.GetOrderHistoryJSONBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode order history body: %v", err) + return + } + if body.Offset == nil || body.Limit == nil { + t.Errorf("iterator omitted pagination fields: %+v", body) + return + } + offsets = append(offsets, *body.Offset) + if *body.Offset == 0 { + _, _ = w.Write([]byte(`{"orders":[{"orderId":1,"symbol":"EVENT-1"},{"orderId":2,"symbol":"EVENT-2"}]}`)) + return + } + if *body.Offset == 2 { + _, _ = w.Write([]byte(`{"orders":[{"orderId":3,"symbol":"EVENT-3"}]}`)) + return + } + t.Errorf("unexpected iterator offset %d", *body.Offset) + _, _ = w.Write([]byte(`{"orders":[]}`)) + })) + defer server.Close() + + service := services.NewPredictionsService(transport.NewClient(transport.WithHTTPClient(server.Client())), server.URL) + limit := 2 + var orderIDs []int64 + for order, err := range service.IterateOrderHistory(context.Background(), &predictions.GetOrderHistoryJSONRequestBody{Limit: &limit}) { + if err != nil { + t.Fatalf("IterateOrderHistory failed: %v", err) + } + if order.OrderId != nil { + orderIDs = append(orderIDs, *order.OrderId) + } + } + if !reflect.DeepEqual(offsets, []int{0, 2}) || !reflect.DeepEqual(orderIDs, []int64{1, 2, 3}) { + t.Fatalf("unexpected order-history iteration offsets=%v orderIDs=%v", offsets, orderIDs) + } +} diff --git a/packages/sdk-go/services/predictions_markets.go b/packages/sdk-go/services/predictions_markets.go new file mode 100644 index 00000000..e0f4ac5d --- /dev/null +++ b/packages/sdk-go/services/predictions_markets.go @@ -0,0 +1,130 @@ +package services + +import ( + "context" + "net/url" + "strconv" + + "github.com/gemini/gemini-go/generated/predictions" +) + +// GetEventStrike returns the strike metadata for an event. +func (s *PredictionsService) GetEventStrike(ctx context.Context, eventTicker string) (*predictions.Strike, error) { + var res predictions.Strike + if err := s.public.get(ctx, "/v1/prediction-markets/events/"+url.PathEscape(eventTicker)+"/strike", &res); err != nil { + return nil, err + } + return &res, nil +} + +// ListNewlyListedEvents returns events created in the last 24 hours. +func (s *PredictionsService) ListNewlyListedEvents(ctx context.Context, params *predictions.ListNewlyListedEventsParams) (*predictions.EventsResponse, error) { + var res predictions.EventsResponse + if err := s.public.get(ctx, withQuery("/v1/prediction-markets/events/newly-listed", listNewlyListedEventsQuery(params)), &res); err != nil { + return nil, err + } + return &res, nil +} + +// ListRecentlySettledEvents returns events settled in the last 24 hours. +func (s *PredictionsService) ListRecentlySettledEvents(ctx context.Context, params *predictions.ListRecentlySettledEventsParams) (*predictions.EventsResponse, error) { + var res predictions.EventsResponse + if err := s.public.get(ctx, withQuery("/v1/prediction-markets/events/recently-settled", listRecentlySettledEventsQuery(params)), &res); err != nil { + return nil, err + } + return &res, nil +} + +// ListUpcomingEvents returns approved events that have not started yet. +func (s *PredictionsService) ListUpcomingEvents(ctx context.Context, params *predictions.ListUpcomingEventsParams) (*predictions.EventsResponse, error) { + var res predictions.EventsResponse + if err := s.public.get(ctx, withQuery("/v1/prediction-markets/events/upcoming", listUpcomingEventsQuery(params)), &res); err != nil { + return nil, err + } + return &res, nil +} + +// GetDailyVolume returns prediction-market volume by category for a completed +// UTC day in YYYY-MM-DD format. +func (s *PredictionsService) GetDailyVolume(ctx context.Context, date string) ([]predictions.PredictionMarketVolumeCategory, error) { + var res []predictions.PredictionMarketVolumeCategory + if err := s.public.get(ctx, "/v1/prediction-markets/volume/"+url.PathEscape(date), &res); err != nil { + return nil, err + } + return res, nil +} + +// GetHourlyVolume returns prediction-market volume by category and UTC hour +// for a completed UTC day in YYYY-MM-DD format. +func (s *PredictionsService) GetHourlyVolume(ctx context.Context, date string) ([]predictions.PredictionMarketHourlyVolumeCategory, error) { + var res []predictions.PredictionMarketHourlyVolumeCategory + if err := s.public.get(ctx, "/v1/prediction-markets/volume/"+url.PathEscape(date)+"/hourly", &res); err != nil { + return nil, err + } + return res, nil +} + +func listNewlyListedEventsQuery(params *predictions.ListNewlyListedEventsParams) url.Values { + if params == nil { + return url.Values{} + } + return listSportsEventsQuery(params.Category, params.Sport, params.SportsMarketType, params.SportsMarketSubject, params.SportsMarketScope, params.SportsMarketMetric, params.Limit, params.Offset) +} + +func listRecentlySettledEventsQuery(params *predictions.ListRecentlySettledEventsParams) url.Values { + if params == nil { + return url.Values{} + } + return listSportsEventsQuery(params.Category, params.Sport, params.SportsMarketType, params.SportsMarketSubject, params.SportsMarketScope, params.SportsMarketMetric, params.Limit, params.Offset) +} + +func listUpcomingEventsQuery(params *predictions.ListUpcomingEventsParams) url.Values { + if params == nil { + return url.Values{} + } + return listSportsEventsQuery(params.Category, params.Sport, params.SportsMarketType, params.SportsMarketSubject, params.SportsMarketScope, params.SportsMarketMetric, params.Limit, params.Offset) +} + +func listSportsEventsQuery(categories *[]string, sports *predictions.SportFilter, marketTypes *predictions.SportsMarketTypeFilter, subjects *predictions.SportsMarketSubjectFilter, scopes *predictions.SportsMarketScopeFilter, metrics *predictions.SportsMarketMetricFilter, limit, offset *int) url.Values { + q := url.Values{} + addSportsMarketFilters(q, categories, sports, marketTypes, subjects, scopes, metrics) + addLimitOffset(q, limit, offset) + return q +} + +func addSportsMarketFilters(q url.Values, categories *[]string, sports *predictions.SportFilter, marketTypes *predictions.SportsMarketTypeFilter, subjects *predictions.SportsMarketSubjectFilter, scopes *predictions.SportsMarketScopeFilter, metrics *predictions.SportsMarketMetricFilter) { + for _, category := range valueOrEmpty(categories) { + q.Add("category", category) + } + for _, sport := range valueOrEmpty(sports) { + q.Add("sport", string(sport)) + } + for _, marketType := range valueOrEmpty(marketTypes) { + q.Add("sports_market_type", string(marketType)) + } + for _, subject := range valueOrEmpty(subjects) { + q.Add("sports_market_subject", string(subject)) + } + for _, scope := range valueOrEmpty(scopes) { + q.Add("sports_market_scope", string(scope)) + } + for _, metric := range valueOrEmpty(metrics) { + q.Add("sports_market_metric", string(metric)) + } +} + +func addLimitOffset(q url.Values, limit, offset *int) { + if limit != nil { + q.Set("limit", strconv.Itoa(*limit)) + } + if offset != nil { + q.Set("offset", strconv.Itoa(*offset)) + } +} + +func withQuery(path string, q url.Values) string { + if len(q) == 0 { + return path + } + return path + "?" + q.Encode() +} diff --git a/packages/sdk-go/services/predictions_pagination.go b/packages/sdk-go/services/predictions_pagination.go new file mode 100644 index 00000000..c00bb3bf --- /dev/null +++ b/packages/sdk-go/services/predictions_pagination.go @@ -0,0 +1,341 @@ +package services + +import ( + "context" + "errors" + "fmt" + "iter" + + "github.com/gemini/gemini-go/generated/predictions" + "github.com/gemini/gemini-go/transport" +) + +// ErrTimeBoundedOrderHistoryPagination indicates that an iterator was asked +// to use offset pagination with a date-bounded order-history request. The API +// ignores offset when either bound is present, so advancing offset would +// repeatedly return the same page. +var ErrTimeBoundedOrderHistoryPagination = errors.New("gemini predictions: time-bounded order history cannot be offset-paginated") + +// ErrInvalidPagination indicates that a caller supplied an invalid offset or +// limit to a lazy prediction-market iterator. +var ErrInvalidPagination = errors.New("gemini predictions: invalid pagination") + +// IteratePositions lazily fetches all current positions using the endpoint's +// offset/limit pagination. The input filters are preserved for every page. +func (s *PredictionsService) IteratePositions(ctx context.Context, params *predictions.GetPositionsParams) iter.Seq2[predictions.Position, error] { + base := predictions.GetPositionsParams{} + if params != nil { + base = *params + } + if err := validatePredictionPagination(base.Limit, base.Offset); err != nil { + return paginationError[predictions.Position](err) + } + pageSize := predictionPageSize(base.Limit, 1000) + initialOffset := predictionPageOffset(base.Offset) + return newPredictionPaginator(ctx, initialOffset, pageSize, func(ctx context.Context, offset, limit int) ([]predictions.Position, bool, error) { + page := base + page.Limit = &limit + page.Offset = &offset + res, err := s.GetPositions(ctx, &page) + if err != nil { + return nil, false, err + } + items := dereferencePositions(res.Positions) + return items, hasMoreByTotal(offset, len(items), res.Total, limit), nil + }) +} + +// IterateSettledPositions lazily fetches all settled positions using the +// endpoint's offset/limit pagination. +func (s *PredictionsService) IterateSettledPositions(ctx context.Context, params *predictions.GetSettledPositionsParams) iter.Seq2[predictions.SettledPosition, error] { + base := predictions.GetSettledPositionsParams{} + if params != nil { + base = *params + } + if err := validatePredictionPagination(base.Limit, base.Offset); err != nil { + return paginationError[predictions.SettledPosition](err) + } + pageSize := predictionPageSize(base.Limit, 1000) + initialOffset := predictionPageOffset(base.Offset) + return newPredictionPaginator(ctx, initialOffset, pageSize, func(ctx context.Context, offset, limit int) ([]predictions.SettledPosition, bool, error) { + page := base + page.Limit = &limit + page.Offset = &offset + res, err := s.GetSettledPositions(ctx, &page) + if err != nil { + return nil, false, err + } + items := dereferenceSettledPositions(res.Positions) + return items, hasMoreByTotal(offset, len(items), res.Total, limit), nil + }) +} + +// IterateActiveOrders lazily fetches active prediction-market orders. +func (s *PredictionsService) IterateActiveOrders(ctx context.Context, params *predictions.GetActiveOrdersJSONRequestBody) iter.Seq2[predictions.OrderResponse, error] { + base := predictions.GetActiveOrdersJSONRequestBody{} + if params != nil { + base = *params + } + if err := validatePredictionPagination(base.Limit, base.Offset); err != nil { + return paginationError[predictions.OrderResponse](err) + } + pageSize := predictionPageSize(base.Limit, 100) + initialOffset := predictionPageOffset(base.Offset) + return newPredictionPaginator(ctx, initialOffset, pageSize, func(ctx context.Context, offset, limit int) ([]predictions.OrderResponse, bool, error) { + page := base + page.Limit = &limit + page.Offset = &offset + res, err := s.GetActiveOrders(ctx, &page) + if err != nil { + return nil, false, err + } + items := dereferenceOrders(res.Orders) + return items, len(items) == limit, nil + }) +} + +// IterateOrderHistory lazily fetches historical prediction-market orders. The +// API ignores offset when From or To is set, so time-bounded requests return +// ErrTimeBoundedOrderHistoryPagination instead of risking an infinite loop. +func (s *PredictionsService) IterateOrderHistory(ctx context.Context, params *predictions.GetOrderHistoryJSONRequestBody) iter.Seq2[predictions.OrderResponse, error] { + base := predictions.GetOrderHistoryJSONRequestBody{} + if params != nil { + base = *params + } + if err := validatePredictionPagination(base.Limit, base.Offset); err != nil { + return paginationError[predictions.OrderResponse](err) + } + if base.From != nil || base.To != nil { + return func(yield func(predictions.OrderResponse, error) bool) { + var zero predictions.OrderResponse + yield(zero, ErrTimeBoundedOrderHistoryPagination) + } + } + pageSize := predictionPageSize(base.Limit, 1000) + initialOffset := predictionPageOffset(base.Offset) + return newPredictionPaginator(ctx, initialOffset, pageSize, func(ctx context.Context, offset, limit int) ([]predictions.OrderResponse, bool, error) { + page := base + page.Limit = &limit + page.Offset = &offset + res, err := s.GetOrderHistory(ctx, &page) + if err != nil { + return nil, false, err + } + items := dereferenceOrders(res.Orders) + return items, len(items) == limit, nil + }) +} + +// IterateCombos lazily fetches combo contracts. +func (s *PredictionsService) IterateCombos(ctx context.Context, params *predictions.ListCombosParams) iter.Seq2[predictions.ComboResponse, error] { + base := predictions.ListCombosParams{} + if params != nil { + base = *params + } + if err := validatePredictionPagination(base.Limit, base.Offset); err != nil { + return paginationError[predictions.ComboResponse](err) + } + pageSize := predictionPageSize(base.Limit, 500) + initialOffset := predictionPageOffset(base.Offset) + return newPredictionPaginator(ctx, initialOffset, pageSize, func(ctx context.Context, offset, limit int) ([]predictions.ComboResponse, bool, error) { + page := base + page.Limit = &limit + page.Offset = &offset + res, err := s.ListCombos(ctx, &page) + if err != nil { + return nil, false, err + } + return res.Combos, hasMoreByTotal(offset, len(res.Combos), res.Pagination.Total, limit), nil + }) +} + +// IterateEvents lazily fetches prediction-market events. +func (s *PredictionsService) IterateEvents(ctx context.Context, params *predictions.ListEventsParams) iter.Seq2[predictions.Event, error] { + base := predictions.ListEventsParams{} + if params != nil { + base = *params + } + return s.iterateEvents(ctx, func(page *predictions.ListEventsParams) (*predictions.EventsResponse, error) { + return s.GetEvents(ctx, page) + }, base, 500) +} + +// IterateNewlyListedEvents lazily fetches newly listed events. +func (s *PredictionsService) IterateNewlyListedEvents(ctx context.Context, params *predictions.ListNewlyListedEventsParams) iter.Seq2[predictions.Event, error] { + base := predictions.ListNewlyListedEventsParams{} + if params != nil { + base = *params + } + return s.iterateEvents(ctx, func(page *predictions.ListEventsParams) (*predictions.EventsResponse, error) { + converted := newlyListedParamsFromEventsParams(page) + return s.ListNewlyListedEvents(ctx, converted) + }, eventsParamsAsListEvents(base.Category, base.Sport, base.SportsMarketType, base.SportsMarketSubject, base.SportsMarketScope, base.SportsMarketMetric, base.Limit, base.Offset), 500) +} + +// IterateRecentlySettledEvents lazily fetches recently settled events. +func (s *PredictionsService) IterateRecentlySettledEvents(ctx context.Context, params *predictions.ListRecentlySettledEventsParams) iter.Seq2[predictions.Event, error] { + base := predictions.ListRecentlySettledEventsParams{} + if params != nil { + base = *params + } + return s.iterateEvents(ctx, func(page *predictions.ListEventsParams) (*predictions.EventsResponse, error) { + converted := recentlySettledParamsFromEventsParams(page) + return s.ListRecentlySettledEvents(ctx, converted) + }, eventsParamsAsListEvents(base.Category, base.Sport, base.SportsMarketType, base.SportsMarketSubject, base.SportsMarketScope, base.SportsMarketMetric, base.Limit, base.Offset), 500) +} + +// IterateUpcomingEvents lazily fetches upcoming events. +func (s *PredictionsService) IterateUpcomingEvents(ctx context.Context, params *predictions.ListUpcomingEventsParams) iter.Seq2[predictions.Event, error] { + base := predictions.ListUpcomingEventsParams{} + if params != nil { + base = *params + } + return s.iterateEvents(ctx, func(page *predictions.ListEventsParams) (*predictions.EventsResponse, error) { + converted := upcomingParamsFromEventsParams(page) + return s.ListUpcomingEvents(ctx, converted) + }, eventsParamsAsListEvents(base.Category, base.Sport, base.SportsMarketType, base.SportsMarketSubject, base.SportsMarketScope, base.SportsMarketMetric, base.Limit, base.Offset), 500) +} + +// IterateLiquidityRewardsEvents lazily fetches liquidity-rewards events. +func (s *PredictionsService) IterateLiquidityRewardsEvents(ctx context.Context, params *predictions.ListLiquidityRewardsEventsParams) iter.Seq2[predictions.LiquidityRewardEvent, error] { + base := predictions.ListLiquidityRewardsEventsParams{} + if params != nil { + base = *params + } + if err := validatePredictionPagination(base.Limit, base.Offset); err != nil { + return paginationError[predictions.LiquidityRewardEvent](err) + } + pageSize := predictionPageSize(base.Limit, 100) + initialOffset := predictionPageOffset(base.Offset) + return newPredictionPaginator(ctx, initialOffset, pageSize, func(ctx context.Context, offset, limit int) ([]predictions.LiquidityRewardEvent, bool, error) { + page := base + page.Limit = &limit + page.Offset = &offset + res, err := s.ListLiquidityRewardsEvents(ctx, &page) + if err != nil { + return nil, false, err + } + return res.Events, hasMoreByTotal(offset, len(res.Events), res.Pagination.Total, limit), nil + }) +} + +func (s *PredictionsService) iterateEvents(ctx context.Context, fetch func(*predictions.ListEventsParams) (*predictions.EventsResponse, error), base predictions.ListEventsParams, defaultPageSize int) iter.Seq2[predictions.Event, error] { + if err := validatePredictionPagination(base.Limit, base.Offset); err != nil { + return paginationError[predictions.Event](err) + } + pageSize := predictionPageSize(base.Limit, defaultPageSize) + initialOffset := predictionPageOffset(base.Offset) + return newPredictionPaginator(ctx, initialOffset, pageSize, func(_ context.Context, offset, limit int) ([]predictions.Event, bool, error) { + page := base + page.Limit = &limit + page.Offset = &offset + res, err := fetch(&page) + if err != nil { + return nil, false, err + } + items := dereferenceEvents(res.Data) + return items, hasMoreByPagination(offset, len(items), res.Pagination, limit), nil + }) +} + +func newPredictionPaginator[T any](ctx context.Context, initialOffset, pageSize int, fetch func(context.Context, int, int) ([]T, bool, error)) iter.Seq2[T, error] { + return transport.NewPaginator(ctx, initialOffset, pageSize, transport.PageFetcher[T](fetch)) +} + +func predictionPageSize(value *int, defaultSize int) int { + if value != nil && *value > 0 { + return *value + } + return defaultSize +} + +func predictionPageOffset(value *int) int { + if value != nil && *value > 0 { + return *value + } + return 0 +} + +func validatePredictionPagination(limit, offset *int) error { + if limit != nil && *limit <= 0 { + return fmt.Errorf("%w: limit must be positive", ErrInvalidPagination) + } + if offset != nil && *offset < 0 { + return fmt.Errorf("%w: offset must be non-negative", ErrInvalidPagination) + } + return nil +} + +func paginationError[T any](err error) iter.Seq2[T, error] { + return func(yield func(T, error) bool) { + var zero T + yield(zero, err) + } +} + +func hasMoreByTotal(offset, count int, total *int, limit int) bool { + if total != nil { + return offset+count < *total + } + return count == limit +} + +func hasMoreByPagination(offset, count int, pagination *predictions.Pagination, limit int) bool { + if pagination != nil && pagination.Total != nil { + return offset+count < *pagination.Total + } + return count == limit +} + +func dereferencePositions(value *[]predictions.Position) []predictions.Position { + if value == nil { + return nil + } + return *value +} + +func dereferenceSettledPositions(value *[]predictions.SettledPosition) []predictions.SettledPosition { + if value == nil { + return nil + } + return *value +} + +func dereferenceOrders(value *[]predictions.OrderResponse) []predictions.OrderResponse { + if value == nil { + return nil + } + return *value +} + +func dereferenceEvents(value *[]predictions.Event) []predictions.Event { + if value == nil { + return nil + } + return *value +} + +func eventsParamsAsListEvents(category *[]string, sport *predictions.SportFilter, marketType *predictions.SportsMarketTypeFilter, subject *predictions.SportsMarketSubjectFilter, scope *predictions.SportsMarketScopeFilter, metric *predictions.SportsMarketMetricFilter, limit, offset *int) predictions.ListEventsParams { + return predictions.ListEventsParams{ + Category: category, + Sport: sport, + SportsMarketType: marketType, + SportsMarketSubject: subject, + SportsMarketScope: scope, + SportsMarketMetric: metric, + Limit: limit, + Offset: offset, + } +} + +func newlyListedParamsFromEventsParams(params *predictions.ListEventsParams) *predictions.ListNewlyListedEventsParams { + return &predictions.ListNewlyListedEventsParams{Category: params.Category, Sport: params.Sport, SportsMarketType: params.SportsMarketType, SportsMarketSubject: params.SportsMarketSubject, SportsMarketScope: params.SportsMarketScope, SportsMarketMetric: params.SportsMarketMetric, Limit: params.Limit, Offset: params.Offset} +} + +func recentlySettledParamsFromEventsParams(params *predictions.ListEventsParams) *predictions.ListRecentlySettledEventsParams { + return &predictions.ListRecentlySettledEventsParams{Category: params.Category, Sport: params.Sport, SportsMarketType: params.SportsMarketType, SportsMarketSubject: params.SportsMarketSubject, SportsMarketScope: params.SportsMarketScope, SportsMarketMetric: params.SportsMarketMetric, Limit: params.Limit, Offset: params.Offset} +} + +func upcomingParamsFromEventsParams(params *predictions.ListEventsParams) *predictions.ListUpcomingEventsParams { + return &predictions.ListUpcomingEventsParams{Category: params.Category, Sport: params.Sport, SportsMarketType: params.SportsMarketType, SportsMarketSubject: params.SportsMarketSubject, SportsMarketScope: params.SportsMarketScope, SportsMarketMetric: params.SportsMarketMetric, Limit: params.Limit, Offset: params.Offset} +} diff --git a/packages/sdk-go/services/predictions_pagination_test.go b/packages/sdk-go/services/predictions_pagination_test.go new file mode 100644 index 00000000..db1cb1b8 --- /dev/null +++ b/packages/sdk-go/services/predictions_pagination_test.go @@ -0,0 +1,56 @@ +package services_test + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gemini/gemini-go/generated/predictions" + "github.com/gemini/gemini-go/services" + "github.com/gemini/gemini-go/transport" +) + +func TestIterateOrderHistoryRejectsTimeBoundedOffsetPagination(t *testing.T) { + var requests int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + _, _ = w.Write([]byte(`{"orders":[]}`)) + })) + defer server.Close() + + service := services.NewPredictionsService( + transport.NewClient(transport.WithHTTPClient(server.Client())), + server.URL, + ) + from := int64(1775001600000) + seen := 0 + for _, err := range service.IterateOrderHistory(context.Background(), &predictions.GetOrderHistoryJSONRequestBody{From: &from}) { + seen++ + if err != services.ErrTimeBoundedOrderHistoryPagination { + t.Fatalf("expected bounded-pagination error, got %v", err) + } + } + if seen != 1 { + t.Fatalf("expected one iterator error, got %d values", seen) + } + if requests != 0 { + t.Fatalf("expected no repeated HTTP requests, got %d", requests) + } +} + +func TestPredictionIteratorsRejectInvalidPagination(t *testing.T) { + service := services.NewPredictionsService(transport.NewClient(), "https://api.gemini.com") + invalidOffset := -1 + seen := 0 + for _, err := range service.IteratePositions(context.Background(), &predictions.GetPositionsParams{Offset: &invalidOffset}) { + seen++ + if !errors.Is(err, services.ErrInvalidPagination) { + t.Fatalf("expected invalid-pagination error, got %v", err) + } + } + if seen != 1 { + t.Fatalf("expected one iterator error, got %d values", seen) + } +} diff --git a/packages/sdk-go/services/predictions_positions.go b/packages/sdk-go/services/predictions_positions.go new file mode 100644 index 00000000..bc5d48ed --- /dev/null +++ b/packages/sdk-go/services/predictions_positions.go @@ -0,0 +1,88 @@ +package services + +import ( + "context" + "net/url" + "strconv" + + "github.com/gemini/gemini-go/generated/predictions" +) + +// GetPositions returns the authenticated account's current prediction-market +// positions. Parameters are sent as query parameters on the signed POST. +func (s *PredictionsService) GetPositions(ctx context.Context, params *predictions.GetPositionsParams) (*predictions.PositionsResponse, error) { + var res predictions.PositionsResponse + if err := s.post(ctx, withQuery("/v1/prediction-markets/positions", getPositionsQuery(params)), nil, &res); err != nil { + return nil, err + } + return &res, nil +} + +// GetSettledPositions returns the authenticated account's settled prediction- +// market positions. Parameters are sent as query parameters on the signed +// POST. +func (s *PredictionsService) GetSettledPositions(ctx context.Context, params *predictions.GetSettledPositionsParams) (*predictions.SettledPositionsResponse, error) { + var res predictions.SettledPositionsResponse + if err := s.post(ctx, withQuery("/v1/prediction-markets/positions/settled", getSettledPositionsQuery(params)), nil, &res); err != nil { + return nil, err + } + return &res, nil +} + +// GetVolumeMetrics returns per-contract volume metrics for an event. +func (s *PredictionsService) GetVolumeMetrics(ctx context.Context, req *predictions.GetVolumeMetricsJSONRequestBody) (*predictions.VolumeMetricsResponse, error) { + var res predictions.VolumeMetricsResponse + if err := s.post(ctx, "/v1/prediction-markets/metrics/volume", req, &res); err != nil { + return nil, err + } + return &res, nil +} + +func getPositionsQuery(params *predictions.GetPositionsParams) url.Values { + if params == nil { + return url.Values{} + } + q := positionsQuery(params.EventTicker, params.Limit, params.Offset, stringPointerValue(params.Sort)) + return q +} + +func getSettledPositionsQuery(params *predictions.GetSettledPositionsParams) url.Values { + if params == nil { + return url.Values{} + } + q := positionsQuery(params.EventTicker, params.Limit, params.Offset, stringPointerValue(params.Sort)) + if params.Search != nil { + q.Set("search", *params.Search) + } + if params.Category != nil { + q.Set("category", *params.Category) + } + if params.WithCashOuts != nil { + q.Set("withCashOuts", strconv.FormatBool(*params.WithCashOuts)) + } + return q +} + +func positionsQuery(eventTicker *string, limit, offset *int, sort string) url.Values { + q := url.Values{} + if eventTicker != nil { + q.Set("eventTicker", *eventTicker) + } + if limit != nil { + q.Set("limit", strconv.Itoa(*limit)) + } + if offset != nil { + q.Set("offset", strconv.Itoa(*offset)) + } + if sort != "" { + q.Set("sort", sort) + } + return q +} + +func stringPointerValue[T ~string](value *T) string { + if value == nil { + return "" + } + return string(*value) +} diff --git a/packages/sdk-go/services/predictions_rewards.go b/packages/sdk-go/services/predictions_rewards.go new file mode 100644 index 00000000..821300af --- /dev/null +++ b/packages/sdk-go/services/predictions_rewards.go @@ -0,0 +1,159 @@ +package services + +import ( + "context" + "net/url" + "strconv" + + "github.com/gemini/gemini-go/generated/predictions" + "github.com/gemini/gemini-go/types" +) + +// GetMakerRebateRates returns the currently effective maker-rebate rules. +func (s *PredictionsService) GetMakerRebateRates(ctx context.Context, params *predictions.GetMakerRebateRatesParams) (*predictions.MakerRebateRatesResponse, error) { + var res predictions.MakerRebateRatesResponse + if err := s.public.get(ctx, withQuery("/v1/prediction-markets/maker-rebate/rates", makerRebateRatesQuery(params)), &res); err != nil { + return nil, err + } + return &res, nil +} + +// ListMakerRebatePayouts returns paginated maker-rebate payouts. +func (s *PredictionsService) ListMakerRebatePayouts(ctx context.Context, params *predictions.ListMakerRebatePayoutsParams) (*predictions.MakerRebatePayoutsResponse, error) { + var res predictions.MakerRebatePayoutsResponse + if err := s.post(ctx, withQuery("/v1/prediction-markets/maker-rebate/payouts", makerRebatePayoutsQuery(params)), nil, &res); err != nil { + return nil, err + } + return &res, nil +} + +// GetMakerRebateLifetimeSummary returns lifetime maker-rebate totals, +// optionally restricted to an inclusive date range. +func (s *PredictionsService) GetMakerRebateLifetimeSummary(ctx context.Context, params *predictions.GetMakerRebateLifetimeSummaryParams) (*predictions.MakerRebateLifetimeSummary, error) { + var res predictions.MakerRebateLifetimeSummary + if err := s.get(ctx, withQuery("/v1/prediction-markets/maker-rebate/summary/total", makerRebateSummaryQuery(params)), &res); err != nil { + return nil, err + } + return &res, nil +} + +// GetLiquidityRewardsConfig returns the public liquidity-rewards program +// configuration. +func (s *PredictionsService) GetLiquidityRewardsConfig(ctx context.Context) (*predictions.LiquidityRewardsConfig, error) { + var res predictions.LiquidityRewardsConfig + if err := s.public.get(ctx, "/v1/prediction-markets/liquidity-rewards/config", &res); err != nil { + return nil, err + } + return &res, nil +} + +// ListLiquidityRewardsEvents returns paginated events participating in the +// liquidity-rewards program. +func (s *PredictionsService) ListLiquidityRewardsEvents(ctx context.Context, params *predictions.ListLiquidityRewardsEventsParams) (*predictions.LiquidityRewardsEventsResponse, error) { + var res predictions.LiquidityRewardsEventsResponse + if err := s.public.get(ctx, withQuery("/v1/prediction-markets/liquidity-rewards/events", liquidityRewardsEventsQuery(params)), &res); err != nil { + return nil, err + } + return &res, nil +} + +// GetLiquidityRewardsDailySummary returns account rewards for the required +// date window. +func (s *PredictionsService) GetLiquidityRewardsDailySummary(ctx context.Context, params *predictions.GetLiquidityRewardsDailySummaryParams) (*predictions.LiquidityRewardsDailySummaryResponse, error) { + var res predictions.LiquidityRewardsDailySummaryResponse + if err := s.get(ctx, withQuery("/v1/prediction-markets/liquidity-rewards/summary/daily", liquidityRewardsDailySummaryQuery(params)), &res); err != nil { + return nil, err + } + return &res, nil +} + +// GetLiquidityRewardsLifetimeSummary returns lifetime account rewards, +// optionally restricted to an inclusive date range. +func (s *PredictionsService) GetLiquidityRewardsLifetimeSummary(ctx context.Context, params *predictions.GetLiquidityRewardsLifetimeSummaryParams) (*predictions.LiquidityRewardsLifetimeSummary, error) { + var res predictions.LiquidityRewardsLifetimeSummary + if err := s.get(ctx, withQuery("/v1/prediction-markets/liquidity-rewards/summary/total", liquidityRewardsLifetimeSummaryQuery(params)), &res); err != nil { + return nil, err + } + return &res, nil +} + +func makerRebateRatesQuery(params *predictions.GetMakerRebateRatesParams) url.Values { + q := url.Values{} + if params != nil && params.Category != nil { + q.Set("category", *params.Category) + } + return q +} + +func makerRebatePayoutsQuery(params *predictions.ListMakerRebatePayoutsParams) url.Values { + q := url.Values{} + if params != nil { + addLimitOffset(q, params.Limit, params.Offset) + } + return q +} + +func makerRebateSummaryQuery(params *predictions.GetMakerRebateLifetimeSummaryParams) url.Values { + q := url.Values{} + if params == nil { + return q + } + addDate(q, "dateFrom", params.DateFrom) + addDate(q, "dateTo", params.DateTo) + return q +} + +func liquidityRewardsEventsQuery(params *predictions.ListLiquidityRewardsEventsParams) url.Values { + q := url.Values{} + if params == nil { + return q + } + if params.Category != nil { + q.Set("category", *params.Category) + } + if params.Search != nil { + q.Set("search", *params.Search) + } + if params.Sort != nil { + q.Set("sort", string(*params.Sort)) + } + if params.Limit != nil { + q.Set("limit", strconv.Itoa(*params.Limit)) + } + if params.Offset != nil { + q.Set("offset", strconv.Itoa(*params.Offset)) + } + return q +} + +func liquidityRewardsDailySummaryQuery(params *predictions.GetLiquidityRewardsDailySummaryParams) url.Values { + q := url.Values{} + if params == nil { + return q + } + addDateValue(q, "dateFrom", params.DateFrom) + addDateValue(q, "dateTo", params.DateTo) + return q +} + +func liquidityRewardsLifetimeSummaryQuery(params *predictions.GetLiquidityRewardsLifetimeSummaryParams) url.Values { + q := url.Values{} + if params == nil { + return q + } + addDate(q, "dateFrom", params.DateFrom) + addDate(q, "dateTo", params.DateTo) + return q +} + +func addDate(q url.Values, name string, value *types.Date) { + if value != nil && !value.IsZero() { + q.Set(name, value.String()) + } +} + +func addDateValue(q url.Values, name string, value types.Date) { + if !value.IsZero() { + q.Set(name, value.String()) + } +} diff --git a/packages/sdk-go/services/predictions_test.go b/packages/sdk-go/services/predictions_test.go new file mode 100644 index 00000000..5338d67c --- /dev/null +++ b/packages/sdk-go/services/predictions_test.go @@ -0,0 +1,227 @@ +package services_test + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "github.com/gemini/gemini-go/auth" + "github.com/gemini/gemini-go/generated/predictions" + "github.com/gemini/gemini-go/services" + "github.com/gemini/gemini-go/transport" +) + +func TestPredictionsService_TermsGating(t *testing.T) { + termsAccepted := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/v1/prediction-markets/terms/accept": + if r.ContentLength != 0 { + t.Errorf("expected bodyless terms acceptance request, got content length %d", r.ContentLength) + } + w.WriteHeader(http.StatusOK) + termsAccepted = true + _, _ = w.Write([]byte(`{"success":true}`)) + case "/v1/prediction-markets/order", "/v1/prediction-markets/order/new": + if !termsAccepted { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"result":"error","reason":"MustAcceptTerms","message":"terms must be accepted"}`)) + return + } + w.WriteHeader(http.StatusOK) + var orderID int64 = 123456789 + _ = json.NewEncoder(w).Encode(predictions.OrderResponse{OrderId: &orderID}) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + transportClient := transport.NewClient( + transport.WithHTTPClient(server.Client()), + transport.WithAuth(auth.NewBearer("test-token")), + ) + + service := services.NewPredictionsService(transportClient, server.URL) + ctx := context.Background() + + req := &predictions.OrderRequest{ + Symbol: "GEMI-FEDJAN26-DN25", + Side: predictions.OrderSideBuy, + Outcome: predictions.Yes, + OrderType: predictions.OrderTypeLimit, + Quantity: "100", + Price: "0.65", + } + + // 1. The first order reaches the backend and is rejected there. + _, err := service.NewOrder(ctx, req) + if err == nil { + t.Fatal("expected error before terms accepted, got nil") + } + if !errors.Is(err, transport.ErrAcceptTermsRequired) { + t.Fatalf("expected ErrAcceptTermsRequired, got %v", err) + } + + // 2. Accept terms + if _, err := service.AcceptPredictionMarketsTerms(ctx); err != nil { + t.Fatalf("failed accepting terms: %v", err) + } + + // 3. Place order again -> MUST SUCCEED + res, err := service.NewOrder(ctx, req) + if err != nil { + t.Fatalf("expected order success after terms accepted, got %v", err) + } + + if res.OrderId == nil || *res.OrderId != 123456789 { + t.Fatalf("unexpected order result: %v", res) + } +} + +func TestPredictionsService_TermsGatingDoesNotAcceptFailedResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/v1/prediction-markets/terms/accept": + _, _ = w.Write([]byte(`{"success":false}`)) + case "/v1/prediction-markets/order": + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"result":"error","reason":"MustAcceptTerms"}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + service := services.NewPredictionsService( + transport.NewClient(transport.WithHTTPClient(server.Client())), + server.URL, + ) + + if _, err := service.AcceptTerms(context.Background()); !errors.Is(err, transport.ErrAcceptTermsRequired) { + t.Fatalf("expected failed terms response to return ErrAcceptTermsRequired, got %v", err) + } + if _, err := service.NewOrder(context.Background(), &predictions.OrderRequest{}); !errors.Is(err, transport.ErrAcceptTermsRequired) { + t.Fatalf("expected backend terms error to be returned after failed acceptance, got %v", err) + } +} + +func TestPredictionsService_ReadEndpoints(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/v1/prediction-markets/terms": + _, _ = w.Write([]byte(`{"content":"terms","termsType":"prediction","version":3,"updatedAt":"2026-08-20T00:00:00Z"}`)) + case "/v1/prediction-markets/terms/status": + _, _ = w.Write([]byte(`{"acceptedVersion":2,"hasAcceptedLatest":false,"latestVersion":3}`)) + case "/v1/prediction-markets/categories": + _, _ = w.Write([]byte(`["crypto","sports"]`)) + case "/v1/prediction-markets/events": + query := r.URL.Query() + want := map[string][]string{ + "status": {"active"}, + "category": {"crypto", "sports"}, + "sport": {"soccer", "basketball"}, + "sports_market_type": {"moneyline", "spread"}, + "sports_market_subject": {"player"}, + "sports_market_scope": {"game"}, + "sports_market_metric": {"goals"}, + "search": {"bitcoin"}, + "limit": {"2"}, + "offset": {"10"}, + } + for key, expected := range want { + if got := query[key]; !reflect.DeepEqual(got, expected) { + t.Errorf("expected %s query to be %v, got %v", key, expected, got) + } + } + _, _ = w.Write([]byte(`{"data":[{"ticker":"SOCCERPLAYERGOAL","title":"Player goals","sportsMarket":{"sport":"soccer","type":"moneyline","subject":"player","scope":{"type":"game","ordinal":1},"metric":"goals"}}]}`)) + case "/v1/prediction-markets/events/BTCUSD": + _, _ = w.Write([]byte(`{"ticker":"BTCUSD","title":"Bitcoin"}`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + service := services.NewPredictionsService( + transport.NewClient(transport.WithHTTPClient(server.Client())), + server.URL, + ) + + terms, err := service.GetTerms(context.Background()) + if err != nil { + t.Fatalf("GetTerms failed: %v", err) + } + if terms.Content != "terms" || terms.Version != 3 { + t.Fatalf("unexpected terms response: %+v", terms) + } + + status, err := service.GetTermsStatus(context.Background()) + if err != nil { + t.Fatalf("GetTermsStatus failed: %v", err) + } + if status.HasAcceptedLatest || status.AcceptedVersion == nil || *status.AcceptedVersion != 2 { + t.Fatalf("unexpected terms status: %+v", status) + } + + categories, err := service.GetCategories(context.Background()) + if err != nil { + t.Fatalf("GetCategories failed: %v", err) + } + if len(categories) != 2 || categories[0] != "crypto" || categories[1] != "sports" { + t.Fatalf("unexpected categories: %v", categories) + } + + eventStatus := predictions.MarketStatusActive + queryCategories := []string{"crypto", "sports"} + sports := predictions.SportFilter{predictions.Soccer, predictions.Basketball} + marketTypes := predictions.SportsMarketTypeFilter{predictions.SportsMarketTypeMoneyline, predictions.SportsMarketTypeSpread} + subjects := predictions.SportsMarketSubjectFilter{predictions.SportsMarketSubjectPlayer} + scopes := predictions.SportsMarketScopeFilter{predictions.SportsMarketScopeTypeGame} + metrics := predictions.SportsMarketMetricFilter{predictions.SportsMarketMetricGoals} + search := "bitcoin" + limit := predictions.Limit(2) + offset := predictions.Offset(10) + events, err := service.GetEvents(context.Background(), &predictions.ListEventsParams{ + Status: &[]predictions.MarketStatus{eventStatus}, + Category: &queryCategories, + Sport: &sports, + SportsMarketType: &marketTypes, + SportsMarketSubject: &subjects, + SportsMarketScope: &scopes, + SportsMarketMetric: &metrics, + Search: &search, + Limit: &limit, + Offset: &offset, + }) + if err != nil { + t.Fatalf("GetEvents failed: %v", err) + } + if events.Data == nil || len(*events.Data) != 1 || (*events.Data)[0].Ticker == nil || *(*events.Data)[0].Ticker != "SOCCERPLAYERGOAL" { + t.Fatalf("unexpected events response: %+v", events) + } + sportsMarket := (*events.Data)[0].SportsMarket + if sportsMarket == nil || sportsMarket.Sport != predictions.Soccer || + sportsMarket.Type != predictions.SportsMarketTypeMoneyline || + sportsMarket.Subject != predictions.SportsMarketSubjectPlayer || + sportsMarket.Scope.Type != predictions.SportsMarketScopeTypeGame || + sportsMarket.Scope.Ordinal == nil || *sportsMarket.Scope.Ordinal != 1 || + sportsMarket.Metric == nil || *sportsMarket.Metric != predictions.SportsMarketMetricGoals { + t.Fatalf("sports metadata was not preserved: %+v", sportsMarket) + } + + event, err := service.GetEvent(context.Background(), "BTCUSD") + if err != nil { + t.Fatalf("GetEvent failed: %v", err) + } + if event.Ticker == nil || *event.Ticker != "BTCUSD" || event.Title == nil || *event.Title != "Bitcoin" { + t.Fatalf("unexpected event response: %+v", event) + } +} diff --git a/packages/sdk-go/services/predictions_trading.go b/packages/sdk-go/services/predictions_trading.go new file mode 100644 index 00000000..c041b527 --- /dev/null +++ b/packages/sdk-go/services/predictions_trading.go @@ -0,0 +1,65 @@ +package services + +import ( + "context" + + "github.com/gemini/gemini-go/generated/predictions" +) + +// PredictionOrderOperationResponse reports the result of a prediction-market +// order operation such as canceling an order. +type PredictionOrderOperationResponse struct { + Result string `json:"result,omitempty"` + Message string `json:"message,omitempty"` +} + +// PlaceOrderBatch submits up to 20 prediction-market orders in one request. +// Results preserve the request order and may contain a mix of accepted and +// rejected entries. +func (s *PredictionsService) PlaceOrderBatch(ctx context.Context, req *predictions.PlaceOrderBatchJSONRequestBody) (*predictions.PlaceOrderBatchResponse, error) { + var res predictions.PlaceOrderBatchResponse + if err := s.post(ctx, "/v1/prediction-markets/order/batch", req, &res); err != nil { + return nil, err + } + return &res, nil +} + +// CancelOrder cancels one prediction-market order. +func (s *PredictionsService) CancelOrder(ctx context.Context, req *predictions.CancelOrderJSONRequestBody) (*PredictionOrderOperationResponse, error) { + var res PredictionOrderOperationResponse + if err := s.post(ctx, "/v1/prediction-markets/order/cancel", req, &res); err != nil { + return nil, err + } + return &res, nil +} + +// CancelOrderBatch cancels up to 20 prediction-market orders in one request. +// Results preserve the request order and may contain a mix of successful and +// rejected entries. +func (s *PredictionsService) CancelOrderBatch(ctx context.Context, req *predictions.CancelOrderBatchJSONRequestBody) (*predictions.CancelOrderBatchResponse, error) { + var res predictions.CancelOrderBatchResponse + if err := s.post(ctx, "/v1/prediction-markets/order/batch/cancel", req, &res); err != nil { + return nil, err + } + return &res, nil +} + +// GetActiveOrders returns the authenticated account's active prediction-market +// orders, optionally filtered and paginated by the request body. +func (s *PredictionsService) GetActiveOrders(ctx context.Context, req *predictions.GetActiveOrdersJSONRequestBody) (*predictions.OrdersResponse, error) { + var res predictions.OrdersResponse + if err := s.post(ctx, "/v1/prediction-markets/orders/active", req, &res); err != nil { + return nil, err + } + return &res, nil +} + +// GetOrderHistory returns historical filled or cancelled prediction-market +// orders, optionally filtered by symbol, status, time range, and pagination. +func (s *PredictionsService) GetOrderHistory(ctx context.Context, req *predictions.GetOrderHistoryJSONRequestBody) (*predictions.OrdersResponse, error) { + var res predictions.OrdersResponse + if err := s.post(ctx, "/v1/prediction-markets/orders/history", req, &res); err != nil { + return nil, err + } + return &res, nil +} diff --git a/packages/sdk-go/services/reconciler.go b/packages/sdk-go/services/reconciler.go new file mode 100644 index 00000000..52b6bc62 --- /dev/null +++ b/packages/sdk-go/services/reconciler.go @@ -0,0 +1,592 @@ +package services + +import ( + "context" + "errors" + "fmt" + "math" + "strconv" + "strings" + "sync" + "sync/atomic" + + "github.com/gemini/gemini-go/generated/trading" + "github.com/gemini/gemini-go/types" + "github.com/gemini/gemini-go/websocket" +) + +// DesiredQuote specifies an intended order for the target quoting grid. +type DesiredQuote struct { + Side string // "buy" or "sell" + Price types.Decimal // Target unit price + Amount types.Decimal // Target order quantity + ClientOrderID string // Optional client order ID +} + +// RestingOrder tracks an active order currently on the exchange order book. +type RestingOrder struct { + OrderID string + ClientOrderID string + Side string + Price types.Decimal + Amount types.Decimal + OriginalAmount types.Decimal +} + +// ReconcileResult summarizes the actions taken during a reconciliation cycle. +type ReconcileResult struct { + Kept int // Orders left untouched (preserved FIFO queue priority) + Cancelled int // Stale/mispriced orders cancelled + Placed int // New quotes successfully placed + Errors []error // Any non-fatal errors during execution +} + +// Err returns the aggregate of all non-fatal errors recorded during the +// reconciliation cycle. Sync returns its result alongside a nil error when it +// can complete the cycle but some individual cancellations or placements +// fail; callers should check both the Sync error and this result error. +func (r *ReconcileResult) Err() error { + if r == nil || len(r.Errors) == 0 { + return nil + } + return errors.Join(r.Errors...) +} + +// ErrStreamingAlreadyStarted indicates that a QuoteReconciler already has an +// active order-event stream. A reconciler must have at most one stream because +// each stream consumes and applies events to the same in-memory ledger. +var ErrStreamingAlreadyStarted = errors.New("gemini reconciler: streaming already started") + +// ReconcilerOption configures the QuoteReconciler behavior. +type ReconcilerOption func(*QuoteReconciler) + +// WithToleranceBps sets the maximum price drift allowed before an order is considered stale. +func WithToleranceBps(bps float64) ReconcilerOption { + return func(r *QuoteReconciler) { + r.toleranceBps = bps + } +} + +// WithQuantization enforces minimum tick and lot sizes on all desired quotes. +func WithQuantization(tickSize, lotSize types.Decimal) ReconcilerOption { + return func(r *QuoteReconciler) { + r.tickSize = tickSize + r.lotSize = lotSize + } +} + +// WithMaxConcurrentRequests limits concurrent cancel and placement requests +// issued by a reconciliation cycle. +func WithMaxConcurrentRequests(limit int) ReconcilerOption { + return func(r *QuoteReconciler) { + if limit > 0 { + r.maxConcurrent = limit + } + } +} + +// WithAccount selects the exchange account used by the reconciler. It is +// required when the client authenticates with a Master API key. +func WithAccount(account string) ReconcilerOption { + return func(r *QuoteReconciler) { + r.account = strings.TrimSpace(account) + } +} + +// QuoteReconciler manages declarative order book quoting, computing minimal cancel-replace diffs. +// It prioritizes WebSocket order streams for low-latency state updates and falls back to HTTP/2 REST. +type QuoteReconciler struct { + syncMu sync.Mutex // Serializes whole Sync execution cycles + streamMu sync.Mutex + streaming bool + mu sync.RWMutex + trading *TradingService + ws *websocket.Client + symbol string + account string + toleranceBps float64 + tickSize types.Decimal + lotSize types.Decimal + maxConcurrent int + resting map[string]RestingOrder // Keyed by OrderID +} + +// NewQuoteReconciler initializes a new smart quote reconciler for a trading symbol. +func NewQuoteReconciler(trading *TradingService, ws *websocket.Client, symbol string, opts ...ReconcilerOption) *QuoteReconciler { + r := &QuoteReconciler{ + trading: trading, + ws: ws, + symbol: strings.ToUpper(strings.TrimSpace(symbol)), + toleranceBps: 0.0, + tickSize: types.Zero(), + lotSize: types.Zero(), + maxConcurrent: 4, + resting: make(map[string]RestingOrder), + } + for _, opt := range opts { + opt(r) + } + return r +} + +func (r *QuoteReconciler) accountPointer() *string { + if r.account == "" { + return nil + } + account := r.account + return &account +} + +func validateCancelResponse(res *trading.CancelOrderResponse) error { + if res == nil { + return errors.New("gemini reconciler: cancel response was empty") + } + if res.IsLive != nil { + if *res.IsLive { + return errors.New("gemini reconciler: cancelled order is still live") + } + return nil + } + if res.IsCancelled != nil && *res.IsCancelled { + return nil + } + return errors.New("gemini reconciler: cancel response did not prove that the order is no longer live") +} + +// Hydrate queries current open orders from the exchange to initialize the in-memory ledger. +func (r *QuoteReconciler) Hydrate(ctx context.Context) error { + if ctx == nil { + return fmt.Errorf("gemini reconciler: nil context") + } + if r.trading == nil { + return fmt.Errorf("gemini reconciler: trading service not configured") + } + r.syncMu.Lock() + defer r.syncMu.Unlock() + orders, err := r.trading.GetActiveOrders(ctx, &trading.ListActiveOrdersJSONBody{ + Account: r.accountPointer(), + }) + if err != nil { + return err + } + + r.mu.Lock() + defer r.mu.Unlock() + + r.resting = make(map[string]RestingOrder, len(orders)) + for _, o := range orders { + if o.Symbol != nil && strings.ToUpper(strings.TrimSpace(*o.Symbol)) != r.symbol { + continue + } + if o.OrderId == nil || o.Price == nil || o.OriginalAmount == nil { + continue + } + p, errP := types.ParseDecimal(*o.Price) + a, errA := types.ParseDecimal(*o.OriginalAmount) + if errP != nil || errA != nil { + continue + } + + origAmount := a + if o.ExecutedAmount != nil { + if exec, errExec := types.ParseDecimal(*o.ExecutedAmount); errExec == nil && exec.IsPositive() { + a = a.Sub(exec) + } + } + if !a.IsPositive() { + continue + } + + sideStr := "" + if o.Side != nil { + switch *o.Side { + case trading.LimitOrderResponseSideBuy: + sideStr = "buy" + case trading.LimitOrderResponseSideSell: + sideStr = "sell" + } + } + if sideStr == "" { + continue + } + + clientOrdID := "" + if o.ClientOrderId != nil { + clientOrdID = *o.ClientOrderId + } + + r.resting[*o.OrderId] = RestingOrder{ + OrderID: *o.OrderId, + ClientOrderID: clientOrdID, + Side: sideStr, + Price: p, + Amount: a, + OriginalAmount: origAmount, + } + } + return nil +} + +// Sync compares desired target quotes against resting orders, executes the minimal diff concurrently, and updates state. +func (r *QuoteReconciler) Sync(ctx context.Context, targets []DesiredQuote) (*ReconcileResult, error) { + r.syncMu.Lock() + defer r.syncMu.Unlock() + + if ctx == nil { + return nil, fmt.Errorf("gemini reconciler: nil context") + } + if r.trading == nil { + return nil, fmt.Errorf("gemini reconciler: trading service not configured") + } + cleanTargets, err := r.normalizeTargets(targets) + if err != nil { + return nil, err + } + + result := &ReconcileResult{ + Errors: make([]error, 0), + } + + // 2. Diff Matching (Under Read Lock): Find which resting orders match target quotes + var toCancel []RestingOrder + var toPlace []DesiredQuote + + r.mu.RLock() + matchedResting := make(map[string]bool) + matchedTargets := make(map[int]bool) + + for tIdx, target := range cleanTargets { + for ordID, ord := range r.resting { + if matchedResting[ordID] { + continue + } + if ord.Side != target.Side { + continue + } + + // Check exact price or within tolerance bps AND amount equality + priceMatch := false + if ord.Price.Cmp(target.Price) == 0 { + priceMatch = true + } else if r.toleranceBps > 0 { + if diffBps, err := ord.Price.BpsDiffChecked(target.Price); err == nil && math.Abs(diffBps) <= r.toleranceBps { + priceMatch = true + } + } + + amountMatch := ord.Amount.Cmp(target.Amount) == 0 + + if priceMatch && amountMatch { + matchedResting[ordID] = true + matchedTargets[tIdx] = true + result.Kept++ + break + } + } + } + + for ordID, ord := range r.resting { + if !matchedResting[ordID] { + toCancel = append(toCancel, ord) + } + } + r.mu.RUnlock() + + for tIdx, target := range cleanTargets { + if !matchedTargets[tIdx] { + toPlace = append(toPlace, target) + } + } + + // 3. Phase 1: Cancels (Concurrent Dispatch - No Lock Held) + var cancelMu sync.Mutex + var cancelledOrders []RestingOrder + cancelErr := runBounded(ctx, r.maxConcurrent, toCancel, func(o RestingOrder) { + if ctx.Err() != nil { + cancelMu.Lock() + result.Errors = append(result.Errors, fmt.Errorf("cancelling order %s: %w", o.OrderID, ctx.Err())) + cancelMu.Unlock() + return + } + idInt, errParse := strconv.ParseInt(o.OrderID, 10, 64) + if errParse != nil { + cancelMu.Lock() + result.Errors = append(result.Errors, fmt.Errorf("invalid order id %s: %w", o.OrderID, errParse)) + cancelMu.Unlock() + return + } + req := &trading.CancelOrderRequest{ + OrderId: idInt, + Account: r.accountPointer(), + } + res, err := r.trading.CancelOrder(ctx, req) + if err == nil { + err = validateCancelResponse(res) + } + cancelMu.Lock() + if err != nil { + result.Errors = append(result.Errors, fmt.Errorf("failed to cancel order %s: %w", o.OrderID, err)) + } else { + cancelledOrders = append(cancelledOrders, o) + result.Cancelled++ + } + cancelMu.Unlock() + }) + if cancelErr != nil { + cancelMu.Lock() + result.Errors = append(result.Errors, fmt.Errorf("cancelling quotes: %w", cancelErr)) + cancelMu.Unlock() + } + + // Apply successful cancellations to resting state + if len(cancelledOrders) > 0 { + r.mu.Lock() + for _, cancelled := range cancelledOrders { + deleteRestingIfUnchanged(r.resting, cancelled) + } + r.mu.Unlock() + } + // Never place replacement orders after a cancellation phase that was not + // fully successful. Doing so can temporarily (or permanently) exceed the + // caller's intended exposure when an old order is still live. + if len(result.Errors) > 0 { + return result, nil + } + + // 4. Phase 2: Placements (Concurrent Dispatch - No Lock Held) + var placeMu sync.Mutex + var placedOrders []RestingOrder + placeErr := runBounded(ctx, r.maxConcurrent, toPlace, func(t DesiredQuote) { + if ctx.Err() != nil { + placeMu.Lock() + result.Errors = append(result.Errors, fmt.Errorf("placing %s quote: %w", t.Side, ctx.Err())) + placeMu.Unlock() + return + } + var res *trading.LimitOrderResponse + var err error + + var opts []OrderOption + if t.ClientOrderID != "" { + opts = append(opts, WithClientOrderID(t.ClientOrderID)) + } + if r.account != "" { + opts = append(opts, withOrderAccount(r.account)) + } + + if t.Side == "buy" { + res, err = r.trading.PostOnlyBid(ctx, r.symbol, t.Amount, t.Price, opts...) + } else if t.Side == "sell" { + res, err = r.trading.PostOnlyAsk(ctx, r.symbol, t.Amount, t.Price, opts...) + } else { + err = fmt.Errorf("invalid order side %q (must be 'buy' or 'sell')", t.Side) + } + if err == nil && res != nil && ((res.IsCancelled != nil && *res.IsCancelled) || (res.IsLive != nil && !*res.IsLive)) { + err = fmt.Errorf("exchange did not leave the order live") + } + + placeMu.Lock() + if err != nil { + result.Errors = append(result.Errors, fmt.Errorf("failed to place %s quote @ %s: %w", t.Side, t.Price.String(), err)) + } else if res == nil || res.OrderId == nil || strings.TrimSpace(*res.OrderId) == "" { + result.Errors = append(result.Errors, fmt.Errorf("failed to place %s quote @ %s: exchange returned no order ID", t.Side, t.Price.String())) + } else { + result.Placed++ + placedOrders = append(placedOrders, RestingOrder{ + OrderID: *res.OrderId, + ClientOrderID: t.ClientOrderID, + Side: t.Side, + Price: t.Price, + Amount: t.Amount, + OriginalAmount: t.Amount, + }) + } + placeMu.Unlock() + }) + if placeErr != nil { + placeMu.Lock() + result.Errors = append(result.Errors, fmt.Errorf("placing quotes: %w", placeErr)) + placeMu.Unlock() + } + + // Apply successful placements to resting state without clobbering fresher WebSocket updates + if len(placedOrders) > 0 { + r.mu.Lock() + for _, p := range placedOrders { + if _, exists := r.resting[p.OrderID]; !exists { + r.resting[p.OrderID] = p + } + } + r.mu.Unlock() + } + + return result, nil +} + +// runBounded executes at most max workers. A semaphore alone would bound +// requests but still create one goroutine per quote; workers keep memory and +// scheduler overhead proportional to the configured concurrency. +func runBounded[T any](ctx context.Context, max int, items []T, fn func(T)) error { + if len(items) == 0 { + return nil + } + if max <= 0 || max > len(items) { + max = len(items) + } + var next atomic.Int64 + var wg sync.WaitGroup + wg.Add(max) + for i := 0; i < max; i++ { + go func() { + defer wg.Done() + for { + if ctx.Err() != nil { + return + } + idx := int(next.Add(1) - 1) + if idx >= len(items) { + return + } + fn(items[idx]) + } + }() + } + wg.Wait() + return ctx.Err() +} + +func (r *QuoteReconciler) normalizeTargets(targets []DesiredQuote) ([]DesiredQuote, error) { + if r.symbol == "" { + return nil, fmt.Errorf("gemini reconciler: symbol is required") + } + if math.IsNaN(r.toleranceBps) || math.IsInf(r.toleranceBps, 0) || r.toleranceBps < 0 { + return nil, fmt.Errorf("gemini reconciler: tolerance must be finite and non-negative") + } + if (!r.tickSize.IsZero() && !r.tickSize.IsPositive()) || (!r.lotSize.IsZero() && !r.lotSize.IsPositive()) { + return nil, fmt.Errorf("gemini reconciler: tick and lot sizes must be positive") + } + + cleanTargets := make([]DesiredQuote, 0, len(targets)) + seen := make(map[string]struct{}, len(targets)) + for _, target := range targets { + q := target + q.Side = strings.ToLower(strings.TrimSpace(q.Side)) + if q.Side != "buy" && q.Side != "sell" { + return nil, fmt.Errorf("gemini reconciler: invalid order side %q", target.Side) + } + if !q.Price.IsPositive() || !q.Amount.IsPositive() { + return nil, fmt.Errorf("gemini reconciler: price and amount must be positive for %s quote", q.Side) + } + if !r.tickSize.IsZero() { + q.Price = q.Price.QuantizePrice(r.tickSize) + } + if !r.lotSize.IsZero() { + q.Amount = q.Amount.QuantizeAmount(r.lotSize) + } + if !q.Price.IsPositive() || !q.Amount.IsPositive() { + return nil, fmt.Errorf("gemini reconciler: quantization produced a non-positive %s quote", q.Side) + } + key := q.Side + "|" + q.Price.String() + "|" + q.Amount.String() + if _, exists := seen[key]; exists { + return nil, fmt.Errorf("gemini reconciler: duplicate target quote %s", key) + } + seen[key] = struct{}{} + cleanTargets = append(cleanTargets, q) + } + return cleanTargets, nil +} + +// ActiveOrders returns a snapshot of currently tracked resting orders. +func (r *QuoteReconciler) ActiveOrders() []RestingOrder { + r.mu.RLock() + defer r.mu.RUnlock() + + res := make([]RestingOrder, 0, len(r.resting)) + for _, o := range r.resting { + res = append(res, o) + } + return res +} + +// CancelAll performs an emergency purge of all tracked orders on the exchange. +func (r *QuoteReconciler) CancelAll(ctx context.Context) error { + if ctx == nil { + return fmt.Errorf("gemini reconciler: nil context") + } + if r.trading == nil { + return fmt.Errorf("gemini reconciler: trading service not configured") + } + r.syncMu.Lock() + defer r.syncMu.Unlock() + + r.mu.RLock() + orders := make([]RestingOrder, 0, len(r.resting)) + for _, order := range r.resting { + orders = append(orders, order) + } + r.mu.RUnlock() + + var cancelMu sync.Mutex + var failures []error + var cancelledOrders []RestingOrder + if err := runBounded(ctx, r.maxConcurrent, orders, func(order RestingOrder) { + id, err := strconv.ParseInt(order.OrderID, 10, 64) + if err != nil { + cancelMu.Lock() + failures = append(failures, fmt.Errorf("invalid order id %s: %w", order.OrderID, err)) + cancelMu.Unlock() + return + } + res, err := r.trading.CancelOrder(ctx, &trading.CancelOrderRequest{ + Account: r.accountPointer(), + OrderId: id, + }) + if err == nil { + err = validateCancelResponse(res) + } + if err != nil { + cancelMu.Lock() + failures = append(failures, fmt.Errorf("failed to cancel order %s: %w", order.OrderID, err)) + cancelMu.Unlock() + return + } + cancelMu.Lock() + cancelledOrders = append(cancelledOrders, order) + cancelMu.Unlock() + }); err != nil { + failures = append(failures, fmt.Errorf("cancelling orders: %w", err)) + } + + if len(cancelledOrders) > 0 { + r.mu.Lock() + for _, cancelled := range cancelledOrders { + deleteRestingIfUnchanged(r.resting, cancelled) + } + r.mu.Unlock() + } + if len(failures) > 0 { + return errors.Join(failures...) + } + return nil +} + +// deleteRestingIfUnchanged removes an order only when the ledger still holds +// the same snapshot that was cancelled. A WebSocket event may update the +// order while the REST cancellation is in flight; retaining that newer state +// is safer than allowing a stale cancellation result to erase it. +func deleteRestingIfUnchanged(resting map[string]RestingOrder, expected RestingOrder) { + current, ok := resting[expected.OrderID] + if !ok || !sameRestingOrder(current, expected) { + return + } + delete(resting, expected.OrderID) +} + +func sameRestingOrder(a, b RestingOrder) bool { + return a.OrderID == b.OrderID && + a.ClientOrderID == b.ClientOrderID && + a.Side == b.Side && + a.Price.Cmp(b.Price) == 0 && + a.Amount.Cmp(b.Amount) == 0 && + a.OriginalAmount.Cmp(b.OriginalAmount) == 0 +} diff --git a/packages/sdk-go/services/reconciler_concurrency_test.go b/packages/sdk-go/services/reconciler_concurrency_test.go new file mode 100644 index 00000000..30799ff5 --- /dev/null +++ b/packages/sdk-go/services/reconciler_concurrency_test.go @@ -0,0 +1,102 @@ +package services_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/gemini/gemini-go/generated/trading" + "github.com/gemini/gemini-go/services" + "github.com/gemini/gemini-go/transport" + "github.com/gemini/gemini-go/types" +) + +func TestQuoteReconciler_LimitsConcurrentRequests(t *testing.T) { + var active atomic.Int32 + var maxActive atomic.Int32 + var orderID atomic.Int64 + started := make(chan struct{}, 8) + release := make(chan struct{}) + var releaseOnce sync.Once + unblock := func() { releaseOnce.Do(func() { close(release) }) } + t.Cleanup(unblock) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/order/new" { + http.NotFound(w, r) + return + } + current := active.Add(1) + for { + previous := maxActive.Load() + if current <= previous || maxActive.CompareAndSwap(previous, current) { + break + } + } + started <- struct{}{} + <-release + active.Add(-1) + id := fmt.Sprintf("%d", orderID.Add(1)) + symbol := "BTCUSD" + _ = json.NewEncoder(w).Encode(trading.LimitOrderResponse{OrderId: &id, Symbol: &symbol}) + })) + t.Cleanup(server.Close) + + reconciler := services.NewQuoteReconciler( + services.NewTradingService( + transport.NewClient(transport.WithHTTPClient(server.Client())), + server.URL, + ), + nil, + "BTCUSD", + services.WithMaxConcurrentRequests(2), + ) + + targets := make([]services.DesiredQuote, 0, 5) + for i := 0; i < 5; i++ { + targets = append(targets, services.DesiredQuote{ + Side: "buy", + Price: types.MustParseDecimal(fmt.Sprintf("%d", 65000+i)), + Amount: types.MustParseDecimal("0.1"), + }) + } + + resultCh := make(chan *services.ReconcileResult, 1) + errCh := make(chan error, 1) + go func() { + result, err := reconciler.Sync(context.Background(), targets) + resultCh <- result + errCh <- err + }() + + for i := 0; i < 2; i++ { + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("expected two concurrent placement requests") + } + } + if got := maxActive.Load(); got != 2 { + t.Fatalf("expected configured concurrency of 2, observed %d", got) + } + unblock() + + select { + case err := <-errCh: + if err != nil { + t.Fatalf("Sync failed: %v", err) + } + case <-time.After(time.Second): + t.Fatal("Sync did not finish after releasing placement requests") + } + result := <-resultCh + if result.Placed != len(targets) || len(result.Errors) != 0 { + t.Fatalf("unexpected reconcile result: %+v", result) + } +} diff --git a/packages/sdk-go/services/reconciler_stream.go b/packages/sdk-go/services/reconciler_stream.go new file mode 100644 index 00000000..eaa61f01 --- /dev/null +++ b/packages/sdk-go/services/reconciler_stream.go @@ -0,0 +1,347 @@ +package services + +import ( + "context" + "fmt" + "strconv" + "strings" + "sync" + "time" + + "github.com/gemini/gemini-go/types" + "github.com/gemini/gemini-go/websocket" +) + +const maxPendingOrderEvents = 4096 + +type orderEventKey websocket.OrderEvent + +func enqueuePendingOrderEvent(pending []*websocket.OrderEvent, pendingKeys map[orderEventKey]struct{}, evt *websocket.OrderEvent) ([]*websocket.OrderEvent, bool) { + if key, ok := orderEventKeyFor(evt); ok { + if _, seen := pendingKeys[key]; seen { + return pending, true + } + if len(pending) >= maxPendingOrderEvents { + return pending, false + } + pendingKeys[key] = struct{}{} + } else if len(pending) >= maxPendingOrderEvents { + return pending, false + } + return append(pending, evt), true +} + +// ApplyOrderEvent updates the in-memory state ledger based on incoming WebSocket order events. +func (r *QuoteReconciler) ApplyOrderEvent(evt *websocket.OrderEvent) { + if evt == nil || (evt.Symbol != "" && strings.ToUpper(strings.TrimSpace(evt.Symbol)) != r.symbol) { + return + } + if evt.OrderID <= 0 { + return + } + + r.mu.Lock() + defer r.mu.Unlock() + + ordID := strconv.FormatInt(evt.OrderID, 10) + status := strings.ToUpper(evt.OrderStatus) + side := strings.ToLower(strings.TrimSpace(evt.Side)) + + switch status { + case "NEW", "ACCEPTED", "BOOKED", "OPEN": + p, errP := types.ParseDecimal(evt.Price) + q, errQ := types.ParseDecimal(evt.Quantity) + if (side == "buy" || side == "sell") && errP == nil && errQ == nil && p.IsPositive() && q.IsPositive() { + r.resting[ordID] = RestingOrder{ + OrderID: ordID, + ClientOrderID: evt.ClientOrderID, + Side: side, + Price: p, + Amount: q, + OriginalAmount: q, + } + } + + case "MODIFIED", "AMENDED", "REPLACED": + p, errP := types.ParseDecimal(evt.Price) + original, errOriginal := types.ParseDecimal(evt.Quantity) + if errP == nil && errOriginal == nil && p.IsPositive() && original.IsPositive() { + remaining := original + if strings.TrimSpace(evt.RemainingQty) != "" { + parsedRemaining, errRemaining := types.ParseDecimal(evt.RemainingQty) + if errRemaining != nil || parsedRemaining.GreaterThan(original) { + return + } + if !parsedRemaining.IsPositive() { + delete(r.resting, ordID) + return + } + remaining = parsedRemaining + } + if current, exists := r.resting[ordID]; exists { + if evt.Side != "" && side != "buy" && side != "sell" { + return + } + current.Price = p + current.Amount = remaining + current.OriginalAmount = original + if evt.ClientOrderID != "" { + current.ClientOrderID = evt.ClientOrderID + } + if evt.Side != "" { + current.Side = side + } + r.resting[ordID] = current + } else if side == "buy" || side == "sell" { + r.resting[ordID] = RestingOrder{ + OrderID: ordID, + ClientOrderID: evt.ClientOrderID, + Side: side, + Price: p, + Amount: remaining, + OriginalAmount: original, + } + } + } + + case "CANCELED", "CANCELLED", "FILLED", "EXPIRED", "REJECTED": + delete(r.resting, ordID) + + case "PARTIALLY_FILLED": + rem, errRem := types.ParseDecimal(evt.RemainingQty) + if errRem == nil && rem.IsPositive() { + if current, exists := r.resting[ordID]; exists { + if rem.GreaterThan(current.OriginalAmount) { + return + } + current.Amount = rem + r.resting[ordID] = current + } else { + p, errP := types.ParseDecimal(evt.Price) + q, errQ := types.ParseDecimal(evt.Quantity) + if errP != nil || !p.IsPositive() || (side != "buy" && side != "sell") { + return + } + if errQ != nil || !q.IsPositive() { + q = rem + } else if q.LessThan(rem) { + return + } + r.resting[ordID] = RestingOrder{ + OrderID: ordID, + ClientOrderID: evt.ClientOrderID, + Side: side, + Price: p, + Amount: rem, + OriginalAmount: q, + } + } + } else if errRem == nil && !rem.IsPositive() { + delete(r.resting, ordID) + } + } +} + +// StartStreaming begins listening to real-time WebSocket order events to continuously synchronize the in-memory ledger. +func (r *QuoteReconciler) StartStreaming(ctx context.Context) (<-chan error, error) { + if r.ws == nil { + return nil, fmt.Errorf("websocket client not configured") + } + if ctx == nil { + ctx = context.Background() + } + r.streamMu.Lock() + if r.streaming { + r.streamMu.Unlock() + return nil, ErrStreamingAlreadyStarted + } + r.streaming = true + r.streamMu.Unlock() + + connectionEvents, stopConnectionEvents := r.ws.SubscribeConnectionEvents(16) + // Subscribe before hydrating so order events cannot arrive in the gap + // between the REST snapshot and WebSocket registration. The reader starts + // immediately and buffers events until hydration establishes a base state, + // then replays them while holding the handoff lock to preserve order. + ch, err := r.ws.SubscribeOrderEvents(ctx) + if err != nil { + stopConnectionEvents() + r.streamMu.Lock() + r.streaming = false + r.streamMu.Unlock() + return nil, fmt.Errorf("failed to subscribe order events: %w", err) + } + + errChan := make(chan error, 1) + streamCtx, cancel := context.WithCancel(ctx) + var handoffMu sync.Mutex + var hydrationMu sync.Mutex + var hydrationGeneration uint64 + hydrating := true + recovering := false + pending := make([]*websocket.OrderEvent, 0, 64) + pendingKeys := make(map[orderEventKey]struct{}, 64) + var pendingErr error + + hydrateAndReplay := func(hydrateCtx context.Context) error { + hydrationMu.Lock() + defer hydrationMu.Unlock() + + handoffMu.Lock() + generation := hydrationGeneration + handoffMu.Unlock() + + if err := r.Hydrate(hydrateCtx); err != nil { + handoffMu.Lock() + stale := generation != hydrationGeneration + handoffMu.Unlock() + if stale { + return nil + } + return err + } + + // Swap batches under the handoff lock, then apply them outside the + // lock. New events continue accumulating while replay performs the + // potentially expensive ledger updates; the loop closes the handoff + // only after all events observed during replay have also been applied. + for { + handoffMu.Lock() + if generation != hydrationGeneration { + handoffMu.Unlock() + return nil + } + if pendingErr != nil { + err := pendingErr + handoffMu.Unlock() + return err + } + batch := pending + pending = nil + if len(batch) == 0 { + pendingKeys = nil + pendingErr = nil + hydrating = false + handoffMu.Unlock() + return nil + } + handoffMu.Unlock() + + for _, evt := range batch { + r.ApplyOrderEvent(evt) + } + } + } + + go func() { + defer stopConnectionEvents() + defer func() { + r.streamMu.Lock() + r.streaming = false + r.streamMu.Unlock() + }() + defer cancel() + defer func() { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cleanupCancel() + _ = r.ws.UnsubscribeOrderEventsChannel(cleanupCtx, ch) + close(errChan) + }() + for { + select { + case <-streamCtx.Done(): + return + case event, ok := <-connectionEvents: + if !ok { + if streamCtx.Err() == nil && r.ws.State() != websocket.StateClosed { + select { + case errChan <- fmt.Errorf("gemini reconciler: connection event stream closed unexpectedly"): + default: + } + return + } + connectionEvents = nil + continue + } + switch event.State { + case websocket.StateReconnecting: + if !recovering { + handoffMu.Lock() + hydrationGeneration++ + hydrating = true + pending = pending[:0] + pendingKeys = make(map[orderEventKey]struct{}, 64) + pendingErr = nil + handoffMu.Unlock() + recovering = true + } + case websocket.StateConnected: + if !recovering { + continue + } + if err := hydrateAndReplay(streamCtx); err != nil { + if streamCtx.Err() == nil { + select { + case errChan <- fmt.Errorf("gemini reconciler: reconnect hydration failed: %w", err): + default: + } + } + return + } + recovering = false + } + case evt, ok := <-ch: + if !ok { + select { + case errChan <- fmt.Errorf("gemini reconciler: order event stream closed unexpectedly"): + default: + } + return + } + if evt == nil { + continue + } + handoffMu.Lock() + if hydrating { + var queued bool + pending, queued = enqueuePendingOrderEvent(pending, pendingKeys, evt) + if !queued { + if pendingErr == nil { + pendingErr = fmt.Errorf("gemini reconciler: pending order event buffer exceeded %d events; resynchronization required", maxPendingOrderEvents) + } + handoffMu.Unlock() + continue + } + handoffMu.Unlock() + continue + } + r.ApplyOrderEvent(evt) + handoffMu.Unlock() + } + } + }() + + // Hydrate after the stream is active. Events received during this request + // are replayed below, eliminating the REST-to-WebSocket startup race. + if err := hydrateAndReplay(ctx); err != nil { + cancel() + stopConnectionEvents() + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cleanupCancel() + _ = r.ws.UnsubscribeOrderEventsChannel(cleanupCtx, ch) + return nil, fmt.Errorf("initial hydration failed: %w", err) + } + + return errChan, nil +} + +// orderEventKeyFor provides startup-window deduplication for repeated +// deliveries. The order stream does not expose a single documented event ID, +// so the complete typed event payload is used as a comparable allocation-free +// key for this handoff. +func orderEventKeyFor(evt *websocket.OrderEvent) (orderEventKey, bool) { + if evt == nil { + return orderEventKey{}, false + } + return orderEventKey(*evt), true +} diff --git a/packages/sdk-go/services/reconciler_stream_internal_test.go b/packages/sdk-go/services/reconciler_stream_internal_test.go new file mode 100644 index 00000000..7a31c3ae --- /dev/null +++ b/packages/sdk-go/services/reconciler_stream_internal_test.go @@ -0,0 +1,37 @@ +package services + +import ( + "testing" + + "github.com/gemini/gemini-go/websocket" +) + +func TestEnqueuePendingOrderEventHasHardLimit(t *testing.T) { + pending := make([]*websocket.OrderEvent, 0, 64) + keys := make(map[orderEventKey]struct{}, 64) + for i := 0; i < maxPendingOrderEvents; i++ { + var queued bool + pending, queued = enqueuePendingOrderEvent(pending, keys, &websocket.OrderEvent{ + EventTime: int64(i), + OrderID: 100, + }) + if !queued { + t.Fatalf("event %d was rejected before the queue reached its limit", i) + } + } + if len(pending) != maxPendingOrderEvents { + t.Fatalf("queued %d events, want %d", len(pending), maxPendingOrderEvents) + } + + duplicate := pending[len(pending)-1] + var queued bool + pending, queued = enqueuePendingOrderEvent(pending, keys, duplicate) + if !queued || len(pending) != maxPendingOrderEvents { + t.Fatalf("duplicate event should not grow or overflow the queue: queued=%t len=%d", queued, len(pending)) + } + + _, queued = enqueuePendingOrderEvent(pending, keys, &websocket.OrderEvent{EventTime: maxPendingOrderEvents, OrderID: 100}) + if queued { + t.Fatal("event beyond the hard queue limit was accepted") + } +} diff --git a/packages/sdk-go/services/reconciler_stream_test.go b/packages/sdk-go/services/reconciler_stream_test.go new file mode 100644 index 00000000..661160ec --- /dev/null +++ b/packages/sdk-go/services/reconciler_stream_test.go @@ -0,0 +1,491 @@ +package services_test + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/gemini/gemini-go/auth" + "github.com/gemini/gemini-go/services" + "github.com/gemini/gemini-go/transport" + "github.com/gemini/gemini-go/websocket" +) + +type reconcilerStreamConn struct { + closed chan struct{} + closeOnce sync.Once + reads chan []byte +} + +func (c *reconcilerStreamConn) ReadMessage(ctx context.Context) (int, []byte, error) { + select { + case <-ctx.Done(): + return 0, nil, ctx.Err() + case <-c.closed: + return 0, nil, errors.New("test websocket closed") + case payload := <-c.reads: + return websocket.TextMessage, payload, nil + } +} + +func (c *reconcilerStreamConn) WriteMessage(ctx context.Context, _ int, payload []byte) error { + var request websocket.RequestFrame + if err := json.Unmarshal(payload, &request); err != nil { + return err + } + ack, err := json.Marshal(map[string]any{ + "id": request.ID, + "status": http.StatusOK, + "result": map[string]any{}, + }) + if err != nil { + return err + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-c.closed: + return errors.New("test websocket closed") + case c.reads <- ack: + return nil + } +} + +func (c *reconcilerStreamConn) Close() error { + c.closeOnce.Do(func() { close(c.closed) }) + return nil +} + +type reconcilerStreamDialer struct { + mu sync.Mutex + conn *reconcilerStreamConn +} + +func (d *reconcilerStreamDialer) Dial(context.Context, string, http.Header) (websocket.Conn, *http.Response, error) { + conn := &reconcilerStreamConn{ + closed: make(chan struct{}), + reads: make(chan []byte, 16), + } + d.mu.Lock() + d.conn = conn + d.mu.Unlock() + return conn, &http.Response{StatusCode: http.StatusSwitchingProtocols}, nil +} + +func (d *reconcilerStreamDialer) latest() *reconcilerStreamConn { + d.mu.Lock() + defer d.mu.Unlock() + return d.conn +} + +func TestQuoteReconciler_StartStreamingHydratesAndAppliesEvents(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/orders" { + http.NotFound(w, r) + return + } + _, _ = w.Write([]byte(`[]`)) + })) + t.Cleanup(server.Close) + + tradingSvc := services.NewTradingService( + transport.NewClient(transport.WithHTTPClient(server.Client())), + server.URL, + ) + dialer := &reconcilerStreamDialer{} + wsClient := websocket.NewPrivateClient( + "wss://ws.gemini.com", + auth.NewHMAC(auth.APIKey("key"), auth.APISecret("secret")), + websocket.WithDialer(dialer), + ) + t.Cleanup(func() { _ = wsClient.Close() }) + + ctx, cancel := context.WithCancel(context.Background()) + reconciler := services.NewQuoteReconciler(tradingSvc, wsClient, "BTCUSD") + errCh, err := reconciler.StartStreaming(ctx) + if err != nil { + t.Fatalf("StartStreaming failed: %v", err) + } + + conn := dialer.latest() + if conn == nil { + t.Fatal("expected streaming dialer to establish a connection") + } + conn.reads <- []byte(`{"e":"order","s":"BTCUSD","S":"BUY","p":"65000","q":"0.1","X":"OPEN","i":987}`) + + deadline := time.Now().Add(time.Second) + var observed bool + for time.Now().Before(deadline) { + if orders := reconciler.ActiveOrders(); len(orders) == 1 && orders[0].OrderID == "987" { + observed = true + break + } + time.Sleep(time.Millisecond) + } + if !observed { + t.Fatal("streaming reconciler did not apply order event") + } + cancel() + select { + case _, ok := <-errCh: + if ok { + t.Fatal("expected streaming error channel to close without an error") + } + case <-time.After(time.Second): + t.Fatal("streaming goroutine did not stop after cancellation") + } +} + +func TestQuoteReconciler_RehydratesAfterOrdinaryReconnect(t *testing.T) { + var orderRequests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/orders" { + http.NotFound(w, r) + return + } + if orderRequests.Add(1) == 1 { + _, _ = w.Write([]byte(`[]`)) + return + } + _, _ = w.Write([]byte(`[{"order_id":"987","symbol":"BTCUSD","price":"65000","original_amount":"0.1","side":"buy"}]`)) + })) + t.Cleanup(server.Close) + + tradingSvc := services.NewTradingService( + transport.NewClient(transport.WithHTTPClient(server.Client())), + server.URL, + ) + dialer := &reconcilerStreamDialer{} + wsClient := websocket.NewPrivateClient( + "wss://ws.gemini.com", + auth.NewHMAC(auth.APIKey("key"), auth.APISecret("secret")), + websocket.WithDialer(dialer), + ) + t.Cleanup(func() { _ = wsClient.Close() }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + reconciler := services.NewQuoteReconciler(tradingSvc, wsClient, "BTCUSD") + errCh, err := reconciler.StartStreaming(ctx) + if err != nil { + t.Fatalf("StartStreaming failed: %v", err) + } + + firstConn := dialer.latest() + if firstConn == nil { + t.Fatal("expected initial streaming connection") + } + if err := firstConn.Close(); err != nil { + t.Fatalf("failed to close initial connection: %v", err) + } + + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + if orders := reconciler.ActiveOrders(); len(orders) == 1 && orders[0].OrderID == "987" { + cancel() + select { + case _, ok := <-errCh: + if ok { + t.Fatal("expected streaming error channel to close after cancellation") + } + case <-time.After(time.Second): + t.Fatal("streaming goroutine did not stop after cancellation") + } + return + } + time.Sleep(time.Millisecond) + } + t.Fatal("reconciler did not rehydrate after ordinary websocket reconnect") +} + +func TestQuoteReconciler_SerializesInitialAndReconnectHydration(t *testing.T) { + var requestCount atomic.Int32 + var activeRequests atomic.Int32 + var maxActiveRequests atomic.Int32 + firstRequestStarted := make(chan struct{}) + secondRequestStarted := make(chan struct{}) + releaseFirstRequest := make(chan struct{}) + var releaseOnce sync.Once + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/orders" { + http.NotFound(w, r) + return + } + current := activeRequests.Add(1) + defer activeRequests.Add(-1) + for { + previous := maxActiveRequests.Load() + if current <= previous || maxActiveRequests.CompareAndSwap(previous, current) { + break + } + } + + switch requestCount.Add(1) { + case 1: + close(firstRequestStarted) + <-releaseFirstRequest + case 2: + close(secondRequestStarted) + } + _, _ = w.Write([]byte(`[]`)) + })) + t.Cleanup(server.Close) + t.Cleanup(func() { releaseOnce.Do(func() { close(releaseFirstRequest) }) }) + + tradingSvc := services.NewTradingService( + transport.NewClient(transport.WithHTTPClient(server.Client())), + server.URL, + ) + dialer := &reconcilerStreamDialer{} + wsClient := websocket.NewPrivateClient( + "wss://ws.gemini.com", + auth.NewHMAC(auth.APIKey("key"), auth.APISecret("secret")), + websocket.WithDialer(dialer), + ) + t.Cleanup(func() { _ = wsClient.Close() }) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + reconciler := services.NewQuoteReconciler(tradingSvc, wsClient, "BTCUSD") + started := make(chan struct { + errCh <-chan error + err error + }, 1) + go func() { + errCh, err := reconciler.StartStreaming(ctx) + started <- struct { + errCh <-chan error + err error + }{errCh: errCh, err: err} + }() + + select { + case <-firstRequestStarted: + case <-time.After(time.Second): + t.Fatal("initial hydration did not start") + } + firstConn := dialer.latest() + if firstConn == nil { + t.Fatal("expected initial streaming connection") + } + if err := firstConn.Close(); err != nil { + t.Fatalf("closing initial test connection failed: %v", err) + } + + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) && dialer.latest() == firstConn { + time.Sleep(time.Millisecond) + } + if dialer.latest() == firstConn { + t.Fatal("reconnect did not establish a replacement connection") + } + + select { + case <-secondRequestStarted: + t.Fatal("reconnect hydration overlapped initial hydration") + case <-time.After(100 * time.Millisecond): + } + releaseOnce.Do(func() { close(releaseFirstRequest) }) + + result := <-started + if result.err != nil { + t.Fatalf("StartStreaming failed: %v", result.err) + } + select { + case <-secondRequestStarted: + case <-time.After(time.Second): + t.Fatal("reconnect hydration did not run after initial hydration completed") + } + if got := maxActiveRequests.Load(); got > 1 { + t.Fatalf("hydration requests overlapped: max active requests %d", got) + } + cancel() + select { + case _, ok := <-result.errCh: + if ok { + t.Fatal("expected streaming error channel to close without an error") + } + case <-time.After(time.Second): + t.Fatal("streaming goroutine did not stop after cancellation") + } +} + +func TestQuoteReconciler_StartStreamingBuffersEventsDuringHydration(t *testing.T) { + hydrationStarted := make(chan struct{}) + releaseHydration := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/orders" { + http.NotFound(w, r) + return + } + close(hydrationStarted) + <-releaseHydration + _, _ = w.Write([]byte(`[]`)) + })) + t.Cleanup(server.Close) + + tradingSvc := services.NewTradingService( + transport.NewClient(transport.WithHTTPClient(server.Client())), + server.URL, + ) + dialer := &reconcilerStreamDialer{} + wsClient := websocket.NewPrivateClient( + "wss://ws.gemini.com", + auth.NewHMAC(auth.APIKey("key"), auth.APISecret("secret")), + websocket.WithDialer(dialer), + ) + t.Cleanup(func() { _ = wsClient.Close() }) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + reconciler := services.NewQuoteReconciler(tradingSvc, wsClient, "BTCUSD") + resultCh := make(chan struct { + errors <-chan error + err error + }, 1) + go func() { + errors, err := reconciler.StartStreaming(ctx) + resultCh <- struct { + errors <-chan error + err error + }{errors: errors, err: err} + }() + + select { + case <-hydrationStarted: + case <-ctx.Done(): + t.Fatalf("hydration did not start: %v", ctx.Err()) + } + + conn := dialer.latest() + if conn == nil { + t.Fatal("expected streaming dialer to establish a connection before hydration") + } + conn.reads <- []byte(`{"e":"order","s":"BTCUSD","S":"BUY","p":"65000","q":"0.1","z":"0.1","X":"OPEN","i":987}`) + close(releaseHydration) + + var result struct { + errors <-chan error + err error + } + select { + case result = <-resultCh: + case <-ctx.Done(): + t.Fatalf("StartStreaming did not finish: %v", ctx.Err()) + } + if result.err != nil { + t.Fatalf("StartStreaming failed: %v", result.err) + } + + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + if orders := reconciler.ActiveOrders(); len(orders) == 1 && orders[0].OrderID == "987" { + return + } + time.Sleep(time.Millisecond) + } + t.Fatal("streaming reconciler dropped the order event received during hydration") +} + +func TestQuoteReconciler_StartStreamingDoesNotUnsubscribeOtherOrderSubscribers(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/orders" { + http.NotFound(w, r) + return + } + _, _ = w.Write([]byte(`[]`)) + })) + t.Cleanup(server.Close) + + tradingSvc := services.NewTradingService( + transport.NewClient(transport.WithHTTPClient(server.Client())), + server.URL, + ) + dialer := &reconcilerStreamDialer{} + wsClient := websocket.NewPrivateClient( + "wss://ws.gemini.com", + auth.NewHMAC(auth.APIKey("key"), auth.APISecret("secret")), + websocket.WithDialer(dialer), + ) + t.Cleanup(func() { _ = wsClient.Close() }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + otherSubscriber, err := wsClient.SubscribeOrderEvents(ctx) + if err != nil { + t.Fatalf("SubscribeOrderEvents failed: %v", err) + } + + reconciler := services.NewQuoteReconciler(tradingSvc, wsClient, "BTCUSD") + errCh, err := reconciler.StartStreaming(ctx) + if err != nil { + t.Fatalf("StartStreaming failed: %v", err) + } + cancel() + select { + case _, ok := <-errCh: + if ok { + t.Fatal("expected streaming error channel to close") + } + case <-time.After(time.Second): + t.Fatal("streaming goroutine did not stop after cancellation") + } + + select { + case _, ok := <-otherSubscriber: + if !ok { + t.Fatal("reconciler cleanup closed an unrelated order event subscriber") + } + default: + } +} + +func TestQuoteReconciler_StartStreamingRejectsDuplicateStream(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/orders" { + http.NotFound(w, r) + return + } + _, _ = w.Write([]byte(`[]`)) + })) + t.Cleanup(server.Close) + + tradingSvc := services.NewTradingService( + transport.NewClient(transport.WithHTTPClient(server.Client())), + server.URL, + ) + dialer := &reconcilerStreamDialer{} + wsClient := websocket.NewPrivateClient( + "wss://ws.gemini.com", + auth.NewHMAC(auth.APIKey("key"), auth.APISecret("secret")), + websocket.WithDialer(dialer), + ) + t.Cleanup(func() { _ = wsClient.Close() }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + reconciler := services.NewQuoteReconciler(tradingSvc, wsClient, "BTCUSD") + errCh, err := reconciler.StartStreaming(ctx) + if err != nil { + t.Fatalf("first StartStreaming failed: %v", err) + } + if _, err := reconciler.StartStreaming(ctx); !errors.Is(err, services.ErrStreamingAlreadyStarted) { + t.Fatalf("expected ErrStreamingAlreadyStarted, got %v", err) + } + cancel() + select { + case _, ok := <-errCh: + if ok { + t.Fatal("expected streaming error channel to close") + } + case <-time.After(time.Second): + t.Fatal("streaming goroutine did not stop after cancellation") + } +} diff --git a/packages/sdk-go/services/reconciler_test.go b/packages/sdk-go/services/reconciler_test.go new file mode 100644 index 00000000..a21f2e86 --- /dev/null +++ b/packages/sdk-go/services/reconciler_test.go @@ -0,0 +1,712 @@ +package services_test + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/gemini/gemini-go/generated/trading" + "github.com/gemini/gemini-go/services" + "github.com/gemini/gemini-go/transport" + "github.com/gemini/gemini-go/types" + "github.com/gemini/gemini-go/websocket" +) + +func TestReconcileResult_ErrAggregatesPartialFailures(t *testing.T) { + result := &services.ReconcileResult{Errors: []error{services.ErrStreamingAlreadyStarted, context.Canceled}} + if err := result.Err(); err == nil { + t.Fatal("expected aggregate reconciliation error") + } else { + if !errors.Is(err, services.ErrStreamingAlreadyStarted) { + t.Fatalf("aggregate error does not retain first cause: %v", err) + } + if !errors.Is(err, context.Canceled) { + t.Fatalf("aggregate error does not retain second cause: %v", err) + } + } + if (&services.ReconcileResult{}).Err() != nil { + t.Fatal("expected no error for a successful reconciliation result") + } +} + +func TestQuoteReconciler_StateDiff(t *testing.T) { + var cancelCalls int32 + var placeCalls int32 + var mu sync.Mutex + var orderCounter int64 = 1000 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch r.URL.Path { + case "/v1/orders": + // Return initial resting orders: + // Order 1: Buy 0.05 BTC @ 64950.00 + // Order 2: Buy 0.05 BTC @ 64880.00 (Stale) + // Order 3: Sell 0.05 BTC @ 65050.00 + // Order 4: Sell 0.05 BTC @ 65100.00 + ord1 := "101" + ord2 := "102" + ord3 := "103" + ord4 := "104" + sym := "BTCUSD" + p1, p2, p3, p4 := "64950.00", "64880.00", "65050.00", "65100.00" + amt := "0.05" + sideBuy := trading.LimitOrderResponseSideBuy + sideSell := trading.LimitOrderResponseSideSell + + orders := []trading.LimitOrderResponse{ + {OrderId: &ord1, Symbol: &sym, Price: &p1, OriginalAmount: &amt, Side: &sideBuy}, + {OrderId: &ord2, Symbol: &sym, Price: &p2, OriginalAmount: &amt, Side: &sideBuy}, + {OrderId: &ord3, Symbol: &sym, Price: &p3, OriginalAmount: &amt, Side: &sideSell}, + {OrderId: &ord4, Symbol: &sym, Price: &p4, OriginalAmount: &amt, Side: &sideSell}, + } + _ = json.NewEncoder(w).Encode(orders) + + case "/v1/order/cancel": + atomic.AddInt32(&cancelCalls, 1) + _ = json.NewEncoder(w).Encode(trading.CancelOrderResponse{IsCancelled: ptrBool(true), IsLive: ptrBool(false)}) + + case "/v1/order/new": + atomic.AddInt32(&placeCalls, 1) + mu.Lock() + orderCounter++ + newID := fmt.Sprintf("ord-%d", orderCounter) + mu.Unlock() + + sym := "BTCUSD" + res := trading.LimitOrderResponse{ + OrderId: &newID, + Symbol: &sym, + } + _ = json.NewEncoder(w).Encode(res) + } + })) + defer server.Close() + + trans := transport.NewClient(transport.WithHTTPClient(server.Client())) + tradingSvc := services.NewTradingService(trans, server.URL) + + reconciler := services.NewQuoteReconciler(tradingSvc, nil, "BTCUSD", + services.WithToleranceBps(0.5), + services.WithQuantization(types.MustParseDecimal("0.01"), types.MustParseDecimal("0.0001")), + ) + + ctx := context.Background() + + // 1. Hydrate state + if err := reconciler.Hydrate(ctx); err != nil { + t.Fatalf("Hydrate failed: %v", err) + } + + active := reconciler.ActiveOrders() + if len(active) != 4 { + t.Fatalf("expected 4 active orders after hydration, got %d", len(active)) + } + + // 2. Define target desired quotes: + // - Buy 0.05 @ 64950.00 (Matches resting 101 -> KEPT, 0 API calls) + // - Buy 0.05 @ 64900.00 (New -> PLACED) + // - Buy 0.05 @ 64850.00 (New -> PLACED) + // - Sell 0.05 @ 65050.00 (Matches resting 103 -> KEPT, 0 API calls) + // - Sell 0.05 @ 65100.00 (Matches resting 104 -> KEPT, 0 API calls) + // - Sell 0.05 @ 65150.00 (New -> PLACED) + // (Resting 102 @ 64880.00 is obsolete -> CANCELLED) + size := types.MustParseDecimal("0.05") + targets := []services.DesiredQuote{ + {Side: "buy", Price: types.MustParseDecimal("64950.00"), Amount: size}, + {Side: "buy", Price: types.MustParseDecimal("64900.00"), Amount: size}, + {Side: "buy", Price: types.MustParseDecimal("64850.00"), Amount: size}, + {Side: "sell", Price: types.MustParseDecimal("65050.00"), Amount: size}, + {Side: "sell", Price: types.MustParseDecimal("65100.00"), Amount: size}, + {Side: "sell", Price: types.MustParseDecimal("65150.00"), Amount: size}, + } + + result, err := reconciler.Sync(ctx, targets) + if err != nil { + t.Fatalf("Sync failed: %v", err) + } + + if result.Kept != 3 { + t.Errorf("expected 3 kept orders, got %d", result.Kept) + } + if result.Cancelled != 1 { + t.Errorf("expected 1 cancelled order, got %d", result.Cancelled) + } + if result.Placed != 3 { + t.Errorf("expected 3 placed orders, got %d", result.Placed) + } + if len(result.Errors) > 0 { + t.Errorf("unexpected errors during sync: %v", result.Errors) + } + + if atomic.LoadInt32(&cancelCalls) != 1 { + t.Errorf("expected 1 cancel HTTP call, got %d", cancelCalls) + } + if atomic.LoadInt32(&placeCalls) != 3 { + t.Errorf("expected 3 place HTTP calls, got %d", placeCalls) + } +} + +func TestQuoteReconciler_PreservesNewerEventDuringCancellation(t *testing.T) { + cancelStarted := make(chan struct{}) + releaseCancel := make(chan struct{}) + var cancelOnce sync.Once + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/order/cancel" { + http.NotFound(w, r) + return + } + cancelOnce.Do(func() { close(cancelStarted) }) + <-releaseCancel + _ = json.NewEncoder(w).Encode(trading.CancelOrderResponse{ + IsCancelled: ptrBool(true), + IsLive: ptrBool(false), + }) + })) + defer server.Close() + + reconciler := services.NewQuoteReconciler( + services.NewTradingService( + transport.NewClient(transport.WithHTTPClient(server.Client())), + server.URL, + ), + nil, + "BTCUSD", + ) + reconciler.ApplyOrderEvent(&websocket.OrderEvent{ + EventType: "order", + Symbol: "BTCUSD", + OrderID: 9001, + Side: "BUY", + Price: "100", + Quantity: "1", + OrderStatus: "NEW", + }) + + resultCh := make(chan *services.ReconcileResult, 1) + errCh := make(chan error, 1) + go func() { + result, err := reconciler.Sync(context.Background(), nil) + resultCh <- result + errCh <- err + }() + + select { + case <-cancelStarted: + case <-time.After(time.Second): + t.Fatal("cancellation request did not start") + } + + // This event represents newer state observed while the REST cancellation + // was in flight. The stale cancellation result must not erase it. + reconciler.ApplyOrderEvent(&websocket.OrderEvent{ + EventType: "order", + Symbol: "BTCUSD", + OrderID: 9001, + Side: "BUY", + OrderStatus: "PARTIALLY_FILLED", + RemainingQty: "0.5", + }) + close(releaseCancel) + + select { + case err := <-errCh: + if err != nil { + t.Fatalf("Sync failed: %v", err) + } + case <-time.After(time.Second): + t.Fatal("Sync did not finish") + } + result := <-resultCh + if result.Cancelled != 1 || len(result.Errors) != 0 { + t.Fatalf("unexpected reconcile result: %+v", result) + } + + active := reconciler.ActiveOrders() + if len(active) != 1 || active[0].OrderID != "9001" || active[0].Amount.String() != "0.5" { + t.Fatalf("newer order state was lost after cancellation: %+v", active) + } +} + +func TestQuoteReconciler_ApplyOrderEvent(t *testing.T) { + reconciler := services.NewQuoteReconciler(nil, nil, "BTCUSD") + + // 1. Ingest NEW order event + reconciler.ApplyOrderEvent(&websocket.OrderEvent{ + EventType: "order", + Symbol: "BTCUSD", + OrderID: 7701, + Side: "BUY", + Price: "65000.00", + Quantity: "0.10", + OrderStatus: "NEW", + }) + + active := reconciler.ActiveOrders() + if len(active) != 1 || active[0].OrderID != "7701" { + t.Fatalf("expected 1 active order with ID 7701, got: %+v", active) + } + + // 2. Ingest PARTIALLY_FILLED event (0.06 remaining) + reconciler.ApplyOrderEvent(&websocket.OrderEvent{ + EventType: "order", + Symbol: "BTCUSD", + OrderID: 7701, + Side: "BUY", + OrderStatus: "PARTIALLY_FILLED", + RemainingQty: "0.06", + }) + + active = reconciler.ActiveOrders() + if len(active) != 1 || active[0].Amount.String() != "0.06" { + t.Fatalf("expected remaining amount 0.06, got: %+v", active[0].Amount.String()) + } + + // 2b. Ingest second PARTIALLY_FILLED event (0.03 remaining) + reconciler.ApplyOrderEvent(&websocket.OrderEvent{ + EventType: "order", + Symbol: "BTCUSD", + OrderID: 7701, + Side: "BUY", + OrderStatus: "PARTIALLY_FILLED", + RemainingQty: "0.03", + }) + + active = reconciler.ActiveOrders() + if len(active) != 1 || active[0].Amount.String() != "0.03" { + t.Fatalf("expected remaining amount 0.03 after second fill, got: %+v", active[0].Amount.String()) + } + + // 3. Ingest FILLED event + reconciler.ApplyOrderEvent(&websocket.OrderEvent{ + EventType: "order", + Symbol: "BTCUSD", + OrderID: 7701, + OrderStatus: "FILLED", + }) + + active = reconciler.ActiveOrders() + if len(active) != 0 { + t.Fatalf("expected 0 active orders after FILLED, got %d", len(active)) + } +} + +func TestQuoteReconciler_UppercaseSides(t *testing.T) { + var placeCalls int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/v1/order/new" { + atomic.AddInt32(&placeCalls, 1) + sym := "BTCUSD" + id := "ord-99" + _ = json.NewEncoder(w).Encode(trading.LimitOrderResponse{ + OrderId: &id, + Symbol: &sym, + }) + } + })) + defer server.Close() + + trans := transport.NewClient(transport.WithHTTPClient(server.Client())) + tradingSvc := services.NewTradingService(trans, server.URL) + reconciler := services.NewQuoteReconciler(tradingSvc, nil, "BTCUSD") + + // Target with uppercase "BUY" and "SELL" + targets := []services.DesiredQuote{ + {Side: "BUY", Price: types.MustParseDecimal("65000.00"), Amount: types.MustParseDecimal("0.01")}, + {Side: "SELL", Price: types.MustParseDecimal("66000.00"), Amount: types.MustParseDecimal("0.01")}, + } + + result, err := reconciler.Sync(context.Background(), targets) + if err != nil { + t.Fatalf("Sync failed: %v", err) + } + if len(result.Errors) > 0 { + t.Fatalf("unexpected errors: %v", result.Errors) + } + if result.Placed != 2 { + t.Fatalf("expected 2 placed orders, got %d", result.Placed) + } +} + +func TestQuoteReconciler_PreservesWebSocketStateOnPlacement(t *testing.T) { + var reconciler *services.QuoteReconciler + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/v1/order/new" { + sym := "BTCUSD" + id := "9999" + + // Simulate immediate WebSocket partial fill arriving before REST response finishes + reconciler.ApplyOrderEvent(&websocket.OrderEvent{ + EventType: "order", + Symbol: "BTCUSD", + OrderID: 9999, + Side: "BUY", + Price: "65000.00", + OrderStatus: "PARTIALLY_FILLED", + RemainingQty: "0.02", // only 0.02 remaining + }) + + _ = json.NewEncoder(w).Encode(trading.LimitOrderResponse{ + OrderId: &id, + Symbol: &sym, + }) + } + })) + defer server.Close() + + trans := transport.NewClient(transport.WithHTTPClient(server.Client())) + tradingSvc := services.NewTradingService(trans, server.URL) + reconciler = services.NewQuoteReconciler(tradingSvc, nil, "BTCUSD") + + targets := []services.DesiredQuote{ + {Side: "buy", Price: types.MustParseDecimal("65000.00"), Amount: types.MustParseDecimal("0.10")}, + } + + _, err := reconciler.Sync(context.Background(), targets) + if err != nil { + t.Fatalf("Sync failed: %v", err) + } + + active := reconciler.ActiveOrders() + if len(active) != 1 { + t.Fatalf("expected 1 active order, got %d", len(active)) + } + // The active amount must remain 0.02 (from WebSocket), NOT 0.10 (from original REST request) + if active[0].Amount.String() != "0.02" { + t.Fatalf("expected active amount to be preserved as 0.02 from WebSocket, got %s", active[0].Amount.String()) + } +} + +func TestQuoteReconciler_OpenAndModifiedStatuses(t *testing.T) { + reconciler := services.NewQuoteReconciler(nil, nil, "BTCUSD") + + // 1. OPEN event + reconciler.ApplyOrderEvent(&websocket.OrderEvent{ + EventType: "order", + Symbol: "BTCUSD", + OrderID: 8888, + Side: "BUY", + Price: "60000.00", + Quantity: "0.5", + OrderStatus: "OPEN", + }) + + active := reconciler.ActiveOrders() + if len(active) != 1 || active[0].OrderID != "8888" || active[0].Price.String() != "60000" { + t.Fatalf("expected order 8888 to be tracked after OPEN event, got %+v", active) + } + + // 2. MODIFIED event + reconciler.ApplyOrderEvent(&websocket.OrderEvent{ + EventType: "order", + Symbol: "BTCUSD", + OrderID: 8888, + Side: "BUY", + Price: "61000.00", + Quantity: "0.75", + RemainingQty: "0.25", + OrderStatus: "MODIFIED", + }) + + active = reconciler.ActiveOrders() + if len(active) != 1 || active[0].Price.String() != "61000" || active[0].Amount.String() != "0.25" || active[0].OriginalAmount.String() != "0.75" { + t.Fatalf("expected order 8888 to be updated after MODIFIED event, got %+v", active) + } +} + +func TestQuoteReconciler_ConcurrentSyncSerialized(t *testing.T) { + var placeCount atomic.Int64 + var orderCounter atomic.Int64 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/v1/order/new" { + placeCount.Add(1) + id := fmt.Sprintf("%d", orderCounter.Add(1)) + sym := "BTCUSD" + _ = json.NewEncoder(w).Encode(trading.LimitOrderResponse{ + OrderId: &id, + Symbol: &sym, + }) + } + })) + defer server.Close() + + trans := transport.NewClient(transport.WithHTTPClient(server.Client())) + tradingSvc := services.NewTradingService(trans, server.URL) + reconciler := services.NewQuoteReconciler(tradingSvc, nil, "BTCUSD") + + const goroutines = 10 + var wg sync.WaitGroup + wg.Add(goroutines) + + targets := []services.DesiredQuote{ + {Side: "buy", Price: types.MustParseDecimal("60000.00"), Amount: types.MustParseDecimal("0.1")}, + } + + for i := 0; i < goroutines; i++ { + go func() { + defer wg.Done() + _, _ = reconciler.Sync(context.Background(), targets) + }() + } + wg.Wait() + + // Only the first Sync should place the order; subsequent Syncs see it as resting and keep it! + if placeCount.Load() != 1 { + t.Fatalf("expected exactly 1 order placement across serialized concurrent Syncs, got %d", placeCount.Load()) + } +} + +func TestQuoteReconciler_DoesNotPlaceAfterCancellationFailure(t *testing.T) { + var placeCalls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/order/cancel": + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"result":"error","reason":"InvalidPayload"}`)) + case "/v1/order/new": + placeCalls.Add(1) + w.WriteHeader(http.StatusInternalServerError) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + tradingSvc := services.NewTradingService(transport.NewClient(transport.WithHTTPClient(server.Client())), server.URL) + reconciler := services.NewQuoteReconciler(tradingSvc, nil, "BTCUSD") + reconciler.ApplyOrderEvent(&websocket.OrderEvent{ + Symbol: "BTCUSD", OrderID: 100, Side: "BUY", Price: "65000", Quantity: "0.1", OrderStatus: "OPEN", + }) + + result, err := reconciler.Sync(context.Background(), []services.DesiredQuote{{ + Side: "buy", Price: types.MustParseDecimal("65100"), Amount: types.MustParseDecimal("0.1"), + }}) + if err != nil { + t.Fatalf("Sync failed: %v", err) + } + if len(result.Errors) == 0 || placeCalls.Load() != 0 { + t.Fatalf("expected cancellation failure to block placements, result=%+v placeCalls=%d", result, placeCalls.Load()) + } +} + +func TestQuoteReconciler_RejectsLiveCancellationResponse(t *testing.T) { + var placeCalls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/order/cancel": + _ = json.NewEncoder(w).Encode(trading.CancelOrderResponse{ + IsCancelled: ptrBool(false), + IsLive: ptrBool(true), + }) + case "/v1/order/new": + placeCalls.Add(1) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + tradingSvc := services.NewTradingService(transport.NewClient(transport.WithHTTPClient(server.Client())), server.URL) + reconciler := services.NewQuoteReconciler(tradingSvc, nil, "BTCUSD") + reconciler.ApplyOrderEvent(&websocket.OrderEvent{ + Symbol: "BTCUSD", OrderID: 100, Side: "BUY", Price: "65000", Quantity: "0.1", OrderStatus: "OPEN", + }) + + result, err := reconciler.Sync(context.Background(), []services.DesiredQuote{{ + Side: "buy", Price: types.MustParseDecimal("65100"), Amount: types.MustParseDecimal("0.1"), + }}) + if err != nil { + t.Fatalf("Sync failed: %v", err) + } + if len(result.Errors) == 0 || placeCalls.Load() != 0 || len(reconciler.ActiveOrders()) != 1 { + t.Fatalf("expected live cancellation response to block replacement, result=%+v places=%d active=%+v", result, placeCalls.Load(), reconciler.ActiveOrders()) + } + + if err := reconciler.CancelAll(context.Background()); err == nil { + t.Fatal("expected CancelAll to reject a response that still reports the order as live") + } + if len(reconciler.ActiveOrders()) != 1 { + t.Fatal("CancelAll must preserve tracking when cancellation status is ambiguous") + } +} + +func TestQuoteReconciler_RejectsNonLiveMakerOrCancelResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/order/new" { + http.NotFound(w, r) + return + } + id := "101" + _ = json.NewEncoder(w).Encode(trading.LimitOrderResponse{OrderId: &id, IsCancelled: ptrBool(true), IsLive: ptrBool(false)}) + })) + t.Cleanup(server.Close) + + tradingSvc := services.NewTradingService(transport.NewClient(transport.WithHTTPClient(server.Client())), server.URL) + reconciler := services.NewQuoteReconciler(tradingSvc, nil, "BTCUSD") + result, err := reconciler.Sync(context.Background(), []services.DesiredQuote{{ + Side: "buy", Price: types.MustParseDecimal("65000"), Amount: types.MustParseDecimal("0.1"), + }}) + if err != nil { + t.Fatalf("Sync failed: %v", err) + } + if result.Placed != 0 || len(result.Errors) == 0 || len(reconciler.ActiveOrders()) != 0 { + t.Fatalf("expected non-live response to be rejected, result=%+v active=%+v", result, reconciler.ActiveOrders()) + } +} + +func ptrBool(v bool) *bool { return &v } + +func TestQuoteReconciler_CancelAllClearsOnlyAfterSuccess(t *testing.T) { + var fail atomic.Bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/order/cancel" { + http.NotFound(w, r) + return + } + if fail.Load() { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"result":"error","reason":"InvalidPayload"}`)) + return + } + _, _ = w.Write([]byte(`{"order_id":"123","is_cancelled":true,"is_live":false}`)) + })) + t.Cleanup(server.Close) + + tradingSvc := services.NewTradingService( + transport.NewClient(transport.WithHTTPClient(server.Client())), + server.URL, + ) + reconciler := services.NewQuoteReconciler(tradingSvc, nil, "BTCUSD") + reconciler.ApplyOrderEvent(&websocket.OrderEvent{ + EventType: "order", + Symbol: "BTCUSD", + OrderID: 123, + Side: "BUY", + Price: "65000", + Quantity: "0.1", + OrderStatus: "OPEN", + }) + + fail.Store(true) + if err := reconciler.CancelAll(context.Background()); err == nil { + t.Fatal("expected CancelAll backend error") + } + if len(reconciler.ActiveOrders()) != 1 { + t.Fatal("failed CancelAll must preserve tracked orders") + } + + fail.Store(false) + if err := reconciler.CancelAll(context.Background()); err != nil { + t.Fatalf("CancelAll failed: %v", err) + } + if len(reconciler.ActiveOrders()) != 0 { + t.Fatal("successful CancelAll must clear tracked orders") + } +} + +func TestQuoteReconciler_StartStreamingConfigurationAndHydrationErrors(t *testing.T) { + if _, err := services.NewQuoteReconciler(nil, nil, "BTCUSD").StartStreaming(context.Background()); err == nil { + t.Fatal("expected StartStreaming to reject a missing WebSocket client") + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte(`{"result":"error","reason":"ServiceUnavailable"}`)) + })) + t.Cleanup(server.Close) + + tradingSvc := services.NewTradingService( + transport.NewClient(transport.WithHTTPClient(server.Client())), + server.URL, + ) + wsClient := websocket.NewClient("wss://ws.gemini.com") + if _, err := services.NewQuoteReconciler(tradingSvc, wsClient, "BTCUSD").StartStreaming(context.Background()); err == nil { + t.Fatal("expected StartStreaming to return initial hydration error") + } + var nilContext context.Context + if _, err := services.NewQuoteReconciler(tradingSvc, wsClient, "BTCUSD").StartStreaming(nilContext); err == nil { + t.Fatal("expected StartStreaming to reject a nil context during hydration") + } +} + +func TestQuoteReconciler_MasterAccountIsPropagated(t *testing.T) { + var mu sync.Mutex + requests := make(map[string]map[string]any) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var payload map[string]any + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + mu.Lock() + requests[r.URL.Path] = payload + mu.Unlock() + + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/v1/orders": + orderID, symbol, price, amount := "101", "BTCUSD", "64950", "0.05" + side := trading.LimitOrderResponseSideBuy + _ = json.NewEncoder(w).Encode([]trading.LimitOrderResponse{{ + OrderId: &orderID, + Symbol: &symbol, + Price: &price, + OriginalAmount: &amount, + Side: &side, + }}) + case "/v1/order/cancel": + _ = json.NewEncoder(w).Encode(trading.CancelOrderResponse{IsCancelled: ptrBool(true), IsLive: ptrBool(false)}) + case "/v1/order/new": + orderID, symbol := "102", "BTCUSD" + _ = json.NewEncoder(w).Encode(trading.LimitOrderResponse{OrderId: &orderID, Symbol: &symbol}) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + tradingSvc := services.NewTradingService( + transport.NewClient(transport.WithHTTPClient(server.Client())), + server.URL, + ) + reconciler := services.NewQuoteReconciler( + tradingSvc, + nil, + "BTCUSD", + services.WithAccount(" subaccount-1 "), + ) + + if err := reconciler.Hydrate(context.Background()); err != nil { + t.Fatalf("Hydrate failed: %v", err) + } + result, err := reconciler.Sync(context.Background(), []services.DesiredQuote{{ + Side: "sell", + Price: types.MustParseDecimal("65050"), + Amount: types.MustParseDecimal("0.05"), + }}) + if err != nil || len(result.Errors) != 0 { + t.Fatalf("Sync failed: %v, errors: %v", err, result.Errors) + } + + mu.Lock() + defer mu.Unlock() + for _, path := range []string{"/v1/orders", "/v1/order/cancel", "/v1/order/new"} { + account, ok := requests[path]["account"] + if !ok || account != "subaccount-1" { + t.Errorf("expected account on %s request, got %v", path, requests[path]) + } + } +} diff --git a/packages/sdk-go/services/sports.go b/packages/sdk-go/services/sports.go new file mode 100644 index 00000000..3206a9f4 --- /dev/null +++ b/packages/sdk-go/services/sports.go @@ -0,0 +1,241 @@ +package services + +import ( + "regexp" + "strings" + + "github.com/gemini/gemini-go/generated/predictions" +) + +// Sports event tickers use a stable market-family suffix followed by an +// optional contract outcome. Player props intentionally use the documented +// PP{STAT} grammar rather than a finite allowlist: the API can add a stat +// without changing the contest identity format. +var contestSuffixPattern = regexp.MustCompile(`-(M|S|T|TT|ML|SPREAD|TOTAL|REGS|REGT|1HS|1HT|2HS|2HT|1QS|1QT|2QS|2QT|3QS|3QT|4QS|4QT|WIN|T5|T10|T20|MC|A|CS|PP[A-Z0-9]+)(-[A-Z0-9]+)?$`) + +// SportsContestCluster groups the prediction-market events that represent one +// sports contest. It is a domain view over generated Event models; the REST +// service continues to return the raw spec-shaped events. +type SportsContestCluster struct { + ContestID string + Title string + Sport predictions.SportsMarketSport + Category string + Tags []string + Moneyline *predictions.Event + Spreads []predictions.Event + Totals []predictions.Event + TeamTotals []predictions.Event + PlayerProps []predictions.Event + Outrights []predictions.Event + Events []predictions.Event + ByScope map[predictions.SportsMarketScopeType][]predictions.Event +} + +// NormalizeContestRoot converts an event ticker or contract symbol into a +// stable contest key. It accepts the GEMI-prefixed symbols used by the API and +// removes common market-type and outcome suffixes. +func NormalizeContestRoot(identifier string) string { + clean := strings.TrimSpace(identifier) + if len(clean) >= len("GEMI-") && strings.EqualFold(clean[:len("GEMI-")], "GEMI-") { + clean = clean[len("GEMI-"):] + } + parts := strings.Split(clean, "-") + if len(parts) >= 4 { + switch strings.ToUpper(parts[len(parts)-2]) { + case "WIN", "T5", "T10", "T20", "MC": + return strings.Join([]string{parts[0], parts[1], parts[len(parts)-1]}, "-") + } + } + return contestSuffixPattern.ReplaceAllString(clean, "") +} + +// ExtractCleanContestTitle removes common market-family suffixes from an +// event title, preferring the moneyline event when one is available. +func ExtractCleanContestTitle(events []predictions.Event, moneyline *predictions.Event) string { + title := "" + if moneyline != nil && moneyline.Title != nil { + title = *moneyline.Title + } else { + for i := range events { + if events[i].Title != nil { + title = *events[i].Title + break + } + } + } + if title == "" { + return "" + } + for _, suffix := range []string{ + ": Total Points", + ": Point Spread", + ": Total Goals", + ": Spread", + ": Match Winner", + " Winner", + ": Quarterback Passing Yards", + } { + if index := strings.Index(title, suffix); index >= 0 { + return strings.TrimSpace(title[:index]) + } + } + if index := strings.IndexByte(title, ':'); index >= 0 { + return strings.TrimSpace(title[:index]) + } + return strings.TrimSpace(title) +} + +// BuildSportsContestCluster groups related events into the Python SDK's +// moneyline/spread/total/prop/period view. Events without sports metadata are +// retained only when their category is Sports, matching the cross-SDK helper. +func BuildSportsContestCluster(contestID string, events []predictions.Event) SportsContestCluster { + cluster := SportsContestCluster{ + ContestID: contestID, + Category: "Sports", + Events: append([]predictions.Event(nil), events...), + ByScope: make(map[predictions.SportsMarketScopeType][]predictions.Event), + } + if len(events) == 0 { + return cluster + } + + for i := range cluster.Events { + event := cluster.Events[i] + if event.SportsMarket != nil && cluster.Sport == "" { + cluster.Sport = event.SportsMarket.Sport + } + if event.Tags != nil { + for _, tag := range *event.Tags { + if !containsString(cluster.Tags, tag) { + cluster.Tags = append(cluster.Tags, tag) + } + } + } + if event.SportsMarket != nil { + scope := event.SportsMarket.Scope.Type + if scope != "" && scope != "full_contest" { + cluster.ByScope[scope] = append(cluster.ByScope[scope], event) + } + } + + marketType := "" + subject := "" + if event.SportsMarket != nil { + marketType = string(event.SportsMarket.Type) + subject = string(event.SportsMarket.Subject) + } + contractCount := 0 + if event.Contracts != nil { + contractCount = len(*event.Contracts) + } + + switch { + case marketType == "moneyline" || (subject == "contest" && marketType == "" && (contractCount == 2 || contractCount == 3)): + if cluster.Moneyline == nil { + cluster.Moneyline = &cluster.Events[i] + } + case marketType == "spread": + cluster.Spreads = append(cluster.Spreads, event) + case marketType == "total": + if subject == "team" { + cluster.TeamTotals = append(cluster.TeamTotals, event) + } else { + cluster.Totals = append(cluster.Totals, event) + } + case marketType == "futures" || (marketType == "prop" && (subject == "participant" || subject == "other") && strings.Contains(strings.ToUpper(eventTicker(event)), "WIN")): + cluster.Outrights = append(cluster.Outrights, event) + case subject == "player" || marketType == "prop": + cluster.PlayerProps = append(cluster.PlayerProps, event) + default: + cluster.Outrights = append(cluster.Outrights, event) + } + } + cluster.Title = ExtractCleanContestTitle(cluster.Events, cluster.Moneyline) + return cluster +} + +// ClusterSportsEvents groups sports events by normalized contest root while +// preserving the first-seen order of the roots. +func ClusterSportsEvents(events []predictions.Event) []SportsContestCluster { + groups := make(map[string][]predictions.Event) + order := make([]string, 0) + for _, event := range events { + if !isSportsEvent(event) { + continue + } + root := NormalizeContestRoot(eventTicker(event)) + if _, exists := groups[root]; !exists { + order = append(order, root) + } + groups[root] = append(groups[root], event) + } + clusters := make([]SportsContestCluster, 0, len(order)) + for _, root := range order { + clusters = append(clusters, BuildSportsContestCluster(root, groups[root])) + } + return clusters +} + +// ResolveSportsContest resolves an event, contract symbol, contest root, or +// search result from an already-fetched event set. It deliberately performs no +// network requests; callers can use PredictionsService.GetEvents or one of the +// specialized list methods to obtain candidates first. +func ResolveSportsContest(events []predictions.Event, identifier string) (*SportsContestCluster, bool) { + if strings.TrimSpace(identifier) == "" { + return nil, false + } + root := NormalizeContestRoot(identifier) + for _, event := range events { + if isSportsEvent(event) && eventHasDirectContractMatch(event, identifier) { + root = NormalizeContestRoot(eventTicker(event)) + break + } + } + matching := make([]predictions.Event, 0) + for _, event := range events { + if isSportsEvent(event) && strings.EqualFold(NormalizeContestRoot(eventTicker(event)), root) { + matching = append(matching, event) + } + } + if len(matching) == 0 { + return nil, false + } + actualRoot := NormalizeContestRoot(eventTicker(matching[0])) + cluster := BuildSportsContestCluster(actualRoot, matching) + return &cluster, true +} + +func isSportsEvent(event predictions.Event) bool { + return event.SportsMarket != nil || (event.Category != nil && strings.EqualFold(*event.Category, "sports")) +} + +func eventTicker(event predictions.Event) string { + if event.Ticker == nil { + return "" + } + return *event.Ticker +} + +func eventHasDirectContractMatch(event predictions.Event, identifier string) bool { + if event.Contracts == nil { + return false + } + for _, contract := range *event.Contracts { + for _, candidate := range []*string{contract.InstrumentSymbol, contract.Ticker} { + if candidate != nil && strings.EqualFold(strings.TrimSpace(*candidate), strings.TrimSpace(identifier)) { + return true + } + } + } + return false +} + +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} diff --git a/packages/sdk-go/services/sports_test.go b/packages/sdk-go/services/sports_test.go new file mode 100644 index 00000000..156ff5e8 --- /dev/null +++ b/packages/sdk-go/services/sports_test.go @@ -0,0 +1,111 @@ +package services_test + +import ( + "reflect" + "testing" + + "github.com/gemini/gemini-go/generated/predictions" + "github.com/gemini/gemini-go/services" +) + +func TestNormalizeContestRoot(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"GEMI-NBA-2026-LAL-BOS-M", "NBA-2026-LAL-BOS"}, + {"NBA-2026-LAL-BOS-SPREAD-3", "NBA-2026-LAL-BOS"}, + {"NBA-2026-LAL-BOS-PPYDS-25", "NBA-2026-LAL-BOS"}, + {"NBA-2026-LAL-BOS-PPPTS-25", "NBA-2026-LAL-BOS"}, + {"NBA-2026-LAL-BOS-PPREB-8", "NBA-2026-LAL-BOS"}, + {"NBA-2026-LAL-BOS-PPAST-7", "NBA-2026-LAL-BOS"}, + {"NBA-2026-LAL-BOS-PP3PM-3", "NBA-2026-LAL-BOS"}, + {"NFL-2026-KC-SF-PPRYDS-50", "NFL-2026-KC-SF"}, + {"EPL-2026-ARS-CHE-A-ARS", "EPL-2026-ARS-CHE"}, + {"FIFAWC-2026-BRA-ARG-CS-BRA21", "FIFAWC-2026-BRA-ARG"}, + {" GEMI-NFL-2026-KC-SF-WIN-KC ", "NFL-2026-KC"}, + {"NBA-2026-LAL-BOS", "NBA-2026-LAL-BOS"}, + } + for _, test := range tests { + t.Run(test.input, func(t *testing.T) { + if got := services.NormalizeContestRoot(test.input); got != test.want { + t.Fatalf("NormalizeContestRoot(%q) = %q, want %q", test.input, got, test.want) + } + }) + } +} + +func TestClusterSportsEvents(t *testing.T) { + category := "Sports" + tags := []string{"NBA", "Basketball"} + titleMoneyline := "Lakers vs Celtics: Match Winner" + titleSpread := "Lakers vs Celtics: Point Spread" + titleProp := "Lakers vs Celtics: Player Points" + moneylineTicker := "GEMI-NBA-2026-LAL-BOS-M" + spreadTicker := "GEMI-NBA-2026-LAL-BOS-SPREAD-3" + propTicker := "GEMI-NBA-2026-LAL-BOS-PPYDS-25" + contractSymbol := "GEMI-NBA-2026-LAL-BOS-LAL" + + events := []predictions.Event{ + { + Category: &category, Ticker: &moneylineTicker, Title: &titleMoneyline, Tags: &tags, + Contracts: &[]predictions.Contract{{InstrumentSymbol: &contractSymbol}, {}}, + SportsMarket: &predictions.SportsMarket{ + Sport: predictions.Basketball, Type: predictions.SportsMarketTypeMoneyline, + Subject: predictions.SportsMarketSubjectContest, + Scope: predictions.SportsMarketScope{Type: predictions.SportsMarketScopeTypeFullContest}, + }, + }, + { + Category: &category, Ticker: &spreadTicker, Title: &titleSpread, Tags: &tags, + SportsMarket: &predictions.SportsMarket{ + Sport: predictions.Basketball, Type: predictions.SportsMarketTypeSpread, + Subject: predictions.SportsMarketSubjectContest, + Scope: predictions.SportsMarketScope{Type: predictions.SportsMarketScopeTypeGame}, + }, + }, + { + Category: &category, Ticker: &propTicker, Title: &titleProp, + SportsMarket: &predictions.SportsMarket{ + Sport: predictions.Basketball, Type: predictions.SportsMarketTypeProp, + Subject: predictions.SportsMarketSubjectPlayer, + Scope: predictions.SportsMarketScope{Type: predictions.SportsMarketScopeTypeGame}, + }, + }, + {Category: stringPointer("Crypto"), Ticker: stringPointer("BTC-PRICE-M")}, + } + + clusters := services.ClusterSportsEvents(events) + if len(clusters) != 1 { + t.Fatalf("expected one sports cluster, got %d", len(clusters)) + } + cluster := clusters[0] + if cluster.Title != "Lakers vs Celtics" || cluster.Sport != predictions.Basketball || cluster.Moneyline == nil { + t.Fatalf("unexpected cluster identity: %+v", cluster) + } + if len(cluster.Spreads) != 1 || len(cluster.PlayerProps) != 1 || len(cluster.ByScope[predictions.SportsMarketScopeTypeGame]) != 2 { + t.Fatalf("unexpected cluster buckets: %+v", cluster) + } + if !reflect.DeepEqual(cluster.Tags, []string{"NBA", "Basketball"}) { + t.Fatalf("unexpected deduplicated tags: %v", cluster.Tags) + } + + resolved, ok := services.ResolveSportsContest(events, "GEMI-NBA-2026-LAL-BOS-SPREAD-3") + if !ok || resolved == nil || resolved.ContestID != "NBA-2026-LAL-BOS" || len(resolved.Events) != 3 { + t.Fatalf("ResolveSportsContest failed: ok=%v cluster=%+v", ok, resolved) + } + if resolved, ok := services.ResolveSportsContest(events, contractSymbol); !ok || resolved == nil || len(resolved.Events) != 3 { + t.Fatalf("contract-symbol resolution failed: ok=%v cluster=%+v", ok, resolved) + } + if _, ok := services.ResolveSportsContest(events, "NFL-2026-KC-SF"); ok { + t.Fatal("expected unknown contest to remain unresolved") + } +} + +func TestExtractCleanContestTitle(t *testing.T) { + title := "French Open: Total Games" + event := predictions.Event{Title: &title} + if got := services.ExtractCleanContestTitle([]predictions.Event{event}, nil); got != "French Open" { + t.Fatalf("unexpected cleaned title: %q", got) + } +} diff --git a/packages/sdk-go/services/staking.go b/packages/sdk-go/services/staking.go new file mode 100644 index 00000000..6a4691f5 --- /dev/null +++ b/packages/sdk-go/services/staking.go @@ -0,0 +1,91 @@ +package services + +import ( + "context" + + "github.com/gemini/gemini-go/generated/account" + "github.com/gemini/gemini-go/transport" +) + +// StakingService provides access to Gemini Staking deposits, redemptions, rates, rewards, and history. +type StakingService struct { + baseService + public baseService +} + +// NewStakingService creates a new StakingService. +func NewStakingService(client *transport.Client, baseURL string) *StakingService { + return NewStakingServiceWithPublicClient(client, client, baseURL) +} + +// NewStakingServiceWithPublicClient creates a staking service with separate +// transports for public rates and authenticated account operations. +func NewStakingServiceWithPublicClient(privateClient, publicClient *transport.Client, baseURL string) *StakingService { + return &StakingService{ + baseService: newBaseService(privateClient, baseURL), + public: newBaseService(publicClient, baseURL), + } +} + +// 1. GetStakingBalances returns the current staked balances and amounts available to unstake. +func (s *StakingService) GetStakingBalances(ctx context.Context, req *account.ListStakingBalancesJSONBody) ([]account.StakingBalance, error) { + if req == nil { + req = &account.ListStakingBalancesJSONBody{} + } + var res []account.StakingBalance + if err := s.post(ctx, "/v1/balances/staking", req, &res); err != nil { + return nil, err + } + return res, nil +} + +// 2. Stake initiates a crypto staking deposit for a supported asset. +func (s *StakingService) Stake(ctx context.Context, req *account.StakeCryptoFundsJSONBody) (*account.StakingDeposit, error) { + var res account.StakingDeposit + if err := s.post(ctx, "/v1/staking/stake", req, &res); err != nil { + return nil, err + } + return &res, nil +} + +// 3. Unstake initiates a staking redemption/withdrawal back to the exchange account. +func (s *StakingService) Unstake(ctx context.Context, req *account.UnstakeCryptoFundsJSONBody) (*account.StakingWithdrawal, error) { + var res account.StakingWithdrawal + if err := s.post(ctx, "/v1/staking/unstake", req, &res); err != nil { + return nil, err + } + return &res, nil +} + +// 4. GetStakingHistory retrieves historical staking transactions (deposits, redemptions, accruals). +func (s *StakingService) GetStakingHistory(ctx context.Context, req *account.ListStakingEventHistoryJSONBody) ([]account.StakingHistory, error) { + if req == nil { + req = &account.ListStakingEventHistoryJSONBody{} + } + var res []account.StakingHistory + if err := s.post(ctx, "/v1/staking/history", req, &res); err != nil { + return nil, err + } + return res, nil +} + +// 5. GetStakingRates retrieves current Gemini Staking interest rates (in bps) and APY grouped by Provider UUID and Currency. +func (s *StakingService) GetStakingRates(ctx context.Context) (map[string]map[string]account.StakingRate, error) { + var res map[string]map[string]account.StakingRate + if err := s.public.get(ctx, "/v1/staking/rates", &res); err != nil { + return nil, err + } + return res, nil +} + +// 6. GetStakingRewards retrieves historical staking reward distributions grouped by Provider UUID and Currency. +func (s *StakingService) GetStakingRewards(ctx context.Context, req *account.ListStakingRewardsJSONBody) (map[string]map[string]account.StakingRewards, error) { + if req == nil { + req = &account.ListStakingRewardsJSONBody{} + } + var res map[string]map[string]account.StakingRewards + if err := s.post(ctx, "/v1/staking/rewards", req, &res); err != nil { + return nil, err + } + return res, nil +} diff --git a/packages/sdk-go/services/staking_test.go b/packages/sdk-go/services/staking_test.go new file mode 100644 index 00000000..10ac526c --- /dev/null +++ b/packages/sdk-go/services/staking_test.go @@ -0,0 +1,123 @@ +package services_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gemini/gemini-go/generated/account" + "github.com/gemini/gemini-go/services" + "github.com/gemini/gemini-go/transport" +) + +func TestStakingService_Methods(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/v1/balances/staking": + _ = json.NewEncoder(w).Encode([]map[string]any{ + {"currency": "ETH", "amount": 10.0, "amountAvailable": 8.0}, + }) + case "/v1/staking/stake": + _ = json.NewEncoder(w).Encode(map[string]any{ + "transactionId": "stk-123", "currency": "ETH", "amount": 2.5, + }) + case "/v1/staking/unstake": + _ = json.NewEncoder(w).Encode(map[string]any{ + "transactionId": "unstk-456", + "currency": "ETH", + "amount": 1.0, + }) + case "/v1/staking/history": + _ = json.NewEncoder(w).Encode([]map[string]any{ + { + "providerId": "62b21e17-2534-4b9f-afcf-b7edb609dd8d", + "transactions": []map[string]any{ + {"transactionId": "stk-123", "amountCurrency": "ETH", "amount": 2.5, "transactionType": "Deposit"}, + }, + }, + }) + case "/v1/staking/rates": + _ = json.NewEncoder(w).Encode(map[string]any{ + "62b21e17-2534-4b9f-afcf-b7edb609dd8d": map[string]any{ + "ETH": map[string]any{"rate": 95.8909, "apyPct": 4.6, "providerId": "62b21e17-2534-4b9f-afcf-b7edb609dd8d"}, + }, + }) + case "/v1/staking/rewards": + _ = json.NewEncoder(w).Encode(map[string]any{ + "62b21e17-2534-4b9f-afcf-b7edb609dd8d": map[string]any{ + "ETH": map[string]any{ + "providerId": "62b21e17-2534-4b9f-afcf-b7edb609dd8d", + "currency": "ETH", + "accrualTotal": 0.01799, + "ratePeriods": []map[string]any{ + {"currency": "ETH", "apyPct": 4.6, "accrualTotal": 0.001}, + }, + }, + }, + }) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + trans := transport.NewClient(transport.WithHTTPClient(server.Client())) + svc := services.NewStakingService(trans, server.URL) + ctx := context.Background() + + // 1. GetStakingBalances + balances, err := svc.GetStakingBalances(ctx, &account.ListStakingBalancesJSONBody{}) + if err != nil || len(balances) != 1 { + t.Fatalf("GetStakingBalances failed: %v", err) + } + + // 2. Stake + stk, err := svc.Stake(ctx, &account.StakeCryptoFundsJSONBody{ + ProviderId: "62b21e17-2534-4b9f-afcf-b7edb609dd8d", + Currency: "ETH", + Amount: "2.5", + }) + if err != nil || stk.TransactionId == nil || *stk.TransactionId != "stk-123" { + t.Fatalf("Stake failed: %v", err) + } + + // 3. Unstake + unstk, err := svc.Unstake(ctx, &account.UnstakeCryptoFundsJSONBody{ + ProviderId: "62b21e17-2534-4b9f-afcf-b7edb609dd8d", + Currency: "ETH", + Amount: "1.0", + }) + if err != nil || unstk.TransactionId == nil || *unstk.TransactionId != "unstk-456" { + t.Fatalf("Unstake failed: %v", err) + } + + // 4. GetStakingHistory + limit := 50 + hist, err := svc.GetStakingHistory(ctx, &account.ListStakingEventHistoryJSONBody{Limit: &limit}) + if err != nil || len(hist) != 1 || hist[0].Transactions == nil || len(*hist[0].Transactions) != 1 { + t.Fatalf("GetStakingHistory failed: %v", err) + } + + // 5. GetStakingRates + rates, err := svc.GetStakingRates(ctx) + if err != nil || len(rates) == 0 { + t.Fatalf("GetStakingRates failed: %v", err) + } + ethRate, ok := rates["62b21e17-2534-4b9f-afcf-b7edb609dd8d"]["ETH"] + if !ok || ethRate.Rate == nil || ethRate.Rate.String() != "95.8909" || ethRate.ApyPct == nil || ethRate.ApyPct.String() != "4.6" { + t.Fatalf("expected ETH rate 95.8909 and APY 4.6, got rate=%v apy=%v", ethRate.Rate, ethRate.ApyPct) + } + + // 6. GetStakingRewards + rewards, err := svc.GetStakingRewards(ctx, &account.ListStakingRewardsJSONBody{Since: "2022-08-20T00:00:00.000Z"}) + if err != nil || len(rewards) == 0 { + t.Fatalf("GetStakingRewards failed: %v", err) + } + ethReward, ok := rewards["62b21e17-2534-4b9f-afcf-b7edb609dd8d"]["ETH"] + if !ok || ethReward.RatePeriods == nil || len(*ethReward.RatePeriods) != 1 { + t.Fatalf("expected ETH rate periods, got %v", ethReward.RatePeriods) + } +} diff --git a/packages/sdk-go/services/trading.go b/packages/sdk-go/services/trading.go new file mode 100644 index 00000000..1732a27d --- /dev/null +++ b/packages/sdk-go/services/trading.go @@ -0,0 +1,229 @@ +package services + +import ( + "context" + "errors" + "strings" + + "github.com/gemini/gemini-go/generated/trading" + "github.com/gemini/gemini-go/transport" + "github.com/gemini/gemini-go/types" +) + +// TradingService provides access to authenticated order management and execution endpoints. +type TradingService struct { + baseService +} + +// NewTradingService creates a new TradingService. +func NewTradingService(client *transport.Client, baseURL string) *TradingService { + return &TradingService{ + baseService: newBaseService(client, baseURL), + } +} + +// NewOrder submits a new order to the Gemini matching engine. +func (s *TradingService) NewOrder(ctx context.Context, req *trading.NewOrderRequest) (*trading.LimitOrderResponse, error) { + if err := validateNewOrderRequest(req); err != nil { + return nil, err + } + var res trading.LimitOrderResponse + if err := s.post(ctx, "/v1/order/new", req, &res); err != nil { + return nil, err + } + return &res, nil +} + +func validateNewOrderRequest(req *trading.NewOrderRequest) error { + if req == nil { + return errors.New("gemini trading: order request is required") + } + + if req.StopPrice != nil && strings.TrimSpace(*req.StopPrice) == "" { + return errors.New("gemini trading: stop price must be a non-empty decimal") + } + if req.StopPrice == nil { + if req.Type == trading.NewOrderRequestTypeExchangeStopLimit { + return errors.New("gemini trading: stop-limit orders require stop price") + } + return nil + } + + if req.Type != trading.NewOrderRequestTypeExchangeStopLimit { + return errors.New("gemini trading: stop price is only valid for stop-limit orders") + } + if req.Options != nil && len(*req.Options) > 0 { + return errors.New("gemini trading: stop-limit orders cannot use execution options") + } + + price, err := types.ParseDecimal(req.Price) + if err != nil || !price.IsPositive() { + return errors.New("gemini trading: stop-limit price must be a positive decimal") + } + stopPrice, err := types.ParseDecimal(*req.StopPrice) + if err != nil || !stopPrice.IsPositive() { + return errors.New("gemini trading: stop price must be a positive decimal") + } + + switch req.Side { + case trading.NewOrderRequestSideBuy: + if stopPrice.Cmp(price) >= 0 { + return errors.New("gemini trading: buy stop price must be below the limit price") + } + case trading.NewOrderRequestSideSell: + if stopPrice.Cmp(price) <= 0 { + return errors.New("gemini trading: sell stop price must be above the limit price") + } + default: + return errors.New("gemini trading: stop-limit order side must be buy or sell") + } + return nil +} + +// OrderOption configures an outgoing order request. +type OrderOption func(*trading.NewOrderRequest) + +// WithClientOrderID assigns a custom client-specified order ID for tracking and idempotency. +func WithClientOrderID(clientOrderID string) OrderOption { + return func(req *trading.NewOrderRequest) { + req.ClientOrderId = &clientOrderID + } +} + +func withOrderAccount(account string) OrderOption { + return func(req *trading.NewOrderRequest) { + req.Account = &account + } +} + +// WithStopPrice sets a stop loss trigger price and configures the order type as exchange stop limit. +func WithStopPrice(stopPrice types.Decimal) OrderOption { + return func(req *trading.NewOrderRequest) { + s := stopPrice.String() + req.StopPrice = &s + req.Type = trading.NewOrderRequestTypeExchangeStopLimit + // Execution options apply only to exchange-limit orders. A stop-limit + // order cannot also be maker-or-cancel or immediate-or-cancel. + req.Options = nil + } +} + +func (s *TradingService) buildAndExecuteOrder(ctx context.Context, side trading.NewOrderRequestSide, symbol string, amount, price types.Decimal, optType *trading.NewOrderRequestOptions, opts []OrderOption) (*trading.LimitOrderResponse, error) { + req := &trading.NewOrderRequest{ + Symbol: symbol, + Side: side, + Type: trading.NewOrderRequestTypeExchangeLimit, + Amount: amount.String(), + Price: price.String(), + } + if optType != nil { + req.Options = &[]trading.NewOrderRequestOptions{*optType} + } + for _, opt := range opts { + opt(req) + } + return s.NewOrder(ctx, req) +} + +// PostOnlyBid places a Maker-or-Cancel buy limit order. +func (s *TradingService) PostOnlyBid(ctx context.Context, symbol string, amount, price types.Decimal, opts ...OrderOption) (*trading.LimitOrderResponse, error) { + moc := trading.MakerOrCancel + return s.buildAndExecuteOrder(ctx, trading.NewOrderRequestSideBuy, symbol, amount, price, &moc, opts) +} + +// PostOnlyAsk places a Maker-or-Cancel sell limit order. +func (s *TradingService) PostOnlyAsk(ctx context.Context, symbol string, amount, price types.Decimal, opts ...OrderOption) (*trading.LimitOrderResponse, error) { + moc := trading.MakerOrCancel + return s.buildAndExecuteOrder(ctx, trading.NewOrderRequestSideSell, symbol, amount, price, &moc, opts) +} + +// LimitBuy places a standard buy limit order. +func (s *TradingService) LimitBuy(ctx context.Context, symbol string, amount, price types.Decimal, opts ...OrderOption) (*trading.LimitOrderResponse, error) { + return s.buildAndExecuteOrder(ctx, trading.NewOrderRequestSideBuy, symbol, amount, price, nil, opts) +} + +// LimitSell places a standard sell limit order. +func (s *TradingService) LimitSell(ctx context.Context, symbol string, amount, price types.Decimal, opts ...OrderOption) (*trading.LimitOrderResponse, error) { + return s.buildAndExecuteOrder(ctx, trading.NewOrderRequestSideSell, symbol, amount, price, nil, opts) +} + +// ImmediateOrCancelBuy places an IOC buy limit order. +func (s *TradingService) ImmediateOrCancelBuy(ctx context.Context, symbol string, amount, price types.Decimal, opts ...OrderOption) (*trading.LimitOrderResponse, error) { + ioc := trading.ImmediateOrCancel + return s.buildAndExecuteOrder(ctx, trading.NewOrderRequestSideBuy, symbol, amount, price, &ioc, opts) +} + +// ImmediateOrCancelSell places an IOC sell limit order. +func (s *TradingService) ImmediateOrCancelSell(ctx context.Context, symbol string, amount, price types.Decimal, opts ...OrderOption) (*trading.LimitOrderResponse, error) { + ioc := trading.ImmediateOrCancel + return s.buildAndExecuteOrder(ctx, trading.NewOrderRequestSideSell, symbol, amount, price, &ioc, opts) +} + +// CancelOrder cancels an active order by order_id or client_order_id. +func (s *TradingService) CancelOrder(ctx context.Context, req *trading.CancelOrderRequest) (*trading.CancelOrderResponse, error) { + var res trading.CancelOrderResponse + if err := s.post(ctx, "/v1/order/cancel", req, &res); err != nil { + return nil, err + } + return &res, nil +} + +// CancelAllOrders cancels all active orders for the authenticated account/session. +func (s *TradingService) CancelAllOrders(ctx context.Context, req *trading.CancelAllOrdersRequest) (*trading.CancelAllResult, error) { + if req == nil { + req = &trading.CancelAllOrdersRequest{} + } + var res trading.CancelAllResult + if err := s.post(ctx, "/v1/order/cancel/all", req, &res); err != nil { + return nil, err + } + return &res, nil +} + +// GetOrderStatus gets current status for an order. +func (s *TradingService) GetOrderStatus(ctx context.Context, req *trading.OrderStatusRequest) (*trading.LimitOrderResponse, error) { + var res trading.LimitOrderResponse + if err := s.post(ctx, "/v1/order/status", req, &res); err != nil { + return nil, err + } + return &res, nil +} + +// GetOrderStatusByID gets current status for an order by order ID. +func (s *TradingService) GetOrderStatusByID(ctx context.Context, orderID int64) (*trading.LimitOrderResponse, error) { + return s.GetOrderStatus(ctx, &trading.OrderStatusRequest{ + OrderId: orderID, + }) +} + +// GetActiveOrders returns all currently open orders. +func (s *TradingService) GetActiveOrders(ctx context.Context, req *trading.ListActiveOrdersJSONBody) ([]trading.LimitOrderResponse, error) { + if req == nil { + req = &trading.ListActiveOrdersJSONBody{} + } + var res []trading.LimitOrderResponse + if err := s.post(ctx, "/v1/orders", req, &res); err != nil { + return nil, err + } + return res, nil +} + +// GetPastTrades returns execution history for the authenticated account. +func (s *TradingService) GetPastTrades(ctx context.Context, req *trading.MyTradesRequest) ([]trading.MyTrade, error) { + var res []trading.MyTrade + if err := s.post(ctx, "/v1/mytrades", req, &res); err != nil { + return nil, err + } + return res, nil +} + +// GetPastTradesBySymbol returns execution history for a symbol with optional limit. +func (s *TradingService) GetPastTradesBySymbol(ctx context.Context, symbol string, limitTrades ...int) ([]trading.MyTrade, error) { + req := &trading.MyTradesRequest{ + Symbol: &symbol, + } + if len(limitTrades) > 0 && limitTrades[0] > 0 { + req.LimitTrades = &limitTrades[0] + } + return s.GetPastTrades(ctx, req) +} diff --git a/packages/sdk-go/services/trading_test.go b/packages/sdk-go/services/trading_test.go new file mode 100644 index 00000000..f3bf98f5 --- /dev/null +++ b/packages/sdk-go/services/trading_test.go @@ -0,0 +1,265 @@ +package services_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gemini/gemini-go/generated/trading" + "github.com/gemini/gemini-go/services" + "github.com/gemini/gemini-go/transport" + "github.com/gemini/gemini-go/types" +) + +func TestTradingService_FluentOrderHelpers(t *testing.T) { + var capturedBody []byte + var capturedPath string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + buf := make([]byte, r.ContentLength) + _, _ = r.Body.Read(buf) + capturedBody = buf + + w.Header().Set("Content-Type", "application/json") + orderID := "998877" + symbol := "BTCUSD" + side := trading.LimitOrderResponseSideBuy + price := "65000.00" + origAmt := "0.05" + res := trading.LimitOrderResponse{ + OrderId: &orderID, + Symbol: &symbol, + Side: &side, + Price: &price, + OriginalAmount: &origAmt, + } + _ = json.NewEncoder(w).Encode(res) + })) + defer server.Close() + + trans := transport.NewClient(transport.WithHTTPClient(server.Client())) + svc := services.NewTradingService(trans, server.URL) + + ctx := context.Background() + amount := types.MustParseDecimal("0.05") + price := types.MustParseDecimal("65000.00") + + // 1. PostOnlyBid + res, err := svc.PostOnlyBid(ctx, "BTCUSD", amount, price) + if err != nil { + t.Fatalf("PostOnlyBid failed: %v", err) + } + if res.OrderId == nil || *res.OrderId != "998877" || capturedPath != "/v1/order/new" { + t.Errorf("unexpected response: %+v", res) + } + + var req trading.NewOrderRequest + if err := json.Unmarshal(capturedBody, &req); err != nil { + t.Fatalf("unmarshaling captured body: %v", err) + } + if req.Side != trading.NewOrderRequestSideBuy || req.Amount != "0.05" || req.Price != "65000" { + t.Errorf("unexpected request payload: %+v", req) + } + if req.Options == nil || len(*req.Options) == 0 || (*req.Options)[0] != trading.MakerOrCancel { + t.Errorf("expected maker-or-cancel option, got: %+v", req.Options) + } + + // 2. PostOnlyAsk + _, err = svc.PostOnlyAsk(ctx, "BTCUSD", amount, price) + if err != nil { + t.Fatalf("PostOnlyAsk failed: %v", err) + } + + // 3. LimitBuy & LimitSell + _, err = svc.LimitBuy(ctx, "BTCUSD", amount, price) + if err != nil { + t.Fatalf("LimitBuy failed: %v", err) + } + _, err = svc.LimitSell(ctx, "BTCUSD", amount, price) + if err != nil { + t.Fatalf("LimitSell failed: %v", err) + } + + // 4. ImmediateOrCancelBuy & ImmediateOrCancelSell + _, err = svc.ImmediateOrCancelBuy(ctx, "BTCUSD", amount, price) + if err != nil { + t.Fatalf("ImmediateOrCancelBuy failed: %v", err) + } + _, err = svc.ImmediateOrCancelSell(ctx, "BTCUSD", amount, price) + if err != nil { + t.Fatalf("ImmediateOrCancelSell failed: %v", err) + } + + // 5. Fluent order placement with OrderOption (ClientOrderID, StopPrice) + _, err = svc.PostOnlyBid(ctx, "BTCUSD", amount, price, + services.WithClientOrderID("custom-client-id-1234"), + services.WithStopPrice(types.MustParseDecimal("64000.00")), + ) + if err != nil { + t.Fatalf("PostOnlyBid with options failed: %v", err) + } + + var optReq trading.NewOrderRequest + if err := json.Unmarshal(capturedBody, &optReq); err != nil { + t.Fatalf("unmarshaling captured body: %v", err) + } + if optReq.ClientOrderId == nil || *optReq.ClientOrderId != "custom-client-id-1234" { + t.Errorf("expected ClientOrderId custom-client-id-1234, got: %v", optReq.ClientOrderId) + } + if optReq.StopPrice == nil || *optReq.StopPrice != "64000" { + t.Errorf("expected StopPrice 64000, got: %v", optReq.StopPrice) + } + if optReq.Options != nil { + t.Errorf("expected stop-limit order to omit execution options, got: %v", optReq.Options) + } +} + +func TestTradingService_ValidatesStopLimitOrders(t *testing.T) { + var requests int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + })) + defer server.Close() + + service := services.NewTradingService(transport.NewClient(transport.WithHTTPClient(server.Client())), server.URL) + valid := func(side trading.NewOrderRequestSide, price, stopPrice string) *trading.NewOrderRequest { + return &trading.NewOrderRequest{ + Symbol: "BTCUSD", + Amount: "1", + Price: price, + Side: side, + StopPrice: &stopPrice, + Type: trading.NewOrderRequestTypeExchangeStopLimit, + } + } + + tests := map[string]*trading.NewOrderRequest{ + "missing stop price": { + Symbol: "BTCUSD", Amount: "1", Price: "100", Side: trading.NewOrderRequestSideBuy, + Type: trading.NewOrderRequestTypeExchangeStopLimit, + }, + "buy trigger is not below limit": valid(trading.NewOrderRequestSideBuy, "100", "100"), + "sell trigger is not above limit": valid(trading.NewOrderRequestSideSell, "100", "99"), + "zero stop price": valid(trading.NewOrderRequestSideBuy, "100", "0"), + "execution option": func() *trading.NewOrderRequest { + req := valid(trading.NewOrderRequestSideBuy, "100", "99") + options := []trading.NewOrderRequestOptions{trading.MakerOrCancel} + req.Options = &options + return req + }(), + "stop price on limit order": func() *trading.NewOrderRequest { + stopPrice := "99" + return &trading.NewOrderRequest{ + Symbol: "BTCUSD", Amount: "1", Price: "100", Side: trading.NewOrderRequestSideBuy, + StopPrice: &stopPrice, Type: trading.NewOrderRequestTypeExchangeLimit, + } + }(), + "whitespace stop price": func() *trading.NewOrderRequest { + stopPrice := " \t" + return &trading.NewOrderRequest{ + Symbol: "BTCUSD", Amount: "1", Price: "100", Side: trading.NewOrderRequestSideBuy, + StopPrice: &stopPrice, Type: trading.NewOrderRequestTypeExchangeLimit, + } + }(), + } + + for name, req := range tests { + t.Run(name, func(t *testing.T) { + if _, err := service.NewOrder(context.Background(), req); err == nil { + t.Fatal("expected stop-limit validation error") + } + }) + } + if requests != 0 { + t.Fatalf("invalid stop-limit requests reached the server: %d", requests) + } + + if _, err := service.NewOrder(context.Background(), valid(trading.NewOrderRequestSideBuy, "100", "99")); err != nil { + t.Fatalf("valid stop-limit order failed validation: %v", err) + } + if requests != 1 { + t.Fatalf("expected one valid request, got %d", requests) + } +} + +func TestTradingService_Methods(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + orderID := "123456" + symbol := "btcusd" + switch r.URL.Path { + case "/v1/order/status": + _ = json.NewEncoder(w).Encode(trading.LimitOrderResponse{ + OrderId: &orderID, + Symbol: &symbol, + }) + case "/v1/orders": + _ = json.NewEncoder(w).Encode([]trading.LimitOrderResponse{ + {OrderId: &orderID, Symbol: &symbol}, + }) + case "/v1/mytrades": + var tradeID int64 = 1001 + _ = json.NewEncoder(w).Encode([]trading.MyTrade{ + {Tid: &tradeID}, + }) + case "/v1/order/cancel": + _ = json.NewEncoder(w).Encode(trading.CancelOrderResponse{ + OrderId: &orderID, + }) + case "/v1/order/cancel/all": + canceled := []int{123456} + _ = json.NewEncoder(w).Encode(trading.CancelAllResult{ + Details: &struct { + CancelRejects *[]int `json:"cancelRejects,omitempty"` + CancelledOrders *[]int `json:"cancelledOrders,omitempty"` + }{ + CancelledOrders: &canceled, + }, + }) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + trans := transport.NewClient(transport.WithHTTPClient(server.Client())) + svc := services.NewTradingService(trans, server.URL) + ctx := context.Background() + + // 1. GetOrderStatusByID + st, err := svc.GetOrderStatusByID(ctx, 123456) + if err != nil || st.OrderId == nil || *st.OrderId != "123456" { + t.Fatalf("GetOrderStatusByID failed: %v, %+v", err, st) + } + + // 2. GetActiveOrders + active, err := svc.GetActiveOrders(ctx, nil) + if err != nil || len(active) != 1 { + t.Fatalf("GetActiveOrders failed: %v, %+v", err, active) + } + + // 3. GetPastTradesBySymbol + trades, err := svc.GetPastTradesBySymbol(ctx, "btcusd", 10) + if err != nil || len(trades) != 1 { + t.Fatalf("GetPastTradesBySymbol failed: %v, %+v", err, trades) + } + + // 4. CancelOrder + cancelRes, err := svc.CancelOrder(ctx, &trading.CancelOrderRequest{ + OrderId: 123456, + }) + if err != nil || cancelRes.OrderId == nil || *cancelRes.OrderId != "123456" { + t.Fatalf("CancelOrder failed: %v, %+v", err, cancelRes) + } + + // 5. CancelAllOrders + cancelAllRes, err := svc.CancelAllOrders(ctx, nil) + if err != nil || cancelAllRes.Details == nil { + t.Fatalf("CancelAllOrders failed: %v, %+v", err, cancelAllRes) + } +} diff --git a/packages/sdk-go/services/transfers.go b/packages/sdk-go/services/transfers.go new file mode 100644 index 00000000..7496d9b0 --- /dev/null +++ b/packages/sdk-go/services/transfers.go @@ -0,0 +1,93 @@ +package services + +import ( + "context" + "net/url" + + "github.com/gemini/gemini-go/generated/account" + "github.com/gemini/gemini-go/transport" +) + +// TransfersService provides access to crypto withdrawals, fee estimates, multi-network past transfers, and custody fees. +type TransfersService struct { + baseService +} + +// CustodyFeeTransfer describes a custody fee charged to the account. +// The endpoint is represented as an inline schema in the API specification, +// so the SDK owns this small typed model rather than exposing a map. +type CustodyFeeTransfer struct { + TxTime *int64 `json:"txTime,omitempty"` + FeeAmount *string `json:"feeAmount,omitempty"` + FeeCurrency *string `json:"feeCurrency,omitempty"` + EID *int64 `json:"eid,omitempty"` + EventType *string `json:"eventType,omitempty"` +} + +// NewTransfersService creates a new TransfersService. +func NewTransfersService(client *transport.Client, baseURL string) *TransfersService { + return &TransfersService{ + baseService: newBaseService(client, baseURL), + } +} + +// WithdrawCryptoV2 submits a cryptocurrency withdrawal request with multichain network support. +// The response is intentionally distinct from V2Transfer: this endpoint +// returns address and fee, while V2Transfer is the historical transfer-list +// shape with destination and feeAmount. +func (s *TransfersService) WithdrawCryptoV2(ctx context.Context, network, ticker, address, amount string, memo ...string) (*account.WithdrawCryptoFundsResponse, error) { + payload := account.WithdrawCryptoFundsJSONBody{ + Address: address, + Amount: amount, + } + if len(memo) > 0 && memo[0] != "" { + m := memo[0] + payload.Memo = &m + } + var res account.WithdrawCryptoFundsResponse + if err := s.post(ctx, "/v2/withdraw/"+url.PathEscape(network)+"/"+url.PathEscape(ticker), payload, &res); err != nil { + return nil, err + } + return &res, nil +} + +// GetWithdrawalFeeEstimateV2 returns estimated fee for a multichain v2 withdrawal. +func (s *TransfersService) GetWithdrawalFeeEstimateV2(ctx context.Context, network, ticker, address, amount string, memo ...string) (*account.FeeEstimateV2Response, error) { + payload := account.FeeEstimateV2Request{ + Address: address, + Amount: amount, + } + if len(memo) > 0 && memo[0] != "" { + m := memo[0] + payload.Memo = &m + } + var res account.FeeEstimateV2Response + if err := s.post(ctx, "/v2/withdraw/"+url.PathEscape(network)+"/"+url.PathEscape(ticker)+"/feeEstimate", payload, &res); err != nil { + return nil, err + } + return &res, nil +} + +// 5. GetTransfers lists historical deposits, withdrawals, and administrative transfers across all networks. +func (s *TransfersService) GetTransfers(ctx context.Context, req *account.ListPastTransfersJSONBody) ([]account.V2Transfer, error) { + if req == nil { + req = &account.ListPastTransfersJSONBody{} + } + var res []account.V2Transfer + if err := s.post(ctx, "/v2/transfers", req, &res); err != nil { + return nil, err + } + return res, nil +} + +// 6. GetCustodyFeeTransfers lists custody fee deductions for the account. +func (s *TransfersService) GetCustodyFeeTransfers(ctx context.Context, req *account.ListCustodyFeeTransfersJSONBody) ([]CustodyFeeTransfer, error) { + if req == nil { + req = &account.ListCustodyFeeTransfersJSONBody{} + } + var res []CustodyFeeTransfer + if err := s.post(ctx, "/v1/custodyaccountfees", req, &res); err != nil { + return nil, err + } + return res, nil +} diff --git a/packages/sdk-go/services/transfers_test.go b/packages/sdk-go/services/transfers_test.go new file mode 100644 index 00000000..4f95b818 --- /dev/null +++ b/packages/sdk-go/services/transfers_test.go @@ -0,0 +1,82 @@ +package services_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gemini/gemini-go/generated/account" + "github.com/gemini/gemini-go/services" + "github.com/gemini/gemini-go/transport" +) + +func TestTransfersService_Methods(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/v2/withdraw/solana/sol": + _ = json.NewEncoder(w).Encode(map[string]string{ + "withdrawalId": "wd-123", + "address": "soladdr", + "amount": "10.0", + "currency": "SOL", + "fee": "0.001", + }) + case "/v2/withdraw/solana/sol/feeEstimate": + fee := 0.0005 + _ = json.NewEncoder(w).Encode(map[string]any{"currency": "SOL", "fee": fee}) + case "/v2/transfers": + amt := "10.0" + curr := "ETH" + _ = json.NewEncoder(w).Encode([]account.V2Transfer{ + {Amount: &amt, Currency: &curr}, + }) + case "/v1/custodyaccountfees": + txTime := int64(1657236174056) + feeAmount := "50.00" + feeCurrency := "USD" + eid := int64(256627) + eventType := "Withdrawal" + _ = json.NewEncoder(w).Encode([]services.CustodyFeeTransfer{ + {TxTime: &txTime, FeeAmount: &feeAmount, FeeCurrency: &feeCurrency, EID: &eid, EventType: &eventType}, + }) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + trans := transport.NewClient(transport.WithHTTPClient(server.Client())) + svc := services.NewTransfersService(trans, server.URL) + ctx := context.Background() + + // 1. WithdrawCryptoV2 + w2, err := svc.WithdrawCryptoV2(ctx, "solana", "sol", "soladdr", "10.0", "memo-1") + if err != nil || w2 == nil || w2.WithdrawalId == nil || *w2.WithdrawalId != "wd-123" || w2.Address == nil || *w2.Address != "soladdr" || w2.Fee == nil || *w2.Fee != "0.001" { + t.Fatalf("WithdrawCryptoV2 failed: %v", err) + } + + // 2. GetWithdrawalFeeEstimateV2 + f2, err := svc.GetWithdrawalFeeEstimateV2(ctx, "solana", "sol", "soladdr", "10.0") + if err != nil || f2.Fee == nil || f2.Fee.String() != "0.0005" { + t.Fatalf("GetWithdrawalFeeEstimateV2 failed: %v", err) + } + + // 3. GetTransfers + limit := 10 + transfers, err := svc.GetTransfers(ctx, &account.ListPastTransfersJSONBody{LimitTransfers: &limit}) + if err != nil || len(transfers) != 1 { + t.Fatalf("GetTransfers failed: %v", err) + } + + // 4. GetCustodyFeeTransfers + fees, err := svc.GetCustodyFeeTransfers(ctx, nil) + if err != nil || len(fees) != 1 { + t.Fatalf("GetCustodyFeeTransfers failed: %v", err) + } + if fees[0].FeeCurrency == nil || *fees[0].FeeCurrency != "USD" { + t.Fatalf("unexpected typed custody fee response: %+v", fees[0]) + } +} diff --git a/packages/sdk-go/transport/coverage_edges_test.go b/packages/sdk-go/transport/coverage_edges_test.go new file mode 100644 index 00000000..42e236f6 --- /dev/null +++ b/packages/sdk-go/transport/coverage_edges_test.go @@ -0,0 +1,160 @@ +package transport_test + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gemini/gemini-go/transport" +) + +func TestTransport_PaginatorEdgeCases(t *testing.T) { + tests := []struct { + name string + seq func(yield func(int, error) bool) + }{ + { + name: "nil fetcher", + seq: func() func(func(int, error) bool) { + var nilContext context.Context + return transport.NewPaginator[int](nilContext, 0, 0, nil) + }(), + }, + { + name: "negative offset", + seq: transport.NewPaginator(context.Background(), -1, 10, func(context.Context, int, int) ([]int, bool, error) { + t.Fatal("negative offset should not call fetcher") + return nil, false, nil + }), + }, + { + name: "canceled context", + seq: func() func(func(int, error) bool) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + return transport.NewPaginator(ctx, 0, 10, func(context.Context, int, int) ([]int, bool, error) { + t.Fatal("canceled context should not call fetcher") + return nil, false, nil + }) + }(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var gotErr error + for _, err := range tt.seq { + if err != nil { + gotErr = err + break + } + } + if gotErr == nil { + t.Fatal("expected paginator to yield an error") + } + }) + } + + var calls []struct{ offset, limit int } + paginator := transport.NewPaginator(context.Background(), 4, 0, func(_ context.Context, offset, limit int) ([]int, bool, error) { + calls = append(calls, struct{ offset, limit int }{offset, limit}) + if len(calls) == 1 { + return []int{10, 11}, true, nil + } + return []int{12}, false, nil + }) + var got []int + for item, err := range paginator { + if err != nil { + t.Fatalf("unexpected pagination error: %v", err) + } + got = append(got, item) + } + if fmt.Sprint(got) != "[10 11 12]" { + t.Fatalf("unexpected paginated items: %v", got) + } + if len(calls) != 2 || calls[0].offset != 4 || calls[0].limit != 50 || calls[1].offset != 6 { + t.Fatalf("unexpected pagination calls: %+v", calls) + } + + fetchErr := errors.New("fetch failed") + for _, err := range transport.NewPaginator(context.Background(), 0, 1, func(context.Context, int, int) ([]string, bool, error) { + return nil, false, fetchErr + }) { + if !errors.Is(err, fetchErr) { + t.Fatalf("expected fetch error, got %v", err) + } + } + + called := 0 + for item, err := range transport.NewPaginator(context.Background(), 0, 1, func(context.Context, int, int) ([]int, bool, error) { + called++ + return []int{1, 2}, true, nil + }) { + if err != nil { + t.Fatalf("unexpected early-stop error: %v", err) + } + if item != 1 { + t.Fatalf("unexpected first item: %d", item) + } + break + } + if called != 1 { + t.Fatalf("expected early stop after one fetch, got %d calls", called) + } +} + +func TestTransport_ErrorHelperEdges(t *testing.T) { + resync := &transport.ResyncRequiredError{LastUpdateID: 10, FirstUpdateID: 12} + if !errors.Is(resync, transport.ErrResyncRequired) { + t.Fatal("expected ResyncRequiredError to unwrap to ErrResyncRequired") + } + if got := resync.Error(); got == "" { + t.Fatal("expected ResyncRequiredError to have a useful message") + } + + for _, reason := range []string{"InvalidNonce", "MustAcceptTerms", "RateLimit"} { + if !(&transport.APIError{Reason: reason}).IsDomain() { + t.Errorf("expected %q to be a domain reason", reason) + } + } + if (&transport.APIError{Reason: "InvalidPayload"}).IsDomain() { + t.Fatal("unexpected domain classification for InvalidPayload") + } + + rateErr := &transport.RateLimitError{APIError: transport.APIError{RequestID: "rate-limit-id"}} + if got := transport.RequestIDFromError(rateErr); got != "rate-limit-id" { + t.Fatalf("expected rate-limit request ID, got %q", got) + } + if got := transport.RequestIDFromError(nil); got != "" { + t.Fatalf("expected empty request ID for nil error, got %q", got) + } + if got := transport.RequestIDFromError(errors.New("unrelated")); got != "" { + t.Fatalf("expected empty request ID for unrelated error, got %q", got) + } +} + +func TestTransport_CustomUserAgentAndCloseIdleConnections(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.UserAgent(); got != "coverage-test-agent" { + t.Errorf("expected custom user agent, got %q", got) + } + w.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(server.Close) + + client := transport.NewClient( + transport.WithHTTPClient(server.Client()), + transport.WithUserAgent("coverage-test-agent"), + transport.WithLogger(nil), + ) + if err := client.Request(context.Background(), http.MethodGet, server.URL, nil, nil); err != nil { + t.Fatalf("request failed: %v", err) + } + client.CloseIdleConnections() + var nilClient *transport.Client + nilClient.CloseIdleConnections() +} diff --git a/packages/sdk-go/transport/errors.go b/packages/sdk-go/transport/errors.go new file mode 100644 index 00000000..2986586a --- /dev/null +++ b/packages/sdk-go/transport/errors.go @@ -0,0 +1,274 @@ +package transport + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "time" +) + +// ----------------------------------------------------------------------------- +// Reason / Error Code Constants (Typed error reason identifiers) +// ----------------------------------------------------------------------------- +const ( + ReasonInvalidNonce = "InvalidNonce" + ReasonGenericNonceError = "GenericNonceError" + ReasonMissingNonce = "MissingNonce" + ReasonInvalidSignature = "InvalidSignature" + ReasonRateLimit = "RateLimit" + ReasonUsageLimit = "UsageLimit" + ReasonInsufficientFunds = "InsufficientFunds" + ReasonMarketClosed = "MarketClosed" + ReasonTradingClosed = "TradingClosed" + ReasonOrderNotFound = "OrderNotFound" + ReasonNoSuchOrder = "NoSuchOrder" + ReasonSelfCrossPrevented = "SelfCrossPrevented" + ReasonMustAcceptTerms = "MustAcceptTerms" +) + +// ----------------------------------------------------------------------------- +// Domain Sentinel Errors (Exchange Business Logic & Invariants) +// ----------------------------------------------------------------------------- +var ( + ErrInvalidNonce = errors.New("gemini domain: invalid or duplicate nonce") + ErrMissingNonce = errors.New("gemini domain: missing nonce in payload") + ErrInvalidSignature = errors.New("gemini domain: invalid signature") + ErrMissingRole = errors.New("gemini domain: api key lacks required role") + ErrInsufficientFunds = errors.New("gemini domain: insufficient funds") + ErrAcceptTermsRequired = errors.New("gemini domain: terms of service must be accepted before trading") + ErrMarketClosed = errors.New("gemini domain: market is closed") + ErrOrderNotFound = errors.New("gemini domain: order not found") + ErrSelfCrossPrevented = errors.New("gemini domain: self cross prevented") +) + +// ----------------------------------------------------------------------------- +// API / HTTP Status Sentinel Errors (Network, Protocol, & Gateway Status) +// ----------------------------------------------------------------------------- +var ( + ErrBadRequest = errors.New("gemini api: bad request (400)") + ErrUnauthorized = errors.New("gemini api: unauthorized (401)") + ErrPermissionDenied = errors.New("gemini api: permission denied (403)") + ErrNotFound = errors.New("gemini api: entity not found (404)") + ErrConflict = errors.New("gemini api: conflict (409)") + ErrRateLimited = errors.New("gemini api: rate limit exceeded (429)") + ErrInternalServer = errors.New("gemini api: internal server error (500)") + ErrServiceUnavailable = errors.New("gemini api: exchange service unavailable (502/503/504)") +) + +// ----------------------------------------------------------------------------- +// Client & Transport Sentinel Errors (SDK State & Local Stream Failures) +// ----------------------------------------------------------------------------- +var ( + // ErrAuthenticationRequired indicates that an authenticated operation was + // attempted without an authentication strategy. + ErrAuthenticationRequired = errors.New("gemini client: authentication strategy required") + + ErrConnectionClosed = errors.New("gemini client: connection closed") + ErrDeadlineExceeded = errors.New("gemini client: request deadline exceeded") + ErrResyncRequired = errors.New("gemini client: orderbook sequence gap detected; resync required") + ErrNoResponse = errors.New("gemini client: transport returned no response") +) + +// ResyncRequiredError describes a sequence gap in live order book update stream. +type ResyncRequiredError struct { + LastUpdateID int64 + FirstUpdateID int64 +} + +func (e *ResyncRequiredError) Error() string { + return fmt.Sprintf("gemini order book gap: had update %d, next diff started at %d; resync required", e.LastUpdateID, e.FirstUpdateID) +} + +func (e *ResyncRequiredError) Unwrap() error { + return ErrResyncRequired +} + +// APIError represents a structured error response returned by the Gemini REST API. +type APIError struct { + StatusCode int + Result string `json:"result,omitempty"` + Reason string `json:"reason,omitempty"` + Message string `json:"message,omitempty"` + RequestID string `json:"-"` + RawBody []byte `json:"-"` + Header http.Header `json:"-"` +} + +// IsDomain reports whether the APIError represents an exchange business logic/domain error. +func (e *APIError) IsDomain() bool { + switch e.Reason { + case "InvalidNonce", "GenericNonceError", "MissingNonce", + "InvalidSignature", + "RateLimit", "UsageLimit", + "InsufficientFunds", + "MissingRole", + "MarketClosed", "TradingClosed", + "OrderNotFound", "NoSuchOrder", + "SelfCrossPrevented", + "MustAcceptTerms": + return true + default: + return false + } +} + +func (e *APIError) Error() string { + msg := "" + if e.Reason != "" && e.Message != "" { + msg = fmt.Sprintf("gemini api error (status %d): %s - %s", e.StatusCode, e.Reason, e.Message) + } else if e.Message != "" { + msg = fmt.Sprintf("gemini api error (status %d): %s", e.StatusCode, e.Message) + } else if e.Reason != "" { + msg = fmt.Sprintf("gemini api error (status %d): %s", e.StatusCode, e.Reason) + } else { + msg = fmt.Sprintf("gemini api error (status %d): unstructured response body", e.StatusCode) + } + + if e.RequestID != "" { + msg += fmt.Sprintf(" [request_id: %s]", e.RequestID) + } + return msg +} + +func (e *APIError) Unwrap() error { + switch e.Reason { + case "MissingNonce": + return ErrMissingNonce + case "MissingRole": + return ErrMissingRole + case "InvalidNonce", "GenericNonceError": + return ErrInvalidNonce + case "InvalidSignature": + return ErrInvalidSignature + case "RateLimit", "UsageLimit": + return ErrRateLimited + case "InsufficientFunds": + return ErrInsufficientFunds + case "MarketClosed", "TradingClosed": + return ErrMarketClosed + case "OrderNotFound", "NoSuchOrder": + return ErrOrderNotFound + case "SelfCrossPrevented": + return ErrSelfCrossPrevented + case "MustAcceptTerms": + return ErrAcceptTermsRequired + default: + switch e.StatusCode { + case http.StatusBadRequest: + return ErrBadRequest + case http.StatusUnauthorized: + return ErrUnauthorized + case http.StatusForbidden: + return ErrPermissionDenied + case http.StatusNotFound: + return ErrNotFound + case http.StatusConflict: + return ErrConflict + case http.StatusTooManyRequests: + return ErrRateLimited + case http.StatusInternalServerError: + return ErrInternalServer + case http.StatusServiceUnavailable, http.StatusBadGateway, http.StatusGatewayTimeout: + return ErrServiceUnavailable + default: + return nil + } + } +} + +// Is implements error matching for APIError. +func (e *APIError) Is(target error) bool { + if target == ErrRateLimited && (e.StatusCode == http.StatusTooManyRequests || e.Reason == "RateLimit" || e.Reason == "UsageLimit") { + return true + } + if target == ErrInvalidNonce && (e.Reason == "InvalidNonce" || e.Reason == "GenericNonceError" || e.Reason == "MissingNonce") { + return true + } + return false +} + +// RateLimitError represents a 429 response with retry delay metadata. +type RateLimitError struct { + APIError + RetryAfter time.Duration +} + +func (e *RateLimitError) Error() string { + if e.RetryAfter > 0 { + return fmt.Sprintf("%s (retry after %v)", e.APIError.Error(), e.RetryAfter) + } + return e.APIError.Error() +} + +func (e *RateLimitError) Unwrap() error { + return &e.APIError +} + +// Is reports whether this error matches target (including sentinel ErrRateLimited). +func (e *RateLimitError) Is(target error) bool { + return target == ErrRateLimited || target == &e.APIError +} + +// RequestIDFromError extracts the Gemini Request ID from an error if present. +func RequestIDFromError(err error) string { + if err == nil { + return "" + } + var apiErr *APIError + if errors.As(err, &apiErr) { + return apiErr.RequestID + } + var rateLimitErr *RateLimitError + if errors.As(err, &rateLimitErr) { + return rateLimitErr.RequestID + } + return "" +} + +func extractRequestID(h http.Header) string { + if h == nil { + return "" + } + if id := h.Get("X-GEMINI-REQUEST-ID"); id != "" { + return id + } + if id := h.Get("X-Request-Id"); id != "" { + return id + } + if id := h.Get("X-Request-ID"); id != "" { + return id + } + return "" +} + +// ClassifyResponse inspects a response status code and body to build a rich typed error. +func ClassifyResponse(resp *http.Response, body []byte) error { + if resp == nil { + return ErrNoResponse + } + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + + apiErr := &APIError{ + StatusCode: resp.StatusCode, + RequestID: extractRequestID(resp.Header), + RawBody: body, + Header: resp.Header, + } + + if len(body) > 0 { + _ = json.Unmarshal(body, apiErr) + } + + if resp.StatusCode == http.StatusTooManyRequests { + retryAfter := parseRetryAfter(resp.Header.Get("Retry-After")) + return &RateLimitError{ + APIError: *apiErr, + RetryAfter: retryAfter, + } + } + + return apiErr +} diff --git a/packages/sdk-go/transport/hooks.go b/packages/sdk-go/transport/hooks.go new file mode 100644 index 00000000..1bca2bb4 --- /dev/null +++ b/packages/sdk-go/transport/hooks.go @@ -0,0 +1,147 @@ +package transport + +import ( + "context" + "log/slog" + "net/http" + "time" +) + +func logURL(req *http.Request) string { + if req == nil || req.URL == nil { + return "" + } + // Query strings can contain account identifiers or caller-supplied tokens. + // The SDK never needs them for transport diagnostics, so keep them out of + // the built-in logs. Custom hooks still receive the original request. + urlCopy := *req.URL + urlCopy.User = nil + urlCopy.RawQuery = "" + urlCopy.ForceQuery = false + return urlCopy.String() +} + +// Hook provides observability lifecycle callbacks for HTTP requests. +// Implementations can attach OpenTelemetry spans, Prometheus metrics, or custom loggers. +type Hook interface { + // OnRequestStart is invoked before the HTTP request is executed. + OnRequestStart(ctx context.Context, req *http.Request) context.Context + + // OnRequestEnd is invoked after the HTTP request finishes (success or failure). + OnRequestEnd(ctx context.Context, req *http.Request, resp *http.Response, duration time.Duration, err error) + + // OnRetry is invoked before a request is retried. + OnRetry(ctx context.Context, req *http.Request, attempt int, backoff time.Duration, err error) + + // OnRateLimit is invoked when a 429 response is received with a Retry-After directive. + OnRateLimit(ctx context.Context, req *http.Request, retryAfter time.Duration) +} + +// MultiHook aggregates multiple Hooks into a single pipeline. +type MultiHook []Hook + +var ( + _ Hook = (MultiHook)(nil) + _ Hook = (*SlogHook)(nil) +) + +// OnRequestStart dispatches OnRequestStart across all registered hooks. +func (m MultiHook) OnRequestStart(ctx context.Context, req *http.Request) context.Context { + for _, h := range m { + if h != nil { + if next := h.OnRequestStart(ctx, req); next != nil { + ctx = next + } + } + } + return ctx +} + +// OnRequestEnd dispatches OnRequestEnd across all registered hooks. +func (m MultiHook) OnRequestEnd(ctx context.Context, req *http.Request, resp *http.Response, duration time.Duration, err error) { + for _, h := range m { + if h != nil { + h.OnRequestEnd(ctx, req, resp, duration, err) + } + } +} + +// OnRetry dispatches OnRetry across all registered hooks. +func (m MultiHook) OnRetry(ctx context.Context, req *http.Request, attempt int, backoff time.Duration, err error) { + for _, h := range m { + if h != nil { + h.OnRetry(ctx, req, attempt, backoff, err) + } + } +} + +// OnRateLimit dispatches OnRateLimit across all registered hooks. +func (m MultiHook) OnRateLimit(ctx context.Context, req *http.Request, retryAfter time.Duration) { + for _, h := range m { + if h != nil { + h.OnRateLimit(ctx, req, retryAfter) + } + } +} + +// SlogHook is a built-in Hook implementation using standard library slog. +type SlogHook struct { + logger *slog.Logger +} + +// NewSlogHook creates a new SlogHook. +func NewSlogHook(logger *slog.Logger) *SlogHook { + if logger == nil { + logger = slog.Default() + } + return &SlogHook{logger: logger} +} + +func (s *SlogHook) OnRequestStart(ctx context.Context, req *http.Request) context.Context { + return ctx +} + +func (s *SlogHook) OnRequestEnd(ctx context.Context, req *http.Request, resp *http.Response, duration time.Duration, err error) { + status := 0 + if resp != nil { + status = resp.StatusCode + } + if err != nil { + s.logger.DebugContext(ctx, "gemini request finished with error", + slog.String("method", req.Method), + slog.String("url", logURL(req)), + slog.Int("status", status), + slog.Duration("duration", duration), + slog.String("error", err.Error()), + ) + } else { + s.logger.DebugContext(ctx, "gemini request completed", + slog.String("method", req.Method), + slog.String("url", logURL(req)), + slog.Int("status", status), + slog.Duration("duration", duration), + ) + } +} + +func (s *SlogHook) OnRetry(ctx context.Context, req *http.Request, attempt int, backoff time.Duration, err error) { + errStr := "" + if err != nil { + errStr = err.Error() + } + s.logger.WarnContext(ctx, "gemini request failed; retrying", + slog.String("method", req.Method), + slog.String("url", logURL(req)), + slog.Int("attempt", attempt), + slog.Duration("backoff", backoff), + slog.String("error", errStr), + ) +} + +func (s *SlogHook) OnRateLimit(ctx context.Context, req *http.Request, retryAfter time.Duration) { + s.logger.WarnContext(ctx, "gemini rate limit encountered", + slog.String("method", req.Method), + slog.String("url", logURL(req)), + slog.Duration("retry_after", retryAfter), + ) +} diff --git a/packages/sdk-go/transport/hooks_test.go b/packages/sdk-go/transport/hooks_test.go new file mode 100644 index 00000000..4410545b --- /dev/null +++ b/packages/sdk-go/transport/hooks_test.go @@ -0,0 +1,31 @@ +package transport_test + +import ( + "bytes" + "context" + "errors" + "log/slog" + "net/http" + "testing" + "time" + + "github.com/gemini/gemini-go/transport" +) + +func TestSlogHook_RedactsQueryStrings(t *testing.T) { + var logs bytes.Buffer + hook := transport.NewSlogHook(slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug}))) + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "https://api.gemini.com/v1/orders?token=caller-secret&account=primary", nil) + if err != nil { + t.Fatalf("creating request: %v", err) + } + + hook.OnRequestEnd(context.Background(), req, nil, time.Second, errors.New("request failed")) + output := logs.String() + if bytes.Contains(logs.Bytes(), []byte("caller-secret")) || bytes.Contains(logs.Bytes(), []byte("token=")) { + t.Fatalf("slog output contains query data: %s", output) + } + if !bytes.Contains(logs.Bytes(), []byte("/v1/orders")) { + t.Fatalf("slog output omitted the request path: %s", output) + } +} diff --git a/packages/sdk-go/transport/pagination.go b/packages/sdk-go/transport/pagination.go new file mode 100644 index 00000000..2fd6f29a --- /dev/null +++ b/packages/sdk-go/transport/pagination.go @@ -0,0 +1,60 @@ +package transport + +import ( + "context" + "errors" + "iter" +) + +// PageFetcher is a function that fetches a page of items at a given offset and limit. +type PageFetcher[T any] func(ctx context.Context, offset, limit int) (items []T, hasMore bool, err error) + +// NewPaginator creates a native Go 1.23+ iter.Seq2 iterator for paginated endpoints. +func NewPaginator[T any](ctx context.Context, initialOffset, pageSize int, fetcher PageFetcher[T]) iter.Seq2[T, error] { + if ctx == nil { + ctx = context.Background() + } + if pageSize <= 0 { + pageSize = 50 + } + + return func(yield func(T, error) bool) { + if fetcher == nil { + var zero T + yield(zero, errors.New("gemini transport: paginator fetcher is nil")) + return + } + if initialOffset < 0 { + var zero T + yield(zero, errors.New("gemini transport: paginator offset must be non-negative")) + return + } + offset := initialOffset + for { + if ctx.Err() != nil { + var zero T + yield(zero, ctx.Err()) + return + } + + items, hasMore, err := fetcher(ctx, offset, pageSize) + if err != nil { + var zero T + yield(zero, err) + return + } + + for _, item := range items { + if !yield(item, nil) { + return // Caller broke out of the for-range loop + } + } + + if !hasMore || len(items) == 0 { + return + } + + offset += len(items) + } + } +} diff --git a/packages/sdk-go/transport/retries.go b/packages/sdk-go/transport/retries.go new file mode 100644 index 00000000..66a1fab3 --- /dev/null +++ b/packages/sdk-go/transport/retries.go @@ -0,0 +1,143 @@ +package transport + +import ( + "errors" + "io" + "math" + // nosemgrep: go.lang.security.audit.crypto.math_random.math-random-used + "math/rand/v2" + "net" + "net/http" + "strconv" + "strings" + "syscall" + "time" +) + +// RetryPolicy defines parameters for automatic HTTP retry behavior. +type RetryPolicy struct { + MaxRetries int + BaseDelay time.Duration + MaxDelay time.Duration + Multiplier float64 + Jitter bool +} + +// DefaultRetryPolicy returns safe defaults matching Gemini TypeScript SDK. +func DefaultRetryPolicy() RetryPolicy { + return RetryPolicy{ + MaxRetries: 5, + BaseDelay: 250 * time.Millisecond, + MaxDelay: 30 * time.Second, + Multiplier: 2.0, + Jitter: true, + } +} + +// IsSafeMethod returns true only for idempotent/safe HTTP methods (GET, HEAD). +func IsSafeMethod(method string) bool { + upper := strings.ToUpper(method) + return upper == http.MethodGet || upper == http.MethodHead +} + +// IsRetryable checks if a request method, status code, or error is safe to retry. +func IsRetryable(method string, statusCode int, err error) bool { + // CRITICAL SAFETY RULE: Never automatically retry mutating operations (POST, PUT, DELETE) + if !IsSafeMethod(method) { + return false + } + + if err != nil { + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { + return true + } + if errors.Is(err, syscall.ECONNRESET) || errors.Is(err, syscall.ECONNREFUSED) || errors.Is(err, syscall.ETIMEDOUT) { + return true + } + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + return true + } + return false + } + + switch statusCode { + case http.StatusTooManyRequests, // 429 + http.StatusBadGateway, // 502 + http.StatusServiceUnavailable, // 503 + http.StatusGatewayTimeout: // 504 + return true + default: + return false + } +} + +// parseRetryAfter parses the Retry-After header as delta seconds or HTTP Date. +func parseRetryAfter(header string) time.Duration { + if header == "" { + return 0 + } + + // 1. Try parsing integer delta seconds + if secs, err := strconv.Atoi(strings.TrimSpace(header)); err == nil && secs >= 0 { + const maxDuration = time.Duration(1<<63 - 1) + if time.Duration(secs) > maxDuration/time.Second { + return maxDuration + } + return time.Duration(secs) * time.Second + } + + // 2. Try parsing HTTP-date + if targetTime, err := http.ParseTime(header); err == nil { + diff := time.Until(targetTime) + if diff > 0 { + return diff + } + } + + return 0 +} + +// CalculateBackoff determines the sleep duration before the next retry attempt. +func (p RetryPolicy) CalculateBackoff(attempt int, retryAfterHeader string) time.Duration { + defaults := DefaultRetryPolicy() + baseDelay := p.BaseDelay + if baseDelay <= 0 { + baseDelay = defaults.BaseDelay + } + maxDelay := p.MaxDelay + if maxDelay <= 0 { + maxDelay = defaults.MaxDelay + } + if maxDelay < baseDelay { + maxDelay = baseDelay + } + multiplier := p.Multiplier + if math.IsNaN(multiplier) || math.IsInf(multiplier, 0) || multiplier < 1 { + multiplier = defaults.Multiplier + } + + // Respect server-specified Retry-After if present + if explicitDelay := parseRetryAfter(retryAfterHeader); explicitDelay > 0 { + if explicitDelay > maxDelay { + return maxDelay + } + return explicitDelay + } + + // Exponential backoff: base * (multiplier ^ attempt) + backoffFactor := math.Pow(multiplier, float64(attempt)) + delay := float64(baseDelay) * backoffFactor + + if math.IsInf(delay, 0) || math.IsNaN(delay) || delay > float64(maxDelay) { + delay = float64(maxDelay) + } + + // Full jitter randomizes uniformly between zero and the calculated delay. + if p.Jitter { + jitterFactor := rand.Float64() // #nosec G404 -- retry jitter is not security-sensitive + delay *= jitterFactor + } + + return time.Duration(delay) +} diff --git a/packages/sdk-go/transport/retry_edges_test.go b/packages/sdk-go/transport/retry_edges_test.go new file mode 100644 index 00000000..da2e13d5 --- /dev/null +++ b/packages/sdk-go/transport/retry_edges_test.go @@ -0,0 +1,91 @@ +package transport + +import ( + "net/http" + "testing" + "time" +) + +func TestParseRetryAfter(t *testing.T) { + tests := []struct { + name string + header string + want time.Duration + wantMax bool + }{ + {name: "empty", header: ""}, + {name: "delta seconds", header: "3", want: 3 * time.Second}, + {name: "trimmed delta", header: " 2 ", want: 2 * time.Second}, + {name: "negative delta", header: "-1"}, + {name: "invalid value", header: "later"}, + {name: "past date", header: time.Now().Add(-time.Minute).UTC().Format(http.TimeFormat)}, + {name: "overflow clamp", header: "9223372037", wantMax: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseRetryAfter(tt.header) + if tt.wantMax { + maxDuration := time.Duration(1<<63 - 1) + if got != maxDuration { + t.Fatalf("parseRetryAfter(%q) = %s, want max duration", tt.header, got) + } + return + } + if tt.want != 0 && got != tt.want { + t.Fatalf("parseRetryAfter(%q) = %s, want %s", tt.header, got, tt.want) + } + if tt.want == 0 && got != 0 { + t.Fatalf("parseRetryAfter(%q) = %s, want zero", tt.header, got) + } + }) + } + + future := time.Now().Add(2 * time.Second).UTC().Format(http.TimeFormat) + if got := parseRetryAfter(future); got <= 0 || got > 2*time.Second { + t.Fatalf("parseRetryAfter(future date) = %s, want a positive delay no greater than two seconds", got) + } +} + +func TestRetryPolicyCalculateBackoffEdges(t *testing.T) { + policy := RetryPolicy{ + BaseDelay: 10 * time.Millisecond, + MaxDelay: 50 * time.Millisecond, + Multiplier: 2, + Jitter: false, + } + + tests := []struct { + name string + policy RetryPolicy + attempt int + retryAfter string + want time.Duration + }{ + {name: "first attempt", attempt: 0, want: 10 * time.Millisecond}, + {name: "exponential attempt", attempt: 2, want: 40 * time.Millisecond}, + {name: "max delay clamp", attempt: 10, want: 50 * time.Millisecond}, + {name: "zero retry after uses base", retryAfter: "0", want: 10 * time.Millisecond}, + {name: "retry after max clamp", retryAfter: "60", want: 50 * time.Millisecond}, + {name: "invalid multiplier uses default", policy: RetryPolicy{BaseDelay: time.Millisecond, MaxDelay: 100 * time.Millisecond, Multiplier: 0, Jitter: false}, attempt: 1, want: 2 * time.Millisecond}, + {name: "invalid max follows base", policy: RetryPolicy{BaseDelay: 20 * time.Millisecond, MaxDelay: time.Millisecond, Multiplier: 2, Jitter: false}, attempt: 2, want: 20 * time.Millisecond}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := policy + if tt.policy.BaseDelay != 0 || tt.policy.MaxDelay != 0 || tt.policy.Multiplier != 0 || tt.policy.Jitter { + p = tt.policy + } + if got := p.CalculateBackoff(tt.attempt, tt.retryAfter); got != tt.want { + t.Fatalf("CalculateBackoff() = %s, want %s", got, tt.want) + } + }) + } + + serverDelayPolicy := policy + serverDelayPolicy.MaxDelay = 2 * time.Second + if got := serverDelayPolicy.CalculateBackoff(0, "1"); got != time.Second { + t.Fatalf("CalculateBackoff(server retry-after) = %s, want one second", got) + } +} diff --git a/packages/sdk-go/transport/trace.go b/packages/sdk-go/transport/trace.go new file mode 100644 index 00000000..e87622fa --- /dev/null +++ b/packages/sdk-go/transport/trace.go @@ -0,0 +1,160 @@ +package transport + +import ( + "context" + "crypto/tls" + "net/http" + "net/http/httptrace" + "sync" + "time" +) + +// LatencyBreakdown records fine-grained network microsecond latencies for an HTTP request. +type LatencyBreakdown struct { + DNSDuration time.Duration + ConnectDuration time.Duration + TLSHandshake time.Duration + ConnectionReused bool + TimeToFirstByte time.Duration + TotalDuration time.Duration +} + +// TraceCallback is invoked with fine-grained latency breakdown upon request completion. +type TraceCallback func(req *http.Request, trace LatencyBreakdown, err error) + +type traceContextKey struct{} + +type traceTimers struct { + mu sync.Mutex + dnsStart time.Time + dnsDuration time.Duration + connectStart time.Time + connectDuration time.Duration + tlsStart time.Time + tlsDuration time.Duration + reused bool + firstByteTime time.Time + start time.Time +} + +// TraceHook captures low-level network performance metrics using net/http/httptrace. +type TraceHook struct { + callback TraceCallback +} + +var _ Hook = (*TraceHook)(nil) + +// NewTraceHook creates a new Hook that profiles microsecond-level HTTP connection latencies. +func NewTraceHook(callback TraceCallback) *TraceHook { + return &TraceHook{callback: callback} +} + +// OnRequestStart attaches httptrace.ClientTrace to the request context. +func (h *TraceHook) OnRequestStart(ctx context.Context, req *http.Request) context.Context { + if h.callback == nil { + return ctx + } + + timers := &traceTimers{ + start: time.Now(), + } + + clientTrace := &httptrace.ClientTrace{ + DNSStart: func(_ httptrace.DNSStartInfo) { + timers.mu.Lock() + defer timers.mu.Unlock() + timers.dnsStart = time.Now() + }, + DNSDone: func(_ httptrace.DNSDoneInfo) { + timers.mu.Lock() + defer timers.mu.Unlock() + if !timers.dnsStart.IsZero() { + timers.dnsDuration += time.Since(timers.dnsStart) + timers.dnsStart = time.Time{} + } + }, + ConnectStart: func(_, _ string) { + timers.mu.Lock() + defer timers.mu.Unlock() + timers.connectStart = time.Now() + }, + ConnectDone: func(_, _ string, _ error) { + timers.mu.Lock() + defer timers.mu.Unlock() + if !timers.connectStart.IsZero() { + timers.connectDuration += time.Since(timers.connectStart) + timers.connectStart = time.Time{} + } + }, + TLSHandshakeStart: func() { + timers.mu.Lock() + defer timers.mu.Unlock() + timers.tlsStart = time.Now() + }, + TLSHandshakeDone: func(_ tls.ConnectionState, _ error) { + timers.mu.Lock() + defer timers.mu.Unlock() + if !timers.tlsStart.IsZero() { + timers.tlsDuration += time.Since(timers.tlsStart) + timers.tlsStart = time.Time{} + } + }, + GotConn: func(info httptrace.GotConnInfo) { + timers.mu.Lock() + defer timers.mu.Unlock() + timers.reused = info.Reused + }, + GotFirstResponseByte: func() { + timers.mu.Lock() + defer timers.mu.Unlock() + if timers.firstByteTime.IsZero() { + timers.firstByteTime = time.Now() + } + }, + } + + ctx = httptrace.WithClientTrace(ctx, clientTrace) + return context.WithValue(ctx, traceContextKey{}, timers) +} + +// OnRequestEnd computes final latency deltas and triggers the TraceCallback. +func (h *TraceHook) OnRequestEnd(ctx context.Context, req *http.Request, _ *http.Response, duration time.Duration, err error) { + if h.callback == nil { + return + } + + timers, ok := ctx.Value(traceContextKey{}).(*traceTimers) + if !ok || timers == nil { + return + } + + timers.mu.Lock() + dnsDuration := timers.dnsDuration + connectDuration := timers.connectDuration + tlsDuration := timers.tlsDuration + connectionReused := timers.reused + firstByteTime := timers.firstByteTime + timers.mu.Unlock() + + var ttfb time.Duration + if !firstByteTime.IsZero() { + ttfb = firstByteTime.Sub(timers.start) + } + + breakdown := LatencyBreakdown{ + DNSDuration: dnsDuration, + ConnectDuration: connectDuration, + TLSHandshake: tlsDuration, + ConnectionReused: connectionReused, + TimeToFirstByte: ttfb, + TotalDuration: duration, + } + + h.callback(req, breakdown, err) +} + +// OnRetry implements Hook. +func (h *TraceHook) OnRetry(_ context.Context, _ *http.Request, _ int, _ time.Duration, _ error) {} + +// OnRateLimit implements Hook. +func (h *TraceHook) OnRateLimit(_ context.Context, _ *http.Request, _ time.Duration) {} diff --git a/packages/sdk-go/transport/trace_test.go b/packages/sdk-go/transport/trace_test.go new file mode 100644 index 00000000..87e80133 --- /dev/null +++ b/packages/sdk-go/transport/trace_test.go @@ -0,0 +1,53 @@ +package transport_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gemini/gemini-go/transport" +) + +func TestTraceHook_LatencyBreakdown(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(10 * time.Millisecond) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"ok"}`)) + })) + defer ts.Close() + + var receivedTrace transport.LatencyBreakdown + var captured bool + + traceHook := transport.NewTraceHook(func(req *http.Request, trace transport.LatencyBreakdown, err error) { + receivedTrace = trace + captured = true + }) + + client := transport.NewClient( + transport.WithHooks(traceHook), + ) + + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, ts.URL, nil) + if err != nil { + t.Fatalf("failed creating request: %v", err) + } + + _, _, err = client.Execute(context.Background(), req, nil) + if err != nil { + t.Fatalf("execute failed: %v", err) + } + + if !captured { + t.Fatal("expected TraceHook callback to be invoked") + } + + if receivedTrace.TotalDuration < 10*time.Millisecond { + t.Fatalf("expected TotalDuration >= 10ms, got %v", receivedTrace.TotalDuration) + } + if receivedTrace.TimeToFirstByte == 0 { + t.Fatal("expected TimeToFirstByte to be recorded") + } +} diff --git a/packages/sdk-go/transport/transport.go b/packages/sdk-go/transport/transport.go new file mode 100644 index 00000000..3df43e8c --- /dev/null +++ b/packages/sdk-go/transport/transport.go @@ -0,0 +1,480 @@ +package transport + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "runtime" + "sync" + "time" + + "github.com/gemini/gemini-go/auth" +) + +var defaultClientUserAgentJSON = func() string { + info := map[string]string{ + "bindings_version": "0.1.0", + "lang": "go", + "lang_version": runtime.Version(), + "platform": runtime.GOOS, + "arch": runtime.GOARCH, + "engine": runtime.Compiler, + } + data, _ := json.Marshal(info) + return string(data) +}() + +// DefaultHTTPTransport returns a production-tuned HTTP transport optimized for high-throughput REST API calls. +func DefaultHTTPTransport() *http.Transport { + return &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{ + Timeout: 10 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + ForceAttemptHTTP2: true, + MaxIdleConns: 128, + MaxIdleConnsPerHost: 128, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + } +} + +// DefaultHTTPClient returns a pre-configured http.Client with connection pooling and reasonable timeouts. +func DefaultHTTPClient() *http.Client { + return &http.Client{ + Transport: DefaultHTTPTransport(), + Timeout: 30 * time.Second, + // API credentials must never be forwarded to an unexpected redirect + // target. The SDK transport intentionally does not follow redirects; + // callers that need redirect behavior should issue the final request + // explicitly so authentication is applied to the intended URL. + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + } +} + +// Client executes HTTP requests with Gemini authentication, safe retries, and structured logging. +type Client struct { + httpClient *http.Client + auth auth.Strategy + retry RetryPolicy + logger *slog.Logger + userAgent string + hooks MultiHook + configErr error +} + +// Option configures the transport Client. +type Option func(*Client) + +// WithAuth sets the authentication strategy. +func WithAuth(strategy auth.Strategy) Option { + return func(c *Client) { + c.auth = strategy + } +} + +// WithRetryPolicy sets the retry policy. +func WithRetryPolicy(p RetryPolicy) Option { + return func(c *Client) { + c.retry = p + } +} + +// WithLogger sets the structured logger. +func WithLogger(l *slog.Logger) Option { + return func(c *Client) { + c.logger = l + } +} + +// WithHTTPClient sets a custom base http.Client. +func WithHTTPClient(hc *http.Client) Option { + return func(c *Client) { + c.httpClient = hc + } +} + +// WithUserAgent sets a custom User-Agent header string. +func WithUserAgent(ua string) Option { + return func(c *Client) { + c.userAgent = ua + } +} + +// WithHooks appends observability hooks to the transport client. +func WithHooks(hooks ...Hook) Option { + return func(c *Client) { + c.hooks = append(c.hooks, hooks...) + } +} + +// WithConfigurationError makes every request fail with the supplied +// configuration error. It is used by higher-level facades that must preserve +// a source-compatible constructor while preventing requests from using an +// unsafe configuration. +func WithConfigurationError(err error) Option { + return func(c *Client) { + c.configErr = err + } +} + +// NewClient creates a new configured Gemini transport client. +func NewClient(opts ...Option) *Client { + c := &Client{ + httpClient: DefaultHTTPClient(), + retry: DefaultRetryPolicy(), + logger: slog.Default(), + userAgent: "gemini-go/0.1.0", + hooks: make(MultiHook, 0, 4), + } + for _, opt := range opts { + opt(c) + } + if c.httpClient == nil { + c.httpClient = DefaultHTTPClient() + } + // Do not allow a caller-provided redirect policy to forward authenticated + // headers or signed bodies to another origin. Clone the client so the + // caller's object is not mutated while preserving its transport, timeout, + // and cookie jar. + httpClient := *c.httpClient + httpClient.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + } + c.httpClient = &httpClient + // Always include SlogHook if logger is present + if c.logger != nil { + c.hooks = append(c.hooks, NewSlogHook(c.logger)) + } + return c +} + +// CloseIdleConnections closes idle HTTP connections held by the client's +// transport. It is safe to call when the client uses a custom http.Client. +func (c *Client) CloseIdleConnections() { + if c != nil && c.httpClient != nil { + c.httpClient.CloseIdleConnections() + } +} + +// ConfigurationError returns the error that causes every request to fail +// before network I/O, if one was supplied during construction. +func (c *Client) ConfigurationError() error { + if c == nil { + return nil + } + return c.configErr +} + +// Execute performs an HTTP request with safe retries and response error +// classification. The returned response body is readable and must be closed by +// the caller; the returned byte slice contains the same captured body. +func (c *Client) Execute(ctx context.Context, req *http.Request, payloadJSON []byte) (*http.Response, []byte, error) { + if c.configErr != nil { + return nil, nil, c.configErr + } + if ctx == nil { + ctx = context.Background() + } + if req == nil { + return nil, nil, errors.New("gemini transport: nil request") + } + if req.URL == nil { + return nil, nil, errors.New("gemini transport: request URL is nil") + } + if req.Header == nil { + req.Header = make(http.Header) + } + if c.userAgent != "" && req.Header.Get("User-Agent") == "" { + req.Header.Set("User-Agent", c.userAgent) + } + if req.Header.Get("X-Gemini-Client-User-Agent") == "" { + req.Header.Set("X-Gemini-Client-User-Agent", defaultClientUserAgentJSON) + } + + start := time.Now() + requestCtx := ctx + ctx = c.hooks.OnRequestStart(ctx, req) + if ctx == nil { + ctx = requestCtx + } + + var releaseRequest func() + if sequencer, ok := c.auth.(auth.RequestSequencer); ok { + var err error + releaseRequest, err = sequencer.AcquireRequest(ctx) + if err != nil { + c.hooks.OnRequestEnd(ctx, req, nil, time.Since(start), err) + return nil, nil, err + } + defer releaseRequest() + } + + // Apply authentication if provided + if c.auth != nil { + if err := c.auth.Authenticate(ctx, req, payloadJSON); err != nil { + err = fmt.Errorf("gemini transport: auth failed: %w", err) + c.hooks.OnRequestEnd(ctx, req, nil, time.Since(start), err) + return nil, nil, err + } + } else if len(payloadJSON) > 0 { + // Public request with body (unauthenticated) + req.Header.Set("Content-Type", "application/json") + req.GetBody = func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(payloadJSON)), nil + } + body, bodyErr := req.GetBody() + if bodyErr != nil { + bodyErr = fmt.Errorf("gemini transport: creating request body: %w", bodyErr) + c.hooks.OnRequestEnd(ctx, req, nil, time.Since(start), bodyErr) + return nil, nil, bodyErr + } + req.Body = body + req.ContentLength = int64(len(payloadJSON)) + } + + var resp *http.Response + var body []byte + var lastErr error + + maxAttempts := c.retry.MaxRetries + if maxAttempts < 0 { + maxAttempts = 0 + } + + for attempt := 0; attempt <= maxAttempts; attempt++ { + if ctx.Err() != nil { + err := normalizeTransportError(ctx.Err()) + c.hooks.OnRequestEnd(ctx, req, resp, time.Since(start), err) + return nil, nil, err + } + + if attempt > 0 { + if c.auth != nil { + if err := c.auth.Authenticate(ctx, req, payloadJSON); err != nil { + err = fmt.Errorf("gemini transport: auth re-sign failed: %w", err) + c.hooks.OnRequestEnd(ctx, req, nil, time.Since(start), err) + return nil, nil, err + } + } else if req.GetBody != nil { + body, bodyErr := req.GetBody() + if bodyErr != nil { + bodyErr = fmt.Errorf("gemini transport: recreating request body: %w", bodyErr) + c.hooks.OnRequestEnd(ctx, req, nil, time.Since(start), bodyErr) + return nil, nil, bodyErr + } + req.Body = body + } + } + + // Execute HTTP call + resp, lastErr = c.httpClient.Do(req.WithContext(ctx)) + if lastErr != nil { + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + resp = nil + } + if lastErr == nil { + if resp == nil { + lastErr = ErrNoResponse + } else { + body, lastErr = readResponseBody(resp) + if lastErr == nil { + // readResponseBody closes the network response body. Expose a + // readable replacement to Execute callers while higher-level + // helpers continue to consume the captured bytes directly. + resp.Body = io.NopCloser(bytes.NewReader(body)) + } + } + + // Calibrate remote clock skew if Date header is present + if resp != nil { + dateStr := resp.Header.Get("Date") + if serverTime, err := http.ParseTime(dateStr); err == nil { + if cal, ok := c.auth.(auth.ClockSkewCalibrator); ok { + cal.CalibrateServerTime(serverTime) + } + } + } + + if lastErr == nil && resp.StatusCode >= 200 && resp.StatusCode < 300 { + c.hooks.OnRequestEnd(ctx, req, resp, time.Since(start), nil) + return resp, body, nil + } + } + + statusCode := 0 + if resp != nil { + statusCode = resp.StatusCode + } + + if statusCode == http.StatusTooManyRequests { + retryAfterHeader := "" + if resp != nil { + retryAfterHeader = resp.Header.Get("Retry-After") + } + delay := parseRetryAfter(retryAfterHeader) + c.hooks.OnRateLimit(ctx, req, delay) + } + + // Check if we should retry + if attempt < c.retry.MaxRetries && IsRetryable(req.Method, statusCode, lastErr) { + retryAfterHeader := "" + if resp != nil { + retryAfterHeader = resp.Header.Get("Retry-After") + } + backoff := c.retry.CalculateBackoff(attempt, retryAfterHeader) + + c.hooks.OnRetry(ctx, req, attempt+1, backoff, lastErr) + + timer := time.NewTimer(backoff) + select { + case <-ctx.Done(): + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + err := normalizeTransportError(ctx.Err()) + c.hooks.OnRequestEnd(ctx, req, resp, time.Since(start), err) + return nil, nil, err + case <-timer.C: + continue + } + } + + break + } + + if lastErr != nil { + lastErr = normalizeTransportError(lastErr) + } + classifiedErr := lastErr + if classifiedErr == nil { + classifiedErr = ClassifyResponse(resp, body) + } + + c.hooks.OnRequestEnd(ctx, req, resp, time.Since(start), classifiedErr) + return resp, body, classifiedErr +} + +func normalizeTransportError(err error) error { + if err == nil { + return nil + } + if errors.Is(err, context.Canceled) { + return err + } + if errors.Is(err, context.DeadlineExceeded) { + return fmt.Errorf("%w: %w", ErrDeadlineExceeded, err) + } + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + return fmt.Errorf("%w: %w", ErrDeadlineExceeded, err) + } + return err +} + +var jsonBufferPool = sync.Pool{ + New: func() any { + return new(bytes.Buffer) + }, +} + +func encodePayload(payload any) ([]byte, func(), error) { + if payload == nil { + return nil, func() {}, nil + } + buf := jsonBufferPool.Get().(*bytes.Buffer) + buf.Reset() + if err := json.NewEncoder(buf).Encode(payload); err != nil { + jsonBufferPool.Put(buf) + return nil, nil, err + } + raw := bytes.TrimRight(buf.Bytes(), "\n") + return raw, func() { + jsonBufferPool.Put(buf) + }, nil +} + +// Request executes a request and unmarshals JSON response into target. +func (c *Client) Request(ctx context.Context, method, targetURL string, payload any, target any) error { + body, _, err := c.RequestRaw(ctx, method, targetURL, payload) + if err != nil { + return err + } + + if target != nil && len(body) > 0 { + if err := json.Unmarshal(body, target); err != nil { + return fmt.Errorf("gemini transport: unmarshaling response into %T: %w", target, err) + } + } + + return nil +} + +// RequestRaw executes a request and returns raw bytes and headers (for file downloads). +func (c *Client) RequestRaw(ctx context.Context, method, targetURL string, payload any) ([]byte, http.Header, error) { + if c.configErr != nil { + return nil, nil, c.configErr + } + if ctx == nil { + ctx = context.Background() + } + payloadBytes, cleanup, err := encodePayload(payload) + if err != nil { + return nil, nil, fmt.Errorf("gemini transport: marshaling payload: %w", err) + } + defer cleanup() + + req, err := http.NewRequestWithContext(ctx, method, targetURL, nil) + if err != nil { + return nil, nil, fmt.Errorf("gemini transport: creating request: %w", err) + } + + resp, body, err := c.Execute(ctx, req, payloadBytes) + if resp != nil && resp.Body != nil { + defer resp.Body.Close() + } + if err != nil { + return nil, nil, err + } + + return body, resp.Header, nil +} + +const maxResponseBodyBytes = 32 * 1024 * 1024 // 32MB safety limit +var ErrBodyExceededLimit = errors.New("gemini transport: response body exceeded maximum limit of 32MB") + +func readResponseBody(resp *http.Response) ([]byte, error) { + if resp == nil || resp.Body == nil { + return nil, nil + } + defer resp.Body.Close() + + limitReader := io.LimitReader(resp.Body, maxResponseBodyBytes+1) + var buf bytes.Buffer + if resp.ContentLength > 0 && resp.ContentLength <= maxResponseBodyBytes { + buf.Grow(int(resp.ContentLength)) + } + if _, err := buf.ReadFrom(limitReader); err != nil { + return nil, err + } + data := buf.Bytes() + if int64(len(data)) > maxResponseBodyBytes { + return nil, ErrBodyExceededLimit + } + return data, nil +} diff --git a/packages/sdk-go/transport/transport_test.go b/packages/sdk-go/transport/transport_test.go new file mode 100644 index 00000000..0b28ad27 --- /dev/null +++ b/packages/sdk-go/transport/transport_test.go @@ -0,0 +1,869 @@ +package transport + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "reflect" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/gemini/gemini-go/auth" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func TestTransport_ExecuteReturnsReadableResponseBody(t *testing.T) { + client := NewClient(WithHTTPClient(&http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"result":"ok"}`)), + Request: req, + }, nil + }), + })) + req, err := http.NewRequest(http.MethodGet, "https://api.gemini.com/v1/test", nil) + if err != nil { + t.Fatalf("creating request: %v", err) + } + resp, body, err := client.Execute(context.Background(), req, nil) + if err != nil { + t.Fatalf("Execute returned error: %v", err) + } + defer resp.Body.Close() + if string(body) != `{"result":"ok"}` { + t.Fatalf("unexpected captured body: %s", body) + } + readable, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("reading returned response body: %v", err) + } + if string(readable) != string(body) { + t.Fatalf("returned response body differs from captured body: %s", readable) + } +} + +func TestTransport_SafeGetRetry(t *testing.T) { + var attempts int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + current := atomic.AddInt32(&attempts, 1) + if current < 3 { + w.Header().Set("Retry-After", "0") + w.WriteHeader(http.StatusTooManyRequests) + _, _ = w.Write([]byte(`{"result":"error","reason":"RateLimit","message":"too many requests"}`)) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"result":"ok","symbols":["BTCUSD"]}`)) + })) + defer server.Close() + + client := NewClient( + WithHTTPClient(server.Client()), + WithRetryPolicy(RetryPolicy{ + MaxRetries: 4, + BaseDelay: time.Millisecond, + MaxDelay: 5 * time.Millisecond, + Multiplier: 1.0, + Jitter: false, + }), + ) + + var target struct { + Result string `json:"result"` + Symbols []string `json:"symbols"` + } + + err := client.Request(context.Background(), http.MethodGet, server.URL, nil, &target) + if err != nil { + t.Fatalf("expected successful retry, got error: %v", err) + } + + if attempts != 3 { + t.Fatalf("expected 3 attempts, got %d", attempts) + } + + if len(target.Symbols) != 1 || target.Symbols[0] != "BTCUSD" { + t.Fatalf("unexpected target result: %+v", target) + } +} + +func TestTransport_SafeGetRetryReplaysRequestBody(t *testing.T) { + var bodies []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("read request body: %v", err) + } + bodies = append(bodies, string(body)) + if len(bodies) == 1 { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client := NewClient( + WithHTTPClient(server.Client()), + WithRetryPolicy(RetryPolicy{ + MaxRetries: 1, + BaseDelay: time.Millisecond, + MaxDelay: 5 * time.Millisecond, + Multiplier: 1.0, + Jitter: false, + }), + ) + + if err := client.Request(context.Background(), http.MethodGet, server.URL, map[string]string{"cursor": "next"}, nil); err != nil { + t.Fatalf("expected successful retry, got %v", err) + } + if len(bodies) != 2 || bodies[0] != bodies[1] { + t.Fatalf("expected identical request bodies across retry, got %q", bodies) + } +} + +func TestTransport_BearerReauthenticatesSafeGetRetry(t *testing.T) { + var attempts atomic.Int32 + var sourceCalls atomic.Int32 + var authHeaders []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeaders = append(authHeaders, r.Header.Get("Authorization")) + if attempts.Add(1) == 1 { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"result":"ok"}`)) + })) + defer server.Close() + + strategy := auth.NewBearerWithSource(auth.TokenFunc(func(context.Context) (string, error) { + if sourceCalls.Add(1) == 1 { + return "oauth-token-1", nil + } + return "oauth-token-2", nil + })) + client := NewClient( + WithHTTPClient(server.Client()), + WithAuth(strategy), + WithRetryPolicy(RetryPolicy{ + MaxRetries: 1, + BaseDelay: time.Millisecond, + MaxDelay: 5 * time.Millisecond, + Multiplier: 1, + Jitter: false, + }), + ) + + if err := client.Request(context.Background(), http.MethodGet, server.URL, nil, nil); err != nil { + t.Fatalf("expected successful authenticated retry, got %v", err) + } + if got := attempts.Load(); got != 2 { + t.Fatalf("HTTP attempts = %d, want 2", got) + } + if got := sourceCalls.Load(); got != 2 { + t.Fatalf("token source calls = %d, want 2", got) + } + if !reflect.DeepEqual(authHeaders, []string{"Bearer oauth-token-1", "Bearer oauth-token-2"}) { + t.Fatalf("Authorization headers across retry = %v", authHeaders) + } +} + +func TestTransport_DefaultClientDoesNotFollowRedirects(t *testing.T) { + var targetHits atomic.Int32 + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + targetHits.Add(1) + w.WriteHeader(http.StatusOK) + })) + defer target.Close() + + redirect := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, target.URL, http.StatusFound) + })) + defer redirect.Close() + + client := NewClient() + err := client.Request(context.Background(), http.MethodGet, redirect.URL, nil, nil) + if err == nil { + t.Fatal("expected redirect response to be returned as an error") + } + if got := targetHits.Load(); got != 0 { + t.Fatalf("expected default client not to follow redirect, target received %d requests", got) + } +} + +func TestTransport_CustomClientDoesNotFollowRedirects(t *testing.T) { + var targetHits atomic.Int32 + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + targetHits.Add(1) + w.WriteHeader(http.StatusOK) + })) + defer target.Close() + + redirect := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, target.URL, http.StatusFound) + })) + defer redirect.Close() + + custom := &http.Client{Transport: http.DefaultTransport} + client := NewClient(WithHTTPClient(custom)) + if err := client.Request(context.Background(), http.MethodGet, redirect.URL, nil, nil); err == nil { + t.Fatal("expected redirect response to be returned as an error") + } + if got := targetHits.Load(); got != 0 { + t.Fatalf("expected custom client not to follow redirect, target received %d requests", got) + } +} + +func TestTransport_BearerCredentialsAreNotForwardedAcrossRedirects(t *testing.T) { + var redirectAuth string + var targetHits atomic.Int32 + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + targetHits.Add(1) + w.WriteHeader(http.StatusOK) + })) + defer target.Close() + + redirect := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + redirectAuth = r.Header.Get("Authorization") + http.Redirect(w, r, target.URL, http.StatusFound) + })) + defer redirect.Close() + + client := NewClient( + WithHTTPClient(redirect.Client()), + WithAuth(auth.NewBearer(auth.BearerToken("redirect-oauth-token"))), + ) + if err := client.Request(context.Background(), http.MethodGet, redirect.URL, nil, nil); err == nil { + t.Fatal("expected redirect response to be returned as an error") + } + if redirectAuth != "Bearer redirect-oauth-token" { + t.Fatalf("redirect request Authorization = %q, want original bearer credentials", redirectAuth) + } + if got := targetHits.Load(); got != 0 { + t.Fatalf("redirect target received %d requests; bearer credentials may have been forwarded", got) + } +} + +func TestTransport_NilInputsAreHandledWithoutPanic(t *testing.T) { + client := NewClient() + var nilContext context.Context + if _, _, err := client.Execute(nilContext, nil, nil); err == nil { + t.Fatal("expected nil request error") + } + if _, _, err := client.Execute(context.Background(), &http.Request{}, nil); err == nil { + t.Fatal("expected nil request URL error") + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + if err := client.Request(nilContext, http.MethodGet, server.URL, nil, nil); err != nil { + t.Fatalf("expected nil context to use a background context, got %v", err) + } +} + +func TestTransport_MutatingPostNeverRetried(t *testing.T) { + var attempts int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&attempts, 1) + w.WriteHeader(http.StatusTooManyRequests) + _, _ = w.Write([]byte(`{"result":"error","reason":"RateLimit","message":"rate limited on order creation"}`)) + })) + defer server.Close() + + client := NewClient( + WithHTTPClient(server.Client()), + WithRetryPolicy(RetryPolicy{ + MaxRetries: 5, + BaseDelay: time.Millisecond, + }), + ) + + err := client.Request(context.Background(), http.MethodPost, server.URL, map[string]string{"order": "123"}, nil) + if err == nil { + t.Fatal("expected error, got nil") + } + + if attempts != 1 { + t.Fatalf("CRITICAL SAFETY FAILURE: POST was retried! Expected 1 attempt, got %d", attempts) + } + + if !errors.Is(err, ErrRateLimited) { + t.Fatalf("expected errors.Is(err, ErrRateLimited) to be true, got %v", err) + } +} + +func TestTransport_BearerPostNeverRetried(t *testing.T) { + var attempts atomic.Int32 + var sourceCalls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempts.Add(1) + w.WriteHeader(http.StatusTooManyRequests) + _, _ = w.Write([]byte(`{"result":"error","reason":"RateLimit","message":"oauth operation rate limited"}`)) + })) + defer server.Close() + + client := NewClient( + WithHTTPClient(server.Client()), + WithAuth(auth.NewBearerWithSource(auth.TokenFunc(func(context.Context) (string, error) { + sourceCalls.Add(1) + return "oauth-token", nil + }))), + WithRetryPolicy(RetryPolicy{MaxRetries: 5, BaseDelay: time.Millisecond}), + ) + + err := client.Request(context.Background(), http.MethodPost, server.URL, map[string]string{"operation": "revoke"}, nil) + if !errors.Is(err, ErrRateLimited) { + t.Fatalf("expected ErrRateLimited, got %v", err) + } + if got := attempts.Load(); got != 1 { + t.Fatalf("OAuth POST attempts = %d, want 1", got) + } + if got := sourceCalls.Load(); got != 1 { + t.Fatalf("token source calls = %d, want 1", got) + } +} + +func TestTransport_ClockSkewCalibration(t *testing.T) { + // Server clock is 2 hours in the future + futureServerTime := time.Now().Add(2 * time.Hour).UTC().Truncate(time.Second) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Date", futureServerTime.Format(http.TimeFormat)) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"result":"ok"}`)) + })) + defer server.Close() + + hmacAuth := auth.NewHMAC("my-key", "my-secret") + client := NewClient( + WithHTTPClient(server.Client()), + WithAuth(hmacAuth), + ) + + // Before calibration, nonce is around local time (within seconds) + nonceBefore, _ := strconv.ParseInt(hmacAuth.NextNonce(), 10, 64) + localMilli := time.Now().UnixMilli() + if nonceBefore-localMilli > 10000 { + t.Fatalf("nonceBefore unexpectedly far from local time: %d vs %d", nonceBefore, localMilli) + } + + // Make request to trigger Date header inspection and clock calibration + var res map[string]any + if err := client.Request(context.Background(), http.MethodGet, server.URL, nil, &res); err != nil { + t.Fatalf("request failed: %v", err) + } + + // After calibration, nonce must match future server time (+2 hours) + nonceAfter, _ := strconv.ParseInt(hmacAuth.NextNonce(), 10, 64) + futureMilli := futureServerTime.UnixMilli() + + diff := nonceAfter - futureMilli + if diff < -2000 || diff > 2000 { + t.Fatalf("expected nonceAfter to be within 2s of server time (%d), got %d (diff=%dms)", + futureMilli, nonceAfter, diff) + } +} + +func TestTransport_SerializesAuthenticatedRequestsThroughRetries(t *testing.T) { + var active atomic.Int32 + var maxActive atomic.Int32 + firstRequestStarted := make(chan struct{}) + releaseFirstRequest := make(chan struct{}) + var firstRequest sync.Once + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + current := active.Add(1) + for { + previous := maxActive.Load() + if current <= previous || maxActive.CompareAndSwap(previous, current) { + break + } + } + firstRequest.Do(func() { close(firstRequestStarted) }) + <-releaseFirstRequest + active.Add(-1) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"result":"ok"}`)) + })) + defer server.Close() + + client := NewClient( + WithHTTPClient(server.Client()), + WithAuth(auth.NewHMAC("key", "secret")), + WithRetryPolicy(RetryPolicy{MaxRetries: 0}), + ) + + const requests = 16 + var wg sync.WaitGroup + errs := make(chan error, requests) + for i := 0; i < requests; i++ { + wg.Add(1) + go func() { + defer wg.Done() + errs <- client.Request(context.Background(), http.MethodGet, server.URL, nil, nil) + }() + } + select { + case <-firstRequestStarted: + case <-time.After(time.Second): + t.Fatal("timed out waiting for the first authenticated request") + } + close(releaseFirstRequest) + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatalf("authenticated request failed: %v", err) + } + } + if got := maxActive.Load(); got != 1 { + t.Fatalf("expected authenticated requests to be serialized, max concurrency was %d", got) + } +} + +func TestTransport_DeadlineErrorsUnwrapToSentinel(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(100 * time.Millisecond) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client := NewClient(WithHTTPClient(server.Client())) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond) + defer cancel() + + err := client.Request(ctx, http.MethodGet, server.URL, nil, nil) + if err == nil || !errors.Is(err, ErrDeadlineExceeded) || !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expected ErrDeadlineExceeded, got %v", err) + } +} + +func TestTransport_NilResponseAndRawBodySafety(t *testing.T) { + if err := ClassifyResponse(nil, nil); !errors.Is(err, ErrNoResponse) { + t.Fatalf("expected ErrNoResponse, got %v", err) + } + + const secretBody = `{"error":"do not put this in the error string"}` + err := ClassifyResponse(&http.Response{StatusCode: http.StatusBadRequest}, []byte(secretBody)) + if err == nil { + t.Fatal("expected classified error") + } + if strings.Contains(err.Error(), secretBody) { + t.Fatalf("API error exposed raw response body: %v", err) + } +} + +type nilStartHook struct{} + +type transportContextKey struct{} + +func (nilStartHook) OnRequestStart(context.Context, *http.Request) context.Context { + return nil +} + +func (nilStartHook) OnRequestEnd(context.Context, *http.Request, *http.Response, time.Duration, error) { +} +func (nilStartHook) OnRetry(context.Context, *http.Request, int, time.Duration, error) {} +func (nilStartHook) OnRateLimit(context.Context, *http.Request, time.Duration) {} + +func TestTransport_HookCannotDiscardRequestContext(t *testing.T) { + ctx := context.WithValue(context.Background(), transportContextKey{}, "preserved") + hooks := MultiHook{nilStartHook{}} + got := hooks.OnRequestStart(ctx, &http.Request{}) + if got == nil || got.Value(transportContextKey{}) != "preserved" { + t.Fatalf("expected hook pipeline to preserve context, got %v", got) + } +} + +type mockRateLimitHook struct { + MultiHook + rateLimitedAfter time.Duration + notified bool +} + +func (m *mockRateLimitHook) OnRateLimit(ctx context.Context, req *http.Request, retryAfter time.Duration) { + m.rateLimitedAfter = retryAfter + m.notified = true +} + +func TestTransport_RateLimitWithRetryAfter(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Retry-After", "3") + w.WriteHeader(http.StatusTooManyRequests) + _, _ = w.Write([]byte(`{"result":"error","reason":"RateLimit","message":"exceeded 100 rps limit"}`)) + })) + defer server.Close() + + hook := &mockRateLimitHook{} + client := NewClient( + WithHTTPClient(server.Client()), + WithHooks(hook), + WithRetryPolicy(RetryPolicy{MaxRetries: 0}), // Don't retry, test immediate error + ) + + err := client.Request(context.Background(), http.MethodGet, server.URL, nil, nil) + if err == nil { + t.Fatal("expected error, got nil") + } + + var rateErr *RateLimitError + if !errors.As(err, &rateErr) { + t.Fatalf("expected *RateLimitError, got %T: %v", err, err) + } + + if rateErr.RetryAfter != 3*time.Second { + t.Fatalf("expected RetryAfter=3s, got %v", rateErr.RetryAfter) + } + + if !hook.notified || hook.rateLimitedAfter != 3*time.Second { + t.Fatalf("expected hook to be notified with 3s, got notified=%v, duration=%v", hook.notified, hook.rateLimitedAfter) + } +} + +func TestTransport_PaginationSeq(t *testing.T) { + items := []string{"item1", "item2", "item3", "item4", "item5"} + + fetcher := func(ctx context.Context, offset, limit int) ([]string, bool, error) { + if offset >= len(items) { + return nil, false, nil + } + end := offset + limit + if end > len(items) { + end = len(items) + } + return items[offset:end], end < len(items), nil + } + + paginator := NewPaginator(context.Background(), 0, 2, fetcher) + + var collected []string + for item, err := range paginator { + if err != nil { + t.Fatalf("unexpected pagination error: %v", err) + } + collected = append(collected, item) + } + + if len(collected) != len(items) { + t.Fatalf("expected %d items, got %d", len(items), len(collected)) + } + for i, v := range collected { + if v != items[i] { + t.Errorf("at index %d: expected %s, got %s", i, items[i], v) + } + } +} + +func TestTransport_ErrorUnwrap(t *testing.T) { + apiErr := &APIError{ + StatusCode: 400, + Reason: "InvalidNonce", + Message: "Nonce 100 is less than last nonce 105", + } + + if !errors.Is(apiErr, ErrInvalidNonce) { + t.Fatalf("expected errors.Is(apiErr, ErrInvalidNonce) to be true") + } + formatted := fmt.Sprintf("%v", apiErr) + if formatted != "gemini api error (status 400): InvalidNonce - Nonce 100 is less than last nonce 105" { + t.Errorf("unexpected error format: %s", formatted) + } +} + +func TestTransport_ContextCancellation(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"result":"ok"}`)) + })) + defer server.Close() + + client := NewClient( + WithHTTPClient(server.Client()), + ) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel immediately + + err := client.Request(ctx, http.MethodGet, server.URL, nil, nil) + if err == nil || !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled error, got %v", err) + } +} + +func TestTransport_APIErrorPayloadParsing(t *testing.T) { + testCases := []struct { + name string + status int + body string + expectedErr error + }{ + { + name: "InvalidSignature", + status: 400, + body: `{"result":"error","reason":"InvalidSignature","message":"Signature failed HMAC-SHA384 verification"}`, + expectedErr: ErrInvalidSignature, + }, + { + name: "MarketClosed", + status: 400, + body: `{"result":"error","reason":"MarketClosed","message":"Trading on this pair is halted"}`, + expectedErr: ErrMarketClosed, + }, + { + name: "OrderNotFound", + status: 404, + body: `{"result":"error","reason":"OrderNotFound","message":"Order 123456 not found in active book"}`, + expectedErr: ErrOrderNotFound, + }, + { + name: "InsufficientFunds", + status: 400, + body: `{"result":"error","reason":"InsufficientFunds","message":"Account balance insufficient for order"}`, + expectedErr: ErrInsufficientFunds, + }, + { + name: "MustAcceptTerms", + status: 403, + body: `{"result":"error","reason":"MustAcceptTerms","message":"User must accept prediction market terms"}`, + expectedErr: ErrAcceptTermsRequired, + }, + { + name: "SelfCrossPrevented", + status: 400, + body: `{"result":"error","reason":"SelfCrossPrevented","message":"Order would execute against existing resting order"}`, + expectedErr: ErrSelfCrossPrevented, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tc.status) + _, _ = w.Write([]byte(tc.body)) + })) + defer server.Close() + + client := NewClient(WithHTTPClient(server.Client())) + err := client.Request(context.Background(), http.MethodGet, server.URL, nil, nil) + if err == nil { + t.Fatalf("expected error, got nil") + } + if !errors.Is(err, tc.expectedErr) { + t.Fatalf("expected errors.Is(err, %v), got %v", tc.expectedErr, err) + } + }) + } +} + +func TestTransport_TelemetryHeaders(t *testing.T) { + var capturedUserAgent string + var capturedClientTelemetry string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedUserAgent = r.Header.Get("User-Agent") + capturedClientTelemetry = r.Header.Get("X-Gemini-Client-User-Agent") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"ok"}`)) + })) + defer server.Close() + + client := NewClient(WithHTTPClient(server.Client())) + err := client.Request(context.Background(), http.MethodGet, server.URL, nil, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if capturedUserAgent != "gemini-go/0.1.0" { + t.Fatalf("expected User-Agent gemini-go/0.1.0, got %s", capturedUserAgent) + } + if capturedClientTelemetry == "" { + t.Fatal("expected X-Gemini-Client-User-Agent header to be populated") + } + + var telemetry map[string]string + if err := json.Unmarshal([]byte(capturedClientTelemetry), &telemetry); err != nil { + t.Fatalf("failed unmarshaling telemetry header JSON: %v", err) + } + if telemetry["lang"] != "go" || telemetry["bindings_version"] != "0.1.0" { + t.Fatalf("unexpected telemetry values: %+v", telemetry) + } +} + +func TestTransport_RequestIDExtraction(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-GEMINI-REQUEST-ID", "req_abc123xyz") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"result":"error","reason":"InvalidPayload","message":"bad field"}`)) + })) + defer server.Close() + + client := NewClient(WithHTTPClient(server.Client())) + err := client.Request(context.Background(), http.MethodGet, server.URL, nil, nil) + if err == nil { + t.Fatal("expected error, got nil") + } + + reqID := RequestIDFromError(err) + if reqID != "req_abc123xyz" { + t.Fatalf("expected request id req_abc123xyz, got %q", reqID) + } + + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected error to be *APIError") + } + if apiErr.RequestID != "req_abc123xyz" { + t.Fatalf("expected APIError.RequestID to be req_abc123xyz, got %q", apiErr.RequestID) + } + if !strings.Contains(err.Error(), "req_abc123xyz") { + t.Fatalf("expected error string to contain request id, got %s", err.Error()) + } +} + +func TestTransport_HTTPStatusTaxonomy(t *testing.T) { + testCases := []struct { + statusCode int + expectedErr error + }{ + {http.StatusBadRequest, ErrBadRequest}, + {http.StatusUnauthorized, ErrUnauthorized}, + {http.StatusForbidden, ErrPermissionDenied}, + {http.StatusNotFound, ErrNotFound}, + {http.StatusConflict, ErrConflict}, + {http.StatusInternalServerError, ErrInternalServer}, + {http.StatusServiceUnavailable, ErrServiceUnavailable}, + } + + for _, tc := range testCases { + t.Run(http.StatusText(tc.statusCode), func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tc.statusCode) + _, _ = w.Write([]byte(`{"error":"raw server message"}`)) + })) + defer server.Close() + + client := NewClient(WithHTTPClient(server.Client())) + err := client.Request(context.Background(), http.MethodGet, server.URL, nil, nil) + if err == nil { + t.Fatal("expected error, got nil") + } + if !errors.Is(err, tc.expectedErr) { + t.Fatalf("expected errors.Is(err, %v), got %v", tc.expectedErr, err) + } + }) + } +} + +func TestTransport_BoundedResponseBody(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/octet-stream") + w.WriteHeader(http.StatusOK) + // Stream 33 MB of zeroes (exceeds 32 MB limit) + chunk := make([]byte, 1024*1024) + for i := 0; i < 33; i++ { + if _, err := w.Write(chunk); err != nil { + return + } + } + })) + defer server.Close() + + client := NewClient(WithHTTPClient(server.Client())) + err := client.Request(context.Background(), http.MethodGet, server.URL, nil, nil) + if err == nil { + t.Fatal("expected error for response body exceeding 32MB limit, got nil") + } + if !errors.Is(err, ErrBodyExceededLimit) { + t.Fatalf("expected ErrBodyExceededLimit, got %v", err) + } +} + +func TestError_AsAndIsTaxonomy(t *testing.T) { + rateLimitErr := &RateLimitError{ + APIError: APIError{ + StatusCode: http.StatusTooManyRequests, + Reason: "RateLimit", + Message: "too many requests", + RequestID: "req-123", + }, + RetryAfter: 2 * time.Second, + } + + var apiErr *APIError + if !errors.As(rateLimitErr, &apiErr) { + t.Fatal("expected errors.As to match *APIError from *RateLimitError") + } + if apiErr.RequestID != "req-123" { + t.Errorf("expected RequestID 'req-123', got '%s'", apiErr.RequestID) + } + if !errors.Is(rateLimitErr, ErrRateLimited) { + t.Fatal("expected errors.Is(rateLimitErr, ErrRateLimited) to be true") + } + + missingNonceErr := &APIError{ + StatusCode: http.StatusBadRequest, + Reason: "MissingNonce", + Message: "nonce missing", + } + if !errors.Is(missingNonceErr, ErrMissingNonce) { + t.Fatal("expected errors.Is(missingNonceErr, ErrMissingNonce) to be true") + } + if !errors.Is(missingNonceErr, ErrInvalidNonce) { + t.Fatal("expected errors.Is(missingNonceErr, ErrInvalidNonce) to be true") + } + + missingRoleErr := &APIError{ + StatusCode: http.StatusForbidden, + Reason: "MissingRole", + Message: "role missing", + } + if !errors.Is(missingRoleErr, ErrMissingRole) { + t.Fatal("expected errors.Is(missingRoleErr, ErrMissingRole) to be true") + } +} + +func BenchmarkTransport_Request(b *testing.B) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"result":"ok","order_id":"12345"}`)) + })) + defer server.Close() + + client := NewClient( + WithHTTPClient(server.Client()), + ) + + payload := map[string]string{"symbol": "BTCUSD", "amount": "1.5"} + var target struct { + Result string `json:"result"` + OrderID string `json:"order_id"` + } + + b.ResetTimer() + b.ReportAllocs() + + for i := 0; i < b.N; i++ { + _ = client.Request(context.Background(), http.MethodPost, server.URL, payload, &target) + } +} diff --git a/packages/sdk-go/types/date.go b/packages/sdk-go/types/date.go new file mode 100644 index 00000000..23f71a40 --- /dev/null +++ b/packages/sdk-go/types/date.go @@ -0,0 +1,62 @@ +package types + +import ( + "bytes" + "encoding/json" + "fmt" + "time" +) + +// Date represents a YYYY-MM-DD date formatted value in JSON. +type Date struct { + time.Time +} + +func NewDate(t time.Time) Date { + return Date{Time: t} +} + +func (d Date) String() string { + if d.IsZero() { + return "" + } + return d.Format("2006-01-02") +} + +func (d Date) MarshalJSON() ([]byte, error) { + if d.IsZero() { + return []byte("null"), nil + } + return json.Marshal(d.Format("2006-01-02")) +} + +func (d *Date) UnmarshalJSON(data []byte) error { + data = bytes.TrimSpace(data) + if bytes.Equal(data, []byte("null")) { + d.Time = time.Time{} + return nil + } + var s string + if err := json.Unmarshal(data, &s); err != nil { + return fmt.Errorf("gemini types: invalid date: %w", err) + } + if s == "" { + d.Time = time.Time{} + return nil + } + t, err := time.Parse("2006-01-02", s) + if err != nil { + return err + } + d.Time = t + return nil +} + +// UUID represents a string UUID identifier. +type UUID = string + +// File represents a binary file download payload. +type File struct { + Data []byte + Filename string +} diff --git a/packages/sdk-go/types/date_test.go b/packages/sdk-go/types/date_test.go new file mode 100644 index 00000000..128e1690 --- /dev/null +++ b/packages/sdk-go/types/date_test.go @@ -0,0 +1,31 @@ +package types_test + +import ( + "testing" + "time" + + "github.com/gemini/gemini-go/types" +) + +func TestDateJSONRoundTripAndValidation(t *testing.T) { + date := types.NewDate(time.Date(2026, time.August, 20, 12, 30, 0, 0, time.UTC)) + encoded, err := date.MarshalJSON() + if err != nil || string(encoded) != `"2026-08-20"` { + t.Fatalf("unexpected date encoding: %s, %v", encoded, err) + } + + var decoded types.Date + if err := decoded.UnmarshalJSON(encoded); err != nil || decoded.String() != "2026-08-20" { + t.Fatalf("unexpected date decoding: %s, %v", decoded, err) + } + + for _, input := range []string{`"2026-08-20`, `2026-08-20`, `true`} { + if err := decoded.UnmarshalJSON([]byte(input)); err == nil { + t.Errorf("expected malformed date JSON %q to fail", input) + } + } + + if err := decoded.UnmarshalJSON([]byte("null")); err != nil || !decoded.IsZero() { + t.Fatalf("expected null date to clear value: %s, %v", decoded, err) + } +} diff --git a/packages/sdk-go/types/decimal.go b/packages/sdk-go/types/decimal.go new file mode 100644 index 00000000..525fe92f --- /dev/null +++ b/packages/sdk-go/types/decimal.go @@ -0,0 +1,654 @@ +package types + +import ( + "bytes" + "encoding/json" + "errors" + "math/big" + "strconv" + "strings" +) + +var ( + // ErrDivisionByZero is returned when a decimal division has a zero divisor. + ErrDivisionByZero = errors.New("gemini types: division by zero") + // ErrInvalidDecimal is returned when input is not a supported decimal value. + ErrInvalidDecimal = errors.New("gemini types: invalid decimal string") + // ErrInvalidDecimalScale is returned when a decimal scale is outside the + // bounded range supported by the SDK. + ErrInvalidDecimalScale = errors.New("gemini types: invalid decimal scale") + // ErrInvalidDecimalPrecision is returned when an arithmetic operation asks + // for an unsafe amount of decimal precision. + ErrInvalidDecimalPrecision = errors.New("gemini types: invalid decimal precision") +) + +// Decimal represents an exact fixed-precision decimal number for financial calculations. +type Decimal struct { + val *big.Int + scale int32 +} + +// DecimalNumber represents an exact decimal that is encoded as a JSON number. +// It is used by generated models for OpenAPI fields declared as +// type:number, format:decimal. Decimal remains the string-encoded type for +// fields declared as type:string, format:decimal. +// +// Decimal methods are promoted through the embedded value. Use DecimalValue +// when a Decimal value is required by an API that accepts the string-encoded +// representation. +type DecimalNumber struct { + Decimal +} + +var ( + bigZero = big.NewInt(0) + bigTen = big.NewInt(10) + pow10BigInt [19]*big.Int +) + +// maxDecimalDigits bounds parser work for untrusted JSON numbers while still +// allowing the precision used by exchange prices, quantities, and fees. +const maxDecimalDigits = 1_000_000 + +const maxDecimalScale = int32(maxDecimalDigits) + +// maxDecimalOperationDigits is intentionally much smaller than the parser +// bound. Arithmetic can allocate powers of ten, so accepting million-digit +// precision would still allow avoidable local resource exhaustion. +const maxDecimalOperationDigits = int32(1_000) + +func init() { + var current int64 = 1 + for i := 0; i <= 18; i++ { + pow10BigInt[i] = big.NewInt(current) + if i < 18 { + current *= 10 + } + } +} + +// Zero returns a Decimal representing 0. +func Zero() Decimal { + return Decimal{val: bigZero, scale: 0} +} + +// NewDecimal creates a Decimal from an integer value and scale (e.g. 150 with +// scale 2 is 1.50). It panics when scale is outside the bounded range accepted +// by ParseDecimal; use NewDecimalChecked when input is not trusted. +func NewDecimal(value int64, scale int32) Decimal { + decimal, err := NewDecimalChecked(value, scale) + if err != nil { + panic(err) + } + return decimal +} + +// NewDecimalChecked creates a Decimal from an integer value and scale and +// returns an error for an unsafe scale. +func NewDecimalChecked(value int64, scale int32) (Decimal, error) { + if scale < -maxDecimalScale || scale > maxDecimalScale { + return Decimal{}, ErrInvalidDecimalScale + } + return Decimal{val: big.NewInt(value), scale: scale}, nil +} + +// NewDecimalNumber creates a JSON-number decimal from an integer value and +// scale (e.g. 150 with scale 2 is 1.50). +func NewDecimalNumber(value int64, scale int32) DecimalNumber { + return DecimalNumber{Decimal: NewDecimal(value, scale)} +} + +// NewDecimalNumberChecked creates a JSON-number decimal and returns an error +// for an unsafe scale. +func NewDecimalNumberChecked(value int64, scale int32) (DecimalNumber, error) { + decimal, err := NewDecimalChecked(value, scale) + if err != nil { + return DecimalNumber{}, err + } + return DecimalNumber{Decimal: decimal}, nil +} + +// MustParseDecimal parses a decimal string or panics on error. +func MustParseDecimal(s string) Decimal { + d, err := ParseDecimal(s) + if err != nil { + panic(err) + } + return d +} + +// ParseDecimalNumber parses a decimal string into a JSON-number decimal. +func ParseDecimalNumber(s string) (DecimalNumber, error) { + decimal, err := ParseDecimal(s) + if err != nil { + return DecimalNumber{}, err + } + return DecimalNumber{Decimal: decimal}, nil +} + +// MustParseDecimalNumber parses a decimal string or panics on error. +func MustParseDecimalNumber(s string) DecimalNumber { + decimal, err := ParseDecimalNumber(s) + if err != nil { + panic(err) + } + return decimal +} + +// DecimalValue returns the underlying string-encoded Decimal value. +func (d DecimalNumber) DecimalValue() Decimal { + return d.Decimal +} + +// ParseDecimal parses a decimal string into an exact Decimal representation. +func ParseDecimal(s string) (Decimal, error) { + s = strings.TrimSpace(s) + if s == "" { + return Decimal{}, ErrInvalidDecimal + } + + sign := byte(0) + if s[0] == '-' || s[0] == '+' { + sign = s[0] + s = s[1:] + } + if s == "" { + return Decimal{}, ErrInvalidDecimal + } + + mantissa := s + exponent := int64(0) + if exponentIdx := strings.IndexAny(s, "eE"); exponentIdx >= 0 { + if strings.ContainsAny(s[exponentIdx+1:], "eE") { + return Decimal{}, ErrInvalidDecimal + } + mantissa = s[:exponentIdx] + exponentText := s[exponentIdx+1:] + if exponentText == "" { + return Decimal{}, ErrInvalidDecimal + } + parsedExponent, err := strconv.ParseInt(exponentText, 10, 32) + if err != nil { + return Decimal{}, ErrInvalidDecimal + } + exponent = parsedExponent + } + if mantissa == "" { + return Decimal{}, ErrInvalidDecimal + } + + dotIdx := strings.IndexByte(mantissa, '.') + if dotIdx >= 0 && strings.IndexByte(mantissa[dotIdx+1:], '.') >= 0 { + return Decimal{}, ErrInvalidDecimal + } + integer := mantissa + fraction := "" + if dotIdx >= 0 { + integer = mantissa[:dotIdx] + fraction = mantissa[dotIdx+1:] + if integer == "" && fraction == "" { + return Decimal{}, ErrInvalidDecimal + } + } + if integer == "" { + integer = "0" + } + + // Construct the unscaled integer representation without the decimal dot. + digits := integer + fraction + if len(digits) > maxDecimalDigits { + return Decimal{}, ErrInvalidDecimal + } + scale := int64(len(fraction)) - exponent + if scale > int64(1<<31-1) || scale < -int64(1<<31-1)-1 { + return Decimal{}, ErrInvalidDecimal + } + if scale < 0 { + zeros := -scale + if zeros > int64(maxDecimalDigits-len(digits)) { + return Decimal{}, ErrInvalidDecimal + } + digits += strings.Repeat("0", int(zeros)) + scale = 0 + } + if scale > int64(maxDecimalDigits) { + return Decimal{}, ErrInvalidDecimal + } + if sign == '-' { + digits = "-" + digits + } + + val, ok := new(big.Int).SetString(digits, 10) + if !ok { + return Decimal{}, ErrInvalidDecimal + } + + // scale is bounded by maxDecimalScale above before this conversion. + safeScale := int32(scale) // #nosec G115 -- validated against the int32-compatible decimal scale bound + return Decimal{val: val, scale: safeScale}.normalize(), nil +} + +// String formats the Decimal as an exact string without exponential notation. +func (d Decimal) String() string { + if d.val == nil || d.val.Sign() == 0 { + return "0" + } + + var stackBuf [64]byte + rawBytes := d.val.Append(stackBuf[:0], 10) + isNeg := false + if len(rawBytes) > 0 && rawBytes[0] == '-' { + isNeg = true + rawBytes = rawBytes[1:] + } + + var res strings.Builder + grow := len(rawBytes) + 4 + if d.scale > 0 { + grow += int(d.scale) + } + res.Grow(grow) + if isNeg { + res.WriteByte('-') + } + + scale := int(d.scale) + if scale <= 0 { + res.Write(rawBytes) + if scale < 0 { + for i := 0; i < -scale; i++ { + res.WriteByte('0') + } + } + return res.String() + } + + if len(rawBytes) <= scale { + res.WriteString("0.") + for i := 0; i < scale-len(rawBytes); i++ { + res.WriteByte('0') + } + res.Write(rawBytes) + } else { + splitIdx := len(rawBytes) - scale + res.Write(rawBytes[:splitIdx]) + res.WriteByte('.') + res.Write(rawBytes[splitIdx:]) + } + + return res.String() +} + +// Float64 converts Decimal to float64. It returns zero when the value cannot +// be represented as a finite float; use Float64Checked when that distinction +// matters. +func (d Decimal) Float64() float64 { + f, _ := d.Float64Checked() + return f +} + +// Float64Checked converts Decimal to float64 and reports overflow or malformed +// internal values instead of silently returning an inaccurate result. +func (d Decimal) Float64Checked() (float64, error) { + f, err := strconv.ParseFloat(d.String(), 64) + if err != nil { + return 0, err + } + return f, nil +} + +// IsZero returns true if the Decimal equals zero. +func (d Decimal) IsZero() bool { + return d.val == nil || d.val.Sign() == 0 +} + +// IsNegative returns true if the Decimal is strictly negative. +func (d Decimal) IsNegative() bool { + return d.val != nil && d.val.Sign() < 0 +} + +// IsPositive returns true if the Decimal is strictly greater than zero. +func (d Decimal) IsPositive() bool { + return d.val != nil && d.val.Sign() > 0 +} + +// Sign returns -1 if d < 0, 0 if d == 0, and +1 if d > 0. +func (d Decimal) Sign() int { + if d.val == nil { + return 0 + } + return d.val.Sign() +} + +// Cmp compares two Decimals: returns -1 if d < other, 0 if d == other, 1 if d > other. +func (d Decimal) Cmp(other Decimal) int { + a, b := alignScale(d, other) + return a.val.Cmp(b.val) +} + +// Equal returns true if d == other. +func (d Decimal) Equal(other Decimal) bool { + return d.Cmp(other) == 0 +} + +// GreaterThan returns true if d > other. +func (d Decimal) GreaterThan(other Decimal) bool { + return d.Cmp(other) > 0 +} + +// LessThan returns true if d < other. +func (d Decimal) LessThan(other Decimal) bool { + return d.Cmp(other) < 0 +} + +// GreaterThanOrEqual returns true if d >= other. +func (d Decimal) GreaterThanOrEqual(other Decimal) bool { + return d.Cmp(other) >= 0 +} + +// LessThanOrEqual returns true if d <= other. +func (d Decimal) LessThanOrEqual(other Decimal) bool { + return d.Cmp(other) <= 0 +} + +// Min returns the smaller of a and b. +func Min(a, b Decimal) Decimal { + if a.Cmp(b) <= 0 { + return a + } + return b +} + +// Max returns the larger of a and b. +func Max(a, b Decimal) Decimal { + if a.Cmp(b) >= 0 { + return a + } + return b +} + +// Add returns d + other. +func (d Decimal) Add(other Decimal) Decimal { + a, b := alignScale(d, other) + res := new(big.Int).Add(a.val, b.val) + return Decimal{val: res, scale: a.scale}.normalize() +} + +// Sub returns d - other. +func (d Decimal) Sub(other Decimal) Decimal { + a, b := alignScale(d, other) + res := new(big.Int).Sub(a.val, b.val) + return Decimal{val: res, scale: a.scale}.normalize() +} + +// Mul returns d * other. +func (d Decimal) Mul(other Decimal) Decimal { + d = d.ensureInit() + other = other.ensureInit() + res := new(big.Int).Mul(d.val, other.val) + return Decimal{val: res, scale: d.scale + other.scale}.normalize() +} + +// Div returns d / other up to precision decimal places (default 8). +func (d Decimal) Div(other Decimal, precision ...int32) (Decimal, error) { + d = d.ensureInit() + other = other.ensureInit() + + if other.IsZero() { + return Decimal{}, ErrDivisionByZero + } + + prec := int32(8) + if len(precision) > 0 && precision[0] >= 0 { + prec = precision[0] + } + if prec > maxDecimalOperationDigits { + return Decimal{}, ErrInvalidDecimalPrecision + } + if d.scale < -maxDecimalScale || d.scale > maxDecimalScale || other.scale < -maxDecimalScale || other.scale > maxDecimalScale { + return Decimal{}, ErrInvalidDecimalScale + } + + scaleDiff64 := int64(prec) + int64(other.scale) - int64(d.scale) + if scaleDiff64 < -int64(maxDecimalOperationDigits) || scaleDiff64 > int64(maxDecimalOperationDigits) { + return Decimal{}, ErrInvalidDecimalScale + } + scaleDiff := int32(scaleDiff64) + var quotient *big.Int + if scaleDiff >= 0 { + num := new(big.Int).Mul(d.val, bigExp10(scaleDiff)) + quotient = new(big.Int).Quo(num, other.val) + } else { + denom := new(big.Int).Mul(other.val, bigExp10(-scaleDiff)) + quotient = new(big.Int).Quo(d.val, denom) + } + + return Decimal{val: quotient, scale: prec}.normalize(), nil +} + +// RoundToTick rounds d down toward negative infinity to the nearest multiple +// of a positive tickSize. This is the exchange-safe behavior for prices and +// quantities, which must never be rounded up. +func (d Decimal) RoundToTick(tickSize Decimal) Decimal { + if !tickSize.IsPositive() { + return d + } + div, err := d.Div(tickSize, 0) + if err != nil { + return d + } + result := div.Mul(tickSize) + if d.IsNegative() && !d.Equal(result) { + result = div.Sub(NewDecimal(1, 0)).Mul(tickSize) + } + return result +} + +// Quantize rounds d down to the nearest multiple of tickSize. +func (d Decimal) Quantize(tickSize Decimal) Decimal { + return d.RoundToTick(tickSize) +} + +// QuantizePrice aligns a price to the exchange's minimum tick size. +func (d Decimal) QuantizePrice(tickSize Decimal) Decimal { + return d.RoundToTick(tickSize) +} + +// QuantizeAmount aligns an order quantity to the exchange's minimum lot size. +func (d Decimal) QuantizeAmount(lotSize Decimal) Decimal { + return d.RoundToTick(lotSize) +} + +// AddBps adds basis points (1 bps = 0.01% = 0.0001) to d using exact decimal arithmetic. +func (d Decimal) AddBps(bps float64) Decimal { + if bps == 0 { + return d + } + bpsDec, err := ParseDecimal(strconv.FormatFloat(bps, 'f', -1, 64)) + if err != nil { + return d + } + tenThousand := NewDecimal(10000, 0) + prec := d.scale + 4 + if prec < 8 { + prec = 8 + } + adjustment, err := d.Mul(bpsDec).Div(tenThousand, prec) + if err != nil { + return d + } + return d.Add(adjustment) +} + +// SubBps subtracts basis points (1 bps = 0.01% = 0.0001) from d. +func (d Decimal) SubBps(bps float64) Decimal { + return d.AddBps(-bps) +} + +// BpsDiff calculates the spread/difference between two prices in basis points: ((d - other) / other) * 10000. +func (d Decimal) BpsDiff(other Decimal) float64 { + result, err := d.BpsDiffChecked(other) + if err != nil { + return 0 + } + return result +} + +// BpsDiffChecked calculates the basis-point difference without converting the +// input decimals to float until the final result. +func (d Decimal) BpsDiffChecked(other Decimal) (float64, error) { + if other.IsZero() { + return 0, ErrDivisionByZero + } + diff, err := d.Sub(other).Div(other, 18) + if err != nil { + return 0, err + } + return diff.Mul(NewDecimal(10000, 0)).Float64Checked() +} + +func (d Decimal) ensureInit() Decimal { + if d.val == nil { + return Decimal{val: bigZero, scale: 0} + } + return d +} + +func (d Decimal) normalize() Decimal { + d = d.ensureInit() + if d.val.Sign() == 0 { + return Zero() + } + if d.scale == 0 { + return d + } + // Strip trailing zeros from scale + rem := new(big.Int) + val := new(big.Int).Set(d.val) + scale := d.scale + + for scale > 0 { + q, r := new(big.Int).QuoRem(val, bigTen, rem) + if r.Sign() == 0 { + val = q + scale-- + } else { + break + } + } + return Decimal{val: val, scale: scale} +} + +func alignScale(a, b Decimal) (Decimal, Decimal) { + a = a.ensureInit() + b = b.ensureInit() + + if a.scale == b.scale { + return a, b + } + + if a.scale < b.scale { + diff := b.scale - a.scale + aVal := new(big.Int).Mul(a.val, bigExp10(diff)) + return Decimal{val: aVal, scale: b.scale}, b + } + + diff := a.scale - b.scale + bVal := new(big.Int).Mul(b.val, bigExp10(diff)) + return a, Decimal{val: bVal, scale: a.scale} +} + +func bigExp10(n int32) *big.Int { + if n <= 0 { + return pow10BigInt[0] + } + if n <= 18 { + return pow10BigInt[n] + } + res := new(big.Int).SetInt64(10) + return res.Exp(res, big.NewInt(int64(n)), nil) +} + +// Abs returns the absolute value of d. +func (d Decimal) Abs() Decimal { + d = d.ensureInit() + if d.IsNegative() { + return Decimal{val: new(big.Int).Abs(d.val), scale: d.scale} + } + return d +} + +// Neg returns the negated value -d. +func (d Decimal) Neg() Decimal { + d = d.ensureInit() + return Decimal{val: new(big.Int).Neg(d.val), scale: d.scale} +} + +// MarshalJSON marshals Decimal as a JSON string. +func (d Decimal) MarshalJSON() ([]byte, error) { + return []byte(`"` + d.String() + `"`), nil +} + +// MarshalJSON marshals DecimalNumber as a JSON number without converting +// through float64. +func (d DecimalNumber) MarshalJSON() ([]byte, error) { + return []byte(d.String()), nil +} + +// UnmarshalJSON unmarshals DecimalNumber from a JSON string or number. Both +// forms are accepted because the exchange has historically returned both +// representations for some endpoints. +func (d *DecimalNumber) UnmarshalJSON(b []byte) error { + var decimal Decimal + if err := decimal.UnmarshalJSON(b); err != nil { + return err + } + d.Decimal = decimal + return nil +} + +// UnmarshalJSON unmarshals Decimal from JSON string or number. +func (d *Decimal) UnmarshalJSON(b []byte) error { + b = bytes.TrimSpace(b) + if bytes.Equal(b, []byte("null")) { + *d = Zero() + return nil + } + var value string + if len(b) > 0 && b[0] == '"' { + if err := json.Unmarshal(b, &value); err != nil { + return ErrInvalidDecimal + } + } else { + var number json.Number + if err := json.Unmarshal(b, &number); err != nil { + return ErrInvalidDecimal + } + value = number.String() + } + parsed, err := ParseDecimal(value) + if err != nil { + return err + } + *d = parsed + return nil +} + +// MarshalText implements encoding.TextMarshaler. +func (d Decimal) MarshalText() ([]byte, error) { + return []byte(d.String()), nil +} + +// UnmarshalText implements encoding.TextUnmarshaler. +func (d *Decimal) UnmarshalText(text []byte) error { + if len(text) == 0 || bytes.Equal(text, []byte("null")) { + *d = Zero() + return nil + } + parsed, err := ParseDecimal(string(text)) + if err != nil { + return err + } + *d = parsed + return nil +} diff --git a/packages/sdk-go/types/decimal_test.go b/packages/sdk-go/types/decimal_test.go new file mode 100644 index 00000000..e8dd8ef3 --- /dev/null +++ b/packages/sdk-go/types/decimal_test.go @@ -0,0 +1,411 @@ +package types_test + +import ( + "encoding/json" + "errors" + "testing" + + "github.com/gemini/gemini-go/types" +) + +func TestDecimal_Arithmetic(t *testing.T) { + d1, err := types.ParseDecimal("0.1") + if err != nil { + t.Fatalf("unexpected parse err: %v", err) + } + + d2 := types.MustParseDecimal("0.2") + + // In IEEE-754 float64, 0.1 + 0.2 != 0.3 + // In types.Decimal, 0.1 + 0.2 must exactly equal 0.3! + sum := d1.Add(d2) + if sum.String() != "0.3" { + t.Fatalf("expected 0.3, got %s", sum.String()) + } + + diff := sum.Sub(d1) + if diff.String() != "0.2" { + t.Fatalf("expected 0.2, got %s", diff.String()) + } + + prod := d1.Mul(d2) + if prod.String() != "0.02" { + t.Fatalf("expected 0.02, got %s", prod.String()) + } + + quo, err := d2.Div(d1) + if err != nil { + t.Fatalf("div error: %v", err) + } + if quo.String() != "2" { + t.Fatalf("expected 2, got %s", quo.String()) + } +} + +func TestDecimal_RoundToTick(t *testing.T) { + price := types.MustParseDecimal("64500.12845") + tickSize := types.MustParseDecimal("0.01") + + rounded := price.RoundToTick(tickSize) + if rounded.String() != "64500.12" { + t.Fatalf("expected rounded 64500.12, got %s", rounded.String()) + } +} + +func TestDecimal_JSON(t *testing.T) { + type Order struct { + Price types.Decimal `json:"price"` + } + + raw := `{"price":"65432.10"}` + var o Order + if err := json.Unmarshal([]byte(raw), &o); err != nil { + t.Fatalf("failed unmarshaling decimal json: %v", err) + } + if o.Price.String() != "65432.1" { + t.Fatalf("expected 65432.1, got %s", o.Price.String()) + } + + out, err := json.Marshal(o) + if err != nil { + t.Fatalf("failed marshaling decimal json: %v", err) + } + if string(out) != `{"price":"65432.1"}` { + t.Fatalf("unexpected json output: %s", string(out)) + } + + var scientific struct { + Price types.Decimal `json:"price"` + } + if err := json.Unmarshal([]byte(`{"price":1e-8}`), &scientific); err != nil { + t.Fatalf("failed unmarshaling scientific decimal json: %v", err) + } + if scientific.Price.String() != "0.00000001" { + t.Fatalf("expected exact scientific decimal, got %s", scientific.Price.String()) + } +} + +func TestDecimalNumber_JSONPreservesNumericWireShape(t *testing.T) { + type Response struct { + TickSize types.DecimalNumber `json:"tick_size"` + } + + var response Response + if err := json.Unmarshal([]byte(`{"tick_size":0.00000001}`), &response); err != nil { + t.Fatalf("failed unmarshaling numeric decimal: %v", err) + } + if response.TickSize.String() != "0.00000001" { + t.Fatalf("unexpected decimal value: %s", response.TickSize) + } + + out, err := json.Marshal(response) + if err != nil { + t.Fatalf("failed marshaling numeric decimal: %v", err) + } + if string(out) != `{"tick_size":0.00000001}` { + t.Fatalf("numeric decimal was not preserved: %s", out) + } + + if err := json.Unmarshal([]byte(`{"tick_size":"1.25"}`), &response); err != nil { + t.Fatalf("failed accepting legacy quoted decimal: %v", err) + } + out, err = json.Marshal(response) + if err != nil { + t.Fatalf("failed marshaling legacy quoted decimal: %v", err) + } + if string(out) != `{"tick_size":1.25}` { + t.Fatalf("quoted input was not normalized to numeric output: %s", out) + } +} + +func TestDecimal_DivisionByZero(t *testing.T) { + d := types.MustParseDecimal("100.5") + _, err := d.Div(types.Zero()) + if err == nil || !errors.Is(err, types.ErrDivisionByZero) { + t.Fatalf("expected ErrDivisionByZero, got %v", err) + } +} + +func TestDecimal_DivisionRejectsUnsafePrecision(t *testing.T) { + d := types.MustParseDecimal("1") + for _, precision := range []int32{1_001, int32(1<<31 - 1)} { + _, err := d.Div(types.MustParseDecimal("3"), precision) + if !errors.Is(err, types.ErrInvalidDecimalPrecision) { + t.Fatalf("Div precision %d error = %v, want ErrInvalidDecimalPrecision", precision, err) + } + } +} + +func TestDecimal_InvalidStrings(t *testing.T) { + invalidInputs := []string{ + "", + "abc", + "12.34.56", + "100e", + "1e2e3", + "--50", + "12-34", + ".", + "-.", + } + + for _, input := range invalidInputs { + _, err := types.ParseDecimal(input) + if err == nil { + t.Errorf("expected error for invalid input %q, got nil", input) + } + } +} + +func TestDecimal_ScientificNotation(t *testing.T) { + tests := map[string]string{ + "1e3": "1000", + "1.25e2": "125", + "-2.5e-2": "-0.025", + ".5e1": "5", + } + for input, expected := range tests { + t.Run(input, func(t *testing.T) { + got, err := types.ParseDecimal(input) + if err != nil { + t.Fatalf("ParseDecimal(%q): %v", input, err) + } + if got.String() != expected { + t.Fatalf("ParseDecimal(%q) = %q, want %q", input, got.String(), expected) + } + }) + } +} + +func TestDecimal_AcceptsBoundaryDecimalSyntax(t *testing.T) { + for input, expected := range map[string]string{ + ".5": "0.5", + "1.": "1", + " -0.25 ": "-0.25", + } { + got, err := types.ParseDecimal(input) + if err != nil { + t.Fatalf("ParseDecimal(%q) failed: %v", input, err) + } + if got.String() != expected { + t.Errorf("ParseDecimal(%q) = %s, want %s", input, got, expected) + } + } +} + +func TestDecimal_ComparisonsAndSigns(t *testing.T) { + dPos := types.MustParseDecimal("10.5") + dNeg := types.MustParseDecimal("-10.5") + dZero := types.Zero() + + if !(dPos.Cmp(dNeg) > 0) { + t.Errorf("expected %s > %s", dPos, dNeg) + } + if !(dNeg.Cmp(dPos) < 0) { + t.Errorf("expected %s < %s", dNeg, dPos) + } + if dPos.Cmp(types.MustParseDecimal("10.500")) != 0 { + t.Errorf("expected 10.5 == 10.500") + } + + if !dNeg.IsNegative() { + t.Errorf("expected %s to be negative", dNeg) + } + if dPos.IsNegative() { + t.Errorf("expected %s to not be negative", dPos) + } + if !dZero.IsZero() { + t.Errorf("expected dZero.IsZero() == true") + } +} + +func TestDecimal_NegativeNumbers(t *testing.T) { + d1 := types.MustParseDecimal("-50.25") + d2 := types.MustParseDecimal("20.10") + + sum := d1.Add(d2) + if sum.String() != "-30.15" { + t.Errorf("expected -30.15, got %s", sum.String()) + } + + prod := d1.Mul(types.MustParseDecimal("-2")) + if prod.String() != "100.5" { + t.Errorf("expected 100.5, got %s", prod.String()) + } +} + +func TestDecimal_BpsMath(t *testing.T) { + mid := types.MustParseDecimal("10000.00") + + // +10 bps = +0.10% = 10010.00 + plus10 := mid.AddBps(10.0) + if plus10.Cmp(types.MustParseDecimal("10010")) != 0 { + t.Errorf("expected 10010, got %s", plus10.String()) + } + + // -10 bps = -0.10% = 9990.00 + minus10 := mid.SubBps(10.0) + if minus10.Cmp(types.MustParseDecimal("9990")) != 0 { + t.Errorf("expected 9990, got %s", minus10.String()) + } + + // Bps diff between 10010 and 10000 should be exactly 10 bps + diffBps := plus10.BpsDiff(mid) + if diffBps < 9.99 || diffBps > 10.01 { + t.Errorf("expected ~10.0 bps diff, got %f", diffBps) + } +} + +func TestDecimal_Quantize(t *testing.T) { + price := types.MustParseDecimal("65123.4567") + tick001 := types.MustParseDecimal("0.01") + tick05 := types.MustParseDecimal("0.05") + + q001 := price.QuantizePrice(tick001) + if q001.Cmp(types.MustParseDecimal("65123.45")) != 0 { + t.Errorf("expected 65123.45, got %s", q001.String()) + } + + q05 := price.QuantizePrice(tick05) + if q05.Cmp(types.MustParseDecimal("65123.45")) != 0 { + t.Errorf("expected 65123.45, got %s", q05.String()) + } + + amount := types.MustParseDecimal("1.234567") + lot0001 := types.MustParseDecimal("0.001") + qLot := amount.QuantizeAmount(lot0001) + if qLot.Cmp(types.MustParseDecimal("1.234")) != 0 { + t.Errorf("expected 1.234, got %s", qLot.String()) + } +} + +func TestDecimal_QuantizeRejectsInvalidTickAndRoundsNegativeDown(t *testing.T) { + value := types.MustParseDecimal("1.23") + if got := value.Quantize(types.Zero()); got.Cmp(value) != 0 { + t.Fatalf("invalid zero tick changed value to %s", got) + } + negative := types.MustParseDecimal("-1.23") + if got := negative.Quantize(types.MustParseDecimal("0.1")); got.Cmp(types.MustParseDecimal("-1.3")) != 0 { + t.Fatalf("expected negative quantization to round down to -1.3, got %s", got) + } +} + +func TestDecimal_Float64CheckedReportsOverflow(t *testing.T) { + value := types.NewDecimal(1, -1000) + if _, err := value.Float64Checked(); err == nil { + t.Fatal("expected Float64Checked to report overflow") + } +} + +func TestDecimal_NewDecimalCheckedRejectsUnsafeScale(t *testing.T) { + if _, err := types.NewDecimalChecked(1, 1_000_001); !errors.Is(err, types.ErrInvalidDecimalScale) { + t.Fatalf("expected ErrInvalidDecimalScale, got %v", err) + } + if _, err := types.NewDecimalChecked(1, -1_000_001); !errors.Is(err, types.ErrInvalidDecimalScale) { + t.Fatalf("expected ErrInvalidDecimalScale, got %v", err) + } +} + +func TestDecimal_AbsNeg(t *testing.T) { + pos := types.MustParseDecimal("42.50") + neg := types.MustParseDecimal("-42.50") + zero := types.Zero() + + if pos.Neg().Cmp(neg) != 0 { + t.Errorf("expected -42.50, got %s", pos.Neg().String()) + } + if neg.Abs().Cmp(pos) != 0 { + t.Errorf("expected 42.50, got %s", neg.Abs().String()) + } + if zero.Abs().Cmp(zero) != 0 { + t.Errorf("expected 0, got %s", zero.Abs().String()) + } + + if pos.Sign() != 1 || neg.Sign() != -1 || zero.Sign() != 0 { + t.Errorf("unexpected Sign results: pos=%d, neg=%d, zero=%d", pos.Sign(), neg.Sign(), zero.Sign()) + } + + if types.Min(pos, neg).Cmp(neg) != 0 { + t.Errorf("expected Min to return neg") + } + if types.Max(pos, neg).Cmp(pos) != 0 { + t.Errorf("expected Max to return pos") + } +} + +func TestDecimal_TextMarshal(t *testing.T) { + d := types.MustParseDecimal("123.456") + text, err := d.MarshalText() + if err != nil { + t.Fatalf("MarshalText failed: %v", err) + } + if string(text) != "123.456" { + t.Fatalf("expected '123.456', got '%s'", string(text)) + } + + var d2 types.Decimal + if err := d2.UnmarshalText(text); err != nil { + t.Fatalf("UnmarshalText failed: %v", err) + } + if d2.Cmp(d) != 0 { + t.Fatalf("expected %s, got %s", d.String(), d2.String()) + } + + var d3 types.Decimal + if err := d3.UnmarshalText([]byte("null")); err != nil { + t.Fatalf("UnmarshalText null failed: %v", err) + } + if !d3.IsZero() { + t.Fatalf("expected zero for null, got %s", d3.String()) + } +} + +func TestDecimal_UnmarshalJSONRejectsMalformedJSON(t *testing.T) { + for _, input := range []string{`"1.25`, `1.25"`, `true`, `{"value":1}`} { + var d types.Decimal + if err := d.UnmarshalJSON([]byte(input)); err == nil { + t.Errorf("expected malformed JSON %q to fail", input) + } + } + + var d types.Decimal + if err := d.UnmarshalJSON([]byte(`"1.25"`)); err != nil || d.String() != "1.25" { + t.Fatalf("expected quoted decimal to decode, got %s, %v", d.String(), err) + } +} + +func BenchmarkDecimal_Parse(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _, _ = types.ParseDecimal("64500.12845") + } +} + +func BenchmarkDecimal_Add(b *testing.B) { + d1 := types.MustParseDecimal("64500.12845") + d2 := types.MustParseDecimal("123.456") + b.ResetTimer() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = d1.Add(d2) + } +} + +func BenchmarkDecimal_Mul(b *testing.B) { + d1 := types.MustParseDecimal("64500.12845") + d2 := types.MustParseDecimal("1.5") + b.ResetTimer() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = d1.Mul(d2) + } +} + +func BenchmarkDecimal_String(b *testing.B) { + d := types.MustParseDecimal("64500.12845") + b.ResetTimer() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = d.String() + } +} diff --git a/packages/sdk-go/types/finance.go b/packages/sdk-go/types/finance.go new file mode 100644 index 00000000..df35443c --- /dev/null +++ b/packages/sdk-go/types/finance.go @@ -0,0 +1,128 @@ +package types + +import ( + "errors" + "math" + "strconv" +) + +var ( + // ErrInvalidLeverage is returned when leverage is less than 1.0. + ErrInvalidLeverage = errors.New("gemini types: leverage must be greater than or equal to 1.0") + + // ErrInvalidPrice is returned when price is non-positive or outside required bounds. + ErrInvalidPrice = errors.New("gemini types: price must be positive") + + // ErrInvalidQuantity is returned when quantity is non-positive. + ErrInvalidQuantity = errors.New("gemini types: quantity must be positive") + + // ErrInvalidRate is returned when a rate is negative, NaN, or infinite. + ErrInvalidRate = errors.New("gemini types: rate must be finite and non-negative") +) + +// CalculateNotional computes the total notional value of an order or trade (Price * Quantity). +func CalculateNotional(price, quantity Decimal) Decimal { + return price.Mul(quantity) +} + +// CalculateFee computes the transaction fee in quote currency: Notional * (feeBps / 10000). +func CalculateFee(notional Decimal, feeBps float64) Decimal { + if feeBps == 0 || notional.IsZero() || math.IsNaN(feeBps) || math.IsInf(feeBps, 0) || feeBps < 0 { + return Zero() + } + bpsDec, err := ParseDecimal(strconv.FormatFloat(feeBps, 'f', -1, 64)) + if err != nil { + return Zero() + } + tenThousand := NewDecimal(10000, 0) + fee, err := notional.Mul(bpsDec).Div(tenThousand, 8) + if err != nil { + return Zero() + } + return fee +} + +// CalculatePnL computes the profit/loss and ROI percentage for a position or closed trade. +// For Long: PnL = (ExitPrice - EntryPrice) * Quantity +// For Short: PnL = (EntryPrice - ExitPrice) * Quantity +func CalculatePnL(entryPrice, exitPrice, quantity Decimal, isLong bool) (pnl Decimal, roiPercent float64) { + if !entryPrice.IsPositive() || !quantity.IsPositive() { + return Zero(), 0 + } + if isLong { + pnl = exitPrice.Sub(entryPrice).Mul(quantity) + } else { + pnl = entryPrice.Sub(exitPrice).Mul(quantity) + } + initialNotional := entryPrice.Mul(quantity) + if !initialNotional.IsZero() { + pnlFloat, pnlErr := pnl.Float64Checked() + notionalFloat, notionalErr := initialNotional.Float64Checked() + if pnlErr == nil && notionalErr == nil && notionalFloat != 0 { + roiPercent = (pnlFloat / notionalFloat) * 100.0 + } + } + return pnl, roiPercent +} + +// CalculateLiquidationPrice estimates the bankruptcy liquidation trigger price for a leveraged position. +// +// Long Position: LiqPrice = EntryPrice * (1 - 1/Leverage + MaintenanceMarginRate) +// Short Position: LiqPrice = EntryPrice * (1 + 1/Leverage - MaintenanceMarginRate) +func CalculateLiquidationPrice(entryPrice Decimal, leverage float64, maintenanceMarginRate float64, isLong bool) (Decimal, error) { + if math.IsNaN(leverage) || math.IsInf(leverage, 0) || leverage < 1.0 { + return Decimal{}, ErrInvalidLeverage + } + if math.IsNaN(maintenanceMarginRate) || math.IsInf(maintenanceMarginRate, 0) || maintenanceMarginRate < 0 { + return Decimal{}, ErrInvalidRate + } + if !entryPrice.IsPositive() { + return Decimal{}, ErrInvalidPrice + } + leverageDec, err := ParseDecimal(strconv.FormatFloat(leverage, 'f', -1, 64)) + if err != nil { + return Decimal{}, err + } + rateDec, err := ParseDecimal(strconv.FormatFloat(maintenanceMarginRate, 'f', -1, 64)) + if err != nil { + return Decimal{}, err + } + one := NewDecimal(1, 0) + inverse, err := one.Div(leverageDec, 18) + if err != nil { + return Decimal{}, err + } + factor := one.Add(inverse).Sub(rateDec) + if isLong { + factor = one.Sub(inverse).Add(rateDec) + if factor.IsNegative() { + factor = Zero() + } + } + return entryPrice.Mul(factor), nil +} + +// PredictionMarketPayout calculates financial returns for binary prediction contracts ($1.00 settlement per winning contract). +// +// price: purchase price per contract (e.g. 0.65 for 65 cents) +// contracts: number of contracts purchased +func PredictionMarketPayout(price Decimal, contracts Decimal) (maxPayout Decimal, cost Decimal, maxProfit Decimal, returnPercent float64, err error) { + oneDollar := NewDecimal(1, 0) + if !price.IsPositive() || price.GreaterThan(oneDollar) { + return Zero(), Zero(), Zero(), 0, errors.New("gemini types: prediction contract price must be between 0.00 and 1.00") + } + if !contracts.IsPositive() { + return Zero(), Zero(), Zero(), 0, ErrInvalidQuantity + } + maxPayout = oneDollar.Mul(contracts) + cost = price.Mul(contracts) + maxProfit = maxPayout.Sub(cost) + if !cost.IsZero() { + profitFloat, profitErr := maxProfit.Float64Checked() + costFloat, costErr := cost.Float64Checked() + if profitErr == nil && costErr == nil && costFloat != 0 { + returnPercent = (profitFloat / costFloat) * 100.0 + } + } + return maxPayout, cost, maxProfit, returnPercent, nil +} diff --git a/packages/sdk-go/types/finance_test.go b/packages/sdk-go/types/finance_test.go new file mode 100644 index 00000000..5a7b7ea1 --- /dev/null +++ b/packages/sdk-go/types/finance_test.go @@ -0,0 +1,126 @@ +package types_test + +import ( + "errors" + "math" + "testing" + + "github.com/gemini/gemini-go/types" +) + +func TestCalculateNotional(t *testing.T) { + price := types.MustParseDecimal("50000.50") + qty := types.MustParseDecimal("1.5") + notional := types.CalculateNotional(price, qty) + expected := "75000.75" + if notional.String() != expected { + t.Fatalf("expected notional %s, got %s", expected, notional.String()) + } +} + +func TestCalculateFee(t *testing.T) { + notional := types.MustParseDecimal("100000") + // 10 bps = 0.10% = 0.001 -> fee should be 100 + fee := types.CalculateFee(notional, 10.0) + if fee.String() != "100" { + t.Fatalf("expected fee 100, got %s", fee.String()) + } + + // 25 bps = 0.25% = 0.0025 -> fee on 50000 = 125 + fee2 := types.CalculateFee(types.MustParseDecimal("50000"), 25.0) + if fee2.String() != "125" { + t.Fatalf("expected fee 125, got %s", fee2.String()) + } + if got := types.CalculateFee(notional, math.NaN()); !got.IsZero() { + t.Fatalf("expected NaN fee rate to produce zero, got %s", got) + } + if got := types.CalculateFee(notional, -1); !got.IsZero() { + t.Fatalf("expected negative fee rate to produce zero, got %s", got) + } +} + +func TestCalculatePnL(t *testing.T) { + entry := types.MustParseDecimal("60000") + exit := types.MustParseDecimal("66000") + qty := types.MustParseDecimal("2") + + // Long: bought at 60k, sold at 66k -> +12,000 PnL (+10% ROI) + pnlLong, roiLong := types.CalculatePnL(entry, exit, qty, true) + if pnlLong.String() != "12000" { + t.Fatalf("expected long pnl 12000, got %s", pnlLong.String()) + } + if roiLong != 10.0 { + t.Fatalf("expected long ROI 10.0, got %f", roiLong) + } + + // Short: shorted at 60k, bought back at 66k -> -12,000 PnL (-10% ROI) + pnlShort, roiShort := types.CalculatePnL(entry, exit, qty, false) + if pnlShort.String() != "-12000" { + t.Fatalf("expected short pnl -12000, got %s", pnlShort.String()) + } + if roiShort != -10.0 { + t.Fatalf("expected short ROI -10.0, got %f", roiShort) + } +} + +func TestCalculateLiquidationPrice(t *testing.T) { + entry := types.MustParseDecimal("50000") + leverage := 10.0 // 10x leverage + mmr := 0.005 // 0.5% maintenance margin rate + + // Long Liq: 50000 * (1 - 0.1 + 0.005) = 50000 * 0.905 = 45250 + liqLong, err := types.CalculateLiquidationPrice(entry, leverage, mmr, true) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if liqLong.String() != "45250" { + t.Fatalf("expected long liquidation price 45250, got %s", liqLong.String()) + } + + // Short Liq: 50000 * (1 + 0.1 - 0.005) = 50000 * 1.095 = 54750 + liqShort, err := types.CalculateLiquidationPrice(entry, leverage, mmr, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if liqShort.String() != "54750" { + t.Fatalf("expected short liquidation price 54750, got %s", liqShort.String()) + } +} + +func TestCalculateLiquidationPriceRejectsInvalidInputs(t *testing.T) { + entry := types.MustParseDecimal("50000") + for _, leverage := range []float64{0.5, math.NaN(), math.Inf(1)} { + if _, err := types.CalculateLiquidationPrice(entry, leverage, 0.005, true); !errors.Is(err, types.ErrInvalidLeverage) { + t.Errorf("leverage %v: expected ErrInvalidLeverage, got %v", leverage, err) + } + } + if _, err := types.CalculateLiquidationPrice(entry, 2, math.NaN(), true); !errors.Is(err, types.ErrInvalidRate) { + t.Fatalf("expected ErrInvalidRate for NaN maintenance rate, got %v", err) + } + if _, err := types.CalculateLiquidationPrice(types.Zero(), 2, 0, true); !errors.Is(err, types.ErrInvalidPrice) { + t.Fatalf("expected ErrInvalidPrice for zero entry, got %v", err) + } +} + +func TestPredictionMarketPayout(t *testing.T) { + price := types.MustParseDecimal("0.60") // 60 cents + contracts := types.MustParseDecimal("100") + + maxPayout, cost, maxProfit, roi, err := types.PredictionMarketPayout(price, contracts) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if maxPayout.String() != "100" { + t.Fatalf("expected max payout 100, got %s", maxPayout.String()) + } + if cost.String() != "60" { + t.Fatalf("expected cost 60, got %s", cost.String()) + } + if maxProfit.String() != "40" { + t.Fatalf("expected max profit 40, got %s", maxProfit.String()) + } + // ROI: (40 / 60) * 100 = 66.6666...% + if roi < 66.6 || roi > 66.7 { + t.Fatalf("expected ROI ~66.67%%, got %f", roi) + } +} diff --git a/packages/sdk-go/websocket/client.go b/packages/sdk-go/websocket/client.go new file mode 100644 index 00000000..c3fe6a86 --- /dev/null +++ b/packages/sdk-go/websocket/client.go @@ -0,0 +1,613 @@ +package websocket + +import ( + "errors" + "fmt" + "log/slog" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/gemini/gemini-go/auth" + "github.com/gemini/gemini-go/transport" +) + +// ErrAuthenticationRequired is returned when an authenticated WebSocket operation +// is attempted on an unauthenticated client. +var ErrAuthenticationRequired = transport.ErrAuthenticationRequired + +// ErrInvalidURL indicates that the WebSocket endpoint is not an absolute URL +// with a host and the required WSS scheme. +var ErrInvalidURL = errors.New("gemini websocket: invalid endpoint URL") + +// ErrRequestFailed is returned when the WebSocket server rejects a correlated +// request with a non-success status. +var ErrRequestFailed = errors.New("gemini websocket: request failed") + +// ErrResubscribeFailed indicates that one or more active feeds could not be +// restored after reconnecting. Existing subscription channels are closed so +// callers cannot mistake a connected socket for a healthy stream. +var ErrResubscribeFailed = errors.New("gemini websocket: feed resubscription failed") + +// ErrSlowConsumer indicates that a subscriber did not drain its bounded +// channel quickly enough and the inbound dispatch queue filled. The client +// reports this through ConnectionEvent.Err and reconnects when enabled. +var ErrSlowConsumer = errors.New("gemini websocket: subscriber too slow") + +// RequestError describes a failed correlated WebSocket request. +type RequestError struct { + ID string + Status int + Code int + Message string +} + +func (e *RequestError) Error() string { + if e.Message != "" { + return fmt.Sprintf("gemini websocket: request %s failed (status %d): %s", e.ID, e.Status, e.Message) + } + return fmt.Sprintf("gemini websocket: request %s failed (status %d)", e.ID, e.Status) +} + +func (e *RequestError) Unwrap() error { + return ErrRequestFailed +} + +// ConnectionState represents the lifecycle status of the WebSocket connection. +type ConnectionState int32 + +const ( + StateDisconnected ConnectionState = iota + StateConnecting + StateConnected + StateReconnecting + StateClosed +) + +func (s ConnectionState) String() string { + switch s { + case StateDisconnected: + return "Disconnected" + case StateConnecting: + return "Connecting" + case StateConnected: + return "Connected" + case StateReconnecting: + return "Reconnecting" + case StateClosed: + return "Closed" + default: + return "Unknown" + } +} + +// ConnectionEvent is published on state transitions. +type ConnectionEvent struct { + State ConnectionState + Err error +} + +// Client manages a resilient, multiplexed WebSocket connection to Gemini. +type Client struct { + url string + dialer Dialer + logger *slog.Logger + headers http.Header + auth auth.Strategy + privateOnly bool + autoReconnect bool + maxReconnects int + snapshotValue int + configErr error + // isolateSnapshots gives snapshot-capable depth feeds a connection per + // symbol. Some environments return partial-depth snapshots without a + // symbol, so sharing one connection would make those frames ambiguous. + isolateSnapshots bool + isolatePartialSnapshots bool + maxMessageSize int64 + livenessInterval time.Duration + livenessTimeout time.Duration + + mu sync.RWMutex + lifecycleGate chan struct{} + writeMu sync.Mutex // Serializes gorilla websocket writes + conn Conn + state atomic.Int32 + lifecycle atomic.Uint64 + doneChan chan struct{} + closeOnce sync.Once + closeErr error + eventChan chan ConnectionEvent + eventMu sync.Mutex + pendingEvent *ConnectionEvent + eventPumpRunning bool + eventClosed bool + eventWg sync.WaitGroup + eventSubscribers map[uint64]chan ConnectionEvent + nextEventSubscriber uint64 + pumpWg sync.WaitGroup + connectDone chan struct{} + connectErr error + pendingMu sync.Mutex + pending map[string]chan requestResult + + subsMu sync.Mutex + rfqScopeMu sync.Mutex + subscriptionWireMu sync.Mutex + subscriptionGates map[string]chan struct{} + subscriptionReplayGate chan struct{} + activeFeeds map[string]RequestFrame + snapshotMode bool + snapshotPending map[string]uint64 + subscriptionGeneration atomic.Uint64 + replayInProgress bool + subTables atomic.Pointer[subTables] + snapshotMu sync.Mutex + snapshotClients map[string]*Client + snapshotClientRefs map[string]int + partialSnapshotClients map[string]*Client + partialSnapshotRefs map[string]int +} + +func newTokenGate() chan struct{} { + gate := make(chan struct{}, 1) + gate <- struct{}{} + return gate +} + +type requestResult struct { + response ResponseFrame + err error +} + +type subTables struct { + depthSubs map[string][]*subscription[DepthUpdate] + partialDepthSubs map[string][]*subscription[OrderBookSnapshot] + tradeSubs map[string][]*subscription[TradeEvent] + tickerSubs map[string][]*subscription[BookTicker] + contractSubs map[string][]*subscription[ContractStatusEvent] + orderSubs map[string][]*subscription[OrderEvent] + balanceSubs map[string][]*subscription[BalanceUpdate] + positionSubs map[string][]*subscription[PositionReport] + settleSubs map[string][]*subscription[SettlementUpdate] + rfqPublicSubs map[string][]*subscription[RFQPublicEvent] + rfqPrivateSubs map[string][]*subscription[RFQPrivateDelivery] +} + +// subscription coordinates delivery with channel closure without requiring +// the client-wide subscription table lock to remain held while a consumer is +// draining a bounded channel. +type subscription[T any] struct { + ch chan *T + done chan struct{} + mu sync.Mutex + sends sync.WaitGroup + closed bool + closeOnce sync.Once +} + +func newSubscription[T any](buffer int) *subscription[T] { + return &subscription[T]{ + ch: make(chan *T, buffer), + done: make(chan struct{}), + } +} + +func (s *subscription[T]) send(stop <-chan struct{}, value *T) { + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return + } + s.sends.Add(1) + s.mu.Unlock() + defer s.sends.Done() + + select { + case s.ch <- value: + case <-s.done: + case <-stop: + } +} + +func (s *subscription[T]) close() { + s.closeOnce.Do(func() { + s.mu.Lock() + s.closed = true + close(s.done) + s.mu.Unlock() + s.sends.Wait() + close(s.ch) + }) +} + +func newSubTables() *subTables { + return &subTables{ + depthSubs: make(map[string][]*subscription[DepthUpdate]), + partialDepthSubs: make(map[string][]*subscription[OrderBookSnapshot]), + tradeSubs: make(map[string][]*subscription[TradeEvent]), + tickerSubs: make(map[string][]*subscription[BookTicker]), + contractSubs: make(map[string][]*subscription[ContractStatusEvent]), + orderSubs: make(map[string][]*subscription[OrderEvent]), + balanceSubs: make(map[string][]*subscription[BalanceUpdate]), + positionSubs: make(map[string][]*subscription[PositionReport]), + settleSubs: make(map[string][]*subscription[SettlementUpdate]), + rfqPublicSubs: make(map[string][]*subscription[RFQPublicEvent]), + rfqPrivateSubs: make(map[string][]*subscription[RFQPrivateDelivery]), + } +} + +func (t *subTables) clone() *subTables { + if t == nil { + return &subTables{} + } + cp := *t + return &cp +} + +func (t *subTables) closeAll() { + if t == nil { + return + } + closeMapSubscriptions(t.depthSubs) + closeMapSubscriptions(t.partialDepthSubs) + closeMapSubscriptions(t.tradeSubs) + closeMapSubscriptions(t.tickerSubs) + closeMapSubscriptions(t.contractSubs) + closeMapSubscriptions(t.orderSubs) + closeMapSubscriptions(t.balanceSubs) + closeMapSubscriptions(t.positionSubs) + closeMapSubscriptions(t.settleSubs) + closeMapSubscriptions(t.rfqPublicSubs) + closeMapSubscriptions(t.rfqPrivateSubs) +} + +func closeMapSubscriptions[T any](m map[string][]*subscription[T]) { + for _, subs := range m { + for _, sub := range subs { + sub.close() + } + } +} + +func closeSubscriptions[T any](subs []*subscription[T]) { + for _, sub := range subs { + sub.close() + } +} + +func cloneMapSlice[T any](src map[string][]*subscription[T]) map[string][]*subscription[T] { + dst := make(map[string][]*subscription[T], len(src)) + for k, v := range src { + cp := make([]*subscription[T], len(v)) + copy(cp, v) + dst[k] = cp + } + return dst +} + +func addMapSub[T any](src map[string][]*subscription[T], key string, sub *subscription[T]) map[string][]*subscription[T] { + dst := cloneMapSlice(src) + dst[key] = append(dst[key], sub) + return dst +} + +func removeMapSub[T any](src map[string][]*subscription[T], key string) (map[string][]*subscription[T], []*subscription[T], bool) { + subs, exists := src[key] + if !exists { + return src, nil, false + } + dst := cloneMapSlice(src) + delete(dst, key) + return dst, subs, true +} + +func appendSubscription[T any](src []*subscription[T], sub *subscription[T]) []*subscription[T] { + dst := make([]*subscription[T], len(src)+1) + copy(dst, src) + dst[len(src)] = sub + return dst +} + +type ClientOption func(*Client) + +// WithConfigurationError makes every connection attempt fail with the supplied +// configuration error. It lets higher-level facades preserve source-compatible +// constructors while rejecting invalid endpoint configuration before dialing. +func WithConfigurationError(err error) ClientOption { + return func(c *Client) { + c.configErr = err + } +} + +func WithDialer(d Dialer) ClientOption { + return func(c *Client) { + c.dialer = d + } +} + +func WithClientLogger(l *slog.Logger) ClientOption { + return func(c *Client) { + if l == nil { + l = slog.Default() + } + c.logger = l + } +} + +func WithHeaders(h http.Header) ClientOption { + return func(c *Client) { + c.headers = h.Clone() + } +} + +// WithAuth configures an authentication strategy (e.g. HMAC or Bearer token) for WebSocket connection upgrade. +func WithAuth(a auth.Strategy) ClientOption { + return func(c *Client) { + c.auth = a + } +} + +// WithAutoReconnect enables or disables automatic connection resumption (default: true). +func WithAutoReconnect(enabled bool) ClientOption { + return func(c *Client) { + c.autoReconnect = enabled + } +} + +// WithMaxReconnects sets the maximum reconnection attempts (0 or negative means infinite retries). +func WithMaxReconnects(max int) ClientOption { + return func(c *Client) { + c.maxReconnects = max + } +} + +// WithSnapshot requests an initial order-book snapshot on the WebSocket +// connection. A value of -1 requests the full book; positive values request +// the corresponding top-N partial snapshot. The setting is opt-in for the +// low-level WebSocket client. +func WithSnapshot(snapshot int) ClientOption { + return func(c *Client) { + parsed, err := url.Parse(c.url) + if err != nil { + return + } + query := parsed.Query() + if snapshot == 0 { + query.Del("snapshot") + } else { + query.Set("snapshot", strconv.Itoa(snapshot)) + } + parsed.RawQuery = query.Encode() + c.url = parsed.String() + c.snapshotValue = snapshot + c.snapshotMode = snapshot != 0 + } +} + +// WithIsolatedSnapshots makes snapshot-capable depth subscriptions use one +// underlying public connection per symbol. It is retained for compatibility +// with callers that need to isolate both differential and partial-depth feeds. +// New code should prefer WithIsolatedPartialSnapshots, which preserves +// multiplexing for differential depth streams whose snapshots include symbols. +func WithIsolatedSnapshots() ClientOption { + return func(c *Client) { + c.isolateSnapshots = true + c.isolatePartialSnapshots = true + } +} + +// WithIsolatedPartialSnapshots makes partial-depth subscriptions use one +// underlying public connection per symbol. Partial-depth snapshot envelopes +// may omit the symbol, so sharing a connection could make those frames +// ambiguous. Differential depth subscriptions remain multiplexed. +func WithIsolatedPartialSnapshots() ClientOption { + return func(c *Client) { + c.isolatePartialSnapshots = true + } +} + +// NewClient creates a new Gemini WebSocket client. +func NewClient(url string, opts ...ClientOption) *Client { + lifecycleGate := make(chan struct{}, 1) + lifecycleGate <- struct{}{} + c := &Client{ + url: url, + logger: slog.Default(), + autoReconnect: true, + maxMessageSize: DefaultMaxMessageSize, + lifecycleGate: lifecycleGate, + activeFeeds: make(map[string]RequestFrame), + doneChan: make(chan struct{}), + eventChan: make(chan ConnectionEvent, 32), + eventSubscribers: make(map[uint64]chan ConnectionEvent), + pending: make(map[string]chan requestResult), + subscriptionGates: make(map[string]chan struct{}), + subscriptionReplayGate: newTokenGate(), + snapshotPending: make(map[string]uint64), + snapshotClients: make(map[string]*Client), + snapshotClientRefs: make(map[string]int), + partialSnapshotClients: make(map[string]*Client), + partialSnapshotRefs: make(map[string]int), + } + c.subTables.Store(newSubTables()) + c.state.Store(int32(StateDisconnected)) + + for _, opt := range opts { + opt(c) + } + if c.configErr == nil { + c.configErr = validateWebSocketURL(c.url) + } + return c +} + +// NewClientWithError creates a WebSocket client and validates its endpoint +// before returning it. +func NewClientWithError(endpoint string, opts ...ClientOption) (*Client, error) { + c := NewClient(endpoint, opts...) + if c.configErr != nil { + return nil, c.configErr + } + return c, nil +} + +func validateWebSocketURL(endpoint string) error { + parsed, err := url.Parse(endpoint) + if err != nil { + return fmt.Errorf("%w: %v", ErrInvalidURL, err) + } + if parsed.Host == "" || !strings.EqualFold(parsed.Scheme, "wss") { + return fmt.Errorf("%w: expected absolute URL with a wss scheme", ErrInvalidURL) + } + if parsed.User != nil || parsed.Fragment != "" { + return fmt.Errorf("%w: endpoint must not include userinfo or fragment", ErrInvalidURL) + } + return nil +} + +// NewPublicClient creates a dedicated unauthenticated Gemini WebSocket client for market data streaming. +func NewPublicClient(url string, opts ...ClientOption) *Client { + c := NewClient(url, opts...) + c.auth = nil + return c +} + +// NewPrivateClient creates a dedicated authenticated Gemini WebSocket client for private account feeds and order execution. +func NewPrivateClient(url string, strategy auth.Strategy, opts ...ClientOption) *Client { + opts = append(opts, WithAuth(strategy)) + c := NewClient(url, opts...) + c.privateOnly = true + return c +} + +// State returns the current connection lifecycle state. +func (c *Client) State() ConnectionState { + return ConnectionState(c.state.Load()) +} + +// Events returns a channel streaming connection lifecycle events. The channel +// is buffered and coalesces notifications when a consumer falls behind; State +// is authoritative. The channel is closed after Close has stopped the read +// and reconnect pumps. +// A subscriber that does not drain its bounded feed channel can cause an +// ErrSlowConsumer event; applications must treat that as a data-recovery +// signal and resync the affected state. +func (c *Client) Events() <-chan ConnectionEvent { + return c.eventChan +} + +// SubscribeConnectionEvents registers an independent lifecycle-event +// subscriber. Unlike Events, this does not consume the client's shared event +// stream, so libraries can observe reconnect boundaries without interfering +// with application consumers. +// +// Events are delivered without blocking connection state transitions. If the +// buffer is full, the subscriber is closed so it cannot miss a lifecycle +// boundary silently. The returned stop function is idempotent. +func (c *Client) SubscribeConnectionEvents(buffer int) (<-chan ConnectionEvent, func()) { + if buffer < 1 { + buffer = 1 + } + ch := make(chan ConnectionEvent, buffer) + + c.eventMu.Lock() + if c.eventClosed { + close(ch) + c.eventMu.Unlock() + return ch, func() {} + } + c.nextEventSubscriber++ + id := c.nextEventSubscriber + c.eventSubscribers[id] = ch + c.eventMu.Unlock() + + var stopOnce sync.Once + stop := func() { + stopOnce.Do(func() { + c.eventMu.Lock() + if subscriber, ok := c.eventSubscribers[id]; ok { + delete(c.eventSubscribers, id) + close(subscriber) + } + c.eventMu.Unlock() + }) + } + return ch, stop +} + +func (c *Client) setState(s ConnectionState, err error) { + c.state.Store(int32(s)) + c.publishEvent(ConnectionEvent{State: s, Err: err}) +} + +func (c *Client) publishEvent(event ConnectionEvent) { + c.eventMu.Lock() + if c.eventClosed { + c.eventMu.Unlock() + return + } + for _, subscriber := range c.eventSubscribers { + select { + case subscriber <- event: + default: + for id, current := range c.eventSubscribers { + if current == subscriber { + close(current) + delete(c.eventSubscribers, id) + break + } + } + } + } + if c.eventPumpRunning { + c.pendingEvent = &event + c.eventMu.Unlock() + return + } + select { + case c.eventChan <- event: + c.eventMu.Unlock() + return + default: + } + c.pendingEvent = &event + if !c.eventPumpRunning { + c.eventPumpRunning = true + c.eventWg.Add(1) + go c.drainEvents() + } + c.eventMu.Unlock() +} + +// drainEvents coalesces backpressure to the latest lifecycle state rather +// than silently dropping every event after the buffer fills. State() remains +// authoritative if a consumer does not drain Events promptly. +func (c *Client) drainEvents() { + defer c.eventWg.Done() + for { + c.eventMu.Lock() + if c.pendingEvent == nil { + c.eventPumpRunning = false + c.eventMu.Unlock() + return + } + event := *c.pendingEvent + c.pendingEvent = nil + c.eventMu.Unlock() + + select { + case c.eventChan <- event: + case <-c.doneChan: + c.eventMu.Lock() + c.pendingEvent = nil + c.eventPumpRunning = false + c.eventMu.Unlock() + return + } + } +} diff --git a/packages/sdk-go/websocket/client_connection.go b/packages/sdk-go/websocket/client_connection.go new file mode 100644 index 00000000..0f129121 --- /dev/null +++ b/packages/sdk-go/websocket/client_connection.go @@ -0,0 +1,810 @@ +package websocket + +import ( + "context" + "crypto/rand" + "errors" + "fmt" + "log/slog" + "math/big" + "net/http" + "strings" + "sync" + "time" + + "github.com/gemini/gemini-go/auth" + "github.com/gemini/gemini-go/transport" +) + +func (c *Client) acquireLifecycle(ctx context.Context) error { + if ctx == nil { + ctx = context.Background() + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-c.doneChan: + return fmt.Errorf("%w: websocket client is closed", transport.ErrConnectionClosed) + case <-c.lifecycleGate: + return nil + } +} + +func (c *Client) releaseLifecycle() { + c.lifecycleGate <- struct{}{} +} + +func (c *Client) startConnectAttemptLocked() uint64 { + lifecycle := c.lifecycle.Add(1) + if c.connectDone == nil { + c.connectDone = make(chan struct{}) + } else { + select { + case <-c.connectDone: + c.connectDone = make(chan struct{}) + default: + } + } + c.connectErr = nil + return lifecycle +} + +func (c *Client) broadcastConnectLocked(err error) { + c.connectErr = err + if c.connectDone != nil { + select { + case <-c.connectDone: + default: + close(c.connectDone) + } + } +} + +func (c *Client) getUpgradeHeadersForURL(ctx context.Context, wsURL string) (http.Header, error) { + headers := c.headers.Clone() + if headers == nil { + headers = make(http.Header) + } + for key := range headers { + if isReservedAuthHeader(key) { + return nil, fmt.Errorf("gemini websocket: custom header %q conflicts with authentication", key) + } + } + if c.auth != nil { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, wsURL, nil) + if err != nil { + return nil, fmt.Errorf("gemini websocket: failed creating upgrade request: %w", err) + } + if wsAuth, ok := c.auth.(interface { + AuthenticateWebSocket(ctx context.Context, req *http.Request) error + }); ok { + if err := wsAuth.AuthenticateWebSocket(ctx, req); err != nil { + return nil, fmt.Errorf("gemini websocket: upgrade auth failed: %w", err) + } + } else { + if err := c.auth.Authenticate(ctx, req, nil); err != nil { + return nil, fmt.Errorf("gemini websocket: upgrade auth failed: %w", err) + } + } + for k, vv := range req.Header { + for _, v := range vv { + headers.Set(k, v) + } + } + } + return headers, nil +} + +func (c *Client) dial(ctx context.Context, dialer Dialer, wsURL string) (Conn, *http.Response, error) { + if sequencer, ok := c.auth.(auth.RequestSequencer); ok { + release, err := sequencer.AcquireRequest(ctx) + if err != nil { + return nil, nil, err + } + defer release() + } + + headers, err := c.getUpgradeHeadersForURL(ctx, wsURL) + if err != nil { + return nil, nil, err + } + conn, resp, err := dialer.Dial(ctx, wsURL, headers) + if err == nil && conn == nil { + return nil, resp, errors.New("gemini websocket: dialer returned a nil connection") + } + return conn, resp, err +} + +func isReservedAuthHeader(key string) bool { + switch strings.ToLower(key) { + case "authorization", "x-gemini-apikey", "x-gemini-nonce", "x-gemini-payload", "x-gemini-signature": + return true + default: + return false + } +} + +// Connect establishes the WebSocket connection using the configured dialer. +// If a connection or reconnection attempt is already in flight, Connect waits until +// the client successfully connects, encounters an error, or the context is cancelled. +func (c *Client) Connect(ctx context.Context) error { + if ctx == nil { + ctx = context.Background() + } + if c.configErr != nil { + return c.configErr + } + c.mu.Lock() + + for { + if c.State() == StateClosed { + c.mu.Unlock() + return fmt.Errorf("%w: websocket client is closed", transport.ErrConnectionClosed) + } + if c.privateOnly && c.auth == nil { + c.mu.Unlock() + return ErrAuthenticationRequired + } + if c.conn != nil && c.State() == StateConnected && !c.replayInProgress { + c.mu.Unlock() + return nil // Already connected + } + if c.State() == StateConnecting || c.State() == StateReconnecting || c.replayInProgress { + // Another dial or reconnect is currently in flight. + // Wait for the active connection attempt to broadcast completion. + done := c.connectDone + c.mu.Unlock() + select { + case <-ctx.Done(): + return ctx.Err() + case <-c.doneChan: + return fmt.Errorf("%w: websocket client is closed", transport.ErrConnectionClosed) + case <-done: + } + c.mu.Lock() + if c.State() == StateConnected && !c.replayInProgress { + c.mu.Unlock() + return nil + } + if c.State() == StateClosed { + c.mu.Unlock() + return fmt.Errorf("%w: websocket client is closed", transport.ErrConnectionClosed) + } + if c.connectErr != nil { + err := c.connectErr + c.mu.Unlock() + return fmt.Errorf("gemini websocket: connection failed: %w", err) + } + continue + } + if c.dialer == nil { + c.mu.Unlock() + return ErrNoDialerConfigured + } + + // State is Disconnected. Serialize the complete new connection attempt + // with stale subscription cleanup and reconnect installation. The helper + // rechecks state after acquiring the gate because another lifecycle may + // have started while this caller was waiting for it. + c.mu.Unlock() + err, retry := c.connectNew(ctx) + if retry { + c.mu.Lock() + continue + } + return err + } +} + +func (c *Client) connectNew(ctx context.Context) (error, bool) { + if err := c.acquireLifecycle(ctx); err != nil { + return err, false + } + lifecycleHeld := true + defer func() { + if lifecycleHeld { + c.releaseLifecycle() + } + }() + + c.mu.Lock() + if c.State() == StateClosed { + c.mu.Unlock() + return fmt.Errorf("%w: websocket client is closed", transport.ErrConnectionClosed), false + } + if c.privateOnly && c.auth == nil { + c.mu.Unlock() + return ErrAuthenticationRequired, false + } + if c.conn != nil && c.State() == StateConnected && !c.replayInProgress { + c.mu.Unlock() + return nil, false + } + if c.State() != StateDisconnected { + c.mu.Unlock() + return nil, true + } + if c.dialer == nil { + c.mu.Unlock() + return ErrNoDialerConfigured, false + } + + c.setState(StateConnecting, nil) + lifecycle := c.startConnectAttemptLocked() + dialer := c.dialer + url := c.url + c.mu.Unlock() + + dialCtx, dialCancel := context.WithCancel(ctx) + go func() { + select { + case <-c.doneChan: + dialCancel() + case <-dialCtx.Done(): + } + }() + conn, _, dialErr := c.dial(dialCtx, dialer, url) + dialCancel() + + c.mu.Lock() + if c.State() == StateClosed { + if conn != nil { + _ = conn.Close() + } + c.broadcastConnectLocked(fmt.Errorf("%w: websocket client is closed", transport.ErrConnectionClosed)) + c.mu.Unlock() + return fmt.Errorf("%w: websocket client is closed", transport.ErrConnectionClosed), false + } + + if dialErr != nil { + if conn != nil { + _ = conn.Close() + } + c.setState(StateDisconnected, dialErr) + c.broadcastConnectLocked(dialErr) + c.mu.Unlock() + return fmt.Errorf("gemini websocket: dial failed: %w", dialErr), false + } + + c.configureReadLimit(conn) + c.conn = conn + c.state.Store(int32(StateConnected)) + c.replayInProgress = true + c.pumpWg.Add(1) + go c.readPump(lifecycle) + c.mu.Unlock() + // The lifecycle gate protects connection installation and stale cleanup. + // Replay waits for server acknowledgements and must not prevent the read + // pump from observing a dropped socket and starting recovery. + c.releaseLifecycle() + lifecycleHeld = false + + if err := c.resubscribeActiveFeeds(lifecycle); err != nil { + resubscribeErr := fmt.Errorf("%w: %w", ErrResubscribeFailed, err) + c.handleResubscribeFailure(lifecycle, resubscribeErr) + return resubscribeErr, false + } + c.mu.Lock() + if c.State() == StateConnected && c.lifecycle.Load() == lifecycle { + c.replayInProgress = false + c.startLivenessPumpLocked(lifecycle) + c.publishEvent(ConnectionEvent{State: StateConnected}) + c.broadcastConnectLocked(nil) + c.mu.Unlock() + return nil, false + } + state := c.State() + err := c.connectErr + c.mu.Unlock() + if state == StateClosed { + return fmt.Errorf("%w: websocket client is closed", transport.ErrConnectionClosed), false + } + if state == StateReconnecting || state == StateConnecting { + return nil, true + } + if err == nil { + err = errors.New("connection lost during feed replay") + } + return fmt.Errorf("gemini websocket: connection failed during feed replay: %w", err), false +} + +// Close gracefully terminates the connection and subscriber channels. +func (c *Client) Close() error { + c.closeOnce.Do(func() { + var err error + c.mu.Lock() + c.setState(StateClosed, nil) + close(c.doneChan) + closedErr := fmt.Errorf("%w: websocket client is closed", transport.ErrConnectionClosed) + c.broadcastConnectLocked(closedErr) + c.failPending(closedErr) + if c.conn != nil { + err = c.conn.Close() + c.conn = nil + } + c.mu.Unlock() + + c.pumpWg.Wait() + c.eventWg.Wait() + + c.subsMu.Lock() + oldTables := c.subTables.Load() + emptyTables := newSubTables() + c.subTables.Store(emptyTables) + c.activeFeeds = make(map[string]RequestFrame) + c.snapshotPending = make(map[string]uint64) + c.subsMu.Unlock() + + oldTables.closeAll() + c.eventMu.Lock() + if !c.eventClosed { + c.eventClosed = true + close(c.eventChan) + for id, subscriber := range c.eventSubscribers { + close(subscriber) + delete(c.eventSubscribers, id) + } + } + c.eventMu.Unlock() + + c.snapshotMu.Lock() + isolatedClients := make([]*Client, 0, len(c.snapshotClients)+len(c.partialSnapshotClients)) + for symbol, snapshotClient := range c.snapshotClients { + isolatedClients = append(isolatedClients, snapshotClient) + delete(c.snapshotClients, symbol) + } + for symbol, snapshotClient := range c.partialSnapshotClients { + isolatedClients = append(isolatedClients, snapshotClient) + delete(c.partialSnapshotClients, symbol) + } + clear(c.snapshotClientRefs) + clear(c.partialSnapshotRefs) + c.snapshotMu.Unlock() + for _, snapshotClient := range isolatedClients { + if closeErr := snapshotClient.Close(); err == nil && closeErr != nil { + err = closeErr + } + } + c.closeErr = err + }) + return c.closeErr +} + +// readPump listens for incoming frames and initiates reconnects if connection drops. +func (c *Client) readPump(lifecycle uint64) { + defer c.pumpWg.Done() + frames := make(chan inboundFrame, 1024) + dispatchDone := make(chan struct{}) + var dispatchWg sync.WaitGroup + var stopDispatchOnce sync.Once + stopDispatch := func() { + stopDispatchOnce.Do(func() { + close(dispatchDone) + dispatchWg.Wait() + }) + } + dispatchWg.Add(1) + go func() { + defer dispatchWg.Done() + for { + select { + case frame := <-frames: + if err := c.dispatchFrame(dispatchDone, frame.payload, frame.generation); err != nil { + c.publishEvent(ConnectionEvent{State: StateConnected, Err: err}) + } + case <-dispatchDone: + return + } + } + }() + defer stopDispatch() + + for { + c.mu.RLock() + conn := c.conn + isClosed := c.State() == StateClosed + stale := c.lifecycle.Load() != lifecycle + c.mu.RUnlock() + + if isClosed || stale || conn == nil { + return + } + + _, payload, err := conn.ReadMessage(context.Background()) + if err == nil { + if c.maxMessageSize > 0 && int64(len(payload)) > c.maxMessageSize { + err = fmt.Errorf("%w: received %d bytes, limit is %d", ErrMessageTooLarge, len(payload), c.maxMessageSize) + } else if c.lifecycle.Load() != lifecycle { + return + } else { + handled, responseErr := c.dispatchResponse(payload) + if responseErr != nil { + err = responseErr + } else if handled { + continue + } else { + select { + case frames <- inboundFrame{payload: payload, generation: c.subscriptionGeneration.Load()}: + continue + case <-c.doneChan: + return + default: + err = fmt.Errorf("%w: inbound frame queue capacity %d exceeded", ErrSlowConsumer, cap(frames)) + } + } + } + } + if err != nil { + stopDispatch() + // Serialize failure handling with connection installation and + // subscription cleanup. This prevents a stale read pump from clearing + // subscriptions after a newer lifecycle has been installed, and keeps + // pending-request admission atomic with invalidating this connection. + if acquireErr := c.acquireLifecycle(context.Background()); acquireErr != nil { + return + } + slowConsumer := errors.Is(err, ErrSlowConsumer) + c.mu.Lock() + if c.State() == StateClosed || c.lifecycle.Load() != lifecycle { + c.mu.Unlock() + c.releaseLifecycle() + return + } + if c.State() == StateDisconnected && c.conn == nil { + // Another lifecycle path already invalidated this connection, for + // example after a failed feed resubscription. + c.mu.Unlock() + c.releaseLifecycle() + return + } + // Connection dropped + c.logger.Warn("gemini websocket connection dropped", slog.String("error", err.Error())) + if c.conn != nil { + _ = c.conn.Close() + c.conn = nil + } + // The failed connection's replay turn is no longer active. A + // subsequent manual Connect must not remain stuck behind the old + // connectDone boundary when automatic reconnect is disabled. + c.replayInProgress = false + + if c.autoReconnect { + c.setState(StateReconnecting, err) + reconnectLifecycle := c.startConnectAttemptLocked() + c.failPending(err) + c.mu.Unlock() + if slowConsumer { + // Apply the slow-consumer policy before starting the reconnect + // loop. This prevents a new subscription established after a + // reconnect from being closed by the old read pump. + c.failSubscriptionsIfCurrentLocked(reconnectLifecycle, err) + } + c.releaseLifecycle() + + c.pumpWg.Add(1) + go func() { + defer c.pumpWg.Done() + c.reconnectLoop(reconnectLifecycle) + }() + } else { + c.setState(StateDisconnected, err) + cleanupLifecycle := c.lifecycle.Add(1) + c.broadcastConnectLocked(err) + c.failPending(err) + c.mu.Unlock() + c.failSubscriptionsIfCurrentLocked(cleanupLifecycle, err) + c.releaseLifecycle() + } + return + } + + } +} + +func (c *Client) reconnectLoop(lifecycle uint64) { + baseBackoff := 100 * time.Millisecond + maxBackoff := 5 * time.Second + var lastDialErr error + + attempt := 1 + for { + if c.maxReconnects > 0 && attempt > c.maxReconnects { + break + } + + shift := attempt - 1 + if shift > 6 { + shift = 6 + } + var jitterFactor float64 = 1.0 + if n, err := rand.Int(rand.Reader, big.NewInt(1000)); err == nil { + jitterFactor = 0.5 + float64(n.Int64())/1000.0 // 0.5 to 1.5 + } + backoff := time.Duration(float64(baseBackoff) * float64(int64(1)< maxBackoff { + backoff = maxBackoff + } + + c.logger.Info("reconnecting to gemini websocket", + slog.Int("attempt", attempt), + slog.Duration("backoff", backoff), + ) + + select { + case <-c.doneChan: + c.mu.Lock() + c.broadcastConnectLocked(fmt.Errorf("%w: websocket client is closed", transport.ErrConnectionClosed)) + c.mu.Unlock() + return + case <-time.After(backoff): + } + + c.mu.Lock() + if c.State() == StateClosed || c.lifecycle.Load() != lifecycle { + c.mu.Unlock() + return + } + if c.State() == StateConnected && c.conn != nil { + // A concurrent manual Connect() restored the connection + c.mu.Unlock() + return + } + c.mu.Unlock() + + // Keep connection installation serialized with stale subscription + // cleanup. Feed replay runs after the gate is released so a dropped + // socket can be failed and replaced without waiting for ACK timeouts. + if err := c.acquireLifecycle(context.Background()); err != nil { + return + } + c.mu.Lock() + if c.State() == StateClosed || c.lifecycle.Load() != lifecycle { + c.mu.Unlock() + c.releaseLifecycle() + return + } + if c.State() == StateConnected && c.conn != nil { + c.mu.Unlock() + c.releaseLifecycle() + return + } + c.mu.Unlock() + + dialCtx, dialCancel := context.WithTimeout(context.Background(), 10*time.Second) + go func() { + select { + case <-c.doneChan: + dialCancel() + case <-dialCtx.Done(): + } + }() + + c.mu.RLock() + dialer := c.dialer + wsURL := c.url + c.mu.RUnlock() + conn, _, dialErr := c.dial(dialCtx, dialer, wsURL) + dialCancel() + if dialErr == nil { + c.configureReadLimit(conn) + c.mu.Lock() + if c.State() == StateClosed || c.lifecycle.Load() != lifecycle { + _ = conn.Close() + c.mu.Unlock() + c.releaseLifecycle() + return + } + if c.State() == StateConnected && c.conn != nil { + // Concurrent manual Connect succeeded before us; close our redundant connection + _ = conn.Close() + c.mu.Unlock() + c.releaseLifecycle() + return + } + c.conn = conn + c.state.Store(int32(StateConnected)) + c.replayInProgress = true + c.pumpWg.Add(1) + go c.readPump(lifecycle) + c.mu.Unlock() + // Do not hold lifecycleGate while replay waits for feed ACKs. The + // read pump must be able to acquire it and handle a dropped socket. + c.releaseLifecycle() + + if err := c.resubscribeActiveFeeds(lifecycle); err != nil { + c.handleResubscribeFailure(lifecycle, fmt.Errorf("%w: %w", ErrResubscribeFailed, err)) + } else { + c.mu.Lock() + if c.State() == StateConnected && c.lifecycle.Load() == lifecycle { + c.replayInProgress = false + c.startLivenessPumpLocked(lifecycle) + c.publishEvent(ConnectionEvent{State: StateConnected}) + c.broadcastConnectLocked(nil) + } + c.mu.Unlock() + } + return + } + + if conn != nil { + _ = conn.Close() + } + c.releaseLifecycle() + lastDialErr = dialErr + if errors.Is(dialErr, auth.ErrTokenSourceFailure) || errors.Is(dialErr, auth.ErrInvalidTokenSource) { + authErr := fmt.Errorf("gemini websocket: reconnect authentication failed: %w", dialErr) + c.mu.Lock() + if c.State() == StateClosed || c.lifecycle.Load() != lifecycle { + c.mu.Unlock() + return + } + c.setState(StateDisconnected, authErr) + c.broadcastConnectLocked(authErr) + c.mu.Unlock() + c.failSubscriptionsIfCurrent(lifecycle, authErr) + return + } + attempt++ + } + + c.mu.Lock() + if c.State() == StateClosed || c.lifecycle.Load() != lifecycle { + c.mu.Unlock() + return + } + maxErr := errors.New("gemini websocket: max reconnect attempts exceeded") + if lastDialErr != nil { + maxErr = fmt.Errorf("%w: last attempt failed: %w", maxErr, lastDialErr) + } + c.setState(StateDisconnected, maxErr) + c.broadcastConnectLocked(maxErr) + c.mu.Unlock() + c.failSubscriptionsIfCurrent(lifecycle, maxErr) +} + +func (c *Client) resubscribeActiveFeeds(lifecycle uint64) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := c.acquireSubscriptionReplay(ctx); err != nil { + return err + } + c.subsMu.Lock() + if c.lifecycle.Load() != lifecycle { + c.subsMu.Unlock() + c.releaseSubscriptionReplay() + return nil + } + frames := make([]struct { + key string + frame RequestFrame + }, 0, len(c.activeFeeds)) + for key, frame := range c.activeFeeds { + frames = append(frames, struct { + key string + frame RequestFrame + }{key: key, frame: frame}) + c.markSnapshotPendingLocked(frame) + } + c.subsMu.Unlock() + c.releaseSubscriptionReplay() + var errs []error + + for _, feed := range frames { + if c.lifecycle.Load() != lifecycle { + return nil + } + if err := c.acquireSubscriptionWire(ctx, feed.key); err != nil { + errs = append(errs, fmt.Errorf("stream %q: acquire wire operation: %w", feed.key, err)) + continue + } + if c.lifecycle.Load() != lifecycle { + c.releaseSubscriptionWire(feed.key) + return nil + } + c.subsMu.Lock() + current, stillActive := c.activeFeeds[feed.key] + stillCurrent := stillActive && sameRequestFrame(current, feed.frame) + c.subsMu.Unlock() + if !stillCurrent { + c.releaseSubscriptionWire(feed.key) + continue + } + _, requestErr := c.requestConnected(ctx, feed.frame.Method, feed.frame.Params) + c.releaseSubscriptionWire(feed.key) + if requestErr != nil { + stream := "" + if len(feed.frame.Params) > 0 { + stream = feed.frame.Params[0] + } + errs = append(errs, fmt.Errorf("stream %q: %w", stream, requestErr)) + } + } + return errors.Join(errs...) +} + +func sameRequestFrame(a, b RequestFrame) bool { + if a.ID != b.ID || a.Method != b.Method || len(a.Params) != len(b.Params) { + return false + } + for i := range a.Params { + if a.Params[i] != b.Params[i] { + return false + } + } + return true +} + +func (c *Client) failSubscriptionsIfCurrent(lifecycle uint64, err error) { + if err == nil { + return + } + if c.lifecycle.Load() != lifecycle || c.State() == StateClosed { + return + } + if acquireErr := c.acquireLifecycle(context.Background()); acquireErr != nil { + return + } + defer c.releaseLifecycle() + c.failSubscriptionsIfCurrentLocked(lifecycle, err) +} + +// failSubscriptionsIfCurrentLocked requires lifecycleGate to be held. The +// gate serializes this table swap with connection installation and stale +// lifecycle cleanup. +func (c *Client) failSubscriptionsIfCurrentLocked(lifecycle uint64, err error) { + if err == nil { + return + } + if c.lifecycle.Load() != lifecycle || c.State() == StateClosed { + return + } + if acquireErr := c.acquireSubscriptionReplay(context.Background()); acquireErr != nil { + return + } + defer c.releaseSubscriptionReplay() + c.subsMu.Lock() + if c.lifecycle.Load() != lifecycle || c.State() == StateClosed { + c.subsMu.Unlock() + return + } + oldTables := c.subTables.Load() + c.subTables.Store(newSubTables()) + c.activeFeeds = make(map[string]RequestFrame) + c.snapshotPending = make(map[string]uint64) + c.subsMu.Unlock() + + oldTables.closeAll() + c.publishEvent(ConnectionEvent{State: c.State(), Err: err}) +} + +func (c *Client) handleResubscribeFailure(lifecycle uint64, err error) { + if err == nil { + return + } + // Keep lifecycle invalidation and subscription-table cleanup in the same + // critical section. Otherwise a concurrent manual Connect can install a + // newer lifecycle between these operations and cause the failed replay's + // cleanup to be discarded as stale. + if acquireErr := c.acquireLifecycle(context.Background()); acquireErr != nil { + return + } + defer c.releaseLifecycle() + + c.mu.Lock() + if c.State() != StateClosed && c.lifecycle.Load() == lifecycle { + c.replayInProgress = false + if c.conn != nil { + _ = c.conn.Close() + c.conn = nil + } + // Invalidate the read pump and any reconnect work associated with the + // failed connection before allowing a new manual Connect to proceed. + cleanupLifecycle := c.lifecycle.Add(1) + c.setState(StateDisconnected, err) + c.broadcastConnectLocked(err) + c.mu.Unlock() + c.failSubscriptionsIfCurrentLocked(cleanupLifecycle, err) + return + } + c.mu.Unlock() +} diff --git a/packages/sdk-go/websocket/client_control_test.go b/packages/sdk-go/websocket/client_control_test.go new file mode 100644 index 00000000..6a9ffa63 --- /dev/null +++ b/packages/sdk-go/websocket/client_control_test.go @@ -0,0 +1,307 @@ +package websocket_test + +import ( + "bytes" + "context" + "errors" + "net/http" + "testing" + "time" + + "github.com/gemini/gemini-go/transport" + "github.com/gemini/gemini-go/websocket" +) + +func TestClient_CloseInterruptsBlockedDial(t *testing.T) { + dialer := &interruptibleDialer{ + conn: newInterruptibleConn(), + started: make(chan struct{}), + } + client := websocket.NewClient("wss://ws.gemini.com", websocket.WithDialer(dialer)) + t.Cleanup(func() { _ = client.Close() }) + + connected := make(chan error, 1) + go func() { connected <- client.Connect(context.Background()) }() + select { + case <-dialer.started: + case <-time.After(time.Second): + t.Fatal("dial did not start") + } + + if err := client.Close(); err != nil { + t.Fatalf("Close failed: %v", err) + } + select { + case err := <-connected: + if !errors.Is(err, transport.ErrConnectionClosed) { + t.Fatalf("expected closed connection error, got %v", err) + } + case <-time.After(time.Second): + t.Fatal("Connect did not return after Close interrupted dial") + } +} + +func TestClient_ClosesConnectionReturnedWithDialError(t *testing.T) { + conn := newSilentConn() + dialer := &errorConnDialer{conn: conn} + client := websocket.NewClient("wss://ws.gemini.com", websocket.WithDialer(dialer)) + t.Cleanup(func() { _ = client.Close() }) + + if err := client.Connect(context.Background()); err == nil { + t.Fatal("expected dial error") + } + select { + case <-conn.closed: + case <-time.After(time.Second): + t.Fatal("client did not close the connection returned with a dial error") + } +} + +func TestClient_CloseInterruptsBlockedWrite(t *testing.T) { + conn := newInterruptibleConn() + client := websocket.NewClient("wss://ws.gemini.com", websocket.WithDialer(&singleConnDialer{conn: conn})) + t.Cleanup(func() { _ = client.Close() }) + + if err := client.Connect(context.Background()); err != nil { + t.Fatalf("Connect failed: %v", err) + } + sendErr := make(chan error, 1) + go func() { sendErr <- client.Send(context.Background(), map[string]string{"type": "ping"}) }() + select { + case <-conn.writeStarted: + case <-time.After(time.Second): + t.Fatal("write did not start") + } + + closeErr := make(chan error, 1) + go func() { closeErr <- client.Close() }() + select { + case err := <-closeErr: + if err != nil { + t.Fatalf("Close failed: %v", err) + } + case <-time.After(time.Second): + t.Fatal("Close did not interrupt blocked write") + } + select { + case <-sendErr: + case <-time.After(time.Second): + t.Fatal("Send did not return after Close interrupted write") + } +} + +func TestClient_PingWaitsForCorrelatedResponse(t *testing.T) { + dialer := &mockDrainDialer{} + client := websocket.NewClient("wss://ws.gemini.com", websocket.WithDialer(dialer)) + t.Cleanup(func() { _ = client.Close() }) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := client.Ping(ctx); err != nil { + t.Fatalf("Ping failed: %v", err) + } + + conn := dialer.latestConn() + conn.mu.Lock() + defer conn.mu.Unlock() + if len(conn.written) != 1 || !bytes.Contains(conn.written[0], []byte(`"method":"ping"`)) { + t.Fatalf("expected correlated ping request, got %s", conn.written) + } +} + +func TestClient_PingReturnsProtocolError(t *testing.T) { + dialer := &mockDrainDialer{ + responseStatus: http.StatusBadRequest, + responseMessage: "invalid ping", + } + client := websocket.NewClient("wss://ws.gemini.com", websocket.WithDialer(dialer)) + t.Cleanup(func() { _ = client.Close() }) + + err := client.Ping(context.Background()) + if !errors.Is(err, websocket.ErrRequestFailed) { + t.Fatalf("expected ErrRequestFailed, got %v", err) + } + var requestErr *websocket.RequestError + if !errors.As(err, &requestErr) || requestErr.Status != http.StatusBadRequest || requestErr.Message != "invalid ping" { + t.Fatalf("unexpected protocol error: %v", err) + } +} + +func TestClient_PendingRequestHonorsContext(t *testing.T) { + conn := newSilentConn() + client := websocket.NewClient("wss://ws.gemini.com", websocket.WithDialer(&silentDialer{conn: conn})) + t.Cleanup(func() { _ = client.Close() }) + + ctx, cancel := context.WithCancel(context.Background()) + requestDone := make(chan error, 1) + go func() { + _, err := client.Request(ctx, "ping", nil) + requestDone <- err + }() + select { + case <-conn.writeStarted: + case <-time.After(time.Second): + t.Fatal("request write did not start") + } + cancel() + + select { + case err := <-requestDone: + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context cancellation, got %v", err) + } + case <-time.After(time.Second): + t.Fatal("pending request did not honor context cancellation") + } +} + +func TestClient_PendingRequestFailsOnClose(t *testing.T) { + conn := newSilentConn() + client := websocket.NewClient("wss://ws.gemini.com", websocket.WithDialer(&silentDialer{conn: conn})) + t.Cleanup(func() { _ = client.Close() }) + + requestDone := make(chan error, 1) + go func() { + _, err := client.Request(context.Background(), "ping", nil) + requestDone <- err + }() + select { + case <-conn.writeStarted: + case <-time.After(time.Second): + t.Fatal("request write did not start") + } + + if err := client.Close(); err != nil { + t.Fatalf("Close failed: %v", err) + } + select { + case err := <-requestDone: + if !errors.Is(err, transport.ErrConnectionClosed) { + t.Fatalf("expected pending request to fail with connection closed, got %v", err) + } + case <-time.After(time.Second): + t.Fatal("pending request did not resolve after Close") + } +} + +func TestClient_PendingRequestFailsOnConnectionDrop(t *testing.T) { + conn := newSilentConn() + client := websocket.NewClient( + "wss://ws.gemini.com", + websocket.WithDialer(&silentDialer{conn: conn}), + websocket.WithAutoReconnect(false), + ) + t.Cleanup(func() { _ = client.Close() }) + + requestDone := make(chan error, 1) + go func() { + _, err := client.Request(context.Background(), "ping", nil) + requestDone <- err + }() + select { + case <-conn.writeStarted: + case <-time.After(time.Second): + t.Fatal("request write did not start") + } + if err := conn.Close(); err != nil { + t.Fatalf("simulated connection drop failed: %v", err) + } + + select { + case err := <-requestDone: + if err == nil || errors.Is(err, context.Canceled) { + t.Fatalf("expected connection-drop error, got %v", err) + } + case <-time.After(time.Second): + t.Fatal("pending request did not resolve after connection drop") + } +} + +func TestClient_UnsubscribePrivateFeedClosesChannelAndSendsRequest(t *testing.T) { + dialer := &mockDrainDialer{} + client := websocket.NewPrivateClient( + "wss://ws.gemini.com", + &mockAuthStrategy{key: "key", secret: "secret"}, + websocket.WithDialer(dialer), + ) + t.Cleanup(func() { _ = client.Close() }) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + orderCh, err := client.SubscribeOrderEvents(ctx) + if err != nil { + t.Fatalf("SubscribeOrderEvents failed: %v", err) + } + if err := client.UnsubscribeOrderEvents(ctx); err != nil { + t.Fatalf("UnsubscribeOrderEvents failed: %v", err) + } + select { + case _, ok := <-orderCh: + if ok { + t.Fatal("expected order event channel to be closed") + } + case <-time.After(time.Second): + t.Fatal("order event channel was not closed") + } + + conn := dialer.latestConn() + conn.mu.Lock() + written := append([][]byte(nil), conn.written...) + conn.mu.Unlock() + if len(written) != 2 || !bytes.Contains(written[0], []byte(`"method":"SUBSCRIBE"`)) || !bytes.Contains(written[1], []byte(`"method":"UNSUBSCRIBE"`)) { + t.Fatalf("expected subscribe and unsubscribe requests, got %s", written) + } + if err := client.UnsubscribeOrderEvents(ctx); err != nil { + t.Fatalf("idempotent UnsubscribeOrderEvents failed: %v", err) + } +} + +func TestClient_UnsubscribeOrderEventsChannelPreservesOtherSubscribers(t *testing.T) { + dialer := &mockDrainDialer{} + client := websocket.NewPrivateClient( + "wss://ws.gemini.com", + &mockAuthStrategy{key: "key", secret: "secret"}, + websocket.WithDialer(dialer), + ) + t.Cleanup(func() { _ = client.Close() }) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + first, err := client.SubscribeOrderEvents(ctx) + if err != nil { + t.Fatalf("first SubscribeOrderEvents failed: %v", err) + } + second, err := client.SubscribeOrderEvents(ctx) + if err != nil { + t.Fatalf("second SubscribeOrderEvents failed: %v", err) + } + if err := client.UnsubscribeOrderEventsChannel(ctx, first); err != nil { + t.Fatalf("UnsubscribeOrderEventsChannel failed: %v", err) + } + select { + case _, ok := <-first: + if ok { + t.Fatal("expected the scoped order event channel to be closed") + } + case <-time.After(time.Second): + t.Fatal("scoped order event channel was not closed") + } + + select { + case _, ok := <-second: + if !ok { + t.Fatal("scoped unsubscribe closed another order event subscriber") + } + default: + } + + dialer.latestConn().readChan <- []byte(`{"e":"order","s":"BTCUSD","S":"BUY","p":"65000","q":"0.1","X":"OPEN","i":987}`) + select { + case event := <-second: + if event == nil || event.OrderID != 987 { + t.Fatalf("unexpected event delivered to remaining subscriber: %+v", event) + } + case <-time.After(time.Second): + t.Fatal("remaining order event subscriber did not receive an event") + } +} diff --git a/packages/sdk-go/websocket/client_dispatch.go b/packages/sdk-go/websocket/client_dispatch.go new file mode 100644 index 00000000..8ee4c478 --- /dev/null +++ b/packages/sdk-go/websocket/client_dispatch.go @@ -0,0 +1,260 @@ +package websocket + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" +) + +var ( + bDepthUpdate = []byte(`"depthUpdate"`) + bLastUpdateID = []byte(`"lastUpdateId"`) + bids = []byte(`"bids"`) + bAsks = []byte(`"asks"`) + bU = []byte(`"U"`) + bTrade = []byte(`"trade"`) + bT = []byte(`"t"`) + bBookTicker = []byte(`"bookTicker"`) + bB = []byte(`"b"`) + bA = []byte(`"a"`) + bContract = []byte(`"contractStatus"`) + bBalance = []byte(`"balanceUpdate"`) + bPosition = []byte(`"positionReport"`) + bSettlement = []byte(`"settlements"`) + bOrderUpdate = []byte(`"orderUpdate"`) + bOrder = []byte(`"order"`) + bRequestForQuote = []byte(`"requestForQuote"`) +) + +type inboundFrame struct { + payload []byte + generation uint64 +} + +func (c *Client) dispatchFrame(stop <-chan struct{}, payload []byte, generation uint64) error { + if c.State() == StateClosed { + return nil + } + if handled, err := c.dispatchResponse(payload); handled { + return err + } + if !json.Valid(payload) { + return fmt.Errorf("%w: invalid JSON", ErrMalformedFrame) + } + + tables := c.subTables.Load() + if tables == nil { + return nil + } + + // The private order stream can include a top-level trade ID (`t`). Check + // the explicit event discriminator before the heuristic market-data probes + // below; otherwise an order update can be silently delivered as a trade. + if bytes.Contains(payload, bOrder) || bytes.Contains(payload, bOrderUpdate) { + var eventEnvelope map[string]json.RawMessage + if err := json.Unmarshal(payload, &eventEnvelope); err == nil { + var explicitType string + if rawType, ok := eventEnvelope["e"]; ok { + _ = json.Unmarshal(rawType, &explicitType) + } + if explicitType == "order" || explicitType == "orderUpdate" { + var order OrderEvent + if err := json.Unmarshal(payload, &order); err == nil { + for _, subs := range tables.orderSubs { + for _, sub := range subs { + sub.send(stop, &order) + } + } + } + return nil + } + } + } + + if bytes.Contains(payload, bRequestForQuote) { + var envelope struct { + EventType string `json:"e"` + EventTime int64 `json:"E"` + DeliveryID string `json:"i"` + } + if err := json.Unmarshal(payload, &envelope); err == nil && envelope.EventType == "requestForQuote" { + if envelope.DeliveryID != "" { + var delivery RFQPrivateDelivery + if err := json.Unmarshal(payload, &delivery); err == nil && validRFQPrivateDelivery(&delivery) { + for _, subs := range tables.rfqPrivateSubs { + for _, sub := range subs { + sub.send(stop, &delivery) + } + } + } + } else { + var event RFQPublicEvent + if err := json.Unmarshal(payload, &event); err == nil && validRFQPublicEvent(&event) { + for _, sub := range tables.rfqPublicSubs["requestForQuote"] { + sub.send(stop, &event) + } + } + } + return nil + } + } + + if bytes.Contains(payload, bLastUpdateID) && bytes.Contains(payload, bids) && bytes.Contains(payload, bAsks) { + handled, err := c.dispatchOrderBookSnapshot(stop, payload, generation, tables) + if handled { + return err + } + } + + // Fast single-pass dispatching to avoid redundant JSON unmarshaling + if bytes.Contains(payload, bDepthUpdate) || bytes.Contains(payload, bU) { + var update DepthUpdate + if err := json.Unmarshal(payload, &update); err == nil && (update.EventType == "depthUpdate" || update.FirstUpdateID > 0) { + symbol := strings.ToUpper(update.Symbol) + update.Symbol = symbol + if c.consumeSnapshotPending(symbol, generation) { + update.Snapshot = true + } + for _, sub := range tables.depthSubs[symbol] { + sub.send(stop, &update) + } + return nil + } + } + + if bytes.Contains(payload, bTrade) || bytes.Contains(payload, bT) { + var trade TradeEvent + if err := json.Unmarshal(payload, &trade); err == nil && (trade.EventType == "trade" || trade.TradeID > 0) { + symbol := strings.ToUpper(trade.Symbol) + trade.Symbol = symbol + for _, sub := range tables.tradeSubs[symbol] { + sub.send(stop, &trade) + } + return nil + } + } + + if bytes.Contains(payload, bBookTicker) || (bytes.Contains(payload, bB) && bytes.Contains(payload, bA)) { + var ticker BookTicker + if err := json.Unmarshal(payload, &ticker); err == nil && ticker.BidPrice != "" { + symbol := strings.ToUpper(ticker.Symbol) + ticker.Symbol = symbol + for _, sub := range tables.tickerSubs[symbol] { + sub.send(stop, &ticker) + } + return nil + } + } + + if bytes.Contains(payload, bContract) { + var contract ContractStatusEvent + if err := json.Unmarshal(payload, &contract); err == nil && (contract.EventType == "contractStatus" || contract.ContractID > 0) { + symbol := strings.ToUpper(contract.Symbol) + contract.Symbol = symbol + if len(tables.contractSubs) > 0 { + if symbol != "" { + for _, sub := range tables.contractSubs[symbol] { + sub.send(stop, &contract) + } + } + for _, sub := range tables.contractSubs[""] { + sub.send(stop, &contract) + } + for _, sub := range tables.contractSubs["*"] { + sub.send(stop, &contract) + } + } + return nil + } + } + + if bytes.Contains(payload, bBalance) { + var balance BalanceUpdate + if err := json.Unmarshal(payload, &balance); err == nil && balance.EventType == "balanceUpdate" { + for _, subs := range tables.balanceSubs { + for _, sub := range subs { + sub.send(stop, &balance) + } + } + return nil + } + } + + if bytes.Contains(payload, bPosition) { + var position PositionReport + if err := json.Unmarshal(payload, &position); err == nil && position.EventType == "positionReport" { + for _, subs := range tables.positionSubs { + for _, sub := range subs { + sub.send(stop, &position) + } + } + return nil + } + } + + if bytes.Contains(payload, bSettlement) { + var settle SettlementUpdate + if err := json.Unmarshal(payload, &settle); err == nil && (settle.Type == "settlements" || len(settle.Settlements) > 0) { + for _, subs := range tables.settleSubs { + for _, sub := range subs { + sub.send(stop, &settle) + } + } + return nil + } + } + + // Fallback probe for order events and other event types + var probe struct { + EventType string `json:"e"` + Type string `json:"type"` + EventTime int64 `json:"E"` + Symbol string `json:"s"` + } + if err := json.Unmarshal(payload, &probe); err != nil { + return fmt.Errorf("%w: invalid JSON", ErrMalformedFrame) + } + + switch probe.EventType { + case "order", "orderUpdate": + var order OrderEvent + if err := json.Unmarshal(payload, &order); err == nil { + for _, subs := range tables.orderSubs { + for _, sub := range subs { + sub.send(stop, &order) + } + } + } + case "balanceUpdate": + var balance BalanceUpdate + if err := json.Unmarshal(payload, &balance); err == nil { + for _, subs := range tables.balanceSubs { + for _, sub := range subs { + sub.send(stop, &balance) + } + } + } + case "positionReport": + var pos PositionReport + if err := json.Unmarshal(payload, &pos); err == nil { + for _, subs := range tables.positionSubs { + for _, sub := range subs { + sub.send(stop, &pos) + } + } + } + } + + if probe.Type == "settlements" { + var settle SettlementUpdate + if err := json.Unmarshal(payload, &settle); err == nil { + for _, subs := range tables.settleSubs { + for _, sub := range subs { + sub.send(stop, &settle) + } + } + } + } + return nil +} diff --git a/packages/sdk-go/websocket/client_dispatch_internal_test.go b/packages/sdk-go/websocket/client_dispatch_internal_test.go new file mode 100644 index 00000000..01c119fb --- /dev/null +++ b/packages/sdk-go/websocket/client_dispatch_internal_test.go @@ -0,0 +1,83 @@ +package websocket + +import ( + "errors" + "testing" + "time" +) + +func TestDispatchFrame_OrderUpdateWithTradeIDRemainsOrderEvent(t *testing.T) { + client := NewClient("wss://ws.gemini.com") + client.state.Store(int32(StateConnected)) + orderSub := newSubscription[OrderEvent](1) + client.subsMu.Lock() + tables := client.subTables.Load().clone() + tables.orderSubs = map[string][]*subscription[OrderEvent]{"orders@account": {orderSub}} + client.subTables.Store(tables) + client.subsMu.Unlock() + + client.dispatchFrame(make(chan struct{}), []byte(`{"e":"orderUpdate","s":"BTCUSD","t":777,"i":12345,"S":"BUY","X":"NEW"}`), 0) + select { + case event := <-orderSub.ch: + if event.EventType != "orderUpdate" || event.TradeID != 777 || event.OrderID != 12345 { + t.Fatalf("unexpected order event: %+v", event) + } + case <-time.After(time.Second): + t.Fatal("order update was not dispatched") + } +} + +func TestDispatchFrame_DoesNotApplyNewSnapshotToQueuedOldFrame(t *testing.T) { + client := NewClient("wss://ws.gemini.com") + client.state.Store(int32(StateConnected)) + client.snapshotMode = true + sub := newSubscription[DepthUpdate](2) + client.subsMu.Lock() + tables := client.subTables.Load().clone() + tables.depthSubs["BTCUSD"] = []*subscription[DepthUpdate]{sub} + client.subTables.Store(tables) + client.snapshotPending["BTCUSD"] = 2 + client.subsMu.Unlock() + + stop := make(chan struct{}) + client.dispatchFrame(stop, []byte(`{"e":"depthUpdate","s":"BTCUSD","U":1,"u":1,"b":[],"a":[]}`), 1) + client.dispatchFrame(stop, []byte(`{"e":"depthUpdate","s":"BTCUSD","U":2,"u":2,"b":[],"a":[]}`), 2) + + first := <-sub.ch + second := <-sub.ch + if first.Snapshot { + t.Fatal("queued old-generation depth event was incorrectly marked as a snapshot") + } + if !second.Snapshot { + t.Fatal("new-generation depth event did not consume the pending snapshot marker") + } +} + +func TestDispatchResponse_FailsPendingRequestOnMalformedCorrelatedResponse(t *testing.T) { + client := NewClient("wss://ws.gemini.com") + resultCh := make(chan requestResult, 1) + client.pendingMu.Lock() + client.pending["7"] = resultCh + client.pendingMu.Unlock() + + handled, err := client.dispatchResponse([]byte(`{"id":7,"status":200,"error":{"code":"not-an-integer"}}`)) + if !handled { + t.Fatal("malformed correlated response was not recognized") + } + if !errors.Is(err, ErrMalformedResponse) { + t.Fatalf("dispatchResponse error = %v, want ErrMalformedResponse", err) + } + select { + case result := <-resultCh: + if !errors.Is(result.err, ErrMalformedResponse) { + t.Fatalf("pending request error = %v, want ErrMalformedResponse", result.err) + } + case <-time.After(time.Second): + t.Fatal("pending request was not failed immediately") + } + client.pendingMu.Lock() + defer client.pendingMu.Unlock() + if _, ok := client.pending["7"]; ok { + t.Fatal("malformed correlated response left the pending request registered") + } +} diff --git a/packages/sdk-go/websocket/client_hardening.go b/packages/sdk-go/websocket/client_hardening.go new file mode 100644 index 00000000..181b00d3 --- /dev/null +++ b/packages/sdk-go/websocket/client_hardening.go @@ -0,0 +1,131 @@ +package websocket + +import ( + "context" + "errors" + "fmt" + "time" +) + +const ( + // DefaultMaxMessageSize is the default maximum inbound WebSocket message + // size. It matches the limit used by the TypeScript SDK and protects the + // read pump from unbounded server payloads. + DefaultMaxMessageSize int64 = 1 << 20 +) + +var ( + // ErrMessageTooLarge indicates that an inbound WebSocket message exceeded + // the configured maximum size. The connection is closed so callers cannot + // continue from an unknown stream boundary. + ErrMessageTooLarge = errors.New("gemini websocket: inbound message exceeds configured limit") + + // ErrMalformedFrame indicates that a frame was not valid JSON. Malformed + // frames are reported through ConnectionEvent.Err and ignored so a single + // bad frame does not tear down an otherwise healthy connection. + ErrMalformedFrame = errors.New("gemini websocket: malformed inbound frame") + + // ErrMalformedResponse indicates that a correlated response could not be + // decoded. The matching pending request is failed immediately when its ID + // can be recovered; otherwise the connection lifecycle fails all pending + // requests while recovering the stream. + ErrMalformedResponse = errors.New("gemini websocket: malformed correlated response") + + // ErrLivenessFailed indicates that an opt-in application-level ping did not + // complete successfully. The underlying error is retained for errors.Is and + // errors.As checks. + ErrLivenessFailed = errors.New("gemini websocket: liveness check failed") +) + +// WithMaxMessageSize configures the maximum size, in bytes, of an inbound +// WebSocket message. The default is DefaultMaxMessageSize. A non-positive +// value disables the client-side limit; transports that support +// ReadLimitSetter receive the same positive limit before the read pump starts. +func WithMaxMessageSize(limit int64) ClientOption { + return func(c *Client) { + c.maxMessageSize = limit + } +} + +// WithLiveness enables an application-level liveness watchdog. At every +// interval the client sends the existing correlated ping method and waits up +// to timeout for its response. A failed check is reported through +// ConnectionEvent.Err and the current connection is closed so the normal +// reconnect policy can recover it. A non-positive interval disables the +// watchdog; a non-positive timeout uses the interval as the timeout. +func WithLiveness(interval, timeout time.Duration) ClientOption { + return func(c *Client) { + if interval <= 0 { + c.livenessInterval = 0 + c.livenessTimeout = 0 + return + } + if timeout <= 0 { + timeout = interval + } + c.livenessInterval = interval + c.livenessTimeout = timeout + } +} + +func (c *Client) configureReadLimit(conn Conn) { + if c.maxMessageSize <= 0 { + return + } + if setter, ok := conn.(ReadLimitSetter); ok { + setter.SetReadLimit(c.maxMessageSize) + } +} + +func (c *Client) startLivenessPumpLocked(lifecycle uint64) { + if c.livenessInterval <= 0 || c.State() != StateConnected || c.lifecycle.Load() != lifecycle { + return + } + c.pumpWg.Add(1) + go c.livenessPump(lifecycle) +} + +func (c *Client) livenessPump(lifecycle uint64) { + defer c.pumpWg.Done() + + ticker := time.NewTicker(c.livenessInterval) + defer ticker.Stop() + + for { + select { + case <-c.doneChan: + return + case <-ticker.C: + } + + c.mu.RLock() + active := c.State() == StateConnected && c.lifecycle.Load() == lifecycle && !c.replayInProgress && c.conn != nil + c.mu.RUnlock() + if !active { + return + } + + ctx, cancel := context.WithTimeout(context.Background(), c.livenessTimeout) + _, err := c.requestConnected(ctx, string(OpPing), nil) + cancel() + if err == nil { + continue + } + + failure := fmt.Errorf("%w: %w", ErrLivenessFailed, err) + c.mu.RLock() + stillCurrent := c.State() == StateConnected && c.lifecycle.Load() == lifecycle && c.conn != nil + conn := c.conn + c.mu.RUnlock() + if !stillCurrent { + return + } + + // Publish the liveness error before closing the transport. The read pump + // will then apply the normal reconnect policy, while callers retain the + // original error identity instead of seeing only a socket-close error. + c.publishEvent(ConnectionEvent{State: StateConnected, Err: failure}) + _ = conn.Close() + return + } +} diff --git a/packages/sdk-go/websocket/client_hardening_test.go b/packages/sdk-go/websocket/client_hardening_test.go new file mode 100644 index 00000000..7b595d01 --- /dev/null +++ b/packages/sdk-go/websocket/client_hardening_test.go @@ -0,0 +1,339 @@ +package websocket + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "sync" + "sync/atomic" + "testing" + "time" +) + +type hardeningConn struct { + closed chan struct{} + closeOnce sync.Once + reads chan []byte + respond atomic.Bool + readLimit atomic.Int64 +} + +func newHardeningConn() *hardeningConn { + conn := &hardeningConn{ + closed: make(chan struct{}), + reads: make(chan []byte, 16), + } + conn.respond.Store(true) + return conn +} + +func (c *hardeningConn) ReadMessage(ctx context.Context) (int, []byte, error) { + select { + case <-ctx.Done(): + return 0, nil, ctx.Err() + case <-c.closed: + return 0, nil, errors.New("hardening connection closed") + case payload := <-c.reads: + return TextMessage, payload, nil + } +} + +func (c *hardeningConn) WriteMessage(ctx context.Context, _ int, payload []byte) error { + var request RequestFrame + if err := json.Unmarshal(payload, &request); err != nil { + return err + } + if !c.respond.Load() { + return nil + } + ack, err := json.Marshal(map[string]any{ + "id": request.ID, + "status": http.StatusOK, + "result": map[string]any{}, + }) + if err != nil { + return err + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-c.closed: + return errors.New("hardening connection closed") + case c.reads <- ack: + return nil + } +} + +func (c *hardeningConn) Close() error { + c.closeOnce.Do(func() { close(c.closed) }) + return nil +} + +func (c *hardeningConn) SetReadLimit(limit int64) { + c.readLimit.Store(limit) +} + +func (c *hardeningConn) feed(payload []byte) { + select { + case <-c.closed: + case c.reads <- payload: + } +} + +type hardeningDialer struct { + conn *hardeningConn + calls atomic.Int32 +} + +func (d *hardeningDialer) Dial(context.Context, string, http.Header) (Conn, *http.Response, error) { + d.calls.Add(1) + if d.conn == nil { + d.conn = newHardeningConn() + } + return d.conn, &http.Response{StatusCode: http.StatusSwitchingProtocols}, nil +} + +func waitForConnectionError(t *testing.T, events <-chan ConnectionEvent, want error) { + t.Helper() + deadline := time.After(2 * time.Second) + for { + select { + case event, ok := <-events: + if !ok { + t.Fatal("connection event channel closed before expected error") + } + if errors.Is(event.Err, want) { + return + } + case <-deadline: + t.Fatalf("timed out waiting for connection error %v", want) + } + } +} + +func TestClient_RejectsOversizedInboundFrame(t *testing.T) { + dialer := &hardeningDialer{conn: newHardeningConn()} + client := NewClient( + "wss://ws.gemini.com", + WithDialer(dialer), + WithAutoReconnect(false), + WithMaxMessageSize(16), + ) + t.Cleanup(func() { _ = client.Close() }) + + events, stop := client.SubscribeConnectionEvents(8) + t.Cleanup(stop) + if err := client.Connect(context.Background()); err != nil { + t.Fatalf("Connect failed: %v", err) + } + if got := dialer.conn.readLimit.Load(); got != 16 { + t.Fatalf("expected transport read limit 16, got %d", got) + } + + dialer.conn.feed([]byte("0123456789abcdefx")) + waitForConnectionError(t, events, ErrMessageTooLarge) + if got := client.State(); got != StateDisconnected { + t.Fatalf("expected disconnected state after oversized frame, got %s", got) + } +} + +func TestClient_MalformedFrameIsObservableAndConnectionContinues(t *testing.T) { + dialer := &hardeningDialer{conn: newHardeningConn()} + client := NewClient("wss://ws.gemini.com", WithDialer(dialer)) + t.Cleanup(func() { _ = client.Close() }) + + events, stop := client.SubscribeConnectionEvents(8) + t.Cleanup(stop) + if err := client.Connect(context.Background()); err != nil { + t.Fatalf("Connect failed: %v", err) + } + dialer.conn.feed([]byte("{")) + waitForConnectionError(t, events, ErrMalformedFrame) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := client.Ping(ctx); err != nil { + t.Fatalf("connection did not continue after malformed frame: %v", err) + } + if got := client.State(); got != StateConnected { + t.Fatalf("expected connected state after malformed frame, got %s", got) + } +} + +func TestClient_LivenessFailurePreservesErrorIdentity(t *testing.T) { + dialer := &hardeningDialer{conn: newHardeningConn()} + client := NewClient( + "wss://ws.gemini.com", + WithDialer(dialer), + WithAutoReconnect(false), + WithLiveness(10*time.Millisecond, 20*time.Millisecond), + ) + t.Cleanup(func() { _ = client.Close() }) + + events, stop := client.SubscribeConnectionEvents(16) + t.Cleanup(stop) + if err := client.Connect(context.Background()); err != nil { + t.Fatalf("Connect failed: %v", err) + } + dialer.conn.respond.Store(false) + + deadline := time.After(2 * time.Second) + for { + select { + case event := <-events: + if errors.Is(event.Err, ErrLivenessFailed) { + if !errors.Is(event.Err, context.DeadlineExceeded) { + t.Fatalf("liveness error lost deadline identity: %v", event.Err) + } + return + } + case <-deadline: + t.Fatal("timed out waiting for liveness failure") + } + } +} + +func TestClient_CloseInterruptsReconnectBackoff(t *testing.T) { + dialer := &backoffDialer{first: newHardeningConn()} + client := NewClient( + "wss://ws.gemini.com", + WithDialer(dialer), + WithAutoReconnect(true), + WithMaxReconnects(3), + ) + t.Cleanup(func() { _ = client.Close() }) + if err := client.Connect(context.Background()); err != nil { + t.Fatalf("Connect failed: %v", err) + } + events, stop := client.SubscribeConnectionEvents(8) + t.Cleanup(stop) + if err := dialer.first.Close(); err != nil { + t.Fatalf("failed closing first connection: %v", err) + } + waitForState(t, events, StateReconnecting) + + closed := make(chan error, 1) + go func() { closed <- client.Close() }() + select { + case err := <-closed: + if err != nil { + t.Fatalf("Close failed: %v", err) + } + case <-time.After(500 * time.Millisecond): + t.Fatal("Close did not interrupt reconnect backoff") + } +} + +func waitForState(t *testing.T, events <-chan ConnectionEvent, state ConnectionState) { + t.Helper() + deadline := time.After(2 * time.Second) + for { + select { + case event := <-events: + if event.State == state { + return + } + case <-deadline: + t.Fatalf("timed out waiting for state %s", state) + } + } +} + +type backoffDialer struct { + first *hardeningConn + calls atomic.Int32 +} + +func (d *backoffDialer) Dial(context.Context, string, http.Header) (Conn, *http.Response, error) { + if d.calls.Add(1) == 1 { + return d.first, &http.Response{StatusCode: http.StatusSwitchingProtocols}, nil + } + return nil, nil, errors.New("dial unavailable during backoff test") +} + +type lateFrameConn struct { + closed chan struct{} + readStarted chan struct{} + releaseRead chan struct{} + closeOnce sync.Once +} + +func newLateFrameConn() *lateFrameConn { + return &lateFrameConn{ + closed: make(chan struct{}), + readStarted: make(chan struct{}), + releaseRead: make(chan struct{}), + } +} + +func (c *lateFrameConn) ReadMessage(context.Context) (int, []byte, error) { + select { + case <-c.readStarted: + default: + close(c.readStarted) + } + <-c.releaseRead + return TextMessage, []byte(`{"e":"depthUpdate","s":"BTCUSD","U":1,"u":1}`), nil +} + +func (c *lateFrameConn) WriteMessage(context.Context, int, []byte) error { return nil } + +func (c *lateFrameConn) Close() error { + c.closeOnce.Do(func() { close(c.closed) }) + return nil +} + +func TestClient_DropsLateFrameFromSupersededLifecycle(t *testing.T) { + conn := newLateFrameConn() + client := NewClient("wss://ws.gemini.com") + t.Cleanup(func() { _ = client.Close() }) + + sub := newSubscription[DepthUpdate](1) + tables := client.subTables.Load().clone() + tables.depthSubs["BTCUSD"] = []*subscription[DepthUpdate]{sub} + client.subTables.Store(tables) + client.mu.Lock() + client.conn = conn + client.state.Store(int32(StateConnected)) + client.lifecycle.Store(1) + client.mu.Unlock() + + client.pumpWg.Add(1) + go client.readPump(1) + select { + case <-conn.readStarted: + case <-time.After(time.Second): + t.Fatal("read pump did not start") + } + client.lifecycle.Store(2) + close(conn.releaseRead) + + select { + case event := <-sub.ch: + t.Fatalf("late frame from superseded lifecycle was delivered: %+v", event) + case <-time.After(100 * time.Millisecond): + } +} + +func TestClient_RPCResponseBypassesBlockedStreamingConsumer(t *testing.T) { + dialer := &hardeningDialer{conn: newHardeningConn()} + client := NewClient("wss://ws.gemini.com", WithDialer(dialer)) + t.Cleanup(func() { _ = client.Close() }) + if err := client.Connect(context.Background()); err != nil { + t.Fatalf("Connect failed: %v", err) + } + + sub := newSubscription[DepthUpdate](1) + sub.ch <- &DepthUpdate{} + tables := client.subTables.Load().clone() + tables.depthSubs["BTCUSD"] = []*subscription[DepthUpdate]{sub} + client.subTables.Store(tables) + dialer.conn.feed([]byte(`{"e":"depthUpdate","s":"BTCUSD","U":1,"u":1}`)) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := client.Ping(ctx); err != nil { + t.Fatalf("RPC response was blocked by streaming consumer: %v", err) + } +} diff --git a/packages/sdk-go/websocket/client_internal_test.go b/packages/sdk-go/websocket/client_internal_test.go new file mode 100644 index 00000000..40548cf9 --- /dev/null +++ b/packages/sdk-go/websocket/client_internal_test.go @@ -0,0 +1,919 @@ +package websocket + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "sync" + "testing" + "time" +) + +func TestPublishEventCoalescesLatestStateInOrder(t *testing.T) { + client := NewClient("wss://ws.gemini.com") + + for i := 0; i < cap(client.eventChan); i++ { + client.publishEvent(ConnectionEvent{State: StateDisconnected}) + } + client.eventMu.Lock() + client.eventPumpRunning = true + client.pendingEvent = &ConnectionEvent{State: StateConnecting} + client.eventWg.Add(1) + client.eventMu.Unlock() + client.publishEvent(ConnectionEvent{State: StateConnected}) + go client.drainEvents() + + for i := 0; i < cap(client.eventChan); i++ { + <-client.eventChan + } + select { + case event := <-client.eventChan: + if event.State != StateConnected { + t.Fatalf("expected latest coalesced state Connected, got %v", event.State) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for coalesced lifecycle event") + } + + client.Close() +} + +func TestFailedIsolatedPartialSubscriptionReleasesTrimmedSymbol(t *testing.T) { + client := NewPublicClient("wss://ws.gemini.com") + + _, err := client.subscribeIsolatedPartialDepth( + context.Background(), + " BTCUSD ", + PartialDepthSubscriptionOptions{Levels: PartialDepthLevel(25)}, + ) + if err == nil { + t.Fatal("expected invalid partial-depth options to fail") + } + + client.snapshotMu.Lock() + _, clientRetained := client.partialSnapshotClients["BTCUSD"] + _, refsRetained := client.partialSnapshotRefs["BTCUSD"] + client.snapshotMu.Unlock() + if clientRetained || refsRetained { + t.Fatal("failed isolated subscription retained the normalized snapshot client") + } +} + +func TestNewClientWithErrorRejectsInvalidEndpoint(t *testing.T) { + for _, endpoint := range []string{ + "https://ws.gemini.com", + "ws://ws.gemini.com", + "wss://user:password@ws.gemini.com", + "wss://ws.gemini.com#fragment", + } { + if _, err := NewClientWithError(endpoint); !errors.Is(err, ErrInvalidURL) { + t.Errorf("endpoint %q error = %v, want ErrInvalidURL", endpoint, err) + } + } + + client := NewClient("https://ws.gemini.com") + defer client.Close() + if err := client.Connect(context.Background()); !errors.Is(err, ErrInvalidURL) { + t.Fatalf("expected Connect to preserve invalid URL error, got %v", err) + } +} + +type slowConsumerConn struct { + closeOnce sync.Once + closed chan struct{} + reads chan []byte +} + +func (c *slowConsumerConn) ReadMessage(ctx context.Context) (int, []byte, error) { + select { + case <-ctx.Done(): + return 0, nil, ctx.Err() + case <-c.closed: + return 0, nil, errors.New("slow consumer test connection closed") + case payload := <-c.reads: + return TextMessage, payload, nil + } +} + +func (c *slowConsumerConn) WriteMessage(ctx context.Context, _ int, payload []byte) error { + var request RequestFrame + if err := json.Unmarshal(payload, &request); err != nil { + return err + } + ack, err := json.Marshal(map[string]any{ + "id": request.ID, + "status": http.StatusOK, + "result": map[string]any{}, + }) + if err != nil { + return err + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-c.closed: + return errors.New("slow consumer test connection closed") + case c.reads <- ack: + return nil + } +} + +func (c *slowConsumerConn) Close() error { + c.closeOnce.Do(func() { close(c.closed) }) + return nil +} + +type slowConsumerDialer struct { + conn *slowConsumerConn +} + +func (d *slowConsumerDialer) Dial(context.Context, string, http.Header) (Conn, *http.Response, error) { + d.conn = &slowConsumerConn{ + closed: make(chan struct{}), + reads: make(chan []byte, 2048), + } + return d.conn, &http.Response{StatusCode: http.StatusSwitchingProtocols}, nil +} + +func TestClient_SlowConsumerReportsExplicitError(t *testing.T) { + dialer := &slowConsumerDialer{} + client := NewClient( + "wss://ws.gemini.com", + WithDialer(dialer), + WithAutoReconnect(false), + ) + defer client.Close() + + depth, err := client.SubscribeDepth(context.Background(), "BTCUSD") + if err != nil { + t.Fatalf("SubscribeDepth failed: %v", err) + } + tables := client.subTables.Load() + if tables == nil || len(tables.depthSubs["BTCUSD"]) != 1 { + t.Fatal("expected one internal depth subscription") + } + depthIn := tables.depthSubs["BTCUSD"][0].ch + for i := 0; i < cap(depthIn); i++ { + depthIn <- &DepthUpdate{} + } + + frame := []byte(`{"e":"depthUpdate","s":"BTCUSD","U":1,"u":1,"b":[],"a":[]}`) + for i := 0; i < 1100; i++ { + dialer.conn.reads <- frame + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + for { + select { + case event := <-client.Events(): + if errors.Is(event.Err, ErrSlowConsumer) { + for i := 0; i < cap(depth); i++ { + select { + case _, ok := <-depth: + if !ok { + t.Fatalf("feed channel closed before draining its buffered values at index %d", i) + } + case <-ctx.Done(): + t.Fatalf("timed out draining feed channel: %v", ctx.Err()) + } + } + select { + case _, ok := <-depth: + if ok { + t.Fatal("expected slow-consumer policy to close feed channels") + } + case <-ctx.Done(): + t.Fatalf("feed channel was not closed: %v", ctx.Err()) + } + return + } + case <-ctx.Done(): + t.Fatalf("timed out waiting for ErrSlowConsumer: %v", ctx.Err()) + } + } +} + +type blockingSubscriptionConn struct { + closed chan struct{} + closeOnce sync.Once + writeStarted chan struct{} + releaseWrite chan struct{} +} + +func (c *blockingSubscriptionConn) ReadMessage(ctx context.Context) (int, []byte, error) { + select { + case <-ctx.Done(): + return 0, nil, ctx.Err() + case <-c.closed: + return 0, nil, errors.New("blocking subscription connection closed") + } +} + +func (c *blockingSubscriptionConn) WriteMessage(ctx context.Context, _ int, _ []byte) error { + select { + case <-c.writeStarted: + default: + close(c.writeStarted) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-c.closed: + return errors.New("blocking subscription connection closed") + case <-c.releaseWrite: + return nil + } +} + +func (c *blockingSubscriptionConn) Close() error { + c.closeOnce.Do(func() { close(c.closed) }) + return nil +} + +func TestSubscriptionCleanupDoesNotWaitForBlockedWireRequest(t *testing.T) { + client := NewClient("wss://ws.gemini.com") + conn := &blockingSubscriptionConn{ + closed: make(chan struct{}), + writeStarted: make(chan struct{}), + releaseWrite: make(chan struct{}), + } + client.mu.Lock() + client.conn = conn + client.state.Store(int32(StateConnected)) + client.lifecycle.Store(1) + client.mu.Unlock() + + subscribeDone := make(chan error, 1) + go func() { + _, err := client.SubscribeDepth(context.Background(), "BTCUSD") + subscribeDone <- err + }() + select { + case <-conn.writeStarted: + case <-time.After(time.Second): + t.Fatal("subscription request did not reach the blocked write") + } + + client.mu.Lock() + client.state.Store(int32(StateDisconnected)) + client.lifecycle.Store(2) + client.mu.Unlock() + cleanupDone := make(chan struct{}) + go func() { + client.failSubscriptionsIfCurrent(2, errors.New("test connection failure")) + close(cleanupDone) + }() + select { + case <-cleanupDone: + case <-time.After(time.Second): + t.Fatal("subscription cleanup blocked behind a wire request") + } + + close(conn.releaseWrite) + if err := client.Close(); err != nil { + t.Fatalf("client Close failed: %v", err) + } + select { + case <-subscribeDone: + case <-time.After(time.Second): + t.Fatal("blocked subscription request did not finish after release") + } +} + +func TestScopedUnsubscribeDetachesFullSubscriber(t *testing.T) { + client := NewClient("wss://ws.gemini.com") + defer client.Close() + + sub := newSubscription[OrderEvent](1) + sub.ch <- &OrderEvent{} + client.subsMu.Lock() + tables := client.subTables.Load().clone() + tables.orderSubs = map[string][]*subscription[OrderEvent]{"orders@account": {sub}} + client.subTables.Store(tables) + client.subsMu.Unlock() + + dispatchDone := make(chan struct{}) + go func() { + client.dispatchFrame(make(chan struct{}), []byte(`{"e":"orderUpdate","s":"BTCUSD","i":123,"X":"OPEN"}`), 0) + close(dispatchDone) + }() + + unsubscribeDone := make(chan error, 1) + go func() { + unsubscribeDone <- client.UnsubscribeOrderEventsChannel(context.Background(), sub.ch) + }() + select { + case err := <-unsubscribeDone: + if err != nil { + t.Fatalf("scoped unsubscribe failed: %v", err) + } + case <-time.After(time.Second): + t.Fatal("scoped unsubscribe blocked on a full subscriber") + } + select { + case <-dispatchDone: + case <-time.After(time.Second): + t.Fatal("dispatch did not stop after the subscriber was detached") + } + select { + case _, ok := <-sub.ch: + if !ok { + t.Fatal("expected the buffered value to remain available before closure") + } + case <-time.After(time.Second): + t.Fatal("detached subscriber buffered value was lost") + } + select { + case _, ok := <-sub.ch: + if ok { + t.Fatal("expected detached subscriber channel to be closed after draining") + } + case <-time.After(time.Second): + t.Fatal("detached subscriber channel was not closed") + } +} + +func TestFailSubscriptionsDoesNotClearNewLifecycle(t *testing.T) { + client := NewClient("wss://ws.gemini.com") + defer client.Close() + + feed := newSubscription[DepthUpdate](1) + client.subsMu.Lock() + tables := client.subTables.Load().clone() + tables.depthSubs["BTCUSD"] = []*subscription[DepthUpdate]{feed} + client.subTables.Store(tables) + client.activeFeeds["depth:BTCUSD@depth"] = RequestFrame{ID: 1, Method: "SUBSCRIBE", Params: []string{"btcusd@depth"}} + client.subsMu.Unlock() + + client.lifecycle.Store(1) + client.subsMu.Lock() + cleanupDone := make(chan struct{}) + go func() { + client.failSubscriptionsIfCurrent(1, errors.New("stale reconnect failed")) + close(cleanupDone) + }() + client.lifecycle.Store(2) + client.subsMu.Unlock() + + select { + case <-cleanupDone: + case <-time.After(time.Second): + t.Fatal("timed out waiting for stale cleanup") + } + + select { + case _, ok := <-feed.ch: + if !ok { + t.Fatal("stale reconnect cleanup closed a subscription from a newer lifecycle") + } + default: + } + client.subsMu.Lock() + defer client.subsMu.Unlock() + if _, ok := client.activeFeeds["depth:BTCUSD@depth"]; !ok { + t.Fatal("stale reconnect cleanup removed a subscription from a newer lifecycle") + } +} + +func TestResubscribeFailureBlocksNewConnectionUntilCleanup(t *testing.T) { + oldConn := newReplayRaceConn() + dialer := &replayRaceDialer{conn: oldConn} + client := NewClient( + "wss://ws.gemini.com", + WithDialer(dialer), + WithAutoReconnect(false), + ) + t.Cleanup(func() { _ = client.Close() }) + + client.mu.Lock() + client.conn = oldConn + client.state.Store(int32(StateConnected)) + client.lifecycle.Store(1) + client.mu.Unlock() + + feed := newSubscription[DepthUpdate](1) + client.subsMu.Lock() + tables := client.subTables.Load().clone() + tables.depthSubs["BTCUSD"] = []*subscription[DepthUpdate]{feed} + client.subTables.Store(tables) + client.activeFeeds["depth:BTCUSD@depth"] = RequestFrame{ID: 1, Method: "SUBSCRIBE", Params: []string{"btcusd@depth"}} + + // Hold subsMu while the failed connection acquires lifecycleGate and stages + // cleanup. A new manual connection must not install and replay against a + // table that the failure path is about to replace. + failureDone := make(chan struct{}) + go func() { + client.handleResubscribeFailure(1, errors.New("resubscribe failed")) + close(failureDone) + }() + deadline := time.After(time.Second) + for client.State() != StateDisconnected || client.lifecycle.Load() != 2 { + select { + case <-deadline: + client.subsMu.Unlock() + t.Fatal("resubscribe failure did not invalidate the old lifecycle") + default: + time.Sleep(time.Millisecond) + } + } + + newConn := newReplayRaceConn() + dialer.conn = newConn + connectDone := make(chan error, 1) + go func() { connectDone <- client.Connect(context.Background()) }() + + select { + case <-connectDone: + client.subsMu.Unlock() + t.Fatal("new connection completed before failed subscriptions were cleaned up") + case <-time.After(50 * time.Millisecond): + } + client.subsMu.Unlock() + + select { + case <-failureDone: + case <-time.After(time.Second): + t.Fatal("resubscribe cleanup did not complete") + } + + select { + case _, ok := <-feed.ch: + if ok { + t.Fatal("expected failed subscription channel to close") + } + case <-time.After(time.Second): + t.Fatal("failed subscription channel was not closed") + } + + select { + case err := <-connectDone: + if err != nil { + t.Fatalf("new connection failed after cleanup: %v", err) + } + case <-time.After(time.Second): + t.Fatal("new connection did not complete after cleanup") + } +} + +func TestRegisterPendingRejectsInvalidatedConnection(t *testing.T) { + client := NewClient("wss://ws.gemini.com") + t.Cleanup(func() { _ = client.Close() }) + + client.mu.Lock() + client.conn = newReplayRaceConn() + client.state.Store(int32(StateConnected)) + client.lifecycle.Store(1) + registered := make(chan error, 1) + go func() { + registered <- client.registerPending("1", make(chan requestResult, 1)) + }() + + // The failure transition and pending admission share c.mu. Once the + // transition invalidates the connection, the request cannot be admitted + // into a map that the read pump has already drained. + client.state.Store(int32(StateDisconnected)) + client.lifecycle.Add(1) + client.failPending(errors.New("connection dropped")) + client.mu.Unlock() + + select { + case err := <-registered: + if err == nil { + t.Fatal("expected pending registration to fail after invalidation") + } + case <-time.After(time.Second): + t.Fatal("pending registration did not complete") + } + client.pendingMu.Lock() + defer client.pendingMu.Unlock() + if len(client.pending) != 0 { + t.Fatalf("expected no pending requests after invalidation, got %d", len(client.pending)) + } +} + +type replayRaceConn struct { + closeOnce sync.Once + closed chan struct{} + readChan chan []byte + replayStarted chan struct{} + releaseReplay chan struct{} + mu sync.Mutex + methods []string + subscribeCount int +} + +func newReplayRaceConn() *replayRaceConn { + return &replayRaceConn{ + closed: make(chan struct{}), + readChan: make(chan []byte, 16), + replayStarted: make(chan struct{}), + releaseReplay: make(chan struct{}), + } +} + +func (c *replayRaceConn) ReadMessage(ctx context.Context) (int, []byte, error) { + select { + case <-ctx.Done(): + return 0, nil, ctx.Err() + case <-c.closed: + return 0, nil, errors.New("replay race connection closed") + case payload := <-c.readChan: + return TextMessage, payload, nil + } +} + +func (c *replayRaceConn) WriteMessage(ctx context.Context, _ int, payload []byte) error { + var request RequestFrame + if err := json.Unmarshal(payload, &request); err != nil { + return err + } + + c.mu.Lock() + c.methods = append(c.methods, request.Method) + waitForReplay := false + if request.Method == "SUBSCRIBE" { + c.subscribeCount++ + if c.subscribeCount == 2 { + close(c.replayStarted) + waitForReplay = true + } + } + c.mu.Unlock() + + if waitForReplay { + select { + case <-ctx.Done(): + return ctx.Err() + case <-c.closed: + return errors.New("replay race connection closed") + case <-c.releaseReplay: + } + } + + ack, err := json.Marshal(map[string]any{ + "id": request.ID, + "status": http.StatusOK, + "result": map[string]any{}, + }) + if err != nil { + return err + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-c.closed: + return errors.New("replay race connection closed") + case c.readChan <- ack: + return nil + } +} + +func (c *replayRaceConn) Close() error { + c.closeOnce.Do(func() { close(c.closed) }) + return nil +} + +type replayRaceDialer struct{ conn *replayRaceConn } + +func (d replayRaceDialer) Dial(context.Context, string, http.Header) (Conn, *http.Response, error) { + return d.conn, &http.Response{StatusCode: http.StatusSwitchingProtocols}, nil +} + +func TestResubscribeSerializesWithUnsubscribe(t *testing.T) { + conn := newReplayRaceConn() + client := NewClient( + "wss://ws.gemini.com", + WithDialer(replayRaceDialer{conn: conn}), + WithAutoReconnect(false), + ) + defer client.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + depth, err := client.SubscribeDepth(ctx, "BTCUSD") + if err != nil { + t.Fatalf("SubscribeDepth failed: %v", err) + } + lifecycle := client.lifecycle.Load() + + resubscribeDone := make(chan error, 1) + go func() { resubscribeDone <- client.resubscribeActiveFeeds(lifecycle) }() + select { + case <-conn.replayStarted: + case <-time.After(time.Second): + t.Fatal("timed out waiting for replay subscribe") + } + + unsubscribed := make(chan error, 1) + go func() { unsubscribed <- client.UnsubscribeDepth(ctx, "BTCUSD") }() + select { + case _, ok := <-depth: + if !ok { + t.Fatal("unsubscribe closed the feed before replay completed") + } + case <-time.After(100 * time.Millisecond): + } + + close(conn.releaseReplay) + if err := <-resubscribeDone; err != nil { + t.Fatalf("resubscribe failed: %v", err) + } + if err := <-unsubscribed; err != nil { + t.Fatalf("unsubscribe failed: %v", err) + } + + conn.mu.Lock() + methods := append([]string(nil), conn.methods...) + conn.mu.Unlock() + want := []string{"SUBSCRIBE", "SUBSCRIBE", "UNSUBSCRIBE"} + if len(methods) != len(want) { + t.Fatalf("expected methods %v, got %v", want, methods) + } + for i := range want { + if methods[i] != want[i] { + t.Fatalf("expected methods %v, got %v", want, methods) + } + } +} + +func TestReplayDoesNotBlockConnectionFailureHandling(t *testing.T) { + conn := newReplayRaceConn() + client := NewClient( + "wss://ws.gemini.com", + WithDialer(replayRaceDialer{conn: conn}), + WithAutoReconnect(false), + ) + defer client.Close() + + client.subsMu.Lock() + client.activeFeeds["depth:BTCUSD@depth"] = RequestFrame{ID: 1, Method: "SUBSCRIBE", Params: []string{"btcusd@depth"}} + client.activeFeeds["depth:ETHUSD@depth"] = RequestFrame{ID: 2, Method: "SUBSCRIBE", Params: []string{"ethusd@depth"}} + client.subsMu.Unlock() + + connectDone := make(chan error, 1) + go func() { connectDone <- client.Connect(context.Background()) }() + select { + case <-conn.replayStarted: + case <-time.After(time.Second): + t.Fatal("timed out waiting for replay to block") + } + + // The socket drops while replay is waiting for its second SUBSCRIBE ACK. + // Failure handling must acquire lifecycleGate immediately rather than wait + // for the replay timeout. + if err := conn.Close(); err != nil { + t.Fatalf("failed closing replay connection: %v", err) + } + select { + case err := <-connectDone: + if err == nil { + t.Fatal("expected Connect to report the dropped socket") + } + case <-time.After(time.Second): + t.Fatal("connection failure handling was blocked by feed replay") + } +} + +type selectiveReplayConn struct { + closed chan struct{} + closeOnce sync.Once + reads chan []byte + firstBlocked chan string + blockOnce sync.Once + release chan struct{} + mu sync.Mutex + requests []RequestFrame +} + +func newSelectiveReplayConn() *selectiveReplayConn { + return &selectiveReplayConn{ + closed: make(chan struct{}), + reads: make(chan []byte, 16), + firstBlocked: make(chan string, 1), + release: make(chan struct{}), + } +} + +func (c *selectiveReplayConn) ReadMessage(ctx context.Context) (int, []byte, error) { + select { + case <-ctx.Done(): + return 0, nil, ctx.Err() + case <-c.closed: + return 0, nil, errors.New("selective replay connection closed") + case payload := <-c.reads: + return TextMessage, payload, nil + } +} + +func (c *selectiveReplayConn) WriteMessage(ctx context.Context, _ int, payload []byte) error { + var request RequestFrame + if err := json.Unmarshal(payload, &request); err != nil { + return err + } + c.mu.Lock() + c.requests = append(c.requests, request) + c.mu.Unlock() + + ack, err := json.Marshal(map[string]any{ + "id": request.ID, + "status": http.StatusOK, + "result": map[string]any{}, + }) + if err != nil { + return err + } + + if request.Method == "SUBSCRIBE" && len(request.Params) > 0 { + var shouldBlock bool + c.blockOnce.Do(func() { + shouldBlock = true + c.firstBlocked <- request.Params[0] + }) + if shouldBlock { + go func() { + select { + case <-c.closed: + case <-c.release: + select { + case <-c.closed: + case c.reads <- ack: + } + } + }() + return nil + } + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-c.closed: + return errors.New("selective replay connection closed") + case c.reads <- ack: + return nil + } +} + +func (c *selectiveReplayConn) Close() error { + c.closeOnce.Do(func() { close(c.closed) }) + return nil +} + +type selectiveReplayDialer struct{ conn *selectiveReplayConn } + +func (d selectiveReplayDialer) Dial(context.Context, string, http.Header) (Conn, *http.Response, error) { + return d.conn, &http.Response{StatusCode: http.StatusSwitchingProtocols}, nil +} + +func TestResubscribeSkipsFeedRemovedAfterSnapshot(t *testing.T) { + conn := newSelectiveReplayConn() + client := NewClient( + "wss://ws.gemini.com", + WithDialer(selectiveReplayDialer{conn: conn}), + WithAutoReconnect(false), + ) + defer client.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := client.Connect(ctx); err != nil { + t.Fatalf("Connect failed: %v", err) + } + + client.subsMu.Lock() + tables := client.subTables.Load().clone() + tables.depthSubs["BTCUSD"] = []*subscription[DepthUpdate]{newSubscription[DepthUpdate](1)} + tables.depthSubs["ETHUSD"] = []*subscription[DepthUpdate]{newSubscription[DepthUpdate](1)} + client.subTables.Store(tables) + client.activeFeeds["depth:BTCUSD@depth"] = RequestFrame{ID: 1, Method: "SUBSCRIBE", Params: []string{"btcusd@depth"}} + client.activeFeeds["depth:ETHUSD@depth"] = RequestFrame{ID: 2, Method: "SUBSCRIBE", Params: []string{"ethusd@depth"}} + client.subsMu.Unlock() + + lifecycle := client.lifecycle.Load() + replayDone := make(chan error, 1) + go func() { replayDone <- client.resubscribeActiveFeeds(lifecycle) }() + + var blockedStream string + select { + case blockedStream = <-conn.firstBlocked: + case <-time.After(time.Second): + t.Fatal("timed out waiting for the first replay request") + } + + unsubSymbol := "ETHUSD" + unsubStream := "ethusd@depth" + if blockedStream == "ethusd@depth" { + unsubSymbol = "BTCUSD" + unsubStream = "btcusd@depth" + } + + unsubDone := make(chan error, 1) + go func() { unsubDone <- client.UnsubscribeDepth(ctx, unsubSymbol) }() + removed := false + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + client.subsMu.Lock() + _, stillActive := client.activeFeeds["depth:"+unsubSymbol+"@depth"] + client.subsMu.Unlock() + if !stillActive { + removed = true + break + } + time.Sleep(time.Millisecond) + } + if !removed { + t.Fatal("unsubscribe of a later feed did not detach it while an earlier replay was blocked") + } + + close(conn.release) + if err := <-unsubDone; err != nil { + t.Fatalf("UnsubscribeDepth failed: %v", err) + } + if err := <-replayDone; err != nil { + t.Fatalf("replay failed: %v", err) + } + + conn.mu.Lock() + requests := append([]RequestFrame(nil), conn.requests...) + conn.mu.Unlock() + for _, request := range requests { + if request.Method == "SUBSCRIBE" && len(request.Params) > 0 && request.Params[0] == unsubStream { + t.Fatalf("replayed feed %q which was removed after the replay snapshot", unsubStream) + } + } +} + +func TestConnectWaitsForFeedReplayBeforeAllowingNewSubscription(t *testing.T) { + conn := newReplayRaceConn() + client := NewClient( + "wss://ws.gemini.com", + WithDialer(replayRaceDialer{conn: conn}), + WithAutoReconnect(false), + ) + defer client.Close() + connectionEvents, stopConnectionEvents := client.SubscribeConnectionEvents(8) + defer stopConnectionEvents() + + client.subsMu.Lock() + client.activeFeeds["depth:BTCUSD@depth"] = RequestFrame{ID: 1, Method: "SUBSCRIBE", Params: []string{"btcusd@depth"}} + client.activeFeeds["depth:ETHUSD@depth"] = RequestFrame{ID: 2, Method: "SUBSCRIBE", Params: []string{"ethusd@depth"}} + client.subsMu.Unlock() + + connectDone := make(chan error, 1) + go func() { connectDone <- client.Connect(context.Background()) }() + select { + case <-conn.replayStarted: + case <-time.After(time.Second): + t.Fatal("timed out waiting for feed replay to reach its blocking request") + } + select { + case event := <-connectionEvents: + if event.State == StateConnected { + t.Fatal("published StateConnected before feed replay completed") + } + case <-time.After(100 * time.Millisecond): + } + + subscribeDone := make(chan error, 1) + go func() { + _, err := client.SubscribeBookTicker(context.Background(), "SOLUSD") + subscribeDone <- err + }() + select { + case err := <-subscribeDone: + t.Fatalf("new subscription completed during feed replay: %v", err) + case <-time.After(100 * time.Millisecond): + } + + conn.mu.Lock() + methodCount := len(conn.methods) + conn.mu.Unlock() + if methodCount != 2 { + t.Fatalf("new subscription raced ahead of replay; saw %d wire methods", methodCount) + } + + close(conn.releaseReplay) + if err := <-connectDone; err != nil { + t.Fatalf("Connect failed: %v", err) + } + connectedEvent := false + deadline := time.After(time.Second) + for !connectedEvent { + select { + case event := <-connectionEvents: + connectedEvent = event.State == StateConnected + case <-deadline: + t.Fatal("did not publish StateConnected after feed replay completed") + } + } + select { + case err := <-subscribeDone: + if err != nil { + t.Fatalf("new subscription failed after replay: %v", err) + } + case <-time.After(time.Second): + t.Fatal("new subscription did not proceed after replay") + } +} diff --git a/packages/sdk-go/websocket/client_requests.go b/packages/sdk-go/websocket/client_requests.go new file mode 100644 index 00000000..dea24252 --- /dev/null +++ b/packages/sdk-go/websocket/client_requests.go @@ -0,0 +1,250 @@ +package websocket + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "strconv" + "strings" + + "github.com/gemini/gemini-go/transport" +) + +// DepthSnapshotOptions controls the optional limit on a one-shot depth +// snapshot request. A zero limit uses the exchange default; valid explicit +// limits range from 1 through 5000. +type DepthSnapshotOptions struct { + Limit int +} + +// GetDepthSnapshot sends the typed WebSocket depth request and decodes its +// full order-book result. +func (c *Client) GetDepthSnapshot(ctx context.Context, symbol string, options DepthSnapshotOptions) (*OrderBookSnapshot, error) { + if options.Limit < 0 || options.Limit > 5000 { + return nil, fmt.Errorf("gemini websocket: depth snapshot limit must be 0 or between 1 and 5000") + } + normSymbol, err := normalizeSymbol(symbol) + if err != nil { + return nil, err + } + params := struct { + Symbol string `json:"symbol"` + Limit int `json:"limit,omitempty"` + }{ + Symbol: strings.ToLower(normSymbol), + Limit: options.Limit, + } + response, err := c.Request(ctx, "depth", params) + if err != nil { + return nil, err + } + if len(response.Result) == 0 { + return nil, errors.New("gemini websocket: depth response missing result") + } + var snapshot OrderBookSnapshot + if err := response.DecodeResult(&snapshot); err != nil { + return nil, fmt.Errorf("gemini websocket: decoding depth response: %w", err) + } + return &snapshot, nil +} + +// Send writes a JSON frame to the active connection. +func (c *Client) Send(ctx context.Context, payload any) error { + if ctx == nil { + ctx = context.Background() + } + data, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("gemini websocket: marshaling payload: %w", err) + } + + c.logger.Debug("websocket sending frame", slog.Int("bytes", len(data))) + c.writeMu.Lock() + defer c.writeMu.Unlock() + + c.mu.RLock() + conn := c.conn + state := c.State() + if state == StateClosed { + c.mu.RUnlock() + return fmt.Errorf("%w: websocket connection closed", transport.ErrConnectionClosed) + } + if conn == nil || state != StateConnected { + c.mu.RUnlock() + return errors.New("gemini websocket: not connected") + } + c.mu.RUnlock() + return conn.WriteMessage(ctx, TextMessage, data) +} + +// Request sends a JSON WebSocket method request and waits for its correlated +// response. The params value is encoded as the wire-level params field and may +// be nil when the method has no parameters. +func (c *Client) Request(ctx context.Context, method string, params any) (ResponseFrame, error) { + if ctx == nil { + ctx = context.Background() + } + if strings.TrimSpace(method) == "" { + return ResponseFrame{}, errors.New("gemini websocket: request method is empty") + } + if err := c.Connect(ctx); err != nil { + return ResponseFrame{}, err + } + return c.requestConnected(ctx, method, params) +} + +// requestConnected sends a request without attempting to establish a +// connection. Subscription and replay code uses this after it has already +// coordinated connection state, so it cannot recursively wait on the +// lifecycle gate while holding another subscription-operation lock. +func (c *Client) requestConnected(ctx context.Context, method string, params any) (ResponseFrame, error) { + if ctx == nil { + ctx = context.Background() + } + if strings.TrimSpace(method) == "" { + return ResponseFrame{}, errors.New("gemini websocket: request method is empty") + } + + id := globalReqID.Add(1) + idString := strconv.FormatInt(id, 10) + resultCh := make(chan requestResult, 1) + if err := c.registerPending(idString, resultCh); err != nil { + return ResponseFrame{}, err + } + + frame := struct { + ID int64 `json:"id"` + Method string `json:"method"` + Params any `json:"params,omitempty"` + }{ + ID: id, + Method: method, + Params: params, + } + if err := c.Send(ctx, frame); err != nil { + c.removePending(idString) + return ResponseFrame{}, err + } + + select { + case result := <-resultCh: + return result.response, result.err + case <-ctx.Done(): + c.removePending(idString) + return ResponseFrame{}, ctx.Err() + case <-c.doneChan: + c.removePending(idString) + return ResponseFrame{}, fmt.Errorf("%w: websocket client is closed", transport.ErrConnectionClosed) + } +} + +// registerPending admits a request only while the connection is still +// connected. It takes c.mu before pendingMu so connection failure can +// invalidate the lifecycle and drain pending requests without allowing a +// request to register in the gap between those operations. +func (c *Client) registerPending(id string, resultCh chan requestResult) error { + c.mu.Lock() + defer c.mu.Unlock() + if c.State() == StateClosed { + return fmt.Errorf("%w: websocket connection closed", transport.ErrConnectionClosed) + } + if c.conn == nil || c.State() != StateConnected { + return errors.New("gemini websocket: not connected") + } + c.pendingMu.Lock() + if c.pending == nil { + c.pending = make(map[string]chan requestResult) + } + c.pending[id] = resultCh + c.pendingMu.Unlock() + return nil +} + +// Ping performs the public WebSocket ping method and waits for the server's +// response. Applications can use it as an explicit liveness check; automatic +// keepalive traffic is intentionally opt-in at the application layer. +func (c *Client) Ping(ctx context.Context) error { + _, err := c.Request(ctx, string(OpPing), nil) + return err +} + +func (c *Client) removePending(id string) { + c.pendingMu.Lock() + delete(c.pending, id) + c.pendingMu.Unlock() +} + +func (c *Client) failPending(err error) { + c.pendingMu.Lock() + pending := c.pending + c.pending = make(map[string]chan requestResult) + c.pendingMu.Unlock() + + for _, resultCh := range pending { + resultCh <- requestResult{err: err} + } +} + +func (c *Client) dispatchResponse(payload []byte) (bool, error) { + var envelope struct { + ID json.RawMessage `json:"id"` + Status *int `json:"status"` + } + if err := json.Unmarshal(payload, &envelope); err != nil || len(envelope.ID) == 0 || envelope.Status == nil { + return false, nil + } + + responseID, idErr := decodeResponseID(envelope.ID) + var response ResponseFrame + if err := json.Unmarshal(payload, &response); err != nil { + malformedErr := fmt.Errorf("%w: %v", ErrMalformedResponse, err) + if idErr == nil { + c.pendingMu.Lock() + resultCh, ok := c.pending[responseID] + if ok { + delete(c.pending, responseID) + } + c.pendingMu.Unlock() + if ok { + resultCh <- requestResult{err: malformedErr} + } + } else { + malformedErr = fmt.Errorf("%w: invalid response id: %v", ErrMalformedResponse, idErr) + } + return true, malformedErr + } + + c.pendingMu.Lock() + resultCh, ok := c.pending[response.ID] + if ok { + delete(c.pending, response.ID) + } + c.pendingMu.Unlock() + if !ok { + return true, nil + } + + result := requestResult{response: response} + if response.Status < http.StatusOK || response.Status >= http.StatusMultipleChoices { + message := "" + code := 0 + if response.Error != nil { + message = response.Error.Message + if message == "" { + message = response.Error.Msg + } + code = response.Error.Code + } + result.err = &RequestError{ + ID: response.ID, + Status: response.Status, + Code: code, + Message: message, + } + } + resultCh <- result + return true, nil +} diff --git a/packages/sdk-go/websocket/client_subscriptions.go b/packages/sdk-go/websocket/client_subscriptions.go new file mode 100644 index 00000000..cbbe8fc2 --- /dev/null +++ b/packages/sdk-go/websocket/client_subscriptions.go @@ -0,0 +1,906 @@ +package websocket + +import ( + "context" + "errors" + "fmt" + "strings" + "sync/atomic" + "time" +) + +var globalReqID atomic.Int64 + +// ErrSubscriptionVariantMismatch indicates that an unsubscribe request targets +// a different cadence or depth variant than the active public feed. +var ErrSubscriptionVariantMismatch = errors.New("gemini websocket: subscription variant mismatch") + +func normalizeSymbol(symbol string) (string, error) { + normalized := strings.ToUpper(strings.TrimSpace(symbol)) + if normalized == "" { + return "", errors.New("gemini websocket: symbol is required") + } + return normalized, nil +} + +// SubscriptionScope identifies the account or authenticated WebSocket session +// associated with a private stream. +type SubscriptionScope string + +const ( + ScopeAccount SubscriptionScope = "account" + ScopeSession SubscriptionScope = "session" +) + +// DepthSubscriptionOptions controls the differential depth stream interval. +// The exchange supports the default interval and a 100ms accelerated stream. +type DepthSubscriptionOptions struct { + Interval time.Duration +} + +// PartialDepthLevel identifies the number of top-of-book levels in a partial +// depth stream. +type PartialDepthLevel int + +const ( + DepthLevel5 PartialDepthLevel = 5 + DepthLevel10 PartialDepthLevel = 10 + DepthLevel20 PartialDepthLevel = 20 +) + +// PartialDepthSubscriptionOptions controls a partial-depth snapshot stream. +// A zero Levels value selects the documented default of 10 levels. +type PartialDepthSubscriptionOptions struct { + Levels PartialDepthLevel + Interval time.Duration +} + +// AccountStreamOptions controls the optional one-second snapshot interval for +// authenticated balance and position streams. +type AccountStreamOptions struct { + Interval time.Duration +} + +func depthIntervalSuffix(interval time.Duration) (string, error) { + switch interval { + case 0: + return "depth", nil + case 100 * time.Millisecond: + return "depth@100ms", nil + default: + return "", fmt.Errorf("gemini websocket: depth interval must be 0 or 100ms") + } +} + +func partialDepthStreamSuffix(options PartialDepthSubscriptionOptions) (string, error) { + levels := options.Levels + if levels == 0 { + levels = DepthLevel10 + } + if levels != DepthLevel5 && levels != DepthLevel10 && levels != DepthLevel20 { + return "", fmt.Errorf("gemini websocket: partial depth levels must be 5, 10, or 20") + } + suffix := fmt.Sprintf("depth%d", levels) + if options.Interval == 0 { + return suffix, nil + } + if options.Interval != 100*time.Millisecond { + return "", fmt.Errorf("gemini websocket: partial depth interval must be 0 or 100ms") + } + return suffix + "@100ms", nil +} + +func accountStreamName(base string, options AccountStreamOptions) (string, error) { + switch options.Interval { + case 0: + return base + "@account", nil + case time.Second: + return base + "@account@1s", nil + default: + return "", fmt.Errorf("gemini websocket: account stream interval must be 0 or 1s") + } +} + +// acquireSubscriptionWire serializes control requests for one feed. Keeping +// the gate per feed means a stalled depth request cannot block cleanup of an +// unrelated private feed. +func (c *Client) acquireSubscriptionWire(ctx context.Context, feedKey string) error { + if ctx == nil { + ctx = context.Background() + } + c.subscriptionWireMu.Lock() + gate := c.subscriptionGates[feedKey] + if gate == nil { + gate = newTokenGate() + c.subscriptionGates[feedKey] = gate + } + c.subscriptionWireMu.Unlock() + + select { + case <-ctx.Done(): + return ctx.Err() + case <-c.doneChan: + return fmt.Errorf("gemini websocket: subscription gate closed: %w", context.Canceled) + case <-gate: + return nil + } +} + +func (c *Client) releaseSubscriptionWire(feedKey string) { + c.subscriptionWireMu.Lock() + gate := c.subscriptionGates[feedKey] + c.subscriptionWireMu.Unlock() + if gate != nil { + gate <- struct{}{} + } +} + +// acquireSubscriptionReplay reserves the short table-snapshot interval used +// during reconnect. It must never be held while waiting for a server ACK. +func (c *Client) acquireSubscriptionReplay(ctx context.Context) error { + if ctx == nil { + ctx = context.Background() + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-c.doneChan: + return fmt.Errorf("gemini websocket: subscription replay gate closed: %w", context.Canceled) + case <-c.subscriptionReplayGate: + return nil + } +} + +func (c *Client) releaseSubscriptionReplay() { + c.subscriptionReplayGate <- struct{}{} +} + +func subscribePublicFeed[T any]( + ctx context.Context, + c *Client, + symbol string, + feedPrefix string, + streamSuffix string, + chBuf int, + getMap func(*subTables) map[string][]*subscription[T], + setMap func(*subTables, map[string][]*subscription[T]), +) (<-chan *T, error) { + normSymbol, err := normalizeSymbol(symbol) + if err != nil { + return nil, err + } + if err := c.Connect(ctx); err != nil { + return nil, err + } + + feedKey := fmt.Sprintf("%s:%s@%s", feedPrefix, normSymbol, streamSuffix) + var stream string + isContract := streamSuffix == "contractStatus" + if isContract { + stream = "contractStatus" + feedKey = "contractStatus" + } else { + stream = fmt.Sprintf("%s@%s", strings.ToLower(normSymbol), streamSuffix) + } + sub := newSubscription[T](chBuf) + + req := RequestFrame{ + ID: globalReqID.Add(1), + Method: "SUBSCRIBE", + Params: []string{stream}, + } + + if err := c.acquireSubscriptionWire(ctx, feedKey); err != nil { + return nil, err + } + defer c.releaseSubscriptionWire(feedKey) + if err := c.acquireSubscriptionReplay(ctx); err != nil { + return nil, err + } + + c.subsMu.Lock() + oldTables := c.subTables.Load() + if feedPrefix == "depth" || feedPrefix == "partialDepth" { + for activeKey := range c.activeFeeds { + prefix := fmt.Sprintf("%s:%s@", feedPrefix, normSymbol) + if strings.HasPrefix(activeKey, prefix) && activeKey != feedKey { + c.subsMu.Unlock() + c.releaseSubscriptionReplay() + return nil, fmt.Errorf("gemini websocket: %s stream variant already active for %s", feedPrefix, normSymbol) + } + } + } + _, alreadyActive := c.activeFeeds[feedKey] + needWireSubscribe := !alreadyActive + newTables := oldTables.clone() + setMap(newTables, addMapSub(getMap(oldTables), normSymbol, sub)) + c.subTables.Store(newTables) + if needWireSubscribe { + c.activeFeeds[feedKey] = req + if feedPrefix == "depth" && c.snapshotMode { + c.snapshotPending[normSymbol] = c.subscriptionGeneration.Add(1) + } + } + c.subsMu.Unlock() + c.releaseSubscriptionReplay() + + if needWireSubscribe { + if _, err := c.requestConnected(ctx, req.Method, req.Params); err != nil { + removePublicSubscription(c, feedKey, normSymbol, sub, feedPrefix, getMap, setMap) + return nil, fmt.Errorf("gemini websocket: subscribe send failed: %w", err) + } + } + + return sub.ch, nil +} + +func unsubscribePublicFeed[T any]( + ctx context.Context, + c *Client, + symbol string, + feedPrefix string, + streamSuffix string, + getMap func(*subTables) map[string][]*subscription[T], + setMap func(*subTables, map[string][]*subscription[T]), +) error { + normSymbol, err := normalizeSymbol(symbol) + if err != nil { + return err + } + feedKey := fmt.Sprintf("%s:%s@%s", feedPrefix, normSymbol, streamSuffix) + var stream string + isContract := streamSuffix == "contractStatus" + if isContract { + stream = "contractStatus" + feedKey = "contractStatus" + } else { + stream = fmt.Sprintf("%s@%s", strings.ToLower(normSymbol), streamSuffix) + } + + if err := c.acquireSubscriptionWire(ctx, feedKey); err != nil { + return err + } + defer c.releaseSubscriptionWire(feedKey) + if err := c.acquireSubscriptionReplay(ctx); err != nil { + return err + } + + c.subsMu.Lock() + if !isContract { + if err := validateActivePublicFeedVariantLocked(c, feedPrefix, normSymbol, feedKey, streamSuffix); err != nil { + c.subsMu.Unlock() + c.releaseSubscriptionReplay() + return err + } + } + oldTables := c.subTables.Load() + newMap, subs, exists := removeMapSub(getMap(oldTables), normSymbol) + if !exists { + c.subsMu.Unlock() + c.releaseSubscriptionReplay() + return nil + } + + newTables := oldTables.clone() + setMap(newTables, newMap) + c.subTables.Store(newTables) + + needWireUnsubscribe := true + if isContract { + if len(newTables.contractSubs) > 0 { + needWireUnsubscribe = false + } else { + delete(c.activeFeeds, "contractStatus") + } + } else { + delete(c.activeFeeds, feedKey) + if feedPrefix == "depth" { + delete(c.snapshotPending, normSymbol) + c.subscriptionGeneration.Add(1) + } + } + + c.subsMu.Unlock() + c.releaseSubscriptionReplay() + closeSubscriptions(subs) + + if needWireUnsubscribe && c.State() == StateConnected { + req := RequestFrame{ + ID: globalReqID.Add(1), + Method: "UNSUBSCRIBE", + Params: []string{stream}, + } + _, err := c.requestConnected(ctx, req.Method, req.Params) + return err + } + return nil +} + +func validateActivePublicFeedVariantLocked(c *Client, feedPrefix, symbol, requestedKey, requestedStream string) error { + prefix := fmt.Sprintf("%s:%s@", feedPrefix, symbol) + for activeKey := range c.activeFeeds { + if strings.HasPrefix(activeKey, prefix) && activeKey != requestedKey { + return fmt.Errorf("%w: active %q, requested %q", ErrSubscriptionVariantMismatch, activeKey, requestedStream) + } + } + return nil +} + +func subscribePrivateFeed[T any]( + ctx context.Context, + c *Client, + feedName string, + chBuf int, + getMap func(*subTables) map[string][]*subscription[T], + setMap func(*subTables, map[string][]*subscription[T]), +) (<-chan *T, error) { + if c.auth == nil { + return nil, ErrAuthenticationRequired + } + + if err := c.Connect(ctx); err != nil { + return nil, err + } + + sub := newSubscription[T](chBuf) + req := RequestFrame{ + ID: globalReqID.Add(1), + Method: "SUBSCRIBE", + Params: []string{feedName}, + } + + if err := c.acquireSubscriptionWire(ctx, feedName); err != nil { + return nil, err + } + defer c.releaseSubscriptionWire(feedName) + if err := c.acquireSubscriptionReplay(ctx); err != nil { + return nil, err + } + + c.subsMu.Lock() + oldTables := c.subTables.Load() + oldMap := getMap(oldTables) + needWireSubscribe := len(oldMap[feedName]) == 0 + newTables := oldTables.clone() + newMap := cloneMapSlice(oldMap) + newMap[feedName] = appendSubscription(oldMap[feedName], sub) + setMap(newTables, newMap) + c.subTables.Store(newTables) + if needWireSubscribe { + c.activeFeeds[feedName] = req + } + c.subsMu.Unlock() + c.releaseSubscriptionReplay() + + if needWireSubscribe { + if _, err := c.requestConnected(ctx, req.Method, req.Params); err != nil { + removePrivateSubscription(c, feedName, sub, getMap, setMap) + return nil, fmt.Errorf("gemini websocket: subscribe send failed: %w", err) + } + } + + return sub.ch, nil +} + +func unsubscribePrivateFeed[T any]( + ctx context.Context, + c *Client, + feedName string, + getMap func(*subTables) map[string][]*subscription[T], + setMap func(*subTables, map[string][]*subscription[T]), +) error { + if err := c.acquireSubscriptionWire(ctx, feedName); err != nil { + return err + } + defer c.releaseSubscriptionWire(feedName) + if err := c.acquireSubscriptionReplay(ctx); err != nil { + return err + } + + c.subsMu.Lock() + oldTables := c.subTables.Load() + oldMap := getMap(oldTables) + subs := oldMap[feedName] + if len(subs) == 0 { + delete(c.activeFeeds, feedName) + c.subsMu.Unlock() + c.releaseSubscriptionReplay() + return nil + } + + newTables := oldTables.clone() + newMap := cloneMapSlice(oldMap) + delete(newMap, feedName) + setMap(newTables, newMap) + c.subTables.Store(newTables) + delete(c.activeFeeds, feedName) + c.subsMu.Unlock() + c.releaseSubscriptionReplay() + closeSubscriptions(subs) + + if c.State() == StateConnected { + var reqCtx context.Context + if ctx != nil { + reqCtx = ctx + } else { + var cancel context.CancelFunc + reqCtx, cancel = context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + } + req := RequestFrame{ + ID: globalReqID.Add(1), + Method: "UNSUBSCRIBE", + Params: []string{feedName}, + } + _, err := c.requestConnected(reqCtx, req.Method, req.Params) + return err + } + return nil +} + +func unsubscribePrivateFeedChannel[T any]( + ctx context.Context, + c *Client, + feedName string, + target <-chan *T, + getMap func(*subTables) map[string][]*subscription[T], + setMap func(*subTables, map[string][]*subscription[T]), +) error { + if target == nil { + return nil + } + if err := c.acquireSubscriptionWire(ctx, feedName); err != nil { + return err + } + defer c.releaseSubscriptionWire(feedName) + if err := c.acquireSubscriptionReplay(ctx); err != nil { + return err + } + + c.subsMu.Lock() + oldTables := c.subTables.Load() + oldMap := getMap(oldTables) + subs := oldMap[feedName] + targetIndex := -1 + for i, sub := range subs { + if sub.ch == target { + targetIndex = i + break + } + } + if targetIndex < 0 { + c.subsMu.Unlock() + c.releaseSubscriptionReplay() + return nil + } + + remaining := make([]*subscription[T], 0, len(subs)-1) + remaining = append(remaining, subs[:targetIndex]...) + remaining = append(remaining, subs[targetIndex+1:]...) + newTables := oldTables.clone() + newMap := cloneMapSlice(oldMap) + if len(remaining) == 0 { + delete(newMap, feedName) + } else { + newMap[feedName] = remaining + } + setMap(newTables, newMap) + c.subTables.Store(newTables) + + needWireUnsubscribe := len(remaining) == 0 + if needWireUnsubscribe { + delete(c.activeFeeds, feedName) + } + sub := subs[targetIndex] + + if needWireUnsubscribe && c.State() == StateConnected { + var reqCtx context.Context + if ctx != nil { + reqCtx = ctx + } else { + var cancel context.CancelFunc + reqCtx, cancel = context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + } + req := RequestFrame{ + ID: globalReqID.Add(1), + Method: "UNSUBSCRIBE", + Params: []string{feedName}, + } + c.subsMu.Unlock() + c.releaseSubscriptionReplay() + sub.close() + _, err := c.requestConnected(reqCtx, req.Method, req.Params) + return err + } + c.subsMu.Unlock() + c.releaseSubscriptionReplay() + sub.close() + return nil +} + +func removePublicSubscription[T any]( + c *Client, + feedKey, symbol string, + target *subscription[T], + feedPrefix string, + getMap func(*subTables) map[string][]*subscription[T], + setMap func(*subTables, map[string][]*subscription[T]), +) { + c.subsMu.Lock() + oldTables := c.subTables.Load() + current := getMap(oldTables) + list := current[symbol] + remaining := make([]*subscription[T], 0, len(list)) + found := false + for _, sub := range list { + if sub == target { + found = true + continue + } + remaining = append(remaining, sub) + } + if found { + newTables := oldTables.clone() + newMap := cloneMapSlice(current) + if len(remaining) == 0 { + delete(newMap, symbol) + } else { + newMap[symbol] = remaining + } + setMap(newTables, newMap) + c.subTables.Store(newTables) + if len(remaining) == 0 { + delete(c.activeFeeds, feedKey) + if feedPrefix == "depth" { + delete(c.snapshotPending, symbol) + } + } + } + c.subsMu.Unlock() + target.close() +} + +func removePrivateSubscription[T any]( + c *Client, + feedName string, + target *subscription[T], + getMap func(*subTables) map[string][]*subscription[T], + setMap func(*subTables, map[string][]*subscription[T]), +) { + c.subsMu.Lock() + oldTables := c.subTables.Load() + oldMap := getMap(oldTables) + current := oldMap[feedName] + remaining := make([]*subscription[T], 0, len(current)) + found := false + for _, sub := range current { + if sub == target { + found = true + continue + } + remaining = append(remaining, sub) + } + if found { + newTables := oldTables.clone() + newMap := cloneMapSlice(oldMap) + if len(remaining) == 0 { + delete(newMap, feedName) + } else { + newMap[feedName] = remaining + } + setMap(newTables, newMap) + c.subTables.Store(newTables) + if len(remaining) == 0 { + delete(c.activeFeeds, feedName) + } + } + c.subsMu.Unlock() + target.close() +} + +func (c *Client) markSnapshotPendingLocked(frame RequestFrame) { + if !c.snapshotMode || len(frame.Params) == 0 { + return + } + stream := frame.Params[0] + symbol, ok := differentialDepthSymbol(stream) + if !ok { + return + } + c.snapshotPending[strings.ToUpper(symbol)] = c.subscriptionGeneration.Add(1) +} + +func differentialDepthSymbol(stream string) (string, bool) { + for _, suffix := range []string{"@depth@100ms", "@depth"} { + if strings.HasSuffix(stream, suffix) { + symbol := strings.TrimSuffix(stream, suffix) + if symbol != "" { + return symbol, true + } + } + } + return "", false +} + +func (c *Client) consumeSnapshotPending(symbol string, frameGeneration uint64) bool { + c.subsMu.Lock() + defer c.subsMu.Unlock() + pendingGeneration, ok := c.snapshotPending[strings.ToUpper(symbol)] + if !ok || frameGeneration < pendingGeneration { + return false + } + delete(c.snapshotPending, strings.ToUpper(symbol)) + return true +} + +// SubscribeDepth creates a typed channel receiving incremental order book depth updates. +func (c *Client) SubscribeDepth(ctx context.Context, symbol string) (<-chan *DepthUpdate, error) { + return c.SubscribeDepthWithOptions(ctx, symbol, DepthSubscriptionOptions{}) +} + +// SubscribeDepthWithOptions creates a typed differential depth stream with +// either the default cadence or the exchange's accelerated 100ms cadence. +func (c *Client) SubscribeDepthWithOptions(ctx context.Context, symbol string, options DepthSubscriptionOptions) (<-chan *DepthUpdate, error) { + suffix, err := depthIntervalSuffix(options.Interval) + if err != nil { + return nil, err + } + if c.usesIsolatedSnapshots() { + return c.subscribeIsolatedDepth(ctx, symbol, suffix) + } + return subscribePublicFeed(ctx, c, symbol, "depth", suffix, 256, + func(t *subTables) map[string][]*subscription[DepthUpdate] { return t.depthSubs }, + func(t *subTables, m map[string][]*subscription[DepthUpdate]) { t.depthSubs = m }, + ) +} + +// SubscribePartialDepth creates a typed channel receiving top-of-book +// snapshots for 5, 10, or 20 levels. The shared Go WebSocket client permits one +// partial-depth variant per symbol because the exchange snapshot payload does +// not identify the subscribed level; a second subscriber to the same variant +// is multiplexed normally. +func (c *Client) SubscribePartialDepth(ctx context.Context, symbol string, options PartialDepthSubscriptionOptions) (<-chan *OrderBookSnapshot, error) { + if c.usesIsolatedPartialSnapshots() { + return c.subscribeIsolatedPartialDepth(ctx, symbol, options) + } + suffix, err := partialDepthStreamSuffix(options) + if err != nil { + return nil, err + } + return subscribePublicFeed(ctx, c, symbol, "partialDepth", suffix, 256, + func(t *subTables) map[string][]*subscription[OrderBookSnapshot] { return t.partialDepthSubs }, + func(t *subTables, m map[string][]*subscription[OrderBookSnapshot]) { t.partialDepthSubs = m }, + ) +} + +// SubscribeTrades creates a typed channel receiving trade stream execution updates. +func (c *Client) SubscribeTrades(ctx context.Context, symbol string) (<-chan *TradeEvent, error) { + return subscribePublicFeed(ctx, c, symbol, "trade", "trade", 256, + func(t *subTables) map[string][]*subscription[TradeEvent] { return t.tradeSubs }, + func(t *subTables, m map[string][]*subscription[TradeEvent]) { t.tradeSubs = m }, + ) +} + +// SubscribeBookTicker creates a typed channel receiving best bid and offer updates. +func (c *Client) SubscribeBookTicker(ctx context.Context, symbol string) (<-chan *BookTicker, error) { + return subscribePublicFeed(ctx, c, symbol, "bookTicker", "bookTicker", 256, + func(t *subTables) map[string][]*subscription[BookTicker] { return t.tickerSubs }, + func(t *subTables, m map[string][]*subscription[BookTicker]) { t.tickerSubs = m }, + ) +} + +// SubscribeContractStatus creates a typed channel receiving prediction market contract status updates. +func (c *Client) SubscribeContractStatus(ctx context.Context, symbol string) (<-chan *ContractStatusEvent, error) { + return subscribePublicFeed(ctx, c, symbol, "contractStatus", "contractStatus", 128, + func(t *subTables) map[string][]*subscription[ContractStatusEvent] { return t.contractSubs }, + func(t *subTables, m map[string][]*subscription[ContractStatusEvent]) { t.contractSubs = m }, + ) +} + +// UnsubscribeDepth closes and removes depth subscription channels for a symbol. +func (c *Client) UnsubscribeDepth(ctx context.Context, symbol string) error { + return c.UnsubscribeDepthWithOptions(ctx, symbol, DepthSubscriptionOptions{}) +} + +// UnsubscribeDepthWithOptions removes a differential depth subscription for +// the selected cadence. +func (c *Client) UnsubscribeDepthWithOptions(ctx context.Context, symbol string, options DepthSubscriptionOptions) error { + suffix, err := depthIntervalSuffix(options.Interval) + if err != nil { + return err + } + if c.usesIsolatedSnapshots() { + return c.unsubscribeIsolatedDepth(ctx, symbol, suffix) + } + return unsubscribePublicFeed(ctx, c, symbol, "depth", suffix, + func(t *subTables) map[string][]*subscription[DepthUpdate] { return t.depthSubs }, + func(t *subTables, m map[string][]*subscription[DepthUpdate]) { t.depthSubs = m }, + ) +} + +// UnsubscribePartialDepth removes the selected partial-depth stream for a +// symbol and closes all local subscribers to that stream. +func (c *Client) UnsubscribePartialDepth(ctx context.Context, symbol string, options PartialDepthSubscriptionOptions) error { + if c.usesIsolatedPartialSnapshots() { + return c.unsubscribeIsolatedPartialDepth(ctx, symbol, options) + } + suffix, err := partialDepthStreamSuffix(options) + if err != nil { + return err + } + return unsubscribePublicFeed(ctx, c, symbol, "partialDepth", suffix, + func(t *subTables) map[string][]*subscription[OrderBookSnapshot] { return t.partialDepthSubs }, + func(t *subTables, m map[string][]*subscription[OrderBookSnapshot]) { t.partialDepthSubs = m }, + ) +} + +// UnsubscribeTrades closes and removes trade subscription channels for a symbol. +func (c *Client) UnsubscribeTrades(ctx context.Context, symbol string) error { + return unsubscribePublicFeed(ctx, c, symbol, "trade", "trade", + func(t *subTables) map[string][]*subscription[TradeEvent] { return t.tradeSubs }, + func(t *subTables, m map[string][]*subscription[TradeEvent]) { t.tradeSubs = m }, + ) +} + +// UnsubscribeBookTicker closes and removes book ticker subscription channels for a symbol. +func (c *Client) UnsubscribeBookTicker(ctx context.Context, symbol string) error { + return unsubscribePublicFeed(ctx, c, symbol, "bookTicker", "bookTicker", + func(t *subTables) map[string][]*subscription[BookTicker] { return t.tickerSubs }, + func(t *subTables, m map[string][]*subscription[BookTicker]) { t.tickerSubs = m }, + ) +} + +// UnsubscribeContractStatus closes and removes contract status subscription channels for a symbol. +func (c *Client) UnsubscribeContractStatus(ctx context.Context, symbol string) error { + return unsubscribePublicFeed(ctx, c, symbol, "contractStatus", "contractStatus", + func(t *subTables) map[string][]*subscription[ContractStatusEvent] { return t.contractSubs }, + func(t *subTables, m map[string][]*subscription[ContractStatusEvent]) { t.contractSubs = m }, + ) +} + +// SubscribeOrderEvents creates a typed channel receiving authenticated order lifecycle events. +// Requires authentication configured on the WebSocket client. +func (c *Client) SubscribeOrderEvents(ctx context.Context) (<-chan *OrderEvent, error) { + return c.SubscribeOrderEventsWithScope(ctx, ScopeAccount) +} + +// SubscribeOrderEventsWithScope creates a typed order stream for the account +// or authenticated WebSocket session. +func (c *Client) SubscribeOrderEventsWithScope(ctx context.Context, scope SubscriptionScope) (<-chan *OrderEvent, error) { + if scope != ScopeAccount && scope != ScopeSession { + return nil, fmt.Errorf("gemini websocket: order stream scope must be account or session") + } + return subscribePrivateFeed(ctx, c, "orders@"+string(scope), 512, + func(t *subTables) map[string][]*subscription[OrderEvent] { return t.orderSubs }, + func(t *subTables, m map[string][]*subscription[OrderEvent]) { t.orderSubs = m }, + ) +} + +// UnsubscribeOrderEvents closes and removes all active order event subscription channels. +func (c *Client) UnsubscribeOrderEvents(ctx context.Context) error { + return c.UnsubscribeOrderEventsWithScope(ctx, ScopeAccount) +} + +// UnsubscribeOrderEventsWithScope closes and removes all order event channels +// for the selected account or session scope. +func (c *Client) UnsubscribeOrderEventsWithScope(ctx context.Context, scope SubscriptionScope) error { + if scope != ScopeAccount && scope != ScopeSession { + return fmt.Errorf("gemini websocket: order stream scope must be account or session") + } + return unsubscribePrivateFeed(ctx, c, "orders@"+string(scope), + func(t *subTables) map[string][]*subscription[OrderEvent] { return t.orderSubs }, + func(t *subTables, m map[string][]*subscription[OrderEvent]) { t.orderSubs = m }, + ) +} + +// UnsubscribeOrderEventsChannel closes and removes only the specified order +// event subscription channel. It leaves other order event subscribers active. +// If the channel was already removed by a disconnect or global unsubscribe, +// this method is a no-op. +func (c *Client) UnsubscribeOrderEventsChannel(ctx context.Context, ch <-chan *OrderEvent) error { + return c.UnsubscribeOrderEventsChannelWithScope(ctx, ScopeAccount, ch) +} + +// UnsubscribeOrderEventsChannelWithScope removes only the specified order +// event channel from the selected account or session scope. +func (c *Client) UnsubscribeOrderEventsChannelWithScope(ctx context.Context, scope SubscriptionScope, ch <-chan *OrderEvent) error { + if scope != ScopeAccount && scope != ScopeSession { + return fmt.Errorf("gemini websocket: order stream scope must be account or session") + } + return unsubscribePrivateFeedChannel(ctx, c, "orders@"+string(scope), ch, + func(t *subTables) map[string][]*subscription[OrderEvent] { return t.orderSubs }, + func(t *subTables, m map[string][]*subscription[OrderEvent]) { t.orderSubs = m }, + ) +} + +// SubscribeBalances creates a typed channel receiving authenticated balance update events. +// Requires authentication configured on the WebSocket client. +func (c *Client) SubscribeBalances(ctx context.Context) (<-chan *BalanceUpdate, error) { + return c.SubscribeBalancesWithOptions(ctx, AccountStreamOptions{}) +} + +// SubscribeBalancesWithOptions creates an authenticated balance stream with +// either live updates or one-second snapshots. +func (c *Client) SubscribeBalancesWithOptions(ctx context.Context, options AccountStreamOptions) (<-chan *BalanceUpdate, error) { + feedName, err := accountStreamName("balances", options) + if err != nil { + return nil, err + } + return subscribePrivateFeed(ctx, c, feedName, 256, + func(t *subTables) map[string][]*subscription[BalanceUpdate] { return t.balanceSubs }, + func(t *subTables, m map[string][]*subscription[BalanceUpdate]) { t.balanceSubs = m }, + ) +} + +// UnsubscribeBalances closes and removes all active balance event subscription channels. +func (c *Client) UnsubscribeBalances(ctx context.Context) error { + return c.UnsubscribeBalancesWithOptions(ctx, AccountStreamOptions{}) +} + +// UnsubscribeBalancesWithOptions removes a balance stream with the selected +// cadence. +func (c *Client) UnsubscribeBalancesWithOptions(ctx context.Context, options AccountStreamOptions) error { + feedName, err := accountStreamName("balances", options) + if err != nil { + return err + } + return unsubscribePrivateFeed(ctx, c, feedName, + func(t *subTables) map[string][]*subscription[BalanceUpdate] { return t.balanceSubs }, + func(t *subTables, m map[string][]*subscription[BalanceUpdate]) { t.balanceSubs = m }, + ) +} + +// SubscribePositions creates a typed channel receiving authenticated position reports. +// Requires authentication configured on the WebSocket client. +func (c *Client) SubscribePositions(ctx context.Context) (<-chan *PositionReport, error) { + return c.SubscribePositionsWithOptions(ctx, AccountStreamOptions{}) +} + +// SubscribePositionsWithOptions creates an authenticated position stream with +// either live updates or one-second snapshots. +func (c *Client) SubscribePositionsWithOptions(ctx context.Context, options AccountStreamOptions) (<-chan *PositionReport, error) { + feedName, err := accountStreamName("positions", options) + if err != nil { + return nil, err + } + return subscribePrivateFeed(ctx, c, feedName, 256, + func(t *subTables) map[string][]*subscription[PositionReport] { return t.positionSubs }, + func(t *subTables, m map[string][]*subscription[PositionReport]) { t.positionSubs = m }, + ) +} + +// UnsubscribePositions closes and removes all active position report subscription channels. +func (c *Client) UnsubscribePositions(ctx context.Context) error { + return c.UnsubscribePositionsWithOptions(ctx, AccountStreamOptions{}) +} + +// UnsubscribePositionsWithOptions removes a position stream with the selected +// cadence. +func (c *Client) UnsubscribePositionsWithOptions(ctx context.Context, options AccountStreamOptions) error { + feedName, err := accountStreamName("positions", options) + if err != nil { + return err + } + return unsubscribePrivateFeed(ctx, c, feedName, + func(t *subTables) map[string][]*subscription[PositionReport] { return t.positionSubs }, + func(t *subTables, m map[string][]*subscription[PositionReport]) { t.positionSubs = m }, + ) +} + +// SubscribeSettlements creates a typed channel receiving authenticated contract settlement updates. +// Requires authentication configured on the WebSocket client. +func (c *Client) SubscribeSettlements(ctx context.Context) (<-chan *SettlementUpdate, error) { + return subscribePrivateFeed(ctx, c, "settlements@account", 128, + func(t *subTables) map[string][]*subscription[SettlementUpdate] { return t.settleSubs }, + func(t *subTables, m map[string][]*subscription[SettlementUpdate]) { t.settleSubs = m }, + ) +} + +// UnsubscribeSettlements closes and removes all active settlement update subscription channels. +func (c *Client) UnsubscribeSettlements(ctx context.Context) error { + return unsubscribePrivateFeed(ctx, c, "settlements@account", + func(t *subTables) map[string][]*subscription[SettlementUpdate] { return t.settleSubs }, + func(t *subTables, m map[string][]*subscription[SettlementUpdate]) { t.settleSubs = m }, + ) +} diff --git a/packages/sdk-go/websocket/client_test.go b/packages/sdk-go/websocket/client_test.go new file mode 100644 index 00000000..74a4d013 --- /dev/null +++ b/packages/sdk-go/websocket/client_test.go @@ -0,0 +1,1176 @@ +package websocket_test + +import ( + "context" + "errors" + "fmt" + "net/http" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/gemini/gemini-go/auth" + "github.com/gemini/gemini-go/websocket" +) + +func TestClient_ServerDrainAndAutoReconnect(t *testing.T) { + dialer := &mockDrainDialer{} + client := websocket.NewClient( + "wss://ws.gemini.com", + websocket.WithDialer(dialer), + websocket.WithAutoReconnect(true), + ) + defer client.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // 1. Subscribe to depth + depthChan, err := client.SubscribeDepth(ctx, "BTCUSD") + if err != nil { + t.Fatalf("failed subscribing to depth: %v", err) + } + + if client.State() != websocket.StateConnected { + t.Fatalf("expected StateConnected, got %v", client.State()) + } + + // Send an initial depth update + conn1 := dialer.latestConn() + conn1.readChan <- []byte(`{"e":"depthUpdate","s":"BTCUSD","u":100,"U":95,"b":[["65000.00","1.0"]],"a":[["65001.00","2.0"]]}`) + + select { + case update := <-depthChan: + if update.LastUpdateID != 100 { + t.Fatalf("expected update 100, got %d", update.LastUpdateID) + } + case <-time.After(1 * time.Second): + t.Fatalf("timed out waiting for initial depth update") + } + + // 2. Simulate server deploy drain by closing the underlying connection + _ = conn1.Close() + + // Wait for automatic reconnect to establish new connection + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + if dialer.connCount() >= 2 && client.State() == websocket.StateConnected { + break + } + time.Sleep(20 * time.Millisecond) + } + + if dialer.connCount() < 2 { + t.Fatalf("expected client to dial a second connection upon server drain, total dials=%d", dialer.connCount()) + } + + if client.State() != websocket.StateConnected { + t.Fatalf("expected client state to be StateConnected after reconnect, got %v", client.State()) + } + + // Verify that active subscriptions continue receiving on the original channel! + conn2 := dialer.latestConn() + conn2.readChan <- []byte(`{"e":"depthUpdate","s":"BTCUSD","u":105,"U":101,"b":[["65002.00","1.5"]],"a":[["65003.00","2.5"]]}`) + + select { + case update := <-depthChan: + if update.LastUpdateID != 105 { + t.Fatalf("expected post-reconnect update 105, got %d", update.LastUpdateID) + } + case <-time.After(1 * time.Second): + t.Fatalf("timed out waiting for post-reconnect update on existing channel") + } +} + +func TestClient_SnapshotModeMarksInitialDepthFrame(t *testing.T) { + dialer := &mockDrainDialer{} + client := websocket.NewPublicClient( + "wss://ws.gemini.com/marketdata?foo=bar", + websocket.WithDialer(dialer), + websocket.WithSnapshot(-1), + ) + defer client.Close() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + depth, err := client.SubscribeDepth(ctx, "BTCUSD") + if err != nil { + t.Fatalf("failed subscribing to depth: %v", err) + } + if got := dialer.lastURL(); got != "wss://ws.gemini.com/marketdata?foo=bar&snapshot=-1" { + t.Fatalf("unexpected snapshot URL: %s", got) + } + + dialer.latestConn().feedServerMsg([]byte(`{"e":"depthUpdate","s":"BTCUSD","U":100,"u":100,"b":[["65000","1"]],"a":[]}`)) + select { + case update := <-depth: + if !update.Snapshot { + t.Fatal("expected first depth frame to be marked as a snapshot") + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for depth update") + } +} + +func TestClient_WebSocketAuthSharesRequestSequencer(t *testing.T) { + dialer := &mockDrainDialer{} + strategy := auth.NewHMAC("key", "secret") + client := websocket.NewPrivateClient("wss://ws.gemini.com", strategy, websocket.WithDialer(dialer)) + defer client.Close() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + release, err := strategy.AcquireRequest(ctx) + if err != nil { + t.Fatalf("failed acquiring request gate: %v", err) + } + connected := make(chan error, 1) + go func() { connected <- client.Connect(ctx) }() + + select { + case err := <-connected: + t.Fatalf("WebSocket connected while request gate was held: %v", err) + case <-time.After(50 * time.Millisecond): + if dialer.connCount() != 0 { + t.Fatal("WebSocket dial started while request gate was held") + } + } + release() + if err := <-connected; err != nil { + t.Fatalf("WebSocket failed after request gate release: %v", err) + } +} + +func TestClient_BearerAuthRefreshesOnReconnect(t *testing.T) { + dialer := &mockDrainDialer{} + currentToken := "oauth-token-1" + strategy := auth.NewBearerWithSource(auth.TokenFunc(func(context.Context) (string, error) { + return currentToken, nil + })) + client := websocket.NewPrivateClient("wss://ws.gemini.com", strategy, + websocket.WithDialer(dialer), + websocket.WithMaxReconnects(2), + ) + defer client.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := client.Connect(ctx); err != nil { + t.Fatalf("initial bearer WebSocket connection failed: %v", err) + } + if got := dialer.latestHeaders().Get("Authorization"); got != "Bearer oauth-token-1" { + t.Fatalf("initial WebSocket Authorization = %q, want Bearer oauth-token-1", got) + } + + currentToken = "oauth-token-2" + if err := dialer.latestConn().Close(); err != nil { + t.Fatalf("closing initial connection: %v", err) + } + + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + if dialer.connCount() >= 2 && client.State() == websocket.StateConnected { + break + } + time.Sleep(20 * time.Millisecond) + } + if dialer.connCount() < 2 { + t.Fatalf("expected a reconnect, total dials=%d", dialer.connCount()) + } + if got := dialer.latestHeaders().Get("Authorization"); got != "Bearer oauth-token-2" { + t.Fatalf("reconnect WebSocket Authorization = %q, want Bearer oauth-token-2", got) + } +} + +func TestClient_BearerReconnectStopsWhenTokenRefreshFails(t *testing.T) { + dialer := &mockDrainDialer{} + refreshErr := errors.New("oauth token refresh failed") + var sourceCalls atomic.Int32 + strategy := auth.NewBearerWithSource(auth.TokenFunc(func(context.Context) (string, error) { + if sourceCalls.Add(1) > 1 { + return "", refreshErr + } + return "oauth-token", nil + })) + client := websocket.NewPrivateClient( + "wss://ws.gemini.com", + strategy, + websocket.WithDialer(dialer), + websocket.WithMaxReconnects(1), + ) + defer client.Close() + events, stopEvents := client.SubscribeConnectionEvents(8) + defer stopEvents() + + if err := client.Connect(context.Background()); err != nil { + t.Fatalf("initial bearer WebSocket connection failed: %v", err) + } + if err := dialer.latestConn().Close(); err != nil { + t.Fatalf("closing initial connection: %v", err) + } + + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + if client.State() == websocket.StateDisconnected && sourceCalls.Load() >= 2 { + break + } + time.Sleep(20 * time.Millisecond) + } + if got := sourceCalls.Load(); got < 2 { + t.Fatalf("token source calls after reconnect failure = %d, want at least 2", got) + } + if got := dialer.connCount(); got != 1 { + t.Fatalf("refresh failure opened %d replacement connections, want 0", got-1) + } + if got := client.State(); got != websocket.StateDisconnected { + t.Fatalf("client state after failed token refresh = %v, want disconnected", got) + } + seenCause := false + deadline = time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + select { + case event := <-events: + if errors.Is(event.Err, refreshErr) { + seenCause = true + } + case <-time.After(20 * time.Millisecond): + } + if seenCause { + break + } + } + if !seenCause { + t.Fatal("reconnect event did not retain token refresh error") + } +} + +func TestPrivateClient_RejectsConnectionWithoutAuthentication(t *testing.T) { + dialer := &mockDrainDialer{} + client := websocket.NewPrivateClient("wss://ws.gemini.com", nil, websocket.WithDialer(dialer)) + defer client.Close() + + err := client.Connect(context.Background()) + if !errors.Is(err, websocket.ErrAuthenticationRequired) { + t.Fatalf("expected ErrAuthenticationRequired, got %v", err) + } + if dialer.connCount() != 0 { + t.Fatalf("expected no private connection attempt without authentication, got %d", dialer.connCount()) + } +} + +func TestClient_MultiplexedFeeds(t *testing.T) { + dialer := &mockDrainDialer{} + client := websocket.NewClient( + "wss://ws.gemini.com", + websocket.WithDialer(dialer), + ) + defer client.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // 1. Subscribe to BTCUSD Depth and ETHUSD Trades concurrently + depthBTC, err := client.SubscribeDepth(ctx, "BTCUSD") + if err != nil { + t.Fatalf("failed subscribing to BTC depth: %v", err) + } + + tradesETH, err := client.SubscribeTrades(ctx, "ETHUSD") + if err != nil { + t.Fatalf("failed subscribing to ETH trades: %v", err) + } + + tickerBTC, err := client.SubscribeBookTicker(ctx, "BTCUSD") + if err != nil { + t.Fatalf("failed subscribing to BTC ticker: %v", err) + } + + conn := dialer.latestConn() + + // Feed in a BTC depth update + conn.readChan <- []byte(`{"e":"depthUpdate","s":"BTCUSD","u":200,"U":190,"b":[["65000.00","1.0"]],"a":[["65001.00","2.0"]]}`) + + // Feed in an ETH trade + conn.readChan <- []byte(`{"e":"trade","s":"ETHUSD","t":9999,"p":"3500.00","q":"2.5","b":123,"a":456,"T":1710000000000,"m":true}`) + + // Feed in a BTC book ticker + conn.readChan <- []byte(`{"e":"bookTicker","s":"BTCUSD","b":"65000.50","B":"4.0","a":"65001.50","A":"5.0"}`) + + // Verify depth received on depthBTC + select { + case d := <-depthBTC: + if d.Symbol != "BTCUSD" || d.LastUpdateID != 200 { + t.Fatalf("unexpected depth data: %+v", d) + } + case <-time.After(1 * time.Second): + t.Fatal("timed out waiting for BTC depth") + } + + // Verify trade received on tradesETH + select { + case tr := <-tradesETH: + if tr.Symbol != "ETHUSD" || tr.Price != "3500.00" || tr.Quantity != "2.5" { + t.Fatalf("unexpected trade data: %+v", tr) + } + case <-time.After(1 * time.Second): + t.Fatal("timed out waiting for ETH trade") + } + + // Verify ticker received on tickerBTC + select { + case tk := <-tickerBTC: + if tk.Symbol != "BTCUSD" || tk.BidPrice != "65000.50" { + t.Fatalf("unexpected ticker data: %+v", tk) + } + case <-time.After(1 * time.Second): + t.Fatal("timed out waiting for BTC ticker") + } +} + +func TestClient_Unsubscribe(t *testing.T) { + dialer := &mockDrainDialer{} + client := websocket.NewClient( + "wss://ws.gemini.com", + websocket.WithDialer(dialer), + ) + defer client.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + depthBTC, err := client.SubscribeDepth(ctx, "BTCUSD") + if err != nil { + t.Fatalf("failed subscribing to depth: %v", err) + } + + tradesETH, err := client.SubscribeTrades(ctx, "ETHUSD") + if err != nil { + t.Fatalf("failed subscribing to trades: %v", err) + } + + tickerBTC, err := client.SubscribeBookTicker(ctx, "BTCUSD") + if err != nil { + t.Fatalf("failed subscribing to ticker: %v", err) + } + + // 1. Unsubscribe depth + if err := client.UnsubscribeDepth(ctx, "BTCUSD"); err != nil { + t.Fatalf("failed unsubscribing depth: %v", err) + } + + select { + case _, ok := <-depthBTC: + if ok { + t.Fatal("expected depth channel to be closed after UnsubscribeDepth") + } + case <-time.After(1 * time.Second): + t.Fatal("timed out waiting for depth channel close") + } + + // 2. Unsubscribe trades + if err := client.UnsubscribeTrades(ctx, "ETHUSD"); err != nil { + t.Fatalf("failed unsubscribing trades: %v", err) + } + + select { + case _, ok := <-tradesETH: + if ok { + t.Fatal("expected trades channel to be closed after UnsubscribeTrades") + } + case <-time.After(1 * time.Second): + t.Fatal("timed out waiting for trades channel close") + } + + // 3. Unsubscribe ticker + if err := client.UnsubscribeBookTicker(ctx, "BTCUSD"); err != nil { + t.Fatalf("failed unsubscribing ticker: %v", err) + } + + select { + case _, ok := <-tickerBTC: + if ok { + t.Fatal("expected ticker channel to be closed after UnsubscribeBookTicker") + } + case <-time.After(1 * time.Second): + t.Fatal("timed out waiting for ticker channel close") + } + + // 4. Idempotent unsubscribe on already unsubscribed / non-existent stream + if err := client.UnsubscribeDepth(ctx, "SOLUSD"); err != nil { + t.Fatalf("expected nil error on unregistering non-existent stream, got: %v", err) + } +} + +func TestClient_UnsubscribeDepthRejectsVariantMismatch(t *testing.T) { + dialer := &mockDrainDialer{} + client := websocket.NewClient("wss://ws.gemini.com", websocket.WithDialer(dialer)) + defer client.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + depth, err := client.SubscribeDepthWithOptions(ctx, "BTCUSD", websocket.DepthSubscriptionOptions{Interval: 100 * time.Millisecond}) + if err != nil { + t.Fatalf("failed subscribing to accelerated depth: %v", err) + } + if err := client.UnsubscribeDepth(ctx, "BTCUSD"); !errors.Is(err, websocket.ErrSubscriptionVariantMismatch) { + t.Fatalf("expected variant mismatch, got %v", err) + } + select { + case _, ok := <-depth: + if !ok { + t.Fatal("variant-mismatched unsubscribe closed the active channel") + } + default: + } + if err := client.UnsubscribeDepthWithOptions(ctx, "BTCUSD", websocket.DepthSubscriptionOptions{Interval: 100 * time.Millisecond}); err != nil { + t.Fatalf("failed unsubscribing accelerated depth: %v", err) + } +} + +func TestClient_UnsubscribePartialDepthRejectsVariantMismatch(t *testing.T) { + dialer := &mockDrainDialer{} + client := websocket.NewClient("wss://ws.gemini.com", websocket.WithDialer(dialer)) + defer client.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + partial, err := client.SubscribePartialDepth(ctx, "BTCUSD", websocket.PartialDepthSubscriptionOptions{Levels: websocket.DepthLevel20, Interval: 100 * time.Millisecond}) + if err != nil { + t.Fatalf("failed subscribing to accelerated partial depth: %v", err) + } + if err := client.UnsubscribePartialDepth(ctx, "BTCUSD", websocket.PartialDepthSubscriptionOptions{}); !errors.Is(err, websocket.ErrSubscriptionVariantMismatch) { + t.Fatalf("expected partial-depth variant mismatch, got %v", err) + } + select { + case _, ok := <-partial: + if !ok { + t.Fatal("variant-mismatched partial unsubscribe closed the active channel") + } + default: + } + if err := client.UnsubscribePartialDepth(ctx, "BTCUSD", websocket.PartialDepthSubscriptionOptions{Levels: websocket.DepthLevel20, Interval: 100 * time.Millisecond}); err != nil { + t.Fatalf("failed unsubscribing accelerated partial depth: %v", err) + } +} + +func TestClient_IsolatedUnsubscribeDepthRejectsVariantMismatch(t *testing.T) { + dialer := &mockDrainDialer{} + client := websocket.NewClient( + "wss://ws.gemini.com", + websocket.WithDialer(dialer), + websocket.WithSnapshot(-1), + websocket.WithIsolatedSnapshots(), + ) + defer client.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + depth, err := client.SubscribeDepthWithOptions(ctx, "BTCUSD", websocket.DepthSubscriptionOptions{Interval: 100 * time.Millisecond}) + if err != nil { + t.Fatalf("failed subscribing to isolated accelerated depth: %v", err) + } + if err := client.UnsubscribeDepth(ctx, "BTCUSD"); !errors.Is(err, websocket.ErrSubscriptionVariantMismatch) { + t.Fatalf("expected isolated variant mismatch, got %v", err) + } + select { + case _, ok := <-depth: + if !ok { + t.Fatal("isolated variant-mismatched unsubscribe closed the active channel") + } + default: + } + if err := client.UnsubscribeDepthWithOptions(ctx, "BTCUSD", websocket.DepthSubscriptionOptions{Interval: 100 * time.Millisecond}); err != nil { + t.Fatalf("failed unsubscribing isolated accelerated depth: %v", err) + } +} + +func TestClient_SlowConsumerReceivesEveryUpdate(t *testing.T) { + dialer := &mockDrainDialer{} + client := websocket.NewClient( + "wss://ws.gemini.com", + websocket.WithDialer(dialer), + ) + defer client.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Subscribe to depth (internal buffer size is 256) + depthCh, err := client.SubscribeDepth(ctx, "BTCUSD") + if err != nil { + t.Fatalf("failed subscribing to depth: %v", err) + } + + conn := dialer.latestConn() + + // Push 300 updates without reading from depthCh (slow consumer simulation) + done := make(chan struct{}) + go func() { + for i := 1; i <= 300; i++ { + msg := fmt.Sprintf(`{"e":"depthUpdate","s":"BTCUSD","u":%d,"U":%d,"b":[["65000.00","1.0"]]}`, i, i) + conn.feedServerMsg([]byte(msg)) + } + close(done) + }() + + // The reader may apply backpressure, but it must not silently discard data. + select { + case <-done: + // The mock connection has enough input buffering for this burst. + case <-time.After(2 * time.Second): + t.Fatal("input feed did not complete") + } + + for i := 1; i <= 300; i++ { + select { + case update := <-depthCh: + if update == nil || update.LastUpdateID != int64(i) { + t.Fatalf("expected update %d, got %+v", i, update) + } + case <-time.After(2 * time.Second): + t.Fatalf("timed out waiting for update %d", i) + } + } +} + +func TestClient_EventsCloseAfterClientClose(t *testing.T) { + dialer := &mockDrainDialer{} + client := websocket.NewClient("wss://ws.gemini.com", websocket.WithDialer(dialer)) + if err := client.Connect(context.Background()); err != nil { + t.Fatalf("Connect failed: %v", err) + } + if err := client.Close(); err != nil { + t.Fatalf("Close failed: %v", err) + } + done := make(chan struct{}) + go func() { + for range client.Events() { + } + close(done) + }() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("timed out waiting for Events channel to close") + } +} + +func TestClient_RejectsReservedCustomAuthHeaders(t *testing.T) { + dialer := &mockDrainDialer{} + client := websocket.NewClient( + "wss://ws.gemini.com", + websocket.WithDialer(dialer), + websocket.WithAuth(&mockAuthStrategy{key: "real-key", secret: "real-secret"}), + websocket.WithHeaders(http.Header{"X-GEMINI-APIKEY": {"spoofed-key"}}), + ) + defer client.Close() + + if err := client.Connect(context.Background()); err == nil { + t.Fatal("expected reserved authentication header to be rejected") + } +} + +func TestClient_ConcurrentConnectAndEvents(t *testing.T) { + dialer := &mockDrainDialer{} + client := websocket.NewClient( + "wss://ws.gemini.com", + websocket.WithDialer(dialer), + websocket.WithAutoReconnect(true), + ) + defer client.Close() + + // 1. Consume Events channel concurrently (simulating an application listener) + var eventsMu sync.Mutex + var receivedEvents []websocket.ConnectionEvent + eventCtx, eventCancel := context.WithCancel(context.Background()) + defer eventCancel() + + go func() { + for { + select { + case <-eventCtx.Done(): + return + case evt, ok := <-client.Events(): + if !ok { + return + } + eventsMu.Lock() + receivedEvents = append(receivedEvents, evt) + eventsMu.Unlock() + } + } + }() + + // 2. Launch 20 concurrent goroutines calling Connect(ctx) + const numWaiters = 20 + var wg sync.WaitGroup + errs := make([]error, numWaiters) + + for i := 0; i < numWaiters; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + errs[idx] = client.Connect(ctx) + }(i) + } + + wg.Wait() + + for i, err := range errs { + if err != nil { + t.Fatalf("waiter %d failed to connect: %v", i, err) + } + } + + if client.State() != websocket.StateConnected { + t.Fatalf("expected StateConnected, got %v", client.State()) + } + + // 3. Trigger disconnect while concurrent goroutines attempt to subscribe or connect + conn1 := dialer.latestConn() + _ = conn1.Close() + + // Wait for client to notice the disconnect and enter reconnecting + deadline := time.Now().Add(1 * time.Second) + for time.Now().Before(deadline) { + if client.State() == websocket.StateReconnecting { + break + } + time.Sleep(5 * time.Millisecond) + } + + // Launch concurrent subscribers while reconnection is occurring + subErrs := make([]error, numWaiters) + for i := 0; i < numWaiters; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _, subErrs[idx] = client.SubscribeDepth(ctx, fmt.Sprintf("BTCUSD_%d", idx)) + }(i) + } + + wg.Wait() + + for i, err := range subErrs { + if err != nil { + t.Fatalf("subscriber %d failed during reconnect: %v", i, err) + } + } + + // Verify events were delivered to the public event listener without being stolen + eventsMu.Lock() + defer eventsMu.Unlock() + if len(receivedEvents) == 0 { + t.Fatal("expected application listener to receive connection lifecycle events") + } +} + +func TestClient_PublicAndPrivateSeparation(t *testing.T) { + dialer := &mockDrainDialer{} + apiKey := "pub-priv-test-key" + apiSecret := "pub-priv-test-secret" + authStrategy := &mockAuthStrategy{key: apiKey, secret: apiSecret} + + publicClient := websocket.NewPublicClient("wss://ws.gemini.com", websocket.WithDialer(dialer)) + defer publicClient.Close() + + privateClient := websocket.NewPrivateClient("wss://ws.gemini.com", authStrategy, websocket.WithDialer(dialer)) + defer privateClient.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // 1. Calling private feeds on Public (unauthenticated) client returns ErrAuthenticationRequired + if _, err := publicClient.SubscribeOrderEvents(ctx); !errors.Is(err, websocket.ErrAuthenticationRequired) { + t.Fatalf("expected ErrAuthenticationRequired on SubscribeOrderEvents, got %v", err) + } + if _, err := publicClient.SubscribeBalances(ctx); !errors.Is(err, websocket.ErrAuthenticationRequired) { + t.Fatalf("expected ErrAuthenticationRequired on SubscribeBalances, got %v", err) + } + if _, err := publicClient.SubscribePositions(ctx); !errors.Is(err, websocket.ErrAuthenticationRequired) { + t.Fatalf("expected ErrAuthenticationRequired on SubscribePositions, got %v", err) + } + if _, err := publicClient.SubscribeSettlements(ctx); !errors.Is(err, websocket.ErrAuthenticationRequired) { + t.Fatalf("expected ErrAuthenticationRequired on SubscribeSettlements, got %v", err) + } + + // 2. Public Client can subscribe to public feeds + depthCh, err := publicClient.SubscribeDepth(ctx, "BTCUSD") + if err != nil { + t.Fatalf("failed subscribing to depth on public client: %v", err) + } + + contractCh, err := publicClient.SubscribeContractStatus(ctx, "TRUMP-2028-YES") + if err != nil { + t.Fatalf("failed subscribing to contract status on public client: %v", err) + } + + // 3. Private Client can subscribe to private feeds + orderCh, err := privateClient.SubscribeOrderEvents(ctx) + if err != nil { + t.Fatalf("failed subscribing to order events on private client: %v", err) + } + + balanceCh, err := privateClient.SubscribeBalances(ctx) + if err != nil { + t.Fatalf("failed subscribing to balance events on private client: %v", err) + } + + posCh, err := privateClient.SubscribePositions(ctx) + if err != nil { + t.Fatalf("failed subscribing to position events on private client: %v", err) + } + + settleCh, err := privateClient.SubscribeSettlements(ctx) + if err != nil { + t.Fatalf("failed subscribing to settlement events on private client: %v", err) + } + + // 4. Verify independent dispatching + pubConn := dialer.connections[0] + privConn := dialer.connections[1] + + // Send contract status to public socket + pubConn.readChan <- []byte(`{"e":"contractStatus","E":1776871540195,"s":"TRUMP-2028-YES","k":"TRUMP-2028","c":"YES","i":1001,"p":"0.50","o":"Awaiting Approval","n":"Active"}`) + // Send balance update to private socket + privConn.readChan <- []byte(`{"e":"balanceUpdate","E":1710000000000,"u":101,"B":[{"a":"USD","f":"45000.00","c":"50000.00"}]}`) + + select { + case evt := <-contractCh: + if evt.Symbol != "TRUMP-2028-YES" || evt.NewStatus != "Active" || evt.PreviousStatus != "Awaiting Approval" || evt.StrikePrice != "0.50" { + t.Fatalf("unexpected contract event: %+v", evt) + } + case <-time.After(1 * time.Second): + t.Fatal("timed out waiting for contract event on public client") + } + + select { + case bal := <-balanceCh: + if len(bal.Balances) != 1 || bal.Balances[0].Asset != "USD" { + t.Fatalf("unexpected balance event: %+v", bal) + } + case <-time.After(1 * time.Second): + t.Fatal("timed out waiting for balance event on private client") + } + + // An order update may also contain a top-level trade ID. It must remain an + // order event rather than being claimed by the public trade heuristic. + privConn.feedServerMsg([]byte(`{"e":"orderUpdate","E":1710000000000,"s":"BTCUSD","t":777,"i":12345,"S":"BUY","X":"NEW","p":"65000.00","q":"0.1","z":"0.1"}`)) + select { + case order := <-orderCh: + if order.EventType != "orderUpdate" || order.TradeID != 777 || order.OrderID != 12345 { + t.Fatalf("unexpected order event: %+v", order) + } + case <-time.After(1 * time.Second): + t.Fatal("timed out waiting for order event on private client") + } + + // Send position report to private socket + privConn.readChan <- []byte(`{"e":"positionReport","E":1710000000000,"u":102,"A":12345,"P":[{"t":"ec","s":"BTC-GUSD-PERP","a":[{"t":"position","v":"2.5"}]}]}`) + // Send settlements update to private socket + privConn.readChan <- []byte(`{"type":"settlements","settlements":[{"symbol":"TRUMP-2028-YES","position":"100","payout":"1000.00","outcome":"won"}]}`) + select { + case pos := <-posCh: + if len(pos.Positions) != 1 || pos.Positions[0].Symbol != "BTC-GUSD-PERP" { + t.Fatalf("unexpected position event: %+v", pos) + } + case <-time.After(1 * time.Second): + t.Fatal("timed out waiting for position event on private client") + } + + select { + case set := <-settleCh: + if len(set.Settlements) != 1 || set.Settlements[0].Symbol != "TRUMP-2028-YES" { + t.Fatalf("unexpected settlement event: %+v", set) + } + case <-time.After(1 * time.Second): + t.Fatal("timed out waiting for settlement event on private client") + } + + // 5. Verify unsubscriptions + if err := publicClient.UnsubscribeContractStatus(ctx, "TRUMP-2028-YES"); err != nil { + t.Fatalf("failed unsubscribing contract status: %v", err) + } + if err := privateClient.UnsubscribeBalances(ctx); err != nil { + t.Fatalf("failed unsubscribing balances: %v", err) + } + if err := privateClient.UnsubscribePositions(ctx); err != nil { + t.Fatalf("failed unsubscribing positions: %v", err) + } + if err := privateClient.UnsubscribeSettlements(ctx); err != nil { + t.Fatalf("failed unsubscribing settlements: %v", err) + } + + _ = depthCh + _ = orderCh +} + +func TestClient_IndependentPublicPrivateLifecycles(t *testing.T) { + dialer := &mockDrainDialer{} + apiKey := "lifecycle-key" + apiSecret := "lifecycle-secret" + authStrategy := &mockAuthStrategy{key: apiKey, secret: apiSecret} + + publicClient := websocket.NewPublicClient("wss://ws.gemini.com", websocket.WithDialer(dialer)) + privateClient := websocket.NewPrivateClient("wss://ws.gemini.com", authStrategy, websocket.WithDialer(dialer)) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := publicClient.Connect(ctx); err != nil { + t.Fatalf("failed connecting public client: %v", err) + } + if err := privateClient.Connect(ctx); err != nil { + t.Fatalf("failed connecting private client: %v", err) + } + + if publicClient.State() != websocket.StateConnected || privateClient.State() != websocket.StateConnected { + t.Fatal("expected both clients to be connected") + } + + // Closing public client must NOT affect private client + if err := publicClient.Close(); err != nil { + t.Fatalf("failed closing public client: %v", err) + } + + if publicClient.State() != websocket.StateClosed { + t.Fatalf("expected public client to be closed, got %v", publicClient.State()) + } + if privateClient.State() != websocket.StateConnected { + t.Fatalf("expected private client to remain connected, got %v", privateClient.State()) + } + + // Closing private client finishes cleanly + if err := privateClient.Close(); err != nil { + t.Fatalf("failed closing private client: %v", err) + } + if privateClient.State() != websocket.StateClosed { + t.Fatalf("expected private client to be closed, got %v", privateClient.State()) + } +} + +func TestClient_SubscribeFailedSendRollback(t *testing.T) { + dialer := &mockDrainDialer{} + client := websocket.NewClient("wss://ws.gemini.com", websocket.WithDialer(dialer)) + if err := client.Connect(context.Background()); err != nil { + t.Fatalf("Connect failed: %v", err) + } + defer client.Close() + + conn := dialer.latestConn() + // Simulate wire write failure + conn.writeErr = errors.New("network write failure") + + ch, err := client.SubscribeDepth(context.Background(), "BTCUSD") + if err == nil { + t.Fatal("expected SubscribeDepth to fail due to wire write error") + } + if ch != nil { + t.Fatal("expected nil channel returned on error") + } + + // Now fix write error and test reconnect - failed feed must NOT resurrect! + conn.writeErr = nil + _ = conn.Close() // trigger reconnect by closing readChan + time.Sleep(100 * time.Millisecond) + + newConn := dialer.latestConn() + if newConn != nil && newConn != conn { + newConn.mu.Lock() + written := len(newConn.written) + newConn.mu.Unlock() + if written > 0 { + t.Fatalf("expected 0 resubscribed feeds on reconnect after failed subscribe, got %d", written) + } + } +} + +func TestClient_ReconnectResubscribeFailureClosesSubscriptions(t *testing.T) { + dialer := &mockDrainDialer{responseStatuses: []int{http.StatusOK, http.StatusBadRequest}} + client := websocket.NewClient( + "wss://ws.gemini.com", + websocket.WithDialer(dialer), + websocket.WithAutoReconnect(true), + websocket.WithMaxReconnects(1), + ) + defer client.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + depth, err := client.SubscribeDepth(ctx, "BTCUSD") + if err != nil { + t.Fatalf("SubscribeDepth failed: %v", err) + } + + conn := dialer.latestConn() + if conn == nil { + t.Fatal("expected initial connection") + } + _ = conn.Close() + + var sawResubscribeError bool + var channelClosed bool + for !sawResubscribeError || !channelClosed { + select { + case _, ok := <-depth: + if !ok { + channelClosed = true + } + case event, ok := <-client.Events(): + if !ok { + t.Fatal("client closed events before reporting resubscribe failure") + } + if errors.Is(event.Err, websocket.ErrResubscribeFailed) { + sawResubscribeError = true + } + case <-ctx.Done(): + t.Fatalf("timed out waiting for explicit resubscribe failure: %v", ctx.Err()) + } + } +} + +func TestClient_GlobalContractStatusSharedWire(t *testing.T) { + dialer := &mockDrainDialer{} + client := websocket.NewClient("wss://ws.gemini.com", websocket.WithDialer(dialer)) + if err := client.Connect(context.Background()); err != nil { + t.Fatalf("Connect failed: %v", err) + } + defer client.Close() + + conn := dialer.latestConn() + + // 1. Subscribe to symbol A + chA, err := client.SubscribeContractStatus(context.Background(), "TRUMP-2028-YES") + if err != nil { + t.Fatalf("SubscribeContractStatus(A) failed: %v", err) + } + + // Wire subscribe count should be 1 + conn.mu.Lock() + if len(conn.written) != 1 { + t.Fatalf("expected 1 wire message sent, got %d", len(conn.written)) + } + conn.mu.Unlock() + + // 2. Subscribe to symbol B + chB, err := client.SubscribeContractStatus(context.Background(), "HARRIS-2028-YES") + if err != nil { + t.Fatalf("SubscribeContractStatus(B) failed: %v", err) + } + + // Wire subscribe count should STILL be 1 (shared single wire feed) + conn.mu.Lock() + if len(conn.written) != 1 { + t.Fatalf("expected wire subscription not duplicated, got %d written messages", len(conn.written)) + } + conn.mu.Unlock() + + // 3. Unsubscribe symbol A + if err := client.UnsubscribeContractStatus(context.Background(), "TRUMP-2028-YES"); err != nil { + t.Fatalf("UnsubscribeContractStatus(A) failed: %v", err) + } + + // Wire stream should NOT be unsubscribed because symbol B is still active! + conn.mu.Lock() + if len(conn.written) != 1 { + t.Fatalf("expected wire stream not unsubscribed while symbol B active, got %d written messages", len(conn.written)) + } + conn.mu.Unlock() + + // Verify symbol A channel was closed + _, ok := <-chA + if ok { + t.Fatal("expected channel A to be closed on unsubscribe") + } + + // 4. Send contract status for symbol B - chB receives it! + conn.readChan <- []byte(`{"e":"contractStatus","E":1776871540195,"s":"HARRIS-2028-YES","k":"HARRIS-2028","c":"YES","i":1002,"p":"0.45","o":"Awaiting Approval","n":"Active"}`) + select { + case evt := <-chB: + if evt.Symbol != "HARRIS-2028-YES" || evt.NewStatus != "Active" { + t.Fatalf("unexpected event on chB: %+v", evt) + } + case <-time.After(1 * time.Second): + t.Fatal("timed out waiting for event on chB") + } + + // 5. Unsubscribe symbol B (last remaining) + if err := client.UnsubscribeContractStatus(context.Background(), "HARRIS-2028-YES"); err != nil { + t.Fatalf("UnsubscribeContractStatus(B) failed: %v", err) + } + + // Now wire unsubscribe SHOULD be sent! + conn.mu.Lock() + if len(conn.written) != 2 { + t.Fatalf("expected 2 wire messages (subscribe + unsubscribe), got %d", len(conn.written)) + } + conn.mu.Unlock() +} + +func TestClient_DeduplicatesPublicWireSubscriptions(t *testing.T) { + dialer := &mockDrainDialer{} + client := websocket.NewClient("wss://ws.gemini.com", websocket.WithDialer(dialer)) + defer client.Close() + + ctx := context.Background() + first, err := client.SubscribeDepth(ctx, "BTCUSD") + if err != nil { + t.Fatalf("first depth subscription failed: %v", err) + } + second, err := client.SubscribeDepth(ctx, "BTCUSD") + if err != nil { + t.Fatalf("second depth subscription failed: %v", err) + } + + conn := dialer.latestConn() + conn.mu.Lock() + writes := len(conn.written) + conn.mu.Unlock() + if writes != 1 { + t.Fatalf("expected one wire SUBSCRIBE for two local subscribers, got %d", writes) + } + + conn.feedServerMsg([]byte(`{"e":"depthUpdate","s":"BTCUSD","U":10,"u":10,"b":[],"a":[]}`)) + for name, ch := range map[string]<-chan *websocket.DepthUpdate{"first": first, "second": second} { + select { + case <-ch: + case <-time.After(time.Second): + t.Fatalf("timed out delivering depth update to %s subscriber", name) + } + } +} + +func TestClient_ConcurrentUnsubscribeAndDispatch(t *testing.T) { + dialer := &mockDrainDialer{} + client := websocket.NewClient("wss://ws.gemini.com", websocket.WithDialer(dialer)) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := client.Connect(ctx); err != nil { + t.Fatalf("Connect failed: %v", err) + } + conn := dialer.latestConn() + + stopFeed := make(chan struct{}) + feedStarted := make(chan struct{}) + var feedWg sync.WaitGroup + feedWg.Add(1) + go func() { + defer feedWg.Done() + close(feedStarted) + for i := 0; i < 1000; i++ { + select { + case <-stopFeed: + return + default: + conn.feedServerMsg([]byte(`{"e":"depthUpdate","s":"BTCUSD","U":10,"u":10,"b":[],"a":[]}`)) + } + } + }() + <-feedStarted + defer func() { + close(stopFeed) + feedWg.Wait() + _ = client.Close() + }() + + for i := 0; i < 100; i++ { + if _, err := client.SubscribeDepth(ctx, "BTCUSD"); err != nil { + t.Fatalf("subscribe %d failed: %v", i, err) + } + if err := client.UnsubscribeDepth(ctx, "BTCUSD"); err != nil { + t.Fatalf("unsubscribe %d failed: %v", i, err) + } + } +} + +func TestClient_ConcurrentSendSafety(t *testing.T) { + dialer := &mockDrainDialer{} + client := websocket.NewClient("wss://ws.gemini.com", websocket.WithDialer(dialer)) + if err := client.Connect(context.Background()); err != nil { + t.Fatalf("Connect failed: %v", err) + } + defer client.Close() + + const goroutines = 20 + var wg sync.WaitGroup + wg.Add(goroutines) + + for i := 0; i < goroutines; i++ { + go func(id int) { + defer wg.Done() + _ = client.Send(context.Background(), map[string]any{"id": id, "type": "ping"}) + }(i) + } + + wg.Wait() +} + +type mockAuthStrategy struct { + key string + secret string +} + +func (m *mockAuthStrategy) Authenticate(ctx context.Context, req *http.Request, payloadJSON []byte) error { + req.Header.Set("X-GEMINI-APIKEY", m.key) + return nil +} + +func (m *mockAuthStrategy) Key() string { + return m.key +} + +func BenchmarkClient_DispatchDepth(b *testing.B) { + dialer := &mockDrainDialer{} + client := websocket.NewClient("wss://ws.gemini.com", websocket.WithDialer(dialer)) + _ = client.Connect(context.Background()) + defer client.Close() + + ch, _ := client.SubscribeDepth(context.Background(), "BTCUSD") + + // Drain goroutine + go func() { + for range ch { + } + }() + + conn := dialer.latestConn() + payload := []byte(`{"e":"depthUpdate","s":"BTCUSD","u":200,"U":190,"b":[["65000.00","1.0"]],"a":[["65001.00","2.0"]]}`) + + b.ResetTimer() + b.ReportAllocs() + + for i := 0; i < b.N; i++ { + conn.readChan <- payload + } +} + +func BenchmarkClient_DispatchBookTicker(b *testing.B) { + dialer := &mockDrainDialer{} + client := websocket.NewClient("wss://ws.gemini.com", websocket.WithDialer(dialer)) + _ = client.Connect(context.Background()) + defer client.Close() + + ch, _ := client.SubscribeBookTicker(context.Background(), "BTCUSD") + + // Drain goroutine + go func() { + for range ch { + } + }() + + conn := dialer.latestConn() + payload := []byte(`{"u":1786980683898700239,"s":"BTCUSD","b":"63956.75","B":"0.05","a":"63956.76","A":"0.25","c":"63956.76","C":"0.1"}`) + + b.ResetTimer() + b.ReportAllocs() + + for i := 0; i < b.N; i++ { + conn.readChan <- payload + } +} diff --git a/packages/sdk-go/websocket/dialer.go b/packages/sdk-go/websocket/dialer.go new file mode 100644 index 00000000..76a7f242 --- /dev/null +++ b/packages/sdk-go/websocket/dialer.go @@ -0,0 +1,47 @@ +package websocket + +import ( + "context" + "errors" + "net/http" +) + +// Standard WebSocket frame types according to RFC 6455 +const ( + TextMessage = 1 + BinaryMessage = 2 + CloseMessage = 8 + PingMessage = 9 + PongMessage = 10 +) + +// ErrNoDialerConfigured is returned when a caller attempts to use WebSocket without configuring a dialer. +var ErrNoDialerConfigured = errors.New("gemini websocket: no dialer configured; import 'github.com/gemini/gemini-go/websocket/gorilla' and pass gemini.WithWebSocketDialer(gorilla.NewDialer()), or provide a custom websocket.Dialer") + +// Conn represents an abstract bidirectional WebSocket connection. +type Conn interface { + // ReadMessage reads the next message payload from the wire. Implementations + // must allow Close to interrupt a blocked read. + ReadMessage(ctx context.Context) (messageType int, payload []byte, err error) + + // WriteMessage sends a payload frame over the wire. Implementations must + // allow Close to interrupt a blocked write. + WriteMessage(ctx context.Context, messageType int, payload []byte) error + + // Close terminates the connection and may be called concurrently with + // ReadMessage and WriteMessage. + Close() error +} + +// ReadLimitSetter is an optional transport capability for enforcing the +// inbound message limit before the transport allocates the complete payload. +// Clients that do not implement it are still protected by Client's +// post-read size check. +type ReadLimitSetter interface { + SetReadLimit(limit int64) +} + +// Dialer abstracts the establishment of a WebSocket connection. +type Dialer interface { + Dial(ctx context.Context, url string, headers http.Header) (Conn, *http.Response, error) +} diff --git a/packages/sdk-go/websocket/fuzz_test.go b/packages/sdk-go/websocket/fuzz_test.go new file mode 100644 index 00000000..4bb5b333 --- /dev/null +++ b/packages/sdk-go/websocket/fuzz_test.go @@ -0,0 +1,48 @@ +package websocket_test + +import ( + "encoding/json" + "testing" + + "github.com/gemini/gemini-go/websocket" +) + +func FuzzWebSocket_FrameParsing(f *testing.F) { + // Seed initial valid JSON frames + f.Add([]byte(`{"e":"depthUpdate","E":1700000000000,"s":"BTCUSD","U":100,"u":101,"b":[["65000.00","1.5"]],"a":[["65001.00","2.0"]]}`)) + f.Add([]byte(`{"e":"trade","E":1700000000000,"s":"ETHUSD","t":12345,"p":"3500.00","q":"0.5","T":1700000000000,"m":true}`)) + f.Add([]byte(`{"e":"bookTicker","u":105,"s":"SOLUSD","b":"150.00","B":"10.0","a":"150.10","A":"12.5"}`)) + f.Add([]byte(`{"result":"error","reason":"InvalidSymbol","message":"Unknown symbol"}`)) + + f.Fuzz(func(t *testing.T, data []byte) { + var probe struct { + EventType string `json:"e"` + Symbol string `json:"s"` + } + _ = json.Unmarshal(data, &probe) + + var depth websocket.DepthUpdate + _ = json.Unmarshal(data, &depth) + + var trade websocket.TradeEvent + _ = json.Unmarshal(data, &trade) + + var ticker websocket.BookTicker + _ = json.Unmarshal(data, &ticker) + }) +} + +func FuzzWebSocket_ResponseParsing(f *testing.F) { + f.Add([]byte(`{"id":42,"status":200,"result":{"value":9007199254740993}}`)) + f.Add([]byte(`{"id":"request-1","status":400,"error":{"code":400,"message":"invalid request"}}`)) + f.Add([]byte(`{"id":null,"status":200,"result":null}`)) + + f.Fuzz(func(t *testing.T, data []byte) { + var frame websocket.ResponseFrame + if err := json.Unmarshal(data, &frame); err != nil { + return + } + var result map[string]any + _ = frame.DecodeResult(&result) + }) +} diff --git a/packages/sdk-go/websocket/gorilla/dialer.go b/packages/sdk-go/websocket/gorilla/dialer.go new file mode 100644 index 00000000..7951ae93 --- /dev/null +++ b/packages/sdk-go/websocket/gorilla/dialer.go @@ -0,0 +1,139 @@ +package gorilla + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "time" + + "github.com/gemini/gemini-go/websocket" + gorilla_ws "github.com/gorilla/websocket" +) + +// connAdapter wraps a Gorilla WebSocket Conn to satisfy websocket.Conn interface. +type connAdapter struct { + conn *gorilla_ws.Conn +} + +var _ websocket.Conn = (*connAdapter)(nil) +var _ websocket.ReadLimitSetter = (*connAdapter)(nil) + +func (c *connAdapter) ReadMessage(ctx context.Context) (int, []byte, error) { + if ctx == nil { + ctx = context.Background() + } + if ctx.Err() != nil { + return 0, nil, ctx.Err() + } + if deadline, ok := ctx.Deadline(); ok { + _ = c.conn.SetReadDeadline(deadline) + } + done, watcherDone := watchContext(ctx, func() error { + return c.conn.SetReadDeadline(time.Unix(1, 0)) + }) + msgType, payload, err := c.conn.ReadMessage() + if done != nil { + close(done) + <-watcherDone + } + _ = c.conn.SetReadDeadline(time.Time{}) + if ctx.Err() != nil { + return 0, nil, ctx.Err() + } + if errors.Is(err, gorilla_ws.ErrReadLimit) { + return 0, nil, fmt.Errorf("%w: %v", websocket.ErrMessageTooLarge, err) + } + return msgType, payload, err +} + +func (c *connAdapter) WriteMessage(ctx context.Context, messageType int, payload []byte) error { + if ctx == nil { + ctx = context.Background() + } + if ctx.Err() != nil { + return ctx.Err() + } + if deadline, ok := ctx.Deadline(); ok { + _ = c.conn.SetWriteDeadline(deadline) + } + done, watcherDone := watchContext(ctx, func() error { + return c.conn.SetWriteDeadline(time.Unix(1, 0)) + }) + err := c.conn.WriteMessage(messageType, payload) + if done != nil { + close(done) + <-watcherDone + } + _ = c.conn.SetWriteDeadline(time.Time{}) + if ctx.Err() != nil { + // A deadline interrupt leaves Gorilla's connection unusable for a + // subsequent write. Close it so the owning client can observe the + // terminal state and reconnect instead of reusing a poisoned socket. + _ = c.conn.Close() + return ctx.Err() + } + var netErr net.Error + if err != nil && errors.As(err, &netErr) && netErr.Timeout() { + _ = c.conn.Close() + } + return err +} + +func watchContext(ctx context.Context, interrupt func() error) (done, watcherDone chan struct{}) { + if ctx.Done() == nil { + return nil, nil + } + done = make(chan struct{}) + watcherDone = make(chan struct{}) + go func() { + defer close(watcherDone) + select { + case <-ctx.Done(): + _ = interrupt() + case <-done: + } + }() + return done, watcherDone +} + +func (c *connAdapter) Close() error { + return c.conn.Close() +} + +func (c *connAdapter) SetReadLimit(limit int64) { + c.conn.SetReadLimit(limit) +} + +// DialerAdapter implements websocket.Dialer using gorilla/websocket. +type DialerAdapter struct { + dialer *gorilla_ws.Dialer +} + +var _ websocket.Dialer = (*DialerAdapter)(nil) + +// NewDialer creates a new Gorilla WebSocket dialer adapter. +func NewDialer(opts ...func(*gorilla_ws.Dialer)) *DialerAdapter { + d := *gorilla_ws.DefaultDialer + dialer := &d + for _, opt := range opts { + opt(dialer) + } + return &DialerAdapter{dialer: dialer} +} + +// Dial establishes a WebSocket connection. +func (d *DialerAdapter) Dial(ctx context.Context, url string, headers http.Header) (websocket.Conn, *http.Response, error) { + if d == nil || d.dialer == nil { + return nil, nil, errors.New("gemini websocket: nil gorilla dialer") + } + if ctx == nil { + ctx = context.Background() + } + conn, resp, err := d.dialer.DialContext(ctx, url, headers) + if err != nil { + return nil, resp, err + } + return &connAdapter{conn: conn}, resp, nil +} diff --git a/packages/sdk-go/websocket/gorilla/dialer_test.go b/packages/sdk-go/websocket/gorilla/dialer_test.go new file mode 100644 index 00000000..6a1977af --- /dev/null +++ b/packages/sdk-go/websocket/gorilla/dialer_test.go @@ -0,0 +1,166 @@ +package gorilla_test + +import ( + "bytes" + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + gemini_ws "github.com/gemini/gemini-go/websocket" + "github.com/gemini/gemini-go/websocket/gorilla" + gorilla_ws "github.com/gorilla/websocket" +) + +var upgrader = gorilla_ws.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, +} + +func TestGorillaDialer_ConnectAndEcho(t *testing.T) { + ts := 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() + for { + mt, message, err := conn.ReadMessage() + if err != nil { + break + } + if err := conn.WriteMessage(mt, message); err != nil { + break + } + } + })) + defer ts.Close() + + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + + dialer := gorilla.NewDialer() + ctx := context.Background() + conn, resp, err := dialer.Dial(ctx, wsURL, nil) + if err != nil { + t.Fatalf("failed dialing mock ws server: %v", err) + } + defer conn.Close() + if resp.StatusCode != http.StatusSwitchingProtocols { + t.Fatalf("expected status 101, got %d", resp.StatusCode) + } + + testPayload := []byte(`{"op":"ping"}`) + if err := conn.WriteMessage(ctx, 1, testPayload); err != nil { + t.Fatalf("failed writing message: %v", err) + } + + msgType, reply, err := conn.ReadMessage(ctx) + if err != nil { + t.Fatalf("failed reading echo response: %v", err) + } + if msgType != 1 || string(reply) != string(testPayload) { + t.Fatalf("unexpected echo response: %s", string(reply)) + } +} + +func TestGorillaDialer_RejectsNilDialer(t *testing.T) { + _, _, err := (&gorilla.DialerAdapter{}).Dial(context.Background(), "ws://127.0.0.1:1", nil) + if err == nil { + t.Fatal("expected nil gorilla dialer to be rejected") + } +} + +func TestGorillaDialer_HonorsCanceledDialContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, _, err := gorilla.NewDialer().Dial(ctx, "ws://127.0.0.1:1", nil) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled, got %v", err) + } +} + +func TestGorillaConn_ReadHonorsContextCancellation(t *testing.T) { + stop := make(chan struct{}) + ready := make(chan struct{}) + ts := 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() + close(ready) + <-stop + })) + defer func() { + close(stop) + ts.Close() + }() + + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + conn, _, err := gorilla.NewDialer().Dial(context.Background(), wsURL, nil) + if err != nil { + t.Fatalf("failed dialing mock ws server: %v", err) + } + defer conn.Close() + select { + case <-ready: + case <-time.After(time.Second): + t.Fatal("server did not finish WebSocket upgrade") + } + + ctx, cancel := context.WithCancel(context.Background()) + readDone := make(chan error, 1) + go func() { + _, _, readErr := conn.ReadMessage(ctx) + readDone <- readErr + }() + cancel() + + select { + case err := <-readDone: + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled from interrupted read, got %v", err) + } + case <-time.After(time.Second): + t.Fatal("read did not return after context cancellation") + } +} + +func TestGorillaConn_EnforcesInboundReadLimit(t *testing.T) { + ready := make(chan struct{}) + ts := 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() + close(ready) + _ = conn.WriteMessage(gorilla_ws.TextMessage, bytes.Repeat([]byte("x"), 64)) + })) + defer ts.Close() + + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + conn, _, err := gorilla.NewDialer().Dial(context.Background(), wsURL, nil) + if err != nil { + t.Fatalf("failed dialing mock ws server: %v", err) + } + defer conn.Close() + select { + case <-ready: + case <-time.After(time.Second): + t.Fatal("server did not finish WebSocket upgrade") + } + + setter, ok := conn.(gemini_ws.ReadLimitSetter) + if !ok { + t.Fatal("Gorilla connection does not expose ReadLimitSetter") + } + setter.SetReadLimit(16) + _, _, err = conn.ReadMessage(context.Background()) + if !errors.Is(err, gemini_ws.ErrMessageTooLarge) { + t.Fatalf("expected ErrMessageTooLarge, got %v", err) + } +} diff --git a/packages/sdk-go/websocket/gorilla/go.mod b/packages/sdk-go/websocket/gorilla/go.mod new file mode 100644 index 00000000..dac265bd --- /dev/null +++ b/packages/sdk-go/websocket/gorilla/go.mod @@ -0,0 +1,10 @@ +module github.com/gemini/gemini-go/websocket/gorilla + +go 1.23 + +require ( + github.com/gemini/gemini-go v0.1.0 + github.com/gorilla/websocket v1.5.3 +) + +replace github.com/gemini/gemini-go => ../../ diff --git a/packages/sdk-go/websocket/gorilla/go.sum b/packages/sdk-go/websocket/gorilla/go.sum new file mode 100644 index 00000000..25a9fc4b --- /dev/null +++ b/packages/sdk-go/websocket/gorilla/go.sum @@ -0,0 +1,2 @@ +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= diff --git a/packages/sdk-go/websocket/gorilla/oauth_integration_test.go b/packages/sdk-go/websocket/gorilla/oauth_integration_test.go new file mode 100644 index 00000000..3a83d190 --- /dev/null +++ b/packages/sdk-go/websocket/gorilla/oauth_integration_test.go @@ -0,0 +1,58 @@ +//go:build integration + +package gorilla_test + +import ( + "context" + "os" + "strings" + "testing" + "time" + + "github.com/gemini/gemini-go" + "github.com/gemini/gemini-go/auth" + "github.com/gemini/gemini-go/websocket/gorilla" +) + +// TestOAuthIntegration validates the bearer authentication path against a +// deployed Gemini environment without performing a mutating operation. It is +// opt-in because it requires a live OAuth access token and network access. +func TestOAuthIntegration(t *testing.T) { + token := strings.TrimSpace(os.Getenv("GEMINI_OAUTH_ACCESS_TOKEN")) + if token == "" { + t.Skip("set GEMINI_OAUTH_ACCESS_TOKEN to run the OAuth integration test") + } + + env := gemini.Sandbox + switch strings.ToLower(strings.TrimSpace(os.Getenv("GEMINI_OAUTH_ENVIRONMENT"))) { + case "", "sandbox": + env = gemini.Sandbox + case "production": + env = gemini.Production + default: + t.Fatalf("GEMINI_OAUTH_ENVIRONMENT must be sandbox or production") + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + client, err := gemini.NewClientWithError( + gemini.WithEnvironment(env), + gemini.WithTokenSource(auth.TokenFunc(func(context.Context) (string, error) { + return token, nil + })), + gemini.WithWebSocketDialer(gorilla.NewDialer()), + ) + if err != nil { + t.Fatalf("creating OAuth client: %v", err) + } + defer client.Close() + + if _, err := client.Account.GetAccount(ctx, nil); err != nil { + t.Fatalf("OAuth REST request failed: %v", err) + } + + if err := client.PrivateWebSocket().Connect(ctx); err != nil { + t.Fatalf("OAuth WebSocket handshake failed: %v", err) + } +} diff --git a/packages/sdk-go/websocket/models.go b/packages/sdk-go/websocket/models.go new file mode 100644 index 00000000..d8648cf1 --- /dev/null +++ b/packages/sdk-go/websocket/models.go @@ -0,0 +1,253 @@ +package websocket + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// RequestOp defines WebSocket JSON-RPC command operations. +type RequestOp string + +const ( + OpSubscribe RequestOp = "SUBSCRIBE" + OpUnsubscribe RequestOp = "UNSUBSCRIBE" + OpOrderNew RequestOp = "order.place" + OpOrderCancel RequestOp = "order.cancel" + OpOrderCancelAll RequestOp = "order.cancel_all" + OpOrderCancelSession RequestOp = "order.cancel_session" + OpConnInfo RequestOp = "conninfo" + OpTime RequestOp = "time" + OpListSubscriptions RequestOp = "LIST_SUBSCRIPTIONS" + OpPing RequestOp = "ping" +) + +// OpConninfo is retained for compatibility. Deprecated: use OpConnInfo. +const OpConninfo RequestOp = OpConnInfo + +// subscriptionFrame is the internal replay representation for a stream +// subscription. General WebSocket requests can have object parameters and do +// not use this shape. +type subscriptionFrame struct { + ID int64 `json:"id,omitempty"` + Method string `json:"method"` + Params []string `json:"params,omitempty"` +} + +// RequestFrame is retained as a compatibility alias for older callers that +// used the subscription replay shape. New code should use typed request +// methods instead. +type RequestFrame = subscriptionFrame + +// ResponseFrame represents a generic incoming JSON response frame. +type ResponseFrame struct { + ID string `json:"id,omitempty"` + Status int `json:"status,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + Error *ResponseErrorPayload `json:"error,omitempty"` +} + +// ResponseErrorPayload contains the exchange-provided details for a failed +// WebSocket request. +type ResponseErrorPayload struct { + Code int `json:"code"` + Message string `json:"message,omitempty"` + Msg string `json:"msg,omitempty"` +} + +// UnmarshalJSON handles both string and numeric "id" fields from exchange responses. +func (r *ResponseFrame) UnmarshalJSON(data []byte) error { + type alias ResponseFrame + aux := struct { + RawID json.RawMessage `json:"id,omitempty"` + *alias + }{ + alias: (*alias)(r), + } + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + if len(aux.RawID) > 0 && !bytes.Equal(aux.RawID, []byte("null")) { + id, err := decodeResponseID(aux.RawID) + if err != nil { + return fmt.Errorf("gemini websocket: invalid response id: %w", err) + } + r.ID = id + } + if r.Error != nil { + if r.Error.Message == "" && r.Error.Msg != "" { + r.Error.Message = r.Error.Msg + } + } + return nil +} + +func decodeResponseID(raw json.RawMessage) (string, error) { + var stringID string + if err := json.Unmarshal(raw, &stringID); err == nil { + return stringID, nil + } + + var numberID json.Number + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + if err := decoder.Decode(&numberID); err != nil { + return "", err + } + return numberID.String(), nil +} + +// DecodeResult unmarshals the generic response result into target without +// converting JSON numbers through float64. +func (r ResponseFrame) DecodeResult(target any) error { + if len(r.Result) == 0 { + return nil + } + decoder := json.NewDecoder(bytes.NewReader(r.Result)) + decoder.UseNumber() + return decoder.Decode(target) +} + +// DepthUpdate represents an incremental L2 order book update. +type DepthUpdate struct { + EventType string `json:"e"` // Event Type (e.g. "depthUpdate") + EventTime int64 `json:"E"` // Event Time in milliseconds/nanoseconds + Symbol string `json:"s"` // Market Symbol (e.g. "BTCUSD") + FirstUpdateID int64 `json:"U"` // First Update ID in this event + LastUpdateID int64 `json:"u"` // Last Update ID in this event + Bids [][]string `json:"b"` // Bids [[price, amount], ...] + Asks [][]string `json:"a"` // Asks [[price, amount], ...] + // Snapshot is set by the client when this frame is the snapshot requested + // on the WebSocket connection. It is intentionally not decoded from JSON: + // Gemini uses the connection query, rather than a per-message marker, to + // select the initial snapshot. + Snapshot bool `json:"-"` +} + +// OrderBookSnapshot represents a full L2 snapshot frame. +type OrderBookSnapshot struct { + EventType string `json:"e"` // "depthSnapshot" or initial frame + EventTime int64 `json:"E"` + Symbol string `json:"s"` + LastUpdateID int64 `json:"lastUpdateId"` + Bids [][]string `json:"bids"` + Asks [][]string `json:"asks"` +} + +// BookTicker represents the top-of-book best bid and offer (BBO). +type BookTicker struct { + EventType string `json:"e"` // "bookTicker" + EventTime int64 `json:"E"` + UpdateID int64 `json:"u"` + Symbol string `json:"s"` + BidPrice string `json:"b"` + BidQty string `json:"B"` + AskPrice string `json:"a"` + AskQty string `json:"A"` + LastTradePrice string `json:"c,omitempty"` + LastTradeQty string `json:"C,omitempty"` +} + +// TradeEvent represents a public trade execution frame. +type TradeEvent struct { + EventType string `json:"e"` // "trade" + EventTime int64 `json:"E"` + Symbol string `json:"s"` + TradeID int64 `json:"t"` + Price string `json:"p"` + Quantity string `json:"q"` + BuyerIsMaker bool `json:"m"` +} + +// OrderEvent represents a private order lifecycle update. +type OrderEvent struct { + EventType string `json:"e"` // "order" or "orderUpdate" + EventTime int64 `json:"E"` + Symbol string `json:"s"` + ClientOrderID string `json:"c"` + Side string `json:"S"` + OrderType string `json:"o"` + TimeInForce string `json:"f"` + Quantity string `json:"q"` + Price string `json:"p"` + StopPrice string `json:"P,omitempty"` + EventOutcome string `json:"O,omitempty"` + ExecutionType string `json:"x"` + OrderStatus string `json:"X"` + OrderRejectReason string `json:"r,omitempty"` + OrderID int64 `json:"i"` + RemainingQty string `json:"z"` + ExecutedQty string `json:"Z"` + LastExecutedPrice string `json:"L,omitempty"` + LastExecutedQty string `json:"l,omitempty"` + FeeAmount string `json:"n,omitempty"` + FeeAsset string `json:"N,omitempty"` + TradeTime int64 `json:"T,omitempty"` + TradeID int64 `json:"t,omitempty"` + IsMaker bool `json:"m,omitempty"` +} + +// BalanceItem represents an asset balance entry within a BalanceUpdate. +type BalanceItem struct { + Asset string `json:"a"` // Asset code (e.g. "USD", "BTC") + Available string `json:"f"` // Available free balance + Cumulative string `json:"c"` // Total cumulative balance +} + +// BalanceUpdate represents an authenticated account balance event. +type BalanceUpdate struct { + EventType string `json:"e"` // "balanceUpdate" + EventTime int64 `json:"E"` + UpdateTime int64 `json:"u"` + Balances []BalanceItem `json:"B"` +} + +// NamedAmount represents a labelled numeric quantity in a position row. +type NamedAmount struct { + Type string `json:"t"` // e.g. "position" + Value string `json:"v"` + Asset string `json:"c,omitempty"` +} + +// PositionRow represents an individual instrument position within a PositionReport. +type PositionRow struct { + ProductType string `json:"t"` // e.g. "ec" + Symbol string `json:"s"` + Amounts []NamedAmount `json:"a"` +} + +// PositionReport represents an authenticated account position update. +type PositionReport struct { + EventType string `json:"e"` // "positionReport" + EventTime int64 `json:"E"` + UpdateTime int64 `json:"u"` + AccountID int64 `json:"A"` + Positions []PositionRow `json:"P"` +} + +// SettlementItem represents an instrument settlement entry within a SettlementUpdate. +type SettlementItem struct { + Symbol string `json:"symbol"` + Position string `json:"position"` + Payout string `json:"payout,omitempty"` + Outcome string `json:"outcome"` +} + +// SettlementUpdate represents an authenticated contract settlement event. +type SettlementUpdate struct { + Type string `json:"type"` // "settlements" + Settlements []SettlementItem `json:"settlements"` +} + +// ContractStatusEvent represents a public prediction contract lifecycle event. +type ContractStatusEvent struct { + EventType string `json:"e"` // "contractStatus" + EventTime int64 `json:"E"` + Symbol string `json:"s"` + EventTicker string `json:"k"` + ContractTicker string `json:"c"` + ContractID int64 `json:"i"` + StrikePrice string `json:"p,omitempty"` + PreviousStatus string `json:"o,omitempty"` + NewStatus string `json:"n,omitempty"` +} diff --git a/packages/sdk-go/websocket/models_test.go b/packages/sdk-go/websocket/models_test.go new file mode 100644 index 00000000..73ac9311 --- /dev/null +++ b/packages/sdk-go/websocket/models_test.go @@ -0,0 +1,71 @@ +package websocket + +import ( + "encoding/json" + "errors" + "testing" +) + +func TestResponseFrame_PreservesNumericIDAndResultNumbers(t *testing.T) { + var frame ResponseFrame + if err := json.Unmarshal([]byte(`{"id":9007199254740993,"result":{"value":9007199254740993}}`), &frame); err != nil { + t.Fatalf("failed to decode response frame: %v", err) + } + if frame.ID != "9007199254740993" { + t.Fatalf("expected exact response ID, got %q", frame.ID) + } + + var result struct { + Value json.Number `json:"value"` + } + if err := frame.DecodeResult(&result); err != nil { + t.Fatalf("failed to decode result: %v", err) + } + if result.Value.String() != "9007199254740993" { + t.Fatalf("expected exact result number, got %q", result.Value) + } + + var generic map[string]any + if err := frame.DecodeResult(&generic); err != nil { + t.Fatalf("failed to decode generic result: %v", err) + } + value, ok := generic["value"].(json.Number) + if !ok || value.String() != "9007199254740993" { + t.Fatalf("expected generic result to preserve exact number, got %#v", generic["value"]) + } +} + +func TestResponseFrame_DecodesStringID(t *testing.T) { + var frame ResponseFrame + if err := json.Unmarshal([]byte(`{"id":"request-1","result":null}`), &frame); err != nil { + t.Fatalf("failed to decode response frame: %v", err) + } + if frame.ID != "request-1" { + t.Fatalf("expected string response ID, got %q", frame.ID) + } +} + +func TestRequestErrorAndConnectionStateFormatting(t *testing.T) { + withMessage := &RequestError{ID: "7", Status: 400, Code: 1001, Message: "invalid request"} + if got := withMessage.Error(); got == "" { + t.Fatal("expected request error message") + } + if !errors.Is(withMessage, ErrRequestFailed) { + t.Fatal("expected RequestError to unwrap to ErrRequestFailed") + } + if got := (&RequestError{ID: "8", Status: 500}).Error(); got == "" { + t.Fatal("expected request error without message to be formatted") + } + if got := ConnectionState(999).String(); got != "Unknown" { + t.Fatalf("expected unknown connection state, got %q", got) + } +} + +func TestOrderOperationConstantsMatchWireMethods(t *testing.T) { + if OpOrderNew != "order.place" { + t.Fatalf("expected order.place, got %q", OpOrderNew) + } + if OpOrderCancel != "order.cancel" { + t.Fatalf("expected order.cancel, got %q", OpOrderCancel) + } +} diff --git a/packages/sdk-go/websocket/orderbook/book.go b/packages/sdk-go/websocket/orderbook/book.go new file mode 100644 index 00000000..0ae2418c --- /dev/null +++ b/packages/sdk-go/websocket/orderbook/book.go @@ -0,0 +1,490 @@ +package orderbook + +import ( + "errors" + "fmt" + "slices" + "strings" + "sync" + + "github.com/gemini/gemini-go/types" +) + +// PriceLevel represents a price and amount pair in the order book. +type PriceLevel struct { + Price string + Amount string + val float64 // Convenience value for float-returning metrics + decimal types.Decimal // Exact price decimal used for keys, sorting, and comparisons + amountDec types.Decimal // Exact amount decimal used for zero-level removal +} + +var ( + ErrInvalidLevel = errors.New("gemini order book: invalid price level") + ErrInvalidSequence = errors.New("gemini order book: invalid sequence ID") +) + +// OrderBook maintains an in-memory L2 sorted order book. +type OrderBook struct { + mu sync.RWMutex + symbol string + lastUpdateID int64 + bids []PriceLevel // Sorted descending (highest bid first) + asks []PriceLevel // Sorted ascending (lowest ask first) + priceCache decimalCache +} + +const maxCachedPriceLength = 128 + +type decimalCache struct { + mu sync.RWMutex + values map[string]types.Decimal +} + +// NewOrderBook creates a new empty OrderBook for a symbol. +func NewOrderBook(symbol string) *OrderBook { + return &OrderBook{ + symbol: symbol, + bids: make([]PriceLevel, 0, 128), + asks: make([]PriceLevel, 0, 128), + priceCache: decimalCache{values: make(map[string]types.Decimal)}, + } +} + +// Symbol returns the market symbol of this order book. +func (b *OrderBook) Symbol() string { + return b.symbol +} + +// LastUpdateID returns the sequence ID of the latest applied update. +func (b *OrderBook) LastUpdateID() int64 { + b.mu.RLock() + defer b.mu.RUnlock() + return b.lastUpdateID +} + +// SetLastUpdateID updates the last sequence ID. +func (b *OrderBook) SetLastUpdateID(id int64) { + b.mu.Lock() + defer b.mu.Unlock() + b.lastUpdateID = id +} + +// Clear resets the order book state. +func (b *OrderBook) Clear() { + b.mu.Lock() + defer b.mu.Unlock() + b.bids = b.bids[:0] + b.asks = b.asks[:0] + b.lastUpdateID = 0 +} + +// ApplySnapshot replaces the order book contents with a full snapshot. The +// update is atomic: malformed levels leave the existing book unchanged. +func (b *OrderBook) ApplySnapshot(lastUpdateID int64, rawBids [][]string, rawAsks [][]string) error { + if lastUpdateID <= 0 { + return fmt.Errorf("%w: snapshot sequence ID must be positive, got %d", ErrInvalidSequence, lastUpdateID) + } + bids, err := parseLevels(rawBids, false, &b.priceCache) + if err != nil { + return fmt.Errorf("gemini order book: invalid bid snapshot: %w", err) + } + asks, err := parseLevels(rawAsks, false, &b.priceCache) + if err != nil { + return fmt.Errorf("gemini order book: invalid ask snapshot: %w", err) + } + + b.mu.Lock() + defer b.mu.Unlock() + + b.lastUpdateID = lastUpdateID + b.bids = append(b.bids[:0], bids...) + b.asks = append(b.asks[:0], asks...) + + // Sort bids descending, asks ascending + slices.SortFunc(b.bids, func(a, b PriceLevel) int { + return b.decimal.Cmp(a.decimal) + }) + slices.SortFunc(b.asks, func(a, b PriceLevel) int { + return a.decimal.Cmp(b.decimal) + }) + return nil +} + +func parseLevels(raw [][]string, allowZeroAmount bool, cache *decimalCache) ([]PriceLevel, error) { + levels := make([]PriceLevel, 0, len(raw)) + for i, entry := range raw { + if len(entry) != 2 { + return nil, fmt.Errorf("%w at index %d: expected [price, amount]", ErrInvalidLevel, i) + } + level, err := parseLevel(entry[0], entry[1], allowZeroAmount, cache) + if err != nil { + return nil, fmt.Errorf("%w at index %d: %v", ErrInvalidLevel, i, err) + } + levels = append(levels, level) + } + return levels, nil +} + +func parseLevel(priceStr, amountStr string, allowZeroAmount bool, cache *decimalCache) (PriceLevel, error) { + price, err := cachedPrice(cache, priceStr) + if err != nil || !price.IsPositive() { + return PriceLevel{}, fmt.Errorf("price %q must be positive decimal", priceStr) + } + amount, err := types.ParseDecimal(amountStr) + if err != nil || amount.IsNegative() || (!allowZeroAmount && !amount.IsPositive()) { + return PriceLevel{}, fmt.Errorf("amount %q must be %s decimal", amountStr, map[bool]string{true: "non-negative", false: "positive"}[allowZeroAmount]) + } + return PriceLevel{ + Price: priceStr, + Amount: amountStr, + val: price.Float64(), + decimal: price, + amountDec: amount, + }, nil +} + +func cachedPrice(cache *decimalCache, raw string) (types.Decimal, error) { + key := strings.TrimSpace(raw) + if cache != nil && len(key) <= maxCachedPriceLength { + cache.mu.RLock() + price, ok := cache.values[key] + cache.mu.RUnlock() + if ok { + return price, nil + } + } + + price, err := types.ParseDecimal(raw) + if err != nil { + return types.Decimal{}, err + } + if cache != nil && len(key) <= maxCachedPriceLength { + cache.mu.Lock() + if len(cache.values) >= 4096 { + clear(cache.values) + } + cache.values[key] = price + cache.mu.Unlock() + } + return price, nil +} + +// ApplyDiff atomically applies a batch of bid and ask level updates and updates the sequence ID under a single lock. +func (b *OrderBook) ApplyDiff(lastUpdateID int64, rawBids [][]string, rawAsks [][]string) error { + if lastUpdateID <= 0 { + return fmt.Errorf("%w: diff sequence ID must be positive, got %d", ErrInvalidSequence, lastUpdateID) + } + bids, err := parseLevels(rawBids, true, &b.priceCache) + if err != nil { + return fmt.Errorf("gemini order book: invalid bid diff: %w", err) + } + asks, err := parseLevels(rawAsks, true, &b.priceCache) + if err != nil { + return fmt.Errorf("gemini order book: invalid ask diff: %w", err) + } + + b.mu.Lock() + defer b.mu.Unlock() + if lastUpdateID < b.lastUpdateID { + return fmt.Errorf("%w: diff sequence ID %d is older than current ID %d", ErrInvalidSequence, lastUpdateID, b.lastUpdateID) + } + + b.lastUpdateID = lastUpdateID + for _, level := range bids { + b.applyLevelLocked(true, level) + } + for _, level := range asks { + b.applyLevelLocked(false, level) + } + return nil +} + +// ApplyLevel updates or deletes a single price level. +func (b *OrderBook) ApplyLevel(isBid bool, priceStr, amountStr string) error { + level, err := parseLevel(priceStr, amountStr, true, &b.priceCache) + if err != nil { + return fmt.Errorf("gemini order book: invalid level: %w", err) + } + b.mu.Lock() + defer b.mu.Unlock() + b.applyLevelLocked(isBid, level) + return nil +} + +func (b *OrderBook) applyLevelLocked(isBid bool, level PriceLevel) { + isZero := level.amountDec.IsZero() + if isBid { + // Bids are sorted descending + idx, found := slices.BinarySearchFunc(b.bids, level.decimal, func(existing PriceLevel, target types.Decimal) int { + return target.Cmp(existing.decimal) // Reversed for descending order + }) + + if isZero { + if found { + b.bids = slices.Delete(b.bids, idx, idx+1) + } + } else { + if found { + b.bids[idx] = level + } else { + b.bids = slices.Insert(b.bids, idx, level) + } + } + } else { + // Asks are sorted ascending + idx, found := slices.BinarySearchFunc(b.asks, level.decimal, func(existing PriceLevel, target types.Decimal) int { + return existing.decimal.Cmp(target) + }) + + if isZero { + if found { + b.asks = slices.Delete(b.asks, idx, idx+1) + } + } else { + if found { + b.asks[idx] = level + } else { + b.asks = slices.Insert(b.asks, idx, level) + } + } + } +} + +// BestBid returns the top bid price level. +func (b *OrderBook) BestBid() (PriceLevel, bool) { + b.mu.RLock() + defer b.mu.RUnlock() + if len(b.bids) == 0 { + return PriceLevel{}, false + } + return b.bids[0], true +} + +// BestAsk returns the top ask price level. +func (b *OrderBook) BestAsk() (PriceLevel, bool) { + b.mu.RLock() + defer b.mu.RUnlock() + if len(b.asks) == 0 { + return PriceLevel{}, false + } + return b.asks[0], true +} + +// IsCrossed returns true if the top bid price is greater than or equal to the top ask price. +func (b *OrderBook) IsCrossed() bool { + b.mu.RLock() + defer b.mu.RUnlock() + if len(b.bids) == 0 || len(b.asks) == 0 { + return false + } + return b.bids[0].decimal.Cmp(b.asks[0].decimal) >= 0 +} + +// Spread returns the bid-ask spread (best ask price minus best bid price). +func (b *OrderBook) Spread() (float64, bool) { + b.mu.RLock() + defer b.mu.RUnlock() + if len(b.bids) == 0 || len(b.asks) == 0 { + return 0, false + } + return b.asks[0].val - b.bids[0].val, true +} + +// Mid returns the mid-market price ((best bid + best ask) / 2). +func (b *OrderBook) Mid() (float64, bool) { + b.mu.RLock() + defer b.mu.RUnlock() + if len(b.bids) == 0 || len(b.asks) == 0 { + return 0, false + } + return (b.asks[0].val + b.bids[0].val) / 2.0, true +} + +// Bids returns up to depth top bids. +func (b *OrderBook) Bids(depth int) []PriceLevel { + b.mu.RLock() + defer b.mu.RUnlock() + if depth <= 0 || depth > len(b.bids) { + depth = len(b.bids) + } + res := make([]PriceLevel, depth) + copy(res, b.bids[:depth]) + return res +} + +// Asks returns up to depth top asks. +func (b *OrderBook) Asks(depth int) []PriceLevel { + b.mu.RLock() + defer b.mu.RUnlock() + if depth <= 0 || depth > len(b.asks) { + depth = len(b.asks) + } + res := make([]PriceLevel, depth) + copy(res, b.asks[:depth]) + return res +} + +// SimulatedFill contains the execution summary of a simulated market order swept through L2 book depth. +type SimulatedFill struct { + FilledQuantity types.Decimal // Total base currency filled + AveragePrice types.Decimal // Volume-weighted average execution price (VWAP) + TotalNotional types.Decimal // Total quote currency proceeds or cost + RemainingQty types.Decimal // Unfilled quantity if book depth was insufficient + SlippageBps float64 // Price impact / drift relative to initial top-of-book (BBO) + LevelsConsumed int // Number of price levels swept +} + +// VWAP calculates the volume-weighted average price and simulated fill for executing targetQty against the order book. +// Set isBuy=true to simulate a market buy order sweeping the ask book. +// Set isBuy=false to simulate a market sell order sweeping the bid book. +func (b *OrderBook) VWAP(isBuy bool, targetQty types.Decimal) (SimulatedFill, error) { + b.mu.RLock() + defer b.mu.RUnlock() + + if !targetQty.IsPositive() { + return SimulatedFill{}, types.ErrInvalidQuantity + } + + var levels []PriceLevel + if isBuy { + levels = b.asks + } else { + levels = b.bids + } + + if len(levels) == 0 { + return SimulatedFill{ + RemainingQty: targetQty, + }, nil + } + + topPrice := levels[0].decimal + + remaining := targetQty + totalFilled := types.Zero() + totalNotional := types.Zero() + levelsConsumed := 0 + + for _, level := range levels { + if remaining.IsZero() { + break + } + + levelPrice := level.decimal + levelQty := level.amountDec + if !levelQty.IsPositive() { + continue + } + + fillQty := types.Min(remaining, levelQty) + fillNotional := levelPrice.Mul(fillQty) + + totalFilled = totalFilled.Add(fillQty) + totalNotional = totalNotional.Add(fillNotional) + remaining = remaining.Sub(fillQty) + levelsConsumed++ + } + + avgPrice := types.Zero() + slippageBps := 0.0 + if totalFilled.IsPositive() { + if div, err := totalNotional.Div(totalFilled, 8); err == nil { + avgPrice = div + } + if isBuy { + if bps, err := avgPrice.BpsDiffChecked(topPrice); err == nil { + slippageBps = bps + } + } else { + if bps, err := topPrice.BpsDiffChecked(avgPrice); err == nil { + slippageBps = bps + } + } + } + + return SimulatedFill{ + FilledQuantity: totalFilled, + AveragePrice: avgPrice, + TotalNotional: totalNotional, + RemainingQty: remaining, + SlippageBps: slippageBps, + LevelsConsumed: levelsConsumed, + }, nil +} + +// Imbalance computes the normalized Order Book Imbalance (OBI) across top N depth levels: +// (BidVolume - AskVolume) / (BidVolume + AskVolume), returning a float in [-1.0, 1.0]. +// Returns (0, false) if the book has no depth. +func (b *OrderBook) Imbalance(depth int) (float64, bool) { + b.mu.RLock() + defer b.mu.RUnlock() + + if len(b.bids) == 0 && len(b.asks) == 0 { + return 0, false + } + + bidLimit := len(b.bids) + if depth > 0 && depth < bidLimit { + bidLimit = depth + } + askLimit := len(b.asks) + if depth > 0 && depth < askLimit { + askLimit = depth + } + + var bidVol, askVol types.Decimal + for i := 0; i < bidLimit; i++ { + bidVol = bidVol.Add(b.bids[i].amountDec) + } + for i := 0; i < askLimit; i++ { + askVol = askVol.Add(b.asks[i].amountDec) + } + + totalVol := bidVol.Add(askVol) + if totalVol.IsZero() { + return 0, false + } + + imbalance, err := bidVol.Sub(askVol).Div(totalVol, 18) + if err != nil { + return 0, false + } + result, err := imbalance.Float64Checked() + if err != nil { + return 0, false + } + return result, true +} + +// CumulativeDepth returns total volume and notional across up to depth levels. +func (b *OrderBook) CumulativeDepth(isBid bool, depth int) (totalQty types.Decimal, totalNotional types.Decimal) { + b.mu.RLock() + defer b.mu.RUnlock() + + var levels []PriceLevel + if isBid { + levels = b.bids + } else { + levels = b.asks + } + + limit := len(levels) + if depth > 0 && depth < limit { + limit = depth + } + + totalQty = types.Zero() + totalNotional = types.Zero() + + for i := 0; i < limit; i++ { + p := levels[i].decimal + q := levels[i].amountDec + if q.IsPositive() { + totalQty = totalQty.Add(q) + totalNotional = totalNotional.Add(p.Mul(q)) + } + } + + return totalQty, totalNotional +} diff --git a/packages/sdk-go/websocket/orderbook/book_bench_test.go b/packages/sdk-go/websocket/orderbook/book_bench_test.go new file mode 100644 index 00000000..9d5ad0d9 --- /dev/null +++ b/packages/sdk-go/websocket/orderbook/book_bench_test.go @@ -0,0 +1,84 @@ +package orderbook_test + +import ( + "fmt" + "testing" + + "github.com/gemini/gemini-go/websocket" + "github.com/gemini/gemini-go/websocket/orderbook" +) + +func BenchmarkOrderBook_ApplyLevel_Insert(b *testing.B) { + prices := make([]string, 500) + for i := 0; i < 500; i++ { + prices[i] = fmt.Sprintf("%d.00", 60000+i) + } + + book := orderbook.NewOrderBook("BTCUSD") + b.ResetTimer() + b.ReportAllocs() + + for i := 0; i < b.N; i++ { + book.ApplyLevel(true, prices[i%500], "1.5") + } +} + +func BenchmarkOrderBook_ApplyLevel_Update(b *testing.B) { + prices := make([]string, 100) + for i := 0; i < 100; i++ { + prices[i] = fmt.Sprintf("%d.00", 60000+i) + } + + book := orderbook.NewOrderBook("BTCUSD") + // Pre-fill 100 levels + for i := 0; i < 100; i++ { + book.ApplyLevel(true, prices[i], "1.0") + } + + b.ResetTimer() + b.ReportAllocs() + + for i := 0; i < b.N; i++ { + book.ApplyLevel(true, prices[i%100], "2.5") + } +} + +func BenchmarkOrderBook_BestBid(b *testing.B) { + book := orderbook.NewOrderBook("BTCUSD") + for i := 0; i < 100; i++ { + price := fmt.Sprintf("%d.00", 60000+i) + book.ApplyLevel(true, price, "1.0") + } + + b.ResetTimer() + b.ReportAllocs() + + for i := 0; i < b.N; i++ { + _, _ = book.BestBid() + } +} + +func BenchmarkLiveOrderBook_IngestDiff(b *testing.B) { + liveBook := orderbook.NewLiveOrderBook("BTCUSD") + liveBook.ApplySnapshot(&websocket.OrderBookSnapshot{ + LastUpdateID: 100, + Bids: [][]string{{"65000.00", "1.0"}}, + Asks: [][]string{{"65001.00", "1.5"}}, + }) + + diff := &websocket.DepthUpdate{ + FirstUpdateID: 101, + LastUpdateID: 102, + Bids: [][]string{{"65000.50", "2.0"}}, + Asks: [][]string{{"65001.50", "0.5"}}, + } + + b.ResetTimer() + b.ReportAllocs() + + for i := 0; i < b.N; i++ { + diff.FirstUpdateID = int64(100 + i*2 + 1) + diff.LastUpdateID = int64(100 + i*2 + 2) + _ = liveBook.IngestDiff(diff) + } +} diff --git a/packages/sdk-go/websocket/orderbook/book_fuzz_test.go b/packages/sdk-go/websocket/orderbook/book_fuzz_test.go new file mode 100644 index 00000000..0c21ebea --- /dev/null +++ b/packages/sdk-go/websocket/orderbook/book_fuzz_test.go @@ -0,0 +1,95 @@ +package orderbook + +import ( + "fmt" + "sort" + "testing" +) + +func FuzzOrderBookApplyLevelMaintainsSortedUniqueLevels(f *testing.F) { + f.Add([]byte{0, 1, 1, 1, 2, 0, 0, 3, 2}) + f.Add([]byte{1, 31, 3, 0, 31, 0, 1, 1, 1}) + + f.Fuzz(func(t *testing.T, data []byte) { + if len(data) > 384 { + data = data[:384] + } + if len(data) < 3 { + return + } + + book := NewOrderBook("BTCUSD") + expected := [2]map[int]struct{}{ + make(map[int]struct{}), + make(map[int]struct{}), + } + + for i := 0; i+2 < len(data); i += 3 { + isBid := data[i]&1 == 0 + bookSide := 0 + if !isBid { + bookSide = 1 + } + price := int(data[i+1]%32) + 1 + amount := int(data[i+2] % 4) + priceText := fmt.Sprintf("%d.00", price) + amountText := fmt.Sprintf("%d.00", amount) + + if err := book.ApplyLevel(isBid, priceText, amountText); err != nil { + t.Fatalf("ApplyLevel(%t, %q, %q): %v", isBid, priceText, amountText, err) + } + if amount == 0 { + delete(expected[bookSide], price) + } else { + expected[bookSide][price] = struct{}{} + } + + assertOrderBookSide(t, book.Bids(0), expected[0], true) + assertOrderBookSide(t, book.Asks(0), expected[1], false) + } + }) +} + +func assertOrderBookSide(t *testing.T, levels []PriceLevel, expected map[int]struct{}, descending bool) { + t.Helper() + wantPrices := make([]int, 0, len(expected)) + for price := range expected { + wantPrices = append(wantPrices, price) + } + sort.Ints(wantPrices) + if descending { + for left, right := 0, len(wantPrices)-1; left < right; left, right = left+1, right-1 { + wantPrices[left], wantPrices[right] = wantPrices[right], wantPrices[left] + } + } + if len(levels) != len(wantPrices) { + t.Fatalf("got %d levels, want %d: %+v", len(levels), len(wantPrices), levels) + } + for i, level := range levels { + want := fmt.Sprintf("%d.00", wantPrices[i]) + if level.Price != want { + t.Fatalf("level %d has price %q, want %q: %+v", i, level.Price, want, levels) + } + } +} + +func TestOrderBookPriceCacheEvictsAtBound(t *testing.T) { + book := NewOrderBook("BTCUSD") + const uniquePrices = 4097 + for i := 1; i <= uniquePrices; i++ { + if err := book.ApplyLevel(true, fmt.Sprintf("%d.00", i), "1.00"); err != nil { + t.Fatalf("ApplyLevel(%d): %v", i, err) + } + } + + book.priceCache.mu.RLock() + cacheSize := len(book.priceCache.values) + _, firstPriceRetained := book.priceCache.values["1.00"] + book.priceCache.mu.RUnlock() + if cacheSize > 4096 { + t.Fatalf("price cache grew beyond its bound: %d", cacheSize) + } + if firstPriceRetained { + t.Fatal("expected the oldest price to be evicted after the cache limit") + } +} diff --git a/packages/sdk-go/websocket/orderbook/book_test.go b/packages/sdk-go/websocket/orderbook/book_test.go new file mode 100644 index 00000000..4630942e --- /dev/null +++ b/packages/sdk-go/websocket/orderbook/book_test.go @@ -0,0 +1,801 @@ +package orderbook + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/gemini/gemini-go/transport" + "github.com/gemini/gemini-go/types" + "github.com/gemini/gemini-go/websocket" +) + +func TestOrderBook_LevelsAndBBO(t *testing.T) { + book := NewOrderBook("BTCUSD") + + rawBids := [][]string{ + {"64000.00", "1.2"}, + {"64500.00", "0.5"}, + {"63900.00", "2.0"}, + } + rawAsks := [][]string{ + {"65000.00", "1.0"}, + {"64800.00", "0.8"}, + {"65200.00", "3.0"}, + } + + book.ApplySnapshot(100, rawBids, rawAsks) + + bestBid, ok := book.BestBid() + if !ok || bestBid.Price != "64500.00" || bestBid.Amount != "0.5" { + t.Fatalf("expected best bid 64500.00 @ 0.5, got %+v", bestBid) + } + + bestAsk, ok := book.BestAsk() + if !ok || bestAsk.Price != "64800.00" || bestAsk.Amount != "0.8" { + t.Fatalf("expected best ask 64800.00 @ 0.8, got %+v", bestAsk) + } + + spread, ok := book.Spread() + if !ok || spread != 300.00 { + t.Fatalf("expected spread 300.00, got %f", spread) + } + + mid, ok := book.Mid() + if !ok || mid != 64650.00 { + t.Fatalf("expected mid 64650.00, got %f", mid) + } + + // Update best bid + book.ApplyLevel(true, "64600.00", "1.5") + bestBid, _ = book.BestBid() + if bestBid.Price != "64600.00" { + t.Errorf("expected updated best bid 64600.00, got %s", bestBid.Price) + } + + // Delete level with 0 amount + book.ApplyLevel(true, "64600.00", "0") + bestBid, _ = book.BestBid() + if bestBid.Price != "64500.00" { + t.Errorf("expected best bid back to 64500.00 after deletion, got %s", bestBid.Price) + } +} + +func TestLiveOrderBook_RejectsMismatchedSymbols(t *testing.T) { + liveBook := NewLiveOrderBook("BTCUSD") + + err := liveBook.ApplySnapshot(&websocket.OrderBookSnapshot{ + Symbol: "ETHUSD", + LastUpdateID: 1, + Bids: [][]string{{"100", "1"}}, + }) + if !errors.Is(err, ErrSymbolMismatch) { + t.Fatalf("expected snapshot symbol mismatch, got %v", err) + } + + err = liveBook.IngestDiff(&websocket.DepthUpdate{ + Symbol: "ETHUSD", + FirstUpdateID: 1, + LastUpdateID: 1, + }) + if !errors.Is(err, ErrSymbolMismatch) { + t.Fatalf("expected diff symbol mismatch, got %v", err) + } +} + +func TestOrderBook_UsesExactPricesForLevelIdentity(t *testing.T) { + book := NewOrderBook("BTCUSD") + if err := book.ApplySnapshot(1, + [][]string{{"10000000000000000.01", "1"}, {"10000000000000000.02", "2"}}, + [][]string{{"10000000000000000.03", "1"}}, + ); err != nil { + t.Fatalf("ApplySnapshot failed: %v", err) + } + + bids := book.Bids(10) + if len(bids) != 2 || bids[0].Price != "10000000000000000.02" || bids[1].Price != "10000000000000000.01" { + t.Fatalf("expected exact descending price order, got %+v", bids) + } + if err := book.ApplyLevel(true, "10000000000000000.01", "3"); err != nil { + t.Fatalf("ApplyLevel failed: %v", err) + } + bids = book.Bids(10) + if len(bids) != 2 || bids[1].Amount != "3" { + t.Fatalf("expected only the requested exact level to change, got %+v", bids) + } +} + +func TestOrderBook_RejectsMalformedUpdatesAtomically(t *testing.T) { + book := NewOrderBook("BTCUSD") + if err := book.ApplySnapshot(10, [][]string{{"65000", "1"}}, [][]string{{"65100", "1"}}); err != nil { + t.Fatalf("initial snapshot failed: %v", err) + } + + if err := book.ApplySnapshot(11, [][]string{{"65001", "."}}, [][]string{{"65101", "1"}}); err == nil { + t.Fatal("expected malformed snapshot to fail") + } + if book.LastUpdateID() != 10 { + t.Fatalf("malformed snapshot changed update ID to %d", book.LastUpdateID()) + } + if bid, ok := book.BestBid(); !ok || bid.Price != "65000" || bid.Amount != "1" { + t.Fatalf("malformed snapshot changed existing book: %+v", bid) + } + + if err := book.ApplyDiff(11, [][]string{{"65000", "-1"}}, nil); err == nil { + t.Fatal("expected negative amount diff to fail") + } + if book.LastUpdateID() != 10 { + t.Fatalf("malformed diff changed update ID to %d", book.LastUpdateID()) + } + if err := book.ApplyLevel(true, "65000", "0"); err != nil { + t.Fatalf("zero amount should delete a level: %v", err) + } + if _, ok := book.BestBid(); ok { + t.Fatal("expected zero amount update to delete the level") + } +} + +func TestOrderBook_RejectsInvalidSequenceIDs(t *testing.T) { + book := NewOrderBook("BTCUSD") + levels := [][]string{{"65000", "1"}} + + for _, sequenceID := range []int64{0, -1} { + if err := book.ApplySnapshot(sequenceID, levels, nil); !errors.Is(err, ErrInvalidSequence) { + t.Fatalf("ApplySnapshot(%d) error = %v, want ErrInvalidSequence", sequenceID, err) + } + } + if err := book.ApplySnapshot(10, levels, nil); err != nil { + t.Fatalf("initial snapshot failed: %v", err) + } + if err := book.ApplyDiff(9, [][]string{{"65000", "2"}}, nil); !errors.Is(err, ErrInvalidSequence) { + t.Fatalf("retrograde ApplyDiff error = %v, want ErrInvalidSequence", err) + } + if book.LastUpdateID() != 10 { + t.Fatalf("retrograde diff changed update ID to %d", book.LastUpdateID()) + } + + liveBook := NewLiveOrderBook("BTCUSD") + err := liveBook.ApplySnapshot(&websocket.OrderBookSnapshot{ + LastUpdateID: 0, + Bids: levels, + }) + if !errors.Is(err, ErrInvalidSequence) { + t.Fatalf("live invalid snapshot error = %v, want ErrInvalidSequence", err) + } + if liveBook.IsLive() { + t.Fatal("invalid snapshot incorrectly marked the live book as live") + } +} + +func TestLiveOrderBook_SequenceAndGaps(t *testing.T) { + liveBook := NewLiveOrderBook("BTCUSD") + + // 1. Ingest diffs before snapshot (pre-buffering) + _ = liveBook.IngestDiff(&websocket.DepthUpdate{ + FirstUpdateID: 101, + LastUpdateID: 102, + Bids: [][]string{{"64600.00", "1.0"}}, + }) + + if liveBook.IsLive() { + t.Fatal("expected book not to be live before snapshot") + } + + // 2. Ingest snapshot + liveBook.ApplySnapshot(&websocket.OrderBookSnapshot{ + LastUpdateID: 100, + Bids: [][]string{{"64500.00", "0.5"}}, + Asks: [][]string{{"64800.00", "0.8"}}, + }) + + if !liveBook.IsLive() { + t.Fatal("expected book to be live after snapshot and buffer flush") + } + + bestBid, _ := liveBook.Book().BestBid() + if bestBid.Price != "64600.00" { + t.Fatalf("expected buffered diff applied, best bid 64600.00, got %s", bestBid.Price) + } + + // 3. Ingest next contiguous diff + err := liveBook.IngestDiff(&websocket.DepthUpdate{ + FirstUpdateID: 103, + LastUpdateID: 105, + Bids: [][]string{{"64700.00", "2.0"}}, + }) + if err != nil { + t.Fatalf("unexpected error on contiguous diff: %v", err) + } + if liveBook.Book().LastUpdateID() != 105 { + t.Errorf("expected lastUpdateId 105, got %d", liveBook.Book().LastUpdateID()) + } + + // 4. Ingest GAP diff (FirstUpdateID 110 > 105 + 1) + err = liveBook.IngestDiff(&websocket.DepthUpdate{ + FirstUpdateID: 110, + LastUpdateID: 112, + Bids: [][]string{{"64800.00", "1.0"}}, + }) + + if err == nil { + t.Fatal("expected gap error, got nil") + } + if !errors.Is(err, transport.ErrResyncRequired) { + t.Fatalf("expected errors.Is(err, ErrResyncRequired) to be true, got %v", err) + } + if liveBook.IsLive() { + t.Fatal("expected book to be marked not live after gap") + } +} + +func TestOrderBook_EmptyBookEdgeCases(t *testing.T) { + book := NewOrderBook("BTCUSD") + + if _, ok := book.BestBid(); ok { + t.Error("expected BestBid to return false on empty book") + } + if _, ok := book.BestAsk(); ok { + t.Error("expected BestAsk to return false on empty book") + } + if _, ok := book.Spread(); ok { + t.Error("expected Spread to return false on empty book") + } + if _, ok := book.Mid(); ok { + t.Error("expected Mid to return false on empty book") + } + if len(book.Bids(10)) != 0 || len(book.Asks(10)) != 0 { + t.Error("expected 0 bids and asks on empty book") + } + + book.ApplyLevel(true, "64000.00", "1.0") + if _, ok := book.Spread(); ok { + t.Error("expected Spread to return false when asks are empty") + } +} + +func TestLiveOrderBook_ResetAndResync(t *testing.T) { + liveBook := NewLiveOrderBook("BTCUSD") + + // Apply snapshot + liveBook.ApplySnapshot(&websocket.OrderBookSnapshot{ + LastUpdateID: 200, + Bids: [][]string{{"64000.00", "1.0"}}, + Asks: [][]string{{"64100.00", "1.0"}}, + }) + if !liveBook.IsLive() { + t.Fatal("expected book to be live") + } + + // Trigger manual reset (e.g. on disconnect/reconnect) + liveBook.Reset() + if liveBook.IsLive() { + t.Fatal("expected book not to be live after Reset()") + } + if liveBook.Book().LastUpdateID() != 0 { + t.Fatalf("expected Reset to clear last update ID, got %d", liveBook.Book().LastUpdateID()) + } + if _, ok := liveBook.Book().BestBid(); ok { + t.Fatal("expected Reset to clear stale bid levels") + } + + // Apply fresh snapshot + liveBook.ApplySnapshot(&websocket.OrderBookSnapshot{ + LastUpdateID: 300, + Bids: [][]string{{"64200.00", "2.0"}}, + Asks: [][]string{{"64300.00", "2.0"}}, + }) + if !liveBook.IsLive() { + t.Fatal("expected book to be live after fresh snapshot") + } + if liveBook.Book().LastUpdateID() != 300 { + t.Errorf("expected LastUpdateID 300, got %d", liveBook.Book().LastUpdateID()) + } +} + +func TestLiveOrderBook_RejectsInvalidSequenceAndBounds(t *testing.T) { + liveBook := NewLiveOrderBook("BTCUSD") + if err := liveBook.ApplySnapshot(&websocket.OrderBookSnapshot{ + LastUpdateID: 100, + Bids: [][]string{{"65000", "1"}}, + Asks: [][]string{{"65100", "1"}}, + }); err != nil { + t.Fatalf("snapshot failed: %v", err) + } + + err := liveBook.IngestDiff(&websocket.DepthUpdate{ + FirstUpdateID: 101, + LastUpdateID: 100, + }) + if err == nil || !errors.Is(err, transport.ErrResyncRequired) { + t.Fatalf("expected invalid bounds to require resync, got %v", err) + } + if liveBook.IsLive() { + t.Fatal("expected invalid sequence to mark book non-live") + } +} + +func TestLiveOrderBook_BoundedPreSnapshotBuffer(t *testing.T) { + liveBook := NewLiveOrderBook("BTCUSD") + for i := 0; i < maxBufferedDiffs; i++ { + if err := liveBook.IngestDiff(&websocket.DepthUpdate{ + FirstUpdateID: int64(i + 1), + LastUpdateID: int64(i + 2), + }); err != nil { + t.Fatalf("unexpected buffer error at %d: %v", i, err) + } + } + if err := liveBook.IngestDiff(&websocket.DepthUpdate{FirstUpdateID: maxBufferedDiffs + 1, LastUpdateID: maxBufferedDiffs + 2}); err == nil { + t.Fatal("expected pre-snapshot buffer overflow to require resync") + } else if !errors.Is(err, transport.ErrResyncRequired) { + t.Fatalf("expected ErrResyncRequired, got %v", err) + } +} + +func TestLiveOrderBook_DropsMalformedBufferedDiffAfterReplayFailure(t *testing.T) { + liveBook := NewLiveOrderBook("BTCUSD") + if err := liveBook.IngestDiff(&websocket.DepthUpdate{ + FirstUpdateID: 101, + LastUpdateID: 101, + Bids: [][]string{{"not-a-price", "1"}}, + }); err != nil { + t.Fatalf("unexpected pre-snapshot buffering error: %v", err) + } + + if err := liveBook.ApplySnapshot(&websocket.OrderBookSnapshot{ + LastUpdateID: 100, + Bids: [][]string{{"65000", "1"}}, + Asks: [][]string{{"65100", "1"}}, + }); err == nil { + t.Fatal("expected malformed buffered diff to fail replay") + } + if liveBook.IsLive() { + t.Fatal("expected malformed replay to mark the book non-live") + } + + if err := liveBook.ApplySnapshot(&websocket.OrderBookSnapshot{ + LastUpdateID: 200, + Bids: [][]string{{"65200", "1"}}, + Asks: [][]string{{"65300", "1"}}, + }); err != nil { + t.Fatalf("expected a later snapshot to recover after dropping the malformed diff: %v", err) + } + if !liveBook.IsLive() { + t.Fatal("expected later valid snapshot to restore live state") + } +} + +func TestLiveOrderBook_OnBBOChanged(t *testing.T) { + liveBook := NewLiveOrderBook("BTCUSD") + + bboChan := make(chan BBO, 10) + liveBook.OnBBOChanged(func(bbo BBO) { + bboChan <- bbo + }) + + // 1. Initial snapshot triggers first BBO callback + liveBook.ApplySnapshot(&websocket.OrderBookSnapshot{ + LastUpdateID: 100, + Bids: [][]string{{"65000.00", "1.0"}}, + Asks: [][]string{{"65100.00", "1.0"}}, + }) + + select { + case bbo := <-bboChan: + if bbo.BestBid != "65000.00" || bbo.BestAsk != "65100.00" { + t.Errorf("unexpected initial BBO: %+v", bbo) + } + if bbo.Mid != 65050.00 { + t.Errorf("expected mid 65050, got %f", bbo.Mid) + } + if bbo.Spread != 100.00 { + t.Errorf("expected spread 100, got %f", bbo.Spread) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for initial BBO callback") + } + + // 2. Incremental diff with higher bid triggers second BBO callback + _ = liveBook.IngestDiff(&websocket.DepthUpdate{ + LastUpdateID: 101, + FirstUpdateID: 101, + Bids: [][]string{{"65020.00", "2.0"}}, + }) + + select { + case bbo := <-bboChan: + if bbo.BestBid != "65020.00" || bbo.BestAsk != "65100.00" { + t.Errorf("unexpected updated BBO: %+v", bbo) + } + if bbo.Mid != 65060.00 { + t.Errorf("expected mid 65060, got %f", bbo.Mid) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for updated BBO callback") + } + + // 3. CurrentBBO synchronous accessor + current := liveBook.CurrentBBO() + if !current.HasBBO || current.BestBid != "65020.00" { + t.Errorf("unexpected CurrentBBO(): %+v", current) + } +} + +func TestLiveOrderBook_BufferedDiffGapOnSnapshot(t *testing.T) { + liveBook := NewLiveOrderBook("BTCUSD") + + // Pre-buffer a diff with a gap relative to subsequent snapshot (e.g., diff starts at 150) + _ = liveBook.IngestDiff(&websocket.DepthUpdate{ + FirstUpdateID: 150, + LastUpdateID: 155, + Bids: [][]string{{"65000.00", "1.0"}}, + }) + + // Snapshot at sequence 100 (gap between 100 and 150) + liveBook.ApplySnapshot(&websocket.OrderBookSnapshot{ + LastUpdateID: 100, + Bids: [][]string{{"64900.00", "1.0"}}, + Asks: [][]string{{"65100.00", "1.0"}}, + }) + + if liveBook.IsLive() { + t.Fatal("expected book not to be live due to buffered sequence gap") + } + if !errors.Is(liveBook.Err(), transport.ErrResyncRequired) { + t.Fatalf("expected ErrResyncRequired error on gap, got: %v", liveBook.Err()) + } +} + +func TestLiveOrderBook_AutoBootInitialDepthUpdate(t *testing.T) { + liveBook := NewLiveOrderBook("BTCUSD") + + // Ingest Gemini wire snapshot frame where U == u (e.g. 100 == 100) + err := liveBook.IngestDiff(&websocket.DepthUpdate{ + EventType: "depthUpdate", + FirstUpdateID: 100, + LastUpdateID: 100, + Snapshot: true, + Bids: [][]string{{"65000.00", "2.0"}}, + Asks: [][]string{{"65100.00", "1.5"}}, + }) + if err != nil { + t.Fatalf("unexpected error on initial depth update: %v", err) + } + + if !liveBook.IsLive() { + t.Fatal("expected book to be live immediately upon receiving initial wire snapshot (U==u)") + } + + bestBid, ok := liveBook.Book().BestBid() + if !ok || bestBid.Price != "65000.00" { + t.Fatalf("expected best bid 65000.00, got %+v", bestBid) + } + + // Apply contiguous subsequent diff (101 to 102) + err = liveBook.IngestDiff(&websocket.DepthUpdate{ + EventType: "depthUpdate", + FirstUpdateID: 101, + LastUpdateID: 102, + Bids: [][]string{{"65050.00", "3.0"}}, + }) + if err != nil { + t.Fatalf("unexpected error on diff ingest: %v", err) + } + bestBid, ok = liveBook.Book().BestBid() + if !ok || bestBid.Price != "65050.00" { + t.Fatalf("expected updated best bid 65050.00, got %+v", bestBid) + } +} + +func TestLiveOrderBook_SnapshotResetsAnAlreadyLiveBook(t *testing.T) { + liveBook := NewLiveOrderBook("BTCUSD") + if err := liveBook.IngestDiff(&websocket.DepthUpdate{ + FirstUpdateID: 100, + LastUpdateID: 100, + Snapshot: true, + Bids: [][]string{{"65000", "1"}}, + }); err != nil { + t.Fatalf("initial snapshot failed: %v", err) + } + if err := liveBook.IngestDiff(&websocket.DepthUpdate{ + FirstUpdateID: 101, + LastUpdateID: 101, + Bids: [][]string{{"65001", "2"}}, + }); err != nil { + t.Fatalf("initial diff failed: %v", err) + } + + if err := liveBook.IngestDiff(&websocket.DepthUpdate{ + LastUpdateID: 200, + Snapshot: true, + Bids: [][]string{{"64000", "3"}}, + }); err != nil { + t.Fatalf("reconnect snapshot failed: %v", err) + } + if got := liveBook.Book().LastUpdateID(); got != 200 { + t.Fatalf("expected snapshot sequence 200, got %d", got) + } + if _, ok := liveBook.Book().BestBid(); !ok { + t.Fatal("expected a bid after reconnect snapshot") + } + if bid, _ := liveBook.Book().BestBid(); bid.Price != "64000" { + t.Fatalf("expected old book to be replaced, got best bid %+v", bid) + } + if err := liveBook.IngestDiff(&websocket.DepthUpdate{ + FirstUpdateID: 201, + LastUpdateID: 201, + Bids: [][]string{{"64100", "4"}}, + }); err != nil { + t.Fatalf("diff after reconnect snapshot failed: %v", err) + } +} + +func TestLiveOrderBook_DoesNotInferSnapshotFromEqualUpdateBounds(t *testing.T) { + liveBook := NewLiveOrderBook("BTCUSD") + if err := liveBook.IngestDiff(&websocket.DepthUpdate{ + EventType: "depthUpdate", + FirstUpdateID: 100, + LastUpdateID: 100, + Bids: [][]string{{"65000.00", "2.0"}}, + }); err != nil { + t.Fatalf("unexpected error while buffering differential update: %v", err) + } + if liveBook.IsLive() { + t.Fatal("equal U/u differential update must not be treated as a snapshot") + } +} + +func TestOrderBook_ApplyDiffAndIsCrossed(t *testing.T) { + book := NewOrderBook("BTCUSD") + + // Apply batch diff + book.ApplyDiff(10, [][]string{ + {"64000.00", "1.0"}, + {"64100.00", "2.0"}, + }, [][]string{ + {"64200.00", "1.5"}, + {"64300.00", "3.0"}, + }) + + if book.LastUpdateID() != 10 { + t.Fatalf("expected lastUpdateId 10, got %d", book.LastUpdateID()) + } + if book.IsCrossed() { + t.Fatal("expected uncrossed book, got crossed") + } + + bestBid, _ := book.BestBid() + bestAsk, _ := book.BestAsk() + if bestBid.Price != "64100.00" || bestAsk.Price != "64200.00" { + t.Fatalf("unexpected BBO: bid=%s, ask=%s", bestBid.Price, bestAsk.Price) + } + + // Make crossed + book.ApplyLevel(true, "64250.00", "1.0") + if !book.IsCrossed() { + t.Fatal("expected crossed book when bid > ask") + } + + // Test LiveOrderBook BBO IsCrossed flag + liveBook := NewLiveOrderBook("BTCUSD") + liveBook.ApplySnapshot(&websocket.OrderBookSnapshot{ + LastUpdateID: 1, + Bids: [][]string{{"64500.00", "1.0"}}, + Asks: [][]string{{"64400.00", "1.0"}}, // crossed + }) + bbo := liveBook.CurrentBBO() + if !bbo.IsCrossed { + t.Fatal("expected BBO.IsCrossed to be true on crossed book") + } +} + +func TestOrderBook_VWAP_Imbalance_CumulativeDepth(t *testing.T) { + book := NewOrderBook("BTCUSD") + + // Set up bids: + // 60,000 @ 1.0 BTC + // 59,900 @ 2.0 BTC + // 59,800 @ 3.0 BTC + // Total Bid Qty = 6.0 BTC, Notional = 60k*1 + 59.9k*2 + 59.8k*3 = 60,000 + 119,800 + 179,400 = 359,200 + + // Set up asks: + // 60,100 @ 1.0 BTC + // 60,200 @ 2.0 BTC + // 60,300 @ 3.0 BTC + // Total Ask Qty = 6.0 BTC, Notional = 60.1k*1 + 60.2k*2 + 60.3k*3 = 60,100 + 120,400 + 180,900 = 361,400 + + book.ApplySnapshot(1, [][]string{ + {"60000.00", "1.0"}, + {"59900.00", "2.0"}, + {"59800.00", "3.0"}, + }, [][]string{ + {"60100.00", "1.0"}, + {"60200.00", "2.0"}, + {"60300.00", "3.0"}, + }) + + // 1. Test Imbalance + // Equal 6.0 bids and 6.0 asks -> Imbalance = 0.0 + imbalance, ok := book.Imbalance(3) + if !ok || imbalance != 0.0 { + t.Fatalf("expected 0.0 imbalance, got %f (ok=%v)", imbalance, ok) + } + + // Add more bids: 60,050 @ 4.0 -> Bids = 10.0, Asks = 6.0 -> (10-6)/(10+6) = 4/16 = +0.25 + book.ApplyLevel(true, "60050.00", "4.0") + imbalance, ok = book.Imbalance(4) + if !ok || imbalance != 0.25 { + t.Fatalf("expected +0.25 imbalance, got %f", imbalance) + } + + // 2. Test CumulativeDepth + bidQty, bidNotional := book.CumulativeDepth(true, 2) + // Top 2 bids: 60050@4.0 + 60000@1.0 = 5.0 Qty, Notional = 240,200 + 60,000 = 300,200 + if bidQty.String() != "5" || bidNotional.String() != "300200" { + t.Fatalf("unexpected CumulativeDepth bids: qty=%s, notional=%s", bidQty.String(), bidNotional.String()) + } + + // 3. Test VWAP Buy + // Sweeping 2.5 BTC of asks: + // 1.0 BTC @ 60,100 = 60,100 + // 1.5 BTC @ 60,200 = 90,300 + // Total Notional = 150,400, Total Qty = 2.5 + // VWAP = 150,400 / 2.5 = 60,160 + fillBuy, err := book.VWAP(true, types.MustParseDecimal("2.5")) + if err != nil { + t.Fatalf("unexpected error in VWAP buy: %v", err) + } + if fillBuy.FilledQuantity.String() != "2.5" { + t.Fatalf("expected filled qty 2.5, got %s", fillBuy.FilledQuantity.String()) + } + if fillBuy.TotalNotional.String() != "150400" { + t.Fatalf("expected total notional 150400, got %s", fillBuy.TotalNotional.String()) + } + if fillBuy.AveragePrice.String() != "60160" { + t.Fatalf("expected average price 60160, got %s", fillBuy.AveragePrice.String()) + } + if fillBuy.LevelsConsumed != 2 { + t.Fatalf("expected 2 levels consumed, got %d", fillBuy.LevelsConsumed) + } + if fillBuy.RemainingQty.IsPositive() { + t.Fatalf("expected 0 remaining qty, got %s", fillBuy.RemainingQty.String()) + } + + // 4. Test VWAP Partial Fill (Requested quantity exceeds book depth) + fillOversized, err := book.VWAP(true, types.MustParseDecimal("10.0")) + if err != nil { + t.Fatalf("unexpected error in oversized VWAP: %v", err) + } + if fillOversized.FilledQuantity.String() != "6" { + t.Fatalf("expected filled qty 6 (all asks), got %s", fillOversized.FilledQuantity.String()) + } + if fillOversized.RemainingQty.String() != "4" { + t.Fatalf("expected remaining qty 4, got %s", fillOversized.RemainingQty.String()) + } +} + +func TestOrderBook_ImbalanceAcceptsOneSidedBooks(t *testing.T) { + tests := []struct { + name string + bids, asks [][]string + want float64 + }{ + { + name: "bids only", + bids: [][]string{{"60000", "2"}}, + want: 1, + }, + { + name: "asks only", + asks: [][]string{{"60000", "2"}}, + want: -1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + book := NewOrderBook("BTCUSD") + if err := book.ApplySnapshot(1, tt.bids, tt.asks); err != nil { + t.Fatalf("ApplySnapshot failed: %v", err) + } + got, ok := book.Imbalance(0) + if !ok || got != tt.want { + t.Fatalf("expected imbalance %v (ok=true), got %v (ok=%v)", tt.want, got, ok) + } + }) + } +} + +func TestOrderBook_SetLastUpdateID(t *testing.T) { + book := NewOrderBook("BTCUSD") + book.SetLastUpdateID(42) + if got := book.LastUpdateID(); got != 42 { + t.Fatalf("expected last update ID 42, got %d", got) + } +} + +func TestLiveOrderBook_ApplySnapshotFromDepthUpdateAndRun(t *testing.T) { + liveBook := NewLiveOrderBook("BTCUSD") + if err := liveBook.ApplySnapshotFromDepthUpdate(nil); err == nil { + t.Fatal("expected nil depth update to fail") + } + if err := liveBook.ApplySnapshotFromDepthUpdate(&websocket.DepthUpdate{ + Symbol: "BTCUSD", + FirstUpdateID: 100, + LastUpdateID: 100, + Bids: [][]string{{"65000", "1"}}, + Asks: [][]string{{"65100", "1"}}, + }); err != nil { + t.Fatalf("ApplySnapshotFromDepthUpdate failed: %v", err) + } + + updated := make(chan struct{}, 1) + liveBook.OnBBOChanged(func(BBO) { updated <- struct{}{} }) + updates := make(chan *websocket.DepthUpdate, 1) + ctx, cancel := context.WithCancel(context.Background()) + runDone := make(chan error, 1) + go func() { runDone <- liveBook.Run(ctx, updates) }() + updates <- &websocket.DepthUpdate{ + FirstUpdateID: 101, + LastUpdateID: 101, + Bids: [][]string{{"65010", "2"}}, + } + select { + case <-updated: + cancel() + case <-time.After(time.Second): + cancel() + t.Fatal("Run did not process the depth update") + } + select { + case err := <-runDone: + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected Run to return context cancellation, got %v", err) + } + case <-time.After(time.Second): + t.Fatal("Run did not stop after context cancellation") + } + + closed := make(chan *websocket.DepthUpdate) + close(closed) + if err := liveBook.Run(context.Background(), closed); !errors.Is(err, transport.ErrConnectionClosed) { + t.Fatalf("expected closed update channel error, got %v", err) + } +} + +func TestLiveOrderBook_BBOListenerUnregister(t *testing.T) { + liveBook := NewLiveOrderBook("BTCUSD") + if unregister := liveBook.OnBBOChanged(nil); unregister == nil { + t.Fatal("expected nil callback to return a no-op unregister function") + } + + updates := make(chan BBO, 2) + unregister := liveBook.OnBBOChanged(func(bbo BBO) { updates <- bbo }) + if err := liveBook.ApplySnapshot(&websocket.OrderBookSnapshot{ + LastUpdateID: 1, + Bids: [][]string{{"100", "1"}}, + Asks: [][]string{{"101", "1"}}, + }); err != nil { + t.Fatalf("snapshot failed: %v", err) + } + select { + case <-updates: + case <-time.After(time.Second): + t.Fatal("expected initial BBO callback") + } + unregister() + unregister() + + if err := liveBook.IngestDiff(&websocket.DepthUpdate{ + FirstUpdateID: 2, + LastUpdateID: 2, + Bids: [][]string{{"102", "1"}}, + }); err != nil { + t.Fatalf("diff failed: %v", err) + } + select { + case bbo := <-updates: + t.Fatalf("received callback after unregister: %+v", bbo) + default: + } +} diff --git a/packages/sdk-go/websocket/orderbook/live_book.go b/packages/sdk-go/websocket/orderbook/live_book.go new file mode 100644 index 00000000..91d9dc1d --- /dev/null +++ b/packages/sdk-go/websocket/orderbook/live_book.go @@ -0,0 +1,443 @@ +package orderbook + +import ( + "context" + "errors" + "fmt" + "slices" + "strings" + "sync" + + "github.com/gemini/gemini-go/transport" + "github.com/gemini/gemini-go/types" + "github.com/gemini/gemini-go/websocket" +) + +// BBO represents the top of the order book and derived metrics. +type BBO struct { + Symbol string + BestBid string + BestAsk string + Mid float64 + Spread float64 + SpreadBps float64 + HasBBO bool + IsCrossed bool +} + +// LiveOrderBook coordinates real-time order book snapshots, diff processing, and sequence gap recovery. +type LiveOrderBook struct { + book *OrderBook + mu sync.RWMutex + live bool + err error + diffQueue []*websocket.DepthUpdate + bboListeners []bboListener + nextListenerID uint64 + lastBBO BBO +} + +// OrderBookView exposes the read-only operations available from a live book. +// LiveOrderBook.Book returns this view so callers cannot mutate sequence state +// behind the live reconciler's recovery and synchronization rules. +type OrderBookView interface { + Symbol() string + LastUpdateID() int64 + BestBid() (PriceLevel, bool) + BestAsk() (PriceLevel, bool) + IsCrossed() bool + Spread() (float64, bool) + Mid() (float64, bool) + Bids(depth int) []PriceLevel + Asks(depth int) []PriceLevel + VWAP(isBuy bool, targetQty types.Decimal) (SimulatedFill, error) + Imbalance(depth int) (float64, bool) + CumulativeDepth(isBid bool, depth int) (totalQty types.Decimal, totalNotional types.Decimal) +} + +type bboListener struct { + id uint64 + callback func(BBO) +} + +const maxBufferedDiffs = 4096 + +// ErrSymbolMismatch indicates that an update was applied to an order book +// for a different market. +var ErrSymbolMismatch = errors.New("gemini order book: symbol mismatch") + +// NewLiveOrderBook creates a new LiveOrderBook manager. +func NewLiveOrderBook(symbol string) *LiveOrderBook { + return &LiveOrderBook{ + book: NewOrderBook(symbol), + diffQueue: make([]*websocket.DepthUpdate, 0, 128), + bboListeners: make([]bboListener, 0, 4), + } +} + +// Book returns a read-only view of the synchronized order book. +func (l *LiveOrderBook) Book() OrderBookView { + return l.book +} + +// IsLive returns true if the book has ingested a baseline snapshot and is actively synchronized. +func (l *LiveOrderBook) IsLive() bool { + l.mu.RLock() + defer l.mu.RUnlock() + return l.live +} + +// Err returns any unrecoverable synchronization error encountered by the live book. +func (l *LiveOrderBook) Err() error { + l.mu.RLock() + defer l.mu.RUnlock() + return l.err +} + +// Reset resets the live state and clears buffered diffs. +func (l *LiveOrderBook) Reset() { + l.mu.Lock() + defer l.mu.Unlock() + l.live = false + l.err = nil + l.diffQueue = l.diffQueue[:0] + l.lastBBO = BBO{} + l.book.Clear() +} + +// CurrentBBO returns the current top-of-book metrics. +func (l *LiveOrderBook) CurrentBBO() BBO { + l.mu.RLock() + defer l.mu.RUnlock() + return l.currentBBOLocked() +} + +func (l *LiveOrderBook) currentBBOLocked() BBO { + bid, hasBid := l.book.BestBid() + ask, hasAsk := l.book.BestAsk() + + bbo := BBO{ + Symbol: l.book.Symbol(), + } + if hasBid && hasAsk { + bbo.BestBid = bid.Price + bbo.BestAsk = ask.Price + bbo.HasBBO = true + bbo.Spread = ask.val - bid.val + bbo.Mid = (ask.val + bid.val) / 2.0 + bbo.IsCrossed = bid.decimal.Cmp(ask.decimal) >= 0 + if bbo.Mid > 0 { + bbo.SpreadBps = (bbo.Spread / bbo.Mid) * 10000.0 + } + } else if hasBid { + bbo.BestBid = bid.Price + } else if hasAsk { + bbo.BestAsk = ask.Price + } + return bbo +} + +// OnBBOChanged registers a callback that triggers when top-of-book prices or +// spread change. Callbacks run synchronously after an update and should be +// short and non-blocking. The returned function unregisters the callback. +func (l *LiveOrderBook) OnBBOChanged(callback func(BBO)) func() { + if callback == nil { + return func() {} + } + l.mu.Lock() + l.nextListenerID++ + id := l.nextListenerID + l.bboListeners = append(l.bboListeners, bboListener{id: id, callback: callback}) + l.mu.Unlock() + return func() { + l.mu.Lock() + defer l.mu.Unlock() + for i, listener := range l.bboListeners { + if listener.id == id { + l.bboListeners = append(l.bboListeners[:i], l.bboListeners[i+1:]...) + return + } + } + } +} + +func (l *LiveOrderBook) checkBBOChangeLocked() ([]func(BBO), BBO, bool) { + if len(l.bboListeners) == 0 { + return nil, BBO{}, false + } + newBBO := l.currentBBOLocked() + if newBBO.BestBid != l.lastBBO.BestBid || newBBO.BestAsk != l.lastBBO.BestAsk { + l.lastBBO = newBBO + listeners := make([]func(BBO), len(l.bboListeners)) + for i, listener := range l.bboListeners { + listeners[i] = listener.callback + } + return listeners, newBBO, true + } + return nil, BBO{}, false +} + +func notifyListeners(listeners []func(BBO), bbo BBO) { + for _, cb := range listeners { + if cb != nil { + cb(bbo) + } + } +} + +// ApplySnapshot ingests a fresh snapshot and applies buffered diffs. +func (l *LiveOrderBook) ApplySnapshot(snapshot *websocket.OrderBookSnapshot) error { + if snapshot == nil { + return fmt.Errorf("gemini order book: nil snapshot") + } + if err := validateSymbol(l.book.Symbol(), snapshot.Symbol); err != nil { + return err + } + l.mu.Lock() + + if err := l.book.ApplySnapshot(snapshot.LastUpdateID, snapshot.Bids, snapshot.Asks); err != nil { + l.live = false + l.err = err + l.mu.Unlock() + return err + } + l.live = true + l.err = nil + + if err := l.replayQueuedDiffsLocked(); err != nil { + l.live = false + l.err = err + } + listeners, bbo, changed := l.checkBBOChangeLocked() + err := l.err + l.mu.Unlock() + + if changed { + notifyListeners(listeners, bbo) + } + return err +} + +// ApplySnapshotFromDepthUpdate boots the live order book directly from an initial depth update frame. +func (l *LiveOrderBook) ApplySnapshotFromDepthUpdate(update *websocket.DepthUpdate) error { + if update == nil { + return fmt.Errorf("gemini order book: nil depth update") + } + return l.ApplySnapshot(&websocket.OrderBookSnapshot{ + EventType: update.EventType, + EventTime: update.EventTime, + Symbol: update.Symbol, + LastUpdateID: update.LastUpdateID, + Bids: update.Bids, + Asks: update.Asks, + }) +} + +// IngestDiff processes an incremental depth update or initial snapshot from the WebSocket feed. +func (l *LiveOrderBook) IngestDiff(diff *websocket.DepthUpdate) error { + if diff == nil { + return nil + } + if err := validateSymbol(l.book.Symbol(), diff.Symbol); err != nil { + return err + } + l.mu.Lock() + + // A snapshot is a new sequence baseline even when the previous stream was + // live (for example after reconnect). Apply it before the live/diff branch; + // treating it as an incremental update can reject a valid snapshot as stale + // or create a false sequence gap. + if diff.Snapshot { + if diff.LastUpdateID <= 0 { + l.live = false + l.err = &transport.ResyncRequiredError{ + LastUpdateID: l.book.LastUpdateID(), + FirstUpdateID: diff.FirstUpdateID, + } + err := l.err + l.mu.Unlock() + return err + } + if err := l.book.ApplySnapshot(diff.LastUpdateID, diff.Bids, diff.Asks); err != nil { + l.live = false + l.err = err + l.mu.Unlock() + return err + } + l.live = true + l.err = nil + if err := l.replayQueuedDiffsLocked(); err != nil { + l.live = false + l.err = err + } + listeners, bbo, changed := l.checkBBOChangeLocked() + err := l.err + l.mu.Unlock() + if changed { + notifyListeners(listeners, bbo) + } + return err + } + + if !l.live { + // Snapshot mode is selected on the connection URL. Do not infer a + // snapshot from U == u: ordinary differential updates may have that + // shape as well. + if diff.FirstUpdateID <= 0 || diff.LastUpdateID < diff.FirstUpdateID { + l.err = &transport.ResyncRequiredError{ + LastUpdateID: l.book.LastUpdateID(), + FirstUpdateID: diff.FirstUpdateID, + } + err := l.err + l.mu.Unlock() + return err + } + // Buffer incoming diffs while waiting for initial snapshot. + if len(l.diffQueue) >= maxBufferedDiffs { + l.live = false + l.err = fmt.Errorf("%w: buffered diff queue exceeded %d updates", transport.ErrResyncRequired, maxBufferedDiffs) + err := l.err + l.mu.Unlock() + return err + } + l.diffQueue = append(l.diffQueue, diff) + l.mu.Unlock() + return nil + } + + lastID := l.book.LastUpdateID() + + // 1. Validate the update's own bounds first. + if diff.FirstUpdateID <= 0 || diff.LastUpdateID < diff.FirstUpdateID { + l.live = false + l.err = &transport.ResyncRequiredError{ + LastUpdateID: lastID, + FirstUpdateID: diff.FirstUpdateID, + } + err := l.err + l.mu.Unlock() + return err + } + + // 2. Drop stale diff + if diff.LastUpdateID <= lastID { + l.mu.Unlock() + return nil + } + + // 3. Validate sequence continuity (no gaps). + nextID := nextUpdateID(lastID) + if diff.FirstUpdateID > nextID { + l.live = false + l.err = &transport.ResyncRequiredError{ + LastUpdateID: lastID, + FirstUpdateID: diff.FirstUpdateID, + } + err := l.err + l.mu.Unlock() + return err + } + + if err := l.applyDiffLocked(diff); err != nil { + l.live = false + l.err = err + l.mu.Unlock() + return err + } + listeners, bbo, changed := l.checkBBOChangeLocked() + l.mu.Unlock() + + if changed { + notifyListeners(listeners, bbo) + } + return nil +} + +func validateSymbol(bookSymbol, updateSymbol string) error { + bookSymbol = strings.TrimSpace(bookSymbol) + updateSymbol = strings.TrimSpace(updateSymbol) + if bookSymbol == "" || updateSymbol == "" || strings.EqualFold(bookSymbol, updateSymbol) { + return nil + } + return fmt.Errorf("%w: book=%q update=%q", ErrSymbolMismatch, bookSymbol, updateSymbol) +} + +func (l *LiveOrderBook) applyDiffLocked(diff *websocket.DepthUpdate) error { + if diff.FirstUpdateID <= 0 || diff.LastUpdateID < diff.FirstUpdateID { + return &transport.ResyncRequiredError{ + LastUpdateID: l.book.LastUpdateID(), + FirstUpdateID: diff.FirstUpdateID, + } + } + return l.book.ApplyDiff(diff.LastUpdateID, diff.Bids, diff.Asks) +} + +func (l *LiveOrderBook) replayQueuedDiffsLocked() error { + if len(l.diffQueue) == 0 { + return nil + } + slices.SortStableFunc(l.diffQueue, func(a, b *websocket.DepthUpdate) int { + if a.FirstUpdateID < b.FirstUpdateID { + return -1 + } + if a.FirstUpdateID > b.FirstUpdateID { + return 1 + } + return 0 + }) + remaining := make([]*websocket.DepthUpdate, 0, len(l.diffQueue)) + for i, diff := range l.diffQueue { + lastID := l.book.LastUpdateID() + if diff.LastUpdateID <= lastID { + continue + } + nextID := nextUpdateID(lastID) + if diff.FirstUpdateID > nextID { + remaining = append(remaining, diff) + continue + } + if err := l.applyDiffLocked(diff); err != nil { + // Drop the malformed or otherwise unusable event while retaining + // later events for the next snapshot. Retaining the failing event + // would make every subsequent snapshot fail in the same place until + // the caller performed a full Reset. + l.diffQueue = append(remaining, l.diffQueue[i+1:]...) + return err + } + } + l.diffQueue = remaining + if len(remaining) > 0 { + return &transport.ResyncRequiredError{ + LastUpdateID: l.book.LastUpdateID(), + FirstUpdateID: remaining[0].FirstUpdateID, + } + } + return nil +} + +func nextUpdateID(lastID int64) int64 { + if lastID == int64(^uint64(0)>>1) { + return lastID + } + return lastID + 1 +} + +// Run pumps updates from a WebSocket channel into the LiveOrderBook. +func (l *LiveOrderBook) Run(ctx context.Context, depthUpdates <-chan *websocket.DepthUpdate) error { + if ctx == nil { + ctx = context.Background() + } + for { + select { + case <-ctx.Done(): + return ctx.Err() + case update, ok := <-depthUpdates: + if !ok { + return transport.ErrConnectionClosed + } + if err := l.IngestDiff(update); err != nil { + return err + } + } + } +} diff --git a/packages/sdk-go/websocket/rfq.go b/packages/sdk-go/websocket/rfq.go new file mode 100644 index 00000000..ba0bbc22 --- /dev/null +++ b/packages/sdk-go/websocket/rfq.go @@ -0,0 +1,418 @@ +package websocket + +import ( + "context" + "errors" + "fmt" + "strings" +) + +// RFQLifecycleState is the lifecycle state carried by RFQ events. +type RFQLifecycleState string + +const ( + RFQStateOpen RFQLifecycleState = "OPEN" + RFQStatePendingAcceptance RFQLifecycleState = "PENDING_ACCEPTANCE" + RFQStateConfirming RFQLifecycleState = "CONFIRMING" + RFQStateFinalizing RFQLifecycleState = "FINALIZING" + RFQStateFinalized RFQLifecycleState = "FINALIZED" + RFQStateCancelled RFQLifecycleState = "CANCELLED" + RFQStateExpired RFQLifecycleState = "EXPIRED" + RFQStateFailed RFQLifecycleState = "FAILED" +) + +// RFQQuoteStatus is the status of a quote on an authenticated RFQ delivery. +type RFQQuoteStatus string + +const ( + RFQQuoteActive RFQQuoteStatus = "ACTIVE" + RFQQuoteWithdrawn RFQQuoteStatus = "WITHDRAWN" + RFQQuoteExpired RFQQuoteStatus = "EXPIRED" + RFQQuoteWon RFQQuoteStatus = "WON" + RFQQuoteLost RFQQuoteStatus = "LOST" +) + +// RFQQuoteTransition identifies the customer-visible transition in a private +// RFQ delivery. +type RFQQuoteTransition string + +const ( + RFQTransitionClosed RFQQuoteTransition = "CLOSED" + RFQTransitionAccepted RFQQuoteTransition = "ACCEPTED" + RFQTransitionConfirmed RFQQuoteTransition = "CONFIRMED" + RFQTransitionDeclined RFQQuoteTransition = "DECLINED" + RFQTransitionFinalized RFQQuoteTransition = "FINALIZED" + RFQTransitionFailed RFQQuoteTransition = "FAILED" +) + +// RFQLeg describes one contract and outcome in a combo RFQ. +type RFQLeg struct { + ContractID string `json:"c"` + Outcome string `json:"o"` + InstrumentSymbol *string `json:"s,omitempty"` +} + +// RFQPublicEvent is an anonymous public combo RFQ discovery or lifecycle event. +// The public feed never contains quote contents or participant identities. +type RFQPublicEvent struct { + EventType string `json:"e"` + EventTime int64 `json:"E"` + RFQID string `json:"r"` + Symbol *string `json:"s,omitempty"` + Legs []RFQLeg `json:"l"` + Notional *string `json:"n,omitempty"` + Quantity *string `json:"q,omitempty"` + ExecutionQty *string `json:"f,omitempty"` + State RFQLifecycleState `json:"S"` + QuoteDeadline *int64 `json:"w,omitempty"` + ExpiryTime *int64 `json:"x,omitempty"` + CreatedAt *int64 `json:"c,omitempty"` +} + +// RFQPrivateDelivery is an authenticated, at-least-once RFQ lifecycle delivery. +// DeliveryID is the durable idempotency key for consumer-side deduplication. +type RFQPrivateDelivery struct { + EventType string `json:"e"` + DeliveryID string `json:"i"` + EventTime int64 `json:"E"` + RFQID string `json:"r"` + Transition RFQQuoteTransition `json:"x"` + State RFQLifecycleState `json:"S"` + QuoteID *string `json:"q,omitempty"` + Price *string `json:"p,omitempty"` + Quantity *string `json:"sz,omitempty"` + QuoteStatus *RFQQuoteStatus `json:"qs,omitempty"` + ValidUntil *int64 `json:"vu,omitempty"` +} + +// RFQSubmitQuoteParams contains the immutable quote submitted by a maker. +type RFQSubmitQuoteParams struct { + RFQID string `json:"rfqId"` + Price string `json:"price"` + Quantity string `json:"quantity"` + ValidUntil *int64 `json:"validUntil,omitempty"` +} + +// RFQWithdrawQuoteParams identifies a maker quote to withdraw. +type RFQWithdrawQuoteParams struct { + RFQID string `json:"rfqId"` + QuoteID string `json:"quoteId"` +} + +// RFQConfirmQuoteParams contains the winning maker's last-look decision. +type RFQConfirmQuoteParams struct { + RFQID string `json:"rfqId"` + QuoteID string `json:"quoteId"` + Confirm bool `json:"confirm"` +} + +// RFQQuoteResponse is returned by submit and withdraw operations. +type RFQQuoteResponse struct { + RFQID string `json:"rfqId"` + QuoteID string `json:"quoteId"` +} + +// RFQConfirmQuoteResponse is returned by the confirm operation. +type RFQConfirmQuoteResponse struct { + RFQID string `json:"rfqId"` + QuoteID string `json:"quoteId"` + Confirmed bool `json:"confirmed"` +} + +// ErrRFQScopeConflict indicates that both mutually exclusive authenticated +// RFQ delivery scopes were requested on the same WebSocket connection. +var ErrRFQScopeConflict = errors.New("gemini websocket: RFQ account and session scopes are mutually exclusive") + +// ErrInvalidRFQParams indicates that an RFQ quote request is missing a +// required identifier or contains a value outside the wire contract. +var ErrInvalidRFQParams = errors.New("gemini websocket: invalid RFQ parameters") + +// SubscribeRFQEvents subscribes to the public combo RFQ discovery stream. +func (c *Client) SubscribeRFQEvents(ctx context.Context) (<-chan *RFQPublicEvent, error) { + return subscribeGlobalFeed(ctx, c, "requestForQuote", 256, + func(t *subTables) map[string][]*subscription[RFQPublicEvent] { return t.rfqPublicSubs }, + func(t *subTables, m map[string][]*subscription[RFQPublicEvent]) { t.rfqPublicSubs = m }) +} + +// UnsubscribeRFQEvents removes all public combo RFQ subscriptions. +func (c *Client) UnsubscribeRFQEvents(ctx context.Context) error { + return unsubscribeGlobalFeed(ctx, c, "requestForQuote", + func(t *subTables) map[string][]*subscription[RFQPublicEvent] { return t.rfqPublicSubs }, + func(t *subTables, m map[string][]*subscription[RFQPublicEvent]) { t.rfqPublicSubs = m }) +} + +// SubscribeRFQDeliveries subscribes to authenticated RFQ deliveries for the +// account or session scope. The account and session streams are mutually +// exclusive on a connection according to the API contract. +func (c *Client) SubscribeRFQDeliveries(ctx context.Context, scope SubscriptionScope) (<-chan *RFQPrivateDelivery, error) { + if scope != ScopeAccount && scope != ScopeSession { + return nil, fmt.Errorf("gemini websocket: RFQ stream scope must be account or session") + } + c.rfqScopeMu.Lock() + defer c.rfqScopeMu.Unlock() + if err := c.ensureRFQScopeAvailable(scope); err != nil { + return nil, err + } + return subscribePrivateFeed(ctx, c, "requestForQuote@"+string(scope), 256, + func(t *subTables) map[string][]*subscription[RFQPrivateDelivery] { return t.rfqPrivateSubs }, + func(t *subTables, m map[string][]*subscription[RFQPrivateDelivery]) { t.rfqPrivateSubs = m }) +} + +// UnsubscribeRFQDeliveries removes all authenticated RFQ subscriptions for a scope. +func (c *Client) UnsubscribeRFQDeliveries(ctx context.Context, scope SubscriptionScope) error { + if scope != ScopeAccount && scope != ScopeSession { + return fmt.Errorf("gemini websocket: RFQ stream scope must be account or session") + } + c.rfqScopeMu.Lock() + defer c.rfqScopeMu.Unlock() + return unsubscribePrivateFeed(ctx, c, "requestForQuote@"+string(scope), + func(t *subTables) map[string][]*subscription[RFQPrivateDelivery] { return t.rfqPrivateSubs }, + func(t *subTables, m map[string][]*subscription[RFQPrivateDelivery]) { t.rfqPrivateSubs = m }) +} + +func (c *Client) ensureRFQScopeAvailable(scope SubscriptionScope) error { + requestedFeed := "requestForQuote@" + string(scope) + tables := c.subTables.Load() + if tables == nil { + return nil + } + for feed, subscriptions := range tables.rfqPrivateSubs { + if feed != requestedFeed && len(subscriptions) > 0 { + return fmt.Errorf("%w: %s is already active", ErrRFQScopeConflict, feed) + } + } + return nil +} + +// SubmitRFQQuote submits an immutable quote on an open RFQ. +func (c *Client) SubmitRFQQuote(ctx context.Context, params RFQSubmitQuoteParams) (*RFQQuoteResponse, error) { + var result RFQQuoteResponse + if err := c.requestTypedRFQ(ctx, "rfq.submit_quote", params, &result); err != nil { + return nil, err + } + return &result, nil +} + +// WithdrawRFQQuote permanently withdraws a maker quote while the RFQ is open. +func (c *Client) WithdrawRFQQuote(ctx context.Context, params RFQWithdrawQuoteParams) (*RFQQuoteResponse, error) { + var result RFQQuoteResponse + if err := c.requestTypedRFQ(ctx, "rfq.withdraw_quote", params, &result); err != nil { + return nil, err + } + return &result, nil +} + +// ConfirmRFQQuote confirms or declines the winning maker quote. +func (c *Client) ConfirmRFQQuote(ctx context.Context, params RFQConfirmQuoteParams) (*RFQConfirmQuoteResponse, error) { + var result RFQConfirmQuoteResponse + if err := c.requestTypedRFQ(ctx, "rfq.confirm_quote", params, &result); err != nil { + return nil, err + } + return &result, nil +} + +func (c *Client) requestTypedRFQ(ctx context.Context, method string, params any, target any) error { + if c.auth == nil { + return ErrAuthenticationRequired + } + if err := validateRFQQuoteParams(params); err != nil { + return err + } + response, err := c.Request(ctx, method, params) + if err != nil { + return err + } + if len(response.Result) == 0 { + return fmt.Errorf("gemini websocket: %s response missing result", method) + } + if err := response.DecodeResult(target); err != nil { + return fmt.Errorf("gemini websocket: decoding %s response: %w", method, err) + } + return nil +} + +func validateRFQQuoteParams(params any) error { + invalid := func(reason string) error { + return fmt.Errorf("%w: %s", ErrInvalidRFQParams, reason) + } + + switch params := params.(type) { + case RFQSubmitQuoteParams: + if strings.TrimSpace(params.RFQID) == "" { + return invalid("rfqId is required") + } + if !validRFQPlainDecimal(¶ms.Price) { + return invalid("price must be a decimal string") + } + if !validRFQPlainDecimal(¶ms.Quantity) { + return invalid("quantity must be a decimal string") + } + if params.ValidUntil != nil && *params.ValidUntil < 0 { + return invalid("validUntil must be non-negative") + } + case RFQWithdrawQuoteParams: + if strings.TrimSpace(params.RFQID) == "" { + return invalid("rfqId is required") + } + if strings.TrimSpace(params.QuoteID) == "" { + return invalid("quoteId is required") + } + case RFQConfirmQuoteParams: + if strings.TrimSpace(params.RFQID) == "" { + return invalid("rfqId is required") + } + if strings.TrimSpace(params.QuoteID) == "" { + return invalid("quoteId is required") + } + default: + return invalid("unsupported parameter type") + } + return nil +} + +func subscribeGlobalFeed[T any]( + ctx context.Context, + c *Client, + feedName string, + chBuf int, + getMap func(*subTables) map[string][]*subscription[T], + setMap func(*subTables, map[string][]*subscription[T]), +) (<-chan *T, error) { + if err := c.Connect(ctx); err != nil { + return nil, err + } + sub := newSubscription[T](chBuf) + req := RequestFrame{ID: globalReqID.Add(1), Method: "SUBSCRIBE", Params: []string{feedName}} + if err := c.acquireSubscriptionWire(ctx, feedName); err != nil { + return nil, err + } + defer c.releaseSubscriptionWire(feedName) + if err := c.acquireSubscriptionReplay(ctx); err != nil { + return nil, err + } + c.subsMu.Lock() + oldTables := c.subTables.Load() + oldMap := getMap(oldTables) + needWireSubscribe := len(oldMap[feedName]) == 0 + newTables := oldTables.clone() + newMap := cloneMapSlice(oldMap) + newMap[feedName] = appendSubscription(oldMap[feedName], sub) + setMap(newTables, newMap) + c.subTables.Store(newTables) + if needWireSubscribe { + c.activeFeeds[feedName] = req + } + c.subsMu.Unlock() + c.releaseSubscriptionReplay() + + if needWireSubscribe { + if _, err := c.requestConnected(ctx, req.Method, req.Params); err != nil { + removeGlobalSubscription(c, feedName, sub, getMap, setMap) + return nil, fmt.Errorf("gemini websocket: subscribe send failed: %w", err) + } + } + return sub.ch, nil +} + +func unsubscribeGlobalFeed[T any]( + ctx context.Context, + c *Client, + feedName string, + getMap func(*subTables) map[string][]*subscription[T], + setMap func(*subTables, map[string][]*subscription[T]), +) error { + return unsubscribePrivateFeed(ctx, c, feedName, getMap, setMap) +} + +func removeGlobalSubscription[T any]( + c *Client, + feedName string, + target *subscription[T], + getMap func(*subTables) map[string][]*subscription[T], + setMap func(*subTables, map[string][]*subscription[T]), +) { + removePrivateSubscription(c, feedName, target, getMap, setMap) +} + +func validRFQLifecycleState(state RFQLifecycleState) bool { + switch state { + case RFQStateOpen, RFQStatePendingAcceptance, RFQStateConfirming, RFQStateFinalizing, + RFQStateFinalized, RFQStateCancelled, RFQStateExpired, RFQStateFailed: + return true + default: + return false + } +} + +func validRFQQuoteStatus(status RFQQuoteStatus) bool { + switch status { + case RFQQuoteActive, RFQQuoteWithdrawn, RFQQuoteExpired, RFQQuoteWon, RFQQuoteLost: + return true + default: + return false + } +} + +func validRFQPlainDecimal(value *string) bool { + if value == nil || *value == "" { + return value == nil + } + hasDigit := false + hasDot := false + for i := 0; i < len(*value); i++ { + char := (*value)[i] + if char >= '0' && char <= '9' { + hasDigit = true + continue + } + if char == '.' && !hasDot { + hasDot = true + continue + } + return false + } + return hasDigit +} + +func validRFQNonEmptyString(value *string) bool { + return value == nil || *value != "" +} + +func validRFQPublicEvent(event *RFQPublicEvent) bool { + if event.EventType != "requestForQuote" || event.EventTime < 0 || event.RFQID == "" || + !validRFQLifecycleState(event.State) || !validRFQNonEmptyString(event.Symbol) || + !validRFQPlainDecimal(event.Notional) || !validRFQPlainDecimal(event.Quantity) || + !validRFQPlainDecimal(event.ExecutionQty) || (event.Notional != nil && event.Quantity != nil) { + return false + } + for _, leg := range event.Legs { + if leg.ContractID == "" || (leg.Outcome != "YES" && leg.Outcome != "NO") || !validRFQNonEmptyString(leg.InstrumentSymbol) { + return false + } + } + for _, timestamp := range []*int64{event.QuoteDeadline, event.ExpiryTime, event.CreatedAt} { + if timestamp != nil && *timestamp < 0 { + return false + } + } + return true +} + +func validRFQPrivateDelivery(delivery *RFQPrivateDelivery) bool { + if delivery.EventType != "requestForQuote" || delivery.DeliveryID == "" || + delivery.EventTime < 0 || delivery.RFQID == "" || !validRFQLifecycleState(delivery.State) { + return false + } + if !validRFQNonEmptyString(delivery.QuoteID) || !validRFQPlainDecimal(delivery.Price) || + !validRFQPlainDecimal(delivery.Quantity) || (delivery.QuoteStatus != nil && !validRFQQuoteStatus(*delivery.QuoteStatus)) || + (delivery.ValidUntil != nil && *delivery.ValidUntil < 0) { + return false + } + switch delivery.Transition { + case RFQTransitionClosed, RFQTransitionAccepted, RFQTransitionConfirmed, + RFQTransitionDeclined, RFQTransitionFinalized, RFQTransitionFailed: + return true + default: + return false + } +} diff --git a/packages/sdk-go/websocket/rfq_test.go b/packages/sdk-go/websocket/rfq_test.go new file mode 100644 index 00000000..dfb732ef --- /dev/null +++ b/packages/sdk-go/websocket/rfq_test.go @@ -0,0 +1,248 @@ +package websocket_test + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/gemini/gemini-go/auth" + "github.com/gemini/gemini-go/websocket" +) + +func TestPublicRFQEventsDecodeAndRejectMalformedShape(t *testing.T) { + dialer := &mockDrainDialer{} + client := websocket.NewPublicClient("wss://ws.gemini.com", websocket.WithDialer(dialer)) + defer client.Close() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + events, err := client.SubscribeRFQEvents(ctx) + if err != nil { + t.Fatalf("SubscribeRFQEvents failed: %v", err) + } + + dialer.latestConn().feedServerMsg([]byte(`{"e":"requestForQuote","E":1780000000000,"r":"rfq-1","s":"GEMI-CMB-1","l":[{"c":"123","o":"YES","s":"GEMI-XRPUSD-260828"}],"n":"50000.00","S":"OPEN","w":1780000001000}`)) + select { + case event := <-events: + if event.RFQID != "rfq-1" || event.State != websocket.RFQStateOpen || event.Legs[0].Outcome != "YES" || event.Legs[0].InstrumentSymbol == nil || *event.Legs[0].InstrumentSymbol != "GEMI-XRPUSD-260828" { + t.Fatalf("unexpected RFQ event: %+v", event) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for RFQ event") + } + + // Instrument symbols are optional in the deployed event contract. An + // omitted field is valid, while an explicitly empty field is malformed. + dialer.latestConn().feedServerMsg([]byte(`{"e":"requestForQuote","E":1780000000001,"r":"rfq-omitted-symbol","l":[{"c":"123","o":"NO"}],"S":"OPEN"}`)) + select { + case event := <-events: + if event.RFQID != "rfq-omitted-symbol" || event.Legs[0].InstrumentSymbol != nil { + t.Fatalf("unexpected omitted instrument symbol event: %+v", event) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for RFQ event without instrument symbol") + } + + dialer.latestConn().feedServerMsg([]byte(`{"e":"requestForQuote","E":1780000000002,"r":"rfq-empty-symbol","l":[{"c":"123","o":"YES","s":""}],"S":"OPEN"}`)) + select { + case event := <-events: + t.Fatalf("received RFQ event with empty instrument symbol: %+v", event) + case <-time.After(50 * time.Millisecond): + } + + // A valid JSON frame with the wrong event shape must not be exposed as a + // typed event; callers should only receive spec-conformant values. + dialer.latestConn().feedServerMsg([]byte(`{"e":"requestForQuote","E":1,"r":"rfq-2","l":[],"S":"UNKNOWN"}`)) + select { + case event := <-events: + t.Fatalf("received malformed RFQ event: %+v", event) + case <-time.After(50 * time.Millisecond): + } +} + +func TestPrivateRFQDeliveryAndQuoteMethodsUseDocumentedWireContract(t *testing.T) { + dialer := &mockDrainDialer{ + responseResult: json.RawMessage(`{"rfqId":"rfq-1","quoteId":"quote-1","confirmed":true}`), + } + client := websocket.NewPrivateClient( + "wss://ws.gemini.com", + auth.NewHMAC("key", "secret"), + websocket.WithDialer(dialer), + ) + defer client.Close() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + deliveries, err := client.SubscribeRFQDeliveries(ctx, websocket.ScopeSession) + if err != nil { + t.Fatalf("SubscribeRFQDeliveries failed: %v", err) + } + dialer.latestConn().feedServerMsg([]byte(`{"e":"requestForQuote","i":"delivery-1","E":1780000001000,"r":"rfq-1","x":"ACCEPTED","S":"CONFIRMING","q":"quote-1","p":"0.55","sz":"100","qs":"ACTIVE","vu":1780000060000}`)) + select { + case delivery := <-deliveries: + if delivery.DeliveryID != "delivery-1" || delivery.Transition != websocket.RFQTransitionAccepted || delivery.QuoteID == nil || *delivery.QuoteID != "quote-1" { + t.Fatalf("unexpected RFQ delivery: %+v", delivery) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for RFQ delivery") + } + + validUntil := int64(1780000060000) + submitted, err := client.SubmitRFQQuote(ctx, websocket.RFQSubmitQuoteParams{ + RFQID: "rfq-1", Price: "0.55", Quantity: "100", ValidUntil: &validUntil, + }) + if err != nil || submitted.RFQID != "rfq-1" || submitted.QuoteID != "quote-1" { + t.Fatalf("SubmitRFQQuote = %+v, %v", submitted, err) + } + withdrawn, err := client.WithdrawRFQQuote(ctx, websocket.RFQWithdrawQuoteParams{RFQID: "rfq-1", QuoteID: "quote-1"}) + if err != nil || withdrawn.RFQID != "rfq-1" || withdrawn.QuoteID != "quote-1" { + t.Fatalf("WithdrawRFQQuote = %+v, %v", withdrawn, err) + } + confirmed, err := client.ConfirmRFQQuote(ctx, websocket.RFQConfirmQuoteParams{RFQID: "rfq-1", QuoteID: "quote-1", Confirm: true}) + if err != nil || !confirmed.Confirmed { + t.Fatalf("ConfirmRFQQuote = %+v, %v", confirmed, err) + } + + writes := writtenFrames(dialer.latestConn()) + methods := make(map[string]json.RawMessage) + for _, payload := range writes { + var frame struct { + Method string `json:"method"` + Params json.RawMessage `json:"params"` + } + if err := json.Unmarshal(payload, &frame); err == nil && frame.Method != "" { + methods[frame.Method] = frame.Params + } + } + for _, method := range []string{"SUBSCRIBE", "rfq.submit_quote", "rfq.withdraw_quote", "rfq.confirm_quote"} { + if _, ok := methods[method]; !ok { + t.Errorf("missing %s request in wire frames", method) + } + } + var submitParams websocket.RFQSubmitQuoteParams + if err := json.Unmarshal(methods["rfq.submit_quote"], &submitParams); err != nil { + t.Fatalf("decode submit params: %v", err) + } + if submitParams.RFQID != "rfq-1" || submitParams.Price != "0.55" || submitParams.ValidUntil == nil || *submitParams.ValidUntil != 1780000060000 { + t.Fatalf("unexpected submit params: %+v", submitParams) + } +} + +func TestRFQQuoteMethodsRequireAuthentication(t *testing.T) { + client := websocket.NewPublicClient("wss://ws.gemini.com") + defer client.Close() + _, err := client.SubmitRFQQuote(context.Background(), websocket.RFQSubmitQuoteParams{}) + if !errors.Is(err, websocket.ErrAuthenticationRequired) { + t.Fatalf("expected authentication error, got %v", err) + } +} + +func TestRFQQuoteMethodsRejectInvalidParametersBeforeWriting(t *testing.T) { + dialer := &mockDrainDialer{} + client := websocket.NewPrivateClient( + "wss://ws.gemini.com", + auth.NewHMAC("key", "secret"), + websocket.WithDialer(dialer), + ) + defer client.Close() + + negativeExpiry := int64(-1) + tests := []struct { + name string + call func() error + }{ + { + name: "submit missing RFQ ID", + call: func() error { + _, err := client.SubmitRFQQuote(context.Background(), websocket.RFQSubmitQuoteParams{Price: "0.55", Quantity: "1"}) + return err + }, + }, + { + name: "submit invalid price", + call: func() error { + _, err := client.SubmitRFQQuote(context.Background(), websocket.RFQSubmitQuoteParams{RFQID: "rfq-1", Price: "not-a-decimal", Quantity: "1"}) + return err + }, + }, + { + name: "submit invalid expiry", + call: func() error { + _, err := client.SubmitRFQQuote(context.Background(), websocket.RFQSubmitQuoteParams{RFQID: "rfq-1", Price: "0.55", Quantity: "1", ValidUntil: &negativeExpiry}) + return err + }, + }, + { + name: "withdraw missing quote ID", + call: func() error { + _, err := client.WithdrawRFQQuote(context.Background(), websocket.RFQWithdrawQuoteParams{RFQID: "rfq-1"}) + return err + }, + }, + { + name: "confirm missing RFQ ID", + call: func() error { + _, err := client.ConfirmRFQQuote(context.Background(), websocket.RFQConfirmQuoteParams{QuoteID: "quote-1", Confirm: false}) + return err + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if err := test.call(); !errors.Is(err, websocket.ErrInvalidRFQParams) { + t.Fatalf("error = %v, want ErrInvalidRFQParams", err) + } + }) + } + if got := dialer.connCount(); got != 0 { + t.Fatalf("invalid RFQ requests opened %d WebSocket connections", got) + } +} + +func TestRFQQuoteMethodsUseBearerAuthentication(t *testing.T) { + dialer := &mockDrainDialer{ + responseResult: json.RawMessage(`{"rfqId":"rfq-1","quoteId":"quote-1","confirmed":true}`), + } + client := websocket.NewPrivateClient( + "wss://ws.gemini.com", + auth.NewBearer(auth.BearerToken("rfq-oauth-token")), + websocket.WithDialer(dialer), + ) + defer client.Close() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if _, err := client.SubmitRFQQuote(ctx, websocket.RFQSubmitQuoteParams{RFQID: "rfq-1", Price: "0.55", Quantity: "1"}); err != nil { + t.Fatalf("SubmitRFQQuote with bearer auth failed: %v", err) + } + if _, err := client.WithdrawRFQQuote(ctx, websocket.RFQWithdrawQuoteParams{RFQID: "rfq-1", QuoteID: "quote-1"}); err != nil { + t.Fatalf("WithdrawRFQQuote with bearer auth failed: %v", err) + } + if _, err := client.ConfirmRFQQuote(ctx, websocket.RFQConfirmQuoteParams{RFQID: "rfq-1", QuoteID: "quote-1", Confirm: false}); err != nil { + t.Fatalf("ConfirmRFQQuote with bearer auth failed: %v", err) + } + if got := dialer.latestHeaders().Get("Authorization"); got != "Bearer rfq-oauth-token" { + t.Fatalf("RFQ WebSocket Authorization = %q, want Bearer rfq-oauth-token", got) + } +} + +func TestRFQDeliveryScopesAreMutuallyExclusive(t *testing.T) { + client := websocket.NewPrivateClient( + "wss://ws.gemini.com", + auth.NewHMAC("key", "secret"), + websocket.WithDialer(&mockDrainDialer{}), + ) + defer client.Close() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if _, err := client.SubscribeRFQDeliveries(ctx, websocket.ScopeAccount); err != nil { + t.Fatalf("account RFQ subscription failed: %v", err) + } + if _, err := client.SubscribeRFQDeliveries(ctx, websocket.ScopeSession); !errors.Is(err, websocket.ErrRFQScopeConflict) { + t.Fatalf("session RFQ subscription error = %v, want ErrRFQScopeConflict", err) + } +} diff --git a/packages/sdk-go/websocket/snapshot_isolation.go b/packages/sdk-go/websocket/snapshot_isolation.go new file mode 100644 index 00000000..43464718 --- /dev/null +++ b/packages/sdk-go/websocket/snapshot_isolation.go @@ -0,0 +1,226 @@ +package websocket + +import ( + "context" + "errors" + "fmt" +) + +// ErrAmbiguousSnapshot indicates that a symbol-less order-book snapshot could +// not be assigned to exactly one active depth feed. +var ErrAmbiguousSnapshot = errors.New("gemini websocket: symbol-less order-book snapshot is ambiguous") + +// ErrMalformedSnapshot indicates that an order-book snapshot envelope was +// recognized but did not contain the required fields or valid price levels. +var ErrMalformedSnapshot = errors.New("gemini websocket: malformed order-book snapshot") + +func (c *Client) usesIsolatedSnapshots() bool { + return c.isolateSnapshots && c.snapshotMode && !c.privateOnly +} + +func (c *Client) usesIsolatedPartialSnapshots() bool { + // Partial-depth snapshots are symbol-less regardless of whether the + // connection also requests a differential-depth baseline. + return (c.isolateSnapshots || c.isolatePartialSnapshots) && !c.privateOnly +} + +func (c *Client) acquireIsolatedSnapshotClient(symbol string) (*Client, error) { + return c.acquireIsolatedClient(symbol, c.snapshotValue, c.snapshotClients, c.snapshotClientRefs) +} + +func (c *Client) acquireIsolatedPartialSnapshotClient(symbol string) (*Client, error) { + return c.acquireIsolatedClient(symbol, 0, c.partialSnapshotClients, c.partialSnapshotRefs) +} + +func (c *Client) acquireIsolatedClient(symbol string, snapshot int, clients map[string]*Client, refs map[string]int) (*Client, error) { + normSymbol, err := normalizeSymbol(symbol) + if err != nil { + return nil, err + } + + c.snapshotMu.Lock() + defer c.snapshotMu.Unlock() + if c.State() == StateClosed { + return nil, fmt.Errorf("gemini websocket: websocket client is closed") + } + + snapshotClient := clients[normSymbol] + if snapshotClient == nil { + snapshotClient = NewPublicClient( + c.url, + WithDialer(c.dialer), + WithClientLogger(c.logger), + WithHeaders(c.headers), + WithAutoReconnect(c.autoReconnect), + WithMaxReconnects(c.maxReconnects), + WithSnapshot(snapshot), + ) + snapshotClient.maxMessageSize = c.maxMessageSize + snapshotClient.livenessInterval = c.livenessInterval + snapshotClient.livenessTimeout = c.livenessTimeout + clients[normSymbol] = snapshotClient + } + refs[normSymbol]++ + return snapshotClient, nil +} + +func (c *Client) releaseIsolatedSnapshotClient(symbol string, snapshotClient *Client) { + c.releaseIsolatedClient(symbol, snapshotClient, c.snapshotClients, c.snapshotClientRefs) +} + +func (c *Client) releaseIsolatedPartialSnapshotClient(symbol string, snapshotClient *Client) { + c.releaseIsolatedClient(symbol, snapshotClient, c.partialSnapshotClients, c.partialSnapshotRefs) +} + +func (c *Client) releaseIsolatedClient(symbol string, snapshotClient *Client, clients map[string]*Client, refs map[string]int) { + normSymbol, err := normalizeSymbol(symbol) + if err != nil { + return + } + c.snapshotMu.Lock() + if clients[normSymbol] != snapshotClient { + c.snapshotMu.Unlock() + return + } + if refs[normSymbol] > 1 { + refs[normSymbol]-- + c.snapshotMu.Unlock() + return + } + delete(refs, normSymbol) + delete(clients, normSymbol) + c.snapshotMu.Unlock() + _ = snapshotClient.Close() +} + +func (c *Client) subscribeIsolatedDepth(ctx context.Context, symbol, suffix string) (<-chan *DepthUpdate, error) { + snapshotClient, err := c.acquireIsolatedSnapshotClient(symbol) + if err != nil { + return nil, err + } + depth, err := subscribePublicFeed( + ctx, + snapshotClient, + symbol, + "depth", + suffix, + 256, + func(t *subTables) map[string][]*subscription[DepthUpdate] { return t.depthSubs }, + func(t *subTables, m map[string][]*subscription[DepthUpdate]) { t.depthSubs = m }, + ) + if err != nil { + c.releaseIsolatedSnapshotClient(symbol, snapshotClient) + return nil, err + } + return depth, nil +} + +func (c *Client) unsubscribeIsolatedDepth(ctx context.Context, symbol, suffix string) error { + snapshotClient, err := c.removeIsolatedClientForVariant( + symbol, + c.snapshotClients, + c.snapshotClientRefs, + "depth", + suffix, + ) + if err != nil { + return err + } + if snapshotClient == nil { + return nil + } + err = unsubscribePublicFeed( + ctx, + snapshotClient, + symbol, + "depth", + suffix, + func(t *subTables) map[string][]*subscription[DepthUpdate] { return t.depthSubs }, + func(t *subTables, m map[string][]*subscription[DepthUpdate]) { t.depthSubs = m }, + ) + closeErr := snapshotClient.Close() + return errors.Join(err, closeErr) +} + +func (c *Client) subscribeIsolatedPartialDepth(ctx context.Context, symbol string, options PartialDepthSubscriptionOptions) (<-chan *OrderBookSnapshot, error) { + snapshotClient, err := c.acquireIsolatedPartialSnapshotClient(symbol) + if err != nil { + return nil, err + } + suffix, err := partialDepthStreamSuffix(options) + if err != nil { + c.releaseIsolatedPartialSnapshotClient(symbol, snapshotClient) + return nil, err + } + depth, err := subscribePublicFeed( + ctx, + snapshotClient, + symbol, + "partialDepth", + suffix, + 256, + func(t *subTables) map[string][]*subscription[OrderBookSnapshot] { return t.partialDepthSubs }, + func(t *subTables, m map[string][]*subscription[OrderBookSnapshot]) { t.partialDepthSubs = m }, + ) + if err != nil { + c.releaseIsolatedPartialSnapshotClient(symbol, snapshotClient) + return nil, err + } + return depth, nil +} + +func (c *Client) unsubscribeIsolatedPartialDepth(ctx context.Context, symbol string, options PartialDepthSubscriptionOptions) error { + suffix, err := partialDepthStreamSuffix(options) + if err != nil { + return err + } + snapshotClient, err := c.removeIsolatedClientForVariant( + symbol, + c.partialSnapshotClients, + c.partialSnapshotRefs, + "partialDepth", + suffix, + ) + if err != nil { + return err + } + if snapshotClient == nil { + return nil + } + err = unsubscribePublicFeed( + ctx, + snapshotClient, + symbol, + "partialDepth", + suffix, + func(t *subTables) map[string][]*subscription[OrderBookSnapshot] { return t.partialDepthSubs }, + func(t *subTables, m map[string][]*subscription[OrderBookSnapshot]) { t.partialDepthSubs = m }, + ) + closeErr := snapshotClient.Close() + return errors.Join(err, closeErr) +} + +func (c *Client) removeIsolatedClientForVariant(symbol string, clients map[string]*Client, refs map[string]int, feedPrefix, suffix string) (*Client, error) { + normSymbol, err := normalizeSymbol(symbol) + if err != nil { + return nil, err + } + c.snapshotMu.Lock() + defer c.snapshotMu.Unlock() + snapshotClient := clients[normSymbol] + if snapshotClient == nil { + return nil, nil + } + + snapshotClient.subsMu.Lock() + feedKey := fmt.Sprintf("%s:%s@%s", feedPrefix, normSymbol, suffix) + err = validateActivePublicFeedVariantLocked(snapshotClient, feedPrefix, normSymbol, feedKey, suffix) + snapshotClient.subsMu.Unlock() + if err != nil { + return nil, err + } + + delete(clients, normSymbol) + delete(refs, normSymbol) + return snapshotClient, nil +} diff --git a/packages/sdk-go/websocket/snapshot_routing.go b/packages/sdk-go/websocket/snapshot_routing.go new file mode 100644 index 00000000..cb6457a9 --- /dev/null +++ b/packages/sdk-go/websocket/snapshot_routing.go @@ -0,0 +1,127 @@ +package websocket + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" +) + +type snapshotEnvelope struct { + LastUpdateID *int64 `json:"lastUpdateId"` + EventTime int64 `json:"E"` + Symbol string `json:"symbol"` + Bids [][]string `json:"bids"` + Asks [][]string `json:"asks"` +} + +// dispatchOrderBookSnapshot recognizes the partial-depth snapshot envelope. +// The envelope intentionally has no required symbol field, so symbol-less +// frames are accepted only when the current generation has one unambiguous +// active depth target. +func (c *Client) dispatchOrderBookSnapshot(stop <-chan struct{}, payload []byte, generation uint64, tables *subTables) (bool, error) { + if !bytes.Contains(payload, bLastUpdateID) || !bytes.Contains(payload, bids) || !bytes.Contains(payload, bAsks) { + return false, nil + } + + var envelope snapshotEnvelope + if err := json.Unmarshal(payload, &envelope); err != nil { + c.reportSnapshotError(fmt.Errorf("%w: %v", ErrMalformedSnapshot, err)) + return true, nil + } + if envelope.LastUpdateID == nil || *envelope.LastUpdateID <= 0 || envelope.Bids == nil || envelope.Asks == nil { + c.reportSnapshotError(ErrMalformedSnapshot) + return true, nil + } + + symbol := strings.ToUpper(envelope.Symbol) + if symbol == "" { + var ok bool + symbol, ok = c.uniqueSnapshotSymbol(tables) + if !ok { + c.reportSnapshotError(ErrAmbiguousSnapshot) + return true, nil + } + } + + // A snapshot from an older generation must not replace a newer pending + // baseline. This matters when a frame was already queued while a reconnect + // or unsubscribe/resubscribe advanced the subscription generation. + if pendingGeneration, pending := c.snapshotPendingGeneration(symbol); pending && generation < pendingGeneration { + return true, nil + } + + snapshot := &OrderBookSnapshot{ + EventTime: envelope.EventTime, + Symbol: symbol, + LastUpdateID: *envelope.LastUpdateID, + Bids: envelope.Bids, + Asks: envelope.Asks, + } + for _, sub := range tables.partialDepthSubs[symbol] { + sub.send(stop, snapshot) + } + + if len(tables.depthSubs[symbol]) == 0 { + return true, nil + } + update := &DepthUpdate{ + EventType: "depthUpdate", + EventTime: envelope.EventTime, + Symbol: symbol, + FirstUpdateID: *envelope.LastUpdateID, + LastUpdateID: *envelope.LastUpdateID, + Bids: envelope.Bids, + Asks: envelope.Asks, + Snapshot: true, + } + if pendingGeneration, pending := c.snapshotPendingGeneration(symbol); pending && generation >= pendingGeneration { + c.consumeSnapshotPending(symbol, generation) + } + for _, sub := range tables.depthSubs[symbol] { + sub.send(stop, update) + } + return true, nil +} + +func (c *Client) uniqueSnapshotSymbol(tables *subTables) (string, bool) { + c.subsMu.Lock() + defer c.subsMu.Unlock() + + candidates := make(map[string]struct{}) + for symbol, subs := range tables.depthSubs { + if len(subs) > 0 { + candidates[symbol] = struct{}{} + } + } + for symbol, subs := range tables.partialDepthSubs { + if len(subs) > 0 { + candidates[symbol] = struct{}{} + } + } + // Pending generations fence stale differential snapshots after a symbol is + // resolved; they must not remove other active symbol-less targets from the + // candidate set. If more than one target shares a connection, routing an + // unlabelled frame is unsafe and the caller reports ErrAmbiguousSnapshot. + if len(candidates) != 1 { + return "", false + } + for symbol := range candidates { + return symbol, true + } + return "", false +} + +func (c *Client) snapshotPendingGeneration(symbol string) (uint64, bool) { + c.subsMu.Lock() + defer c.subsMu.Unlock() + pendingGeneration, ok := c.snapshotPending[strings.ToUpper(symbol)] + return pendingGeneration, ok +} + +func (c *Client) reportSnapshotError(err error) { + if err == nil { + return + } + c.publishEvent(ConnectionEvent{State: c.State(), Err: err}) +} diff --git a/packages/sdk-go/websocket/snapshot_routing_internal_test.go b/packages/sdk-go/websocket/snapshot_routing_internal_test.go new file mode 100644 index 00000000..e04a6288 --- /dev/null +++ b/packages/sdk-go/websocket/snapshot_routing_internal_test.go @@ -0,0 +1,39 @@ +package websocket + +import ( + "testing" + "time" +) + +func TestDispatchSymbolLessSnapshotFencesOlderGeneration(t *testing.T) { + client := NewClient("wss://ws.sandbox.gemini.com", WithSnapshot(-1)) + client.state.Store(int32(StateConnected)) + + sub := newSubscription[DepthUpdate](1) + client.subsMu.Lock() + tables := client.subTables.Load().clone() + tables.depthSubs["BTCUSD"] = []*subscription[DepthUpdate]{sub} + client.subTables.Store(tables) + client.snapshotPending["BTCUSD"] = 2 + client.subsMu.Unlock() + + stop := make(chan struct{}) + const snapshot = `{"lastUpdateId":7,"bids":[["100","1"]],"asks":[]}` + client.dispatchFrame(stop, []byte(snapshot), 1) + select { + case update := <-sub.ch: + t.Fatalf("older-generation snapshot was dispatched: %+v", update) + default: + } + + client.dispatchFrame(stop, []byte(snapshot), 2) + select { + case update := <-sub.ch: + if update.Symbol != "BTCUSD" || update.LastUpdateID != 7 || !update.Snapshot { + t.Fatalf("unexpected current-generation snapshot: %+v", update) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for current-generation snapshot") + } + client.Close() +} diff --git a/packages/sdk-go/websocket/snapshot_routing_test.go b/packages/sdk-go/websocket/snapshot_routing_test.go new file mode 100644 index 00000000..00942126 --- /dev/null +++ b/packages/sdk-go/websocket/snapshot_routing_test.go @@ -0,0 +1,400 @@ +package websocket_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/gemini/gemini-go/websocket" +) + +const symbolLessSnapshot = `{"lastUpdateId":1,"bids":[["100","1"]],"asks":[["101","1"]]}` + +func waitForConnections(t *testing.T, dialer *mockDrainDialer, want int) { + t.Helper() + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + if dialer.connCount() >= want { + return + } + time.Sleep(time.Millisecond) + } + t.Fatalf("timed out waiting for %d WebSocket connections; got %d", want, dialer.connCount()) +} + +func waitForWrites(t *testing.T, conn *mockDrainConn, want int) { + t.Helper() + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + conn.mu.Lock() + got := len(conn.written) + conn.mu.Unlock() + if got >= want { + return + } + time.Sleep(time.Millisecond) + } + t.Fatal("timed out waiting for replay subscription write") +} + +func waitForConnectionError(t *testing.T, events <-chan websocket.ConnectionEvent, want error) { + t.Helper() + deadline := time.NewTimer(time.Second) + defer deadline.Stop() + for { + select { + case event, ok := <-events: + if !ok { + t.Fatal("connection event channel closed before expected error") + } + if errors.Is(event.Err, want) { + return + } + case <-deadline.C: + t.Fatalf("timed out waiting for connection error %v", want) + } + } +} + +func TestSandboxSymbolLessSnapshotRoutesToDepthSubscription(t *testing.T) { + dialer := &mockDrainDialer{} + client := websocket.NewPublicClient( + "wss://ws.sandbox.gemini.com", + websocket.WithDialer(dialer), + websocket.WithSnapshot(-1), + websocket.WithIsolatedSnapshots(), + ) + defer client.Close() + + depth, err := client.SubscribeDepth(context.Background(), "BTCUSD") + if err != nil { + t.Fatalf("SubscribeDepth failed: %v", err) + } + dialer.latestConn().feedServerMsg([]byte(symbolLessSnapshot)) + + select { + case update := <-depth: + if update == nil || update.Symbol != "BTCUSD" || update.LastUpdateID != 1 || !update.Snapshot { + t.Fatalf("unexpected symbol-less snapshot update: %+v", update) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for symbol-less sandbox snapshot") + } +} + +func TestSandboxPartialSnapshotsAreIsolatedPerSymbol(t *testing.T) { + dialer := &mockDrainDialer{} + client := websocket.NewPublicClient( + "wss://ws.sandbox.gemini.com", + websocket.WithDialer(dialer), + websocket.WithSnapshot(-1), + websocket.WithIsolatedSnapshots(), + ) + defer client.Close() + + ops := websocket.PartialDepthSubscriptionOptions{Levels: websocket.DepthLevel20} + btc, err := client.SubscribePartialDepth(context.Background(), "BTCUSD", ops) + if err != nil { + t.Fatalf("BTC partial depth subscription failed: %v", err) + } + btcConn := dialer.latestConn() + eth, err := client.SubscribePartialDepth(context.Background(), "ETHUSD", ops) + if err != nil { + t.Fatalf("ETH partial depth subscription failed: %v", err) + } + ethConn := dialer.latestConn() + if btcConn == ethConn || dialer.connCount() != 2 { + t.Fatalf("expected one isolated connection per symbol, got %d", dialer.connCount()) + } + + btcConn.feedServerMsg([]byte(`{"lastUpdateId":10,"bids":[["100","1"]],"asks":[]}`)) + ethConn.feedServerMsg([]byte(`{"lastUpdateId":20,"bids":[["200","2"]],"asks":[]}`)) + + select { + case snapshot := <-btc: + if snapshot == nil || snapshot.Symbol != "BTCUSD" || snapshot.LastUpdateID != 10 { + t.Fatalf("unexpected BTC snapshot: %+v", snapshot) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for BTC snapshot") + } + select { + case snapshot := <-eth: + if snapshot == nil || snapshot.Symbol != "ETHUSD" || snapshot.LastUpdateID != 20 { + t.Fatalf("unexpected ETH snapshot: %+v", snapshot) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for ETH snapshot") + } +} + +func TestPartialSnapshotsAreIsolatedWithoutDifferentialSnapshotMode(t *testing.T) { + dialer := &mockDrainDialer{} + client := websocket.NewPublicClient( + "wss://ws.gemini.com", + websocket.WithDialer(dialer), + websocket.WithIsolatedSnapshots(), + ) + defer client.Close() + + options := websocket.PartialDepthSubscriptionOptions{Levels: websocket.DepthLevel10} + btc, err := client.SubscribePartialDepth(context.Background(), "BTCUSD", options) + if err != nil { + t.Fatalf("BTC partial depth subscription failed: %v", err) + } + btcConn := dialer.latestConn() + eth, err := client.SubscribePartialDepth(context.Background(), "ETHUSD", options) + if err != nil { + t.Fatalf("ETH partial depth subscription failed: %v", err) + } + ethConn := dialer.latestConn() + if btcConn == ethConn || dialer.connCount() != 2 { + t.Fatalf("expected isolated connections without snapshot query, got %d", dialer.connCount()) + } + + btcConn.feedServerMsg([]byte(`{"lastUpdateId":10,"bids":[],"asks":[]}`)) + ethConn.feedServerMsg([]byte(`{"lastUpdateId":20,"bids":[],"asks":[]}`)) + select { + case snapshot := <-btc: + if snapshot.Symbol != "BTCUSD" || snapshot.LastUpdateID != 10 { + t.Fatalf("unexpected BTC snapshot: %+v", snapshot) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for BTC snapshot") + } + select { + case snapshot := <-eth: + if snapshot.Symbol != "ETHUSD" || snapshot.LastUpdateID != 20 { + t.Fatalf("unexpected ETH snapshot: %+v", snapshot) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for ETH snapshot") + } +} + +func TestDifferentialDepthRemainsMultiplexedWithPartialIsolation(t *testing.T) { + dialer := &mockDrainDialer{} + client := websocket.NewPublicClient( + "wss://ws.gemini.com", + websocket.WithDialer(dialer), + websocket.WithSnapshot(-1), + websocket.WithIsolatedPartialSnapshots(), + ) + defer client.Close() + + ctx := context.Background() + btc, err := client.SubscribeDepth(ctx, "BTCUSD") + if err != nil { + t.Fatalf("BTC depth subscription failed: %v", err) + } + eth, err := client.SubscribeDepth(ctx, "ETHUSD") + if err != nil { + t.Fatalf("ETH depth subscription failed: %v", err) + } + if got := dialer.connCount(); got != 1 { + t.Fatalf("expected differential depth to remain multiplexed, got %d connections", got) + } + + conn := dialer.latestConn() + conn.feedServerMsg([]byte(`{"e":"depthUpdate","s":"BTCUSD","U":1,"u":1,"b":[],"a":[]}`)) + conn.feedServerMsg([]byte(`{"e":"depthUpdate","s":"ETHUSD","U":1,"u":1,"b":[],"a":[]}`)) + select { + case update := <-btc: + if update.Symbol != "BTCUSD" || !update.Snapshot { + t.Fatalf("unexpected BTC update: %+v", update) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for BTC update") + } + select { + case update := <-eth: + if update.Symbol != "ETHUSD" || !update.Snapshot { + t.Fatalf("unexpected ETH update: %+v", update) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for ETH update") + } +} + +func TestSandboxSymbolLessSnapshotResynchronizesAfterReconnect(t *testing.T) { + dialer := &mockDrainDialer{} + client := websocket.NewPublicClient( + "wss://ws.sandbox.gemini.com", + websocket.WithDialer(dialer), + websocket.WithSnapshot(-1), + websocket.WithIsolatedSnapshots(), + ) + defer client.Close() + + depth, err := client.SubscribeDepth(context.Background(), "BTCUSD") + if err != nil { + t.Fatalf("SubscribeDepth failed: %v", err) + } + first := dialer.latestConn() + first.feedServerMsg([]byte(symbolLessSnapshot)) + select { + case <-depth: + case <-time.After(time.Second): + t.Fatal("timed out waiting for initial snapshot") + } + + if err := first.Close(); err != nil { + t.Fatalf("closing test connection failed: %v", err) + } + waitForConnections(t, dialer, 2) + second := dialer.latestConn() + waitForWrites(t, second, 1) + second.feedServerMsg([]byte(`{"lastUpdateId":42,"bids":[["420","2"]],"asks":[]}`)) + + select { + case update := <-depth: + if update == nil || update.Symbol != "BTCUSD" || update.LastUpdateID != 42 || !update.Snapshot { + t.Fatalf("unexpected resnapshot update: %+v", update) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for reconnect snapshot") + } +} + +func TestAcceleratedDepthSnapshotResynchronizesAfterReconnect(t *testing.T) { + dialer := &mockDrainDialer{} + client := websocket.NewPublicClient( + "wss://ws.gemini.com", + websocket.WithDialer(dialer), + websocket.WithSnapshot(-1), + ) + defer client.Close() + + depth, err := client.SubscribeDepthWithOptions(context.Background(), "BTCUSD", websocket.DepthSubscriptionOptions{ + Interval: 100 * time.Millisecond, + }) + if err != nil { + t.Fatalf("SubscribeDepthWithOptions failed: %v", err) + } + first := dialer.latestConn() + first.feedServerMsg([]byte(`{"e":"depthUpdate","s":"BTCUSD","U":1,"u":1,"b":[],"a":[]}`)) + select { + case update := <-depth: + if update == nil || !update.Snapshot { + t.Fatalf("expected initial accelerated depth frame to be a snapshot: %+v", update) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for initial accelerated depth snapshot") + } + + if err := first.Close(); err != nil { + t.Fatalf("closing test connection failed: %v", err) + } + waitForConnections(t, dialer, 2) + second := dialer.latestConn() + waitForWrites(t, second, 1) + + second.feedServerMsg([]byte(`{"e":"depthUpdate","s":"BTCUSD","U":10,"u":10,"b":[],"a":[]}`)) + select { + case update := <-depth: + if update == nil || !update.Snapshot || update.LastUpdateID != 10 { + t.Fatalf("expected accelerated reconnect frame to be a snapshot: %+v", update) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for accelerated reconnect snapshot") + } +} + +func TestSymbolSubscriptionsRejectEmptyAndNormalizeWhitespace(t *testing.T) { + dialer := &mockDrainDialer{} + client := websocket.NewPublicClient("wss://ws.gemini.com", websocket.WithDialer(dialer)) + defer client.Close() + + if _, err := client.SubscribeTrades(context.Background(), " "); err == nil { + t.Fatal("expected empty symbol subscription to fail before connecting") + } + trades, err := client.SubscribeTrades(context.Background(), " btcusd ") + if err != nil { + t.Fatalf("whitespace-padded symbol subscription failed: %v", err) + } + if err := client.UnsubscribeTrades(context.Background(), " BTCUSD "); err != nil { + t.Fatalf("whitespace-padded unsubscribe failed: %v", err) + } + select { + case _, ok := <-trades: + if ok { + t.Fatal("expected unsubscribed trade channel to be closed") + } + case <-time.After(time.Second): + t.Fatal("trade channel did not close after unsubscribe") + } + if got := dialer.lastURL(); got == "" { + t.Fatal("expected normalized symbol subscription to connect") + } +} + +func TestSharedClientRejectsAmbiguousAndMalformedSnapshots(t *testing.T) { + t.Run("ambiguous", func(t *testing.T) { + dialer := &mockDrainDialer{} + client := websocket.NewPublicClient( + "wss://ws.sandbox.gemini.com", + websocket.WithDialer(dialer), + websocket.WithSnapshot(-1), + ) + defer client.Close() + + btc, err := client.SubscribeDepth(context.Background(), "BTCUSD") + if err != nil { + t.Fatalf("BTC subscription failed: %v", err) + } + eth, err := client.SubscribeDepth(context.Background(), "ETHUSD") + if err != nil { + t.Fatalf("ETH subscription failed: %v", err) + } + events, stop := client.SubscribeConnectionEvents(4) + defer stop() + dialer.latestConn().feedServerMsg([]byte(symbolLessSnapshot)) + waitForConnectionError(t, events, websocket.ErrAmbiguousSnapshot) + + select { + case update := <-btc: + t.Fatalf("ambiguous snapshot reached BTC: %+v", update) + default: + } + select { + case update := <-eth: + t.Fatalf("ambiguous snapshot reached ETH: %+v", update) + default: + } + }) + + t.Run("malformed", func(t *testing.T) { + dialer := &mockDrainDialer{} + client := websocket.NewPublicClient( + "wss://ws.sandbox.gemini.com", + websocket.WithDialer(dialer), + websocket.WithSnapshot(-1), + ) + defer client.Close() + + depth, err := client.SubscribeDepth(context.Background(), "BTCUSD") + if err != nil { + t.Fatalf("subscription failed: %v", err) + } + events, stop := client.SubscribeConnectionEvents(4) + defer stop() + dialer.latestConn().feedServerMsg([]byte(`{"lastUpdateId":"bad","bids":[],"asks":[]}`)) + waitForConnectionError(t, events, websocket.ErrMalformedSnapshot) + + select { + case update := <-depth: + t.Fatalf("malformed snapshot reached depth feed: %+v", update) + default: + } + dialer.latestConn().feedServerMsg([]byte(symbolLessSnapshot)) + select { + case update := <-depth: + if update == nil || update.Symbol != "BTCUSD" { + t.Fatalf("unexpected recovery snapshot: %+v", update) + } + case <-time.After(time.Second): + t.Fatal("pending snapshot was consumed by malformed frame") + } + }) +} diff --git a/packages/sdk-go/websocket/test_helpers_test.go b/packages/sdk-go/websocket/test_helpers_test.go new file mode 100644 index 00000000..8606e95a --- /dev/null +++ b/packages/sdk-go/websocket/test_helpers_test.go @@ -0,0 +1,298 @@ +package websocket_test + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "sync" + + "github.com/gemini/gemini-go/websocket" +) + +// mockDrainDialer is the deterministic in-memory transport used by lifecycle +// and subscription contract tests. It acknowledges method requests so tests +// exercise the same request/response path as the real server. +type mockDrainDialer struct { + mu sync.Mutex + connections []*mockDrainConn + urls []string + headers []http.Header + responseStatus int + responseStatuses []int + responseMessage string + responseResult json.RawMessage +} + +type mockDrainConn struct { + mu sync.Mutex + closed bool + closedChan chan struct{} + readChan chan []byte + written [][]byte + writeErr error + responseStatus int + responseMessage string + responseResult json.RawMessage +} + +func (m *mockDrainConn) feedServerMsg(msg []byte) { + select { + case <-m.closedChan: + case m.readChan <- msg: + } +} + +func (m *mockDrainConn) ReadMessage(ctx context.Context) (int, []byte, error) { + select { + case <-ctx.Done(): + return 0, nil, ctx.Err() + case <-m.closedChan: + return 0, nil, errors.New("connection reset by peer (server deploy drain)") + case msg := <-m.readChan: + return websocket.TextMessage, msg, nil + } +} + +func (m *mockDrainConn) WriteMessage(ctx context.Context, _ int, payload []byte) error { + m.mu.Lock() + if m.writeErr != nil { + err := m.writeErr + m.mu.Unlock() + return err + } + m.written = append(m.written, append([]byte(nil), payload...)) + status := m.responseStatus + if status == 0 { + status = http.StatusOK + } + responseMessage := m.responseMessage + responseResult := append(json.RawMessage(nil), m.responseResult...) + m.mu.Unlock() + + var request struct { + ID int64 `json:"id"` + Method string `json:"method"` + } + if err := json.Unmarshal(payload, &request); err != nil || request.ID == 0 || request.Method == "" { + return nil + } + + response := map[string]any{ + "id": request.ID, + "status": status, + } + if status >= http.StatusBadRequest { + response["error"] = map[string]any{"code": status, "message": responseMessage} + } else { + if len(responseResult) > 0 { + var result any + if err := json.Unmarshal(responseResult, &result); err == nil { + response["result"] = result + } else { + response["result"] = map[string]any{} + } + } else { + response["result"] = map[string]any{} + } + } + ack, _ := json.Marshal(response) + select { + case <-ctx.Done(): + return ctx.Err() + case <-m.closedChan: + return errors.New("connection closed") + case m.readChan <- ack: + return nil + } +} + +func (m *mockDrainConn) Close() error { + m.mu.Lock() + defer m.mu.Unlock() + if !m.closed { + m.closed = true + close(m.closedChan) + } + return nil +} + +func (d *mockDrainDialer) Dial(_ context.Context, url string, headers http.Header) (websocket.Conn, *http.Response, error) { + d.mu.Lock() + defer d.mu.Unlock() + d.urls = append(d.urls, url) + d.headers = append(d.headers, headers.Clone()) + responseStatus := d.responseStatus + if len(d.responseStatuses) > len(d.connections) { + responseStatus = d.responseStatuses[len(d.connections)] + } + conn := &mockDrainConn{ + closedChan: make(chan struct{}), + readChan: make(chan []byte, 512), + responseStatus: responseStatus, + responseMessage: d.responseMessage, + responseResult: append(json.RawMessage(nil), d.responseResult...), + } + d.connections = append(d.connections, conn) + return conn, &http.Response{StatusCode: http.StatusSwitchingProtocols}, nil +} + +func (d *mockDrainDialer) lastURL() string { + d.mu.Lock() + defer d.mu.Unlock() + if len(d.urls) == 0 { + return "" + } + return d.urls[len(d.urls)-1] +} + +func (d *mockDrainDialer) latestConn() *mockDrainConn { + d.mu.Lock() + defer d.mu.Unlock() + if len(d.connections) == 0 { + return nil + } + return d.connections[len(d.connections)-1] +} + +func (d *mockDrainDialer) latestHeaders() http.Header { + d.mu.Lock() + defer d.mu.Unlock() + if len(d.headers) == 0 { + return nil + } + return d.headers[len(d.headers)-1].Clone() +} + +func (d *mockDrainDialer) connCount() int { + d.mu.Lock() + defer d.mu.Unlock() + return len(d.connections) +} + +type interruptibleConn struct { + closeOnce sync.Once + closed chan struct{} + readStarted chan struct{} + writeStarted chan struct{} +} + +func newInterruptibleConn() *interruptibleConn { + return &interruptibleConn{ + closed: make(chan struct{}), + readStarted: make(chan struct{}), + writeStarted: make(chan struct{}), + } +} + +func (c *interruptibleConn) ReadMessage(ctx context.Context) (int, []byte, error) { + select { + case <-c.readStarted: + default: + close(c.readStarted) + } + select { + case <-c.closed: + return 0, nil, errors.New("connection closed") + case <-ctx.Done(): + return 0, nil, ctx.Err() + } +} + +func (c *interruptibleConn) WriteMessage(ctx context.Context, _ int, _ []byte) error { + select { + case <-c.writeStarted: + default: + close(c.writeStarted) + } + select { + case <-c.closed: + return errors.New("connection closed") + case <-ctx.Done(): + return ctx.Err() + } +} + +func (c *interruptibleConn) Close() error { + c.closeOnce.Do(func() { close(c.closed) }) + return nil +} + +type interruptibleDialer struct { + conn *interruptibleConn + started chan struct{} +} + +func (d *interruptibleDialer) Dial(ctx context.Context, _ string, _ http.Header) (websocket.Conn, *http.Response, error) { + select { + case <-d.started: + default: + close(d.started) + } + select { + case <-ctx.Done(): + return nil, nil, ctx.Err() + case <-d.conn.closed: + return nil, nil, errors.New("dialer closed") + } +} + +type singleConnDialer struct { + conn *interruptibleConn +} + +func (d *singleConnDialer) Dial(context.Context, string, http.Header) (websocket.Conn, *http.Response, error) { + return d.conn, &http.Response{StatusCode: http.StatusSwitchingProtocols}, nil +} + +type errorConnDialer struct { + conn *silentConn +} + +func (d *errorConnDialer) Dial(context.Context, string, http.Header) (websocket.Conn, *http.Response, error) { + return d.conn, nil, errors.New("dial failed after allocating connection") +} + +type silentConn struct { + closeOnce sync.Once + closed chan struct{} + writeStarted chan struct{} +} + +func newSilentConn() *silentConn { + return &silentConn{ + closed: make(chan struct{}), + writeStarted: make(chan struct{}), + } +} + +func (c *silentConn) ReadMessage(ctx context.Context) (int, []byte, error) { + select { + case <-c.closed: + return 0, nil, errors.New("connection closed") + case <-ctx.Done(): + return 0, nil, ctx.Err() + } +} + +func (c *silentConn) WriteMessage(context.Context, int, []byte) error { + select { + case <-c.writeStarted: + default: + close(c.writeStarted) + } + return nil +} + +func (c *silentConn) Close() error { + c.closeOnce.Do(func() { close(c.closed) }) + return nil +} + +type silentDialer struct { + conn *silentConn +} + +func (d *silentDialer) Dial(context.Context, string, http.Header) (websocket.Conn, *http.Response, error) { + return d.conn, &http.Response{StatusCode: http.StatusSwitchingProtocols}, nil +} diff --git a/packages/sdk-go/websocket/typed_parity_test.go b/packages/sdk-go/websocket/typed_parity_test.go new file mode 100644 index 00000000..50294f1d --- /dev/null +++ b/packages/sdk-go/websocket/typed_parity_test.go @@ -0,0 +1,220 @@ +package websocket_test + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/gemini/gemini-go/auth" + "github.com/gemini/gemini-go/websocket" +) + +func TestTypedPublicDepthOptionsUseDocumentedStreams(t *testing.T) { + dialer := &mockDrainDialer{} + client := websocket.NewPublicClient("wss://ws.gemini.com", websocket.WithDialer(dialer)) + defer client.Close() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + depth, err := client.SubscribeDepthWithOptions(ctx, "BTCUSD", websocket.DepthSubscriptionOptions{ + Interval: 100 * time.Millisecond, + }) + if err != nil { + t.Fatalf("SubscribeDepthWithOptions failed: %v", err) + } + partial, err := client.SubscribePartialDepth(ctx, "ETHUSD", websocket.PartialDepthSubscriptionOptions{ + Levels: websocket.DepthLevel20, + Interval: 100 * time.Millisecond, + }) + if err != nil { + t.Fatalf("SubscribePartialDepth failed: %v", err) + } + + writes := writtenFrames(dialer.latestConn()) + assertSubscribeFrame(t, writes[0], "btcusd@depth@100ms") + assertSubscribeFrame(t, writes[1], "ethusd@depth20@100ms") + + dialer.latestConn().feedServerMsg([]byte(`{"lastUpdateId":42,"symbol":"ETHUSD","bids":[["10","2"]],"asks":[["11","3"]]}`)) + select { + case snapshot := <-partial: + if snapshot.Symbol != "ETHUSD" || snapshot.LastUpdateID != 42 || len(snapshot.Bids) != 1 || len(snapshot.Asks) != 1 { + t.Fatalf("unexpected partial-depth snapshot: %+v", snapshot) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for partial-depth snapshot") + } + + dialer.latestConn().feedServerMsg([]byte(`{"e":"depthUpdate","s":"BTCUSD","U":41,"u":42,"b":[["10","2"]],"a":[]}`)) + select { + case update := <-depth: + if update.Symbol != "BTCUSD" || update.LastUpdateID != 42 { + t.Fatalf("unexpected differential-depth update: %+v", update) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for differential-depth update") + } +} + +func TestTypedDepthSnapshotUsesDocumentedRequestAndDecodesResult(t *testing.T) { + dialer := &mockDrainDialer{ + responseResult: json.RawMessage(`{"lastUpdateId":9,"bids":[["10","2"]],"asks":[["11","3"]]}`), + } + client := websocket.NewPublicClient("wss://ws.gemini.com", websocket.WithDialer(dialer)) + defer client.Close() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + snapshot, err := client.GetDepthSnapshot(ctx, "BTCUSD", websocket.DepthSnapshotOptions{Limit: 20}) + if err != nil { + t.Fatalf("GetDepthSnapshot failed: %v", err) + } + if snapshot.LastUpdateID != 9 || len(snapshot.Bids) != 1 || len(snapshot.Asks) != 1 { + t.Fatalf("unexpected depth snapshot: %+v", snapshot) + } + + writes := writtenFrames(dialer.latestConn()) + if len(writes) != 1 { + t.Fatalf("expected one depth request, got %d", len(writes)) + } + var frame struct { + Method string `json:"method"` + Params struct { + Symbol string `json:"symbol"` + Limit int `json:"limit"` + } `json:"params"` + } + if err := json.Unmarshal(writes[0], &frame); err != nil { + t.Fatalf("decode depth request: %v", err) + } + if frame.Method != "depth" || frame.Params.Symbol != "btcusd" || frame.Params.Limit != 20 { + t.Fatalf("unexpected depth request: %s", writes[0]) + } +} + +func TestTypedPrivateOptionsUseDocumentedScopesAndIntervals(t *testing.T) { + dialer := &mockDrainDialer{} + client := websocket.NewPrivateClient( + "wss://ws.gemini.com", + auth.NewHMAC("key", "secret"), + websocket.WithDialer(dialer), + ) + defer client.Close() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + orders, err := client.SubscribeOrderEventsWithScope(ctx, websocket.ScopeSession) + if err != nil { + t.Fatalf("SubscribeOrderEventsWithScope failed: %v", err) + } + balances, err := client.SubscribeBalancesWithOptions(ctx, websocket.AccountStreamOptions{Interval: time.Second}) + if err != nil { + t.Fatalf("SubscribeBalancesWithOptions failed: %v", err) + } + positions, err := client.SubscribePositionsWithOptions(ctx, websocket.AccountStreamOptions{Interval: time.Second}) + if err != nil { + t.Fatalf("SubscribePositionsWithOptions failed: %v", err) + } + if _, err := client.SubscribeSettlements(ctx); err != nil { + t.Fatalf("SubscribeSettlements failed: %v", err) + } + + writes := writtenFrames(dialer.latestConn()) + assertSubscribeFrame(t, writes[0], "orders@session") + assertSubscribeFrame(t, writes[1], "balances@account@1s") + assertSubscribeFrame(t, writes[2], "positions@account@1s") + assertSubscribeFrame(t, writes[3], "settlements@account") + + dialer.latestConn().feedServerMsg([]byte(`{"e":"orderUpdate","s":"GEMI-X","i":7,"X":"NEW"}`)) + select { + case event := <-orders: + if event.OrderID != 7 || event.Symbol != "GEMI-X" { + t.Fatalf("unexpected session order event: %+v", event) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for session order event") + } + + dialer.latestConn().feedServerMsg([]byte(`{"e":"balanceUpdate","E":1,"u":2,"B":[]}`)) + select { + case update := <-balances: + if update.EventType != "balanceUpdate" || update.UpdateTime != 2 { + t.Fatalf("unexpected balance update: %+v", update) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for balance update") + } + + dialer.latestConn().feedServerMsg([]byte(`{"e":"positionReport","E":1,"u":2,"A":3,"P":[]}`)) + select { + case report := <-positions: + if report.EventType != "positionReport" || report.AccountID != 3 { + t.Fatalf("unexpected position report: %+v", report) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for position report") + } +} + +func TestTypedSubscriptionOptionsRejectUnsupportedValues(t *testing.T) { + client := websocket.NewPublicClient("wss://ws.gemini.com") + defer client.Close() + + ctx := context.Background() + if _, err := client.SubscribeDepthWithOptions(ctx, "BTCUSD", websocket.DepthSubscriptionOptions{Interval: time.Second}); err == nil { + t.Fatal("expected invalid differential-depth interval to fail") + } + if _, err := client.SubscribePartialDepth(ctx, "BTCUSD", websocket.PartialDepthSubscriptionOptions{Levels: 25}); err == nil { + t.Fatal("expected invalid partial-depth level to fail") + } + if _, err := client.SubscribeBalancesWithOptions(ctx, websocket.AccountStreamOptions{Interval: 2 * time.Second}); err == nil { + t.Fatal("expected invalid account-stream interval to fail") + } + if _, err := client.SubscribeOrderEventsWithScope(ctx, websocket.SubscriptionScope("invalid")); err == nil { + t.Fatal("expected invalid order scope to fail") + } +} + +func TestTypedDepthVariantsDoNotShareAnAmbiguousSymbol(t *testing.T) { + dialer := &mockDrainDialer{} + client := websocket.NewPublicClient("wss://ws.gemini.com", websocket.WithDialer(dialer)) + defer client.Close() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if _, err := client.SubscribeDepth(ctx, "BTCUSD"); err != nil { + t.Fatalf("SubscribeDepth failed: %v", err) + } + if _, err := client.SubscribeDepthWithOptions(ctx, "BTCUSD", websocket.DepthSubscriptionOptions{Interval: 100 * time.Millisecond}); err == nil { + t.Fatal("expected conflicting depth variant to fail") + } + if _, err := client.SubscribeDepthWithOptions(ctx, "ETHUSD", websocket.DepthSubscriptionOptions{Interval: 100 * time.Millisecond}); err != nil { + t.Fatalf("depth gate was not released after conflict: %v", err) + } +} + +func writtenFrames(conn *mockDrainConn) [][]byte { + conn.mu.Lock() + defer conn.mu.Unlock() + frames := make([][]byte, len(conn.written)) + for i, frame := range conn.written { + frames[i] = append([]byte(nil), frame...) + } + return frames +} + +func assertSubscribeFrame(t *testing.T, payload []byte, stream string) { + t.Helper() + var frame struct { + Method string `json:"method"` + Params []string `json:"params"` + } + if err := json.Unmarshal(payload, &frame); err != nil { + t.Fatalf("decode request %q: %v", payload, err) + } + if frame.Method != "SUBSCRIBE" || len(frame.Params) != 1 || frame.Params[0] != stream { + t.Fatalf("unexpected subscribe frame: %s", payload) + } +} diff --git a/packages/sdk-go/websocket/typed_requests.go b/packages/sdk-go/websocket/typed_requests.go new file mode 100644 index 00000000..dfeb152c --- /dev/null +++ b/packages/sdk-go/websocket/typed_requests.go @@ -0,0 +1,314 @@ +package websocket + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + + "github.com/gemini/gemini-go/types" +) + +// JSONObject is the lossless generic object shape used by WebSocket methods +// whose result fields are intentionally open-ended in the API contract. +// ResponseFrame.DecodeResult uses json.Number for numeric values. +type JSONObject map[string]any + +// OrderSide identifies whether an order buys or sells. +type OrderSide string + +const ( + OrderSideBuy OrderSide = "BUY" + OrderSideSell OrderSide = "SELL" +) + +// OrderType identifies the execution style requested by order.place. Stop- +// limit orders use OrderTypeLimit together with StopPrice. +type OrderType string + +const ( + OrderTypeLimit OrderType = "LIMIT" + OrderTypeMarket OrderType = "MARKET" +) + +// TimeInForce controls how long an order remains eligible for execution. +type TimeInForce string + +const ( + TimeInForceGTC TimeInForce = "GTC" + TimeInForceIOC TimeInForce = "IOC" + TimeInForceFOK TimeInForce = "FOK" + TimeInForceMOC TimeInForce = "MOC" +) + +// EventOutcome identifies the prediction-market outcome attached to an order. +type EventOutcome string + +const ( + EventOutcomeYes EventOutcome = "YES" + EventOutcomeNo EventOutcome = "NO" +) + +// OrderPlaceParams is the typed payload for order.place. A stop-limit order +// uses Type "LIMIT" with both Price and StopPrice; the exchange reports its +// resulting order type as STOP_LIMIT on order events. The WebSocket contract +// permits an equal stop and limit price; REST order validation has separate +// strict inequality rules. +type OrderPlaceParams struct { + Symbol string `json:"symbol"` + Side OrderSide `json:"side"` + Type OrderType `json:"type"` + TimeInForce TimeInForce `json:"timeInForce"` + Price string `json:"price,omitempty"` + StopPrice string `json:"stopPrice,omitempty"` + Quantity string `json:"quantity"` + ClientOrderID string `json:"clientOrderId,omitempty"` + EventOutcome EventOutcome `json:"eventOutcome,omitempty"` +} + +// OrderCancelParams is the typed payload for order.cancel. OrderID accepts +// either the numeric or string identifier form supported by the exchange. +type OrderCancelParams struct { + OrderID any `json:"orderId"` +} + +// CancelAllOptions protects the account-wide cancellation methods from an +// accidental empty invocation. +type CancelAllOptions struct { + Confirm bool +} + +// ErrCancelConfirmationRequired indicates that a destructive cancel-all +// request was not explicitly confirmed by the caller. +var ErrCancelConfirmationRequired = errors.New("gemini websocket: cancel-all operation requires explicit confirmation") + +// ConnInfo returns the open-ended result of conninfo. +func (c *Client) ConnInfo(ctx context.Context) (JSONObject, error) { + response, err := c.Request(ctx, string(OpConnInfo), nil) + if err != nil { + return nil, err + } + return decodeObjectResult(response, string(OpConnInfo)) +} + +// Time returns the raw result of time. The API does not guarantee a stable +// result shape, so the raw JSON is preserved for callers to decode as needed. +func (c *Client) Time(ctx context.Context) (json.RawMessage, error) { + response, err := c.Request(ctx, string(OpTime), nil) + if err != nil { + return nil, err + } + var result json.RawMessage + if err := response.DecodeResult(&result); err != nil { + return nil, fmt.Errorf("gemini websocket: decoding time response: %w", err) + } + return result, nil +} + +// ListSubscriptions returns the streams currently registered on the +// connection. +func (c *Client) ListSubscriptions(ctx context.Context) ([]string, error) { + response, err := c.Request(ctx, string(OpListSubscriptions), nil) + if err != nil { + return nil, err + } + var result []string + if err := response.DecodeResult(&result); err != nil { + return nil, fmt.Errorf("gemini websocket: decoding subscriptions response: %w", err) + } + return result, nil +} + +// SubscribeStreams subscribes to one or more raw stream names using the +// documented WebSocket control-plane method. Raw subscriptions are not +// represented in the typed subscription registry and are not replayed after a +// reconnect; use a typed Subscribe* method when automatic feed resumption is +// required. +func (c *Client) SubscribeStreams(ctx context.Context, streams ...string) error { + return c.updateStreams(ctx, string(OpSubscribe), streams) +} + +// UnsubscribeStreams unsubscribes from one or more raw stream names using the +// documented WebSocket control-plane method. It only affects the server-side +// subscription and does not alter the typed subscription registry. +func (c *Client) UnsubscribeStreams(ctx context.Context, streams ...string) error { + return c.updateStreams(ctx, string(OpUnsubscribe), streams) +} + +func (c *Client) updateStreams(ctx context.Context, method string, streams []string) error { + if len(streams) == 0 { + return errors.New("gemini websocket: at least one stream is required") + } + for _, stream := range streams { + if strings.TrimSpace(stream) == "" { + return errors.New("gemini websocket: stream name is empty") + } + } + _, err := c.Request(ctx, method, streams) + return err +} + +// PlaceOrder sends the typed order.place request on an authenticated client. +func (c *Client) PlaceOrder(ctx context.Context, params OrderPlaceParams) (JSONObject, error) { + if err := validateOrderPlaceParams(params); err != nil { + return nil, err + } + response, err := c.authenticatedRequest(ctx, string(OpOrderNew), params) + if err != nil { + return nil, err + } + return decodeObjectResult(response, "order.place") +} + +// CancelOrder sends the typed order.cancel request on an authenticated client. +func (c *Client) CancelOrder(ctx context.Context, params OrderCancelParams) (JSONObject, error) { + if !validOrderID(params.OrderID) { + return nil, errors.New("gemini websocket: order ID is required") + } + response, err := c.authenticatedRequest(ctx, string(OpOrderCancel), params) + if err != nil { + return nil, err + } + return decodeObjectResult(response, "order.cancel") +} + +// CancelAllOrders cancels all active orders after explicit confirmation. +func (c *Client) CancelAllOrders(ctx context.Context, options CancelAllOptions) (JSONObject, error) { + return c.cancelAll(ctx, OpOrderCancelAll, options) +} + +// CancelSessionOrders cancels all active orders for the current session after +// explicit confirmation. +func (c *Client) CancelSessionOrders(ctx context.Context, options CancelAllOptions) (JSONObject, error) { + return c.cancelAll(ctx, OpOrderCancelSession, options) +} + +func (c *Client) cancelAll(ctx context.Context, method RequestOp, options CancelAllOptions) (JSONObject, error) { + if !options.Confirm { + return nil, ErrCancelConfirmationRequired + } + response, err := c.authenticatedRequest(ctx, string(method), nil) + if err != nil { + return nil, err + } + return decodeObjectResult(response, string(method)) +} + +func (c *Client) authenticatedRequest(ctx context.Context, method string, params any) (ResponseFrame, error) { + if c.auth == nil { + return ResponseFrame{}, ErrAuthenticationRequired + } + return c.Request(ctx, method, params) +} + +func decodeObjectResult(response ResponseFrame, method string) (JSONObject, error) { + result := JSONObject{} + if len(response.Result) == 0 || string(response.Result) == "null" { + return result, nil + } + if err := response.DecodeResult(&result); err != nil { + return nil, fmt.Errorf("gemini websocket: decoding %s response: %w", method, err) + } + if result == nil { + return JSONObject{}, nil + } + return result, nil +} + +func validateOrderPlaceParams(params OrderPlaceParams) error { + if strings.TrimSpace(params.Symbol) == "" || strings.TrimSpace(params.Quantity) == "" { + return errors.New("gemini websocket: order symbol and quantity are required") + } + if params.Side != OrderSideBuy && params.Side != OrderSideSell { + return errors.New("gemini websocket: order side must be BUY or SELL") + } + if err := validatePositiveDecimal("quantity", params.Quantity); err != nil { + return err + } + if params.StopPrice != "" && strings.TrimSpace(params.StopPrice) == "" { + return errors.New("gemini websocket: stopPrice must be a non-empty decimal") + } + switch params.Type { + case OrderTypeLimit: + if strings.TrimSpace(params.Price) == "" { + return errors.New("gemini websocket: limit orders require price") + } + if err := validatePositiveDecimal("price", params.Price); err != nil { + return err + } + case OrderTypeMarket: + if strings.TrimSpace(params.StopPrice) != "" { + return errors.New("gemini websocket: stopPrice is not valid with market orders") + } + default: + return errors.New("gemini websocket: order type must be LIMIT or MARKET; use LIMIT with stopPrice for stop-limit orders") + } + if strings.TrimSpace(params.StopPrice) != "" { + if err := validatePositiveDecimal("stopPrice", params.StopPrice); err != nil { + return err + } + price, _ := types.ParseDecimal(params.Price) + stopPrice, _ := types.ParseDecimal(params.StopPrice) + switch params.Side { + case OrderSideBuy: + if stopPrice.Cmp(price) > 0 { + return errors.New("gemini websocket: buy stopPrice must not exceed price") + } + case OrderSideSell: + if stopPrice.Cmp(price) < 0 { + return errors.New("gemini websocket: sell stopPrice must not be below price") + } + } + } + switch params.TimeInForce { + case TimeInForceGTC, TimeInForceIOC, TimeInForceFOK, TimeInForceMOC: + default: + return errors.New("gemini websocket: unsupported time-in-force") + } + if params.EventOutcome != "" && params.EventOutcome != EventOutcomeYes && params.EventOutcome != EventOutcomeNo { + return errors.New("gemini websocket: event outcome must be YES or NO") + } + return nil +} + +func validatePositiveDecimal(name, value string) error { + decimal, err := types.ParseDecimal(value) + if err != nil || !decimal.IsPositive() { + return fmt.Errorf("gemini websocket: %s must be a positive decimal", name) + } + return nil +} + +func validOrderID(value any) bool { + switch id := value.(type) { + case string: + return strings.TrimSpace(id) != "" + case json.Number: + n, err := strconv.ParseUint(string(id), 10, 64) + return err == nil && n > 0 + case int: + return id > 0 + case int8: + return id > 0 + case int16: + return id > 0 + case int32: + return id > 0 + case int64: + return id > 0 + case uint: + return id > 0 + case uint8: + return id > 0 + case uint16: + return id > 0 + case uint32: + return id > 0 + case uint64: + return id > 0 + default: + return false + } +} diff --git a/packages/sdk-go/websocket/typed_requests_test.go b/packages/sdk-go/websocket/typed_requests_test.go new file mode 100644 index 00000000..fde81d75 --- /dev/null +++ b/packages/sdk-go/websocket/typed_requests_test.go @@ -0,0 +1,217 @@ +package websocket_test + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/gemini/gemini-go/auth" + "github.com/gemini/gemini-go/websocket" +) + +func TestTypedUtilityAndSubscriptionRequests(t *testing.T) { + dialer := &mockDrainDialer{responseResult: json.RawMessage(`{"serverTime":123}`)} + client := websocket.NewPublicClient("wss://ws.gemini.com", websocket.WithDialer(dialer)) + defer client.Close() + + info, err := client.ConnInfo(context.Background()) + if err != nil || info["serverTime"] != json.Number("123") { + t.Fatalf("ConnInfo failed: %v %#v", err, info) + } + if _, err := client.Time(context.Background()); err != nil { + t.Fatalf("Time failed: %v", err) + } + if err := client.SubscribeStreams(context.Background(), "orders@account"); err != nil { + t.Fatalf("SubscribeStreams failed: %v", err) + } + if err := client.UnsubscribeStreams(context.Background(), "orders@account"); err != nil { + t.Fatalf("UnsubscribeStreams failed: %v", err) + } + + listDialer := &mockDrainDialer{responseResult: json.RawMessage(`["orders@account"]`)} + listClient := websocket.NewPublicClient("wss://ws.gemini.com", websocket.WithDialer(listDialer)) + defer listClient.Close() + streams, err := listClient.ListSubscriptions(context.Background()) + if err != nil || len(streams) != 1 || streams[0] != "orders@account" { + t.Fatalf("ListSubscriptions failed: %v %v", err, streams) + } +} + +func TestTypedOrderRequestsRequireAuthAndConfirmation(t *testing.T) { + public := websocket.NewPublicClient("wss://ws.gemini.com") + defer public.Close() + if _, err := public.PlaceOrder(context.Background(), websocket.OrderPlaceParams{}); err == nil { + t.Fatal("expected invalid public order request to fail") + } + + privateDialer := &mockDrainDialer{responseResult: json.RawMessage(`{"orderId":7}`)} + private := websocket.NewPrivateClient( + "wss://ws.gemini.com", + auth.NewHMAC("key", "secret"), + websocket.WithDialer(privateDialer), + ) + defer private.Close() + + if _, err := private.CancelAllOrders(context.Background(), websocket.CancelAllOptions{}); err != websocket.ErrCancelConfirmationRequired { + t.Fatalf("expected cancel confirmation error, got %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if _, err := private.PlaceOrder(ctx, websocket.OrderPlaceParams{ + Symbol: "GEMI-TEST", + Side: "BUY", + Type: "LIMIT", + TimeInForce: "GTC", + Quantity: "1.25", + Price: "0.50", + }); err != nil { + t.Fatalf("PlaceOrder failed: %v", err) + } + if _, err := private.PlaceOrder(ctx, websocket.OrderPlaceParams{ + Symbol: "GEMI-TEST", + Side: "SELL", + Type: "LIMIT", + TimeInForce: "GTC", + Quantity: "1.25", + Price: "0.45", + StopPrice: "0.48", + }); err != nil { + t.Fatalf("STOP_LIMIT PlaceOrder failed: %v", err) + } + if _, err := private.CancelOrder(ctx, websocket.OrderCancelParams{OrderID: "7"}); err != nil { + t.Fatalf("CancelOrder failed: %v", err) + } + if _, err := private.CancelAllOrders(ctx, websocket.CancelAllOptions{Confirm: true}); err != nil { + t.Fatalf("CancelAllOrders failed: %v", err) + } + if _, err := private.CancelSessionOrders(ctx, websocket.CancelAllOptions{Confirm: true}); err != nil { + t.Fatalf("CancelSessionOrders failed: %v", err) + } + + type typedOrderFrame struct { + Method string `json:"method"` + Params struct { + Type string `json:"type"` + Price string `json:"price"` + StopPrice string `json:"stopPrice"` + } `json:"params"` + } + var frames []typedOrderFrame + for _, payload := range writtenFrames(privateDialer.latestConn()) { + var frame typedOrderFrame + if err := json.Unmarshal(payload, &frame); err == nil { + frames = append(frames, frame) + } + } + var foundStopLimit bool + for _, frame := range frames { + if frame.Method == "order.place" && frame.Params.Type == "LIMIT" && frame.Params.StopPrice != "" { + foundStopLimit = frame.Params.Price == "0.45" && frame.Params.StopPrice == "0.48" + } + } + if !foundStopLimit { + t.Fatal("LIMIT stop-limit request did not preserve both price and stopPrice") + } +} + +func TestTypedOrderRequestsMatchStopLimitWireFormat(t *testing.T) { + client := websocket.NewPrivateClient("wss://ws.gemini.com", auth.NewHMAC("key", "secret")) + defer client.Close() + + err := func() error { + _, err := client.PlaceOrder(context.Background(), websocket.OrderPlaceParams{ + Symbol: "GEMI-TEST", Side: "BUY", Type: "STOP_LIMIT", TimeInForce: "GTC", + Quantity: "1", Price: "0.50", StopPrice: "0.49", + }) + return err + }() + if err == nil { + t.Fatal("expected STOP_LIMIT request type to be rejected; use LIMIT with stopPrice") + } +} + +func TestTypedOrderRequestsRejectInvalidValuesBeforeSending(t *testing.T) { + client := websocket.NewPublicClient("wss://ws.gemini.com") + defer client.Close() + + base := websocket.OrderPlaceParams{ + Symbol: "GEMI-TEST", + Side: websocket.OrderSideBuy, + Type: websocket.OrderTypeLimit, + TimeInForce: websocket.TimeInForceGTC, + Quantity: "1", + Price: "0.50", + } + for name, params := range map[string]websocket.OrderPlaceParams{ + "zero quantity": func() websocket.OrderPlaceParams { + p := base + p.Quantity = "0" + return p + }(), + "invalid price": func() websocket.OrderPlaceParams { + p := base + p.Price = "not-a-decimal" + return p + }(), + "invalid outcome": func() websocket.OrderPlaceParams { + p := base + p.EventOutcome = "MAYBE" + return p + }(), + "buy stop trigger exceeds limit": func() websocket.OrderPlaceParams { + p := base + p.StopPrice = "0.51" + return p + }(), + "sell stop trigger is below limit": func() websocket.OrderPlaceParams { + p := base + p.Side = websocket.OrderSideSell + p.StopPrice = "0.49" + return p + }(), + "whitespace stop price": func() websocket.OrderPlaceParams { + p := base + p.StopPrice = " \t" + return p + }(), + } { + t.Run(name, func(t *testing.T) { + if _, err := client.PlaceOrder(context.Background(), params); err == nil { + t.Fatal("expected invalid order to fail validation") + } + }) + } + + for name, orderID := range map[string]any{ + "zero integer": json.Number("0"), + "negative integer": json.Number("-1"), + "fractional number": json.Number("1.5"), + "non-numeric number": json.Number("not-a-number"), + } { + t.Run(name, func(t *testing.T) { + if _, err := client.CancelOrder(context.Background(), websocket.OrderCancelParams{OrderID: orderID}); err == nil { + t.Fatal("expected invalid order ID to fail validation") + } + }) + } +} + +func TestTypedOrderRequestsAllowEqualStopAndLimitPrices(t *testing.T) { + client := websocket.NewPrivateClient( + "wss://ws.gemini.com", + auth.NewHMAC("key", "secret"), + websocket.WithDialer(&mockDrainDialer{}), + ) + defer client.Close() + + for _, params := range []websocket.OrderPlaceParams{ + {Symbol: "GEMI-TEST", Side: websocket.OrderSideBuy, Type: websocket.OrderTypeLimit, TimeInForce: websocket.TimeInForceGTC, Quantity: "1", Price: "0.50", StopPrice: "0.50"}, + {Symbol: "GEMI-TEST", Side: websocket.OrderSideSell, Type: websocket.OrderTypeLimit, TimeInForce: websocket.TimeInForceGTC, Quantity: "1", Price: "0.50", StopPrice: "0.50"}, + } { + if _, err := client.PlaceOrder(context.Background(), params); err != nil { + t.Fatalf("equal stop/limit prices should be accepted: %v", err) + } + } +} From 3a0ef63463d6470a43963b3e02726546ac4098f0 Mon Sep 17 00:00:00 2001 From: Andrew Fuller Date: Fri, 28 Aug 2026 13:40:23 -0400 Subject: [PATCH 02/26] fix(sdk-go): address security review findings --- packages/sdk-go/transport/retries.go | 9 ++-- packages/sdk-go/transport/transport.go | 52 +++++++++++---------- packages/sdk-go/transport/transport_test.go | 29 ++++++++++++ 3 files changed, 63 insertions(+), 27 deletions(-) diff --git a/packages/sdk-go/transport/retries.go b/packages/sdk-go/transport/retries.go index 66a1fab3..8003fcee 100644 --- a/packages/sdk-go/transport/retries.go +++ b/packages/sdk-go/transport/retries.go @@ -1,11 +1,11 @@ package transport import ( + "crypto/rand" "errors" "io" "math" - // nosemgrep: go.lang.security.audit.crypto.math_random.math-random-used - "math/rand/v2" + "math/big" "net" "net/http" "strconv" @@ -135,7 +135,10 @@ func (p RetryPolicy) CalculateBackoff(attempt int, retryAfterHeader string) time // Full jitter randomizes uniformly between zero and the calculated delay. if p.Jitter { - jitterFactor := rand.Float64() // #nosec G404 -- retry jitter is not security-sensitive + jitterFactor := 1.0 + if randomValue, err := rand.Int(rand.Reader, big.NewInt(1_000_000)); err == nil { + jitterFactor = float64(randomValue.Int64()) / 1_000_000 + } delay *= jitterFactor } diff --git a/packages/sdk-go/transport/transport.go b/packages/sdk-go/transport/transport.go index 3df43e8c..feeeb51a 100644 --- a/packages/sdk-go/transport/transport.go +++ b/packages/sdk-go/transport/transport.go @@ -277,34 +277,33 @@ func (c *Client) Execute(ctx context.Context, req *http.Request, payloadJSON []b } } - // Execute HTTP call - resp, lastErr = c.httpClient.Do(req.WithContext(ctx)) - if lastErr != nil { - if resp != nil && resp.Body != nil { - _ = resp.Body.Close() + // Execute HTTP call. Keep the response for this attempt separate from + // the response exposed to hooks and callers so an error path can never + // leave a partially initialized response behind. + attemptResp, attemptErr := c.httpClient.Do(req.WithContext(ctx)) + lastErr = attemptErr + resp = nil + if attemptErr != nil { + if attemptResp != nil && attemptResp.Body != nil { + _ = attemptResp.Body.Close() } - resp = nil - } - if lastErr == nil { - if resp == nil { - lastErr = ErrNoResponse - } else { - body, lastErr = readResponseBody(resp) - if lastErr == nil { - // readResponseBody closes the network response body. Expose a - // readable replacement to Execute callers while higher-level - // helpers continue to consume the captured bytes directly. - resp.Body = io.NopCloser(bytes.NewReader(body)) - } + } else if attemptResp == nil { + lastErr = ErrNoResponse + } else { + resp = attemptResp + body, lastErr = readResponseBody(resp) + if lastErr == nil { + // readResponseBody closes the network response body. Expose a + // readable replacement to Execute callers while higher-level + // helpers continue to consume the captured bytes directly. + resp.Body = io.NopCloser(bytes.NewReader(body)) } // Calibrate remote clock skew if Date header is present - if resp != nil { - dateStr := resp.Header.Get("Date") - if serverTime, err := http.ParseTime(dateStr); err == nil { - if cal, ok := c.auth.(auth.ClockSkewCalibrator); ok { - cal.CalibrateServerTime(serverTime) - } + dateStr := resp.Header.Get("Date") + if serverTime, err := http.ParseTime(dateStr); err == nil { + if cal, ok := c.auth.(auth.ClockSkewCalibrator); ok { + cal.CalibrateServerTime(serverTime) } } @@ -313,6 +312,11 @@ func (c *Client) Execute(ctx context.Context, req *http.Request, payloadJSON []b return resp, body, nil } } + if attemptErr == nil && resp == nil { + // Defensive fallback: the branch above should always assign resp + // when attemptErr is nil and attemptResp is non-nil. + lastErr = ErrNoResponse + } statusCode := 0 if resp != nil { diff --git a/packages/sdk-go/transport/transport_test.go b/packages/sdk-go/transport/transport_test.go index 0b28ad27..e7f74b44 100644 --- a/packages/sdk-go/transport/transport_test.go +++ b/packages/sdk-go/transport/transport_test.go @@ -279,6 +279,35 @@ func TestTransport_NilInputsAreHandledWithoutPanic(t *testing.T) { } } +func TestTransport_DropsPartialResponseWhenRoundTripReturnsError(t *testing.T) { + partialResponse := &http.Response{ + StatusCode: http.StatusBadGateway, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("partial response")), + } + client := NewClient( + WithHTTPClient(&http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return partialResponse, errors.New("connection failed") + })}), + WithRetryPolicy(RetryPolicy{Jitter: false}), + ) + req, err := http.NewRequest(http.MethodGet, "https://api.gemini.com/v1/test", nil) + if err != nil { + t.Fatalf("creating request: %v", err) + } + + resp, body, err := client.Execute(context.Background(), req, nil) + if resp != nil { + t.Fatalf("Execute returned partial response: %#v", resp) + } + if body != nil { + t.Fatalf("Execute returned partial body: %q", body) + } + if err == nil || !strings.Contains(err.Error(), "connection failed") { + t.Fatalf("Execute error = %v, want connection failure", err) + } +} + func TestTransport_MutatingPostNeverRetried(t *testing.T) { var attempts int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { From 4f9a8a91358e87b18c793d87118bbe56f4fc43b8 Mon Sep 17 00:00:00 2001 From: Andrew Fuller Date: Fri, 28 Aug 2026 13:42:37 -0400 Subject: [PATCH 03/26] ci(sdk-go): use resolvable checkout pin --- .github/workflows/validate-go-sdk.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/validate-go-sdk.yml b/.github/workflows/validate-go-sdk.yml index e643be48..5ecadf96 100644 --- a/.github/workflows/validate-go-sdk.yml +++ b/.github/workflows/validate-go-sdk.yml @@ -18,7 +18,7 @@ jobs: working-directory: packages/sdk-go steps: - - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906953 # v4.3.0 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false From 38023107a2300ddf65d3611e7905ee17d73cabe3 Mon Sep 17 00:00:00 2001 From: Andrew Fuller Date: Fri, 28 Aug 2026 13:43:02 -0400 Subject: [PATCH 04/26] fix(sdk-go): fail closed on unauthenticated private WebSocket privateWS was constructed with cfg.configErr instead of the derived privateConfigErr, so PrivateWebSocket() would dial with no auth when no auth strategy was configured, unlike the private REST client which already fails closed with ErrAuthenticationRequired. --- packages/sdk-go/client.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk-go/client.go b/packages/sdk-go/client.go index 0340f7b3..1e6b4795 100644 --- a/packages/sdk-go/client.go +++ b/packages/sdk-go/client.go @@ -194,7 +194,7 @@ func newClientFromConfig(cfg *clientConfig) *Client { cfg.auth, websocket.WithDialer(cfg.wsDialer), websocket.WithClientLogger(cfg.logger), - websocket.WithConfigurationError(cfg.configErr), + websocket.WithConfigurationError(privateConfigErr), ) return &Client{ From 759897ca5a9d2015dafcc0ee87eab1e34f0e7ab3 Mon Sep 17 00:00:00 2001 From: Andrew Fuller Date: Fri, 28 Aug 2026 13:46:53 -0400 Subject: [PATCH 05/26] ci(sdk-go): use patched Go toolchain for scans --- packages/sdk-go/scripts/go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk-go/scripts/go.mod b/packages/sdk-go/scripts/go.mod index 211afe28..1393a0d8 100644 --- a/packages/sdk-go/scripts/go.mod +++ b/packages/sdk-go/scripts/go.mod @@ -1,6 +1,6 @@ module generate-tools -go 1.24.3 +go 1.25.13 require ( github.com/getkin/kin-openapi v0.135.0 From 070eac521d32e397747269646975cf13a148b28d Mon Sep 17 00:00:00 2001 From: Andrew Fuller Date: Fri, 28 Aug 2026 13:47:24 -0400 Subject: [PATCH 06/26] fix(sdk-go): bound decimal scale to close alignScale DoS vector alignScale (used by Cmp/Add/Sub/Equal/Min/Max) exponentiated an unbounded scale difference via bigExp10, unlike Div which already caps at maxDecimalOperationDigits. A single price/amount field parsed with an extreme scale could force a multi-million-bit big.Int computation on every comparison. Cap maxDecimalDigits/maxDecimalScale at the same 1,000-digit bound Div already treats as safe, so no parsed Decimal can produce a scale difference arithmetic isn't bounded to handle cheaply. --- packages/sdk-go/types/decimal.go | 13 ++++++++----- packages/sdk-go/types/decimal_test.go | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/packages/sdk-go/types/decimal.go b/packages/sdk-go/types/decimal.go index 525fe92f..4b099b03 100644 --- a/packages/sdk-go/types/decimal.go +++ b/packages/sdk-go/types/decimal.go @@ -47,14 +47,17 @@ var ( ) // maxDecimalDigits bounds parser work for untrusted JSON numbers while still -// allowing the precision used by exchange prices, quantities, and fees. -const maxDecimalDigits = 1_000_000 +// allowing the precision used by exchange prices, quantities, and fees. It is +// capped at maxDecimalOperationDigits so that scale differences produced by +// parsing can never exceed what arithmetic (including scale-aligning +// comparisons and additions, not just Div) can safely exponentiate. +const maxDecimalDigits = 1_000 const maxDecimalScale = int32(maxDecimalDigits) -// maxDecimalOperationDigits is intentionally much smaller than the parser -// bound. Arithmetic can allocate powers of ten, so accepting million-digit -// precision would still allow avoidable local resource exhaustion. +// maxDecimalOperationDigits bounds the scale difference any single +// arithmetic operation will exponentiate via bigExp10. Accepting +// higher-digit precision would allow avoidable local resource exhaustion. const maxDecimalOperationDigits = int32(1_000) func init() { diff --git a/packages/sdk-go/types/decimal_test.go b/packages/sdk-go/types/decimal_test.go index e8dd8ef3..a93ae21f 100644 --- a/packages/sdk-go/types/decimal_test.go +++ b/packages/sdk-go/types/decimal_test.go @@ -136,6 +136,23 @@ func TestDecimal_DivisionRejectsUnsafePrecision(t *testing.T) { } } +func TestDecimal_RejectsUnsafeScaleBeforeComparison(t *testing.T) { + // A scale this large would previously parse successfully and force + // alignScale to exponentiate an unbounded power of ten on every Cmp/Add, + // letting one adversarial price field turn a comparison loop into a + // CPU/memory exhaustion vector. It must now be rejected at parse time. + _, err := types.ParseDecimal("1e-1000001") + if !errors.Is(err, types.ErrInvalidDecimal) { + t.Fatalf("ParseDecimal with scale 1_000_001 error = %v, want ErrInvalidDecimal", err) + } + + huge := types.MustParseDecimal("1e-1000") + small := types.MustParseDecimal("1") + if huge.Cmp(small) >= 0 { + t.Errorf("expected %s < %s", huge, small) + } +} + func TestDecimal_InvalidStrings(t *testing.T) { invalidInputs := []string{ "", From b10eb0484ae07e8bef6767229ebe0b3b2a8127cc Mon Sep 17 00:00:00 2001 From: Andrew Fuller Date: Fri, 28 Aug 2026 13:52:24 -0400 Subject: [PATCH 07/26] fix(sdk-go): fix short-position liquidation floor and rebate fees CalculateLiquidationPrice floored the negative-factor case for long positions but not shorts, so an extreme maintenance margin rate (e.g. leverage=1, mmr=3.0) could return a negative liquidation price with a nil error. CalculateFee also silently zeroed any negative feeBps instead of computing the maker rebate it represents. --- packages/sdk-go/types/finance.go | 5 ++++- packages/sdk-go/types/finance_test.go | 20 ++++++++++++++++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/sdk-go/types/finance.go b/packages/sdk-go/types/finance.go index df35443c..8db46487 100644 --- a/packages/sdk-go/types/finance.go +++ b/packages/sdk-go/types/finance.go @@ -27,7 +27,7 @@ func CalculateNotional(price, quantity Decimal) Decimal { // CalculateFee computes the transaction fee in quote currency: Notional * (feeBps / 10000). func CalculateFee(notional Decimal, feeBps float64) Decimal { - if feeBps == 0 || notional.IsZero() || math.IsNaN(feeBps) || math.IsInf(feeBps, 0) || feeBps < 0 { + if feeBps == 0 || notional.IsZero() || math.IsNaN(feeBps) || math.IsInf(feeBps, 0) { return Zero() } bpsDec, err := ParseDecimal(strconv.FormatFloat(feeBps, 'f', -1, 64)) @@ -93,6 +93,9 @@ func CalculateLiquidationPrice(entryPrice Decimal, leverage float64, maintenance return Decimal{}, err } factor := one.Add(inverse).Sub(rateDec) + if factor.IsNegative() { + factor = Zero() + } if isLong { factor = one.Sub(inverse).Add(rateDec) if factor.IsNegative() { diff --git a/packages/sdk-go/types/finance_test.go b/packages/sdk-go/types/finance_test.go index 5a7b7ea1..7191ecb5 100644 --- a/packages/sdk-go/types/finance_test.go +++ b/packages/sdk-go/types/finance_test.go @@ -34,8 +34,10 @@ func TestCalculateFee(t *testing.T) { if got := types.CalculateFee(notional, math.NaN()); !got.IsZero() { t.Fatalf("expected NaN fee rate to produce zero, got %s", got) } - if got := types.CalculateFee(notional, -1); !got.IsZero() { - t.Fatalf("expected negative fee rate to produce zero, got %s", got) + // Negative bps represents a maker rebate and must produce a negative + // (credited) fee rather than being silently zeroed out. + if got := types.CalculateFee(notional, -10.0); got.String() != "-100" { + t.Fatalf("expected negative fee rate to produce a -100 rebate, got %s", got) } } @@ -87,6 +89,20 @@ func TestCalculateLiquidationPrice(t *testing.T) { } } +func TestCalculateLiquidationPriceFloorsShortPositionAtZero(t *testing.T) { + entry := types.MustParseDecimal("50000") + // leverage=1, mmr=3.0 pass validation individually but drive the short + // factor (1 + 1/leverage - mmr) negative; the price must floor at zero + // like the long side already does, not go negative. + liqShort, err := types.CalculateLiquidationPrice(entry, 1, 3.0, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !liqShort.IsZero() { + t.Fatalf("expected short liquidation price to floor at zero, got %s", liqShort) + } +} + func TestCalculateLiquidationPriceRejectsInvalidInputs(t *testing.T) { entry := types.MustParseDecimal("50000") for _, leverage := range []float64{0.5, math.NaN(), math.Inf(1)} { From bc848db60b9989d6f414ab2afbf5cbf17261925a Mon Sep 17 00:00:00 2001 From: Andrew Fuller Date: Fri, 28 Aug 2026 13:52:24 -0400 Subject: [PATCH 08/26] fix(sdk-go): detect nil TokenFunc in Bearer.Validate Validate() only checked for a nil TokenSource interface, so a non-nil interface wrapping a nil TokenFunc (e.g. an unset auth.TokenFunc var) passed validation and panicked on the first real request instead of failing fast at construction. --- packages/sdk-go/auth/bearer.go | 3 +++ packages/sdk-go/auth/bearer_test.go | 1 + 2 files changed, 4 insertions(+) diff --git a/packages/sdk-go/auth/bearer.go b/packages/sdk-go/auth/bearer.go index cc39171e..24a6de8a 100644 --- a/packages/sdk-go/auth/bearer.go +++ b/packages/sdk-go/auth/bearer.go @@ -73,6 +73,9 @@ func (b *Bearer) Validate() error { if b == nil || b.tokenSource == nil { return ErrInvalidTokenSource } + if tf, ok := b.tokenSource.(TokenFunc); ok && tf == nil { + return fmt.Errorf("%w: token function is nil", ErrInvalidTokenSource) + } if source, ok := b.tokenSource.(staticTokenSource); ok && source.token == "" { return fmt.Errorf("%w: static token is empty", ErrInvalidTokenSource) } diff --git a/packages/sdk-go/auth/bearer_test.go b/packages/sdk-go/auth/bearer_test.go index 2481d9da..aa18f640 100644 --- a/packages/sdk-go/auth/bearer_test.go +++ b/packages/sdk-go/auth/bearer_test.go @@ -237,6 +237,7 @@ func TestBearer_Validate(t *testing.T) { wantErr bool }{ {name: "nil source", strategy: auth.NewBearerWithSource(nil), wantErr: true}, + {name: "nil token func", strategy: auth.NewBearerWithSource(auth.TokenFunc(nil)), wantErr: true}, {name: "empty static token", strategy: auth.NewBearer(""), wantErr: true}, {name: "dynamic source", strategy: auth.NewBearerWithSource(auth.TokenFunc(func(context.Context) (string, error) { return "token", nil From 58f8a5eb0484ceb708a9454d5750f8d30077c89a Mon Sep 17 00:00:00 2001 From: Andrew Fuller Date: Fri, 28 Aug 2026 14:03:26 -0400 Subject: [PATCH 09/26] docs(sdk-go): clarify Send()'s intentionally brief conn lock scope A code review flagged Send() releasing c.mu before WriteMessage as a race against reconnect's conn swap/close. On investigation this is not a bug: the Conn interface documents Close as safe to call concurrently with an in-flight WriteMessage, gorilla/websocket guarantees it, and holding c.mu for the write's duration was tried and reverted because it deadlocks Close/state-transition paths against a slow or blocked write (TestSubscriptionCleanupDoesNotWaitForBlockedWireRequest exists specifically to guard against that). No behavior change; adds a comment explaining why the lock is released early. --- packages/sdk-go/websocket/client_requests.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/sdk-go/websocket/client_requests.go b/packages/sdk-go/websocket/client_requests.go index dea24252..ba1072f9 100644 --- a/packages/sdk-go/websocket/client_requests.go +++ b/packages/sdk-go/websocket/client_requests.go @@ -77,6 +77,11 @@ func (c *Client) Send(ctx context.Context, payload any) error { return errors.New("gemini websocket: not connected") } c.mu.RUnlock() + // Close is documented (see the Conn interface) as safe to call + // concurrently with an in-flight WriteMessage, and gorilla/websocket + // itself guarantees this, so conn is not held past this point: doing so + // would make Close/state-transition paths that need c.mu wait on a + // slow or blocked write (see TestSubscriptionCleanupDoesNotWaitForBlockedWireRequest). return conn.WriteMessage(ctx, TextMessage, data) } From 803cdcb0e3c5807ea8b1c62e0a7da26ecb3fa950 Mon Sep 17 00:00:00 2001 From: Andrew Fuller Date: Fri, 28 Aug 2026 14:03:27 -0400 Subject: [PATCH 10/26] fix(sdk-go): make reconciler duplicate-order matching deterministic Sync() matched resting orders to target quotes by iterating the r.resting map directly. Go's randomized map iteration order meant that when two resting orders shared side/price/amount (e.g. after a partial fill), which one got kept vs. cancelled-and-replaced was nondeterministic, silently breaking the documented FIFO queue-priority guarantee. Sort resting orders ascending by their exchange-assigned OrderID (a monotonically increasing uint64) before matching, so the oldest queued order is always preferred. --- packages/sdk-go/services/reconciler.go | 36 ++++++++++- packages/sdk-go/services/reconciler_test.go | 68 +++++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/packages/sdk-go/services/reconciler.go b/packages/sdk-go/services/reconciler.go index 52b6bc62..54189665 100644 --- a/packages/sdk-go/services/reconciler.go +++ b/packages/sdk-go/services/reconciler.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "math" + "sort" "strconv" "strings" "sync" @@ -255,8 +256,17 @@ func (r *QuoteReconciler) Sync(ctx context.Context, targets []DesiredQuote) (*Re matchedResting := make(map[string]bool) matchedTargets := make(map[int]bool) + // Map iteration order is randomized, so scanning r.resting directly would + // make it nondeterministic which duplicate order (e.g. two resting orders + // left with identical side/price/amount after a partial fill) gets kept + // vs. cancelled-and-replaced. Gemini order IDs are assigned as a + // monotonically increasing uint64, so sorting ascending by OrderID and + // matching in that order always prefers the oldest, best-queued order. + restingByAge := restingOrdersByAge(r.resting) + for tIdx, target := range cleanTargets { - for ordID, ord := range r.resting { + for _, ord := range restingByAge { + ordID := ord.OrderID if matchedResting[ordID] { continue } @@ -570,6 +580,30 @@ func (r *QuoteReconciler) CancelAll(ctx context.Context) error { return nil } +// restingOrdersByAge returns resting orders sorted oldest-first by their +// exchange-assigned OrderID, which Gemini issues as a monotonically +// increasing uint64. Orders whose ID isn't a valid uint64 sort after all +// numeric IDs, ordered lexicographically among themselves, rather than +// panicking or reordering unpredictably. +func restingOrdersByAge(resting map[string]RestingOrder) []RestingOrder { + ordered := make([]RestingOrder, 0, len(resting)) + for _, o := range resting { + ordered = append(ordered, o) + } + sort.Slice(ordered, func(i, j int) bool { + iID, iErr := strconv.ParseUint(ordered[i].OrderID, 10, 64) + jID, jErr := strconv.ParseUint(ordered[j].OrderID, 10, 64) + if iErr == nil && jErr == nil { + return iID < jID + } + if iErr == nil || jErr == nil { + return iErr == nil + } + return ordered[i].OrderID < ordered[j].OrderID + }) + return ordered +} + // deleteRestingIfUnchanged removes an order only when the ledger still holds // the same snapshot that was cancelled. A WebSocket event may update the // order while the REST cancellation is in flight; retaining that newer state diff --git a/packages/sdk-go/services/reconciler_test.go b/packages/sdk-go/services/reconciler_test.go index a21f2e86..6e8ef232 100644 --- a/packages/sdk-go/services/reconciler_test.go +++ b/packages/sdk-go/services/reconciler_test.go @@ -237,6 +237,74 @@ func TestQuoteReconciler_PreservesNewerEventDuringCancellation(t *testing.T) { } } +func TestQuoteReconciler_SyncPrefersOldestDuplicateOrder(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path != "/v1/order/cancel" { + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode(trading.CancelOrderResponse{IsCancelled: ptrBool(true), IsLive: ptrBool(false)}) + })) + defer server.Close() + + reconciler := services.NewQuoteReconciler( + services.NewTradingService( + transport.NewClient(transport.WithHTTPClient(server.Client())), + server.URL, + ), + nil, + "BTCUSD", + ) + + // Two resting orders left with identical side/price/quantity, as can + // happen after a partial fill. Order 101 was queued first; the reconciler + // must deterministically keep it and cancel the newer duplicate (102) + // rather than depending on Go's randomized map iteration order. + for _, id := range []int64{101, 102} { + reconciler.ApplyOrderEvent(&websocket.OrderEvent{ + EventType: "order", + Symbol: "BTCUSD", + OrderID: id, + Side: "BUY", + Price: "100", + Quantity: "1", + OrderStatus: "NEW", + }) + } + + target := []services.DesiredQuote{ + {Side: "buy", Price: types.MustParseDecimal("100"), Amount: types.MustParseDecimal("1")}, + } + + for i := 0; i < 20; i++ { + result, err := reconciler.Sync(context.Background(), target) + if err != nil { + t.Fatalf("Sync failed: %v", err) + } + if result.Kept != 1 || result.Cancelled != 1 { + t.Fatalf("run %d: unexpected reconcile result: %+v", i, result) + } + + active := reconciler.ActiveOrders() + if len(active) != 1 || active[0].OrderID != "101" { + t.Fatalf("run %d: expected oldest order 101 to be kept, got %+v", i, active) + } + + // Restore order 102 for the next iteration so repeated runs keep + // exercising the same duplicate-matching decision. + reconciler.ApplyOrderEvent(&websocket.OrderEvent{ + EventType: "order", + Symbol: "BTCUSD", + OrderID: 102, + Side: "BUY", + Price: "100", + Quantity: "1", + OrderStatus: "NEW", + }) + } +} + func TestQuoteReconciler_ApplyOrderEvent(t *testing.T) { reconciler := services.NewQuoteReconciler(nil, nil, "BTCUSD") From d86a37dec2697e2413d6bb66b3a75f7bee3814e7 Mon Sep 17 00:00:00 2001 From: Andrew Fuller Date: Fri, 28 Aug 2026 14:03:27 -0400 Subject: [PATCH 11/26] fix(sdk-go): accept float64 order IDs in validOrderID encoding/json decodes JSON numbers into interface{} as float64, so an order ID round-tripped through generic JSON decoding elsewhere in a caller's application had no matching case and was always rejected with "order ID is required". --- packages/sdk-go/websocket/typed_requests.go | 2 ++ .../sdk-go/websocket/typed_requests_test.go | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/packages/sdk-go/websocket/typed_requests.go b/packages/sdk-go/websocket/typed_requests.go index dfeb152c..8a3745a7 100644 --- a/packages/sdk-go/websocket/typed_requests.go +++ b/packages/sdk-go/websocket/typed_requests.go @@ -308,6 +308,8 @@ func validOrderID(value any) bool { return id > 0 case uint64: return id > 0 + case float64: + return id > 0 default: return false } diff --git a/packages/sdk-go/websocket/typed_requests_test.go b/packages/sdk-go/websocket/typed_requests_test.go index fde81d75..710debd0 100644 --- a/packages/sdk-go/websocket/typed_requests_test.go +++ b/packages/sdk-go/websocket/typed_requests_test.go @@ -198,6 +198,23 @@ func TestTypedOrderRequestsRejectInvalidValuesBeforeSending(t *testing.T) { } } +func TestTypedOrderRequestsAcceptFloat64OrderID(t *testing.T) { + // encoding/json decodes JSON numbers into interface{} as float64, so an + // order ID that has round-tripped through generic JSON decoding elsewhere + // in a caller's application must still pass validation here. + dialer := &mockDrainDialer{responseResult: json.RawMessage(`{"orderId":7}`)} + client := websocket.NewPrivateClient( + "wss://ws.gemini.com", + auth.NewHMAC("key", "secret"), + websocket.WithDialer(dialer), + ) + defer client.Close() + + if _, err := client.CancelOrder(context.Background(), websocket.OrderCancelParams{OrderID: float64(7)}); err != nil { + t.Fatalf("expected float64 order ID to pass validation, got: %v", err) + } +} + func TestTypedOrderRequestsAllowEqualStopAndLimitPrices(t *testing.T) { client := websocket.NewPrivateClient( "wss://ws.gemini.com", From 1583d85ff08dbe8f6e5fc2c3a9c252b97531349c Mon Sep 17 00:00:00 2001 From: Andrew Fuller Date: Fri, 28 Aug 2026 14:09:40 -0400 Subject: [PATCH 12/26] fix(sdk-go): treat empty HTTP method as safe-to-retry GET net/http treats an empty Request.Method as GET on the wire (e.g. a *http.Request built via struct literal, bypassing http.NewRequest's normalization), but IsSafeMethod("") returned false, so transient network errors on such a request were never retried even though the request is a safe, idempotent GET. --- packages/sdk-go/transport/retries.go | 4 ++++ packages/sdk-go/transport/retry_edges_test.go | 23 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/packages/sdk-go/transport/retries.go b/packages/sdk-go/transport/retries.go index 8003fcee..50b5a840 100644 --- a/packages/sdk-go/transport/retries.go +++ b/packages/sdk-go/transport/retries.go @@ -36,6 +36,10 @@ func DefaultRetryPolicy() RetryPolicy { // IsSafeMethod returns true only for idempotent/safe HTTP methods (GET, HEAD). func IsSafeMethod(method string) bool { + // net/http treats an empty Request.Method as GET on the wire. + if method == "" { + return true + } upper := strings.ToUpper(method) return upper == http.MethodGet || upper == http.MethodHead } diff --git a/packages/sdk-go/transport/retry_edges_test.go b/packages/sdk-go/transport/retry_edges_test.go index da2e13d5..e7dd2cd5 100644 --- a/packages/sdk-go/transport/retry_edges_test.go +++ b/packages/sdk-go/transport/retry_edges_test.go @@ -47,6 +47,29 @@ func TestParseRetryAfter(t *testing.T) { } } +func TestIsSafeMethod(t *testing.T) { + tests := []struct { + method string + want bool + }{ + // net/http treats an empty Request.Method as GET on the wire (a + // struct literal built without http.NewRequest leaves it empty), so + // it must be treated as safe like an explicit GET. + {method: "", want: true}, + {method: http.MethodGet, want: true}, + {method: "get", want: true}, + {method: http.MethodHead, want: true}, + {method: http.MethodPost, want: false}, + {method: http.MethodPut, want: false}, + {method: http.MethodDelete, want: false}, + } + for _, tt := range tests { + if got := IsSafeMethod(tt.method); got != tt.want { + t.Errorf("IsSafeMethod(%q) = %v, want %v", tt.method, got, tt.want) + } + } +} + func TestRetryPolicyCalculateBackoffEdges(t *testing.T) { policy := RetryPolicy{ BaseDelay: 10 * time.Millisecond, From e70cc514cb9c5b000c6ccdcdae8aa71b740302d1 Mon Sep 17 00:00:00 2001 From: Andrew Fuller Date: Fri, 28 Aug 2026 14:09:40 -0400 Subject: [PATCH 13/26] fix(sdk-go): fully redact short/medium-length API keys APIKey.String() revealed a fixed 4-char prefix and 4-char suffix for any key longer than 8 characters, so a 10-character key showed 8 of its 10 characters (80%) in logs/telemetry. Only reveal the prefix+suffix fingerprint once the key is long enough that it's a minority of the key (len > 20, so at most ~40% revealed); shorter keys are now fully redacted like before. --- packages/sdk-go/auth/bearer_test.go | 10 ++++++++++ packages/sdk-go/auth/secrets.go | 7 +++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/sdk-go/auth/bearer_test.go b/packages/sdk-go/auth/bearer_test.go index aa18f640..57c807dc 100644 --- a/packages/sdk-go/auth/bearer_test.go +++ b/packages/sdk-go/auth/bearer_test.go @@ -275,6 +275,16 @@ func TestBearerToken_Redaction(t *testing.T) { } } +func TestAPIKey_ShortAndMediumLengthKeysAreFullyRedacted(t *testing.T) { + // A prefix+suffix reveal is only safe when it's a minority of the key. + // At 10 characters, revealing 4+4 would expose 80% of the key. + for _, key := range []auth.APIKey{"abcdefghij", "exactly-twenty-chars"} { + if got := key.String(); got != "[REDACTED_KEY]" { + t.Errorf("APIKey(%q).String() = %q, want fully redacted", string(key), got) + } + } +} + func TestCredentials_FullRedactionCoverage(t *testing.T) { key := auth.APIKey("my-long-test-api-key-12345") secret := auth.APISecret("super-secret-hmac-key") diff --git a/packages/sdk-go/auth/secrets.go b/packages/sdk-go/auth/secrets.go index 1e51a859..526a357e 100644 --- a/packages/sdk-go/auth/secrets.go +++ b/packages/sdk-go/auth/secrets.go @@ -8,10 +8,13 @@ import ( // APIKey represents a Gemini API Key identifier. type APIKey string -// String masks the API key for safe terminal/log output. +// String masks the API key for safe terminal/log output. It only reveals an +// 8-character prefix+suffix fingerprint when the key is long enough that the +// reveal is a minority of the key (at most ~40%); shorter keys are fully +// redacted so a short-to-medium key can't have most of itself exposed. func (k APIKey) String() string { raw := string(k) - if len(raw) <= 8 { + if len(raw) <= 20 { return "[REDACTED_KEY]" } return fmt.Sprintf("%s...%s", raw[:4], raw[len(raw)-4:]) From 9f74f8bb9de105a6e2c877ae5a905fb903e15f42 Mon Sep 17 00:00:00 2001 From: Andrew Fuller Date: Fri, 28 Aug 2026 15:30:55 -0400 Subject: [PATCH 14/26] fix(sdk-go): close remaining security gaps --- packages/sdk-go/README.md | 10 +++-- packages/sdk-go/aliases.go | 6 ++- packages/sdk-go/auth/hmac.go | 12 ++++-- packages/sdk-go/auth/hmac_test.go | 7 ++++ packages/sdk-go/errors.go | 4 ++ packages/sdk-go/services/perpetuals_test.go | 2 +- packages/sdk-go/services/predictions_test.go | 2 +- packages/sdk-go/services/trading.go | 16 +++++++- packages/sdk-go/services/trading_test.go | 20 +++++++++- packages/sdk-go/transport/errors.go | 8 ++++ packages/sdk-go/transport/transport.go | 4 ++ packages/sdk-go/transport/transport_test.go | 37 ++++++++++++++++--- packages/sdk-go/websocket/client_requests.go | 8 ++++ packages/sdk-go/websocket/client_test.go | 17 +++++++++ packages/sdk-go/websocket/typed_requests.go | 7 +++- .../sdk-go/websocket/typed_requests_test.go | 16 ++++++++ 16 files changed, 156 insertions(+), 20 deletions(-) diff --git a/packages/sdk-go/README.md b/packages/sdk-go/README.md index 9df2a5ab..01c6f093 100644 --- a/packages/sdk-go/README.md +++ b/packages/sdk-go/README.md @@ -72,8 +72,9 @@ defer client.Close() Custom REST endpoints must use `https`; custom WebSocket endpoints must use the `wss` scheme. Endpoints may include a path prefix but cannot include userinfo, a query string, or a fragment. `NewClientWithError` validates both before -returning a client. The lower-level transport adapters remain protocol -agnostic so they can be used with isolated test fixtures. +returning a client. Authenticated low-level transport requests also require an +`https` URL; unauthenticated adapters remain protocol agnostic so they can be +used with isolated HTTP test fixtures. --- @@ -383,8 +384,9 @@ order as `STOP_LIMIT` on the order-event stream. `stopPrice` is not valid with `MARKET`. The WebSocket contract allows `stopPrice == Price`; the legacy REST `TradingService.NewOrder` contract requires a strict inequality (`stopPrice < Price` for buys and `stopPrice > Price` for sells). Account-wide cancellation requires -`CancelAllOptions{Confirm: true}` so a destructive request cannot be issued by -omitting a parameter accidentally. +`CancelAllOptions{Confirm: true}` over WebSocket or +`CancelAllOrdersOptions{Confirm: true}` through `TradingService` so a +destructive request cannot be issued by omitting a parameter accidentally. Partial-depth subscriptions use one underlying connection per symbol because their snapshot envelope may not include a symbol. Differential depth diff --git a/packages/sdk-go/aliases.go b/packages/sdk-go/aliases.go index 73f7718e..8d3dffd4 100644 --- a/packages/sdk-go/aliases.go +++ b/packages/sdk-go/aliases.go @@ -62,6 +62,10 @@ type ( // QuoteReconciler manages declarative target order diffing and execution. QuoteReconciler = services.QuoteReconciler + // CancelAllOrdersOptions protects the REST account-wide cancellation method + // from accidental invocation. + CancelAllOrdersOptions = services.CancelAllOrdersOptions + // OrderOption configures an outgoing order request. OrderOption = services.OrderOption @@ -142,7 +146,7 @@ func NewPaginator[T any](ctx context.Context, initialOffset, pageSize int, fetch // Re-exported helper functions and options. var ( - // ErrCancelConfirmationRequired indicates that a destructive WebSocket cancellation was not confirmed. + // ErrCancelConfirmationRequired indicates that a destructive cancel-all operation was not confirmed. ErrCancelConfirmationRequired = websocket.ErrCancelConfirmationRequired // ErrNoDialerConfigured indicates that no WebSocket transport adapter was configured. diff --git a/packages/sdk-go/auth/hmac.go b/packages/sdk-go/auth/hmac.go index 6131715c..edc47992 100644 --- a/packages/sdk-go/auth/hmac.go +++ b/packages/sdk-go/auth/hmac.go @@ -5,7 +5,6 @@ import ( "context" "crypto/hmac" "crypto/sha512" - "crypto/subtle" "encoding/base64" "encoding/hex" "encoding/json" @@ -315,8 +314,15 @@ func (h *HMAC) AuthenticateWebSocket(ctx context.Context, req *http.Request) err // VerifySignature verifies an incoming Gemini HMAC-SHA384 signature in constant time against a Base64-encoded payload. func VerifySignature(secret APISecret, b64Payload, signature string) bool { + if strings.TrimSpace(string(secret)) == "" || len(signature) != hex.EncodedLen(sha512.Size384) { + return false + } + provided, err := hex.DecodeString(signature) + if err != nil { + return false + } + hasher := hmac.New(sha512.New384, []byte(secret)) _, _ = io.WriteString(hasher, b64Payload) - sig := hashToHex(hasher) - return subtle.ConstantTimeCompare([]byte(strings.ToLower(sig)), []byte(strings.ToLower(signature))) == 1 + return hmac.Equal(hasher.Sum(nil), provided) } diff --git a/packages/sdk-go/auth/hmac_test.go b/packages/sdk-go/auth/hmac_test.go index 2a91d765..09a01e31 100644 --- a/packages/sdk-go/auth/hmac_test.go +++ b/packages/sdk-go/auth/hmac_test.go @@ -294,6 +294,13 @@ func TestVerifySignature(t *testing.T) { if VerifySignature(secret, "tampered-payload", validSig) { t.Fatal("expected tampered payload to fail verification") } + + if VerifySignature("", b64Payload, NewHMAC("any-key", "").Sign([]byte(b64Payload))) { + t.Fatal("expected an empty HMAC secret to fail closed") + } + if VerifySignature(secret, b64Payload, strings.Repeat("g", 96)) { + t.Fatal("expected malformed hexadecimal signature to fail verification") + } } func TestHMAC_BuildPayload_NoDuplicateEnvelopeKeys(t *testing.T) { diff --git a/packages/sdk-go/errors.go b/packages/sdk-go/errors.go index ec845b56..5b9fe359 100644 --- a/packages/sdk-go/errors.go +++ b/packages/sdk-go/errors.go @@ -52,6 +52,10 @@ var ( // attempted without an authentication strategy. ErrAuthenticationRequired = transport.ErrAuthenticationRequired + // ErrHTTPSRequired indicates that an authenticated request used an insecure + // URL scheme. + ErrHTTPSRequired = transport.ErrHTTPSRequired + // ErrInvalidEnvironment indicates that a client was configured with an // environment that is not one of the SDK's known deployment targets. ErrInvalidEnvironment = errors.New("gemini: invalid environment") diff --git a/packages/sdk-go/services/perpetuals_test.go b/packages/sdk-go/services/perpetuals_test.go index 9a2fbad4..01c96b98 100644 --- a/packages/sdk-go/services/perpetuals_test.go +++ b/packages/sdk-go/services/perpetuals_test.go @@ -63,7 +63,7 @@ func TestPerpetualsService_FundingEndpoints(t *testing.T) { }) }) - server := httptest.NewServer(mux) + server := httptest.NewTLSServer(mux) defer server.Close() tr := transport.NewClient( diff --git a/packages/sdk-go/services/predictions_test.go b/packages/sdk-go/services/predictions_test.go index 5338d67c..4e124599 100644 --- a/packages/sdk-go/services/predictions_test.go +++ b/packages/sdk-go/services/predictions_test.go @@ -17,7 +17,7 @@ import ( func TestPredictionsService_TermsGating(t *testing.T) { termsAccepted := false - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/v1/prediction-markets/terms/accept": diff --git a/packages/sdk-go/services/trading.go b/packages/sdk-go/services/trading.go index 1732a27d..3c64a4a3 100644 --- a/packages/sdk-go/services/trading.go +++ b/packages/sdk-go/services/trading.go @@ -15,6 +15,13 @@ type TradingService struct { baseService } +// CancelAllOrdersOptions controls the destructive account-wide cancellation +// endpoint. Callers must explicitly set Confirm to true before a request is +// sent. +type CancelAllOrdersOptions struct { + Confirm bool +} + // NewTradingService creates a new TradingService. func NewTradingService(client *transport.Client, baseURL string) *TradingService { return &TradingService{ @@ -168,8 +175,13 @@ func (s *TradingService) CancelOrder(ctx context.Context, req *trading.CancelOrd return &res, nil } -// CancelAllOrders cancels all active orders for the authenticated account/session. -func (s *TradingService) CancelAllOrders(ctx context.Context, req *trading.CancelAllOrdersRequest) (*trading.CancelAllResult, error) { +// CancelAllOrders cancels all active orders for the authenticated account/session +// after explicit confirmation. The variadic form preserves source compatibility +// for existing callers, but omitting confirmation now fails closed. +func (s *TradingService) CancelAllOrders(ctx context.Context, req *trading.CancelAllOrdersRequest, options ...CancelAllOrdersOptions) (*trading.CancelAllResult, error) { + if len(options) != 1 || !options[0].Confirm { + return nil, transport.ErrCancelConfirmationRequired + } if req == nil { req = &trading.CancelAllOrdersRequest{} } diff --git a/packages/sdk-go/services/trading_test.go b/packages/sdk-go/services/trading_test.go index f3bf98f5..0e0c02b8 100644 --- a/packages/sdk-go/services/trading_test.go +++ b/packages/sdk-go/services/trading_test.go @@ -3,6 +3,7 @@ package services_test import ( "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "testing" @@ -258,8 +259,25 @@ func TestTradingService_Methods(t *testing.T) { } // 5. CancelAllOrders - cancelAllRes, err := svc.CancelAllOrders(ctx, nil) + cancelAllRes, err := svc.CancelAllOrders(ctx, nil, services.CancelAllOrdersOptions{Confirm: true}) if err != nil || cancelAllRes.Details == nil { t.Fatalf("CancelAllOrders failed: %v, %+v", err, cancelAllRes) } } + +func TestTradingService_CancelAllRequiresExplicitConfirmation(t *testing.T) { + var requests int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + svc := services.NewTradingService(transport.NewClient(transport.WithHTTPClient(server.Client())), server.URL) + if _, err := svc.CancelAllOrders(context.Background(), nil); !errors.Is(err, transport.ErrCancelConfirmationRequired) { + t.Fatalf("unconfirmed CancelAllOrders error = %v, want ErrCancelConfirmationRequired", err) + } + if requests != 0 { + t.Fatalf("unconfirmed CancelAllOrders reached the server %d times", requests) + } +} diff --git a/packages/sdk-go/transport/errors.go b/packages/sdk-go/transport/errors.go index 2986586a..be68825d 100644 --- a/packages/sdk-go/transport/errors.go +++ b/packages/sdk-go/transport/errors.go @@ -64,6 +64,14 @@ var ( // attempted without an authentication strategy. ErrAuthenticationRequired = errors.New("gemini client: authentication strategy required") + // ErrHTTPSRequired indicates that an authenticated HTTP request used an + // insecure URL scheme. + ErrHTTPSRequired = errors.New("gemini client: authenticated requests require HTTPS") + + // ErrCancelConfirmationRequired indicates that a destructive cancel-all + // operation was not explicitly confirmed by the caller. + ErrCancelConfirmationRequired = errors.New("gemini client: cancel-all operation requires explicit confirmation") + ErrConnectionClosed = errors.New("gemini client: connection closed") ErrDeadlineExceeded = errors.New("gemini client: request deadline exceeded") ErrResyncRequired = errors.New("gemini client: orderbook sequence gap detected; resync required") diff --git a/packages/sdk-go/transport/transport.go b/packages/sdk-go/transport/transport.go index feeeb51a..e0d3f79d 100644 --- a/packages/sdk-go/transport/transport.go +++ b/packages/sdk-go/transport/transport.go @@ -11,6 +11,7 @@ import ( "net" "net/http" "runtime" + "strings" "sync" "time" @@ -192,6 +193,9 @@ func (c *Client) Execute(ctx context.Context, req *http.Request, payloadJSON []b if req.URL == nil { return nil, nil, errors.New("gemini transport: request URL is nil") } + if c.auth != nil && !strings.EqualFold(req.URL.Scheme, "https") { + return nil, nil, ErrHTTPSRequired + } if req.Header == nil { req.Header = make(http.Header) } diff --git a/packages/sdk-go/transport/transport_test.go b/packages/sdk-go/transport/transport_test.go index e7f74b44..7d1e657b 100644 --- a/packages/sdk-go/transport/transport_test.go +++ b/packages/sdk-go/transport/transport_test.go @@ -57,6 +57,33 @@ func TestTransport_ExecuteReturnsReadableResponseBody(t *testing.T) { } } +func TestTransport_AuthenticatedRequestsRequireHTTPS(t *testing.T) { + var roundTrips atomic.Int32 + client := NewClient( + WithHTTPClient(&http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + roundTrips.Add(1) + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"result":"unexpected"}`)), + Request: req, + }, nil + })}), + WithAuth(auth.NewBearer(auth.BearerToken("test-token"))), + ) + + req, err := http.NewRequest(http.MethodGet, "http://api.gemini.com/v1/test", nil) + if err != nil { + t.Fatalf("creating request: %v", err) + } + if _, _, err := client.Execute(context.Background(), req, nil); !errors.Is(err, ErrHTTPSRequired) { + t.Fatalf("Execute error = %v, want ErrHTTPSRequired", err) + } + if got := roundTrips.Load(); got != 0 { + t.Fatalf("insecure authenticated request reached RoundTripper %d times", got) + } +} + func TestTransport_SafeGetRetry(t *testing.T) { var attempts int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -141,7 +168,7 @@ func TestTransport_BearerReauthenticatesSafeGetRetry(t *testing.T) { var attempts atomic.Int32 var sourceCalls atomic.Int32 var authHeaders []string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { authHeaders = append(authHeaders, r.Header.Get("Authorization")) if attempts.Add(1) == 1 { w.WriteHeader(http.StatusServiceUnavailable) @@ -239,7 +266,7 @@ func TestTransport_BearerCredentialsAreNotForwardedAcrossRedirects(t *testing.T) })) defer target.Close() - redirect := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + redirect := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { redirectAuth = r.Header.Get("Authorization") http.Redirect(w, r, target.URL, http.StatusFound) })) @@ -342,7 +369,7 @@ func TestTransport_MutatingPostNeverRetried(t *testing.T) { func TestTransport_BearerPostNeverRetried(t *testing.T) { var attempts atomic.Int32 var sourceCalls atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { attempts.Add(1) w.WriteHeader(http.StatusTooManyRequests) _, _ = w.Write([]byte(`{"result":"error","reason":"RateLimit","message":"oauth operation rate limited"}`)) @@ -374,7 +401,7 @@ func TestTransport_ClockSkewCalibration(t *testing.T) { // Server clock is 2 hours in the future futureServerTime := time.Now().Add(2 * time.Hour).UTC().Truncate(time.Second) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Date", futureServerTime.Format(http.TimeFormat)) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"result":"ok"}`)) @@ -417,7 +444,7 @@ func TestTransport_SerializesAuthenticatedRequestsThroughRetries(t *testing.T) { firstRequestStarted := make(chan struct{}) releaseFirstRequest := make(chan struct{}) var firstRequest sync.Once - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { current := active.Add(1) for { previous := maxActive.Load() diff --git a/packages/sdk-go/websocket/client_requests.go b/packages/sdk-go/websocket/client_requests.go index ba1072f9..a93a1a6d 100644 --- a/packages/sdk-go/websocket/client_requests.go +++ b/packages/sdk-go/websocket/client_requests.go @@ -95,12 +95,20 @@ func (c *Client) Request(ctx context.Context, method string, params any) (Respon if strings.TrimSpace(method) == "" { return ResponseFrame{}, errors.New("gemini websocket: request method is empty") } + if requiresAuthentication(method) && c.auth == nil { + return ResponseFrame{}, ErrAuthenticationRequired + } if err := c.Connect(ctx); err != nil { return ResponseFrame{}, err } return c.requestConnected(ctx, method, params) } +func requiresAuthentication(method string) bool { + method = strings.ToLower(strings.TrimSpace(method)) + return strings.HasPrefix(method, "order.") || strings.HasPrefix(method, "rfq.") +} + // requestConnected sends a request without attempting to establish a // connection. Subscription and replay code uses this after it has already // coordinated connection state, so it cannot recursively wait on the diff --git a/packages/sdk-go/websocket/client_test.go b/packages/sdk-go/websocket/client_test.go index 74a4d013..12ce7d27 100644 --- a/packages/sdk-go/websocket/client_test.go +++ b/packages/sdk-go/websocket/client_test.go @@ -810,6 +810,23 @@ func TestClient_PublicAndPrivateSeparation(t *testing.T) { _ = orderCh } +func TestClient_GenericPrivateMethodsRequireAuthentication(t *testing.T) { + dialer := &mockDrainDialer{} + client := websocket.NewPublicClient("wss://ws.gemini.com", websocket.WithDialer(dialer)) + defer client.Close() + + for _, method := range []string{"order.place", "order.cancel", "order.cancel_all", "rfq.submit_quote", "rfq.confirm_quote"} { + t.Run(method, func(t *testing.T) { + if _, err := client.Request(context.Background(), method, nil); !errors.Is(err, websocket.ErrAuthenticationRequired) { + t.Fatalf("Request(%q) error = %v, want ErrAuthenticationRequired", method, err) + } + }) + } + if got := dialer.connCount(); got != 0 { + t.Fatalf("unauthenticated private methods opened %d WebSocket connections", got) + } +} + func TestClient_IndependentPublicPrivateLifecycles(t *testing.T) { dialer := &mockDrainDialer{} apiKey := "lifecycle-key" diff --git a/packages/sdk-go/websocket/typed_requests.go b/packages/sdk-go/websocket/typed_requests.go index 8a3745a7..f3fbe4e7 100644 --- a/packages/sdk-go/websocket/typed_requests.go +++ b/packages/sdk-go/websocket/typed_requests.go @@ -5,9 +5,11 @@ import ( "encoding/json" "errors" "fmt" + "math" "strconv" "strings" + "github.com/gemini/gemini-go/transport" "github.com/gemini/gemini-go/types" ) @@ -82,7 +84,7 @@ type CancelAllOptions struct { // ErrCancelConfirmationRequired indicates that a destructive cancel-all // request was not explicitly confirmed by the caller. -var ErrCancelConfirmationRequired = errors.New("gemini websocket: cancel-all operation requires explicit confirmation") +var ErrCancelConfirmationRequired = transport.ErrCancelConfirmationRequired // ConnInfo returns the open-ended result of conninfo. func (c *Client) ConnInfo(ctx context.Context) (JSONObject, error) { @@ -309,7 +311,8 @@ func validOrderID(value any) bool { case uint64: return id > 0 case float64: - return id > 0 + const maxSafeJSONInteger = 1<<53 - 1 + return id > 0 && id <= maxSafeJSONInteger && math.Trunc(id) == id && !math.IsNaN(id) && !math.IsInf(id, 0) default: return false } diff --git a/packages/sdk-go/websocket/typed_requests_test.go b/packages/sdk-go/websocket/typed_requests_test.go index 710debd0..7e23d555 100644 --- a/packages/sdk-go/websocket/typed_requests_test.go +++ b/packages/sdk-go/websocket/typed_requests_test.go @@ -3,6 +3,7 @@ package websocket_test import ( "context" "encoding/json" + "math" "testing" "time" @@ -215,6 +216,21 @@ func TestTypedOrderRequestsAcceptFloat64OrderID(t *testing.T) { } } +func TestTypedOrderRequestsRejectUnsafeFloat64OrderIDs(t *testing.T) { + client := websocket.NewPrivateClient( + "wss://ws.gemini.com", + auth.NewHMAC("key", "secret"), + websocket.WithDialer(&mockDrainDialer{}), + ) + defer client.Close() + + for _, orderID := range []float64{0, -1, 1.5, math.NaN(), math.Inf(1), float64(1 << 53)} { + if _, err := client.CancelOrder(context.Background(), websocket.OrderCancelParams{OrderID: orderID}); err == nil { + t.Fatalf("expected float64 order ID %v to fail validation", orderID) + } + } +} + func TestTypedOrderRequestsAllowEqualStopAndLimitPrices(t *testing.T) { client := websocket.NewPrivateClient( "wss://ws.gemini.com", From 11081519dc34a370b1ccdfca8864ce3a7df70886 Mon Sep 17 00:00:00 2001 From: Andrew Fuller Date: Fri, 28 Aug 2026 17:23:02 -0400 Subject: [PATCH 15/26] feat(go-sdk): add PKCE OAuth support --- packages/sdk-go/README.md | 70 ++- packages/sdk-go/auth/bearer.go | 5 +- packages/sdk-go/cmd/demo/main.go | 173 +++++- packages/sdk-go/cmd/demo/main_test.go | 24 + packages/sdk-go/oauth/oauth.go | 745 ++++++++++++++++++++++++++ packages/sdk-go/oauth/oauth_test.go | 411 ++++++++++++++ 6 files changed, 1419 insertions(+), 9 deletions(-) create mode 100644 packages/sdk-go/cmd/demo/main_test.go create mode 100644 packages/sdk-go/oauth/oauth.go create mode 100644 packages/sdk-go/oauth/oauth_test.go diff --git a/packages/sdk-go/README.md b/packages/sdk-go/README.md index 01c6f093..192b3d3e 100644 --- a/packages/sdk-go/README.md +++ b/packages/sdk-go/README.md @@ -118,9 +118,45 @@ client := gemini.NewClient( The token source is application-owned. It must be safe for concurrent calls, honor the request context, and return a current non-expired access token. The SDK calls it for each authenticated HTTP attempt and each WebSocket connection -or reconnect; it does not perform the OAuth authorization-code exchange, cache -tokens, or force-refresh after a `401` response. Use `gemini.EndpointsFor` to -look up the authorization and token endpoints for `Production` or `Sandbox`. +or reconnect; it does not force-refresh after a `401` response. The optional +`github.com/gemini/gemini-go/oauth` package provides PKCE authorization-code +and refresh-token helpers without making interactive login part of the core +client. + +The OAuth package keeps token persistence application-owned. Its +`Config.Login` convenience uses a fixed loopback callback such as +`http://localhost:8787/callback`; that is the only HTTP URL permitted by the +package. Authorization and token endpoints must use HTTPS. Applications that +already have their own browser flow can use `Config.AuthCodeURL` and +`Config.Exchange` directly, then pass the result to `oauth.NewTokenSource`: + +```go +oauthConfig := oauth.Config{ + ClientID: os.Getenv("GEMINI_OAUTH_CLIENT_ID"), + Endpoint: oauth.Endpoint{ + AuthURL: "https://exchange.gemini.com/auth", + TokenURL: "https://exchange.gemini.com/auth/token", + }, + RedirectURL: "http://localhost:8787/callback", + Scopes: []string{"account:read", "orders:create"}, +} + +token, err := oauthConfig.Login(ctx, openBrowser) +if err != nil { + log.Fatal(err) +} +source, err := oauth.NewTokenSource(oauthConfig, *token) +if err != nil { + log.Fatal(err) +} +client := gemini.NewClient(gemini.WithTokenSource(source)) +``` + +`oauth.Source` refreshes once for concurrent callers, honors cancellation +while waiting for another refresh, and preserves a refresh token when the +provider omits it from a rotation response. It does not write credentials to +disk or a keychain; callers may load and persist tokens through their own +secure storage. If a token source fails during an automatic WebSocket reconnect, the SDK stops that reconnect loop, reports the underlying error through connection events, and leaves the client disconnected so the source can be repaired before a @@ -157,6 +193,34 @@ GEMINI_OAUTH_ENVIRONMENT=sandbox \ go test -tags=integration ./... ``` +### Local OAuth and RFQ Demo + +The live demo uses the Markets CLI's public OAuth client ID by default, opens +the production consent page, and keeps the resulting tokens in memory only: + +```bash +cd cmd/demo +GEMINI_DEMO_OAUTH_LOGIN=1 go run . +``` + +To arm one live RFQ quote submission, also provide an explicit confirmation and +the quote parameters. The demo submits at most one quote, and only for an open +RFQ observed during its bounded window: + +```bash +GEMINI_DEMO_OAUTH_LOGIN=1 \ +GEMINI_DEMO_RFQ_SUBMIT=1 \ +GEMINI_DEMO_RFQ_CONFIRM=I_UNDERSTAND_THIS_SUBMITS_A_LIVE_RFQ_QUOTE \ +GEMINI_DEMO_RFQ_PRICE="0.55" \ +GEMINI_DEMO_RFQ_QUANTITY="100" \ +go run . +``` + +Use `GEMINI_OAUTH_CLIENT_ID` to override the default client ID and +`GEMINI_OAUTH_CLIENT_SECRET` only when the OAuth application requires one. +The loopback callback is HTTP on localhost by OAuth convention; all Gemini +authorization and token endpoints remain HTTPS. + ### 3. Webhook Signature Verification Verify incoming Gemini HMAC-SHA384 webhook signatures in constant time: diff --git a/packages/sdk-go/auth/bearer.go b/packages/sdk-go/auth/bearer.go index 24a6de8a..9f9a800f 100644 --- a/packages/sdk-go/auth/bearer.go +++ b/packages/sdk-go/auth/bearer.go @@ -14,8 +14,9 @@ import ( // Token is called for each authenticated HTTP attempt, including retries, and // for each WebSocket connection or reconnect. Implementations should return a // currently valid token, honor context cancellation, and be safe for concurrent -// calls. The SDK does not cache tokens or perform the OAuth authorization-code -// exchange itself. +// calls. The low-level auth package does not persist tokens or perform an +// interactive OAuth authorization-code exchange; the optional oauth package +// provides those protocol helpers. type TokenSource interface { // Token returns an active, non-expired access token or an error if renewal fails. Token(ctx context.Context) (string, error) diff --git a/packages/sdk-go/cmd/demo/main.go b/packages/sdk-go/cmd/demo/main.go index e8367fb2..037e429f 100644 --- a/packages/sdk-go/cmd/demo/main.go +++ b/packages/sdk-go/cmd/demo/main.go @@ -2,11 +2,15 @@ package main import ( "context" + "encoding/json" "fmt" "log" "log/slog" "net/http" "os" + "os/exec" + "runtime" + "strings" "time" "github.com/gemini/gemini-go" @@ -14,11 +18,18 @@ import ( "github.com/gemini/gemini-go/geminitest" "github.com/gemini/gemini-go/generated/account" "github.com/gemini/gemini-go/generated/predictions" + geminioauth "github.com/gemini/gemini-go/oauth" "github.com/gemini/gemini-go/transport" + "github.com/gemini/gemini-go/websocket" "github.com/gemini/gemini-go/websocket/gorilla" "github.com/gemini/gemini-go/websocket/orderbook" ) +const ( + demoRFQSubmitConfirmation = "I_UNDERSTAND_THIS_SUBMITS_A_LIVE_RFQ_QUOTE" + demoCLIClientID = "6a03a47b-1bb4-491a-b0a7-35ad17473e71" +) + func main() { logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) slog.SetDefault(logger) @@ -31,7 +42,7 @@ func main() { defer cancel() // 1. Initialize Client against Production endpoints with pluggable Gorilla dialer and latency tracing - client := gemini.NewClient( + clientOptions := []gemini.Option{ gemini.WithEnvironment(gemini.Production), gemini.WithWebSocketDialer(gorilla.NewDialer()), gemini.WithLogger(logger), @@ -42,7 +53,63 @@ func main() { fmt.Printf(" [Trace: %v (New Socket, TLS: %v, TTFB: %v)]\n", trace.TotalDuration.Round(time.Microsecond), trace.TLSHandshake.Round(time.Microsecond), trace.TimeToFirstByte.Round(time.Microsecond)) } }), - ) + } + bearerToken := strings.TrimSpace(os.Getenv("GEMINI_ACCESS_TOKEN")) + oauthLoginEnabled := os.Getenv("GEMINI_DEMO_OAUTH_LOGIN") == "1" + rfqSubmitEnabled := os.Getenv("GEMINI_DEMO_RFQ_SUBMIT") == "1" + rfqPrice := strings.TrimSpace(os.Getenv("GEMINI_DEMO_RFQ_PRICE")) + rfqQuantity := strings.TrimSpace(os.Getenv("GEMINI_DEMO_RFQ_QUANTITY")) + if bearerToken != "" && oauthLoginEnabled { + log.Fatal("set only one of GEMINI_ACCESS_TOKEN or GEMINI_DEMO_OAUTH_LOGIN=1") + } + if rfqSubmitEnabled { + if os.Getenv("GEMINI_DEMO_RFQ_CONFIRM") != demoRFQSubmitConfirmation { + log.Fatalf("RFQ submit mode requires GEMINI_DEMO_RFQ_CONFIRM=%s", demoRFQSubmitConfirmation) + } + if rfqPrice == "" || rfqQuantity == "" { + log.Fatal("RFQ submit mode requires GEMINI_DEMO_RFQ_PRICE and GEMINI_DEMO_RFQ_QUANTITY") + } + fmt.Printf("⚠️ [RFQ] Submit mode armed for price=%s quantity=%s; it will submit at most one live quote\n", rfqPrice, rfqQuantity) + } + authConfigured := bearerToken != "" + if bearerToken != "" { + clientOptions = append(clientOptions, gemini.WithBearerToken(bearerToken)) + fmt.Println("🔑 [OAuth 2.0] Production bearer token configured for private WebSocket validation") + } + if oauthLoginEnabled { + clientID := strings.TrimSpace(os.Getenv("GEMINI_OAUTH_CLIENT_ID")) + if clientID == "" { + clientID = demoCLIClientID + } + oauthConfig := geminioauth.Config{ + ClientID: clientID, + ClientSecret: strings.TrimSpace(os.Getenv("GEMINI_OAUTH_CLIENT_SECRET")), + Endpoint: geminioauth.Endpoint{ + AuthURL: "https://exchange.gemini.com/auth", + TokenURL: "https://exchange.gemini.com/auth/token", + }, + RedirectURL: "http://localhost:8787/callback", + Scopes: demoOAuthScopes(rfqSubmitEnabled), + } + fmt.Println("🔐 [OAuth 2.0] Starting CLI-compatible PKCE login (credentials remain in memory)...") + token, err := oauthConfig.Login(ctx, openBrowser) + if err != nil { + log.Fatalf("OAuth PKCE login failed: %v\n", err) + } + source, err := geminioauth.NewTokenSource(oauthConfig, *token) + if err != nil { + log.Fatalf("OAuth token source setup failed: %v\n", err) + } + clientOptions = append(clientOptions, gemini.WithTokenSource(source)) + authConfigured = true + fmt.Println(" ✅ OAuth PKCE authorization completed; bearer token source configured") + } + if rfqSubmitEnabled { + if !authConfigured { + log.Fatal("RFQ submit mode requires GEMINI_ACCESS_TOKEN or GEMINI_DEMO_OAUTH_LOGIN=1") + } + } + client := gemini.NewClient(clientOptions...) // 2. REST API: GetSymbols fmt.Print("📡 [REST] Fetching active market symbols...") @@ -180,8 +247,16 @@ func main() { // 9a. Private WebSocket Guardrails: Unauthenticated calls fail safely privateWS := client.PrivateWebSocket() - if _, err := privateWS.SubscribeOrderEvents(ctx); err == gemini.ErrAuthenticationRequired { - fmt.Println(" 🛡️ Private WebSocket correctly enforces authentication guard (ErrAuthenticationRequired)") + if !authConfigured { + if _, err := privateWS.SubscribeOrderEvents(ctx); err == gemini.ErrAuthenticationRequired { + fmt.Println(" 🛡️ Private WebSocket correctly enforces authentication guard (ErrAuthenticationRequired)") + } + } else { + fmt.Println(" 🔐 Connecting authenticated Private WebSocket (bearer token not displayed)...") + if err := privateWS.Connect(ctx); err != nil { + log.Fatalf("Authenticated Private WebSocket connection failed: %v\n", err) + } + fmt.Println(" ✅ OAuth bearer private WebSocket handshake established") } // 9b. Public WebSocket: High-throughput unauthenticated market data stream @@ -194,6 +269,13 @@ func main() { } fmt.Println(" ✅ Public Handshake Established (101 Switching Protocols)") + fmt.Println(" 📡 Subscribing to public RFQ discovery stream (requestForQuote)...") + rfqCh, err := ws.SubscribeRFQEvents(ctx) + if err != nil { + log.Fatalf("SubscribeRFQEvents failed: %v\n", err) + } + fmt.Println(" ✅ RFQ discovery subscription established") + fmt.Println(" 📡 Subscribing to BTCUSD Depth Feed (btcusd@depth)...") depthCh, err := ws.SubscribeDepth(ctx, "BTCUSD") if err != nil { @@ -217,10 +299,43 @@ func main() { fmt.Println("\n📥 Streaming real-time market updates & synchronizing L2 Order Book...") receivedDepth := 0 receivedTicker := 0 + rfqObserved := false + rfqQuoteSubmitted := false + rfqWaitCompleted := false + var rfqEvents <-chan *websocket.RFQPublicEvent = rfqCh + rfqTimer := time.NewTimer(5 * time.Second) + defer rfqTimer.Stop() + var rfqWait <-chan time.Time = rfqTimer.C deadline := time.After(20 * time.Second) for receivedDepth < 4 || receivedTicker < 2 { select { + case rfq, ok := <-rfqEvents: + if !ok { + fmt.Println(" ℹ️ RFQ discovery stream closed before an event was observed") + rfqEvents = nil + rfqWait = nil + rfqWaitCompleted = true + continue + } + if !rfqObserved { + printRFQEvent(rfq) + rfqObserved = true + rfqEvents = nil + rfqWait = nil + + if rfqSubmitEnabled && rfq != nil && rfq.State == websocket.RFQStateOpen { + quote, err := privateWS.SubmitRFQQuote(ctx, websocket.RFQSubmitQuoteParams{ + RFQID: rfq.RFQID, Price: rfqPrice, Quantity: rfqQuantity, + }) + if err != nil { + log.Fatalf("SubmitRFQQuote failed: %v\n", err) + } + rfqQuoteSubmitted = true + fmt.Printf(" ✅ Submitted one live RFQ quote (RFQ ID: %s, Quote ID: %s)\n", quote.RFQID, quote.QuoteID) + } + } + case depth, ok := <-depthCh: if !ok { log.Fatal("Depth channel closed unexpectedly") @@ -254,12 +369,62 @@ func main() { fmt.Printf(" 🔵 [BookTicker Event %d] %s | Bid: $%s (%s BTC) | Ask: $%s (%s BTC)\n", receivedTicker, bt.Symbol, bt.BidPrice, bt.BidQty, bt.AskPrice, bt.AskQty) + case <-rfqWait: + rfqWaitCompleted = true + rfqEvents = nil + rfqWait = nil + fmt.Println(" ℹ️ No RFQ event was published during the 5-second observation window") + case <-deadline: log.Fatalf("Timed out after 20s (received %d depth, %d ticker updates)\n", receivedDepth, receivedTicker) } } + if rfqObserved { + fmt.Println(" ✅ RFQ discovery stream delivered a live event") + } else if !rfqWaitCompleted { + fmt.Println(" ℹ️ RFQ discovery subscription was active; no event arrived during the bounded validation window") + } + if rfqSubmitEnabled && !rfqQuoteSubmitted { + fmt.Println(" ℹ️ RFQ submit mode was armed, but no open RFQ was observed; no quote was submitted") + } fmt.Println("\n==================================================") fmt.Println("🎉 ALL REAL LIVE ORDER BOOK & STREAM CHECKS PASSED!") fmt.Println("==================================================") } + +func openBrowser(rawURL string) error { + switch runtime.GOOS { + case "darwin": + return exec.Command("open", rawURL).Start() + case "linux": + return exec.Command("xdg-open", rawURL).Start() + case "windows": + return exec.Command("rundll32", "url.dll,FileProtocolHandler", rawURL).Start() + default: + return fmt.Errorf("unsupported operating system %q; open the authorization URL manually", runtime.GOOS) + } +} + +// demoOAuthScopes returns the minimum scopes needed by the selected demo mode. +func demoOAuthScopes(rfqSubmitEnabled bool) []string { + scopes := []string{"account:read", "balances:read", "orders:read", "history:read"} + if rfqSubmitEnabled { + scopes = append(scopes, "orders:create") + } + return scopes +} + +func printRFQEvent(event *websocket.RFQPublicEvent) { + if event == nil { + fmt.Println(" ⚠️ RFQ discovery stream returned an empty event") + return + } + + payload, err := json.MarshalIndent(event, " ", " ") + if err != nil { + fmt.Printf(" ⚠️ Could not render RFQ event: %v\n", err) + return + } + fmt.Printf(" ✅ Received live RFQ event:\n%s\n", payload) +} diff --git a/packages/sdk-go/cmd/demo/main_test.go b/packages/sdk-go/cmd/demo/main_test.go new file mode 100644 index 00000000..34cb2df9 --- /dev/null +++ b/packages/sdk-go/cmd/demo/main_test.go @@ -0,0 +1,24 @@ +package main + +import "testing" + +func TestDemoOAuthScopesRequestWriteAccessOnlyForRFQSubmission(t *testing.T) { + readOnly := demoOAuthScopes(false) + if containsScope(readOnly, "orders:create") { + t.Fatalf("read-only demo scopes unexpectedly include orders:create: %v", readOnly) + } + + withRFQ := demoOAuthScopes(true) + if !containsScope(withRFQ, "orders:create") { + t.Fatalf("RFQ demo scopes omit orders:create: %v", withRFQ) + } +} + +func containsScope(scopes []string, want string) bool { + for _, scope := range scopes { + if scope == want { + return true + } + } + return false +} diff --git a/packages/sdk-go/oauth/oauth.go b/packages/sdk-go/oauth/oauth.go new file mode 100644 index 00000000..65c65138 --- /dev/null +++ b/packages/sdk-go/oauth/oauth.go @@ -0,0 +1,745 @@ +// Package oauth provides OAuth 2.0 authorization-code and PKCE helpers for +// applications using the Gemini Go SDK. +// +// The package keeps interactive authorization optional. Applications can use +// Config.AuthCodeURL and Config.Exchange in their own browser flow, or use +// Config.Login for a loopback callback on localhost. TokenSource converts the +// resulting token into auth.TokenSource for REST requests and WebSocket +// connections. +package oauth + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/gemini/gemini-go/auth" +) + +const ( + defaultEarlyExpiry = 30 * time.Second + defaultTokenTimeout = 15 * time.Second + maxTokenResponseBytes = 64 << 10 +) + +var ( + // ErrInvalidConfig indicates that the OAuth client configuration is not + // safe or complete enough to use. + ErrInvalidConfig = errors.New("gemini oauth: invalid configuration") + // ErrInvalidRedirectURL indicates that a redirect URL is malformed or is + // not a permitted HTTPS or loopback callback URL. + ErrInvalidRedirectURL = errors.New("gemini oauth: invalid redirect URL") + // ErrInvalidPKCE indicates that a PKCE verifier or state value is invalid. + ErrInvalidPKCE = errors.New("gemini oauth: invalid PKCE parameters") + // ErrStateMismatch indicates that the authorization callback did not match + // the state generated for the authorization attempt. + ErrStateMismatch = errors.New("gemini oauth: authorization state mismatch") + // ErrBrowserOpenerRequired indicates that Login was called without a + // browser opener. + ErrBrowserOpenerRequired = errors.New("gemini oauth: browser opener is required") + // ErrRedirectNotAllowed indicates that an OAuth token request attempted to + // follow an HTTP redirect. Token requests are never redirected. + ErrRedirectNotAllowed = errors.New("gemini oauth: token endpoint redirects are not allowed") + // ErrInvalidToken indicates that an OAuth token response or source token is + // missing required data. + ErrInvalidToken = errors.New("gemini oauth: invalid token") + // ErrRefreshTokenUnavailable indicates that an expired token has no refresh + // token available. + ErrRefreshTokenUnavailable = errors.New("gemini oauth: refresh token unavailable") + // ErrTokenRefresh identifies a failed refresh operation. + ErrTokenRefresh = errors.New("gemini oauth: token refresh failed") + // ErrTokenEndpoint indicates that an OAuth token endpoint rejected a + // request. + ErrTokenEndpoint = errors.New("gemini oauth: token endpoint rejected request") +) + +// Endpoint contains the OAuth authorization and token endpoint URLs. +// Both endpoints must use HTTPS. +type Endpoint struct { + AuthURL string + TokenURL string +} + +// Config configures an OAuth authorization-code flow. +// +// ClientSecret is optional for public PKCE clients. HTTP requests to the +// token endpoint never follow redirects and use a bounded response body. +// HTTPClient is used only for token requests; when nil, the package uses a +// client with a finite timeout. +type Config struct { + ClientID string + ClientSecret string + Endpoint Endpoint + RedirectURL string + Scopes []string + HTTPClient *http.Client +} + +// Token is an OAuth access token and its optional refresh metadata. +type Token struct { + AccessToken string + RefreshToken string + TokenType string + ExpiresAt time.Time + Scope string +} + +// Valid reports whether the access token is present and remains valid after +// applying earlyExpiry. A zero ExpiresAt means the authorization server did +// not provide an expiry and the token is treated as valid until rejected. +func (t Token) Valid(now time.Time, earlyExpiry time.Duration) bool { + if strings.TrimSpace(t.AccessToken) == "" { + return false + } + return t.ExpiresAt.IsZero() || now.Add(earlyExpiry).Before(t.ExpiresAt) +} + +// GeneratePKCE returns a fresh RFC 7636 verifier and its S256 challenge. +func GeneratePKCE() (verifier string, challenge string, err error) { + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", "", fmt.Errorf("generate PKCE verifier: %w", err) + } + verifier = base64.RawURLEncoding.EncodeToString(raw) + challenge = pkceChallenge(verifier) + return verifier, challenge, nil +} + +// GenerateState returns a fresh state value suitable for CSRF protection. +func GenerateState() (string, error) { + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", fmt.Errorf("generate OAuth state: %w", err) + } + return base64.RawURLEncoding.EncodeToString(raw), nil +} + +// AuthCodeURL builds a PKCE authorization URL. The verifier is never placed +// in the URL; only its S256 challenge is sent to the authorization server. +func (c Config) AuthCodeURL(state, verifier string) (string, error) { + if err := c.validate(); err != nil { + return "", err + } + if err := validateState(state); err != nil { + return "", err + } + if err := validateVerifier(verifier); err != nil { + return "", err + } + + authURL, err := url.Parse(c.Endpoint.AuthURL) + if err != nil { + return "", fmt.Errorf("parse authorization endpoint: %w", err) + } + params := authURL.Query() + params.Set("client_id", c.ClientID) + params.Set("response_type", "code") + params.Set("redirect_uri", c.RedirectURL) + params.Set("state", state) + params.Set("code_challenge", pkceChallenge(verifier)) + params.Set("code_challenge_method", "S256") + if scope := normalizedScopes(c.Scopes); scope != "" { + params.Set("scope", scope) + } + authURL.RawQuery = params.Encode() + return authURL.String(), nil +} + +// Exchange exchanges a one-time authorization code for an access token. +func (c Config) Exchange(ctx context.Context, code, verifier string) (*Token, error) { + if err := c.validate(); err != nil { + return nil, err + } + if strings.TrimSpace(code) == "" { + return nil, fmt.Errorf("%w: authorization code is required", ErrInvalidPKCE) + } + if err := validateVerifier(verifier); err != nil { + return nil, err + } + form := url.Values{ + "grant_type": {"authorization_code"}, + "client_id": {c.ClientID}, + "code": {code}, + "redirect_uri": {c.RedirectURL}, + "code_verifier": {verifier}, + } + if c.ClientSecret != "" { + form.Set("client_secret", c.ClientSecret) + } + return c.tokenRequest(ctx, form) +} + +// Refresh exchanges a refresh token for a new access token. +func (c Config) Refresh(ctx context.Context, refreshToken string) (*Token, error) { + if err := c.validateTokenRequest(); err != nil { + return nil, err + } + if strings.TrimSpace(refreshToken) == "" { + return nil, ErrRefreshTokenUnavailable + } + form := url.Values{ + "grant_type": {"refresh_token"}, + "client_id": {c.ClientID}, + "refresh_token": {refreshToken}, + } + if c.ClientSecret != "" { + form.Set("client_secret", c.ClientSecret) + } + return c.tokenRequest(ctx, form) +} + +// BrowserOpener opens an authorization URL in a user agent. +type BrowserOpener func(string) error + +// Authorize runs an authorization-code flow using a caller-supplied consent +// handler. The handler must return the code and the state received from the +// provider callback. This is useful for web applications and custom CLI UIs. +func (c Config) Authorize(ctx context.Context, handler func(context.Context, string) (code, state string, err error)) (*Token, error) { + if handler == nil { + return nil, ErrBrowserOpenerRequired + } + if err := c.validate(); err != nil { + return nil, err + } + if ctx == nil { + ctx = context.Background() + } + verifier, _, err := GeneratePKCE() + if err != nil { + return nil, err + } + state, err := GenerateState() + if err != nil { + return nil, err + } + authURL, err := c.AuthCodeURL(state, verifier) + if err != nil { + return nil, err + } + code, returnedState, err := handler(ctx, authURL) + if err != nil { + return nil, err + } + if !secureStringEqual(state, returnedState) { + return nil, ErrStateMismatch + } + return c.Exchange(ctx, code, verifier) +} + +// Login runs an interactive PKCE flow using a loopback callback. The +// configured RedirectURL must be an exact localhost/loopback URL with a fixed +// port, such as http://localhost:8787/callback. HTTP is permitted only for +// this loopback callback; authorization and token endpoints must be HTTPS. +func (c Config) Login(ctx context.Context, openBrowser BrowserOpener) (*Token, error) { + if openBrowser == nil { + return nil, ErrBrowserOpenerRequired + } + if err := validateLoopbackRedirect(c.RedirectURL); err != nil { + return nil, err + } + + return c.Authorize(ctx, func(ctx context.Context, authURL string) (string, string, error) { + return c.loopbackCallback(ctx, authURL, openBrowser) + }) +} + +// Source is a concurrent-safe auth.TokenSource backed by an access token and +// its refresh token. Concurrent callers share one refresh operation, while a +// caller waiting for another refresh can still cancel its own wait. +type Source struct { + config Config + earlyExpiry time.Duration + now func() time.Time + + mu sync.Mutex + token Token + refreshing *refreshState +} + +type refreshState struct { + done chan struct{} + token string + err error +} + +var _ auth.TokenSource = (*Source)(nil) + +// SourceOption configures a refreshable token source. +type SourceOption func(*Source) error + +// WithEarlyExpiry refreshes before the token's expiry by d. The default is +// thirty seconds. A negative value is rejected. +func WithEarlyExpiry(d time.Duration) SourceOption { + return func(source *Source) error { + if d < 0 { + return fmt.Errorf("%w: early expiry cannot be negative", ErrInvalidConfig) + } + source.earlyExpiry = d + return nil + } +} + +// WithClock replaces the clock used for expiry checks. It is intended for +// deterministic tests and should not normally be used by applications. +func WithClock(now func() time.Time) SourceOption { + return func(source *Source) error { + if now == nil { + return fmt.Errorf("%w: clock cannot be nil", ErrInvalidConfig) + } + source.now = now + return nil + } +} + +// NewTokenSource creates a refreshable auth.TokenSource from an OAuth token. +// The initial token may already be expired if it has a refresh token; the +// first call then refreshes it. Token persistence is intentionally left to +// the caller so the SDK never writes credentials unexpectedly. +func NewTokenSource(config Config, initial Token, opts ...SourceOption) (*Source, error) { + if err := config.validateTokenRequest(); err != nil { + return nil, err + } + if strings.TrimSpace(initial.AccessToken) == "" && strings.TrimSpace(initial.RefreshToken) == "" { + return nil, ErrInvalidToken + } + if initial.TokenType == "" { + initial.TokenType = "Bearer" + } + if !strings.EqualFold(initial.TokenType, "Bearer") { + return nil, fmt.Errorf("%w: unsupported token type %q", ErrInvalidToken, initial.TokenType) + } + source := &Source{ + config: config, + earlyExpiry: defaultEarlyExpiry, + now: time.Now, + token: initial, + } + for _, opt := range opts { + if opt == nil { + continue + } + if err := opt(source); err != nil { + return nil, err + } + } + return source, nil +} + +// Token returns a current access token, refreshing it when it is expired or +// within the configured early-expiry window. +func (s *Source) Token(ctx context.Context) (string, error) { + if s == nil { + return "", ErrInvalidToken + } + if ctx == nil { + ctx = context.Background() + } + if err := ctx.Err(); err != nil { + return "", err + } + + s.mu.Lock() + if s.token.Valid(s.now(), s.earlyExpiry) { + token := s.token.AccessToken + s.mu.Unlock() + return token, nil + } + if strings.TrimSpace(s.token.RefreshToken) == "" { + s.mu.Unlock() + return "", ErrRefreshTokenUnavailable + } + if current := s.refreshing; current != nil { + s.mu.Unlock() + select { + case <-current.done: + if current.err != nil { + return "", fmt.Errorf("%w: %w", ErrTokenRefresh, current.err) + } + return current.token, nil + case <-ctx.Done(): + return "", ctx.Err() + } + } + + current := &refreshState{done: make(chan struct{})} + s.refreshing = current + refreshToken := s.token.RefreshToken + s.mu.Unlock() + + refreshed, err := s.config.Refresh(ctx, refreshToken) + if err == nil { + if refreshed.RefreshToken == "" { + refreshed.RefreshToken = refreshToken + } + if refreshed.TokenType == "" { + refreshed.TokenType = "Bearer" + } + } + + s.mu.Lock() + if err == nil { + s.token = *refreshed + current.token = refreshed.AccessToken + } else { + current.err = err + } + s.refreshing = nil + close(current.done) + s.mu.Unlock() + if err != nil { + return "", fmt.Errorf("%w: %w", ErrTokenRefresh, err) + } + return current.token, nil +} + +func (c Config) validate() error { + if err := c.validateTokenRequest(); err != nil { + return err + } + if err := validateHTTPSURL(c.Endpoint.AuthURL, "authorization endpoint"); err != nil { + return err + } + if err := validateRedirectURL(c.RedirectURL); err != nil { + return err + } + if _, err := normalizedScopesChecked(c.Scopes); err != nil { + return err + } + return nil +} + +func (c Config) validateTokenRequest() error { + if strings.TrimSpace(c.ClientID) == "" { + return fmt.Errorf("%w: client ID is required", ErrInvalidConfig) + } + if err := validateHTTPSURL(c.Endpoint.TokenURL, "token endpoint"); err != nil { + return err + } + return nil +} + +func validateHTTPSURL(raw, name string) error { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed.Host == "" || !strings.EqualFold(parsed.Scheme, "https") || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return fmt.Errorf("%w: %s must be an HTTPS URL without userinfo, query, or fragment", ErrInvalidConfig, name) + } + return nil +} + +func validateRedirectURL(raw string) error { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.Path == "" { + return fmt.Errorf("%w: redirect URL must be absolute and cannot contain userinfo, query, or fragment", ErrInvalidRedirectURL) + } + if !strings.EqualFold(parsed.Scheme, "https") && !strings.EqualFold(parsed.Scheme, "http") { + return fmt.Errorf("%w: redirect URL must use HTTPS or loopback HTTP", ErrInvalidRedirectURL) + } + if strings.EqualFold(parsed.Scheme, "http") && !isLoopbackHost(parsed.Hostname()) { + return fmt.Errorf("%w: HTTP redirects are allowed only on loopback hosts", ErrInvalidRedirectURL) + } + return nil +} + +func validateLoopbackRedirect(raw string) error { + if err := validateRedirectURL(raw); err != nil { + return err + } + parsed, _ := url.Parse(strings.TrimSpace(raw)) + if !strings.EqualFold(parsed.Scheme, "http") || !isLoopbackHost(parsed.Hostname()) || parsed.Port() == "" || parsed.Port() == "0" { + return fmt.Errorf("%w: Login requires a fixed HTTP loopback redirect port", ErrInvalidRedirectURL) + } + return nil +} + +func isLoopbackHost(host string) bool { + if strings.EqualFold(host, "localhost") { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +func normalizedScopes(scopes []string) string { + normalized, _ := normalizedScopesChecked(scopes) + // Gemini's OAuth authorization endpoint uses a comma-delimited scope + // parameter, matching the existing Markets CLI contract. + return strings.Join(normalized, ",") +} + +func normalizedScopesChecked(scopes []string) ([]string, error) { + normalized := make([]string, 0, len(scopes)) + for _, raw := range scopes { + scope := strings.TrimSpace(raw) + if scope == "" || strings.ContainsAny(scope, " \t\r\n") { + return nil, fmt.Errorf("%w: scopes must be non-empty single values", ErrInvalidConfig) + } + normalized = append(normalized, scope) + } + return normalized, nil +} + +func validateState(state string) error { + if strings.TrimSpace(state) == "" || strings.ContainsAny(state, "\r\n") { + return fmt.Errorf("%w: state is required", ErrInvalidPKCE) + } + return nil +} + +func validateVerifier(verifier string) error { + if len(verifier) < 43 || len(verifier) > 128 || strings.ContainsAny(verifier, " \t\r\n") { + return fmt.Errorf("%w: verifier must be 43 to 128 characters", ErrInvalidPKCE) + } + for _, char := range verifier { + if !(char >= 'A' && char <= 'Z') && !(char >= 'a' && char <= 'z') && !(char >= '0' && char <= '9') && !strings.ContainsRune("-._~", char) { + return fmt.Errorf("%w: verifier contains an invalid character", ErrInvalidPKCE) + } + } + return nil +} + +func pkceChallenge(verifier string) string { + digest := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(digest[:]) +} + +func secureStringEqual(left, right string) bool { + if len(left) != len(right) { + return false + } + return subtle.ConstantTimeCompare([]byte(left), []byte(right)) == 1 +} + +type tokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresIn *int64 `json:"expires_in"` + Scope string `json:"scope"` + Error string `json:"error"` + Description string `json:"error_description"` +} + +// TokenEndpointError describes a non-success OAuth token response without +// retaining or exposing the response body, which may contain sensitive data. +type TokenEndpointError struct { + StatusCode int + Code string + Description string +} + +func (e *TokenEndpointError) Error() string { + if e == nil { + return "gemini oauth: token endpoint error" + } + message := fmt.Sprintf("gemini oauth: token endpoint returned HTTP %d", e.StatusCode) + if e.Code != "" { + message += ": " + e.Code + } + return message +} + +func (e *TokenEndpointError) Unwrap() error { return ErrTokenEndpoint } + +func (c Config) tokenRequest(ctx context.Context, form url.Values) (*Token, error) { + if ctx == nil { + ctx = context.Background() + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.Endpoint.TokenURL, strings.NewReader(form.Encode())) + if err != nil { + return nil, fmt.Errorf("create token request: %w", err) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + client := c.tokenHTTPClient() + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("token request: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, maxTokenResponseBytes+1)) + if err != nil { + return nil, fmt.Errorf("read token response: %w", err) + } + if len(body) > maxTokenResponseBytes { + return nil, fmt.Errorf("%w: token response exceeds %d bytes", ErrInvalidToken, maxTokenResponseBytes) + } + + var response tokenResponse + decodeErr := json.Unmarshal(body, &response) + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices || response.Error != "" { + return nil, &TokenEndpointError{StatusCode: resp.StatusCode, Code: response.Error, Description: response.Description} + } + if decodeErr != nil { + return nil, fmt.Errorf("%w: decode token response: %v", ErrInvalidToken, decodeErr) + } + if strings.TrimSpace(response.AccessToken) == "" { + return nil, fmt.Errorf("%w: token response has no access token", ErrInvalidToken) + } + if response.ExpiresIn != nil && *response.ExpiresIn < 0 { + return nil, fmt.Errorf("%w: token expiry is negative", ErrInvalidToken) + } + tokenType := response.TokenType + if tokenType == "" { + tokenType = "Bearer" + } + if !strings.EqualFold(tokenType, "Bearer") { + return nil, fmt.Errorf("%w: unsupported token type %q", ErrInvalidToken, tokenType) + } + token := &Token{ + AccessToken: response.AccessToken, + RefreshToken: response.RefreshToken, + TokenType: "Bearer", + Scope: response.Scope, + } + if response.ExpiresIn != nil { + if *response.ExpiresIn > int64((time.Duration(1<<63-1))/time.Second) { + return nil, fmt.Errorf("%w: token expiry is too large", ErrInvalidToken) + } + token.ExpiresAt = time.Now().Add(time.Duration(*response.ExpiresIn) * time.Second) + } + return token, nil +} + +func (c Config) tokenHTTPClient() *http.Client { + client := c.HTTPClient + if client == nil { + client = &http.Client{} + } + clone := *client + clone.CheckRedirect = func(*http.Request, []*http.Request) error { + return ErrRedirectNotAllowed + } + clone.Jar = nil + if clone.Timeout == 0 { + clone.Timeout = defaultTokenTimeout + } + return &clone +} + +func (c Config) loopbackCallback(ctx context.Context, authURL string, openBrowser BrowserOpener) (string, string, error) { + parsed, err := url.Parse(c.RedirectURL) + if err != nil { + return "", "", err + } + authorizationURL, err := url.Parse(authURL) + if err != nil { + return "", "", fmt.Errorf("parse OAuth authorization URL: %w", err) + } + expectedState := authorizationURL.Query().Get("state") + if err := validateState(expectedState); err != nil { + return "", "", err + } + listenHost := parsed.Hostname() + if strings.EqualFold(listenHost, "localhost") { + listenHost = "127.0.0.1" + } + listenAddress := net.JoinHostPort(listenHost, parsed.Port()) + listener, err := net.Listen("tcp", listenAddress) + if err != nil { + return "", "", fmt.Errorf("listen for OAuth callback: %w", err) + } + defer listener.Close() + + resultCh := make(chan callbackResult, 1) + var delivered atomic.Bool + mux := http.NewServeMux() + mux.HandleFunc(parsed.EscapedPath(), func(writer http.ResponseWriter, request *http.Request) { + if len(request.RequestURI) > 8<<10 { + http.Error(writer, "request URI too long", http.StatusRequestURITooLong) + return + } + if request.Method != http.MethodGet { + writer.Header().Set("Allow", http.MethodGet) + http.Error(writer, "method not allowed", http.StatusMethodNotAllowed) + return + } + query := request.URL.Query() + returnedState := query.Get("state") + if !secureStringEqual(expectedState, returnedState) { + writeCallbackResponse(writer, http.StatusBadRequest, "Invalid authorization state.") + return + } + if query.Get("error") != "" { + if delivered.CompareAndSwap(false, true) { + resultCh <- callbackResult{state: returnedState, err: &AuthorizationError{Code: query.Get("error"), Description: query.Get("error_description")}} + } + writeCallbackResponse(writer, http.StatusOK, "Authorization was denied. You may close this window.") + return + } + code := query.Get("code") + if code == "" { + writeCallbackResponse(writer, http.StatusBadRequest, "Authorization code is missing.") + return + } + if delivered.CompareAndSwap(false, true) { + resultCh <- callbackResult{code: code, state: returnedState} + } + writeCallbackResponse(writer, http.StatusOK, "Authorization complete. You may close this window.") + }) + + server := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second} + serveErr := make(chan error, 1) + go func() { + if err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { + serveErr <- err + } + }() + defer server.Close() + + if err := openBrowser(authURL); err != nil { + return "", "", fmt.Errorf("open OAuth authorization URL: %w", err) + } + + select { + case result := <-resultCh: + return result.code, result.state, result.err + case err := <-serveErr: + return "", "", fmt.Errorf("serve OAuth callback: %w", err) + case <-ctx.Done(): + return "", "", ctx.Err() + } +} + +type callbackResult struct { + code string + state string + err error +} + +// AuthorizationError reports an authorization-server denial without exposing +// callback URLs or authorization codes. +type AuthorizationError struct { + Code string + Description string +} + +func (e *AuthorizationError) Error() string { + if e == nil { + return "gemini oauth: authorization failed" + } + if e.Description == "" { + return fmt.Sprintf("gemini oauth: authorization failed: %s", e.Code) + } + return fmt.Sprintf("gemini oauth: authorization failed: %s: %s", e.Code, e.Description) +} + +func writeCallbackResponse(writer http.ResponseWriter, status int, message string) { + writer.Header().Set("Cache-Control", "no-store") + writer.Header().Set("Content-Type", "text/plain; charset=utf-8") + writer.WriteHeader(status) + _, _ = io.WriteString(writer, message) +} diff --git a/packages/sdk-go/oauth/oauth_test.go b/packages/sdk-go/oauth/oauth_test.go new file mode 100644 index 00000000..74e8e22b --- /dev/null +++ b/packages/sdk-go/oauth/oauth_test.go @@ -0,0 +1,411 @@ +package oauth + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +const testVerifier = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~" + +func TestGeneratePKCE(t *testing.T) { + verifier, challenge, err := GeneratePKCE() + if err != nil { + t.Fatalf("GeneratePKCE() error = %v", err) + } + if err := validateVerifier(verifier); err != nil { + t.Fatalf("generated verifier is invalid: %v", err) + } + want := sha256.Sum256([]byte(verifier)) + if got := base64.RawURLEncoding.EncodeToString(want[:]); got != challenge { + t.Fatalf("challenge = %q, want %q", challenge, got) + } + secondVerifier, _, err := GeneratePKCE() + if err != nil { + t.Fatalf("second GeneratePKCE() error = %v", err) + } + if verifier == secondVerifier { + t.Fatal("GeneratePKCE returned the same verifier twice") + } +} + +func TestAuthCodeURLIncludesPKCEWithoutSecrets(t *testing.T) { + cfg := Config{ + ClientID: "cli-client-id", + ClientSecret: "do-not-put-this-in-a-url", + Endpoint: Endpoint{AuthURL: "https://exchange.example/auth", TokenURL: "https://exchange.example/auth/token"}, + RedirectURL: "http://localhost:8787/callback", + Scopes: []string{"account:read", "orders:create"}, + } + got, err := cfg.AuthCodeURL("state-value", testVerifier) + if err != nil { + t.Fatalf("AuthCodeURL() error = %v", err) + } + parsed, err := url.Parse(got) + if err != nil { + t.Fatalf("parse AuthCodeURL() result: %v", err) + } + query := parsed.Query() + if query.Get("client_id") != cfg.ClientID || query.Get("response_type") != "code" || query.Get("redirect_uri") != cfg.RedirectURL { + t.Fatalf("unexpected authorization query: %v", query) + } + if query.Get("code_challenge_method") != "S256" || query.Get("code_challenge") != pkceChallenge(testVerifier) { + t.Fatalf("unexpected PKCE query: %v", query) + } + if query.Get("scope") != "account:read,orders:create" { + t.Fatalf("scope = %q", query.Get("scope")) + } + if strings.Contains(got, cfg.ClientSecret) || strings.Contains(got, "code_verifier") { + t.Fatalf("authorization URL contains sensitive PKCE/client-secret data: %s", got) + } +} + +func TestConfigRejectsInsecureEndpointsAndRedirects(t *testing.T) { + cfg := validConfig("https://exchange.example") + cfg.Endpoint.TokenURL = "http://exchange.example/token" + if _, err := cfg.AuthCodeURL("state", testVerifier); !errors.Is(err, ErrInvalidConfig) { + t.Fatalf("AuthCodeURL() error = %v, want ErrInvalidConfig", err) + } + + cfg = validConfig("https://exchange.example") + cfg.RedirectURL = "http://example.com/callback" + if _, err := cfg.AuthCodeURL("state", testVerifier); !errors.Is(err, ErrInvalidRedirectURL) { + t.Fatalf("AuthCodeURL() error = %v, want ErrInvalidRedirectURL", err) + } + + cfg = validConfig("https://exchange.example") + cfg.RedirectURL = "https://example.com/callback?secret=not-allowed" + if _, err := cfg.AuthCodeURL("state", testVerifier); !errors.Is(err, ErrInvalidRedirectURL) { + t.Fatalf("AuthCodeURL() error = %v, want ErrInvalidRedirectURL", err) + } +} + +func TestAuthorizeExchangesCodeWithPKCE(t *testing.T) { + var authorizationQuery url.Values + server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/token" { + http.NotFound(writer, request) + return + } + if request.Method != http.MethodPost || request.Header.Get("Authorization") != "" { + t.Errorf("unexpected token request: method=%s authorization=%q", request.Method, request.Header.Get("Authorization")) + } + if err := request.ParseForm(); err != nil { + t.Errorf("ParseForm() error = %v", err) + } + if request.Form.Get("grant_type") != "authorization_code" || request.Form.Get("client_id") != "client-id" || request.Form.Get("code") != "auth-code" { + t.Errorf("unexpected token form: %v", request.Form) + } + if request.Form.Get("redirect_uri") != "http://localhost:8787/callback" { + t.Errorf("redirect_uri = %q", request.Form.Get("redirect_uri")) + } + if authorizationQuery.Get("code_challenge") != pkceChallenge(request.Form.Get("code_verifier")) { + t.Errorf("code verifier did not match authorization challenge") + } + writer.Header().Set("Content-Type", "application/json") + _, _ = writer.Write([]byte(`{"access_token":"access-token","refresh_token":"refresh-token","token_type":"Bearer","expires_in":3600,"scope":"account:read"}`)) + })) + defer server.Close() + + cfg := validConfig(server.URL) + cfg.HTTPClient = server.Client() + token, err := cfg.Authorize(context.Background(), func(_ context.Context, authURL string) (string, string, error) { + parsed, parseErr := url.Parse(authURL) + if parseErr != nil { + return "", "", parseErr + } + authorizationQuery = parsed.Query() + return "auth-code", authorizationQuery.Get("state"), nil + }) + if err != nil { + t.Fatalf("Authorize() error = %v", err) + } + if token.AccessToken != "access-token" || token.RefreshToken != "refresh-token" || token.TokenType != "Bearer" || token.Scope != "account:read" { + t.Fatalf("unexpected token: %+v", token) + } + if token.ExpiresAt.IsZero() { + t.Fatal("expected token expiry") + } +} + +func TestAuthorizeRejectsStateMismatchBeforeTokenExchange(t *testing.T) { + var exchanges atomic.Int32 + server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + exchanges.Add(1) + writer.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + cfg := validConfig(server.URL) + cfg.HTTPClient = server.Client() + _, err := cfg.Authorize(context.Background(), func(context.Context, string) (string, string, error) { + return "auth-code", "wrong-state", nil + }) + if !errors.Is(err, ErrStateMismatch) { + t.Fatalf("Authorize() error = %v, want ErrStateMismatch", err) + } + if exchanges.Load() != 0 { + t.Fatalf("token endpoint called %d times after state mismatch", exchanges.Load()) + } +} + +func TestLoginRejectsInvalidCallbackStateAndThenAcceptsValidCallback(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "application/json") + _, _ = writer.Write([]byte(`{"access_token":"access-token","refresh_token":"refresh-token","token_type":"Bearer"}`)) + })) + defer server.Close() + + port := freeLoopbackPort(t) + cfg := validConfig(server.URL) + cfg.RedirectURL = fmt.Sprintf("http://127.0.0.1:%d/callback", port) + cfg.HTTPClient = server.Client() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + token, err := cfg.Login(ctx, func(authURL string) error { + parsed, parseErr := url.Parse(authURL) + if parseErr != nil { + return parseErr + } + callback := cfg.RedirectURL + "?code=wrong-code&state=wrong-state" + response, requestErr := http.Get(callback) + if requestErr != nil { + return requestErr + } + _ = response.Body.Close() + if response.StatusCode != http.StatusBadRequest { + return fmt.Errorf("wrong-state callback status = %d", response.StatusCode) + } + callback = cfg.RedirectURL + "?code=auth-code&state=" + url.QueryEscape(parsed.Query().Get("state")) + response, requestErr = http.Get(callback) + if requestErr != nil { + return requestErr + } + _ = response.Body.Close() + if response.StatusCode != http.StatusOK { + return fmt.Errorf("valid callback status = %d", response.StatusCode) + } + return nil + }) + if err != nil { + t.Fatalf("Login() error = %v", err) + } + if token.AccessToken != "access-token" { + t.Fatalf("unexpected token: %+v", token) + } +} + +func TestLoginRequiresLoopbackCallbackAndBrowserOpener(t *testing.T) { + cfg := validConfig("https://exchange.example") + if _, err := cfg.Login(context.Background(), nil); !errors.Is(err, ErrBrowserOpenerRequired) { + t.Fatalf("Login() error = %v, want ErrBrowserOpenerRequired", err) + } + + cfg.RedirectURL = "http://example.com/callback" + if _, err := cfg.Login(context.Background(), func(string) error { return nil }); !errors.Is(err, ErrInvalidRedirectURL) { + t.Fatalf("Login() error = %v, want ErrInvalidRedirectURL", err) + } +} + +func TestTokenSourceRefreshesOnceForConcurrentCallers(t *testing.T) { + var refreshes atomic.Int32 + server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + refreshes.Add(1) + if err := request.ParseForm(); err != nil { + t.Errorf("ParseForm() error = %v", err) + } + if request.Form.Get("grant_type") != "refresh_token" || request.Form.Get("refresh_token") != "refresh-token" { + t.Errorf("unexpected refresh form: %v", request.Form) + } + writer.Header().Set("Content-Type", "application/json") + _, _ = writer.Write([]byte(`{"access_token":"refreshed-token","token_type":"Bearer","expires_in":3600}`)) + })) + defer server.Close() + + cfg := validConfig(server.URL) + cfg.HTTPClient = server.Client() + source, err := NewTokenSource(cfg, Token{ + AccessToken: "expired-token", + RefreshToken: "refresh-token", + ExpiresAt: time.Now().Add(-time.Hour), + }, WithEarlyExpiry(0)) + if err != nil { + t.Fatalf("NewTokenSource() error = %v", err) + } + + const callers = 16 + results := make(chan string, callers) + errorsCh := make(chan error, callers) + var wg sync.WaitGroup + wg.Add(callers) + for i := 0; i < callers; i++ { + go func() { + defer wg.Done() + value, callErr := source.Token(context.Background()) + results <- value + errorsCh <- callErr + }() + } + wg.Wait() + close(results) + close(errorsCh) + for callErr := range errorsCh { + if callErr != nil { + t.Fatalf("Token() error = %v", callErr) + } + } + for value := range results { + if value != "refreshed-token" { + t.Fatalf("Token() = %q, want refreshed-token", value) + } + } + if refreshes.Load() != 1 { + t.Fatalf("refresh endpoint called %d times, want 1", refreshes.Load()) + } +} + +func TestTokenSourceWaitingCallHonorsCancellation(t *testing.T) { + refreshStarted := make(chan struct{}) + releaseRefresh := make(chan struct{}) + var refreshes atomic.Int32 + server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + refreshes.Add(1) + close(refreshStarted) + <-releaseRefresh + writer.Header().Set("Content-Type", "application/json") + _, _ = writer.Write([]byte(`{"access_token":"refreshed-token","token_type":"Bearer","expires_in":3600}`)) + })) + defer server.Close() + + cfg := validConfig(server.URL) + cfg.HTTPClient = server.Client() + source, err := NewTokenSource(cfg, Token{AccessToken: "expired", RefreshToken: "refresh", ExpiresAt: time.Now().Add(-time.Hour)}, WithEarlyExpiry(0)) + if err != nil { + t.Fatalf("NewTokenSource() error = %v", err) + } + + firstResult := make(chan error, 1) + go func() { + _, callErr := source.Token(context.Background()) + firstResult <- callErr + }() + select { + case <-refreshStarted: + case <-time.After(time.Second): + t.Fatal("timed out waiting for refresh to start") + } + + ctx, cancel := context.WithCancel(context.Background()) + canceledResult := make(chan error, 1) + go func() { + _, callErr := source.Token(ctx) + canceledResult <- callErr + }() + cancel() + select { + case callErr := <-canceledResult: + if !errors.Is(callErr, context.Canceled) { + t.Fatalf("waiting Token() error = %v, want context.Canceled", callErr) + } + case <-time.After(time.Second): + t.Fatal("canceled Token() did not return") + } + close(releaseRefresh) + if callErr := <-firstResult; callErr != nil { + t.Fatalf("first Token() error = %v", callErr) + } + if refreshes.Load() != 1 { + t.Fatalf("refresh endpoint called %d times, want 1", refreshes.Load()) + } +} + +func TestTokenRequestsDoNotRetryOrFollowRedirects(t *testing.T) { + var redirected atomic.Int32 + target := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + redirected.Add(1) + writer.WriteHeader(http.StatusOK) + })) + defer target.Close() + + var requests atomic.Int32 + server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + requests.Add(1) + http.Redirect(writer, request, target.URL, http.StatusFound) + })) + defer server.Close() + + cfg := validConfig(server.URL) + cfg.HTTPClient = server.Client() + _, err := cfg.Refresh(context.Background(), "refresh-token") + if !errors.Is(err, ErrRedirectNotAllowed) { + t.Fatalf("Refresh() error = %v, want ErrRedirectNotAllowed", err) + } + if requests.Load() != 1 || redirected.Load() != 0 { + t.Fatalf("token endpoint requests=%d redirected requests=%d, want 1 and 0", requests.Load(), redirected.Load()) + } +} + +func TestTokenEndpointErrorsDoNotExposeRequestSecrets(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "application/json") + _, _ = writer.Write([]byte(`{"error":"invalid_grant","error_description":"refresh-token-should-not-appear"}`)) + })) + defer server.Close() + + cfg := validConfig(server.URL) + cfg.HTTPClient = server.Client() + _, err := cfg.Refresh(context.Background(), "refresh-token") + if !errors.Is(err, ErrTokenEndpoint) { + t.Fatalf("Refresh() error = %v, want ErrTokenEndpoint", err) + } + if strings.Contains(err.Error(), "refresh-token") { + t.Fatalf("Refresh() error exposed a request secret: %v", err) + } +} + +func TestNewTokenSourceRejectsInvalidInitialToken(t *testing.T) { + cfg := validConfig("https://exchange.example") + if _, err := NewTokenSource(cfg, Token{}); !errors.Is(err, ErrInvalidToken) { + t.Fatalf("NewTokenSource() error = %v, want ErrInvalidToken", err) + } + if _, err := NewTokenSource(cfg, Token{AccessToken: "token", TokenType: "MAC"}); !errors.Is(err, ErrInvalidToken) { + t.Fatalf("NewTokenSource() error = %v, want ErrInvalidToken", err) + } + if _, err := NewTokenSource(cfg, Token{AccessToken: "token"}, WithEarlyExpiry(-time.Second)); !errors.Is(err, ErrInvalidConfig) { + t.Fatalf("NewTokenSource() error = %v, want ErrInvalidConfig", err) + } +} + +func validConfig(baseURL string) Config { + return Config{ + ClientID: "client-id", + Endpoint: Endpoint{AuthURL: baseURL + "/authorize", TokenURL: baseURL + "/token"}, + RedirectURL: "http://localhost:8787/callback", + Scopes: []string{"account:read"}, + } +} + +func freeLoopbackPort(t *testing.T) int { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen for free port: %v", err) + } + port := listener.Addr().(*net.TCPAddr).Port + if err := listener.Close(); err != nil { + t.Fatalf("close free port listener: %v", err) + } + return port +} From d04759387c08c3c923a8b8ff8f43f06c8d33539b Mon Sep 17 00:00:00 2001 From: Andrew Fuller Date: Fri, 28 Aug 2026 18:46:20 -0400 Subject: [PATCH 16/26] fix(sdk-go): harden OAuth and transport security --- packages/sdk-go/README.md | 12 +-- packages/sdk-go/oauth/oauth.go | 73 +++++++++++++++---- packages/sdk-go/oauth/oauth_test.go | 19 +++++ packages/sdk-go/services/account_test.go | 2 +- packages/sdk-go/services/clearing_test.go | 2 +- packages/sdk-go/services/heartbeat_test.go | 2 +- packages/sdk-go/services/margin_test.go | 2 +- packages/sdk-go/services/marketdata_test.go | 2 +- .../services/predictions_expansion_test.go | 4 +- .../services/predictions_less_used_test.go | 4 +- .../services/predictions_pagination_test.go | 2 +- packages/sdk-go/services/predictions_test.go | 4 +- .../services/reconciler_concurrency_test.go | 2 +- .../sdk-go/services/reconciler_stream_test.go | 12 +-- packages/sdk-go/services/reconciler_test.go | 24 +++--- packages/sdk-go/services/staking_test.go | 2 +- packages/sdk-go/services/trading_test.go | 8 +- packages/sdk-go/services/transfers_test.go | 2 +- .../sdk-go/transport/coverage_edges_test.go | 2 +- packages/sdk-go/transport/trace_test.go | 3 +- packages/sdk-go/transport/transport.go | 5 +- packages/sdk-go/transport/transport_test.go | 66 +++++++++++------ packages/sdk-go/websocket/client_requests.go | 56 +++++++++++++- packages/sdk-go/websocket/client_test.go | 20 ++++- packages/sdk-go/websocket/typed_requests.go | 18 ++--- .../sdk-go/websocket/typed_requests_test.go | 6 +- 26 files changed, 257 insertions(+), 97 deletions(-) diff --git a/packages/sdk-go/README.md b/packages/sdk-go/README.md index 192b3d3e..7f731a28 100644 --- a/packages/sdk-go/README.md +++ b/packages/sdk-go/README.md @@ -72,9 +72,9 @@ defer client.Close() Custom REST endpoints must use `https`; custom WebSocket endpoints must use the `wss` scheme. Endpoints may include a path prefix but cannot include userinfo, a query string, or a fragment. `NewClientWithError` validates both before -returning a client. Authenticated low-level transport requests also require an -`https` URL; unauthenticated adapters remain protocol agnostic so they can be -used with isolated HTTP test fixtures. +returning a client. The low-level HTTP transport also rejects every non-HTTPS +request, including public requests; use a TLS test server or in-memory +`RoundTripper` for isolated tests. --- @@ -441,8 +441,10 @@ use `ConnInfo`, `Time`, `ListSubscriptions`, `SubscribeStreams`, and `UnsubscribeStreams` for protocol control requests, and `PlaceOrder`, `CancelOrder`, `CancelAllOrders`, and `CancelSessionOrders` for trading requests. Raw `SubscribeStreams` calls are direct protocol operations and are -not replayed automatically after reconnect; use the typed feed subscription -methods when feed resumption is required. `PlaceOrder` accepts `LIMIT` and `MARKET`. A stop-limit order uses +not replayed automatically after reconnect; private stream names fail closed +on a public client. Use `RequestAuthenticated` for dynamically named private +methods, and use the typed feed subscription methods when feed resumption is +required. `PlaceOrder` accepts `LIMIT` and `MARKET`. A stop-limit order uses `Type: "LIMIT"` with both `Price` and `StopPrice`; Gemini reports the resulting order as `STOP_LIMIT` on the order-event stream. `stopPrice` is not valid with `MARKET`. The WebSocket contract allows `stopPrice == Price`; the legacy REST diff --git a/packages/sdk-go/oauth/oauth.go b/packages/sdk-go/oauth/oauth.go index 65c65138..7b8ae47c 100644 --- a/packages/sdk-go/oauth/oauth.go +++ b/packages/sdk-go/oauth/oauth.go @@ -644,15 +644,10 @@ func (c Config) loopbackCallback(ctx context.Context, authURL string, openBrowse return "", "", err } listenHost := parsed.Hostname() - if strings.EqualFold(listenHost, "localhost") { - listenHost = "127.0.0.1" - } - listenAddress := net.JoinHostPort(listenHost, parsed.Port()) - listener, err := net.Listen("tcp", listenAddress) + listeners, err := listenLoopback(listenHost, parsed.Port()) if err != nil { return "", "", fmt.Errorf("listen for OAuth callback: %w", err) } - defer listener.Close() resultCh := make(chan callbackResult, 1) var delivered atomic.Bool @@ -692,13 +687,21 @@ func (c Config) loopbackCallback(ctx context.Context, authURL string, openBrowse }) server := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second} - serveErr := make(chan error, 1) - go func() { - if err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { - serveErr <- err - } + serveErr := make(chan error, len(listeners)) + for _, listener := range listeners { + go func(listener net.Listener) { + if err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { + serveErr <- err + } + }(listener) + } + defer func() { + _ = server.Close() }() - defer server.Close() + + if len(listeners) == 0 { + return "", "", errors.New("no loopback callback listeners available") + } if err := openBrowser(authURL); err != nil { return "", "", fmt.Errorf("open OAuth authorization URL: %w", err) @@ -714,6 +717,50 @@ func (c Config) loopbackCallback(ctx context.Context, authURL string, openBrowse } } +func listenLoopback(host, port string) ([]net.Listener, error) { + type listenTarget struct { + network string + address string + } + + var targets []listenTarget + switch { + case strings.EqualFold(host, "localhost"): + // Browsers may resolve localhost to either loopback family. Bind both + // exact loopback addresses when the platform permits it; neither target + // exposes the callback server on a non-loopback interface. + targets = []listenTarget{ + {network: "tcp4", address: net.JoinHostPort("127.0.0.1", port)}, + {network: "tcp6", address: net.JoinHostPort("::1", port)}, + } + default: + ip := net.ParseIP(host) + if ip == nil || !ip.IsLoopback() { + return nil, fmt.Errorf("callback host %q is not loopback", host) + } + network := "tcp6" + if ip.To4() != nil { + network = "tcp4" + } + targets = []listenTarget{{network: network, address: net.JoinHostPort(host, port)}} + } + + listeners := make([]net.Listener, 0, len(targets)) + var errs []error + for _, target := range targets { + listener, err := net.Listen(target.network, target.address) + if err != nil { + errs = append(errs, fmt.Errorf("%s %s: %w", target.network, target.address, err)) + continue + } + listeners = append(listeners, listener) + } + if len(listeners) == 0 { + return nil, errors.Join(errs...) + } + return listeners, nil +} + type callbackResult struct { code string state string @@ -741,5 +788,5 @@ func writeCallbackResponse(writer http.ResponseWriter, status int, message strin writer.Header().Set("Cache-Control", "no-store") writer.Header().Set("Content-Type", "text/plain; charset=utf-8") writer.WriteHeader(status) - _, _ = io.WriteString(writer, message) + _, _ = writer.Write([]byte(message)) } diff --git a/packages/sdk-go/oauth/oauth_test.go b/packages/sdk-go/oauth/oauth_test.go index 74e8e22b..7f88cfea 100644 --- a/packages/sdk-go/oauth/oauth_test.go +++ b/packages/sdk-go/oauth/oauth_test.go @@ -207,6 +207,25 @@ func TestLoginRejectsInvalidCallbackStateAndThenAcceptsValidCallback(t *testing. } } +func TestListenLoopbackBindsOnlyLoopbackAddresses(t *testing.T) { + listeners, err := listenLoopback("localhost", fmt.Sprintf("%d", freeLoopbackPort(t))) + if err != nil { + t.Fatalf("listenLoopback() error = %v", err) + } + for _, listener := range listeners { + listener := listener + t.Cleanup(func() { _ = listener.Close() }) + host, _, err := net.SplitHostPort(listener.Addr().String()) + if err != nil { + t.Fatalf("split listener address %q: %v", listener.Addr(), err) + } + ip := net.ParseIP(host) + if ip == nil || !ip.IsLoopback() { + t.Fatalf("listener bound to non-loopback address %q", listener.Addr()) + } + } +} + func TestLoginRequiresLoopbackCallbackAndBrowserOpener(t *testing.T) { cfg := validConfig("https://exchange.example") if _, err := cfg.Login(context.Background(), nil); !errors.Is(err, ErrBrowserOpenerRequired) { diff --git a/packages/sdk-go/services/account_test.go b/packages/sdk-go/services/account_test.go index c030c547..a4a21f7c 100644 --- a/packages/sdk-go/services/account_test.go +++ b/packages/sdk-go/services/account_test.go @@ -16,7 +16,7 @@ import ( func TestAccountService_Methods(t *testing.T) { var capturedPath string requestPayloads := make(map[string]map[string]any) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { capturedPath = r.URL.Path if r.Method != http.MethodPost { t.Errorf("expected POST for %s, got %s", r.URL.Path, r.Method) diff --git a/packages/sdk-go/services/clearing_test.go b/packages/sdk-go/services/clearing_test.go index 44fbb3fb..c65d0246 100644 --- a/packages/sdk-go/services/clearing_test.go +++ b/packages/sdk-go/services/clearing_test.go @@ -13,7 +13,7 @@ import ( ) func TestClearingService_Methods(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/v1/clearing/new": diff --git a/packages/sdk-go/services/heartbeat_test.go b/packages/sdk-go/services/heartbeat_test.go index c5099e3b..41659bde 100644 --- a/packages/sdk-go/services/heartbeat_test.go +++ b/packages/sdk-go/services/heartbeat_test.go @@ -17,7 +17,7 @@ func TestHeartbeatSession_LifecycleAndErrors(t *testing.T) { var failMode atomic.Bool beats := make(chan struct{}, 16) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/heartbeat" { http.NotFound(w, r) return diff --git a/packages/sdk-go/services/margin_test.go b/packages/sdk-go/services/margin_test.go index ad8d5121..f6955257 100644 --- a/packages/sdk-go/services/margin_test.go +++ b/packages/sdk-go/services/margin_test.go @@ -14,7 +14,7 @@ import ( ) func TestMarginService_Methods(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/v1/margin/account": diff --git a/packages/sdk-go/services/marketdata_test.go b/packages/sdk-go/services/marketdata_test.go index 6ee35480..0cf969f3 100644 --- a/packages/sdk-go/services/marketdata_test.go +++ b/packages/sdk-go/services/marketdata_test.go @@ -14,7 +14,7 @@ import ( func TestMarketDataService_Methods(t *testing.T) { var capturedPath string var capturedQuery string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { capturedPath = r.URL.Path capturedQuery = r.URL.RawQuery w.Header().Set("Content-Type", "application/json") diff --git a/packages/sdk-go/services/predictions_expansion_test.go b/packages/sdk-go/services/predictions_expansion_test.go index b0a81b22..06e84f40 100644 --- a/packages/sdk-go/services/predictions_expansion_test.go +++ b/packages/sdk-go/services/predictions_expansion_test.go @@ -16,7 +16,7 @@ import ( ) func TestPredictionsServicePriorityEndpoints(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/v1/prediction-markets/order/batch": @@ -198,7 +198,7 @@ func TestPredictionsServicePriorityEndpoints(t *testing.T) { func TestPredictionsServiceIteratePositions(t *testing.T) { var offsets []string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { offsets = append(offsets, r.URL.Query().Get("offset")) w.Header().Set("Content-Type", "application/json") switch r.URL.Query().Get("offset") { diff --git a/packages/sdk-go/services/predictions_less_used_test.go b/packages/sdk-go/services/predictions_less_used_test.go index 58582ecd..ea7597de 100644 --- a/packages/sdk-go/services/predictions_less_used_test.go +++ b/packages/sdk-go/services/predictions_less_used_test.go @@ -15,7 +15,7 @@ import ( ) func TestPredictionsServiceLessUsedMethods(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/v1/prediction-markets/events/EVENT-1/strike": @@ -95,7 +95,7 @@ func TestPredictionsServiceLessUsedMethods(t *testing.T) { func TestPredictionsServiceOrderHistoryIterator(t *testing.T) { var offsets []int - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body predictions.GetOrderHistoryJSONBody if err := json.NewDecoder(r.Body).Decode(&body); err != nil { t.Errorf("decode order history body: %v", err) diff --git a/packages/sdk-go/services/predictions_pagination_test.go b/packages/sdk-go/services/predictions_pagination_test.go index db1cb1b8..ea9ee986 100644 --- a/packages/sdk-go/services/predictions_pagination_test.go +++ b/packages/sdk-go/services/predictions_pagination_test.go @@ -14,7 +14,7 @@ import ( func TestIterateOrderHistoryRejectsTimeBoundedOffsetPagination(t *testing.T) { var requests int - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests++ _, _ = w.Write([]byte(`{"orders":[]}`)) })) diff --git a/packages/sdk-go/services/predictions_test.go b/packages/sdk-go/services/predictions_test.go index 4e124599..42e72e60 100644 --- a/packages/sdk-go/services/predictions_test.go +++ b/packages/sdk-go/services/predictions_test.go @@ -85,7 +85,7 @@ func TestPredictionsService_TermsGating(t *testing.T) { } func TestPredictionsService_TermsGatingDoesNotAcceptFailedResponse(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/v1/prediction-markets/terms/accept": @@ -113,7 +113,7 @@ func TestPredictionsService_TermsGatingDoesNotAcceptFailedResponse(t *testing.T) } func TestPredictionsService_ReadEndpoints(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/v1/prediction-markets/terms": diff --git a/packages/sdk-go/services/reconciler_concurrency_test.go b/packages/sdk-go/services/reconciler_concurrency_test.go index 30799ff5..5eb78534 100644 --- a/packages/sdk-go/services/reconciler_concurrency_test.go +++ b/packages/sdk-go/services/reconciler_concurrency_test.go @@ -27,7 +27,7 @@ func TestQuoteReconciler_LimitsConcurrentRequests(t *testing.T) { unblock := func() { releaseOnce.Do(func() { close(release) }) } t.Cleanup(unblock) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/order/new" { http.NotFound(w, r) return diff --git a/packages/sdk-go/services/reconciler_stream_test.go b/packages/sdk-go/services/reconciler_stream_test.go index 661160ec..e7961ac4 100644 --- a/packages/sdk-go/services/reconciler_stream_test.go +++ b/packages/sdk-go/services/reconciler_stream_test.go @@ -85,7 +85,7 @@ func (d *reconcilerStreamDialer) latest() *reconcilerStreamConn { } func TestQuoteReconciler_StartStreamingHydratesAndAppliesEvents(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/orders" { http.NotFound(w, r) return @@ -144,7 +144,7 @@ func TestQuoteReconciler_StartStreamingHydratesAndAppliesEvents(t *testing.T) { func TestQuoteReconciler_RehydratesAfterOrdinaryReconnect(t *testing.T) { var orderRequests atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/orders" { http.NotFound(w, r) return @@ -213,7 +213,7 @@ func TestQuoteReconciler_SerializesInitialAndReconnectHydration(t *testing.T) { releaseFirstRequest := make(chan struct{}) var releaseOnce sync.Once - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/orders" { http.NotFound(w, r) return @@ -320,7 +320,7 @@ func TestQuoteReconciler_SerializesInitialAndReconnectHydration(t *testing.T) { func TestQuoteReconciler_StartStreamingBuffersEventsDuringHydration(t *testing.T) { hydrationStarted := make(chan struct{}) releaseHydration := make(chan struct{}) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/orders" { http.NotFound(w, r) return @@ -395,7 +395,7 @@ func TestQuoteReconciler_StartStreamingBuffersEventsDuringHydration(t *testing.T } func TestQuoteReconciler_StartStreamingDoesNotUnsubscribeOtherOrderSubscribers(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/orders" { http.NotFound(w, r) return @@ -448,7 +448,7 @@ func TestQuoteReconciler_StartStreamingDoesNotUnsubscribeOtherOrderSubscribers(t } func TestQuoteReconciler_StartStreamingRejectsDuplicateStream(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/orders" { http.NotFound(w, r) return diff --git a/packages/sdk-go/services/reconciler_test.go b/packages/sdk-go/services/reconciler_test.go index 6e8ef232..551b2a98 100644 --- a/packages/sdk-go/services/reconciler_test.go +++ b/packages/sdk-go/services/reconciler_test.go @@ -42,7 +42,7 @@ func TestQuoteReconciler_StateDiff(t *testing.T) { var mu sync.Mutex var orderCounter int64 = 1000 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.URL.Path { @@ -160,7 +160,7 @@ func TestQuoteReconciler_PreservesNewerEventDuringCancellation(t *testing.T) { releaseCancel := make(chan struct{}) var cancelOnce sync.Once - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/order/cancel" { http.NotFound(w, r) return @@ -238,7 +238,7 @@ func TestQuoteReconciler_PreservesNewerEventDuringCancellation(t *testing.T) { } func TestQuoteReconciler_SyncPrefersOldestDuplicateOrder(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.URL.Path != "/v1/order/cancel" { http.NotFound(w, r) @@ -370,7 +370,7 @@ func TestQuoteReconciler_ApplyOrderEvent(t *testing.T) { func TestQuoteReconciler_UppercaseSides(t *testing.T) { var placeCalls int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.URL.Path == "/v1/order/new" { atomic.AddInt32(&placeCalls, 1) @@ -408,7 +408,7 @@ func TestQuoteReconciler_UppercaseSides(t *testing.T) { func TestQuoteReconciler_PreservesWebSocketStateOnPlacement(t *testing.T) { var reconciler *services.QuoteReconciler - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.URL.Path == "/v1/order/new" { sym := "BTCUSD" @@ -497,7 +497,7 @@ func TestQuoteReconciler_ConcurrentSyncSerialized(t *testing.T) { var placeCount atomic.Int64 var orderCounter atomic.Int64 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.URL.Path == "/v1/order/new" { placeCount.Add(1) @@ -539,7 +539,7 @@ func TestQuoteReconciler_ConcurrentSyncSerialized(t *testing.T) { func TestQuoteReconciler_DoesNotPlaceAfterCancellationFailure(t *testing.T) { var placeCalls atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/v1/order/cancel": w.WriteHeader(http.StatusBadRequest) @@ -572,7 +572,7 @@ func TestQuoteReconciler_DoesNotPlaceAfterCancellationFailure(t *testing.T) { func TestQuoteReconciler_RejectsLiveCancellationResponse(t *testing.T) { var placeCalls atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/v1/order/cancel": _ = json.NewEncoder(w).Encode(trading.CancelOrderResponse{ @@ -612,7 +612,7 @@ func TestQuoteReconciler_RejectsLiveCancellationResponse(t *testing.T) { } func TestQuoteReconciler_RejectsNonLiveMakerOrCancelResponse(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/order/new" { http.NotFound(w, r) return @@ -639,7 +639,7 @@ func ptrBool(v bool) *bool { return &v } func TestQuoteReconciler_CancelAllClearsOnlyAfterSuccess(t *testing.T) { var fail atomic.Bool - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/order/cancel" { http.NotFound(w, r) return @@ -690,7 +690,7 @@ func TestQuoteReconciler_StartStreamingConfigurationAndHydrationErrors(t *testin t.Fatal("expected StartStreaming to reject a missing WebSocket client") } - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusServiceUnavailable) _, _ = w.Write([]byte(`{"result":"error","reason":"ServiceUnavailable"}`)) })) @@ -713,7 +713,7 @@ func TestQuoteReconciler_StartStreamingConfigurationAndHydrationErrors(t *testin func TestQuoteReconciler_MasterAccountIsPropagated(t *testing.T) { var mu sync.Mutex requests := make(map[string]map[string]any) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var payload map[string]any if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) diff --git a/packages/sdk-go/services/staking_test.go b/packages/sdk-go/services/staking_test.go index 10ac526c..9db3ed55 100644 --- a/packages/sdk-go/services/staking_test.go +++ b/packages/sdk-go/services/staking_test.go @@ -13,7 +13,7 @@ import ( ) func TestStakingService_Methods(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/v1/balances/staking": diff --git a/packages/sdk-go/services/trading_test.go b/packages/sdk-go/services/trading_test.go index 0e0c02b8..5184d4d1 100644 --- a/packages/sdk-go/services/trading_test.go +++ b/packages/sdk-go/services/trading_test.go @@ -18,7 +18,7 @@ func TestTradingService_FluentOrderHelpers(t *testing.T) { var capturedBody []byte var capturedPath string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { capturedPath = r.URL.Path buf := make([]byte, r.ContentLength) _, _ = r.Body.Read(buf) @@ -120,7 +120,7 @@ func TestTradingService_FluentOrderHelpers(t *testing.T) { func TestTradingService_ValidatesStopLimitOrders(t *testing.T) { var requests int - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests++ w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{}`)) @@ -189,7 +189,7 @@ func TestTradingService_ValidatesStopLimitOrders(t *testing.T) { } func TestTradingService_Methods(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") orderID := "123456" symbol := "btcusd" @@ -267,7 +267,7 @@ func TestTradingService_Methods(t *testing.T) { func TestTradingService_CancelAllRequiresExplicitConfirmation(t *testing.T) { var requests int - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests++ w.WriteHeader(http.StatusInternalServerError) })) diff --git a/packages/sdk-go/services/transfers_test.go b/packages/sdk-go/services/transfers_test.go index 4f95b818..42ab9de5 100644 --- a/packages/sdk-go/services/transfers_test.go +++ b/packages/sdk-go/services/transfers_test.go @@ -13,7 +13,7 @@ import ( ) func TestTransfersService_Methods(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/v2/withdraw/solana/sol": diff --git a/packages/sdk-go/transport/coverage_edges_test.go b/packages/sdk-go/transport/coverage_edges_test.go index 42e236f6..ce4e0bd2 100644 --- a/packages/sdk-go/transport/coverage_edges_test.go +++ b/packages/sdk-go/transport/coverage_edges_test.go @@ -138,7 +138,7 @@ func TestTransport_ErrorHelperEdges(t *testing.T) { } func TestTransport_CustomUserAgentAndCloseIdleConnections(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if got := r.UserAgent(); got != "coverage-test-agent" { t.Errorf("expected custom user agent, got %q", got) } diff --git a/packages/sdk-go/transport/trace_test.go b/packages/sdk-go/transport/trace_test.go index 87e80133..3dd8cb92 100644 --- a/packages/sdk-go/transport/trace_test.go +++ b/packages/sdk-go/transport/trace_test.go @@ -11,7 +11,7 @@ import ( ) func TestTraceHook_LatencyBreakdown(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { time.Sleep(10 * time.Millisecond) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"status":"ok"}`)) @@ -27,6 +27,7 @@ func TestTraceHook_LatencyBreakdown(t *testing.T) { }) client := transport.NewClient( + transport.WithHTTPClient(ts.Client()), transport.WithHooks(traceHook), ) diff --git a/packages/sdk-go/transport/transport.go b/packages/sdk-go/transport/transport.go index e0d3f79d..911b3f09 100644 --- a/packages/sdk-go/transport/transport.go +++ b/packages/sdk-go/transport/transport.go @@ -193,7 +193,10 @@ func (c *Client) Execute(ctx context.Context, req *http.Request, payloadJSON []b if req.URL == nil { return nil, nil, errors.New("gemini transport: request URL is nil") } - if c.auth != nil && !strings.EqualFold(req.URL.Scheme, "https") { + // Keep the transport boundary HTTPS-only even for public requests. Public + // data must not silently downgrade when a caller supplies a custom endpoint; + // test servers should use TLS or an in-memory RoundTripper instead. + if !strings.EqualFold(req.URL.Scheme, "https") { return nil, nil, ErrHTTPSRequired } if req.Header == nil { diff --git a/packages/sdk-go/transport/transport_test.go b/packages/sdk-go/transport/transport_test.go index 7d1e657b..8ddea00a 100644 --- a/packages/sdk-go/transport/transport_test.go +++ b/packages/sdk-go/transport/transport_test.go @@ -84,9 +84,33 @@ func TestTransport_AuthenticatedRequestsRequireHTTPS(t *testing.T) { } } +func TestTransport_PublicRequestsRequireHTTPS(t *testing.T) { + var roundTrips atomic.Int32 + client := NewClient(WithHTTPClient(&http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + roundTrips.Add(1) + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"result":"unexpected"}`)), + Request: req, + }, nil + })})) + + req, err := http.NewRequest(http.MethodGet, "http://api.gemini.com/v1/symbols", nil) + if err != nil { + t.Fatalf("creating request: %v", err) + } + if _, _, err := client.Execute(context.Background(), req, nil); !errors.Is(err, ErrHTTPSRequired) { + t.Fatalf("Execute error = %v, want ErrHTTPSRequired", err) + } + if got := roundTrips.Load(); got != 0 { + t.Fatalf("insecure public request reached RoundTripper %d times", got) + } +} + func TestTransport_SafeGetRetry(t *testing.T) { var attempts int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { current := atomic.AddInt32(&attempts, 1) if current < 3 { w.Header().Set("Retry-After", "0") @@ -131,7 +155,7 @@ func TestTransport_SafeGetRetry(t *testing.T) { func TestTransport_SafeGetRetryReplaysRequestBody(t *testing.T) { var bodies []string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { t.Errorf("read request body: %v", err) @@ -213,18 +237,18 @@ func TestTransport_BearerReauthenticatesSafeGetRetry(t *testing.T) { func TestTransport_DefaultClientDoesNotFollowRedirects(t *testing.T) { var targetHits atomic.Int32 - target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + target := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { targetHits.Add(1) w.WriteHeader(http.StatusOK) })) defer target.Close() - redirect := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + redirect := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, target.URL, http.StatusFound) })) defer redirect.Close() - client := NewClient() + client := NewClient(WithHTTPClient(redirect.Client())) err := client.Request(context.Background(), http.MethodGet, redirect.URL, nil, nil) if err == nil { t.Fatal("expected redirect response to be returned as an error") @@ -236,19 +260,18 @@ func TestTransport_DefaultClientDoesNotFollowRedirects(t *testing.T) { func TestTransport_CustomClientDoesNotFollowRedirects(t *testing.T) { var targetHits atomic.Int32 - target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + target := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { targetHits.Add(1) w.WriteHeader(http.StatusOK) })) defer target.Close() - redirect := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + redirect := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, target.URL, http.StatusFound) })) defer redirect.Close() - custom := &http.Client{Transport: http.DefaultTransport} - client := NewClient(WithHTTPClient(custom)) + client := NewClient(WithHTTPClient(redirect.Client())) if err := client.Request(context.Background(), http.MethodGet, redirect.URL, nil, nil); err == nil { t.Fatal("expected redirect response to be returned as an error") } @@ -260,7 +283,7 @@ func TestTransport_CustomClientDoesNotFollowRedirects(t *testing.T) { func TestTransport_BearerCredentialsAreNotForwardedAcrossRedirects(t *testing.T) { var redirectAuth string var targetHits atomic.Int32 - target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + target := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { targetHits.Add(1) w.WriteHeader(http.StatusOK) })) @@ -297,10 +320,11 @@ func TestTransport_NilInputsAreHandledWithoutPanic(t *testing.T) { t.Fatal("expected nil request URL error") } - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) defer server.Close() + client = NewClient(WithHTTPClient(server.Client())) if err := client.Request(nilContext, http.MethodGet, server.URL, nil, nil); err != nil { t.Fatalf("expected nil context to use a background context, got %v", err) } @@ -337,7 +361,7 @@ func TestTransport_DropsPartialResponseWhenRoundTripReturnsError(t *testing.T) { func TestTransport_MutatingPostNeverRetried(t *testing.T) { var attempts int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt32(&attempts, 1) w.WriteHeader(http.StatusTooManyRequests) _, _ = w.Write([]byte(`{"result":"error","reason":"RateLimit","message":"rate limited on order creation"}`)) @@ -495,7 +519,7 @@ func TestTransport_SerializesAuthenticatedRequestsThroughRetries(t *testing.T) { } func TestTransport_DeadlineErrorsUnwrapToSentinel(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { time.Sleep(100 * time.Millisecond) w.WriteHeader(http.StatusOK) })) @@ -560,7 +584,7 @@ func (m *mockRateLimitHook) OnRateLimit(ctx context.Context, req *http.Request, } func TestTransport_RateLimitWithRetryAfter(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Retry-After", "3") w.WriteHeader(http.StatusTooManyRequests) _, _ = w.Write([]byte(`{"result":"error","reason":"RateLimit","message":"exceeded 100 rps limit"}`)) @@ -644,7 +668,7 @@ func TestTransport_ErrorUnwrap(t *testing.T) { } func TestTransport_ContextCancellation(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"result":"ok"}`)) })) @@ -710,7 +734,7 @@ func TestTransport_APIErrorPayloadParsing(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(tc.status) _, _ = w.Write([]byte(tc.body)) @@ -733,7 +757,7 @@ func TestTransport_TelemetryHeaders(t *testing.T) { var capturedUserAgent string var capturedClientTelemetry string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { capturedUserAgent = r.Header.Get("User-Agent") capturedClientTelemetry = r.Header.Get("X-Gemini-Client-User-Agent") w.Header().Set("Content-Type", "application/json") @@ -765,7 +789,7 @@ func TestTransport_TelemetryHeaders(t *testing.T) { } func TestTransport_RequestIDExtraction(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-GEMINI-REQUEST-ID", "req_abc123xyz") w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) @@ -812,7 +836,7 @@ func TestTransport_HTTPStatusTaxonomy(t *testing.T) { for _, tc := range testCases { t.Run(http.StatusText(tc.statusCode), func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(tc.statusCode) _, _ = w.Write([]byte(`{"error":"raw server message"}`)) })) @@ -831,7 +855,7 @@ func TestTransport_HTTPStatusTaxonomy(t *testing.T) { } func TestTransport_BoundedResponseBody(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/octet-stream") w.WriteHeader(http.StatusOK) // Stream 33 MB of zeroes (exceeds 32 MB limit) @@ -899,7 +923,7 @@ func TestError_AsAndIsTaxonomy(t *testing.T) { } func BenchmarkTransport_Request(b *testing.B) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"result":"ok","order_id":"12345"}`)) diff --git a/packages/sdk-go/websocket/client_requests.go b/packages/sdk-go/websocket/client_requests.go index a93a1a6d..1befa129 100644 --- a/packages/sdk-go/websocket/client_requests.go +++ b/packages/sdk-go/websocket/client_requests.go @@ -89,13 +89,27 @@ func (c *Client) Send(ctx context.Context, payload any) error { // response. The params value is encoded as the wire-level params field and may // be nil when the method has no parameters. func (c *Client) Request(ctx context.Context, method string, params any) (ResponseFrame, error) { + return c.request(ctx, method, params) +} + +// RequestAuthenticated sends a JSON WebSocket method request that is required +// to use the configured authentication strategy. Use this for private methods +// when the method name is supplied dynamically. +func (c *Client) RequestAuthenticated(ctx context.Context, method string, params any) (ResponseFrame, error) { + if c.auth == nil { + return ResponseFrame{}, ErrAuthenticationRequired + } + return c.request(ctx, method, params) +} + +func (c *Client) request(ctx context.Context, method string, params any) (ResponseFrame, error) { if ctx == nil { ctx = context.Background() } if strings.TrimSpace(method) == "" { return ResponseFrame{}, errors.New("gemini websocket: request method is empty") } - if requiresAuthentication(method) && c.auth == nil { + if requiresAuthentication(method, params) && c.auth == nil { return ResponseFrame{}, ErrAuthenticationRequired } if err := c.Connect(ctx); err != nil { @@ -104,9 +118,45 @@ func (c *Client) Request(ctx context.Context, method string, params any) (Respon return c.requestConnected(ctx, method, params) } -func requiresAuthentication(method string) bool { +func requiresAuthentication(method string, params any) bool { method = strings.ToLower(strings.TrimSpace(method)) - return strings.HasPrefix(method, "order.") || strings.HasPrefix(method, "rfq.") + if strings.HasPrefix(method, "order.") || strings.HasPrefix(method, "rfq.") { + return true + } + if method != "subscribe" && method != "unsubscribe" { + return false + } + + // The protocol uses SUBSCRIBE/UNSUBSCRIBE for both public and private + // feeds. Decode the params shape used by the wire protocol so a caller + // cannot accidentally send an authenticated feed request through a public + // client using the generic Request method. + encoded, err := json.Marshal(params) + if err != nil { + // A malformed subscription payload must not become a way to bypass the + // public/private boundary. The server will reject it anyway, so fail + // closed before anything is sent on a public connection. + return true + } + var streams []string + if err := json.Unmarshal(encoded, &streams); err != nil { + return true + } + for _, stream := range streams { + stream = strings.ToLower(strings.TrimSpace(stream)) + for _, privatePrefix := range []string{ + "orders@", + "balances@", + "positions@", + "settlements@", + "requestforquote@", + } { + if strings.HasPrefix(stream, privatePrefix) { + return true + } + } + } + return false } // requestConnected sends a request without attempting to establish a diff --git a/packages/sdk-go/websocket/client_test.go b/packages/sdk-go/websocket/client_test.go index 12ce7d27..42b75282 100644 --- a/packages/sdk-go/websocket/client_test.go +++ b/packages/sdk-go/websocket/client_test.go @@ -815,10 +815,22 @@ func TestClient_GenericPrivateMethodsRequireAuthentication(t *testing.T) { client := websocket.NewPublicClient("wss://ws.gemini.com", websocket.WithDialer(dialer)) defer client.Close() - for _, method := range []string{"order.place", "order.cancel", "order.cancel_all", "rfq.submit_quote", "rfq.confirm_quote"} { - t.Run(method, func(t *testing.T) { - if _, err := client.Request(context.Background(), method, nil); !errors.Is(err, websocket.ErrAuthenticationRequired) { - t.Fatalf("Request(%q) error = %v, want ErrAuthenticationRequired", method, err) + tests := []struct { + method string + params any + }{ + {method: "order.place"}, + {method: "order.cancel"}, + {method: "order.cancel_all"}, + {method: "rfq.submit_quote"}, + {method: "rfq.confirm_quote"}, + {method: "SUBSCRIBE", params: []string{"orders@account"}}, + {method: "UNSUBSCRIBE", params: []string{"balances@account@1s"}}, + } + for _, test := range tests { + t.Run(test.method, func(t *testing.T) { + if _, err := client.Request(context.Background(), test.method, test.params); !errors.Is(err, websocket.ErrAuthenticationRequired) { + t.Fatalf("Request(%q) error = %v, want ErrAuthenticationRequired", test.method, err) } }) } diff --git a/packages/sdk-go/websocket/typed_requests.go b/packages/sdk-go/websocket/typed_requests.go index f3fbe4e7..5243151e 100644 --- a/packages/sdk-go/websocket/typed_requests.go +++ b/packages/sdk-go/websocket/typed_requests.go @@ -124,17 +124,18 @@ func (c *Client) ListSubscriptions(ctx context.Context) ([]string, error) { } // SubscribeStreams subscribes to one or more raw stream names using the -// documented WebSocket control-plane method. Raw subscriptions are not -// represented in the typed subscription registry and are not replayed after a -// reconnect; use a typed Subscribe* method when automatic feed resumption is -// required. +// documented WebSocket control-plane method. Private stream names require an +// authenticated client. Raw subscriptions are not represented in the typed +// subscription registry and are not replayed after a reconnect; use a typed +// Subscribe* method when automatic feed resumption is required. func (c *Client) SubscribeStreams(ctx context.Context, streams ...string) error { return c.updateStreams(ctx, string(OpSubscribe), streams) } // UnsubscribeStreams unsubscribes from one or more raw stream names using the -// documented WebSocket control-plane method. It only affects the server-side -// subscription and does not alter the typed subscription registry. +// documented WebSocket control-plane method. Private stream names require an +// authenticated client. It only affects the server-side subscription and does +// not alter the typed subscription registry. func (c *Client) UnsubscribeStreams(ctx context.Context, streams ...string) error { return c.updateStreams(ctx, string(OpUnsubscribe), streams) } @@ -199,10 +200,7 @@ func (c *Client) cancelAll(ctx context.Context, method RequestOp, options Cancel } func (c *Client) authenticatedRequest(ctx context.Context, method string, params any) (ResponseFrame, error) { - if c.auth == nil { - return ResponseFrame{}, ErrAuthenticationRequired - } - return c.Request(ctx, method, params) + return c.RequestAuthenticated(ctx, method, params) } func decodeObjectResult(response ResponseFrame, method string) (JSONObject, error) { diff --git a/packages/sdk-go/websocket/typed_requests_test.go b/packages/sdk-go/websocket/typed_requests_test.go index 7e23d555..2e23ca77 100644 --- a/packages/sdk-go/websocket/typed_requests_test.go +++ b/packages/sdk-go/websocket/typed_requests_test.go @@ -13,7 +13,11 @@ import ( func TestTypedUtilityAndSubscriptionRequests(t *testing.T) { dialer := &mockDrainDialer{responseResult: json.RawMessage(`{"serverTime":123}`)} - client := websocket.NewPublicClient("wss://ws.gemini.com", websocket.WithDialer(dialer)) + client := websocket.NewPrivateClient( + "wss://ws.gemini.com", + auth.NewHMAC("key", "secret"), + websocket.WithDialer(dialer), + ) defer client.Close() info, err := client.ConnInfo(context.Background()) From 5b919d71d815338c665f1682b628132a03dbd98a Mon Sep 17 00:00:00 2001 From: Andrew Fuller Date: Fri, 28 Aug 2026 20:43:34 -0400 Subject: [PATCH 17/26] fix(sdk-go): escape OAuth callback responses --- packages/sdk-go/oauth/oauth.go | 5 ++++- packages/sdk-go/oauth/oauth_test.go | 12 ++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/sdk-go/oauth/oauth.go b/packages/sdk-go/oauth/oauth.go index 7b8ae47c..e9c777d5 100644 --- a/packages/sdk-go/oauth/oauth.go +++ b/packages/sdk-go/oauth/oauth.go @@ -17,6 +17,7 @@ import ( "encoding/json" "errors" "fmt" + "html/template" "io" "net" "net/http" @@ -66,6 +67,8 @@ var ( ErrTokenEndpoint = errors.New("gemini oauth: token endpoint rejected request") ) +var callbackResponseTemplate = template.Must(template.New("oauth-callback-response").Parse("{{.}}")) + // Endpoint contains the OAuth authorization and token endpoint URLs. // Both endpoints must use HTTPS. type Endpoint struct { @@ -788,5 +791,5 @@ func writeCallbackResponse(writer http.ResponseWriter, status int, message strin writer.Header().Set("Cache-Control", "no-store") writer.Header().Set("Content-Type", "text/plain; charset=utf-8") writer.WriteHeader(status) - _, _ = writer.Write([]byte(message)) + _ = callbackResponseTemplate.Execute(writer, message) } diff --git a/packages/sdk-go/oauth/oauth_test.go b/packages/sdk-go/oauth/oauth_test.go index 7f88cfea..0f661acd 100644 --- a/packages/sdk-go/oauth/oauth_test.go +++ b/packages/sdk-go/oauth/oauth_test.go @@ -226,6 +226,18 @@ func TestListenLoopbackBindsOnlyLoopbackAddresses(t *testing.T) { } } +func TestWriteCallbackResponseEscapesBody(t *testing.T) { + recorder := httptest.NewRecorder() + writeCallbackResponse(recorder, http.StatusOK, "") + + if strings.Contains(recorder.Body.String(), "