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,115 @@
|
|
|
1
|
+
"""Wire constants for the Sahi APIs.
|
|
2
|
+
|
|
3
|
+
Every enumerated value the API accepts or returns is named here, so callers use
|
|
4
|
+
a symbol instead of a bare string and typos surface at import time rather than
|
|
5
|
+
as a rejected order. The values are the exact wire strings from the API
|
|
6
|
+
reference; the SDK never translates them.
|
|
7
|
+
|
|
8
|
+
:class:`SahiAPI` re-exports these as class attributes (``SahiAPI.ORDER_TYPE_LIMIT``),
|
|
9
|
+
which is the form most examples use. They are defined here, at module level,
|
|
10
|
+
with no dependency on the rest of the package.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
# --- Exchange segments -------------------------------------------------------
|
|
16
|
+
# The market an instrument trades on, used by the order endpoints as part of the
|
|
17
|
+
# (exchange_segment, symbol) pair.
|
|
18
|
+
EXCHANGE_SEGMENT_NSE = "NSE" # NSE cash
|
|
19
|
+
EXCHANGE_SEGMENT_NFO = "NFO" # NSE futures & options
|
|
20
|
+
EXCHANGE_SEGMENT_BSE = "BSE" # BSE cash
|
|
21
|
+
EXCHANGE_SEGMENT_BFO = "BFO" # BSE futures & options
|
|
22
|
+
# Commodity segments. The order API accepts both values and the funds endpoint
|
|
23
|
+
# reports an option-premium pool for each, but commodity trading is not enabled
|
|
24
|
+
# on Sahi yet: an order on either segment is rejected by the server.
|
|
25
|
+
EXCHANGE_SEGMENT_MCX = "MCX" # MCX commodity derivatives
|
|
26
|
+
EXCHANGE_SEGMENT_NCOM = "NCOM" # NSE commodity derivatives
|
|
27
|
+
|
|
28
|
+
# --- Transaction type --------------------------------------------------------
|
|
29
|
+
TXN_TYPE_BUY = "B"
|
|
30
|
+
TXN_TYPE_SELL = "S"
|
|
31
|
+
|
|
32
|
+
# --- Order type --------------------------------------------------------------
|
|
33
|
+
ORDER_TYPE_LIMIT = "LMT"
|
|
34
|
+
ORDER_TYPE_MARKET = "MKT"
|
|
35
|
+
ORDER_TYPE_SL_LIMIT = "SL-LMT" # stop-loss limit
|
|
36
|
+
ORDER_TYPE_SL_MARKET = "SL-MKT" # stop-loss market
|
|
37
|
+
|
|
38
|
+
# --- Product -----------------------------------------------------------------
|
|
39
|
+
PRODUCT_DELIVERY = "C" # delivery, cash segment
|
|
40
|
+
PRODUCT_INTRADAY = "I" # intraday
|
|
41
|
+
PRODUCT_NORMAL = "M" # carry-forward, F&O
|
|
42
|
+
|
|
43
|
+
# --- Validity ----------------------------------------------------------------
|
|
44
|
+
VALIDITY_DAY = "DAY"
|
|
45
|
+
VALIDITY_IOC = "IOC" # immediate-or-cancel
|
|
46
|
+
|
|
47
|
+
# --- Order variant -----------------------------------------------------------
|
|
48
|
+
VARIANT_REGULAR = "REGULAR"
|
|
49
|
+
VARIANT_AMO = "AMO" # after-market order
|
|
50
|
+
|
|
51
|
+
# --- Order status (response only) --------------------------------------------
|
|
52
|
+
# The six values the API returns. A stop order (SL-LMT / SL-MKT) whose trigger
|
|
53
|
+
# has not yet fired, and an after-market order waiting for the next session,
|
|
54
|
+
# both show as Open: the API has no separate "awaiting trigger" status.
|
|
55
|
+
ORDER_STATUS_OPEN = "Open"
|
|
56
|
+
ORDER_STATUS_OPEN_PARTIAL = "OpenPartial"
|
|
57
|
+
ORDER_STATUS_PARTIAL_FILLED = "PartialFilled"
|
|
58
|
+
ORDER_STATUS_COMPLETED = "Completed"
|
|
59
|
+
ORDER_STATUS_CANCELLED = "Cancelled"
|
|
60
|
+
ORDER_STATUS_REJECTED = "Rejected"
|
|
61
|
+
|
|
62
|
+
# --- Candle sizes ------------------------------------------------------------
|
|
63
|
+
# Case-sensitive: "1M" is not one minute and is rejected.
|
|
64
|
+
CANDLE_1M = "1m"
|
|
65
|
+
CANDLE_3M = "3m"
|
|
66
|
+
CANDLE_5M = "5m"
|
|
67
|
+
CANDLE_15M = "15m"
|
|
68
|
+
CANDLE_30M = "30m"
|
|
69
|
+
CANDLE_1H = "1h"
|
|
70
|
+
CANDLE_1D = "1d"
|
|
71
|
+
|
|
72
|
+
# --- Tick-stream modes -------------------------------------------------------
|
|
73
|
+
# The subscription mode for the market-data WebSocket, from lightest to
|
|
74
|
+
# richest: last price only, full quote, or quote plus market depth.
|
|
75
|
+
MODE_LTP = "LTP"
|
|
76
|
+
MODE_QUOTE = "QUOTE"
|
|
77
|
+
MODE_DEPTH = "DEPTH"
|
|
78
|
+
|
|
79
|
+
# --- Scopes ------------------------------------------------------------------
|
|
80
|
+
# The permissions a key can hold and a token can carry.
|
|
81
|
+
SCOPE_ORDERS_WRITE = "orders.write"
|
|
82
|
+
SCOPE_ORDERS_READ = "orders.read"
|
|
83
|
+
SCOPE_POSITIONS_READ = "positions.read"
|
|
84
|
+
SCOPE_HOLDINGS_READ = "holdings.read"
|
|
85
|
+
SCOPE_FUNDS_READ = "funds.read"
|
|
86
|
+
SCOPE_PROFILE_READ = "profile.read"
|
|
87
|
+
SCOPE_MARKETDATA_READ = "marketdata.read"
|
|
88
|
+
|
|
89
|
+
# --- Instrument types --------------------------------------------------------
|
|
90
|
+
# The instrument_type a tick reports (proto enum names with the prefix stripped
|
|
91
|
+
# by the feed codec). The scrip master classifies instruments too, but with its
|
|
92
|
+
# own short forms (STK, FUTIDX, ...); these are the tick-stream spellings.
|
|
93
|
+
INSTRUMENT_TYPE_INDEX = "INDEX"
|
|
94
|
+
INSTRUMENT_TYPE_STOCK = "STOCK"
|
|
95
|
+
INSTRUMENT_TYPE_ETF = "ETF"
|
|
96
|
+
INSTRUMENT_TYPE_INDEX_FUT = "INDEX_FUT"
|
|
97
|
+
INSTRUMENT_TYPE_INDEX_OPT_CE = "INDEX_OPT_CE"
|
|
98
|
+
INSTRUMENT_TYPE_INDEX_OPT_PE = "INDEX_OPT_PE"
|
|
99
|
+
INSTRUMENT_TYPE_STOCK_FUT = "STOCK_FUT"
|
|
100
|
+
INSTRUMENT_TYPE_STOCK_OPT_CE = "STOCK_OPT_CE"
|
|
101
|
+
INSTRUMENT_TYPE_STOCK_OPT_PE = "STOCK_OPT_PE"
|
|
102
|
+
|
|
103
|
+
# --- WebSocket close codes ---------------------------------------------------
|
|
104
|
+
# Application close codes both streams use, beyond the standard 1000/1006. The
|
|
105
|
+
# SDK reconnects on 4408 and 4409 and treats the others as final.
|
|
106
|
+
WS_CLOSE_AUTH_EXPIRED = 4401 # token reached exp; mint a fresh one and reconnect
|
|
107
|
+
WS_CLOSE_FORBIDDEN = 4403 # token lacks the stream's scope
|
|
108
|
+
WS_CLOSE_HEARTBEAT = 4408 # no pong within 15 s of the server ping
|
|
109
|
+
WS_CLOSE_DROPPED = 4409 # client fell too far behind (slow consumer); re-sync after reconnect
|
|
110
|
+
WS_CLOSE_TOO_MANY = 4429 # over the 5-concurrent-connection limit for this token/account
|
|
111
|
+
|
|
112
|
+
# Every module-level constant is public and re-exported at the package top level,
|
|
113
|
+
# so callers can write `from sahiapi import ORDER_TYPE_LIMIT`. The names are all
|
|
114
|
+
# upper-case; nothing else here is.
|
|
115
|
+
__all__ = [name for name in dir() if name.isupper()]
|
sahiapi/sahi/env.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""The hosts the SDK talks to.
|
|
2
|
+
|
|
3
|
+
The SDK always talks to the production Sahi hosts. There is no argument or
|
|
4
|
+
environment variable for pointing it anywhere else.
|
|
5
|
+
|
|
6
|
+
The two hosts are separate: the **API** host serves everything under ``/v1``,
|
|
7
|
+
and the **auth** host serves ``/api/v1/apikeys/token``. WebSocket URLs are
|
|
8
|
+
derived from the API host by swapping the scheme, so there is only ever one
|
|
9
|
+
host to know about.
|
|
10
|
+
|
|
11
|
+
Code in this package reads these names through the module, as
|
|
12
|
+
``env.API_BASE_URL``, rather than importing them by name.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"API_BASE_URL",
|
|
19
|
+
"AUTH_BASE_URL",
|
|
20
|
+
"websocket_base_url",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
#: The API host (serves ``/v1``). No trailing slash.
|
|
24
|
+
API_BASE_URL = "https://direct.sahi.com"
|
|
25
|
+
#: The auth host (serves the token endpoint). No trailing slash.
|
|
26
|
+
AUTH_BASE_URL = "https://apis.sahi.com"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def websocket_base_url(api_base_url: str) -> str:
|
|
30
|
+
"""Derive the WebSocket base URL from an API base URL by scheme swap.
|
|
31
|
+
|
|
32
|
+
``https`` becomes ``wss`` and ``http`` becomes ``ws``; any other scheme is
|
|
33
|
+
returned unchanged so an explicitly ``wss://`` base is respected.
|
|
34
|
+
"""
|
|
35
|
+
if api_base_url.startswith("https://"):
|
|
36
|
+
return "wss://" + api_base_url[len("https://") :]
|
|
37
|
+
if api_base_url.startswith("http://"):
|
|
38
|
+
return "ws://" + api_base_url[len("http://") :]
|
|
39
|
+
return api_base_url
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"""Exception hierarchy for the Sahi SDK.
|
|
2
|
+
|
|
3
|
+
Every error the SDK raises descends from :class:`SahiBaseException`, so one
|
|
4
|
+
``except`` clause can handle anything the SDK might raise. Below it the tree
|
|
5
|
+
splits three ways:
|
|
6
|
+
|
|
7
|
+
* :class:`SahiConfigurationException` — the SDK was used wrongly before any
|
|
8
|
+
request was made: a credential of the wrong shape, a missing setting.
|
|
9
|
+
* :class:`SahiAPIException` — a request reached a Sahi host and came back
|
|
10
|
+
non-2xx. It carries the server's ``status``, ``code`` and ``message``, the
|
|
11
|
+
request id for support, and a ``hint`` saying what to do about it.
|
|
12
|
+
* :class:`SahiFeedException` — a WebSocket stream failed to connect, was closed
|
|
13
|
+
by the server for good, or was asked for data it does not hold.
|
|
14
|
+
|
|
15
|
+
Match on the exception *type*, never on the message text. The two Sahi hosts use
|
|
16
|
+
different code vocabularies for the same envelope — the auth host uses
|
|
17
|
+
``PascalCase`` (``Unauthorized``), everything under ``/v1`` uses
|
|
18
|
+
``SCREAMING_SNAKE_CASE`` (``IP_NOT_ALLOWED``) — and :func:`exception_for` folds
|
|
19
|
+
that difference away so each subclass means one thing regardless of host.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from typing import Literal
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"SahiAPIException",
|
|
28
|
+
"SahiAccountLockedException",
|
|
29
|
+
"SahiAuthenticationException",
|
|
30
|
+
"SahiAuthorisationException",
|
|
31
|
+
"SahiBadRequestException",
|
|
32
|
+
"SahiBaseException",
|
|
33
|
+
"SahiConfigurationException",
|
|
34
|
+
"SahiConnectionException",
|
|
35
|
+
"SahiFeedAuthExpiredException",
|
|
36
|
+
"SahiFeedConnectionException",
|
|
37
|
+
"SahiFeedException",
|
|
38
|
+
"SahiFeedForbiddenException",
|
|
39
|
+
"SahiFeedNotSubscribedException",
|
|
40
|
+
"SahiInvalidOrderStateException",
|
|
41
|
+
"SahiIpNotAllowedException",
|
|
42
|
+
"SahiKeyNotApprovedException",
|
|
43
|
+
"SahiNotFoundException",
|
|
44
|
+
"SahiOrderNotFoundException",
|
|
45
|
+
"SahiRateLimitException",
|
|
46
|
+
"SahiServerException",
|
|
47
|
+
"SahiTimeoutException",
|
|
48
|
+
"SahiValidationException",
|
|
49
|
+
"exception_for",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class SahiBaseException(Exception):
|
|
54
|
+
"""Base class for every exception raised by the SDK."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class SahiConfigurationException(SahiBaseException):
|
|
58
|
+
"""The SDK was misconfigured or misused before a request could be made.
|
|
59
|
+
|
|
60
|
+
Raised locally, never in response to a server reply. The message says what
|
|
61
|
+
was wrong and what to pass instead.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class SahiAPIException(SahiBaseException):
|
|
66
|
+
"""A request to a Sahi host failed.
|
|
67
|
+
|
|
68
|
+
Usually the host returned a non-2xx response. Two subclasses cover the cases
|
|
69
|
+
where no response arrived: :class:`SahiConnectionException` (nothing was
|
|
70
|
+
sent) and :class:`SahiTimeoutException` (the request may have been sent).
|
|
71
|
+
|
|
72
|
+
Attributes:
|
|
73
|
+
status: The HTTP status code, or ``None`` if no response arrived.
|
|
74
|
+
code: The stable ``code`` from the error envelope, verbatim as the host
|
|
75
|
+
sent it (``"Unauthorized"`` on the auth host, ``"IP_NOT_ALLOWED"``
|
|
76
|
+
under ``/v1``). ``None`` if the body carried none.
|
|
77
|
+
message: The server's human-readable ``message``, if any.
|
|
78
|
+
request_id: The request id, for quoting to Sahi support. Echoed from the
|
|
79
|
+
response when the host returns one, otherwise the value the SDK
|
|
80
|
+
sent with the request.
|
|
81
|
+
hint: What to do about it, per the API reference. Each subclass supplies
|
|
82
|
+
its own; the base class has none.
|
|
83
|
+
|
|
84
|
+
``str(exc)`` combines all of these, so logging the exception is enough to
|
|
85
|
+
diagnose and act on it.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
#: Actionable guidance appended to ``str()``; overridden per subclass.
|
|
89
|
+
hint: str = ""
|
|
90
|
+
|
|
91
|
+
def __init__(
|
|
92
|
+
self,
|
|
93
|
+
message: str | None = None,
|
|
94
|
+
*,
|
|
95
|
+
status: int | None = None,
|
|
96
|
+
code: str | None = None,
|
|
97
|
+
request_id: str | None = None,
|
|
98
|
+
) -> None:
|
|
99
|
+
self.status = status
|
|
100
|
+
self.code = code
|
|
101
|
+
self.message = message
|
|
102
|
+
self.request_id = request_id
|
|
103
|
+
super().__init__(self._render())
|
|
104
|
+
|
|
105
|
+
def _render(self) -> str:
|
|
106
|
+
head = " ".join(
|
|
107
|
+
p for p in (f"HTTP {self.status}" if self.status else "", self.code or "") if p
|
|
108
|
+
)
|
|
109
|
+
body = self.message or ""
|
|
110
|
+
text = f"{head}: {body}" if head and body else head or body or self.__class__.__name__
|
|
111
|
+
if self.hint:
|
|
112
|
+
text = f"{text.rstrip('.')}. {self.hint}"
|
|
113
|
+
if self.request_id:
|
|
114
|
+
text = f"{text} [request_id={self.request_id}]"
|
|
115
|
+
return text
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class SahiBadRequestException(SahiAPIException):
|
|
119
|
+
"""400: the request was malformed.
|
|
120
|
+
|
|
121
|
+
On the auth host this also covers ``scopes`` that are not a subset of the
|
|
122
|
+
key's scopes — a token can only ever narrow a key, never widen it.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
hint = "Check the request fields against the API reference."
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class SahiValidationException(SahiBadRequestException):
|
|
129
|
+
"""``VALIDATION_FAILED``: a field failed the API's business rules.
|
|
130
|
+
|
|
131
|
+
Arrives as ``400`` for a malformed field and as ``422`` for one the API
|
|
132
|
+
rejects on meaning — an unknown symbol, a quantity over the freeze quantity,
|
|
133
|
+
a contract past expiry. The server ``message`` names the field or rule.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
hint = "The server message names the field or rule that failed."
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class SahiAuthenticationException(SahiAPIException):
|
|
140
|
+
"""401: the credential or token was missing, unknown, wrong, or revoked.
|
|
141
|
+
|
|
142
|
+
The auth host deliberately does not say which: an unknown key id, a wrong
|
|
143
|
+
secret and a revoked key all return the same response.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
hint = (
|
|
147
|
+
"Check the key id and secret. If a token was in use, it may have expired or "
|
|
148
|
+
"its key may have been revoked — mint a new token."
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class SahiAccountLockedException(SahiAuthenticationException):
|
|
153
|
+
"""401 ``Locked``: the account behind the credential is frozen or locked."""
|
|
154
|
+
|
|
155
|
+
hint = "The account is locked or frozen. Contact Sahi support to restore access."
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class SahiAuthorisationException(SahiAPIException):
|
|
159
|
+
"""403: the token is valid but not permitted to do this.
|
|
160
|
+
|
|
161
|
+
Under ``/v1`` this means the token lacks the scope the endpoint needs.
|
|
162
|
+
Scopes are fixed when the key is created and a token can only narrow them.
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
hint = (
|
|
166
|
+
"The token lacks the scope this endpoint needs. Mint a token with the required "
|
|
167
|
+
"scope, or create a key that holds it."
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class SahiKeyNotApprovedException(SahiAuthorisationException):
|
|
172
|
+
"""403 on the auth host: the key is not approved for the current trading day.
|
|
173
|
+
|
|
174
|
+
Approval is a daily act and lapses at 06:00 IST; it is an exchange
|
|
175
|
+
requirement, not something the SDK can bypass.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
hint = "Approve the key for today's trading day in your Sahi account, then mint again."
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class SahiIpNotAllowedException(SahiAuthorisationException):
|
|
182
|
+
"""403 ``IP_NOT_ALLOWED``: an order write did not come from a registered IP.
|
|
183
|
+
|
|
184
|
+
Placing, modifying and cancelling orders must originate from a static IP
|
|
185
|
+
registered on the account. Reads and market data are not IP-checked.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
hint = (
|
|
189
|
+
"Order writes must come from a static IP registered on your account. Check the "
|
|
190
|
+
"public egress address of this host — it usually means the address changed."
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class SahiNotFoundException(SahiAPIException):
|
|
195
|
+
"""404: the route or resource does not exist.
|
|
196
|
+
|
|
197
|
+
Also returned by the funds, margin and profile endpoints when the account
|
|
198
|
+
behind the token is not yet set up for trading.
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
hint = (
|
|
202
|
+
"Check the path. For funds, margin and profile this can also mean the account "
|
|
203
|
+
"is not yet set up for trading."
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class SahiOrderNotFoundException(SahiNotFoundException):
|
|
208
|
+
"""404 ``ORDER_NOT_FOUND``: no such order on today's book for this account."""
|
|
209
|
+
|
|
210
|
+
hint = "The order is not on today's book, or does not belong to this account."
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class SahiInvalidOrderStateException(SahiAPIException):
|
|
214
|
+
"""``INVALID_ORDER_STATE``: the order cannot change from its current state.
|
|
215
|
+
|
|
216
|
+
Only ``Open`` and ``OpenPartial`` orders can be modified or cancelled; a
|
|
217
|
+
completed, cancelled or rejected order is final.
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
hint = "Only Open and OpenPartial orders can be modified or cancelled."
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class SahiRateLimitException(SahiAPIException):
|
|
224
|
+
"""429: a rate-limit window was exhausted.
|
|
225
|
+
|
|
226
|
+
Attributes:
|
|
227
|
+
retry_after: Seconds until the window that tripped has run out, from the
|
|
228
|
+
``Retry-After`` header. ``None`` on the auth host, which sends no
|
|
229
|
+
header — wait for the next minute instead.
|
|
230
|
+
"""
|
|
231
|
+
|
|
232
|
+
hint = (
|
|
233
|
+
"Wait retry_after seconds before retrying (or until the next minute when it is "
|
|
234
|
+
"not given). Batch requests instead of looping per instrument."
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
def __init__(
|
|
238
|
+
self,
|
|
239
|
+
message: str | None = None,
|
|
240
|
+
*,
|
|
241
|
+
status: int | None = None,
|
|
242
|
+
code: str | None = None,
|
|
243
|
+
request_id: str | None = None,
|
|
244
|
+
retry_after: int | None = None,
|
|
245
|
+
) -> None:
|
|
246
|
+
self.retry_after = retry_after
|
|
247
|
+
super().__init__(message, status=status, code=code, request_id=request_id)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class SahiServerException(SahiAPIException):
|
|
251
|
+
"""5xx: something failed on Sahi's side."""
|
|
252
|
+
|
|
253
|
+
hint = (
|
|
254
|
+
"Retry an idempotent read after a short wait. Do not blindly retry an order write: "
|
|
255
|
+
"check the order book first so the order is not sent twice."
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class SahiTimeoutException(SahiAPIException):
|
|
260
|
+
"""No response arrived within the timeout.
|
|
261
|
+
|
|
262
|
+
The request may or may not have reached the server. For an order write,
|
|
263
|
+
read the order book before retrying so the order is not placed twice.
|
|
264
|
+
"""
|
|
265
|
+
|
|
266
|
+
hint = (
|
|
267
|
+
"The request may have reached the server. For an order write, check the order "
|
|
268
|
+
"book before retrying."
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class SahiConnectionException(SahiAPIException):
|
|
273
|
+
"""The connection to the host could not be opened.
|
|
274
|
+
|
|
275
|
+
DNS failure, connection refused, a TLS handshake error or a connect timeout.
|
|
276
|
+
Nothing was sent, so the request is safe to retry once the network or the
|
|
277
|
+
base URL is fixed.
|
|
278
|
+
"""
|
|
279
|
+
|
|
280
|
+
hint = "Nothing reached the server; the request is safe to retry."
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class SahiFeedException(SahiBaseException):
|
|
284
|
+
"""Base class for WebSocket stream errors.
|
|
285
|
+
|
|
286
|
+
Attributes:
|
|
287
|
+
close_code: The close code the server sent when it ended the stream
|
|
288
|
+
(``4401``, ``4403`` or ``4429``; compare against the ``WS_CLOSE_*``
|
|
289
|
+
constants). ``None`` when no close frame was involved: the server
|
|
290
|
+
refused the HTTP handshake, or the SDK raised locally, for instance
|
|
291
|
+
a send on a socket that is not open.
|
|
292
|
+
"""
|
|
293
|
+
|
|
294
|
+
def __init__(self, message: str, *, close_code: int | None = None) -> None:
|
|
295
|
+
self.close_code = close_code
|
|
296
|
+
super().__init__(message)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
class SahiFeedConnectionException(SahiFeedException):
|
|
300
|
+
"""The stream could not be used because of a connection-level refusal.
|
|
301
|
+
|
|
302
|
+
Raised for close code ``4429`` (the per-token or per-account limit of five
|
|
303
|
+
concurrent connections is exceeded) and for a send attempted while the
|
|
304
|
+
stream is not connected. ``close_code`` tells the two apart: it is ``4429``
|
|
305
|
+
in the first case, which clears once another connection is closed, and
|
|
306
|
+
``None`` in the second, which is a caller-side ordering problem. A missed
|
|
307
|
+
heartbeat (``4408``), a slow-consumer drop (``4409``) or an ordinary network
|
|
308
|
+
drop is *not* this exception: those reconnect transparently and nothing is
|
|
309
|
+
raised.
|
|
310
|
+
"""
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
class SahiFeedAuthExpiredException(SahiFeedException):
|
|
314
|
+
"""The token was rejected: close code ``4401``, or ``401`` at the handshake.
|
|
315
|
+
|
|
316
|
+
A socket is never extended past its token's ``exp``. Mint a fresh token,
|
|
317
|
+
build a new feed, and re-subscribe.
|
|
318
|
+
"""
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
class SahiFeedForbiddenException(SahiFeedException):
|
|
322
|
+
"""The token lacks the stream's scope: ``403`` at the handshake, or close ``4403``."""
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
class SahiFeedNotSubscribedException(SahiFeedException):
|
|
326
|
+
"""A cached value was requested for an instrument that is not subscribed."""
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
# Host discriminators for exception_for. Typed as a Literal so a wrong host is a
|
|
330
|
+
# type error rather than a silent fall-through to the API-host branch.
|
|
331
|
+
Host = Literal["auth", "api"]
|
|
332
|
+
HOST_AUTH: Host = "auth"
|
|
333
|
+
HOST_API: Host = "api"
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _normalise(code: str | None) -> str:
|
|
337
|
+
"""Fold a code to a host-independent key: upper-case, no separators."""
|
|
338
|
+
if not code:
|
|
339
|
+
return ""
|
|
340
|
+
return code.upper().replace("_", "").replace("-", "")
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def exception_for(
|
|
344
|
+
status: int,
|
|
345
|
+
*,
|
|
346
|
+
host: Host,
|
|
347
|
+
code: str | None = None,
|
|
348
|
+
message: str | None = None,
|
|
349
|
+
request_id: str | None = None,
|
|
350
|
+
retry_after: int | None = None,
|
|
351
|
+
) -> SahiAPIException:
|
|
352
|
+
"""Build the right :class:`SahiAPIException` for a non-2xx response.
|
|
353
|
+
|
|
354
|
+
The mapping keys on the HTTP status, the ``host`` the call went to
|
|
355
|
+
(:data:`HOST_AUTH` or :data:`HOST_API`) and the envelope ``code``. A
|
|
356
|
+
recognised code selects its specific subclass whatever the status, so a
|
|
357
|
+
``422 VALIDATION_FAILED`` and a ``400 VALIDATION_FAILED`` land in the same
|
|
358
|
+
place. Otherwise the status selects a status-level class; an unmapped status
|
|
359
|
+
falls back to :class:`SahiServerException` for 5xx and
|
|
360
|
+
:class:`SahiAPIException` for anything else.
|
|
361
|
+
|
|
362
|
+
Args:
|
|
363
|
+
status: HTTP status code of the response.
|
|
364
|
+
host: :data:`HOST_AUTH` or :data:`HOST_API`. A ``403`` means "key not
|
|
365
|
+
approved" on the auth host but "insufficient scope" under ``/v1``.
|
|
366
|
+
code: The envelope ``code``, verbatim.
|
|
367
|
+
message: The envelope ``message``.
|
|
368
|
+
request_id: The request id to attach for support.
|
|
369
|
+
retry_after: Parsed ``Retry-After`` seconds, for 429s.
|
|
370
|
+
|
|
371
|
+
Returns:
|
|
372
|
+
An unraised exception instance for the caller to raise.
|
|
373
|
+
"""
|
|
374
|
+
key = _normalise(code)
|
|
375
|
+
|
|
376
|
+
def build(cls: type[SahiAPIException]) -> SahiAPIException:
|
|
377
|
+
return cls(message, status=status, code=code, request_id=request_id)
|
|
378
|
+
|
|
379
|
+
# Code-specific mappings first — they are unambiguous regardless of status.
|
|
380
|
+
if key == "VALIDATIONFAILED":
|
|
381
|
+
return build(SahiValidationException)
|
|
382
|
+
if key == "ORDERNOTFOUND":
|
|
383
|
+
return build(SahiOrderNotFoundException)
|
|
384
|
+
if key == "INVALIDORDERSTATE":
|
|
385
|
+
return build(SahiInvalidOrderStateException)
|
|
386
|
+
if key == "IPNOTALLOWED":
|
|
387
|
+
return build(SahiIpNotAllowedException)
|
|
388
|
+
if key == "LOCKED":
|
|
389
|
+
return build(SahiAccountLockedException)
|
|
390
|
+
|
|
391
|
+
if status == 400:
|
|
392
|
+
return build(SahiBadRequestException)
|
|
393
|
+
if status == 401:
|
|
394
|
+
return build(SahiAuthenticationException)
|
|
395
|
+
if status == 403:
|
|
396
|
+
if host == HOST_AUTH:
|
|
397
|
+
return build(SahiKeyNotApprovedException)
|
|
398
|
+
return build(SahiAuthorisationException)
|
|
399
|
+
if status == 404:
|
|
400
|
+
return build(SahiNotFoundException)
|
|
401
|
+
if status == 429:
|
|
402
|
+
return SahiRateLimitException(
|
|
403
|
+
message,
|
|
404
|
+
status=status,
|
|
405
|
+
code=code,
|
|
406
|
+
request_id=request_id,
|
|
407
|
+
retry_after=retry_after,
|
|
408
|
+
)
|
|
409
|
+
if 500 <= status < 600:
|
|
410
|
+
return build(SahiServerException)
|
|
411
|
+
|
|
412
|
+
return build(SahiAPIException)
|