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 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
+ [![PyPI](https://img.shields.io/pypi/v/tejhq.svg)](https://pypi.org/project/tejhq/)
42
+ [![Python](https://img.shields.io/pypi/pyversions/tejhq.svg)](https://pypi.org/project/tejhq/)
43
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.