twmarketdata 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- twmarketdata-0.1.0.dist-info/METADATA +319 -0
- twmarketdata-0.1.0.dist-info/RECORD +9 -0
- twmarketdata-0.1.0.dist-info/WHEEL +4 -0
- twmarketdata-0.1.0.dist-info/licenses/LICENSE +21 -0
- twmd/__init__.py +63 -0
- twmd/access.py +145 -0
- twmd/client.py +422 -0
- twmd/errors.py +137 -0
- twmd/frames.py +141 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: twmarketdata
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Data-access client for the TW Market Data API. Retrieval only, for research and education.
|
|
5
|
+
Project-URL: Homepage, https://twmarketdata.com
|
|
6
|
+
Project-URL: Documentation, https://api.twmarketdata.com/openapi.json
|
|
7
|
+
Author: TW Market Data
|
|
8
|
+
License: MIT License
|
|
9
|
+
|
|
10
|
+
Copyright (c) 2026 TW Market Data
|
|
11
|
+
|
|
12
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
13
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
14
|
+
in the Software without restriction, including without limitation the rights
|
|
15
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
16
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
17
|
+
furnished to do so, subject to the following conditions:
|
|
18
|
+
|
|
19
|
+
The above copyright notice and this permission notice shall be included in all
|
|
20
|
+
copies or substantial portions of the Software.
|
|
21
|
+
|
|
22
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
23
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
24
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
25
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
26
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
27
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
28
|
+
SOFTWARE.
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
Keywords: api-client,dataset,market-data,research,taiwan
|
|
31
|
+
Classifier: Development Status :: 3 - Alpha
|
|
32
|
+
Classifier: Intended Audience :: Developers
|
|
33
|
+
Classifier: Intended Audience :: Education
|
|
34
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
35
|
+
Classifier: Programming Language :: Python :: 3
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
41
|
+
Classifier: Topic :: Database :: Front-Ends
|
|
42
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
43
|
+
Classifier: Typing :: Typed
|
|
44
|
+
Requires-Python: >=3.9
|
|
45
|
+
Requires-Dist: httpx<1.0,>=0.27
|
|
46
|
+
Requires-Dist: pandas>=1.5
|
|
47
|
+
Provides-Extra: test
|
|
48
|
+
Requires-Dist: pytest>=7.4; extra == 'test'
|
|
49
|
+
Requires-Dist: respx>=0.21; extra == 'test'
|
|
50
|
+
Description-Content-Type: text/markdown
|
|
51
|
+
|
|
52
|
+
# twmd
|
|
53
|
+
|
|
54
|
+
Python data-access client for the [TW Market Data](https://twmarketdata.com) API.
|
|
55
|
+
|
|
56
|
+
`twmd` retrieves published datasets over HTTP and returns them as pandas
|
|
57
|
+
DataFrames. It is a transport layer for data retrieval, intended for research
|
|
58
|
+
and educational use. It fetches records as the API publishes them and performs
|
|
59
|
+
no analysis, scoring, ranking, or interpretation of any kind. Deciding what the
|
|
60
|
+
data means, and what if anything to do about it, is entirely the caller's own
|
|
61
|
+
work and responsibility.
|
|
62
|
+
|
|
63
|
+
Responses carry the API's own `lineage.not_investment_advice` flag; this client
|
|
64
|
+
preserves it unmodified.
|
|
65
|
+
|
|
66
|
+
## Install
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
pip install twmarketdata
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The distribution is named `twmarketdata`; the import package is `twmd`:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
import twmd
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Pure Python. Dependencies are `httpx` and `pandas` — no compiled extensions, no
|
|
79
|
+
system libraries, installable in a restricted sandbox.
|
|
80
|
+
|
|
81
|
+
Requires Python 3.9 or newer. The floors are deliberately low (`pandas>=1.5`,
|
|
82
|
+
`httpx>=0.27`) so this installs into existing environments without forcing an
|
|
83
|
+
upgrade, and CI runs the full suite pinned to exactly those floors so they stay
|
|
84
|
+
honest rather than aspirational.
|
|
85
|
+
|
|
86
|
+
One caveat that is not ours to fix: pandas 1.5 wheels are built against the
|
|
87
|
+
numpy 1.x C ABI and fail at import under numpy 2 with `numpy.dtype size
|
|
88
|
+
changed`. If you are pinned to pandas 1.x, pin `numpy<2` alongside it. Anything
|
|
89
|
+
from pandas 2.2.2 onward works with numpy 2 unconstrained.
|
|
90
|
+
|
|
91
|
+
## Quick start — no API key needed
|
|
92
|
+
|
|
93
|
+
Five sample tickers are served without credentials on selected datasets, so
|
|
94
|
+
this runs with zero configuration:
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from twmd import Client
|
|
98
|
+
|
|
99
|
+
client = Client()
|
|
100
|
+
df = client.get_dataset("twse-daily-price", symbol="2330", limit=5)
|
|
101
|
+
|
|
102
|
+
print(df[["date", "open", "high", "low", "close", "volume_shares"]])
|
|
103
|
+
print(df.attrs["data_as_of"]) # data freshness date
|
|
104
|
+
print(df.attrs["lineage"]) # provider, source endpoints, source table
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Authentication
|
|
108
|
+
|
|
109
|
+
Set your key in the environment; it is never written to disk or logged:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
export TWMD_API_KEY="sk_live_..."
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`Client()` picks it up automatically. You may also pass `Client(api_key=...)`
|
|
116
|
+
explicitly. With no key set, the client runs in key-free mode and reaches the
|
|
117
|
+
datasets listed below.
|
|
118
|
+
|
|
119
|
+
## Key-free access matrix
|
|
120
|
+
|
|
121
|
+
Key-free access is scoped **per dataset**, not globally per ticker. The same
|
|
122
|
+
ticker that is served on one dataset may require a key on another. Measured
|
|
123
|
+
against the live API on 2026-07-21:
|
|
124
|
+
|
|
125
|
+
| Tier | Datasets | Tickers served without a key |
|
|
126
|
+
| --- | --- | --- |
|
|
127
|
+
| Open | `security-master`, `market-index` | any |
|
|
128
|
+
| Sample | `twse-daily-price`, `tpex-daily-price`, `monthly-revenue` | `2330`, `2317`, `2454`, `0050`, `2603` only |
|
|
129
|
+
| Key required | everything else, including `institutional-flow`, `market-prices`, `financial-metrics`, `income-statement`, `balance-sheet` | none |
|
|
130
|
+
|
|
131
|
+
Check before requesting:
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
from twmd import is_key_free
|
|
135
|
+
|
|
136
|
+
is_key_free("twse-daily-price", "2330") # True
|
|
137
|
+
is_key_free("twse-daily-price", "1101") # False — outside the sample set
|
|
138
|
+
is_key_free("security-master", "1101") # True — open dataset
|
|
139
|
+
is_key_free("institutional-flow", "2330") # False — key required
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Datasets not in the table are treated as key-required, which is the safe
|
|
143
|
+
default. The client never blocks a request on this basis — it only uses the
|
|
144
|
+
matrix to explain a 401 after the fact.
|
|
145
|
+
|
|
146
|
+
## DataFrames and metadata
|
|
147
|
+
|
|
148
|
+
Records become rows. Everything else in the response envelope is preserved on
|
|
149
|
+
`df.attrs`.
|
|
150
|
+
|
|
151
|
+
The API uses **two envelope shapes**, and `twmd` handles both transparently:
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
# rows / count — twse-daily-price, tpex-daily-price, monthly-revenue
|
|
155
|
+
df.attrs["dataset"] # "twse_daily_price"
|
|
156
|
+
df.attrs["count"] # record count
|
|
157
|
+
df.attrs["data_as_of"] # data freshness date
|
|
158
|
+
df.attrs["source_role"] # e.g. "official_twse"
|
|
159
|
+
df.attrs["lineage"] # provider, official_source, source_endpoints, table
|
|
160
|
+
df.attrs["meta"] # last_trading_day, market_status
|
|
161
|
+
|
|
162
|
+
# items / row_count — security-master, market-index
|
|
163
|
+
df.attrs["dataset_id"] # "security-master"
|
|
164
|
+
df.attrs["row_count"] # record count
|
|
165
|
+
df.attrs["as_of_date"] # snapshot date
|
|
166
|
+
df.attrs["survivorship_bias_warning"] # integrity caveat raised by the API
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Envelope contents vary by dataset — `monthly-revenue` sends only `dataset`,
|
|
170
|
+
`rows` and `count` — so read `attrs` defensively.
|
|
171
|
+
|
|
172
|
+
Records in the `items` variant contain nested objects (`security_identity`,
|
|
173
|
+
`market_identity`, `index_level`). These stay as dict-valued columns rather than
|
|
174
|
+
being flattened, so a DataFrame reflects what the API actually sent. Expand one
|
|
175
|
+
when you want to:
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
import pandas as pd
|
|
179
|
+
identity = pd.json_normalize(df["security_identity"])
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Note that `security-master` carries a `survivorship_bias_warning` stating the
|
|
183
|
+
current master is not point-in-time complete. It is surfaced on `attrs`
|
|
184
|
+
unmodified; check it before using that dataset for historical work.
|
|
185
|
+
|
|
186
|
+
## Errors
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
from twmd import Client, TwmdAuthError, TwmdPaymentRequired
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
df = client.get_dataset("institutional-flow", symbol="2330")
|
|
193
|
+
except TwmdAuthError as exc:
|
|
194
|
+
print(exc.error_code) # "missing_api_key" or "invalid_api_key"
|
|
195
|
+
print(exc.body) # decoded response body, verbatim
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Every `TwmdAPIError` subclass — every row in the table below except the last —
|
|
199
|
+
exposes `.status_code`, `.body` (decoded body, unmodified), `.text` and
|
|
200
|
+
`.error_code`. `TwmdTransportError` and `TwmdConfigError` derive from `TwmdError`
|
|
201
|
+
directly and carry none of those, since no response was received.
|
|
202
|
+
|
|
203
|
+
| Status | Exception | Retried |
|
|
204
|
+
| --- | --- | --- |
|
|
205
|
+
| 401 | `TwmdAuthError` | no |
|
|
206
|
+
| 402 | `TwmdPaymentRequired` | no |
|
|
207
|
+
| 404 | `TwmdNotFoundError` | no |
|
|
208
|
+
| 422 | `TwmdValidationError` | no |
|
|
209
|
+
| 429 | `TwmdRateLimitError` | yes |
|
|
210
|
+
| 5xx | `TwmdServerError` | yes |
|
|
211
|
+
| network failure | `TwmdTransportError` | yes |
|
|
212
|
+
|
|
213
|
+
Retries use exponential backoff with jitter. A `Retry-After` header — in either
|
|
214
|
+
RFC 7231 form, delta-seconds or HTTP-date — overrides that and is honoured
|
|
215
|
+
exactly, with no jitter and no shortening, since retrying sooner than the server
|
|
216
|
+
permitted is worse than waiting too long. If it asks for more than
|
|
217
|
+
`RETRY_AFTER_MAX` (120s) the client stops and raises the server's error rather
|
|
218
|
+
than blocking for minutes.
|
|
219
|
+
|
|
220
|
+
401 messages are annotated with the key-free status of the dataset and ticker
|
|
221
|
+
you asked for, so "I forgot my key" is distinguishable from "that ticker is not
|
|
222
|
+
in the sample set".
|
|
223
|
+
|
|
224
|
+
### On 402
|
|
225
|
+
|
|
226
|
+
**No 402 response has been observed from this API.** The probe on 2026-07-21
|
|
227
|
+
found zero endpoints declaring 402 in the published OpenAPI document, and
|
|
228
|
+
unauthenticated requests to key-gated datasets return 401 `missing_api_key`.
|
|
229
|
+
`TwmdPaymentRequired` exists as a documented extension point: if the API does
|
|
230
|
+
return 402 — the untested path is a *valid but unentitled* key, which requires
|
|
231
|
+
live credentials to exercise — the full decoded body is preserved verbatim on
|
|
232
|
+
`.body` and reaches your code intact. The client deliberately does not model or
|
|
233
|
+
require any field layout inside it, since no 402 body exists to model.
|
|
234
|
+
|
|
235
|
+
```python
|
|
236
|
+
except TwmdPaymentRequired as exc:
|
|
237
|
+
handle_payment_required(exc.body) # whole body, nothing dropped
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
## Source attribution
|
|
241
|
+
|
|
242
|
+
Pass `source` to mark every request with the integration that produced it, so
|
|
243
|
+
the publisher can attribute traffic:
|
|
244
|
+
|
|
245
|
+
```python
|
|
246
|
+
client = Client(source="ecosys/tradingagents")
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
It is attached as a `source` query parameter on every request. A per-call
|
|
250
|
+
`source=` on `get_dataset` overrides the client-wide value. It is an ordinary
|
|
251
|
+
query parameter — it does not change the response, does not enter a data
|
|
252
|
+
response's `request_context.filters`, and carries nothing about the user. Leave
|
|
253
|
+
it unset to send nothing.
|
|
254
|
+
|
|
255
|
+
## Pagination
|
|
256
|
+
|
|
257
|
+
```python
|
|
258
|
+
df = client.get_all("twse-daily-price", symbol="2330", limit=1000)
|
|
259
|
+
|
|
260
|
+
for page in client.iter_pages("twse-daily-price", symbol="2330", limit=500):
|
|
261
|
+
process(page)
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
The API publishes no cursor: no `cursor` or `next_cursor` field appears in any
|
|
265
|
+
response or in the OpenAPI document. Paging is `limit`/`offset`, and `offset`
|
|
266
|
+
was measured to be **ignored** on the key-free endpoints — `offset=3` returned
|
|
267
|
+
the same records as `offset=0`.
|
|
268
|
+
|
|
269
|
+
So pagination here is defensive. It advances `offset` as documented, then stops
|
|
270
|
+
when either a page comes back shorter than `limit`, or a page repeats the
|
|
271
|
+
previous page's first record — the signature of a silently ignored `offset`.
|
|
272
|
+
Against endpoints that ignore `offset` this yields exactly one page, which is
|
|
273
|
+
the correct outcome rather than a failure. `max_pages` caps the loop.
|
|
274
|
+
|
|
275
|
+
## `as_of`
|
|
276
|
+
|
|
277
|
+
`get_dataset()` accepts an `as_of` argument and forwards it as a query
|
|
278
|
+
parameter. **Its scope is narrow.** As measured on 2026-07-21:
|
|
279
|
+
|
|
280
|
+
- `as_of` is declared on exactly four endpoints — `income-statement`,
|
|
281
|
+
`cash-flow-statement`, `balance-sheet`, `financials` — all of which require an
|
|
282
|
+
API key.
|
|
283
|
+
- On every other endpoint it is not a declared parameter. The API silently
|
|
284
|
+
ignores unknown query parameters, returning 200 rather than 422, so passing
|
|
285
|
+
`as_of` there has no observable effect and raises no error.
|
|
286
|
+
- Its behaviour on the four declared endpoints is **unverified** by this
|
|
287
|
+
project, which has no credentials to exercise them.
|
|
288
|
+
|
|
289
|
+
Treat `as_of` as forwarded-but-unconfirmed rather than as a general
|
|
290
|
+
point-in-time facility. The `data_as_of` field in responses is a data-freshness
|
|
291
|
+
date and is a different thing.
|
|
292
|
+
|
|
293
|
+
## Development
|
|
294
|
+
|
|
295
|
+
```bash
|
|
296
|
+
pip install -e ".[test]"
|
|
297
|
+
pytest -m "not live" # offline, no network
|
|
298
|
+
pytest -m live # hits the real API, key-free paths only
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
The live suite asserts a sample of the key-free matrix — seven dataset/ticker
|
|
302
|
+
pairs — still matches what the API serves, so drift in those surfaces as a test
|
|
303
|
+
failure. It is a tripwire, not full coverage: the five sample tickers are
|
|
304
|
+
verified only against `twse-daily-price`, and three datasets listed as
|
|
305
|
+
key-required are assumed rather than probed (see
|
|
306
|
+
`access.PRESUMED_KEY_REQUIRED_DATASETS`, and `access.provenance()` to tell the
|
|
307
|
+
two apart).
|
|
308
|
+
|
|
309
|
+
## Scope
|
|
310
|
+
|
|
311
|
+
This package retrieves data. It does not generate recommendations, forecasts,
|
|
312
|
+
signals, valuations, or opinions about any security, and nothing it returns
|
|
313
|
+
should be read as such. The data is provided for research and educational
|
|
314
|
+
purposes; verify it against the original sources before relying on it. Use of
|
|
315
|
+
the underlying API is governed by TW Market Data's own terms.
|
|
316
|
+
|
|
317
|
+
## License
|
|
318
|
+
|
|
319
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
twmd/__init__.py,sha256=rbJOYxujUF3BPHF6VCKOuHPFTdA4nFpGTjWjk-JIN3A,1438
|
|
2
|
+
twmd/access.py,sha256=GGDYA14DmTLUsJqtwXE4lIBsKUxhDdje61yUknFf-d0,5225
|
|
3
|
+
twmd/client.py,sha256=qn5MxZx27jUHJoIh3n3GBZ1UbcG-i8T0w14pVRBB3E0,16479
|
|
4
|
+
twmd/errors.py,sha256=uFH69pHrOZ4Wxj6dbZTUaq_rFBiQ-LK4_8z4HjVmo7Q,4572
|
|
5
|
+
twmd/frames.py,sha256=9fkPEW8lKM-cNLPno0qfK2VdvpSQttSumLlMMUnpyzc,5377
|
|
6
|
+
twmarketdata-0.1.0.dist-info/METADATA,sha256=sjL5-qZStlzX-PbaEDkq41JocJ_m4AU1HBBYC2osC2Q,12981
|
|
7
|
+
twmarketdata-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
8
|
+
twmarketdata-0.1.0.dist-info/licenses/LICENSE,sha256=l7ThmYQSxsHt9D0YFvjVa7rSMIANnBxjDPMLhhWUR1M,1071
|
|
9
|
+
twmarketdata-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 TW Market Data
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
twmd/__init__.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""twmd — data-access client for the TW Market Data API.
|
|
2
|
+
|
|
3
|
+
Fetches published datasets and returns them as pandas DataFrames, for research
|
|
4
|
+
and educational use. This package is a retrieval layer only: it transports
|
|
5
|
+
records as the API sends them and adds no analysis or interpretation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .access import (
|
|
11
|
+
KEY_REQUIRED_DATASETS,
|
|
12
|
+
OPEN_DATASETS,
|
|
13
|
+
PRESUMED_KEY_REQUIRED_DATASETS,
|
|
14
|
+
SAMPLE_DATASETS,
|
|
15
|
+
SAMPLE_TICKERS,
|
|
16
|
+
access_tier,
|
|
17
|
+
explain,
|
|
18
|
+
is_key_free,
|
|
19
|
+
provenance,
|
|
20
|
+
)
|
|
21
|
+
from .client import API_KEY_ENV, DEFAULT_BASE_URL, Client
|
|
22
|
+
from .errors import (
|
|
23
|
+
TwmdAPIError,
|
|
24
|
+
TwmdAuthError,
|
|
25
|
+
TwmdConfigError,
|
|
26
|
+
TwmdError,
|
|
27
|
+
TwmdNotFoundError,
|
|
28
|
+
TwmdPaymentRequired,
|
|
29
|
+
TwmdRateLimitError,
|
|
30
|
+
TwmdServerError,
|
|
31
|
+
TwmdTransportError,
|
|
32
|
+
TwmdValidationError,
|
|
33
|
+
)
|
|
34
|
+
from .frames import to_dataframe
|
|
35
|
+
|
|
36
|
+
__version__ = "0.1.0"
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"Client",
|
|
40
|
+
"API_KEY_ENV",
|
|
41
|
+
"DEFAULT_BASE_URL",
|
|
42
|
+
"SAMPLE_TICKERS",
|
|
43
|
+
"OPEN_DATASETS",
|
|
44
|
+
"SAMPLE_DATASETS",
|
|
45
|
+
"KEY_REQUIRED_DATASETS",
|
|
46
|
+
"PRESUMED_KEY_REQUIRED_DATASETS",
|
|
47
|
+
"access_tier",
|
|
48
|
+
"is_key_free",
|
|
49
|
+
"explain",
|
|
50
|
+
"provenance",
|
|
51
|
+
"to_dataframe",
|
|
52
|
+
"TwmdError",
|
|
53
|
+
"TwmdConfigError",
|
|
54
|
+
"TwmdTransportError",
|
|
55
|
+
"TwmdAPIError",
|
|
56
|
+
"TwmdAuthError",
|
|
57
|
+
"TwmdPaymentRequired",
|
|
58
|
+
"TwmdNotFoundError",
|
|
59
|
+
"TwmdValidationError",
|
|
60
|
+
"TwmdRateLimitError",
|
|
61
|
+
"TwmdServerError",
|
|
62
|
+
"__version__",
|
|
63
|
+
]
|
twmd/access.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Which dataset/ticker combinations are reachable without an API key.
|
|
2
|
+
|
|
3
|
+
This module encodes an **empirically measured** matrix, not an assumption. It
|
|
4
|
+
was produced by probing the live API without credentials on 2026-07-21. The
|
|
5
|
+
headline finding: key-free access is scoped per *dataset*, not globally per
|
|
6
|
+
ticker. The same ticker that returns 200 on ``twse-daily-price`` returns 401 on
|
|
7
|
+
``institutional-flow``.
|
|
8
|
+
|
|
9
|
+
Three access tiers were observed:
|
|
10
|
+
|
|
11
|
+
``OPEN``
|
|
12
|
+
Reachable without a key for any ticker.
|
|
13
|
+
``SAMPLE``
|
|
14
|
+
Reachable without a key only for the five sample tickers in
|
|
15
|
+
:data:`SAMPLE_TICKERS`. Any other ticker returns 401 ``missing_api_key``.
|
|
16
|
+
``KEY_REQUIRED``
|
|
17
|
+
Every request needs a key.
|
|
18
|
+
|
|
19
|
+
Datasets absent from the tables below are treated as ``KEY_REQUIRED``, which is
|
|
20
|
+
the safe default: the client will still send the request, so an unlisted
|
|
21
|
+
dataset that is in fact open keeps working. This module drives *advisory*
|
|
22
|
+
hints, never a client-side block.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from typing import FrozenSet, Optional
|
|
28
|
+
|
|
29
|
+
#: Tickers served without a key on :data:`SAMPLE_DATASETS`. Measured 2026-07-21.
|
|
30
|
+
SAMPLE_TICKERS: FrozenSet[str] = frozenset({"2330", "2317", "2454", "0050", "2603"})
|
|
31
|
+
|
|
32
|
+
#: Datasets served without a key for any ticker. Measured 2026-07-21.
|
|
33
|
+
OPEN_DATASETS: FrozenSet[str] = frozenset({"security-master", "market-index"})
|
|
34
|
+
|
|
35
|
+
#: Datasets served without a key only for :data:`SAMPLE_TICKERS`. Measured 2026-07-21.
|
|
36
|
+
SAMPLE_DATASETS: FrozenSet[str] = frozenset(
|
|
37
|
+
{"twse-daily-price", "tpex-daily-price", "monthly-revenue"}
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
#: Datasets *measured* returning 401 without a key, including for sample
|
|
41
|
+
#: tickers. Each of these was probed directly on 2026-07-21.
|
|
42
|
+
KEY_REQUIRED_DATASETS: FrozenSet[str] = frozenset(
|
|
43
|
+
{
|
|
44
|
+
"institutional-flow",
|
|
45
|
+
"technical-indicators",
|
|
46
|
+
"valuation-data",
|
|
47
|
+
"market-prices",
|
|
48
|
+
"financial-metrics",
|
|
49
|
+
"market-snapshot",
|
|
50
|
+
"market-breadth",
|
|
51
|
+
"income-statement",
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
#: Datasets *believed* key-gated but never probed. They declare ``x-api-key`` in
|
|
56
|
+
#: the OpenAPI document and sit alongside measured-gated siblings, but no
|
|
57
|
+
#: request was issued against them. Kept separate from
|
|
58
|
+
#: :data:`KEY_REQUIRED_DATASETS` so the distinction between measured and assumed
|
|
59
|
+
#: is not lost. They resolve to the same tier; only the provenance differs.
|
|
60
|
+
PRESUMED_KEY_REQUIRED_DATASETS: FrozenSet[str] = frozenset(
|
|
61
|
+
{
|
|
62
|
+
"cash-flow-statement",
|
|
63
|
+
"balance-sheet",
|
|
64
|
+
"financials",
|
|
65
|
+
}
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
OPEN = "open"
|
|
69
|
+
SAMPLE = "sample"
|
|
70
|
+
KEY_REQUIRED = "key_required"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def access_tier(dataset: str) -> str:
|
|
74
|
+
"""Return the access tier for ``dataset``.
|
|
75
|
+
|
|
76
|
+
Unknown datasets return :data:`KEY_REQUIRED`, the safe default.
|
|
77
|
+
"""
|
|
78
|
+
key = dataset.strip("/")
|
|
79
|
+
if key in OPEN_DATASETS:
|
|
80
|
+
return OPEN
|
|
81
|
+
if key in SAMPLE_DATASETS:
|
|
82
|
+
return SAMPLE
|
|
83
|
+
return KEY_REQUIRED
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def provenance(dataset: str) -> str:
|
|
87
|
+
"""How the tier for ``dataset`` was established: ``measured`` or ``assumed``.
|
|
88
|
+
|
|
89
|
+
Lets a caller — or a future contract re-probe — tell apart what was actually
|
|
90
|
+
observed from what is only the safe default.
|
|
91
|
+
"""
|
|
92
|
+
key = dataset.strip("/")
|
|
93
|
+
measured = OPEN_DATASETS | SAMPLE_DATASETS | KEY_REQUIRED_DATASETS
|
|
94
|
+
return "measured" if key in measured else "assumed"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def is_key_free(dataset: str, ticker: Optional[str] = None) -> bool:
|
|
98
|
+
"""Whether ``dataset`` (optionally for ``ticker``) is reachable without a key.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
dataset: Dataset path segment, e.g. ``"twse-daily-price"``.
|
|
102
|
+
ticker: Symbol to check. Required to get a meaningful answer for
|
|
103
|
+
``SAMPLE``-tier datasets; without it this returns ``False`` for
|
|
104
|
+
them, since only five specific tickers qualify.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
``True`` if an unauthenticated request is expected to succeed.
|
|
108
|
+
|
|
109
|
+
Example:
|
|
110
|
+
>>> is_key_free("twse-daily-price", "2330")
|
|
111
|
+
True
|
|
112
|
+
>>> is_key_free("twse-daily-price", "1101")
|
|
113
|
+
False
|
|
114
|
+
>>> is_key_free("security-master", "1101")
|
|
115
|
+
True
|
|
116
|
+
>>> is_key_free("institutional-flow", "2330")
|
|
117
|
+
False
|
|
118
|
+
"""
|
|
119
|
+
tier = access_tier(dataset)
|
|
120
|
+
if tier == OPEN:
|
|
121
|
+
return True
|
|
122
|
+
if tier == SAMPLE:
|
|
123
|
+
return ticker is not None and str(ticker) in SAMPLE_TICKERS
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def explain(dataset: str, ticker: Optional[str] = None) -> str:
|
|
128
|
+
"""Human-readable reason for the :func:`is_key_free` verdict.
|
|
129
|
+
|
|
130
|
+
Used to enrich 401 messages so a caller can tell "I forgot my key" apart
|
|
131
|
+
from "this dataset is open but that ticker is not in the sample set".
|
|
132
|
+
"""
|
|
133
|
+
tier = access_tier(dataset)
|
|
134
|
+
if tier == OPEN:
|
|
135
|
+
return f"'{dataset}' is served without an API key for any ticker."
|
|
136
|
+
if tier == SAMPLE:
|
|
137
|
+
listed = ", ".join(sorted(SAMPLE_TICKERS))
|
|
138
|
+
if ticker is not None and str(ticker) in SAMPLE_TICKERS:
|
|
139
|
+
return f"'{dataset}' is served without an API key for ticker {ticker}."
|
|
140
|
+
return (
|
|
141
|
+
f"'{dataset}' is served without an API key only for these sample "
|
|
142
|
+
f"tickers: {listed}."
|
|
143
|
+
+ (f" Ticker {ticker} is not among them." if ticker is not None else "")
|
|
144
|
+
)
|
|
145
|
+
return f"'{dataset}' requires an API key for every request."
|
twmd/client.py
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
"""Synchronous data-access client for the TW Market Data API.
|
|
2
|
+
|
|
3
|
+
Retrieves published datasets and returns them as pandas DataFrames. This is a
|
|
4
|
+
transport layer: it fetches records and hands them over unmodified. It does not
|
|
5
|
+
compute, score, rank, or interpret anything.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import email.utils
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
import random
|
|
15
|
+
import time
|
|
16
|
+
from typing import Any, Dict, Iterator, Mapping, Optional, Sequence
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
import pandas as pd
|
|
20
|
+
|
|
21
|
+
from . import access
|
|
22
|
+
from .errors import (
|
|
23
|
+
TwmdAPIError,
|
|
24
|
+
TwmdAuthError,
|
|
25
|
+
TwmdConfigError,
|
|
26
|
+
TwmdRateLimitError,
|
|
27
|
+
TwmdServerError,
|
|
28
|
+
TwmdTransportError,
|
|
29
|
+
error_for_status,
|
|
30
|
+
)
|
|
31
|
+
from .frames import concat_frames, to_dataframe
|
|
32
|
+
|
|
33
|
+
DEFAULT_BASE_URL = "https://api.twmarketdata.com"
|
|
34
|
+
API_KEY_ENV = "TWMD_API_KEY"
|
|
35
|
+
BASE_URL_ENV = "TWMD_BASE_URL"
|
|
36
|
+
API_KEY_HEADER = "X-API-Key"
|
|
37
|
+
|
|
38
|
+
DEFAULT_TIMEOUT = 30.0
|
|
39
|
+
DEFAULT_MAX_RETRIES = 3
|
|
40
|
+
DEFAULT_BACKOFF_FACTOR = 0.5
|
|
41
|
+
DEFAULT_BACKOFF_MAX = 20.0
|
|
42
|
+
|
|
43
|
+
#: Statuses worth retrying. 401/402/404/422 are terminal — retrying cannot help.
|
|
44
|
+
RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})
|
|
45
|
+
|
|
46
|
+
#: Query parameter names the API uses for a ticker, in the order it accepts them.
|
|
47
|
+
_TICKER_PARAMS = ("symbol", "ticker")
|
|
48
|
+
|
|
49
|
+
#: Longest wait honoured from a ``Retry-After`` header, in seconds. Beyond this
|
|
50
|
+
#: the client gives up rather than retrying earlier than the server allowed.
|
|
51
|
+
RETRY_AFTER_MAX = 120.0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _fingerprint(page: "pd.DataFrame") -> str:
|
|
55
|
+
"""Stable identity string for a page's first record.
|
|
56
|
+
|
|
57
|
+
Used to detect a server that ignored ``offset`` and re-sent page one.
|
|
58
|
+
Serialised rather than compared as a dict because a partially-null numeric
|
|
59
|
+
column yields ``NaN``, and ``NaN != NaN`` makes two identical rows compare
|
|
60
|
+
unequal — which would silently disable the repeat-page guard and let the
|
|
61
|
+
loop refetch page one up to ``max_pages`` times.
|
|
62
|
+
"""
|
|
63
|
+
return json.dumps(page.iloc[0].to_dict(), sort_keys=True, default=str)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def parse_retry_after(value: Optional[str]) -> Optional[float]:
|
|
67
|
+
"""Parse a ``Retry-After`` header into seconds.
|
|
68
|
+
|
|
69
|
+
RFC 7231 permits two forms: delta-seconds, and an HTTP-date. Both are
|
|
70
|
+
handled; anything unparseable returns ``None`` so the caller falls back to
|
|
71
|
+
its own backoff.
|
|
72
|
+
"""
|
|
73
|
+
if not value:
|
|
74
|
+
return None
|
|
75
|
+
text = value.strip()
|
|
76
|
+
try:
|
|
77
|
+
return max(0.0, float(text))
|
|
78
|
+
except ValueError:
|
|
79
|
+
pass
|
|
80
|
+
try:
|
|
81
|
+
when = email.utils.parsedate_to_datetime(text)
|
|
82
|
+
except (TypeError, ValueError):
|
|
83
|
+
return None
|
|
84
|
+
if when is None:
|
|
85
|
+
return None
|
|
86
|
+
now = datetime.now(when.tzinfo) if when.tzinfo else datetime.now()
|
|
87
|
+
return max(0.0, (when - now).total_seconds())
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class Client:
|
|
91
|
+
"""Client for the TW Market Data read API.
|
|
92
|
+
|
|
93
|
+
Credentials are read from the ``TWMD_API_KEY`` environment variable by
|
|
94
|
+
default; a key is never written to disk or logged by this client. Requests
|
|
95
|
+
without a key still succeed against the key-free datasets described in
|
|
96
|
+
:mod:`twmd.access`.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
api_key: API key. Defaults to ``os.environ["TWMD_API_KEY"]``. When
|
|
100
|
+
neither is set the client runs in key-free mode.
|
|
101
|
+
base_url: API root. Defaults to ``TWMD_BASE_URL`` or
|
|
102
|
+
``https://api.twmarketdata.com``.
|
|
103
|
+
source: Integration marker attached to every request as a ``source``
|
|
104
|
+
query parameter, so the publisher can attribute traffic to the
|
|
105
|
+
integration that produced it (e.g. ``"ecosys/tradingagents"``). It is
|
|
106
|
+
an ordinary query parameter, does not change the response, and
|
|
107
|
+
carries nothing about the user. A per-call ``source`` passed to
|
|
108
|
+
:meth:`get_dataset` overrides it. Leave unset to send nothing.
|
|
109
|
+
timeout: Per-request timeout in seconds.
|
|
110
|
+
max_retries: Retry attempts for transient failures (429, 5xx, network
|
|
111
|
+
errors). Terminal statuses are never retried.
|
|
112
|
+
backoff_factor: Base for exponential backoff, in seconds.
|
|
113
|
+
transport: Optional httpx transport, for tests.
|
|
114
|
+
|
|
115
|
+
Example:
|
|
116
|
+
Key-free, zero configuration::
|
|
117
|
+
|
|
118
|
+
from twmd import Client
|
|
119
|
+
|
|
120
|
+
client = Client()
|
|
121
|
+
df = client.get_dataset("twse-daily-price", symbol="2330", limit=5)
|
|
122
|
+
print(df[["date", "close"]])
|
|
123
|
+
print(df.attrs["lineage"])
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
def __init__(
|
|
127
|
+
self,
|
|
128
|
+
api_key: Optional[str] = None,
|
|
129
|
+
*,
|
|
130
|
+
base_url: Optional[str] = None,
|
|
131
|
+
source: Optional[str] = None,
|
|
132
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
133
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
134
|
+
backoff_factor: float = DEFAULT_BACKOFF_FACTOR,
|
|
135
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
136
|
+
) -> None:
|
|
137
|
+
if max_retries < 0:
|
|
138
|
+
raise TwmdConfigError("max_retries must be >= 0")
|
|
139
|
+
self._api_key = api_key if api_key is not None else os.environ.get(API_KEY_ENV)
|
|
140
|
+
if self._api_key is not None:
|
|
141
|
+
self._api_key = self._api_key.strip() or None
|
|
142
|
+
self.base_url = (base_url or os.environ.get(BASE_URL_ENV) or DEFAULT_BASE_URL).rstrip("/")
|
|
143
|
+
self.source = (source or "").strip() or None
|
|
144
|
+
self.max_retries = max_retries
|
|
145
|
+
self.backoff_factor = backoff_factor
|
|
146
|
+
|
|
147
|
+
headers = {"Accept": "application/json", "User-Agent": "twmd-python/0.1.0"}
|
|
148
|
+
if self._api_key:
|
|
149
|
+
headers[API_KEY_HEADER] = self._api_key
|
|
150
|
+
self._http = httpx.Client(
|
|
151
|
+
base_url=self.base_url,
|
|
152
|
+
headers=headers,
|
|
153
|
+
timeout=timeout,
|
|
154
|
+
transport=transport,
|
|
155
|
+
follow_redirects=True,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# -- lifecycle ---------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def has_api_key(self) -> bool:
|
|
162
|
+
"""Whether this client is carrying a key. Never exposes the key itself."""
|
|
163
|
+
return self._api_key is not None
|
|
164
|
+
|
|
165
|
+
def close(self) -> None:
|
|
166
|
+
"""Close the underlying connection pool."""
|
|
167
|
+
self._http.close()
|
|
168
|
+
|
|
169
|
+
def __enter__(self) -> "Client":
|
|
170
|
+
return self
|
|
171
|
+
|
|
172
|
+
def __exit__(self, *exc_info: Any) -> None:
|
|
173
|
+
self.close()
|
|
174
|
+
|
|
175
|
+
# -- public API --------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
def get_dataset(
|
|
178
|
+
self,
|
|
179
|
+
dataset: str,
|
|
180
|
+
*,
|
|
181
|
+
as_of: Optional[str] = None,
|
|
182
|
+
**params: Any,
|
|
183
|
+
) -> pd.DataFrame:
|
|
184
|
+
"""Fetch one page of a dataset as a DataFrame.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
dataset: Dataset path segment, e.g. ``"twse-daily-price"``. A full
|
|
188
|
+
path such as ``"/v2/datasets/twse-daily-price"`` also works.
|
|
189
|
+
as_of: Point-in-time parameter, forwarded as the ``as_of`` query
|
|
190
|
+
parameter. **Scope is narrow — read this before relying on it.**
|
|
191
|
+
As of the contract probe on 2026-07-21, ``as_of`` is declared on
|
|
192
|
+
exactly four endpoints, all of which require a key:
|
|
193
|
+
``income-statement``, ``cash-flow-statement``, ``balance-sheet``
|
|
194
|
+
and ``financials``. On every other endpoint it is not a declared
|
|
195
|
+
parameter, and because the API silently ignores unknown query
|
|
196
|
+
parameters — returning 200 rather than 422 — passing it there
|
|
197
|
+
has no observable effect and produces no error. Its behaviour on
|
|
198
|
+
the four declared endpoints has not been verified by this
|
|
199
|
+
client, which has no credentials to exercise them. Treat it as
|
|
200
|
+
forwarded-but-unconfirmed. Note that the ``data_as_of`` field in
|
|
201
|
+
responses is a data-freshness date and is a different thing.
|
|
202
|
+
**params: Query parameters passed through to the API verbatim,
|
|
203
|
+
e.g. ``symbol``, ``limit``, ``date_from``, ``date_to``.
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
DataFrame of records. Envelope metadata — ``dataset``, ``count``,
|
|
207
|
+
``data_as_of``, ``source_role``, ``lineage``, ``meta`` — is on
|
|
208
|
+
``df.attrs``. The API's own ``lineage.not_investment_advice`` flag
|
|
209
|
+
is preserved as sent.
|
|
210
|
+
|
|
211
|
+
Raises:
|
|
212
|
+
TwmdAuthError: 401. Message is annotated with the key-free status of
|
|
213
|
+
this dataset/ticker pair.
|
|
214
|
+
TwmdPaymentRequired: 402, if the API ever returns it. Full body on
|
|
215
|
+
``.body``.
|
|
216
|
+
TwmdAPIError: Any other non-2xx response.
|
|
217
|
+
TwmdTransportError: Network failure after retries.
|
|
218
|
+
"""
|
|
219
|
+
query = dict(params)
|
|
220
|
+
if as_of is not None:
|
|
221
|
+
query["as_of"] = as_of
|
|
222
|
+
payload = self._request(self._dataset_path(dataset), query)
|
|
223
|
+
return to_dataframe(payload)
|
|
224
|
+
|
|
225
|
+
def iter_pages(
|
|
226
|
+
self,
|
|
227
|
+
dataset: str,
|
|
228
|
+
*,
|
|
229
|
+
limit: int = 1000,
|
|
230
|
+
max_pages: int = 100,
|
|
231
|
+
as_of: Optional[str] = None,
|
|
232
|
+
**params: Any,
|
|
233
|
+
) -> Iterator[pd.DataFrame]:
|
|
234
|
+
"""Yield successive pages of a dataset.
|
|
235
|
+
|
|
236
|
+
**Pagination is defensive by necessity.** The API publishes no cursor:
|
|
237
|
+
the probe on 2026-07-21 found no ``cursor`` or ``next_cursor`` field in
|
|
238
|
+
any response and none in the OpenAPI document. Paging is expressed as
|
|
239
|
+
``limit``/``offset``, and ``offset`` was measured to be *ignored* on the
|
|
240
|
+
key-free endpoints — ``offset=3`` returned the same records as
|
|
241
|
+
``offset=0``. So this method advances ``offset`` as the API documents,
|
|
242
|
+
but guards against it having no effect:
|
|
243
|
+
|
|
244
|
+
1. Stop when a page returns fewer records than ``limit`` (the last page).
|
|
245
|
+
2. Stop when a page repeats the previous page's first record, which is
|
|
246
|
+
what a silently-ignored ``offset`` produces. Without this guard the
|
|
247
|
+
loop would refetch page one until ``max_pages``.
|
|
248
|
+
|
|
249
|
+
In practice, against endpoints that ignore ``offset``, this yields
|
|
250
|
+
exactly one page. That is the correct outcome, not a failure.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
dataset: Dataset path segment.
|
|
254
|
+
limit: Records requested per page.
|
|
255
|
+
max_pages: Hard ceiling on pages fetched, as a runaway guard.
|
|
256
|
+
as_of: See :meth:`get_dataset`.
|
|
257
|
+
**params: Additional query parameters.
|
|
258
|
+
|
|
259
|
+
Yields:
|
|
260
|
+
One DataFrame per page.
|
|
261
|
+
"""
|
|
262
|
+
offset = 0
|
|
263
|
+
previous_first: Optional[str] = None
|
|
264
|
+
for _ in range(max_pages):
|
|
265
|
+
page = self.get_dataset(dataset, as_of=as_of, limit=limit, offset=offset, **params)
|
|
266
|
+
if page.empty:
|
|
267
|
+
return
|
|
268
|
+
first = _fingerprint(page)
|
|
269
|
+
if previous_first is not None and first == previous_first:
|
|
270
|
+
# offset had no effect; the server re-sent the same page.
|
|
271
|
+
return
|
|
272
|
+
yield page
|
|
273
|
+
if len(page) < limit:
|
|
274
|
+
return
|
|
275
|
+
previous_first = first
|
|
276
|
+
offset += limit
|
|
277
|
+
|
|
278
|
+
def get_all(
|
|
279
|
+
self,
|
|
280
|
+
dataset: str,
|
|
281
|
+
*,
|
|
282
|
+
limit: int = 1000,
|
|
283
|
+
max_pages: int = 100,
|
|
284
|
+
as_of: Optional[str] = None,
|
|
285
|
+
**params: Any,
|
|
286
|
+
) -> pd.DataFrame:
|
|
287
|
+
"""Fetch every page and concatenate. See :meth:`iter_pages` for limits."""
|
|
288
|
+
pages = list(
|
|
289
|
+
self.iter_pages(
|
|
290
|
+
dataset, limit=limit, max_pages=max_pages, as_of=as_of, **params
|
|
291
|
+
)
|
|
292
|
+
)
|
|
293
|
+
return concat_frames(pages)
|
|
294
|
+
|
|
295
|
+
def list_datasets(self) -> pd.DataFrame:
|
|
296
|
+
"""Fetch the dataset catalog."""
|
|
297
|
+
payload = self._request("/v2/datasets", {})
|
|
298
|
+
return to_dataframe(payload)
|
|
299
|
+
|
|
300
|
+
def is_key_free(self, dataset: str, ticker: Optional[str] = None) -> bool:
|
|
301
|
+
"""Whether a dataset/ticker pair is reachable without a key.
|
|
302
|
+
|
|
303
|
+
Thin re-export of :func:`twmd.access.is_key_free` so callers can check
|
|
304
|
+
before requesting.
|
|
305
|
+
"""
|
|
306
|
+
return access.is_key_free(dataset, ticker)
|
|
307
|
+
|
|
308
|
+
# -- internals ---------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
@staticmethod
|
|
311
|
+
def _dataset_path(dataset: str) -> str:
|
|
312
|
+
name = dataset.strip("/")
|
|
313
|
+
if name.startswith("v2/"):
|
|
314
|
+
return "/" + name
|
|
315
|
+
return "/v2/datasets/" + name
|
|
316
|
+
|
|
317
|
+
def _request(self, path: str, params: Mapping[str, Any]) -> Dict[str, Any]:
|
|
318
|
+
"""Issue a GET with retry/backoff and map failures onto exceptions."""
|
|
319
|
+
clean = {k: v for k, v in params.items() if v is not None}
|
|
320
|
+
# Attach the integration source marker to every request. An explicit
|
|
321
|
+
# per-call ``source`` wins, so a caller can override it; otherwise the
|
|
322
|
+
# client-wide marker is used. It is an ordinary query parameter the API
|
|
323
|
+
# ignores for data selection (verified: it does not enter the
|
|
324
|
+
# ``request_context.filters`` of data-shaped responses).
|
|
325
|
+
if self.source is not None and "source" not in clean:
|
|
326
|
+
clean["source"] = self.source
|
|
327
|
+
last_exc: Optional[Exception] = None
|
|
328
|
+
|
|
329
|
+
for attempt in range(self.max_retries + 1):
|
|
330
|
+
try:
|
|
331
|
+
response = self._http.get(path, params=clean)
|
|
332
|
+
except httpx.HTTPError as exc:
|
|
333
|
+
last_exc = TwmdTransportError(f"request to {path} failed: {exc}")
|
|
334
|
+
if attempt < self.max_retries:
|
|
335
|
+
self._sleep_before_retry(attempt, None)
|
|
336
|
+
continue
|
|
337
|
+
raise last_exc from exc
|
|
338
|
+
|
|
339
|
+
if response.status_code < 300:
|
|
340
|
+
return self._decode(response)
|
|
341
|
+
|
|
342
|
+
body = self._safe_json(response)
|
|
343
|
+
exc_obj = error_for_status(
|
|
344
|
+
response.status_code,
|
|
345
|
+
body=body,
|
|
346
|
+
text=response.text,
|
|
347
|
+
request_url=str(response.request.url),
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
if response.status_code in RETRY_STATUSES and attempt < self.max_retries:
|
|
351
|
+
if self._sleep_before_retry(attempt, response.headers.get("Retry-After")):
|
|
352
|
+
last_exc = exc_obj
|
|
353
|
+
continue
|
|
354
|
+
# Server asked us to wait longer than we are willing to.
|
|
355
|
+
raise exc_obj
|
|
356
|
+
|
|
357
|
+
if isinstance(exc_obj, TwmdAuthError):
|
|
358
|
+
raise self._annotate_auth_error(exc_obj, path, clean)
|
|
359
|
+
raise exc_obj
|
|
360
|
+
|
|
361
|
+
# Retries exhausted on a retryable status.
|
|
362
|
+
assert last_exc is not None
|
|
363
|
+
raise last_exc
|
|
364
|
+
|
|
365
|
+
def _decode(self, response: httpx.Response) -> Dict[str, Any]:
|
|
366
|
+
try:
|
|
367
|
+
payload = response.json()
|
|
368
|
+
except ValueError as exc:
|
|
369
|
+
raise TwmdAPIError(
|
|
370
|
+
response.status_code,
|
|
371
|
+
text=response.text,
|
|
372
|
+
request_url=str(response.request.url),
|
|
373
|
+
) from exc
|
|
374
|
+
if not isinstance(payload, dict):
|
|
375
|
+
return {"rows": payload if isinstance(payload, list) else [payload]}
|
|
376
|
+
return payload
|
|
377
|
+
|
|
378
|
+
@staticmethod
|
|
379
|
+
def _safe_json(response: httpx.Response) -> Optional[Dict[str, Any]]:
|
|
380
|
+
try:
|
|
381
|
+
payload = response.json()
|
|
382
|
+
except ValueError:
|
|
383
|
+
return None
|
|
384
|
+
return payload if isinstance(payload, dict) else {"detail": payload}
|
|
385
|
+
|
|
386
|
+
def _annotate_auth_error(
|
|
387
|
+
self, exc: TwmdAuthError, path: str, params: Mapping[str, Any]
|
|
388
|
+
) -> TwmdAuthError:
|
|
389
|
+
"""Append key-free context to a 401 so the cause is obvious."""
|
|
390
|
+
dataset = path.rsplit("/", 1)[-1]
|
|
391
|
+
ticker = next(
|
|
392
|
+
(str(params[p]) for p in _TICKER_PARAMS if params.get(p) is not None), None
|
|
393
|
+
)
|
|
394
|
+
hint = access.explain(dataset, ticker)
|
|
395
|
+
if not self.has_api_key:
|
|
396
|
+
hint += f" No API key is set; export {API_KEY_ENV} to send one."
|
|
397
|
+
exc.args = (f"{exc.args[0]} — {hint}",)
|
|
398
|
+
return exc
|
|
399
|
+
|
|
400
|
+
def _sleep_before_retry(self, attempt: int, retry_after: Optional[str]) -> bool:
|
|
401
|
+
"""Wait before the next attempt. Returns whether a retry should proceed.
|
|
402
|
+
|
|
403
|
+
A ``Retry-After`` header is honoured **exactly** — no jitter, no
|
|
404
|
+
shortening — because retrying sooner than the server permitted is worse
|
|
405
|
+
than waiting too long. If it asks for longer than
|
|
406
|
+
:data:`RETRY_AFTER_MAX`, this returns ``False`` so the caller raises the
|
|
407
|
+
server's own error rather than sleeping for minutes or retrying early.
|
|
408
|
+
|
|
409
|
+
Otherwise: exponential backoff with jitter.
|
|
410
|
+
"""
|
|
411
|
+
delay = parse_retry_after(retry_after)
|
|
412
|
+
if delay is not None:
|
|
413
|
+
if delay > RETRY_AFTER_MAX:
|
|
414
|
+
return False
|
|
415
|
+
time.sleep(delay)
|
|
416
|
+
return True
|
|
417
|
+
backoff = min(self.backoff_factor * (2 ** attempt), DEFAULT_BACKOFF_MAX)
|
|
418
|
+
time.sleep(backoff * (0.5 + random.random() / 2))
|
|
419
|
+
return True
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
__all__: Sequence[str] = ["Client"]
|
twmd/errors.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Exception hierarchy for the TWMD data-access client.
|
|
2
|
+
|
|
3
|
+
Every exception carries the raw decoded response body verbatim on ``.body`` so
|
|
4
|
+
callers can render whatever the API returned without this layer editing,
|
|
5
|
+
summarising, or dropping fields.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Mapping, Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TwmdError(Exception):
|
|
14
|
+
"""Base class for every error raised by this client."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TwmdConfigError(TwmdError):
|
|
18
|
+
"""Client was constructed with an unusable configuration."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TwmdTransportError(TwmdError):
|
|
22
|
+
"""Network-level failure: DNS, connection, timeout, TLS."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TwmdAPIError(TwmdError):
|
|
26
|
+
"""The API returned a non-2xx response.
|
|
27
|
+
|
|
28
|
+
Attributes:
|
|
29
|
+
status_code: HTTP status code.
|
|
30
|
+
body: Decoded response body, verbatim. ``None`` if the body was not
|
|
31
|
+
valid JSON, in which case ``text`` holds the raw payload.
|
|
32
|
+
text: Raw response text.
|
|
33
|
+
error_code: The API's ``error`` field, when present.
|
|
34
|
+
request_url: URL that produced this response.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
status_code: int,
|
|
40
|
+
*,
|
|
41
|
+
body: Optional[Mapping[str, Any]] = None,
|
|
42
|
+
text: str = "",
|
|
43
|
+
request_url: str = "",
|
|
44
|
+
) -> None:
|
|
45
|
+
self.status_code = status_code
|
|
46
|
+
self.body = body
|
|
47
|
+
self.text = text
|
|
48
|
+
self.request_url = request_url
|
|
49
|
+
self.error_code = body.get("error") if isinstance(body, Mapping) else None
|
|
50
|
+
message = ""
|
|
51
|
+
if isinstance(body, Mapping):
|
|
52
|
+
message = str(body.get("message") or body.get("detail") or "")
|
|
53
|
+
if not message:
|
|
54
|
+
message = text[:200]
|
|
55
|
+
super().__init__(f"HTTP {status_code}: {message}" if message else f"HTTP {status_code}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class TwmdAuthError(TwmdAPIError):
|
|
59
|
+
"""HTTP 401. No API key was sent, or the key was rejected.
|
|
60
|
+
|
|
61
|
+
Observed ``error`` codes:
|
|
62
|
+
|
|
63
|
+
- ``missing_api_key`` — the request carried no key and the requested
|
|
64
|
+
dataset/ticker combination is not in the key-free set. See
|
|
65
|
+
:mod:`twmd.access`.
|
|
66
|
+
- ``invalid_api_key`` — a key was sent but is unknown or revoked.
|
|
67
|
+
|
|
68
|
+
As of the contract probe on 2026-07-21 this is the status returned for
|
|
69
|
+
*unauthenticated* access to key-gated datasets. It is not a billing signal.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class TwmdPaymentRequired(TwmdAPIError):
|
|
74
|
+
"""HTTP 402, reserved for a billing/entitlement signal.
|
|
75
|
+
|
|
76
|
+
**Not observed in the live contract.** A probe of the published OpenAPI
|
|
77
|
+
document on 2026-07-21 found zero endpoints declaring a 402 response, and
|
|
78
|
+
unauthenticated requests to key-gated datasets return
|
|
79
|
+
:class:`TwmdAuthError` (401) instead. This class exists so that if the API
|
|
80
|
+
does return 402 — the untested path is a *valid but unentitled* key, which
|
|
81
|
+
cannot be exercised without live credentials — the response reaches the
|
|
82
|
+
caller intact rather than being flattened into a generic error.
|
|
83
|
+
|
|
84
|
+
The entire decoded body is preserved verbatim on ``.body``. This client
|
|
85
|
+
deliberately does not model, validate, or require any particular field
|
|
86
|
+
layout inside it, because no 402 body has been observed to model.
|
|
87
|
+
|
|
88
|
+
Example:
|
|
89
|
+
>>> try:
|
|
90
|
+
... client.get_dataset("institutional-flow", symbol="2330")
|
|
91
|
+
... except TwmdPaymentRequired as exc:
|
|
92
|
+
... handle_payment_required(exc.body) # full body, unmodified
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class TwmdNotFoundError(TwmdAPIError):
|
|
97
|
+
"""HTTP 404. Unknown dataset path or resource."""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class TwmdValidationError(TwmdAPIError):
|
|
101
|
+
"""HTTP 422. The API rejected a parameter value.
|
|
102
|
+
|
|
103
|
+
Note that the API tolerates *unknown* query parameters silently — it
|
|
104
|
+
returns 200 and ignores them rather than raising 422. See
|
|
105
|
+
:meth:`twmd.client.Client.get_dataset` for what that means for ``as_of``.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class TwmdRateLimitError(TwmdAPIError):
|
|
110
|
+
"""HTTP 429. Retried automatically, honouring ``Retry-After`` when sent."""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class TwmdServerError(TwmdAPIError):
|
|
114
|
+
"""HTTP 5xx. Retried automatically."""
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
_STATUS_MAP = {
|
|
118
|
+
401: TwmdAuthError,
|
|
119
|
+
402: TwmdPaymentRequired,
|
|
120
|
+
404: TwmdNotFoundError,
|
|
121
|
+
422: TwmdValidationError,
|
|
122
|
+
429: TwmdRateLimitError,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def error_for_status(
|
|
127
|
+
status_code: int,
|
|
128
|
+
*,
|
|
129
|
+
body: Optional[Mapping[str, Any]] = None,
|
|
130
|
+
text: str = "",
|
|
131
|
+
request_url: str = "",
|
|
132
|
+
) -> TwmdAPIError:
|
|
133
|
+
"""Map an HTTP status code onto the matching exception class."""
|
|
134
|
+
cls = _STATUS_MAP.get(status_code)
|
|
135
|
+
if cls is None:
|
|
136
|
+
cls = TwmdServerError if status_code >= 500 else TwmdAPIError
|
|
137
|
+
return cls(status_code, body=body, text=text, request_url=request_url)
|
twmd/frames.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Convert API response envelopes into pandas DataFrames.
|
|
2
|
+
|
|
3
|
+
The response envelope observed on 2026-07-21 looks like::
|
|
4
|
+
|
|
5
|
+
{
|
|
6
|
+
"dataset": "twse_daily_price",
|
|
7
|
+
"rows": [ {...}, ... ],
|
|
8
|
+
"count": 2,
|
|
9
|
+
"data_as_of": "2026-07-17",
|
|
10
|
+
"source_role": "official_twse",
|
|
11
|
+
"lineage": {"provider": "TWSE", ..., "not_investment_advice": true},
|
|
12
|
+
"meta": {"last_trading_day": "...", "market_status": [...]}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
Not every dataset uses that shape. Two envelope variants were observed on
|
|
16
|
+
2026-07-21:
|
|
17
|
+
|
|
18
|
+
``rows`` / ``count``
|
|
19
|
+
Used by ``twse-daily-price``, ``tpex-daily-price``, ``monthly-revenue``.
|
|
20
|
+
Records are flat. ``monthly-revenue`` sends a shorter envelope carrying only
|
|
21
|
+
``dataset``, ``rows`` and ``count``.
|
|
22
|
+
``items`` / ``row_count``
|
|
23
|
+
Used by ``security-master`` and ``market-index``. Records contain *nested*
|
|
24
|
+
objects — ``security_identity``, ``market_identity``, ``index_level`` and so
|
|
25
|
+
on — and the envelope carries extra integrity metadata such as
|
|
26
|
+
``survivorship_bias_warning``.
|
|
27
|
+
``data`` / ``quality.row_count``
|
|
28
|
+
Used by ``delisting`` and ``stock-delisting-lifecycle``. Adds
|
|
29
|
+
``request_context`` (the filters the server actually applied, plus the
|
|
30
|
+
min/max dates it holds), a ``lineage`` block naming the official source
|
|
31
|
+
family, and a top-level ``error`` slot.
|
|
32
|
+
|
|
33
|
+
Catalog endpoints under ``/v2/data-catalog/`` name their list after themselves
|
|
34
|
+
(``reconciliation``, ``stats``); these are picked up by a generic fallback
|
|
35
|
+
rather than an entry per endpoint.
|
|
36
|
+
|
|
37
|
+
:func:`to_dataframe` accepts both. Nested record fields are left as dict-valued
|
|
38
|
+
columns rather than flattened, so what you get back is what the API sent; use
|
|
39
|
+
:func:`pandas.json_normalize` on the column if you want it expanded.
|
|
40
|
+
|
|
41
|
+
Everything outside the record list is preserved on ``DataFrame.attrs`` rather
|
|
42
|
+
than discarded — including the API's own ``lineage.not_investment_advice`` flag
|
|
43
|
+
and any ``survivorship_bias_warning``, both passed through untouched.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
from typing import Any, Mapping, Optional, Tuple
|
|
49
|
+
|
|
50
|
+
import pandas as pd
|
|
51
|
+
|
|
52
|
+
#: Envelope keys known to hold the record list, in precedence order.
|
|
53
|
+
RECORD_KEYS: Tuple[str, ...] = ("rows", "items", "data")
|
|
54
|
+
|
|
55
|
+
#: Envelope keys that may hold the server-reported record count.
|
|
56
|
+
COUNT_KEYS: Tuple[str, ...] = ("count", "row_count")
|
|
57
|
+
|
|
58
|
+
#: Envelope keys that never hold records, so the generic fallback skips them.
|
|
59
|
+
_NON_RECORD_KEYS = frozenset({"lineage", "meta", "quality", "request_context", "error"})
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def record_key(payload: Mapping[str, Any]) -> Optional[str]:
|
|
63
|
+
"""Return which envelope key holds the records, or ``None`` if none does.
|
|
64
|
+
|
|
65
|
+
Tries the known keys first, then falls back to the first list-valued key
|
|
66
|
+
that is not known metadata. The fallback covers catalog endpoints that name
|
|
67
|
+
their list after themselves — ``/v2/data-catalog/reconciliation`` returns
|
|
68
|
+
``reconciliation``, ``/v2/data-catalog/stats`` returns ``stats`` — without
|
|
69
|
+
needing an entry per endpoint.
|
|
70
|
+
"""
|
|
71
|
+
for key in RECORD_KEYS:
|
|
72
|
+
if isinstance(payload.get(key), list):
|
|
73
|
+
return key
|
|
74
|
+
for key, value in payload.items():
|
|
75
|
+
if key not in _NON_RECORD_KEYS and isinstance(value, list):
|
|
76
|
+
return key
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def server_count(payload: Mapping[str, Any]) -> Optional[int]:
|
|
81
|
+
"""Return the server-reported record count across every envelope variant.
|
|
82
|
+
|
|
83
|
+
Checks the top level first, then ``quality.row_count``, where the
|
|
84
|
+
``data``-shaped envelopes put it.
|
|
85
|
+
"""
|
|
86
|
+
for key in COUNT_KEYS:
|
|
87
|
+
value = payload.get(key)
|
|
88
|
+
if isinstance(value, int):
|
|
89
|
+
return value
|
|
90
|
+
quality = payload.get("quality")
|
|
91
|
+
if isinstance(quality, Mapping):
|
|
92
|
+
for key in COUNT_KEYS:
|
|
93
|
+
value = quality.get(key)
|
|
94
|
+
if isinstance(value, int):
|
|
95
|
+
return value
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def to_dataframe(payload: Mapping[str, Any]) -> pd.DataFrame:
|
|
100
|
+
"""Build a DataFrame from one response envelope.
|
|
101
|
+
|
|
102
|
+
Handles both the ``rows``/``count`` and ``items``/``row_count`` variants.
|
|
103
|
+
Records become rows; every other envelope key is copied onto ``df.attrs``
|
|
104
|
+
verbatim, so lineage, freshness and integrity metadata survive.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
payload: Decoded JSON response body.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
A DataFrame of the records. Empty (no columns) when the record list is
|
|
111
|
+
empty or absent.
|
|
112
|
+
"""
|
|
113
|
+
key = record_key(payload)
|
|
114
|
+
records = payload.get(key) or [] if key else []
|
|
115
|
+
frame = pd.DataFrame(records)
|
|
116
|
+
frame.attrs.update({k: v for k, v in payload.items() if k != key})
|
|
117
|
+
return frame
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def concat_frames(frames: "list[pd.DataFrame]") -> pd.DataFrame:
|
|
121
|
+
"""Concatenate paginated frames, keeping the first page's metadata.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
frames: Frames in page order.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
One DataFrame with a fresh RangeIndex. ``attrs`` is taken from the
|
|
128
|
+
first page, with whichever count key that page used — ``count`` or
|
|
129
|
+
``row_count`` — replaced by the combined record total.
|
|
130
|
+
"""
|
|
131
|
+
if not frames:
|
|
132
|
+
return pd.DataFrame()
|
|
133
|
+
if len(frames) == 1:
|
|
134
|
+
return frames[0]
|
|
135
|
+
combined = pd.concat(frames, ignore_index=True)
|
|
136
|
+
combined.attrs.update(frames[0].attrs)
|
|
137
|
+
for key in COUNT_KEYS:
|
|
138
|
+
if key in combined.attrs:
|
|
139
|
+
combined.attrs[key] = len(combined)
|
|
140
|
+
combined.attrs["pages_fetched"] = len(frames)
|
|
141
|
+
return combined
|