tickerall 0.1.0__py3-none-any.whl
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.
- tickerall/__init__.py +80 -0
- tickerall/client.py +341 -0
- tickerall/errors.py +138 -0
- tickerall/namespaces/__init__.py +1 -0
- tickerall/namespaces/accounts.py +56 -0
- tickerall/namespaces/candles.py +42 -0
- tickerall/namespaces/history.py +68 -0
- tickerall/namespaces/orders.py +62 -0
- tickerall/namespaces/positions.py +74 -0
- tickerall/namespaces/sessions.py +130 -0
- tickerall/namespaces/stream.py +334 -0
- tickerall/py.typed +0 -0
- tickerall/types.py +411 -0
- tickerall-0.1.0.dist-info/METADATA +227 -0
- tickerall-0.1.0.dist-info/RECORD +17 -0
- tickerall-0.1.0.dist-info/WHEEL +4 -0
- tickerall-0.1.0.dist-info/licenses/LICENSE +21 -0
tickerall/__init__.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""TickerAll — official Python client for the TickerAll REST + WebSocket API.
|
|
2
|
+
|
|
3
|
+
Place trades, stream live market data, and manage broker sessions
|
|
4
|
+
programmatically — without an MT4/MT5 terminal in the path.
|
|
5
|
+
|
|
6
|
+
from tickerall import Tickerall
|
|
7
|
+
|
|
8
|
+
client = Tickerall(api_key="cf_live_...")
|
|
9
|
+
result = client.sessions.start(
|
|
10
|
+
broker="mt5", server="Exness-MT5Trial14",
|
|
11
|
+
account=415724042, password="...",
|
|
12
|
+
)
|
|
13
|
+
stream = client.stream.connect()
|
|
14
|
+
stream.on("tick", lambda e: print(e.symbol, e.bid, e.ask))
|
|
15
|
+
stream.subscribe_ticks(result.account_id, ["BTCUSDm"])
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from .client import Tickerall
|
|
21
|
+
from .errors import (
|
|
22
|
+
TickerallApiError,
|
|
23
|
+
TickerallAuthError,
|
|
24
|
+
TickerallBrokerError,
|
|
25
|
+
TickerallForbiddenError,
|
|
26
|
+
TickerallNotFoundError,
|
|
27
|
+
TickerallServiceUnavailableError,
|
|
28
|
+
TickerallValidationError,
|
|
29
|
+
)
|
|
30
|
+
from .namespaces.stream import TickerallStream
|
|
31
|
+
from .types import (
|
|
32
|
+
AccountDetail,
|
|
33
|
+
AccountEvent,
|
|
34
|
+
AccountInfo,
|
|
35
|
+
AccountListing,
|
|
36
|
+
AccountSnapshot,
|
|
37
|
+
Candle,
|
|
38
|
+
ClosePositionResult,
|
|
39
|
+
HistoryTrade,
|
|
40
|
+
ModifyPositionResult,
|
|
41
|
+
PendingRearmAccount,
|
|
42
|
+
PlaceOrderResult,
|
|
43
|
+
Position,
|
|
44
|
+
PositionEvent,
|
|
45
|
+
SessionStartResult,
|
|
46
|
+
SymbolSpec,
|
|
47
|
+
TickEvent,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
__version__ = "0.1.0"
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"Tickerall",
|
|
54
|
+
"TickerallStream",
|
|
55
|
+
# errors
|
|
56
|
+
"TickerallApiError",
|
|
57
|
+
"TickerallAuthError",
|
|
58
|
+
"TickerallForbiddenError",
|
|
59
|
+
"TickerallValidationError",
|
|
60
|
+
"TickerallNotFoundError",
|
|
61
|
+
"TickerallBrokerError",
|
|
62
|
+
"TickerallServiceUnavailableError",
|
|
63
|
+
# types
|
|
64
|
+
"SessionStartResult",
|
|
65
|
+
"PendingRearmAccount",
|
|
66
|
+
"AccountListing",
|
|
67
|
+
"AccountInfo",
|
|
68
|
+
"AccountDetail",
|
|
69
|
+
"Position",
|
|
70
|
+
"SymbolSpec",
|
|
71
|
+
"Candle",
|
|
72
|
+
"HistoryTrade",
|
|
73
|
+
"PlaceOrderResult",
|
|
74
|
+
"ClosePositionResult",
|
|
75
|
+
"ModifyPositionResult",
|
|
76
|
+
"TickEvent",
|
|
77
|
+
"PositionEvent",
|
|
78
|
+
"AccountEvent",
|
|
79
|
+
"AccountSnapshot",
|
|
80
|
+
]
|
tickerall/client.py
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""The TickerAll client — synchronous REST + a background-thread WebSocket
|
|
2
|
+
stream.
|
|
3
|
+
|
|
4
|
+
Design mirrors the official TypeScript client: typed namespaces
|
|
5
|
+
(``sessions`` / ``accounts`` / ``orders`` / ``positions`` / ``candles`` /
|
|
6
|
+
``history`` / ``stream``), stable idempotency keys on state-changing calls,
|
|
7
|
+
transparent re-arm of kept sessions, and a typed error hierarchy.
|
|
8
|
+
|
|
9
|
+
All REST methods are synchronous and thread-safe, so this drops cleanly into
|
|
10
|
+
sync codebases (incl. the ``MetaTrader5`` ecosystem) and into async apps via
|
|
11
|
+
``asyncio.to_thread``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import time
|
|
17
|
+
import uuid
|
|
18
|
+
from typing import Any, Dict, List, Optional
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
from .errors import (
|
|
23
|
+
TickerallApiError,
|
|
24
|
+
TickerallServiceUnavailableError,
|
|
25
|
+
map_error_response,
|
|
26
|
+
)
|
|
27
|
+
from .namespaces.accounts import AccountsNamespace
|
|
28
|
+
from .namespaces.candles import CandlesNamespace
|
|
29
|
+
from .namespaces.history import HistoryNamespace
|
|
30
|
+
from .namespaces.orders import OrdersNamespace
|
|
31
|
+
from .namespaces.positions import PositionsNamespace
|
|
32
|
+
from .namespaces.sessions import SessionsNamespace
|
|
33
|
+
from .namespaces.stream import StreamNamespace
|
|
34
|
+
|
|
35
|
+
DEFAULT_BASE_URL = "https://api.tickerall.com"
|
|
36
|
+
DEFAULT_STREAM_URL = "wss://api.tickerall.com/v1/stream"
|
|
37
|
+
DEFAULT_TIMEOUT_S = 30.0
|
|
38
|
+
SDK_VERSION = "0.1.0"
|
|
39
|
+
|
|
40
|
+
# Ceiling on how long a queue_if_reconnecting call waits across replays.
|
|
41
|
+
DEFAULT_QUEUE_MAX_S = 60.0
|
|
42
|
+
# Backoff between replay attempts (seconds); each clamped to the remaining time.
|
|
43
|
+
_REPLAY_DELAYS_S = [0.5, 1.0, 2.0, 4.0, 8.0]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Tickerall:
|
|
47
|
+
"""Synchronous client for the TickerAll REST + WebSocket API.
|
|
48
|
+
|
|
49
|
+
Example::
|
|
50
|
+
|
|
51
|
+
from tickerall import Tickerall
|
|
52
|
+
|
|
53
|
+
client = Tickerall(api_key="cf_live_...")
|
|
54
|
+
result = client.sessions.start(
|
|
55
|
+
broker="mt5", server="Exness-MT5Trial14",
|
|
56
|
+
account=415724042, password="...",
|
|
57
|
+
)
|
|
58
|
+
bars = client.candles.get(result.account_id, symbol="BTCUSDm", hours=24)
|
|
59
|
+
client.sessions.end(result.account_id)
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
api_key: str,
|
|
65
|
+
*,
|
|
66
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
67
|
+
stream_url: str = DEFAULT_STREAM_URL,
|
|
68
|
+
timeout: float = DEFAULT_TIMEOUT_S,
|
|
69
|
+
user_agent: Optional[str] = None,
|
|
70
|
+
http_client: Optional[httpx.Client] = None,
|
|
71
|
+
on_rearm: Optional["object"] = None,
|
|
72
|
+
) -> None:
|
|
73
|
+
if not api_key:
|
|
74
|
+
raise ValueError("Tickerall: `api_key` is required.")
|
|
75
|
+
self._api_key = api_key
|
|
76
|
+
self._base_url = base_url.rstrip("/")
|
|
77
|
+
self._stream_url = stream_url
|
|
78
|
+
self._timeout = timeout
|
|
79
|
+
self._user_agent = (
|
|
80
|
+
f"{user_agent} tickerall-python/{SDK_VERSION}"
|
|
81
|
+
if user_agent
|
|
82
|
+
else f"tickerall-python/{SDK_VERSION}"
|
|
83
|
+
)
|
|
84
|
+
self._on_rearm = on_rearm
|
|
85
|
+
self._owns_http = http_client is None
|
|
86
|
+
self._http = http_client or httpx.Client()
|
|
87
|
+
|
|
88
|
+
# Kept-session credentials — RAM only, NEVER persisted. Lets the client
|
|
89
|
+
# transparently re-arm an account that has gone cold (e.g. an atlas
|
|
90
|
+
# restart dropped its server-side credentials).
|
|
91
|
+
self._kept_creds: Dict[str, Dict[str, Any]] = {}
|
|
92
|
+
|
|
93
|
+
# Namespaces
|
|
94
|
+
self.sessions = SessionsNamespace(self)
|
|
95
|
+
self.accounts = AccountsNamespace(self)
|
|
96
|
+
self.orders = OrdersNamespace(self)
|
|
97
|
+
self.positions = PositionsNamespace(self)
|
|
98
|
+
self.candles = CandlesNamespace(self)
|
|
99
|
+
self.history = HistoryNamespace(self)
|
|
100
|
+
self.stream = StreamNamespace(self, self._stream_url, self._api_key, self._user_agent)
|
|
101
|
+
|
|
102
|
+
# ── Context manager ──────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
def __enter__(self) -> "Tickerall":
|
|
105
|
+
return self
|
|
106
|
+
|
|
107
|
+
def __exit__(self, *exc: Any) -> None:
|
|
108
|
+
self.close()
|
|
109
|
+
|
|
110
|
+
def close(self) -> None:
|
|
111
|
+
"""Close the underlying HTTP connection pool (if the client owns it)."""
|
|
112
|
+
if self._owns_http:
|
|
113
|
+
self._http.close()
|
|
114
|
+
|
|
115
|
+
# ── Internal request machinery (used by namespaces) ──────────────────────
|
|
116
|
+
|
|
117
|
+
def _request(
|
|
118
|
+
self,
|
|
119
|
+
method: str,
|
|
120
|
+
path: str,
|
|
121
|
+
*,
|
|
122
|
+
body: Any = None,
|
|
123
|
+
idempotency_key: Optional[str] = None,
|
|
124
|
+
timeout: Optional[float] = None,
|
|
125
|
+
expect_no_content: bool = False,
|
|
126
|
+
account_id: Optional[str] = None,
|
|
127
|
+
) -> Any:
|
|
128
|
+
"""Run a request, transparently re-arming a kept session and retrying
|
|
129
|
+
once if the target account has gone cold."""
|
|
130
|
+
return self._request_with_rearm(
|
|
131
|
+
method,
|
|
132
|
+
path,
|
|
133
|
+
body=body,
|
|
134
|
+
idempotency_key=idempotency_key,
|
|
135
|
+
timeout=timeout,
|
|
136
|
+
expect_no_content=expect_no_content,
|
|
137
|
+
account_id=account_id,
|
|
138
|
+
is_rearm_retry=False,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def _request_with_rearm(
|
|
142
|
+
self,
|
|
143
|
+
method: str,
|
|
144
|
+
path: str,
|
|
145
|
+
*,
|
|
146
|
+
body: Any,
|
|
147
|
+
idempotency_key: Optional[str],
|
|
148
|
+
timeout: Optional[float],
|
|
149
|
+
expect_no_content: bool,
|
|
150
|
+
account_id: Optional[str],
|
|
151
|
+
is_rearm_retry: bool,
|
|
152
|
+
) -> Any:
|
|
153
|
+
try:
|
|
154
|
+
return self._request_once(
|
|
155
|
+
method,
|
|
156
|
+
path,
|
|
157
|
+
body=body,
|
|
158
|
+
idempotency_key=idempotency_key,
|
|
159
|
+
timeout=timeout,
|
|
160
|
+
expect_no_content=expect_no_content,
|
|
161
|
+
)
|
|
162
|
+
except TickerallApiError as err:
|
|
163
|
+
if (
|
|
164
|
+
not is_rearm_retry
|
|
165
|
+
and account_id is not None
|
|
166
|
+
and account_id in self._kept_creds
|
|
167
|
+
and err.code == "BROKER_ACCOUNT_NOT_HOT"
|
|
168
|
+
):
|
|
169
|
+
self.rearm(account_id)
|
|
170
|
+
return self._request_with_rearm(
|
|
171
|
+
method,
|
|
172
|
+
path,
|
|
173
|
+
body=body,
|
|
174
|
+
idempotency_key=idempotency_key,
|
|
175
|
+
timeout=timeout,
|
|
176
|
+
expect_no_content=expect_no_content,
|
|
177
|
+
account_id=account_id,
|
|
178
|
+
is_rearm_retry=True,
|
|
179
|
+
)
|
|
180
|
+
raise
|
|
181
|
+
|
|
182
|
+
def _request_once(
|
|
183
|
+
self,
|
|
184
|
+
method: str,
|
|
185
|
+
path: str,
|
|
186
|
+
*,
|
|
187
|
+
body: Any,
|
|
188
|
+
idempotency_key: Optional[str],
|
|
189
|
+
timeout: Optional[float],
|
|
190
|
+
expect_no_content: bool,
|
|
191
|
+
) -> Any:
|
|
192
|
+
url = f"{self._base_url}{path}"
|
|
193
|
+
headers = {
|
|
194
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
195
|
+
"User-Agent": self._user_agent,
|
|
196
|
+
"Accept": "application/json",
|
|
197
|
+
}
|
|
198
|
+
if body is not None:
|
|
199
|
+
headers["Content-Type"] = "application/json"
|
|
200
|
+
if idempotency_key:
|
|
201
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
response = self._http.request(
|
|
205
|
+
method,
|
|
206
|
+
url,
|
|
207
|
+
headers=headers,
|
|
208
|
+
json=body if body is not None else None,
|
|
209
|
+
timeout=timeout if timeout is not None else self._timeout,
|
|
210
|
+
)
|
|
211
|
+
except httpx.TimeoutException as err:
|
|
212
|
+
raise TickerallServiceUnavailableError(
|
|
213
|
+
status=0, code="REQUEST_TIMEOUT", message=str(err) or "Request timed out"
|
|
214
|
+
) from err
|
|
215
|
+
except httpx.RequestError as err:
|
|
216
|
+
# fetch threw before any response — TickerAll was unreachable.
|
|
217
|
+
raise TickerallServiceUnavailableError(
|
|
218
|
+
status=0,
|
|
219
|
+
code="NETWORK_ERROR",
|
|
220
|
+
message=str(err) or "Network request failed",
|
|
221
|
+
details=repr(err),
|
|
222
|
+
) from err
|
|
223
|
+
|
|
224
|
+
request_id = response.headers.get("x-request-id")
|
|
225
|
+
|
|
226
|
+
if not response.is_success:
|
|
227
|
+
err_body = _safe_read_error_body(response)
|
|
228
|
+
raise map_error_response(
|
|
229
|
+
status=response.status_code,
|
|
230
|
+
code=(err_body or {}).get("error") or f"HTTP_{response.status_code}",
|
|
231
|
+
message=(err_body or {}).get("message")
|
|
232
|
+
or response.reason_phrase
|
|
233
|
+
or "Request failed",
|
|
234
|
+
request_id=request_id,
|
|
235
|
+
details=(err_body or {}).get("details"),
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
if expect_no_content or response.status_code == 204 or not response.content:
|
|
239
|
+
return None
|
|
240
|
+
return response.json()
|
|
241
|
+
|
|
242
|
+
def _request_idempotent(
|
|
243
|
+
self,
|
|
244
|
+
method: str,
|
|
245
|
+
path: str,
|
|
246
|
+
*,
|
|
247
|
+
body: Any = None,
|
|
248
|
+
account_id: Optional[str] = None,
|
|
249
|
+
idempotency_key: Optional[str] = None,
|
|
250
|
+
queue_if_reconnecting: bool = False,
|
|
251
|
+
queue_max_s: float = DEFAULT_QUEUE_MAX_S,
|
|
252
|
+
timeout: Optional[float] = None,
|
|
253
|
+
) -> Any:
|
|
254
|
+
"""State-changing request with a stable idempotency key.
|
|
255
|
+
|
|
256
|
+
The key is fixed ONCE so every replay attempt carries the same key —
|
|
257
|
+
the server then dedupes any attempt that already executed, which is
|
|
258
|
+
what makes queue-and-replay safe (no double trade).
|
|
259
|
+
"""
|
|
260
|
+
key = idempotency_key or _generate_idempotency_key()
|
|
261
|
+
if not queue_if_reconnecting:
|
|
262
|
+
# Default: fail fast. A transient connectivity failure surfaces as
|
|
263
|
+
# TickerallServiceUnavailableError for the caller to re-decide.
|
|
264
|
+
return self._request(
|
|
265
|
+
method, path, body=body, idempotency_key=key, timeout=timeout, account_id=account_id
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
# Queue-and-replay: retry transient failures with backoff until the
|
|
269
|
+
# call succeeds or queue_max_s elapses. (Calls are sequential in a sync
|
|
270
|
+
# client, so in-order replay is naturally preserved.)
|
|
271
|
+
deadline = time.monotonic() + queue_max_s
|
|
272
|
+
attempt = 0
|
|
273
|
+
while True:
|
|
274
|
+
try:
|
|
275
|
+
return self._request(
|
|
276
|
+
method, path, body=body, idempotency_key=key, timeout=timeout, account_id=account_id
|
|
277
|
+
)
|
|
278
|
+
except TickerallApiError as err:
|
|
279
|
+
remaining = deadline - time.monotonic()
|
|
280
|
+
if not err.transient or remaining <= 0:
|
|
281
|
+
raise
|
|
282
|
+
base = _REPLAY_DELAYS_S[min(attempt, len(_REPLAY_DELAYS_S) - 1)]
|
|
283
|
+
attempt += 1
|
|
284
|
+
time.sleep(min(base, remaining))
|
|
285
|
+
|
|
286
|
+
# ── Kept sessions (auto re-arm) ──────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
def _register_kept_session(self, account_id: str, params: Dict[str, Any]) -> None:
|
|
289
|
+
self._kept_creds[account_id] = params
|
|
290
|
+
|
|
291
|
+
def _unregister_kept_session(self, account_id: str) -> None:
|
|
292
|
+
self._kept_creds.pop(account_id, None)
|
|
293
|
+
|
|
294
|
+
def kept_session_ids(self) -> List[str]:
|
|
295
|
+
"""Account IDs currently kept alive (have cached re-arm credentials)."""
|
|
296
|
+
return list(self._kept_creds.keys())
|
|
297
|
+
|
|
298
|
+
def rearm(self, account_id: str) -> None:
|
|
299
|
+
"""Re-supply credentials for a kept account (a fresh session.start) to
|
|
300
|
+
bring a cold connection back. Raises if the account isn't kept alive."""
|
|
301
|
+
creds = self._kept_creds.get(account_id)
|
|
302
|
+
if creds is None:
|
|
303
|
+
raise TickerallApiError(
|
|
304
|
+
status=0,
|
|
305
|
+
code="NO_KEPT_CREDENTIALS",
|
|
306
|
+
message=f"No kept credentials for account {account_id}; call sessions.keep_alive first.",
|
|
307
|
+
)
|
|
308
|
+
if callable(self._on_rearm):
|
|
309
|
+
try:
|
|
310
|
+
self._on_rearm(account_id)
|
|
311
|
+
except Exception:
|
|
312
|
+
pass
|
|
313
|
+
# session.start carries no account_id, so this never recurses into re-arm.
|
|
314
|
+
self._request_idempotent("POST", "/v1/sessions", body=creds)
|
|
315
|
+
|
|
316
|
+
def rearm_all(self) -> None:
|
|
317
|
+
"""Re-arm every kept session — best-effort (one failure won't block the
|
|
318
|
+
others). Used after the stream reconnects (atlas may have restarted)."""
|
|
319
|
+
for account_id in list(self._kept_creds.keys()):
|
|
320
|
+
try:
|
|
321
|
+
self.rearm(account_id)
|
|
322
|
+
except Exception:
|
|
323
|
+
pass
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _safe_read_error_body(response: httpx.Response) -> Optional[Dict[str, Any]]:
|
|
327
|
+
try:
|
|
328
|
+
text = response.text
|
|
329
|
+
if not text:
|
|
330
|
+
return None
|
|
331
|
+
try:
|
|
332
|
+
data = response.json()
|
|
333
|
+
return data if isinstance(data, dict) else {"error": "PARSE_ERROR", "message": text}
|
|
334
|
+
except Exception:
|
|
335
|
+
return {"error": "PARSE_ERROR", "message": text}
|
|
336
|
+
except Exception:
|
|
337
|
+
return None
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _generate_idempotency_key() -> str:
|
|
341
|
+
return str(uuid.uuid4())
|
tickerall/errors.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Typed exceptions for the TickerAll SDK.
|
|
2
|
+
|
|
3
|
+
Mirrors the error model of the TypeScript client: a single base class
|
|
4
|
+
(:class:`TickerallApiError`) carrying ``status`` / ``code`` / ``request_id`` /
|
|
5
|
+
``details`` / ``transient``, plus narrow subclasses you can catch
|
|
6
|
+
selectively. :func:`map_error_response` turns an HTTP error body into the
|
|
7
|
+
right subclass.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, Optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TickerallApiError(Exception):
|
|
16
|
+
"""Base error for every failed TickerAll request.
|
|
17
|
+
|
|
18
|
+
Attributes:
|
|
19
|
+
status: HTTP status code (0 for failures that never reached a response,
|
|
20
|
+
e.g. a network error or timeout).
|
|
21
|
+
code: Machine-readable error code, e.g. ``BROKER_REJECTED`` or
|
|
22
|
+
``REQUEST_TIMEOUT``.
|
|
23
|
+
request_id: The ``x-request-id`` header, when present — quote it in
|
|
24
|
+
support requests.
|
|
25
|
+
details: Optional structured detail the server attached.
|
|
26
|
+
transient: ``True`` when the failure was a momentary connectivity issue
|
|
27
|
+
(network blip, deploy, restart) that is safe to retry. ``False``
|
|
28
|
+
for application errors (auth, validation, broker rejection) that
|
|
29
|
+
will not change on retry.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
*,
|
|
35
|
+
status: int,
|
|
36
|
+
code: str,
|
|
37
|
+
message: str,
|
|
38
|
+
request_id: Optional[str] = None,
|
|
39
|
+
details: Any = None,
|
|
40
|
+
transient: bool = False,
|
|
41
|
+
) -> None:
|
|
42
|
+
super().__init__(message)
|
|
43
|
+
self.status = status
|
|
44
|
+
self.code = code
|
|
45
|
+
self.message = message
|
|
46
|
+
self.request_id = request_id
|
|
47
|
+
self.details = details
|
|
48
|
+
self.transient = transient
|
|
49
|
+
|
|
50
|
+
def __str__(self) -> str: # pragma: no cover - cosmetic
|
|
51
|
+
rid = f" (request_id={self.request_id})" if self.request_id else ""
|
|
52
|
+
return f"[{self.code}] {self.message}{rid}"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class TickerallAuthError(TickerallApiError):
|
|
56
|
+
"""401 — your API key is missing, malformed, or revoked."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class TickerallForbiddenError(TickerallApiError):
|
|
60
|
+
"""403 — authenticated, but not allowed to do this (plan limit, reserved
|
|
61
|
+
resource, role)."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class TickerallValidationError(TickerallApiError):
|
|
65
|
+
"""400 / 422 — the request itself was malformed (bad symbol, missing
|
|
66
|
+
field, out-of-range volume)."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class TickerallNotFoundError(TickerallApiError):
|
|
70
|
+
"""404 — the account, position, or resource does not exist."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class TickerallBrokerError(TickerallApiError):
|
|
74
|
+
"""The broker rejected or could not satisfy the request (rejected trade,
|
|
75
|
+
auth failure at the broker, account not hot, …)."""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class TickerallServiceUnavailableError(TickerallApiError):
|
|
79
|
+
"""TickerAll was momentarily unreachable — a network blip, a deploy, or
|
|
80
|
+
the service restarting.
|
|
81
|
+
|
|
82
|
+
This is NOT your fault and NOT a broker rejection: the request never
|
|
83
|
+
reached a verdict, so it is safe to retry. ``transient`` is always
|
|
84
|
+
``True``. By default a trade fails fast with this error so you can
|
|
85
|
+
re-decide with fresh prices; pass ``queue_if_reconnecting=True`` to
|
|
86
|
+
instead queue-and-replay it (with its stable idempotency key, so it
|
|
87
|
+
cannot double-execute) once connectivity returns.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
91
|
+
kwargs["transient"] = True
|
|
92
|
+
super().__init__(**kwargs)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
_BROKER_CODES = frozenset(
|
|
96
|
+
{
|
|
97
|
+
"BROKER_REJECTED",
|
|
98
|
+
"BROKER_AUTH_FAILED",
|
|
99
|
+
"BROKER_UNREACHABLE",
|
|
100
|
+
"BROKER_ACCOUNT_NOT_HOT",
|
|
101
|
+
"BROKER_ACCOUNT_ALREADY_LINKED",
|
|
102
|
+
"BROKER_ACCOUNT_NOT_FOUND",
|
|
103
|
+
"TICKET_NOT_FOUND",
|
|
104
|
+
}
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def map_error_response(
|
|
109
|
+
*,
|
|
110
|
+
status: int,
|
|
111
|
+
code: str,
|
|
112
|
+
message: str,
|
|
113
|
+
request_id: Optional[str] = None,
|
|
114
|
+
details: Any = None,
|
|
115
|
+
) -> TickerallApiError:
|
|
116
|
+
"""Pick the right exception subclass for a server error response."""
|
|
117
|
+
init = dict(
|
|
118
|
+
status=status, code=code, message=message, request_id=request_id, details=details
|
|
119
|
+
)
|
|
120
|
+
# The pool draining for a deploy/restart is the canonical transient case —
|
|
121
|
+
# a retry lands on the fresh instance.
|
|
122
|
+
if code == "POOL_SHUTTING_DOWN":
|
|
123
|
+
return TickerallServiceUnavailableError(**init)
|
|
124
|
+
if code in _BROKER_CODES:
|
|
125
|
+
return TickerallBrokerError(**init)
|
|
126
|
+
if status == 401:
|
|
127
|
+
return TickerallAuthError(**init)
|
|
128
|
+
if status == 403:
|
|
129
|
+
return TickerallForbiddenError(**init)
|
|
130
|
+
if status == 404:
|
|
131
|
+
return TickerallNotFoundError(**init)
|
|
132
|
+
if status in (400, 422):
|
|
133
|
+
return TickerallValidationError(**init)
|
|
134
|
+
# A bare 502/503 with no recognised broker code is the proxy reporting
|
|
135
|
+
# TickerAll itself as momentarily down (e.g. mid-deploy) — transient.
|
|
136
|
+
if status in (502, 503):
|
|
137
|
+
return TickerallServiceUnavailableError(**init)
|
|
138
|
+
return TickerallApiError(**init)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Typed API namespaces for the TickerAll client."""
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, List, Optional
|
|
4
|
+
from urllib.parse import quote
|
|
5
|
+
|
|
6
|
+
from ..types import AccountDetail, AccountListing, SymbolSpec
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from ..client import Tickerall
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AccountsNamespace:
|
|
13
|
+
"""List and inspect your connected broker accounts."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, client: "Tickerall") -> None:
|
|
16
|
+
self._client = client
|
|
17
|
+
|
|
18
|
+
def list(self, *, timeout: Optional[float] = None) -> List[AccountListing]:
|
|
19
|
+
"""All broker accounts linked to your API key."""
|
|
20
|
+
res = self._client._request("GET", "/v1/accounts", timeout=timeout)
|
|
21
|
+
return [AccountListing.from_dict(a) for a in res or []]
|
|
22
|
+
|
|
23
|
+
def get(self, account_id: str, *, timeout: Optional[float] = None) -> AccountDetail:
|
|
24
|
+
"""A full snapshot of one account — balance/equity info and open
|
|
25
|
+
positions when online, or an offline hint when its connection is
|
|
26
|
+
cold."""
|
|
27
|
+
res = self._client._request(
|
|
28
|
+
"GET",
|
|
29
|
+
f"/v1/accounts/{quote(account_id, safe='')}",
|
|
30
|
+
account_id=account_id,
|
|
31
|
+
timeout=timeout,
|
|
32
|
+
)
|
|
33
|
+
return AccountDetail.from_dict(res)
|
|
34
|
+
|
|
35
|
+
def symbols(self, account_id: str, *, timeout: Optional[float] = None) -> List[str]:
|
|
36
|
+
"""The broker-native symbol names this account can trade."""
|
|
37
|
+
res = self._client._request(
|
|
38
|
+
"GET",
|
|
39
|
+
f"/v1/accounts/{quote(account_id, safe='')}/symbols",
|
|
40
|
+
account_id=account_id,
|
|
41
|
+
timeout=timeout,
|
|
42
|
+
)
|
|
43
|
+
return list((res or {}).get("symbols", []))
|
|
44
|
+
|
|
45
|
+
def symbol_specs(self, account_id: str, *, timeout: Optional[float] = None) -> List[SymbolSpec]:
|
|
46
|
+
"""Per-symbol volume specs (min / max / step) for validating an order
|
|
47
|
+
size before placing it. Each spec carries ``spec_source``: ``'broker'``
|
|
48
|
+
(authoritative) or ``'derived'`` (best-effort). MT5 only; an MT4
|
|
49
|
+
account returns an empty list."""
|
|
50
|
+
res = self._client._request(
|
|
51
|
+
"GET",
|
|
52
|
+
f"/v1/accounts/{quote(account_id, safe='')}/symbol-specs",
|
|
53
|
+
account_id=account_id,
|
|
54
|
+
timeout=timeout,
|
|
55
|
+
)
|
|
56
|
+
return [SymbolSpec.from_dict(s) for s in (res or {}).get("specs", [])]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, List, Optional
|
|
4
|
+
from urllib.parse import quote, urlencode
|
|
5
|
+
|
|
6
|
+
from ..types import Candle, Timeframe
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from ..client import Tickerall
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CandlesNamespace:
|
|
13
|
+
"""Fetch historical OHLC candles."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, client: "Tickerall") -> None:
|
|
16
|
+
self._client = client
|
|
17
|
+
|
|
18
|
+
def get(
|
|
19
|
+
self,
|
|
20
|
+
account_id: str,
|
|
21
|
+
*,
|
|
22
|
+
symbol: str,
|
|
23
|
+
hours: int,
|
|
24
|
+
timeframe: Optional[Timeframe] = None,
|
|
25
|
+
timeout: Optional[float] = None,
|
|
26
|
+
) -> List[Candle]:
|
|
27
|
+
"""Historical OHLC candles for ``symbol`` from a connected account.
|
|
28
|
+
|
|
29
|
+
Coarser timeframes reach further back; pass a large ``hours`` and take
|
|
30
|
+
what comes back. Deep look-backs are served on an isolated history
|
|
31
|
+
connection and won't disturb your live tick stream.
|
|
32
|
+
|
|
33
|
+
Example::
|
|
34
|
+
|
|
35
|
+
bars = client.candles.get(account_id, symbol="BTCUSDm", hours=8760, timeframe="D1")
|
|
36
|
+
"""
|
|
37
|
+
query = {"symbol": symbol, "hours": str(hours)}
|
|
38
|
+
if timeframe:
|
|
39
|
+
query["timeframe"] = timeframe
|
|
40
|
+
path = f"/v1/accounts/{quote(account_id, safe='')}/candles?{urlencode(query)}"
|
|
41
|
+
res = self._client._request("GET", path, timeout=timeout)
|
|
42
|
+
return [Candle.from_dict(c) for c in (res or {}).get("candles", [])]
|