tejhq 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tej/__init__.py +73 -0
- tej/_http.py +183 -0
- tej/_version.py +1 -0
- tej/async_client.py +293 -0
- tej/client.py +308 -0
- tej/exceptions.py +77 -0
- tej/models.py +134 -0
- tej/py.typed +0 -0
- tejhq-0.2.0.dist-info/METADATA +257 -0
- tejhq-0.2.0.dist-info/RECORD +12 -0
- tejhq-0.2.0.dist-info/WHEEL +4 -0
- tejhq-0.2.0.dist-info/licenses/LICENSE +21 -0
tej/__init__.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Python SDK for `tej-api <https://api.tejhq.dev>`_.
|
|
2
|
+
|
|
3
|
+
Zero runtime dependencies. Sync + async clients with identical surface.
|
|
4
|
+
|
|
5
|
+
Quick start::
|
|
6
|
+
|
|
7
|
+
from tej import Client
|
|
8
|
+
|
|
9
|
+
c = Client()
|
|
10
|
+
rows = c.ohlcv("RELIANCE", "nse", "2025-01-01", "2025-01-31")
|
|
11
|
+
print(len(rows))
|
|
12
|
+
|
|
13
|
+
Async::
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
from tej import AsyncClient
|
|
17
|
+
|
|
18
|
+
async def main():
|
|
19
|
+
async with AsyncClient() as c:
|
|
20
|
+
return await c.ohlcv("RELIANCE", "nse", "2025-01-01", "2025-01-31")
|
|
21
|
+
|
|
22
|
+
asyncio.run(main())
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from ._version import __version__
|
|
26
|
+
from .async_client import AsyncClient
|
|
27
|
+
from .client import Client
|
|
28
|
+
from .exceptions import (
|
|
29
|
+
AuthError,
|
|
30
|
+
BadRequestError,
|
|
31
|
+
NetworkError,
|
|
32
|
+
NotFoundError,
|
|
33
|
+
ProRequiredError,
|
|
34
|
+
RateLimitError,
|
|
35
|
+
ServerError,
|
|
36
|
+
TejError,
|
|
37
|
+
)
|
|
38
|
+
from .models import (
|
|
39
|
+
OHLCV,
|
|
40
|
+
Action,
|
|
41
|
+
AdjustedRow,
|
|
42
|
+
Envelope,
|
|
43
|
+
Exchange,
|
|
44
|
+
MetricsRow,
|
|
45
|
+
ResolveHit,
|
|
46
|
+
SnapshotRow,
|
|
47
|
+
SymbolInterval,
|
|
48
|
+
UniverseMember,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
"__version__",
|
|
53
|
+
"Action",
|
|
54
|
+
"AdjustedRow",
|
|
55
|
+
"AsyncClient",
|
|
56
|
+
"AuthError",
|
|
57
|
+
"BadRequestError",
|
|
58
|
+
"Client",
|
|
59
|
+
"Envelope",
|
|
60
|
+
"Exchange",
|
|
61
|
+
"MetricsRow",
|
|
62
|
+
"NetworkError",
|
|
63
|
+
"NotFoundError",
|
|
64
|
+
"OHLCV",
|
|
65
|
+
"ProRequiredError",
|
|
66
|
+
"RateLimitError",
|
|
67
|
+
"ResolveHit",
|
|
68
|
+
"ServerError",
|
|
69
|
+
"SnapshotRow",
|
|
70
|
+
"SymbolInterval",
|
|
71
|
+
"TejError",
|
|
72
|
+
"UniverseMember",
|
|
73
|
+
]
|
tej/_http.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Stdlib HTTP transport shared by the sync and async clients.
|
|
2
|
+
|
|
3
|
+
Zero runtime deps. Sync uses ``urllib.request``. Async wraps sync calls in
|
|
4
|
+
``asyncio.to_thread`` so concurrent fetches via ``asyncio.gather`` still get
|
|
5
|
+
real parallelism without dragging in ``httpx``/``aiohttp``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import random
|
|
12
|
+
import re
|
|
13
|
+
import time
|
|
14
|
+
import urllib.error
|
|
15
|
+
import urllib.parse
|
|
16
|
+
import urllib.request
|
|
17
|
+
from collections.abc import Mapping
|
|
18
|
+
from typing import Any, Callable
|
|
19
|
+
|
|
20
|
+
from ._version import __version__
|
|
21
|
+
from .exceptions import NetworkError, ServerError, TejError, from_status
|
|
22
|
+
|
|
23
|
+
DEFAULT_BASE_URL = "https://api.tejhq.dev"
|
|
24
|
+
DEFAULT_TIMEOUT = 30.0
|
|
25
|
+
DEFAULT_MAX_RETRIES = 3
|
|
26
|
+
DEFAULT_BACKOFF_BASE = 0.4
|
|
27
|
+
DEFAULT_BACKOFF_CAP = 4.0
|
|
28
|
+
USER_AGENT = f"tej-sdk-py/{__version__}"
|
|
29
|
+
|
|
30
|
+
RETRYABLE_STATUSES = frozenset({429, 500, 502, 503, 504})
|
|
31
|
+
|
|
32
|
+
_SYMBOL_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9\-&]{0,29}$")
|
|
33
|
+
_EXCHANGE_VALUES = frozenset({"nse", "bse"})
|
|
34
|
+
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def normalize_exchange(value: str) -> str:
|
|
38
|
+
"""Lowercase + validate against `nse` | `bse`."""
|
|
39
|
+
if not isinstance(value, str):
|
|
40
|
+
raise ValueError(f"exchange must be str, got {type(value).__name__}")
|
|
41
|
+
v = value.strip().lower()
|
|
42
|
+
if v not in _EXCHANGE_VALUES:
|
|
43
|
+
raise ValueError(f"exchange must be 'nse' or 'bse', got {value!r}")
|
|
44
|
+
return v
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def normalize_symbol(value: str) -> str:
|
|
48
|
+
if not isinstance(value, str):
|
|
49
|
+
raise ValueError(f"symbol must be str, got {type(value).__name__}")
|
|
50
|
+
v = value.strip().upper()
|
|
51
|
+
if not _SYMBOL_RE.match(v):
|
|
52
|
+
raise ValueError(
|
|
53
|
+
f"symbol must match [A-Z0-9][A-Z0-9-&]{{0,29}}, got {value!r}"
|
|
54
|
+
)
|
|
55
|
+
return v
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def validate_date(value: str, field: str) -> str:
|
|
59
|
+
if not isinstance(value, str):
|
|
60
|
+
raise ValueError(f"{field} must be str (YYYY-MM-DD), got {type(value).__name__}")
|
|
61
|
+
if not _DATE_RE.match(value):
|
|
62
|
+
raise ValueError(f"{field} must be YYYY-MM-DD, got {value!r}")
|
|
63
|
+
return value
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def build_url(base_url: str, path: str, query: Mapping[str, Any] | None = None) -> str:
|
|
67
|
+
url = base_url.rstrip("/") + "/" + path.lstrip("/")
|
|
68
|
+
if query:
|
|
69
|
+
items = [(k, str(v)) for k, v in query.items() if v is not None]
|
|
70
|
+
if items:
|
|
71
|
+
url = url + "?" + urllib.parse.urlencode(items)
|
|
72
|
+
return url
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _backoff_sleep(attempt: int, base: float, cap: float) -> float:
|
|
76
|
+
expo = min(cap, base * (2 ** attempt))
|
|
77
|
+
jitter = random.uniform(0, expo / 2)
|
|
78
|
+
return float(expo / 2 + jitter)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _parse_body(raw: bytes, status: int, request_id: str | None) -> Any:
|
|
82
|
+
if not raw:
|
|
83
|
+
return None
|
|
84
|
+
try:
|
|
85
|
+
return json.loads(raw.decode("utf-8"))
|
|
86
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
87
|
+
if 200 <= status < 300:
|
|
88
|
+
raise TejError(
|
|
89
|
+
f"failed to decode JSON response: {exc}",
|
|
90
|
+
status_code=status,
|
|
91
|
+
request_id=request_id,
|
|
92
|
+
) from exc
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _raise_for_status(status: int, body: Any, request_id: str | None) -> None:
|
|
97
|
+
if 200 <= status < 300:
|
|
98
|
+
return
|
|
99
|
+
error_code: str | None = None
|
|
100
|
+
message = f"HTTP {status}"
|
|
101
|
+
if isinstance(body, dict):
|
|
102
|
+
error_code = body.get("error") if isinstance(body.get("error"), str) else None
|
|
103
|
+
msg = body.get("message")
|
|
104
|
+
if isinstance(msg, str) and msg:
|
|
105
|
+
message = msg
|
|
106
|
+
elif error_code:
|
|
107
|
+
message = error_code
|
|
108
|
+
raise from_status(status, message, error_code=error_code, request_id=request_id)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def request_json(
|
|
112
|
+
method: str,
|
|
113
|
+
url: str,
|
|
114
|
+
*,
|
|
115
|
+
headers: Mapping[str, str] | None = None,
|
|
116
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
117
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
118
|
+
backoff_base: float = DEFAULT_BACKOFF_BASE,
|
|
119
|
+
backoff_cap: float = DEFAULT_BACKOFF_CAP,
|
|
120
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
121
|
+
) -> tuple[int, Any, Mapping[str, str]]:
|
|
122
|
+
"""One HTTP round-trip with retry/backoff.
|
|
123
|
+
|
|
124
|
+
Returns ``(status, parsed_json_or_none, response_headers)``. Raises
|
|
125
|
+
:class:`TejError` on 4xx/5xx after exhausting retries, or
|
|
126
|
+
:class:`NetworkError` on transport failure.
|
|
127
|
+
"""
|
|
128
|
+
merged_headers = {"User-Agent": USER_AGENT, "Accept": "application/json"}
|
|
129
|
+
if headers:
|
|
130
|
+
merged_headers.update(headers)
|
|
131
|
+
|
|
132
|
+
last_exc: BaseException | None = None
|
|
133
|
+
for attempt in range(max_retries + 1):
|
|
134
|
+
req = urllib.request.Request(url, method=method, headers=dict(merged_headers))
|
|
135
|
+
try:
|
|
136
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
137
|
+
status = resp.status
|
|
138
|
+
resp_headers = {k.lower(): v for k, v in resp.headers.items()}
|
|
139
|
+
raw = resp.read()
|
|
140
|
+
request_id = resp_headers.get("x-request-id")
|
|
141
|
+
body = _parse_body(raw, status, request_id)
|
|
142
|
+
if status in RETRYABLE_STATUSES and attempt < max_retries:
|
|
143
|
+
sleep(_backoff_sleep(attempt, backoff_base, backoff_cap))
|
|
144
|
+
continue
|
|
145
|
+
_raise_for_status(status, body, request_id)
|
|
146
|
+
return status, body, resp_headers
|
|
147
|
+
except urllib.error.HTTPError as exc:
|
|
148
|
+
status = exc.code
|
|
149
|
+
resp_headers = {k.lower(): v for k, v in exc.headers.items()} if exc.headers else {}
|
|
150
|
+
raw = exc.read() if hasattr(exc, "read") else b""
|
|
151
|
+
request_id = resp_headers.get("x-request-id")
|
|
152
|
+
body = _parse_body(raw, status, request_id)
|
|
153
|
+
if status in RETRYABLE_STATUSES and attempt < max_retries:
|
|
154
|
+
sleep(_backoff_sleep(attempt, backoff_base, backoff_cap))
|
|
155
|
+
continue
|
|
156
|
+
_raise_for_status(status, body, request_id)
|
|
157
|
+
return status, body, resp_headers # unreachable; keeps type checker happy
|
|
158
|
+
except urllib.error.URLError as exc:
|
|
159
|
+
last_exc = exc
|
|
160
|
+
if attempt < max_retries:
|
|
161
|
+
sleep(_backoff_sleep(attempt, backoff_base, backoff_cap))
|
|
162
|
+
continue
|
|
163
|
+
raise NetworkError(f"request to {url} failed: {exc.reason}") from exc
|
|
164
|
+
except TimeoutError as exc:
|
|
165
|
+
last_exc = exc
|
|
166
|
+
if attempt < max_retries:
|
|
167
|
+
sleep(_backoff_sleep(attempt, backoff_base, backoff_cap))
|
|
168
|
+
continue
|
|
169
|
+
raise NetworkError(f"request to {url} timed out after {timeout}s") from exc
|
|
170
|
+
|
|
171
|
+
raise NetworkError(f"request to {url} failed after {max_retries} retries: {last_exc}")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def envelope_data(body: Any) -> list[dict[str, Any]]:
|
|
175
|
+
"""Extract the ``data`` array from a standard envelope response."""
|
|
176
|
+
if not isinstance(body, dict):
|
|
177
|
+
raise ServerError(f"unexpected response shape: {type(body).__name__}")
|
|
178
|
+
data = body.get("data")
|
|
179
|
+
if data is None:
|
|
180
|
+
return []
|
|
181
|
+
if not isinstance(data, list):
|
|
182
|
+
raise ServerError(f"response 'data' must be a list, got {type(data).__name__}")
|
|
183
|
+
return data
|
tej/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
tej/async_client.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""Asynchronous client for tej-api.
|
|
2
|
+
|
|
3
|
+
>>> import asyncio
|
|
4
|
+
>>> from tej import AsyncClient
|
|
5
|
+
>>>
|
|
6
|
+
>>> async def main():
|
|
7
|
+
... async with AsyncClient() as c:
|
|
8
|
+
... rows = await c.ohlcv("RELIANCE", "nse", "2025-01-01", "2025-01-31")
|
|
9
|
+
... return len(rows)
|
|
10
|
+
>>> asyncio.run(main())
|
|
11
|
+
21
|
|
12
|
+
|
|
13
|
+
The zero-runtime-dependency rule means we cannot pull in ``httpx`` or
|
|
14
|
+
``aiohttp``. ``asyncio.to_thread`` runs the blocking stdlib ``urllib`` call in
|
|
15
|
+
the default executor, which still gives real parallelism for ``gather``-style
|
|
16
|
+
concurrent fan-out (each in-flight request occupies one worker thread). For
|
|
17
|
+
ten or so concurrent EOD requests this is indistinguishable from native async
|
|
18
|
+
I/O. If you need to fan out hundreds of requests, raise the loop's executor
|
|
19
|
+
size with ``loop.set_default_executor(ThreadPoolExecutor(max_workers=N))``.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
from collections.abc import Iterable, Mapping
|
|
26
|
+
from types import TracebackType
|
|
27
|
+
from typing import Any, cast
|
|
28
|
+
|
|
29
|
+
from . import _http
|
|
30
|
+
from .models import (
|
|
31
|
+
OHLCV,
|
|
32
|
+
Action,
|
|
33
|
+
AdjustedRow,
|
|
34
|
+
Envelope,
|
|
35
|
+
MetricsRow,
|
|
36
|
+
ResolveHit,
|
|
37
|
+
SnapshotRow,
|
|
38
|
+
SymbolInterval,
|
|
39
|
+
UniverseMember,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class AsyncClient:
|
|
44
|
+
"""Asynchronous client. Same surface as :class:`Client` with `await`."""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
*,
|
|
49
|
+
base_url: str = _http.DEFAULT_BASE_URL,
|
|
50
|
+
api_key: str | None = None,
|
|
51
|
+
timeout: float = _http.DEFAULT_TIMEOUT,
|
|
52
|
+
max_retries: int = _http.DEFAULT_MAX_RETRIES,
|
|
53
|
+
user_agent_suffix: str | None = None,
|
|
54
|
+
default_headers: Mapping[str, str] | None = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
self.base_url = base_url
|
|
57
|
+
self.api_key = api_key
|
|
58
|
+
self.timeout = timeout
|
|
59
|
+
self.max_retries = max_retries
|
|
60
|
+
|
|
61
|
+
headers: dict[str, str] = {}
|
|
62
|
+
if default_headers:
|
|
63
|
+
headers.update(default_headers)
|
|
64
|
+
if api_key:
|
|
65
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
66
|
+
if user_agent_suffix:
|
|
67
|
+
headers["User-Agent"] = f"{_http.USER_AGENT} {user_agent_suffix}"
|
|
68
|
+
self._headers = headers
|
|
69
|
+
|
|
70
|
+
async def __aenter__(self) -> AsyncClient:
|
|
71
|
+
return self
|
|
72
|
+
|
|
73
|
+
async def __aexit__(
|
|
74
|
+
self,
|
|
75
|
+
exc_type: type[BaseException] | None,
|
|
76
|
+
exc: BaseException | None,
|
|
77
|
+
tb: TracebackType | None,
|
|
78
|
+
) -> None:
|
|
79
|
+
await self.aclose()
|
|
80
|
+
|
|
81
|
+
async def aclose(self) -> None:
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
async def _get(self, path: str, query: Mapping[str, Any] | None = None) -> Any:
|
|
85
|
+
url = _http.build_url(self.base_url, path, query)
|
|
86
|
+
result = await asyncio.to_thread(
|
|
87
|
+
_http.request_json,
|
|
88
|
+
"GET",
|
|
89
|
+
url,
|
|
90
|
+
headers=self._headers,
|
|
91
|
+
timeout=self.timeout,
|
|
92
|
+
max_retries=self.max_retries,
|
|
93
|
+
)
|
|
94
|
+
_, body, _ = result
|
|
95
|
+
return body
|
|
96
|
+
|
|
97
|
+
# ---- system probes -------------------------------------------------
|
|
98
|
+
|
|
99
|
+
async def health(self) -> dict[str, Any]:
|
|
100
|
+
body = await self._get("/health")
|
|
101
|
+
return body if isinstance(body, dict) else {}
|
|
102
|
+
|
|
103
|
+
async def ready(self) -> dict[str, Any]:
|
|
104
|
+
body = await self._get("/ready")
|
|
105
|
+
return body if isinstance(body, dict) else {}
|
|
106
|
+
|
|
107
|
+
# ---- market data ---------------------------------------------------
|
|
108
|
+
|
|
109
|
+
async def ohlcv(
|
|
110
|
+
self,
|
|
111
|
+
symbol: str,
|
|
112
|
+
exchange: str,
|
|
113
|
+
from_: str | None = None,
|
|
114
|
+
to: str | None = None,
|
|
115
|
+
) -> list[OHLCV]:
|
|
116
|
+
ex = _http.normalize_exchange(exchange)
|
|
117
|
+
sym = _http.normalize_symbol(symbol)
|
|
118
|
+
query: dict[str, str] = {}
|
|
119
|
+
if from_ is not None:
|
|
120
|
+
query["from"] = _http.validate_date(from_, "from_")
|
|
121
|
+
if to is not None:
|
|
122
|
+
query["to"] = _http.validate_date(to, "to")
|
|
123
|
+
body = await self._get(f"/v1/ohlcv/{ex}/{sym}", query or None)
|
|
124
|
+
return _http.envelope_data(body) # type: ignore[return-value]
|
|
125
|
+
|
|
126
|
+
async def snapshot(self, exchange: str, date: str) -> list[SnapshotRow]:
|
|
127
|
+
ex = _http.normalize_exchange(exchange)
|
|
128
|
+
d = _http.validate_date(date, "date")
|
|
129
|
+
body = await self._get(f"/v1/snapshot/{ex}", {"date": d})
|
|
130
|
+
return _http.envelope_data(body) # type: ignore[return-value]
|
|
131
|
+
|
|
132
|
+
async def actions(self, symbol: str) -> list[Action]:
|
|
133
|
+
sym = _http.normalize_symbol(symbol)
|
|
134
|
+
body = await self._get(f"/v1/actions/{sym}")
|
|
135
|
+
return _http.envelope_data(body) # type: ignore[return-value]
|
|
136
|
+
|
|
137
|
+
# ---- free key tier -------------------------------------------------
|
|
138
|
+
|
|
139
|
+
async def adjusted(
|
|
140
|
+
self,
|
|
141
|
+
symbol: str,
|
|
142
|
+
exchange: str,
|
|
143
|
+
from_: str | None = None,
|
|
144
|
+
to: str | None = None,
|
|
145
|
+
) -> list[AdjustedRow]:
|
|
146
|
+
"""Back-adjusted prices for one symbol. Needs a free API key.
|
|
147
|
+
|
|
148
|
+
Same range semantics as :meth:`ohlcv`. Adds ``adj_factor_cumulative``
|
|
149
|
+
and ``adj_close``, continuous through splits, bonuses and dividends.
|
|
150
|
+
"""
|
|
151
|
+
ex = _http.normalize_exchange(exchange)
|
|
152
|
+
sym = _http.normalize_symbol(symbol)
|
|
153
|
+
body = await self._get(f"/v1/adjusted/{ex}/{sym}", _range_query(from_, to))
|
|
154
|
+
return _http.envelope_data(body) # type: ignore[return-value]
|
|
155
|
+
|
|
156
|
+
async def symbols(
|
|
157
|
+
self,
|
|
158
|
+
exchange: str,
|
|
159
|
+
*,
|
|
160
|
+
symbol: str | None = None,
|
|
161
|
+
isin: str | None = None,
|
|
162
|
+
) -> list[SymbolInterval]:
|
|
163
|
+
"""Symbol history by ``symbol`` or ``isin``. Needs a free API key.
|
|
164
|
+
|
|
165
|
+
One row per contiguous run of an ISIN under a symbol.
|
|
166
|
+
"""
|
|
167
|
+
if symbol is None and isin is None:
|
|
168
|
+
raise ValueError("pass symbol= or isin=")
|
|
169
|
+
ex = _http.normalize_exchange(exchange)
|
|
170
|
+
query: dict[str, str] = {}
|
|
171
|
+
if symbol is not None:
|
|
172
|
+
query["symbol"] = _http.normalize_symbol(symbol)
|
|
173
|
+
if isin is not None:
|
|
174
|
+
query["isin"] = isin.strip().upper()
|
|
175
|
+
body = await self._get(f"/v1/symbols/{ex}", query)
|
|
176
|
+
return _http.envelope_data(body) # type: ignore[return-value]
|
|
177
|
+
|
|
178
|
+
async def me(self) -> dict[str, Any]:
|
|
179
|
+
"""Account, keys and today's usage for the calling key."""
|
|
180
|
+
body = await self._get("/v1/me")
|
|
181
|
+
return body if isinstance(body, dict) else {}
|
|
182
|
+
|
|
183
|
+
# ---- pro tier ------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
async def metrics(
|
|
186
|
+
self,
|
|
187
|
+
symbol: str,
|
|
188
|
+
exchange: str,
|
|
189
|
+
from_: str | None = None,
|
|
190
|
+
to: str | None = None,
|
|
191
|
+
) -> list[MetricsRow]:
|
|
192
|
+
"""Per-day derived metrics on adjusted close. Needs a Pro key.
|
|
193
|
+
|
|
194
|
+
Returns at 1/5/21/63/126/252 days and YTD, 52-week high and low with
|
|
195
|
+
distance, average volume and turnover. Rolling fields are ``None``
|
|
196
|
+
until a full window of history exists.
|
|
197
|
+
"""
|
|
198
|
+
ex = _http.normalize_exchange(exchange)
|
|
199
|
+
sym = _http.normalize_symbol(symbol)
|
|
200
|
+
body = await self._get(f"/v1/metrics/{ex}/{sym}", _range_query(from_, to))
|
|
201
|
+
return _http.envelope_data(body) # type: ignore[return-value]
|
|
202
|
+
|
|
203
|
+
async def universe(
|
|
204
|
+
self,
|
|
205
|
+
name: str = "liquid500",
|
|
206
|
+
exchange: str = "nse",
|
|
207
|
+
as_of: str | None = None,
|
|
208
|
+
) -> list[UniverseMember]:
|
|
209
|
+
"""Point-in-time liquidity universe. Needs a Pro key.
|
|
210
|
+
|
|
211
|
+
``name`` is ``liquid100``, ``liquid250`` or ``liquid500``: the top N
|
|
212
|
+
by trailing 63-day turnover at the latest monthly rebalance on or
|
|
213
|
+
before ``as_of`` (default today). Delisted names stay in the months
|
|
214
|
+
they qualified for, so there is no survivorship bias.
|
|
215
|
+
"""
|
|
216
|
+
n = name.strip().lower()
|
|
217
|
+
if n not in ("liquid100", "liquid250", "liquid500"):
|
|
218
|
+
raise ValueError("name must be liquid100, liquid250 or liquid500")
|
|
219
|
+
query: dict[str, str] = {"exchange": _http.normalize_exchange(exchange)}
|
|
220
|
+
if as_of is not None:
|
|
221
|
+
query["as_of"] = _http.validate_date(as_of, "as_of")
|
|
222
|
+
body = await self._get(f"/v1/universe/{n}", query)
|
|
223
|
+
return _http.envelope_data(body) # type: ignore[return-value]
|
|
224
|
+
|
|
225
|
+
async def batch(
|
|
226
|
+
self,
|
|
227
|
+
symbols: Iterable[str],
|
|
228
|
+
exchange: str = "nse",
|
|
229
|
+
from_: str | None = None,
|
|
230
|
+
to: str | None = None,
|
|
231
|
+
) -> dict[str, list[OHLCV]]:
|
|
232
|
+
"""OHLCV for up to 50 symbols in one request. Needs a Pro key.
|
|
233
|
+
|
|
234
|
+
Returns a dict keyed by symbol; unknown symbols map to ``[]``.
|
|
235
|
+
"""
|
|
236
|
+
syms = [_http.normalize_symbol(s) for s in symbols]
|
|
237
|
+
if not syms:
|
|
238
|
+
raise ValueError("symbols must not be empty")
|
|
239
|
+
if len(syms) > 50:
|
|
240
|
+
raise ValueError("at most 50 symbols per batch")
|
|
241
|
+
query = _range_query(from_, to) or {}
|
|
242
|
+
query["symbols"] = ",".join(syms)
|
|
243
|
+
query["exchange"] = _http.normalize_exchange(exchange)
|
|
244
|
+
body = await self._get("/v1/batch", query)
|
|
245
|
+
data = body.get("data") if isinstance(body, dict) else None
|
|
246
|
+
return data if isinstance(data, dict) else {}
|
|
247
|
+
|
|
248
|
+
async def resolve(
|
|
249
|
+
self,
|
|
250
|
+
q: str,
|
|
251
|
+
exchange: str = "both",
|
|
252
|
+
limit: int = 5,
|
|
253
|
+
) -> list[ResolveHit]:
|
|
254
|
+
"""Resolve free text to symbols. Needs a Pro key.
|
|
255
|
+
|
|
256
|
+
``"Reliance Industries"``, ``"tata mot"``, ``"infosis"`` or a former
|
|
257
|
+
ticker like ``"ZOMATO"`` all resolve. Best match first, with
|
|
258
|
+
``score`` and ``matched_on``.
|
|
259
|
+
"""
|
|
260
|
+
q = q.strip()
|
|
261
|
+
if not 2 <= len(q) <= 80:
|
|
262
|
+
raise ValueError("q must be 2 to 80 characters")
|
|
263
|
+
if not 1 <= limit <= 25:
|
|
264
|
+
raise ValueError("limit must be 1 to 25")
|
|
265
|
+
ex = "both" if exchange.lower() == "both" else _http.normalize_exchange(exchange)
|
|
266
|
+
body = await self._get("/v1/resolve", {"q": q, "exchange": ex, "limit": str(limit)})
|
|
267
|
+
return _http.envelope_data(body) # type: ignore[return-value]
|
|
268
|
+
|
|
269
|
+
async def ohlcv_envelope(
|
|
270
|
+
self,
|
|
271
|
+
symbol: str,
|
|
272
|
+
exchange: str,
|
|
273
|
+
from_: str | None = None,
|
|
274
|
+
to: str | None = None,
|
|
275
|
+
) -> Envelope:
|
|
276
|
+
ex = _http.normalize_exchange(exchange)
|
|
277
|
+
sym = _http.normalize_symbol(symbol)
|
|
278
|
+
query: dict[str, str] = {}
|
|
279
|
+
if from_ is not None:
|
|
280
|
+
query["from"] = _http.validate_date(from_, "from_")
|
|
281
|
+
if to is not None:
|
|
282
|
+
query["to"] = _http.validate_date(to, "to")
|
|
283
|
+
body = await self._get(f"/v1/ohlcv/{ex}/{sym}", query or None)
|
|
284
|
+
return cast(Envelope, body) if isinstance(body, dict) else Envelope(data=[])
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _range_query(from_: str | None, to: str | None) -> dict[str, str] | None:
|
|
288
|
+
query: dict[str, str] = {}
|
|
289
|
+
if from_ is not None:
|
|
290
|
+
query["from"] = _http.validate_date(from_, "from_")
|
|
291
|
+
if to is not None:
|
|
292
|
+
query["to"] = _http.validate_date(to, "to")
|
|
293
|
+
return query or None
|
tej/client.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""Synchronous client for tej-api.
|
|
2
|
+
|
|
3
|
+
>>> from tej import Client
|
|
4
|
+
>>> c = Client()
|
|
5
|
+
>>> rows = c.ohlcv("RELIANCE", "nse", "2025-01-01", "2025-01-31")
|
|
6
|
+
>>> len(rows)
|
|
7
|
+
21
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Iterable, Mapping
|
|
13
|
+
from types import TracebackType
|
|
14
|
+
from typing import Any, cast
|
|
15
|
+
|
|
16
|
+
from . import _http
|
|
17
|
+
from .models import (
|
|
18
|
+
OHLCV,
|
|
19
|
+
Action,
|
|
20
|
+
AdjustedRow,
|
|
21
|
+
Envelope,
|
|
22
|
+
MetricsRow,
|
|
23
|
+
ResolveHit,
|
|
24
|
+
SnapshotRow,
|
|
25
|
+
SymbolInterval,
|
|
26
|
+
UniverseMember,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Client:
|
|
31
|
+
"""Synchronous client.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
base_url: API base URL. Defaults to https://api.tejhq.dev.
|
|
35
|
+
api_key: Optional API key, sent as ``Authorization: Bearer <key>``.
|
|
36
|
+
Not required for any free-tier endpoint.
|
|
37
|
+
timeout: Per-request timeout in seconds.
|
|
38
|
+
max_retries: How many times to retry on 5xx / 429 / network errors
|
|
39
|
+
with exponential backoff. Default 3.
|
|
40
|
+
user_agent_suffix: Appended to the default User-Agent. Useful for
|
|
41
|
+
attribution from your own app.
|
|
42
|
+
default_headers: Extra headers merged into every request.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
*,
|
|
48
|
+
base_url: str = _http.DEFAULT_BASE_URL,
|
|
49
|
+
api_key: str | None = None,
|
|
50
|
+
timeout: float = _http.DEFAULT_TIMEOUT,
|
|
51
|
+
max_retries: int = _http.DEFAULT_MAX_RETRIES,
|
|
52
|
+
user_agent_suffix: str | None = None,
|
|
53
|
+
default_headers: Mapping[str, str] | None = None,
|
|
54
|
+
) -> None:
|
|
55
|
+
self.base_url = base_url
|
|
56
|
+
self.api_key = api_key
|
|
57
|
+
self.timeout = timeout
|
|
58
|
+
self.max_retries = max_retries
|
|
59
|
+
|
|
60
|
+
headers: dict[str, str] = {}
|
|
61
|
+
if default_headers:
|
|
62
|
+
headers.update(default_headers)
|
|
63
|
+
if api_key:
|
|
64
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
65
|
+
if user_agent_suffix:
|
|
66
|
+
headers["User-Agent"] = f"{_http.USER_AGENT} {user_agent_suffix}"
|
|
67
|
+
self._headers = headers
|
|
68
|
+
|
|
69
|
+
# context-manager parity with AsyncClient even though there is no socket
|
|
70
|
+
# to close in the stdlib transport; lets users write the same shape of
|
|
71
|
+
# code under either client.
|
|
72
|
+
def __enter__(self) -> Client:
|
|
73
|
+
return self
|
|
74
|
+
|
|
75
|
+
def __exit__(
|
|
76
|
+
self,
|
|
77
|
+
exc_type: type[BaseException] | None,
|
|
78
|
+
exc: BaseException | None,
|
|
79
|
+
tb: TracebackType | None,
|
|
80
|
+
) -> None:
|
|
81
|
+
self.close()
|
|
82
|
+
|
|
83
|
+
def close(self) -> None:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
def _get(self, path: str, query: Mapping[str, Any] | None = None) -> Any:
|
|
87
|
+
url = _http.build_url(self.base_url, path, query)
|
|
88
|
+
_, body, _ = _http.request_json(
|
|
89
|
+
"GET",
|
|
90
|
+
url,
|
|
91
|
+
headers=self._headers,
|
|
92
|
+
timeout=self.timeout,
|
|
93
|
+
max_retries=self.max_retries,
|
|
94
|
+
)
|
|
95
|
+
return body
|
|
96
|
+
|
|
97
|
+
# ---- system probes -------------------------------------------------
|
|
98
|
+
|
|
99
|
+
def health(self) -> dict[str, Any]:
|
|
100
|
+
body = self._get("/health")
|
|
101
|
+
return body if isinstance(body, dict) else {}
|
|
102
|
+
|
|
103
|
+
def ready(self) -> dict[str, Any]:
|
|
104
|
+
body = self._get("/ready")
|
|
105
|
+
return body if isinstance(body, dict) else {}
|
|
106
|
+
|
|
107
|
+
# ---- market data ---------------------------------------------------
|
|
108
|
+
|
|
109
|
+
def ohlcv(
|
|
110
|
+
self,
|
|
111
|
+
symbol: str,
|
|
112
|
+
exchange: str,
|
|
113
|
+
from_: str | None = None,
|
|
114
|
+
to: str | None = None,
|
|
115
|
+
) -> list[OHLCV]:
|
|
116
|
+
"""End-of-day OHLCV rows for one symbol over a date range.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
symbol: Trading symbol, e.g. ``"RELIANCE"``. Case-insensitive.
|
|
120
|
+
exchange: ``"nse"`` or ``"bse"``. Case-insensitive.
|
|
121
|
+
from_: Inclusive start ``YYYY-MM-DD``. Defaults to ``to`` minus
|
|
122
|
+
90 days on the server.
|
|
123
|
+
to: Inclusive end ``YYYY-MM-DD``. Defaults to today (UTC) on
|
|
124
|
+
the server.
|
|
125
|
+
"""
|
|
126
|
+
ex = _http.normalize_exchange(exchange)
|
|
127
|
+
sym = _http.normalize_symbol(symbol)
|
|
128
|
+
query: dict[str, str] = {}
|
|
129
|
+
if from_ is not None:
|
|
130
|
+
query["from"] = _http.validate_date(from_, "from_")
|
|
131
|
+
if to is not None:
|
|
132
|
+
query["to"] = _http.validate_date(to, "to")
|
|
133
|
+
body = self._get(f"/v1/ohlcv/{ex}/{sym}", query or None)
|
|
134
|
+
return _http.envelope_data(body) # type: ignore[return-value]
|
|
135
|
+
|
|
136
|
+
def snapshot(self, exchange: str, date: str) -> list[SnapshotRow]:
|
|
137
|
+
"""Every symbol's bhavcopy line for one trading date on one exchange."""
|
|
138
|
+
ex = _http.normalize_exchange(exchange)
|
|
139
|
+
d = _http.validate_date(date, "date")
|
|
140
|
+
body = self._get(f"/v1/snapshot/{ex}", {"date": d})
|
|
141
|
+
return _http.envelope_data(body) # type: ignore[return-value]
|
|
142
|
+
|
|
143
|
+
def actions(self, symbol: str) -> list[Action]:
|
|
144
|
+
"""All corporate actions ever recorded for one symbol."""
|
|
145
|
+
sym = _http.normalize_symbol(symbol)
|
|
146
|
+
body = self._get(f"/v1/actions/{sym}")
|
|
147
|
+
return _http.envelope_data(body) # type: ignore[return-value]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ---- free key tier -------------------------------------------------
|
|
151
|
+
|
|
152
|
+
def adjusted(
|
|
153
|
+
self,
|
|
154
|
+
symbol: str,
|
|
155
|
+
exchange: str,
|
|
156
|
+
from_: str | None = None,
|
|
157
|
+
to: str | None = None,
|
|
158
|
+
) -> list[AdjustedRow]:
|
|
159
|
+
"""Back-adjusted prices for one symbol. Needs a free API key.
|
|
160
|
+
|
|
161
|
+
Same range semantics as :meth:`ohlcv`. Adds ``adj_factor_cumulative``
|
|
162
|
+
and ``adj_close``, continuous through splits, bonuses and dividends.
|
|
163
|
+
"""
|
|
164
|
+
ex = _http.normalize_exchange(exchange)
|
|
165
|
+
sym = _http.normalize_symbol(symbol)
|
|
166
|
+
body = self._get(f"/v1/adjusted/{ex}/{sym}", _range_query(from_, to))
|
|
167
|
+
return _http.envelope_data(body) # type: ignore[return-value]
|
|
168
|
+
|
|
169
|
+
def symbols(
|
|
170
|
+
self,
|
|
171
|
+
exchange: str,
|
|
172
|
+
*,
|
|
173
|
+
symbol: str | None = None,
|
|
174
|
+
isin: str | None = None,
|
|
175
|
+
) -> list[SymbolInterval]:
|
|
176
|
+
"""Symbol history by ``symbol`` or ``isin``. Needs a free API key.
|
|
177
|
+
|
|
178
|
+
One row per contiguous run of an ISIN under a symbol.
|
|
179
|
+
"""
|
|
180
|
+
if symbol is None and isin is None:
|
|
181
|
+
raise ValueError("pass symbol= or isin=")
|
|
182
|
+
ex = _http.normalize_exchange(exchange)
|
|
183
|
+
query: dict[str, str] = {}
|
|
184
|
+
if symbol is not None:
|
|
185
|
+
query["symbol"] = _http.normalize_symbol(symbol)
|
|
186
|
+
if isin is not None:
|
|
187
|
+
query["isin"] = isin.strip().upper()
|
|
188
|
+
body = self._get(f"/v1/symbols/{ex}", query)
|
|
189
|
+
return _http.envelope_data(body) # type: ignore[return-value]
|
|
190
|
+
|
|
191
|
+
def me(self) -> dict[str, Any]:
|
|
192
|
+
"""Account, keys and today's usage for the calling key."""
|
|
193
|
+
body = self._get("/v1/me")
|
|
194
|
+
return body if isinstance(body, dict) else {}
|
|
195
|
+
|
|
196
|
+
# ---- pro tier ------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
def metrics(
|
|
199
|
+
self,
|
|
200
|
+
symbol: str,
|
|
201
|
+
exchange: str,
|
|
202
|
+
from_: str | None = None,
|
|
203
|
+
to: str | None = None,
|
|
204
|
+
) -> list[MetricsRow]:
|
|
205
|
+
"""Per-day derived metrics on adjusted close. Needs a Pro key.
|
|
206
|
+
|
|
207
|
+
Returns at 1/5/21/63/126/252 days and YTD, 52-week high and low with
|
|
208
|
+
distance, average volume and turnover. Rolling fields are ``None``
|
|
209
|
+
until a full window of history exists.
|
|
210
|
+
"""
|
|
211
|
+
ex = _http.normalize_exchange(exchange)
|
|
212
|
+
sym = _http.normalize_symbol(symbol)
|
|
213
|
+
body = self._get(f"/v1/metrics/{ex}/{sym}", _range_query(from_, to))
|
|
214
|
+
return _http.envelope_data(body) # type: ignore[return-value]
|
|
215
|
+
|
|
216
|
+
def universe(
|
|
217
|
+
self,
|
|
218
|
+
name: str = "liquid500",
|
|
219
|
+
exchange: str = "nse",
|
|
220
|
+
as_of: str | None = None,
|
|
221
|
+
) -> list[UniverseMember]:
|
|
222
|
+
"""Point-in-time liquidity universe. Needs a Pro key.
|
|
223
|
+
|
|
224
|
+
``name`` is ``liquid100``, ``liquid250`` or ``liquid500``: the top N
|
|
225
|
+
by trailing 63-day turnover at the latest monthly rebalance on or
|
|
226
|
+
before ``as_of`` (default today). Delisted names stay in the months
|
|
227
|
+
they qualified for, so there is no survivorship bias.
|
|
228
|
+
"""
|
|
229
|
+
n = name.strip().lower()
|
|
230
|
+
if n not in ("liquid100", "liquid250", "liquid500"):
|
|
231
|
+
raise ValueError("name must be liquid100, liquid250 or liquid500")
|
|
232
|
+
query: dict[str, str] = {"exchange": _http.normalize_exchange(exchange)}
|
|
233
|
+
if as_of is not None:
|
|
234
|
+
query["as_of"] = _http.validate_date(as_of, "as_of")
|
|
235
|
+
body = self._get(f"/v1/universe/{n}", query)
|
|
236
|
+
return _http.envelope_data(body) # type: ignore[return-value]
|
|
237
|
+
|
|
238
|
+
def batch(
|
|
239
|
+
self,
|
|
240
|
+
symbols: Iterable[str],
|
|
241
|
+
exchange: str = "nse",
|
|
242
|
+
from_: str | None = None,
|
|
243
|
+
to: str | None = None,
|
|
244
|
+
) -> dict[str, list[OHLCV]]:
|
|
245
|
+
"""OHLCV for up to 50 symbols in one request. Needs a Pro key.
|
|
246
|
+
|
|
247
|
+
Returns a dict keyed by symbol; unknown symbols map to ``[]``.
|
|
248
|
+
"""
|
|
249
|
+
syms = [_http.normalize_symbol(s) for s in symbols]
|
|
250
|
+
if not syms:
|
|
251
|
+
raise ValueError("symbols must not be empty")
|
|
252
|
+
if len(syms) > 50:
|
|
253
|
+
raise ValueError("at most 50 symbols per batch")
|
|
254
|
+
query = _range_query(from_, to) or {}
|
|
255
|
+
query["symbols"] = ",".join(syms)
|
|
256
|
+
query["exchange"] = _http.normalize_exchange(exchange)
|
|
257
|
+
body = self._get("/v1/batch", query)
|
|
258
|
+
data = body.get("data") if isinstance(body, dict) else None
|
|
259
|
+
return data if isinstance(data, dict) else {}
|
|
260
|
+
|
|
261
|
+
def resolve(
|
|
262
|
+
self,
|
|
263
|
+
q: str,
|
|
264
|
+
exchange: str = "both",
|
|
265
|
+
limit: int = 5,
|
|
266
|
+
) -> list[ResolveHit]:
|
|
267
|
+
"""Resolve free text to symbols. Needs a Pro key.
|
|
268
|
+
|
|
269
|
+
``"Reliance Industries"``, ``"tata mot"``, ``"infosis"`` or a former
|
|
270
|
+
ticker like ``"ZOMATO"`` all resolve. Best match first, with
|
|
271
|
+
``score`` and ``matched_on``.
|
|
272
|
+
"""
|
|
273
|
+
q = q.strip()
|
|
274
|
+
if not 2 <= len(q) <= 80:
|
|
275
|
+
raise ValueError("q must be 2 to 80 characters")
|
|
276
|
+
if not 1 <= limit <= 25:
|
|
277
|
+
raise ValueError("limit must be 1 to 25")
|
|
278
|
+
ex = "both" if exchange.lower() == "both" else _http.normalize_exchange(exchange)
|
|
279
|
+
body = self._get("/v1/resolve", {"q": q, "exchange": ex, "limit": str(limit)})
|
|
280
|
+
return _http.envelope_data(body) # type: ignore[return-value]
|
|
281
|
+
|
|
282
|
+
# ---- envelope variants (with meta) ---------------------------------
|
|
283
|
+
|
|
284
|
+
def ohlcv_envelope(
|
|
285
|
+
self,
|
|
286
|
+
symbol: str,
|
|
287
|
+
exchange: str,
|
|
288
|
+
from_: str | None = None,
|
|
289
|
+
to: str | None = None,
|
|
290
|
+
) -> Envelope:
|
|
291
|
+
ex = _http.normalize_exchange(exchange)
|
|
292
|
+
sym = _http.normalize_symbol(symbol)
|
|
293
|
+
query: dict[str, str] = {}
|
|
294
|
+
if from_ is not None:
|
|
295
|
+
query["from"] = _http.validate_date(from_, "from_")
|
|
296
|
+
if to is not None:
|
|
297
|
+
query["to"] = _http.validate_date(to, "to")
|
|
298
|
+
body = self._get(f"/v1/ohlcv/{ex}/{sym}", query or None)
|
|
299
|
+
return cast(Envelope, body) if isinstance(body, dict) else Envelope(data=[])
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _range_query(from_: str | None, to: str | None) -> dict[str, str] | None:
|
|
303
|
+
query: dict[str, str] = {}
|
|
304
|
+
if from_ is not None:
|
|
305
|
+
query["from"] = _http.validate_date(from_, "from_")
|
|
306
|
+
if to is not None:
|
|
307
|
+
query["to"] = _http.validate_date(to, "to")
|
|
308
|
+
return query or None
|
tej/exceptions.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class TejError(Exception):
|
|
5
|
+
"""Base exception for all tej SDK errors."""
|
|
6
|
+
|
|
7
|
+
def __init__(
|
|
8
|
+
self,
|
|
9
|
+
message: str,
|
|
10
|
+
*,
|
|
11
|
+
status_code: int | None = None,
|
|
12
|
+
error_code: str | None = None,
|
|
13
|
+
request_id: str | None = None,
|
|
14
|
+
) -> None:
|
|
15
|
+
super().__init__(message)
|
|
16
|
+
self.status_code = status_code
|
|
17
|
+
self.error_code = error_code
|
|
18
|
+
self.request_id = request_id
|
|
19
|
+
|
|
20
|
+
def __str__(self) -> str:
|
|
21
|
+
parts = [super().__str__()]
|
|
22
|
+
if self.status_code is not None:
|
|
23
|
+
parts.append(f"status={self.status_code}")
|
|
24
|
+
if self.error_code:
|
|
25
|
+
parts.append(f"error={self.error_code}")
|
|
26
|
+
if self.request_id:
|
|
27
|
+
parts.append(f"request_id={self.request_id}")
|
|
28
|
+
return " ".join(parts)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class BadRequestError(TejError):
|
|
32
|
+
"""400: invalid path or query parameter."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class NotFoundError(TejError):
|
|
36
|
+
"""404: resource not found."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AuthError(TejError):
|
|
40
|
+
"""HTTP 401: no API key was sent where one is required, or the key is
|
|
41
|
+
malformed, unknown, or revoked. ``error_code`` is ``key_required`` or
|
|
42
|
+
``invalid_key``. Get a free key at https://tejhq.dev/keys."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ProRequiredError(TejError):
|
|
46
|
+
"""402: endpoint is part of the Pro tier."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class RateLimitError(TejError):
|
|
50
|
+
"""429: client exceeded rate limit."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ServerError(TejError):
|
|
54
|
+
"""5xx: upstream failure."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class NetworkError(TejError):
|
|
58
|
+
"""Transport-level failure (DNS, connection, timeout, TLS)."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def from_status(status: int, message: str, **kwargs: object) -> TejError:
|
|
62
|
+
cls: type[TejError]
|
|
63
|
+
if status == 400:
|
|
64
|
+
cls = BadRequestError
|
|
65
|
+
elif status == 401:
|
|
66
|
+
cls = AuthError
|
|
67
|
+
elif status == 402:
|
|
68
|
+
cls = ProRequiredError
|
|
69
|
+
elif status == 404:
|
|
70
|
+
cls = NotFoundError
|
|
71
|
+
elif status == 429:
|
|
72
|
+
cls = RateLimitError
|
|
73
|
+
elif 500 <= status < 600:
|
|
74
|
+
cls = ServerError
|
|
75
|
+
else:
|
|
76
|
+
cls = TejError
|
|
77
|
+
return cls(message, status_code=status, **kwargs) # type: ignore[arg-type]
|
tej/models.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Row schemas for tej-api responses.
|
|
2
|
+
|
|
3
|
+
These are `TypedDict`s rather than dataclasses so that values returned by the
|
|
4
|
+
SDK are plain `dict`s, usable directly with `polars.DataFrame(rows)`,
|
|
5
|
+
`pandas.DataFrame(rows)`, or any other tabular library, without an extra
|
|
6
|
+
conversion step.
|
|
7
|
+
|
|
8
|
+
All date fields are strings formatted as ``YYYY-MM-DD`` (matching the API's
|
|
9
|
+
``format: date`` declaration). Parse with ``datetime.date.fromisoformat`` or
|
|
10
|
+
``polars.col("date").str.to_date()`` if you need a date object.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any, Literal, TypedDict
|
|
16
|
+
|
|
17
|
+
Exchange = Literal["nse", "bse", "NSE", "BSE"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class OHLCV(TypedDict):
|
|
21
|
+
date: str
|
|
22
|
+
open: float
|
|
23
|
+
high: float
|
|
24
|
+
low: float
|
|
25
|
+
close: float
|
|
26
|
+
last: float
|
|
27
|
+
prev_close: float
|
|
28
|
+
volume: int
|
|
29
|
+
turnover: float
|
|
30
|
+
trades: int
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SnapshotRow(TypedDict):
|
|
34
|
+
symbol: str
|
|
35
|
+
series: str
|
|
36
|
+
isin: str
|
|
37
|
+
name: str
|
|
38
|
+
open: float
|
|
39
|
+
high: float
|
|
40
|
+
low: float
|
|
41
|
+
close: float
|
|
42
|
+
last: float
|
|
43
|
+
prev_close: float
|
|
44
|
+
volume: int
|
|
45
|
+
turnover: float
|
|
46
|
+
trades: int
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Action(TypedDict, total=False):
|
|
50
|
+
exchange: str
|
|
51
|
+
symbol: str
|
|
52
|
+
isin: str
|
|
53
|
+
company: str
|
|
54
|
+
ex_date: str
|
|
55
|
+
record_date: str | None
|
|
56
|
+
type: str
|
|
57
|
+
ratio_num: int | None
|
|
58
|
+
ratio_den: int | None
|
|
59
|
+
cash_amount: float | None
|
|
60
|
+
face_value_from: float | None
|
|
61
|
+
face_value_to: float | None
|
|
62
|
+
raw_subject: str
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class AdjustedRow(TypedDict):
|
|
66
|
+
date: str
|
|
67
|
+
open: float
|
|
68
|
+
high: float
|
|
69
|
+
low: float
|
|
70
|
+
close: float
|
|
71
|
+
volume: int
|
|
72
|
+
turnover: float
|
|
73
|
+
adj_factor_cumulative: float
|
|
74
|
+
adj_close: float
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class SymbolInterval(TypedDict):
|
|
78
|
+
isin: str
|
|
79
|
+
symbol: str
|
|
80
|
+
valid_from: str
|
|
81
|
+
valid_to: str
|
|
82
|
+
trading_days: int
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class MetricsRow(TypedDict):
|
|
86
|
+
date: str
|
|
87
|
+
isin: str
|
|
88
|
+
adj_close: float
|
|
89
|
+
ret_1d: float | None
|
|
90
|
+
ret_5d: float | None
|
|
91
|
+
ret_21d: float | None
|
|
92
|
+
ret_63d: float | None
|
|
93
|
+
ret_126d: float | None
|
|
94
|
+
ret_252d: float | None
|
|
95
|
+
ret_ytd: float | None
|
|
96
|
+
high_52w: float | None
|
|
97
|
+
low_52w: float | None
|
|
98
|
+
pct_off_52w_high: float | None
|
|
99
|
+
pct_off_52w_low: float | None
|
|
100
|
+
avg_vol_20d: float | None
|
|
101
|
+
avg_vol_60d: float | None
|
|
102
|
+
avg_turnover_20d: float | None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class UniverseMember(TypedDict):
|
|
106
|
+
rank: int
|
|
107
|
+
symbol: str
|
|
108
|
+
isin: str
|
|
109
|
+
name: str
|
|
110
|
+
avg_turnover_63d: float
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class ResolveHit(TypedDict, total=False):
|
|
114
|
+
exchange: str
|
|
115
|
+
symbol: str
|
|
116
|
+
isin: str
|
|
117
|
+
name: str
|
|
118
|
+
score: float
|
|
119
|
+
matched_on: Literal["symbol", "former_symbol", "name", "symbol_prefix", "name_prefix", "fuzzy"]
|
|
120
|
+
former_symbols: list[str]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class Envelope(TypedDict, total=False):
|
|
124
|
+
data: list[dict[str, Any]]
|
|
125
|
+
meta: dict[str, Any]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
__all__ = [
|
|
129
|
+
"Action",
|
|
130
|
+
"Envelope",
|
|
131
|
+
"Exchange",
|
|
132
|
+
"OHLCV",
|
|
133
|
+
"SnapshotRow",
|
|
134
|
+
]
|
tej/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: tejhq
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Python SDK for tej-api: open EOD market data for NSE and BSE.
|
|
5
|
+
Project-URL: Homepage, https://tejhq.dev
|
|
6
|
+
Project-URL: Documentation, https://tejhq.dev/docs
|
|
7
|
+
Project-URL: Repository, https://github.com/tejhq/tej-sdk-py
|
|
8
|
+
Project-URL: Issues, https://github.com/tejhq/tej-sdk-py/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/tejhq/tej-sdk-py/blob/master/CHANGELOG.md
|
|
10
|
+
Author-email: TejHQ <support@tejhq.dev>
|
|
11
|
+
License: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: bhavcopy,bse,india,market-data,nse,ohlcv,stocks
|
|
14
|
+
Classifier: Development Status :: 3 - Alpha
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
17
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
+
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
26
|
+
Classifier: Typing :: Typed
|
|
27
|
+
Requires-Python: >=3.9
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
32
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
33
|
+
Provides-Extra: pandas
|
|
34
|
+
Requires-Dist: pandas>=2.0; extra == 'pandas'
|
|
35
|
+
Provides-Extra: polars
|
|
36
|
+
Requires-Dist: polars>=1.0; extra == 'polars'
|
|
37
|
+
Description-Content-Type: text/markdown
|
|
38
|
+
|
|
39
|
+
# tej
|
|
40
|
+
|
|
41
|
+
[](https://pypi.org/project/tejhq/)
|
|
42
|
+
[](https://pypi.org/project/tejhq/)
|
|
43
|
+
[](LICENSE)
|
|
44
|
+
|
|
45
|
+
Python SDK for [tej-api](https://api.tejhq.dev): open, free, end-of-day market data for NSE and BSE. No signup, no keys, no catch.
|
|
46
|
+
|
|
47
|
+
**Zero runtime dependencies.** Sync and async clients with identical surface. Returns plain `list[dict]` rows so you can feed them straight to polars, pandas, or your own code without an extra conversion step.
|
|
48
|
+
|
|
49
|
+
## Install
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install tejhq
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
That is the entire install. No `httpx`, no `aiohttp`, no `pydantic`. Just stdlib. Distribution is `tejhq`; import as `tej`.
|
|
56
|
+
|
|
57
|
+
## Quick start (sync)
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from tej import Client
|
|
61
|
+
|
|
62
|
+
c = Client()
|
|
63
|
+
|
|
64
|
+
# Last 90 days of RELIANCE (default range)
|
|
65
|
+
rows = c.ohlcv("RELIANCE", "nse")
|
|
66
|
+
print(rows[-1])
|
|
67
|
+
# {'date': '2026-06-05', 'open': 1442.0, 'high': 1455.7, ...}
|
|
68
|
+
|
|
69
|
+
# Full history since 2010
|
|
70
|
+
rows = c.ohlcv("RELIANCE", "nse", "2010-01-04", "2026-06-05")
|
|
71
|
+
print(len(rows)) # ~4040
|
|
72
|
+
|
|
73
|
+
# Full-market snapshot for one trading date
|
|
74
|
+
snap = c.snapshot("nse", "2025-05-28")
|
|
75
|
+
print(len(snap)) # ~2700 symbols
|
|
76
|
+
|
|
77
|
+
# Corporate actions (dividends, splits, bonuses)
|
|
78
|
+
acts = c.actions("RELIANCE")
|
|
79
|
+
print(acts[0])
|
|
80
|
+
# {'exchange': 'NSE', 'type': 'dividend', 'ex_date': '2024-10-28', 'cash_amount': 10.0, ...}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Quick start (async)
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
import asyncio
|
|
87
|
+
from tej import AsyncClient
|
|
88
|
+
|
|
89
|
+
async def main():
|
|
90
|
+
async with AsyncClient() as c:
|
|
91
|
+
# Fan out 10 symbols concurrently
|
|
92
|
+
symbols = ["RELIANCE", "TCS", "INFY", "HDFCBANK", "ICICIBANK",
|
|
93
|
+
"SBIN", "HINDUNILVR", "ITC", "LT", "AXISBANK"]
|
|
94
|
+
results = await asyncio.gather(*[c.ohlcv(s, "nse") for s in symbols])
|
|
95
|
+
for sym, rows in zip(symbols, results):
|
|
96
|
+
print(sym, len(rows))
|
|
97
|
+
|
|
98
|
+
asyncio.run(main())
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Use with polars / pandas
|
|
102
|
+
|
|
103
|
+
`tej` returns `list[dict]` so you bring your own dataframe library.
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
import polars as pl
|
|
107
|
+
from tej import Client
|
|
108
|
+
|
|
109
|
+
rows = Client().ohlcv("RELIANCE", "nse", "2010-01-04", "2026-06-05")
|
|
110
|
+
df = pl.DataFrame(rows)
|
|
111
|
+
df = df.with_columns(pl.col("date").str.to_date())
|
|
112
|
+
df.head()
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
import pandas as pd
|
|
117
|
+
from tej import Client
|
|
118
|
+
|
|
119
|
+
rows = Client().ohlcv("RELIANCE", "nse", "2025-01-01", "2025-06-01")
|
|
120
|
+
df = pd.DataFrame(rows)
|
|
121
|
+
df["date"] = pd.to_datetime(df["date"])
|
|
122
|
+
df.set_index("date", inplace=True)
|
|
123
|
+
df["close"].plot()
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Optional extras install the dataframe library alongside the SDK:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
pip install "tejhq[polars]"
|
|
130
|
+
pip install "tejhq[pandas]"
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Free key endpoints
|
|
134
|
+
|
|
135
|
+
Get a key at [tejhq.dev/keys](https://tejhq.dev/keys), no card, no password.
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
from tej import Client
|
|
139
|
+
|
|
140
|
+
c = Client(api_key="tej_live_...")
|
|
141
|
+
|
|
142
|
+
# Back-adjusted prices, continuous through splits, bonuses and dividends
|
|
143
|
+
adj = c.adjusted("RELIANCE", "nse", "2024-10-01", "2024-11-30")
|
|
144
|
+
print(adj[-1]["adj_close"], adj[-1]["adj_factor_cumulative"])
|
|
145
|
+
|
|
146
|
+
# Symbol history by ISIN or symbol
|
|
147
|
+
c.symbols("nse", isin="INE040A01034")
|
|
148
|
+
c.symbols("nse", symbol="HDFCBANK")
|
|
149
|
+
|
|
150
|
+
# Account, keys, today's usage
|
|
151
|
+
c.me()
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Pro endpoints
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
c = Client(api_key="tej_live_...") # a key on the Pro tier
|
|
158
|
+
|
|
159
|
+
# Derived metrics per day: returns, 52w high/low, average volume and turnover
|
|
160
|
+
m = c.metrics("RELIANCE", "nse", "2026-01-01")
|
|
161
|
+
|
|
162
|
+
# Point-in-time liquidity universe, survivorship-bias-free
|
|
163
|
+
members = c.universe("liquid500", "nse", as_of="2019-03-15")
|
|
164
|
+
symbols = [x["symbol"] for x in members]
|
|
165
|
+
|
|
166
|
+
# Up to 50 symbols in one request, dict keyed by symbol
|
|
167
|
+
bars = c.batch(["RELIANCE", "TCS", "INFY"], "nse", "2026-01-01")
|
|
168
|
+
|
|
169
|
+
# Free text to symbol
|
|
170
|
+
c.resolve("tata motors", exchange="nse", limit=3)
|
|
171
|
+
c.resolve("zomato") # former ticker resolves to the current one
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## API reference
|
|
175
|
+
|
|
176
|
+
| Method | Endpoint | Tier | Returns |
|
|
177
|
+
| --- | --- | --- | --- |
|
|
178
|
+
| `c.ohlcv(symbol, exchange, from_=None, to=None)` | `GET /v1/ohlcv/{exchange}/{symbol}` | keyless | `list[OHLCV]` |
|
|
179
|
+
| `c.snapshot(exchange, date)` | `GET /v1/snapshot/{exchange}?date=` | keyless | `list[SnapshotRow]` |
|
|
180
|
+
| `c.actions(symbol)` | `GET /v1/actions/{symbol}` | keyless | `list[Action]` |
|
|
181
|
+
| `c.adjusted(symbol, exchange, from_=None, to=None)` | `GET /v1/adjusted/{exchange}/{symbol}` | free key | `list[AdjustedRow]` |
|
|
182
|
+
| `c.symbols(exchange, symbol=None, isin=None)` | `GET /v1/symbols/{exchange}` | free key | `list[SymbolInterval]` |
|
|
183
|
+
| `c.me()` | `GET /v1/me` | free key | `dict` |
|
|
184
|
+
| `c.metrics(symbol, exchange, from_=None, to=None)` | `GET /v1/metrics/{exchange}/{symbol}` | pro | `list[MetricsRow]` |
|
|
185
|
+
| `c.universe(name, exchange="nse", as_of=None)` | `GET /v1/universe/{name}` | pro | `list[UniverseMember]` |
|
|
186
|
+
| `c.batch(symbols, exchange="nse", from_=None, to=None)` | `GET /v1/batch` | pro | `dict[str, list[OHLCV]]` |
|
|
187
|
+
| `c.resolve(q, exchange="both", limit=5)` | `GET /v1/resolve` | pro | `list[ResolveHit]` |
|
|
188
|
+
| `c.health()`, `c.ready()` | `GET /health`, `GET /ready` | keyless | `dict` |
|
|
189
|
+
|
|
190
|
+
Need the response envelope (with `meta`)? Use `c.ohlcv_envelope(...)`, which returns the raw `{"data": [...], "meta": {...}}` dict.
|
|
191
|
+
|
|
192
|
+
`AsyncClient` exposes the same surface with `await`.
|
|
193
|
+
|
|
194
|
+
## Configuration
|
|
195
|
+
|
|
196
|
+
```python
|
|
197
|
+
from tej import Client
|
|
198
|
+
|
|
199
|
+
c = Client(
|
|
200
|
+
base_url="https://api.tejhq.dev", # override for self-hosted or staging
|
|
201
|
+
api_key=None, # tej_live_... from https://tejhq.dev/keys; keyless works for ohlcv, snapshot, actions
|
|
202
|
+
timeout=30.0, # seconds
|
|
203
|
+
max_retries=3, # exponential backoff on 5xx/429/network
|
|
204
|
+
user_agent_suffix="my-app/1.0", # for attribution
|
|
205
|
+
default_headers={"X-My-Header": "hi"},
|
|
206
|
+
)
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## Errors
|
|
210
|
+
|
|
211
|
+
All errors inherit from `tej.TejError`. The specific subclass tells you what happened:
|
|
212
|
+
|
|
213
|
+
| Exception | When |
|
|
214
|
+
| --- | --- |
|
|
215
|
+
| `BadRequestError` | HTTP 400, bad path or query parameter (also raised locally on invalid args before the request goes out, as a plain `ValueError`) |
|
|
216
|
+
| `AuthError` | HTTP 401, `key_required` when no key was sent to a gated endpoint, `invalid_key` when the key is malformed, unknown, or revoked |
|
|
217
|
+
| `NotFoundError` | HTTP 404 |
|
|
218
|
+
| `ProRequiredError` | HTTP 402, the key's tier is too low for this endpoint (`/v1/universe`, `/v1/metrics` need Pro) |
|
|
219
|
+
| `TejError` with `error_code == "key_required"` | HTTP 401, the endpoint needs a key and none was sent (`/v1/adjusted`, `/v1/symbols`) |
|
|
220
|
+
| `TejError` with `error_code == "invalid_key"` | HTTP 401, the key is malformed, unknown, or revoked |
|
|
221
|
+
| `RateLimitError` | HTTP 429 |
|
|
222
|
+
| `ServerError` | HTTP 5xx |
|
|
223
|
+
| `NetworkError` | DNS, connection, TLS, or timeout failure |
|
|
224
|
+
| `TejError` | Anything else |
|
|
225
|
+
|
|
226
|
+
```python
|
|
227
|
+
from tej import Client, ProRequiredError
|
|
228
|
+
|
|
229
|
+
try:
|
|
230
|
+
Client().ohlcv("RELIANCE", "nse")
|
|
231
|
+
except ProRequiredError as e:
|
|
232
|
+
print(e.error_code) # 'pro_required'
|
|
233
|
+
print(e.request_id) # tej-api request id from response headers
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
## Why a separate package
|
|
237
|
+
|
|
238
|
+
[tej-api](https://github.com/tejhq/tej-api) is a Go service. [tej-bazaar](https://github.com/tejhq/tej-bazaar) is the Python ingestion pipeline. This repo is the thin client that anyone can `pip install` without dragging in either, and that releases on its own cadence as the API evolves.
|
|
239
|
+
|
|
240
|
+
## Coverage
|
|
241
|
+
|
|
242
|
+
- **NSE bhavcopy + corp actions**: 2010-01-04 to today, ~4,047 trading days, ~7M rows
|
|
243
|
+
- **BSE bhavcopy + corp actions**: 2024-07-08 to today (the SEBI CMTS cutover), ~470 days, ~1M rows
|
|
244
|
+
- **Cron refresh**: weekdays 20:00 IST
|
|
245
|
+
|
|
246
|
+
Keyless endpoints are served at the Cloudflare edge from pre-rendered JSON, so most requests return in well under a second and repeat requests are cache hits. Keyless access is rate limited to 100 requests per 10 seconds per IP at the edge and 120 per minute at origin; the SDK retries 429s with backoff. Keyed requests bypass the edge cache and carry `X-RateLimit-Limit-Day` and `X-RateLimit-Remaining-Day` headers: 1,000 a day and 300 a minute on a free key.
|
|
247
|
+
|
|
248
|
+
## License
|
|
249
|
+
|
|
250
|
+
MIT. Use it, fork it, ship products on top of it.
|
|
251
|
+
|
|
252
|
+
## Links
|
|
253
|
+
|
|
254
|
+
- API docs: [tejhq.dev/docs](https://tejhq.dev/docs)
|
|
255
|
+
- OpenAPI spec: [api.tejhq.dev/openapi.yaml](https://api.tejhq.dev/openapi.yaml)
|
|
256
|
+
- Bulk parquet downloads: [data.tejhq.dev](https://data.tejhq.dev) and [huggingface.co/datasets/tejhq/indian-markets](https://huggingface.co/datasets/tejhq/indian-markets)
|
|
257
|
+
- Issues: [github.com/tejhq/tej-sdk-py/issues](https://github.com/tejhq/tej-sdk-py/issues)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
tej/__init__.py,sha256=cvXBc4C47jM40881gb6Pn7YRNuNXoY7-_Zre7ZTINGM,1368
|
|
2
|
+
tej/_http.py,sha256=eZwE0DikjVdoGV55TWWNLk84P_F-1DzTdAMjK9BeA5Q,6881
|
|
3
|
+
tej/_version.py,sha256=Zn1KFblwuFHiDRdRAiRnDBRkbPttWh44jKa5zG2ov0E,22
|
|
4
|
+
tej/async_client.py,sha256=DgbLzP5LfA1wnL3kJp5ImUFm45lU1wZq7LN9tzdldNw,10636
|
|
5
|
+
tej/client.py,sha256=wcYUBq1LCgi8bSYjBMTIjrFAsf5wUPgCBc-zdbe6C2w,11078
|
|
6
|
+
tej/exceptions.py,sha256=wgeBni_NcYq-jfxfb2vGsrpXLp4E2_CevRB85nrDg74,2068
|
|
7
|
+
tej/models.py,sha256=BxHjmUVmAmMdps5HMJiG5qv-MiWVYCKIWQUYJNvidGI,2770
|
|
8
|
+
tej/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
tejhq-0.2.0.dist-info/METADATA,sha256=ltpz7LtQEKMDzbqneSuKtfv8PlI2x7h-fSvo4GJ9sI0,9985
|
|
10
|
+
tejhq-0.2.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
11
|
+
tejhq-0.2.0.dist-info/licenses/LICENSE,sha256=GqzYB0aqsY1UGrHmfen4ZuybTbp_mYRF9Ii0gyeTEPs,1062
|
|
12
|
+
tejhq-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 TejHQ
|
|
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.
|