pyqauto 0.3.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.
pyqauto/__init__.py ADDED
@@ -0,0 +1,133 @@
1
+ """Simple public API for pyqauto."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from .models import KlineBar, QuoteRecord
8
+ from .router import QuoteRouter
9
+
10
+ __version__ = "0.3.0"
11
+
12
+ _default_router: QuoteRouter | None = None
13
+
14
+
15
+ def configure(
16
+ *,
17
+ pytdx_servers_path: str | Path | None = None,
18
+ source_policy_path: str | Path | None = None,
19
+ audit_jsonl_path: str | Path | None = None,
20
+ audit_sqlite_path: str | Path | None = None,
21
+ ) -> QuoteRouter:
22
+ """Configure and cache the default router for module-level calls."""
23
+
24
+ global _default_router
25
+ _default_router = QuoteRouter.from_config(
26
+ pytdx_servers_path=pytdx_servers_path,
27
+ source_policy_path=source_policy_path,
28
+ audit_jsonl_path=audit_jsonl_path,
29
+ audit_sqlite_path=audit_sqlite_path,
30
+ )
31
+ return _default_router
32
+
33
+
34
+ def get_router() -> QuoteRouter:
35
+ """Return the cached default router, creating it from bundled defaults."""
36
+
37
+ global _default_router
38
+ if _default_router is None:
39
+ _default_router = QuoteRouter.from_config()
40
+ return _default_router
41
+
42
+
43
+ def quote(symbol: str, *, include_raw: bool = False) -> QuoteRecord:
44
+ """Fetch one realtime quote."""
45
+
46
+ return get_router().realtime_quotes([symbol], include_raw=include_raw)[0]
47
+
48
+
49
+ def quotes(symbols: list[str], *, include_raw: bool = False) -> list[QuoteRecord]:
50
+ """Fetch realtime quotes."""
51
+
52
+ return get_router().realtime_quotes(list(symbols), include_raw=include_raw)
53
+
54
+
55
+ def full_quotes(symbols: list[str], *, include_raw: bool = False) -> list[QuoteRecord]:
56
+ """Fetch full realtime quotes."""
57
+
58
+ return get_router().full_realtime_quotes(list(symbols), include_raw=include_raw)
59
+
60
+
61
+ def index(symbols: list[str], *, include_raw: bool = False) -> list[QuoteRecord]:
62
+ """Fetch index realtime quotes."""
63
+
64
+ return get_router().index_realtime(list(symbols), include_raw=include_raw)
65
+
66
+
67
+ def minute(
68
+ symbol: str,
69
+ *,
70
+ period: str = "1m",
71
+ count: int = 240,
72
+ include_raw: bool = False,
73
+ ) -> list[KlineBar]:
74
+ """Fetch pytdx-only minute K-line bars."""
75
+
76
+ return get_router().minute_kline(
77
+ symbol,
78
+ period=period,
79
+ count=count,
80
+ include_raw=include_raw,
81
+ )
82
+
83
+
84
+ def daily(
85
+ symbol: str,
86
+ *,
87
+ count: int = 120,
88
+ include_raw: bool = False,
89
+ ) -> list[KlineBar]:
90
+ """Fetch pytdx-only daily K-line bars."""
91
+
92
+ return get_router().daily_kline(symbol, count=count, include_raw=include_raw)
93
+
94
+
95
+ def kline(
96
+ symbol: str,
97
+ *,
98
+ period: str = "1m",
99
+ count: int = 120,
100
+ include_raw: bool = False,
101
+ ) -> list[KlineBar]:
102
+ """Fetch K-line bars through the unified K-line API."""
103
+
104
+ return get_router().kline(
105
+ symbol,
106
+ period=period,
107
+ count=count,
108
+ include_raw=include_raw,
109
+ )
110
+
111
+
112
+ def diagnose() -> dict:
113
+ """Return local router configuration without connecting to providers."""
114
+
115
+ return get_router().diagnose()
116
+
117
+
118
+ __all__ = [
119
+ "KlineBar",
120
+ "QuoteRecord",
121
+ "QuoteRouter",
122
+ "__version__",
123
+ "configure",
124
+ "daily",
125
+ "diagnose",
126
+ "full_quotes",
127
+ "get_router",
128
+ "index",
129
+ "kline",
130
+ "minute",
131
+ "quote",
132
+ "quotes",
133
+ ]
@@ -0,0 +1,11 @@
1
+ """Quote source adapters."""
2
+
3
+ from .easyquotation_sina_adapter import EasyQuotationSinaAdapter
4
+ from .easyquotation_tencent_adapter import EasyQuotationTencentAdapter
5
+ from .pytdx_adapter import PytdxAdapter
6
+
7
+ __all__ = [
8
+ "EasyQuotationSinaAdapter",
9
+ "EasyQuotationTencentAdapter",
10
+ "PytdxAdapter",
11
+ ]
@@ -0,0 +1,139 @@
1
+ """Base adapter contracts and shared helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from pyqauto.exceptions import AdapterError, UnsupportedSymbolError
9
+ from pyqauto.models import KlineBar, QuoteRecord
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class PytdxServer:
14
+ """One pytdx server configuration entry."""
15
+
16
+ host: str
17
+ port: int
18
+ role: str
19
+ latency_ms: int
20
+ enabled: bool = True
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class NormalizedSymbol:
25
+ """Provider-ready symbol code and pytdx market id."""
26
+
27
+ code: str
28
+ market: int
29
+ suffix: str | None = None
30
+
31
+
32
+ class BaseQuoteAdapter:
33
+ """Synchronous quote adapter interface."""
34
+
35
+ source: str = "unknown"
36
+ source_level: str | None = None
37
+
38
+ def realtime_quotes(
39
+ self, symbols: list[str], *, include_raw: bool = False
40
+ ) -> list[QuoteRecord]:
41
+ raise AdapterError(f"{self.source} does not support realtime_quotes")
42
+
43
+ def full_realtime_quotes(
44
+ self, symbols: list[str], *, include_raw: bool = False
45
+ ) -> list[QuoteRecord]:
46
+ return self.realtime_quotes(symbols, include_raw=include_raw)
47
+
48
+ def index_realtime(
49
+ self, symbols: list[str], *, include_raw: bool = False
50
+ ) -> list[QuoteRecord]:
51
+ return self.realtime_quotes(symbols, include_raw=include_raw)
52
+
53
+ def minute_kline(
54
+ self,
55
+ symbol: str,
56
+ *,
57
+ period: str = "1m",
58
+ count: int = 240,
59
+ include_raw: bool = False,
60
+ ) -> list[KlineBar]:
61
+ raise AdapterError(f"{self.source} does not support minute_kline")
62
+
63
+ def daily_kline(
64
+ self,
65
+ symbol: str,
66
+ *,
67
+ count: int = 120,
68
+ include_raw: bool = False,
69
+ ) -> list[KlineBar]:
70
+ raise AdapterError(f"{self.source} does not support daily_kline")
71
+
72
+
73
+ def as_float(value: Any) -> float | None:
74
+ """Best-effort conversion to float with empty values mapped to None."""
75
+
76
+ if value is None or value == "":
77
+ return None
78
+ try:
79
+ return float(value)
80
+ except (TypeError, ValueError):
81
+ return None
82
+
83
+
84
+ def first_value(row: dict[str, Any], keys: tuple[str, ...]) -> Any:
85
+ """Return the first present value for a tuple of candidate keys."""
86
+
87
+ for key in keys:
88
+ if key in row and row[key] not in (None, ""):
89
+ return row[key]
90
+ return None
91
+
92
+
93
+ def market_for_symbol(symbol: str) -> int:
94
+ """Return pytdx market id for a common A-share symbol."""
95
+
96
+ return normalize_symbol(symbol).market
97
+
98
+
99
+ def code_for_symbol(symbol: str) -> str:
100
+ """Return the six-digit provider code for a documented symbol."""
101
+
102
+ return normalize_symbol(symbol).code
103
+
104
+
105
+ def normalize_symbol(symbol: str) -> NormalizedSymbol:
106
+ """Normalize documented A-share symbol forms.
107
+
108
+ Supported forms are six digits and six digits followed by .SH or .SZ.
109
+ Bare 6/5/9 prefixes use Shanghai, while bare 0/3 prefixes use Shenzhen.
110
+ """
111
+
112
+ raw = str(symbol or "").strip().upper()
113
+ if "." in raw:
114
+ code, separator, suffix = raw.partition(".")
115
+ if separator != "." or suffix not in {"SH", "SZ"}:
116
+ raise UnsupportedSymbolError(f"unsupported symbol suffix: {symbol}")
117
+ if not _is_six_digit_code(code):
118
+ raise UnsupportedSymbolError(f"unsupported symbol code: {symbol}")
119
+ return NormalizedSymbol(code=code, market=1 if suffix == "SH" else 0, suffix=suffix)
120
+
121
+ if not _is_six_digit_code(raw):
122
+ raise UnsupportedSymbolError(f"unsupported symbol code: {symbol}")
123
+ if raw.startswith(("5", "6", "9")):
124
+ return NormalizedSymbol(code=raw, market=1)
125
+ if raw.startswith(("0", "3")):
126
+ return NormalizedSymbol(code=raw, market=0)
127
+ raise UnsupportedSymbolError(f"unsupported symbol prefix: {symbol}")
128
+
129
+
130
+ def source_id(source: str, source_level: str | None) -> str:
131
+ """Return a compact source identifier for fallback chains."""
132
+
133
+ if source_level:
134
+ return f"{source}:{source_level}"
135
+ return source
136
+
137
+
138
+ def _is_six_digit_code(value: str) -> bool:
139
+ return len(value) == 6 and value.isdigit()
@@ -0,0 +1,100 @@
1
+ """easyquotation Sina adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from pyqauto.adapters.base import (
8
+ BaseQuoteAdapter,
9
+ as_float,
10
+ code_for_symbol,
11
+ first_value,
12
+ )
13
+ from pyqauto.exceptions import AdapterError, ErrorCode, SourceUnavailableError
14
+ from pyqauto.models import QuoteRecord
15
+
16
+
17
+ class EasyQuotationSinaAdapter(BaseQuoteAdapter):
18
+ """Adapter for easyquotation's Sina provider."""
19
+
20
+ source = "easyquotation_sina"
21
+ provider = "sina"
22
+
23
+ def realtime_quotes(
24
+ self, symbols: list[str], *, include_raw: bool = False
25
+ ) -> list[QuoteRecord]:
26
+ return self._stocks(symbols, include_raw=include_raw)
27
+
28
+ def full_realtime_quotes(
29
+ self, symbols: list[str], *, include_raw: bool = False
30
+ ) -> list[QuoteRecord]:
31
+ return self._stocks(symbols, include_raw=include_raw)
32
+
33
+ def index_realtime(
34
+ self, symbols: list[str], *, include_raw: bool = False
35
+ ) -> list[QuoteRecord]:
36
+ return self._stocks(symbols, include_raw=include_raw)
37
+
38
+ def _stocks(self, symbols: list[str], *, include_raw: bool) -> list[QuoteRecord]:
39
+ if not symbols:
40
+ return []
41
+
42
+ normalized_symbols = [code_for_symbol(symbol) for symbol in symbols]
43
+ quotation = self._quotation()
44
+ data = quotation.stocks(normalized_symbols)
45
+ if not data:
46
+ raise SourceUnavailableError(
47
+ f"{self.source} returned no quote records",
48
+ code=_source_error_code(self.source),
49
+ )
50
+
51
+ records: list[QuoteRecord] = []
52
+ for symbol in normalized_symbols:
53
+ row = data.get(symbol) or data.get(symbol.lower()) or data.get(symbol.upper())
54
+ if row:
55
+ records.append(self._normalize(symbol, row, include_raw=include_raw))
56
+ if not records:
57
+ raise SourceUnavailableError(
58
+ f"{self.source} returned no requested symbols",
59
+ code=_source_error_code(self.source),
60
+ )
61
+ return records
62
+
63
+ def _quotation(self) -> Any:
64
+ try:
65
+ import easyquotation
66
+ except Exception as exc: # pragma: no cover - depends on user env
67
+ raise AdapterError("easyquotation package is not available") from exc
68
+ return easyquotation.use(self.provider)
69
+
70
+ def _normalize(
71
+ self, symbol: str, row: dict[str, Any], *, include_raw: bool
72
+ ) -> QuoteRecord:
73
+ date_value = first_value(row, ("date",))
74
+ time_value = first_value(row, ("time",))
75
+ if date_value and time_value:
76
+ dt_value = f"{date_value} {time_value}"
77
+ else:
78
+ dt_value = str(first_value(row, ("datetime", "time")) or "") or None
79
+
80
+ return QuoteRecord(
81
+ symbol=symbol,
82
+ name=first_value(row, ("name",)),
83
+ price=as_float(first_value(row, ("now", "price", "close"))),
84
+ open=as_float(first_value(row, ("open",))),
85
+ high=as_float(first_value(row, ("high",))),
86
+ low=as_float(first_value(row, ("low",))),
87
+ pre_close=as_float(first_value(row, ("close", "pre_close", "last_close"))),
88
+ volume=as_float(first_value(row, ("volume", "vol"))),
89
+ amount=as_float(first_value(row, ("turnover", "amount"))),
90
+ datetime=dt_value,
91
+ source=self.source,
92
+ source_level=self.source_level,
93
+ raw=dict(row) if include_raw else None,
94
+ )
95
+
96
+
97
+ def _source_error_code(source: str) -> ErrorCode:
98
+ if source == "easyquotation_tencent":
99
+ return ErrorCode.EASYQUOTATION_TENCENT_FAILED
100
+ return ErrorCode.EASYQUOTATION_SINA_FAILED
@@ -0,0 +1,10 @@
1
+ """easyquotation Tencent adapter."""
2
+
3
+ from pyqauto.adapters.easyquotation_sina_adapter import EasyQuotationSinaAdapter
4
+
5
+
6
+ class EasyQuotationTencentAdapter(EasyQuotationSinaAdapter):
7
+ """Adapter for easyquotation's Tencent provider."""
8
+
9
+ source = "easyquotation_tencent"
10
+ provider = "tencent"
@@ -0,0 +1,222 @@
1
+ """pytdx quote adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from pyqauto.adapters.base import (
8
+ BaseQuoteAdapter,
9
+ PytdxServer,
10
+ as_float,
11
+ code_for_symbol,
12
+ first_value,
13
+ market_for_symbol,
14
+ )
15
+ from pyqauto.exceptions import (
16
+ AdapterError,
17
+ ErrorCode,
18
+ SourceUnavailableError,
19
+ UnsupportedPeriodError,
20
+ )
21
+ from pyqauto.models import KlineBar, QuoteRecord
22
+ from pyqauto.policy import (
23
+ SUPPORTED_DAILY_KLINE_PERIODS,
24
+ SUPPORTED_MINUTE_KLINE_PERIODS,
25
+ )
26
+
27
+ # pytdx get_security_bars category values for supported K-line periods.
28
+ PYTDX_KLINE_PERIOD_CATEGORIES = {
29
+ "5m": 0,
30
+ "15m": 1,
31
+ "30m": 2,
32
+ "60m": 3,
33
+ "1d": 4,
34
+ "1m": 7,
35
+ }
36
+
37
+
38
+ class PytdxAdapter(BaseQuoteAdapter):
39
+ """Adapter for one pytdx server entry."""
40
+
41
+ source = "pytdx"
42
+
43
+ def __init__(self, server: PytdxServer, *, timeout: float = 3.0) -> None:
44
+ self.server = server
45
+ self.source_level = server.role
46
+ self.timeout = timeout
47
+
48
+ def realtime_quotes(
49
+ self, symbols: list[str], *, include_raw: bool = False
50
+ ) -> list[QuoteRecord]:
51
+ return self._security_quotes(symbols, include_raw=include_raw)
52
+
53
+ def full_realtime_quotes(
54
+ self, symbols: list[str], *, include_raw: bool = False
55
+ ) -> list[QuoteRecord]:
56
+ return self._security_quotes(symbols, include_raw=include_raw)
57
+
58
+ def index_realtime(
59
+ self, symbols: list[str], *, include_raw: bool = False
60
+ ) -> list[QuoteRecord]:
61
+ return self._security_quotes(symbols, include_raw=include_raw)
62
+
63
+ def minute_kline(
64
+ self,
65
+ symbol: str,
66
+ *,
67
+ period: str = "1m",
68
+ count: int = 240,
69
+ include_raw: bool = False,
70
+ ) -> list[KlineBar]:
71
+ if period not in SUPPORTED_MINUTE_KLINE_PERIODS:
72
+ supported = ", ".join(SUPPORTED_MINUTE_KLINE_PERIODS)
73
+ raise UnsupportedPeriodError(
74
+ f"unsupported pytdx minute period: {period}; supported: {supported}"
75
+ )
76
+ return self._security_bars(
77
+ symbol,
78
+ period=period,
79
+ count=count,
80
+ include_raw=include_raw,
81
+ empty_message="pytdx returned no minute_kline records",
82
+ )
83
+
84
+ def daily_kline(
85
+ self,
86
+ symbol: str,
87
+ *,
88
+ count: int = 120,
89
+ include_raw: bool = False,
90
+ ) -> list[KlineBar]:
91
+ period = SUPPORTED_DAILY_KLINE_PERIODS[0]
92
+ return self._security_bars(
93
+ symbol,
94
+ period=period,
95
+ count=count,
96
+ include_raw=include_raw,
97
+ empty_message="pytdx returned no daily_kline records",
98
+ )
99
+
100
+ def _security_bars(
101
+ self,
102
+ symbol: str,
103
+ *,
104
+ period: str,
105
+ count: int,
106
+ include_raw: bool,
107
+ empty_message: str,
108
+ ) -> list[KlineBar]:
109
+ category = PYTDX_KLINE_PERIOD_CATEGORIES.get(period)
110
+ if category is None:
111
+ supported = ", ".join(PYTDX_KLINE_PERIOD_CATEGORIES)
112
+ raise UnsupportedPeriodError(
113
+ f"unsupported pytdx kline period: {period}; supported: {supported}"
114
+ )
115
+ normalized_symbol = code_for_symbol(symbol)
116
+ api = self._new_api()
117
+ self._connect(api)
118
+ try:
119
+ rows = api.get_security_bars(
120
+ category, market_for_symbol(symbol), normalized_symbol, 0, count
121
+ )
122
+ finally:
123
+ self._disconnect(api)
124
+
125
+ if not rows:
126
+ raise SourceUnavailableError(empty_message)
127
+ return [
128
+ self._normalize_kline_row(
129
+ normalized_symbol,
130
+ row,
131
+ period=period,
132
+ include_raw=include_raw,
133
+ )
134
+ for row in rows
135
+ ]
136
+
137
+ def _security_quotes(
138
+ self, symbols: list[str], *, include_raw: bool = False
139
+ ) -> list[QuoteRecord]:
140
+ if not symbols:
141
+ return []
142
+
143
+ api = self._new_api()
144
+ self._connect(api)
145
+ try:
146
+ request_symbols = [
147
+ (market_for_symbol(symbol), code_for_symbol(symbol)) for symbol in symbols
148
+ ]
149
+ rows = api.get_security_quotes(request_symbols)
150
+ finally:
151
+ self._disconnect(api)
152
+
153
+ if not rows:
154
+ raise SourceUnavailableError("pytdx returned no quote records")
155
+ return [
156
+ self._normalize_quote_row(row, include_raw=include_raw)
157
+ for row in rows
158
+ ]
159
+
160
+ def _new_api(self) -> Any:
161
+ try:
162
+ from pytdx.hq import TdxHq_API
163
+ except Exception as exc: # pragma: no cover - depends on user env
164
+ raise AdapterError("pytdx package is not available") from exc
165
+ return TdxHq_API(heartbeat=True, auto_retry=False)
166
+
167
+ def _connect(self, api: Any) -> None:
168
+ connected = api.connect(self.server.host, self.server.port, time_out=self.timeout)
169
+ if not connected:
170
+ raise SourceUnavailableError(
171
+ "pytdx server connection failed",
172
+ code=ErrorCode.PYTDX_CONNECT_FAILED,
173
+ )
174
+
175
+ def _disconnect(self, api: Any) -> None:
176
+ try:
177
+ api.disconnect()
178
+ except Exception:
179
+ return
180
+
181
+ def _normalize_quote_row(
182
+ self, row: dict[str, Any], *, include_raw: bool
183
+ ) -> QuoteRecord:
184
+ symbol = str(first_value(row, ("code", "symbol")) or "")
185
+ return QuoteRecord(
186
+ symbol=symbol,
187
+ name=first_value(row, ("name",)),
188
+ price=as_float(first_value(row, ("price", "now", "close"))),
189
+ open=as_float(first_value(row, ("open",))),
190
+ high=as_float(first_value(row, ("high",))),
191
+ low=as_float(first_value(row, ("low",))),
192
+ pre_close=as_float(first_value(row, ("last_close", "pre_close", "close"))),
193
+ volume=as_float(first_value(row, ("vol", "volume"))),
194
+ amount=as_float(first_value(row, ("amount",))),
195
+ datetime=str(first_value(row, ("datetime", "time")) or "") or None,
196
+ source=self.source,
197
+ source_level=self.source_level,
198
+ raw=dict(row) if include_raw else None,
199
+ )
200
+
201
+ def _normalize_kline_row(
202
+ self,
203
+ symbol: str,
204
+ row: dict[str, Any],
205
+ *,
206
+ period: str,
207
+ include_raw: bool,
208
+ ) -> KlineBar:
209
+ return KlineBar(
210
+ symbol=symbol,
211
+ close=as_float(first_value(row, ("close", "price"))),
212
+ open=as_float(first_value(row, ("open",))),
213
+ high=as_float(first_value(row, ("high",))),
214
+ low=as_float(first_value(row, ("low",))),
215
+ volume=as_float(first_value(row, ("vol", "volume"))),
216
+ amount=as_float(first_value(row, ("amount",))),
217
+ datetime=str(first_value(row, ("datetime", "time")) or "") or None,
218
+ period=period,
219
+ source=self.source,
220
+ source_level=self.source_level,
221
+ raw=dict(row) if include_raw else None,
222
+ )