carrac-sdk 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- carrac_sdk-0.1.0/.gitignore +8 -0
- carrac_sdk-0.1.0/PKG-INFO +80 -0
- carrac_sdk-0.1.0/README.md +53 -0
- carrac_sdk-0.1.0/pyproject.toml +39 -0
- carrac_sdk-0.1.0/src/carrac/__init__.py +34 -0
- carrac_sdk-0.1.0/src/carrac/client.py +146 -0
- carrac_sdk-0.1.0/src/carrac/errors.py +46 -0
- carrac_sdk-0.1.0/src/carrac/resources.py +114 -0
- carrac_sdk-0.1.0/tests/test_client.py +172 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: carrac-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for Carrac — market data, plays, alerts, usage
|
|
5
|
+
Project-URL: Homepage, https://carrac.cc
|
|
6
|
+
Project-URL: Documentation, https://app.carrac.cc/api/docs
|
|
7
|
+
Project-URL: Source, https://github.com/pgyula86/carrac-sdk-python
|
|
8
|
+
Author-email: Carrac <hello@carrac.cc>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: carrac,crypto,market-data,sdk,trading
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Requires-Dist: httpx>=0.25
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest-httpx>=0.30; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# Carrac Python SDK
|
|
29
|
+
|
|
30
|
+
Official Python client for the [Carrac](https://carrac.cc) API. Use it to fetch market data, manage plays, create alerts, and inspect your API usage from any Python application.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install carrac-sdk
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Requires Python ≥ 3.9 and a Carrac Premium subscription (API keys require Premium).
|
|
39
|
+
|
|
40
|
+
## Quick start
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from carrac import Client
|
|
44
|
+
|
|
45
|
+
cvx = Client(api_key="carrac_k_...")
|
|
46
|
+
|
|
47
|
+
# Market data
|
|
48
|
+
chart = cvx.charts.get("BTC", timeframe="4h", indicators="CPR,RSI")
|
|
49
|
+
|
|
50
|
+
# Composite call — everything about an asset in one request
|
|
51
|
+
detail = cvx.composite.asset_detail("BTC", format="compact")
|
|
52
|
+
|
|
53
|
+
# Create an alert
|
|
54
|
+
cvx.alerts.create(symbol="BTC", alert_type="price", condition="above", price=120000)
|
|
55
|
+
|
|
56
|
+
# Scanner
|
|
57
|
+
ranked = cvx.scanner.list(direction="long", verdict="STRONG,GOOD")
|
|
58
|
+
|
|
59
|
+
# Your usage
|
|
60
|
+
usage = cvx.usage.get()
|
|
61
|
+
print(usage["rest"]["usage"]) # [{window:"minute", used:7, limit:240, remaining:233}, ...]
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Authentication
|
|
65
|
+
|
|
66
|
+
1. Go to **Settings → API & Developer** in the Carrac dashboard.
|
|
67
|
+
2. Click **Create Key**, copy the full key (shown once).
|
|
68
|
+
3. Use it with `Client(api_key=...)` or set `CARRAC_API_KEY` in your environment.
|
|
69
|
+
|
|
70
|
+
## Features
|
|
71
|
+
|
|
72
|
+
- Per-user scoped keys — every call is rate-limited against your own Premium budget.
|
|
73
|
+
- Automatic 429 backoff honouring `Retry-After`.
|
|
74
|
+
- Pagination iterators for list endpoints.
|
|
75
|
+
- Works in any Python ≥ 3.9 runtime (CPython, PyPy, Lambda, serverless).
|
|
76
|
+
|
|
77
|
+
## Docs
|
|
78
|
+
|
|
79
|
+
Interactive API reference: https://app.carrac.cc/api/docs
|
|
80
|
+
OpenAPI spec: https://app.carrac.cc/api/openapi.json
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Carrac Python SDK
|
|
2
|
+
|
|
3
|
+
Official Python client for the [Carrac](https://carrac.cc) API. Use it to fetch market data, manage plays, create alerts, and inspect your API usage from any Python application.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install carrac-sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Requires Python ≥ 3.9 and a Carrac Premium subscription (API keys require Premium).
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from carrac import Client
|
|
17
|
+
|
|
18
|
+
cvx = Client(api_key="carrac_k_...")
|
|
19
|
+
|
|
20
|
+
# Market data
|
|
21
|
+
chart = cvx.charts.get("BTC", timeframe="4h", indicators="CPR,RSI")
|
|
22
|
+
|
|
23
|
+
# Composite call — everything about an asset in one request
|
|
24
|
+
detail = cvx.composite.asset_detail("BTC", format="compact")
|
|
25
|
+
|
|
26
|
+
# Create an alert
|
|
27
|
+
cvx.alerts.create(symbol="BTC", alert_type="price", condition="above", price=120000)
|
|
28
|
+
|
|
29
|
+
# Scanner
|
|
30
|
+
ranked = cvx.scanner.list(direction="long", verdict="STRONG,GOOD")
|
|
31
|
+
|
|
32
|
+
# Your usage
|
|
33
|
+
usage = cvx.usage.get()
|
|
34
|
+
print(usage["rest"]["usage"]) # [{window:"minute", used:7, limit:240, remaining:233}, ...]
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Authentication
|
|
38
|
+
|
|
39
|
+
1. Go to **Settings → API & Developer** in the Carrac dashboard.
|
|
40
|
+
2. Click **Create Key**, copy the full key (shown once).
|
|
41
|
+
3. Use it with `Client(api_key=...)` or set `CARRAC_API_KEY` in your environment.
|
|
42
|
+
|
|
43
|
+
## Features
|
|
44
|
+
|
|
45
|
+
- Per-user scoped keys — every call is rate-limited against your own Premium budget.
|
|
46
|
+
- Automatic 429 backoff honouring `Retry-After`.
|
|
47
|
+
- Pagination iterators for list endpoints.
|
|
48
|
+
- Works in any Python ≥ 3.9 runtime (CPython, PyPy, Lambda, serverless).
|
|
49
|
+
|
|
50
|
+
## Docs
|
|
51
|
+
|
|
52
|
+
Interactive API reference: https://app.carrac.cc/api/docs
|
|
53
|
+
OpenAPI spec: https://app.carrac.cc/api/openapi.json
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "carrac-sdk"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python SDK for Carrac — market data, plays, alerts, usage"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
authors = [{ name = "Carrac", email = "hello@carrac.cc" }]
|
|
11
|
+
license = "MIT"
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
keywords = ["carrac", "trading", "crypto", "market-data", "sdk"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Intended Audience :: Financial and Insurance Industry",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.9",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Topic :: Office/Business :: Financial :: Investment",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"httpx>=0.25",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
dev = ["pytest>=7", "pytest-httpx>=0.30"]
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://carrac.cc"
|
|
35
|
+
Documentation = "https://app.carrac.cc/api/docs"
|
|
36
|
+
Source = "https://github.com/pgyula86/carrac-sdk-python"
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.wheel]
|
|
39
|
+
packages = ["src/carrac"]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Carrac Python SDK — trading market data, plays, alerts, usage."""
|
|
2
|
+
|
|
3
|
+
from .client import Client
|
|
4
|
+
from .errors import (
|
|
5
|
+
CarracError,
|
|
6
|
+
AuthError,
|
|
7
|
+
RateLimitError,
|
|
8
|
+
NotFoundError,
|
|
9
|
+
ApiError,
|
|
10
|
+
# Backward-compat aliases; remove in next major version
|
|
11
|
+
ConvexityError,
|
|
12
|
+
ConvexityAuthError,
|
|
13
|
+
ConvexityNotFoundError,
|
|
14
|
+
ConvexityRateLimitError,
|
|
15
|
+
ConvexityApiError,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__version__ = "0.1.0"
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"Client",
|
|
22
|
+
# New names
|
|
23
|
+
"CarracError",
|
|
24
|
+
"AuthError",
|
|
25
|
+
"RateLimitError",
|
|
26
|
+
"NotFoundError",
|
|
27
|
+
"ApiError",
|
|
28
|
+
# Backward-compat aliases; remove in next major version
|
|
29
|
+
"ConvexityError",
|
|
30
|
+
"ConvexityAuthError",
|
|
31
|
+
"ConvexityNotFoundError",
|
|
32
|
+
"ConvexityRateLimitError",
|
|
33
|
+
"ConvexityApiError",
|
|
34
|
+
]
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Carrac API client.
|
|
2
|
+
|
|
3
|
+
Thin wrapper around httpx. Resource namespaces (charts, plays, alerts, ...)
|
|
4
|
+
organize the surface; all I/O goes through Client._request so retries and
|
|
5
|
+
error handling stay in one place.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from .errors import AuthError, NotFoundError, RateLimitError, ApiError
|
|
14
|
+
|
|
15
|
+
DEFAULT_BASE_URL = "https://app.carrac.cc"
|
|
16
|
+
DEFAULT_TIMEOUT = 30.0
|
|
17
|
+
DEFAULT_USER_AGENT = "carrac-python-sdk/0.1.0"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Client:
|
|
21
|
+
"""HTTP client for the Carrac API.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
api_key: your Carrac API key. Falls back to CARRAC_API_KEY env var.
|
|
25
|
+
Also accepts the legacy CONVEXITY_API_KEY for backward compatibility.
|
|
26
|
+
base_url: override for staging / self-hosted deployments.
|
|
27
|
+
timeout: request timeout in seconds.
|
|
28
|
+
max_retries: how many times to automatically retry on 429 / 5xx.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, api_key=None, base_url=None, timeout=DEFAULT_TIMEOUT,
|
|
32
|
+
max_retries=3, user_agent=DEFAULT_USER_AGENT):
|
|
33
|
+
self.api_key = (
|
|
34
|
+
api_key
|
|
35
|
+
or os.environ.get("CARRAC_API_KEY")
|
|
36
|
+
or os.environ.get("CONVEXITY_API_KEY") # backward-compat
|
|
37
|
+
)
|
|
38
|
+
if not self.api_key:
|
|
39
|
+
raise ValueError(
|
|
40
|
+
"No API key provided. Pass api_key=... or set CARRAC_API_KEY."
|
|
41
|
+
)
|
|
42
|
+
self.base_url = (
|
|
43
|
+
base_url
|
|
44
|
+
or os.environ.get("CARRAC_BASE_URL")
|
|
45
|
+
or os.environ.get("CONVEXITY_BASE_URL") # backward-compat
|
|
46
|
+
or DEFAULT_BASE_URL
|
|
47
|
+
).rstrip("/")
|
|
48
|
+
self.timeout = timeout
|
|
49
|
+
self.max_retries = max_retries
|
|
50
|
+
self._http = httpx.Client(
|
|
51
|
+
base_url=self.base_url,
|
|
52
|
+
timeout=timeout,
|
|
53
|
+
headers={
|
|
54
|
+
"X-API-Key": self.api_key,
|
|
55
|
+
"User-Agent": user_agent,
|
|
56
|
+
"Accept": "application/json",
|
|
57
|
+
},
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Resource namespaces
|
|
61
|
+
from .resources import Charts, Plays, Alerts, Composite, Scanner, Usage, Keys
|
|
62
|
+
self.charts = Charts(self)
|
|
63
|
+
self.plays = Plays(self)
|
|
64
|
+
self.alerts = Alerts(self)
|
|
65
|
+
self.composite = Composite(self)
|
|
66
|
+
self.scanner = Scanner(self)
|
|
67
|
+
self.usage = Usage(self)
|
|
68
|
+
self.keys = Keys(self)
|
|
69
|
+
|
|
70
|
+
# ── Context manager support ──
|
|
71
|
+
def __enter__(self):
|
|
72
|
+
return self
|
|
73
|
+
|
|
74
|
+
def __exit__(self, *a):
|
|
75
|
+
self.close()
|
|
76
|
+
|
|
77
|
+
def close(self):
|
|
78
|
+
self._http.close()
|
|
79
|
+
|
|
80
|
+
# ── Core request ──
|
|
81
|
+
|
|
82
|
+
def _request(self, method, path, *, params=None, json=None):
|
|
83
|
+
attempts = 0
|
|
84
|
+
while True:
|
|
85
|
+
attempts += 1
|
|
86
|
+
try:
|
|
87
|
+
resp = self._http.request(method, path, params=params, json=json)
|
|
88
|
+
except httpx.TransportError as exc:
|
|
89
|
+
if attempts > self.max_retries:
|
|
90
|
+
raise ApiError(f"Network error: {exc}") from exc
|
|
91
|
+
time.sleep(min(2 ** attempts, 10))
|
|
92
|
+
continue
|
|
93
|
+
|
|
94
|
+
if resp.status_code == 429 and attempts <= self.max_retries:
|
|
95
|
+
retry_after = int(resp.headers.get("Retry-After", "1"))
|
|
96
|
+
time.sleep(min(retry_after, 30))
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
if 500 <= resp.status_code < 600 and attempts <= self.max_retries:
|
|
100
|
+
time.sleep(min(2 ** attempts, 10))
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
self._raise_for_status(resp)
|
|
104
|
+
|
|
105
|
+
if resp.status_code == 204 or not resp.content:
|
|
106
|
+
return None
|
|
107
|
+
try:
|
|
108
|
+
return resp.json()
|
|
109
|
+
except ValueError as exc:
|
|
110
|
+
raise ApiError(f"Non-JSON response: {resp.text[:200]}") from exc
|
|
111
|
+
|
|
112
|
+
def _raise_for_status(self, resp):
|
|
113
|
+
if resp.is_success:
|
|
114
|
+
return
|
|
115
|
+
body = {}
|
|
116
|
+
try:
|
|
117
|
+
body = resp.json()
|
|
118
|
+
except ValueError:
|
|
119
|
+
pass
|
|
120
|
+
message = body.get("error") or resp.text or resp.reason_phrase
|
|
121
|
+
|
|
122
|
+
if resp.status_code in (401, 403):
|
|
123
|
+
raise AuthError(message, status_code=resp.status_code, response=body)
|
|
124
|
+
if resp.status_code == 404:
|
|
125
|
+
raise NotFoundError(message, status_code=resp.status_code, response=body)
|
|
126
|
+
if resp.status_code == 429:
|
|
127
|
+
raise RateLimitError(
|
|
128
|
+
message,
|
|
129
|
+
retry_after=int(resp.headers.get("Retry-After", "0")),
|
|
130
|
+
status_code=429,
|
|
131
|
+
response=body,
|
|
132
|
+
)
|
|
133
|
+
raise ApiError(message, status_code=resp.status_code, response=body)
|
|
134
|
+
|
|
135
|
+
# ── Shortcut methods ──
|
|
136
|
+
def get(self, path, **params):
|
|
137
|
+
return self._request("GET", path, params=params or None)
|
|
138
|
+
|
|
139
|
+
def post(self, path, json=None, **params):
|
|
140
|
+
return self._request("POST", path, params=params or None, json=json)
|
|
141
|
+
|
|
142
|
+
def put(self, path, json=None, **params):
|
|
143
|
+
return self._request("PUT", path, params=params or None, json=json)
|
|
144
|
+
|
|
145
|
+
def delete(self, path, **params):
|
|
146
|
+
return self._request("DELETE", path, params=params or None)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""SDK error hierarchy.
|
|
2
|
+
|
|
3
|
+
All API failures raise a subclass of CarracError. Callers can catch the
|
|
4
|
+
base class to handle any API failure, or a specific subclass for targeted
|
|
5
|
+
recovery (e.g. retrying on RateLimitError).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CarracError(Exception):
|
|
10
|
+
"""Base class for all SDK errors."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, message, status_code=None, response=None):
|
|
13
|
+
super().__init__(message)
|
|
14
|
+
self.status_code = status_code
|
|
15
|
+
self.response = response
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AuthError(CarracError):
|
|
19
|
+
"""401/403 — missing, invalid, or insufficient API key / tier."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class NotFoundError(CarracError):
|
|
23
|
+
"""404 — resource does not exist or is not visible to this key."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class RateLimitError(CarracError):
|
|
27
|
+
"""429 — sliding-window budget exceeded.
|
|
28
|
+
|
|
29
|
+
`retry_after` is seconds until the soonest window frees up.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, message, retry_after=0, status_code=429, response=None):
|
|
33
|
+
super().__init__(message, status_code=status_code, response=response)
|
|
34
|
+
self.retry_after = retry_after
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ApiError(CarracError):
|
|
38
|
+
"""5xx or unclassified 4xx — server-side failure, treat as transient."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# Backward-compat aliases; remove in next major version
|
|
42
|
+
ConvexityError = CarracError
|
|
43
|
+
ConvexityAuthError = AuthError
|
|
44
|
+
ConvexityNotFoundError = NotFoundError
|
|
45
|
+
ConvexityRateLimitError = RateLimitError
|
|
46
|
+
ConvexityApiError = ApiError
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Resource namespaces — thin wrappers that build paths and delegate to the client."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class _Resource:
|
|
5
|
+
def __init__(self, client):
|
|
6
|
+
self._client = client
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Charts(_Resource):
|
|
10
|
+
def get(self, symbol, *, timeframe=None, indicators=None):
|
|
11
|
+
"""OHLCV + indicator data for a symbol (see /api/chart-internal)."""
|
|
12
|
+
params = {}
|
|
13
|
+
if timeframe:
|
|
14
|
+
params["timeframe"] = timeframe
|
|
15
|
+
if indicators:
|
|
16
|
+
params["indicators"] = indicators
|
|
17
|
+
return self._client.get(f"/api/chart-internal/{symbol}", **params)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Plays(_Resource):
|
|
21
|
+
def list(self, *, status=None, asset=None, type=None,
|
|
22
|
+
include_resolved=False, limit=50, offset=0):
|
|
23
|
+
params = {"limit": limit, "offset": offset, "include_resolved": str(include_resolved).lower()}
|
|
24
|
+
if status:
|
|
25
|
+
params["status"] = status
|
|
26
|
+
if asset:
|
|
27
|
+
params["asset"] = asset
|
|
28
|
+
if type:
|
|
29
|
+
params["type"] = type
|
|
30
|
+
return self._client.get("/api/plays", **params)
|
|
31
|
+
|
|
32
|
+
def active(self):
|
|
33
|
+
return self._client.get("/api/plays/active")
|
|
34
|
+
|
|
35
|
+
def context(self):
|
|
36
|
+
"""Compact plays context for AI briefings."""
|
|
37
|
+
return self._client.get("/api/plays/context")
|
|
38
|
+
|
|
39
|
+
def get(self, play_id):
|
|
40
|
+
return self._client.get(f"/api/plays/{play_id}")
|
|
41
|
+
|
|
42
|
+
def create(self, **fields):
|
|
43
|
+
return self._client.post("/api/plays", json=fields)
|
|
44
|
+
|
|
45
|
+
def update(self, play_id, **fields):
|
|
46
|
+
return self._client.put(f"/api/plays/{play_id}", json=fields)
|
|
47
|
+
|
|
48
|
+
def delete(self, play_id):
|
|
49
|
+
return self._client.delete(f"/api/plays/{play_id}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Alerts(_Resource):
|
|
53
|
+
def list(self, *, active=True, triggered_since=None):
|
|
54
|
+
params = {"active": str(active).lower()}
|
|
55
|
+
if triggered_since:
|
|
56
|
+
params["triggered_since"] = triggered_since
|
|
57
|
+
return self._client.get("/api/alerts", **params)
|
|
58
|
+
|
|
59
|
+
def create(self, **fields):
|
|
60
|
+
return self._client.post("/api/alerts", json=fields)
|
|
61
|
+
|
|
62
|
+
def delete(self, alert_id):
|
|
63
|
+
return self._client.delete(f"/api/alerts/{alert_id}")
|
|
64
|
+
|
|
65
|
+
def toggle(self, alert_id):
|
|
66
|
+
return self._client.post(f"/api/alerts/{alert_id}/toggle")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Composite(_Resource):
|
|
70
|
+
def asset_detail(self, symbol, *, format=None):
|
|
71
|
+
params = {"format": format} if format else {}
|
|
72
|
+
return self._client.get(f"/api/asset/{symbol}/detail", **params)
|
|
73
|
+
|
|
74
|
+
def live_positions(self, *, asset=None, format=None):
|
|
75
|
+
params = {}
|
|
76
|
+
if asset:
|
|
77
|
+
params["asset"] = asset
|
|
78
|
+
if format:
|
|
79
|
+
params["format"] = format
|
|
80
|
+
return self._client.get("/api/positions/live", **params)
|
|
81
|
+
|
|
82
|
+
def batch(self, assets, *, include=None, format=None):
|
|
83
|
+
if isinstance(assets, (list, tuple, set)):
|
|
84
|
+
assets = ",".join(assets)
|
|
85
|
+
params = {"assets": assets}
|
|
86
|
+
if include:
|
|
87
|
+
params["include"] = ",".join(include) if not isinstance(include, str) else include
|
|
88
|
+
if format:
|
|
89
|
+
params["format"] = format
|
|
90
|
+
return self._client.get("/api/batch", **params)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class Scanner(_Resource):
|
|
94
|
+
def list(self, *, verdict=None, direction="both"):
|
|
95
|
+
params = {"direction": direction}
|
|
96
|
+
if verdict:
|
|
97
|
+
params["verdict"] = verdict
|
|
98
|
+
return self._client.get("/api/scanner", **params)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class Usage(_Resource):
|
|
102
|
+
def get(self, *, history=True, days=30):
|
|
103
|
+
return self._client.get("/api/usage", history=str(history).lower(), days=days)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class Keys(_Resource):
|
|
107
|
+
def list(self):
|
|
108
|
+
return self._client.get("/api/keys")
|
|
109
|
+
|
|
110
|
+
def create(self, name, *, scope="full"):
|
|
111
|
+
return self._client.post("/api/keys", json={"name": name, "scope": scope})
|
|
112
|
+
|
|
113
|
+
def revoke(self, key_id):
|
|
114
|
+
return self._client.delete(f"/api/keys/{key_id}")
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Unit tests for the Python SDK (httpx MockTransport — no network)."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
import httpx
|
|
5
|
+
|
|
6
|
+
from carrac import Client, AuthError, RateLimitError, NotFoundError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _mock_client(handler):
|
|
10
|
+
"""Create a Client backed by a MockTransport handler."""
|
|
11
|
+
transport = httpx.MockTransport(handler)
|
|
12
|
+
c = Client(api_key="test-key")
|
|
13
|
+
c._http = httpx.Client(
|
|
14
|
+
base_url=c.base_url,
|
|
15
|
+
transport=transport,
|
|
16
|
+
headers={"X-API-Key": c.api_key, "Accept": "application/json"},
|
|
17
|
+
)
|
|
18
|
+
return c
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TestAuth:
|
|
22
|
+
def test_requires_api_key(self, monkeypatch):
|
|
23
|
+
monkeypatch.delenv("CARRAC_API_KEY", raising=False)
|
|
24
|
+
monkeypatch.delenv("CONVEXITY_API_KEY", raising=False)
|
|
25
|
+
with pytest.raises(ValueError):
|
|
26
|
+
Client()
|
|
27
|
+
|
|
28
|
+
def test_uses_env_var_fallback(self, monkeypatch):
|
|
29
|
+
monkeypatch.setenv("CARRAC_API_KEY", "carrac_k_fromenv")
|
|
30
|
+
c = Client()
|
|
31
|
+
assert c.api_key == "carrac_k_fromenv"
|
|
32
|
+
|
|
33
|
+
def test_legacy_env_var_fallback(self, monkeypatch):
|
|
34
|
+
monkeypatch.delenv("CARRAC_API_KEY", raising=False)
|
|
35
|
+
monkeypatch.setenv("CONVEXITY_API_KEY", "cvx_k_fromenv")
|
|
36
|
+
c = Client()
|
|
37
|
+
assert c.api_key == "cvx_k_fromenv"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TestResources:
|
|
41
|
+
def test_charts_get_builds_correct_url(self):
|
|
42
|
+
captured = {}
|
|
43
|
+
|
|
44
|
+
def handler(req):
|
|
45
|
+
captured["url"] = str(req.url)
|
|
46
|
+
captured["headers"] = dict(req.headers)
|
|
47
|
+
return httpx.Response(200, json={"candles": [], "symbol": "BTC"})
|
|
48
|
+
|
|
49
|
+
c = _mock_client(handler)
|
|
50
|
+
out = c.charts.get("BTC", timeframe="1h", indicators="CPR,RSI")
|
|
51
|
+
assert "/api/chart-internal/BTC" in captured["url"]
|
|
52
|
+
assert "timeframe=1h" in captured["url"]
|
|
53
|
+
assert "indicators=CPR%2CRSI" in captured["url"]
|
|
54
|
+
assert captured["headers"]["x-api-key"] == "test-key"
|
|
55
|
+
assert out["symbol"] == "BTC"
|
|
56
|
+
|
|
57
|
+
def test_plays_create_posts_json(self):
|
|
58
|
+
captured = {}
|
|
59
|
+
|
|
60
|
+
def handler(req):
|
|
61
|
+
captured["method"] = req.method
|
|
62
|
+
captured["body"] = req.content.decode()
|
|
63
|
+
return httpx.Response(201, json={"id": 99, "title": "Test"})
|
|
64
|
+
|
|
65
|
+
c = _mock_client(handler)
|
|
66
|
+
out = c.plays.create(title="Test", type="directional", primary_asset="BTC")
|
|
67
|
+
assert captured["method"] == "POST"
|
|
68
|
+
assert '"title":"Test"' in captured["body"] or '"title": "Test"' in captured["body"]
|
|
69
|
+
assert out["id"] == 99
|
|
70
|
+
|
|
71
|
+
def test_composite_batch_serializes_list_assets(self):
|
|
72
|
+
captured = {}
|
|
73
|
+
|
|
74
|
+
def handler(req):
|
|
75
|
+
captured["url"] = str(req.url)
|
|
76
|
+
return httpx.Response(200, json={"assets": {}})
|
|
77
|
+
|
|
78
|
+
c = _mock_client(handler)
|
|
79
|
+
c.composite.batch(["BTC", "ETH"], include=["market", "scanner"])
|
|
80
|
+
assert "assets=BTC%2CETH" in captured["url"]
|
|
81
|
+
assert "include=market%2Cscanner" in captured["url"]
|
|
82
|
+
|
|
83
|
+
def test_usage_get_includes_history_param(self):
|
|
84
|
+
captured = {}
|
|
85
|
+
|
|
86
|
+
def handler(req):
|
|
87
|
+
captured["url"] = str(req.url)
|
|
88
|
+
return httpx.Response(200, json={"user_id": 1, "rest": {}, "mcp": {}})
|
|
89
|
+
|
|
90
|
+
c = _mock_client(handler)
|
|
91
|
+
c.usage.get(history=False, days=7)
|
|
92
|
+
assert "history=false" in captured["url"]
|
|
93
|
+
assert "days=7" in captured["url"]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class TestErrorHandling:
|
|
97
|
+
def test_401_raises_auth_error(self):
|
|
98
|
+
def handler(req):
|
|
99
|
+
return httpx.Response(401, json={"error": "bad key"})
|
|
100
|
+
|
|
101
|
+
c = _mock_client(handler)
|
|
102
|
+
c.max_retries = 0
|
|
103
|
+
with pytest.raises(AuthError) as exc:
|
|
104
|
+
c.charts.get("BTC")
|
|
105
|
+
assert exc.value.status_code == 401
|
|
106
|
+
|
|
107
|
+
def test_404_raises_notfound(self):
|
|
108
|
+
def handler(req):
|
|
109
|
+
return httpx.Response(404, json={"error": "not found"})
|
|
110
|
+
|
|
111
|
+
c = _mock_client(handler)
|
|
112
|
+
c.max_retries = 0
|
|
113
|
+
with pytest.raises(NotFoundError):
|
|
114
|
+
c.plays.get(999)
|
|
115
|
+
|
|
116
|
+
def test_429_raises_after_retries(self):
|
|
117
|
+
calls = {"n": 0}
|
|
118
|
+
|
|
119
|
+
def handler(req):
|
|
120
|
+
calls["n"] += 1
|
|
121
|
+
return httpx.Response(429, headers={"Retry-After": "2"},
|
|
122
|
+
json={"error": "rate limited", "limit": 240,
|
|
123
|
+
"window": "minute", "retry_after": 2})
|
|
124
|
+
|
|
125
|
+
c = _mock_client(handler)
|
|
126
|
+
c.max_retries = 2
|
|
127
|
+
with pytest.raises(RateLimitError) as exc:
|
|
128
|
+
c.charts.get("BTC")
|
|
129
|
+
assert calls["n"] == 3 # initial + 2 retries
|
|
130
|
+
# retry_after comes from the Retry-After header, not the JSON body.
|
|
131
|
+
assert exc.value.retry_after == 2
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class TestPaths:
|
|
135
|
+
def test_alerts_delete_builds_correct_url(self):
|
|
136
|
+
captured = {}
|
|
137
|
+
|
|
138
|
+
def handler(req):
|
|
139
|
+
captured["method"] = req.method
|
|
140
|
+
captured["path"] = req.url.path
|
|
141
|
+
return httpx.Response(204)
|
|
142
|
+
|
|
143
|
+
c = _mock_client(handler)
|
|
144
|
+
c.alerts.delete(42)
|
|
145
|
+
assert captured["method"] == "DELETE"
|
|
146
|
+
assert captured["path"] == "/api/alerts/42"
|
|
147
|
+
|
|
148
|
+
def test_alerts_toggle_posts_to_correct_url(self):
|
|
149
|
+
captured = {}
|
|
150
|
+
|
|
151
|
+
def handler(req):
|
|
152
|
+
captured["method"] = req.method
|
|
153
|
+
captured["path"] = req.url.path
|
|
154
|
+
return httpx.Response(200, json={"id": 7, "active": False})
|
|
155
|
+
|
|
156
|
+
c = _mock_client(handler)
|
|
157
|
+
c.alerts.toggle(7)
|
|
158
|
+
assert captured["method"] == "POST"
|
|
159
|
+
assert captured["path"] == "/api/alerts/7/toggle"
|
|
160
|
+
|
|
161
|
+
def test_keys_revoke_builds_correct_url(self):
|
|
162
|
+
captured = {}
|
|
163
|
+
|
|
164
|
+
def handler(req):
|
|
165
|
+
captured["method"] = req.method
|
|
166
|
+
captured["path"] = req.url.path
|
|
167
|
+
return httpx.Response(200, json={"success": True})
|
|
168
|
+
|
|
169
|
+
c = _mock_client(handler)
|
|
170
|
+
c.keys.revoke(5)
|
|
171
|
+
assert captured["method"] == "DELETE"
|
|
172
|
+
assert captured["path"] == "/api/keys/5"
|