vquanty 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.
Potentially problematic release.
This version of vquanty might be problematic. Click here for more details.
- vquanty/__init__.py +41 -0
- vquanty/_client.py +218 -0
- vquanty/_state.py +17 -0
- vquanty/data.py +33 -0
- vquanty-0.1.0.dist-info/METADATA +35 -0
- vquanty-0.1.0.dist-info/RECORD +7 -0
- vquanty-0.1.0.dist-info/WHEEL +4 -0
vquanty/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Client for the personal TEJ data API.
|
|
2
|
+
|
|
3
|
+
import vquanty
|
|
4
|
+
vquanty.login("tej_...") # saved for next time
|
|
5
|
+
from vquanty import data
|
|
6
|
+
close = data.get("price:收盤價")
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from . import _state, data
|
|
12
|
+
from ._client import CONFIG_PATH, AuthError, Client, Config, VquantyError
|
|
13
|
+
|
|
14
|
+
__all__ = ["AuthError", "Client", "Config", "VquantyError", "data", "login", "logout"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def login(
|
|
18
|
+
api_key: str,
|
|
19
|
+
base_url: str | None = None,
|
|
20
|
+
cf_client_id: str | None = None,
|
|
21
|
+
cf_client_secret: str | None = None,
|
|
22
|
+
save: bool = True,
|
|
23
|
+
) -> None:
|
|
24
|
+
"""Set credentials; with ``save`` they persist in ~/.config/vquanty/config.json (0600)."""
|
|
25
|
+
config = Config.load()
|
|
26
|
+
config.api_key = api_key
|
|
27
|
+
config.base_url = base_url or config.base_url
|
|
28
|
+
config.cf_client_id = cf_client_id or config.cf_client_id
|
|
29
|
+
config.cf_client_secret = cf_client_secret or config.cf_client_secret
|
|
30
|
+
if not config.base_url:
|
|
31
|
+
raise ValueError("base_url is required on first login")
|
|
32
|
+
client = Client(config)
|
|
33
|
+
client.get_json("/v1/datasets") # fail fast on a bad key or URL
|
|
34
|
+
if save:
|
|
35
|
+
config.save()
|
|
36
|
+
_state.reset(client)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def logout() -> None:
|
|
40
|
+
CONFIG_PATH.unlink(missing_ok=True)
|
|
41
|
+
_state.reset()
|
vquanty/_client.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""HTTP client with an ETag-validated local Parquet cache.
|
|
2
|
+
|
|
3
|
+
Each key is cached on disk as ``<sha1(key)>.parquet`` plus a JSON sidecar
|
|
4
|
+
holding the server's ETag (the data fingerprint). A fetch sends the ETag back
|
|
5
|
+
as ``If-None-Match``; the server answers 304 when nothing changed, so a frame
|
|
6
|
+
is downloaded once per data update per device. Within one process a frame is
|
|
7
|
+
not revalidated again for ``revalidate_after`` seconds.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import time
|
|
16
|
+
import warnings
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import pandas as pd
|
|
21
|
+
import requests
|
|
22
|
+
|
|
23
|
+
CONFIG_PATH = Path(
|
|
24
|
+
os.environ.get("VQUANTY_CONFIG") or Path.home() / ".config/vquanty/config.json"
|
|
25
|
+
)
|
|
26
|
+
DEFAULT_BASE_URL = "https://vquanty.ai-quantamental.com"
|
|
27
|
+
DEFAULT_CACHE = Path(
|
|
28
|
+
os.environ.get("VQUANTY_CACHE_DIR") or Path.home() / ".cache/vquanty"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class VquantyError(RuntimeError):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AuthError(VquantyError):
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Config:
|
|
42
|
+
api_key: str | None = None
|
|
43
|
+
base_url: str | None = None
|
|
44
|
+
cf_client_id: str | None = None
|
|
45
|
+
cf_client_secret: str | None = None
|
|
46
|
+
cache_dir: Path = DEFAULT_CACHE
|
|
47
|
+
revalidate_after: float = 300.0
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def load(cls) -> Config:
|
|
51
|
+
saved: dict = {}
|
|
52
|
+
if CONFIG_PATH.exists():
|
|
53
|
+
saved = json.loads(CONFIG_PATH.read_text())
|
|
54
|
+
env = os.environ.get
|
|
55
|
+
return cls(
|
|
56
|
+
api_key=env("VQUANTY_API_KEY") or saved.get("api_key"),
|
|
57
|
+
base_url=env("VQUANTY_BASE_URL")
|
|
58
|
+
or saved.get("base_url")
|
|
59
|
+
or DEFAULT_BASE_URL,
|
|
60
|
+
cf_client_id=env("VQUANTY_CF_CLIENT_ID") or saved.get("cf_client_id"),
|
|
61
|
+
cf_client_secret=env("VQUANTY_CF_CLIENT_SECRET")
|
|
62
|
+
or saved.get("cf_client_secret"),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def save(self) -> None:
|
|
66
|
+
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
payload = {
|
|
68
|
+
k: v
|
|
69
|
+
for k, v in {
|
|
70
|
+
"api_key": self.api_key,
|
|
71
|
+
"base_url": None
|
|
72
|
+
if self.base_url == DEFAULT_BASE_URL
|
|
73
|
+
else self.base_url,
|
|
74
|
+
"cf_client_id": self.cf_client_id,
|
|
75
|
+
"cf_client_secret": self.cf_client_secret,
|
|
76
|
+
}.items()
|
|
77
|
+
if v
|
|
78
|
+
}
|
|
79
|
+
tmp = CONFIG_PATH.with_suffix(".tmp")
|
|
80
|
+
tmp.write_text(json.dumps(payload, indent=2))
|
|
81
|
+
os.chmod(tmp, 0o600)
|
|
82
|
+
os.replace(tmp, CONFIG_PATH)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class Client:
|
|
87
|
+
config: Config = field(default_factory=Config.load)
|
|
88
|
+
_memo: dict[str, tuple[str, pd.DataFrame, float]] = field(
|
|
89
|
+
default_factory=dict, repr=False
|
|
90
|
+
)
|
|
91
|
+
_session: requests.Session = field(default_factory=requests.Session, repr=False)
|
|
92
|
+
|
|
93
|
+
# -- http -------------------------------------------------------------------
|
|
94
|
+
def _headers(self) -> dict[str, str]:
|
|
95
|
+
cfg = self.config
|
|
96
|
+
if not cfg.api_key or not cfg.base_url:
|
|
97
|
+
raise AuthError("not logged in; call vquanty.login(api_key)")
|
|
98
|
+
headers = {"Authorization": f"Bearer {cfg.api_key}"}
|
|
99
|
+
if cfg.cf_client_id and cfg.cf_client_secret:
|
|
100
|
+
headers["CF-Access-Client-Id"] = cfg.cf_client_id
|
|
101
|
+
headers["CF-Access-Client-Secret"] = cfg.cf_client_secret
|
|
102
|
+
return headers
|
|
103
|
+
|
|
104
|
+
def _url(self, path: str) -> str:
|
|
105
|
+
return self.config.base_url.rstrip("/") + path
|
|
106
|
+
|
|
107
|
+
@staticmethod
|
|
108
|
+
def _raise_for(resp: requests.Response) -> None:
|
|
109
|
+
if resp.status_code < 400:
|
|
110
|
+
return
|
|
111
|
+
try:
|
|
112
|
+
detail = resp.json().get("detail", resp.text)
|
|
113
|
+
except ValueError:
|
|
114
|
+
detail = resp.text[:300]
|
|
115
|
+
if resp.status_code == 401:
|
|
116
|
+
raise AuthError(f"API key rejected: {detail}")
|
|
117
|
+
if resp.status_code == 403:
|
|
118
|
+
raise AuthError(
|
|
119
|
+
"blocked before reaching the server (Cloudflare Access?); check cf_client_id/secret"
|
|
120
|
+
)
|
|
121
|
+
if resp.status_code == 404:
|
|
122
|
+
raise KeyError(detail)
|
|
123
|
+
raise VquantyError(f"HTTP {resp.status_code}: {detail}")
|
|
124
|
+
|
|
125
|
+
def get_json(self, path: str):
|
|
126
|
+
resp = self._session.get(
|
|
127
|
+
self._url(path), headers=self._headers(), timeout=(10, 60)
|
|
128
|
+
)
|
|
129
|
+
self._raise_for(resp)
|
|
130
|
+
return resp.json()
|
|
131
|
+
|
|
132
|
+
# -- cache ------------------------------------------------------------------
|
|
133
|
+
def _paths(self, key: str) -> tuple[Path, Path]:
|
|
134
|
+
stem = hashlib.sha1(key.encode()).hexdigest()
|
|
135
|
+
base = Path(self.config.cache_dir)
|
|
136
|
+
return base / f"{stem}.parquet", base / f"{stem}.json"
|
|
137
|
+
|
|
138
|
+
def _read_cached(self, key: str) -> tuple[str, pd.DataFrame] | None:
|
|
139
|
+
data_path, meta_path = self._paths(key)
|
|
140
|
+
if not (data_path.exists() and meta_path.exists()):
|
|
141
|
+
return None
|
|
142
|
+
meta = json.loads(meta_path.read_text())
|
|
143
|
+
return meta["etag"], pd.read_parquet(data_path)
|
|
144
|
+
|
|
145
|
+
def get(self, key: str, force_download: bool = False) -> pd.DataFrame:
|
|
146
|
+
now = time.monotonic()
|
|
147
|
+
memo = self._memo.get(key)
|
|
148
|
+
if memo and not force_download and now - memo[2] < self.config.revalidate_after:
|
|
149
|
+
return memo[1].copy()
|
|
150
|
+
|
|
151
|
+
cached = (
|
|
152
|
+
None
|
|
153
|
+
if force_download
|
|
154
|
+
else ((memo[0], memo[1]) if memo else self._read_cached(key))
|
|
155
|
+
)
|
|
156
|
+
headers = self._headers()
|
|
157
|
+
if cached:
|
|
158
|
+
headers["If-None-Match"] = cached[0]
|
|
159
|
+
try:
|
|
160
|
+
resp = self._session.get(
|
|
161
|
+
self._url("/v1/data"),
|
|
162
|
+
params={"key": key},
|
|
163
|
+
headers=headers,
|
|
164
|
+
timeout=(10, 600),
|
|
165
|
+
stream=True,
|
|
166
|
+
)
|
|
167
|
+
except requests.RequestException as exc:
|
|
168
|
+
if cached:
|
|
169
|
+
warnings.warn(
|
|
170
|
+
f"server unreachable ({exc.__class__.__name__}); using cached {key}",
|
|
171
|
+
stacklevel=2,
|
|
172
|
+
)
|
|
173
|
+
self._memo[key] = (cached[0], cached[1], now)
|
|
174
|
+
return cached[1].copy()
|
|
175
|
+
raise VquantyError(
|
|
176
|
+
f"server unreachable and {key!r} not cached: {exc}"
|
|
177
|
+
) from exc
|
|
178
|
+
|
|
179
|
+
if resp.status_code == 304 and cached:
|
|
180
|
+
resp.close()
|
|
181
|
+
self._memo[key] = (cached[0], cached[1], now)
|
|
182
|
+
return cached[1].copy()
|
|
183
|
+
if resp.status_code == 503 and cached:
|
|
184
|
+
resp.close()
|
|
185
|
+
warnings.warn(
|
|
186
|
+
f"server busy updating data; using cached {key}", stacklevel=2
|
|
187
|
+
)
|
|
188
|
+
return cached[1].copy()
|
|
189
|
+
self._raise_for(resp)
|
|
190
|
+
|
|
191
|
+
data_path, meta_path = self._paths(key)
|
|
192
|
+
data_path.parent.mkdir(parents=True, exist_ok=True)
|
|
193
|
+
tmp = data_path.with_suffix(f".{os.getpid()}.tmp")
|
|
194
|
+
with tmp.open("wb") as out:
|
|
195
|
+
for chunk in resp.iter_content(chunk_size=1 << 20):
|
|
196
|
+
out.write(chunk)
|
|
197
|
+
os.replace(tmp, data_path)
|
|
198
|
+
etag = resp.headers.get("ETag", "")
|
|
199
|
+
meta_path.write_text(
|
|
200
|
+
json.dumps(
|
|
201
|
+
{
|
|
202
|
+
"key": key,
|
|
203
|
+
"etag": etag,
|
|
204
|
+
"server_key": resp.headers.get("X-Tej-Key"),
|
|
205
|
+
"fetched_at": time.time(),
|
|
206
|
+
},
|
|
207
|
+
ensure_ascii=False,
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
df = pd.read_parquet(data_path)
|
|
211
|
+
self._memo[key] = (etag, df, now)
|
|
212
|
+
return df.copy()
|
|
213
|
+
|
|
214
|
+
def clear_cache(self) -> None:
|
|
215
|
+
self._memo.clear()
|
|
216
|
+
base = Path(self.config.cache_dir)
|
|
217
|
+
for path in list(base.glob("*.parquet")) + list(base.glob("*.json")):
|
|
218
|
+
path.unlink(missing_ok=True)
|
vquanty/_state.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ._client import Client
|
|
4
|
+
|
|
5
|
+
_client: Client | None = None
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def client() -> Client:
|
|
9
|
+
global _client
|
|
10
|
+
if _client is None:
|
|
11
|
+
_client = Client()
|
|
12
|
+
return _client
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def reset(new: Client | None = None) -> None:
|
|
16
|
+
global _client
|
|
17
|
+
_client = new
|
vquanty/data.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""FinLab-style data access.
|
|
2
|
+
|
|
3
|
+
from vquanty import data
|
|
4
|
+
close = data.get("price:收盤價") # DataFrame, index=date, columns=coid
|
|
5
|
+
data.datasets() # what can be pivoted
|
|
6
|
+
data.fields("price") # field keys of one dataset
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import pandas as pd
|
|
12
|
+
|
|
13
|
+
from . import _state
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get(key: str, force_download: bool = False) -> pd.DataFrame:
|
|
17
|
+
"""Wide frame for ``dataset:field``; downloads only when the server data changed."""
|
|
18
|
+
return _state.client().get(key, force_download=force_download)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def datasets() -> pd.DataFrame:
|
|
22
|
+
return pd.DataFrame(_state.client().get_json("/v1/datasets"))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def fields(dataset: str) -> pd.DataFrame:
|
|
26
|
+
from urllib.parse import quote
|
|
27
|
+
|
|
28
|
+
info = _state.client().get_json(f"/v1/datasets/{quote(dataset, safe='')}")
|
|
29
|
+
return pd.DataFrame(info["fields"])
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def clear_cache() -> None:
|
|
33
|
+
_state.client().clear_cache()
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: vquanty
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Client for the personal TEJ data API (FinLab-style wide frames with local cache)
|
|
5
|
+
Classifier: Operating System :: OS Independent
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Requires-Dist: pandas>=2.0
|
|
9
|
+
Requires-Dist: pyarrow>=14
|
|
10
|
+
Requires-Dist: requests>=2.31
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# vquanty
|
|
14
|
+
|
|
15
|
+
Client for a personal Taiwan market data API. Returns FinLab-style wide
|
|
16
|
+
frames (index = date, columns = security code) and caches them locally;
|
|
17
|
+
a frame is downloaded again only when the server data changes.
|
|
18
|
+
|
|
19
|
+
Access requires an API key issued by the server owner.
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install vquanty
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
import vquanty
|
|
27
|
+
|
|
28
|
+
vquanty.login("tej_...") # or set VQUANTY_API_KEY
|
|
29
|
+
from vquanty import data
|
|
30
|
+
|
|
31
|
+
close = data.get("price:收盤價")
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Environment variables: `VQUANTY_API_KEY`, `VQUANTY_BASE_URL`,
|
|
35
|
+
`VQUANTY_CACHE_DIR`, `VQUANTY_CF_CLIENT_ID`, `VQUANTY_CF_CLIENT_SECRET`.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
vquanty/__init__.py,sha256=8OSPBcWtcZk6inIhKz3pqngiYcIDNOgyiPJXyaqsz6c,1241
|
|
2
|
+
vquanty/_client.py,sha256=OM5zuTGPEBcQUCsyONpwONx3bJyUfDL4GFaHzJB6_5w,7548
|
|
3
|
+
vquanty/_state.py,sha256=4tOkP_Te1khHrmAV7YjTsv3ti40jmG_0pbfuyz3uBt4,295
|
|
4
|
+
vquanty/data.py,sha256=0YU-CJXdh4qJZl2nfOoTRuZCNChoRbE5IMLqIUYXIlk,933
|
|
5
|
+
vquanty-0.1.0.dist-info/METADATA,sha256=aJV1TRozE78Lo2HGHa9xeNRf-0wsbXp8bcXT1KtDhM8,972
|
|
6
|
+
vquanty-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
7
|
+
vquanty-0.1.0.dist-info/RECORD,,
|