sahiapi 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.
@@ -0,0 +1,315 @@
1
+ """Scrip master: the daily instrument file and lookups over it.
2
+
3
+ The scrip master is a CSV of every tradable and quotable instrument, rebuilt once
4
+ per trading day around 06:00 IST. Its ``id`` column is the ``instrument_id`` every
5
+ market-data call takes, and its ``symbol`` and ``segment`` columns are what the
6
+ order endpoints take: ``segment`` is the ``exchange_segment`` of an order, while
7
+ ``exchange`` is the listing exchange and is *not* that field — every NSE option
8
+ carries ``exchange=NSE`` and ``segment=NFO``. Download the file once at start of
9
+ day and look instruments up locally.
10
+
11
+ The file is parsed by its header row rather than a fixed column list, so the SDK
12
+ tracks whatever columns the file carries. Each row is returned as a ``dict`` of
13
+ strings, except a small set of always-integer columns (``id``, ``lot_size``,
14
+ ``freeze_quantity``, ``token``, ``price_precision``) which are cast to ``int``
15
+ when present. Money, tick sizes and ``price_multiplier`` are left as the strings
16
+ the file holds — some are rupees, some paise, and a commodity multiplier is
17
+ fractional — so the SDK never guesses a unit; see the API reference's Scrip master
18
+ page for which column is which.
19
+
20
+ One parsed copy is shared by every client in the process, keyed by URL: the file
21
+ is around 10 MB and costs well over a hundred megabytes once parsed, and it is
22
+ the same file for every account.
23
+
24
+ The download URL is not part of the public API reference; it is provided when
25
+ your API access is set up. Pass it as ``scrip_master_url`` to :class:`SahiAPI`, or
26
+ set ``SAHI_SCRIP_MASTER_URL``. Until one is given, these methods raise
27
+ :class:`SahiConfigurationException`.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import csv
33
+ import io
34
+ import threading
35
+ import time
36
+ from dataclasses import dataclass, field
37
+ from email.utils import parsedate_to_datetime
38
+ from typing import Any
39
+
40
+ import httpx
41
+
42
+ from .base import EndpointGroup
43
+ from .exceptions import (
44
+ SahiAPIException,
45
+ SahiConfigurationException,
46
+ SahiConnectionException,
47
+ SahiTimeoutException,
48
+ )
49
+
50
+ __all__ = ["_Instruments"]
51
+
52
+ # Columns that are always integers where present. Everything else stays a string,
53
+ # because the file mixes rupee and paise money columns and the SDK never guesses.
54
+ # price_multiplier is deliberately not here: it is integral for most rows but
55
+ # fractional on MCX commodities (0.125 on an 8-gram GOLDGUINEA contract), and a
56
+ # column that is an int for most rows and a str for the rest is worse than either.
57
+ _INT_COLUMNS = ("id", "lot_size", "freeze_quantity", "token", "price_precision")
58
+
59
+ # Seconds in the 06:00 IST offset: IST is UTC+5:30, and the file rebuilds at
60
+ # 06:00 IST, so the trading day rolls at 00:30 UTC.
61
+ _IST_OFFSET_SECONDS = 5 * 3600 + 30 * 60
62
+ _DAY_ROLL_SECONDS = 6 * 3600 # 06:00
63
+
64
+ # The rebuild lands a little after 06:00 IST, and by a margin that moves from day
65
+ # to day, so a copy whose Last-Modified predates today's roll is yesterday's file
66
+ # and is re-checked rather than trusted. The check is conditional (a 304 is a few
67
+ # hundred bytes), and this is how often it may run in that window.
68
+ _RECHECK_INTERVAL_SECONDS = 60.0
69
+
70
+
71
+ @dataclass
72
+ class _Master:
73
+ """One parsed scrip master, shared by every client using the same URL."""
74
+
75
+ rows: list[dict[str, Any]]
76
+ by_id: dict[int, dict[str, Any]] = field(default_factory=dict)
77
+ by_symbol: dict[str, list[dict[str, Any]]] = field(default_factory=dict)
78
+ by_isin: dict[str, list[dict[str, Any]]] = field(default_factory=dict)
79
+ #: ``Last-Modified`` as epoch seconds, and verbatim for a conditional request.
80
+ last_modified: float | None = None
81
+ last_modified_raw: str | None = None
82
+ etag: str | None = None
83
+ #: When this copy was last known to be the server's current one.
84
+ checked_at: float = 0.0
85
+ #: Trading day of that check, for a server that sends no validators at all.
86
+ day: int = 0
87
+
88
+
89
+ # One parsed file per URL for the whole process. The lock is held across the
90
+ # download so that N clients starting together fetch once rather than N times.
91
+ _CACHE: dict[str, _Master] = {}
92
+ _CACHE_LOCK = threading.Lock()
93
+
94
+
95
+ class _Instruments(EndpointGroup):
96
+ """Scrip-master download and lookup methods mixed into :class:`SahiAPI`.
97
+
98
+ ``SahiAPI`` provides ``_scrip_master_url`` (from its constructor or the
99
+ environment); the parsed file itself lives in this module, shared by URL.
100
+ """
101
+
102
+ # Provided/managed by SahiAPI; declared here so the mixin is self-describing.
103
+ _scrip_master_url: str | None
104
+
105
+ def get_instruments(
106
+ self, *, refresh: bool = False, timeout: float | None = None
107
+ ) -> list[dict[str, Any]]:
108
+ """Return every instrument in today's scrip master.
109
+
110
+ Downloads and parses the file on first use, then serves it from memory —
111
+ one copy per URL, shared by every client in the process. Freshness follows
112
+ the file rather than the clock: a copy built at or after today's 06:00 IST
113
+ rebuild serves the rest of the day, and a copy older than that is
114
+ re-checked (conditionally, at most once a minute) until the new file
115
+ appears. A re-check that cannot reach the server leaves the copy in place
116
+ and is retried after the interval, so a blip does not cost a file already
117
+ loaded — and a host that stays down leaves the last copy serving, rather
118
+ than failing lookups. ``refresh=True`` downloads again regardless, and
119
+ raises if it cannot.
120
+
121
+ Args:
122
+ refresh: Force a re-download even if a cached copy is current.
123
+ timeout: Download timeout in seconds (default 30).
124
+
125
+ Returns:
126
+ One ``dict`` per row, keyed by the file's header. Values are strings
127
+ except the always-integer columns. The list is a fresh copy each call,
128
+ but the row dicts are shared with the lookup indexes, with the cache
129
+ and with every other client on this URL, so treat them as read-only —
130
+ mutating a row changes what later lookups return.
131
+
132
+ Raises:
133
+ SahiConfigurationException: If no scrip-master URL is configured.
134
+ SahiConnectionException: If the download could not be started.
135
+ SahiTimeoutException: If the download times out.
136
+ SahiAPIException: If the download fails (``status`` set for a non-2xx).
137
+ """
138
+ return list(self._master(refresh=refresh, timeout=timeout).rows)
139
+
140
+ def get_instrument_by_id(
141
+ self, instrument_id: int, *, timeout: float | None = None
142
+ ) -> dict[str, Any] | None:
143
+ """Return the instrument with this ``id``, or ``None`` if there is none."""
144
+ return self._master(refresh=False, timeout=timeout).by_id.get(instrument_id)
145
+
146
+ def get_instruments_by_symbol(
147
+ self, symbol: str, exchange_segment: str | None = None, *, timeout: float | None = None
148
+ ) -> list[dict[str, Any]]:
149
+ """Return instruments with this ``symbol``.
150
+
151
+ A symbol can appear in more than one segment — an ETF listed on both
152
+ exchanges, say — so this returns a list.
153
+
154
+ Args:
155
+ symbol: The file's ``symbol`` column, which is also the ``symbol`` an
156
+ order takes.
157
+ exchange_segment: Narrow to one segment, matched against the file's
158
+ ``segment`` column: the same ``NSE`` / ``NFO`` / ``BSE`` / ``BFO``
159
+ value an order takes. A symbol is unique within a segment, so this
160
+ selects at most one row. Note this is *not* the ``exchange``
161
+ column, which holds the listing exchange (``NSE`` for an NFO
162
+ option).
163
+ timeout: Download timeout for the first load.
164
+ """
165
+ master = self._master(refresh=False, timeout=timeout)
166
+ matches = master.by_symbol.get(symbol, [])
167
+ if exchange_segment is None:
168
+ return list(matches)
169
+ return [row for row in matches if row.get("segment") == exchange_segment]
170
+
171
+ def get_instruments_by_isin(
172
+ self, isin: str, *, timeout: float | None = None
173
+ ) -> list[dict[str, Any]]:
174
+ """Return instruments with this ``isin`` (a stock can list on both exchanges)."""
175
+ return list(self._master(refresh=False, timeout=timeout).by_isin.get(isin, []))
176
+
177
+ # -- internals ------------------------------------------------------------
178
+
179
+ def _master(self, *, refresh: bool, timeout: float | None) -> _Master:
180
+ url = self._scrip_master_url
181
+ if not url:
182
+ raise SahiConfigurationException(
183
+ "no scrip-master URL is configured. Pass scrip_master_url=... to "
184
+ "SahiAPI() or set SAHI_SCRIP_MASTER_URL. The URL is provided when "
185
+ "your API access is set up."
186
+ )
187
+ with _CACHE_LOCK:
188
+ cached = _CACHE.get(url)
189
+ if not refresh and cached is not None and _is_current(cached):
190
+ return cached
191
+ try:
192
+ # On a refresh the cached copy is not offered for validation: the
193
+ # caller asked for the file, not confirmation that it is the same.
194
+ master = _download(url, timeout, cached=None if refresh else cached)
195
+ except SahiAPIException:
196
+ # A revalidation that could not reach the server is no reason to
197
+ # lose a file already in hand: keep serving it and try again after
198
+ # the interval. A first load has nothing to serve, and a refresh
199
+ # was asked for explicitly, so both still raise.
200
+ if refresh or cached is None:
201
+ raise
202
+ cached.checked_at = time.time()
203
+ return cached
204
+ _CACHE[url] = master
205
+ return master
206
+
207
+
208
+ def _is_current(master: _Master) -> bool:
209
+ """Whether this copy can be served without asking the server again."""
210
+ if master.last_modified is not None and master.last_modified >= _day_roll_epoch():
211
+ return True # built at or after today's rebuild; immutable for the day
212
+ if master.last_modified is None and master.etag is None:
213
+ # No validators at all: nothing to re-check against, so fall back to the
214
+ # trading-day key.
215
+ if master.day == _trading_day():
216
+ return True
217
+ # Older than today's rebuild, or carrying only an ETag: re-check, but no more
218
+ # often than this. The same interval paces the retry after a failed check.
219
+ return (time.time() - master.checked_at) < _RECHECK_INTERVAL_SECONDS
220
+
221
+
222
+ def _download(url: str, timeout: float | None, cached: _Master | None) -> _Master:
223
+ """Fetch the file, conditionally when there is a copy worth revalidating."""
224
+ headers: dict[str, str] = {}
225
+ if cached is not None:
226
+ if cached.etag:
227
+ headers["If-None-Match"] = cached.etag
228
+ elif cached.last_modified_raw:
229
+ headers["If-Modified-Since"] = cached.last_modified_raw
230
+ # The scrip host is a static file server, not an API host: no error envelope,
231
+ # no request id. Map failures onto the SDK's own exceptions — the same ones
232
+ # the API host raises — so callers can catch one set for everything.
233
+ try:
234
+ response = httpx.get(url, headers=headers, timeout=timeout if timeout is not None else 30.0)
235
+ except (httpx.ConnectError, httpx.ConnectTimeout) as e: # DNS, refused, TLS
236
+ raise SahiConnectionException(
237
+ f"could not download the scrip master from {url}: {e}. "
238
+ "Check network access and the URL."
239
+ ) from e
240
+ except httpx.TimeoutException as e:
241
+ raise SahiTimeoutException("scrip-master download timed out") from e
242
+ except httpx.HTTPError as e: # protocol errors after the connection opened
243
+ raise SahiAPIException(f"scrip-master download failed: {e}") from e
244
+
245
+ if cached is not None and response.status_code == 304:
246
+ cached.checked_at = time.time()
247
+ cached.day = _trading_day()
248
+ return cached
249
+ if not response.is_success:
250
+ raise SahiAPIException("scrip-master download failed", status=response.status_code)
251
+
252
+ master = _Master(rows=_parse(response.text))
253
+ _build_indexes(master)
254
+ master.last_modified_raw = response.headers.get("Last-Modified")
255
+ master.last_modified = _http_date_epoch(master.last_modified_raw)
256
+ master.etag = response.headers.get("ETag")
257
+ master.checked_at = time.time()
258
+ master.day = _trading_day()
259
+ return master
260
+
261
+
262
+ def _parse(text: str) -> list[dict[str, Any]]:
263
+ reader = csv.DictReader(io.StringIO(text))
264
+ rows: list[dict[str, Any]] = []
265
+ for raw in reader:
266
+ row: dict[str, Any] = dict(raw)
267
+ for column in _INT_COLUMNS:
268
+ value = row.get(column)
269
+ if value is not None and value != "":
270
+ try:
271
+ row[column] = int(value)
272
+ except ValueError:
273
+ pass # leave a non-integer value as its original string
274
+ rows.append(row)
275
+ return rows
276
+
277
+
278
+ def _build_indexes(master: _Master) -> None:
279
+ by_id: dict[int, dict[str, Any]] = {}
280
+ by_symbol: dict[str, list[dict[str, Any]]] = {}
281
+ by_isin: dict[str, list[dict[str, Any]]] = {}
282
+ for row in master.rows:
283
+ rid = row.get("id")
284
+ if isinstance(rid, int):
285
+ by_id[rid] = row
286
+ symbol = row.get("symbol")
287
+ if symbol:
288
+ by_symbol.setdefault(symbol, []).append(row)
289
+ isin = row.get("isin")
290
+ if isin:
291
+ by_isin.setdefault(isin, []).append(row)
292
+ master.by_id = by_id
293
+ master.by_symbol = by_symbol
294
+ master.by_isin = by_isin
295
+
296
+
297
+ def _http_date_epoch(value: str | None) -> float | None:
298
+ """Epoch seconds for an HTTP date header, or ``None`` if it is absent or odd."""
299
+ if not value:
300
+ return None
301
+ try:
302
+ return parsedate_to_datetime(value).timestamp()
303
+ except (TypeError, ValueError):
304
+ return None
305
+
306
+
307
+ def _trading_day() -> int:
308
+ """The current trading day as a day number, rolling at 06:00 IST."""
309
+ ist_now = time.time() + _IST_OFFSET_SECONDS
310
+ return int((ist_now - _DAY_ROLL_SECONDS) // 86400)
311
+
312
+
313
+ def _day_roll_epoch() -> float:
314
+ """Epoch seconds of the most recent 06:00 IST boundary."""
315
+ return _trading_day() * 86400.0 + _DAY_ROLL_SECONDS - _IST_OFFSET_SECONDS
@@ -0,0 +1,268 @@
1
+ """Market-data REST endpoints: LTP, quotes, candles and the option chain.
2
+
3
+ Mirrors the Market data page of the API reference. Everything here needs the
4
+ ``marketdata.read`` scope and addresses instruments by ``instrument_id`` — the
5
+ integer ``id`` from the scrip master. Prices are integer paise and timestamps are
6
+ epoch milliseconds, returned unchanged.
7
+
8
+ The one method beyond the raw endpoints is :meth:`get_candle_history`, which pages
9
+ a long history for you. ``GET /v1/marketdata/candles`` returns at most 2500 candles
10
+ per response, clipped at the start of the window, so a wide range needs several
11
+ calls; ``get_candle_history`` follows ``has_more`` backward and returns the rows in
12
+ ascending time order.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import Sequence
18
+ from typing import Any
19
+
20
+ from . import constants
21
+ from .base import EndpointGroup
22
+
23
+ __all__ = ["_MarketData"]
24
+
25
+ _MAX_INSTRUMENTS_PER_REQUEST = 500
26
+ _CANDLE_SIZES = (
27
+ constants.CANDLE_1M,
28
+ constants.CANDLE_3M,
29
+ constants.CANDLE_5M,
30
+ constants.CANDLE_15M,
31
+ constants.CANDLE_30M,
32
+ constants.CANDLE_1H,
33
+ constants.CANDLE_1D,
34
+ )
35
+
36
+
37
+ class _MarketData(EndpointGroup):
38
+ """Market-data methods mixed into :class:`SahiAPI`."""
39
+
40
+ def get_ltp(
41
+ self, instrument_ids: int | Sequence[int], *, timeout: float | None = None
42
+ ) -> dict[str, Any]:
43
+ """Last traded price for one or many instruments (``GET /v1/marketdata/ltp``).
44
+
45
+ Args:
46
+ instrument_ids: One ``instrument_id``, or up to 500 of them. The
47
+ endpoint is single or bulk, so a lone id needs no list around it;
48
+ the response is keyed by id either way.
49
+
50
+ Returns:
51
+ ``{"data": {"<id>": {"last_trade_price": ..., "last_trade_time": ...}}}``.
52
+ A non-existent instrument's value is ``null``; the call still returns
53
+ ``200``.
54
+
55
+ Raises:
56
+ ValueError: If no ids are given, or more than 500 are.
57
+ """
58
+ ids = self._as_instrument_ids(instrument_ids)
59
+ return self._request( # type: ignore[no-any-return]
60
+ "GET",
61
+ "/v1/marketdata/ltp",
62
+ params={"i": ids},
63
+ timeout=timeout,
64
+ retry_on_rate_limit=True,
65
+ )
66
+
67
+ def get_quotes(
68
+ self, instrument_ids: int | Sequence[int], *, timeout: float | None = None
69
+ ) -> dict[str, Any]:
70
+ """Full quote with market depth (``GET /v1/marketdata/quotes``).
71
+
72
+ Args:
73
+ instrument_ids: One ``instrument_id``, or up to 500 of them, as on
74
+ :meth:`get_ltp`.
75
+
76
+ Returns:
77
+ ``{"data": {"<id>": {...}}}`` — the REST equivalent of the tick
78
+ stream's depth tick: price, volume, DPR band, open interest and depth.
79
+
80
+ Raises:
81
+ ValueError: If no ids are given, or more than 500 are.
82
+ """
83
+ ids = self._as_instrument_ids(instrument_ids)
84
+ return self._request( # type: ignore[no-any-return]
85
+ "GET",
86
+ "/v1/marketdata/quotes",
87
+ params={"i": ids},
88
+ timeout=timeout,
89
+ retry_on_rate_limit=True,
90
+ )
91
+
92
+ def get_candles(
93
+ self,
94
+ instrument_id: int,
95
+ size: str,
96
+ from_ms: int,
97
+ to_ms: int,
98
+ *,
99
+ include_partial: bool = False,
100
+ oi: bool = False,
101
+ timeout: float | None = None,
102
+ ) -> dict[str, Any]:
103
+ """Historical candles (``GET /v1/marketdata/candles``).
104
+
105
+ Args:
106
+ instrument_id: The instrument to fetch.
107
+ size: One of ``1m``, ``3m``, ``5m``, ``15m``, ``30m``, ``1h``, ``1d``.
108
+ Case-sensitive.
109
+ from_ms: Window start, epoch milliseconds, inclusive.
110
+ to_ms: Window end, epoch milliseconds, inclusive. Must be ``>= from_ms``.
111
+ include_partial: When true and the market is open, append the
112
+ in-progress candle as the last entry.
113
+ oi: When true, append open interest as a 7th element to each candle.
114
+ timeout: Per-call timeout override.
115
+
116
+ Returns:
117
+ ``{"instrument_id", "size", "candles": [[ts,o,h,l,c,v(,oi)]], "has_more"}``.
118
+ Each candle is a fixed-length array; the width is 6, or 7 with ``oi``.
119
+ At most 2500 candles per response, clipped at the start of the window.
120
+
121
+ Raises:
122
+ ValueError: If ``size`` is not one of the supported sizes, or
123
+ ``to_ms`` is before ``from_ms``.
124
+ """
125
+ self._check_candle_size(size)
126
+ if to_ms < from_ms:
127
+ raise ValueError(f"to_ms ({to_ms}) must not be before from_ms ({from_ms})")
128
+ return self._request( # type: ignore[no-any-return]
129
+ "GET",
130
+ "/v1/marketdata/candles",
131
+ params={
132
+ "instrument_id": instrument_id,
133
+ "size": size,
134
+ "from": from_ms,
135
+ "to": to_ms,
136
+ "include_partial": include_partial,
137
+ "oi": oi,
138
+ },
139
+ timeout=timeout,
140
+ retry_on_rate_limit=True,
141
+ )
142
+
143
+ def get_candle_history(
144
+ self,
145
+ instrument_id: int,
146
+ size: str,
147
+ from_ms: int,
148
+ to_ms: int,
149
+ *,
150
+ oi: bool = False,
151
+ timeout: float | None = None,
152
+ ) -> list[list[Any]]:
153
+ """Every candle in a range, ascending, paging past the 2500 cap.
154
+
155
+ A single :meth:`get_candles` call returns at most 2500 candles, clipped at
156
+ the start of the window, and the endpoint pages backward only: each call
157
+ ends at ``first_timestamp - 1`` of the one before. This walks that chain
158
+ to the start of the range and returns the rows oldest-first — the same raw
159
+ arrays :meth:`get_candles` returns, unchanged.
160
+
161
+ The whole range is fetched before anything is returned, so a wide window
162
+ is many requests (candles are budgeted at 2 per second and 250 per
163
+ minute) held in memory at once, and a failure part-way loses the walk.
164
+ Call :meth:`get_candles` yourself to page a long history under your own
165
+ control, following ``has_more`` as above.
166
+
167
+ ``include_partial`` is not offered here: a multi-call history is a
168
+ completed series, and a partial candle only makes sense on a single live
169
+ call.
170
+
171
+ Returns:
172
+ Every candle in ``[from_ms, to_ms]``, in ascending timestamp order.
173
+ Empty when the instrument has no candles in the range.
174
+ """
175
+ pages: list[list[list[Any]]] = []
176
+ current_to = to_ms
177
+ while current_to >= from_ms:
178
+ response = self.get_candles(
179
+ instrument_id, size, from_ms, current_to, oi=oi, timeout=timeout
180
+ )
181
+ candles = response.get("candles") or []
182
+ if not candles:
183
+ break
184
+ pages.append(candles)
185
+ if not response.get("has_more"):
186
+ break
187
+ earliest_ts = candles[0][0]
188
+ current_to = int(earliest_ts) - 1
189
+ rows: list[list[Any]] = []
190
+ for page in reversed(pages):
191
+ rows.extend(page)
192
+ return rows
193
+
194
+ def get_option_expiries(
195
+ self, underlying_id: int, *, timeout: float | None = None
196
+ ) -> dict[str, Any]:
197
+ """Available expiries for an underlying (``GET /v1/marketdata/optionchain/expiries``).
198
+
199
+ Args:
200
+ underlying_id: The underlying's ``instrument_id`` — the index or
201
+ stock, not a derivative.
202
+
203
+ Returns:
204
+ ``{"expiries": ["YYYY-MM-DD", ...]}``.
205
+ """
206
+ return self._request( # type: ignore[no-any-return]
207
+ "GET",
208
+ "/v1/marketdata/optionchain/expiries",
209
+ params={"underlying_id": underlying_id},
210
+ timeout=timeout,
211
+ retry_on_rate_limit=True,
212
+ )
213
+
214
+ def get_option_chain(
215
+ self,
216
+ underlying_id: int,
217
+ expiry: str | None = None,
218
+ *,
219
+ timeout: float | None = None,
220
+ ) -> dict[str, Any]:
221
+ """Option chain for an underlying and expiry (``GET /v1/marketdata/optionchain``).
222
+
223
+ Args:
224
+ underlying_id: The underlying's ``instrument_id``.
225
+ expiry: ``YYYY-MM-DD``, one of the expiries endpoint's values. Omit to
226
+ use the nearest unexpired expiry.
227
+
228
+ Returns:
229
+ The chain dict: ``underlying_id``, the resolved ``expiry``,
230
+ ``last_price`` and ``strikes`` (one row per strike, with ``ce`` / ``pe``
231
+ legs).
232
+ """
233
+ params: dict[str, Any] = {"underlying_id": underlying_id}
234
+ if expiry is not None:
235
+ params["expiry"] = expiry
236
+ return self._request( # type: ignore[no-any-return]
237
+ "GET",
238
+ "/v1/marketdata/optionchain",
239
+ params=params,
240
+ timeout=timeout,
241
+ retry_on_rate_limit=True,
242
+ )
243
+
244
+ @staticmethod
245
+ def _as_instrument_ids(instrument_ids: int | Sequence[int]) -> list[int]:
246
+ # The endpoint is single or bulk, so a lone id is wrapped rather than
247
+ # refused. Both limits are the API's own rules; failing here saves a round
248
+ # trip that would come back 400 and gives a message that names the fix.
249
+ ids = [instrument_ids] if isinstance(instrument_ids, int) else list(instrument_ids)
250
+ if not ids:
251
+ raise ValueError("instrument_ids must contain at least one instrument_id")
252
+ if len(ids) > _MAX_INSTRUMENTS_PER_REQUEST:
253
+ raise ValueError(
254
+ f"at most {_MAX_INSTRUMENTS_PER_REQUEST} instrument_ids per request, got "
255
+ f"{len(ids)}; split the list into batches of "
256
+ f"{_MAX_INSTRUMENTS_PER_REQUEST}"
257
+ )
258
+ return ids
259
+
260
+ @staticmethod
261
+ def _check_candle_size(size: str) -> None:
262
+ # Sizes are case-sensitive on the server ("1M" is rejected as not one
263
+ # minute), which is the kind of typo a clear local error prevents.
264
+ if size not in _CANDLE_SIZES:
265
+ raise ValueError(
266
+ f"unknown candle size {size!r}; use one of {', '.join(_CANDLE_SIZES)} "
267
+ "(case-sensitive; the SahiAPI.CANDLE_* constants are the safe spelling)"
268
+ )