Skip to content

Commit e702dd3

Browse files
authored
Merge pull request #15 from coingecko/feat/watch-websocket-streaming
feat: add cg watch for real-time WebSocket price streaming
2 parents 7099c30 + 4b0b971 commit e702dd3

13 files changed

Lines changed: 1594 additions & 10 deletions

File tree

CLAUDE.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ go test -race ./...
2525
```
2626
coingecko-cli/
2727
├── main.go # Entry point
28-
├── cmd/ # Cobra commands (auth, status, price, markets, search, trending, history, top_gainers_losers, tui, version)
28+
├── cmd/ # Cobra commands (auth, status, price, markets, search, trending, history, top_gainers_losers, watch, tui, version)
2929
├── internal/
3030
│ ├── api/
3131
│ │ ├── client.go # HTTP client, auth, error handling
@@ -43,6 +43,9 @@ coingecko-cli/
4343
│ │ └── color.go # ANSI color (NO_COLOR/TTY aware)
4444
│ ├── export/
4545
│ │ └── csv.go # CSV file export
46+
│ ├── ws/
47+
│ │ ├── client.go # WebSocket client (ActionCable protocol, reconnect, state machine)
48+
│ │ └── client_test.go # WebSocket client tests (httptest + gorilla/websocket upgrader)
4649
│ └── tui/
4750
│ ├── styles.go # Shared lipgloss styles, brand colors, frame/layout helpers
4851
│ ├── markets.go # Markets TUI model
@@ -100,6 +103,7 @@ coingecko-cli/
100103
| `cg history --from/--to --interval hourly` | `/coins/{id}/market_chart/range` (batched) | `coins-id-market-chart-range` |
101104
| `cg history --from/--to --ohlc` | `/coins/{id}/ohlc/range` (batched for large ranges) | `coins-id-ohlc-range` |
102105
| `cg top-gainers-losers` | `/coins/top_gainers_losers` | `coins-top-gainers-losers` |
106+
| `cg watch` | `wss://stream.coingecko.com/v1` (WebSocket) ||
103107

104108
## Distribution
105109

@@ -114,7 +118,7 @@ coingecko-cli/
114118

115119
- CoinGecko `/coins/{id}/market_chart/range` expects UNIX timestamps in seconds — CLI accepts `YYYY-MM-DD` and converts in the command layer
116120
- CoinGecko `/coins/{id}/history` uses `DD-MM-YYYY` date format — CLI accepts `YYYY-MM-DD` and converts
117-
- Symbol resolution (for `cg price --symbols`) uses `/search` endpoint, picks exact case-insensitive match with highest market_cap_rank
121+
- Symbol resolution: `cg price --symbols` uses `/simple/price?symbols=` directly (API accepts symbols natively). `cg watch --symbols` uses `/search` to resolve symbols to coin IDs (WebSocket requires coin IDs), picking exact case-insensitive match with highest market_cap_rank
118122
- TUI detail view fetches coin detail + OHLC concurrently via `tea.Batch`
119123
- `RateLimitError` typed error carries `RetryAfter` seconds, satisfies `errors.Is(err, ErrRateLimited)` via custom `Is()` method
120124
- API text (coin names, symbols) sanitized via `display.SanitizeCell` to strip terminal escape injection
@@ -124,6 +128,9 @@ coingecko-cli/
124128
- **OHLC range batching**: daily chunks ≤170 days (API limit 180), hourly chunks ≤30 days (API limit 31). `--ohlc --interval` requires paid plans
125129
- **`--interval` values**: only `daily` and `hourly` are accepted; `5m` is not supported (Enterprise-only)
126130
- **Hourly data availability**: CoinGecko hourly data is only available from 2018-01-30 onwards; 5-minute data from 2018-02-09 onwards. The CLI validates this client-side and rejects requests with `--interval hourly` before the cutoff date
127-
- **Command test seams**: `cmd/client_factory.go` exposes injectable `newAPIClient` and `loadConfig` vars so command integration tests can swap in `httptest` servers and test configs without touching real API or config files
131+
- **WebSocket streaming**: `cg watch` uses CoinGecko's ActionCable WebSocket API (`CGSimplePrice` channel) for real-time price updates (~10s). Paid-only, USD prices only. API key passed via `x_cg_pro_api_key` query param. Coin IDs are validated via `/simple/price` before connecting; invalid IDs are skipped with a warning
132+
- **WebSocket reconnect**: automatic reconnect with exponential backoff (1s→30s cap + jitter). `Close()` sets an atomic `closing` flag that suppresses reconnect. Single `readLoop` goroutine owns the connection lifecycle
133+
- **WebSocket test seams**: `cmd/client_factory.go` exposes `Streamer` interface + `newStreamer` factory for injecting test doubles in command tests. WS protocol tests use `httptest` + `gorilla/websocket.Upgrader`
134+
- **Command test seams**: `cmd/client_factory.go` exposes injectable `newAPIClient`, `loadConfig`, and `newStreamer` vars so command integration tests can swap in httptest servers and test configs without touching real API or config files
128135
- **Pagination helper**: `FetchAllMarkets` in `internal/api/coins.go` handles multi-page fetching (250/page) with trim-to-total, used by both `cg markets` and `cg tui markets`
129136
- **TUI trending tier awareness**: demo gets 15 coins, paid gets 30 via `show_max=coins` API param

