tidata 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tidata-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,87 @@
1
+ Metadata-Version: 2.4
2
+ Name: tidata
3
+ Version: 0.1.0
4
+ Summary: Python client for the TradeInsight API — yfinance-compatible Ticker.history()
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/TradeInsight-Info/tidata
7
+ Project-URL: Bug Tracker, https://github.com/TradeInsight-Info/tidata/issues
8
+ Requires-Python: >=3.9
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: requests>=2.28
11
+ Requires-Dist: pandas>=1.5
12
+ Provides-Extra: test
13
+ Requires-Dist: pytest>=7; extra == "test"
14
+ Requires-Dist: pytest-cov; extra == "test"
15
+ Requires-Dist: responses>=0.23; extra == "test"
16
+
17
+ # trading-data-py
18
+
19
+ Python client for the [TradeInsight](https://tradeinsight.info) Trading Data Service API.
20
+ Provides a `Ticker` class with a `history()` method that returns a pandas DataFrame
21
+ with yfinance-compatible column names.
22
+
23
+ ## Installation
24
+
25
+ ```bash
26
+ pip install trading-data-py
27
+ ```
28
+
29
+ Or install from source:
30
+
31
+ ```bash
32
+ git clone https://github.com/tradeinsight/trading-data-py.git
33
+ cd trading-data-py
34
+ pip install -e .
35
+ ```
36
+
37
+ ## Quick Start
38
+
39
+ Set your API key in the environment:
40
+
41
+ ```bash
42
+ export TRADING_DATA_API_KEY=your_key_here
43
+ ```
44
+
45
+ Then use the client:
46
+
47
+ ```python
48
+ from trading_data import Ticker
49
+
50
+ # API key is read from TRADING_DATA_API_KEY env var automatically
51
+ t = Ticker("AAPL")
52
+
53
+ # Adjusted prices (yfinance-compatible)
54
+ df = t.history(start="2024-01-01", end="2024-12-31")
55
+ print(df.head())
56
+ # Open High Low Close Volume Dividends Stock Splits
57
+ # Date
58
+ # 2024-01-02 184.210... 185.880... 183.430... 185.200... 79047200.0 0.0 0.0
59
+
60
+ # Raw (unadjusted) prices
61
+ df_raw = t.history(start="2024-01-01", end="2024-12-31", auto_adjust=False)
62
+ ```
63
+
64
+ ## Configuration
65
+
66
+ | Parameter | Description | Default |
67
+ |-----------|-------------|---------|
68
+ | `symbol` | Ticker symbol (e.g. `"AAPL"`) | required |
69
+ | `api_key` | API key — also reads `TRADING_DATA_API_KEY` env var | `None` |
70
+ | `base_url` | API base URL | `https://api.tradeinsight.info` |
71
+ | `timeout` | HTTP timeout in seconds | `30` |
72
+
73
+ ## Exceptions
74
+
75
+ | Exception | API error code |
76
+ |-----------|---------------|
77
+ | `TickerNotFoundError` | `TICKER_NOT_FOUND`, `INVALID_TICKER` |
78
+ | `AuthenticationError` | `UNAUTHORIZED`, `INVALID_API_KEY`, `API_KEY_REQUIRED` |
79
+ | `RateLimitError` | `RATE_LIMIT_EXCEEDED`, `TOO_MANY_REQUESTS` |
80
+ | `InvalidParameterError` | `TICKER_REQUIRED`, `INVALID_DATE`, `INVALID_PARAMETER` |
81
+ | `APIError` | Any other error code (base class) |
82
+
83
+ All exceptions inherit from `APIError` which exposes `.code` and `.message`.
84
+
85
+ ## License
86
+
87
+ MIT
tidata-0.1.0/README.md ADDED
@@ -0,0 +1,71 @@
1
+ # trading-data-py
2
+
3
+ Python client for the [TradeInsight](https://tradeinsight.info) Trading Data Service API.
4
+ Provides a `Ticker` class with a `history()` method that returns a pandas DataFrame
5
+ with yfinance-compatible column names.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pip install trading-data-py
11
+ ```
12
+
13
+ Or install from source:
14
+
15
+ ```bash
16
+ git clone https://github.com/tradeinsight/trading-data-py.git
17
+ cd trading-data-py
18
+ pip install -e .
19
+ ```
20
+
21
+ ## Quick Start
22
+
23
+ Set your API key in the environment:
24
+
25
+ ```bash
26
+ export TRADING_DATA_API_KEY=your_key_here
27
+ ```
28
+
29
+ Then use the client:
30
+
31
+ ```python
32
+ from trading_data import Ticker
33
+
34
+ # API key is read from TRADING_DATA_API_KEY env var automatically
35
+ t = Ticker("AAPL")
36
+
37
+ # Adjusted prices (yfinance-compatible)
38
+ df = t.history(start="2024-01-01", end="2024-12-31")
39
+ print(df.head())
40
+ # Open High Low Close Volume Dividends Stock Splits
41
+ # Date
42
+ # 2024-01-02 184.210... 185.880... 183.430... 185.200... 79047200.0 0.0 0.0
43
+
44
+ # Raw (unadjusted) prices
45
+ df_raw = t.history(start="2024-01-01", end="2024-12-31", auto_adjust=False)
46
+ ```
47
+
48
+ ## Configuration
49
+
50
+ | Parameter | Description | Default |
51
+ |-----------|-------------|---------|
52
+ | `symbol` | Ticker symbol (e.g. `"AAPL"`) | required |
53
+ | `api_key` | API key — also reads `TRADING_DATA_API_KEY` env var | `None` |
54
+ | `base_url` | API base URL | `https://api.tradeinsight.info` |
55
+ | `timeout` | HTTP timeout in seconds | `30` |
56
+
57
+ ## Exceptions
58
+
59
+ | Exception | API error code |
60
+ |-----------|---------------|
61
+ | `TickerNotFoundError` | `TICKER_NOT_FOUND`, `INVALID_TICKER` |
62
+ | `AuthenticationError` | `UNAUTHORIZED`, `INVALID_API_KEY`, `API_KEY_REQUIRED` |
63
+ | `RateLimitError` | `RATE_LIMIT_EXCEEDED`, `TOO_MANY_REQUESTS` |
64
+ | `InvalidParameterError` | `TICKER_REQUIRED`, `INVALID_DATE`, `INVALID_PARAMETER` |
65
+ | `APIError` | Any other error code (base class) |
66
+
67
+ All exceptions inherit from `APIError` which exposes `.code` and `.message`.
68
+
69
+ ## License
70
+
71
+ MIT
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "tidata"
7
+ version = "0.1.0"
8
+ description = "Python client for the TradeInsight API — yfinance-compatible Ticker.history()"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.9"
12
+ dependencies = [
13
+ "requests>=2.28",
14
+ "pandas>=1.5",
15
+ ]
16
+
17
+ [project.urls]
18
+ Homepage = "https://github.com/TradeInsight-Info/tidata"
19
+ "Bug Tracker" = "https://github.com/TradeInsight-Info/tidata/issues"
20
+
21
+ [project.optional-dependencies]
22
+ test = ["pytest>=7", "pytest-cov", "responses>=0.23"]
23
+
24
+ [tool.setuptools.packages.find]
25
+ where = ["."]
26
+ include = ["tidata*"]
tidata-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,488 @@
1
+ """Pytest suite for tidata.tifinance.Ticker.history()."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import pandas as pd
8
+ import pytest
9
+ import responses as resp_lib
10
+
11
+ from tidata.tifinance import Ticker
12
+ from tidata.tifinance.exceptions import (
13
+ APIError,
14
+ AuthenticationError,
15
+ InvalidParameterError,
16
+ RateLimitError,
17
+ TickerNotFoundError,
18
+ )
19
+
20
+ BASE_URL = "https://api.tradeinsight.info/trading-data/v1"
21
+ OHLC_URL = f"{BASE_URL}/ohlc"
22
+
23
+ # Dummy key used only in tests — never a real credential
24
+ _TEST_API_KEY = "test-key-for-pytest" # noqa: S105
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Fixtures / helpers
28
+ # ---------------------------------------------------------------------------
29
+
30
+ _THREE_ROWS = [
31
+ {
32
+ "date": "2024-01-02",
33
+ "open": 185.0,
34
+ "high": 188.5,
35
+ "low": 184.0,
36
+ "close": 187.0,
37
+ "adj_open": 184.5,
38
+ "adj_high": 188.0,
39
+ "adj_low": 183.8,
40
+ "adj_close": 186.5,
41
+ "volume": 60_000_000,
42
+ "adj_volume": 60_000_000,
43
+ "dividend": 0.0,
44
+ "split_ratio": 0.0,
45
+ },
46
+ {
47
+ "date": "2024-01-03",
48
+ "open": 187.0,
49
+ "high": 190.0,
50
+ "low": 185.5,
51
+ "close": 189.0,
52
+ "adj_open": 186.5,
53
+ "adj_high": 189.5,
54
+ "adj_low": 185.0,
55
+ "adj_close": 188.5,
56
+ "volume": 55_000_000,
57
+ "adj_volume": 55_000_000,
58
+ "dividend": 0.24,
59
+ "split_ratio": 0.0,
60
+ },
61
+ {
62
+ "date": "2024-01-04",
63
+ "open": 189.0,
64
+ "high": 192.0,
65
+ "low": 187.0,
66
+ "close": 191.0,
67
+ "adj_open": 188.5,
68
+ "adj_high": 191.5,
69
+ "adj_low": 186.5,
70
+ "adj_close": 190.5,
71
+ "volume": 50_000_000,
72
+ "adj_volume": 50_000_000,
73
+ "dividend": 0.0,
74
+ "split_ratio": 4.0,
75
+ },
76
+ ]
77
+
78
+
79
+ def _make_ticker(symbol: str = "AAPL") -> Ticker:
80
+ return Ticker(symbol, api_key=_TEST_API_KEY)
81
+
82
+
83
+ def _error_body(code: str, message: str = "error") -> str:
84
+ return json.dumps({"code": code, "message": message})
85
+
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # Happy-path tests
89
+ # ---------------------------------------------------------------------------
90
+
91
+
92
+ @resp_lib.activate
93
+ def test_history_happy_path_shape():
94
+ """Successful /ohlc call returns a DataFrame with the right shape."""
95
+ resp_lib.add(
96
+ resp_lib.GET,
97
+ OHLC_URL,
98
+ json={"data": _THREE_ROWS},
99
+ status=200,
100
+ )
101
+ ticker = _make_ticker()
102
+ df = ticker.history(start="2024-01-02", end="2024-01-05")
103
+
104
+ assert isinstance(df, pd.DataFrame)
105
+ assert df.shape == (3, 7) # 7 columns: Open High Low Close Volume Dividends Stock Splits
106
+
107
+
108
+ @resp_lib.activate
109
+ def test_history_happy_path_index():
110
+ """Index is DatetimeIndex named 'Date', sorted ascending."""
111
+ resp_lib.add(resp_lib.GET, OHLC_URL, json={"data": _THREE_ROWS}, status=200)
112
+ df = _make_ticker().history(start="2024-01-02", end="2024-01-05")
113
+
114
+ assert df.index.name == "Date"
115
+ assert isinstance(df.index, pd.DatetimeIndex)
116
+ assert list(df.index) == sorted(df.index)
117
+
118
+
119
+ @resp_lib.activate
120
+ def test_history_happy_path_values():
121
+ """Spot-check a numeric cell value for the adjusted close."""
122
+ resp_lib.add(resp_lib.GET, OHLC_URL, json={"data": _THREE_ROWS}, status=200)
123
+ df = _make_ticker().history(start="2024-01-02", end="2024-01-05")
124
+
125
+ assert df.loc[pd.Timestamp("2024-01-02"), "Close"] == pytest.approx(186.5)
126
+ assert df.loc[pd.Timestamp("2024-01-02"), "Volume"] == 60_000_000
127
+
128
+
129
+ @resp_lib.activate
130
+ def test_history_top_level_list_response():
131
+ """API may return a bare list (not wrapped in {"data": ...})."""
132
+ resp_lib.add(resp_lib.GET, OHLC_URL, json=_THREE_ROWS, status=200)
133
+ df = _make_ticker().history(start="2024-01-02", end="2024-01-05")
134
+ assert df.shape[0] == 3
135
+
136
+
137
+ # ---------------------------------------------------------------------------
138
+ # auto_adjust=True (default)
139
+ # ---------------------------------------------------------------------------
140
+
141
+
142
+ @resp_lib.activate
143
+ def test_auto_adjust_true_columns():
144
+ """auto_adjust=True uses adj_* fields → yfinance column names."""
145
+ resp_lib.add(resp_lib.GET, OHLC_URL, json={"data": _THREE_ROWS}, status=200)
146
+ df = _make_ticker().history(start="2024-01-02", end="2024-01-05", auto_adjust=True)
147
+
148
+ assert set(df.columns) == {"Open", "High", "Low", "Close", "Volume", "Dividends", "Stock Splits"}
149
+
150
+
151
+ @resp_lib.activate
152
+ def test_auto_adjust_true_uses_adj_values():
153
+ """With auto_adjust=True, 'Close' holds adj_close, not raw close."""
154
+ resp_lib.add(resp_lib.GET, OHLC_URL, json={"data": _THREE_ROWS}, status=200)
155
+ df = _make_ticker().history(start="2024-01-02", end="2024-01-05", auto_adjust=True)
156
+
157
+ # adj_close for 2024-01-02 is 186.5; raw close is 187.0
158
+ assert df.loc[pd.Timestamp("2024-01-02"), "Close"] == pytest.approx(186.5)
159
+
160
+
161
+ # ---------------------------------------------------------------------------
162
+ # auto_adjust=False
163
+ # ---------------------------------------------------------------------------
164
+
165
+
166
+ @resp_lib.activate
167
+ def test_auto_adjust_false_columns():
168
+ """auto_adjust=False uses raw open/high/low/close fields."""
169
+ resp_lib.add(resp_lib.GET, OHLC_URL, json={"data": _THREE_ROWS}, status=200)
170
+ df = _make_ticker().history(start="2024-01-02", end="2024-01-05", auto_adjust=False)
171
+
172
+ assert set(df.columns) == {"Open", "High", "Low", "Close", "Volume", "Dividends", "Stock Splits"}
173
+
174
+
175
+ @resp_lib.activate
176
+ def test_auto_adjust_false_uses_raw_values():
177
+ """With auto_adjust=False, 'Close' holds the raw close, not adj_close."""
178
+ resp_lib.add(resp_lib.GET, OHLC_URL, json={"data": _THREE_ROWS}, status=200)
179
+ df = _make_ticker().history(start="2024-01-02", end="2024-01-05", auto_adjust=False)
180
+
181
+ # raw close for 2024-01-02 is 187.0; adj_close is 186.5
182
+ assert df.loc[pd.Timestamp("2024-01-02"), "Close"] == pytest.approx(187.0)
183
+
184
+
185
+ # ---------------------------------------------------------------------------
186
+ # Dividends and Stock Splits
187
+ # ---------------------------------------------------------------------------
188
+
189
+
190
+ @resp_lib.activate
191
+ def test_dividends_column_present_and_correct():
192
+ """'Dividends' column is populated from the 'dividend' field."""
193
+ resp_lib.add(resp_lib.GET, OHLC_URL, json={"data": _THREE_ROWS}, status=200)
194
+ df = _make_ticker().history(start="2024-01-02", end="2024-01-05")
195
+
196
+ assert "Dividends" in df.columns
197
+ assert df.loc[pd.Timestamp("2024-01-03"), "Dividends"] == pytest.approx(0.24)
198
+ assert df.loc[pd.Timestamp("2024-01-02"), "Dividends"] == pytest.approx(0.0)
199
+
200
+
201
+ @resp_lib.activate
202
+ def test_stock_splits_column_present_and_correct():
203
+ """'Stock Splits' column is populated from the 'split_ratio' field."""
204
+ resp_lib.add(resp_lib.GET, OHLC_URL, json={"data": _THREE_ROWS}, status=200)
205
+ df = _make_ticker().history(start="2024-01-02", end="2024-01-05")
206
+
207
+ assert "Stock Splits" in df.columns
208
+ assert df.loc[pd.Timestamp("2024-01-04"), "Stock Splits"] == pytest.approx(4.0)
209
+ assert df.loc[pd.Timestamp("2024-01-02"), "Stock Splits"] == pytest.approx(0.0)
210
+
211
+
212
+ # ---------------------------------------------------------------------------
213
+ # Empty response
214
+ # ---------------------------------------------------------------------------
215
+
216
+
217
+ @resp_lib.activate
218
+ def test_history_empty_response():
219
+ """Empty data list returns an empty DataFrame with the canonical schema."""
220
+ resp_lib.add(resp_lib.GET, OHLC_URL, json={"data": []}, status=200)
221
+ df = _make_ticker().history(start="2024-01-02", end="2024-01-05")
222
+
223
+ assert isinstance(df, pd.DataFrame)
224
+ assert len(df) == 0
225
+ assert set(df.columns) == {"Open", "High", "Low", "Close", "Volume", "Dividends", "Stock Splits"}
226
+
227
+
228
+ # ---------------------------------------------------------------------------
229
+ # Error-code mapping tests
230
+ # ---------------------------------------------------------------------------
231
+
232
+
233
+ @resp_lib.activate
234
+ def test_ticker_not_found_error():
235
+ """TICKER_NOT_FOUND maps to TickerNotFoundError."""
236
+ resp_lib.add(
237
+ resp_lib.GET,
238
+ OHLC_URL,
239
+ body=_error_body("TICKER_NOT_FOUND", "Ticker ZZZZ not found"),
240
+ status=404,
241
+ content_type="application/json",
242
+ )
243
+ with pytest.raises(TickerNotFoundError) as exc_info:
244
+ _make_ticker("ZZZZ").history(start="2024-01-02", end="2024-01-05")
245
+
246
+ assert exc_info.value.code == "TICKER_NOT_FOUND"
247
+
248
+
249
+ @resp_lib.activate
250
+ def test_unauthorized_error():
251
+ """UNAUTHORIZED maps to AuthenticationError."""
252
+ resp_lib.add(
253
+ resp_lib.GET,
254
+ OHLC_URL,
255
+ body=_error_body("UNAUTHORIZED", "Invalid API key"),
256
+ status=401,
257
+ content_type="application/json",
258
+ )
259
+ with pytest.raises(AuthenticationError) as exc_info:
260
+ _make_ticker().history(start="2024-01-02", end="2024-01-05")
261
+
262
+ assert exc_info.value.code == "UNAUTHORIZED"
263
+
264
+
265
+ @resp_lib.activate
266
+ def test_rate_limit_exceeded_error():
267
+ """RATE_LIMIT_EXCEEDED maps to RateLimitError."""
268
+ resp_lib.add(
269
+ resp_lib.GET,
270
+ OHLC_URL,
271
+ body=_error_body("RATE_LIMIT_EXCEEDED", "Too many requests"),
272
+ status=429,
273
+ content_type="application/json",
274
+ )
275
+ with pytest.raises(RateLimitError) as exc_info:
276
+ _make_ticker().history(start="2024-01-02", end="2024-01-05")
277
+
278
+ assert exc_info.value.code == "RATE_LIMIT_EXCEEDED"
279
+
280
+
281
+ @resp_lib.activate
282
+ def test_ticker_required_error():
283
+ """TICKER_REQUIRED maps to InvalidParameterError."""
284
+ resp_lib.add(
285
+ resp_lib.GET,
286
+ OHLC_URL,
287
+ body=_error_body("TICKER_REQUIRED", "Ticker is required"),
288
+ status=400,
289
+ content_type="application/json",
290
+ )
291
+ with pytest.raises(InvalidParameterError) as exc_info:
292
+ _make_ticker().history(start="2024-01-02", end="2024-01-05")
293
+
294
+ assert exc_info.value.code == "TICKER_REQUIRED"
295
+
296
+
297
+ @resp_lib.activate
298
+ def test_unknown_error_code_raises_api_error():
299
+ """An unrecognised error code falls back to the base APIError."""
300
+ resp_lib.add(
301
+ resp_lib.GET,
302
+ OHLC_URL,
303
+ body=_error_body("SOME_WEIRD_CODE", "Something went wrong"),
304
+ status=500,
305
+ content_type="application/json",
306
+ )
307
+ with pytest.raises(APIError) as exc_info:
308
+ _make_ticker().history(start="2024-01-02", end="2024-01-05")
309
+
310
+ # Must be base APIError, not a subclass
311
+ assert type(exc_info.value) is APIError
312
+ assert exc_info.value.code == "SOME_WEIRD_CODE"
313
+
314
+
315
+ @resp_lib.activate
316
+ def test_non_json_error_response():
317
+ """A non-JSON 500 body still raises APIError with an HTTP_ code."""
318
+ resp_lib.add(
319
+ resp_lib.GET,
320
+ OHLC_URL,
321
+ body="Internal Server Error",
322
+ status=500,
323
+ content_type="text/plain",
324
+ )
325
+ with pytest.raises(APIError) as exc_info:
326
+ _make_ticker().history(start="2024-01-02", end="2024-01-05")
327
+
328
+ assert exc_info.value.code.startswith("HTTP_")
329
+
330
+
331
+ # ---------------------------------------------------------------------------
332
+ # Ticker initialisation / header tests
333
+ # ---------------------------------------------------------------------------
334
+
335
+
336
+ @resp_lib.activate
337
+ def test_api_key_sent_as_header():
338
+ """The Authorization: Bearer header is included in the request."""
339
+ resp_lib.add(resp_lib.GET, OHLC_URL, json={"data": []}, status=200)
340
+ _make_ticker().history(start="2024-01-02", end="2024-01-05")
341
+
342
+ assert resp_lib.calls[0].request.headers.get("Authorization") == f"Bearer {_TEST_API_KEY}"
343
+
344
+
345
+ def test_symbol_normalised_to_uppercase():
346
+ """Symbol is normalised to uppercase on construction."""
347
+ ticker = Ticker("aapl", api_key="k")
348
+ assert ticker.symbol == "AAPL"
349
+
350
+
351
+ # ---------------------------------------------------------------------------
352
+ # period resolution
353
+ # ---------------------------------------------------------------------------
354
+
355
+ from datetime import date, timedelta
356
+
357
+
358
+ def test_period_invalid_raises():
359
+ with pytest.raises(InvalidParameterError):
360
+ _make_ticker().history(period="3y")
361
+
362
+
363
+ def test_period_all_three_raises():
364
+ with pytest.raises(ValueError, match="nonsense"):
365
+ _make_ticker().history(period="1y", start="2022-01-01", end="2023-01-01")
366
+
367
+
368
+ @resp_lib.activate
369
+ def test_period_1y_end_is_today():
370
+ resp_lib.add(resp_lib.GET, OHLC_URL, json={"data": [], "total": 0, "limit": 1000, "offset": 0}, status=200)
371
+ _make_ticker().history(period="1y")
372
+ qs = resp_lib.calls[0].request.url
373
+ assert f"end={date.today().isoformat()}" in qs
374
+
375
+
376
+ @resp_lib.activate
377
+ def test_period_1y_start_is_365_days_ago():
378
+ resp_lib.add(resp_lib.GET, OHLC_URL, json={"data": [], "total": 0, "limit": 1000, "offset": 0}, status=200)
379
+ _make_ticker().history(period="1y")
380
+ qs = resp_lib.calls[0].request.url
381
+ expected = (date.today() - timedelta(days=365)).isoformat()
382
+ assert f"start={expected}" in qs
383
+
384
+
385
+ @resp_lib.activate
386
+ def test_period_with_end_sets_start():
387
+ """period='1y', end='2023-01-01' -> start=2022-01-01, end=2022-12-31 (exclusive end)."""
388
+ resp_lib.add(resp_lib.GET, OHLC_URL, json={"data": [], "total": 0, "limit": 1000, "offset": 0}, status=200)
389
+ _make_ticker().history(period="1y", end="2023-01-01")
390
+ qs = resp_lib.calls[0].request.url
391
+ assert "start=2022-01-01" in qs
392
+ assert "end=2022-12-31" in qs
393
+
394
+
395
+ @resp_lib.activate
396
+ def test_period_with_start_sets_end():
397
+ """period='1y', start='2022-01-01' -> end=2022-12-31."""
398
+ resp_lib.add(resp_lib.GET, OHLC_URL, json={"data": [], "total": 0, "limit": 1000, "offset": 0}, status=200)
399
+ _make_ticker().history(period="1y", start="2022-01-01")
400
+ qs = resp_lib.calls[0].request.url
401
+ assert "start=2022-01-01" in qs
402
+ assert "end=2022-12-31" in qs
403
+
404
+
405
+ @resp_lib.activate
406
+ def test_period_ytd_start_is_jan_1():
407
+ resp_lib.add(resp_lib.GET, OHLC_URL, json={"data": [], "total": 0, "limit": 1000, "offset": 0}, status=200)
408
+ _make_ticker().history(period="ytd")
409
+ qs = resp_lib.calls[0].request.url
410
+ jan1 = f"{date.today().year}-01-01"
411
+ assert f"start={jan1}" in qs
412
+
413
+
414
+ @resp_lib.activate
415
+ def test_period_max_same_as_10y():
416
+ import urllib.parse
417
+ resp_lib.add(resp_lib.GET, OHLC_URL, json={"data": [], "total": 0, "limit": 1000, "offset": 0}, status=200)
418
+ _make_ticker().history(period="max")
419
+ qs_max = resp_lib.calls[0].request.url
420
+
421
+ resp_lib.add(resp_lib.GET, OHLC_URL, json={"data": [], "total": 0, "limit": 1000, "offset": 0}, status=200)
422
+ _make_ticker().history(period="10y")
423
+ qs_10y = resp_lib.calls[1].request.url
424
+
425
+ p_max = dict(urllib.parse.parse_qsl(urllib.parse.urlparse(qs_max).query))
426
+ p_10y = dict(urllib.parse.parse_qsl(urllib.parse.urlparse(qs_10y).query))
427
+ assert p_max["start"] == p_10y["start"]
428
+ assert p_max["end"] == p_10y["end"]
429
+
430
+
431
+ # ---------------------------------------------------------------------------
432
+ # interval and actions
433
+ # ---------------------------------------------------------------------------
434
+
435
+
436
+ def test_interval_non_1d_raises():
437
+ with pytest.raises(InvalidParameterError, match="interval"):
438
+ _make_ticker().history(start="2024-01-02", end="2024-01-05", interval="1wk")
439
+
440
+
441
+ @resp_lib.activate
442
+ def test_actions_false_drops_columns():
443
+ resp_lib.add(resp_lib.GET, OHLC_URL, json={"data": _THREE_ROWS, "total": 3, "limit": 1000, "offset": 0}, status=200)
444
+ df = _make_ticker().history(start="2024-01-02", end="2024-01-05", actions=False)
445
+ assert "Dividends" not in df.columns
446
+ assert "Stock Splits" not in df.columns
447
+ assert set(df.columns) == {"Open", "High", "Low", "Close", "Volume"}
448
+
449
+
450
+ # ---------------------------------------------------------------------------
451
+ # pagination
452
+ # ---------------------------------------------------------------------------
453
+
454
+
455
+ def _make_rows(n: int) -> list[dict]:
456
+ return [
457
+ {
458
+ "date": f"2020-01-{(i % 28) + 1:02d}",
459
+ "adj_open": 100.0, "adj_high": 101.0, "adj_low": 99.0,
460
+ "adj_close": 100.5, "adj_volume": 1_000_000,
461
+ "open": 100.0, "high": 101.0, "low": 99.0,
462
+ "close": 100.5, "volume": 1_000_000,
463
+ "dividend": 0.0, "split_ratio": 0.0,
464
+ }
465
+ for i in range(n)
466
+ ]
467
+
468
+
469
+ @resp_lib.activate
470
+ def test_pagination_concatenates_pages():
471
+ resp_lib.add(resp_lib.GET, OHLC_URL, json={"data": _make_rows(1000), "total": 2500, "limit": 1000, "offset": 0}, status=200)
472
+ resp_lib.add(resp_lib.GET, OHLC_URL, json={"data": _make_rows(1000), "total": 2500, "limit": 1000, "offset": 1000}, status=200)
473
+ resp_lib.add(resp_lib.GET, OHLC_URL, json={"data": _make_rows(500), "total": 2500, "limit": 1000, "offset": 2000}, status=200)
474
+
475
+ df = _make_ticker().history(start="2020-01-01", end="2027-01-01")
476
+ assert len(df) == 2500
477
+ assert len(resp_lib.calls) == 3
478
+ assert "offset=0" in resp_lib.calls[0].request.url
479
+ assert "offset=1000" in resp_lib.calls[1].request.url
480
+ assert "offset=2000" in resp_lib.calls[2].request.url
481
+
482
+
483
+ @resp_lib.activate
484
+ def test_single_page_no_extra_requests():
485
+ resp_lib.add(resp_lib.GET, OHLC_URL, json={"data": _THREE_ROWS, "total": 3, "limit": 1000, "offset": 0}, status=200)
486
+ df = _make_ticker().history(start="2024-01-02", end="2024-01-05")
487
+ assert df.shape[0] == 3
488
+ assert len(resp_lib.calls) == 1
@@ -0,0 +1 @@
1
+ """tidata — TradeInsight data library. Use: from tidata.tifinance import Ticker"""
@@ -0,0 +1,17 @@
1
+ from .ticker import Ticker
2
+ from .exceptions import (
3
+ APIError,
4
+ AuthenticationError,
5
+ InvalidParameterError,
6
+ RateLimitError,
7
+ TickerNotFoundError,
8
+ )
9
+
10
+ __all__ = [
11
+ "Ticker",
12
+ "APIError",
13
+ "AuthenticationError",
14
+ "InvalidParameterError",
15
+ "RateLimitError",
16
+ "TickerNotFoundError",
17
+ ]
@@ -0,0 +1,48 @@
1
+ """Typed exceptions for the tidata API client."""
2
+
3
+
4
+ class APIError(Exception):
5
+ """Base exception for all TradeInsight API errors."""
6
+
7
+ def __init__(self, code: str, message: str) -> None:
8
+ self.code = code
9
+ self.message = message
10
+ super().__init__(f"[{code}] {message}")
11
+
12
+
13
+ class TickerNotFoundError(APIError):
14
+ """Raised when the requested ticker symbol does not exist."""
15
+
16
+
17
+ class AuthenticationError(APIError):
18
+ """Raised when the API key is missing, invalid, or expired."""
19
+
20
+
21
+ class RateLimitError(APIError):
22
+ """Raised when the API rate limit has been exceeded."""
23
+
24
+
25
+ class InvalidParameterError(APIError):
26
+ """Raised when a required or invalid parameter is supplied."""
27
+
28
+
29
+ _CODE_MAP: dict[str, type[APIError]] = {
30
+ "TICKER_NOT_FOUND": TickerNotFoundError,
31
+ "TICKER_REQUIRED": InvalidParameterError,
32
+ "INVALID_TICKER": TickerNotFoundError,
33
+ "UNAUTHORIZED": AuthenticationError,
34
+ "FORBIDDEN": AuthenticationError,
35
+ "INVALID_API_KEY": AuthenticationError,
36
+ "API_KEY_REQUIRED": AuthenticationError,
37
+ "RATE_LIMIT_EXCEEDED": RateLimitError,
38
+ "TOO_MANY_REQUESTS": RateLimitError,
39
+ "INVALID_DATE": InvalidParameterError,
40
+ "INVALID_PARAMETER": InvalidParameterError,
41
+ "DATE_REQUIRED": InvalidParameterError,
42
+ }
43
+
44
+
45
+ def from_code(code: str, message: str) -> APIError:
46
+ """Return the most specific exception class for the given error code."""
47
+ exc_class = _CODE_MAP.get(code.upper(), APIError)
48
+ return exc_class(code, message)
@@ -0,0 +1,263 @@
1
+ """TradeInsight API client — Ticker class with yfinance-compatible history()."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from datetime import date, timedelta
7
+ from typing import Optional
8
+
9
+ import pandas as pd
10
+ import requests
11
+
12
+ from .exceptions import APIError, InvalidParameterError, from_code
13
+
14
+ _DEFAULT_BASE_URL = "https://api.tradeinsight.info/trading-data/v1"
15
+
16
+ _ADJ_COLUMN_MAP = {
17
+ "adj_open": "Open",
18
+ "adj_high": "High",
19
+ "adj_low": "Low",
20
+ "adj_close": "Close",
21
+ "adj_volume": "Volume",
22
+ }
23
+
24
+ _RAW_COLUMN_MAP = {
25
+ "open": "Open",
26
+ "high": "High",
27
+ "low": "Low",
28
+ "close": "Close",
29
+ "volume": "Volume",
30
+ }
31
+
32
+ _PERIOD_DELTAS: dict[str, timedelta] = {
33
+ "1d": timedelta(days=1),
34
+ "5d": timedelta(days=5),
35
+ "1mo": timedelta(days=30),
36
+ "3mo": timedelta(days=91),
37
+ "6mo": timedelta(days=182),
38
+ "1y": timedelta(days=365),
39
+ "2y": timedelta(days=730),
40
+ "5y": timedelta(days=1825),
41
+ "10y": timedelta(days=3650),
42
+ "max": timedelta(days=3650), # treated as 10y
43
+ }
44
+
45
+ _VALID_PERIODS = frozenset(_PERIOD_DELTAS) | {"ytd"}
46
+
47
+
48
+ def _parse_date(s: str, param_name: str) -> date:
49
+ try:
50
+ return date.fromisoformat(s)
51
+ except ValueError:
52
+ raise InvalidParameterError(
53
+ "INVALID_PARAMETER",
54
+ f"Invalid date for '{param_name}': {s!r}. Expected YYYY-MM-DD.",
55
+ )
56
+
57
+
58
+ def _resolve_dates(
59
+ period: str | None,
60
+ start: str | None,
61
+ end: str | None,
62
+ ) -> tuple[str, str]:
63
+ """Convert period/start/end to an inclusive (start, end) pair for the API.
64
+
65
+ yfinance callers pass end as exclusive when they supply it explicitly —
66
+ we subtract 1 day. Internally computed ends use today as-is (inclusive).
67
+ """
68
+ today = date.today()
69
+
70
+ if period is not None and start is not None and end is not None:
71
+ raise ValueError(
72
+ "Setting period, start and end is nonsense. Set maximum 2 of them."
73
+ )
74
+
75
+ if period is not None:
76
+ p = period.lower()
77
+ if p not in _VALID_PERIODS:
78
+ raise InvalidParameterError(
79
+ "INVALID_PARAMETER",
80
+ f"Invalid period '{period}'. Valid: {', '.join(sorted(_VALID_PERIODS))}",
81
+ )
82
+
83
+ if p == "ytd":
84
+ return date(today.year, 1, 1).isoformat(), today.isoformat()
85
+
86
+ delta = _PERIOD_DELTAS[p]
87
+
88
+ if end is not None:
89
+ end_d = _parse_date(end, "end") - timedelta(days=1) # exclusive -> inclusive
90
+ return (end_d - delta + timedelta(days=1)).isoformat(), end_d.isoformat()
91
+ elif start is not None:
92
+ start_d = _parse_date(start, "start")
93
+ end_d = min(start_d + delta - timedelta(days=1), today)
94
+ if end_d < start_d:
95
+ raise InvalidParameterError(
96
+ "INVALID_PARAMETER",
97
+ f"'start' ({start}) is in the future; no data available.",
98
+ )
99
+ return start_d.isoformat(), end_d.isoformat()
100
+ else:
101
+ return (today - delta).isoformat(), today.isoformat()
102
+
103
+ if start is None:
104
+ raise ValueError("Provide 'period' or 'start'.")
105
+
106
+ start_d = _parse_date(start, "start")
107
+ if end is None:
108
+ return start_d.isoformat(), today.isoformat()
109
+ else:
110
+ end_d = _parse_date(end, "end") - timedelta(days=1) # exclusive -> inclusive
111
+ return start_d.isoformat(), end_d.isoformat()
112
+
113
+
114
+ class Ticker:
115
+ """Client for a single ticker symbol against the TradeInsight API.
116
+
117
+ Parameters
118
+ ----------
119
+ symbol:
120
+ Ticker symbol, e.g. ``"AAPL"``.
121
+ api_key:
122
+ API key. Falls back to the ``TIDATA_API_KEY`` environment variable.
123
+ base_url:
124
+ Override the API base URL.
125
+ timeout:
126
+ HTTP request timeout in seconds (default: 30).
127
+ """
128
+
129
+ def __init__(
130
+ self,
131
+ symbol: str,
132
+ api_key: Optional[str] = None,
133
+ base_url: str = _DEFAULT_BASE_URL,
134
+ timeout: int = 30,
135
+ ) -> None:
136
+ self.symbol = symbol.upper().strip()
137
+ self.api_key: Optional[str] = api_key or os.environ.get("TIDATA_API_KEY")
138
+ self.base_url = base_url.rstrip("/")
139
+ self.timeout = timeout
140
+ self._session = requests.Session()
141
+ if self.api_key:
142
+ self._session.headers.update({"Authorization": f"Bearer {self.api_key}"})
143
+
144
+ def history(
145
+ self,
146
+ period: str | None = None,
147
+ interval: str = "1d",
148
+ start: str | None = None,
149
+ end: str | None = None,
150
+ auto_adjust: bool = True,
151
+ actions: bool = True,
152
+ **kwargs,
153
+ ) -> pd.DataFrame:
154
+ """Fetch OHLCV history for this ticker.
155
+
156
+ Parameters
157
+ ----------
158
+ period:
159
+ Shorthand time period, e.g. ``"1y"``, ``"6mo"``, ``"ytd"``, ``"max"``.
160
+ Mutually exclusive with providing both ``start`` and ``end``.
161
+ interval:
162
+ Data interval. Only ``"1d"`` is currently supported.
163
+ start:
164
+ Start date ``YYYY-MM-DD`` (inclusive).
165
+ end:
166
+ End date ``YYYY-MM-DD`` (exclusive, yfinance convention).
167
+ auto_adjust:
168
+ When ``True`` (default), return split/dividend-adjusted prices.
169
+ actions:
170
+ When ``True`` (default), include Dividends and Stock Splits columns.
171
+ """
172
+ if interval != "1d":
173
+ raise InvalidParameterError(
174
+ "INVALID_PARAMETER",
175
+ "only interval='1d' is supported",
176
+ )
177
+
178
+ effective_period = period
179
+ if effective_period is None and start is None:
180
+ effective_period = "1mo"
181
+
182
+ resolved_start, resolved_end = _resolve_dates(effective_period, start, end)
183
+
184
+ params = {
185
+ "ticker": self.symbol,
186
+ "start": resolved_start,
187
+ "end": resolved_end,
188
+ "adjust_volume": "true" if auto_adjust else "false",
189
+ }
190
+ rows = self._fetch_all(params)
191
+ return self._build_dataframe(rows, auto_adjust=auto_adjust, actions=actions)
192
+
193
+ def _fetch_all(self, params: dict) -> list:
194
+ """Paginate through all result pages and return combined rows."""
195
+ rows: list = []
196
+ offset = 0
197
+ while True:
198
+ page_params = {**params, "limit": 1000, "offset": offset}
199
+ response = self._session.get(
200
+ f"{self.base_url}/ohlc",
201
+ params=page_params,
202
+ timeout=self.timeout,
203
+ )
204
+ if not response.ok:
205
+ self._raise_for_error(response)
206
+ data = response.json()
207
+ if isinstance(data, list):
208
+ page = data
209
+ elif isinstance(data, dict) and "data" in data:
210
+ page = data["data"]
211
+ else:
212
+ page = []
213
+ rows.extend(page)
214
+ if len(page) < 1000:
215
+ break
216
+ offset += 1000
217
+ return rows
218
+
219
+ def _build_dataframe(
220
+ self, rows: list, auto_adjust: bool, actions: bool
221
+ ) -> pd.DataFrame:
222
+ if not rows:
223
+ return self._empty_dataframe()
224
+ df = pd.DataFrame(rows)
225
+ col_map = _ADJ_COLUMN_MAP if auto_adjust else _RAW_COLUMN_MAP
226
+ df = df.rename(columns=col_map)
227
+ df["Dividends"] = pd.to_numeric(
228
+ df["dividend"] if "dividend" in df.columns else pd.Series(0.0, index=df.index),
229
+ errors="coerce",
230
+ ).fillna(0.0)
231
+ df["Stock Splits"] = pd.to_numeric(
232
+ df["split_ratio"] if "split_ratio" in df.columns else pd.Series(0.0, index=df.index),
233
+ errors="coerce",
234
+ ).fillna(0.0)
235
+ keep = ["date", "Open", "High", "Low", "Close", "Volume", "Dividends", "Stock Splits"]
236
+ existing = [c for c in keep if c in df.columns]
237
+ df = df[existing].copy()
238
+ df["date"] = pd.to_datetime(df["date"])
239
+ df = df.rename(columns={"date": "Date"}).set_index("Date")
240
+ df = df.sort_index()
241
+ for col in ["Open", "High", "Low", "Close", "Volume"]:
242
+ if col in df.columns:
243
+ df[col] = pd.to_numeric(df[col], errors="coerce")
244
+ if not actions:
245
+ df = df.drop(columns=["Dividends", "Stock Splits"], errors="ignore")
246
+ return df
247
+
248
+ def _raise_for_error(self, response: requests.Response) -> None:
249
+ try:
250
+ body = response.json()
251
+ code = body.get("code", f"HTTP_{response.status_code}")
252
+ message = body.get("message", response.text or "Unknown error")
253
+ except Exception:
254
+ code = f"HTTP_{response.status_code}"
255
+ message = response.text or "Unknown error"
256
+ raise from_code(code, message)
257
+
258
+ @staticmethod
259
+ def _empty_dataframe() -> pd.DataFrame:
260
+ return pd.DataFrame(
261
+ columns=["Open", "High", "Low", "Close", "Volume", "Dividends", "Stock Splits"],
262
+ index=pd.DatetimeIndex([], name="Date"),
263
+ )
@@ -0,0 +1,87 @@
1
+ Metadata-Version: 2.4
2
+ Name: tidata
3
+ Version: 0.1.0
4
+ Summary: Python client for the TradeInsight API — yfinance-compatible Ticker.history()
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/TradeInsight-Info/tidata
7
+ Project-URL: Bug Tracker, https://github.com/TradeInsight-Info/tidata/issues
8
+ Requires-Python: >=3.9
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: requests>=2.28
11
+ Requires-Dist: pandas>=1.5
12
+ Provides-Extra: test
13
+ Requires-Dist: pytest>=7; extra == "test"
14
+ Requires-Dist: pytest-cov; extra == "test"
15
+ Requires-Dist: responses>=0.23; extra == "test"
16
+
17
+ # trading-data-py
18
+
19
+ Python client for the [TradeInsight](https://tradeinsight.info) Trading Data Service API.
20
+ Provides a `Ticker` class with a `history()` method that returns a pandas DataFrame
21
+ with yfinance-compatible column names.
22
+
23
+ ## Installation
24
+
25
+ ```bash
26
+ pip install trading-data-py
27
+ ```
28
+
29
+ Or install from source:
30
+
31
+ ```bash
32
+ git clone https://github.com/tradeinsight/trading-data-py.git
33
+ cd trading-data-py
34
+ pip install -e .
35
+ ```
36
+
37
+ ## Quick Start
38
+
39
+ Set your API key in the environment:
40
+
41
+ ```bash
42
+ export TRADING_DATA_API_KEY=your_key_here
43
+ ```
44
+
45
+ Then use the client:
46
+
47
+ ```python
48
+ from trading_data import Ticker
49
+
50
+ # API key is read from TRADING_DATA_API_KEY env var automatically
51
+ t = Ticker("AAPL")
52
+
53
+ # Adjusted prices (yfinance-compatible)
54
+ df = t.history(start="2024-01-01", end="2024-12-31")
55
+ print(df.head())
56
+ # Open High Low Close Volume Dividends Stock Splits
57
+ # Date
58
+ # 2024-01-02 184.210... 185.880... 183.430... 185.200... 79047200.0 0.0 0.0
59
+
60
+ # Raw (unadjusted) prices
61
+ df_raw = t.history(start="2024-01-01", end="2024-12-31", auto_adjust=False)
62
+ ```
63
+
64
+ ## Configuration
65
+
66
+ | Parameter | Description | Default |
67
+ |-----------|-------------|---------|
68
+ | `symbol` | Ticker symbol (e.g. `"AAPL"`) | required |
69
+ | `api_key` | API key — also reads `TRADING_DATA_API_KEY` env var | `None` |
70
+ | `base_url` | API base URL | `https://api.tradeinsight.info` |
71
+ | `timeout` | HTTP timeout in seconds | `30` |
72
+
73
+ ## Exceptions
74
+
75
+ | Exception | API error code |
76
+ |-----------|---------------|
77
+ | `TickerNotFoundError` | `TICKER_NOT_FOUND`, `INVALID_TICKER` |
78
+ | `AuthenticationError` | `UNAUTHORIZED`, `INVALID_API_KEY`, `API_KEY_REQUIRED` |
79
+ | `RateLimitError` | `RATE_LIMIT_EXCEEDED`, `TOO_MANY_REQUESTS` |
80
+ | `InvalidParameterError` | `TICKER_REQUIRED`, `INVALID_DATE`, `INVALID_PARAMETER` |
81
+ | `APIError` | Any other error code (base class) |
82
+
83
+ All exceptions inherit from `APIError` which exposes `.code` and `.message`.
84
+
85
+ ## License
86
+
87
+ MIT
@@ -0,0 +1,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ tests/test_ticker.py
4
+ tidata/__init__.py
5
+ tidata.egg-info/PKG-INFO
6
+ tidata.egg-info/SOURCES.txt
7
+ tidata.egg-info/dependency_links.txt
8
+ tidata.egg-info/requires.txt
9
+ tidata.egg-info/top_level.txt
10
+ tidata/tifinance/__init__.py
11
+ tidata/tifinance/exceptions.py
12
+ tidata/tifinance/ticker.py
@@ -0,0 +1,7 @@
1
+ requests>=2.28
2
+ pandas>=1.5
3
+
4
+ [test]
5
+ pytest>=7
6
+ pytest-cov
7
+ responses>=0.23
@@ -0,0 +1 @@
1
+ tidata