From 26b23a9ab0c8b9bd5588e42574de9929fc1f9550 Mon Sep 17 00:00:00 2001 From: Kabzelife <141412378+Kabzelife@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:45:22 +0200 Subject: [PATCH] v0.6.0: Trade Feed (Data API, stream, webhooks) - Data API (sync + async): trade_feed / trade_feed_traders / add_to_trade_feed / remove_from_trade_feed / trade_feed_notifications + iter_* variants, in a user's name (scope trade_feed) - TradeFeedStream (wss://api-id.cabalspy.xyz/v1/trade-feed/stream) with reconnect, heartbeat watchdog and async iteration - trade_feed.trade / trade_feed.thesis webhook TypedDicts and type guards - New module cabalspy_id.trade_feed; cabalspy_id.api exports the Trade Feed parts Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 29 +++ README.md | 109 ++++++++- cabalspy_id/__init__.py | 74 +++++- cabalspy_id/_client.py | 155 ++++++++++++ cabalspy_id/_constants.py | 20 +- cabalspy_id/_trade_feed_stream.py | 369 ++++++++++++++++++++++++++++ cabalspy_id/_types.py | 202 +++++++++++++++- cabalspy_id/_webhooks.py | 37 ++- cabalspy_id/api.py | 67 +++++- cabalspy_id/trade_feed.py | 152 ++++++++++++ examples/trade_feed.py | 59 +++++ pyproject.toml | 3 +- tests/test_submodules.py | 13 + tests/test_trade_feed.py | 385 ++++++++++++++++++++++++++++++ 14 files changed, 1655 insertions(+), 19 deletions(-) create mode 100644 cabalspy_id/_trade_feed_stream.py create mode 100644 cabalspy_id/trade_feed.py create mode 100644 examples/trade_feed.py create mode 100644 tests/test_trade_feed.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 465ae28..9142026 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,34 @@ All notable changes to this package are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses [Semantic Versioning](https://semver.org/). +## [0.6.0] - 2026-10-15 + +Trade Feed: your users add CabalSpy ID traders (public trading, verified wallets) to their Trade +Feed and your platform tells them about every trade and thesis of those traders. Needs the new +OIDC scope `trade_feed`. Nothing existing changed. + +### Added + +- Data API methods in a user's name (`csid_id`; `PermissionError` `consent_required` without the + scope), sync and async: `trade_feed(csid_id, filter=, limit=, cursor=)`, + `trade_feed_traders(csid_id, ...)`, `add_to_trade_feed(csid_id, trader, notify_buys=, + notify_sells=, notify_theses=, min_sol=, min_bnb=, min_eth=, muted=)` (`created` = 201 / 200), + `remove_from_trade_feed(csid_id, trader)`, `trade_feed_notifications(csid_id, ...)`, and + `iter_trade_feed`, `iter_trade_feed_traders`, `iter_trade_feed_notifications`. +- `TradeFeedStream` (`client.trade_feed_stream()`, `wss://api-id.cabalspy.xyz/v1/trade-feed/stream`, + every plan, `stream` extra): `trade_feed.trade` / `trade_feed.thesis` events with + `data["recipients"]`, reconnect with backoff, heartbeat watchdog, `async for` yielding + `{"event", "id", "data"}`. Option `trade_feed_stream_url`, constant `DEFAULT_TRADE_FEED_STREAM_URL`. +- Webhooks `trade_feed.trade` and `trade_feed.thesis` in `WEBHOOK_EVENTS`, TypedDicts + (`TradeFeedTradeEvent`, `TradeFeedThesisEvent`, `TradeFeedTradeData`, `TradeFeedThesisData`) and + type guards `is_trade_feed_event`, `is_trade_feed_trade_event`, `is_trade_feed_thesis_event`. +- Types: `TradeFeedItem` (`TradeFeedTradeItem` | `TradeFeedThesisItem`), `TradeFeedTrader`, + `TradeFeedTrade`, `TradeFeedThesis`, `TradeFeedLinks`, `TradeFeedEntry`, `TradeFeedSettings`, + `TradeFeedAddResult`, `TradeFeedRemoveResult`, `TradeFeedNotification`, `TradeFeedFilter`, + `TradeFeedLabel`, `TRADE_FEED_FILTERS`, `TRADE_FEED_EVENTS`, `TRADE_FEED_MAX_TRADERS`; + `OidcScope` includes `trade_feed`. +- New module `cabalspy_id.trade_feed`; `cabalspy_id.api` exports the Trade Feed parts too. + ## [0.5.2] - 2026-10-15 The public thesis API (`https://id.cabalspy.xyz/thesis/v1/...`) and its live socket @@ -121,6 +149,7 @@ First release. `NotFoundError`, `RateLimitError`, `ServerError`, `APIConnectionError`, `InvalidResponseError`, `OAuthError`, `TokenVerificationError`, `WebhookVerificationError`. +[0.6.0]: https://github.com/CabalSpy/cabalspy-id-python/compare/v0.5.2...v0.6.0 [0.5.2]: https://github.com/CabalSpy/cabalspy-id-python/compare/v0.5.1...v0.5.2 [0.5.1]: https://github.com/CabalSpy/cabalspy-id-python/compare/v0.5.0...v0.5.1 [0.5.0]: https://github.com/CabalSpy/cabalspy-id-python/compare/v0.4.1...v0.5.0 diff --git a/README.md b/README.md index 5527911..1e4cbdf 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,8 @@ pip install "cabalspy-id[stream]" | | | |---|---| | `CabalSpyIDAuth` / `AsyncCabalSpyIDAuth` | OpenID Connect for your server: PKCE, code exchange, refresh, revoke, userinfo, ID token verification (ES256 / JWKS) | -| `CabalSpyID` / `AsyncCabalSpyID` | Data API client for `https://api-id.cabalspy.xyz/v1`, typed responses, pagination, live trade stream | +| `CabalSpyID` / `AsyncCabalSpyID` | Data API client for `https://api-id.cabalspy.xyz/v1`, typed responses, pagination, live trade stream, Trade Feed | +| `TradeFeedStream` | live Trade Feed events of your users (`trade_feed.trade`, `trade_feed.thesis`) | | `ThesisClient` / `AsyncThesisClient` | public token thesis API with your public `client_id` and a user's access token, plus `live()` updates | | `verify_webhook` | Standard Webhooks signature verification | @@ -34,6 +35,7 @@ Everything for developers — instance (client_id), client secrets, redirect URI - **Social trading feed** — every trade of your users live, with their X profile and PnL - **Leaderboards** — rank your users by PnL, volume, win rate or trades - **Copy trading** — follow verified wallets of real, identified traders +- **Trade Feed** — your users add traders to their Trade Feed; you notify them of every trade and thesis of those traders - **Token theses** — holders-only opinions on a token with the author's real position, as a SocialFi widget or your own UI - **Trader profiles** — X profile, verified wallets, holdings, PnL calendar - **KOL badges** — reviewed badges for influential traders, shown wherever they sign in @@ -45,9 +47,9 @@ One codebase, one version line. The focused packages re-export parts of `cabalsp | Package | npm | PyPI | What it contains | |---|---|---|---| | **cabalspy-id** | [`cabalspy-id`](https://www.npmjs.com/package/cabalspy-id) | [`cabalspy-id`](https://pypi.org/project/cabalspy-id/) | everything below | -| **socialfi-api** ([repo](https://github.com/CabalSpy/socialfi-api)) | [`socialfi-api`](https://www.npmjs.com/package/socialfi-api) | [`socialfi-api`](https://pypi.org/project/socialfi-api/) | the SocialFi API: Data API, Login with CabalSpy ID, trade stream, webhooks (= `cabalspy-id/api`, `cabalspy_id.api`) | +| **socialfi-api** ([repo](https://github.com/CabalSpy/socialfi-api)) | [`socialfi-api`](https://www.npmjs.com/package/socialfi-api) | [`socialfi-api`](https://pypi.org/project/socialfi-api/) | the SocialFi API: Data API, Login with CabalSpy ID, trade and Trade Feed streams, webhooks (= `cabalspy-id/api`, `cabalspy_id.api`) | | **socialfi-thesis** ([repo](https://github.com/CabalSpy/socialfi-thesis)) | [`socialfi-thesis`](https://www.npmjs.com/package/socialfi-thesis) | [`socialfi-thesis`](https://pypi.org/project/socialfi-thesis/) | token theses: Data API thesis methods, public thesis client, theses stream (= `cabalspy-id/thesis`, `cabalspy_id.thesis`) | -| **socialfi-widget** ([repo](https://github.com/CabalSpy/socialfi-widget)) | [`socialfi-widget`](https://www.npmjs.com/package/socialfi-widget) | – | the browser SocialFi widgets: login button and theses widget, vanilla and React (= `cabalspy-id/widget`) | +| **socialfi-widget** ([repo](https://github.com/CabalSpy/socialfi-widget)) | [`socialfi-widget`](https://www.npmjs.com/package/socialfi-widget) | – | the browser SocialFi widgets: login button, theses widget and Trade Feed widgets, vanilla and React (= `cabalspy-id/widget`) | ### Modules @@ -56,6 +58,7 @@ One codebase, one version line. The focused packages re-export parts of `cabalsp | `cabalspy_id` | everything | | `cabalspy_id.api` | `CabalSpyID`, `AsyncCabalSpyID`, `CabalSpyIDStream`, `CabalSpyIDAuth`, `verify_webhook`, errors and Data API types | | `cabalspy_id.thesis` | `CabalSpyID` (thesis methods), `ThesisClient`, `AsyncThesisClient`, `ThesisLive`, `ThesesStream`, thesis types | +| `cabalspy_id.trade_feed` | `CabalSpyID` (Trade Feed methods), `TradeFeedStream`, `verify_webhook`, `is_trade_feed_event` / `is_trade_feed_trade_event` / `is_trade_feed_thesis_event`, Trade Feed types | The objects are the same as in `cabalspy_id`. @@ -365,6 +368,100 @@ client_id errors use the SDK's exception classes, so check `err.code` (docs: `ht `ThesisLive` does not reconnect after 4401 or 4403 (`run_forever()` raises the error); after 4429 it waits 60 s before reconnecting. +## Trade Feed + +Your users add CabalSpy ID traders to their **Trade Feed** and your platform tells them about every trade and thesis of those traders. Only traders whose trading is public can be added (nobody can add themselves, at most 300 traders per person); a trader who switches to private disappears from every Trade Feed until they are public again. Verified wallet addresses are public, so trades carry the wallet and explorer links. + +Each trade is labelled by what it did to the trader's position in the token: `new` (first buy, or a buy after closing), `add`, `partial` (with `sold_percent`), `close`, or `sell` (entry unknown); sells with a known entry carry the realized `pnl_usd` / `pnl_percent`. Per trader each person filters buys / sells / theses, sets a minimum size per native coin (`min_sol`, `min_bnb`, `min_eth`) or mutes; new traders start with the person's defaults. + +Everything happens **in the name of one of your users**: they must sign in to an app of your instance with the scope **`trade_feed`** (otherwise `consent_required`, 403). + +**The platform flow:** + +1. The user signs in with `scope=["openid", "profile", "trade_feed"]`. +2. You add a trader to their Trade Feed (Data API, or the "+ Trade Feed" widget on your site). +3. The trader trades: you receive a `trade_feed.trade` webhook (or stream event) with `data["recipients"]`, the `csid_id`s of your users whose filters it passed. +4. You notify those users in your app. + +```python +from cabalspy_id import CabalSpyID, is_trade_feed_trade_event, verify_webhook + +# 1. sign-in with the scope trade_feed (CabalSpyIDAuth, see "Server-side login") +req = auth.create_authorization_url(scope=["openid", "profile", "trade_feed"]) +# ... in the callback: result = auth.handle_callback(...); result.claims["sub"] is the user's csid_id + +# 2. add a trader (handle with or without @, or csid_id); settings are optional +csid = CabalSpyID() +added = csid.add_to_trade_feed(result.claims["sub"], "@alice", notify_theses=False, min_sol=0.5) +print(added["created"], added["trader"]["username"], added["settings"]) + +# 3. + 4. the webhook names your users to notify +event = verify_webhook(request.body, request.headers, os.environ["CSID_WEBHOOK_SECRET"]) +if is_trade_feed_trade_event(event): + trader, trade = event["data"]["trader"], event["data"]["trade"] + verb = "bought" if trade["side"] == "buy" else "sold" + notify_users(event["data"]["recipients"], f"@{trader['username']} {verb} ${trade['symbol']} ({trade['label']})") +``` + +### Data API + +| Method | Endpoint | +|---|---| +| `trade_feed(csid_id, filter=, limit=, cursor=)` / `iter_trade_feed()` | `GET /v1/trade-feed` (`filter`: `all`, `buys`, `sells`, `theses`) | +| `trade_feed_traders(csid_id, limit=, cursor=)` / `iter_trade_feed_traders()` | `GET /v1/trade-feed/traders` (`get_raw(...).pagination` has `total`, `max`) | +| `add_to_trade_feed(csid_id, trader, notify_buys=, notify_sells=, notify_theses=, min_sol=, min_bnb=, min_eth=, muted=)` | `POST /v1/trade-feed/traders` (`created`: True = 201 new, False = 200 updated) | +| `remove_from_trade_feed(csid_id, trader)` | `POST /v1/trade-feed/traders/delete` (`removed`: False when not there) | +| `trade_feed_notifications(csid_id, limit=, cursor=)` / `iter_trade_feed_notifications()` | `GET /v1/trade-feed/notifications` (for platforms without webhooks) | + +`AsyncCabalSpyID` has the same methods as coroutines (and async iterators). + +```python +for item in csid.iter_trade_feed(user_id, filter="buys"): + if item["type"] == "trade": + print(item["trader"]["username"], item["trade"]["label"], item["trade"]["amount_usd"], item["trade"]["wallet_url"]) + else: + print(item["trader"]["username"], item["thesis"]["stance"], item["thesis"]["body"]) +``` + +Every feed item, notification and event has the same shape (`TradeFeedItem`): `id`, `type` (`trade` | `thesis`), `timestamp`, `trader`, `trade` or `thesis`, `links` (`token`, `terminal`, `profile`); feed pages add `cursor`, events add `recipients`. + +Errors: `trader_not_found` (404, `NotFoundError`), `cannot_add_self` (400, `BadRequestError`), `trading_private` and `consent_required` (403, `PermissionError`), `trade_feed_full` (409, `CabalSpyIDError`), `rate_limited` (429, `RateLimitError`), `invalid_cursor` (400). + +### Trade Feed stream (every plan) + +The same events as the webhooks, live: one event per trade or thesis that concerns your users, with `data["recipients"]`. At most 5 connections per API key. Async, needs the `stream` extra. + +```python +async with AsyncCabalSpyID() as csid: + tf = csid.trade_feed_stream() + + @tf.on("trade_feed.trade") + async def on_trade(data): + await notify_users(data["recipients"], data) + + await tf.connect() # returns on "ready" + await tf.run_forever() + + # or iterate {"event": ..., "id": ..., "data": ...} + async with csid.trade_feed_stream() as tf: + async for ev in tf: + print(ev["event"], ev["data"]["recipients"]) +``` + +Events: `ready`, `trade_feed.trade`, `trade_feed.thesis`, `heartbeat`, `pong`, `open`, `close`, `reconnecting`, `error`. It reconnects with backoff after a dropped connection or a missed heartbeat; `invalid_api_key` is fatal, `too_many_connections` is retried. + +### Widgets + +The "+ Trade Feed" button and the embedded feed run in the browser (`widget.js`, your public `client_id` required): + +```html +
+
+ +``` + +They share their sign-in with the theses widget (`CSID.tradeFeed` is the same object as `CSID.thesis`: `setAccessToken` with an access token that has the scope `trade_feed`, `setSignInHandler`). For React and typed helpers see the [TypeScript SDK](https://www.npmjs.com/package/cabalspy-id). + ## Live stream Every trade of your users the moment it is indexed, with the trader's profile (Pro plan and up). Async only, needs `pip install "cabalspy-id[stream]"`. @@ -391,16 +488,18 @@ The server sends a heartbeat every 30 seconds. The client reconnects with expone ## Webhooks -Deliveries follow the [Standard Webhooks](https://www.standardwebhooks.com) spec (`webhook-id`, `webhook-timestamp`, `webhook-signature`, secret `whsec_...`) and are retried for about 2 days. Events: `wallet.verified`, `wallet.removed`, `profile.updated`, `kol.granted`, `consent.revoked`, `thesis.created`, `thesis.reply`, `thesis.reaction` — only for users within the plan's limit. +Deliveries follow the [Standard Webhooks](https://www.standardwebhooks.com) spec (`webhook-id`, `webhook-timestamp`, `webhook-signature`, secret `whsec_...`) and are retried for about 2 days. Events: `wallet.verified`, `wallet.removed`, `profile.updated`, `kol.granted`, `consent.revoked`, `thesis.created`, `thesis.reply`, `thesis.reaction`, `trade_feed.trade`, `trade_feed.thesis` — only for users within the plan's limit. The Trade Feed events carry no `sub`; `data["recipients"]` lists your users to tell (see [Trade Feed](#trade-feed)). ```python -from cabalspy_id import verify_webhook +from cabalspy_id import is_trade_feed_event, verify_webhook # pass the RAW body, exactly as received event = verify_webhook(request.body, request.headers, os.environ["CSID_WEBHOOK_SECRET"]) if event["type"] == "consent.revoked": delete_user_data(event["data"]["sub"]) +elif is_trade_feed_event(event): # trade_feed.trade / trade_feed.thesis + notify_users(event["data"]["recipients"], event["data"]) ``` Timestamps older than 5 minutes are rejected (`tolerance=`). During a secret roll several signatures are sent; any match is accepted. Dedupe on `event["id"]`, it is the same on every retry. `sign_webhook()` builds test requests for your receiver. diff --git a/cabalspy_id/__init__.py b/cabalspy_id/__init__.py index 66cabfb..53696d9 100644 --- a/cabalspy_id/__init__.py +++ b/cabalspy_id/__init__.py @@ -1,8 +1,8 @@ """CabalSpy ID — the verified social layer for crypto trading. Login with CabalSpy ID (OpenID Connect), the Data API for verified wallets, KOL -badges, trading stats and token theses, the live stream, the public thesis API -and webhook verification. +badges, trading stats, token theses and the Trade Feed, the live streams, the +public thesis API and webhook verification. from cabalspy_id import CabalSpyID, CabalSpyIDAuth, verify_webhook @@ -25,6 +25,7 @@ DEFAULT_THESES_STREAM_URL, DEFAULT_THESIS_URL, DEFAULT_THESIS_WS_URL, + DEFAULT_TRADE_FEED_STREAM_URL, LEADERBOARD_SORTS, MAX_THESES_STREAM_TOKENS, PERIODS, @@ -35,6 +36,9 @@ THESIS_MAX_BODY, THESIS_REPORT_REASONS, THESIS_SCOPES, + TRADE_FEED_EVENTS, + TRADE_FEED_FILTERS, + TRADE_FEED_MAX_TRADERS, WEBHOOK_EVENTS, Chain, Currency, @@ -48,6 +52,9 @@ ThesisScope, ThesisSort, ThesisStance, + TradeFeedEventType, + TradeFeedFilter, + TradeFeedLabel, ) from ._errors import ( APIConnectionError, @@ -115,6 +122,25 @@ TrackerResponse, TrackerTrade, Trade, + TradeFeedAddResult, + TradeFeedEntry, + TradeFeedEvent, + TradeFeedEventData, + TradeFeedItem, + TradeFeedLinks, + TradeFeedMinNative, + TradeFeedNotification, + TradeFeedRemoveResult, + TradeFeedSettings, + TradeFeedThesis, + TradeFeedThesisData, + TradeFeedThesisEvent, + TradeFeedThesisItem, + TradeFeedTrade, + TradeFeedTradeData, + TradeFeedTradeEvent, + TradeFeedTradeItem, + TradeFeedTrader, TradeOwner, TraderProfile, VerifiedWallet, @@ -126,7 +152,14 @@ XProfileClaim, ) from ._users import HandleKind, handle_kind, has_x -from ._webhooks import HeaderSource, sign_webhook, verify_webhook +from ._webhooks import ( + HeaderSource, + is_trade_feed_event, + is_trade_feed_thesis_event, + is_trade_feed_trade_event, + sign_webhook, + verify_webhook, +) __version__ = SDK_VERSION @@ -141,6 +174,10 @@ def __getattr__(name: str) -> object: from . import _theses_stream return getattr(_theses_stream, name) + if name in ("TradeFeedStream", "TRADE_FEED_STREAM_EVENTS"): + from . import _trade_feed_stream + + return getattr(_trade_feed_stream, name) raise AttributeError(f"module 'cabalspy_id' has no attribute {name!r}") @@ -151,6 +188,7 @@ def __getattr__(name: str) -> object: "AsyncCabalSpyID", "CabalSpyIDStream", "ThesesStream", + "TradeFeedStream", "Envelope", "CabalSpyIDAuth", "AsyncCabalSpyIDAuth", @@ -161,6 +199,9 @@ def __getattr__(name: str) -> object: "is_limited", "verify_webhook", "sign_webhook", + "is_trade_feed_event", + "is_trade_feed_trade_event", + "is_trade_feed_thesis_event", "HeaderSource", "has_x", "handle_kind", @@ -200,6 +241,14 @@ def __getattr__(name: str) -> object: "THESIS_SCOPES", "MAX_THESES_STREAM_TOKENS", "DEFAULT_THESES_STREAM_URL", + "DEFAULT_TRADE_FEED_STREAM_URL", + "TRADE_FEED_EVENTS", + "TRADE_FEED_FILTERS", + "TRADE_FEED_MAX_TRADERS", + "TRADE_FEED_STREAM_EVENTS", + "TradeFeedFilter", + "TradeFeedLabel", + "TradeFeedEventType", "ThesisScope", "THESIS_EMOJIS", "THESIS_REPORT_REASONS", @@ -270,4 +319,23 @@ def __getattr__(name: str) -> object: "ThesisNotices", "ThesisReactor", "ThesisReactions", + "TradeFeedTrader", + "TradeFeedTrade", + "TradeFeedThesis", + "TradeFeedLinks", + "TradeFeedTradeItem", + "TradeFeedThesisItem", + "TradeFeedItem", + "TradeFeedMinNative", + "TradeFeedSettings", + "TradeFeedEntry", + "TradeFeedAddResult", + "TradeFeedRemoveResult", + "TradeFeedNotification", + "TradeFeedTradeData", + "TradeFeedThesisData", + "TradeFeedEventData", + "TradeFeedTradeEvent", + "TradeFeedThesisEvent", + "TradeFeedEvent", ] diff --git a/cabalspy_id/_client.py b/cabalspy_id/_client.py index 13bc31e..8efc460 100644 --- a/cabalspy_id/_client.py +++ b/cabalspy_id/_client.py @@ -25,12 +25,14 @@ DEFAULT_BASE_URL, DEFAULT_STREAM_URL, DEFAULT_THESES_STREAM_URL, + DEFAULT_TRADE_FEED_STREAM_URL, LEADERBOARD_SORTS, PERIODS, SCOPES, THESIS_EMOJIS, THESIS_REPORT_REASONS, THESIS_SCOPES, + TRADE_FEED_FILTERS, USER_AGENT, Chain, LeaderboardSort, @@ -42,6 +44,7 @@ ThesisScope, ThesisSort, ThesisStance, + TradeFeedFilter, ) from ._errors import ( APIConnectionError, @@ -58,6 +61,7 @@ if TYPE_CHECKING: from ._stream import CabalSpyIDStream from ._theses_stream import ThesesStream + from ._trade_feed_stream import TradeFeedStream __all__ = ["CabalSpyID", "AsyncCabalSpyID", "Envelope"] @@ -163,6 +167,19 @@ def _theses_query( } +def _trade_feed_query( + csid_id: str, filter: TradeFeedFilter | None, limit: int | None, cursor: str | None +) -> dict[str, Any]: + _required(csid_id, "csid_id") + _one_of(filter, TRADE_FEED_FILTERS, "filter") + return {"csid_id": csid_id, "filter": filter, "limit": limit, "cursor": cursor} + + +def _trade_feed_page(csid_id: str, limit: int | None, cursor: str | None) -> dict[str, Any]: + _required(csid_id, "csid_id") + return {"csid_id": csid_id, "limit": limit, "cursor": cursor} + + def _post_retryable(exc: CabalSpyIDError) -> bool: """Writes are retried only when the API's rate limit refused them before they ran.""" return isinstance(exc, RateLimitError) and exc.code == "rate_limit_exceeded" @@ -537,6 +554,91 @@ def delete_thesis(self, *, thesis_id: str, csid_id: str) -> Any: _required(csid_id, "csid_id") return self._post("/theses/delete", {"thesis_id": thesis_id, "csid_id": csid_id}) + # ── Trade Feed ─────────────────────────────────────────────────────── + # In the name of one of your users (csid_id, the "sub" of their ID token): + # they must have signed in to an app of your instance with the scope + # "trade_feed", else PermissionError "consent_required". + + def trade_feed( + self, + csid_id: str, + *, + filter: TradeFeedFilter | None = None, + limit: int | None = None, + cursor: str | None = None, + ) -> Any: + """GET /v1/trade-feed — the user's Trade Feed: trades (labelled new / add / + partial / close / sell, with the realized PnL of sells) and theses of the + traders in it, newest first. + + ``filter``: all (default), buys, sells, theses; ``limit`` 1-100 (default 30). + Returns a list of :data:`~cabalspy_id.TradeFeedItem`. + """ + return self._get("/trade-feed", _trade_feed_query(csid_id, filter, limit, cursor)) + + def trade_feed_traders(self, csid_id: str, *, limit: int | None = None, cursor: str | None = None) -> Any: + """GET /v1/trade-feed/traders — the traders in the user's Trade Feed with their + settings, newest first. ``limit`` 1-300 (default 100); the envelope's + pagination (``get_raw``) carries ``total`` and ``max``. + + Returns a list of :class:`~cabalspy_id.TradeFeedEntry`. + """ + return self._get("/trade-feed/traders", _trade_feed_page(csid_id, limit, cursor)) + + def add_to_trade_feed( + self, + csid_id: str, + trader: str, + *, + notify_buys: bool | None = None, + notify_sells: bool | None = None, + notify_theses: bool | None = None, + min_sol: float | None = None, + min_bnb: float | None = None, + min_eth: float | None = None, + muted: bool | None = None, + ) -> Any: + """POST /v1/trade-feed/traders — adds a trader (handle with or without @, or + csid_id) to the user's Trade Feed; for a trader already there, updates the + given settings. ``created`` tells which (HTTP 201 / 200). Omitted settings + keep their value; a new trader starts with the person's defaults. + + Errors: trader_not_found (NotFoundError), cannot_add_self (BadRequestError), + trading_private and consent_required (PermissionError), trade_feed_full + (409, CabalSpyIDError), rate_limited (RateLimitError). + Returns :class:`~cabalspy_id.TradeFeedAddResult`. + """ + _required(csid_id, "csid_id") + _required(trader, "trader") + settings = { + "notify_buys": notify_buys, + "notify_sells": notify_sells, + "notify_theses": notify_theses, + "min_sol": min_sol, + "min_bnb": min_bnb, + "min_eth": min_eth, + "muted": muted, + } + body = {k: v for k, v in settings.items() if v is not None} + return self._post("/trade-feed/traders", {**body, "csid_id": csid_id, "trader": trader}) + + def remove_from_trade_feed(self, csid_id: str, trader: str) -> Any: + """POST /v1/trade-feed/traders/delete — removes a trader; ``removed`` is False + when they were not there. Returns :class:`~cabalspy_id.TradeFeedRemoveResult`. + """ + _required(csid_id, "csid_id") + _required(trader, "trader") + return self._post("/trade-feed/traders/delete", {"csid_id": csid_id, "trader": trader}) + + def trade_feed_notifications(self, csid_id: str, *, limit: int | None = None, cursor: str | None = None) -> Any: + """GET /v1/trade-feed/notifications — the user's recent Trade Feed events (the + ones that passed their filters), newest first; for platforms without webhooks. + ``limit`` 1-100 (default 50). + + Returns a list of :class:`~cabalspy_id.TradeFeedNotification`. + """ + return self._get("/trade-feed/notifications", _trade_feed_page(csid_id, limit, cursor)) + # ═══════════════════════════════════════════════════════════════════════════ # CLIENTS @@ -552,6 +654,7 @@ def __init__( base_url: str | None = None, stream_url: str | None = None, theses_stream_url: str | None = None, + trade_feed_stream_url: str | None = None, timeout: float = 30.0, max_retries: int = 2, headers: Mapping[str, str] | None = None, @@ -564,6 +667,7 @@ def __init__( self.base_url = (base_url or _env("CABALSPY_ID_BASE_URL") or DEFAULT_BASE_URL).rstrip("/") self.stream_url = (stream_url or _env("CABALSPY_ID_STREAM_URL") or DEFAULT_STREAM_URL).rstrip("/") self.theses_stream_url = (theses_stream_url or DEFAULT_THESES_STREAM_URL).rstrip("/") + self.trade_feed_stream_url = (trade_feed_stream_url or DEFAULT_TRADE_FEED_STREAM_URL).rstrip("/") self.timeout = timeout self.max_retries = max_retries self._extra_headers = dict(headers or {}) @@ -677,6 +781,18 @@ def theses_stream(self, *, scope: ThesisScope | None = None, **kwargs: Any) -> T return ThesesStream(self.theses_stream_url, self._api_key, scope=scope, instance=self.instance, **kwargs) + def trade_feed_stream(self, **kwargs: Any) -> TradeFeedStream: + """Live Trade Feed events of your users (every plan): ``trade_feed.trade`` and + ``trade_feed.thesis`` with ``data["recipients"]``, like the webhooks. Async only. + + Needs the ``stream`` extra: ``pip install "cabalspy-id[stream]"``. + """ + if not self._api_key: + raise AuthenticationError("The Trade Feed stream needs an API key.", status=401, code="missing_api_key") + from ._trade_feed_stream import TradeFeedStream + + return TradeFeedStream(self.trade_feed_stream_url, self._api_key, instance=self.instance, **kwargs) + class CabalSpyID(_BaseClient): """Synchronous Data API client. @@ -812,6 +928,23 @@ def iter_thesis_replies( for page in self.pages("/theses/replies", {"id": id, "limit": limit, "csid_id": csid_id}): yield from page.data + def iter_trade_feed( + self, csid_id: str, *, filter: TradeFeedFilter | None = None, limit: int | None = None + ) -> Iterator[dict[str, Any]]: + """The whole Trade Feed of a user, newest first, following ``next_cursor``.""" + for page in self.pages("/trade-feed", _trade_feed_query(csid_id, filter, limit, None)): + yield from page.data + + def iter_trade_feed_traders(self, csid_id: str, *, limit: int | None = None) -> Iterator[dict[str, Any]]: + """Every trader in the user's Trade Feed.""" + for page in self.pages("/trade-feed/traders", _trade_feed_page(csid_id, limit, None)): + yield from page.data + + def iter_trade_feed_notifications(self, csid_id: str, *, limit: int | None = None) -> Iterator[dict[str, Any]]: + """The user's Trade Feed notifications, newest first.""" + for page in self.pages("/trade-feed/notifications", _trade_feed_page(csid_id, limit, None)): + yield from page.data + class AsyncCabalSpyID(_BaseClient): """Asynchronous Data API client. @@ -949,6 +1082,28 @@ async def iter_thesis_replies( for item in page.data: yield item + async def iter_trade_feed( + self, csid_id: str, *, filter: TradeFeedFilter | None = None, limit: int | None = None + ) -> AsyncIterator[dict[str, Any]]: + """The whole Trade Feed of a user, newest first, following ``next_cursor``.""" + async for page in self.pages("/trade-feed", _trade_feed_query(csid_id, filter, limit, None)): + for item in page.data: + yield item + + async def iter_trade_feed_traders(self, csid_id: str, *, limit: int | None = None) -> AsyncIterator[dict[str, Any]]: + """Every trader in the user's Trade Feed.""" + async for page in self.pages("/trade-feed/traders", _trade_feed_page(csid_id, limit, None)): + for item in page.data: + yield item + + async def iter_trade_feed_notifications( + self, csid_id: str, *, limit: int | None = None + ) -> AsyncIterator[dict[str, Any]]: + """The user's Trade Feed notifications, newest first.""" + async for page in self.pages("/trade-feed/notifications", _trade_feed_page(csid_id, limit, None)): + for item in page.data: + yield item + def _users_query(params: Mapping[str, Any]) -> dict[str, Any]: allowed = {"limit", "kol", "cursor", "scope"} diff --git a/cabalspy_id/_constants.py b/cabalspy_id/_constants.py index 218c144..0696eb0 100644 --- a/cabalspy_id/_constants.py +++ b/cabalspy_id/_constants.py @@ -4,7 +4,7 @@ from typing import Literal -SDK_VERSION = "0.5.2" +SDK_VERSION = "0.6.0" USER_AGENT = f"cabalspy-id-python/{SDK_VERSION}" DEFAULT_BASE_URL = "https://api-id.cabalspy.xyz/v1" @@ -17,7 +17,8 @@ Currency = Literal["SOL", "BNB", "ETH"] Scope = Literal["instance", "global"] #: ``thesis``: post theses and replies, react and report in the user's name. -OidcScope = Literal["openid", "profile", "wallets", "onchain", "thesis", "offline_access"] +#: ``trade_feed``: manage the user's Trade Feed; they receive ``trade_feed.*`` events. +OidcScope = Literal["openid", "profile", "wallets", "onchain", "thesis", "trade_feed", "offline_access"] ThesisStance = Literal["bull", "bear"] ThesisSort = Literal["new", "top"] ThesisEmoji = Literal["like", "🔥", "🚀", "💎", "👀", "😂", "🤝", "💀", "🐂", "🐻"] @@ -50,6 +51,8 @@ "thesis.created", "thesis.reply", "thesis.reaction", + "trade_feed.trade", + "trade_feed.thesis", "webhook.test", ) @@ -68,3 +71,16 @@ DEFAULT_THESIS_WS_URL = "wss://id.cabalspy.xyz/thesis/v1/ws" #: Header that carries the instance's public client_id on every public thesis API call. CLIENT_ID_HEADER = "X-CSID-Client-Id" + +# ── Trade Feed ─────────────────────────────────────────────────────────────── + +TradeFeedFilter = Literal["all", "buys", "sells", "theses"] +#: new (first buy / buy after closing), add, partial (sold_percent), close, sell (entry unknown) +TradeFeedLabel = Literal["new", "add", "partial", "close", "sell"] +TradeFeedEventType = Literal["trade_feed.trade", "trade_feed.thesis"] +TRADE_FEED_FILTERS: tuple[TradeFeedFilter, ...] = ("all", "buys", "sells", "theses") +#: Webhook / stream event types of the Trade Feed. +TRADE_FEED_EVENTS: tuple[TradeFeedEventType, ...] = ("trade_feed.trade", "trade_feed.thesis") +#: At most this many traders in one person's Trade Feed. +TRADE_FEED_MAX_TRADERS = 300 +DEFAULT_TRADE_FEED_STREAM_URL = "wss://api-id.cabalspy.xyz/v1/trade-feed/stream" diff --git a/cabalspy_id/_trade_feed_stream.py b/cabalspy_id/_trade_feed_stream.py new file mode 100644 index 0000000..c015eb8 --- /dev/null +++ b/cabalspy_id/_trade_feed_stream.py @@ -0,0 +1,369 @@ +"""Trade Feed stream: wss://api-id.cabalspy.xyz/v1/trade-feed/stream (every plan). + +Every Trade Feed event that concerns your users (those who allowed the scope +``trade_feed``): one ``trade_feed.trade`` or ``trade_feed.thesis`` per trade or +thesis of a trader in their Trade Feeds, with ``data["recipients"]`` = your +users whose filters it passed. The same events as the webhooks. At most 5 +connections per API key. + + async with AsyncCabalSpyID() as csid: + tf = csid.trade_feed_stream() + + @tf.on("trade_feed.trade") + async def _(data): + await notify(data["recipients"], data) + + await tf.connect() # returns on "ready" + await tf.run_forever() + + # or iterate ({"event": ..., "id": ..., "data": ...}): + async with csid.trade_feed_stream() as tf: + async for ev in tf: + ... +""" + +from __future__ import annotations + +import asyncio +import contextlib +import inspect +import json +import random +from collections.abc import AsyncIterator, Callable +from typing import Any, TypeVar, overload +from urllib.parse import urlencode + +from ._constants import TRADE_FEED_EVENTS +from ._errors import APIConnectionError, CabalSpyIDError, PermissionError_, RateLimitError + +try: + import websockets +except ImportError: # pragma: no cover + websockets = None # type: ignore[assignment] + +Handler = Callable[[Any], Any] +H = TypeVar("H", bound=Callable[[Any], Any]) + +#: Events: ready, trade_feed.trade, trade_feed.thesis, heartbeat, pong, open, close, reconnecting, error. +TRADE_FEED_STREAM_EVENTS = ( + "ready", + *TRADE_FEED_EVENTS, + "heartbeat", + "pong", + "open", + "close", + "reconnecting", + "error", +) + + +def _refused(code: str, message: str) -> CabalSpyIDError: + if code == "too_many_connections": + return RateLimitError(message, code=code, status=429) + return PermissionError_(message, code=code, status=403) + + +class TradeFeedStream: + """Async client for the Trade Feed stream. Obtain one via ``client.trade_feed_stream()``. + + Handlers of ``trade_feed.trade`` / ``trade_feed.thesis`` get the event's + ``data`` (:class:`~cabalspy_id.TradeFeedTradeData` / + :class:`~cabalspy_id.TradeFeedThesisData`); ``data["id"]`` is stable per + trade or thesis, so dedupe on it. + """ + + def __init__( + self, + url: str, + api_key: str, + *, + instance: str | None = None, + reconnect: bool = True, + reconnect_delay: float = 1.0, + max_reconnect_delay: float = 30.0, + heartbeat_timeout: float | None = 75.0, + open_timeout: float = 20.0, + ) -> None: + if websockets is None: # pragma: no cover + raise ImportError('The Trade Feed stream needs the websockets package: pip install "cabalspy-id[stream]"') + extra = {"instance": instance} if instance else {} + self._url = f"{url}?{urlencode({'api_key': api_key, **extra})}" + #: The URL with the key masked, safe to log. + self.safe_url = f"{url}?{urlencode({'api_key': '***', **extra}, safe='*')}" + self.reconnect = reconnect + self.reconnect_delay = reconnect_delay + self.max_reconnect_delay = max_reconnect_delay + self.heartbeat_timeout = heartbeat_timeout + self.open_timeout = open_timeout + + self._ws: Any = None + self._ready = False + self._handlers: dict[str, list[Handler]] = {} + self._attempt = 0 + self._closed = False + self._fatal: CabalSpyIDError | None = None + self._reader: asyncio.Task[None] | None = None + self._ready_future: asyncio.Future[dict[str, Any]] | None = None + self._done = asyncio.Event() + self._backlog: list[Any] | None = None + self._keepers: list[tuple[str, Handler]] = [] + self._last_ids: dict[int, str] = {} + + # ── handlers ───────────────────────────────────────────────────────── + + @overload + def on(self, event: str) -> Callable[[H], H]: ... + + @overload + def on(self, event: str, handler: H) -> H: ... + + def on(self, event: str, handler: Handler | None = None) -> Any: + """Registers a handler (sync or async). Usable directly or as a decorator.""" + if event not in TRADE_FEED_STREAM_EVENTS: + raise ValueError(f"unknown event {event!r}; one of {', '.join(TRADE_FEED_STREAM_EVENTS)}") + + def register(fn: Handler) -> Handler: + self._handlers.setdefault(event, []).append(fn) + return fn + + return register if handler is None else register(handler) + + def off(self, event: str, handler: Handler) -> None: + if handler in self._handlers.get(event, []): + self._handlers[event].remove(handler) + + async def _emit(self, event: str, payload: Any) -> None: + for handler in list(self._handlers.get(event, [])): + try: + result = handler(payload) + if inspect.isawaitable(result): + await result + except Exception as exc: + if event != "error": + await self._emit("error", exc) + + # ── connection ─────────────────────────────────────────────────────── + + @property + def connected(self) -> bool: + return self._ws is not None and self._ready + + async def connect(self) -> dict[str, Any]: + """Opens the socket. Returns the ``ready`` data (``{"events": [...]}``).""" + self._closed = False + self._fatal = None + self._ready = False + self._done.clear() + self._ready_future = asyncio.get_running_loop().create_future() + try: + ws = await websockets.connect(self._url, open_timeout=self.open_timeout) + except Exception as exc: + raise APIConnectionError(f"Could not connect to {self.safe_url}: {exc}", code="ws_error") from exc + self._ws = ws + await self._emit("open", None) + self._reader = asyncio.create_task(self._read(ws, self._ready_future)) + return await self._ready_future + + async def _read(self, ws: Any, ready: asyncio.Future[dict[str, Any]]) -> None: + info: dict[str, Any] = {} + try: + while True: + try: + if self.heartbeat_timeout: + raw = await asyncio.wait_for(ws.recv(), timeout=self.heartbeat_timeout) + else: + raw = await ws.recv() + except asyncio.TimeoutError: + await self._emit( + "error", + APIConnectionError( + "No heartbeat from the Trade Feed stream; reconnecting", code="heartbeat_timeout" + ), + ) + with contextlib.suppress(Exception): + await ws.close(code=4000, reason="heartbeat timeout") + break + try: + msg = json.loads(raw) + except (ValueError, TypeError): + continue + if isinstance(msg, dict): + await self._dispatch(msg, ready) + except websockets.ConnectionClosed as exc: + rcvd = getattr(exc, "rcvd", None) + info = {"code": getattr(rcvd, "code", None), "reason": getattr(rcvd, "reason", "")} + except asyncio.CancelledError: + raise + except Exception as exc: + await self._emit("error", exc) + finally: + if self._ws is ws: + self._ws = None + self._ready = False + await self._emit("close", info) + if not ready.done(): + ready.set_exception( + self._fatal + or APIConnectionError( + f"Trade Feed stream closed before it was ready (code {info.get('code')})", code="ws_closed_early" + ) + ) + if not self._closed and not self._fatal and self.reconnect: + await self._reconnect() + else: + self._done.set() + + async def _dispatch(self, msg: dict[str, Any], ready: asyncio.Future[dict[str, Any]]) -> None: + event = msg.get("event") + if event == "ready": + self._ready = True + self._attempt = 0 + data = msg.get("data") or {"events": list(TRADE_FEED_EVENTS)} + await self._emit("ready", data) + if not ready.done(): + ready.set_result(data) + elif event in TRADE_FEED_EVENTS: + data = msg.get("data") + if isinstance(data, dict) and msg.get("id"): + self._last_ids[id(data)] = str(msg["id"]) + try: + await self._emit(str(event), data) + finally: + self._last_ids.pop(id(data), None) + elif event == "heartbeat": + await self._emit("heartbeat", {"ts": msg.get("ts")}) + elif event == "pong": + await self._emit("pong", {}) + elif event == "error": + # Refused (the server closes the socket): a bad key is final, too_many_connections is retried. + err = msg.get("error") or {} + code, message = str(err.get("code") or "stream_error"), str(err.get("message") or "Stream error") + exc = _refused(code, message) + if code != "too_many_connections": + self._fatal = exc + await self._emit("error", exc) + if not ready.done(): + ready.set_exception(exc) + + async def _reconnect(self) -> None: + while not self._closed: + delay = min(self.reconnect_delay * (2**self._attempt), self.max_reconnect_delay) + random.random() * 0.25 + self._attempt += 1 + await self._emit("reconnecting", {"attempt": self._attempt, "delay": delay}) + await asyncio.sleep(delay) + if self._closed: + break + try: + await self.connect() + return + except CabalSpyIDError as exc: + await self._emit("error", exc) + if self._fatal: + break + if self._ws is not None or (self._reader and not self._reader.done()): + return + self._done.set() + + async def ping(self) -> None: + """Sends a ping (answered with ``pong``).""" + if self._ws is not None: + await self._ws.send("ping") + + async def run_forever(self) -> None: + """Blocks until :meth:`close` is called or a fatal error stops the stream (raised).""" + await self._done.wait() + if self._fatal: + raise self._fatal + + async def close(self) -> None: + """Closes the socket and stops reconnecting.""" + self._closed = True + ws, self._ws = self._ws, None + self._ready = False + if ws is not None: + with contextlib.suppress(Exception): + await ws.close() + task = self._reader + if task and not task.done() and task is not asyncio.current_task(): + try: + await asyncio.wait_for(asyncio.shield(task), timeout=2) + except (asyncio.TimeoutError, Exception): + task.cancel() + self._done.set() + + async def __aenter__(self) -> TradeFeedStream: + # Events that arrive between connecting and the first loop are kept for it. + self._backlog = [] + backlog = self._backlog + keepers: list[tuple[str, Handler]] = [ + (name, self._listener(backlog.append, name)) for name in TRADE_FEED_EVENTS + ] + for name, fn in keepers: + self.on(name, fn) + self._keepers = keepers + try: + await self.connect() + except BaseException: + self._drop_backlog() + raise + return self + + async def __aexit__(self, *exc: Any) -> None: + await self.close() + + def _listener(self, put: Callable[[Any], Any], name: str) -> Handler: + def fn(data: Any) -> None: + put({"event": name, "id": self._last_ids.get(id(data), ""), "data": data}) + + return fn + + def _drop_backlog(self) -> list[Any]: + backlog, self._backlog = self._backlog or [], None + for name, fn in self._keepers: + self.off(name, fn) + self._keepers = [] + return backlog + + async def __aiter__(self) -> AsyncIterator[dict[str, Any]]: + """Iterates over Trade Feed events as ``{"event", "id", "data"}``; connects if needed. + + Ends when :meth:`close` is called, or raises the fatal error. ``break`` + does not close the socket by itself: use ``async with`` or call :meth:`close`. + """ + queue: asyncio.Queue[Any] = asyncio.Queue() + stop = object() + for item in self._drop_backlog(): + queue.put_nowait(item) + + listeners = [(name, self._listener(queue.put_nowait, name)) for name in TRADE_FEED_EVENTS] + + def on_error(_exc: Any) -> None: + if self._fatal: + queue.put_nowait(stop) + + def on_close(_info: Any) -> None: + if self._closed or not self.reconnect: + queue.put_nowait(stop) + + for name, fn in listeners: + self.on(name, fn) + self.on("error", on_error) + self.on("close", on_close) + owns = self._ws is None + try: + if owns: + await self.connect() + while True: + item = await queue.get() + if item is stop: + if self._fatal: + raise self._fatal + return + yield item + finally: + for name, fn in listeners: + self.off(name, fn) + self.off("error", on_error) + self.off("close", on_close) + if owns and not self._closed: + await self.close() diff --git a/cabalspy_id/_types.py b/cabalspy_id/_types.py index 3223135..ed92db4 100644 --- a/cabalspy_id/_types.py +++ b/cabalspy_id/_types.py @@ -13,7 +13,7 @@ from typing import Any, Literal, TypedDict -from ._constants import Chain, Currency, LeaderboardSort, Period, Scope +from ._constants import Chain, Currency, LeaderboardSort, Period, Scope, TradeFeedEventType, TradeFeedLabel # ── envelope ───────────────────────────────────────────────────────────────── @@ -564,7 +564,8 @@ class WebhookEvent(TypedDict): type: str created_at: str sandbox: bool - #: Always carries "sub" (the user's CabalSpy ID), except for webhook.test. + #: Carries "sub" (the user's CabalSpy ID), except for webhook.test and the + #: Trade Feed events (``trade_feed.*`` list the users to tell in ``data["recipients"]``). data: dict[str, Any] @@ -708,3 +709,200 @@ class ThesisNotices(TypedDict): class ThesisReactions(TypedDict): id: str reactions: dict[str, int] + + +# ── Trade Feed ─────────────────────────────────────────────────────────────── + + +class TradeFeedTrader(TypedDict): + """A trader as everyone may see them.""" + + csid_id: str + #: X handle; for people without X ``tg.`` or ``id.``. + username: str + name: str | None + image_url: str | None + #: Has the KOL badge. + kol: bool + profile_url: str | None + + +class TradeFeedTrade(TypedDict): + """A trade of a trader in a Trade Feed.""" + + blockchain: Chain + mint: str + symbol: str | None + name: str | None + logo: str | None + side: Literal["buy", "sell"] + #: new | add | partial | close | sell + label: TradeFeedLabel + #: ``partial`` sells: the share of the position sold, 0-100. + sold_percent: float | None + #: Size in the chain's native coin (``currency``). + amount_native: float + currency: Currency | None + amount_usd: float | None + token_amount: float | None + price_usd: float | None + #: Sells with a known entry: realized PnL of this sell. + pnl_usd: float | None + pnl_percent: float | None + #: The trader's verified wallet (verified wallets are public). + wallet: str + wallet_url: str | None + tx_signature: str + tx_url: str | None + + +class TradeFeedThesis(TypedDict): + """A thesis of a trader in a Trade Feed.""" + + id: str + blockchain: Chain + mint: str + symbol: str | None + name: str | None + logo: str | None + stance: Literal["bull", "bear"] | None + body: str + likes: int + replies: int + #: The token's theses on CabalSpy ID. + url: str + + +class TradeFeedLinks(TypedDict): + #: CabalSpy Terminal (Solana, BNB), else the token's theses. + token: str + #: CabalSpy Terminal chart; None on chains it does not cover. + terminal: str | None + #: The trader's CabalSpy ID profile. + profile: str | None + + +class _TradeFeedItemCore(TypedDict): + #: Stable id of the trade ("t...") or thesis ("h..."): dedupe on it. + id: str + timestamp: str + trader: TradeFeedTrader + links: TradeFeedLinks + + +class _TradeFeedCursor(TypedDict, total=False): + #: Feed pages only: this item's position (pass it as ``cursor`` to read on from here). + cursor: str + + +class TradeFeedTradeItem(_TradeFeedItemCore, _TradeFeedCursor): + type: Literal["trade"] + trade: TradeFeedTrade + + +class TradeFeedThesisItem(_TradeFeedItemCore, _TradeFeedCursor): + type: Literal["thesis"] + thesis: TradeFeedThesis + + +#: One entry of a Trade Feed: ``item["type"]`` is "trade" or "thesis". +TradeFeedItem = TradeFeedTradeItem | TradeFeedThesisItem + + +class TradeFeedMinNative(TypedDict): + SOL: float + BNB: float + ETH: float + + +class TradeFeedSettings(TypedDict): + """What a person is told about one trader.""" + + notify_buys: bool + notify_sells: bool + notify_theses: bool + #: Minimum trade size per native coin (0 = every trade). + min_native: TradeFeedMinNative + #: Muted: still in the feed, but no notifications. + muted: bool + + +class TradeFeedEntry(TypedDict): + """A trader in a person's Trade Feed.""" + + trader: TradeFeedTrader + #: False while the trader keeps their trading private (nothing of them shows up then). + trading_public: bool + added_at: str + #: In how many Trade Feeds this trader is. + in_trade_feeds: int + settings: TradeFeedSettings + + +class TradeFeedAddResult(TypedDict): + in_trade_feed: Literal[True] + #: False: the trader was already there (settings, if given, were updated). + created: bool + trader: TradeFeedTrader + settings: TradeFeedSettings + + +class TradeFeedRemoveResult(TypedDict): + in_trade_feed: Literal[False] + #: False: the trader was not in the Trade Feed. + removed: bool + + +class TradeFeedNotification(TypedDict): + """A Trade Feed event of one person (one that passed their filters).""" + + id: int + event: TradeFeedEventType + #: One plain-text line, e.g. "Trade Feed: @handle bought $SYM — 1.2 SOL ($240) · new position". + text: str + created_at: str + data: TradeFeedItem + + +class TradeFeedTradeData(_TradeFeedItemCore): + """``data`` of a ``trade_feed.trade`` webhook or stream event.""" + + type: Literal["trade"] + trade: TradeFeedTrade + #: Your users who should hear about it (their csid_ids). + recipients: list[str] + + +class TradeFeedThesisData(_TradeFeedItemCore): + """``data`` of a ``trade_feed.thesis`` webhook or stream event.""" + + type: Literal["thesis"] + thesis: TradeFeedThesis + #: Your users who should hear about it (their csid_ids). + recipients: list[str] + + +TradeFeedEventData = TradeFeedTradeData | TradeFeedThesisData + + +class TradeFeedTradeEvent(TypedDict): + """A ``trade_feed.trade`` webhook.""" + + id: str + type: Literal["trade_feed.trade"] + created_at: str + sandbox: bool + data: TradeFeedTradeData + + +class TradeFeedThesisEvent(TypedDict): + """A ``trade_feed.thesis`` webhook.""" + + id: str + type: Literal["trade_feed.thesis"] + created_at: str + sandbox: bool + data: TradeFeedThesisData + + +TradeFeedEvent = TradeFeedTradeEvent | TradeFeedThesisEvent diff --git a/cabalspy_id/_webhooks.py b/cabalspy_id/_webhooks.py index bb01da8..47fa024 100644 --- a/cabalspy_id/_webhooks.py +++ b/cabalspy_id/_webhooks.py @@ -15,12 +15,24 @@ import hmac import json import time -from collections.abc import Iterable -from typing import Any, Protocol +from collections.abc import Iterable, Mapping +from typing import TYPE_CHECKING, Any, Protocol from ._errors import WebhookVerificationError -__all__ = ["verify_webhook", "sign_webhook", "HeaderSource"] +if TYPE_CHECKING: + from typing import TypeGuard + + from ._types import TradeFeedEvent, TradeFeedThesisEvent, TradeFeedTradeEvent + +__all__ = [ + "verify_webhook", + "sign_webhook", + "HeaderSource", + "is_trade_feed_event", + "is_trade_feed_trade_event", + "is_trade_feed_thesis_event", +] class HeaderSource(Protocol): @@ -114,3 +126,22 @@ def verify_webhook( if not isinstance(event, dict): raise WebhookVerificationError("Webhook body is not a JSON object", code="invalid_payload") return event + + +# ── Trade Feed events ──────────────────────────────────────────────────────── + + +def is_trade_feed_trade_event(event: Mapping[str, Any]) -> TypeGuard[TradeFeedTradeEvent]: + """True for a ``trade_feed.trade`` event: a trader in your users' Trade Feeds + traded; ``event["data"]["recipients"]`` are the users to tell.""" + return isinstance(event, Mapping) and event.get("type") == "trade_feed.trade" + + +def is_trade_feed_thesis_event(event: Mapping[str, Any]) -> TypeGuard[TradeFeedThesisEvent]: + """True for a ``trade_feed.thesis`` event: a trader in your users' Trade Feeds posted a thesis.""" + return isinstance(event, Mapping) and event.get("type") == "trade_feed.thesis" + + +def is_trade_feed_event(event: Mapping[str, Any]) -> TypeGuard[TradeFeedEvent]: + """True for any Trade Feed event (``trade_feed.trade`` or ``trade_feed.thesis``).""" + return isinstance(event, Mapping) and event.get("type") in ("trade_feed.trade", "trade_feed.thesis") diff --git a/cabalspy_id/api.py b/cabalspy_id/api.py index a71774b..043fa5b 100644 --- a/cabalspy_id/api.py +++ b/cabalspy_id/api.py @@ -1,6 +1,7 @@ """``cabalspy_id.api`` — the SocialFi API: Data API clients (users, profiles, -verified wallets, trading stats, leaderboards), the live trade stream, Login -with CabalSpy ID (OpenID Connect) and webhook verification. +verified wallets, trading stats, leaderboards, Trade Feed), the live trade and +Trade Feed streams, Login with CabalSpy ID (OpenID Connect) and webhook +verification. from cabalspy_id.api import CabalSpyID, CabalSpyIDAuth, verify_webhook @@ -16,11 +17,15 @@ DEFAULT_BASE_URL, DEFAULT_ISSUER, DEFAULT_STREAM_URL, + DEFAULT_TRADE_FEED_STREAM_URL, LEADERBOARD_SORTS, PERIODS, SCOPES, SDK_VERSION, STREAM_TOPICS, + TRADE_FEED_EVENTS, + TRADE_FEED_FILTERS, + TRADE_FEED_MAX_TRADERS, WEBHOOK_EVENTS, ActiveHoldings, ActiveTokens, @@ -79,6 +84,28 @@ TrackerResponse, TrackerTrade, Trade, + TradeFeedAddResult, + TradeFeedEntry, + TradeFeedEvent, + TradeFeedEventData, + TradeFeedEventType, + TradeFeedFilter, + TradeFeedItem, + TradeFeedLabel, + TradeFeedLinks, + TradeFeedMinNative, + TradeFeedNotification, + TradeFeedRemoveResult, + TradeFeedSettings, + TradeFeedThesis, + TradeFeedThesisData, + TradeFeedThesisEvent, + TradeFeedThesisItem, + TradeFeedTrade, + TradeFeedTradeData, + TradeFeedTradeEvent, + TradeFeedTradeItem, + TradeFeedTrader, TradeOwner, TraderProfile, VerifiedWallet, @@ -93,6 +120,9 @@ handle_kind, has_x, is_limited, + is_trade_feed_event, + is_trade_feed_thesis_event, + is_trade_feed_trade_event, sign_webhook, verify_webhook, ) @@ -106,6 +136,7 @@ "CabalSpyID", "AsyncCabalSpyID", "CabalSpyIDStream", + "TradeFeedStream", "Envelope", "CabalSpyIDAuth", "AsyncCabalSpyIDAuth", @@ -116,6 +147,9 @@ "is_limited", "verify_webhook", "sign_webhook", + "is_trade_feed_event", + "is_trade_feed_trade_event", + "is_trade_feed_thesis_event", "HeaderSource", "has_x", "handle_kind", @@ -146,6 +180,14 @@ "STREAM_TOPICS", "DEFAULT_BASE_URL", "DEFAULT_STREAM_URL", + "DEFAULT_TRADE_FEED_STREAM_URL", + "TRADE_FEED_EVENTS", + "TRADE_FEED_FILTERS", + "TRADE_FEED_MAX_TRADERS", + "TRADE_FEED_STREAM_EVENTS", + "TradeFeedFilter", + "TradeFeedLabel", + "TradeFeedEventType", "DEFAULT_ISSUER", "Chain", "Period", @@ -191,11 +233,30 @@ "TokenResponse", "DiscoveryDocument", "WebhookEvent", + "TradeFeedTrader", + "TradeFeedTrade", + "TradeFeedThesis", + "TradeFeedLinks", + "TradeFeedTradeItem", + "TradeFeedThesisItem", + "TradeFeedItem", + "TradeFeedMinNative", + "TradeFeedSettings", + "TradeFeedEntry", + "TradeFeedAddResult", + "TradeFeedRemoveResult", + "TradeFeedNotification", + "TradeFeedTradeData", + "TradeFeedThesisData", + "TradeFeedEventData", + "TradeFeedTradeEvent", + "TradeFeedThesisEvent", + "TradeFeedEvent", ] def __getattr__(name: str) -> Any: # The stream needs the optional websockets dependency: loaded lazily, like the root package. - if name in ("CabalSpyIDStream", "STREAM_EVENTS"): + if name in ("CabalSpyIDStream", "STREAM_EVENTS", "TradeFeedStream", "TRADE_FEED_STREAM_EVENTS"): return getattr(_root, name) raise AttributeError(f"module 'cabalspy_id.api' has no attribute {name!r}") diff --git a/cabalspy_id/trade_feed.py b/cabalspy_id/trade_feed.py new file mode 100644 index 0000000..f189df4 --- /dev/null +++ b/cabalspy_id/trade_feed.py @@ -0,0 +1,152 @@ +"""``cabalspy_id.trade_feed`` — Trade Feed: your users add CabalSpy ID traders +(public trading, verified wallets) to their Trade Feed and you tell them about +every trade and thesis of those traders. + +- ``CabalSpyID`` / ``AsyncCabalSpyID``: Data API Trade Feed methods in a user's + name (scope ``trade_feed``): ``trade_feed``, ``trade_feed_traders``, + ``add_to_trade_feed``, ``remove_from_trade_feed``, ``trade_feed_notifications`` + (+ ``iter_...``) +- ``TradeFeedStream``: live ``trade_feed.trade`` / ``trade_feed.thesis`` events with ``recipients`` +- ``is_trade_feed_event`` and friends: type guards for the ``trade_feed.*`` webhooks + + from cabalspy_id.trade_feed import CabalSpyID, verify_webhook, is_trade_feed_trade_event + +The same objects as ``cabalspy_id``. +""" + +from typing import Any + +import cabalspy_id as _root +from cabalspy_id import ( + CHAINS, + CURRENCY_BY_CHAIN, + DEFAULT_TRADE_FEED_STREAM_URL, + SDK_VERSION, + TRADE_FEED_EVENTS, + TRADE_FEED_FILTERS, + TRADE_FEED_MAX_TRADERS, + APIConnectionError, + AsyncCabalSpyID, + AuthenticationError, + BadRequestError, + CabalSpyID, + CabalSpyIDError, + Chain, + Currency, + Envelope, + HandleKind, + HeaderSource, + InvalidResponseError, + NotFoundError, + Pagination, + PermissionError, + PlanUserLimitError, + RateLimitError, + ServerError, + TradeFeedAddResult, + TradeFeedEntry, + TradeFeedEvent, + TradeFeedEventData, + TradeFeedEventType, + TradeFeedFilter, + TradeFeedItem, + TradeFeedLabel, + TradeFeedLinks, + TradeFeedMinNative, + TradeFeedNotification, + TradeFeedRemoveResult, + TradeFeedSettings, + TradeFeedThesis, + TradeFeedThesisData, + TradeFeedThesisEvent, + TradeFeedThesisItem, + TradeFeedTrade, + TradeFeedTradeData, + TradeFeedTradeEvent, + TradeFeedTradeItem, + TradeFeedTrader, + WebhookEvent, + WebhookVerificationError, + handle_kind, + has_x, + is_trade_feed_event, + is_trade_feed_thesis_event, + is_trade_feed_trade_event, + sign_webhook, + verify_webhook, +) + +__version__ = SDK_VERSION + +__all__ = [ + "__version__", + "SDK_VERSION", + # clients + "CabalSpyID", + "AsyncCabalSpyID", + "TradeFeedStream", + "Envelope", + "verify_webhook", + "sign_webhook", + "is_trade_feed_event", + "is_trade_feed_trade_event", + "is_trade_feed_thesis_event", + "HeaderSource", + "has_x", + "handle_kind", + "HandleKind", + # errors + "CabalSpyIDError", + "BadRequestError", + "AuthenticationError", + "PermissionError", + "PlanUserLimitError", + "NotFoundError", + "RateLimitError", + "ServerError", + "APIConnectionError", + "InvalidResponseError", + "WebhookVerificationError", + # constants + "CHAINS", + "CURRENCY_BY_CHAIN", + "TRADE_FEED_EVENTS", + "TRADE_FEED_FILTERS", + "TRADE_FEED_MAX_TRADERS", + "TRADE_FEED_STREAM_EVENTS", + "DEFAULT_TRADE_FEED_STREAM_URL", + "Chain", + "Currency", + "TradeFeedFilter", + "TradeFeedLabel", + "TradeFeedEventType", + # types + "Pagination", + "WebhookEvent", + "TradeFeedTrader", + "TradeFeedTrade", + "TradeFeedThesis", + "TradeFeedLinks", + "TradeFeedTradeItem", + "TradeFeedThesisItem", + "TradeFeedItem", + "TradeFeedMinNative", + "TradeFeedSettings", + "TradeFeedEntry", + "TradeFeedAddResult", + "TradeFeedRemoveResult", + "TradeFeedNotification", + "TradeFeedTradeData", + "TradeFeedThesisData", + "TradeFeedEventData", + "TradeFeedTradeEvent", + "TradeFeedThesisEvent", + "TradeFeedEvent", +] + + +def __getattr__(name: str) -> Any: + # The stream needs the optional websockets dependency: loaded lazily, like the root package. + if name in ("TradeFeedStream", "TRADE_FEED_STREAM_EVENTS"): + return getattr(_root, name) + raise AttributeError(f"module 'cabalspy_id.trade_feed' has no attribute {name!r}") diff --git a/examples/trade_feed.py b/examples/trade_feed.py new file mode 100644 index 0000000..8100d5f --- /dev/null +++ b/examples/trade_feed.py @@ -0,0 +1,59 @@ +"""Trade Feed in a user's name, then the live Trade Feed stream. + + CABALSPY_API_KEY=... CSID_USER= python examples/trade_feed.py alice + +The user must have signed in to your app with the scope "trade_feed". +""" + +from __future__ import annotations + +import asyncio +import os +import sys + +from cabalspy_id import AsyncCabalSpyID, CabalSpyID + + +def main() -> None: + user = os.environ["CSID_USER"] + trader = sys.argv[1] if len(sys.argv) > 1 else "alice" + with CabalSpyID() as csid: # CABALSPY_API_KEY from the environment + added = csid.add_to_trade_feed(user, trader, notify_theses=True, min_sol=0.1) + print("added" if added["created"] else "updated", added["trader"]["username"], added["settings"]) + + for entry in csid.trade_feed_traders(user): + print("in the Trade Feed:", entry["trader"]["username"], "" if entry["trading_public"] else "(private)") + + for n, item in enumerate(csid.iter_trade_feed(user, filter="all")): + if item["type"] == "trade": + t = item["trade"] + print( + item["timestamp"], + item["trader"]["username"], + t["side"], + t["symbol"], + t["amount_native"], + t["label"], + ) + else: + print(item["timestamp"], item["trader"]["username"], "thesis", item["thesis"]["body"][:60]) + if n >= 19: + break + + +async def live() -> None: + # Every user of your instance (the same events as the webhooks); needs cabalspy-id[stream]. + async with AsyncCabalSpyID() as csid: + tf = csid.trade_feed_stream() + tf.on( + "trade_feed.trade", + lambda d: print("tell", d["recipients"], "->", d["trader"]["username"], d["trade"]["label"]), + ) + tf.on("error", lambda e: print("error", getattr(e, "code", e))) + await tf.connect() + await tf.run_forever() + + +if __name__ == "__main__": + main() + asyncio.run(live()) diff --git a/pyproject.toml b/pyproject.toml index 89ae5d7..f5b5de3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "cabalspy-id" -version = "0.5.2" +version = "0.6.0" description = "SocialFi API & SDK for CabalSpy ID — add a social layer to any app: login with X + verified wallets, live trades and PnL of your users, token theses, leaderboards and copy trading on Solana, BNB Chain, Base, Ethereum and Robinhood Chain." readme = "README.md" license = "MIT" @@ -113,3 +113,4 @@ ignore = ["RUF022"] # names served lazily by the module __getattr__ (optional websockets dependency) "cabalspy_id/api.py" = ["F822"] "cabalspy_id/thesis.py" = ["F822"] +"cabalspy_id/trade_feed.py" = ["F822"] diff --git a/tests/test_submodules.py b/tests/test_submodules.py index 885843c..251344a 100644 --- a/tests/test_submodules.py +++ b/tests/test_submodules.py @@ -30,3 +30,16 @@ def test_star_imports() -> None: ns_thesis: dict[str, object] = {} exec("from cabalspy_id.thesis import *", ns_thesis) assert ns_thesis["ThesisClient"] is cabalspy_id.ThesisClient + + +def test_trade_feed_subset_is_the_same_objects() -> None: + from cabalspy_id import trade_feed + + for name in trade_feed.__all__: + if name == "__version__": + continue + assert getattr(trade_feed, name) is getattr(cabalspy_id, name), name + assert "ThesisClient" not in trade_feed.__all__ + ns: dict[str, object] = {} + exec("from cabalspy_id.trade_feed import *", ns) + assert ns["TradeFeedStream"] is cabalspy_id.TradeFeedStream diff --git a/tests/test_trade_feed.py b/tests/test_trade_feed.py new file mode 100644 index 0000000..5e3d91e --- /dev/null +++ b/tests/test_trade_feed.py @@ -0,0 +1,385 @@ +"""Trade Feed: Data API methods (sync + async), the stream against a local server, webhooks, module.""" + +from __future__ import annotations + +import asyncio +import json +import time +from typing import Any +from urllib.parse import parse_qs, urlsplit + +import httpx +import pytest +from websockets.asyncio.server import serve + +import cabalspy_id +import cabalspy_id.api +import cabalspy_id.trade_feed +from cabalspy_id import ( + SDK_VERSION, + AsyncCabalSpyID, + BadRequestError, + CabalSpyID, + CabalSpyIDError, + NotFoundError, + PermissionError, + RateLimitError, + is_trade_feed_event, + is_trade_feed_thesis_event, + is_trade_feed_trade_event, + sign_webhook, + verify_webhook, +) + +from .conftest import META, RATE, Recorder, api_error, ok + +USER = "0b6a3a4e-5a4f-4a59-9a59-0f5c5f5c5f5c" +MINT = "So11111111111111111111111111111111111111112" +TRADER = { + "csid_id": "11111111-2222-4333-8444-555555555555", + "username": "alice", + "name": "Alice", + "image_url": None, + "kol": True, + "profile_url": "https://id.cabalspy.xyz/u/alice", +} +TRADE_ITEM: dict[str, Any] = { + "id": "t0123456789abcdef0123456789abcdef", + "type": "trade", + "timestamp": "2026-10-15T12:00:00Z", + "trader": TRADER, + "trade": { + "blockchain": "solana", + "mint": MINT, + "symbol": "ABC", + "name": "Abc", + "logo": None, + "side": "sell", + "label": "partial", + "sold_percent": 50.0, + "amount_native": 1.5, + "currency": "SOL", + "amount_usd": 300.0, + "token_amount": 1000.0, + "price_usd": 0.3, + "pnl_usd": 120.0, + "pnl_percent": 66.67, + "wallet": "WaLLet", + "wallet_url": "https://solscan.io/account/WaLLet", + "tx_signature": "sig", + "tx_url": "https://solscan.io/tx/sig", + }, + "links": { + "token": f"https://app.cabalspy.xyz/token/{MINT}", + "terminal": f"https://app.cabalspy.xyz/token/{MINT}", + "profile": TRADER["profile_url"], + }, + "cursor": "c1", +} +THESIS_ITEM: dict[str, Any] = { + "id": "h42", + "type": "thesis", + "timestamp": "2026-10-15T11:00:00Z", + "trader": TRADER, + "thesis": { + "id": "42", + "blockchain": "base", + "mint": "0xabc", + "symbol": None, + "name": None, + "logo": None, + "stance": "bull", + "body": "up", + "likes": 1, + "replies": 0, + "url": "https://id.cabalspy.xyz/try/thesis?chain=base&mint=0xabc", + }, + "links": {"token": "https://id.cabalspy.xyz/try/thesis?chain=base&mint=0xabc", "terminal": None, "profile": None}, +} +SETTINGS = { + "notify_buys": True, + "notify_sells": True, + "notify_theses": False, + "min_native": {"SOL": 0.5, "BNB": 0.0, "ETH": 0.0}, + "muted": False, +} +EVENT_DATA = {**{k: v for k, v in TRADE_ITEM.items() if k != "cursor"}, "recipients": [USER]} + + +def path(r: httpx.Request) -> str: + q = r.url.query.decode() + return f"{r.url.path}?{q}" if q else r.url.path + + +def paged(first: list[Any], second: list[Any], cursor: str) -> Recorder: + def handler(r: httpx.Request) -> httpx.Response: + if r.url.params.get("cursor"): + return ok(second, pagination={"limit": 1, "next_cursor": None, "has_more": False}) + return ok(first, pagination={"limit": 1, "next_cursor": cursor, "has_more": True}) + + return Recorder(handler) + + +# ── Data API ──────────────────────────────────────────────────────────────── + + +def test_feed_with_filter_and_pagination() -> None: + rec = paged([TRADE_ITEM], [THESIS_ITEM], "c1") + csid = CabalSpyID("k", http_client=rec.client(), max_retries=0) + page = csid.trade_feed(USER, filter="sells", limit=1) + assert page[0]["trade"]["label"] == "partial" + first = rec.requests[0] + assert path(first) == f"/v1/trade-feed?csid_id={USER}&filter=sells&limit=1" + assert first.headers["authorization"] == "Bearer k" + assert first.headers["user-agent"] == f"cabalspy-id-python/{SDK_VERSION}" + assert [i["id"] for i in csid.iter_trade_feed(USER, limit=1)] == [TRADE_ITEM["id"], "h42"] + assert path(rec.requests[2]) == f"/v1/trade-feed?csid_id={USER}&limit=1&cursor=c1" + with pytest.raises(BadRequestError) as e: + csid.trade_feed(USER, filter="everything") # type: ignore[arg-type] + assert e.value.code == "invalid_parameter" + with pytest.raises(BadRequestError): + csid.trade_feed("") + + +def test_traders_add_remove() -> None: + entry = {"trader": TRADER, "trading_public": True, "added_at": "2026-10-15T00:00:00+00:00", "in_trade_feeds": 3} + entry["settings"] = SETTINGS + calls = {"add": 0} + + def handler(r: httpx.Request) -> httpx.Response: + if r.url.path == "/v1/trade-feed/traders" and r.method == "GET": + return ok( + [entry], pagination={"limit": 100, "total": 1, "max": 300, "next_cursor": None, "has_more": False} + ) + if r.url.path == "/v1/trade-feed/traders/delete": + return ok({"in_trade_feed": False, "removed": True}) + calls["add"] += 1 + status = 201 if calls["add"] == 1 else 200 + data = {"in_trade_feed": True, "created": status == 201, "trader": TRADER, "settings": SETTINGS} + return httpx.Response(status, json={"success": True, "data": data, "meta": META}, headers=RATE) + + rec = Recorder(handler) + csid = CabalSpyID("k", http_client=rec.client(), max_retries=0) + traders = csid.get_raw("/trade-feed/traders", {"csid_id": USER}) + assert traders.pagination == {"limit": 100, "total": 1, "max": 300, "next_cursor": None, "has_more": False} + assert csid.trade_feed_traders(USER, limit=100)[0]["settings"]["min_native"]["SOL"] == 0.5 + assert path(rec.requests[1]) == f"/v1/trade-feed/traders?csid_id={USER}&limit=100" + assert len(list(csid.iter_trade_feed_traders(USER))) == 1 + + added = csid.add_to_trade_feed(USER, "@alice", notify_theses=False, min_sol=0.5) + assert added["created"] is True + post = rec.requests[-1] + assert post.method == "POST" and post.url.path == "/v1/trade-feed/traders" + assert json.loads(post.content) == {"notify_theses": False, "min_sol": 0.5, "csid_id": USER, "trader": "@alice"} + assert csid.add_to_trade_feed(USER, "alice", muted=True)["created"] is False + + assert csid.remove_from_trade_feed(USER, "alice") == {"in_trade_feed": False, "removed": True} + assert json.loads(rec.requests[-1].content) == {"csid_id": USER, "trader": "alice"} + with pytest.raises(BadRequestError) as e: + csid.add_to_trade_feed(USER, "") + assert e.value.parameter == "trader" + + +@pytest.mark.parametrize( + ("status", "code", "cls"), + [ + (404, "trader_not_found", NotFoundError), + (400, "cannot_add_self", BadRequestError), + (403, "trading_private", PermissionError), + (403, "consent_required", PermissionError), + (409, "trade_feed_full", CabalSpyIDError), + (429, "rate_limited", RateLimitError), + (400, "invalid_cursor", BadRequestError), + ], +) +def test_errors(status: int, code: str, cls: type[CabalSpyIDError]) -> None: + rec = Recorder(lambda r: api_error(status, code)) + csid = CabalSpyID("k", http_client=rec.client(), max_retries=2) + with pytest.raises(cls) as e: + csid.add_to_trade_feed(USER, "bob") + assert e.value.code == code and e.value.status == status + assert len(rec.requests) == 1 # writes are not retried (only the API's own rate_limit_exceeded) + + +def test_notifications() -> None: + n = { + "id": 9, + "event": "trade_feed.trade", + "text": "Trade Feed: @alice sold $ABC", + "created_at": "x", + "data": TRADE_ITEM, + } + rec = paged([n], [], "n9") + csid = CabalSpyID("k", http_client=rec.client(), max_retries=0) + assert csid.trade_feed_notifications(USER, limit=1)[0]["event"] == "trade_feed.trade" + assert path(rec.requests[0]) == f"/v1/trade-feed/notifications?csid_id={USER}&limit=1" + assert len(list(csid.iter_trade_feed_notifications(USER, limit=1))) == 1 + assert path(rec.requests[2]) == f"/v1/trade-feed/notifications?csid_id={USER}&limit=1&cursor=n9" + + +async def test_async_client() -> None: + rec = paged([TRADE_ITEM], [THESIS_ITEM], "c1") + async with AsyncCabalSpyID("k", http_client=rec.async_client(), max_retries=0) as csid: + page = await csid.trade_feed(USER, filter="all") + assert page[0]["id"] == TRADE_ITEM["id"] + items = [i["type"] async for i in csid.iter_trade_feed(USER)] + assert items == ["trade", "thesis"] + assert [t async for t in csid.iter_trade_feed_traders(USER)] + assert [t async for t in csid.iter_trade_feed_notifications(USER)] + added = await csid.add_to_trade_feed(USER, "alice") + assert added == [TRADE_ITEM] # the mock answers every GET/POST with the first page + await csid.remove_from_trade_feed(USER, "alice") + await csid.trade_feed_traders(USER) + await csid.trade_feed_notifications(USER) + assert rec.requests[-3].url.path == "/v1/trade-feed/traders/delete" + + +# ── stream ────────────────────────────────────────────────────────────────── + + +class Server: + def __init__(self) -> None: + self.paths: list[str] = [] + self.received: list[str] = [] + self.mode = "ok" + self.connections = 0 + + async def handler(self, ws: Any) -> None: + self.connections += 1 + self.paths.append(ws.request.path) + if self.mode == "deny": + await ws.send(json.dumps({"event": "error", "error": {"code": "invalid_api_key", "message": "bad key"}})) + await ws.close(code=4403) + return + if self.mode == "busy" and self.connections == 1: + await ws.send(json.dumps({"event": "error", "error": {"code": "too_many_connections", "message": "5"}})) + await ws.close(code=4403) + return + await ws.send(json.dumps({"event": "ready", "data": {"events": ["trade_feed.trade", "trade_feed.thesis"]}})) + await ws.send(json.dumps({"event": "trade_feed.trade", "id": "ev-1", "data": EVENT_DATA})) + await ws.send( + json.dumps({"event": "trade_feed.thesis", "id": "ev-2", "data": {**THESIS_ITEM, "recipients": [USER]}}) + ) + await ws.send(json.dumps({"event": "heartbeat", "ts": 5})) + async for raw in ws: + self.received.append(raw) + if raw == "ping": + await ws.send(json.dumps({"event": "pong"})) + + +@pytest.fixture +async def server(): + s = Server() + async with serve(s.handler, "127.0.0.1", 0) as srv: + s.url = f"ws://127.0.0.1:{srv.sockets[0].getsockname()[1]}/v1/trade-feed/stream" # type: ignore[attr-defined] + yield s + + +def client(s: Server) -> CabalSpyID: + return CabalSpyID("k", instance="csid_i", trade_feed_stream_url=s.url) # type: ignore[attr-defined] + + +async def wait_for(cond: Any, timeout: float = 2.0) -> None: + for _ in range(int(timeout / 0.02)): + if cond(): + return + await asyncio.sleep(0.02) + + +async def test_stream_events(server: Server) -> None: + tf = client(server).trade_feed_stream() + got: list[str] = [] + tf.on("trade_feed.trade", lambda d: got.append(f"trade:{d['trade']['label']}:{','.join(d['recipients'])}")) + tf.on("trade_feed.thesis", lambda d: got.append(f"thesis:{d['thesis']['stance']}")) + tf.on("heartbeat", lambda h: got.append(f"hb:{h['ts']}")) + tf.on("pong", lambda _p: got.append("pong")) + ready = await tf.connect() + assert ready == {"events": ["trade_feed.trade", "trade_feed.thesis"]} + q = {k: v[0] for k, v in parse_qs(urlsplit(server.paths[0]).query).items()} + assert q == {"api_key": "k", "instance": "csid_i"} + assert urlsplit(server.paths[0]).path == "/v1/trade-feed/stream" + assert "api_key=***" in tf.safe_url + await tf.ping() + await wait_for(lambda: "pong" in got) + await tf.close() + assert got == [f"trade:partial:{USER}", "thesis:bull", "hb:5", "pong"] + assert server.received == ["ping"] + with pytest.raises(ValueError): + tf.on("trade", lambda _d: None) + + +async def test_stream_iterator(server: Server) -> None: + seen: list[tuple[str, str]] = [] + async with client(server).trade_feed_stream() as tf: + async for ev in tf: + seen.append((ev["event"], ev["id"])) + if len(seen) == 2: + break + assert seen == [("trade_feed.trade", "ev-1"), ("trade_feed.thesis", "ev-2")] + + +async def test_stream_refusal_is_fatal(server: Server) -> None: + server.mode = "deny" + tf = client(server).trade_feed_stream(reconnect_delay=0.01) + with pytest.raises(PermissionError) as e: + await tf.connect() + assert e.value.code == "invalid_api_key" + await asyncio.sleep(0.1) + assert server.connections == 1 + + +async def test_stream_too_many_connections_is_retried(server: Server) -> None: + server.mode = "busy" + tf = client(server).trade_feed_stream(reconnect_delay=0.01) + with pytest.raises(RateLimitError): + await tf.connect() + await wait_for(lambda: tf.connected) + assert server.connections == 2 + await tf.close() + + +def test_stream_needs_key(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("CABALSPY_ID_API_KEY", raising=False) + monkeypatch.delenv("CABALSPY_API_KEY", raising=False) + with pytest.raises(cabalspy_id.AuthenticationError): + CabalSpyID().trade_feed_stream() + + +# ── webhooks and module ───────────────────────────────────────────────────── + + +def test_webhook_type_guards() -> None: + secret = "whsec_" + "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=" + body = json.dumps( + {"id": "evt_tf", "type": "trade_feed.trade", "created_at": "2026-10-15T12:00:02+00:00", "sandbox": False} + | {"data": EVENT_DATA} + ) + ts = int(time.time()) + headers = { + "webhook-id": "evt_tf", + "webhook-timestamp": str(ts), + "webhook-signature": sign_webhook(secret, "evt_tf", ts, body), + } + event = verify_webhook(body, headers, secret) + assert is_trade_feed_event(event) and not is_trade_feed_thesis_event(event) + assert is_trade_feed_trade_event(event) + assert event["data"]["recipients"] == [USER] + assert event["data"]["trade"]["pnl_usd"] == 120.0 + assert not is_trade_feed_event({"type": "thesis.created"}) + assert is_trade_feed_thesis_event({"type": "trade_feed.thesis"}) + assert "trade_feed.trade" in cabalspy_id.WEBHOOK_EVENTS + + +def test_trade_feed_module_is_the_same_objects() -> None: + m = cabalspy_id.trade_feed + assert m.CabalSpyID is cabalspy_id.CabalSpyID + assert m.TradeFeedStream is cabalspy_id.TradeFeedStream + assert cabalspy_id.api.TradeFeedStream is cabalspy_id.TradeFeedStream + assert m.is_trade_feed_trade_event is cabalspy_id.is_trade_feed_trade_event + assert m.PermissionError is cabalspy_id.PermissionError + assert m.TRADE_FEED_FILTERS == ("all", "buys", "sells", "theses") + assert m.TRADE_FEED_MAX_TRADERS == 300 + assert m.DEFAULT_TRADE_FEED_STREAM_URL == "wss://api-id.cabalspy.xyz/v1/trade-feed/stream" + assert m.TRADE_FEED_STREAM_EVENTS[0] == "ready" + for name in m.__all__: + assert getattr(m, name) is not None