sahiapi 0.1.0__tar.gz → 0.3.0__tar.gz

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.
Files changed (32) hide show
  1. sahiapi-0.3.0/CHANGELOG.md +82 -0
  2. sahiapi-0.3.0/LICENSE +21 -0
  3. sahiapi-0.3.0/PKG-INFO +111 -0
  4. sahiapi-0.3.0/README.md +79 -0
  5. {sahiapi-0.1.0 → sahiapi-0.3.0}/pyproject.toml +6 -6
  6. {sahiapi-0.1.0 → sahiapi-0.3.0}/src/sahiapi/__init__.py +4 -0
  7. {sahiapi-0.1.0 → sahiapi-0.3.0}/src/sahiapi/sahi/auth.py +30 -13
  8. {sahiapi-0.1.0 → sahiapi-0.3.0}/src/sahiapi/sahi/client.py +13 -7
  9. {sahiapi-0.1.0 → sahiapi-0.3.0}/src/sahiapi/sahi/env.py +7 -1
  10. {sahiapi-0.1.0 → sahiapi-0.3.0}/src/sahiapi/sahi/exceptions.py +88 -1
  11. {sahiapi-0.1.0 → sahiapi-0.3.0}/src/sahiapi/sahi/feed.py +7 -7
  12. {sahiapi-0.1.0 → sahiapi-0.3.0}/src/sahiapi/sahi/funds.py +5 -6
  13. {sahiapi-0.1.0 → sahiapi-0.3.0}/src/sahiapi/sahi/http.py +10 -0
  14. {sahiapi-0.1.0 → sahiapi-0.3.0}/src/sahiapi/sahi/instruments.py +4 -13
  15. {sahiapi-0.1.0 → sahiapi-0.3.0}/src/sahiapi/sahi/marketdata.py +15 -16
  16. {sahiapi-0.1.0 → sahiapi-0.3.0}/src/sahiapi/sahi/proto/codec.py +44 -10
  17. {sahiapi-0.1.0 → sahiapi-0.3.0}/src/sahiapi/sahi/ws_client.py +7 -5
  18. sahiapi-0.1.0/CHANGELOG.md +0 -30
  19. sahiapi-0.1.0/LICENSE +0 -7
  20. sahiapi-0.1.0/PKG-INFO +0 -97
  21. sahiapi-0.1.0/README.md +0 -57
  22. {sahiapi-0.1.0 → sahiapi-0.3.0}/.gitignore +0 -0
  23. {sahiapi-0.1.0 → sahiapi-0.3.0}/src/sahiapi/py.typed +0 -0
  24. {sahiapi-0.1.0 → sahiapi-0.3.0}/src/sahiapi/sahi/__init__.py +0 -0
  25. {sahiapi-0.1.0 → sahiapi-0.3.0}/src/sahiapi/sahi/account.py +0 -0
  26. {sahiapi-0.1.0 → sahiapi-0.3.0}/src/sahiapi/sahi/base.py +0 -0
  27. {sahiapi-0.1.0 → sahiapi-0.3.0}/src/sahiapi/sahi/constants.py +0 -0
  28. {sahiapi-0.1.0 → sahiapi-0.3.0}/src/sahiapi/sahi/orders.py +0 -0
  29. {sahiapi-0.1.0 → sahiapi-0.3.0}/src/sahiapi/sahi/portfolio.py +0 -0
  30. {sahiapi-0.1.0 → sahiapi-0.3.0}/src/sahiapi/sahi/proto/__init__.py +0 -0
  31. {sahiapi-0.1.0 → sahiapi-0.3.0}/src/sahiapi/sahi/proto/marketdata.proto +0 -0
  32. {sahiapi-0.1.0 → sahiapi-0.3.0}/src/sahiapi/sahi/proto/marketdata_pb2.py +0 -0
