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 +87 -0
- tidata-0.1.0/README.md +71 -0
- tidata-0.1.0/pyproject.toml +26 -0
- tidata-0.1.0/setup.cfg +4 -0
- tidata-0.1.0/tests/test_ticker.py +488 -0
- tidata-0.1.0/tidata/__init__.py +1 -0
- tidata-0.1.0/tidata/tifinance/__init__.py +17 -0
- tidata-0.1.0/tidata/tifinance/exceptions.py +48 -0
- tidata-0.1.0/tidata/tifinance/ticker.py +263 -0
- tidata-0.1.0/tidata.egg-info/PKG-INFO +87 -0
- tidata-0.1.0/tidata.egg-info/SOURCES.txt +12 -0
- tidata-0.1.0/tidata.egg-info/dependency_links.txt +1 -0
- tidata-0.1.0/tidata.egg-info/requires.txt +7 -0
- tidata-0.1.0/tidata.egg-info/top_level.txt +1 -0
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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tidata
|