vquanty 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of vquanty might be problematic. Click here for more details.

@@ -0,0 +1,10 @@
1
+ __pycache__/
2
+ *.py[oc]
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info
7
+ .env
8
+ .pytest_cache/
9
+ .ruff_cache/
10
+ .DS_Store
vquanty-0.1.0/PKG-INFO ADDED
@@ -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,23 @@
1
+ # vquanty
2
+
3
+ Client for a personal Taiwan market data API. Returns FinLab-style wide
4
+ frames (index = date, columns = security code) and caches them locally;
5
+ a frame is downloaded again only when the server data changes.
6
+
7
+ Access requires an API key issued by the server owner.
8
+
9
+ ```bash
10
+ pip install vquanty
11
+ ```
12
+
13
+ ```python
14
+ import vquanty
15
+
16
+ vquanty.login("tej_...") # or set VQUANTY_API_KEY
17
+ from vquanty import data
18
+
19
+ close = data.get("price:收盤價")
20
+ ```
21
+
22
+ Environment variables: `VQUANTY_API_KEY`, `VQUANTY_BASE_URL`,
23
+ `VQUANTY_CACHE_DIR`, `VQUANTY_CF_CLIENT_ID`, `VQUANTY_CF_CLIENT_SECRET`.
@@ -0,0 +1,35 @@
1
+ [project]
2
+ name = "vquanty"
3
+ version = "0.1.0"
4
+ description = "Client for the personal TEJ data API (FinLab-style wide frames with local cache)"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ classifiers = [
8
+ "Programming Language :: Python :: 3",
9
+ "Operating System :: OS Independent",
10
+ ]
11
+ dependencies = [
12
+ "pandas>=2.0",
13
+ "pyarrow>=14",
14
+ "requests>=2.31",
15
+ ]
16
+
17
+ [build-system]
18
+ requires = ["hatchling"]
19
+ build-backend = "hatchling.build"
20
+
21
+ [tool.hatch.build.targets.wheel]
22
+ packages = ["vquanty"]
23
+
24
+ [tool.hatch.build.targets.sdist]
25
+ only-include = ["vquanty", "README.md", "pyproject.toml"]
26
+
27
+ [dependency-groups]
28
+ dev = [
29
+ "pytest>=8",
30
+ "python-dotenv>=1",
31
+ "ruff>=0.6",
32
+ ]
33
+
34
+ [tool.pytest.ini_options]
35
+ testpaths = ["tests"]
@@ -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()
@@ -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)
@@ -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
@@ -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()