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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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