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,355 @@
|
|
|
1
|
+
"""One WebSocket connection, with reconnect and close-code handling.
|
|
2
|
+
|
|
3
|
+
Both Sahi streams share the same connection rules: the access token goes on the
|
|
4
|
+
upgrade as a Bearer header, the server pings every 30 seconds and expects a pong
|
|
5
|
+
within 15, and some close codes are fatal while others just mean reconnect. This
|
|
6
|
+
class implements those rules once; :class:`~sahiapi.sahi.feed.SahiFeed` owns one
|
|
7
|
+
instance per stream.
|
|
8
|
+
|
|
9
|
+
The connection runs a receive loop on a daemon thread and hands each frame to an
|
|
10
|
+
``on_message`` callback. On a transient drop it reconnects with exponential
|
|
11
|
+
backoff and calls ``on_open`` again so the owner can re-send its subscriptions
|
|
12
|
+
(stream state does not survive a reconnect). That covers a missed heartbeat
|
|
13
|
+
(4408) and a slow-consumer drop (4409) as well as network drops — the API
|
|
14
|
+
reference asks clients to reconnect and re-sync on both. A fatal close — an
|
|
15
|
+
expired token (4401), a forbidden scope (4403) or too many connections (4429) —
|
|
16
|
+
stops the loop and records the error for the owner to surface. A handshake the
|
|
17
|
+
server refuses with HTTP 401 or 403 is fatal too: retrying an already-expired
|
|
18
|
+
token would loop forever.
|
|
19
|
+
|
|
20
|
+
The loop is deliberately hard to kill by accident. A frame the owner cannot
|
|
21
|
+
handle is dropped and logged rather than ending the stream, and a failure in
|
|
22
|
+
``on_open`` is treated as a transient drop and reconnected.
|
|
23
|
+
|
|
24
|
+
The transport is injectable so the reconnect and close-code logic can be tested
|
|
25
|
+
without a real socket; by default it is ``websocket-client``. Frames are read
|
|
26
|
+
with ``recv_data`` so a close frame arrives as itself, with the server's status
|
|
27
|
+
code in its payload: the plain ``recv`` turns a close into an empty string and
|
|
28
|
+
keeps the code nowhere, so a fatal 4401 or 4429 would look like a clean drop.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import logging
|
|
34
|
+
import random
|
|
35
|
+
import struct
|
|
36
|
+
import threading
|
|
37
|
+
from typing import Any, Callable, Protocol
|
|
38
|
+
|
|
39
|
+
from . import constants, exceptions
|
|
40
|
+
|
|
41
|
+
__all__ = ["Transport", "WebSocketConnection"]
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Transport(Protocol):
|
|
47
|
+
"""The minimal socket surface this class needs.
|
|
48
|
+
|
|
49
|
+
``websocket.WebSocket`` from ``websocket-client`` satisfies it; tests provide
|
|
50
|
+
a fake.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def recv_data(self) -> tuple[int, Any]: ...
|
|
54
|
+
def send_binary(self, data: bytes) -> Any: ...
|
|
55
|
+
def send(self, data: str) -> Any: ...
|
|
56
|
+
def close(self) -> Any: ...
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
ConnectFn = Callable[[str, list[str]], Transport]
|
|
60
|
+
|
|
61
|
+
# RFC 6455 opcodes, as websocket-client's ``recv_data`` reports them.
|
|
62
|
+
_OPCODE_TEXT = 0x1
|
|
63
|
+
_OPCODE_BINARY = 0x2
|
|
64
|
+
_OPCODE_CLOSE = 0x8
|
|
65
|
+
|
|
66
|
+
_BACKOFF_START = 1.0
|
|
67
|
+
_BACKOFF_CAP = 30.0
|
|
68
|
+
# Each backoff sleep is scaled by a factor drawn from this range.
|
|
69
|
+
_BACKOFF_JITTER = (0.5, 1.5)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# Twice the server's 30 s ping interval: a healthy socket delivers bytes at
|
|
73
|
+
# least that often, so a read that waits longer is a half-open link.
|
|
74
|
+
_READ_TIMEOUT_SECONDS = 60.0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _default_connect(url: str, header: list[str]) -> Transport:
|
|
78
|
+
import websocket
|
|
79
|
+
|
|
80
|
+
# Without a timeout a link that died silently (a NAT drop, a Wi-Fi switch)
|
|
81
|
+
# blocks recv forever and the stream goes quiet with nothing to notice. The
|
|
82
|
+
# timeout turns that silence into an exception, and the loop reconnects.
|
|
83
|
+
return websocket.create_connection(
|
|
84
|
+
url, header=header, enable_multithread=True, timeout=_READ_TIMEOUT_SECONDS
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _close_code_of(payload: Any) -> int | None:
|
|
89
|
+
"""The status code a close frame carries (RFC 6455 §5.5.1), or None if it has none."""
|
|
90
|
+
if isinstance(payload, (bytes, bytearray)) and len(payload) >= 2:
|
|
91
|
+
return int(struct.unpack("!H", bytes(payload[:2]))[0])
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _fatal_error_for(close_code: int | None) -> exceptions.SahiFeedException | None:
|
|
96
|
+
if close_code == constants.WS_CLOSE_AUTH_EXPIRED:
|
|
97
|
+
return exceptions.SahiFeedAuthExpiredException(
|
|
98
|
+
"the server closed the stream because the token expired or was revoked (4401). "
|
|
99
|
+
"Mint a new token, build a new SahiFeed and re-subscribe.",
|
|
100
|
+
close_code=close_code,
|
|
101
|
+
)
|
|
102
|
+
if close_code == constants.WS_CLOSE_FORBIDDEN:
|
|
103
|
+
return exceptions.SahiFeedForbiddenException(
|
|
104
|
+
"the token lacks the scope this stream needs (4403): orders.read for order "
|
|
105
|
+
"updates, marketdata.read for ticks. Mint a token that carries it.",
|
|
106
|
+
close_code=close_code,
|
|
107
|
+
)
|
|
108
|
+
if close_code == constants.WS_CLOSE_TOO_MANY:
|
|
109
|
+
return exceptions.SahiFeedConnectionException(
|
|
110
|
+
"too many concurrent connections (4429): each stream allows 5 at once, per token "
|
|
111
|
+
"for the tick stream and per account for order updates. Close an existing "
|
|
112
|
+
"connection before opening another.",
|
|
113
|
+
close_code=close_code,
|
|
114
|
+
)
|
|
115
|
+
# 4408 (heartbeat) and 4409 (slow consumer) fall through: reconnect and re-sync.
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _fatal_error_for_handshake(exc: BaseException) -> exceptions.SahiFeedException | None:
|
|
120
|
+
"""Map an upgrade refused by the server to a fatal error, or None to retry.
|
|
121
|
+
|
|
122
|
+
Before the socket exists the server can only refuse with an HTTP status, so
|
|
123
|
+
an expired token shows up here as 401, not as a 4401 close. websocket-client
|
|
124
|
+
exposes the status as ``status_code`` on its handshake exception.
|
|
125
|
+
"""
|
|
126
|
+
status = getattr(exc, "status_code", None)
|
|
127
|
+
if status == 401:
|
|
128
|
+
return exceptions.SahiFeedAuthExpiredException(
|
|
129
|
+
"the server rejected the token at the WebSocket handshake (401): it has expired "
|
|
130
|
+
"or been revoked. Mint a new token, build a new SahiFeed and re-subscribe."
|
|
131
|
+
)
|
|
132
|
+
if status == 403:
|
|
133
|
+
return exceptions.SahiFeedForbiddenException(
|
|
134
|
+
"the server refused the WebSocket handshake (403): the token lacks the scope "
|
|
135
|
+
"this stream needs. Mint a token that carries it."
|
|
136
|
+
)
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class WebSocketConnection:
|
|
141
|
+
"""A self-reconnecting WebSocket connection for one stream.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
url: The ``wss://`` URL to connect to.
|
|
145
|
+
access_token: Bearer token for the upgrade request.
|
|
146
|
+
binary: True for the protobuf tick stream, False for the JSON order
|
|
147
|
+
stream. Decides whether outgoing sends and the frames handed to
|
|
148
|
+
``on_message`` are bytes or text.
|
|
149
|
+
on_message: Called with each received frame (bytes if ``binary`` else str),
|
|
150
|
+
on the connection's own thread.
|
|
151
|
+
on_open: Called after every successful (re)connect, before reading, so the
|
|
152
|
+
owner can re-send subscriptions. Optional.
|
|
153
|
+
on_fatal: Called once, on the connection's thread, with the error that
|
|
154
|
+
ended the loop for good. Must not call :meth:`stop` — that would join
|
|
155
|
+
the calling thread. Optional.
|
|
156
|
+
name: A short label used for the daemon thread name.
|
|
157
|
+
connect_fn: Transport factory, for testing. Defaults to websocket-client.
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
def __init__(
|
|
161
|
+
self,
|
|
162
|
+
url: str,
|
|
163
|
+
*,
|
|
164
|
+
access_token: str,
|
|
165
|
+
binary: bool,
|
|
166
|
+
on_message: Callable[[Any], None],
|
|
167
|
+
on_open: Callable[[], None] | None = None,
|
|
168
|
+
on_fatal: Callable[[exceptions.SahiFeedException], None] | None = None,
|
|
169
|
+
name: str = "sahi-ws",
|
|
170
|
+
connect_fn: ConnectFn | None = None,
|
|
171
|
+
) -> None:
|
|
172
|
+
self._url = url
|
|
173
|
+
self._header = [f"Authorization: Bearer {access_token}"]
|
|
174
|
+
self._binary = binary
|
|
175
|
+
self._on_message = on_message
|
|
176
|
+
self._on_open = on_open
|
|
177
|
+
self._on_fatal = on_fatal
|
|
178
|
+
self._name = name
|
|
179
|
+
self._connect = connect_fn or _default_connect
|
|
180
|
+
|
|
181
|
+
self._thread: threading.Thread | None = None
|
|
182
|
+
self._stopped = threading.Event()
|
|
183
|
+
self._send_lock = threading.Lock()
|
|
184
|
+
self._transport: Transport | None = None
|
|
185
|
+
self._epoch = 0 # bumped each time a new transport is installed
|
|
186
|
+
self._fatal_error: exceptions.SahiFeedException | None = None
|
|
187
|
+
|
|
188
|
+
# -- lifecycle ------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
def start(self) -> None:
|
|
191
|
+
"""Start the background connect/receive loop, if not already running."""
|
|
192
|
+
if self._thread is not None and self._thread.is_alive():
|
|
193
|
+
return
|
|
194
|
+
self._stopped.clear()
|
|
195
|
+
self._fatal_error = None
|
|
196
|
+
self._thread = threading.Thread(target=self._run, name=self._name, daemon=True)
|
|
197
|
+
self._thread.start()
|
|
198
|
+
|
|
199
|
+
def stop(self) -> None:
|
|
200
|
+
"""Stop the loop and close the socket."""
|
|
201
|
+
self._stopped.set()
|
|
202
|
+
with self._send_lock:
|
|
203
|
+
transport, self._transport = self._transport, None
|
|
204
|
+
if transport is not None:
|
|
205
|
+
self._close_quietly(transport)
|
|
206
|
+
if self._thread is not None:
|
|
207
|
+
self._thread.join(timeout=5.0)
|
|
208
|
+
self._thread = None
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def fatal_error(self) -> exceptions.SahiFeedException | None:
|
|
212
|
+
"""The fatal close error that stopped the loop, if any."""
|
|
213
|
+
return self._fatal_error
|
|
214
|
+
|
|
215
|
+
def is_running(self) -> bool:
|
|
216
|
+
"""Whether the background loop is alive."""
|
|
217
|
+
return self._thread is not None and self._thread.is_alive()
|
|
218
|
+
|
|
219
|
+
@property
|
|
220
|
+
def epoch(self) -> int | None:
|
|
221
|
+
"""A number that changes on every (re)connect, or None while not connected.
|
|
222
|
+
|
|
223
|
+
A sender that must not repeat a frame across a reconnect records the
|
|
224
|
+
epoch it sent under and passes it back to :meth:`send`; the reconnect
|
|
225
|
+
replay carries anything that missed the old socket.
|
|
226
|
+
"""
|
|
227
|
+
with self._send_lock:
|
|
228
|
+
return self._epoch if self._transport is not None else None
|
|
229
|
+
|
|
230
|
+
# -- sending --------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
def send(self, data: bytes | str, *, epoch: int | None = None) -> None:
|
|
233
|
+
"""Send a frame on the current connection.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
data: The frame.
|
|
237
|
+
epoch: If given, the :attr:`epoch` the frame was prepared under. A
|
|
238
|
+
frame prepared for an older connection is refused rather than
|
|
239
|
+
sent on the new one, which the replay has already brought up.
|
|
240
|
+
|
|
241
|
+
Raises:
|
|
242
|
+
SahiFeedConnectionException: If the connection is not currently open,
|
|
243
|
+
or ``epoch`` no longer matches it.
|
|
244
|
+
"""
|
|
245
|
+
with self._send_lock:
|
|
246
|
+
transport = self._transport
|
|
247
|
+
if transport is None:
|
|
248
|
+
raise exceptions.SahiFeedConnectionException(
|
|
249
|
+
"the stream is not connected yet; subscriptions are queued and sent "
|
|
250
|
+
"automatically once it is"
|
|
251
|
+
)
|
|
252
|
+
if epoch is not None and epoch != self._epoch:
|
|
253
|
+
raise exceptions.SahiFeedConnectionException(
|
|
254
|
+
"the stream reconnected since this frame was prepared; the reconnect "
|
|
255
|
+
"replay has already re-sent the subscription table"
|
|
256
|
+
)
|
|
257
|
+
if self._binary:
|
|
258
|
+
transport.send_binary(data if isinstance(data, bytes) else data.encode())
|
|
259
|
+
else:
|
|
260
|
+
transport.send(data if isinstance(data, str) else data.decode())
|
|
261
|
+
|
|
262
|
+
# -- loop -----------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
def _run(self) -> None:
|
|
265
|
+
backoff = _BACKOFF_START
|
|
266
|
+
while not self._stopped.is_set():
|
|
267
|
+
try:
|
|
268
|
+
transport = self._connect(self._url, self._header)
|
|
269
|
+
except Exception as e:
|
|
270
|
+
fatal = _fatal_error_for_handshake(e)
|
|
271
|
+
if fatal is not None:
|
|
272
|
+
self._finish_fatal(fatal)
|
|
273
|
+
return
|
|
274
|
+
if self._sleep_or_stop(backoff):
|
|
275
|
+
return
|
|
276
|
+
backoff = min(_BACKOFF_CAP, backoff * 2)
|
|
277
|
+
continue
|
|
278
|
+
|
|
279
|
+
with self._send_lock:
|
|
280
|
+
self._transport = transport
|
|
281
|
+
self._epoch += 1
|
|
282
|
+
backoff = _BACKOFF_START
|
|
283
|
+
if self._on_open is not None:
|
|
284
|
+
try:
|
|
285
|
+
self._on_open()
|
|
286
|
+
except Exception:
|
|
287
|
+
# A failed replay (e.g. the socket dropped between connect and
|
|
288
|
+
# send) is a transient drop, not a reason to die silently.
|
|
289
|
+
logger.warning("on_open failed; reconnecting", exc_info=True)
|
|
290
|
+
with self._send_lock:
|
|
291
|
+
self._transport = None
|
|
292
|
+
self._close_quietly(transport)
|
|
293
|
+
if self._sleep_or_stop(backoff):
|
|
294
|
+
return
|
|
295
|
+
backoff = min(_BACKOFF_CAP, backoff * 2)
|
|
296
|
+
continue
|
|
297
|
+
|
|
298
|
+
fatal = self._receive_until_closed(transport)
|
|
299
|
+
with self._send_lock:
|
|
300
|
+
self._transport = None
|
|
301
|
+
if fatal is not None:
|
|
302
|
+
self._finish_fatal(fatal)
|
|
303
|
+
return
|
|
304
|
+
if self._stopped.is_set():
|
|
305
|
+
return
|
|
306
|
+
if self._sleep_or_stop(backoff):
|
|
307
|
+
return
|
|
308
|
+
backoff = min(_BACKOFF_CAP, backoff * 2)
|
|
309
|
+
|
|
310
|
+
def _receive_until_closed(self, transport: Transport) -> exceptions.SahiFeedException | None:
|
|
311
|
+
"""Read frames until the socket closes. Return a fatal error, or None to retry."""
|
|
312
|
+
while not self._stopped.is_set():
|
|
313
|
+
try:
|
|
314
|
+
opcode, payload = transport.recv_data()
|
|
315
|
+
except Exception:
|
|
316
|
+
# A transport error (timeout, reset, our own stop) carries no
|
|
317
|
+
# close code: the server never said why, so reconnect.
|
|
318
|
+
return None
|
|
319
|
+
if opcode == _OPCODE_CLOSE:
|
|
320
|
+
# websocket-client has already answered the close; the reason is
|
|
321
|
+
# the status code at the front of the payload.
|
|
322
|
+
return _fatal_error_for(_close_code_of(payload))
|
|
323
|
+
if opcode == _OPCODE_TEXT:
|
|
324
|
+
frame: Any = payload.decode("utf-8") if isinstance(payload, bytes) else payload
|
|
325
|
+
elif opcode == _OPCODE_BINARY:
|
|
326
|
+
frame = payload
|
|
327
|
+
else:
|
|
328
|
+
continue # ping/pong are answered inside recv_data; nothing to deliver
|
|
329
|
+
try:
|
|
330
|
+
self._on_message(frame)
|
|
331
|
+
except Exception:
|
|
332
|
+
# One undecodable or unhandled frame must not end the stream.
|
|
333
|
+
logger.warning("dropping frame the handler could not process", exc_info=True)
|
|
334
|
+
return None
|
|
335
|
+
|
|
336
|
+
def _finish_fatal(self, fatal: exceptions.SahiFeedException) -> None:
|
|
337
|
+
self._fatal_error = fatal
|
|
338
|
+
if self._on_fatal is not None:
|
|
339
|
+
self._on_fatal(fatal)
|
|
340
|
+
|
|
341
|
+
@staticmethod
|
|
342
|
+
def _close_quietly(transport: Transport) -> None:
|
|
343
|
+
try:
|
|
344
|
+
transport.close()
|
|
345
|
+
except Exception:
|
|
346
|
+
pass
|
|
347
|
+
|
|
348
|
+
def _sleep_or_stop(self, seconds: float) -> bool:
|
|
349
|
+
"""Wait about ``seconds``, returning True if a stop was requested meanwhile.
|
|
350
|
+
|
|
351
|
+
The wait is jittered by up to half either way. Clients dropped together
|
|
352
|
+
(a deploy closes every socket at once) would otherwise retry at the same
|
|
353
|
+
1, 2, 4 s marks, and each upgrade counts against the stream rate limit.
|
|
354
|
+
"""
|
|
355
|
+
return self._stopped.wait(timeout=seconds * random.uniform(*_BACKOFF_JITTER))
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: sahiapi
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The official Python client library for the Sahi APIs.
|
|
5
|
+
Project-URL: Homepage, https://docs.sahi.com/
|
|
6
|
+
Project-URL: Documentation, https://docs.sahi.com/
|
|
7
|
+
Project-URL: Repository, https://github.com/aarityatech/sahi-python
|
|
8
|
+
Author: Aaritya Technologies
|
|
9
|
+
License: Copyright (c) 2026 Aaritya Technologies Private Limited. All rights reserved.
|
|
10
|
+
|
|
11
|
+
This software and its source code are proprietary and confidential. Until an
|
|
12
|
+
open-source license is applied by the copyright holder, no permission is granted
|
|
13
|
+
to copy, use, modify, or distribute this software outside Aaritya Technologies.
|
|
14
|
+
|
|
15
|
+
An open-source license will replace this file when the project is published.
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Keywords: bse,market-data,nse,options,sahi,stocks,trading
|
|
18
|
+
Classifier: Development Status :: 3 - Alpha
|
|
19
|
+
Classifier: Intended Audience :: Developers
|
|
20
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
21
|
+
Classifier: Operating System :: OS Independent
|
|
22
|
+
Classifier: Programming Language :: Python :: 3
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
26
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
27
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
28
|
+
Classifier: Typing :: Typed
|
|
29
|
+
Requires-Python: >=3.9
|
|
30
|
+
Requires-Dist: httpx>=0.27
|
|
31
|
+
Requires-Dist: protobuf>=4.25
|
|
32
|
+
Requires-Dist: websocket-client>=1.7
|
|
33
|
+
Provides-Extra: dev
|
|
34
|
+
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
35
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
36
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
37
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
38
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
39
|
+
Description-Content-Type: text/markdown
|
|
40
|
+
|
|
41
|
+
# sahiapi
|
|
42
|
+
|
|
43
|
+
The official Python client library for the [Sahi APIs](https://docs.sahi.com/):
|
|
44
|
+
orders, portfolio, funds, market data and live streams.
|
|
45
|
+
|
|
46
|
+
> Early development. The public interface is not yet stable.
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install sahiapi
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Requires Python 3.9 or newer.
|
|
55
|
+
|
|
56
|
+
## Quickstart
|
|
57
|
+
|
|
58
|
+
Create an API key in your Sahi account and approve it for the trading day, then:
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
import os
|
|
62
|
+
from sahiapi import SahiAPI
|
|
63
|
+
|
|
64
|
+
api = SahiAPI.from_credentials(
|
|
65
|
+
key_id=os.environ["SAHI_KEY_ID"],
|
|
66
|
+
secret=os.environ["SAHI_SECRET"],
|
|
67
|
+
)
|
|
68
|
+
print(api.get_profile())
|
|
69
|
+
print(api.get_funds())
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Tokens expire at 06:00 IST, so mint a fresh one each trading day.
|
|
73
|
+
|
|
74
|
+
## Documentation
|
|
75
|
+
|
|
76
|
+
Guides, the full API reference and examples are at **https://docs.sahi.com/**.
|
|
77
|
+
|
|
78
|
+
The SDK returns the API's JSON exactly as documented there: money is integer
|
|
79
|
+
paise, timestamps are epoch milliseconds, and field names are unchanged.
|
|
80
|
+
|
|
81
|
+
## Development
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
uv sync --extra dev
|
|
85
|
+
uv run pytest
|
|
86
|
+
uv run ruff check .
|
|
87
|
+
uv run mypy
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The tick-stream protobuf bindings are generated and committed; regenerate them
|
|
91
|
+
with `tools/regen_proto.sh` after editing the `.proto`. Releases are cut with
|
|
92
|
+
`tools/release.sh`; see `.github/workflows/release.yml`.
|
|
93
|
+
|
|
94
|
+
## License
|
|
95
|
+
|
|
96
|
+
Proprietary during development; an open-source license will be applied before
|
|
97
|
+
public release. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
sahiapi/__init__.py,sha256=AhwVUCc2pDjODD1HoEKtmTHq8X8FWKQenbOQmOJb6A0,2713
|
|
2
|
+
sahiapi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
sahiapi/sahi/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
sahiapi/sahi/account.py,sha256=U1P6Q42BUV0e6FhEsCDV3zzRHztEZ6f_J8CTYVXjVSs,781
|
|
5
|
+
sahiapi/sahi/auth.py,sha256=fugQpiaSyvoDuyOaM5Gyeq8rVLgAEuc4_SdVUpN3frY,6191
|
|
6
|
+
sahiapi/sahi/base.py,sha256=Gr7JFPuu31jhCPzYe50TMjaDS0OME9Pfw7e7p1TlDYc,1085
|
|
7
|
+
sahiapi/sahi/client.py,sha256=bRO3m5BbRiMITm--YvAXezwsKSLk6LH0OkAOidX6dN0,12458
|
|
8
|
+
sahiapi/sahi/constants.py,sha256=ZeVu3FljUDBDtWd8Ngr0HHxzu-B7lyuAsfVebRvPD80,5170
|
|
9
|
+
sahiapi/sahi/env.py,sha256=ApBmTYa2c6RLl7P9AsE0JlPJuPdKH_tf-NrtTfbR2g8,1361
|
|
10
|
+
sahiapi/sahi/exceptions.py,sha256=0lo3vlCBSlxmnY9XTuz2TtfqUVeA0nQkZ-kawOywjnY,14924
|
|
11
|
+
sahiapi/sahi/feed.py,sha256=K0hW7gIxIKrcXIicm1MEZRbjdmHvcKWsg-clcUHGIwQ,31370
|
|
12
|
+
sahiapi/sahi/funds.py,sha256=4BNnZh024D-gZu5Nd2mX-OYPuKGrrzvImdekHEZvxuE,5599
|
|
13
|
+
sahiapi/sahi/http.py,sha256=AHDEnZvrIQu8Zk4Z2Clp-eKxChy-srGCkEtwLdR74ts,11329
|
|
14
|
+
sahiapi/sahi/instruments.py,sha256=RtgDm0i3xCjNj0IeEXoJuNop8gObNudHZHG-at8jY60,14169
|
|
15
|
+
sahiapi/sahi/marketdata.py,sha256=Cjw05P87SqKhEFZiUcCQ1QygcPRdK8PijO23awBNfrA,10208
|
|
16
|
+
sahiapi/sahi/orders.py,sha256=9qWqzjBXAMlm3i89HXm5u3ufWoIHfmh47H6Hs2NYcUg,8595
|
|
17
|
+
sahiapi/sahi/portfolio.py,sha256=AKYL6NevAsyra_KYAIDbXms5S4yaakdP2Peplvfuh3g,1905
|
|
18
|
+
sahiapi/sahi/ws_client.py,sha256=fKhcutQPIpu-w-qbqik25q-aej4Z4BL-QcarZi1koa0,15054
|
|
19
|
+
sahiapi/sahi/proto/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
20
|
+
sahiapi/sahi/proto/codec.py,sha256=pRuysMnlb4d2554uJ9xeXf30CC27DZLDstt8HOTvSXk,4622
|
|
21
|
+
sahiapi/sahi/proto/marketdata.proto,sha256=yVUMl26SKt-nvi8wGfFlU33L0cBceNlD3JkBC3fu7rE,3171
|
|
22
|
+
sahiapi/sahi/proto/marketdata_pb2.py,sha256=XDokj9dyu0ZGC-SeYv7E7f8LSxrePeDNbnU8hLbSoZc,6963
|
|
23
|
+
sahiapi-0.1.0.dist-info/METADATA,sha256=21QMm1vKdTQZ8Rislfejfva5SmnB4ILCxxXhyuX8SYI,3116
|
|
24
|
+
sahiapi-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
25
|
+
sahiapi-0.1.0.dist-info/licenses/LICENSE,sha256=0IoMnDb-h6e1mLOCDhfEmXzVZTmiD2CQN7ZdP7ctfXE,395
|
|
26
|
+
sahiapi-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright (c) 2026 Aaritya Technologies Private Limited. All rights reserved.
|
|
2
|
+
|
|
3
|
+
This software and its source code are proprietary and confidential. Until an
|
|
4
|
+
open-source license is applied by the copyright holder, no permission is granted
|
|
5
|
+
to copy, use, modify, or distribute this software outside Aaritya Technologies.
|
|
6
|
+
|
|
7
|
+
An open-source license will replace this file when the project is published.
|