tickerdb 0.1.1__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.
tickerdb/__init__.py ADDED
@@ -0,0 +1,64 @@
1
+ """TickerDB Python SDK - Financial data at your fingertips.
2
+
3
+ Usage::
4
+
5
+ from tickerdb import TickerDB
6
+
7
+ client = TickerDB("your_api_key")
8
+ result = client.summary("AAPL")
9
+
10
+ For async usage::
11
+
12
+ from tickerdb import AsyncTickerDB
13
+
14
+ async with AsyncTickerDB("your_api_key") as client:
15
+ result = await client.summary("AAPL")
16
+ """
17
+
18
+ from .async_client import AsyncSearchQuery, AsyncTickerDB
19
+ from .client import SearchQuery, TickerDB
20
+ from .exceptions import (
21
+ AuthenticationError,
22
+ DataUnavailableError,
23
+ ForbiddenError,
24
+ NotFoundError,
25
+ RateLimitError,
26
+ TickerDBError,
27
+ )
28
+ from .types import (
29
+ APIResponse,
30
+ BandMeta,
31
+ Event,
32
+ EventsContext,
33
+ EventsResponse,
34
+ RateLimits,
35
+ SchemaResponse,
36
+ SearchParams,
37
+ SearchResponse,
38
+ Stability,
39
+ )
40
+
41
+ __all__ = [
42
+ "TickerDB",
43
+ "AsyncTickerDB",
44
+ "SearchQuery",
45
+ "AsyncSearchQuery",
46
+ "TickerDBError",
47
+ "AuthenticationError",
48
+ "ForbiddenError",
49
+ "NotFoundError",
50
+ "RateLimitError",
51
+ "DataUnavailableError",
52
+ "APIResponse",
53
+ "BandMeta",
54
+ "Event",
55
+ "EventsContext",
56
+ "EventsResponse",
57
+ "RateLimits",
58
+ "SchemaResponse",
59
+ "SearchParams",
60
+ "SearchResponse",
61
+ "Stability",
62
+ ]
63
+
64
+ __version__ = "0.1.0"
@@ -0,0 +1,502 @@
1
+ """Asynchronous TickerDB client."""
2
+
3
+ from typing import Any, Dict, List, Optional
4
+
5
+ import httpx
6
+
7
+ from .exceptions import (
8
+ AuthenticationError,
9
+ DataUnavailableError,
10
+ ForbiddenError,
11
+ NotFoundError,
12
+ RateLimitError,
13
+ TickerDBError,
14
+ )
15
+ from .types import RateLimits
16
+
17
+ _DEFAULT_BASE_URL = "https://api.tickerdb.com/v1"
18
+ _DEFAULT_TIMEOUT = 30.0
19
+
20
+
21
+ def _parse_rate_limits(headers: httpx.Headers) -> RateLimits:
22
+ """Extract rate-limit information from response headers."""
23
+
24
+ def _int_or_none(key: str) -> Optional[int]:
25
+ val = headers.get(key)
26
+ if val is None:
27
+ return None
28
+ try:
29
+ return int(val)
30
+ except (ValueError, TypeError):
31
+ return None
32
+
33
+ return RateLimits(
34
+ request_limit=_int_or_none("X-Request-Limit"),
35
+ requests_used=_int_or_none("X-Requests-Used"),
36
+ requests_remaining=_int_or_none("X-Requests-Remaining"),
37
+ request_reset=headers.get("X-Request-Reset"),
38
+ hourly_request_limit=_int_or_none("X-Hourly-Request-Limit"),
39
+ hourly_requests_used=_int_or_none("X-Hourly-Requests-Used"),
40
+ hourly_requests_remaining=_int_or_none("X-Hourly-Requests-Remaining"),
41
+ hourly_request_reset=headers.get("X-Hourly-Request-Reset"),
42
+ )
43
+
44
+
45
+ def _raise_for_status(response: httpx.Response) -> None:
46
+ """Raise a typed TickerDBError if the response indicates an error."""
47
+ if response.status_code < 400:
48
+ return
49
+
50
+ try:
51
+ body = response.json()
52
+ except Exception:
53
+ raise TickerDBError(
54
+ status_code=response.status_code,
55
+ error_type="unknown_error",
56
+ message=response.text or "Unknown error",
57
+ )
58
+
59
+ error = body.get("error", {})
60
+ error_type = error.get("type", "unknown_error")
61
+ message = error.get("message", "Unknown error")
62
+ upgrade_url = error.get("upgrade_url")
63
+ reset = error.get("reset")
64
+
65
+ kwargs: Dict[str, Any] = dict(
66
+ status_code=response.status_code,
67
+ error_type=error_type,
68
+ message=message,
69
+ upgrade_url=upgrade_url,
70
+ reset=reset,
71
+ raw=body,
72
+ )
73
+
74
+ error_cls = {
75
+ 401: AuthenticationError,
76
+ 403: ForbiddenError,
77
+ 404: NotFoundError,
78
+ 429: RateLimitError,
79
+ 503: DataUnavailableError,
80
+ }.get(response.status_code, TickerDBError)
81
+
82
+ raise error_cls(**kwargs)
83
+
84
+
85
+ class AsyncSearchQuery:
86
+ """Fluent query builder for the async search endpoint.
87
+
88
+ Usage::
89
+
90
+ results = await client.query() \\
91
+ .eq("momentum_rsi_zone", "oversold") \\
92
+ .eq("sector", "Technology") \\
93
+ .select("ticker", "sector", "momentum_rsi_zone") \\
94
+ .sort("extremes_condition_percentile", "asc") \\
95
+ .limit(10) \\
96
+ .execute()
97
+ """
98
+
99
+ def __init__(self, client: "AsyncTickerDB") -> None:
100
+ self._client = client
101
+ self._filters: list = []
102
+ self._fields: Optional[List[str]] = None
103
+ self._sort_by: Optional[str] = None
104
+ self._sort_direction: Optional[str] = None
105
+ self._limit: Optional[int] = None
106
+ self._offset: Optional[int] = None
107
+ self._timeframe: Optional[str] = None
108
+
109
+ def eq(self, field: str, value: Any) -> "AsyncSearchQuery":
110
+ self._filters.append({"field": field, "op": "eq", "value": value})
111
+ return self
112
+
113
+ def neq(self, field: str, value: Any) -> "AsyncSearchQuery":
114
+ self._filters.append({"field": field, "op": "neq", "value": value})
115
+ return self
116
+
117
+ def in_(self, field: str, values: list) -> "AsyncSearchQuery":
118
+ self._filters.append({"field": field, "op": "in", "value": values})
119
+ return self
120
+
121
+ def gt(self, field: str, value: Any) -> "AsyncSearchQuery":
122
+ self._filters.append({"field": field, "op": "gt", "value": value})
123
+ return self
124
+
125
+ def gte(self, field: str, value: Any) -> "AsyncSearchQuery":
126
+ self._filters.append({"field": field, "op": "gte", "value": value})
127
+ return self
128
+
129
+ def lt(self, field: str, value: Any) -> "AsyncSearchQuery":
130
+ self._filters.append({"field": field, "op": "lt", "value": value})
131
+ return self
132
+
133
+ def lte(self, field: str, value: Any) -> "AsyncSearchQuery":
134
+ self._filters.append({"field": field, "op": "lte", "value": value})
135
+ return self
136
+
137
+ def select(self, *fields: str) -> "AsyncSearchQuery":
138
+ self._fields = list(fields)
139
+ return self
140
+
141
+ def sort(self, field: str, direction: str = "desc") -> "AsyncSearchQuery":
142
+ self._sort_by = field
143
+ self._sort_direction = direction
144
+ return self
145
+
146
+ def limit(self, n: int) -> "AsyncSearchQuery":
147
+ self._limit = n
148
+ return self
149
+
150
+ def offset(self, n: int) -> "AsyncSearchQuery":
151
+ self._offset = n
152
+ return self
153
+
154
+ def timeframe(self, tf: str) -> "AsyncSearchQuery":
155
+ self._timeframe = tf
156
+ return self
157
+
158
+ async def execute(self) -> Dict[str, Any]:
159
+ """Execute the built query and return results."""
160
+ return await self._client.search(
161
+ filters=self._filters,
162
+ fields=self._fields,
163
+ sort_by=self._sort_by,
164
+ sort_direction=self._sort_direction,
165
+ limit=self._limit,
166
+ offset=self._offset,
167
+ timeframe=self._timeframe,
168
+ )
169
+
170
+
171
+ class AsyncTickerDB:
172
+ """Asynchronous client for the TickerDB financial data API.
173
+
174
+ Args:
175
+ api_key: Your TickerDB bearer token.
176
+ base_url: Override the default API base URL.
177
+ timeout: Request timeout in seconds (default 30).
178
+ **httpx_kwargs: Additional keyword arguments forwarded to ``httpx.AsyncClient``.
179
+
180
+ Usage::
181
+
182
+ import asyncio
183
+ from tickerdb import AsyncTickerDB
184
+
185
+ async def main():
186
+ async with AsyncTickerDB("your_api_key") as client:
187
+ result = await client.summary("AAPL")
188
+ print(result["data"])
189
+
190
+ asyncio.run(main())
191
+ """
192
+
193
+ def __init__(
194
+ self,
195
+ api_key: str,
196
+ base_url: str = _DEFAULT_BASE_URL,
197
+ timeout: float = _DEFAULT_TIMEOUT,
198
+ **httpx_kwargs: Any,
199
+ ) -> None:
200
+ self._base_url = base_url.rstrip("/")
201
+ self._client = httpx.AsyncClient(
202
+ headers={
203
+ "Authorization": f"Bearer {api_key}",
204
+ "Accept": "application/json",
205
+ "User-Agent": "tickerdb-python/0.1.0",
206
+ },
207
+ timeout=timeout,
208
+ **httpx_kwargs,
209
+ )
210
+
211
+ # ------------------------------------------------------------------
212
+ # Internal helpers
213
+ # ------------------------------------------------------------------
214
+
215
+ async def _request(
216
+ self,
217
+ method: str,
218
+ path: str,
219
+ *,
220
+ params: Optional[Dict[str, Any]] = None,
221
+ json: Optional[Dict[str, Any]] = None,
222
+ ) -> Dict[str, Any]:
223
+ """Send a request and return parsed data + rate limits."""
224
+ url = f"{self._base_url}{path}"
225
+
226
+ if params:
227
+ params = {k: v for k, v in params.items() if v is not None}
228
+
229
+ response = await self._client.request(method, url, params=params, json=json)
230
+ _raise_for_status(response)
231
+
232
+ data = response.json()
233
+ rate_limits = _parse_rate_limits(response.headers)
234
+
235
+ return {"data": data, "rate_limits": rate_limits}
236
+
237
+ # ------------------------------------------------------------------
238
+ # Public API methods
239
+ # ------------------------------------------------------------------
240
+
241
+ async def summary(
242
+ self,
243
+ ticker: str,
244
+ *,
245
+ timeframe: Optional[str] = None,
246
+ date: Optional[str] = None,
247
+ start: Optional[str] = None,
248
+ end: Optional[str] = None,
249
+ field: Optional[str] = None,
250
+ band: Optional[str] = None,
251
+ limit: Optional[int] = None,
252
+ before: Optional[str] = None,
253
+ after: Optional[str] = None,
254
+ context_ticker: Optional[str] = None,
255
+ context_field: Optional[str] = None,
256
+ context_band: Optional[str] = None,
257
+ ) -> Dict[str, Any]:
258
+ """Get a summary for a single ticker.
259
+
260
+ Supports 4 modes depending on which parameters are provided:
261
+
262
+ - **Snapshot** (default): Current categorical state.
263
+ - **Historical snapshot**: Pass ``date`` for a point-in-time snapshot.
264
+ - **Historical series**: Pass ``start``/``end`` for a date range.
265
+ - **Events**: Pass ``field`` (and optionally ``band``) for band
266
+ transition history with aftermath data.
267
+
268
+ Args:
269
+ ticker: Asset ticker symbol (e.g. ``"AAPL"``).
270
+ timeframe: ``"daily"`` or ``"weekly"`` (default ``"daily"``).
271
+ date: ISO 8601 date string (``YYYY-MM-DD``) for point-in-time.
272
+ start: Range start date (``YYYY-MM-DD``). Use with ``end``.
273
+ end: Range end date (``YYYY-MM-DD``). Use with ``start``.
274
+ field: Band field name for event queries (e.g. ``"rsi_zone"``).
275
+ band: Filter to a specific band value (e.g. ``"deep_oversold"``).
276
+ limit: Max event results (1-100). Only used with ``field``.
277
+ before: Return events before this date (``YYYY-MM-DD``).
278
+ after: Return events after this date (``YYYY-MM-DD``).
279
+ context_ticker: Cross-asset correlation ticker (e.g. ``"SPY"``).
280
+ context_field: Band field on context ticker.
281
+ context_band: Required band on context ticker.
282
+
283
+ Returns:
284
+ Dict with ``data`` and ``rate_limits`` keys.
285
+ """
286
+ return await self._request(
287
+ "GET",
288
+ f"/summary/{ticker}",
289
+ params={
290
+ "timeframe": timeframe,
291
+ "date": date,
292
+ "start": start,
293
+ "end": end,
294
+ "field": field,
295
+ "band": band,
296
+ "limit": limit,
297
+ "before": before,
298
+ "after": after,
299
+ "context_ticker": context_ticker,
300
+ "context_field": context_field,
301
+ "context_band": context_band,
302
+ },
303
+ )
304
+
305
+ async def search(
306
+ self,
307
+ *,
308
+ filters: Optional[Dict[str, Any]] = None,
309
+ timeframe: Optional[str] = None,
310
+ limit: Optional[int] = None,
311
+ offset: Optional[int] = None,
312
+ fields: Optional[List[str]] = None,
313
+ sort_by: Optional[str] = None,
314
+ sort_direction: Optional[str] = None,
315
+ ) -> Dict[str, Any]:
316
+ """Search for assets matching filter criteria.
317
+
318
+ Args:
319
+ filters: Dict of filter criteria.
320
+ timeframe: ``"daily"`` or ``"weekly"``.
321
+ limit: Max results to return.
322
+ offset: Pagination offset.
323
+ fields: List of column names to return (e.g.
324
+ ``["ticker", "sector", "momentum_rsi_zone"]``).
325
+ Use ``["*"]`` for all 120+ fields. Default if omitted: ticker,
326
+ asset_class, sector, performance, trend_direction, momentum_rsi_zone,
327
+ extremes_condition, extremes_condition_rarity, volatility_regime,
328
+ volume_ratio_band, fundamentals_valuation_zone, range_position.
329
+ ``ticker`` is always included.
330
+ sort_by: Column name to sort results by. Must be a valid field
331
+ from the schema.
332
+ sort_direction: ``"asc"`` or ``"desc"`` (default ``"desc"``).
333
+
334
+ Returns:
335
+ Dict with ``data`` and ``rate_limits`` keys.
336
+ """
337
+ import json as _json
338
+
339
+ params: Dict[str, Any] = {
340
+ "timeframe": timeframe,
341
+ "limit": limit,
342
+ "offset": offset,
343
+ "sort_by": sort_by,
344
+ "sort_direction": sort_direction,
345
+ }
346
+ if filters is not None:
347
+ params["filters"] = _json.dumps(filters)
348
+ if fields is not None:
349
+ params["fields"] = _json.dumps(fields)
350
+ return await self._request("GET", "/search", params=params)
351
+
352
+ def query(self) -> AsyncSearchQuery:
353
+ """Create a fluent query builder for the search endpoint.
354
+
355
+ Usage::
356
+
357
+ results = await client.query() \\
358
+ .eq("momentum_rsi_zone", "oversold") \\
359
+ .eq("sector", "Technology") \\
360
+ .select("ticker", "sector", "momentum_rsi_zone") \\
361
+ .sort("extremes_condition_percentile", "asc") \\
362
+ .limit(10) \\
363
+ .execute()
364
+
365
+ Returns:
366
+ An :class:`AsyncSearchQuery` builder instance.
367
+ """
368
+ return AsyncSearchQuery(self)
369
+
370
+ async def schema(self) -> Dict[str, Any]:
371
+ """Get the schema of available fields and their valid band values.
372
+
373
+ Returns:
374
+ Dict with ``data`` and ``rate_limits`` keys.
375
+ """
376
+ return await self._request("GET", "/schema/fields")
377
+
378
+ async def watchlist(
379
+ self,
380
+ tickers: List[str],
381
+ *,
382
+ timeframe: Optional[str] = None,
383
+ ) -> Dict[str, Any]:
384
+ """Get watchlist data for multiple tickers.
385
+
386
+ Args:
387
+ tickers: List of ticker symbols.
388
+ timeframe: ``"daily"`` or ``"weekly"``.
389
+
390
+ Returns:
391
+ Dict with ``data`` and ``rate_limits`` keys.
392
+ """
393
+ body: Dict[str, Any] = {"tickers": tickers}
394
+ if timeframe is not None:
395
+ body["timeframe"] = timeframe
396
+ return await self._request("POST", "/watchlist", json=body)
397
+
398
+ async def watchlist_changes(
399
+ self,
400
+ *,
401
+ timeframe: Optional[str] = None,
402
+ ) -> Dict[str, Any]:
403
+ """Get field-level state changes for your saved watchlist tickers.
404
+
405
+ Returns structured diffs showing what changed since the last pipeline
406
+ run (day-over-day for daily, week-over-week for weekly). Available on
407
+ all tiers.
408
+
409
+ Args:
410
+ timeframe: ``"daily"`` or ``"weekly"``.
411
+
412
+ Returns:
413
+ Dict with ``data`` and ``rate_limits`` keys.
414
+ """
415
+ params: Dict[str, str] = {}
416
+ if timeframe is not None:
417
+ params["timeframe"] = timeframe
418
+ return await self._request("GET", "/watchlist/changes", params=params)
419
+
420
+ # ------------------------------------------------------------------
421
+ # Webhook management
422
+ # ------------------------------------------------------------------
423
+
424
+ async def list_webhooks(self) -> Dict[str, Any]:
425
+ """List all webhooks for the current account.
426
+
427
+ Returns:
428
+ Dict with ``data`` and ``rate_limits`` keys.
429
+ """
430
+ return await self._request("GET", "/webhooks")
431
+
432
+ async def create_webhook(
433
+ self,
434
+ url: str,
435
+ events: Optional[Dict[str, bool]] = None,
436
+ ) -> Dict[str, Any]:
437
+ """Create a new webhook.
438
+
439
+ Args:
440
+ url: The URL to receive webhook events.
441
+ events: Dict mapping event names to enabled booleans.
442
+
443
+ Returns:
444
+ Dict with ``data`` and ``rate_limits`` keys.
445
+ """
446
+ body: Dict[str, Any] = {"url": url}
447
+ if events is not None:
448
+ body["events"] = events
449
+ return await self._request("POST", "/webhooks", json=body)
450
+
451
+ async def update_webhook(
452
+ self,
453
+ id: str,
454
+ *,
455
+ url: Optional[str] = None,
456
+ events: Optional[Dict[str, bool]] = None,
457
+ active: Optional[bool] = None,
458
+ ) -> Dict[str, Any]:
459
+ """Update an existing webhook.
460
+
461
+ Args:
462
+ id: The webhook ID.
463
+ url: New URL for the webhook.
464
+ events: Updated event subscriptions.
465
+ active: Whether the webhook is active.
466
+
467
+ Returns:
468
+ Dict with ``data`` and ``rate_limits`` keys.
469
+ """
470
+ body: Dict[str, Any] = {"id": id}
471
+ if url is not None:
472
+ body["url"] = url
473
+ if events is not None:
474
+ body["events"] = events
475
+ if active is not None:
476
+ body["active"] = active
477
+ return await self._request("PUT", "/webhooks", json=body)
478
+
479
+ async def delete_webhook(self, id: str) -> Dict[str, Any]:
480
+ """Delete a webhook.
481
+
482
+ Args:
483
+ id: The webhook ID to delete.
484
+
485
+ Returns:
486
+ Dict with ``data`` and ``rate_limits`` keys.
487
+ """
488
+ return await self._request("DELETE", "/webhooks", json={"id": id})
489
+
490
+ # ------------------------------------------------------------------
491
+ # Context manager support
492
+ # ------------------------------------------------------------------
493
+
494
+ async def close(self) -> None:
495
+ """Close the underlying HTTP client."""
496
+ await self._client.aclose()
497
+
498
+ async def __aenter__(self) -> "AsyncTickerDB":
499
+ return self
500
+
501
+ async def __aexit__(self, *args: Any) -> None:
502
+ await self.close()