@@ -0,0 +1,82 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project aims to
5
+ follow [Semantic Versioning](https://semver.org/) once it reaches `1.0.0`.
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.3.0] - 2026-10-28
10
+
11
+ ### Added
12
+ - TOTP keys: `SahiAPI.from_credentials` and `get_access_token` take `totp`, the
13
+ key's current six-digit authenticator code, which doubles as the day's
14
+ approval. A wrong code raises `SahiInvalidTotpException`, carrying the
15
+ server's `server_time` for clock comparison.
16
+
17
+ ### Changed
18
+ - The `SahiKeyNotApprovedException` hint also covers an account that does not
19
+ have API keys enabled yet. A TOTP key gets this exception only for that reason.
20
+
21
+ ## [0.2.0] - 2026-10-22
22
+
23
+ First public release. Includes everything listed under 0.1.0.
24
+
25
+ ### Added
26
+ - `SahiMaintenanceException` (a `SahiServerException`) for `503 UNDER_MAINTENANCE`,
27
+ the margin endpoints' daily 04:45–07:00 IST window, with a hint that says so
28
+ instead of "retry after a short wait".
29
+
30
+ ### Changed
31
+ - `SahiFeed` cache reads are `last_ltp`, `last_quote` and `last_depth` (were
32
+ `get_ltp`, `get_quote`, `get_depth`), so they no longer share a name with the
33
+ REST calls `SahiAPI.get_ltp` / `get_quotes` that have a different signature.
34
+ - `get_ltp` / `get_quotes` no longer cap the id list locally. The SDK said 500
35
+ while the API allows 100, which is the trouble with a limit baked into a
36
+ release: the server's `400 VALIDATION_FAILED` names the limit, and the
37
+ docstrings state it.
38
+ - The scrip master downloads from the published URL by default
39
+ (`https://assets.sahi.com/external/instruments.csv`); `scrip_master_url=` and
40
+ `SAHI_SCRIP_MASTER_URL` now override it rather than being required.
41
+ - Tick dicts carry every field, in the shape the REST endpoints return: an
42
+ `optional` field the server did not send is `None` (an index's
43
+ `last_trade_time`, a stock's `open_interest`), an empty order book is `[]`, an
44
+ unset counter is `0`. Previously these keys were missing.
45
+ - Licensed under MIT (previously proprietary).
46
+ - The PyPI page is a short overview that points to https://docs.sahi.com/ for
47
+ guides and the API reference; development notes moved to `CONTRIBUTING.md`.
48
+
49
+ ### Fixed
50
+ - `SahiFeed` sends a `User-Agent` on the WebSocket upgrade. Production refused
51
+ the handshake without one (HTTP 403), so neither stream connected.
52
+ - A 4xx with no API error envelope (the edge refusing an over-long URL, e.g.
53
+ hundreds of instrument ids in one `get_ltp`) is a plain `SahiAPIException`
54
+ that says the request never reached the API, not a `SahiAuthorisationException`
55
+ with a missing-scope hint.
56
+
57
+ ## [0.1.0] - 2026-10-18
58
+
59
+ Pre-launch placeholder; yanked on PyPI. Use 0.2.0 or later.
60
+
61
+ ### Added
62
+ - Initial SDK: authentication (`SahiAPI.from_credentials` for the one-call path,
63
+ `get_access_token` + `SahiAPI(token)` for a shared token, rotation via
64
+ `set_access_token`), and full REST coverage — orders, portfolio, funds,
65
+ account, market data (with `get_candle_history`) and the scrip master.
66
+ - `SahiFeed` for the two WebSocket streams: the protobuf tick stream (LTP, quote,
67
+ depth) and JSON order updates, with reconnect, subscription replay and a local
68
+ tick cache whose reads raise the stream's error once it has closed fatally.
69
+ - A typed exception hierarchy mapped from `(status, host, code)`. Every API
70
+ exception (`SahiAPIException`) carries the server `status`, `code`,
71
+ `message`, a `request_id` and an actionable `hint`, all included in `str()`;
72
+ a feed exception carries the WebSocket `close_code` instead.
73
+ - Every wire enum as a constant and `SahiAPI` class attribute.
74
+ - `on_connect` on the order stream for the API's Stream + Snapshot bootstrap;
75
+ transparent reconnect on heartbeat (4408) and slow-consumer (4409) drops.
76
+ - Packaging, CI (ruff, mypy strict, pytest on Python 3.9–3.13) and a
77
+ trusted-publishing release workflow.
78
+
79
+ [Unreleased]: https://github.com/aarityatech/sahi-python/compare/sahiapi/v0.3.0...HEAD
80
+ [0.3.0]: https://github.com/aarityatech/sahi-python/compare/sahiapi/v0.2.0...sahiapi/v0.3.0
81
+ [0.2.0]: https://github.com/aarityatech/sahi-python/compare/sahiapi/v0.1.0...sahiapi/v0.2.0
82
+ [0.1.0]: https://github.com/aarityatech/sahi-python/releases/tag/sahiapi/v0.1.0
sahiapi-0.3.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aaritya Technologies Private Limited
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
sahiapi-0.3.0/PKG-INFO ADDED
@@ -0,0 +1,111 @@
1
+ Metadata-Version: 2.5
2
+ Name: sahiapi
3
+ Version: 0.3.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/sdk/python/
7
+ Author-email: Aaritya Technologies <customercare@sahi.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: bse,market-data,nse,options,sahi,stocks,trading
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Intended Audience :: Financial and Insurance Industry
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.9
22
+ Requires-Dist: httpx>=0.27
23
+ Requires-Dist: protobuf>=4.25
24
+ Requires-Dist: websocket-client>=1.7
25
+ Provides-Extra: dev
26
+ Requires-Dist: mypy>=1.11; extra == 'dev'
27
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
28
+ Requires-Dist: pytest>=8.0; extra == 'dev'
29
+ Requires-Dist: respx>=0.21; extra == 'dev'
30
+ Requires-Dist: ruff>=0.6; extra == 'dev'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # sahiapi
34
+
35
+ The official Python SDK for the [Sahi trading APIs](https://docs.sahi.com/).
36
+
37
+ Build trading and investing workflows in Python: place and manage orders, track
38
+ holdings, positions and funds, pull quotes, candles and option chains, and
39
+ receive live ticks and order updates over WebSocket.
40
+
41
+ [Sahi](https://www.sahi.com/) © 2026. Released under the [MIT License](https://opensource.org/license/mit).
42
+
43
+ ## Documentation
44
+
45
+ - [Python SDK documentation](https://docs.sahi.com/sdk/python/)
46
+ - [Sahi API reference](https://docs.sahi.com/)
47
+ - [Authentication and API keys](https://docs.sahi.com/api/v1/reference/authentication/)
48
+ - [Orders API reference](https://docs.sahi.com/api/v1/reference/orders/)
49
+
50
+ ## Features
51
+
52
+ - **Orders**: place, modify and cancel orders, and read the order book and the
53
+ trades behind each order.
54
+ - **Portfolio**: holdings and open positions.
55
+ - **Funds**: available balance and margin in use.
56
+ - **Market data**: LTP and quotes for many instruments in one call, historical
57
+ candles, option expiries and option chains.
58
+ - **Live streams**: `SahiFeed` streams LTP, quotes and market depth, plus
59
+ order updates, and reconnects and resubscribes on its own.
60
+ - **Typed errors**: every API failure raises an exception carrying the HTTP
61
+ status, error code, request id and a hint on how to fix it.
62
+
63
+ ## Installation
64
+
65
+ ```bash
66
+ pip install sahiapi
67
+ ```
68
+
69
+ Requires Python 3.9 or newer.
70
+
71
+ ## Quickstart
72
+
73
+ Create an API key in your Sahi account and approve it for the trading day, then
74
+ connect:
75
+
76
+ ```python
77
+ import os
78
+ from sahiapi import SahiAPI
79
+
80
+ api = SahiAPI.from_credentials(
81
+ key_id=os.environ["SAHI_KEY_ID"],
82
+ secret=os.environ["SAHI_SECRET"],
83
+ )
84
+ print(api.get_profile())
85
+ print(api.get_funds())
86
+ ```
87
+
88
+ For a TOTP key, also pass the current authenticator code as `totp`. See
89
+ [Mint with a TOTP key](https://docs.sahi.com/sdk/python/authentication/#mint-with-a-totp-key).
90
+
91
+ ### Example: place a limit order
92
+
93
+ Prices are in paise, so ₹2,499.55 is `249955`.
94
+
95
+ ```python
96
+ response = api.place_order(
97
+ exchange_segment=SahiAPI.EXCHANGE_SEGMENT_NSE,
98
+ symbol="RELIANCE",
99
+ txn_type=SahiAPI.TXN_TYPE_BUY,
100
+ qty=1,
101
+ order_type=SahiAPI.ORDER_TYPE_LIMIT,
102
+ product=SahiAPI.PRODUCT_DELIVERY,
103
+ validity=SahiAPI.VALIDITY_DAY,
104
+ price=249955,
105
+ )
106
+ order_id = response["order_ids"][0]
107
+ print(api.get_order_detail(order_id))
108
+ ```
109
+
110
+ See the [Python SDK documentation](https://docs.sahi.com/sdk/python/) for streaming, market data and
111
+ every other endpoint.
@@ -0,0 +1,79 @@
1
+ # sahiapi
2
+
3
+ The official Python SDK for the [Sahi trading APIs](https://docs.sahi.com/).
4
+
5
+ Build trading and investing workflows in Python: place and manage orders, track
6
+ holdings, positions and funds, pull quotes, candles and option chains, and
7
+ receive live ticks and order updates over WebSocket.
8
+
9
+ [Sahi](https://www.sahi.com/) © 2026. Released under the [MIT License](https://opensource.org/license/mit).
10
+
11
+ ## Documentation
12
+
13
+ - [Python SDK documentation](https://docs.sahi.com/sdk/python/)
14
+ - [Sahi API reference](https://docs.sahi.com/)
15
+ - [Authentication and API keys](https://docs.sahi.com/api/v1/reference/authentication/)
16
+ - [Orders API reference](https://docs.sahi.com/api/v1/reference/orders/)
17
+
18
+ ## Features
19
+
20
+ - **Orders**: place, modify and cancel orders, and read the order book and the
21
+ trades behind each order.
22
+ - **Portfolio**: holdings and open positions.
23
+ - **Funds**: available balance and margin in use.
24
+ - **Market data**: LTP and quotes for many instruments in one call, historical
25
+ candles, option expiries and option chains.
26
+ - **Live streams**: `SahiFeed` streams LTP, quotes and market depth, plus
27
+ order updates, and reconnects and resubscribes on its own.
28
+ - **Typed errors**: every API failure raises an exception carrying the HTTP
29
+ status, error code, request id and a hint on how to fix it.
30
+
31
+ ## Installation
32
+
33
+ ```bash
34
+ pip install sahiapi
35
+ ```
36
+
37
+ Requires Python 3.9 or newer.
38
+
39
+ ## Quickstart
40
+
41
+ Create an API key in your Sahi account and approve it for the trading day, then
42
+ connect:
43
+
44
+ ```python
45
+ import os
46
+ from sahiapi import SahiAPI
47
+
48
+ api = SahiAPI.from_credentials(
49
+ key_id=os.environ["SAHI_KEY_ID"],
50
+ secret=os.environ["SAHI_SECRET"],
51
+ )
52
+ print(api.get_profile())
53
+ print(api.get_funds())
54
+ ```
55
+
56
+ For a TOTP key, also pass the current authenticator code as `totp`. See
57
+ [Mint with a TOTP key](https://docs.sahi.com/sdk/python/authentication/#mint-with-a-totp-key).
58
+
59
+ ### Example: place a limit order
60
+
61
+ Prices are in paise, so ₹2,499.55 is `249955`.
62
+
63
+ ```python
64
+ response = api.place_order(
65
+ exchange_segment=SahiAPI.EXCHANGE_SEGMENT_NSE,
66
+ symbol="RELIANCE",
67
+ txn_type=SahiAPI.TXN_TYPE_BUY,
68
+ qty=1,
69
+ order_type=SahiAPI.ORDER_TYPE_LIMIT,
70
+ product=SahiAPI.PRODUCT_DELIVERY,
71
+ validity=SahiAPI.VALIDITY_DAY,
72
+ price=249955,
73
+ )
74
+ order_id = response["order_ids"][0]
75
+ print(api.get_order_detail(order_id))
76
+ ```
77
+
78
+ See the [Python SDK documentation](https://docs.sahi.com/sdk/python/) for streaming, market data and
79
+ every other endpoint.
@@ -1,5 +1,6 @@
1
1
  [build-system]
2
- requires = ["hatchling", "hatch-vcs"]
2
+ # 1.27 is the first hatchling to write PEP 639 license metadata.
3
+ requires = ["hatchling>=1.27", "hatch-vcs"]
3
4
  build-backend = "hatchling.build"
4
5
 
5
6
  [project]
@@ -10,11 +11,11 @@ dynamic = ["version"]
10
11
  description = "The official Python client library for the Sahi APIs."
11
12
  readme = "README.md"
12
13
  requires-python = ">=3.9"
13
- license = { file = "LICENSE" }
14
- authors = [{ name = "Aaritya Technologies" }]
14
+ license = "MIT"
15
+ license-files = ["LICENSE"]
16
+ authors = [{ name = "Aaritya Technologies", email = "customercare@sahi.com" }]
15
17
  keywords = ["sahi", "trading", "stocks", "options", "market-data", "nse", "bse"]
16
18
  classifiers = [
17
- "Development Status :: 3 - Alpha",
18
19
  "Intended Audience :: Developers",
19
20
  "Intended Audience :: Financial and Insurance Industry",
20
21
  "Operating System :: OS Independent",
@@ -53,8 +54,7 @@ sahiapi-uat = { path = "tools/sahiapi_uat" }
53
54
 
54
55
  [project.urls]
55
56
  Homepage = "https://docs.sahi.com/"
56
- Documentation = "https://docs.sahi.com/"
57
- Repository = "https://github.com/aarityatech/sahi-python"
57
+ Documentation = "https://docs.sahi.com/sdk/python/"
58
58
 
59
59
  [tool.hatch.version]
60
60
  source = "vcs"
@@ -35,8 +35,10 @@ from .sahi.exceptions import (
35
35
  SahiFeedForbiddenException,
36
36
  SahiFeedNotSubscribedException,
37
37
  SahiInvalidOrderStateException,
38
+ SahiInvalidTotpException,
38
39
  SahiIpNotAllowedException,
39
40
  SahiKeyNotApprovedException,
41
+ SahiMaintenanceException,
40
42
  SahiNotFoundException,
41
43
  SahiOrderNotFoundException,
42
44
  SahiRateLimitException,
@@ -71,8 +73,10 @@ __all__ = [
71
73
  "SahiFeedForbiddenException",
72
74
  "SahiFeedNotSubscribedException",
73
75
  "SahiInvalidOrderStateException",
76
+ "SahiInvalidTotpException",
74
77
  "SahiIpNotAllowedException",
75
78
  "SahiKeyNotApprovedException",
79
+ "SahiMaintenanceException",
76
80
  "SahiNotFoundException",
77
81
  "SahiOrderNotFoundException",
78
82
  "SahiRateLimitException",
@@ -1,10 +1,13 @@
1
1
  """Authentication: minting an access token and reading what it carries.
2
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.
3
+ A Sahi access token is minted from an API key with :func:`get_access_token`.
4
+ A REGULAR key is approved for the trading day in your Sahi account and then
5
+ exchanges its key id and secret for a token. A TOTP key skips the daily click:
6
+ its six-digit authenticator code goes with the key id and secret, and the first
7
+ correct code of the day is that day's approval, so a strategy can mint
8
+ unattended. The token is a Bearer JWT that every REST and WebSocket call
9
+ carries, and it expires at 06:00 IST — there is no refresh, so mint a fresh one
10
+ each trading day.
8
11
 
9
12
  The helpers here also read a token's own claims without verifying its signature,
10
13
  to tell callers when a token expires and what it is allowed to do. They are for
@@ -40,6 +43,7 @@ def get_access_token(
40
43
  key_id: str,
41
44
  secret: str,
42
45
  *,
46
+ totp: str | None = None,
43
47
  scopes: list[str] | None = None,
44
48
  timeout: float = 10.0,
45
49
  ) -> dict[str, Any]:
@@ -51,6 +55,11 @@ def get_access_token(
51
55
  Args:
52
56
  key_id: The key id from your Sahi account.
53
57
  secret: The secret shown once when the key was created.
58
+ totp: The current six-digit code for a TOTP key, from the authenticator
59
+ enrolled when the key was created. Required for a TOTP key and
60
+ refused for a REGULAR one. The SDK never holds the TOTP secret:
61
+ generate the code yourself (for example with ``pyotp``) and pass it
62
+ in, so the TOTP secret stays wherever you keep it.
54
63
  scopes: Optional subset of the key's scopes to narrow the token to. A
55
64
  token can only ever carry scopes the key holds. Omit it, or pass an
56
65
  empty list, to get the key's full set.
@@ -60,20 +69,28 @@ def get_access_token(
60
69
  The token response dict, unchanged: ``access_token``, ``token_type``,
61
70
  ``expires_in``, ``expires_at`` and ``scope``.
62
71
 
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.
72
+ The key id, secret and code are sent as given: their format is the auth
73
+ server's to define, so the SDK does not check it.
65
74
 
66
75
  Raises:
67
76
  SahiAuthenticationException: Unknown key id, wrong secret, or a revoked
68
- key (401 — deliberately indistinguishable).
77
+ key (401). The message names a revoked key; the other two read the same.
78
+ SahiInvalidTotpException: The secret was right but the TOTP code was
79
+ not; carries the server's clock for comparison.
69
80
  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.
81
+ SahiKeyNotApprovedException: A REGULAR key not approved for the current
82
+ trading day, or any key whose account does not have API keys
83
+ enabled yet.
84
+ SahiBadRequestException: A field is malformed, ``scopes`` is not a
85
+ subset of the key's scopes, ``totp`` is missing or not six digits
86
+ for a TOTP key, or ``totp`` was sent for a REGULAR key.
87
+ SahiRateLimitException: Too many mint attempts, or ten wrong TOTP codes
88
+ within a minute; wait for the next minute.
75
89
  """
76
90
  body: dict[str, Any] = {"key_id": key_id, "secret": secret}
91
+ # Absent when not given: the server reads a missing, null or empty code the same way.
92
+ if totp is not None:
93
+ body["totp"] = totp
77
94
  if scopes is not None:
78
95
  body["scopes"] = scopes
79
96
 
@@ -95,10 +95,10 @@ class SahiAPI(_Orders, _Portfolio, _Funds, _Account, _MarketData, _Instruments):
95
95
  token: The response of :func:`sahiapi.get_access_token` (preferred), or
96
96
  just its ``access_token`` string. Use :meth:`from_credentials` to go
97
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.
98
+ scrip_master_url: The scrip-master CSV download URL the instrument
99
+ lookups read. Defaults to ``$SAHI_SCRIP_MASTER_URL`` if set, else the
100
+ published file (``env.SCRIP_MASTER_URL``); only another copy of the
101
+ file needs this.
102
102
  timeout: Default per-request timeout in seconds.
103
103
 
104
104
  The enum values the API uses are available as class attributes, e.g.
@@ -194,7 +194,9 @@ class SahiAPI(_Orders, _Portfolio, _Funds, _Account, _MarketData, _Instruments):
194
194
  self._http = HttpClient(
195
195
  env.API_BASE_URL, host=HOST_API, access_token=access_token, timeout=timeout
196
196
  )
197
- self._scrip_master_url = scrip_master_url or os.environ.get(_ENV_SCRIP_MASTER_URL)
197
+ self._scrip_master_url = (
198
+ scrip_master_url or os.environ.get(_ENV_SCRIP_MASTER_URL) or env.SCRIP_MASTER_URL
199
+ )
198
200
  self._token = state
199
201
 
200
202
  # -- authentication -------------------------------------------------------
@@ -205,6 +207,7 @@ class SahiAPI(_Orders, _Portfolio, _Funds, _Account, _MarketData, _Instruments):
205
207
  key_id: str,
206
208
  secret: str,
207
209
  *,
210
+ totp: str | None = None,
208
211
  scopes: list[str] | None = None,
209
212
  scrip_master_url: str | None = None,
210
213
  timeout: float = 10.0,
@@ -220,11 +223,14 @@ class SahiAPI(_Orders, _Portfolio, _Funds, _Account, _MarketData, _Instruments):
220
223
  Args:
221
224
  key_id: The key id from your Sahi account.
222
225
  secret: The secret shown once at key creation.
226
+ totp: The current six-digit code for a TOTP key; its first correct
227
+ code of the day approves the key, so no dashboard click is
228
+ needed. Omit it for a REGULAR key.
223
229
  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``).
230
+ scrip_master_url: Scrip-master CSV URL, as on the constructor.
225
231
  timeout: Default per-request timeout in seconds, also used for the mint.
226
232
  """
227
- token = auth.get_access_token(key_id, secret, scopes=scopes, timeout=timeout)
233
+ token = auth.get_access_token(key_id, secret, totp=totp, scopes=scopes, timeout=timeout)
228
234
  return cls(token, scrip_master_url=scrip_master_url, timeout=timeout)
229
235
 
230
236
  def set_access_token(self, token: Token) -> None:
@@ -1,7 +1,10 @@
1
1
  """The hosts the SDK talks to.
2
2
 
3
3
  The SDK always talks to the production Sahi hosts. There is no argument or
4
- environment variable for pointing it anywhere else.
4
+ environment variable for pointing it anywhere else. The scrip master is the one
5
+ exception: it is a static file whose URL the API reference publishes, and
6
+ ``SahiAPI(scrip_master_url=...)`` or ``SAHI_SCRIP_MASTER_URL`` can point at
7
+ another copy.
5
8
 
6
9
  The two hosts are separate: the **API** host serves everything under ``/v1``,
7
10
  and the **auth** host serves ``/api/v1/apikeys/token``. WebSocket URLs are
@@ -17,6 +20,7 @@ from __future__ import annotations
17
20
  __all__ = [
18
21
  "API_BASE_URL",
19
22
  "AUTH_BASE_URL",
23
+ "SCRIP_MASTER_URL",
20
24
  "websocket_base_url",
21
25
  ]
22
26
 
@@ -24,6 +28,8 @@ __all__ = [
24
28
  API_BASE_URL = "https://direct.sahi.com"
25
29
  #: The auth host (serves the token endpoint). No trailing slash.
26
30
  AUTH_BASE_URL = "https://apis.sahi.com"
31
+ #: The scrip-master CSV, as published on the API reference's Scrip master page.
32
+ SCRIP_MASTER_URL = "https://assets.sahi.com/external/instruments.csv"
27
33
 
28
34
 
29
35
  def websocket_base_url(api_base_url: str) -> str:
@@ -38,8 +38,10 @@ __all__ = [
38
38
  "SahiFeedForbiddenException",
39
39
  "SahiFeedNotSubscribedException",
40
40
  "SahiInvalidOrderStateException",
41
+ "SahiInvalidTotpException",
41
42
  "SahiIpNotAllowedException",
42
43
  "SahiKeyNotApprovedException",
44
+ "SahiMaintenanceException",
43
45
  "SahiNotFoundException",
44
46
  "SahiOrderNotFoundException",
45
47
  "SahiRateLimitException",
@@ -155,6 +157,46 @@ class SahiAccountLockedException(SahiAuthenticationException):
155
157
  hint = "The account is locked or frozen. Contact Sahi support to restore access."
156
158
 
157
159
 
160
+ class SahiInvalidTotpException(SahiAuthenticationException):
161
+ """401 ``InvalidTotp``: the key id and secret were right but the TOTP code was not.
162
+
163
+ Raised only for a TOTP key, and only after the secret verified, so the
164
+ credentials themselves are fine. The code was wrong or came from a clock
165
+ more than one step (30 s) away from the server's. Ten wrong codes in a
166
+ minute turn into :class:`SahiRateLimitException`.
167
+
168
+ Attributes:
169
+ server_time: The server's clock at the refusal, as the RFC 3339 string
170
+ it sent, or ``None`` if the envelope carried none. Compare it with
171
+ this host's clock: drift is the usual cause of a code that looks
172
+ right in the authenticator app yet fails here.
173
+ """
174
+
175
+ hint = (
176
+ "Mint again with a fresh code from the key's authenticator. If it fails again, "
177
+ "compare this machine's clock with the server_time on the exception."
178
+ )
179
+
180
+ def __init__(
181
+ self,
182
+ message: str | None = None,
183
+ *,
184
+ status: int | None = None,
185
+ code: str | None = None,
186
+ request_id: str | None = None,
187
+ server_time: str | None = None,
188
+ ) -> None:
189
+ # Set before super().__init__: _render() runs there and reads it.
190
+ self.server_time = server_time
191
+ super().__init__(message, status=status, code=code, request_id=request_id)
192
+
193
+ def _render(self) -> str:
194
+ text = super()._render()
195
+ if self.server_time:
196
+ text = f"{text} [server_time={self.server_time}]"
197
+ return text
198
+
199
+
158
200
  class SahiAuthorisationException(SahiAPIException):
159
201
  """403: the token is valid but not permitted to do this.
160
202
 
@@ -173,9 +215,19 @@ class SahiKeyNotApprovedException(SahiAuthorisationException):
173
215
 
174
216
  Approval is a daily act and lapses at 06:00 IST; it is an exchange
175
217
  requirement, not something the SDK can bypass.
218
+
219
+ The auth host also answers 403 when API keys are not enabled for the
220
+ account. That check runs after the secret is verified but before the
221
+ TOTP code is read. So a TOTP key, whose code is its approval, lands here
222
+ only for that reason, and the server message says so; nothing done to the
223
+ key can fix it.
176
224
  """
177
225
 
178
- hint = "Approve the key for today's trading day in your Sahi account, then mint again."
226
+ hint = (
227
+ "Approve the key for today's trading day in your Sahi account, then mint again. "
228
+ "If the message says API keys are not available for this account, the account "
229
+ "is not enabled yet; contact Sahi."
230
+ )
179
231
 
180
232
 
181
233
  class SahiIpNotAllowedException(SahiAuthorisationException):
@@ -256,6 +308,17 @@ class SahiServerException(SahiAPIException):
256
308
  )
257
309
 
258
310
 
311
+ class SahiMaintenanceException(SahiServerException):
312
+ """503 ``UNDER_MAINTENANCE``: the daily 04:45-07:00 IST maintenance window.
313
+
314
+ The margin endpoints refuse requests while the OMS runs its beginning-of-day
315
+ processing. Nothing is wrong with the request, and a short-wait retry is
316
+ pointless: wait for the window to end.
317
+ """
318
+
319
+ hint = "The OMS is in its daily 04:45-07:00 IST maintenance window. Retry after 07:00 IST."
320
+
321
+
259
322
  class SahiTimeoutException(SahiAPIException):
260
323
  """No response arrived within the timeout.
261
324
 
@@ -348,6 +411,8 @@ def exception_for(
348
411
  message: str | None = None,
349
412
  request_id: str | None = None,
350
413
  retry_after: int | None = None,
414
+ envelope: bool = True,
415
+ server_time: str | None = None,
351
416
  ) -> SahiAPIException:
352
417
  """Build the right :class:`SahiAPIException` for a non-2xx response.
353
418
 
@@ -367,10 +432,26 @@ def exception_for(
367
432
  message: The envelope ``message``.
368
433
  request_id: The request id to attach for support.
369
434
  retry_after: Parsed ``Retry-After`` seconds, for 429s.
435
+ envelope: Whether the body was the API's JSON error envelope. A 4xx
436
+ without one never reached the API: the edge (CloudFront's WAF)
437
+ refused it, in practice for an over-long URL. Mapping it by status
438
+ would hand the user a scope or key hint for a problem that is the
439
+ request's size, so it comes back as the base class with a message
440
+ that says where it was refused.
441
+ server_time: The envelope's ``server_time``, which only a wrong TOTP
442
+ code carries; ignored for every other class.
370
443
 
371
444
  Returns:
372
445
  An unraised exception instance for the caller to raise.
373
446
  """
447
+ if not envelope and 400 <= status < 500 and status != 429:
448
+ return SahiAPIException(
449
+ "the request was refused before it reached the API (no API error envelope), "
450
+ "usually because the URL is too long; send less per call",
451
+ status=status,
452
+ code=None,
453
+ request_id=request_id,
454
+ )
374
455
  key = _normalise(code)
375
456
 
376
457
  def build(cls: type[SahiAPIException]) -> SahiAPIException:
@@ -387,6 +468,12 @@ def exception_for(
387
468
  return build(SahiIpNotAllowedException)
388
469
  if key == "LOCKED":
389
470
  return build(SahiAccountLockedException)
471
+ if key == "UNDERMAINTENANCE":
472
+ return build(SahiMaintenanceException)
473
+ if key == "INVALIDTOTP":
474
+ return SahiInvalidTotpException(
475
+ message, status=status, code=code, request_id=request_id, server_time=server_time
476
+ )
390
477
 
391
478
  if status == 400:
392
479
  return build(SahiBadRequestException)
@@ -8,8 +8,8 @@ Sahi has two streams, and :class:`SahiFeed` owns one connection for each:
8
8
  your account's order events with no subscription.
9
9
 
10
10
  Both share a token and the connection rules in :mod:`~sahiapi.sahi.ws_client`.
11
- Ticks update a local cache as they arrive, so :meth:`get_ltp`, :meth:`get_quote`
12
- and :meth:`get_depth` return the latest value even before you start consuming.
11
+ Ticks update a local cache as they arrive, so :meth:`last_ltp`, :meth:`last_quote`
12
+ and :meth:`last_depth` return the latest value even before you start consuming.
13
13
  Once the tick stream has closed fatally (an expired token above all) those
14
14
  reads raise that error instead of serving a frozen price, so a caller that only
15
15
  polls the cache learns of it too.
@@ -194,7 +194,7 @@ class SahiFeed:
194
194
  the socket thread.
195
195
 
196
196
  The call does not wait for the server. The ids count as subscribed at
197
- once (:meth:`get_ltp` returns ``None`` until a tick lands), and enter the
197
+ once (:meth:`last_ltp` returns ``None`` until a tick lands), and enter the
198
198
  replayed table when the server's ``ack`` arrives. The server refuses a
199
199
  request whole if any id is unknown or the per-connection cap would be
200
200
  passed; that batch is then dropped, none of its ids are subscribed, and
@@ -240,7 +240,7 @@ class SahiFeed:
240
240
  def unsubscribe(self, instrument_ids: list[int]) -> None:
241
241
  """Stop ticks for these instruments and drop their cached values.
242
242
 
243
- Afterwards :meth:`get_ltp` / :meth:`get_quote` / :meth:`get_depth` raise
243
+ Afterwards :meth:`last_ltp` / :meth:`last_quote` / :meth:`last_depth` raise
244
244
  :class:`SahiFeedNotSubscribedException` for them until re-subscribed.
245
245
  """
246
246
  dropped = set(instrument_ids)
@@ -279,7 +279,7 @@ class SahiFeed:
279
279
 
280
280
  # -- cache reads ----------------------------------------------------------
281
281
 
282
- def get_ltp(self, instrument_id: int) -> dict[str, Any] | None:
282
+ def last_ltp(self, instrument_id: int) -> dict[str, Any] | None:
283
283
  """Return the latest LTP tick for an instrument, or ``None`` if none has arrived.
284
284
 
285
285
  Raises:
@@ -290,7 +290,7 @@ class SahiFeed:
290
290
  """
291
291
  return self._cached("ltp", instrument_id)
292
292
 
293
- def get_quote(self, instrument_id: int) -> dict[str, Any] | None:
293
+ def last_quote(self, instrument_id: int) -> dict[str, Any] | None:
294
294
  """Return the latest quote tick for an instrument, or ``None`` if none yet.
295
295
 
296
296
  Raises:
@@ -301,7 +301,7 @@ class SahiFeed:
301
301
  """
302
302
  return self._cached("quote", instrument_id)
303
303
 
304
- def get_depth(self, instrument_id: int) -> dict[str, Any] | None:
304
+ def last_depth(self, instrument_id: int) -> dict[str, Any] | None:
305
305
  """Return the latest depth tick for an instrument, or ``None`` if none yet.
306
306
 
307
307
  Raises:
@@ -63,10 +63,9 @@ class _Funds(EndpointGroup):
63
63
  ``{"required_margin": ..., "available_margin": ...}``, in paise.
64
64
 
65
65
  Raises:
66
- SahiServerException: ``503 UNDER_MAINTENANCE`` between 04:45 and
67
- 07:00 IST, while the OMS runs its beginning-of-day processing.
68
- Retry after 07:00 IST; the exception's generic hint of a short
69
- wait does not apply here.
66
+ SahiMaintenanceException: ``503 UNDER_MAINTENANCE`` between 04:45
67
+ and 07:00 IST, while the OMS runs its beginning-of-day
68
+ processing. Retry after 07:00 IST.
70
69
  """
71
70
  body = build_order_body(
72
71
  exchange_segment,
@@ -116,8 +115,8 @@ class _Funds(EndpointGroup):
116
115
  TypeError: If a leg is missing a required field or carries one the
117
116
  API does not define. The message names the leg by zero-based
118
117
  index, as the server's own leg errors do.
119
- SahiServerException: ``503 UNDER_MAINTENANCE`` between 04:45 and
120
- 07:00 IST, as on :meth:`get_order_margin`. Retry after 07:00 IST.
118
+ SahiMaintenanceException: ``503 UNDER_MAINTENANCE`` between 04:45
119
+ and 07:00 IST, as on :meth:`get_order_margin`.
121
120
  """
122
121
  # The server ignores fields it does not know, so a misspelled key would
123
122
  # otherwise be quoted at a value the caller never asked for; building each
@@ -274,6 +274,8 @@ class HttpClient:
274
274
  def _error_from(self, response: httpx.Response, request_id: str) -> exceptions.SahiAPIException:
275
275
  code: str | None = None
276
276
  message: str | None = None
277
+ envelope = True
278
+ server_time: str | None = None
277
279
  try:
278
280
  body = response.json()
279
281
  if isinstance(body, dict):
@@ -281,7 +283,13 @@ class HttpClient:
281
283
  # A gateway may send a numeric code; the mapper needs a string.
282
284
  code = raw_code if isinstance(raw_code, str) else None
283
285
  message = body.get("message")
286
+ # Only a wrong TOTP code carries it; kept verbatim so a caller
287
+ # can compare clocks without the SDK parsing a timestamp.
288
+ raw_time = body.get("server_time")
289
+ server_time = raw_time if isinstance(raw_time, str) else None
284
290
  except ValueError:
291
+ # HTML or plain text: the edge answered, not the API.
292
+ envelope = False
285
293
  message = response.text or None
286
294
 
287
295
  server_request_id = response.headers.get(_REQUEST_ID_HEADER) or request_id
@@ -293,6 +301,8 @@ class HttpClient:
293
301
  message=message,
294
302
  request_id=server_request_id,
295
303
  retry_after=retry_after,
304
+ envelope=envelope,
305
+ server_time=server_time,
296
306
  )
297
307
 
298
308
  @staticmethod
@@ -21,10 +21,9 @@ One parsed copy is shared by every client in the process, keyed by URL: the file
21
21
  is around 10 MB and costs well over a hundred megabytes once parsed, and it is
22
22
  the same file for every account.
23
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`.
24
+ The file is downloaded from the URL the API reference's Scrip master page
25
+ publishes (``env.SCRIP_MASTER_URL``). Another copy can be used by passing
26
+ ``scrip_master_url`` to :class:`SahiAPI` or setting ``SAHI_SCRIP_MASTER_URL``.
28
27
  """
29
28
 
30
29
  from __future__ import annotations
@@ -42,7 +41,6 @@ import httpx
42
41
  from .base import EndpointGroup
43
42
  from .exceptions import (
44
43
  SahiAPIException,
45
- SahiConfigurationException,
46
44
  SahiConnectionException,
47
45
  SahiTimeoutException,
48
46
  )
@@ -100,7 +98,7 @@ class _Instruments(EndpointGroup):
100
98
  """
101
99
 
102
100
  # Provided/managed by SahiAPI; declared here so the mixin is self-describing.
103
- _scrip_master_url: str | None
101
+ _scrip_master_url: str
104
102
 
105
103
  def get_instruments(
106
104
  self, *, refresh: bool = False, timeout: float | None = None
@@ -130,7 +128,6 @@ class _Instruments(EndpointGroup):
130
128
  mutating a row changes what later lookups return.
131
129
 
132
130
  Raises:
133
- SahiConfigurationException: If no scrip-master URL is configured.
134
131
  SahiConnectionException: If the download could not be started.
135
132
  SahiTimeoutException: If the download times out.
136
133
  SahiAPIException: If the download fails (``status`` set for a non-2xx).
@@ -178,12 +175,6 @@ class _Instruments(EndpointGroup):
178
175
 
179
176
  def _master(self, *, refresh: bool, timeout: float | None) -> _Master:
180
177
  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
178
  with _CACHE_LOCK:
188
179
  cached = _CACHE.get(url)
189
180
  if not refresh and cached is not None and _is_current(cached):
@@ -22,7 +22,6 @@ from .base import EndpointGroup
22
22
 
23
23
  __all__ = ["_MarketData"]
24
24
 
25
- _MAX_INSTRUMENTS_PER_REQUEST = 500
26
25
  _CANDLE_SIZES = (
27
26
  constants.CANDLE_1M,
28
27
  constants.CANDLE_3M,
@@ -43,9 +42,15 @@ class _MarketData(EndpointGroup):
43
42
  """Last traded price for one or many instruments (``GET /v1/marketdata/ltp``).
44
43
 
45
44
  Args:
46
- instrument_ids: One ``instrument_id``, or up to 500 of them. The
45
+ instrument_ids: One ``instrument_id``, or a list of them. The
47
46
  endpoint is single or bulk, so a lone id needs no list around it;
48
- the response is keyed by id either way.
47
+ the response is keyed by id either way. The API accepts up to
48
+ 100 ids per call and answers ``400 VALIDATION_FAILED`` above
49
+ that; past roughly 200 the URL outgrows the edge's limit and
50
+ the request is refused before reaching the API (a plain
51
+ :class:`SahiAPIException`, HTTP 403). Batch a longer list. The
52
+ SDK does not cap it: a limit baked into a release would outlive
53
+ a server change.
49
54
 
50
55
  Returns:
51
56
  ``{"data": {"<id>": {"last_trade_price": ..., "last_trade_time": ...}}}``.
@@ -53,7 +58,7 @@ class _MarketData(EndpointGroup):
53
58
  ``200``.
54
59
 
55
60
  Raises:
56
- ValueError: If no ids are given, or more than 500 are.
61
+ ValueError: If no ids are given.
57
62
  """
58
63
  ids = self._as_instrument_ids(instrument_ids)
59
64
  return self._request( # type: ignore[no-any-return]
@@ -70,15 +75,15 @@ class _MarketData(EndpointGroup):
70
75
  """Full quote with market depth (``GET /v1/marketdata/quotes``).
71
76
 
72
77
  Args:
73
- instrument_ids: One ``instrument_id``, or up to 500 of them, as on
74
- :meth:`get_ltp`.
78
+ instrument_ids: One ``instrument_id``, or a list of them, as on
79
+ :meth:`get_ltp` (the API's limit of 100 per call applies here too).
75
80
 
76
81
  Returns:
77
82
  ``{"data": {"<id>": {...}}}`` — the REST equivalent of the tick
78
83
  stream's depth tick: price, volume, DPR band, open interest and depth.
79
84
 
80
85
  Raises:
81
- ValueError: If no ids are given, or more than 500 are.
86
+ ValueError: If no ids are given.
82
87
  """
83
88
  ids = self._as_instrument_ids(instrument_ids)
84
89
  return self._request( # type: ignore[no-any-return]
@@ -243,18 +248,12 @@ class _MarketData(EndpointGroup):
243
248
 
244
249
  @staticmethod
245
250
  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.
251
+ # Lone id is wrapped, not refused: the endpoint is single-or-bulk.
252
+ # No local id cap on purpose: a limit baked into a released SDK outlives
253
+ # a server change, and the server's 400 names the limit anyway.
249
254
  ids = [instrument_ids] if isinstance(instrument_ids, int) else list(instrument_ids)
250
255
  if not ids:
251
256
  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
257
  return ids
259
258
 
260
259
  @staticmethod
@@ -11,9 +11,11 @@ The decoded dicts keep the proto field names verbatim (``instrument_id``,
11
11
  ``last_trade_price``), keep integers as integers (unlike protobuf's JSON mapping,
12
12
  which renders 64-bit ints as strings), and give enum values as their string names
13
13
  with the enum prefix stripped (``NSE``, ``LTP``), matching the constants
14
- elsewhere in the SDK. Only fields the frame actually carries appear, so an absent
15
- optional such as an index's ``last_trade_time`` is simply missing rather than a
16
- zero.
14
+ elsewhere in the SDK. Every field of the message is present, in the shape the
15
+ REST endpoints return: an ``optional`` the server left unset (an index's
16
+ ``last_trade_time``, a stock's ``open_interest``) is ``None``, an empty repeated
17
+ field is ``[]`` and an unset counter is ``0``, so a reader never has to guard
18
+ for a missing key.
17
19
  """
18
20
 
19
21
  from __future__ import annotations
@@ -111,16 +113,48 @@ def _convert_value(field: FieldDescriptor, value: Any) -> Any:
111
113
  return value
112
114
 
113
115
 
116
+ # Per message type: (name, kind, field) for every field, built once. Decoding
117
+ # runs per tick, and asking the descriptor the same questions each time cost
118
+ # ~40% over the ListFields() loop it replaced.
119
+ _FIELD_PLANS: dict[str, list[tuple[str, str, FieldDescriptor]]] = {}
120
+
121
+
122
+ def _field_plan(message: Message) -> list[tuple[str, str, FieldDescriptor]]:
123
+ descriptor = message.DESCRIPTOR
124
+ plan = _FIELD_PLANS.get(descriptor.full_name)
125
+ if plan is None:
126
+ plan = []
127
+ for field in descriptor.fields:
128
+ if _is_repeated(field):
129
+ kind = "repeated"
130
+ elif field.has_presence: # proto3 ``optional`` and sub-messages
131
+ kind = "optional"
132
+ else:
133
+ kind = "scalar"
134
+ plan.append((field.name, kind, field))
135
+ _FIELD_PLANS[descriptor.full_name] = plan
136
+ return plan
137
+
138
+
114
139
  def _message_to_dict(message: Message) -> dict[str, Any]:
115
- """Convert a protobuf message to a plain dict, set fields only.
140
+ """Convert a protobuf message to a plain dict with every field present.
141
+
142
+ The wire omits what is zero or empty, so the dict fills it back in to the
143
+ shape the REST endpoints return: an ``optional`` field the server did not
144
+ set is ``None`` (an index has no ``last_trade_time``, a stock no
145
+ ``open_interest``), a repeated field with no items is ``[]`` (no order book
146
+ yet), and an unset counter is ``0``. Integers stay integers, enums become
147
+ their stripped string names, nested messages recurse.
116
148
 
117
- Integers stay integers, enums become their stripped string names, nested
118
- messages recurse, and repeated fields become lists.
149
+ Users key on this shape; dropping a key or changing a default here breaks
150
+ code that reads ``tick["open_interest"]`` on a stock without a guard.
119
151
  """
120
152
  result: dict[str, Any] = {}
121
- for field, value in message.ListFields():
122
- if _is_repeated(field):
123
- result[field.name] = [_convert_value(field, item) for item in value]
153
+ for name, kind, field in _field_plan(message):
154
+ if kind == "repeated":
155
+ result[name] = [_convert_value(field, item) for item in getattr(message, name)]
156
+ elif kind == "optional" and not message.HasField(name):
157
+ result[name] = None
124
158
  else:
125
- result[field.name] = _convert_value(field, value)
159
+ result[name] = _convert_value(field, getattr(message, name))
126
160
  return result
@@ -1,10 +1,11 @@
1
1
  """One WebSocket connection, with reconnect and close-code handling.
2
2
 
3
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.
4
+ upgrade as a Bearer header next to the SDK's ``User-Agent`` (production's
5
+ CloudFront refuses an upgrade without one, with a 403), the server pings every
6
+ 30 seconds and expects a pong within 15, and some close codes are fatal while
7
+ others just mean reconnect. This class implements those rules once;
8
+ :class:`~sahiapi.sahi.feed.SahiFeed` owns one instance per stream.
8
9
 
9
10
  The connection runs a receive loop on a daemon thread and hands each frame to an
10
11
  ``on_message`` callback. On a transient drop it reconnects with exponential
@@ -37,6 +38,7 @@ import threading
37
38
  from typing import Any, Callable, Protocol
38
39
 
39
40
  from . import constants, exceptions
41
+ from .http import _user_agent
40
42
 
41
43
  __all__ = ["Transport", "WebSocketConnection"]
42
44
 
@@ -170,7 +172,7 @@ class WebSocketConnection:
170
172
  connect_fn: ConnectFn | None = None,
171
173
  ) -> None:
172
174
  self._url = url
173
- self._header = [f"Authorization: Bearer {access_token}"]
175
+ self._header = [f"Authorization: Bearer {access_token}", f"User-Agent: {_user_agent()}"]
174
176
  self._binary = binary
175
177
  self._on_message = on_message
176
178
  self._on_open = on_open
@@ -1,30 +0,0 @@
1
- # Changelog
2
-
3
- All notable changes to this project are documented here. The format follows
4
- [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project aims to
5
- follow [Semantic Versioning](https://semver.org/) once it reaches `1.0.0`.
6
-
7
- ## [Unreleased]
8
-
9
- ## [0.1.0] - 2026-10-18
10
-
11
- ### Added
12
- - Initial SDK: authentication (`SahiAPI.from_credentials` for the one-call path,
13
- `get_access_token` + `SahiAPI(token)` for a shared token, rotation via
14
- `set_access_token`), and full REST coverage — orders, portfolio, funds,
15
- account, market data (with `get_candle_history`) and the scrip master.
16
- - `SahiFeed` for the two WebSocket streams: the protobuf tick stream (LTP, quote,
17
- depth) and JSON order updates, with reconnect, subscription replay and a local
18
- tick cache whose reads raise the stream's error once it has closed fatally.
19
- - A typed exception hierarchy mapped from `(status, host, code)`. Every API
20
- exception (`SahiAPIException`) carries the server `status`, `code`,
21
- `message`, a `request_id` and an actionable `hint`, all included in `str()`;
22
- a feed exception carries the WebSocket `close_code` instead.
23
- - Every wire enum as a constant and `SahiAPI` class attribute.
24
- - `on_connect` on the order stream for the API's Stream + Snapshot bootstrap;
25
- transparent reconnect on heartbeat (4408) and slow-consumer (4409) drops.
26
- - Packaging, CI (ruff, mypy strict, pytest on Python 3.9–3.13) and a
27
- trusted-publishing release workflow.
28
-
29
- [Unreleased]: https://github.com/aarityatech/sahi-python/compare/sahiapi/v0.1.0...HEAD
30
- [0.1.0]: https://github.com/aarityatech/sahi-python/releases/tag/sahiapi/v0.1.0
sahiapi-0.1.0/LICENSE DELETED
@@ -1,7 +0,0 @@
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.
sahiapi-0.1.0/PKG-INFO DELETED
@@ -1,97 +0,0 @@
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).
sahiapi-0.1.0/README.md DELETED
@@ -1,57 +0,0 @@
1
- # sahiapi
2
-
3
- The official Python client library for the [Sahi APIs](https://docs.sahi.com/):
4
- orders, portfolio, funds, market data and live streams.
5
-
6
- > Early development. The public interface is not yet stable.
7
-
8
- ## Install
9
-
10
- ```bash
11
- pip install sahiapi
12
- ```
13
-
14
- Requires Python 3.9 or newer.
15
-
16
- ## Quickstart
17
-
18
- Create an API key in your Sahi account and approve it for the trading day, then:
19
-
20
- ```python
21
- import os
22
- from sahiapi import SahiAPI
23
-
24
- api = SahiAPI.from_credentials(
25
- key_id=os.environ["SAHI_KEY_ID"],
26
- secret=os.environ["SAHI_SECRET"],
27
- )
28
- print(api.get_profile())
29
- print(api.get_funds())
30
- ```
31
-
32
- Tokens expire at 06:00 IST, so mint a fresh one each trading day.
33
-
34
- ## Documentation
35
-
36
- Guides, the full API reference and examples are at **https://docs.sahi.com/**.
37
-
38
- The SDK returns the API's JSON exactly as documented there: money is integer
39
- paise, timestamps are epoch milliseconds, and field names are unchanged.
40
-
41
- ## Development
42
-
43
- ```bash
44
- uv sync --extra dev
45
- uv run pytest
46
- uv run ruff check .
47
- uv run mypy
48
- ```
49
-
50
- The tick-stream protobuf bindings are generated and committed; regenerate them
51
- with `tools/regen_proto.sh` after editing the `.proto`. Releases are cut with
52
- `tools/release.sh`; see `.github/workflows/release.yml`.
53
-
54
- ## License
55
-
56
- Proprietary during development; an open-source license will be applied before
57
- public release. See [LICENSE](LICENSE).
File without changes
File without changes