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
sahiapi/__init__.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""sahiapi — the official Python client for the Sahi APIs.
|
|
2
|
+
|
|
3
|
+
The public interface lives here. Everything under :mod:`sahiapi.sahi` is
|
|
4
|
+
internal and may change without notice.
|
|
5
|
+
|
|
6
|
+
from sahiapi import SahiAPI
|
|
7
|
+
|
|
8
|
+
api = SahiAPI.from_credentials(key_id="...", secret="...")
|
|
9
|
+
print(api.get_profile())
|
|
10
|
+
|
|
11
|
+
The SDK mirrors the API reference at https://docs.sahi.com/ exactly: it does
|
|
12
|
+
not rename fields, build models, or convert units. Money is integer paise,
|
|
13
|
+
quantities are integer units, timestamps are epoch milliseconds, and identifiers
|
|
14
|
+
are opaque strings.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from .sahi import constants
|
|
20
|
+
from .sahi.auth import get_access_token, token_expires_at, token_scopes
|
|
21
|
+
from .sahi.client import SahiAPI
|
|
22
|
+
from .sahi.constants import * # noqa: F403 -- re-export every wire constant
|
|
23
|
+
from .sahi.exceptions import (
|
|
24
|
+
SahiAccountLockedException,
|
|
25
|
+
SahiAPIException,
|
|
26
|
+
SahiAuthenticationException,
|
|
27
|
+
SahiAuthorisationException,
|
|
28
|
+
SahiBadRequestException,
|
|
29
|
+
SahiBaseException,
|
|
30
|
+
SahiConfigurationException,
|
|
31
|
+
SahiConnectionException,
|
|
32
|
+
SahiFeedAuthExpiredException,
|
|
33
|
+
SahiFeedConnectionException,
|
|
34
|
+
SahiFeedException,
|
|
35
|
+
SahiFeedForbiddenException,
|
|
36
|
+
SahiFeedNotSubscribedException,
|
|
37
|
+
SahiInvalidOrderStateException,
|
|
38
|
+
SahiIpNotAllowedException,
|
|
39
|
+
SahiKeyNotApprovedException,
|
|
40
|
+
SahiNotFoundException,
|
|
41
|
+
SahiOrderNotFoundException,
|
|
42
|
+
SahiRateLimitException,
|
|
43
|
+
SahiServerException,
|
|
44
|
+
SahiTimeoutException,
|
|
45
|
+
SahiValidationException,
|
|
46
|
+
)
|
|
47
|
+
from .sahi.feed import SahiFeed
|
|
48
|
+
|
|
49
|
+
try: # pragma: no cover - trivial metadata lookup
|
|
50
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
51
|
+
|
|
52
|
+
__version__ = version("sahiapi")
|
|
53
|
+
except PackageNotFoundError: # pragma: no cover - running from a source tree
|
|
54
|
+
__version__ = "0.0.0+unknown"
|
|
55
|
+
|
|
56
|
+
__all__ = [
|
|
57
|
+
# Client and authentication
|
|
58
|
+
"SahiAPI",
|
|
59
|
+
"SahiAPIException",
|
|
60
|
+
"SahiAccountLockedException",
|
|
61
|
+
"SahiAuthenticationException",
|
|
62
|
+
"SahiAuthorisationException",
|
|
63
|
+
"SahiBadRequestException",
|
|
64
|
+
"SahiBaseException",
|
|
65
|
+
"SahiConfigurationException",
|
|
66
|
+
"SahiConnectionException",
|
|
67
|
+
"SahiFeed",
|
|
68
|
+
"SahiFeedAuthExpiredException",
|
|
69
|
+
"SahiFeedConnectionException",
|
|
70
|
+
"SahiFeedException",
|
|
71
|
+
"SahiFeedForbiddenException",
|
|
72
|
+
"SahiFeedNotSubscribedException",
|
|
73
|
+
"SahiInvalidOrderStateException",
|
|
74
|
+
"SahiIpNotAllowedException",
|
|
75
|
+
"SahiKeyNotApprovedException",
|
|
76
|
+
"SahiNotFoundException",
|
|
77
|
+
"SahiOrderNotFoundException",
|
|
78
|
+
"SahiRateLimitException",
|
|
79
|
+
"SahiServerException",
|
|
80
|
+
"SahiTimeoutException",
|
|
81
|
+
"SahiValidationException",
|
|
82
|
+
"__version__",
|
|
83
|
+
"constants",
|
|
84
|
+
"get_access_token",
|
|
85
|
+
"token_expires_at",
|
|
86
|
+
"token_scopes",
|
|
87
|
+
]
|
|
88
|
+
__all__ += list(constants.__all__)
|
sahiapi/py.typed
ADDED
|
File without changes
|
sahiapi/sahi/__init__.py
ADDED
|
File without changes
|
sahiapi/sahi/account.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Account endpoint: profile.
|
|
2
|
+
|
|
3
|
+
Mirrors the Account page of the API reference. ``get_profile`` needs the
|
|
4
|
+
``profile.read`` scope. Read it once at start of day; it does not change during a
|
|
5
|
+
session.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .base import EndpointGroup
|
|
13
|
+
|
|
14
|
+
__all__ = ["_Account"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class _Account(EndpointGroup):
|
|
18
|
+
"""Account methods mixed into :class:`SahiAPI`."""
|
|
19
|
+
|
|
20
|
+
def get_profile(self, *, timeout: float | None = None) -> dict[str, Any]:
|
|
21
|
+
"""Return your identity and account status (``GET /v1/profile``).
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
``{"user_id": ..., "name": ...}``.
|
|
25
|
+
"""
|
|
26
|
+
return self._request( # type: ignore[no-any-return]
|
|
27
|
+
"GET", "/v1/profile", timeout=timeout, retry_on_rate_limit=True
|
|
28
|
+
)
|
sahiapi/sahi/auth.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Authentication: minting an access token and reading what it carries.
|
|
2
|
+
|
|
3
|
+
A Sahi access token is minted from an API key. You create the key in your Sahi
|
|
4
|
+
account, approve it for the trading day, and exchange the key id and secret for a
|
|
5
|
+
token with :func:`get_access_token`. The token is a Bearer JWT that every REST
|
|
6
|
+
and WebSocket call carries, and it expires at 06:00 IST — there is no refresh, so
|
|
7
|
+
mint a fresh one each trading day.
|
|
8
|
+
|
|
9
|
+
The helpers here also read a token's own claims without verifying its signature,
|
|
10
|
+
to tell callers when a token expires and what it is allowed to do. They are for
|
|
11
|
+
information only: the SDK never refuses a request on them, and the server is
|
|
12
|
+
always the authority.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import base64
|
|
18
|
+
import binascii
|
|
19
|
+
import json
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from . import env
|
|
24
|
+
from .exceptions import HOST_AUTH, SahiConfigurationException
|
|
25
|
+
from .http import HttpClient
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"decode_token_payload",
|
|
29
|
+
"expiry_from_payload",
|
|
30
|
+
"get_access_token",
|
|
31
|
+
"scopes_from_payload",
|
|
32
|
+
"token_expires_at",
|
|
33
|
+
"token_scopes",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
_TOKEN_PATH = "/api/v1/apikeys/token"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_access_token(
|
|
40
|
+
key_id: str,
|
|
41
|
+
secret: str,
|
|
42
|
+
*,
|
|
43
|
+
scopes: list[str] | None = None,
|
|
44
|
+
timeout: float = 10.0,
|
|
45
|
+
) -> dict[str, Any]:
|
|
46
|
+
"""Exchange an API key for a short-lived access token.
|
|
47
|
+
|
|
48
|
+
Calls ``POST /api/v1/apikeys/token`` on the auth host and returns the token
|
|
49
|
+
response as-is.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
key_id: The key id from your Sahi account.
|
|
53
|
+
secret: The secret shown once when the key was created.
|
|
54
|
+
scopes: Optional subset of the key's scopes to narrow the token to. A
|
|
55
|
+
token can only ever carry scopes the key holds. Omit it, or pass an
|
|
56
|
+
empty list, to get the key's full set.
|
|
57
|
+
timeout: Request timeout in seconds.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
The token response dict, unchanged: ``access_token``, ``token_type``,
|
|
61
|
+
``expires_in``, ``expires_at`` and ``scope``.
|
|
62
|
+
|
|
63
|
+
The key id and secret are sent as given: their format is the auth server's
|
|
64
|
+
to define, so the SDK does not check it.
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
SahiAuthenticationException: Unknown key id, wrong secret, or a revoked
|
|
68
|
+
key (401 — deliberately indistinguishable).
|
|
69
|
+
SahiAccountLockedException: The account is frozen or locked.
|
|
70
|
+
SahiKeyNotApprovedException: The key is not approved for the current
|
|
71
|
+
trading day.
|
|
72
|
+
SahiBadRequestException: A field is malformed, or ``scopes`` is not a
|
|
73
|
+
subset of the key's scopes.
|
|
74
|
+
SahiRateLimitException: Too many mint attempts; wait for the next minute.
|
|
75
|
+
"""
|
|
76
|
+
body: dict[str, Any] = {"key_id": key_id, "secret": secret}
|
|
77
|
+
if scopes is not None:
|
|
78
|
+
body["scopes"] = scopes
|
|
79
|
+
|
|
80
|
+
with HttpClient(env.AUTH_BASE_URL, host=HOST_AUTH, timeout=timeout) as client:
|
|
81
|
+
result = client.request("POST", _TOKEN_PATH, json=body)
|
|
82
|
+
return result # type: ignore[no-any-return] # token endpoint returns a JSON object
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def decode_token_payload(access_token: str) -> dict[str, Any]:
|
|
86
|
+
"""Decode a JWT's payload claims without verifying the signature.
|
|
87
|
+
|
|
88
|
+
This reads what the token asserts about itself; it does not check that the
|
|
89
|
+
assertions are trustworthy. The server verifies the signature on every call.
|
|
90
|
+
|
|
91
|
+
Raises:
|
|
92
|
+
SahiConfigurationException: If the value is not a decodable JWT.
|
|
93
|
+
"""
|
|
94
|
+
parts = access_token.split(".")
|
|
95
|
+
if len(parts) != 3:
|
|
96
|
+
raise SahiConfigurationException("access token is not a well-formed JWT")
|
|
97
|
+
payload_segment = parts[1]
|
|
98
|
+
padding = "=" * (-len(payload_segment) % 4)
|
|
99
|
+
try:
|
|
100
|
+
raw = base64.urlsafe_b64decode(payload_segment + padding)
|
|
101
|
+
payload = json.loads(raw)
|
|
102
|
+
except (binascii.Error, ValueError) as e:
|
|
103
|
+
raise SahiConfigurationException("could not decode the access token payload") from e
|
|
104
|
+
if not isinstance(payload, dict):
|
|
105
|
+
raise SahiConfigurationException("access token payload is not a JSON object")
|
|
106
|
+
return payload
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def expiry_from_payload(payload: dict[str, Any]) -> datetime | None:
|
|
110
|
+
"""The ``exp`` claim as a timezone-aware UTC datetime, or ``None`` if absent.
|
|
111
|
+
|
|
112
|
+
Raises:
|
|
113
|
+
SahiConfigurationException: If ``exp`` is present but is not a number
|
|
114
|
+
of epoch seconds that fits in a datetime — a token minted by
|
|
115
|
+
something other than Sahi.
|
|
116
|
+
"""
|
|
117
|
+
exp = payload.get("exp")
|
|
118
|
+
if exp is None:
|
|
119
|
+
return None
|
|
120
|
+
# bool is an int subclass; a literal true/false is not an epoch time.
|
|
121
|
+
if isinstance(exp, bool) or not isinstance(exp, (int, float)):
|
|
122
|
+
raise SahiConfigurationException(
|
|
123
|
+
f"access token 'exp' claim must be epoch seconds, got {type(exp).__name__}. "
|
|
124
|
+
"Use a token minted by get_access_token()."
|
|
125
|
+
)
|
|
126
|
+
try:
|
|
127
|
+
return datetime.fromtimestamp(exp, tz=timezone.utc)
|
|
128
|
+
except (OverflowError, OSError, ValueError) as e:
|
|
129
|
+
raise SahiConfigurationException(
|
|
130
|
+
"access token 'exp' claim is out of range for an epoch time. "
|
|
131
|
+
"Use a token minted by get_access_token()."
|
|
132
|
+
) from e
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def scopes_from_payload(payload: dict[str, Any]) -> frozenset[str]:
|
|
136
|
+
"""The ``scope`` claim split on whitespace; an absent or empty claim is ``frozenset()``."""
|
|
137
|
+
scope = payload.get("scope")
|
|
138
|
+
if not scope or not isinstance(scope, str):
|
|
139
|
+
return frozenset()
|
|
140
|
+
return frozenset(scope.split())
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def token_expires_at(access_token: str) -> datetime | None:
|
|
144
|
+
"""Return the token's expiry as a timezone-aware UTC datetime.
|
|
145
|
+
|
|
146
|
+
Reads the ``exp`` claim. Returns ``None`` if the token carries no ``exp``.
|
|
147
|
+
|
|
148
|
+
Raises:
|
|
149
|
+
SahiConfigurationException: If the value is not a decodable JWT, or its
|
|
150
|
+
``exp`` is not an epoch time.
|
|
151
|
+
"""
|
|
152
|
+
return expiry_from_payload(decode_token_payload(access_token))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def token_scopes(access_token: str) -> frozenset[str]:
|
|
156
|
+
"""Return the scopes the token carries, from its ``scope`` claim.
|
|
157
|
+
|
|
158
|
+
The claim is a space-separated string; an absent claim yields an empty set.
|
|
159
|
+
|
|
160
|
+
Raises:
|
|
161
|
+
SahiConfigurationException: If the value is not a decodable JWT.
|
|
162
|
+
"""
|
|
163
|
+
return scopes_from_payload(decode_token_payload(access_token))
|
sahiapi/sahi/base.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Shared base for the per-domain endpoint groups.
|
|
2
|
+
|
|
3
|
+
Each docs page maps to a mixin (``_Orders``, ``_Portfolio``, …) that lists the
|
|
4
|
+
methods for that page. :class:`SahiAPI` inherits all of them, so the user sees
|
|
5
|
+
one flat class while each file stays focused on a single resource. Every mixin
|
|
6
|
+
subclasses :class:`EndpointGroup`, which declares the ``_request`` method the
|
|
7
|
+
mixins call. :class:`SahiAPI` provides the real implementation; this base gives
|
|
8
|
+
the mixins a typed reference to it and keeps them independent of the client
|
|
9
|
+
module, so there is no import cycle.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
__all__ = ["EndpointGroup"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class EndpointGroup:
|
|
20
|
+
"""Base for domain mixins. ``SahiAPI`` supplies the real ``_request``."""
|
|
21
|
+
|
|
22
|
+
def _request(
|
|
23
|
+
self,
|
|
24
|
+
method: str,
|
|
25
|
+
path: str,
|
|
26
|
+
*,
|
|
27
|
+
params: dict[str, Any] | None = None,
|
|
28
|
+
json: Any = None,
|
|
29
|
+
timeout: float | None = None,
|
|
30
|
+
retry_on_rate_limit: bool = False,
|
|
31
|
+
) -> Any: # pragma: no cover - overridden by SahiAPI
|
|
32
|
+
raise NotImplementedError
|
sahiapi/sahi/client.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""The ``SahiAPI`` client: construction, authentication and token lifecycle.
|
|
2
|
+
|
|
3
|
+
``SahiAPI`` is the single entry point for the REST API. The short path is
|
|
4
|
+
:meth:`SahiAPI.from_credentials`, which mints a token and returns a ready client
|
|
5
|
+
in one call. When a token is minted once and shared — across processes, or a
|
|
6
|
+
token cached for the day — mint it with :func:`sahiapi.get_access_token` and pass
|
|
7
|
+
the whole response to the constructor. The domain methods (orders, portfolio, funds,
|
|
8
|
+
account, market data, instruments) are added by mixins in their own modules; this
|
|
9
|
+
module owns everything common to them: the HTTP client, the token and its
|
|
10
|
+
lifecycle, and the enum constants exposed as class attributes.
|
|
11
|
+
|
|
12
|
+
The client does no network I/O at construction and never refreshes a token on
|
|
13
|
+
its own — tokens expire at 06:00 IST by design. When a token expires, mint a new
|
|
14
|
+
one and pass it to :meth:`set_access_token`.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
from collections.abc import Mapping
|
|
21
|
+
from datetime import datetime
|
|
22
|
+
from types import TracebackType
|
|
23
|
+
from typing import Any, NamedTuple, Union
|
|
24
|
+
|
|
25
|
+
from . import auth, constants, env
|
|
26
|
+
from .account import _Account
|
|
27
|
+
from .exceptions import HOST_API, SahiConfigurationException
|
|
28
|
+
from .funds import _Funds
|
|
29
|
+
from .http import HttpClient
|
|
30
|
+
from .instruments import _Instruments
|
|
31
|
+
from .marketdata import _MarketData
|
|
32
|
+
from .orders import _Orders
|
|
33
|
+
from .portfolio import _Portfolio
|
|
34
|
+
|
|
35
|
+
__all__ = ["SahiAPI"]
|
|
36
|
+
|
|
37
|
+
_ENV_SCRIP_MASTER_URL = "SAHI_SCRIP_MASTER_URL"
|
|
38
|
+
|
|
39
|
+
#: What the client accepts as a token: the response dict from
|
|
40
|
+
#: :func:`get_access_token`, or just its ``access_token`` string.
|
|
41
|
+
Token = Union[str, Mapping[str, Any]]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _TokenState(NamedTuple):
|
|
45
|
+
"""Everything the client knows about its current token, published as one value."""
|
|
46
|
+
|
|
47
|
+
access_token: str
|
|
48
|
+
expires_at: datetime | None
|
|
49
|
+
scopes: frozenset[str]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _token_state(access_token: str) -> _TokenState:
|
|
53
|
+
"""Read the claims the client reports, when it can. The parsing lives in ``auth``.
|
|
54
|
+
|
|
55
|
+
The token's format is the server's to change. A token the SDK cannot read is
|
|
56
|
+
still used as given: it just reports no expiry and no scopes.
|
|
57
|
+
"""
|
|
58
|
+
try:
|
|
59
|
+
payload = auth.decode_token_payload(access_token)
|
|
60
|
+
return _TokenState(
|
|
61
|
+
access_token,
|
|
62
|
+
auth.expiry_from_payload(payload),
|
|
63
|
+
auth.scopes_from_payload(payload),
|
|
64
|
+
)
|
|
65
|
+
except SahiConfigurationException:
|
|
66
|
+
return _TokenState(access_token, None, frozenset())
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _access_token_of(token: Token, where: str) -> str:
|
|
70
|
+
"""Accept the token response or a bare token string.
|
|
71
|
+
|
|
72
|
+
Taking the whole response keeps the ``access_token`` field an implementation
|
|
73
|
+
detail of the auth API rather than something every caller must know about.
|
|
74
|
+
"""
|
|
75
|
+
if isinstance(token, Mapping):
|
|
76
|
+
value = token.get("access_token")
|
|
77
|
+
if not isinstance(value, str) or not value:
|
|
78
|
+
raise SahiConfigurationException(
|
|
79
|
+
f"{where} was given a dict without an 'access_token' field. Pass the "
|
|
80
|
+
"response of get_access_token() as-is."
|
|
81
|
+
)
|
|
82
|
+
token = value
|
|
83
|
+
if not isinstance(token, str):
|
|
84
|
+
raise SahiConfigurationException(
|
|
85
|
+
f"{where} expects the token response from get_access_token() or its "
|
|
86
|
+
f"access_token string, not {type(token).__name__}."
|
|
87
|
+
)
|
|
88
|
+
return token
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class SahiAPI(_Orders, _Portfolio, _Funds, _Account, _MarketData, _Instruments):
|
|
92
|
+
"""Client for the Sahi REST API.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
token: The response of :func:`sahiapi.get_access_token` (preferred), or
|
|
96
|
+
just its ``access_token`` string. Use :meth:`from_credentials` to go
|
|
97
|
+
straight from an API key to a client.
|
|
98
|
+
scrip_master_url: The scrip-master CSV download URL, needed for the
|
|
99
|
+
instrument lookups. Defaults to ``$SAHI_SCRIP_MASTER_URL``. The URL
|
|
100
|
+
is not in the public docs; it is provided when your API access is set
|
|
101
|
+
up.
|
|
102
|
+
timeout: Default per-request timeout in seconds.
|
|
103
|
+
|
|
104
|
+
The enum values the API uses are available as class attributes, e.g.
|
|
105
|
+
``SahiAPI.EXCHANGE_SEGMENT_NSE`` and ``SahiAPI.ORDER_TYPE_LIMIT``.
|
|
106
|
+
|
|
107
|
+
Every method returns the parsed response body unchanged and raises a subclass
|
|
108
|
+
of :class:`~sahiapi.SahiAPIException` for any non-2xx reply. Each exception
|
|
109
|
+
carries the HTTP ``status``, the server ``code`` and ``message``, a
|
|
110
|
+
``request_id`` to quote to support, and a ``hint`` saying what to do; its
|
|
111
|
+
``str()`` includes all of them. Catch the specific subclass you can act on
|
|
112
|
+
(:class:`~sahiapi.SahiAuthenticationException`,
|
|
113
|
+
:class:`~sahiapi.SahiRateLimitException`, …) and let the rest propagate.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
# -- exchange segments --
|
|
117
|
+
EXCHANGE_SEGMENT_NSE = constants.EXCHANGE_SEGMENT_NSE
|
|
118
|
+
EXCHANGE_SEGMENT_NFO = constants.EXCHANGE_SEGMENT_NFO
|
|
119
|
+
EXCHANGE_SEGMENT_BSE = constants.EXCHANGE_SEGMENT_BSE
|
|
120
|
+
EXCHANGE_SEGMENT_BFO = constants.EXCHANGE_SEGMENT_BFO
|
|
121
|
+
EXCHANGE_SEGMENT_MCX = constants.EXCHANGE_SEGMENT_MCX
|
|
122
|
+
EXCHANGE_SEGMENT_NCOM = constants.EXCHANGE_SEGMENT_NCOM
|
|
123
|
+
# -- transaction type --
|
|
124
|
+
TXN_TYPE_BUY = constants.TXN_TYPE_BUY
|
|
125
|
+
TXN_TYPE_SELL = constants.TXN_TYPE_SELL
|
|
126
|
+
# -- order type --
|
|
127
|
+
ORDER_TYPE_LIMIT = constants.ORDER_TYPE_LIMIT
|
|
128
|
+
ORDER_TYPE_MARKET = constants.ORDER_TYPE_MARKET
|
|
129
|
+
ORDER_TYPE_SL_LIMIT = constants.ORDER_TYPE_SL_LIMIT
|
|
130
|
+
ORDER_TYPE_SL_MARKET = constants.ORDER_TYPE_SL_MARKET
|
|
131
|
+
# -- product --
|
|
132
|
+
PRODUCT_DELIVERY = constants.PRODUCT_DELIVERY
|
|
133
|
+
PRODUCT_INTRADAY = constants.PRODUCT_INTRADAY
|
|
134
|
+
PRODUCT_NORMAL = constants.PRODUCT_NORMAL
|
|
135
|
+
# -- validity --
|
|
136
|
+
VALIDITY_DAY = constants.VALIDITY_DAY
|
|
137
|
+
VALIDITY_IOC = constants.VALIDITY_IOC
|
|
138
|
+
# -- variant --
|
|
139
|
+
VARIANT_REGULAR = constants.VARIANT_REGULAR
|
|
140
|
+
VARIANT_AMO = constants.VARIANT_AMO
|
|
141
|
+
# -- order status --
|
|
142
|
+
ORDER_STATUS_OPEN = constants.ORDER_STATUS_OPEN
|
|
143
|
+
ORDER_STATUS_OPEN_PARTIAL = constants.ORDER_STATUS_OPEN_PARTIAL
|
|
144
|
+
ORDER_STATUS_PARTIAL_FILLED = constants.ORDER_STATUS_PARTIAL_FILLED
|
|
145
|
+
ORDER_STATUS_COMPLETED = constants.ORDER_STATUS_COMPLETED
|
|
146
|
+
ORDER_STATUS_CANCELLED = constants.ORDER_STATUS_CANCELLED
|
|
147
|
+
ORDER_STATUS_REJECTED = constants.ORDER_STATUS_REJECTED
|
|
148
|
+
# -- candle sizes --
|
|
149
|
+
CANDLE_1M = constants.CANDLE_1M
|
|
150
|
+
CANDLE_3M = constants.CANDLE_3M
|
|
151
|
+
CANDLE_5M = constants.CANDLE_5M
|
|
152
|
+
CANDLE_15M = constants.CANDLE_15M
|
|
153
|
+
CANDLE_30M = constants.CANDLE_30M
|
|
154
|
+
CANDLE_1H = constants.CANDLE_1H
|
|
155
|
+
CANDLE_1D = constants.CANDLE_1D
|
|
156
|
+
# -- tick-stream modes --
|
|
157
|
+
MODE_LTP = constants.MODE_LTP
|
|
158
|
+
MODE_QUOTE = constants.MODE_QUOTE
|
|
159
|
+
MODE_DEPTH = constants.MODE_DEPTH
|
|
160
|
+
# -- scopes --
|
|
161
|
+
SCOPE_ORDERS_WRITE = constants.SCOPE_ORDERS_WRITE
|
|
162
|
+
SCOPE_ORDERS_READ = constants.SCOPE_ORDERS_READ
|
|
163
|
+
SCOPE_POSITIONS_READ = constants.SCOPE_POSITIONS_READ
|
|
164
|
+
SCOPE_HOLDINGS_READ = constants.SCOPE_HOLDINGS_READ
|
|
165
|
+
SCOPE_FUNDS_READ = constants.SCOPE_FUNDS_READ
|
|
166
|
+
SCOPE_PROFILE_READ = constants.SCOPE_PROFILE_READ
|
|
167
|
+
SCOPE_MARKETDATA_READ = constants.SCOPE_MARKETDATA_READ
|
|
168
|
+
# -- tick-stream instrument types --
|
|
169
|
+
INSTRUMENT_TYPE_ETF = constants.INSTRUMENT_TYPE_ETF
|
|
170
|
+
INSTRUMENT_TYPE_INDEX = constants.INSTRUMENT_TYPE_INDEX
|
|
171
|
+
INSTRUMENT_TYPE_INDEX_FUT = constants.INSTRUMENT_TYPE_INDEX_FUT
|
|
172
|
+
INSTRUMENT_TYPE_INDEX_OPT_CE = constants.INSTRUMENT_TYPE_INDEX_OPT_CE
|
|
173
|
+
INSTRUMENT_TYPE_INDEX_OPT_PE = constants.INSTRUMENT_TYPE_INDEX_OPT_PE
|
|
174
|
+
INSTRUMENT_TYPE_STOCK = constants.INSTRUMENT_TYPE_STOCK
|
|
175
|
+
INSTRUMENT_TYPE_STOCK_FUT = constants.INSTRUMENT_TYPE_STOCK_FUT
|
|
176
|
+
INSTRUMENT_TYPE_STOCK_OPT_CE = constants.INSTRUMENT_TYPE_STOCK_OPT_CE
|
|
177
|
+
INSTRUMENT_TYPE_STOCK_OPT_PE = constants.INSTRUMENT_TYPE_STOCK_OPT_PE
|
|
178
|
+
# -- WebSocket close codes --
|
|
179
|
+
WS_CLOSE_AUTH_EXPIRED = constants.WS_CLOSE_AUTH_EXPIRED
|
|
180
|
+
WS_CLOSE_DROPPED = constants.WS_CLOSE_DROPPED
|
|
181
|
+
WS_CLOSE_FORBIDDEN = constants.WS_CLOSE_FORBIDDEN
|
|
182
|
+
WS_CLOSE_HEARTBEAT = constants.WS_CLOSE_HEARTBEAT
|
|
183
|
+
WS_CLOSE_TOO_MANY = constants.WS_CLOSE_TOO_MANY
|
|
184
|
+
|
|
185
|
+
def __init__(
|
|
186
|
+
self,
|
|
187
|
+
token: Token,
|
|
188
|
+
*,
|
|
189
|
+
scrip_master_url: str | None = None,
|
|
190
|
+
timeout: float = 10.0,
|
|
191
|
+
) -> None:
|
|
192
|
+
access_token = _access_token_of(token, "SahiAPI()")
|
|
193
|
+
state = _token_state(access_token)
|
|
194
|
+
self._http = HttpClient(
|
|
195
|
+
env.API_BASE_URL, host=HOST_API, access_token=access_token, timeout=timeout
|
|
196
|
+
)
|
|
197
|
+
self._scrip_master_url = scrip_master_url or os.environ.get(_ENV_SCRIP_MASTER_URL)
|
|
198
|
+
self._token = state
|
|
199
|
+
|
|
200
|
+
# -- authentication -------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
@classmethod
|
|
203
|
+
def from_credentials(
|
|
204
|
+
cls,
|
|
205
|
+
key_id: str,
|
|
206
|
+
secret: str,
|
|
207
|
+
*,
|
|
208
|
+
scopes: list[str] | None = None,
|
|
209
|
+
scrip_master_url: str | None = None,
|
|
210
|
+
timeout: float = 10.0,
|
|
211
|
+
) -> SahiAPI:
|
|
212
|
+
"""Mint a token and return a ready client in one call.
|
|
213
|
+
|
|
214
|
+
This is the usual entry point: the token never has to be handled by the
|
|
215
|
+
caller. It performs one request to the auth host and raises the same
|
|
216
|
+
exceptions as :func:`sahiapi.get_access_token` (not approved today, wrong
|
|
217
|
+
secret, …). Use :func:`sahiapi.get_access_token` plus the constructor
|
|
218
|
+
instead when one token must be shared or cached.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
key_id: The key id from your Sahi account.
|
|
222
|
+
secret: The secret shown once at key creation.
|
|
223
|
+
scopes: Optional subset of the key's scopes to narrow the token to.
|
|
224
|
+
scrip_master_url: Scrip-master CSV URL (default ``$SAHI_SCRIP_MASTER_URL``).
|
|
225
|
+
timeout: Default per-request timeout in seconds, also used for the mint.
|
|
226
|
+
"""
|
|
227
|
+
token = auth.get_access_token(key_id, secret, scopes=scopes, timeout=timeout)
|
|
228
|
+
return cls(token, scrip_master_url=scrip_master_url, timeout=timeout)
|
|
229
|
+
|
|
230
|
+
def set_access_token(self, token: Token) -> None:
|
|
231
|
+
"""Swap in a freshly minted token for daily rotation.
|
|
232
|
+
|
|
233
|
+
Accepts the same forms as the constructor. Reuses the client and its
|
|
234
|
+
connection pool; any open :class:`SahiFeed` is untouched and keeps the
|
|
235
|
+
token it was started with.
|
|
236
|
+
|
|
237
|
+
The header is swapped and the expiry and scopes published together as
|
|
238
|
+
one value, so no request ever sees a half-updated token.
|
|
239
|
+
"""
|
|
240
|
+
access_token = _access_token_of(token, "set_access_token()")
|
|
241
|
+
state = _token_state(access_token)
|
|
242
|
+
self._http.set_access_token(access_token)
|
|
243
|
+
self._token = state
|
|
244
|
+
|
|
245
|
+
@property
|
|
246
|
+
def token_expires_at(self) -> datetime | None:
|
|
247
|
+
"""When the current token expires (UTC), or ``None`` if it carries no readable ``exp``."""
|
|
248
|
+
return self._token.expires_at
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def scopes(self) -> frozenset[str]:
|
|
252
|
+
"""The scopes the current token carries; empty if its claims cannot be read."""
|
|
253
|
+
return self._token.scopes
|
|
254
|
+
|
|
255
|
+
@property
|
|
256
|
+
def access_token(self) -> str:
|
|
257
|
+
"""The bearer token string in use, as :class:`~sahiapi.SahiFeed` borrows it.
|
|
258
|
+
|
|
259
|
+
Read-only: rotate it with :meth:`set_access_token`. A feed copies the
|
|
260
|
+
value when it is built and keeps it until stopped.
|
|
261
|
+
"""
|
|
262
|
+
return self._token.access_token
|
|
263
|
+
|
|
264
|
+
# -- lifecycle ------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
def close(self) -> None:
|
|
267
|
+
"""Close the underlying HTTP connection pool."""
|
|
268
|
+
self._http.close()
|
|
269
|
+
|
|
270
|
+
def __enter__(self) -> SahiAPI:
|
|
271
|
+
return self
|
|
272
|
+
|
|
273
|
+
def __exit__(
|
|
274
|
+
self,
|
|
275
|
+
exc_type: type[BaseException] | None,
|
|
276
|
+
exc: BaseException | None,
|
|
277
|
+
tb: TracebackType | None,
|
|
278
|
+
) -> None:
|
|
279
|
+
self.close()
|
|
280
|
+
|
|
281
|
+
# -- request plumbing (used by the domain mixins) -------------------------
|
|
282
|
+
|
|
283
|
+
def _request(
|
|
284
|
+
self,
|
|
285
|
+
method: str,
|
|
286
|
+
path: str,
|
|
287
|
+
*,
|
|
288
|
+
params: dict[str, Any] | None = None,
|
|
289
|
+
json: Any = None,
|
|
290
|
+
timeout: float | None = None,
|
|
291
|
+
retry_on_rate_limit: bool = False,
|
|
292
|
+
) -> Any:
|
|
293
|
+
"""Send a request with the current token.
|
|
294
|
+
|
|
295
|
+
Expiry is not checked locally: this host's clock can drift, and the
|
|
296
|
+
server is the authority. An expired token comes back as a ``401`` and
|
|
297
|
+
raises :class:`~sahiapi.SahiAuthenticationException` like any other.
|
|
298
|
+
"""
|
|
299
|
+
return self._http.request(
|
|
300
|
+
method,
|
|
301
|
+
path,
|
|
302
|
+
params=params,
|
|
303
|
+
json=json,
|
|
304
|
+
timeout=timeout,
|
|
305
|
+
retry_on_rate_limit=retry_on_rate_limit,
|
|
306
|
+
)
|