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.
- sahiapi/__init__.py +88 -0
- sahiapi/py.typed +0 -0
- sahiapi/sahi/__init__.py +0 -0
- sahiapi/sahi/account.py +28 -0
- sahiapi/sahi/auth.py +163 -0
- sahiapi/sahi/base.py +32 -0
- sahiapi/sahi/client.py +306 -0
- sahiapi/sahi/constants.py +115 -0
- sahiapi/sahi/env.py +39 -0
- sahiapi/sahi/exceptions.py +412 -0
- sahiapi/sahi/feed.py +668 -0
- sahiapi/sahi/funds.py +134 -0
- sahiapi/sahi/http.py +301 -0
- sahiapi/sahi/instruments.py +315 -0
- sahiapi/sahi/marketdata.py +268 -0
- sahiapi/sahi/orders.py +225 -0
- sahiapi/sahi/portfolio.py +49 -0
- sahiapi/sahi/proto/__init__.py +0 -0
- sahiapi/sahi/proto/codec.py +126 -0
- sahiapi/sahi/proto/marketdata.proto +138 -0
- sahiapi/sahi/proto/marketdata_pb2.py +55 -0
- sahiapi/sahi/ws_client.py +355 -0
- sahiapi-0.1.0.dist-info/METADATA +97 -0
- sahiapi-0.1.0.dist-info/RECORD +26 -0
- sahiapi-0.1.0.dist-info/WHEEL +4 -0
- sahiapi-0.1.0.dist-info/licenses/LICENSE +7 -0
|
@@ -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
|
+
)
|