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 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", [])]