sahiapi 0.1.0__tar.gz → 0.2.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.
- sahiapi-0.2.0/CHANGELOG.md +69 -0
- sahiapi-0.2.0/LICENSE +21 -0
- sahiapi-0.2.0/PKG-INFO +108 -0
- sahiapi-0.2.0/README.md +76 -0
- {sahiapi-0.1.0 → sahiapi-0.2.0}/pyproject.toml +6 -6
- {sahiapi-0.1.0 → sahiapi-0.2.0}/src/sahiapi/__init__.py +2 -0
- {sahiapi-0.1.0 → sahiapi-0.2.0}/src/sahiapi/sahi/client.py +8 -6
- {sahiapi-0.1.0 → sahiapi-0.2.0}/src/sahiapi/sahi/env.py +7 -1
- {sahiapi-0.1.0 → sahiapi-0.2.0}/src/sahiapi/sahi/exceptions.py +29 -0
- {sahiapi-0.1.0 → sahiapi-0.2.0}/src/sahiapi/sahi/feed.py +7 -7
- {sahiapi-0.1.0 → sahiapi-0.2.0}/src/sahiapi/sahi/funds.py +5 -6
- {sahiapi-0.1.0 → sahiapi-0.2.0}/src/sahiapi/sahi/http.py +4 -0
- {sahiapi-0.1.0 → sahiapi-0.2.0}/src/sahiapi/sahi/instruments.py +4 -13
- {sahiapi-0.1.0 → sahiapi-0.2.0}/src/sahiapi/sahi/marketdata.py +15 -16
- {sahiapi-0.1.0 → sahiapi-0.2.0}/src/sahiapi/sahi/proto/codec.py +44 -10
- {sahiapi-0.1.0 → sahiapi-0.2.0}/src/sahiapi/sahi/ws_client.py +7 -5
- sahiapi-0.1.0/CHANGELOG.md +0 -30
- sahiapi-0.1.0/LICENSE +0 -7
- sahiapi-0.1.0/PKG-INFO +0 -97
- sahiapi-0.1.0/README.md +0 -57
- {sahiapi-0.1.0 → sahiapi-0.2.0}/.gitignore +0 -0
- {sahiapi-0.1.0 → sahiapi-0.2.0}/src/sahiapi/py.typed +0 -0
- {sahiapi-0.1.0 → sahiapi-0.2.0}/src/sahiapi/sahi/__init__.py +0 -0
- {sahiapi-0.1.0 → sahiapi-0.2.0}/src/sahiapi/sahi/account.py +0 -0
- {sahiapi-0.1.0 → sahiapi-0.2.0}/src/sahiapi/sahi/auth.py +0 -0
- {sahiapi-0.1.0 → sahiapi-0.2.0}/src/sahiapi/sahi/base.py +0 -0
- {sahiapi-0.1.0 → sahiapi-0.2.0}/src/sahiapi/sahi/constants.py +0 -0
- {sahiapi-0.1.0 → sahiapi-0.2.0}/src/sahiapi/sahi/orders.py +0 -0
- {sahiapi-0.1.0 → sahiapi-0.2.0}/src/sahiapi/sahi/portfolio.py +0 -0
- {sahiapi-0.1.0 → sahiapi-0.2.0}/src/sahiapi/sahi/proto/__init__.py +0 -0
- {sahiapi-0.1.0 → sahiapi-0.2.0}/src/sahiapi/sahi/proto/marketdata.proto +0 -0
- {sahiapi-0.1.0 → sahiapi-0.2.0}/src/sahiapi/sahi/proto/marketdata_pb2.py +0 -0
|
@@ -0,0 +1,69 @@
|
|
|
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.2.0] - 2026-10-22
|
|
10
|
+
|
|
11
|
+
First public release. Includes everything listed under 0.1.0.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- `SahiMaintenanceException` (a `SahiServerException`) for `503 UNDER_MAINTENANCE`,
|
|
15
|
+
the margin endpoints' daily 04:45–07:00 IST window, with a hint that says so
|
|
16
|
+
instead of "retry after a short wait".
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
- `SahiFeed` cache reads are `last_ltp`, `last_quote` and `last_depth` (were
|
|
20
|
+
`get_ltp`, `get_quote`, `get_depth`), so they no longer share a name with the
|
|
21
|
+
REST calls `SahiAPI.get_ltp` / `get_quotes` that have a different signature.
|
|
22
|
+
- `get_ltp` / `get_quotes` no longer cap the id list locally. The SDK said 500
|
|
23
|
+
while the API allows 100, which is the trouble with a limit baked into a
|
|
24
|
+
release: the server's `400 VALIDATION_FAILED` names the limit, and the
|
|
25
|
+
docstrings state it.
|
|
26
|
+
- The scrip master downloads from the published URL by default
|
|
27
|
+
(`https://assets.sahi.com/external/instruments.csv`); `scrip_master_url=` and
|
|
28
|
+
`SAHI_SCRIP_MASTER_URL` now override it rather than being required.
|
|
29
|
+
- Tick dicts carry every field, in the shape the REST endpoints return: an
|
|
30
|
+
`optional` field the server did not send is `None` (an index's
|
|
31
|
+
`last_trade_time`, a stock's `open_interest`), an empty order book is `[]`, an
|
|
32
|
+
unset counter is `0`. Previously these keys were missing.
|
|
33
|
+
- Licensed under MIT (previously proprietary).
|
|
34
|
+
- The PyPI page is a short overview that points to https://docs.sahi.com/ for
|
|
35
|
+
guides and the API reference; development notes moved to `CONTRIBUTING.md`.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
- `SahiFeed` sends a `User-Agent` on the WebSocket upgrade. Production refused
|
|
39
|
+
the handshake without one (HTTP 403), so neither stream connected.
|
|
40
|
+
- A 4xx with no API error envelope (the edge refusing an over-long URL, e.g.
|
|
41
|
+
hundreds of instrument ids in one `get_ltp`) is a plain `SahiAPIException`
|
|
42
|
+
that says the request never reached the API, not a `SahiAuthorisationException`
|
|
43
|
+
with a missing-scope hint.
|
|
44
|
+
|
|
45
|
+
## [0.1.0] - 2026-10-18
|
|
46
|
+
|
|
47
|
+
Pre-launch placeholder; yanked on PyPI. Use 0.2.0 or later.
|
|
48
|
+
|
|
49
|
+
### Added
|
|
50
|
+
- Initial SDK: authentication (`SahiAPI.from_credentials` for the one-call path,
|
|
51
|
+
`get_access_token` + `SahiAPI(token)` for a shared token, rotation via
|
|
52
|
+
`set_access_token`), and full REST coverage — orders, portfolio, funds,
|
|
53
|
+
account, market data (with `get_candle_history`) and the scrip master.
|
|
54
|
+
- `SahiFeed` for the two WebSocket streams: the protobuf tick stream (LTP, quote,
|
|
55
|
+
depth) and JSON order updates, with reconnect, subscription replay and a local
|
|
56
|
+
tick cache whose reads raise the stream's error once it has closed fatally.
|
|
57
|
+
- A typed exception hierarchy mapped from `(status, host, code)`. Every API
|
|
58
|
+
exception (`SahiAPIException`) carries the server `status`, `code`,
|
|
59
|
+
`message`, a `request_id` and an actionable `hint`, all included in `str()`;
|
|
60
|
+
a feed exception carries the WebSocket `close_code` instead.
|
|
61
|
+
- Every wire enum as a constant and `SahiAPI` class attribute.
|
|
62
|
+
- `on_connect` on the order stream for the API's Stream + Snapshot bootstrap;
|
|
63
|
+
transparent reconnect on heartbeat (4408) and slow-consumer (4409) drops.
|
|
64
|
+
- Packaging, CI (ruff, mypy strict, pytest on Python 3.9–3.13) and a
|
|
65
|
+
trusted-publishing release workflow.
|
|
66
|
+
|
|
67
|
+
[Unreleased]: https://github.com/aarityatech/sahi-python/compare/sahiapi/v0.2.0...HEAD
|
|
68
|
+
[0.2.0]: https://github.com/aarityatech/sahi-python/compare/sahiapi/v0.1.0...sahiapi/v0.2.0
|
|
69
|
+
[0.1.0]: https://github.com/aarityatech/sahi-python/releases/tag/sahiapi/v0.1.0
|
sahiapi-0.2.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.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: sahiapi
|
|
3
|
+
Version: 0.2.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
|
+
### Example: place a limit order
|
|
89
|
+
|
|
90
|
+
Prices are in paise, so ₹2,499.55 is `249955`.
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
response = api.place_order(
|
|
94
|
+
exchange_segment=SahiAPI.EXCHANGE_SEGMENT_NSE,
|
|
95
|
+
symbol="RELIANCE",
|
|
96
|
+
txn_type=SahiAPI.TXN_TYPE_BUY,
|
|
97
|
+
qty=1,
|
|
98
|
+
order_type=SahiAPI.ORDER_TYPE_LIMIT,
|
|
99
|
+
product=SahiAPI.PRODUCT_DELIVERY,
|
|
100
|
+
validity=SahiAPI.VALIDITY_DAY,
|
|
101
|
+
price=249955,
|
|
102
|
+
)
|
|
103
|
+
order_id = response["order_ids"][0]
|
|
104
|
+
print(api.get_order_detail(order_id))
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
See the [Python SDK documentation](https://docs.sahi.com/sdk/python/) for streaming, market data and
|
|
108
|
+
every other endpoint.
|
sahiapi-0.2.0/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
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
|
+
### Example: place a limit order
|
|
57
|
+
|
|
58
|
+
Prices are in paise, so ₹2,499.55 is `249955`.
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
response = api.place_order(
|
|
62
|
+
exchange_segment=SahiAPI.EXCHANGE_SEGMENT_NSE,
|
|
63
|
+
symbol="RELIANCE",
|
|
64
|
+
txn_type=SahiAPI.TXN_TYPE_BUY,
|
|
65
|
+
qty=1,
|
|
66
|
+
order_type=SahiAPI.ORDER_TYPE_LIMIT,
|
|
67
|
+
product=SahiAPI.PRODUCT_DELIVERY,
|
|
68
|
+
validity=SahiAPI.VALIDITY_DAY,
|
|
69
|
+
price=249955,
|
|
70
|
+
)
|
|
71
|
+
order_id = response["order_ids"][0]
|
|
72
|
+
print(api.get_order_detail(order_id))
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
See the [Python SDK documentation](https://docs.sahi.com/sdk/python/) for streaming, market data and
|
|
76
|
+
every other endpoint.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
[build-system]
|
|
2
|
-
|
|
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 =
|
|
14
|
-
|
|
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"
|
|
@@ -37,6 +37,7 @@ from .sahi.exceptions import (
|
|
|
37
37
|
SahiInvalidOrderStateException,
|
|
38
38
|
SahiIpNotAllowedException,
|
|
39
39
|
SahiKeyNotApprovedException,
|
|
40
|
+
SahiMaintenanceException,
|
|
40
41
|
SahiNotFoundException,
|
|
41
42
|
SahiOrderNotFoundException,
|
|
42
43
|
SahiRateLimitException,
|
|
@@ -73,6 +74,7 @@ __all__ = [
|
|
|
73
74
|
"SahiInvalidOrderStateException",
|
|
74
75
|
"SahiIpNotAllowedException",
|
|
75
76
|
"SahiKeyNotApprovedException",
|
|
77
|
+
"SahiMaintenanceException",
|
|
76
78
|
"SahiNotFoundException",
|
|
77
79
|
"SahiOrderNotFoundException",
|
|
78
80
|
"SahiRateLimitException",
|
|
@@ -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
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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 =
|
|
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 -------------------------------------------------------
|
|
@@ -221,7 +223,7 @@ class SahiAPI(_Orders, _Portfolio, _Funds, _Account, _MarketData, _Instruments):
|
|
|
221
223
|
key_id: The key id from your Sahi account.
|
|
222
224
|
secret: The secret shown once at key creation.
|
|
223
225
|
scopes: Optional subset of the key's scopes to narrow the token to.
|
|
224
|
-
scrip_master_url: Scrip-master CSV URL
|
|
226
|
+
scrip_master_url: Scrip-master CSV URL, as on the constructor.
|
|
225
227
|
timeout: Default per-request timeout in seconds, also used for the mint.
|
|
226
228
|
"""
|
|
227
229
|
token = auth.get_access_token(key_id, secret, scopes=scopes, timeout=timeout)
|
|
@@ -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:
|
|
@@ -40,6 +40,7 @@ __all__ = [
|
|
|
40
40
|
"SahiInvalidOrderStateException",
|
|
41
41
|
"SahiIpNotAllowedException",
|
|
42
42
|
"SahiKeyNotApprovedException",
|
|
43
|
+
"SahiMaintenanceException",
|
|
43
44
|
"SahiNotFoundException",
|
|
44
45
|
"SahiOrderNotFoundException",
|
|
45
46
|
"SahiRateLimitException",
|
|
@@ -256,6 +257,17 @@ class SahiServerException(SahiAPIException):
|
|
|
256
257
|
)
|
|
257
258
|
|
|
258
259
|
|
|
260
|
+
class SahiMaintenanceException(SahiServerException):
|
|
261
|
+
"""503 ``UNDER_MAINTENANCE``: the daily 04:45-07:00 IST maintenance window.
|
|
262
|
+
|
|
263
|
+
The margin endpoints refuse requests while the OMS runs its beginning-of-day
|
|
264
|
+
processing. Nothing is wrong with the request, and a short-wait retry is
|
|
265
|
+
pointless: wait for the window to end.
|
|
266
|
+
"""
|
|
267
|
+
|
|
268
|
+
hint = "The OMS is in its daily 04:45-07:00 IST maintenance window. Retry after 07:00 IST."
|
|
269
|
+
|
|
270
|
+
|
|
259
271
|
class SahiTimeoutException(SahiAPIException):
|
|
260
272
|
"""No response arrived within the timeout.
|
|
261
273
|
|
|
@@ -348,6 +360,7 @@ def exception_for(
|
|
|
348
360
|
message: str | None = None,
|
|
349
361
|
request_id: str | None = None,
|
|
350
362
|
retry_after: int | None = None,
|
|
363
|
+
envelope: bool = True,
|
|
351
364
|
) -> SahiAPIException:
|
|
352
365
|
"""Build the right :class:`SahiAPIException` for a non-2xx response.
|
|
353
366
|
|
|
@@ -367,10 +380,24 @@ def exception_for(
|
|
|
367
380
|
message: The envelope ``message``.
|
|
368
381
|
request_id: The request id to attach for support.
|
|
369
382
|
retry_after: Parsed ``Retry-After`` seconds, for 429s.
|
|
383
|
+
envelope: Whether the body was the API's JSON error envelope. A 4xx
|
|
384
|
+
without one never reached the API: the edge (CloudFront's WAF)
|
|
385
|
+
refused it, in practice for an over-long URL. Mapping it by status
|
|
386
|
+
would hand the user a scope or key hint for a problem that is the
|
|
387
|
+
request's size, so it comes back as the base class with a message
|
|
388
|
+
that says where it was refused.
|
|
370
389
|
|
|
371
390
|
Returns:
|
|
372
391
|
An unraised exception instance for the caller to raise.
|
|
373
392
|
"""
|
|
393
|
+
if not envelope and 400 <= status < 500 and status != 429:
|
|
394
|
+
return SahiAPIException(
|
|
395
|
+
"the request was refused before it reached the API (no API error envelope), "
|
|
396
|
+
"usually because the URL is too long; send less per call",
|
|
397
|
+
status=status,
|
|
398
|
+
code=None,
|
|
399
|
+
request_id=request_id,
|
|
400
|
+
)
|
|
374
401
|
key = _normalise(code)
|
|
375
402
|
|
|
376
403
|
def build(cls: type[SahiAPIException]) -> SahiAPIException:
|
|
@@ -387,6 +414,8 @@ def exception_for(
|
|
|
387
414
|
return build(SahiIpNotAllowedException)
|
|
388
415
|
if key == "LOCKED":
|
|
389
416
|
return build(SahiAccountLockedException)
|
|
417
|
+
if key == "UNDERMAINTENANCE":
|
|
418
|
+
return build(SahiMaintenanceException)
|
|
390
419
|
|
|
391
420
|
if status == 400:
|
|
392
421
|
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:`
|
|
12
|
-
and :meth:`
|
|
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:`
|
|
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:`
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
67
|
-
07:00 IST, while the OMS runs its beginning-of-day
|
|
68
|
-
Retry after 07:00 IST
|
|
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
|
-
|
|
120
|
-
07:00 IST, as on :meth:`get_order_margin`.
|
|
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,7 @@ 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
|
|
277
278
|
try:
|
|
278
279
|
body = response.json()
|
|
279
280
|
if isinstance(body, dict):
|
|
@@ -282,6 +283,8 @@ class HttpClient:
|
|
|
282
283
|
code = raw_code if isinstance(raw_code, str) else None
|
|
283
284
|
message = body.get("message")
|
|
284
285
|
except ValueError:
|
|
286
|
+
# HTML or plain text: the edge answered, not the API.
|
|
287
|
+
envelope = False
|
|
285
288
|
message = response.text or None
|
|
286
289
|
|
|
287
290
|
server_request_id = response.headers.get(_REQUEST_ID_HEADER) or request_id
|
|
@@ -293,6 +296,7 @@ class HttpClient:
|
|
|
293
296
|
message=message,
|
|
294
297
|
request_id=server_request_id,
|
|
295
298
|
retry_after=retry_after,
|
|
299
|
+
envelope=envelope,
|
|
296
300
|
)
|
|
297
301
|
|
|
298
302
|
@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
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
#
|
|
247
|
-
#
|
|
248
|
-
#
|
|
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.
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
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
|
-
|
|
118
|
-
|
|
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
|
|
122
|
-
if
|
|
123
|
-
result[
|
|
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[
|
|
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
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
sahiapi-0.1.0/CHANGELOG.md
DELETED
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|