README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ A fast, full-featured terminal interface for the [CoinGecko API](https://docs.co
1717
- **📊 Unlimited Markets** — Fetch 1,000+ coins with automatic pagination
1818
- **🔥 Trending & Top Movers** — Real-time trending coins, NFTs, and categories
1919
- **📥 CSV Export** — Export any market or history query for analysis in Excel or Python
20+
- **📡 Live WebSocket Streaming** — Real-time price updates via `cg watch` with NDJSON output for piping
2021
- **⌨️ JSON Output** — Machine-readable `-o json` for scripting and pipelines
2122
- **🤖 Agent/LLM Friendly**`--dry-run` mode and `cg commands` for tool integration
2223

@@ -309,6 +310,29 @@ cg top-gainers-losers --top-coins 300 --export gainers.csv
309310

310311
---
311312

313+
### `cg watch` — Live Price Streaming (Exclusive for [Analyst plan](https://www.coingecko.com/en/api/pricing) & above)
314+
315+
Stream real-time price updates via CoinGecko's WebSocket API. Updates arrive approximately every 10 seconds. USD prices only.
316+
317+
```sh
318+
cg watch --ids bitcoin,ethereum # Live updating table
319+
cg watch --symbols btc,eth # Resolve symbols, then stream
320+
cg watch --ids bitcoin -o json # NDJSON output (pipe-friendly)
321+
cg watch --ids bitcoin -o json | jq .price # Stream prices with jq
322+
cg watch --ids bitcoin --dry-run # Show WebSocket request info
323+
```
324+
325+
| Flag | Default | Description |
326+
|---|---|---|
327+
| `--ids` || Comma-separated coin IDs |
328+
| `--symbols` || Comma-separated symbols (resolved to IDs) |
329+
330+
**Table mode** (default): clears screen and re-renders on each update. Press `Ctrl+C` to quit.
331+
332+
**JSON mode** (`-o json`): one JSON object per line per update to stdout. Exits cleanly on broken pipe.
333+
334+
---
335+
312336
## Category Filtering
313337

314338
CoinGecko tracks 500+ categories including Real World Assets, commodities, and tokenized stocks. Use the `--category` flag to filter:
@@ -396,6 +420,7 @@ Commands:
396420
trending Show trending coins, NFTs, and categories (24h)
397421
history Get historical price data for a coin
398422
top-gainers-losers Show top gaining and losing coins (paid plans only)
423+
watch Stream live coin prices via WebSocket (analyst or above)
399424
tui Interactive terminal UI (markets, trending)
400425
commands List all commands with API metadata (for agents/LLMs)
401426
help Print help for a command
@@ -440,6 +465,7 @@ make lint
440465
| [lipgloss](https://github.com/charmbracelet/lipgloss) | Terminal styling and layout |
441466
| [huh](https://github.com/charmbracelet/huh) | Interactive auth prompts |
442467
| [ntcharts](https://github.com/NimbleMarkets/ntcharts) | Braille terminal charts |
468+
| [gorilla/websocket](https://github.com/gorilla/websocket) | WebSocket client for live price streaming |
443469
| [goreleaser](https://goreleaser.com) | Cross-platform release builds |
444470

445471
## License

cmd/client_factory.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
package cmd
22

33
import (
4+
"context"
5+
46
"github.com/coingecko/coingecko-cli/internal/api"
57
"github.com/coingecko/coingecko-cli/internal/config"
8+
"github.com/coingecko/coingecko-cli/internal/ws"
69
)
710

811
// newAPIClient is the factory used by command handlers to create API clients.
@@ -14,3 +17,15 @@ var newAPIClient = func(cfg *config.Config) *api.Client {
1417
// loadConfig is the function used by command handlers to load configuration.
1518
// Tests override this to inject test configs without touching the real config file.
1619
var loadConfig = config.Load
20+
21+
// Streamer abstracts the WebSocket streaming client for testability.
22+
type Streamer interface {
23+
Connect(ctx context.Context) (<-chan *ws.CoinUpdate, error)
24+
Close() error
25+
}
26+
27+
// newStreamer is the factory used by command handlers to create WebSocket clients.
28+
// Tests override this to inject test doubles.
29+
var newStreamer = func(cfg *config.Config, coinIDs []string) Streamer {
30+
return ws.NewClient(cfg, coinIDs)
31+
}

cmd/commands.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package cmd
33
import (
44
"strings"
55

6+
"github.com/coingecko/coingecko-cli/internal/ws"
67
"github.com/spf13/cobra"
78
"github.com/spf13/pflag"
89
)
@@ -15,6 +16,7 @@ type commandAnnotation struct {
1516
OASOperationID string
1617
OASOperationIDs map[string]string
1718
OASSpec string
19+
Transport string // "rest" (default) or "websocket"
1820
PaidOnly bool
1921
RequiresAuth bool
2022
}
@@ -69,6 +71,12 @@ var commandMeta = map[string]commandAnnotation{
6971
PaidOnly: true,
7072
RequiresAuth: true,
7173
},
74+
"watch": {
75+
APIEndpoint: ws.DefaultWSURL,
76+
Transport: "websocket",
77+
PaidOnly: true,
78+
RequiresAuth: true,
79+
},
7280
}
7381

7482
type flagInfo struct {
@@ -126,6 +134,7 @@ type commandInfo struct {
126134
OutputFormats []string `json:"output_formats"`
127135
RequiresAuth bool `json:"requires_auth"`
128136
PaidOnly bool `json:"paid_only"`
137+
Transport string `json:"transport,omitempty"`
129138
APIEndpoint string `json:"api_endpoint,omitempty"`
130139
APIEndpoints map[string]string `json:"api_endpoints,omitempty"`
131140
OASOperationID string `json:"oas_operation_id,omitempty"`
@@ -207,6 +216,7 @@ func runCommands(cmd *cobra.Command, args []string) error {
207216
if meta, ok := commandMeta[c.Name()]; ok {
208217
info.PaidOnly = meta.PaidOnly
209218
info.RequiresAuth = meta.RequiresAuth
219+
info.Transport = meta.Transport
210220
info.APIEndpoint = meta.APIEndpoint
211221
info.APIEndpoints = meta.APIEndpoints
212222
info.OASOperationID = meta.OASOperationID

cmd/dryrun.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cmd
22

33
import (
44
"github.com/coingecko/coingecko-cli/internal/config"
5+
"github.com/coingecko/coingecko-cli/internal/ws"
56
"github.com/spf13/cobra"
67
)
78

@@ -39,6 +40,38 @@ func printDryRunWithOp(cfg *config.Config, cmdName, opKey, endpoint string, para
3940
return printDryRunFull(cfg, cmdName, opKey, endpoint, params, pagination, "")
4041
}
4142

43+
type dryRunWSOutput struct {
44+
PreflightRequests []dryRunOutput `json:"preflight_requests,omitempty"`
45+
Transport string `json:"transport"`
46+
URL string `json:"url"`
47+
SubscribePayload any `json:"subscribe_payload"`
48+
SetTokensPayload any `json:"set_tokens_payload"`
49+
Note string `json:"note,omitempty"`
50+
}
51+
52+
func printDryRunWS(cfg *config.Config, coinIDs []string, preflights []dryRunOutput) error {
53+
masked := cfg.MaskedKey()
54+
url := ws.DefaultWSURL + "?x_cg_pro_api_key=" + masked
55+
56+
out := dryRunWSOutput{
57+
PreflightRequests: preflights,
58+
Transport: "websocket",
59+
URL: url,
60+
SubscribePayload: map[string]string{
61+
"command": "subscribe",
62+
"identifier": ws.ChannelID,
63+
},
64+
SetTokensPayload: map[string]any{
65+
"command": "message",
66+
"identifier": ws.ChannelID,
67+
"data": map[string]any{"action": "set_tokens", "coin_id": coinIDs},
68+
},
69+
Note: "Paid plan required. Updates stream as NDJSON (~every 10s). USD prices only.",
70+
}
71+
72+
return printJSONRaw(out)
73+
}
74+
4275
func printDryRunFull(cfg *config.Config, cmdName, opKey, endpoint string, params map[string]string, pagination *paginationInfo, note string) error {
4376
headerKey, _ := cfg.AuthHeader()
4477
masked := cfg.MaskedKey()

0 commit comments

Comments
 (0)